Repository: spacecowboy/NotePad
Branch: master
Commit: 3b18a5bd2510
Files: 527
Total size: 1.8 MB
Directory structure:
gitextract_hq_i4sxh/
├── .github/
│ ├── FUNDING.yml
│ ├── ISSUE_TEMPLATE/
│ │ ├── bug_report.md
│ │ ├── feature_request.md
│ │ └── notifications_do_not_appear.md
│ └── workflows/
│ ├── android_build.yml
│ ├── android_tests.yml
│ └── publish_playstore.yml
├── .gitignore
├── LICENSE
├── README.md
├── app/
│ ├── build.gradle
│ ├── dbgenout/
│ │ └── com/
│ │ └── nononsenseapps/
│ │ └── notepad/
│ │ └── database/
│ │ ├── DBItem.java
│ │ ├── DatabaseHandler.java
│ │ ├── DatabaseTriggers.java
│ │ ├── DatabaseViews.java
│ │ ├── ItemProvider.java
│ │ └── taskviewItem.java
│ ├── dbsetup.py
│ ├── proguard.pro
│ └── src/
│ ├── androidTest/
│ │ └── java/
│ │ └── com/
│ │ └── nononsenseapps/
│ │ └── notepad/
│ │ ├── espresso_tests/
│ │ │ ├── BaseTestClass.java
│ │ │ ├── EspressoHelper.java
│ │ │ ├── TestAddBigNumberOfNotesScrollDownAndDeleteOne.java
│ │ │ ├── TestAddNewNoteShouldShowNameInNotesScreen.java
│ │ │ ├── TestAddNewNoteWithDueDateCheckDateIsVisible.java
│ │ │ ├── TestAddNewNoteWithReminderDateAndTime.java
│ │ │ ├── TestAddNoteToTaskList.java
│ │ │ ├── TestAddNotesAndRotateScreen.java
│ │ │ ├── TestAddTaskListAndRotateScreen.java
│ │ │ ├── TestAddTaskListCheckItIsAddedToDrawer.java
│ │ │ ├── TestAddTaskListsScrollNavigationDrawer.java
│ │ │ ├── TestCompletedTasksAreCleared.java
│ │ │ ├── TestCreateNoteAndDeleteIt.java
│ │ │ ├── TestCreateTaskListAndDeleteIt.java
│ │ │ ├── TestPasswords.java
│ │ │ └── TestSaveLoadJsonBackup.java
│ │ └── test/
│ │ ├── DBFreshTest.java
│ │ ├── DBProviderMovementTest.java
│ │ ├── DBProviderTest.java
│ │ ├── DBUpgradeTest.java
│ │ ├── DaoTaskTest.java
│ │ ├── DashClockSettingsTest.java
│ │ ├── DateTimeTest.java
│ │ ├── FragmentTaskDetailTest.java
│ │ ├── FragmentTaskListsTest.java
│ │ ├── FragmentTaskListsViewPagerTest.java
│ │ ├── Helper.java
│ │ ├── OrgSyncTest.java
│ │ ├── ProviderHelperTest.java
│ │ ├── RFCDateTest.java
│ │ └── StorageTest.java
│ └── main/
│ ├── AndroidManifest.xml
│ ├── assets/
│ │ └── .gitignore
│ ├── java/
│ │ └── com/
│ │ ├── google/
│ │ │ └── android/
│ │ │ └── apps/
│ │ │ └── dashclock/
│ │ │ └── ui/
│ │ │ └── DragGripView.java
│ │ └── nononsenseapps/
│ │ ├── helpers/
│ │ │ ├── ActivityHelper.java
│ │ │ ├── DocumentFileHelper.java
│ │ │ ├── FileHelper.java
│ │ │ ├── FilePickerHelper.java
│ │ │ ├── ListHelper.java
│ │ │ ├── NnnLogger.java
│ │ │ ├── NotificationHelper.java
│ │ │ ├── PermissionsHelper.java
│ │ │ ├── PreferencesHelper.java
│ │ │ ├── RFC3339Date.java
│ │ │ ├── SyncStatusMonitor.java
│ │ │ ├── ThemeHelper.java
│ │ │ ├── TimeFormatter.java
│ │ │ └── UpdateNotifier.java
│ │ ├── notepad/
│ │ │ ├── NnnApp.java
│ │ │ ├── NotePadBroadcastReceiver.java
│ │ │ ├── activities/
│ │ │ │ ├── ActivitySearch.java
│ │ │ │ ├── ActivitySearchDeleted.java
│ │ │ │ ├── ActivityTaskHistory.java
│ │ │ │ └── main/
│ │ │ │ ├── ActivityMain.java
│ │ │ │ ├── ActivityMainHelper.java
│ │ │ │ └── DrawerCursorLoader.java
│ │ │ ├── android/
│ │ │ │ └── provider/
│ │ │ │ ├── DummyProvider.java
│ │ │ │ ├── ProviderHelper.java
│ │ │ │ ├── ProviderManager.java
│ │ │ │ └── TextFileProvider.java
│ │ │ ├── dashclock/
│ │ │ │ ├── DashclockPrefActivity.java
│ │ │ │ ├── DashclockPrefsFragment.java
│ │ │ │ └── TasksExtension.java
│ │ │ ├── database/
│ │ │ │ ├── DAO.java
│ │ │ │ ├── DatabaseHandler.java
│ │ │ │ ├── LegacyDBHelper.java
│ │ │ │ ├── MyContentProvider.java
│ │ │ │ ├── Notification.java
│ │ │ │ ├── RemoteTask.java
│ │ │ │ ├── RemoteTaskList.java
│ │ │ │ ├── Task.java
│ │ │ │ └── TaskList.java
│ │ │ ├── fragments/
│ │ │ │ ├── DialogConfirmBase.java
│ │ │ │ ├── DialogConfirmBaseV11.java
│ │ │ │ ├── DialogDeleteCompletedTasks.java
│ │ │ │ ├── DialogDeleteList.java
│ │ │ │ ├── DialogDeleteTask.java
│ │ │ │ ├── DialogEditList.java
│ │ │ │ ├── DialogExportBackup.java
│ │ │ │ ├── DialogMoveToList.java
│ │ │ │ ├── DialogPassword.java
│ │ │ │ ├── DialogPasswordV11.java
│ │ │ │ ├── DialogRestore.java
│ │ │ │ ├── DialogRestoreBackup.java
│ │ │ │ ├── FragmentSearch.java
│ │ │ │ ├── FragmentSearchDeleted.java
│ │ │ │ ├── TaskDetailFragment.java
│ │ │ │ ├── TaskListFragment.java
│ │ │ │ └── TaskListViewPagerFragment.java
│ │ │ ├── interfaces/
│ │ │ │ ├── ListOpener.java
│ │ │ │ ├── MenuStateController.java
│ │ │ │ └── OnFragmentInteractionListener.java
│ │ │ ├── prefs/
│ │ │ │ ├── AboutPrefs.java
│ │ │ │ ├── AppearancePrefs.java
│ │ │ │ ├── BackupPrefs.java
│ │ │ │ ├── Constants.java
│ │ │ │ ├── IndexPrefs.java
│ │ │ │ ├── ListPrefs.java
│ │ │ │ ├── NotificationPrefs.java
│ │ │ │ ├── PasswordPrefs.java
│ │ │ │ ├── PrefsActivity.java
│ │ │ │ └── SyncPrefs.java
│ │ │ ├── sync/
│ │ │ │ ├── SyncAdapter.java
│ │ │ │ ├── files/
│ │ │ │ │ └── JSONBackup.java
│ │ │ │ ├── googleapi/
│ │ │ │ │ ├── GoogleTask.java
│ │ │ │ │ └── GoogleTaskList.java
│ │ │ │ └── orgsync/
│ │ │ │ ├── BackgroundSyncScheduler.java
│ │ │ │ ├── DBSyncBase.java
│ │ │ │ ├── Monitor.java
│ │ │ │ ├── OrgConverter.java
│ │ │ │ ├── OrgProvider.java
│ │ │ │ ├── OrgSyncService.java
│ │ │ │ ├── RemoteTaskListFile.java
│ │ │ │ ├── RemoteTaskNode.java
│ │ │ │ ├── SDSynchronizer.java
│ │ │ │ ├── Synchronizer.java
│ │ │ │ └── SynchronizerInterface.java
│ │ │ └── widget/
│ │ │ ├── list/
│ │ │ │ ├── ListWidgetConfig.java
│ │ │ │ ├── ListWidgetProvider.java
│ │ │ │ ├── ListWidgetService.java
│ │ │ │ └── WidgetPrefs.java
│ │ │ └── shortcut/
│ │ │ └── ShortcutConfig.java
│ │ └── ui/
│ │ ├── DateView.java
│ │ ├── DelegateFrame.java
│ │ ├── ExtraTypesCursorAdapter.java
│ │ ├── ExtrasCursorAdapter.java
│ │ ├── GreyableToggleButton.java
│ │ ├── NoteCheckBox.java
│ │ ├── NotificationItemHelper.java
│ │ ├── ShowcaseHelper.java
│ │ ├── StyledEditText.java
│ │ ├── TitleNoteTextView.java
│ │ ├── ViewsHelper.java
│ │ └── WeekDaysView.java
│ └── res/
│ ├── anim/
│ │ ├── activity_slide_in_right.xml
│ │ ├── activity_slide_out_right_full.xml
│ │ ├── cycle_7.xml
│ │ ├── shake.xml
│ │ ├── slide_in_bottom.xml
│ │ ├── slide_in_top.xml
│ │ ├── slide_out_bottom.xml
│ │ └── slide_out_top.xml
│ ├── drawable/
│ │ ├── btn_toggle_bg.xml
│ │ ├── folder_move_24dp.xml
│ │ ├── folder_plus_24dp.xml
│ │ ├── ic_add_24dp.xml
│ │ ├── ic_alarm_24dp.xml
│ │ ├── ic_alarm_add_24dp.xml
│ │ ├── ic_archive_24dp.xml
│ │ ├── ic_brightness_6_24dp.xml
│ │ ├── ic_check_24dp.xml
│ │ ├── ic_checkbox_checked.xml
│ │ ├── ic_checkbox_unchecked.xml
│ │ ├── ic_clear_24dp.xml
│ │ ├── ic_copy_24dp.xml
│ │ ├── ic_delete_24dp.xml
│ │ ├── ic_export.xml
│ │ ├── ic_folder_24dp.xml
│ │ ├── ic_help_24dp.xml
│ │ ├── ic_import.xml
│ │ ├── ic_info_24dp.xml
│ │ ├── ic_lock_closed_24dp.xml
│ │ ├── ic_lock_open_24dp.xml
│ │ ├── ic_notebook_minus_24dp.xml
│ │ ├── ic_refresh_24dp.xml
│ │ ├── ic_sd_storage_24dp.xml
│ │ ├── ic_search_24dp.xml
│ │ ├── ic_select_all.xml
│ │ ├── ic_settings_24dp.xml
│ │ ├── ic_share_24dp.xml
│ │ ├── ic_sort_24dp.xml
│ │ ├── ic_stat_notification_edit.xml
│ │ ├── ic_underline_accent.xml
│ │ ├── ic_undo_24dp.xml
│ │ ├── img_default_selector_dark.xml
│ │ ├── img_default_selector_light.xml
│ │ ├── tasklist_item_blackclassic_bg.xml
│ │ ├── tasklist_item_darkcard_bg.xml
│ │ ├── tasklist_item_lightcard_bg.xml
│ │ └── tasklist_item_lightclassic_bg.xml
│ ├── drawable-anydpi-v26/
│ │ ├── app_icon.xml
│ │ ├── icon_foreground_symbol.xml
│ │ ├── icon_gradient.xml
│ │ └── icon_monochrome.xml
│ ├── layout/
│ │ ├── actionbar_custom_view_done.xml
│ │ ├── actionbar_custom_view_done_discard.xml
│ │ ├── actionbar_discard_button.xml
│ │ ├── actionbar_done_button.xml
│ │ ├── activity_dashclock_settings.xml
│ │ ├── activity_main.xml
│ │ ├── activity_settings.xml
│ │ ├── activity_shortcut_config.xml
│ │ ├── activity_task_history.xml
│ │ ├── activity_widget_config.xml
│ │ ├── activity_widget_config_part_preview.xml
│ │ ├── activity_widget_config_part_settings.xml
│ │ ├── app_pref_about_layout.xml
│ │ ├── app_pref_password_layout.xml
│ │ ├── dialog_ok_cancel.xml
│ │ ├── drawer_header.xml
│ │ ├── drawer_layout.xml
│ │ ├── fragment_dialog_editlist.xml
│ │ ├── fragment_dialog_movetolist.xml
│ │ ├── fragment_dialog_password.xml
│ │ ├── fragment_dialog_restore.xml
│ │ ├── fragment_search.xml
│ │ ├── fragment_task_detail.xml
│ │ ├── fragment_task_list.xml
│ │ ├── fragment_tasklist_viewpager.xml
│ │ ├── fullscreen_fragment.xml
│ │ ├── notification_view.xml
│ │ ├── simple_light_list_item_activated_1.xml
│ │ ├── simple_listitem.xml
│ │ ├── spinner_item.xml
│ │ ├── tasklist_header.xml
│ │ ├── tasklist_item_card_section.xml
│ │ ├── tasklist_item_rich.xml
│ │ ├── weekdays_layout.xml
│ │ ├── widget_layout.xml
│ │ ├── widgetlist_header.xml
│ │ └── widgetlist_item.xml
│ ├── layout-sw600dp-land/
│ │ ├── activity_main.xml
│ │ └── activity_settings.xml
│ ├── menu/
│ │ ├── activity_deleted_context.xml
│ │ ├── activity_main.xml
│ │ ├── fragment_search.xml
│ │ ├── fragment_tasklist.xml
│ │ ├── fragment_tasklist_context.xml
│ │ ├── fragment_tasklists_viewpager.xml
│ │ └── fragment_tasks_detail.xml
│ ├── values/
│ │ ├── arrays.xml
│ │ ├── attr.xml
│ │ ├── bool.xml
│ │ ├── color.xml
│ │ ├── constants.xml
│ │ ├── dashclock_preference_values.xml
│ │ ├── dimens.xml
│ │ ├── layout_constants.xml
│ │ ├── strings.xml
│ │ ├── styles.xml
│ │ ├── themes.xml
│ │ └── widget_params.xml
│ ├── values-af/
│ │ └── strings.xml
│ ├── values-ar/
│ │ └── strings.xml
│ ├── values-bg/
│ │ └── strings.xml
│ ├── values-ca-rES/
│ │ └── strings.xml
│ ├── values-co/
│ │ └── strings.xml
│ ├── values-cs/
│ │ └── strings.xml
│ ├── values-da/
│ │ └── strings.xml
│ ├── values-de/
│ │ └── strings.xml
│ ├── values-el/
│ │ └── strings.xml
│ ├── values-es/
│ │ └── strings.xml
│ ├── values-et/
│ │ └── strings.xml
│ ├── values-fa/
│ │ └── strings.xml
│ ├── values-fi-rFI/
│ │ └── strings.xml
│ ├── values-fr/
│ │ └── strings.xml
│ ├── values-gl/
│ │ └── strings.xml
│ ├── values-hu/
│ │ └── strings.xml
│ ├── values-in-rID/
│ │ └── strings.xml
│ ├── values-is/
│ │ └── strings.xml
│ ├── values-it/
│ │ └── strings.xml
│ ├── values-iw-rIL/
│ │ └── strings.xml
│ ├── values-ja/
│ │ └── strings.xml
│ ├── values-ko/
│ │ └── strings.xml
│ ├── values-land/
│ │ ├── constants.xml
│ │ └── dimens.xml
│ ├── values-nb-rNO/
│ │ └── strings.xml
│ ├── values-nl/
│ │ └── strings.xml
│ ├── values-pl/
│ │ └── strings.xml
│ ├── values-pt/
│ │ └── strings.xml
│ ├── values-pt-rBR/
│ │ └── strings.xml
│ ├── values-pt-rPT/
│ │ └── strings.xml
│ ├── values-ro/
│ │ └── strings.xml
│ ├── values-ru/
│ │ └── strings.xml
│ ├── values-sk/
│ │ └── strings.xml
│ ├── values-sl/
│ │ └── strings.xml
│ ├── values-sv/
│ │ └── strings.xml
│ ├── values-sw600dp/
│ │ ├── bool.xml
│ │ └── dimens.xml
│ ├── values-sw600dp-land/
│ │ ├── dimens.xml
│ │ └── layout_constants.xml
│ ├── values-sw720dp/
│ │ └── dimens.xml
│ ├── values-sw720dp-land/
│ │ ├── dimens.xml
│ │ └── layout_constants.xml
│ ├── values-ta/
│ │ └── strings.xml
│ ├── values-tr/
│ │ └── strings.xml
│ ├── values-uk/
│ │ └── strings.xml
│ ├── values-v26/
│ │ └── styles.xml
│ ├── values-vec/
│ │ └── strings.xml
│ ├── values-vi/
│ │ └── strings.xml
│ ├── values-zh-rCN/
│ │ └── strings.xml
│ ├── values-zh-rTW/
│ │ └── strings.xml
│ └── xml/
│ ├── app_pref_backup.xml
│ ├── app_pref_headers.xml
│ ├── app_pref_list.xml
│ ├── app_pref_main.xml
│ ├── app_pref_notifications.xml
│ ├── app_pref_sync.xml
│ ├── dashclock_pref_general.xml
│ ├── listwidgetinfo.xml
│ ├── searchable.xml
│ └── searchabledeleted.xml
├── build.gradle
├── contract/
│ ├── build.gradle
│ └── src/
│ └── main/
│ ├── AndroidManifest.xml
│ └── java/
│ └── com/
│ └── nononsenseapps/
│ └── notepad/
│ └── providercontract/
│ ├── ProviderBase.java
│ ├── ProviderContract.java
│ └── UriContract.java
├── customTasks.gradle
├── deploy_playstore.sh
├── documents/
│ ├── CONTRIBUTING.md
│ ├── FAQ.md
│ ├── PRIVACY.md
│ └── TUTORIAL.md
├── external/
│ └── drag-sort-listview/
│ ├── AndroidManifest.xml
│ ├── build.gradle
│ ├── res/
│ │ └── values/
│ │ └── dslv_attrs.xml
│ └── src/
│ └── com/
│ └── mobeta/
│ └── android/
│ └── dslv/
│ ├── DragSortController.java
│ ├── DragSortCursorAdapter.java
│ ├── DragSortItemView.java
│ ├── DragSortItemViewCheckable.java
│ ├── DragSortListView.java
│ ├── ResourceDragSortCursorAdapter.java
│ ├── SimpleDragSortCursorAdapter.java
│ └── SimpleFloatViewManager.java
├── fastlane/
│ ├── Appfile
│ ├── Fastfile
│ └── metadata/
│ └── android/
│ ├── ar/
│ │ ├── full_description.txt
│ │ ├── short_description.txt
│ │ └── title.txt
│ ├── bg/
│ │ ├── changelogs/
│ │ │ ├── 57130.txt
│ │ │ ├── 70000.txt
│ │ │ ├── 71000.txt
│ │ │ ├── 71100.txt
│ │ │ ├── 71200.txt
│ │ │ ├── 71300.txt
│ │ │ ├── 71500.txt
│ │ │ ├── 71600.txt
│ │ │ ├── 71700.txt
│ │ │ ├── 71800.txt
│ │ │ └── 71900.txt
│ │ ├── full_description.txt
│ │ ├── short_description.txt
│ │ └── title.txt
│ ├── cs-CZ/
│ │ ├── full_description.txt
│ │ ├── short_description.txt
│ │ └── title.txt
│ ├── de-DE/
│ │ ├── changelogs/
│ │ │ └── 57130.txt
│ │ ├── full_description.txt
│ │ ├── short_description.txt
│ │ └── title.txt
│ ├── en-US/
│ │ ├── changelogs/
│ │ │ ├── 57130.txt
│ │ │ ├── 70000.txt
│ │ │ ├── 71000.txt
│ │ │ ├── 71100.txt
│ │ │ ├── 71200.txt
│ │ │ ├── 71300.txt
│ │ │ ├── 71400.txt
│ │ │ ├── 71500.txt
│ │ │ ├── 71600.txt
│ │ │ ├── 71700.txt
│ │ │ ├── 71800.txt
│ │ │ ├── 71900.txt
│ │ │ ├── 72000.txt
│ │ │ ├── 72100.txt
│ │ │ ├── 72200.txt
│ │ │ ├── 72300.txt
│ │ │ └── 72600.txt
│ │ ├── full_description.txt
│ │ ├── images/
│ │ │ ├── featureGraphic.psd
│ │ │ ├── featureGraphic.xcf
│ │ │ └── promoGraphic.psd
│ │ ├── short_description.txt
│ │ └── title.txt
│ ├── es-ES/
│ │ ├── changelogs/
│ │ │ ├── 57130.txt
│ │ │ ├── 70000.txt
│ │ │ ├── 71000.txt
│ │ │ ├── 71100.txt
│ │ │ ├── 71200.txt
│ │ │ ├── 71300.txt
│ │ │ ├── 71400.txt
│ │ │ ├── 71500.txt
│ │ │ ├── 71600.txt
│ │ │ ├── 71700.txt
│ │ │ ├── 71800.txt
│ │ │ └── 71900.txt
│ │ ├── full_description.txt
│ │ ├── short_description.txt
│ │ └── title.txt
│ ├── fi-FI/
│ │ ├── changelogs/
│ │ │ └── 71400.txt
│ │ ├── full_description.txt
│ │ ├── short_description.txt
│ │ └── title.txt
│ ├── fr-FR/
│ │ ├── full_description.txt
│ │ ├── short_description.txt
│ │ └── title.txt
│ ├── he/
│ │ ├── full_description.txt
│ │ ├── short_description.txt
│ │ └── title.txt
│ ├── id/
│ │ ├── changelogs/
│ │ │ └── 57130.txt
│ │ ├── full_description.txt
│ │ ├── short_description.txt
│ │ └── title.txt
│ ├── is/
│ │ ├── full_description.txt
│ │ ├── short_description.txt
│ │ └── title.txt
│ ├── it-IT/
│ │ ├── full_description.txt
│ │ ├── short_description.txt
│ │ └── title.txt
│ ├── nb-NO/
│ │ ├── changelogs/
│ │ │ ├── 57130.txt
│ │ │ ├── 70000.txt
│ │ │ └── 71000.txt
│ │ ├── full_description.txt
│ │ ├── short_description.txt
│ │ └── title.txt
│ ├── pl-PL/
│ │ ├── changelogs/
│ │ │ ├── 57130.txt
│ │ │ ├── 70000.txt
│ │ │ ├── 71000.txt
│ │ │ ├── 71100.txt
│ │ │ ├── 71200.txt
│ │ │ ├── 71300.txt
│ │ │ ├── 71400.txt
│ │ │ ├── 71500.txt
│ │ │ ├── 71600.txt
│ │ │ └── 71700.txt
│ │ ├── full_description.txt
│ │ ├── short_description.txt
│ │ └── title.txt
│ ├── pt-BR/
│ │ ├── full_description.txt
│ │ ├── short_description.txt
│ │ └── title.txt
│ ├── pt-PT/
│ │ ├── full_description.txt
│ │ ├── short_description.txt
│ │ └── title.txt
│ ├── ro/
│ │ ├── full_description.txt
│ │ ├── short_description.txt
│ │ └── title.txt
│ ├── ru-RU/
│ │ ├── changelogs/
│ │ │ ├── 57130.txt
│ │ │ ├── 70000.txt
│ │ │ ├── 71000.txt
│ │ │ ├── 71100.txt
│ │ │ ├── 71200.txt
│ │ │ ├── 71300.txt
│ │ │ ├── 71400.txt
│ │ │ ├── 71500.txt
│ │ │ ├── 71600.txt
│ │ │ ├── 71700.txt
│ │ │ ├── 71800.txt
│ │ │ ├── 71900.txt
│ │ │ ├── 72000.txt
│ │ │ ├── 72100.txt
│ │ │ ├── 72200.txt
│ │ │ ├── 72300.txt
│ │ │ └── 72600.txt
│ │ ├── full_description.txt
│ │ ├── short_description.txt
│ │ └── title.txt
│ ├── ta-IN/
│ │ ├── changelogs/
│ │ │ ├── 57130.txt
│ │ │ ├── 70000.txt
│ │ │ ├── 71000.txt
│ │ │ ├── 71100.txt
│ │ │ ├── 71200.txt
│ │ │ ├── 71300.txt
│ │ │ ├── 71400.txt
│ │ │ ├── 71500.txt
│ │ │ ├── 71600.txt
│ │ │ ├── 71700.txt
│ │ │ ├── 71800.txt
│ │ │ ├── 71900.txt
│ │ │ └── 72000.txt
│ │ ├── full_description.txt
│ │ ├── short_description.txt
│ │ └── title.txt
│ ├── tr-TR/
│ │ ├── changelogs/
│ │ │ ├── 57130.txt
│ │ │ ├── 70000.txt
│ │ │ ├── 71000.txt
│ │ │ ├── 71100.txt
│ │ │ ├── 71200.txt
│ │ │ ├── 71300.txt
│ │ │ ├── 71400.txt
│ │ │ ├── 71500.txt
│ │ │ ├── 71600.txt
│ │ │ ├── 71700.txt
│ │ │ ├── 71800.txt
│ │ │ ├── 71900.txt
│ │ │ ├── 72000.txt
│ │ │ ├── 72100.txt
│ │ │ ├── 72200.txt
│ │ │ ├── 72300.txt
│ │ │ └── 72600.txt
│ │ ├── full_description.txt
│ │ ├── short_description.txt
│ │ └── title.txt
│ ├── vec/
│ │ ├── full_description.txt
│ │ ├── short_description.txt
│ │ └── title.txt
│ └── zh-CN/
│ ├── changelogs/
│ │ └── 70000.txt
│ ├── full_description.txt
│ ├── short_description.txt
│ └── title.txt
├── github_on_emu_started.sh
├── gradle/
│ └── wrapper/
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradle.properties
├── gradlew
├── gradlew.bat
├── release.sh
└── settings.gradle
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/FUNDING.yml
================================================
# multiple github accounts are allowed, but only one of everything else. Setup is explained in:
# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository
github: [spacecowboy, CampelloManuel]
ko_fi: spacecowboy
custom: https://www.paypal.com/paypalme/campellomanuel
================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.md
================================================
---
name: Bug report
about: Report issues of the app's features
title: ''
labels: bug
assignees: ''
---
### Describe the bug
write a clear and short description of what the bug is.
### how To Reproduce
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
also state what is the expected result and what is the actual result.
### If applicable
* add the stacktrace
* add logcat output as explained [here](https://f-droid.org/docs/Getting_logcat_messages_after_crash/)
### Technical information
- Device: [e.g. Nexus 7 (2013)]
- OS: [e.g. LineageOS 15.1]
- app version: [e.g. v3.2.4]
- app commit id (only for nightly builds): [e.g. 4b333bb]
If relevant, please say what your sync settings are, and report any odd stuff happening in general.
================================================
FILE: .github/ISSUE_TEMPLATE/feature_request.md
================================================
---
name: Feature request
about: Suggest an idea for the app
title: ''
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here
================================================
FILE: .github/ISSUE_TEMPLATE/notifications_do_not_appear.md
================================================
---
name: Notifications don't show up
about: The app does not show notifications for reminders
title: ''
labels: bug
assignees: ''
---
**Please describe the problem and how to reproduce it:**
...
**Checklist**
- [ ] Consulted [https://dontkillmyapp.com/]
- [ ] Power management: battery optimization is disabled for OpenTracks
**Technical information**
- Device: [e.g. Nexus 7 (2013)]
- OS: [e.g. LineageOS 15.1]
- app version: [e.g. v3.2.4]
- app commit id (only for nightly builds): [e.g. 4b333bb]
================================================
FILE: .github/workflows/android_build.yml
================================================
name: Android build
on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
jobs:
# this job builds and uploads the apk
build_the_apk:
# there's no need to run it on forks
if: github.repository == 'spacecowboy/NotePad'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: perform the checkout
uses: actions/checkout@v4
- name: perform the validation
uses: gradle/actions/wrapper-validation@v3
- name: perform the JDK setup
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: 'gradle'
- name: perform Gradle caching
uses: gradle/actions/setup-gradle@v3
- name: perform the Gradle build
run: ./gradlew build
- name: perform additional checks with our custom tasks
run: ./gradlew checkLanguages checkFastlane
# TODO output files from this step are not saved. Take a look at them
- name: perform lint checks with gradle
run: ./gradlew lint
- name: perform the APK upload
uses: actions/upload-artifact@v4
with:
name: app_debug
path: app/build/outputs/apk/debug/app-debug.apk
retention-days: 7 # we're not publishing the app: nobody needs this apk
================================================
FILE: .github/workflows/android_tests.yml
================================================
name: Android tests
on:
push:
branches: [ "master" ]
# runs when you commit to the master branch and when merging pull requests.
# Not on receiving pull requests, as it would be a waste.
paths-ignore:
- '**.md'
- '.github/**'
- '!.github/workflows/build.yml'
jobs:
# this job runs all the tests
job_tests:
if: github.repository == 'spacecowboy/NotePad' # no need to run it on forks
timeout-minutes: 45 # test jobs take ~11 minutes each. We give time to download the AVD images
continue-on-error: true # run tests for ALL configurations, don't stop at the 1° failure
strategy:
matrix:
# test on emulators with these Android API versions. They all have ATD images.
api-level: [ 30, 32, 34, 35 ]
# use stripped-down, test-friendly Android OS images without, or with, google apps
target: [ aosp_atd, google_atd ]
# profile: [ 5.1in WVGA, 10.1in WXGA ] # tests on tablets don't work anyway...
runs-on: ubuntu-latest # List of installed software:
# https://github.com/actions/runner-images/blob/main/images/ubuntu/Ubuntu2204-Readme.md
permissions:
contents: read
steps:
- name: perform the checkout
uses: actions/checkout@v4
- name: perform the set up for the JDK
uses: actions/setup-java@v4
with:
java-version: 21
distribution: temurin
- name: perform the validation
continue-on-error: true
uses: gradle/actions/wrapper-validation@v3
- name: perform Gradle setup & caching
uses: gradle/actions/setup-gradle@v3
- name: Check for hardware acceleration availability
continue-on-error: true
# Read the console logs for the result. The android emulator can't use
# acceleration, according to the logs. No you can't install HAXM.
run: $ANDROID_HOME/emulator/emulator -accel-check
- name: Enable KVM group perms as required by reactivecircus/android-emulator-runner
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: perform AVD caching
uses: actions/cache@v4
id: avd-cache
# they appear in
# https://github.com/spacecowboy/NotePad/actions/caches?query=sort%3Asize-desc
with:
path: |
~/.android/avd/*
~/.android/adb*
key: avd-${{ matrix.api-level }}-${{ matrix.target }}
- name: create AVD and generate snapshot for caching
# here it automatically installs the needed android SDK components
# see https://github.com/ReactiveCircus/android-emulator-runner/blob/main/README.md
if: steps.avd-cache.outputs.cache-hit != 'true'
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: ${{ matrix.api-level }}
target: ${{ matrix.target }}
profile: 5.1in WVGA # low resolution: faster. This has no software buttons
arch: x86_64
disk-size: 2G # needed for saving org and json files. We need 2 GB for API 34
force-avd-creation: false
# The emulator is picky on these parameters. For...
# tests with "-accel on": see github run #84
# tests with "-gpu host": the emulators never boot!
# tests with "-gpu off": see github run #87, faster & more stable
emulator-options: -no-boot-anim -no-window -gpu off
disable-animations: true
disable-spellchecker: true
# starts the emulator, runs this script, then closes the emulator
script: echo "Generated AVD snapshot for caching."
- name: perform the Gradle build
run: ./gradlew build
- name: run custom tasks for additional checks
run: ./gradlew checkLanguages checkFastlane
- name: make the emulator script executable
run: chmod +x github_on_emu_started.sh
- name: run the tests
# note that by now the app is already built
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: ${{ matrix.api-level }}
target: ${{ matrix.target }}
arch: x86_64
profile: 5.4in FWVGA
disk-size: 500M
force-avd-creation: false
disable-animations: true
disable-spellchecker: true
emulator-options: -no-snapshot-save -verbose -no-boot-anim -no-window -gpu off
script: bash ./github_on_emu_started.sh
- name: upload the generated files
uses: actions/upload-artifact@v4
if: always()
with:
name: files-api${{ matrix.api-level }}-${{ matrix.target }}
path: |
logcat-dump.txt
app/build/reports/androidTests/**
================================================
FILE: .github/workflows/publish_playstore.yml
================================================
name: Publish to Play store
on:
push:
# Branch only during testing
#branches:
# - master
tags:
- '*'
jobs:
build_and_deploy:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: perform the checkout
uses: actions/checkout@v4
with:
submodules: 'recursive'
fetch-depth: 0
- name: perform the validation
uses: gradle/actions/wrapper-validation@v3
- name: perform the JDK setup
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: 'gradle'
- name: perform Gradle caching
uses: gradle/actions/setup-gradle@v3
- name: run custom tasks to ensure app can be published on play store
run: ./gradlew checkLanguages checkFastlane
- name: build and deploy
run: ./deploy_playstore.sh
env:
SERVICEACCOUNTJSON: ${{ secrets.SERVICEACCOUNTJSON }}
KEYSTOREPASSWORD: ${{ secrets.KEYSTOREPASSWORD }}
KEYSTORE: ${{ secrets.KEYSTORE }}
KEYPASSWORD: ${{ secrets.KEYPASSWORD }}
KEYALIAS: ${{ secrets.KEYALIAS }}
================================================
FILE: .gitignore
================================================
secret.properties
_release_keys_
NotePad_ant
NotePad_ant/*
.factorypath
bin/
build/
.gradle
gen/
NotePad.apk
Notes_for_ICS_Market_Link.png
icon/
proguard/
.apt_generated
.settings*
translate-temp/
*~
*iml
.idea
local.properties
================================================
FILE: LICENSE
================================================
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc.
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
Copyright (C)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
Copyright (C)
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
.
================================================
FILE: README.md
================================================
# NoNonsense Notes
[](https://github.com/spacecowboy/NotePad/actions/workflows/android_build.yml) [](https://github.com/spacecowboy/NotePad/actions/workflows/android_tests.yml) [](https://hosted.weblate.org/engage/no-nonsense-notes/) \
\
_A note-taking app for Android with reminders, since 2012_
[](https://f-droid.org/repository/browse/?fdid=com.nononsenseapps.notepad) [](https://play.google.com/store/apps/details?id=com.nononsenseapps.notepad.play)
_
## Translation
Help translate the app on [Hosted Weblate](https://hosted.weblate.org/projects/no-nonsense-notes/) \
## How does it work ?
Read the [tutorial](./documents/TUTORIAL.md) to learn about the app,
or the [FAQ](./documents/FAQ.md) for other kinds of questions.
## Reporting bugs
Please [report bugs](https://github.com/spacecowboy/NotePad/issues) using the provided template.
## Build the project
```sh
git clone https://github.com/spacecowboy/NotePad
cd NotePad
./gradlew check
./gradlew installDebug
```
if it does not work, [open an issue](https://github.com/spacecowboy/NotePad/issues)
## GPLv3+ License
```text
Copyright (C) 2014 Jonas Kalderstam
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
```
[Full license](./LICENSE)
## Useful links
* [FAQ](./documents/FAQ.md)
* [app tutorial](./documents/TUTORIAL.md)
* [Contribution guide](./documents/CONTRIBUTING.md)
* [Releases](https://github.com/spacecowboy/NotePad/releases)
* [Privacy policy](./documents/PRIVACY.md)
* [f-droid forum](https://forum.f-droid.org/t/i-want-to-update-an-old-discontinued-app-nononsense-notes/20037)
Built by @spacecowboy, maintained by @CampelloManuel. \
The app is currently being updated. Old versions are still available on F-droid.
================================================
FILE: app/build.gradle
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
apply plugin: 'com.android.application'
// apply plugin: 'kotlin-android' we don't use kotlin for this app
android {
compileSdk = 36
namespace = "com.nononsenseapps.notepad" // for R.*
buildFeatures {
buildConfig = true
// replaces part of the androidannotations library. See mBinding in java classes
viewBinding = true
// allow declaring different string resources for different buildTypes
resValues = true
// these features are useless for us
compose = false
aidl = false
renderScript = false
shaders = false
}
lint {
htmlReport = false // TODO re-enable and fix all warnings reported in the html file
textReport = false
abortOnError = false
explainIssues = true
checkTestSources = true
// turn off checking the given issue id's
disable += ['RtlHardcoded', 'ApplySharedPref']
}
packagingOptions {
resources {
// excludes += ['META-INF/LICENSE.txt', 'META-INF/NOTICE.txt']
}
}
if (project.hasProperty('STORE_FILE')) {
signingConfigs {
release {
storeFile file(STORE_FILE)
storePassword STORE_PASSWORD
keyAlias KEY_ALIAS
keyPassword KEY_PASSWORD
}
}
} else {
// println "No key store defined. Signed release not available..."
}
defaultConfig {
applicationId "com.nononsenseapps.notepad"
targetSdkVersion 36
// most people have at least android 7.1 anyway: https://apilevels.com/
// 93% of users are on API>=27 => don't write special code for 23<=API<=26, it's worthless
minSdkVersion 23
// If these values remain static, incremental builds are optimized and faster.
// Also, FDROID is happier. Update them only with ./release.sh
versionCode 72600
versionName "7.2.6"
vectorDrawables.useSupportLibrary = true
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunnerArguments disableAnalytics: 'true'
javaCompileOptions {
annotationProcessorOptions {
// you need these to make org.androidannotations:androidannotations compile
// in "playStore" build type.
// TODO get rid of androidannotations and delete this javaCompileOptions {...}
arguments = [
"resourcePackageName": android.defaultConfig.applicationId,
"androidManifestFile": "$projectDir/src/main/AndroidManifest.xml".toString()
]
}
}
}
// disable code shrinking in debug, enable it in release mode
buildTypes {
debug {
// identify the debug version to allow installing it
// alongside the fdroid or play store version
applicationIdSuffix ".debug"
versionNameSuffix "-Debug"
minifyEnabled = false
shrinkResources = false
pseudoLocalesEnabled = true
// TODO test https://developer.android.com/guide/topics/resources/pseudolocales
// just to make gradle shut up about missing classes
proguardFiles 'proguard.pro'
// to install the play store and fdroid versions simultaneously, content provider
// authorities must be unique => define one for xml files
resValue "string", "NnnAuthority_1", defaultConfig.applicationId + applicationIdSuffix + ".MyContentAuthority"
}
release {
// the FDROID release, where we CAN'T use applicationIdSuffix or versionNameSuffix
// enable R8
minifyEnabled true
shrinkResources = true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard.pro'
if (project.hasProperty('STORE_FILE')) {
signingConfig = signingConfigs.release
}
/// to install the play store and fdroid versions simultaneously, content provider
// authorities must be unique => define one for xml and for java
resValue "string", "NnnAuthority_1", defaultConfig.applicationId + ".MyContentAuthority"
}
playStore {
// a RELEASE build for the google play store. The ONLY difference is the package name,
// which lets you install this app alongside the version from f-droid
applicationIdSuffix ".play"
versionNameSuffix "-Play"
// enable R8
minifyEnabled true
shrinkResources = true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard.pro'
if (project.hasProperty('STORE_FILE')) {
signingConfig = signingConfigs.release
} else {
println "STORE_FILE property is not set: cannot sign release build for the play store!"
}
// This is a sorted list of fallback build types used when a dependency does not
// include a "playStore" build type. The plugin selects the first build type available
// in the dependency. So this will use the "release" mode of drag-sort-listview for
// our "playStore" profile
matchingFallbacks = ['release', 'debug']
// to install the play store and fdroid versions simultaneously, content provider
// authorities must be unique => define one for xml and for java
resValue "string", "NnnAuthority_1", defaultConfig.applicationId + applicationIdSuffix + ".MyContentAuthority"
}
}
testOptions {
// androidx espresso tests require this
animationsDisabled = true
}
compileOptions {
// enable support for new language APIs in old devices
coreLibraryDesugaringEnabled = true
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
// kotlinOptions { jvmTarget = 17 } // compile kotlin to the same JAVA 17
// control which types of configuration APKs you want your
// app bundle to support
bundle {
language {
// download ALL languages, because we have
// a built-in language picker
enableSplit = false
}
}
}
dependencies {
// AndroidX
implementation "androidx.cursoradapter:cursoradapter:1.0.0"
implementation 'androidx.fragment:fragment:1.8.8'
implementation 'androidx.appcompat:appcompat:1.7.1'
implementation "androidx.preference:preference:1.2.1"
implementation "androidx.startup:startup-runtime:1.2.0"
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
// the logcat complained once because we didn't have this
implementation 'androidx.tracing:tracing:1.3.0'
// pretty widgets
implementation 'com.google.android.material:material:1.12.0'
// Time library, open source & up to date
implementation 'joda-time:joda-time:2.14.0'
// Dashclock API, open source & abandoned, but it still works
implementation 'com.google.android.apps.dashclock:dashclock-api:2.0.0'
// Writes org files, open source & abandoned, but it still works
implementation 'org.cowboyprogrammer.orgparser:orgparser:1.3.1'
// for manual list sorting, local source copy, open source & abandoned
implementation project(':external:drag-sort-listview')
// for highlighting functionality on 1° launch, open source & up to date
implementation 'com.getkeepsafe.taptargetview:taptargetview:1.15.0'
// from deleted branch 'version6'
implementation project(path: ':contract')
// for ListenableFuture<>
implementation 'com.google.guava:guava:33.4.8-android'
// annotations library, open source & abandoned
annotationProcessor "org.androidannotations:androidannotations:4.8.0"
implementation "org.androidannotations:androidannotations-api:4.8.0"
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5'
// Tests libraries
androidTestImplementation 'androidx.test.ext:junit:1.3.0'
androidTestImplementation 'androidx.test:rules:1.7.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0'
androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.7.0'
androidTestImplementation "androidx.test.espresso:espresso-intents:3.7.0"
androidTestUtil "androidx.test.services:test-services:1.6.0"
// no kotlin!
// implementation "org.jetbrains.kotlin:kotlin-stdlib:1.7.20"
}
================================================
FILE: app/dbgenout/com/nononsenseapps/notepad/database/DBItem.java
================================================
package com.nononsenseapps.notepad.database;
import android.content.Context;
import android.content.ContentValues;
import android.database.Cursor;
import android.net.Uri;
public abstract class DBItem {
public static final String COL_ID = "_id";
public DBItem() {}
public DBItem(final Cursor cursor) {}
public abstract ContentValues getContent();
public abstract String getTableName();
public abstract long getId();
public abstract void setId(final long id);
public abstract String[] getFields();
public Uri getUri() {
return Uri.withAppendedPath(getBaseUri(), Long.toString(getId()));
}
public Uri getBaseUri() {
return Uri.withAppendedPath(
Uri.parse(ItemProvider.SCHEME
+ ItemProvider.AUTHORITY), getTableName());
}
public void notifyProvider(final Context context) {
try {
context.getContentResolver().notifyChange(getUri(), null, false);
} catch (UnsupportedOperationException e) {
// Catch this for test suite. Mock provider cant notify
}
}
}
================================================
FILE: app/dbgenout/com/nononsenseapps/notepad/database/DatabaseHandler.java
================================================
package com.nononsenseapps.notepad.database;
import java.util.ArrayList;
import java.util.List;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
/**
* Database handler, SQLite wrapper and ORM layer.
*/
public class DatabaseHandler extends SQLiteOpenHelper {
// All Static variables
// Database Version
private static final int DATABASE_VERSION = 1;
// Database Name
private static final String DATABASE_NAME = "SampleDB";
private final Context context;
private static DatabaseHandler instance = null;
public synchronized static DatabaseHandler getInstance(Context context) {
if (instance == null)
instance = new DatabaseHandler(context.getApplicationContext());
return instance;
}
public DatabaseHandler(Context context) {
super(context.getApplicationContext(), DATABASE_NAME, null,
DATABASE_VERSION);
this.context = context.getApplicationContext();
}
@Override
public void onOpen(SQLiteDatabase db) {
super.onOpen(db);
if (!db.isReadOnly()) {
// Enable foreign key constraints
// This line requires android16
// db.setForeignKeyConstraintsEnabled(true);
// This line works everywhere though
db.execSQL("PRAGMA foreign_keys=ON;");
// Create temporary triggers and views
DatabaseTriggers.createTemp(db);
DatabaseViews.createTemp(db);
}
}
@Override
public synchronized void onCreate(SQLiteDatabase db) {
db.execSQL("DROP TABLE IF EXISTS " + taskviewItem.TABLE_NAME);
db.execSQL(taskviewItem.CREATE_TABLE);
// Create Triggers and Views
DatabaseTriggers.create(db);
DatabaseViews.create(db);
}
// Upgrading database
@Override
public synchronized void onUpgrade(SQLiteDatabase db, int oldVersion,
int newVersion) {
// Try to drop and recreate. You should do something clever here
onCreate(db);
}
// Convenience methods
public synchronized boolean putItem(final DBItem item) {
boolean success = false;
int result = 0;
final SQLiteDatabase db = this.getWritableDatabase();
final ContentValues values = item.getContent();
if (item.getId() > -1) {
result += db.update(item.getTableName(), values,
DBItem.COL_ID + " IS ?",
new String[] { String.valueOf(item.getId()) });
}
// Update failed or wasn't possible, insert instead
if (result < 1) {
final long id = db.insert(item.getTableName(), null, values);
if (id > 0) {
item.setId(id);
success = true;
}
} else {
success = true;
}
if (success) {
item.notifyProvider(context);
}
return success;
}
public synchronized int deleteItem(DBItem item) {
final SQLiteDatabase db = this.getWritableDatabase();
final int result = db.delete(item.getTableName(), DBItem.COL_ID
+ " IS ?", new String[] { Long.toString(item.getId()) });
if (result > 0) {
item.notifyProvider(context);
}
return result;
}
public synchronized Cursor gettaskviewItemCursor(final long id) {
final SQLiteDatabase db = this.getReadableDatabase();
final Cursor cursor = db.query(taskviewItem.TABLE_NAME,
taskviewItem.FIELDS, taskviewItem.COL_ID + " IS ?",
new String[] { String.valueOf(id) }, null, null, null, null);
return cursor;
}
public synchronized taskviewItem gettaskviewItem(final long id) {
final Cursor cursor = gettaskviewItemCursor(id);
final taskviewItem result;
if (cursor.moveToFirst()) {
result = new taskviewItem(cursor);
} else {
result = null;
}
cursor.close();
return result;
}
public synchronized Cursor getAlltaskviewItemsCursor(final String selection,
final String[] args,
final String sortOrder) {
final SQLiteDatabase db = this.getReadableDatabase();
final Cursor cursor = db.query(taskviewItem.TABLE_NAME,
taskviewItem.FIELDS, selection, args, null, null, sortOrder, null);
return cursor;
}
public synchronized List getAlltaskviewItems(final String selection,
final String[] args,
final String sortOrder) {
final List result = new ArrayList();
final Cursor cursor = getAlltaskviewItemsCursor(selection, args, sortOrder);
while (cursor.moveToNext()) {
taskviewItem q = new taskviewItem(cursor);
result.add(q);
}
cursor.close();
return result;
}
}
================================================
FILE: app/dbgenout/com/nononsenseapps/notepad/database/DatabaseTriggers.java
================================================
package com.nononsenseapps.notepad.database;
import android.database.sqlite.SQLiteDatabase;
public class DatabaseTriggers {
/**
* Create permanent triggers. They are dropped first,
* if they already exist.
*/
public static void create(final SQLiteDatabase db) {
}
/**
* Create temporary triggers. Nothing is done if they
* already exist.
*/
public static void createTemp(final SQLiteDatabase db) {
}
}
================================================
FILE: app/dbgenout/com/nononsenseapps/notepad/database/DatabaseViews.java
================================================
package com.nononsenseapps.notepad.database;
import android.database.sqlite.SQLiteDatabase;
public class DatabaseViews {
/**
* Create permanent views. They are dropped first,
* if they already exist.
*/
public static void create(final SQLiteDatabase db) {
}
/**
* Create temporary views. Nothing is done if they
* already exist.
*/
public static void createTemp(final SQLiteDatabase db) {
db.execSQL(taskview_template);
}
private static final String taskview_template =
"CREATE TEMP VIEW IF NOT EXISTS taskview_template AS SELECT _id FROM taskfts WHERE ftstable MATCH 'textfilter';";
}
================================================
FILE: app/dbgenout/com/nononsenseapps/notepad/database/ItemProvider.java
================================================
package com.nononsenseapps.notepad.database;
import android.content.ContentProvider;
import android.content.ContentValues;
import android.content.UriMatcher;
import android.database.Cursor;
import android.net.Uri;
public class ItemProvider extends ContentProvider {
public static final String AUTHORITY = "com.nononsenseapps.notepad.database.AUTHORITY";
public static final String SCHEME = "content://";
private static final UriMatcher sURIMatcher = new UriMatcher(
UriMatcher.NO_MATCH);
static {
taskviewItem.addMatcherUris(sURIMatcher);
}
@Override
public boolean onCreate() {
return true;
}
@Override
public int delete(Uri uri, String selection, String[] selectionArgs) {
// Setup some common parsing and stuff
final String table;
final ContentValues values = new ContentValues();
final ArrayList args = new ArrayList();
if (selectionArgs != null) {
for (String arg : selectionArgs) {
args.add(arg);
}
}
final StringBuilder sb = new StringBuilder();
if (selection != null && !selection.isEmpty()) {
sb.append("(").append(selection).append(")");
}
// Configure table and args depending on uri
switch (sURIMatcher.match(uri)) {
case taskviewItem.BASEITEMCODE:
table = taskviewItem.TABLE_NAME;
if (selection != null && !selection.isEmpty()) {
sb.append(" AND ");
}
sb.append(taskviewItem.COL_ID + " IS ?");
args.add(uri.getLastPathSegment());
// Alternative is this
// values.put(taskviewItem.COL_DELETED, 1);
break;
default:
throw new IllegalArgumentException("Unknown URI " + uri);
}
// Write to DB
final SQLiteDatabase db = DatabaseHandler.getInstance(getContext())
.getWritableDatabase();
final String[] argArray = new String[args.size()];
final int result = db.delete(table, sb.toString(),
args.toArray(argArray));
// Or alternatively
//final int result = db.update(table, values, sb.toString(),
// args.toArray(argArray));
if (result > 0) {
// Support upload sync
getContext().getContentResolver().notifyChange(uri, null, true);
}
return result;
}
@Override
public Uri insert(Uri uri, ContentValues values) {
// TODO: Implement this to handle requests to insert a new row.
throw new UnsupportedOperationException("Not yet implemented");
}
@Override
public int update(Uri uri, ContentValues values, String selection,
String[] selectionArgs) {
// TODO: Implement this to handle requests to update one or more rows.
throw new UnsupportedOperationException("Not yet implemented");
}
@Override
public String getType(Uri uri) {
switch (sURIMatcher.match(uri)) {
case taskviewItem.BASEITEMCODE:
return taskviewItem.TYPE_ITEM;
case taskviewItem.BASEURICODE:
return taskviewItem.TYPE_DIR;
default:
throw new IllegalArgumentException("Unknown URI " + uri);
}
}
@Override
public Cursor query(Uri uri, String[] projection, String selection,
String[] args, String sortOrder) {
Cursor result = null;
final long id;
final DatabaseHandler handler = DatabaseHandler.getInstance(getContext());
switch (sURIMatcher.match(uri)) {
case taskviewItem.BASEITEMCODE:
id = Long.parseLong(uri.getLastPathSegment());
result = handler.gettaskviewItemCursor(id);
result.setNotificationUri(getContext().getContentResolver(), uri);
break;
case taskviewItem.BASEURICODE:
result = handler.getAlltaskviewItemsCursor(selection, args, sortOrder);
result.setNotificationUri(getContext().getContentResolver(), uri);
break;
default:
throw new IllegalArgumentException("Unknown URI " + uri);
}
return result;
}
}
================================================
FILE: app/dbgenout/com/nononsenseapps/notepad/database/taskviewItem.java
================================================
package com.nononsenseapps.notepad.database;
import android.content.ContentValues;
import android.content.UriMatcher;
import android.database.Cursor;
import android.net.Uri;
/**
* Represents taskview in the database.
*/
public class taskviewItem extends DBItem {
public static final String TABLE_NAME = "taskview";
public static Uri URI() {
return Uri.withAppendedPath(
Uri.parse(ItemProvider.SCHEME
+ ItemProvider.AUTHORITY), TABLE_NAME);
}
// Column names
public static final String COL_ID = "_id";
public static final String COL_TITLE = "title";
public static final String COL_FILTERTEXT = "filterText";
public static final String COL_FILTERDUEEARLIEST = "filterDueEarliest";
public static final String COL_FILTERDUELATEST = "filterDueLatest";
public static final String COL_FILTERCOMPLETED = "filterCompleted";
// For database projection so order is consistent
public static final String[] FIELDS = { COL_ID, COL_TITLE, COL_FILTERTEXT, COL_FILTERDUEEARLIEST, COL_FILTERDUELATEST, COL_FILTERCOMPLETED };
public Long _id = -1;
public String title = "";
public String filterText = "";
public Long filterDueEarliest;
public Long filterDueLatest;
public Long filterCompleted;
public static final int BASEURICODE = 0x2d72bbd;
public static final int BASEITEMCODE = 0x824cd6d;
public static void addMatcherUris(UriMatcher sURIMatcher) {
sURIMatcher.addURI(ItemProvider.AUTHORITY, TABLE_NAME, BASEURICODE);
sURIMatcher.addURI(ItemProvider.AUTHORITY, TABLE_NAME + "/#", BASEITEMCODE);
}
public static final String TYPE_DIR = "vnd.android.cursor.dir/vnd.com.nononsenseapps.notepad.database." + TABLE_NAME;
public static final String TYPE_ITEM = "vnd.android.cursor.item/vnd.com.nononsenseapps.notepad.database." + TABLE_NAME;
public taskviewItem() {
super();
}
public taskviewItem(final Cursor cursor) {
super();
// Projection expected to match FIELDS array
this._id = cursor.getLong(0);
this.title = cursor.getString(1);
this.filterText = cursor.getString(2);
this.filterDueEarliest = cursor.isNull(3) ? null : cursor.getLong(3);
this.filterDueLatest = cursor.isNull(4) ? null : cursor.getLong(4);
this.filterCompleted = cursor.isNull(5) ? null : cursor.getLong(5);
}
public ContentValues getContent() {
ContentValues values = new ContentValues();
values.put(COL_TITLE, title);
values.put(COL_FILTERTEXT, filterText);
if (filterDueEarliest != null) {
values.put(COL_FILTERDUEEARLIEST, filterDueEarliest);
} else {
values.putNull(COL_FILTERDUEEARLIEST);
}
if (filterDueLatest != null) {
values.put(COL_FILTERDUELATEST, filterDueLatest);
} else {
values.putNull(COL_FILTERDUELATEST);
}
if (filterCompleted != null) {
values.put(COL_FILTERCOMPLETED, filterCompleted);
} else {
values.putNull(COL_FILTERCOMPLETED);
}
return values;
}
public String getTableName() {
return TABLE_NAME;
}
public String[] getFields() {
return FIELDS;
}
public long getId() {
return _id;
}
public void setId(final long id) {
_id = id;
}
public static final String CREATE_TABLE =
"CREATE TABLE taskview"
+ " (_id INTEGER PRIMARY KEY,"
+ " title TEXT NOT NULL DEFAULT '',"
+ " filterText TEXT NOT NULL DEFAULT '',"
+ " filterDueEarliest INTEGER,"
+ " filterDueLatest INTEGER,"
+ " filterCompleted INTEGER"
+ ""
+ " )";
}
================================================
FILE: app/dbsetup.py
================================================
"""Generate a sample project with triggers"""
from AndroidCodeGenerator.generator import Generator
from AndroidCodeGenerator.sql_validator import SQLTester
from AndroidCodeGenerator.db_table import (Table, Column, ForeignKey, Unique,
Trigger, Check)
from AndroidCodeGenerator.database_triggers import DatabaseTriggers
tables = []
triggers = []
tasklists = Table('tasklist')
tasklists.add_cols(Column('title').text.not_null.default("''"),
Column('updated').integer,
Column('listtype').text,
Column('sorting').text,
Column('deleted').integer.not_null.default(0),
# New fields
Column('ctime').timestamp.default_current_timestamp,
Column('mtime').timestamp.default_current_timestamp,
# GTask fields
Column('account').text,
Column('remoteid').text)
tasklists.add_constraints(Unique('account', 'remoteid').on_conflict_replace)
tables.append(tasklists)
tasks = Table('task')
tasks.add_cols(Column('title').text.not_null.default("''"),
Column('note').text.not_null.default("''"),
Column('completed').integer,
Column('updated').integer,
Column('due').integer,
Column('locked').integer.not_null.default(0),
Column('deleted').integer.not_null.default(0),
# Positions
Column('posleft').integer.not_null.default(1),
Column('posright').integer.not_null.default(2),
Column('tasklist').integer.not_null,
# New fields
Column('ctime').timestamp.default_current_timestamp,
Column('mtime').timestamp.default_current_timestamp,
# GTask fields
Column('account').text,
Column('remoteid').text)
tasks.add_constraints(ForeignKey('tasklist').references(tasklists.name)\
.on_delete_cascade,
Unique('account', 'remoteid').on_conflict_replace,
Check('posleft', '> 0'),
Check('posright', '> 1'))
tables.append(tasks)
log = Table('history')
log.add_cols(Column('taskid').integer,
Column('title').text.not_null.default("''"),
Column('note').text.not_null.default("''"),
Column('deleted').integer.not_null.default(0),
Column('updated').timestamp.not_null.default_current_timestamp)
log.add_constraints(ForeignKey('taskid').references(tasks.name)\
.on_delete_set_null)
tables.append(log)
for name in ['tr_up_history', 'tr_ins_history',
'tr_del_history']:
t = Trigger(name).temp.if_not_exists
deleted = 'new.deleted'
if 'up' in name:
t.after.update_on(tasks.name)
elif 'ins' in name:
t.after.insert_on(tasks.name)
else:
t.before.delete_on(tasks.name)
deleted = 1
t.do_sql("INSERT INTO {} \
(taskid, title, note, deleted) \
VALUES (new._id, new.title, new.note, {})".format(log.name, deleted))
triggers.append(t)
# Notifications
nots = Table('notification')
nots.add_cols(Column('time').integer,
Column('permanent').integer.not_null.default(0),
Column('taskid').integer,
Column('repeats').integer.not_null.default(0),
Column('locationname').text,
Column('latitude').real,
Column('longitude').real,
Column('radius').real)
nots.add_constraints(ForeignKey('taskid').references(tasks.name)\
.on_delete_cascade)
tables.append(nots)
# Let's try to create the SQL
st = SQLTester()
st.add_tables(*tables)
st.add_triggers(*triggers)
st.test_create()
#g = Generator(path='./sample/src/com/example/appname/database/')
#g.add_tables(persons, log)
#g.add_triggers(trigger)
#g.write()
================================================
FILE: app/proguard.pro
================================================
# Add project specific ProGuard rules here.
# You can edit the include path and order by changing the proguardFiles
# directive in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# Add any project specific keep options here:
# I only care about minimizing
-dontobfuscate
# Everything in the app is essential
-keep class com.nononsenseapps.** { *; }
-keep public class com.nononsenseapps.** { *; }
-keep class com.mobeta.android.** { *; }
-keepclassmembers enum * {
public static **[] values();
public static ** valueOf(java.lang.String);
}
# gradle (and stackoverflow) agree that suppressing the warning is appropriate
-dontwarn org.joda.convert.**
================================================
FILE: app/src/androidTest/java/com/nononsenseapps/notepad/espresso_tests/BaseTestClass.java
================================================
package com.nononsenseapps.notepad.espresso_tests;
import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.assertion.ViewAssertions.matches;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
import static androidx.test.espresso.matcher.ViewMatchers.isRoot;
import android.Manifest;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import androidx.preference.PreferenceManager;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.espresso.intent.rule.IntentsTestRule;
import androidx.test.ext.junit.rules.ActivityScenarioRule;
import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.rule.GrantPermissionRule;
import com.nononsenseapps.helpers.NnnLogger;
import com.nononsenseapps.notepad.activities.main.ActivityMain_;
import com.nononsenseapps.notepad.database.DatabaseHandler;
import org.junit.After;
import org.junit.Before;
import org.junit.Rule;
public class BaseTestClass {
/**
* A JUnit {@link Rule @Rule} to launch your activity under test. This replaces
* ActivityInstrumentationTestCase2. Rules are executed for each test method and will run
* before any of your setup code in the @Before method. To get a reference to the activity
* you can use: {@link IntentsTestRule#getActivity()}
*
* NOTE: the newer alternative, {@link ActivityScenarioRule}, DOES NOT WORK
*/
@SuppressWarnings("deprecation")
@Rule
public IntentsTestRule mActRule;
/**
* Since API 33 we need permission for notifications
*/
@Rule
public GrantPermissionRule mNotifRule;
/**
* @return a string with the content of the given resourceId
*/
public String getStringResource(int resourceId) {
return mActRule.getActivity().getString(resourceId);
}
@After
public void clearAppData() {
if (mActRule != null) mActRule.finishActivity();
Context context = ApplicationProvider.getApplicationContext();
// clear the app's data as the test is starting & finishing
PreferenceManager.getDefaultSharedPreferences(context).edit().clear().commit();
DatabaseHandler.resetDatabase(context);
}
/**
* Tries in many ways to give notification permission for OS versions that need it
*/
private void giveNotifyPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
// this permission works only on API >= 33, it crashes on older versions!
mNotifRule = GrantPermissionRule.grant(Manifest.permission.POST_NOTIFICATIONS);
String command = "pm grant " +
ApplicationProvider.getApplicationContext().getPackageName() + " " +
Manifest.permission.POST_NOTIFICATIONS;
// this one is more likely to work
InstrumentationRegistry
.getInstrumentation()
.getUiAutomation()
.executeShellCommand(command);
} else {
mNotifRule = null;
}
}
/**
* Many times, on the github VM, the tests fail with RootViewWithoutFocusException,
* I think it's due to the emulator being slow. Let's launch the activity and wait
* for it to load before starting the real test
*/
@Before
public void launchAndWait() {
// ensure that this is called BEFORE trying to start the activity
clearAppData();
// first, acquire all the required permissions ...
giveNotifyPermission();
// ... then, create and run the entry point to the app
mActRule = new IntentsTestRule<>(ActivityMain_.class);
Intent launchApp = new Intent(ApplicationProvider.getApplicationContext(), ActivityMain_.class);
mActRule.launchActivity(launchApp);
try {
// it responds => we can return now
onView(isRoot()).check(matches(isDisplayed()));
return;
} catch (Exception e) {
NnnLogger.error(this.getClass(), "Activity isn't responsive:");
NnnLogger.exception(e);
}
// maybe we just have to wait
EspressoHelper.waitUi();
// if it's still not enough, let's crash here
try {
onView(isRoot()).check(matches(isDisplayed()));
} catch (Exception e) {
NnnLogger.error(this.getClass(), "Can't launch activity:");
NnnLogger.exception(e);
throw e;
}
}
}
================================================
FILE: app/src/androidTest/java/com/nononsenseapps/notepad/espresso_tests/EspressoHelper.java
================================================
package com.nononsenseapps.notepad.espresso_tests;
import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.action.ViewActions.click;
import static androidx.test.espresso.action.ViewActions.closeSoftKeyboard;
import static androidx.test.espresso.action.ViewActions.typeText;
import static androidx.test.espresso.assertion.ViewAssertions.matches;
import static androidx.test.espresso.matcher.RootMatchers.isDialog;
import static androidx.test.espresso.matcher.ViewMatchers.isClickable;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
import static androidx.test.espresso.matcher.ViewMatchers.isRoot;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static androidx.test.espresso.matcher.ViewMatchers.withText;
import static org.hamcrest.Matchers.instanceOf;
import android.app.UiAutomation;
import android.os.SystemClock;
import androidx.annotation.IdRes;
import androidx.test.espresso.AmbiguousViewMatcherException;
import androidx.test.espresso.Espresso;
import androidx.test.espresso.ViewInteraction;
import androidx.test.espresso.contrib.DrawerActions;
import androidx.test.platform.app.InstrumentationRegistry;
import com.getkeepsafe.taptargetview.TapTargetView;
import com.nononsenseapps.helpers.NnnLogger;
import com.nononsenseapps.notepad.activities.main.ActivityMain;
import com.nononsenseapps.notepad.R;
import org.junit.Assert;
public class EspressoHelper {
/**
* Wait for 350ms to work around timing issues on slow emulators. It's called to solve issues
* with flaky tests on github runners. Sometime tests need it, sometimes they don't,
* but you can't know. It should go after every call to {@link ViewInteraction#perform}
*/
public static void waitUi() {
InstrumentationRegistry.getInstrumentation().waitForIdleSync();
SystemClock.sleep(400);
}
/**
* open the drawer on the left
*/
public static void openDrawer() {
try {
onView(withId(R.id.drawerLayout)).check(matches(isDisplayed()));
} catch (Exception e) {
Assert.fail("Can't find the drawerLayout, maybe a dialog is still open?");
return;
}
onView(withId(R.id.drawerLayout)).perform(DrawerActions.open());
}
public static void createNoteWithName(String noteName) {
onView(withId(R.id.menu_add))
.check(matches(isDisplayed()))
.check(matches(isClickable()));
onView(withId(R.id.menu_add)).perform(click());
EspressoHelper.hideShowCaseViewIfShown();
onView(withId(R.id.taskText)).perform(typeText(noteName));
}
/**
* Presses "+" and writes text for each note given in noteNames
*/
public static void createNotes(String[] noteNames) {
for (String noteName : noteNames) {
createNoteWithName(noteName);
}
}
/**
* Add a new task list. The drawer should be open when this is called
*
* @param taskListName name of the task list
*/
public static void createTaskList(String taskListName) {
EspressoHelper.openDrawer();
// dismiss the other showcase view
EspressoHelper.hideShowCaseViewIfShown();
onView(withId(R.id.drawer_menu_createlist)).check(matches(isDisplayed()));
// sometimes the automator will hold the button too long. This will show the tooltip,
// and the test will fail because the button did not get the click to show the dialog.
// It's a matter of luck: retry the test and it will work
onView(withId(R.id.drawer_menu_createlist)).perform(click());
waitUi(); // the popup may need time to load
// the text field in the dialog visibile must be visible
onViewWithIdInDialog(R.id.titleField).check(matches(isDisplayed()));
// fill the popup
onViewWithIdInDialog(R.id.titleField).perform(typeText(taskListName));
onViewWithIdInDialog(R.id.dialog_yes).check(matches(isDisplayed()));
onViewWithIdInDialog(R.id.dialog_yes).perform(closeSoftKeyboard());
onViewWithIdInDialog(R.id.dialog_yes).perform(click());
}
/**
* shorthand for onView(withId(viewId)).inRoot(isDialog())
*
* @param viewId it's R.id.something
*/
public static ViewInteraction onViewWithIdInDialog(@IdRes int viewId) {
return onView(withId(viewId)).inRoot(isDialog());
}
/**
* @return TRUE if the app is in tablet mode, FALSE in phone mode
*/
public static boolean isInTabletMode() {
boolean isInPortraitMode = InstrumentationRegistry
.getInstrumentation()
.getTargetContext()
.getResources()
.getBoolean(R.bool.fillEditor);
return !isInPortraitMode;
}
public static void navigateUp() {
onView(isRoot()).perform(closeSoftKeyboard());
if (isInTabletMode()) {
// we are in tablet mode: press "+" to make a note appear in the list
onView(withId(R.id.menu_add)).perform(click());
} else {
// we are in phone mode: close the keyboard & press the back button
Espresso.pressBack();
}
}
/**
* Exits the "settings" activity, going back to {@link ActivityMain}
*/
public static void exitPrefsActivity() {
String label = InstrumentationRegistry
.getInstrumentation()
.getTargetContext()
.getString(R.string.menu_preferences);
try {
// TODO improve & return if necessary
onView(withText(label)).check(matches(isDisplayed()));
} catch (Exception e) {
NnnLogger.warning(EspressoHelper.class, "Can't determine if PrefsActivity is shown:");
NnnLogger.exception(e);
}
// for now, assume this function is called only when a fragment of PrefsActivity is shown
if (isInTabletMode()) {
// tablets show menu & category => press back only once
Espresso.pressBack();
} else {
// in phones, go back to the menu, then back to ActivityMain
Espresso.pressBack();
Espresso.pressBack();
}
}
/**
* @return TRUE if the {@link TapTargetView} is currently shown, FALSE otherwise
*/
private static Boolean isShowCaseOverlayVisible() {
try {
onView(instanceOf(TapTargetView.class)).check(matches(isDisplayed()));
return true;
} catch (AmbiguousViewMatcherException ignored) {
// we have at least one, so it counts as visible
return true;
} catch (Throwable ignored) {
// it has to be "Throwable", not "Exception"
return false;
}
}
/**
* If the {@link TapTargetView} is shown, touch the screen to hide it, so that tests
* can then interact with the app.
*/
public static void hideShowCaseViewIfShown() {
waitUi();
if (!EspressoHelper.isShowCaseOverlayVisible()) return;
// click anywhere to dismiss it
try {
onView(instanceOf(TapTargetView.class)).perform(click());
} catch (Exception ignored) {
Assert.fail("Could not dismiss the TapTargetView");
return;
}
waitUi();
}
/**
* Rotate the screen twice, waiting ~4 seconds for the animations to finish.
* It automatically understands if the phone or tablet is "naturally" held in
* landscape or portrait mode, so test should be done with the emulator's default
* settings: phones in portrait mode and tablets in landscape mode
*/
public static void rotateScreenAndWait() {
var uiAuto = InstrumentationRegistry
.getInstrumentation()
.getUiAutomation();
// rotate it
uiAuto.setRotation(UiAutomation.ROTATION_FREEZE_270);
// wait 1s for the rotation to finish
waitUi();
waitUi();
// rotating the screen sometimes makes the taptargetview appear in the wrong place.
// I have no idea why. In any case, we have to close it now, or else the next
// screen rotation will make the app crash. Yes it's incomprehensible, but now it works
EspressoHelper.hideShowCaseViewIfShown();
// rotate it more
uiAuto.setRotation(UiAutomation.ROTATION_FREEZE_0);
waitUi();
waitUi();
// unfreeze it and let it go back to its default state
uiAuto.setRotation(UiAutomation.ROTATION_UNFREEZE);
waitUi();
waitUi();
waitUi();
}
}
================================================
FILE: app/src/androidTest/java/com/nononsenseapps/notepad/espresso_tests/TestAddBigNumberOfNotesScrollDownAndDeleteOne.java
================================================
package com.nononsenseapps.notepad.espresso_tests;
import static android.view.View.FIND_VIEWS_WITH_TEXT;
import static androidx.test.espresso.Espresso.onData;
import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.action.ViewActions.click;
import static androidx.test.espresso.action.ViewActions.scrollTo;
import static androidx.test.espresso.matcher.ViewMatchers.hasMinimumChildCount;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static junit.framework.Assert.assertTrue;
import static org.hamcrest.Matchers.allOf;
import static org.hamcrest.Matchers.equalToIgnoringCase;
import static org.hamcrest.Matchers.instanceOf;
import android.view.View;
import android.widget.ListView;
import androidx.test.espresso.ViewAssertion;
import androidx.test.espresso.matcher.CursorMatchers;
import androidx.test.filters.LargeTest;
import com.mobeta.android.dslv.DragSortListView;
import com.nononsenseapps.notepad.R;
import org.junit.Test;
import java.util.ArrayList;
@LargeTest
public class TestAddBigNumberOfNotesScrollDownAndDeleteOne extends BaseTestClass {
private final String[] noteNameList = {
"prepare food", "take dogs out", "water plants", "sleep",
"go for a jog", "do some work", "play with the dog",
"work out", "do weird stuff", "read a book", "drink water",
"write a book", "proofread the book", "publish the book",
"ponder life", "build a house", "repair the house", "call contractor",
"write another book", "scrap the book project", "start a blog",
" ", " "
};
/**
* credit to Chemouna @ GitHub
*/
private static ViewAssertion doesNotHaveViewWithText(final String text) {
return (view, e) -> {
if (!(view instanceof ListView rv)) {
throw e;
}
ArrayList outviews = new ArrayList<>();
for (int index = 0; index < rv.getAdapter().getCount(); index++) {
rv
//.findViewHolderForAdapterPosition(index)
//.itemView
.findViewsWithText(outviews, text, FIND_VIEWS_WITH_TEXT);
if (!outviews.isEmpty()) break;
}
assertTrue(outviews.isEmpty());
};
}
@Test
public void testAddBigNumberOfNotesScrollDownAndDeleteOne() {
EspressoHelper.hideShowCaseViewIfShown();
EspressoHelper.createNotes(noteNameList);
// exit the note editing mode
EspressoHelper.navigateUp();
// click on the bottom-most note
onData(CursorMatchers
.withRowString("title", equalToIgnoringCase(noteNameList[0])))
.inAdapterView(allOf(
hasMinimumChildCount(1),
instanceOf(DragSortListView.class)))
.perform(scrollTo())
.perform(click());
// delete the note
onView(withId(R.id.menu_delete)).perform(click());
EspressoHelper.waitUi();
onView(withId(android.R.id.button1)).perform(click());
// check that the 1° note added was deleted
onView(allOf(withId(android.R.id.list), isDisplayed()))
.check(doesNotHaveViewWithText(noteNameList[0]));
// if the showcaseview is visible when closing the app, there will be a crash
EspressoHelper.hideShowCaseViewIfShown();
}
}
================================================
FILE: app/src/androidTest/java/com/nononsenseapps/notepad/espresso_tests/TestAddNewNoteShouldShowNameInNotesScreen.java
================================================
package com.nononsenseapps.notepad.espresso_tests;
import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.assertion.ViewAssertions.matches;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
import static androidx.test.espresso.matcher.ViewMatchers.withText;
import static org.hamcrest.Matchers.equalToIgnoringCase;
import androidx.test.filters.LargeTest;
import org.junit.Before;
import org.junit.Test;
@LargeTest
public class TestAddNewNoteShouldShowNameInNotesScreen extends BaseTestClass {
private String noteName1;
@Before
public void initStrings() {
noteName1 = "prepare food";
}
@Test
public void testAddNewNoteShouldShowNameInNotesScreen() {
EspressoHelper.hideShowCaseViewIfShown();
EspressoHelper.createNoteWithName(noteName1);
EspressoHelper.navigateUp();
onView(withText(equalToIgnoringCase(noteName1))).check(matches(isDisplayed()));
}
}
================================================
FILE: app/src/androidTest/java/com/nononsenseapps/notepad/espresso_tests/TestAddNewNoteWithDueDateCheckDateIsVisible.java
================================================
package com.nononsenseapps.notepad.espresso_tests;
import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.action.ViewActions.click;
import static androidx.test.espresso.assertion.ViewAssertions.matches;
import static androidx.test.espresso.matcher.ViewMatchers.isCompletelyDisplayed;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static org.hamcrest.Matchers.allOf;
import androidx.test.filters.LargeTest;
import com.nononsenseapps.notepad.R;
import org.junit.Before;
import org.junit.Test;
@LargeTest
public class TestAddNewNoteWithDueDateCheckDateIsVisible extends BaseTestClass {
private String noteName1;
@Before
public void initStrings() {
noteName1 = "prepare food";
}
@Test
public void testAddNewNoteWithDueDateCheckDateIsVisible() {
EspressoHelper.hideShowCaseViewIfShown();
EspressoHelper.createNoteWithName(noteName1);
onView(withId(R.id.dueDateBox)).perform(click());
onView(withId(android.R.id.button1)).perform(click());
EspressoHelper.navigateUp();
// target only the dateview in the note shown in the "tasks" list
var dateView = onView(allOf(withId(R.id.date), isCompletelyDisplayed()));
dateView.check(matches(isDisplayed()));
}
}
================================================
FILE: app/src/androidTest/java/com/nononsenseapps/notepad/espresso_tests/TestAddNewNoteWithReminderDateAndTime.java
================================================
package com.nononsenseapps.notepad.espresso_tests;
import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.action.ViewActions.click;
import static androidx.test.espresso.assertion.ViewAssertions.matches;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static androidx.test.espresso.matcher.ViewMatchers.withText;
import static org.hamcrest.Matchers.equalToIgnoringCase;
import androidx.test.filters.LargeTest;
import com.nononsenseapps.notepad.R;
import org.junit.Before;
import org.junit.Test;
@LargeTest
public class TestAddNewNoteWithReminderDateAndTime extends BaseTestClass {
private String noteName1;
@Before
public void initStrings() {
noteName1 = "prepare food";
}
@Test
public void testAddNewNoteWithReminderDateAndTime() {
EspressoHelper.hideShowCaseViewIfShown();
EspressoHelper.createNoteWithName(noteName1);
//add reminder
onView(withId(R.id.notificationAdd)).perform(click());
EspressoHelper.waitUi();
//add date
onView(withId(R.id.notificationDate)).perform(click());
EspressoHelper.waitUi();
onView(withId(android.R.id.button1)).perform(click());
//add time
onView(withId(com.nononsenseapps.notepad.R.id.notificationTime)).perform(click());
onView(withId(android.R.id.button1)).perform(click());
EspressoHelper.navigateUp();
//check that the date field is visible
onView(withText(equalToIgnoringCase(noteName1))).perform(click());
onView(withId(R.id.notificationDate)).check(matches(isDisplayed()));
// maybe we should also check someting like
// onView(withId(R.id.notificationDate)).check(matches(withText("november 10")));
}
}
================================================
FILE: app/src/androidTest/java/com/nononsenseapps/notepad/espresso_tests/TestAddNoteToTaskList.java
================================================
package com.nononsenseapps.notepad.espresso_tests;
import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.action.ViewActions.click;
import static androidx.test.espresso.assertion.ViewAssertions.matches;
import static androidx.test.espresso.matcher.ViewMatchers.hasSibling;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static androidx.test.espresso.matcher.ViewMatchers.withText;
import static org.hamcrest.Matchers.allOf;
import androidx.test.filters.LargeTest;
import org.junit.Before;
import org.junit.Test;
@LargeTest
public class TestAddNoteToTaskList extends BaseTestClass {
private String taskListName, noteName1;
@Before
public void initStrings() {
taskListName = "a random task list";
noteName1 = "prepare food";
}
@Test
public void testAddNoteToTaskList() {
EspressoHelper.hideShowCaseViewIfShown();
EspressoHelper.createTaskList(taskListName);
// make sure the correct task list is selected
EspressoHelper.openDrawer();
onView(allOf(withText(taskListName), withId(android.R.id.text1)))
.check(matches(isDisplayed()));
onView(allOf(withText(taskListName), withId(android.R.id.text1)))
.perform(click());
EspressoHelper.waitUi();
// add the note
EspressoHelper.createNoteWithName(noteName1);
EspressoHelper.navigateUp();
// make sure that the number of notes for the task list is actually 1
EspressoHelper.openDrawer();
onView(allOf(withText(taskListName), hasSibling(withText("1"))))
.check(matches(withText(taskListName)));
}
}
================================================
FILE: app/src/androidTest/java/com/nononsenseapps/notepad/espresso_tests/TestAddNotesAndRotateScreen.java
================================================
package com.nononsenseapps.notepad.espresso_tests;
import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.assertion.ViewAssertions.matches;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
import static androidx.test.espresso.matcher.ViewMatchers.withText;
import static org.hamcrest.Matchers.allOf;
import static org.hamcrest.Matchers.instanceOf;
import static org.hamcrest.Matchers.equalToIgnoringCase;
import androidx.test.filters.LargeTest;
import com.nononsenseapps.ui.TitleNoteTextView;
import org.junit.Before;
import org.junit.Test;
@LargeTest
public class TestAddNotesAndRotateScreen extends BaseTestClass {
String noteName1, noteName2, noteName3, noteName4;
@Before
public void initStrings() {
noteName1 = "prepare food";
noteName2 = "take dogs out";
noteName3 = "water plants";
noteName4 = "sleep";
}
@Test
public void testAddNotesAndRotateScreen() {
EspressoHelper.hideShowCaseViewIfShown();
String[] noteNames = { noteName1, noteName2, noteName3, noteName4 };
EspressoHelper.createNotes(noteNames);
EspressoHelper.navigateUp();
EspressoHelper.rotateScreenAndWait();
// in case it's still there
EspressoHelper.hideShowCaseViewIfShown();
// check that textviews still show up.
onView(allOf(
instanceOf(TitleNoteTextView.class),
withText(equalToIgnoringCase(noteNames[0]))))
.check(matches(isDisplayed()));
onView(allOf(
instanceOf(TitleNoteTextView.class),
withText(equalToIgnoringCase(noteNames[1]))))
.check(matches(isDisplayed()));
onView(allOf(
instanceOf(TitleNoteTextView.class),
withText(equalToIgnoringCase(noteNames[2]))))
.check(matches(isDisplayed()));
onView(allOf(
instanceOf(TitleNoteTextView.class),
withText(equalToIgnoringCase(noteNames[3]))))
.check(matches(isDisplayed()));
}
}
================================================
FILE: app/src/androidTest/java/com/nononsenseapps/notepad/espresso_tests/TestAddTaskListAndRotateScreen.java
================================================
package com.nononsenseapps.notepad.espresso_tests;
import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.assertion.ViewAssertions.matches;
import static androidx.test.espresso.matcher.ViewMatchers.hasDescendant;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static androidx.test.espresso.matcher.ViewMatchers.withText;
import static org.hamcrest.Matchers.equalToIgnoringCase;
import static org.hamcrest.Matchers.allOf;
import androidx.test.espresso.contrib.RecyclerViewActions;
import androidx.test.filters.LargeTest;
import org.junit.Before;
import org.junit.Test;
@LargeTest
public class TestAddTaskListAndRotateScreen extends BaseTestClass {
private String taskListName;
@Before
public void initStrings() {
taskListName = "a random task list";
}
@Test
public void testAddTaskListAndRotateScreen() {
EspressoHelper.hideShowCaseViewIfShown();
EspressoHelper.createTaskList(taskListName);
EspressoHelper.openDrawer();
EspressoHelper.rotateScreenAndWait();
// make sure the task list is still visible.
// if the rotations didn't finish, it will crash here
RecyclerViewActions
.scrollTo(hasDescendant(withText(equalToIgnoringCase(taskListName))));
onView(allOf(
withText(equalToIgnoringCase(taskListName)),
withId(android.R.id.text1)))
.check(matches(isDisplayed()));
}
}
================================================
FILE: app/src/androidTest/java/com/nononsenseapps/notepad/espresso_tests/TestAddTaskListCheckItIsAddedToDrawer.java
================================================
package com.nononsenseapps.notepad.espresso_tests;
import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.assertion.ViewAssertions.matches;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static androidx.test.espresso.matcher.ViewMatchers.withText;
import static org.hamcrest.Matchers.allOf;
import androidx.test.filters.LargeTest;
import org.junit.Before;
import org.junit.Test;
@LargeTest
public class TestAddTaskListCheckItIsAddedToDrawer extends BaseTestClass {
private String taskListName;
@Before
public void initStrings() {
taskListName = "a random task list";
}
@Test
public void testAddTaskListCheckItIsAddedToDrawer() {
EspressoHelper.hideShowCaseViewIfShown();
EspressoHelper.createTaskList(taskListName);
EspressoHelper.openDrawer();
//check that the new note is found and has the correct text
onView(allOf(withText(taskListName), withId(android.R.id.text1)))
.check(matches(isDisplayed()));
}
}
================================================
FILE: app/src/androidTest/java/com/nononsenseapps/notepad/espresso_tests/TestAddTaskListsScrollNavigationDrawer.java
================================================
package com.nononsenseapps.notepad.espresso_tests;
import static androidx.test.espresso.Espresso.onData;
import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.Espresso.openContextualActionModeOverflowMenu;
import static androidx.test.espresso.action.ViewActions.click;
import static androidx.test.espresso.action.ViewActions.scrollTo;
import static androidx.test.espresso.assertion.ViewAssertions.matches;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static androidx.test.espresso.matcher.ViewMatchers.withText;
import static org.hamcrest.Matchers.allOf;
import static org.hamcrest.Matchers.equalToIgnoringCase;
import androidx.test.espresso.matcher.CursorMatchers;
import androidx.test.filters.LargeTest;
import com.nononsenseapps.notepad.R;
import org.junit.Test;
@LargeTest
public class TestAddTaskListsScrollNavigationDrawer extends BaseTestClass {
final String[] taskListNames = { "Lorem", "ipsum ", "dolor ", "sit ", "amet", "consectetur ",
"adipiscing ", "elit", "sed ", "do ", "eiusmod ", "tempor ", "incididunt ",
"ut ", "labore " };
@Test
public void testAddTaskListsScrollNavigationDrawer() {
String SETTINGS_TEXT = getStringResource(R.string.menu_preferences);
String SETTINGS_APPEARANCE_TEXT = getStringResource(R.string.settings_cat_appearance);
EspressoHelper.hideShowCaseViewIfShown();
for (String name : taskListNames) {
EspressoHelper.createTaskList(name);
EspressoHelper.openDrawer();
}
EspressoHelper.openDrawer();
// onData() can scroll to the item, but can't click it
onData(CursorMatchers
.withRowString("title", equalToIgnoringCase("ut ")))
.inAdapterView(withId(R.id.leftDrawer))
.perform(scrollTo())
.check(matches(isDisplayed()));
// onView() can click on the item, but can't scroll to it
onView(allOf(
withText(equalToIgnoringCase("ut ")),
withId(android.R.id.text1)))
.perform(click());
EspressoHelper.openDrawer();
// open the preferences page and check that it is visible
openContextualActionModeOverflowMenu();
onView(withText(SETTINGS_TEXT)).perform(click());
onView(withText(SETTINGS_APPEARANCE_TEXT))
.check(matches(isDisplayed()));
}
}
================================================
FILE: app/src/androidTest/java/com/nononsenseapps/notepad/espresso_tests/TestCompletedTasksAreCleared.java
================================================
package com.nononsenseapps.notepad.espresso_tests;
import static androidx.test.espresso.Espresso.onData;
import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.Espresso.openContextualActionModeOverflowMenu;
import static androidx.test.espresso.action.ViewActions.click;
import static androidx.test.espresso.action.ViewActions.closeSoftKeyboard;
import static androidx.test.espresso.assertion.ViewAssertions.doesNotExist;
import static androidx.test.espresso.matcher.ViewMatchers.hasChildCount;
import static androidx.test.espresso.matcher.ViewMatchers.isRoot;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static androidx.test.espresso.matcher.ViewMatchers.withText;
import static org.hamcrest.Matchers.allOf;
import static org.hamcrest.Matchers.anything;
import androidx.test.espresso.DataInteraction;
import androidx.test.espresso.Espresso;
import androidx.test.espresso.PerformException;
import androidx.test.filters.LargeTest;
import com.nononsenseapps.notepad.R;
import org.junit.Before;
import org.junit.Test;
@LargeTest
public class TestCompletedTasksAreCleared extends BaseTestClass {
String noteName1, noteName2, noteName3, noteName4;
@Before
public void initStrings() {
noteName1 = "prepare food";
noteName2 = "take dogs out";
noteName3 = "water plants";
noteName4 = "sleep";
}
@Test
public void testCompletedTasksAreCleared() {
EspressoHelper.hideShowCaseViewIfShown();
String[] noteNames = { noteName1, noteName2, noteName3, noteName4 };
EspressoHelper.createNotes(noteNames);
EspressoHelper.navigateUp();
clickCheckBoxAt(1);
clickCheckBoxAt(3);
//clear notes
openContextualActionModeOverflowMenu();
String CLEAR_COMPLETED = getStringResource(R.string.menu_clearcompleted);
onView(withText(CLEAR_COMPLETED)).perform(click());
onView(withId(android.R.id.button1)).perform(click());
//check that the notes do not exist any more
onView(withText(noteNames[0]))
.check(doesNotExist());
onView(withText(noteNames[2]))
.check(doesNotExist());
}
/**
* this function expects the list to have 5 children (=notes): the 4 added in this test
* + the default 'welcome' note
*/
private void clickCheckBoxAt(int position) {
// the keyboard may cover the notes, which makes the next lines crash!
try {
Espresso.closeSoftKeyboard();
} catch (PerformException ignored) {
// keyboard was already closed
}
DataInteraction di = onData(anything())
.inAdapterView(allOf(withId(android.R.id.list), hasChildCount(5)))
.atPosition(position)
.onChildView(withId(R.id.checkbox));
di.perform(click());
}
}
================================================
FILE: app/src/androidTest/java/com/nononsenseapps/notepad/espresso_tests/TestCreateNoteAndDeleteIt.java
================================================
package com.nononsenseapps.notepad.espresso_tests;
import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.action.ViewActions.click;
import static androidx.test.espresso.assertion.ViewAssertions.doesNotExist;
import static androidx.test.espresso.assertion.ViewAssertions.matches;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static androidx.test.espresso.matcher.ViewMatchers.withText;
import static org.hamcrest.Matchers.equalToIgnoringCase;
import androidx.test.filters.LargeTest;
import com.nononsenseapps.notepad.R;
import org.junit.Before;
import org.junit.Test;
@LargeTest
public class TestCreateNoteAndDeleteIt extends BaseTestClass {
private String noteName1;
@Before
public void initStrings() {
noteName1 = "prepare food";
}
@Test
public void testCreateNoteAndDeleteIt() {
EspressoHelper.hideShowCaseViewIfShown();
EspressoHelper.createNoteWithName(noteName1);
EspressoHelper.navigateUp();
onView(withText(equalToIgnoringCase(noteName1))).perform(click());
onView(withId(R.id.menu_delete)).perform(click());
onView(withId(android.R.id.button1)).perform(click());
// assert that we're back in the list
onView(withId(R.id.menu_search)).check(matches(isDisplayed()));
//check that the view is not visible
onView(withText(equalToIgnoringCase(noteName1))).check(doesNotExist());
}
}
================================================
FILE: app/src/androidTest/java/com/nononsenseapps/notepad/espresso_tests/TestCreateTaskListAndDeleteIt.java
================================================
package com.nononsenseapps.notepad.espresso_tests;
import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.action.ViewActions.click;
import static androidx.test.espresso.action.ViewActions.closeSoftKeyboard;
import static androidx.test.espresso.action.ViewActions.longClick;
import static androidx.test.espresso.assertion.ViewAssertions.doesNotExist;
import static androidx.test.espresso.matcher.RootMatchers.isDialog;
import static androidx.test.espresso.matcher.ViewMatchers.isRoot;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static androidx.test.espresso.matcher.ViewMatchers.withText;
import static org.hamcrest.Matchers.allOf;
import androidx.test.filters.LargeTest;
import com.nononsenseapps.notepad.R;
import org.junit.Before;
import org.junit.Test;
@LargeTest
public class TestCreateTaskListAndDeleteIt extends BaseTestClass {
private String taskListName;
@Before
public void initStrings() {
taskListName = "a random task list";
}
@Test
public void testCreateTaskListAndDeleteIt() {
EspressoHelper.hideShowCaseViewIfShown();
EspressoHelper.createTaskList(taskListName);
EspressoHelper.openDrawer();
onView(allOf(withText(taskListName), withId(android.R.id.text1))).perform(longClick());
EspressoHelper.waitUi();
onView(isRoot()).inRoot(isDialog()).perform(closeSoftKeyboard());
onView(withId(R.id.deleteButton)).perform(click());
onView(withText(this.getStringResource(android.R.string.ok))).perform(click());
onView(withText(taskListName)).check(doesNotExist());
}
}
================================================
FILE: app/src/androidTest/java/com/nononsenseapps/notepad/espresso_tests/TestPasswords.java
================================================
package com.nononsenseapps.notepad.espresso_tests;
import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.Espresso.openContextualActionModeOverflowMenu;
import static androidx.test.espresso.action.ViewActions.click;
import static androidx.test.espresso.action.ViewActions.typeText;
import static androidx.test.espresso.assertion.ViewAssertions.matches;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static androidx.test.espresso.matcher.ViewMatchers.withText;
import static org.hamcrest.Matchers.allOf;
import static org.hamcrest.Matchers.instanceOf;
import static org.hamcrest.Matchers.equalToIgnoringCase;
import com.nononsenseapps.notepad.R;
import com.nononsenseapps.ui.TitleNoteTextView;
import org.junit.Test;
public class TestPasswords extends BaseTestClass {
// when the note is locked, only this is shown
final String noteTitle = "this is my note";
// note tile + content, shown in the edittext
final String fullNoteText1 = noteTitle + "\n.\ncontent line";
// typed into the popup
final String password = "itsnotasecrettoanybody";
@Test
public void testAddNoteLockWithPassword() {
EspressoHelper.hideShowCaseViewIfShown();
EspressoHelper.createNoteWithName(fullNoteText1);
EspressoHelper.navigateUp();
onView(withText(equalToIgnoringCase(fullNoteText1))).check(matches(isDisplayed()));
onView(withText(equalToIgnoringCase(fullNoteText1))).perform(click());
openContextualActionModeOverflowMenu();
String MENU_TEXT = getStringResource(R.string.lock_note);
onView(withText(MENU_TEXT)).perform(click());
EspressoHelper
.onViewWithIdInDialog(R.id.passwordField)
.perform(typeText(password));
EspressoHelper
.onViewWithIdInDialog(R.id.passwordVerificationField)
.perform(typeText(password));
EspressoHelper
.onViewWithIdInDialog(R.id.dialog_yes)
.perform(click());
EspressoHelper.waitUi();
// then it opens the popup again, to ask the password
EspressoHelper
.onViewWithIdInDialog(R.id.passwordField)
.check(matches(isDisplayed()));
EspressoHelper
.onViewWithIdInDialog(R.id.passwordField)
.perform(typeText(password));
EspressoHelper
.onViewWithIdInDialog(R.id.dialog_yes)
.check(matches(isDisplayed()));
EspressoHelper
.onViewWithIdInDialog(R.id.dialog_yes)
.perform(click());
// the note on the (custom) edittext should appear correctly
onView(withId(R.id.taskText))
.check(matches(withText(equalToIgnoringCase(fullNoteText1))));
EspressoHelper.navigateUp();
// in the list view, only the title is shown
onView(allOf(
withText(equalToIgnoringCase(noteTitle)),
instanceOf(TitleNoteTextView.class)))
.check(matches(isDisplayed()));
}
}
================================================
FILE: app/src/androidTest/java/com/nononsenseapps/notepad/espresso_tests/TestSaveLoadJsonBackup.java
================================================
package com.nononsenseapps.notepad.espresso_tests;
import static androidx.test.espresso.Espresso.onData;
import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.Espresso.openContextualActionModeOverflowMenu;
import static androidx.test.espresso.action.ViewActions.click;
import static androidx.test.espresso.action.ViewActions.scrollTo;
import static androidx.test.espresso.assertion.ViewAssertions.matches;
import static androidx.test.espresso.intent.Intents.intending;
import static androidx.test.espresso.intent.matcher.IntentMatchers.hasAction;
import static androidx.test.espresso.matcher.ViewMatchers.hasChildCount;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static androidx.test.espresso.matcher.ViewMatchers.withText;
import static org.hamcrest.Matchers.allOf;
import static org.hamcrest.Matchers.anything;
import android.app.Activity;
import android.app.Instrumentation;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import androidx.documentfile.provider.DocumentFile;
import androidx.test.filters.LargeTest;
import androidx.test.platform.app.InstrumentationRegistry;
import com.nononsenseapps.notepad.R;
import org.junit.Test;
@LargeTest
public class TestSaveLoadJsonBackup extends BaseTestClass {
final String noteText1 = "random note";
final String noteText2 = "other random note";
/**
* @return an intent that replicates the response of the system's filepicker,
* using a custom fileprovider
*/
private static Instrumentation.ActivityResult createResponseIntent() {
Context context = InstrumentationRegistry
.getInstrumentation()
.getTargetContext();
// TODO here you have to create a uri for a documentfile that represents a
// folder where we can write files. good luck! There's no explanation of this anywhere
Uri uri1 = Uri.parse("content://com.android.externalstorage.documents/tree/primary%3Agoogletest%2Ftest_outputfiles");
var docDir = DocumentFile.fromTreeUri(context, uri1);
var uri2 = docDir.getUri();
Intent i = new Intent()
.setData(uri2)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
return new Instrumentation.ActivityResult(Activity.RESULT_OK, i);
}
@Test
public void testSaveLoadBackup() {
EspressoHelper.hideShowCaseViewIfShown();
// add 2 notes
EspressoHelper.createNoteWithName(noteText1);
EspressoHelper.createNoteWithName(noteText2);
EspressoHelper.navigateUp();
// make a backup
openContextualActionModeOverflowMenu();
String SETTINGS_TEXT = getStringResource(R.string.menu_preferences);
onView(withText(SETTINGS_TEXT))
.perform(scrollTo()) // in case the keyboard is covering the menu
.perform(click());
String SETTINGS_BACKUP_TEXT = getStringResource(R.string.backup);
onView(withText(SETTINGS_BACKUP_TEXT)).perform(click());
// register answer to mock the filepicker
intending(hasAction(Intent.ACTION_OPEN_DOCUMENT_TREE))
.respondWith(createResponseIntent());
// choose a backup directory
String CHOOSE_DIR = getStringResource(R.string.choose_backup_folder);
onView(withText(CHOOSE_DIR)).perform();
// then click export
String EXPORT_BACKUP_TEXT = getStringResource(R.string.backup_export);
onView(withText(EXPORT_BACKUP_TEXT)).perform(click());
EspressoHelper.onViewWithIdInDialog(android.R.id.button1).check(matches(isDisplayed()));
EspressoHelper.onViewWithIdInDialog(android.R.id.button1).perform(click());
EspressoHelper.waitUi();
// return to the notes list
EspressoHelper.exitPrefsActivity();
// from here on, it does not work. check createResponseIntent()
if (true) return;
// check & delete both notes
clickCheckBoxAt(0);
clickCheckBoxAt(1);
// clear completed notes
openContextualActionModeOverflowMenu();
String CLEAR_COMPLETED = getStringResource(R.string.menu_clearcompleted);
onView(withText(CLEAR_COMPLETED)).perform(click());
EspressoHelper.onViewWithIdInDialog(android.R.id.button1).check(matches(isDisplayed()));
EspressoHelper.onViewWithIdInDialog(android.R.id.button1).perform(click());
// restore the backup
openContextualActionModeOverflowMenu();
onView(withText(SETTINGS_TEXT)).perform(click());
onView(withText(SETTINGS_BACKUP_TEXT)).perform(click());
String IMPORT_BACKUP_TEXT = getStringResource(R.string.backup_import);
onView(withText(IMPORT_BACKUP_TEXT)).perform(click());
EspressoHelper.onViewWithIdInDialog(android.R.id.button1).perform(click());
EspressoHelper.waitUi();
// return to the notes list
EspressoHelper.exitPrefsActivity();
// ensure both notes were restored
onView(withText(noteText1)).check(matches(isDisplayed()));
onView(withText(noteText2)).check(matches(isDisplayed()));
}
// TODO explore accessibility tests. See
// https://developer.android.com/training/testing/espresso/accessibility-checking
// this one expects the list to have 2 children
private void clickCheckBoxAt(int position) {
var i = onData(anything())
.inAdapterView(allOf(withId(android.R.id.list), hasChildCount(2)))
.atPosition(position)
.onChildView(withId(R.id.checkbox));
i.perform(click());
}
}
================================================
FILE: app/src/androidTest/java/com/nononsenseapps/notepad/test/DBFreshTest.java
================================================
package com.nononsenseapps.notepad.test;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import androidx.test.filters.SmallTest;
import androidx.test.platform.app.InstrumentationRegistry;
import com.nononsenseapps.notepad.database.DatabaseHandler;
import com.nononsenseapps.notepad.database.LegacyDBHelper;
import com.nononsenseapps.notepad.database.Task;
import com.nononsenseapps.notepad.database.TaskList;
import junit.framework.TestCase;
public class DBFreshTest extends TestCase {
static final String PREFIX = "fresh_test_";
private Context context;
@Override
public void setUp() throws Exception {
super.setUp();
context = InstrumentationRegistry.getInstrumentation().getTargetContext();
}
@Override
public void tearDown() throws Exception {
super.tearDown();
}
@SmallTest
public void testFreshInstall() {
context.deleteDatabase(PREFIX + LegacyDBHelper.LEGACY_DATABASE_NAME);
context.deleteDatabase(PREFIX + DatabaseHandler.DATABASE_NAME);
final SQLiteDatabase db = new DatabaseHandler(context, PREFIX).getReadableDatabase();
// Just open the database, there should be one list and one task present
Cursor tlc = db.query(TaskList.TABLE_NAME, TaskList.Columns.FIELDS,
null, null, null, null, null);
assertEquals("Should be ONE list present on fresh installs",
1, tlc.getCount());
tlc.close();
Cursor tc = db.query(Task.TABLE_NAME, Task.Columns.FIELDS,
null, null, null, null, null);
assertEquals("Should be 1 task present on fresh installs, the 'welcome' task",
1, tc.getCount());
tc.close();
db.close();
assertTrue("Could not delete database",
context.deleteDatabase(PREFIX + LegacyDBHelper.LEGACY_DATABASE_NAME));
assertTrue("Could not delete database",
context.deleteDatabase(PREFIX + DatabaseHandler.DATABASE_NAME));
}
}
================================================
FILE: app/src/androidTest/java/com/nononsenseapps/notepad/test/DBProviderMovementTest.java
================================================
package com.nononsenseapps.notepad.test;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.database.Cursor;
import android.database.SQLException;
import android.database.sqlite.SQLiteConstraintException;
import android.net.Uri;
import android.util.Log;
import androidx.test.platform.app.InstrumentationRegistry;
import com.nononsenseapps.notepad.database.DAO;
import com.nononsenseapps.notepad.database.Task;
import com.nononsenseapps.notepad.database.TaskList;
import junit.framework.TestCase;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Locale;
import java.util.Random;
public class DBProviderMovementTest extends TestCase {
private ContentResolver resolver;
@Override
public void setUp() throws Exception {
super.setUp();
resolver = InstrumentationRegistry
.getInstrumentation()
.getTargetContext()
.getContentResolver();
}
@Override
public void tearDown() throws Exception {
super.tearDown();
}
/*
* Util methods
*/
private void assertUriReturnsResult(final Uri uri, final String[] fields) {
assertUriReturnsResult(uri, fields, null, null);
}
private void assertUriReturnsResult(final Uri uri, final String[] fields,
final String where, final String[] whereArgs) {
final Cursor c = resolver.query(uri, fields, where, whereArgs, null);
final boolean notEmpty = c.moveToFirst();
c.close();
assertTrue("Uri did not return a result: " + uri.getEncodedPath(),
notEmpty);
}
private Cursor assertCursorGood(final Cursor c) {
assertNotNull(c);
assertFalse(c.isClosed());
return c;
}
private void assertTasksCountIs(final long listId, final int count) {
assertEquals(count, getTasks(listId).size());
}
private void assertTaskLeftRightAreSequential(final long listId) {
// Get ordered
ArrayList tasks = getTasks(listId);
long prev = 0;
for (Task t : tasks) {
assertTrue("Left must be less than right! " + t.left + " !< "
+ t.right, t.left < t.right);
assertTrue("Previous item must have smaller left",
prev < t.left);
if (t.right == t.left + 1) {
prev = t.right;
} else {
prev = t.left;
}
}
// Test maximum value
Cursor c = resolver.query(Task.URI, Task.Columns.FIELDS,
Task.Columns.DBLIST + " IS ?",
new String[] { Long.toString(listId) }, Task.Columns.RIGHT + " DESC");
assertCursorGood(c);
HashSet positions = new HashSet<>();
if (c.getCount() > 0) {
// Right most will be twice the number of tasks
assertTrue(c.moveToFirst());
final Task last = new Task(c);
assertEquals(String.format(Locale.US, "%d != 2 * %d", last.right, c.getCount()),
(long) last.right, 2L * c.getCount());
// Make sure there are no duplicates and such
for (long i = 1; i <= c.getCount() * 2L; i++) {
positions.add(i);
}
positions.remove(last.left);
positions.remove(last.right);
while (c.moveToNext()) {
Task task = new Task(c);
positions.remove(task.left);
positions.remove(task.right);
}
}
c.close();
assertEquals("Must be duplicate positions in the list", 0,
positions.size());
}
private TaskList insertList() {
final TaskList tl = new TaskList();
tl.title = "A test list";
tl.setId(resolver.insert(tl.getBaseUri(), tl.getContent()));
assertTrue(0 < tl._id);
return tl;
}
private void deleteList(final TaskList tl) {
assertTrue(0 < resolver.delete(tl.getUri(), null, null));
}
private ArrayList insertTasks(final long listId, final int number) {
ArrayList results = new ArrayList<>(number);
for (int i = 0; i < number; i++) {
int count = 0;
Task t = new Task();
t.title = "Task" + ++count;
t.dblist = listId;
Uri uri = resolver.insert(Task.URI, t.getContent());
if (uri != null) {
t.setId(uri);
results.add(t);
}
assertTaskLeftRightAreSequential(listId);
}
assertTaskLeftRightAreSequential(listId);
assertTasksCountIs(listId, number);
return results;
}
private ArrayList getTasks(final long listId) {
final ArrayList results = new ArrayList<>();
final Cursor c = resolver.query(Task.URI,
Task.Columns.FIELDS, Task.Columns.DBLIST + " IS ?",
new String[] { Long.toString(listId) }, Task.Columns.LEFT);
assertCursorGood(c);
while (c.moveToNext()) {
results.add(new Task(c));
}
c.close();
return results;
}
private Task getTask(final long id) {
final Cursor c = resolver.query(Task.getUri(id), Task.Columns.FIELDS,
null, null, null);
assertCursorGood(c);
Task t = null;
if (c.moveToFirst()) {
t = new Task(c);
}
c.close();
return t;
}
private ArrayList getDeletedTask(final String title) {
final ArrayList results = new ArrayList<>();
final Cursor c = resolver.query(Task.URI_DELETED_QUERY,
Task.Columns.FIELDS, Task.Columns.TITLE + "IS ?",
new String[] { title }, null);
assertCursorGood(c);
while (c.moveToNext()) {
results.add(new Task(c));
}
c.close();
return results;
}
private void deleteTasks(final ArrayList tasks) {
for (final Task t : tasks) {
deleteTask(t);
}
}
private void deleteTask(Task t) {
assertTrue(0 < resolver.delete(t.getUri(), null, null));
assertTaskLeftRightAreSequential(t.dblist);
}
private void moveTasksToList(final TaskList tl, final Task... ts) {
long[] ids = new long[ts.length];
for (int i = 0; i < ts.length; i++) {
ids[i] = ts[i]._id;
}
final ContentValues val = new ContentValues();
val.put(Task.Columns.DBLIST, tl._id);
// where _ID in (1, 2, 3)
final String whereId = Task.Columns._ID + " IN (" + DAO.arrayToCommaString(ids) + ")";
var mContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
mContext.getContentResolver().update(Task.URI, val, whereId, null);
// Verify that task was moved
// Check new
assertTaskLeftRightAreSequential(tl._id);
}
private ArrayList moveAndAssert(final TaskList tl, final int fromPos,
final int toPos) {
Log.i("nononsenseapps test", "Testing move from: " + fromPos + " to "
+ toPos);
// Get ordered
final ArrayList oldtasks = getTasks(tl._id);
// Move 5 to 4
final Task movingTask = oldtasks.get(fromPos);
final Task targetTask = oldtasks.get(toPos);
final int result = movingTask.moveTo(resolver, targetTask);
// Verity that things changed or not
if (movingTask._id != targetTask._id)
assertTrue("Moving a task should update rows", 0 < result);
else
assertEquals("Moving a task to itself shouldn't change anything", 0, result);
// Find new values
final ArrayList newtasks = getTasks(tl._id);
Task newone = null;
Task newtarget = null;
for (Task t : newtasks) {
if (t._id == movingTask._id) {
newone = t;
}
if (t._id == targetTask._id) {
newtarget = t;
}
}
Log.d("nononsenseapps test", "old, target, new, newtarget: "
+ movingTask.left + "," + movingTask.right + " "
+ targetTask.left + "," + targetTask.right + " " + newone.left
+ "," + newone.right + " " + newtarget.left + ","
+ newtarget.right);
assertNotNull("Couldnt find the moved task", newone);
if (targetTask.left < movingTask.left) {
assertEquals("Left value does not equal target", targetTask.left,
newone.left);
assertEquals("Right value does not equal target left + 1",
targetTask.left + 1, (long) newone.right);
} else if (targetTask.right > movingTask.right) {
assertEquals("Left value does not equal target right - 1",
targetTask.right - 1, (long) newone.left);
assertEquals("Right value does not equal target", targetTask.right,
newone.right);
}
assertEquals("Width should be 1 after a move", 1, newone.right
- newone.left);
// assertEquals("Target should have moved 2 steps", 2,);
assertEquals("Number of tasks should not change", oldtasks.size(), newtasks.size());
assertTaskLeftRightAreSequential(tl._id);
return getTasks(tl._id);
}
/*
* Test methods
*/
public void testDeleteList() {
final TaskList tl = insertList();
final int count = 10;
final long listId = tl._id;
assertTasksCountIs(listId, 0);
insertTasks(listId, count);
assertTasksCountIs(listId, count);
deleteList(tl);
// Should return nothing
// Cursor c = resolver.query(TaskList.URI, TaskList.Columns.FIELDS,
// TaskList.Columns._ID + " IS ?",
// new String[] { Long.toString(listId) }, null);
// assertCursorGood(c);
// assertEquals("List should be gone", 0, c.getCount());
// removing list should delete all tasks within
assertTasksCountIs(listId, 0);
// c.close();
}
public void testInsertAndRemoveTasks() {
final TaskList tl = insertList();
ArrayList tasks = insertTasks(tl._id, 10);
assertTasksCountIs(tl._id, 10);
deleteTasks(tasks);
assertTasksCountIs(tl._id, 0);
deleteList(tl);
}
public void testInsertTaskInWrongList() {
// Should not be possible to insert
// into a non-existing list because of foreign key constraints
final long wrongId = 92525;
assertTasksCountIs(wrongId, 0);
Task t = new Task();
t.title = "Task";
t.dblist = wrongId;
boolean thrown = false;
Uri uri = null;
try {
uri = resolver.insert(Task.URI, t.getContent());
} catch (SQLException e) {
thrown = true;
}
assertNull(uri);
assertTasksCountIs(wrongId, 0);
}
public void testInvalidPos() {
// Positions must be greater than 0
// or should throw constraint failed
TaskList tl = insertList();
ArrayList ts = insertTasks(tl._id, 1);
Task t = ts.get(0);
t.left = 0L;
boolean failed = false;
try {
resolver.update(t.getUri(), t.getContent(), null, null);
} catch (SQLiteConstraintException e) {
failed = true;
}
//assertTrue("Setting left to 0 should throw exception!", failed);
t.left = 5L;
t.right = 0L;
failed = false;
try {
resolver.update(t.getUri(), t.getContent(), null, null);
} catch (SQLiteConstraintException e) {
failed = true;
}
//assertTrue("Setting right to 0 should throw exception", failed);
deleteList(tl);
}
public void testMoveTask() {
final TaskList tl = insertList();
int count = 10;
insertTasks(tl._id, count);
assertTaskLeftRightAreSequential(tl._id);
// Move some tasks around
moveAndAssert(tl, 0, count - 1);
moveAndAssert(tl, count - 1, 0);
moveAndAssert(tl, 1, count - 2);
moveAndAssert(tl, count - 2, 1);
moveAndAssert(tl, 4, 0);
moveAndAssert(tl, 4, 9);
for (int i = 0; i < count * 2; i++) {
moveAndAssert(tl, 0, count - 1);
}
for (int i = 0; i < count * 2; i++) {
moveAndAssert(tl, count - 2, 2);
}
Random rand = new Random();
int min = 0, max = count - 1;
for (int i = 0; i < count * 2; i++) {
int fromPos = rand.nextInt(max - min + 1) + min;
int toPos = fromPos;
while (toPos == fromPos) {
toPos = rand.nextInt(max - min + 1) + min;
}
// two unique positions generated, now move
moveAndAssert(tl, fromPos, toPos);
}
// Clean up
deleteList(tl);
}
public void testMoveTaskToList() {
final TaskList tl = insertList();
final TaskList tl2 = insertList();
int count = 10;
ArrayList tasks1 = insertTasks(tl._id, count);
ArrayList tasks2 = insertTasks(tl2._id, count);
assertTaskLeftRightAreSequential(tl._id);
assertTaskLeftRightAreSequential(tl2._id);
// Move some tasks around
Random rand = new Random();
int min = 0, max = count - 1;
for (int i = 0; i < 100; i++) {
if (rand.nextBoolean() && tasks1.size() > 1) {
int taskIndex = rand.nextInt(tasks1.size());
Task t1 = tasks1.remove(taskIndex);
taskIndex = rand.nextInt(tasks1.size());
Task t2 = tasks1.remove(taskIndex);
moveTasksToList(tl2, t1, t2);
tasks2.add(t1);
tasks2.add(t2);
} else if (tasks2.size() > 1) {
int taskIndex = rand.nextInt(tasks2.size());
Task t1 = tasks2.remove(taskIndex);
taskIndex = rand.nextInt(tasks2.size());
Task t2 = tasks2.remove(taskIndex);
moveTasksToList(tl, t1, t2);
tasks1.add(t1);
tasks1.add(t2);
}
}
// Clean up
deleteList(tl);
deleteList(tl2);
}
// public void testIndents() {
// final TaskList tl = insertList();
// int count = 7;
// insertTasks(tl._id, count);
// ArrayList orgTasks = getTasks(tl._id);
//
// // Indenting the first item should fail (not change anything)
// // as it's impossible to do
//
// indentAndAssert(orgTasks.get(0), false);
//
// // Test a successful one
// /*
// * a0 b1 c0 d0 e0
// */
// orgTasks = indentAndAssert(orgTasks.get(1), true);
// assertEquals("Task level should be two", 2, orgTasks.get(1).level);
//
// // Indenting it again should fail though
// /*
// * a0 b1 c0 d0 e0 f0 g0
// */
// orgTasks = indentAndAssert(orgTasks.get(1), false);
// assertEquals("Task level should still be two", 2, orgTasks.get(1).level);
//
// // Try last item
// /*
// * a0 b1 c0 d0 e0 f0 g1
// */
// orgTasks = indentAndAssert(orgTasks.get(count - 1), true);
// assertEquals("Task level should be two", 2,
// orgTasks.get(count - 1).level);
//
// /*
// * a0 b1 c2 d1 e0 f1 g2
// */
// orgTasks = indentAndAssert(orgTasks.get(2), true);
// orgTasks = indentAndAssert(orgTasks.get(2), true);
// orgTasks = indentAndAssert(orgTasks.get(2), false);
// assertEquals("Task level should be three", 3, orgTasks.get(2).level);
// orgTasks = indentAndAssert(orgTasks.get(3), true);
// assertEquals("Task level incorrect", 2, orgTasks.get(3).level);
// orgTasks = indentAndAssert(orgTasks.get(5), true);
// orgTasks = indentAndAssert(orgTasks.get(5), false);
// assertEquals("Task level incorrect", 2, orgTasks.get(5).level);
// orgTasks = indentAndAssert(orgTasks.get(6), true);
// orgTasks = indentAndAssert(orgTasks.get(6), false);
// assertEquals("Task level incorrect", 3, orgTasks.get(6).level);
//
// /*
// * a0 b1 c2 d3 e4 f5 g6
// */
// orgTasks = indentAndAssert(orgTasks.get(0), false);
// orgTasks = indentAndAssert(orgTasks.get(1), false);
// orgTasks = indentAndAssert(orgTasks.get(2), false);
// orgTasks = indentAndAssert(orgTasks.get(3), true);
// orgTasks = indentAndAssert(orgTasks.get(3), true);
// orgTasks = indentAndAssert(orgTasks.get(3), false);
// orgTasks = indentAndAssert(orgTasks.get(4), true);
// orgTasks = indentAndAssert(orgTasks.get(4), true);
// orgTasks = indentAndAssert(orgTasks.get(4), true);
// orgTasks = indentAndAssert(orgTasks.get(4), true);
// orgTasks = indentAndAssert(orgTasks.get(4), false);
// orgTasks = indentAndAssert(orgTasks.get(5), true);
// orgTasks = indentAndAssert(orgTasks.get(5), true);
// orgTasks = indentAndAssert(orgTasks.get(5), true);
// orgTasks = indentAndAssert(orgTasks.get(5), true);
// orgTasks = indentAndAssert(orgTasks.get(5), false);
// orgTasks = indentAndAssert(orgTasks.get(6), true);
// orgTasks = indentAndAssert(orgTasks.get(6), true);
// orgTasks = indentAndAssert(orgTasks.get(6), true);
// orgTasks = indentAndAssert(orgTasks.get(6), true);
// orgTasks = indentAndAssert(orgTasks.get(6), false);
//
// for (int i = 0; i < orgTasks.size(); i++) {
// assertEquals("Task level incorrect", 1 + i, orgTasks.get(i).level);
// }
//
// // Let's start unindenting stuff!
//
// // Unindent root should fail
// orgTasks = unIndentAndAssert(orgTasks.get(0), false);
// // Last one should succeed many times
// for (int i = orgTasks.size(); i > 1; i--) {
// assertEquals("Level incorrect (i = " + i + ")", i,
// orgTasks.get(orgTasks.size() - 1).level);
// orgTasks = unIndentAndAssert(orgTasks.get(orgTasks.size() - 1),
// true);
// }
// // Should now be a root
// assertEquals("Level incorrect", 1,
// orgTasks.get(orgTasks.size() - 1).level);
//
// // Let's do the rest, top to bottom
// // All preceeding items are affected by this amount
// int cum = 0;
// for (int j = 1; j < orgTasks.size() - 1; j++) {
// for (int i = j + 1; i - cum > 1; i--) {
// assertEquals("Level incorrect (i = " + i + ")", i - cum,
// orgTasks.get(j).level);
// orgTasks = unIndentAndAssert(orgTasks.get(j), true);
// }
// cum += 1;
// // Should now be a root
// assertEquals("Level incorrect", 1, orgTasks.get(j).level);
// }
//
// deleteList(tl);
// }
//
// public void testIndentsHarder() {
// // Was something I actually did and noticed a crash
// final TaskList tl = insertList();
// int count = 3;
// insertTasks(tl._id, count);
// ArrayList orgTasks = getTasks(tl._id);
//
// // The issue is the order things are moved in the statement
// // Moved in order of their IDs!
//
// // Now at 2,1,0
// // Want 0,2,1 (where 2 is indented)
// orgTasks = moveAndAssert(tl, 2, 0);
// orgTasks = indentAndAssert(orgTasks.get(1), true);
//
// // This crashed by trying to set right to null
// // Since the parent was re-assigned before the child
// orgTasks = unIndentAndAssert(orgTasks.get(1), true);
//
// deleteList(tl);
// }
//
// public void testMoveIndentedTrees() {
// // important to test moving tasks in a tree structure
// final TaskList tl = insertList();
// int count = 9;
// insertTasks(tl._id, count);
// ArrayList orgTasks = getTasks(tl._id);
//
// // Roots at 0, 2 and 6
// // 0
// indentAndAssert(orgTasks.get(1), true);
// // 2
// indentAndAssert(orgTasks.get(3), true);
// indentAndAssert(orgTasks.get(4), true);
// indentAndAssert(orgTasks.get(4), true);
// indentAndAssert(orgTasks.get(5), true);
// indentAndAssert(orgTasks.get(5), true);
// indentAndAssert(orgTasks.get(5), true);
// // 6
// indentAndAssert(orgTasks.get(7), true);
// indentAndAssert(orgTasks.get(8), true);
// indentAndAssert(orgTasks.get(8), true);
//
// // move 2 tree to bottom
// // now part of six tree
// moveAndAssert(tl, 2, 8);
//
// // Move 6 tree to top
// moveAndAssert(tl, 3, 0);
//
// // Move 2 tree to top
// moveAndAssert(tl, 6, 0);
//
// deleteList(tl);
// }
public void testTaskContent() {
Task t = new Task();
t.title = "Hej";
t.dblist = 1L;
ContentValues values = t.getContent();
assertFalse(values.containsKey(Task.Columns.LEFT));
assertFalse(values.containsKey(Task.Columns.RIGHT));
}
public void testDeleteTrigger() {
// Deleting an item should place a copy of it in the delete-table
final TaskList tl = insertList();
insertTasks(tl._id, 1);
ArrayList orgTasks = getTasks(tl._id);
final Task orgTask = orgTasks.get(0);
final String t = "HABANA MAMAMANA";
orgTask.title = t;
resolver.update(orgTask.getUri(), orgTask.getContent(), null, null);
resolver.delete(orgTask.getUri(), null, null);
orgTasks = getTasks(tl._id);
assertEquals("List should be empty now", 0, orgTasks.size());
// Get the item from backup table instead
Cursor c = resolver.query(Task.URI_DELETED_QUERY,
Task.Columns.DELETEFIELDS, Task.Columns.TITLE + " IS ?",
new String[] { t }, null);
assertTrue("Task should be found in delete table after delete!",
c.moveToFirst());
deleteList(tl);
c.close();
}
}
================================================
FILE: app/src/androidTest/java/com/nononsenseapps/notepad/test/DBProviderTest.java
================================================
package com.nononsenseapps.notepad.test;
import android.content.ContentResolver;
import android.content.Context;
import android.database.Cursor;
import android.database.DatabaseUtils;
import android.net.Uri;
import android.os.SystemClock;
import androidx.preference.PreferenceManager;
import androidx.test.filters.MediumTest;
import androidx.test.platform.app.InstrumentationRegistry;
import com.nononsenseapps.helpers.NnnLogger;
import com.nononsenseapps.notepad.database.DatabaseHandler;
import com.nononsenseapps.notepad.database.Task;
import com.nononsenseapps.notepad.database.TaskList;
import junit.framework.TestCase;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.List;
public class DBProviderTest extends TestCase {
private Context mContext;
private ContentResolver mResolver;
@Override
public void setUp() throws Exception {
super.setUp();
mContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
mResolver = mContext.getContentResolver();
// clear app data
PreferenceManager.getDefaultSharedPreferences(mContext).edit().clear().commit();
DatabaseHandler.resetDatabase(mContext);
}
private void assertUriReturnsResult(final Uri uri, final String[] fields) {
assertUriReturnsResult(uri, fields, null, null, -1);
}
private void assertUriReturnsResult(final Uri uri, final String[] fields, final String where,
final String[] whereArgs, final int count) {
final Cursor c = mResolver
.query(uri, fields, where, whereArgs, null);
assertNotNull(c);
String READABLE_CURSOR_DUMP = DatabaseUtils.dumpCursorToString(c);
NnnLogger.debug(DBProviderTest.class, READABLE_CURSOR_DUMP);
final int cursorCount = c.getCount();
c.close();
if (count < 0) {
assertTrue("Uri did not return a result: " + uri.getEncodedPath(),
cursorCount > 0);
} else {
// I don't know, sometimes it happens...
assertEquals("Uri did not return expected number of results!",
count, cursorCount);
}
}
private TaskList getNewList() {
TaskList result = new TaskList();
result.title = "111aaTestingList";
result.save(mContext);
return result;
}
private ArrayList insertSomeTasks(final TaskList list, final int count) {
ArrayList tasks = new ArrayList<>();
for (int i = 0; i < count; i++) {
Task t = new Task();
t.title = "testTask" + i;
t.note = "testNote" + i;
t.due = Calendar.getInstance().getTimeInMillis();
t.dblist = list._id;
t.save(mContext);
tasks.add(t);
}
return tasks;
}
@MediumTest
public void testTaskListURIs() {
final TaskList list = getNewList();
assertUriReturnsResult(TaskList.URI, TaskList.Columns.FIELDS);
assertUriReturnsResult(TaskList.URI_WITH_COUNT, TaskList.Columns.FIELDS);
list.delete(mContext);
}
@MediumTest
public void testTaskURIs() {
final TaskList list = getNewList();
final int taskCount = 5;
final List tasks = insertSomeTasks(list, taskCount);
assertUriReturnsResult(Task.URI, Task.Columns.FIELDS);
// maybe the next line call to assertUriReturnsResult() fails due to timing issues ?
SystemClock.sleep(500);
// Sectioned Date query
assertUriReturnsResult(
Task.URI_SECTIONED_BY_DATE,
Task.Columns.FIELDS,
// see issue #525: on some android devices, "dblist" is a column of type BLOB,
// but we expect it to always be INTEGER. On older devices this cast is redundant,
// but on newer OS versions it fixes the bug when selecting notes by due date
"CAST(" + Task.Columns.DBLIST + " AS INTEGER) IS ?",
new String[] { Long.toString(list._id) },
taskCount + 1);
// History query
Task t = tasks.get(0);
final int histCount = 22;
for (int i = 0; i < 22; i++) {
// edit the note & save it
t.title += " hist" + i;
t.save(mContext);
}
// Should return insert (1) + update count (histCount)
assertUriReturnsResult(Task.URI_TASK_HISTORY,
Task.Columns.HISTORY_COLUMNS, Task.Columns.HIST_TASK_ID
+ " IS ?", new String[] { Long.toString(t._id) },
histCount + 1);
// TODO remember legacy uris
// TODO need a projection mapper
// assertUriReturnsResult(LegacyDBHelper.NotePad.Notes.CONTENT_URI,
// new String[] { LegacyDBHelper.NotePad.Notes.COLUMN_NAME_TITLE });
// assertUriReturnsResult(
// LegacyDBHelper.NotePad.Notes.CONTENT_VISIBLE_URI,
// new String[] { LegacyDBHelper.NotePad.Notes.COLUMN_NAME_TITLE });
list.delete(mContext);
// Should return insert NOTHING since it should have been deleted
assertUriReturnsResult(Task.URI_TASK_HISTORY,
Task.Columns.HISTORY_COLUMNS, Task.Columns.HIST_TASK_ID
+ " IS ?", new String[] { Long.toString(t._id) }, 0);
}
}
================================================
FILE: app/src/androidTest/java/com/nononsenseapps/notepad/test/DBUpgradeTest.java
================================================
package com.nononsenseapps.notepad.test;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.provider.BaseColumns;
import androidx.test.filters.MediumTest;
import androidx.test.platform.app.InstrumentationRegistry;
import com.nononsenseapps.notepad.database.DatabaseHandler;
import com.nononsenseapps.notepad.database.LegacyDBHelper;
import com.nononsenseapps.notepad.database.LegacyDBHelper.NotePad;
import com.nononsenseapps.notepad.database.Notification;
import com.nononsenseapps.notepad.database.Task;
import com.nononsenseapps.notepad.database.TaskList;
import junit.framework.TestCase;
public class DBUpgradeTest extends TestCase {
static final String PREFIX = "dbupgrade_test_";
final String aTime = "2013-03-23T02:43:35.000Z";
final String anId = "MDIwMzMwNjA0MjM5MzQ4MzIzMjU6MDow";
final String anAccount = "fake@account.com";
final int numOfLegacyLists = 2;
final int numOfLegacyNotes = 4;
private Context context;
@Override
public void setUp() throws Exception {
super.setUp();
context = InstrumentationRegistry.getInstrumentation().getTargetContext();
}
@Override
public void tearDown() throws Exception {
super.tearDown();
}
private void createTables(final SQLiteDatabase legacyDB) {
// Lists
legacyDB.execSQL("CREATE TABLE " + NotePad.Lists.TABLE_NAME + " ("
+ BaseColumns._ID + " INTEGER PRIMARY KEY,"
+ NotePad.Lists.COLUMN_NAME_TITLE
+ " TEXT DEFAULT '' NOT NULL,"
+ NotePad.Lists.COLUMN_NAME_MODIFIED
+ " INTEGER DEFAULT 0 NOT NULL,"
+ NotePad.Lists.COLUMN_NAME_MODIFICATION_DATE
+ " INTEGER DEFAULT 0 NOT NULL,"
+ NotePad.Lists.COLUMN_NAME_DELETED
+ " INTEGER DEFAULT 0 NOT NULL" + ");");
legacyDB.execSQL("CREATE TABLE " + NotePad.GTaskLists.TABLE_NAME + " ("
+ BaseColumns._ID + " INTEGER PRIMARY KEY,"
+ NotePad.GTaskLists.COLUMN_NAME_DB_ID
+ " INTEGER UNIQUE NOT NULL REFERENCES "
+ NotePad.Lists.TABLE_NAME + ","
+ NotePad.GTaskLists.COLUMN_NAME_GTASKS_ID
+ " INTEGER NOT NULL,"
+ NotePad.GTaskLists.COLUMN_NAME_GOOGLE_ACCOUNT
+ " INTEGER NOT NULL," + NotePad.GTaskLists.COLUMN_NAME_UPDATED
+ " TEXT," + NotePad.GTaskLists.COLUMN_NAME_ETAG + " TEXT"
+ ");");
// Notes
legacyDB.execSQL("CREATE TABLE " + NotePad.Notes.TABLE_NAME + " ("
+ BaseColumns._ID + " INTEGER PRIMARY KEY,"
+ NotePad.Notes.COLUMN_NAME_TITLE
+ " TEXT DEFAULT '' NOT NULL," + NotePad.Notes.COLUMN_NAME_NOTE
+ " TEXT DEFAULT '' NOT NULL,"
+ NotePad.Notes.COLUMN_NAME_CREATE_DATE
+ " INTEGER DEFAULT 0 NOT NULL,"
+ NotePad.Notes.COLUMN_NAME_MODIFICATION_DATE
+ " INTEGER DEFAULT 0 NOT NULL,"
+ NotePad.Notes.COLUMN_NAME_DUE_DATE + " TEXT,"
+ NotePad.Notes.COLUMN_NAME_LIST
+ " INTEGER NOT NULL REFERENCES " + NotePad.Lists.TABLE_NAME
+ "," + NotePad.Notes.COLUMN_NAME_GTASKS_STATUS
+ " TEXT NOT NULL," + NotePad.Notes.COLUMN_NAME_POSITION
+ " TEXT," + NotePad.Notes.COLUMN_NAME_HIDDEN
+ " INTEGER DEFAULT 0 NOT NULL,"
+ NotePad.Notes.COLUMN_NAME_MODIFIED
+ " INTEGER DEFAULT 0 NOT NULL,"
+ NotePad.Notes.COLUMN_NAME_INDENTLEVEL
+ " INTEGER DEFAULT 0 NOT NULL,"
+ NotePad.Notes.COLUMN_NAME_POSSUBSORT
+ " TEXT DEFAULT '' NOT NULL,"
+ NotePad.Notes.COLUMN_NAME_LOCALHIDDEN + " INTEGER DEFAULT 0,"
+ NotePad.Notes.COLUMN_NAME_PARENT + " TEXT,"
+ NotePad.Notes.COLUMN_NAME_DELETED
+ " INTEGER DEFAULT 0 NOT NULL" + ");");
legacyDB.execSQL("CREATE TABLE " + NotePad.GTasks.TABLE_NAME + " ("
+ BaseColumns._ID + " INTEGER PRIMARY KEY,"
+ NotePad.GTasks.COLUMN_NAME_DB_ID
+ " INTEGER UNIQUE NOT NULL REFERENCES " + NotePad.Notes.TABLE_NAME
+ "," + NotePad.GTasks.COLUMN_NAME_GTASKS_ID
+ " INTEGER NOT NULL,"
+ NotePad.GTasks.COLUMN_NAME_GOOGLE_ACCOUNT
+ " INTEGER NOT NULL," + NotePad.GTasks.COLUMN_NAME_UPDATED
+ " TEXT," + NotePad.GTasks.COLUMN_NAME_ETAG + " TEXT" + ");");
// Notifications
legacyDB.execSQL("CREATE TABLE " + NotePad.Notifications.TABLE_NAME
+ " (" + NotePad.Notifications._ID + " INTEGER PRIMARY KEY,"
+ NotePad.Notifications.COLUMN_NAME_TIME
+ " INTEGER NOT NULL DEFAULT 0,"
+ NotePad.Notifications.COLUMN_NAME_PERMANENT
+ " INTEGER NOT NULL DEFAULT 0,"
+ NotePad.Notifications.COLUMN_NAME_NOTEID + " INTEGER,"
+ "FOREIGN KEY(" + NotePad.Notifications.COLUMN_NAME_NOTEID
+ ") REFERENCES " + NotePad.Notes.TABLE_NAME + "("
+ NotePad.Notes._ID + ") ON DELETE CASCADE" + ");");
}
private void initializeDB(final SQLiteDatabase legacyDB) {
legacyDB.beginTransaction();
// Need to create the tables so we have something to test with.
createTables(legacyDB);
// Insert some lists, and some notes
final ContentValues values = new ContentValues();
for (int i = 0; i < numOfLegacyLists; i++) {
values.clear();
// One plain
values.put(LegacyDBHelper.NotePad.Lists.COLUMN_NAME_TITLE, "List"
+ i);
values.put(LegacyDBHelper.NotePad.Lists.COLUMN_NAME_MODIFIED, 1);
values.put(LegacyDBHelper.NotePad.Lists.COLUMN_NAME_DELETED, 0);
final long listId = legacyDB.insert(
LegacyDBHelper.NotePad.Lists.TABLE_NAME, null, values);
assertTrue("Failed to insert legacy test list: " + listId,
listId > 0);
long gtasklistid = -1;
// One with google id
if (i % 2 == 0) {
values.clear();
values.put(LegacyDBHelper.NotePad.GTaskLists.COLUMN_NAME_DB_ID,
listId);
values.put(
LegacyDBHelper.NotePad.GTaskLists.COLUMN_NAME_GOOGLE_ACCOUNT,
anAccount);
values.put(
LegacyDBHelper.NotePad.GTaskLists.COLUMN_NAME_GTASKS_ID,
anId);
values.put(
LegacyDBHelper.NotePad.GTaskLists.COLUMN_NAME_UPDATED,
aTime);
gtasklistid = legacyDB.insert(
LegacyDBHelper.NotePad.GTaskLists.TABLE_NAME, null,
values);
assertTrue(
"Failed to insert google dummy list: " + gtasklistid,
gtasklistid > 0);
}
// Insert notes
for (int j = 0; j < numOfLegacyNotes; j++) {
values.clear();
values.put(LegacyDBHelper.NotePad.Notes.COLUMN_NAME_TITLE,
"default" + j);
values.put(LegacyDBHelper.NotePad.Notes.COLUMN_NAME_NOTE,
"defaulttext");
values.put(LegacyDBHelper.NotePad.Notes.COLUMN_NAME_MODIFIED, 1);
values.put(LegacyDBHelper.NotePad.Notes.COLUMN_NAME_DELETED, 0);
values.put(LegacyDBHelper.NotePad.Notes.COLUMN_NAME_LIST,
listId);
// Gets the current system time in milliseconds
Long now = System.currentTimeMillis();
values.put(
LegacyDBHelper.NotePad.Notes.COLUMN_NAME_CREATE_DATE,
now);
values.put(
LegacyDBHelper.NotePad.Notes.COLUMN_NAME_MODIFICATION_DATE,
now);
values.put(LegacyDBHelper.NotePad.Notes.COLUMN_NAME_DUE_DATE,
"");
values.put(
LegacyDBHelper.NotePad.Notes.COLUMN_NAME_GTASKS_STATUS,
"needsAction");
values.put(LegacyDBHelper.NotePad.Notes.COLUMN_NAME_POSSUBSORT,
"");
values.put(
LegacyDBHelper.NotePad.Notes.COLUMN_NAME_INDENTLEVEL, 0);
final long noteId = legacyDB.insert(
LegacyDBHelper.NotePad.Notes.TABLE_NAME, null, values);
assertTrue("Note insertion should not fail", noteId > 0);
if (gtasklistid > -1) {
// Give SOME of the notes google ids
if (j % 2 == 0) {
values.clear();
values.put(
LegacyDBHelper.NotePad.GTasks.COLUMN_NAME_DB_ID,
noteId);
values.put(
LegacyDBHelper.NotePad.GTasks.COLUMN_NAME_GOOGLE_ACCOUNT,
anAccount);
values.put(
LegacyDBHelper.NotePad.GTasks.COLUMN_NAME_GTASKS_ID,
anId + j);
values.put(
LegacyDBHelper.NotePad.GTasks.COLUMN_NAME_UPDATED,
aTime);
final long gtaskid = legacyDB.insert(
LegacyDBHelper.NotePad.GTasks.TABLE_NAME, null,
values);
assertTrue("Gtask insert should not fail", gtaskid > 0);
}
}
// Give all a notification
values.clear();
values.put(
LegacyDBHelper.NotePad.Notifications.COLUMN_NAME_NOTEID,
noteId);
values.put(
LegacyDBHelper.NotePad.Notifications.COLUMN_NAME_TIME,
System.currentTimeMillis());
final long notId = legacyDB.insert(
LegacyDBHelper.NotePad.Notifications.TABLE_NAME, null,
values);
assertTrue("legacy notificaiton insert failed", notId > 0);
}
}
legacyDB.setTransactionSuccessful();
legacyDB.endTransaction();
}
@MediumTest
public void testExistingUpgrade() {
// First delete test databases if they exist
context.deleteDatabase(PREFIX + LegacyDBHelper.LEGACY_DATABASE_NAME);
context.deleteDatabase(PREFIX + DatabaseHandler.DATABASE_NAME);
final SQLiteDatabase legacyDB = new LegacyDBHelper(context, PREFIX)
.getWritableDatabase();
initializeDB(legacyDB);
// Check that things exist
Cursor c = DatabaseHandler.getLegacyLists(legacyDB);
assertEquals("LegacyDB not correct for tests", numOfLegacyLists,
c.getCount());
c.close();
c = DatabaseHandler.getLegacyNotes(legacyDB);
assertEquals("LegacyDB not correct for tests", numOfLegacyLists
* numOfLegacyNotes, c.getCount());
c.close();
c = DatabaseHandler.getLegacyNotifications(legacyDB);
assertEquals("LegacyDB not correct for tests", numOfLegacyLists
* numOfLegacyNotes, c.getCount());
c.close();
// Check that new database correctly converts old
final SQLiteDatabase db = new DatabaseHandler(context, PREFIX).getReadableDatabase();
c = db.query(TaskList.TABLE_NAME, TaskList.Columns.FIELDS, null, null,
null, null, null);
assertEquals("Unexpected amount of lists returned", numOfLegacyLists,
c.getCount());
// TODO Examine details
c.close();
c = db.query(Task.TABLE_NAME, Task.Columns.FIELDS, null, null, null,
null, null);
assertEquals("Incorrect number of notes converted", numOfLegacyLists
* numOfLegacyNotes, c.getCount());
// TODO examine details
c.close();
c = db.query(Notification.TABLE_NAME, Notification.Columns.FIELDS,
null, null, null, null, null);
assertEquals("Incorrect number of notifications converted",
numOfLegacyLists * numOfLegacyNotes, c.getCount());
// TODO examine details
c.close();
db.close();
legacyDB.close();
assertTrue(
"Could not delete database",
context.deleteDatabase(PREFIX
+ LegacyDBHelper.LEGACY_DATABASE_NAME));
assertTrue("Could not delete database",
context.deleteDatabase(PREFIX + DatabaseHandler.DATABASE_NAME));
}
}
================================================
FILE: app/src/androidTest/java/com/nononsenseapps/notepad/test/DaoTaskTest.java
================================================
package com.nononsenseapps.notepad.test;
import static org.junit.Assert.assertNotEquals;
import androidx.test.filters.MediumTest;
import com.nononsenseapps.notepad.database.Task;
import junit.framework.TestCase;
public class DaoTaskTest extends TestCase {
private void copyValsFromTo(final Task task1, final Task task2) {
task2.title = task1.title;
task2.note = task1.note;
task2.completed = task1.completed;
task2.due = task1.due;
}
@MediumTest
public void testTask() {
final Task task1 = new Task();
task1.title = "title1";
task1.note = "note1";
// Equals method should be true for these
assertEquals("Task should equal itself!", task1, task1);
task1.due = 924592L;
assertEquals("Task should equal itself!", task1, task1);
task1.completed = 230456L;
assertEquals("Task should equal itself!", task1, task1);
// Create copy
final Task task2 = new Task();
copyValsFromTo(task1, task2);
assertTrue((task1.title != null && task1.title.equals(task2.title)));
assertTrue((task1.note != null && task1.note.equals(task2.note)));
assertEquals(task1.due, task2.due);
assertTrue(((task1.completed != null) == (task2.completed != null)));
assertEquals("Task1 should equal task2!", task1, task2);
// Completed should only care about null status
task2.completed = 9272958113551L;
assertEquals("Completed should only care about null values", task1, task2);
// Should all fail
copyValsFromTo(task1, task2);
task2.title = "badfa";
assertNotEquals("Task1 should not equal task2!", task1, task2);
copyValsFromTo(task1, task2);
task2.note = "badfa";
assertNotEquals("Task1 should not equal task2!", task1, task2);
copyValsFromTo(task1, task2);
task2.due = 29037572395L;
assertNotEquals("Task1 should not equal task2!", task1, task2);
copyValsFromTo(task1, task2);
task2.completed = null;
assertNotEquals("Task1 should not equal task2!", task1, task2);
copyValsFromTo(task1, task2);
task2.due = null;
assertNotEquals("Task1 should not equal task2!", task1, task2);
copyValsFromTo(task1, task2);
task2.title = "badfa";
task2.note = "asdfal";
assertNotEquals("Task1 should not equal task2!", task1, task2);
copyValsFromTo(task1, task2);
task2.title = "badfa";
task2.due = null;
assertNotEquals("Task1 should not equal task2!", task1, task2);
copyValsFromTo(task1, task2);
task2.note = "badfa";
task2.due = 292374522222L;
assertNotEquals("Task1 should not equal task2!", task1, task2);
copyValsFromTo(task1, task2);
task2.title = "badfa";
task2.note = "asdf";
task2.due = null;
assertNotEquals("Task1 should not equal task2!", task1, task2);
copyValsFromTo(task1, task2);
task2.title = "badfa";
task2.note = "asdf";
task2.due = null;
task2.completed = null;
assertNotEquals("Task1 should not equal task2!", task1, task2);
}
}
================================================
FILE: app/src/androidTest/java/com/nononsenseapps/notepad/test/DashClockSettingsTest.java
================================================
/*
* Copyright (c) 2014 Jonas Kalderstam.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.nononsenseapps.notepad.test;
import static org.junit.Assert.assertNotNull;
import androidx.test.rule.ActivityTestRule;
import com.nononsenseapps.notepad.dashclock.DashclockPrefActivity;
import org.junit.Rule;
import org.junit.Test;
/**
* Verify that the activity opens OK on any screensize.
*/
public class DashClockSettingsTest {
// the replacement, ActivityScenarioRule does not work
@SuppressWarnings("deprecation")
@Rule
public final ActivityTestRule mActivityRule
= new ActivityTestRule<>(DashclockPrefActivity.class, false);
@Test
public void testLoadOK() {
assertNotNull(mActivityRule.getActivity());
Helper.takeScreenshot("Activity_loaded");
}
}
================================================
FILE: app/src/androidTest/java/com/nononsenseapps/notepad/test/DateTimeTest.java
================================================
package com.nononsenseapps.notepad.test;
import androidx.test.filters.SmallTest;
import com.nononsenseapps.notepad.database.Task;
import junit.framework.TestCase;
/**
* Tests related to the Date and Time formats
*/
public class DateTimeTest extends TestCase {
@Override
public void setUp() throws Exception {
super.setUp();
}
@Override
public void tearDown() throws Exception {
super.tearDown();
}
@SmallTest
public void test_setAsCompleted() {
Task t = new Task();
t.setAsCompletedForLegacy(); // <-- see its comments
assertTrue(t.completed > 0);
}
}
================================================
FILE: app/src/androidTest/java/com/nononsenseapps/notepad/test/FragmentTaskDetailTest.java
================================================
package com.nononsenseapps.notepad.test;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import androidx.fragment.app.Fragment;
import com.nononsenseapps.notepad.activities.main.ActivityMain_;
import com.nononsenseapps.notepad.espresso_tests.BaseTestClass;
import com.nononsenseapps.notepad.espresso_tests.EspressoHelper;
import org.junit.Test;
public class FragmentTaskDetailTest extends BaseTestClass {
@Test
public void testFragmentLoaded() {
EspressoHelper.hideShowCaseViewIfShown();
EspressoHelper.createNoteWithName("test note content");
Fragment fragment = mActRule
.getActivity()
.getSupportFragmentManager()
.findFragmentByTag(ActivityMain_.DETAILTAG);
assertNotNull("Editor should NOT be null", fragment);
assertTrue("Editor should be visible",
fragment.isAdded() && fragment.isVisible());
Helper.takeScreenshot("Editor_loaded");
}
}
================================================
FILE: app/src/androidTest/java/com/nononsenseapps/notepad/test/FragmentTaskListsTest.java
================================================
package com.nononsenseapps.notepad.test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import android.widget.ListView;
import androidx.fragment.app.Fragment;
import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.rule.ActivityTestRule;
import com.nononsenseapps.notepad.R;
import com.nononsenseapps.notepad.activities.main.ActivityMain_;
import org.junit.Rule;
import org.junit.Test;
public class FragmentTaskListsTest {
// the replacement, ActivityScenarioRule does not work
@SuppressWarnings("deprecation")
@Rule
public final ActivityTestRule mActivityRule
= new ActivityTestRule<>(ActivityMain_.class, false);
@Test
public void testSanity() {
assertEquals("This should succeed", 1, 1);
assertNotNull("Fragment1-holder should always be present",
mActivityRule.getActivity().findViewById(R.id.fragment1));
}
@Test
public void testFragmentLoaded() {
InstrumentationRegistry.getInstrumentation().waitForIdleSync();
assertNotNull(mActivityRule.getActivity());
Fragment listPagerFragment = mActivityRule
.getActivity()
.getSupportFragmentManager()
.findFragmentByTag(ActivityMain_.LISTPAGERTAG);
assertNotNull("List pager fragment should not be null", listPagerFragment);
assertTrue("List pager fragment should be visible",
listPagerFragment.isAdded() && listPagerFragment.isVisible());
ListView taskList = listPagerFragment
.getView()
.findViewById(android.R.id.list);
assertNotNull("Could not find the list!", taskList);
Helper.takeScreenshot("List_loaded");
}
}
================================================
FILE: app/src/androidTest/java/com/nononsenseapps/notepad/test/FragmentTaskListsViewPagerTest.java
================================================
package com.nononsenseapps.notepad.test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import androidx.test.rule.ActivityTestRule;
import com.nononsenseapps.notepad.R;
import com.nononsenseapps.notepad.activities.main.ActivityMain_;
import org.junit.Rule;
import org.junit.Test;
public class FragmentTaskListsViewPagerTest {
// the replacement, ActivityScenarioRule does not work
@SuppressWarnings("deprecation")
@Rule
public final ActivityTestRule mActivityRule
= new ActivityTestRule<>(ActivityMain_.class, false);
@Test
public void testSanity() {
assertEquals("This should succeed", 1, 1);
assertNotNull("Error in the activityrule", mActivityRule.getActivity());
assertNotNull("Fragment1-holder should always be present",
mActivityRule.getActivity().findViewById(R.id.fragment1));
}
}
================================================
FILE: app/src/androidTest/java/com/nononsenseapps/notepad/test/Helper.java
================================================
package com.nononsenseapps.notepad.test;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import android.content.Context;
import android.database.Cursor;
import android.graphics.Bitmap;
import androidx.test.platform.app.InstrumentationRegistry;
import com.nononsenseapps.notepad.database.Task;
import com.nononsenseapps.notepad.database.TaskList;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
public class Helper {
private static Task getATask(final Context context) {
Cursor c = context
.getContentResolver()
.query(Task.URI, Task.Columns.FIELDS, null, null, null);
Task result = null;
if (c.moveToFirst())
result = new Task(c);
return result;
}
private static TaskList getATaskList(final Context context) {
Cursor c = context
.getContentResolver()
.query(TaskList.URI, TaskList.Columns.FIELDS, null, null, null);
TaskList result = null;
if (c.moveToFirst())
result = new TaskList(c);
return result;
}
/**
* Takes a screenshots and saves it as "filename", for example
* /storage/emulated/0/Android/data/com.nononsenseapps.notepad/files/screenshots/fileName.png
* This is mandatory in new android versions, since it's the only folder we can easily write to
*/
public static void takeScreenshot(String fileName) {
// wait a second for the activity to load.
try {Thread.sleep(1000);} catch (InterruptedException ignored) {}
var tool = InstrumentationRegistry.getInstrumentation();
Bitmap bmp = tool.getUiAutomation().takeScreenshot();
File dir = tool.getTargetContext().getExternalFilesDir("screenshots");
if (!dir.exists()) {
assertTrue("Could not create directory", dir.mkdirs());
}
// the png file
var file = new File(dir, fileName + ".png");
try (var out = new FileOutputStream(file.getAbsolutePath())) {
bmp.compress(Bitmap.CompressFormat.PNG, 100, out);
} catch (IOException e) {
fail("Could not save png screenshot");
}
}
}
================================================
FILE: app/src/androidTest/java/com/nononsenseapps/notepad/test/OrgSyncTest.java
================================================
package com.nononsenseapps.notepad.test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import android.content.ContentResolver;
import android.content.Context;
import android.database.Cursor;
import androidx.test.platform.app.InstrumentationRegistry;
import com.nononsenseapps.helpers.FileHelper;
import com.nononsenseapps.notepad.database.RemoteTask;
import com.nononsenseapps.notepad.database.RemoteTaskList;
import com.nononsenseapps.notepad.database.Task;
import com.nononsenseapps.notepad.database.TaskList;
import com.nononsenseapps.notepad.sync.orgsync.OrgConverter;
import com.nononsenseapps.notepad.sync.orgsync.SDSynchronizer;
import org.cowboyprogrammer.org.OrgFile;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashSet;
/**
* Test the synchronizer code.
* Methods starting with 'testFresh' are meant to be reused in higher-order
* tests.
*/
public class OrgSyncTest {
private static final String ACCOUNT = "bobtester";
private static String DIR;
/**
* @return a Context to use during testing. It reports the same packagename of the
* app: com.nononsenseapps.notepad
*/
private static Context getTheContext() {
// VERY IMPORTANT: it should NOT be .getContext(), because that one uses the namespace
// com.nononsenseapps.notepad.test which saves the files on a wrong path, which makes
// every test in this class fail! ApplicationProvider.getApplicationContext() seems fine.
return InstrumentationRegistry.getInstrumentation().getTargetContext();
}
@Before
public void setUp() {
DIR = FileHelper.getUserSelectedOrgDir(getTheContext());
assertNotNull(DIR);
var d = new File(DIR);
if (!d.exists()) assertTrue(d.mkdirs());
// since the app starts with a default, "Welcome!" note, we must clear all notes,
// (and tasks, org files, ...) to make sure the tests in this class can complete
tearDown();
}
/**
* Performs a cleanup of the app's data and org files
*/
@After
public void tearDown() {
ContentResolver resolver = getTheContext().getContentResolver();
resolver.delete(TaskList.URI, null, null);
resolver.delete(Task.URI, null, null);
resolver.delete(RemoteTaskList.URI, null, null);
resolver.delete(RemoteTask.URI, null, null);
File d = new File(DIR);
File[] filesInFolder = d.listFiles();
assertNotNull("Can not get files in folder", filesInFolder);
for (File f : filesInFolder) {
FileHelper.tryDeleteFile(f, getTheContext());
}
}
public ArrayList getTaskLists() {
ContentResolver resolver = getTheContext().getContentResolver();
Cursor c = resolver.query(TaskList.URI, TaskList.Columns
.FIELDS, null, null, null);
ArrayList result = new ArrayList<>();
while (c.moveToNext()) {
result.add(new TaskList(c));
}
c.close();
return result;
}
public ArrayList getTasks(final long listid) {
ContentResolver resolver = getTheContext().getContentResolver();
Cursor c = resolver.query(Task.URI, Task.Columns
.FIELDS, Task.Columns.DBLIST + " IS ?",
new String[] { Long.toString(listid) }, null
);
ArrayList result = new ArrayList<>();
while (c.moveToNext()) {
result.add(new Task(c));
}
c.close();
return result;
}
public ArrayList getRemoteTaskLists() {
ContentResolver resolver = getTheContext().getContentResolver();
Cursor c = resolver.query(RemoteTaskList.URI, RemoteTaskList.Columns
.FIELDS, RemoteTaskList.Columns.ACCOUNT + " IS ?",
new String[] { ACCOUNT }, null
);
ArrayList result = new ArrayList<>();
while (c.moveToNext()) {
result.add(new RemoteTaskList(c));
}
c.close();
return result;
}
public ArrayList getRemoteTasks() {
ContentResolver resolver = getTheContext().getContentResolver();
Cursor c = resolver.query(RemoteTask.URI, RemoteTask.Columns
.FIELDS, RemoteTask.Columns.ACCOUNT + " IS ?",
new String[] { ACCOUNT }, null
);
ArrayList result = new ArrayList<>();
while (c.moveToNext()) {
result.add(new RemoteTask(c));
}
c.close();
return result;
}
@Test
public void testPass() {
// This always passes
assertTrue(true);
}
@Test
public void testTester() {
TestSynchronizer tester = new TestSynchronizer(getTheContext());
assertTrue(tester.isConfigured());
}
/**
* End result: synced state of one tasklist with two tasks.
* Tested flow branches:
* - Lists: Create file
* - Tasks: Create node
*/
@Test
public void testFreshSimple() {
// First create a list with 2 tasks
TaskList list = new TaskList();
list.title = "TestList";
list.save(getTheContext());
assertTrue(list._id > 0);
final int taskCount = 2;
for (int i = 0; i < taskCount; i++) {
Task t = new Task();
t.dblist = list._id;
t.title = "Task" + i;
t.note = "A body for the task";
t.save(getTheContext());
assertTrue(t._id > 0);
}
TestSynchronizer synchronizer = new TestSynchronizer(getTheContext());
try {
synchronizer.fullSync();
} catch (Exception e) {
fail(e.getLocalizedMessage());
}
// See the result
HashSet filenames = synchronizer.getRemoteFilenames();
assertEquals("Only one list was created.", 1, filenames.size());
String filename = null;
for (String f : filenames) {
filename = f;
}
assertEquals("Wrong filename", list.title + ".org", filename);
// Check that the database is correct
ArrayList remoteLists = getRemoteTaskLists();
assertEquals("Should only be one RemoteList!", 1, remoteLists.size());
ArrayList remoteTasks = getRemoteTasks();
assertEquals("Should be exactly 2 RemoteTasks", taskCount, remoteTasks.size());
long lastDbid = -1;
for (int i = 1; i < remoteTasks.size() + 1; i++) {
RemoteTask r = remoteTasks.remove(i - 1);
// Check for duplicates
assertEquals("Id is not correct", i, r._id);
assertTrue(lastDbid != r.dbid);
lastDbid = r.dbid;
}
}
public void syncAndAssertNothingChanged(final int taskCount) {
TestSynchronizer synchronizer = new TestSynchronizer(getTheContext());
try {
synchronizer.fullSync();
} catch (Exception e) {
fail(e.getLocalizedMessage());
}
// It should NOT have written to disk at all
assertEquals("No changes should not be written!", 0,
synchronizer.getPutRemoteCount());
// Check that the database is still correct
ArrayList lists = getTaskLists();
assertEquals("Should only be one list", 1, lists.size());
ArrayList tasks = getTasks(lists.get(0)._id);
assertEquals("Should be only 2 tasks in list", taskCount, tasks.size());
ArrayList remoteLists = getRemoteTaskLists();
assertEquals("Should only be one RemoteList!", 1, remoteLists.size());
ArrayList remoteTasks = getRemoteTasks();
assertEquals("Should be exactly 2 RemoteTasks", taskCount, remoteTasks.size());
long lastDbid = -1;
for (int i = 1; i < remoteTasks.size() + 1; i++) {
RemoteTask r = remoteTasks.get(i - 1);
// Check for duplicates
assertEquals("Id is not correct", i, r._id);
assertTrue(lastDbid != r.dbid);
lastDbid = r.dbid;
}
}
/**
* Nothing has changed here.
* Tested flow branches:
* - Lists: Update Merge
* - Tasks: Update Merge
*/
@Test
public void testNothingNew() {
final int taskCount = 2;
testFreshSimple();
syncAndAssertNothingChanged(taskCount);
syncAndAssertNothingChanged(taskCount);
syncAndAssertNothingChanged(taskCount);
syncAndAssertNothingChanged(taskCount);
}
/**
* Having two lists with the same name is possible in the app,
* but obviously impossible at the filesystem level.
* Tested flow branches:
* - Lists: Create file
*/
@Test
public void testDuplicateName() {
// Create first list
TaskList list1 = new TaskList();
list1.title = "TestList";
list1.save(getTheContext());
assertTrue(list1._id > 0);
// Create second list
TaskList list2 = new TaskList();
list2.title = "TestList";
list2.save(getTheContext());
assertTrue(list2._id > 0);
// Sync it
TestSynchronizer synchronizer = new TestSynchronizer(getTheContext());
try {
synchronizer.fullSync();
} catch (Exception e) {
fail(e.getLocalizedMessage());
}
// Make sure the second one was renamed!
for (TaskList tl : getTaskLists()) {
if (tl._id == list1._id) {
assertEquals(list1.title, tl.title);
} else if (tl._id == list2._id) {
assertEquals("List should have been renamed",
list2.title + 1, tl.title);
}
}
HashSet filenames = synchronizer.getRemoteFilenames();
assertEquals(2, filenames.size());
assertTrue(filenames.contains(list1.title + ".org"));
assertTrue(filenames.contains(list2.title + 1 + ".org"));
}
/**
* Renaming a list in the app should rename the file.
* Tested branches:
* - Update list, renamed
*/
@Test
public void testRenamedList() {
// Create first list
TaskList list1 = new TaskList();
list1.title = "TestList";
list1.save(getTheContext());
assertTrue(list1._id > 0);
// Sync it
TestSynchronizer synchronizer = new TestSynchronizer(getTheContext());
try {
synchronizer.fullSync();
} catch (Exception e) {
fail(e.getLocalizedMessage());
}
File org = new File(DIR, OrgConverter.getTitleAsFilename
(list1));
// Make sure original file is there
assertTrue(org.exists());
// Rename the list
list1.title = "RenamedList";
list1.save(getTheContext());
// Sync it
try {
synchronizer.fullSync();
} catch (Exception e) {
fail(e.getLocalizedMessage());
}
// Make sure rename was successful
assertFalse(org.exists());
File renamed = new File(DIR, OrgConverter.getTitleAsFilename
(list1));
assertTrue(renamed.exists());
}
/**
* Deleting a list should delete the corresponding file and all tasks.
* Tested branches:
* - Delete File Db
*/
@Test
public void testDeletedList() {
// Setup simple DB
final int taskCount = 2;
testFreshSimple();
// Delete list(s)
File file = null;
ArrayList lists = getTaskLists();
for (TaskList list : lists) {
file = new File(DIR, OrgConverter.getTitleAsFilename(list));
list.delete(getTheContext());
}
assertNotNull(file);
// Make sure it exists at this point
assertTrue(file.exists());
// And that the database still has a record of it
ArrayList remoteLists = getRemoteTaskLists();
assertEquals("Should be one RemoteList!", 1, remoteLists.size());
ArrayList remoteTasks = getRemoteTasks();
assertEquals("Should be exactly 2 RemoteTasks", taskCount, remoteTasks.size());
// Sync it again
TestSynchronizer synchronizer = new TestSynchronizer(getTheContext());
try {
synchronizer.fullSync();
} catch (Exception e) {
fail(e.getLocalizedMessage());
}
// Check that the database has removed it
lists = getTaskLists();
assertTrue("Should be no list", lists.isEmpty());
remoteLists = getRemoteTaskLists();
assertTrue("Should be no RemoteList!", remoteLists.isEmpty());
remoteTasks = getRemoteTasks();
assertTrue("Should be no RemoteTasks", remoteTasks.isEmpty());
// Make sure no file exists anymore
assertFalse(file.exists());
}
/**
* Test moving 1 task from List A to List B
*/
@Test
public void testMoveOne() {
// First create Two lists
TaskList listA = new TaskList();
listA.title = "TestListA";
listA.save(getTheContext());
assertTrue(listA._id > 0);
TaskList listB = new TaskList();
listB.title = "TestListB";
listB.save(getTheContext());
assertTrue(listB._id > 0);
// Add one task in ListA
Task t = new Task();
t.dblist = listA._id;
t.title = "Task";
t.note = "A body for the task";
t.save(getTheContext());
assertTrue(t._id > 0);
// Sync it
TestSynchronizer synchronizer = new TestSynchronizer(getTheContext());
try {
synchronizer.fullSync();
} catch (Exception e) {
fail(e.getLocalizedMessage());
}
// Check state of sync
ArrayList remoteLists = getRemoteTaskLists();
assertEquals("Should be two RemoteLists!", 2, remoteLists.size());
ArrayList remoteTasks = getRemoteTasks();
assertEquals("Should be exactly 1 RemoteTask", 1, remoteTasks.size());
assertEquals("RemoteTask is in wrong list!", listA._id,
(long) remoteTasks.get(0).listdbid);
// Move the task
t.dblist = listB._id;
t.save(getTheContext());
// Trigger should have deleted remotes now
remoteTasks = getRemoteTasks();
for (RemoteTask rt : remoteTasks) {
assertEquals("RemoteTask should be deleted after move before sync", "deleted", rt.deleted);
}
// Sync it
try {
synchronizer.fullSync();
} catch (Exception e) {
fail(e.getLocalizedMessage());
}
// Check state of sync
remoteLists = getRemoteTaskLists();
assertEquals("Should be two RemoteLists after move!", 2, remoteLists.size());
remoteTasks = getRemoteTasks();
assertEquals("Should be exactly 1 RemoteTask after move", 1, remoteTasks.size());
assertEquals("RemoteTask is in wrong list after move!", listB._id,
(long) remoteTasks.get(0).listdbid);
}
/**
* Test moving 20 tasks from List A to List B
*/
@Test
public void testMoveMany() {
// First create Two lists
TaskList listA = new TaskList();
listA.title = "TestListA";
listA.save(getTheContext());
assertTrue(listA._id > 0);
TaskList listB = new TaskList();
listB.title = "TestListB";
listB.save(getTheContext());
assertTrue(listB._id > 0);
final int taskCount = 20;
ArrayList tasks = new ArrayList<>();
for (int i = 0; i < taskCount; i++) {
Task t = new Task();
t.dblist = listA._id;
t.title = "Task" + i;
t.note = "A body for the task";
t.save(getTheContext());
assertTrue(t._id > 0);
tasks.add(t);
}
// Sync it
TestSynchronizer synchronizer = new TestSynchronizer(getTheContext());
try {
synchronizer.fullSync();
} catch (Exception e) {
fail(e.getLocalizedMessage());
}
// Check state of sync
ArrayList remoteLists = getRemoteTaskLists();
assertEquals("Should be two RemoteLists!", 2, remoteLists.size());
ArrayList remoteTasks = getRemoteTasks();
assertEquals("Should be exactly x RemoteTask", taskCount, remoteTasks.size());
for (RemoteTask remoteTask : remoteTasks) {
assertEquals("RemoteTask is in wrong list!", listA._id,
(long) remoteTask.listdbid);
}
// Move the tasks
for (Task t : tasks) {
t.dblist = listB._id;
t.save(getTheContext());
}
// Trigger should have deleted remotes now
remoteTasks = getRemoteTasks();
for (RemoteTask rt : remoteTasks) {
assertEquals("RemoteTask should be deleted after move before sync", "deleted", rt.deleted);
}
// Sync it
try {
synchronizer.fullSync();
} catch (Exception e) {
fail(e.getLocalizedMessage());
}
// Check state of sync
remoteLists = getRemoteTaskLists();
assertEquals("Should be two RemoteLists after move!", 2, remoteLists.size());
remoteTasks = getRemoteTasks();
assertEquals("Should be exactly x RemoteTask after move and sync", taskCount, remoteTasks.size());
for (RemoteTask remoteTask : remoteTasks) {
assertEquals("RemoteTask is in wrong list after move!", listB._id,
(long) remoteTask.listdbid);
}
}
/**
* Test moving 12 tasks from List A to List B where there are 12 lists each with 20 tasks
*/
@Test
public void testMoveManyAmongMany() {
final int listCount = 12;
final int taskCount = 20;
final int movedTaskCount = 12;
ArrayList tasksToMove = new ArrayList<>();
TaskList listA = null, listB = null;
// First create Lists
for (int listIndex = 0; listIndex < listCount; listIndex++) {
TaskList list = new TaskList();
list.title = "TestList" + listIndex;
list.save(getTheContext());
assertTrue(list._id > 0);
if (listA == null)
listA = list;
else if (listB == null)
listB = list;
for (int i = 0; i < taskCount; i++) {
Task t = new Task();
t.dblist = list._id;
t.title = "Task" + listIndex + "." + i;
t.note = "A body for the task";
t.save(getTheContext());
assertTrue(t._id > 0);
if (tasksToMove.size() < movedTaskCount) {
tasksToMove.add(t);
}
}
}
// Sync it
TestSynchronizer synchronizer = new TestSynchronizer(getTheContext());
try {
synchronizer.fullSync();
} catch (Exception e) {
fail(e.getLocalizedMessage());
}
// Check state of sync
ArrayList remoteLists = getRemoteTaskLists();
assertEquals("Should be X RemoteLists!", listCount, remoteLists.size());
ArrayList remoteTasks = getRemoteTasks();
assertEquals("Should be exactly x RemoteTask", taskCount * listCount, remoteTasks.size());
// Move the tasks
assertNotNull(listA);
assertNotNull(listB);
assertTrue("List A and B should be different!", listA._id != listB._id);
assertEquals("Expected something to move", movedTaskCount, tasksToMove.size());
for (Task t : tasksToMove) {
assertEquals("Expected task to be in list A!", listA._id, (long) t.dblist);
t.dblist = listB._id;
t.save(getTheContext());
}
// Trigger should have deleted remotes now
remoteTasks = getRemoteTasks();
int deletecount = 0;
int realcount = 0;
for (RemoteTask rt : remoteTasks) {
if ("deleted".equals(rt.deleted)) {
deletecount += 1;
} else {
realcount += 1;
}
}
assertEquals("Deleted remotetasks did not match", movedTaskCount, deletecount);
assertEquals("Remaining remotetasks did not match", taskCount * listCount - movedTaskCount, realcount);
// Sync it
try {
synchronizer.fullSync();
} catch (Exception e) {
fail(e.getLocalizedMessage());
}
// Check state of sync
ArrayList remoteTaskLists = getRemoteTaskLists();
remoteTasks = getRemoteTasks();
deletecount = 0;
realcount = 0;
for (RemoteTask rt : remoteTasks) {
if ("deleted".equals(rt.deleted)) {
deletecount += 1;
} else {
realcount += 1;
}
}
assertEquals("Number of remote lits did not match", listCount, remoteTaskLists.size());
assertEquals("Deleted remotetasks did not match", 0, deletecount);
assertEquals("Remaining remotetasks did not match", taskCount * listCount, realcount);
int nowInB = 0;
for (RemoteTask remoteTask : remoteTasks) {
assertNotEquals("deleted", remoteTask.deleted);
if (remoteTask.listdbid == listB._id) {
nowInB += 1;
}
}
assertEquals("RemoteTasks in b not expected count", taskCount + movedTaskCount,
nowInB);
// Check same things for local tasks
ArrayList taskLists = getTaskLists();
assertEquals("Number of lists did not match", listCount, taskLists.size());
for (TaskList list : taskLists) {
ArrayList tasks = getTasks(list._id);
if (listA._id == list._id) {
assertEquals("Not expected count in A", taskCount - movedTaskCount, tasks.size());
} else if (listB._id == list._id) {
assertEquals("Not expected count in B", taskCount + movedTaskCount, tasks.size());
} else {
assertEquals("Not expected count in C->", taskCount, tasks.size());
}
}
}
@Test
public void testFilenameWithSlash() {
// Filenames with slashes are not permitted
final TaskList lista = new TaskList();
lista.title = "Test/List/Slash/Name";
lista.save(getTheContext());
assertTrue(lista._id > 0);
// Sync it
TestSynchronizer synchronizer = new TestSynchronizer(getTheContext());
try {
synchronizer.fullSync();
} catch (Exception e) {
fail(e.getLocalizedMessage());
}
// Check contents after sync
final TaskList listb = getTaskLists().get(0);
// Should no longer have slashes in name
assertFalse(listb.title.contains("/"));
assertEquals("Test_List_Slash_Name", listb.title);
}
@Test
public void testContentStability() {
// Make sure content is not changed
// Create list
final TaskList lista = new TaskList();
lista.title = "TestList";
lista.save(getTheContext());
assertTrue(lista._id > 0);
final Task task1a = new Task();
task1a.title = "The title1";
task1a.note = "A note without newline";
task1a.dblist = lista._id;
task1a.save(getTheContext());
assertTrue(task1a._id > 0);
final Task task2a = new Task();
task2a.title = "The title2";
task2a.note = "Another note\non two lines";
task2a.dblist = lista._id;
task2a.save(getTheContext());
assertTrue(task2a._id > 0);
// Sync it
TestSynchronizer synchronizer = new TestSynchronizer(getTheContext());
try {
synchronizer.fullSync();
} catch (Exception e) {
fail(e.getLocalizedMessage());
}
// Check contents after sync
final TaskList listb = getTaskLists().get(0);
assertEquals(lista.title, listb.title);
for (Task taskb : getTasks(listb._id)) {
Task org;
if (taskb._id == task1a._id) {
org = task1a;
} else {
org = task2a;
}
// Compare title and note
assertEquals(org.title, taskb.title);
assertEquals(org.note, taskb.note);
}
// Sync it again
try {
synchronizer.fullSync();
} catch (Exception e) {
fail(e.getLocalizedMessage());
}
// Check contents after sync
final TaskList listc = getTaskLists().get(0);
assertEquals(lista.title, listc.title);
for (Task taskc : getTasks(listb._id)) {
Task org;
if (taskc._id == task1a._id) {
org = task1a;
} else {
org = task2a;
}
// Compare title and note
assertEquals(org.title, taskc.title);
assertEquals(org.note, taskc.note);
}
}
static class TestSynchronizer extends SDSynchronizer {
private int putRemoteCount = 0;
public TestSynchronizer(Context context) {
super(context);
}
@Override
public boolean isConfigured() {
return true;
}
/**
* @return A unique name for this service. Should be descriptive, like
* SDOrg or SSHOrg.
*/
@Override
public String getServiceName() {
return ACCOUNT;
}
@Override
public String getAccountName() {
return ACCOUNT;
}
/**
* Replaces the file on the remote end with the given content.
*
* @param orgFile The file to save. Uses the filename stored in the object.
*/
@Override
public void putRemoteFile(OrgFile orgFile) throws IOException {
putRemoteCount += 1;
super.putRemoteFile(orgFile);
}
public int getPutRemoteCount() {
return putRemoteCount;
}
public void setPutRemoteCount(final int putRemoteCount) {
this.putRemoteCount = putRemoteCount;
}
}
}
================================================
FILE: app/src/androidTest/java/com/nononsenseapps/notepad/test/ProviderHelperTest.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.test;
import static org.junit.Assert.assertEquals;
import com.nononsenseapps.notepad.android.provider.ProviderHelper;
import org.junit.Test;
public class ProviderHelperTest {
@Test
public void testGetRelativePath() throws Exception {
assertEquals("/foo/bar",
ProviderHelper.getRelativePath("/ACTION/foo/bar"));
assertEquals("/foo/bar",
ProviderHelper.getRelativePath("ACTION/foo/bar"));
assertEquals("/",
ProviderHelper.getRelativePath("/Action"));
assertEquals("/",
ProviderHelper.getRelativePath("Action"));
}
@Test
public void testFirstPart() throws Exception {
assertEquals("", ProviderHelper.firstPart(""));
assertEquals("foo", ProviderHelper.firstPart("foo"));
assertEquals("foo", ProviderHelper.firstPart("/foo"));
assertEquals("foo", ProviderHelper.firstPart("/foo/bar"));
assertEquals("foo", ProviderHelper.firstPart("foo/bar"));
}
@Test
public void testRestPart() throws Exception {
assertEquals("", ProviderHelper.restPart("foo"));
assertEquals("", ProviderHelper.restPart("/foo"));
assertEquals("", ProviderHelper.restPart("/foo/"));
assertEquals("bar/baz", ProviderHelper.restPart("/foo/bar/baz"));
assertEquals("bar/baz", ProviderHelper.restPart("foo/bar/baz"));
assertEquals("bar/baz", ProviderHelper.restPart("/foo/bar/baz"));
}
@Test
public void testMatchPath() throws Exception {
// Root cases
assertEquals(ProviderHelper.URI_ROOT,
ProviderHelper.matchPath(null));
assertEquals(ProviderHelper.URI_ROOT,
ProviderHelper.matchPath(""));
assertEquals(ProviderHelper.URI_ROOT,
ProviderHelper.matchPath("/"));
// List
assertEquals(ProviderHelper.URI_LIST,
ProviderHelper.matchPath("/list"));
assertEquals(ProviderHelper.URI_LIST,
ProviderHelper.matchPath("/list/"));
assertEquals(ProviderHelper.URI_LIST,
ProviderHelper.matchPath("list/"));
assertEquals(ProviderHelper.URI_LIST,
ProviderHelper.matchPath("/list/"));
assertEquals(ProviderHelper.URI_LIST,
ProviderHelper.matchPath("/list/foo"));
assertEquals(ProviderHelper.URI_LIST,
ProviderHelper.matchPath("list/foo/bar/"));
// Details
assertEquals(ProviderHelper.URI_DETAILS,
ProviderHelper.matchPath("/details/foo"));
assertEquals(ProviderHelper.URI_DETAILS,
ProviderHelper.matchPath("details/foo/bar/"));
// These uris are invalid
assertEquals(ProviderHelper.URI_NOMATCH,
ProviderHelper.matchPath("details"));
assertEquals(ProviderHelper.URI_NOMATCH,
ProviderHelper.matchPath("details/"));
assertEquals(ProviderHelper.URI_NOMATCH,
ProviderHelper.matchPath("unknownpredicate/foo/bar"));
}
@Test
public void testJoin() throws Exception {
assertEquals("/foo/bar", ProviderHelper.join("/foo", "bar"));
assertEquals("/foo/bar", ProviderHelper.join("/foo", "/bar"));
assertEquals("/foo/bar", ProviderHelper.join("/foo/", "/bar"));
assertEquals("/foo/bar", ProviderHelper.join("/foo/", "bar"));
assertEquals("/", ProviderHelper.join("/", "/"));
assertEquals("/", ProviderHelper.join("/", ""));
assertEquals("/", ProviderHelper.join("", "/"));
}
}
================================================
FILE: app/src/androidTest/java/com/nononsenseapps/notepad/test/RFCDateTest.java
================================================
package com.nononsenseapps.notepad.test;
import android.util.Log;
import com.nononsenseapps.helpers.RFC3339Date;
import junit.framework.TestCase;
import java.util.Calendar;
public class RFCDateTest extends TestCase {
static final String TAG = "nononsenseapps rfctest";
// Sun May 5 23:53:10 2013
//static final long atime = 1367790790000L;
// 2 Hours in milli seconds, for MY TIMEZONE
//static final long twohours = 7200000L;
// public void testCalendar() {
// Calendar c = Calendar.getInstance();
// c.setTimeInMillis(RFC3339Date.localAsRFC3339(atime));
//
// assertEquals("GMT would show as 21:53", 21, c.get(Calendar.HOUR_OF_DAY));
//
// //Log.d(TAG, "gtinm: " + c.getTimeInMillis() + ", gtgt: " + c.getTime().getTime());
// //assertTrue("Is wrong time", c.getTimeInMillis() == c.getTime().getTime());
// }
// public void testUTCFUCKSHIT() throws IndexOutOfBoundsException, ParseException {
// Log.d(TAG, "Start");
// Log.d(TAG, RFC3339Date.localAsRFC3339(atime));
// Log.d(TAG, RFC3339Date.UTCAsRFC3339(atime));
// String a = RFC3339Date.localAsRFC3339(atime);
// Long l = RFC3339Date.parseRFC3339Date(a).getTime();
// Log.d(TAG, RFC3339Date.UTCAsRFC3339(l));
// Log.d(TAG, RFC3339Date.localAsRFC3339(l));
// Log.d(TAG, "End");
//
// // Should return UTC time!
// final long utctime = RFC3339Date.localMilliToUTCMilli(atime);
//
// assertEquals("If UTC, difference should be two hours: " + atime + ", " + utctime, twohours, atime - utctime);
// }
public void test_asRFC3339ZuluDate() {
String result = RFC3339Date.asRFC3339ZuluDate(1402275911568L);
assertEquals("Dates should be equal", result, "2014-06-09T00:00:00Z");
}
public void testParseRFCDateBackAndForth() {
// Make sure conversion is consistent
// Calendar returns local time
final long long1 = Calendar.getInstance().getTime().getTime();
// String neutral
final String string1 = RFC3339Date.asRFC3339(long1);
Log.d(TAG, long1 + " = " + string1);
try {
// utc
final long long2 = RFC3339Date.parseRFC3339Date(string1).getTime();
final String string2 = RFC3339Date.asRFC3339(long2);
// utc again
final long long3 = RFC3339Date.parseRFC3339Date(string2).getTime();
Log.d(TAG, long2 + " = " + string2);
assertEquals("TimeInMilli did not match", long1 / 1000, long3 / 1000);
//assertEquals("RFC String did not match", string1, string2);
} catch (Exception e) {
fail(e.getLocalizedMessage());
}
}
}
================================================
FILE: app/src/androidTest/java/com/nononsenseapps/notepad/test/StorageTest.java
================================================
package com.nononsenseapps.notepad.test;
import androidx.test.platform.app.InstrumentationRegistry;
import com.nononsenseapps.helpers.FileHelper;
import junit.framework.TestCase;
import java.io.File;
/**
* Various tests related to storage, filesystem, ...
*/
public class StorageTest extends TestCase {
public void testIfExternalStorageIsAvailable() {
var context = InstrumentationRegistry
.getInstrumentation()
.getTargetContext();
File dir = context.getExternalFilesDir("example");
assertNotNull("External storage is not available!", dir);
String dir2 = FileHelper.getUserSelectedOrgDir(context);
assertNotNull("Can't determine org directory!", dir2);
}
}
================================================
FILE: app/src/main/AndroidManifest.xml
================================================
================================================
FILE: app/src/main/assets/.gitignore
================================================
secretkeys.properties
================================================
FILE: app/src/main/java/com/google/android/apps/dashclock/ui/DragGripView.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.google.android.apps.dashclock.ui;
import android.content.Context;
import android.content.res.Resources;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.util.AttributeSet;
import android.view.Gravity;
import android.view.View;
import com.nononsenseapps.notepad.R;
public class DragGripView extends View {
private static final int[] ATTRS = new int[] {
android.R.attr.gravity,
android.R.attr.color,
};
private static final int HORIZ_RIDGES = 2;
private int mGravity = Gravity.END;
private final Paint mRidgePaint;
private final float mRidgeSize;
private final float mRidgeGap;
private int mWidth;
private int mHeight;
public DragGripView(Context context) {
this(context, null, 0);
}
public DragGripView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public DragGripView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
final TypedArray a = context.obtainStyledAttributes(attrs, ATTRS);
mGravity = a.getInteger(0, mGravity);
int mColor = a.getColor(1, /* default: */ 0x33333333);
a.recycle();
final Resources res = getResources();
mRidgeSize = res.getDimensionPixelSize(R.dimen.drag_grip_ridge_size);
mRidgeGap = res.getDimensionPixelSize(R.dimen.drag_grip_ridge_gap);
mRidgePaint = new Paint();
mRidgePaint.setColor(mColor);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
setMeasuredDimension(
View.resolveSize(
(int) (HORIZ_RIDGES * (mRidgeSize + mRidgeGap) - mRidgeGap)
+ getPaddingLeft() + getPaddingRight(),
widthMeasureSpec),
View.resolveSize(
(int) mRidgeSize,
heightMeasureSpec));
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
float drawWidth = HORIZ_RIDGES * (mRidgeSize + mRidgeGap) - mRidgeGap;
float drawLeft;
//getLayoutDirection()
switch (Gravity.getAbsoluteGravity(mGravity, LAYOUT_DIRECTION_LTR)
& Gravity.HORIZONTAL_GRAVITY_MASK) {
case Gravity.CENTER_HORIZONTAL:
drawLeft = getPaddingLeft()
+ ((mWidth - getPaddingLeft() - getPaddingRight()) - drawWidth) / 2;
break;
case Gravity.RIGHT:
drawLeft = getWidth() - getPaddingRight() - drawWidth;
break;
default:
drawLeft = getPaddingLeft();
}
int vertRidges = (int) ((mHeight - getPaddingTop() - getPaddingBottom() + mRidgeGap)
/ (mRidgeSize + mRidgeGap));
float drawHeight = vertRidges * (mRidgeSize + mRidgeGap) - mRidgeGap;
float drawTop = getPaddingTop()
+ ((mHeight - getPaddingTop() - getPaddingBottom()) - drawHeight) / 2;
for (int y = 0; y < vertRidges; y++) {
for (int x = 0; x < HORIZ_RIDGES; x++) {
canvas.drawRect(
drawLeft + x * (mRidgeSize + mRidgeGap),
drawTop + y * (mRidgeSize + mRidgeGap),
drawLeft + x * (mRidgeSize + mRidgeGap) + mRidgeSize,
drawTop + y * (mRidgeSize + mRidgeGap) + mRidgeSize,
mRidgePaint);
}
}
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
mHeight = h;
mWidth = w;
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/helpers/ActivityHelper.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.helpers;
import android.content.Context;
import android.content.SharedPreferences;
import android.content.SharedPreferences.OnSharedPreferenceChangeListener;
import android.content.res.Configuration;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.preference.PreferenceManager;
import com.nononsenseapps.notepad.R;
import java.util.Arrays;
import java.util.Locale;
/**
* Contains helper methods for activities
*/
public final class ActivityHelper {
// TODO everything in this "helpers" namespace could be moved to its own
// gradle module. This would speed up builds, but maybe it's harder to manage?
// forbid instances: it's a static class
private ActivityHelper() {}
/**
* @return the users's default or selected locale
*/
public static Locale getUserLocale(Context context) {
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
String lang = prefs.getString(context.getString(R.string.pref_locale), "");
final Locale locale;
if (lang.isEmpty())
locale = Locale.getDefault();
else if (lang.length() == 5) {
locale = new Locale(lang.substring(0, 2), lang.substring(3, 5));
} else if (lang.length() == 3) {
// for example: "vec"
locale = new Locale(lang);
} else {
locale = new Locale(lang.substring(0, 2));
}
return locale;
}
/**
* Set configured locale on the given activity. Call it before Activity.onCreate()
*/
public static void setSelectedLanguage(@NonNull AppCompatActivity context) {
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
Configuration config = context.getResources().getConfiguration();
String lang = prefs.getString(context.getString(R.string.pref_locale), "");
if (lang.isEmpty()) {
// usually the user doesn't use a custom language, avoid running useless code
return;
}
boolean localeExists = Arrays.asList(Locale.getISOLanguages()).contains(lang);
if (!localeExists) {
NnnLogger.warning(ActivityHelper.class,
"Trying to set a locale that does not exist on this device: " + lang);
}
if (!config.locale.toString().equals(lang)) {
config.locale = getUserLocale(context);
context.getResources()
.updateConfiguration(config, context.getResources().getDisplayMetrics());
}
if (context instanceof OnSharedPreferenceChangeListener) {
prefs.registerOnSharedPreferenceChangeListener(
(OnSharedPreferenceChangeListener) context);
}
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/helpers/DocumentFileHelper.java
================================================
package com.nononsenseapps.helpers;
import android.content.Context;
import android.net.Uri;
import android.os.ParcelFileDescriptor;
import android.provider.MediaStore;
import android.webkit.MimeTypeMap;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.provider.DocumentsContractCompat;
import androidx.documentfile.provider.DocumentFile;
import com.nononsenseapps.notepad.prefs.BackupPrefs;
import java.io.FileDescriptor;
import java.io.FileOutputStream;
import java.util.function.Function;
/**
* Functions to work with {@link DocumentFile}. See
* here
* And, to understand why {@link DocumentFile} is better than {@link MediaStore}, see
* here.
* This API can read files created by this app even after you reinstall it, so it's
* better than {@link MediaStore}
*/
public final class DocumentFileHelper {
/**
* Hardcoded filename of the backup file. The user chooses where to save this
*/
private static final String backupJsonFileName = "NoNonsenseNotes_Backup.json";
public static boolean isWritableFolder(DocumentFile docDir) {
return docDir != null && docDir.exists() && docDir.isDirectory() && docDir.canWrite();
}
/**
* Get a {@link FileDescriptor} for the file at the given {@link Uri} and
* run the code in the {@link Function}
*
* @return TRUE if it finished, FALSE if there was an error
*/
private static boolean doWithFileDescriptorFor(@NonNull DocumentFile target,
@NonNull Context context,
Function function) {
try {
ParcelFileDescriptor pfd = context
.getContentResolver()
.openFileDescriptor(target.getUri(), "rw");
FileDescriptor fileDescriptor = pfd.getFileDescriptor();
boolean ok = fileDescriptor.valid();
if (!ok) return false;
function.apply(fileDescriptor);
pfd.close();
return true;
} catch (Exception ex) {
NnnLogger.exception(ex);
return false;
}
}
/**
* Write "content" in "destination" using the {@link DocumentFile} API
*
* @param destination a file, not a folder
* @return TRUE if it succeeded, FALSE otherwise
*/
public static boolean write(String content, DocumentFile destination, Context context) {
if (content == null || destination == null || context == null) return false;
if (!DocumentsContractCompat.isDocumentUri(context, destination.getUri())) return false;
return doWithFileDescriptorFor(destination, context, fd -> {
try {
var fileOutputStream = new FileOutputStream(fd);
fileOutputStream.write(content.getBytes());
// Let the document provider know you're done by closing the stream.
fileOutputStream.close();
} catch (Exception e) {
return false;
}
return true;
});
}
/**
* Delete the existing Json file and create a new one, for the backup
*
* @return the newly created {@link DocumentFile}, or null if it wasn't possible to create one
*/
public static DocumentFile createBackupJsonFile(Context context) {
Uri dirUri = BackupPrefs.getSelectedBackupDirUri(context);
if (dirUri == null) return null;
var docDir = DocumentFile.fromTreeUri(context, dirUri);
if (docDir == null) return null;
var oldDocFile = docDir.findFile(backupJsonFileName);
if (oldDocFile != null && oldDocFile.exists()) {
// already exists => delete it before creating a new one
oldDocFile.delete();
}
// android doesn't care about the mimetype anyway, having the extension
// in displayName is enough
String mt = MimeTypeMap.getSingleton().getMimeTypeFromExtension("json");
return docDir.createFile(mt, backupJsonFileName);
}
/**
* @return the {@link DocumentFile} representing the json file that will be read to restore
* the backup, or NULL if it could not find the file. It's in the user-selected backup
* directory. See {@link BackupPrefs}
*/
@Nullable
public static DocumentFile getSelectedBackupJsonFile(Context context) {
Uri dirUri = BackupPrefs.getSelectedBackupDirUri(context);
// user didn't choose a folder
if (dirUri == null) return null;
// somehow it's invalid
if (!DocumentsContractCompat.isTreeUri(dirUri)) return null;
DocumentFile dirDoc = DocumentFile.fromTreeUri(context, dirUri);
if (dirDoc == null) return null;
DocumentFile fileDoc = dirDoc.findFile("NoNonsenseNotes_Backup.json");
if (fileDoc != null && DocumentsContractCompat.isDocumentUri(context, fileDoc.getUri()))
return fileDoc;
else
return null;
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/helpers/FileHelper.java
================================================
package com.nononsenseapps.helpers;
import android.content.Context;
import android.media.MediaScannerConnection;
import android.os.SystemClock;
import androidx.annotation.NonNull;
import java.io.File;
import java.io.FileOutputStream;
import java.io.PrintStream;
/**
* Methods to help navigate through Google's mess regarding file access.
* These use the {@link File} API, so they work well only in
* {@link Context#getExternalFilesDir}. Avoid these for Android 10 and higher,
* prefer {@link FilePickerHelper} instead
*/
public final class FileHelper {
/**
* Writes the given {@link String} to the given {@link File}. Does not work outside
* of the external files directory, due to bad design by Google
*
* @return TRUE if it worked, FALSE otherwise
*/
@Deprecated
private static boolean writeStringToFile(String content, File target) {
if (content == null || target == null) return false;
if (target.isDirectory() || target.getParentFile() == null) return false;
NnnLogger.debug(FileHelper.class,
"Writing, with PrintStream, to file " + target.getAbsolutePath());
try {
target.getParentFile().mkdirs();
} catch (SecurityException se) {
NnnLogger.error(FileHelper.class, "Can't create: " + target.getParentFile());
NnnLogger.exception(se);
return false;
}
try (PrintStream out = new PrintStream(new FileOutputStream(target))) {
out.print(content);
out.close();
return true;
} catch (Exception e) {
NnnLogger.exception(e);
return false;
}
}
/**
* @return the path of the directory where ORG files are saved,
* or NULL if it could not get one. This path is now hardcoded
* to something like Android/data/packagename
*/
public static String getUserSelectedOrgDir(@NonNull Context ctx) {
// we are going to use the default directory:
// /storage/emulated/0/Android/data/packagename/files/orgfiles/
File dir = ctx.getExternalFilesDir("orgfiles");
// most likely, the shared storage is not available in this device/emulator
if (dir == null) return null;
// must ensure that it exists
if (!dir.exists()) dir.mkdirs();
boolean ok = dir.exists() && dir.isDirectory() && dir.canWrite();
if (ok) return dir.getAbsolutePath();
else return null;
}
/**
* When you delete a file in android, additional attention is required.
* This function takes care of that. Does not work above API 29
*
* @return TRUE if it succeeded, FALSE otherwise
*/
public static boolean tryDeleteFile(@NonNull File toDelete, @NonNull Context context) {
boolean contains = toDelete
.getAbsolutePath()
.contains(getUserSelectedOrgDir(context));
// the File API only works in that directory
if (!contains) return false;
if (toDelete.exists()) {
try {
if (!toDelete.delete()) return false;
} catch (SecurityException e) {
return false;
}
}
// once you successfully deleted it, you have to update the media scanner to
// let android know that the file was deleted, ELSE IT WILL CRASH!
MediaScannerConnection.scanFile(context, new String[] { toDelete.getAbsolutePath() },
null, null);
// wait a bit for the mediascanner to do its work
// 2 seconds should be enough
SystemClock.sleep(1900);
return true;
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/helpers/FilePickerHelper.java
================================================
package com.nononsenseapps.helpers;
import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Build;
import android.provider.DocumentsContract;
import android.widget.Toast;
import androidx.annotation.Nullable;
import androidx.core.provider.DocumentsContractCompat;
import androidx.documentfile.provider.DocumentFile;
import androidx.preference.PreferenceFragmentCompat;
import androidx.preference.PreferenceManager;
import com.nononsenseapps.notepad.R;
/**
* Methods to use android's built-in file picker. See {@link DocumentFileHelper}
* that you can use to handle the {@link Uri} returned by this file picker
*/
public final class FilePickerHelper {
/**
* For onActivityResult
*/
public static final int REQ_CODE = 123321;
/**
* Shows the system's default filepicker, to let the user choose a directory. See:
* this link
*
* @param prefFragComp The settings page that launched this file picker
* @param initialDir the starting directory to show, or NULL if you don't care
*/
public static void showFolderPickerActivity(PreferenceFragmentCompat prefFragComp,
@Nullable Uri initialDir) {
var i = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
// don't add this: it stops working on some devices, like the emulator with API 25!
// i.setType(DocumentsContract.Document.MIME_TYPE_DIR);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// get the previously selected Uri, if available
boolean uriIsOk = initialDir != null && DocumentsContractCompat.isTreeUri(initialDir);
if (uriIsOk) i.putExtra(DocumentsContract.EXTRA_INITIAL_URI, initialDir);
// else the filepicker will just open in its default state. whatever.
}
try {
// Start the built-in filepicker
prefFragComp.startActivityForResult(i, REQ_CODE);
} catch (ActivityNotFoundException e) {
Toast.makeText(prefFragComp.getContext(), R.string.file_picker_not_available,
Toast.LENGTH_SHORT).show();
}
}
/**
* Called when the user picks a "directory" with the system's filepicker
*
* @param fromActivityResult an {@link Intent} from onActivityResult
* @param keyOfPrefToUpdate key of the preference where "uri" will be saved in
*/
public static void onUriPicked(Intent fromActivityResult, Context context,
String keyOfPrefToUpdate) {
Uri uri = fromActivityResult.getData();
if (!DocumentsContractCompat.isTreeUri(uri)) return;
// represents the directory that the user just picked
// Use this instead of the "File" class
DocumentFile docDir = DocumentFile.fromTreeUri(context, uri);
// to maintain permission when the device restarts
try {
context.getContentResolver().takePersistableUriPermission(uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
} catch (SecurityException se) {
// no permissions found for this URI. isWritableFolder() will return false
NnnLogger.warning(FilePickerHelper.class,
"Can't take persistable uri permissions from: " + uri);
NnnLogger.exception(se);
}
if (DocumentFileHelper.isWritableFolder(docDir)) {
// save the uri in the preferences, with the given key
PreferenceManager
.getDefaultSharedPreferences(context)
.edit()
.putString(keyOfPrefToUpdate, uri.toString())
.apply();
} else {
Toast.makeText(context, R.string.cannot_write_to_directory, Toast.LENGTH_SHORT).show();
}
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/helpers/ListHelper.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.helpers;
import android.content.Context;
import android.content.SharedPreferences;
import android.database.Cursor;
import androidx.preference.PreferenceManager;
import com.nononsenseapps.notepad.R;
import com.nononsenseapps.notepad.database.TaskList;
import com.nononsenseapps.notepad.fragments.TaskListFragment;
import com.nononsenseapps.notepad.fragments.TaskListViewPagerFragment;
/**
* Simple utility class to hold some general functions for lists
*/
public final class ListHelper {
/**
* If temp list is > 0, returns it if it exists. Else, checks if a default list is set
* then returns that. If none set, then returns first (alphabetical) list
* Returns #{TaskListFragment.LIST_ID_ALL} if no lists in database.
*/
public static long getAViewList(final Context context, final long tempList) {
long returnList = tempList;
// TODO useless, you already have getAShowList() in this class
if (returnList == TaskListFragment.LIST_ID_ALL) {
// This is fine
return returnList;
}
// Otherwise, try and get a real list
returnList = getARealList(context, returnList);
if (returnList < 1) {
// If nothing was found, return all of them in this case
returnList = TaskListFragment.LIST_ID_ALL;
}
return returnList;
}
/**
* Guarantees default list is valid.
*
* @return If "tempList" > 0, returns it if it exists.
* Else, checks if a default list is set then returns that.
* If none set, then returns first (alphabetical) list.
* If no lists exist in the database, returns -1.
*/
public static long getARealList(final Context context, final long tempList) {
long returnList = tempList;
if (returnList < 1 && returnList != TaskListFragment.LIST_ID_ALL) {
assert returnList == -1; // ... I think ??
// Then check if a default list is specified
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
String defListPrefName = context.getString(R.string.pref_defaultlist);
returnList = Long.parseLong(prefs.getString(defListPrefName, "-1"));
}
if (returnList > 0) {
// See if it exists
final Cursor c = context
.getContentResolver()
.query(TaskList.URI,
TaskList.Columns.FIELDS,
TaskList.Columns._ID + " IS ?",
new String[] { Long.toString(returnList) },
null);
if (c != null) {
if (c.moveToFirst()) {
returnList = c.getLong(0);
} else {
returnList = -1;
}
c.close();
}
}
if (returnList < 1) {
assert returnList == -1; // ... I think ??
// Fetch a valid list from database if previous attempts are invalid
String orderingSql = context
.getResources()
.getString(R.string.const_as_alphabetic, TaskList.Columns.TITLE);
final Cursor c = context
.getContentResolver()
.query(TaskList.URI, TaskList.Columns.FIELDS, null,
null, orderingSql);
if (c != null) {
if (c.moveToFirst()) {
returnList = c.getLong(0);
}
c.close();
}
}
return returnList;
}
/**
* For {@link TaskListViewPagerFragment}
*
* @param tempList from ActivityMainHelper.getListId()
* @return and ID that might belong to a "meta list"
*/
public static long getAShowList(final Context context, final long tempList) {
long returnList = tempList;
if (returnList == -1) {
// Then check if a default list is specified
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
String defListId = prefs.getString(
context.getString(R.string.pref_defaultlist), "-1");
returnList = prefs.getLong(
context.getString(R.string.pref_defaultstartlist), Long.parseLong(defListId));
}
if (returnList == -1) {
returnList = getARealList(context, returnList);
}
// If nothing was found, show ALL
if (returnList == -1) {
returnList = TaskListFragment.LIST_ID_ALL;
}
return returnList;
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/helpers/NnnLogger.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.helpers;
import android.util.Log;
import androidx.annotation.NonNull;
/**
* Our own No Nonsense Notes Logger
*/
public final class NnnLogger {
/**
* Logs the given exception with tag "NNN"
*/
public static void exception(@NonNull Exception e) {
String msg = e.getMessage();
Log.e("NNN", msg == null ? "(No message)" : msg);
String stackTrace = Log.getStackTraceString(e);
Log.e("NNN", stackTrace);
}
/**
* Logs the given error message with tag "NNN". If you have an {@link Exception} object,
* please use {@link NnnLogger#exception(Exception)} instead
*
* @param caller the class who's calling this function. Its name is added to the message
* @param message the additional message sent to logcat
*/
public static void error(@NonNull Class caller, @NonNull String message) {
try {
String tag2 = caller.getSimpleName();
Log.e("NNN", tag2 + ": " + message);
} catch (Exception ignored) {
Log.e("NNN", message);
}
}
/**
* Logs the given warning message with tag "NNN".
*
* @param caller the class who's calling this function. Its name is added to the message
* @param message the additional message sent to logcat. Mostly {@link String}, but it
* will try to write everything
*/
public static void warning(@NonNull Class caller, @NonNull Object message) {
try {
String tag2 = caller.getSimpleName();
Log.w("NNN", tag2 + ": " + message);
} catch (Exception ex) {
Log.d("NNN", "Can't write LOG line: " + ex.getMessage());
}
}
/**
* Logs the given message with tag "NNN", but only in debug mode
*
* @param caller the class who's calling this function. Its name is added to the message
* @param message the additional message sent to logcat. Mostly {@link String}, but it
* will try to write everything
*/
public static void debug(@NonNull Class caller, @NonNull Object message) {
try {
String tag2 = caller.getSimpleName();
Log.d("NNN", tag2 + ": " + message);
} catch (Exception ex) {
Log.d("NNN", "Can't write LOG line: " + ex.getMessage());
}
}
// TODO this file is in com.nononsenseapps.helpers => move it to its own gradle module.
// Having many of these (one per namespace makes sense) will speed up parallel builds.
// you can have at least 3 gradle modules: drag-sort-listview,
// nononsenseapps-helpers, nononsenseapps-utils, nononsenseapps-ui
// and stuff in com.nononsenseapps.notepad remains in the "app" project
}
================================================
FILE: app/src/main/java/com/nononsenseapps/helpers/NotificationHelper.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.helpers;
import android.annotation.TargetApi;
import android.app.AlarmManager;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.database.ContentObserver;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.net.Uri;
import android.os.Build;
import android.os.Handler;
import android.os.SystemClock;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.RequiresApi;
import androidx.core.app.NotificationCompat;
import androidx.preference.PreferenceManager;
import com.nononsenseapps.notepad.R;
import com.nononsenseapps.notepad.database.Task;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
/**
* Receives signals and contains helper methods to show Android Notifications. Remember that a
* {@link android.app.Notification} is different from one of our reminders, which the database
* saves as {@link com.nononsenseapps.notepad.database.Notification}
*/
public final class NotificationHelper extends BroadcastReceiver {
// Intent notification argument
public static final String NOTIFICATION_CANCEL_ARG = "notification_cancel_arg";
public static final String NOTIFICATION_DELETE_ARG = "notification_delete_arg";
static final String ARG_TASKID = "taskid";
public static final String CHANNEL_ID = "remindersNotificationChannelId";
// if you edit these, update AndroidManifest.xml !
private static final String ACTION_COMPLETE = "com.nononsenseapps.notepad.ACTION.COMPLETE";
private static final String ACTION_SNOOZE = "com.nononsenseapps.notepad.ACTION.SNOOZE";
private static final String ACTION_RESCHEDULE = "com.nononsenseapps.notepad.ACTION.RESCHEDULE";
/**
* Fires notifications that have elapsed and sets an alarm to be woken at
* the next notification.
* If the intent action is ACTION_DELETE, will delete the notification with
* the indicated ID, and cancel it from any active notifications.
*/
@Override
public void onReceive(Context context, @NonNull Intent intent) {
String action = intent.getAction();
if (action != null) {
// should we cancel something? we decide it here:
if (Intent.ACTION_BOOT_COMPLETED.equals(action) || Intent.ACTION_RUN.equals(action)) {
// => can't cancel anything. Just schedule and notify at end of the function.
// You receive:
// Intent.ACTION_BOOT_COMPLETED when the phone is rebooted.
// Intent.ACTION_RUN for notifications scheduled through the Alarm Manager
} else {
// something like content://com.nononsenseapps.NotePad/notification/1
Uri notifUri = intent.getData();
// always dismiss the android notification
cancelNotification(context, notifUri);
switch (action) {
case Intent.ACTION_DELETE, ACTION_RESCHEDULE ->
// User swiped notification away: delete reminder
com.nononsenseapps.notepad.database.Notification
.deleteOrReschedule(context, notifUri);
case ACTION_SNOOZE -> {
// the user choose to dismiss this notification and display it later.
// the one that launched the notification snoozed by the user
var oldReminder = com.nononsenseapps.notepad.database.Notification
.fromUri(notifUri, context);
if (oldReminder.isRepeating()) {
// for repeating reminders
// add a new (non-repeating) reminder, for "snooze" effect
var newReminder = new com.nononsenseapps.notepad.database
.Notification(oldReminder.taskID);
newReminder.time = getSnoozedReminderNewTimeMillis();
newReminder.repeats = 0; // I WANT this
newReminder.save(context);
// then, reschedule repeating reminder to next applicable day, as if
// user swiped notification away
com.nononsenseapps.notepad.database.Notification
.deleteOrReschedule(context, notifUri);
// Later, "newReminder" will show up, but we handle that in other
// branch of this if block.
} else {
// for non-repeating reminders: snoozing overwrites the due time of the
// reminder that triggered this notification
com.nononsenseapps.notepad.database.Notification
.setTime(context, notifUri, getSnoozedReminderNewTimeMillis());
}
}
case ACTION_COMPLETE -> {
final long taskId = intent.getLongExtra(ARG_TASKID, -1);
// Complete note
Task.setCompletedSynced(context, true, taskId);
// Delete notifications with the same task id
com.nononsenseapps.notepad.database.Notification
.removeWithTaskIdsSynced(context, taskId);
}
}
}
}
// run this in ANY case
schedule(context);
}
/**
* @return the time, in unix milliseconds, that corresponds to 30 minutes from when this
* function is called. Used when the user presses "snooze" in the notification.
*/
public static long getSnoozedReminderNewTimeMillis() {
// TODO snooze logic is hardcoded to 30' here.
// Set a custom timer in the preferences and load the number here
final long minutes = 30;
// msec/sec * sec/min * (snooze minutes)
final long snoozeDelayInMillis = 1000 * 60 * minutes;
final Calendar now = Calendar.getInstance();
// it's 30 minutes from [when the user presses "snooze"], expressed in unix millseconds
return now.getTimeInMillis() + snoozeDelayInMillis;
}
/**
* creates the notification channel needed on API 26 and higher to show notifications.
* This is safe to call multiple times. All of its settings overwrite those of the
* single notification!
*/
@TargetApi(Build.VERSION_CODES.O)
@RequiresApi(Build.VERSION_CODES.O)
public static void createNotificationChannel(final Context context, NotificationManager nm) {
String name = context.getString(R.string.notification_channel_name);
String description = context.getString(R.string.notification_channel_description);
// This is equivalent to notifications before API 24
// the user can change it in the system's settings page
int importance = NotificationManager.IMPORTANCE_DEFAULT;
NotificationChannel channel = new NotificationChannel(CHANNEL_ID, name, importance);
channel.setDescription(description);
// here you could also set other stuff, but the user can set all of those in the system's
// notification channel pref. page, which can be opened through our NotificationPrefs
// fragment. And that's better than us rewriting android code!
nm.createNotificationChannel(channel);
}
public static void clearNotification(@NonNull final Context context,
@NonNull final Intent intent) {
if (intent.getLongExtra(NOTIFICATION_DELETE_ARG, -1) > 0) {
com.nononsenseapps.notepad.database.Notification.deleteOrReschedule(context,
com.nononsenseapps.notepad.database.Notification.getUri(
intent.getLongExtra(NOTIFICATION_DELETE_ARG, -1)));
}
if (intent.getLongExtra(NOTIFICATION_CANCEL_ARG, -1) > 0) {
NotificationHelper.cancelNotification(context,
(int) intent.getLongExtra(NOTIFICATION_CANCEL_ARG, -1));
}
}
/**
* Displays notifications that have a time occurring in the past. If no notifications
* like that exist, it will cancel any notifications showing.
*/
private static void notifyPast(Context context) {
// Get list of past notifications
final Calendar now = Calendar.getInstance();
final List notifications
= com.nononsenseapps.notepad.database.Notification
.getNotificationsWithTime(context, now.getTimeInMillis(), true);
// Remove duplicates
makeUnique(context, notifications);
final NotificationManager notificationManager = (NotificationManager) context
.getSystemService(Context.NOTIFICATION_SERVICE);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
createNotificationChannel(context, notificationManager);
}
NnnLogger.debug(NotificationHelper.class,
"N° of notifications: " + notifications.size());
// If empty, cancel
if (notifications.isEmpty()) {
// TODO cancelAll permanent notifications here if/when that is implemented.
// Don't touch others. Dont do this, it clears location
// notificationManager.cancelAll();
return;
}
// else, notify
if (!areNotificationsVisible(notificationManager)) {
// android API >= 33 lets users disable notifications.
// The user turned OFF notifications for this app => send a warning
NnnLogger.warning(NotificationHelper.class,
"areNotificationsVisible() claims the user denied notifications");
Toast.makeText(context, R.string.msg_enable_notifications,
Toast.LENGTH_SHORT).show();
}
// Fetch sound and vibrate settings. The following settings are ARE ONLY VALID
// ON ANDROID API < 26, by design. Newer android versions set these things on the
// notification CHANNEL. For that, we just bring the user to the OS settings page,
// instead of creating our preferences code
// => the code here is only for API 23, 24 and 25 devices
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
// Always use default lights
int lightAndVibrate = Notification.DEFAULT_LIGHTS;
// If vibrate on, use default vibration pattern also
if (prefs.getBoolean(context.getString(R.string.key_pref_vibrate), false))
lightAndVibrate |= Notification.DEFAULT_VIBRATE;
// Need to get a new one because the action buttons will duplicate otherwise
NotificationCompat.Builder builder;
// (Here there was code to group notifications together by list, but i removed it.
// Check git history if you're interested)
// get priority and ringtone. See NotificationPrefs.java
final int priority = Integer.parseInt(
prefs.getString(context.getString(R.string.key_pref_prio), "0"));
final Uri ringtone = Uri.parse(prefs.getString(
context.getString(R.string.key_pref_ringtone),
"DEFAULT_NOTIFICATION_URI"));
// Notify for each individually
for (com.nononsenseapps.notepad.database.Notification note : notifications) {
// notifications.length is ~3 => optimization is not needed in this loop
builder = getNotificationBuilder(context, priority, lightAndVibrate, ringtone);
notifyBigText(context, notificationManager, builder, note);
}
}
/**
* Returns a notification builder set with non-item specific properties.
*/
private static NotificationCompat.Builder getNotificationBuilder(final Context context,
final int priority,
final int lightAndVibrate,
final Uri ringtone) {
// useless ? the small icon should be enough
final Bitmap largeIcon = BitmapFactory
.decodeResource(context.getResources(), R.drawable.app_icon);
// note that many of these settings (ringtone, vibration, ...) are IGNORED in
// android API >= 26. Instead, the user should edit the notification channel
// preferences in the page we link to from NotificationPrefs.java
return new NotificationCompat
.Builder(context, CHANNEL_ID) // we use only 1 channel in this app
.setWhen(0)
.setSmallIcon(R.drawable.ic_stat_notification_edit)
.setLargeIcon(largeIcon)
.setPriority(priority)
.setDefaults(lightAndVibrate)
.setAutoCancel(true)
.setSound(ringtone)
.setOnlyAlertOnce(true);
}
/**
* Remove from the database, and the specified list, duplicate
* notifications. The result is that each note is only associated with ONE
* EXPIRED notification.
*/
private static void makeUnique(final Context context,
final List notifications) {
// get duplicates and iterate over them
for (var noti : getLatestOccurence(notifications)) {
// remove all but the first one from database, and big list
for (var dupNoti : getDuplicates(noti, notifications)) {
notifications.remove(dupNoti);
cancelNotification(context, dupNoti);
// Cancelled called in delete
dupNoti.deleteOrReschedule(context);
}
}
}
/**
* Returns the first occurrence of each note's notification. Effectively the
* returned list has unique elements with regard to the note id.
*/
private static List getLatestOccurence(
final List notifications) {
final ArrayList seenIds = new ArrayList<>();
final ArrayList firsts = new ArrayList<>();
com.nononsenseapps.notepad.database.Notification noti;
for (int i = notifications.size() - 1; i >= 0; i--) {
noti = notifications.get(i);
if (!seenIds.contains(noti.taskID)) {
seenIds.add(noti.taskID);
firsts.add(noti);
}
}
return firsts;
}
private static List getDuplicates(
final com.nononsenseapps.notepad.database.Notification first,
final List notifications) {
final ArrayList dups = new ArrayList<>();
for (com.nononsenseapps.notepad.database.Notification noti : notifications) {
if (noti.taskID.equals(first.taskID) && noti._id != first._id) {
dups.add(noti);
}
}
return dups;
}
/**
* Configures and shows an android notification for the given reminder.
* Needs the builder that contains non-note specific values.
*
* @param note the reminder that triggered this android notification
*/
private static void notifyBigText(final Context context,
final NotificationManager notificationManager,
final NotificationCompat.Builder builder,
final com.nononsenseapps.notepad.database.Notification note) {
// create the intent that reacts to deleting the notification
final Intent iDelete = new Intent(context, NotificationHelper.class)
.setAction(Intent.ACTION_DELETE)
.setData(note.getUri());
if (note.isRepeating()) {
iDelete.setAction(ACTION_RESCHEDULE);
}
iDelete.putExtra(ARG_TASKID, note.taskID);
// Delete it on clear
PendingIntent piDelete = PendingIntent.getBroadcast(context, 0,
iDelete, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
// Open intent
final Intent openIntent = new Intent(Intent.ACTION_VIEW, Task.getUri(note.taskID));
// Should create a new instance to avoid fragment problems
openIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK | Intent.FLAG_ACTIVITY_NEW_TASK);
// Repeating reminders should have a delete intent:
// opening the note should delete/reschedule the notification
openIntent.putExtra(NOTIFICATION_DELETE_ARG, note._id);
// Opening always cancels the notification though
openIntent.putExtra(NOTIFICATION_CANCEL_ARG, note._id);
// Open note on click
PendingIntent clickIntent = PendingIntent.getActivity(context, 0,
openIntent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
// Action to complete
Intent iComplete = new Intent(context, NotificationHelper.class)
.setAction(ACTION_COMPLETE)
.setData(note.getUri())
.putExtra(ARG_TASKID, note.taskID);
PendingIntent piComplete = PendingIntent.getBroadcast(context, 0,
iComplete, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
// Action to snooze
Intent iSnooze = new Intent(context, NotificationHelper.class)
.setAction(ACTION_SNOOZE)
.setData(note.getUri())
.putExtra(ARG_TASKID, note.taskID);
PendingIntent piSnooze = PendingIntent.getBroadcast(context, 0,
iSnooze, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
// Build notification
builder.setContentTitle(note.taskTitle)
.setContentText(note.taskNote)
.setChannelId(CHANNEL_ID)
.setContentIntent(clickIntent)
.setStyle(new NotificationCompat.BigTextStyle().bigText(note.taskNote));
// the Delete intent for non-location repeats
builder.setDeleteIntent(piDelete);
// Snooze button only on time reminders, not location-based reminders
if (note.time != null) {
// see ACTION_SNOOZE branch in onReceive()
builder.addAction(R.drawable.ic_alarm_24dp, context.getText(R.string.snooze), piSnooze);
}
if (!note.isRepeating() && note.belongsToNoteInListOfTasks(context)) {
// Show a complete button only on:
// * non-repeating reminders. See issue #478
// * reminders for task-types and not note-types, see #312
builder.addAction(R.drawable.ic_check_24dp, context.getText(R.string.completed), piComplete);
}
final Notification noti = builder.build();
notificationManager.notify((int) note._id, noti);
}
/**
* @return TRUE if notifications from this app will be visible to the user
*/
public static boolean areNotificationsVisible(@NonNull NotificationManager manager) {
boolean canShowNotif;
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
canShowNotif = manager.areNotificationsEnabled() && !manager.areNotificationsPaused();
} else if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) {
canShowNotif = manager.areNotificationsEnabled();
} else {
canShowNotif = true;
}
return canShowNotif;
}
private static long getLatestTime(
final List notifications) {
long latest = 0;
for (com.nononsenseapps.notepad.database.Notification noti : notifications) {
if (noti.time > latest) latest = noti.time;
}
return latest;
}
/**
* Schedules this {@link BroadcastReceiver} to be woken up at the next notification time.
* Uses {@link AlarmManager}, which can set alarms with different priorities.
* See this page.
* You can't expect android to be precise or reliable: reminders will appear within
* a few minutes from the specified time, or may not appear at all until the app
* is restarted. OEM and vendors make this impossibile to solve
*/
private static void scheduleNext(Context context) {
// Get first future notification
final Calendar now = Calendar.getInstance();
final List notifications
= com.nononsenseapps.notepad.database.Notification
.getNotificationsWithTime(context, now.getTimeInMillis(), false);
// TODO check these:
// https://developer.android.com/reference/android/Manifest.permission#SCHEDULE_EXACT_ALARM
// https://developer.android.com/reference/android/Manifest.permission#USE_EXACT_ALARM
// if not empty, schedule alarm wake up
if (!notifications.isEmpty()) {
// at first's time
var thingToNotify = notifications.get(0);
// must be an explicit intent
Intent intent = new Intent(context, NotificationHelper.class)
.addFlags(Intent.FLAG_RECEIVER_FOREGROUND) // useless flag => remove freely
.setAction(Intent.ACTION_RUN);
// Create a new PendingIntent and add it to the AlarmManager
var pendingIntent = PendingIntent.getBroadcast(context, 1, intent,
PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_IMMUTABLE);
AlarmManager aMgr = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
aMgr.cancel(pendingIntent);
if (useExactReminders(context, aMgr)) {
// an "exact" alarm is more reliable, but requires user permission in API 31
aMgr.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP,
getTimeForAlarm(thingToNotify), pendingIntent);
/*
There is also .setAlarmClock(), but it didn't work when I tried.
It's has the highest priority, but the OS may show the reminder's trigger
time in the statubar on top of the screen. Therefore it overwrites any
alarm set by the clock app (waking up, bed time, ...). Since many
users (me, at least) are more interested in seeing those on the status bar,
(and not this app's reminders), we should avoid using setAlarmClock().
In any case, it would look like this:
aMgr.setAlarmClock(new AlarmManager.AlarmClockInfo(
getTimeForAlarm(thingToNotify), new PendingIntent(...)), pendingIntent);
*/
} else {
// these kinds of alarms don't require permission, but they are more vague
aMgr.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP,
getTimeForAlarm(thingToNotify), pendingIntent);
}
}
// old function deleted in december 2024. It was causing issue #543
// monitorUri(context);
}
/**
* @return TRUE if the Alarms (reminders) should be sent with the Exact method,
* which is more reliable and precise but heavier on the battery. The user can choose this
* in the preferences, and the OS may deny us the permission to do it
*/
private static boolean useExactReminders(Context context, AlarmManager aMgr) {
// The user can (must) request the use of Exact alarms
boolean shouldUseExact = PreferencesHelper.shouldUseExactAlarms(context);
if (!shouldUseExact) return false;
// the user may revoke the permission in android 12
boolean canUseExact;
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
canUseExact = aMgr.canScheduleExactAlarms();
} else {
// in older androids, we can always use Exact reminders
canUseExact = true;
}
// it SHOULD and CAN do it
return canUseExact;
}
/**
* @return the time to start the alarm, of the System.currentTimeMillis() type,
* so a {@link Long} representing a "wall clock time" in UTC
*/
private static long getTimeForAlarm(com.nononsenseapps.notepad.database.Notification input) {
// TODO since android takes some time to understand that it has to send the reminder,
// here we could subtract 60~100 seconds so that, by the time it understands what to
// do, we're not late with the reminder. Seems paranoic, though
return input.time; // - 60 * 1000
}
/**
* Schedules coming notifications, and displays expired ones.
* Only notififies once for existing notifications.
*/
public static void schedule(final Context context) {
notifyPast(context);
scheduleNext(context);
}
/**
* Updates or Inserts the given Notification in the database. Immediately notifies and
* schedules next wake up on finish.
*/
private static void updateNotification(final Context context,
final com.nononsenseapps.notepad.database.Notification notification) {
// Avoid INSERTing only if update is success. This way the editor can update
// a deleted notification and still have it persisted in the database
boolean shouldInsert = true;
// If id is -1, then this should be inserted
if (notification._id > 0) {
// Else it should be updated
int result = notification.save(context);
if (result > 0) shouldInsert = false;
}
if (shouldInsert) {
notification._id = -1;
notification.save(context);
}
schedule(context);
}
/**
* Deletes the indicated notification from the notification tray. Does not touch database.
* Called by {@link com.nononsenseapps.notepad.database.Notification#delete}
*/
public static void cancelNotification(final Context context,
final com.nononsenseapps.notepad.database.Notification not) {
if (not != null) {
cancelNotification(context, not.getUri());
}
}
/**
* removes the ANDROID notification: IT does not touch the db record
*/
public static void cancelNotification(final Context context, final Uri uri) {
if (uri == null) return;
cancelNotification(context, Integer.parseInt(uri.getLastPathSegment()));
}
/**
* removes the ANDROID notification: IT does not touch the db record
*/
public static void cancelNotification(final Context context, final int notId) {
final NotificationManager notificationManager = (NotificationManager) context
.getSystemService(Context.NOTIFICATION_SERVICE);
notificationManager.cancel(notId);
}
/**
* Given a list of notifications, returns a list of the lists the notes
* belong to.
*/
private static Collection getRelatedLists(
final List notifications) {
final HashSet lists = new HashSet<>();
for (com.nononsenseapps.notepad.database.Notification not : notifications) {
lists.add(not.listID);
}
return lists;
}
/**
* Returns a list of those notifications that are associated to notes in the
* specified list.
*/
private static List getSubList(
final long listId,
final List notifications) {
final ArrayList subList = new ArrayList<>();
for (com.nononsenseapps.notepad.database.Notification not : notifications) {
if (not.listID == listId) {
subList.add(not);
}
}
return subList;
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/helpers/PermissionsHelper.java
================================================
/*
* Copyright (c) 2015. Jonas Kalderstam
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.helpers;
import android.content.Context;
import android.content.pm.PackageManager;
import androidx.annotation.NonNull;
import androidx.core.content.ContextCompat;
/**
* Helper class which handles runtime permissions.
*/
public final class PermissionsHelper {
/**
* Permissions to show notifications
*/
public static final String[] FOR_NOTIFICATIONS =
new String[] { "android.permission.POST_NOTIFICATIONS" };
public static final int REQCODE_NOTIFICATIONS = 3;
/**
* @return TRUE if all the specified permissions are granted, FALSE otherwise
*/
public static boolean hasPermissions(@NonNull Context context, String... permissions) {
for (String permission : permissions) {
boolean hasPermission = PackageManager.PERMISSION_GRANTED
== ContextCompat.checkSelfPermission(context, permission);
if (!hasPermission) {
return false;
}
}
return true;
}
/**
* @param permissions that were requested
* @param grantResults of the request
* @return true if all results were granted, false otherwise
*/
public static boolean permissionsGranted(@NonNull String[] permissions,
@NonNull int[] grantResults) {
return permissions.length > 0 && allGranted(grantResults);
}
private static boolean allGranted(int[] items) {
for (int item : items) {
if (PackageManager.PERMISSION_GRANTED != item) {
return false;
}
}
return true;
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/helpers/PreferencesHelper.java
================================================
/*
* Copyright (c) 2015. Jonas Kalderstam
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.helpers;
import android.content.Context;
import android.content.SharedPreferences;
import android.provider.Settings;
import androidx.annotation.NonNull;
import androidx.preference.PreferenceManager;
import com.nononsenseapps.notepad.R;
import com.nononsenseapps.notepad.prefs.PasswordPrefs;
import com.nononsenseapps.notepad.prefs.SyncPrefs;
/**
* Helper class to save common options to shared preferences.
*/
public final class PreferencesHelper {
private static SharedPreferences Prefs(@NonNull Context context) {
return PreferenceManager.getDefaultSharedPreferences(context);
}
public static boolean shouldUseExactAlarms(@NonNull Context context) {
String key = context.getString(R.string.key_pref_should_use_exact_alarms);
return Prefs(context).getBoolean(key, false);
}
public static boolean isSdSyncEnabled(@NonNull Context context) {
return Prefs(context).getBoolean(SyncPrefs.KEY_SD_ENABLE, false);
}
/**
* Disable SD synchronization in the settings
*/
public static void disableSdCardSync(@NonNull Context context) {
Prefs(context).edit().putBoolean(SyncPrefs.KEY_SD_ENABLE, false).apply();
}
/**
* @return TRUE if synchronization is enabled in the global preference,
* regardless of any sync method chosen. This being false should block all sync code
* from running at all
*/
public static boolean isSincEnabledAtAll(@NonNull Context context) {
String key = context.getString(R.string.key_pref_sync_enabled_master);
return Prefs(context).getBoolean(key, false);
}
private static String getStr(@NonNull Context c, int id) {
return c.getResources().getString(id);
}
public static void setSortingDue(@NonNull Context context) {
Prefs(context)
.edit()
.putString(getStr(context, R.string.pref_sorttype), getStr(context, R.string.const_duedate))
.commit();
}
public static void setSortingManual(@NonNull Context context) {
Prefs(context)
.edit()
.putString(getStr(context, R.string.pref_sorttype), getStr(context, R.string.const_possubsort))
.commit();
}
public static void setSortingAlphabetic(Context context) {
Prefs(context)
.edit()
.putString(getStr(context, R.string.pref_sorttype), getStr(context, R.string.const_alphabetic))
.commit();
}
private static void put(@NonNull Context context, @NonNull String key, @NonNull String value) {
Prefs(context).edit().putString(key, value).apply();
}
public static void put(@NonNull Context context, @NonNull String key, boolean value) {
Prefs(context).edit().putBoolean(key, value).apply();
}
/**
* @return TRUE if the password for locking notes is set, FALSE if it isn't
*/
public static boolean isPasswordSet(@NonNull Context context) {
return !Prefs(context).getString(PasswordPrefs.KEY_PASSWORD, "").isEmpty();
}
/**
* @return TRUE if animations are enabled in the system settings. Used to choose if animations
* should be displayed in the app
*/
public static boolean areAnimationsEnabled(@NonNull Context context) {
// there are 3 redundant system settings that control animations
String[] sysSettingsToCheck = new String[] {
Settings.Global.ANIMATOR_DURATION_SCALE,
Settings.Global.TRANSITION_ANIMATION_SCALE,
Settings.Global.WINDOW_ANIMATION_SCALE
};
// if at least 1 of those is set to "0x", which means "disable animations",
// we assume that the user wants to disable all animations, also in this app
for (String option : sysSettingsToCheck) {
float f = Settings.Global.getFloat(context.getContentResolver(), option, 1.0f);
if (f == 0) return false;
}
// if none of the 3 settings is "0x", we assume that animations are enabled
return true;
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/helpers/RFC3339Date.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.helpers;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.Locale;
import java.util.TimeZone;
public final class RFC3339Date {
// TODO see TimeFormatter & TimeHelper in this package. One of these 3 classes HAS to be redundant
public static java.util.Date parseRFC3339Date(String datestring) {
if (datestring == null || datestring.isEmpty()) {
return null;
}
Date d;
// if there is no time zone, we don't need to do any special parsing.
if (datestring.endsWith("Z")) {
try {
SimpleDateFormat s = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'",
Locale.US); // spec for RFC3339
s.setCalendar(Calendar.getInstance(TimeZone.getTimeZone("UTC")));
d = s.parse(datestring);
} catch (ParseException pe) { // try again with optional
// decimals
SimpleDateFormat s = new SimpleDateFormat(
"yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'",
Locale.US); // spec for RFC3339
// (with fractional seconds)
s.setCalendar(Calendar.getInstance(TimeZone.getTimeZone("UTC")));
s.setLenient(true);
try {
d = s.parse(datestring);
} catch (ParseException e) {
return null;
}
}
return d;
}
SimpleDateFormat s = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.US);
// spec for RFC3339
s.setCalendar(Calendar.getInstance(TimeZone.getTimeZone("UTC")));
try {
d = s.parse(datestring);
} catch (java.text.ParseException pe) { // try again with optional decimals
s = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSSZ", Locale.US);
s.setCalendar(Calendar.getInstance(TimeZone.getTimeZone("UTC")));
// spec for RFC3339(with fractional seconds)
s.setLenient(true);
try {
d = s.parse(datestring);
} catch (ParseException e) {
return null;
}
}
return d;
}
/**
* Given a UTC date (2013-02-21), and a local time(13:23), will combine them
* into 2013-02-21T13:23 local time.
*
* DateString should be in RFC3339.
* If time is null, defaults to 23:59
*/
public static Long combineDateAndTime(final String datestring, final Long time) {
final java.util.Date d = parseRFC3339Date(datestring);
if (d == null) {
return null;
}
// UTC
final Calendar utc = GregorianCalendar.getInstance(TimeZone.getTimeZone("UTC"));
utc.setTime(d);
utc.set(Calendar.HOUR_OF_DAY, 0);
utc.set(Calendar.MINUTE, 0);
// Local date
final Calendar local = GregorianCalendar.getInstance();
local.set(Calendar.YEAR, utc.get(Calendar.YEAR));
local.set(Calendar.MONTH, utc.get(Calendar.MONTH));
local.set(Calendar.DAY_OF_MONTH, utc.get(Calendar.DAY_OF_MONTH));
// Default to 23:59
local.set(Calendar.MINUTE, 59);
local.set(Calendar.HOUR_OF_DAY, 23);
// Time
if (time == null) {
return local.getTimeInMillis();
}
final Calendar localTime = GregorianCalendar.getInstance();
localTime.setTimeInMillis(time);
local.set(Calendar.MINUTE, localTime.get(Calendar.MINUTE));
local.set(Calendar.HOUR_OF_DAY, localTime.get(Calendar.HOUR_OF_DAY));
return local.getTimeInMillis();
}
public static String asRFC3339(final Long time) {
if (time == null)
return null;
return asRFC3339(new Date(time));
}
/**
* For GTasks syncing. Given a date and time, say 2013-02-21T13:34.
* Will return 2013-02-21T00:00Z.
*/
public static String asRFC3339ZuluDate(final Long time) {
if (time == null)
return null;
// Local time calendar
Calendar cal = GregorianCalendar.getInstance();
cal.setTimeInMillis(time);
// Extract the date
return String.format(Locale.US, "%d", cal.get(Calendar.YEAR)) +
"-" +
String.format(Locale.US, "%02d", (1 + cal.get(Calendar.MONTH))) +
"-" +
String.format(Locale.US, "%02d", cal.get(Calendar.DAY_OF_MONTH)) +
"T00:00:00Z";
}
private static String asRFC3339(final java.util.Date date) {
if (date == null) return null;
// spec for RFC3339:
final SimpleDateFormat s =
new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.US);
return s.format(date);
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/helpers/SyncStatusMonitor.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.helpers;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.os.Bundle;
import android.widget.Toast;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.content.ContextCompat;
import com.nononsenseapps.notepad.R;
import com.nononsenseapps.notepad.sync.SyncAdapter;
public final class SyncStatusMonitor extends BroadcastReceiver {
AppCompatActivity activity;
OnSyncStartStopListener listener;
/**
* Call this in the activity's onResume
*/
public void startMonitoring(AppCompatActivity activity, OnSyncStartStopListener listener) {
// in the caller, the activity acts also as the listener, anyway
this.activity = activity;
this.listener = listener;
ContextCompat.registerReceiver(activity, this, new IntentFilter(SyncAdapter.SYNC_FINISHED), ContextCompat.RECEIVER_NOT_EXPORTED);
ContextCompat.registerReceiver(activity, this, new IntentFilter(SyncAdapter.SYNC_STARTED), ContextCompat.RECEIVER_NOT_EXPORTED);
if (!PreferencesHelper.isSincEnabledAtAll(activity)) {
// not starting: sync is disabled in the prefs
return;
}
// get the selected account and verify if it is valid
boolean isAccountValid = false;
// Sync state might have changed, make sure we're spinning when we should
try {
listener.onSyncStartStop(isAccountValid);
} catch (Exception e) {
NnnLogger.debug(SyncStatusMonitor.class, e.getMessage());
}
}
/**
* Call this in the activity's onPause
*/
public void stopMonitoring() {
try {
activity.unregisterReceiver(this);
} catch (Exception e) {
NnnLogger.exception(e);
}
try {
listener.onSyncStartStop(false);
} catch (Exception e) {
NnnLogger.exception(e);
}
}
@Override
public void onReceive(final Context context, final Intent intent) {
if (!PreferencesHelper.isSincEnabledAtAll(context)) {
NnnLogger.debug(SyncStatusMonitor.class,
"ignore onReceive(): sync is disabled in the prefs");
return;
}
if (intent.getAction().equals(SyncAdapter.SYNC_STARTED)) {
activity.runOnUiThread(() -> {
try {
listener.onSyncStartStop(true);
} catch (Exception e) {
NnnLogger.exception(e);
}
});
} else { //if (intent.getAction().equals(SyncAdapter.SYNC_FINISHED)) {
activity.runOnUiThread(() -> {
try {
listener.onSyncStartStop(false);
} catch (Exception e) {
NnnLogger.exception(e);
}
});
Bundle b = intent.getExtras();
if (b == null) {
b = Bundle.EMPTY;
}
tellUser(context, b.getInt(SyncAdapter.SYNC_RESULT, SyncAdapter.SUCCESS));
}
}
private void tellUser(Context context, int result) {
int text;
switch (result) {
case SyncAdapter.ERROR -> text = R.string.sync_failed;
case SyncAdapter.LOGIN_FAIL -> text = R.string.sync_login_failed;
default -> {
return;
}
}
NnnLogger.debug(SyncStatusMonitor.class, "SYNC: " + result);
Toast toast = Toast.makeText(context, text, Toast.LENGTH_SHORT);
toast.show();
}
public interface OnSyncStartStopListener {
/**
* This is always called on the activity's UI thread.
*/
void onSyncStartStop(final boolean ongoing);
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/helpers/ThemeHelper.java
================================================
package com.nononsenseapps.helpers;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.TypedValue;
import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.preference.PreferenceManager;
import com.nononsenseapps.notepad.R;
import com.nononsenseapps.notepad.prefs.AppearancePrefs;
/**
* contains functions that handle themes and related resources
*/
public final class ThemeHelper {
// forbid instances: it's a static class
private ThemeHelper() {}
/**
* It is different from {@link R.color#accent} if the user sets a custom Material3
* theme in android 13 or greater
*
* @return This theme's accent color, in the form 0xAARRGGBB
*/
@ColorInt
public static int getThemeAccentColor(@NonNull Context context) {
var outValue = new TypedValue();
context.getTheme()
.resolveAttribute(android.R.attr.colorAccent, outValue, true);
return outValue.data;
}
/**
* Set the theme chosen by the user in {@link AppearancePrefs} for this activity
*/
public static void setTheme(@NonNull AppCompatActivity activity) {
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(activity);
final String theme = prefs.getString(AppearancePrefs.KEY_THEME,
activity.getString(R.string.const_theme_light_ab));
if (activity.getString(R.string.const_theme_light_ab).equals(theme)) {
activity.setTheme(R.style.ThemeNnnLight);
} else if (activity.getString(R.string.const_theme_black).equals(theme)) {
activity.setTheme(R.style.ThemeNnnPitchBlack);
} else if (activity.getString(R.string.const_theme_classic).equals(theme)) {
activity.setTheme(R.style.ThemeNnnClassicLight);
} else if (theme.equals(activity.getResources().getString(R.string.const_theme_googlenow_dark))) {
activity.setTheme(R.style.ThemeNnnDark);
} else {
// any theme you want to add should go in a new if block ~here
}
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/helpers/TimeFormatter.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.helpers;
import android.content.Context;
import android.content.SharedPreferences;
import androidx.preference.PreferenceManager;
import com.nononsenseapps.notepad.R;
import java.text.SimpleDateFormat;
import java.time.LocalDate;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.Locale;
/**
* A class that helps with displaying locale and preference specific dates
*/
public final class TimeFormatter {
public static final String WEEKDAY_SHORTEST_FORMAT = "E";
public static Locale getLocale(final String lang) {
final Locale locale;
if (lang == null || lang.isEmpty()) {
locale = Locale.getDefault();
} else if (lang.length() == 5) {
locale = new Locale(lang.substring(0, 2), lang.substring(3, 5));
} else {
locale = new Locale(lang.substring(0, 2));
}
return locale;
}
/**
* Formats date according to the designated locale
*/
public static String getLocalDateString(final Context context, final String lang,
final String format, final long timeInMillis) {
return getLocalFormatter(context, lang, format).format(
new Date(timeInMillis));
}
/**
* Formats the date according to the locale the user has defined in settings
*/
public static String getLocalDateString(final Context context,
final String format, final long timeInMillis) {
return getLocalDateString(
context,
PreferenceManager.getDefaultSharedPreferences(context)
.getString(context.getString(R.string.pref_locale), ""),
format, timeInMillis);
}
/**
* Dont use for performance critical settings
*/
public static String getLocalDateStringLong(final Context context,
final long time) {
return getLocalFormatterLong(context).format(new Date(time));
}
public static String getLocalDateOnlyStringLong(final Context context, final long time) {
return getLocalFormatterLongDateOnly(context).format(new Date(time));
}
public static String getLocalTimeOnlyString(final Context context, final long time) {
final String format;
if (android.text.format.DateFormat.is24HourFormat(context)) {
// 00:59
format = "HH:mm";
} else {
// 12:59 am
format = "h:mm a";
}
// like "it_IT"
String localePrefVal = PreferenceManager
.getDefaultSharedPreferences(context)
.getString(context.getString(R.string.pref_locale), "");
return new SimpleDateFormat(format, getLocale(localePrefVal)).format(new Date(time));
}
/**
* Dont use for performance critical settings
*/
public static String getLocalDateStringShort(final Context context,
final long time) {
return getLocalFormatterShort(context).format(new Date(time));
}
/**
* Replaces first "localtime" in format string to a time which respects
* global 24h setting.
*/
private static String withSuitableTime(final Context context, final String formatString) {
if (android.text.format.DateFormat.is24HourFormat(context)) {
// 00:59
return formatString.replaceFirst("localtime", "HH:mm");
} else {
// 12:59 am
return formatString.replaceFirst("localtime", "h:mm a");
}
}
/**
* Removes "localtime" in format string
*/
private static String withSuitableDateOnly(final Context context,
final String formatString) {
return formatString.replaceAll("\\s*localtime\\s*", " ");
}
/**
* @param lang if app is in japanese, it's "ja" and uses values-ja/strings.xml
*/
private static SimpleDateFormat getLocalFormatter(final Context context,
final String lang, final String format) {
final Locale locale = getLocale(lang);
SimpleDateFormat sdf;
try {
//noinspection UnusedAssignment
sdf = new SimpleDateFormat(withSuitableTime(context, format), locale);
} catch (IllegalArgumentException iae) {
NnnLogger.error(TimeFormatter.class,
"Error in translated date format strings. In: values-" + lang
+ ", value: " + format);
NnnLogger.exception(iae);
} finally {
// just log the error, but crash anyway
sdf = new SimpleDateFormat(withSuitableTime(context, format), locale);
}
return sdf;
}
public static GregorianCalendar getLocalCalendar(final Context context) {
var prefs = PreferenceManager.getDefaultSharedPreferences(context);
final Locale locale = getLocale(prefs
.getString(context.getString(R.string.pref_locale), ""));
return new GregorianCalendar(locale);
}
/**
* Good for performance critical situations, like lists
*/
public static SimpleDateFormat getLocalFormatterLong(final Context context) {
return getLocalFormatter(
context,
PreferenceManager.getDefaultSharedPreferences(context)
.getString(context.getString(R.string.pref_locale), ""),
withSuitableTime(
context,
PreferenceManager
.getDefaultSharedPreferences(context)
.getString(
context.getString(R.string.key_pref_dateformat_long),
context.getString(R.string.dateformat_long_1))));
}
public static SimpleDateFormat getDateFormatter(final Context context) {
return getLocalFormatter(
context,
PreferenceManager.getDefaultSharedPreferences(context)
.getString(context.getString(R.string.pref_locale), ""),
context.getString(R.string.dateformat_just_date));
}
public static SimpleDateFormat getLocalFormatterLongDateOnly(final Context context) {
return getLocalFormatter(
context,
PreferenceManager.getDefaultSharedPreferences(context)
.getString(context.getString(R.string.pref_locale), ""),
withSuitableDateOnly(
context,
PreferenceManager
.getDefaultSharedPreferences(context)
.getString(
context.getString(R.string.key_pref_dateformat_long),
context.getString(R.string.dateformat_long_1))));
}
/**
* Good for performance critical situations, like lists
*/
public static SimpleDateFormat getLocalFormatterShort(final Context context) {
String userDateFormat = PreferenceManager
.getDefaultSharedPreferences(context)
.getString(context.getString(R.string.key_pref_dateformat_short),
context.getString(R.string.dateformat_short_1));
return getLocalFormatter(context,
PreferenceManager.getDefaultSharedPreferences(context)
.getString(context.getString(R.string.pref_locale), ""),
withSuitableTime(context, userDateFormat)); // <-- notice this
}
/**
* Returns the format chosen by the user to show dates in the list view. For example,
* a timestamp 1754752027 could be displayed as "sat 9 aug 2025" or "sat, 9 aug"
*/
public static SimpleDateFormat getLocalFormatterShortDateOnly(final Context context) {
SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context);
String userFavLocale = pref.getString(context.getString(R.string.pref_locale), "");
String userFavShortDateFormat = pref.getString(
context.getString(R.string.key_pref_dateformat_short),
context.getString(R.string.dateformat_short_2));
return getLocalFormatter(context, userFavLocale,
withSuitableDateOnly( // <-- notice this!
context, userFavShortDateFormat));
}
public static SimpleDateFormat getLocalFormatterMicro(final Context context) {
return getLocalFormatter(
context,
PreferenceManager.getDefaultSharedPreferences(context)
.getString(context.getString(R.string.pref_locale), ""),
withSuitableTime(context,
context.getString(R.string.dateformat_micro)));
}
/**
* Good for performance critical situations, like lists
*/
public static SimpleDateFormat getLocalFormatterWeekday(final Context context) {
return getLocalFormatter(context,
PreferenceManager
.getDefaultSharedPreferences(context)
.getString(context.getString(R.string.pref_locale), ""),
context.getString(R.string.dateformat_weekday));
}
/**
* Good for performance critical situations, like lists
*/
public static SimpleDateFormat getLocalFormatterWeekdayShort(final Context context) {
return getLocalFormatter(context,
PreferenceManager
.getDefaultSharedPreferences(context)
.getString(context.getString(R.string.pref_locale), ""),
WEEKDAY_SHORTEST_FORMAT);
}
/**
* @return how many days the next month will have, so 28 for february and so on
*/
public static int getHowManyDaysInTheNextMonth() {
Calendar x = Calendar.getInstance();
x.add(Calendar.MONTH, 1);
return x.getActualMaximum(Calendar.DAY_OF_MONTH);
}
/**
* @return the number of days from today until the beginning of the next month,
* so 15 if this is being run on 14 feb 2023, because we consider 1 mar 2023
*/
public static int getHowManyDaysUntilFirstOfNextMonth() {
LocalDate today = LocalDate.now();
LocalDate endOfMonth = today.withDayOfMonth(today.lengthOfMonth());
long daysBetween = java.time.temporal.ChronoUnit.DAYS.between(today, endOfMonth);
return (int) daysBetween + 1;
}
/**
* @return same as {@link #getHowManyDaysInTheNextMonth()} but for the year
*/
public static int getHowManyDaysInNextYear() {
Calendar x = Calendar.getInstance();
x.add(Calendar.YEAR, 1);
return x.getActualMaximum(Calendar.DAY_OF_YEAR);
}
/**
* @return same as {@link #getHowManyDaysUntilFirstOfNextMonth} but for the year
*/
public static int getHowManyDaysUntilFirstOfNextYear() {
LocalDate today = LocalDate.now();
LocalDate endOfYear = today.withDayOfYear(today.lengthOfYear());
long daysBetween = java.time.temporal.ChronoUnit.DAYS.between(today, endOfYear);
return (int) daysBetween + 1;
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/helpers/UpdateNotifier.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.helpers;
import android.appwidget.AppWidgetManager;
import android.content.ComponentName;
import android.content.Context;
import android.net.Uri;
import com.nononsenseapps.notepad.R;
import com.nononsenseapps.notepad.database.Notification;
import com.nononsenseapps.notepad.database.Task;
import com.nononsenseapps.notepad.database.TaskList;
import com.nononsenseapps.notepad.widget.list.ListWidgetProvider;
import com.nononsenseapps.notepad.widget.list.WidgetPrefs;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* The purpose here is to make it easy for other classes to notify that
* something has changed in the database. Will also call update on the widgets
* appropriately.
*/
public final class UpdateNotifier {
/**
* Will update all notes and specific uri if present
*/
public static void notifyChangeNote(Context context) {
notifyChange(context, Task.URI);
updateWidgets(context);
}
/**
* Will update all notes and specific uri if present
*
* @param uri optional uri
*/
public static void notifyChangeNote(Context context, Uri uri) {
notifyChange(context, uri);
notifyChangeNote(context);
}
/**
* Will update all notes
*/
public static void notifyChangeList(Context context) {
notifyChange(context, TaskList.URI);
updateWidgets(context);
}
/**
* Will update all lists and specific uri if present
*
* @param uri optional uri
*/
public static void notifyChangeList(Context context, Uri uri) {
notifyChange(context, uri);
notifyChangeList(context);
}
/**
* Will update all lists and specific uri if present
* Always updates notifications
*
* @param uri optional uri
*/
private static void notifyChange(Context context, Uri uri) {
if (uri == null) return;
context.getContentResolver().notifyChange(uri, null, false);
context.getContentResolver().notifyChange(Notification.URI, null);
}
/**
* Runs code in a separate thread
*/
private static final ExecutorService mExecutor = Executors.newSingleThreadExecutor();
/**
* Instead of doing this in a service which might be killed, simply call
* this whenever something is changed in here
*
* Update all widgets's views as this database has changed somehow
*/
public static void updateWidgets(Context context) {
final AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(context);
int[] appWidgetIds = appWidgetManager.getAppWidgetIds(
new ComponentName(context, ListWidgetProvider.class));
// .notifyAppWidgetViewDataChanged() is a very slow function that takes 40 seconds for
// each list widget present in the launcher. We run it in a background thread. This way,
// we ensure that, when the user taps a checkbox in the app, the note is recognized as
// completed right away, without waiting for every list-widget to update. This fixes #574.
// See onDataSetChanged() in ListWidgetService.java, where the slow query is located.
mExecutor.execute(() -> {
for (int widgetId : appWidgetIds) { // Only update widgets that exist
final WidgetPrefs prefs = new WidgetPrefs(context, widgetId);
if (prefs.isPresent()) {
// Tell the widgets that the list items should be invalidated and refreshed!
// Will call onDatasetChanged in ListWidgetService, doing a new requery
appWidgetManager.notifyAppWidgetViewDataChanged(widgetId, R.id.notesList);
}
}
});
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/NnnApp.java
================================================
package com.nononsenseapps.notepad;
import android.app.Application;
import android.os.Build;
import android.os.StrictMode;
import androidx.annotation.RequiresApi;
import com.google.android.material.color.DynamicColors;
import com.nononsenseapps.notepad.activities.main.ActivityMain;
/**
* Represents this app. The application object is not guaranteed to stay
* in memory forever, it WILL get killed.
*/
public class NnnApp extends Application {
/**
* Called when the application is starting, before any other application
* objects have been created. {@link ActivityMain} is a better place to
* put initialization logic
*/
@Override
public void onCreate() {
// enableStrictModeAnalysis();
super.onCreate();
// use dynamic colors for android >= 13
DynamicColors.applyToActivitiesIfAvailable(this);
}
/**
* Detects every disk read/write operation, and every time a cursor is not closed.
* Useful for tests during development. Remember that disk activity is core app
* functionality!
*/
@RequiresApi(api = Build.VERSION_CODES.P)
private static void enableStrictModeAnalysis() {
StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder()
.detectDiskReads()
.detectDiskWrites()
.detectNetwork()
.detectAll()
.penaltyLog()
.penaltyFlashScreen()
.build());
StrictMode.setVmPolicy(new StrictMode.VmPolicy.Builder()
.detectLeakedSqlLiteObjects()
.detectLeakedClosableObjects()
.detectNonSdkApiUsage()
.detectAll()
.penaltyLog()
.build());
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/NotePadBroadcastReceiver.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.provider.BaseColumns;
import com.nononsenseapps.notepad.database.Task;
import com.nononsenseapps.notepad.widget.list.ListWidgetProvider;
/**
* Used by {@link ListWidgetProvider} to receive the signal
* that a note was completed in the widget
*/
public class NotePadBroadcastReceiver extends BroadcastReceiver {
// TODO but at this point can't you just embed this in ListWidgetProvider.onReceive() ?
// if you edit these, see also AndroidManifest.xml
public static final String SET_NOTE_COMPLETE = "com.nononsenseapps.SetNoteComplete";
public static final String SET_NOTE_INCOMPLETE = "com.nononsenseapps.SetNoteIncomplete";
@Override
public void onReceive(final Context context, final Intent intent) {
Bundle extras = intent.getExtras();
if (extras == null || context == null) return;
long id = extras.getLong(BaseColumns._ID, -1);
if (id <= 0) return;
String action = intent.getAction();
switch (action) {
case SET_NOTE_COMPLETE -> {
Task.setCompleted(context, true, id);
// Toast.makeText(context, R.string.completed, Toast.LENGTH_SHORT).show();
// Broadcast that it has been completed, primarily for AndroidAgendaWidget
Intent i = new Intent(context.getString(R.string.note_completed_broadcast_intent));
context.sendBroadcast(i);
}
case SET_NOTE_INCOMPLETE -> Task.setCompleted(context, false, id);
default -> {
}
}
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/activities/ActivitySearch.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.activities;
import android.app.SearchManager;
import android.content.Intent;
import android.os.Bundle;
import android.view.MenuItem;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.widget.SearchView;
import androidx.fragment.app.Fragment;
import com.nononsenseapps.helpers.ActivityHelper;
import com.nononsenseapps.helpers.ThemeHelper;
import com.nononsenseapps.notepad.R;
import com.nononsenseapps.notepad.activities.main.ActivityMain_;
import com.nononsenseapps.notepad.databinding.FullscreenFragmentBinding;
import com.nononsenseapps.notepad.fragments.FragmentSearch;
public class ActivitySearch extends AppCompatActivity {
protected String mQuery = "";
/**
* for {@link R.layout#fullscreen_fragment}
*/
private FullscreenFragmentBinding mBinding;
@Override
public void onCreate(Bundle savedInstanceState) {
// Must do this before super.onCreate
ThemeHelper.setTheme(this);
ActivityHelper.setSelectedLanguage(this);
super.onCreate(savedInstanceState);
mBinding = FullscreenFragmentBinding.inflate(getLayoutInflater());
setContentView(mBinding.getRoot());
loadContent();
getSupportActionBar().setDisplayShowTitleEnabled(true);
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
handleIntent(getIntent());
}
/**
* To allow child classes to override content
*/
protected Fragment getFragment() {
return FragmentSearch.getInstance(mQuery);
}
/**
* Shows the {@link FragmentSearch} with the results
*/
void loadContent() {
getSupportFragmentManager()
.beginTransaction()
.replace(R.id.fragmentPlaceHolder, getFragment())
.commit();
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
setIntent(intent);
handleIntent(intent);
}
void handleIntent(Intent intent) {
if (intent == null) return;
if (Intent.ACTION_SEARCH.equals(intent.getAction())) {
// as a result of voice search in the main activity
mQuery = intent.getStringExtra(SearchManager.QUERY);
var searchViewMenuItem = (SearchView) this.findViewById(R.id.menu_search);
if (searchViewMenuItem == null) {
// the search activity did not load yet (for example, a voice search is opening
// ActivitySearch from ActivityMain). In this case, you need to load the content
loadContent();
} else {
// there is a searchview in this activity. You MUST NOT re-create the fragment.
// Instead, update the query text, and the fragment will show the results
searchViewMenuItem.setQuery(mQuery, false);
}
} else if (Intent.ACTION_VIEW.equals(intent.getAction())) {
// when you click a note from the search suggestion in the main activity
intent.setClass(getApplicationContext(), ActivityMain_.class);
startActivity(intent);
finish();
} else if (intent.getAction() == null) {
// the archive view was launched from ActivityMain
}
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
if (item.getItemId() == android.R.id.home) {
finish();
return true;
}
return false;
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/activities/ActivitySearchDeleted.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.activities;
import androidx.fragment.app.Fragment;
import com.nononsenseapps.notepad.fragments.FragmentSearchDeleted;
public class ActivitySearchDeleted extends ActivitySearch {
@Override
protected Fragment getFragment() {
return FragmentSearchDeleted.getInstance(mQuery);
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/activities/ActivityTaskHistory.java
================================================
package com.nononsenseapps.notepad.activities;
import android.annotation.SuppressLint;
import android.content.Intent;
import android.database.Cursor;
import android.os.Bundle;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.View;
import android.view.ViewGroup;
import android.widget.SeekBar;
import androidx.annotation.NonNull;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AppCompatActivity;
import androidx.loader.app.LoaderManager;
import androidx.loader.app.LoaderManager.LoaderCallbacks;
import androidx.loader.content.CursorLoader;
import androidx.loader.content.Loader;
import com.nononsenseapps.helpers.ActivityHelper;
import com.nononsenseapps.helpers.ThemeHelper;
import com.nononsenseapps.helpers.TimeFormatter;
import com.nononsenseapps.notepad.R;
import com.nononsenseapps.notepad.database.Task;
import com.nononsenseapps.notepad.databinding.ActivityTaskHistoryBinding;
import com.nononsenseapps.notepad.fragments.TaskDetailFragment;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
import java.util.TimeZone;
/**
* shows a history of all the (saved) previous versions of a note.
* Open it from {@link TaskDetailFragment }
*/
public class ActivityTaskHistory extends AppCompatActivity {
public static final String RESULT_TEXT_KEY = "task_text_key";
private long mTaskID;
private boolean loaded = false;
private Cursor mCursor;
private SimpleDateFormat timeFormatter;
private SimpleDateFormat dbTimeParser;
/**
* for {@link R.layout#activity_task_history}
*/
private ActivityTaskHistoryBinding mBinding;
@Override
public void onCreate(Bundle savedInstanceState) {
// Must do this before super.onCreate
ThemeHelper.setTheme(this);
ActivityHelper.setSelectedLanguage(this);
super.onCreate(savedInstanceState);
mBinding = ActivityTaskHistoryBinding.inflate(getLayoutInflater());
setContentView(mBinding.getRoot());
mBinding.seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
@Override
public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
onSeekBarChanged(progress);
}
@Override
public void onStartTrackingTouch(SeekBar seekBar) {}
@Override
public void onStopTrackingTouch(SeekBar seekBar) {}
});
// Intent must contain a task id
if (getIntent() == null || getIntent().getLongExtra(Task.Columns._ID, -1) < 1) {
setResult(RESULT_CANCELED, new Intent());
finish();
return;
} else {
mTaskID = getIntent().getLongExtra(Task.Columns._ID, -1);
}
timeFormatter = TimeFormatter.getLocalFormatterLong(this);
// Default datetime format in sqlite. Set to UTC timezone
dbTimeParser = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US);
dbTimeParser.setTimeZone(TimeZone.getTimeZone("UTC"));
loadActionBarLayout();
}
void loadActionBarLayout() {
// Prepare for failure
setResult(RESULT_CANCELED, new Intent());
// Inflate a "Done/Discard" custom action bar view.
LayoutInflater inflater = (LayoutInflater) getSupportActionBar()
.getThemedContext()
.getSystemService(LAYOUT_INFLATER_SERVICE);
@SuppressLint("InflateParams") final View customActionBarView = inflater
.inflate(R.layout.actionbar_custom_view_done_discard, null);
customActionBarView
.findViewById(R.id.actionbar_done)
.setOnClickListener(v -> {
// "Done"
String txt = mBinding.taskText.getText().toString();
final Intent returnIntent = new Intent();
returnIntent.putExtra(RESULT_TEXT_KEY, txt);
setResult(RESULT_OK, returnIntent);
finish();
});
customActionBarView
.findViewById(R.id.actionbar_discard)
// "Cancel result already set"
.setOnClickListener(v -> finish());
// Show the custom action bar view and hide the normal Home icon and title.
getSupportActionBar()
.setDisplayOptions(ActionBar.DISPLAY_SHOW_CUSTOM,
ActionBar.DISPLAY_SHOW_CUSTOM | ActionBar.DISPLAY_SHOW_HOME
| ActionBar.DISPLAY_SHOW_TITLE);
getSupportActionBar()
.setCustomView(customActionBarView, new ActionBar.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
}
@Override
public void onStart() {
super.onStart();
LoaderManager.getInstance(this).restartLoader(0, null,
new LoaderCallbacks() {
@NonNull
@Override
public Loader onCreateLoader(int arg0, Bundle arg1) {
return new CursorLoader(ActivityTaskHistory.this,
Task.URI_TASK_HISTORY, Task.Columns.HISTORY_COLUMNS_UPDATED,
Task.Columns.HIST_TASK_ID + " IS ?",
new String[] { Long.toString(mTaskID) }, null);
}
@Override
public void onLoadFinished(@NonNull Loader arg0, Cursor c) {
mCursor = c;
setSeekBarProperties();
if (!loaded) {
mBinding.seekBar.setProgress(c.getCount() - 1);
loaded = true;
}
}
@Override
public void onLoaderReset(@NonNull Loader arg0) {
mCursor = null;
setSeekBarProperties();
}
});
}
void onSeekBarChanged(int progress) {
if (mCursor == null) return;
if (progress < mCursor.getCount()) {
mCursor.moveToPosition(progress);
mBinding.taskText.setTextTitle(mCursor.getString(1));
mBinding.taskText.setTextRest(mCursor.getString(2));
try {
Date x = dbTimeParser.parse(mCursor.getString(3));
mBinding.timestamp.setText(timeFormatter.format(x));
} catch (ParseException e) {
Log.d("nononsenseapps time", e.getLocalizedMessage());
}
}
}
void setSeekBarProperties() {
if (mCursor == null) {
mBinding.seekBar.setEnabled(false);
mBinding.seekBar.setMax(0);
} else {
mBinding.seekBar.setEnabled(true);
mBinding.seekBar.setMax(mCursor.getCount() - 1);
}
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
return false;
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/activities/main/ActivityMain.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.activities.main;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.SharedPreferences.OnSharedPreferenceChangeListener;
import android.content.res.Configuration;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.inputmethod.InputMethodManager;
import android.widget.ListView;
import android.widget.Toast;
import androidx.activity.OnBackPressedCallback;
import androidx.annotation.NonNull;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.ActionBarDrawerToggle;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.widget.SearchView;
import androidx.core.view.GravityCompat;
import androidx.drawerlayout.widget.DrawerLayout;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentTransaction;
import androidx.loader.app.LoaderManager;
import androidx.loader.app.LoaderManager.LoaderCallbacks;
import androidx.preference.PreferenceManager;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import com.nononsenseapps.helpers.ActivityHelper;
import com.nononsenseapps.helpers.ListHelper;
import com.nononsenseapps.helpers.NnnLogger;
import com.nononsenseapps.helpers.NotificationHelper;
import com.nononsenseapps.helpers.PermissionsHelper;
import com.nononsenseapps.helpers.PreferencesHelper;
import com.nononsenseapps.helpers.SyncStatusMonitor;
import com.nononsenseapps.helpers.SyncStatusMonitor.OnSyncStartStopListener;
import com.nononsenseapps.helpers.ThemeHelper;
import com.nononsenseapps.notepad.R;
import com.nononsenseapps.notepad.database.Task;
import com.nononsenseapps.notepad.database.TaskList;
import com.nononsenseapps.notepad.databinding.ActivityMainBinding;
import com.nononsenseapps.notepad.fragments.DialogEditList;
import com.nononsenseapps.notepad.fragments.TaskDetailFragment;
import com.nononsenseapps.notepad.fragments.TaskDetailFragment_;
import com.nononsenseapps.notepad.fragments.TaskListFragment;
import com.nononsenseapps.notepad.fragments.TaskListViewPagerFragment;
import com.nononsenseapps.notepad.interfaces.ListOpener;
import com.nononsenseapps.notepad.interfaces.MenuStateController;
import com.nononsenseapps.notepad.interfaces.OnFragmentInteractionListener;
import com.nononsenseapps.notepad.prefs.AppearancePrefs;
import com.nononsenseapps.notepad.prefs.PrefsActivity;
import com.nononsenseapps.notepad.sync.orgsync.BackgroundSyncScheduler;
import com.nononsenseapps.notepad.sync.orgsync.OrgSyncService;
import com.nononsenseapps.ui.ExtraTypesCursorAdapter;
import org.androidannotations.annotations.AfterViews;
import org.androidannotations.annotations.EActivity;
import org.androidannotations.annotations.InstanceState;
import org.androidannotations.annotations.UiThread;
import org.androidannotations.annotations.UiThread.Propagation;
import org.androidannotations.annotations.ViewById;
import java.util.ArrayList;
import java.util.concurrent.Executors;
/**
* This is extended by {@link ActivityMain_}. It was renamed to ActivityList
* in release 6.0.0 beta, it has to do with getting rid of the annotations
* library that generates {@link ActivityMain_}
*/
@EActivity(R.layout.activity_main)
public class ActivityMain extends AppCompatActivity
implements OnFragmentInteractionListener, OnSyncStartStopListener,
MenuStateController, OnSharedPreferenceChangeListener {
// Set to true in bundle if exits should be animated
public static final String ANIMATEEXIT = "animateexit";
// Using tags for test
public static final String DETAILTAG = "detailfragment";
public static final String LISTPAGERTAG = "listpagerfragment";
@ViewById(resName = "leftDrawer")
ListView leftDrawer;
@ViewById(resName = "drawerLayout")
DrawerLayout drawerLayout;
@ViewById(resName = "fragment1")
View fragment1;
// Only present on tablets
@ViewById(resName = "fragment2")
View fragment2;
// Shown on tablets on start up. Hide on selection
@ViewById(resName = "taskHint")
View taskHint;
/**
* Which slide animations the {@link TaskDetailFragment} should use for enter and exit
*/
boolean mReverseAnimation = false;
boolean mAnimateExit = false;
/**
* If transitions in this activity should be animated.
* The value is regularly updated by {@link ActivityMain#onResume()}
*/
boolean mShouldAnimate = true;
/**
* Changes depending on what we're showing since the started activity can receive new intents
*/
@InstanceState
boolean isShowingEditor = false;
boolean isDrawerClosed = true;
SyncStatusMonitor syncStatusReceiver = null;
// WIll only be the viewpager fragment
ListOpener listOpener = null;
/**
* Helper component that ties the action bar to the navigation drawer.
*/
private ActionBarDrawerToggle mDrawerToggle;
// Only not if opening note directly
private boolean shouldAddToBackStack = true;
private Bundle state;
private boolean shouldRestart = false;
/**
* for both {@link R.layout#activity_main}
*/
private ActivityMainBinding mBinding;
/**
* called when you rotate the screen, for example. With this, the {@link ActivityMain} can
* remember if the task detail view was showing before. Better than
* {@link AppCompatActivity#onRestoreInstanceState(Bundle)} because this runs before
* {@link #onCreate(Bundle)}, and it's important.
*/
private void restoreSavedInstanceState_(Bundle savedInstanceState) {
if (savedInstanceState == null) return;
isShowingEditor = savedInstanceState.getBoolean("isShowingEditor");
}
/*
@Override
public void onSaveInstanceState(@NonNull Bundle bundle_) {
super.onSaveInstanceState(bundle_);
bundle_.putBoolean("isShowingEditor", isShowingEditor);
}
*/
@Override
protected void onPostCreate(Bundle savedInstanceState) {
super.onPostCreate(savedInstanceState);
// Sync the toggle state after onRestoreInstanceState has occurred.
if (mDrawerToggle != null) mDrawerToggle.syncState();
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
super.onCreateOptionsMenu(menu);
getMenuInflater().inflate(R.menu.activity_main, menu);
return true;
}
/**
* Need a reference to close it when pressing the back button
*/
MenuItem mSearchViewMenuItem;
@Override
public boolean onPrepareOptionsMenu(Menu menu) {
menu.setGroupVisible(R.id.activity_menu_group, isDrawerClosed);
menu.setGroupVisible(R.id.activity_reverse_menu_group, !isDrawerClosed);
// save a reference so we can close it when pressing the back button
mSearchViewMenuItem = menu.findItem(R.id.menu_search);
return super.onPrepareOptionsMenu(menu);
}
@Override
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
// Pass the event to ActionBarDrawerToggle. If it returns true, then it has handled the
// drawer icon touch event
if (mDrawerToggle.onOptionsItemSelected(item)) {
return true;
}
// Handle your other action bar items...
int itemId = item.getItemId();
if (itemId == android.R.id.home) {
// the <- arrow was pressed, maybe from the "task detail" page
if (isShowingEditor) {
// Only true in portrait mode
final View focusView = ActivityMain.this.getCurrentFocus();
InputMethodManager inputManager = this.getSystemService(InputMethodManager.class);
if (inputManager != null && focusView != null) {
// hide soft keyboard
inputManager.hideSoftInputFromWindow(focusView.getWindowToken(),
InputMethodManager.HIDE_NOT_ALWAYS);
}
// Should load the same list again
// Try getting the list from the original intent
final long listId = ActivityMainHelper.getListId(getIntent());
final Intent intent = new Intent()
.setAction(Intent.ACTION_VIEW)
.setClass(ActivityMain.this, ActivityMain_.class);
if (listId > 0) {
intent.setData(TaskList.getUri(listId));
}
// Set the intent before, so we set the correct action bar
setIntent(intent);
while (getSupportFragmentManager().popBackStackImmediate()) {
// Need to pop the entire stack and then load
}
mReverseAnimation = true;
intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
} else {
// Handled by the drawer
}
return true;
} else if (itemId == R.id.drawer_menu_createlist) {
// Show fragment
DialogEditList dialog = DialogEditList.getInstance();
dialog.setListener(this::openList);
dialog.show(getSupportFragmentManager(), "fragment_create_list");
return true;
} else if (itemId == R.id.menu_preferences) {
Intent intent = new Intent();
intent.setClass(this, PrefsActivity.class);
startActivity(intent);
return true;
} else if (itemId == R.id.menu_sync) {
handleSyncRequest();
return true;
} else if (itemId == R.id.menu_delete) {
return false;
} else {
return false;
}
}
@Override
public void finish() {
super.finish();
// Only animate when specified. Should be when it was animated "in"
if (mAnimateExit && mShouldAnimate) {
overridePendingTransition(R.anim.activity_slide_in_right,
R.anim.activity_slide_out_right_full);
}
}
/**
* Opens the specified list and closes the left drawer
*/
void openList(final long id) {
// Open list
Intent i = new Intent(ActivityMain.this, ActivityMain_.class)
.setAction(Intent.ACTION_VIEW)
.setData(TaskList.getUri(id))
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
// If editor is on screen, we need to reload fragments
if (listOpener == null) {
while (getSupportFragmentManager().popBackStackImmediate()) {
// Need to pop the entire stack and then load
}
mReverseAnimation = true;
startActivity(i);
} else {
// If not popped, then send the call to the fragment
// directly
NnnLogger.debug(ActivityMain.class, "calling listOpener");
listOpener.openList(id);
}
// And then close drawer
if (drawerLayout != null && leftDrawer != null) {
drawerLayout.closeDrawer(leftDrawer, mShouldAnimate);
}
}
private void handleSyncRequest() {
if (!PreferencesHelper.isSincEnabledAtAll(this)) {
Toast.makeText(this, R.string.no_sync_method_chosen,
Toast.LENGTH_SHORT).show();
setRefreshOfAllSwipeLayoutsTo(false);
return;
}
boolean syncing = false;
if (OrgSyncService.areAnyEnabled(this)) {
syncing = true;
OrgSyncService.start(this);
}
if (syncing) {
// In case of connectivity problems, stop the progress bar
Handler handler = new Handler(Looper.getMainLooper());
Executors.newSingleThreadExecutor().execute(() -> {
// Background work here
try {
Thread.sleep(30 * 1000);
} catch (InterruptedException e) {
NnnLogger.exception(e);
}
handler.post(() -> {
// UI Thread work here
// Notify that the refresh has finished
setRefreshOfAllSwipeLayoutsTo(false);
});
});
} else {
// explain to the user why the swipe-refresh was canceled
Toast.makeText(this, R.string.no_sync_method_chosen,
Toast.LENGTH_SHORT).show();
setRefreshOfAllSwipeLayoutsTo(false);
}
}
@Override
public void onConfigurationChanged(@NonNull Configuration newConfig) {
super.onConfigurationChanged(newConfig);
if (mDrawerToggle != null) mDrawerToggle.onConfigurationChanged(newConfig);
}
@Override
public void onCreate(Bundle b) {
// Must do this before super.onCreate
ThemeHelper.setTheme(this);
ActivityHelper.setSelectedLanguage(this);
super.onCreate(b);
/*
View bindings don't work here, you must keep android annotations
restoreSavedInstanceState_(b);
super.onCreate(b);
mBinding = ActivityMainBinding.inflate(getLayoutInflater());
setContentView(mBinding.getRoot());
loadContent();
*/
syncStatusReceiver = new SyncStatusMonitor();
// First load, then don't add to backstack
shouldAddToBackStack = false;
// To know if we should animate exits
if (getIntent() != null && getIntent().getBooleanExtra(ANIMATEEXIT, false)) {
mAnimateExit = true;
}
// To listen on fragment changes
getSupportFragmentManager().addOnBackStackChangedListener(() -> {
if (isShowingEditor && !ActivityMainHelper.isNoteIntent(getIntent())) {
setHomeAsDrawer(true);
}
// Always update menu
invalidateOptionsMenu();
}
);
if (b != null) {
NnnLogger.debug(ActivityMain.class, "Activity Saved not null: " + b);
this.state = b;
}
// Setup FAB. TODO are we going to add one ?
// mFab = (FloatingActionButton) findViewById(R.id.fab);
// mFab.setOnClickListener(view -> {
// //addTaskInList("", ListHelper.getARealList(this, id_of_the_list));
// });
// Clear possible notifications, schedule future ones
final Intent intent = getIntent();
// Clear notification if present
NotificationHelper.clearNotification(this, intent);
// Schedule notifications
NotificationHelper.schedule(this);
// Schedule syncs
BackgroundSyncScheduler.scheduleSync(this);
// Sync if appropriate
OrgSyncService.start(this);
// Android 13 enforces a more complicated way to call the back button handler
getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) {
@Override
public void handleOnBackPressed() {
newOnBackPressed();
}
});
// keep showing the popup to ask for notification permissions on startup.
// The callback function doesn't matter. Android will stop showing it if
// the user denies the permission twice.
if (!PermissionsHelper.hasPermissions(this, PermissionsHelper.FOR_NOTIFICATIONS))
this.requestPermissions(PermissionsHelper.FOR_NOTIFICATIONS,
PermissionsHelper.REQCODE_NOTIFICATIONS);
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
prefs.registerOnSharedPreferenceChangeListener(this);
}
@Override
public void onDestroy() {
// avoid crashes due to drawer's resources being called when the activity is closing
leftDrawer.setAdapter(null);
super.onDestroy();
OrgSyncService.stop(this);
}
/**
* Updated for android 13+
*/
void newOnBackPressed() {
if (drawerLayout.isDrawerOpen(leftDrawer)) {
// close the drawer on the left if it's open
drawerLayout.closeDrawer(leftDrawer, mShouldAnimate);
return;
}
// If search view is expanded, collapse it instead of closing the activity
if (mSearchViewMenuItem != null && mSearchViewMenuItem.isActionViewExpanded()) {
mSearchViewMenuItem.collapseActionView();
invalidateOptionsMenu();
return;
}
// Reset intent so we get proper fragment handling when the stack pops
if (getSupportFragmentManager().getBackStackEntryCount() <= 1) {
// if you remove this, it shows the wrong icons in the actionbar
// when you navigate back from TaskDetailView
setIntent(new Intent(this, ActivityMain_.class));
}
// replicate super.onBackPressed() behavior
if (getSupportFragmentManager().getBackStackEntryCount() > 0) {
getSupportFragmentManager().popBackStack();
} else {
finish();
}
}
@Override
public void onPause() {
super.onPause();
// deactivate monitor
if (syncStatusReceiver != null) {
syncStatusReceiver.stopMonitoring();
}
// deactivate any progress bar
setRefreshOfAllSwipeLayoutsTo(false);
// Pause sync monitors
OrgSyncService.pause(this);
}
@Override
public void onNewIntent(Intent intent) {
super.onNewIntent(intent);
setIntent(intent);
loadFragments();
// Just to be sure it gets done. Clear notification if present
NotificationHelper.clearNotification(this, intent);
}
@Override
public void onResume() {
if (shouldRestart) {
restartAndRefresh();
}
super.onResume();
// activate monitor
if (syncStatusReceiver != null) {
syncStatusReceiver.startMonitoring(this, this);
}
OrgSyncService.start(this);
mShouldAnimate = PreferencesHelper.areAnimationsEnabled(this);
}
/**
* Restarts the activity using the same intent that started it.
* Disables animations to get a seamless restart.
*/
private void restartAndRefresh() {
shouldRestart = false;
Intent intent = getIntent();
overridePendingTransition(0, 0);
intent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION);
finish();
overridePendingTransition(0, 0);
startActivity(intent);
}
@UiThread(propagation = Propagation.REUSE)
void loadFragments() {
final Intent intent = getIntent();
// Mandatory
Fragment left = null;
String leftTag = null;
// Only if fragment2 is not null
Fragment right = null;
if (this.state != null) {
this.state = null;
if (isShowingEditor && fragment2 != null) {
// Should only be true in portrait
isShowingEditor = false;
}
// Find fragments
// This is an instance state variable
if (isShowingEditor) {
// Portrait, with editor, modify action bar
setHomeAsDrawer(false);
// Done
return;
}
// Find the listpager
left = getSupportFragmentManager().findFragmentByTag(LISTPAGERTAG);
listOpener = (ListOpener) left;
if (left != null) {
if (fragment2 == null) return; // Done
right = getSupportFragmentManager().findFragmentByTag(DETAILTAG);
}
if (left != null && right != null) return; // Done
}
// Load stuff
final FragmentTransaction transaction = getSupportFragmentManager().beginTransaction();
if (mShouldAnimate) {
if (mReverseAnimation) {
mReverseAnimation = false;
transaction.setCustomAnimations(
R.anim.slide_in_bottom, R.anim.slide_out_top,
R.anim.slide_in_top, R.anim.slide_out_bottom);
} else {
transaction.setCustomAnimations(
R.anim.slide_in_top, R.anim.slide_out_bottom,
R.anim.slide_in_bottom, R.anim.slide_out_top);
}
}
// If it contains a noteId, load an editor. If also tablet, load the lists
if (fragment2 != null) {
if (ActivityMainHelper.getNoteId(intent) > 0) {
right = TaskDetailFragment_.getInstance(ActivityMainHelper.getNoteId(intent));
} else if (ActivityMainHelper.isNoteIntent(intent)) {
// some text was shared to this app
right = TaskDetailFragment_.getInstance(
ActivityMainHelper.getNoteShareText(intent),
ActivityMainHelper.getListIdToShow(intent, this));
}
} else if (ActivityMainHelper.isNoteIntent(intent)) {
isShowingEditor = true;
listOpener = null;
leftTag = DETAILTAG;
if (ActivityMainHelper.getNoteId(intent) > 0) {
left = TaskDetailFragment_.getInstance(ActivityMainHelper.getNoteId(intent));
} else {
// Get a share text (null safe)
// In a list (if specified, or default otherwise)
left = TaskDetailFragment_.getInstance(
ActivityMainHelper.getNoteShareText(intent),
ListHelper.getARealList(this, ActivityMainHelper.getListId(intent))
);
}
// fucking stack
while (getSupportFragmentManager().popBackStackImmediate()) {
// Need to pop the entire stack and then load
}
if (shouldAddToBackStack) {
transaction.addToBackStack(null);
}
setHomeAsDrawer(false);
}
/*
* Other case, is a list id or a tablet
*/
if (!ActivityMainHelper.isNoteIntent(intent) || fragment2 != null) {
// If we're no longer in the editor, reset the action bar
if (fragment2 == null) {
setHomeAsDrawer(true);
}
// TODO
isShowingEditor = false;
left = TaskListViewPagerFragment.getInstance(
ActivityMainHelper.getListIdToShow(intent, this));
leftTag = LISTPAGERTAG;
listOpener = (ListOpener) left;
}
if (fragment2 != null && right != null) {
transaction.replace(R.id.fragment2, right, DETAILTAG);
taskHint.setVisibility(View.GONE);
}
transaction.replace(R.id.fragment1, left, leftTag);
// Commit transaction. Allow state loss as workaround for bug
// https://code.google.com/p/android/issues/detail?id=19917
transaction.commitAllowingStateLoss();
// Next go, always add
shouldAddToBackStack = true;
}
void setHomeAsDrawer(final boolean value) {
mDrawerToggle.setDrawerIndicatorEnabled(value);
}
/**
* Loads the appropriate fragments depending on state and intent.
*/
@AfterViews
protected void loadContent() {
loadLeftDrawer();
loadFragments();
}
/**
* Load a list of lists in the left drawer
*/
protected void loadLeftDrawer() {
// TODO very long function. you should move everything related to drawer
// into static methods in ActivityMainHelper.java
// TODO handle being called repeatably better?
// Set a listener on drawer events
if (mDrawerToggle == null) {
// ActionBarDrawerToggle ties together the the proper interactions
// between the navigation drawer and the action bar app icon.
mDrawerToggle = new ActionBarDrawerToggle(this, drawerLayout,
R.string.navigation_drawer_open, R.string.navigation_drawer_close) {
/** custom implementation of
* {@link ActionBarDrawerToggle#onOptionsItemSelected(MenuItem)} from AndroidX
* where we can disable sliding animations
*/
@Override
public boolean onOptionsItemSelected(MenuItem item) {
if (item == null) return false;
if (item.getItemId() != android.R.id.home) return false;
if (!this.isDrawerIndicatorEnabled()) return false;
int drawerLockMode = drawerLayout.getDrawerLockMode(GravityCompat.START);
if (drawerLayout.isDrawerVisible(GravityCompat.START)
&& (drawerLockMode != DrawerLayout.LOCK_MODE_LOCKED_OPEN)) {
// drawer menu is open --> close it
drawerLayout.closeDrawer(GravityCompat.START, mShouldAnimate);
// mandatory callback, to update menu items
onDrawerClosed(leftDrawer);
} else if (drawerLockMode != DrawerLayout.LOCK_MODE_LOCKED_CLOSED) {
// drawer menu is closed --> open it
drawerLayout.openDrawer(GravityCompat.START, mShouldAnimate);
// mandatory callback, to update menu items
onDrawerOpened(leftDrawer);
}
return true;
}
/**
* Called when a drawer has settled in a completely closed state.
*/
@Override
public void onDrawerClosed(View view) {
if (getSupportActionBar() != null) {
// hide 'Notes' (R.string.app_name_short) from the toolbar
getSupportActionBar().setDisplayShowTitleEnabled(false);
}
isDrawerClosed = true;
// creates call to onPrepareOptionsMenu()
invalidateOptionsMenu();
super.onDrawerClosed(view);
}
@Override
public void onDrawerOpened(View drawerView) {
if (getSupportActionBar() != null) {
// show title "all lists" when drawer is opened
getSupportActionBar().setDisplayShowTitleEnabled(true);
getSupportActionBar().setTitle(R.string.show_from_all_lists);
}
// controls which actionbar items are presented to the user
isDrawerClosed = false;
// creates call to onPrepareOptionsMenu()
invalidateOptionsMenu();
super.onDrawerOpened(drawerView);
}
};
// this controls only the arrow that rotates on the corner
mDrawerToggle.setDrawerSlideAnimationEnabled(mShouldAnimate);
// Set the drawer toggle as the DrawerListener
drawerLayout.setDrawerListener(mDrawerToggle);
}
ActionBar supActBar = getSupportActionBar();
if (supActBar == null) {
NnnLogger.error(ActivityMain.class,
"Coding error: actionbar is null. A crash will follow");
} else {
supActBar.setDisplayHomeAsUpEnabled(true);
supActBar.setHomeButtonEnabled(true);
// hide 'Notes' (R.string.app_name_short) from the toolbar
supActBar.setDisplayShowTitleEnabled(false);
}
// Use extra items. From top to bottom, they are "TASKS", "Overdue", "Today",
// "Next 5 days", "Lists". Note that 2 of those are used as section titles & dividers
final int[] extraIds = new int[] { -1, TaskListFragment.LIST_ID_OVERDUE,
TaskListFragment.LIST_ID_TODAY, TaskListFragment.LIST_ID_WEEK, -1 };
// The corresponding names. This is fine for initial conditions
final int[] extraStrings = new int[] { R.string.tasks,
R.string.date_header_overdue,
R.string.date_header_today,
R.string.next_5_days,
R.string.lists };
// Use this for real data
final ArrayList> extraData = new ArrayList<>();
// Task header
extraData.add(new ArrayList<>());
extraData.get(0).add(R.string.tasks);
// Overdue
extraData.add(new ArrayList<>());
extraData.get(1).add(R.string.date_header_overdue);
// Today
extraData.add(new ArrayList<>());
extraData.get(2).add(R.string.date_header_today);
// Week
extraData.add(new ArrayList<>());
extraData.get(3).add(R.string.next_5_days);
// Lists header
extraData.add(new ArrayList<>());
extraData.get(4).add(R.string.lists);
final int[] extraTypes = new int[] { 1, 0, 0, 0, 1 };
final ExtraTypesCursorAdapter adapter = new ExtraTypesCursorAdapter(
this,
R.layout.simple_listitem,
null,
new String[] { TaskList.Columns.TITLE, TaskList.Columns.VIEW_COUNT },
new int[] { android.R.id.text1, android.R.id.text2 },
extraIds, // id -1 for headers, ignore clicks on them
extraStrings,
extraTypes,
new int[] { R.layout.drawer_header }
);
adapter.setExtraData(extraData);
leftDrawer.setAdapter(adapter);
// Set click handler (go to list)
leftDrawer.setOnItemClickListener((arg0, v, pos, id) -> {
if (id < -1) {
// Set preference which type was chosen
PreferenceManager
.getDefaultSharedPreferences(ActivityMain.this)
.edit()
.putLong(TaskListFragment.LIST_ALL_ID_PREF_KEY, id)
.commit();
}
openList(id);
});
// set long-click handler (open popup)
leftDrawer.setOnItemLongClickListener((arg0, arg1, pos, id) -> {
// Open dialog to edit list
if (id > 0) {
DialogEditList dialog = DialogEditList.getInstance(id);
dialog.show(getSupportFragmentManager(), "fragment_edit_list");
return true;
} else if (id < -1) {
// Set as "default"
PreferenceManager
.getDefaultSharedPreferences(ActivityMain.this)
.edit()
.putLong(getString(R.string.pref_defaultstartlist), id)
.putLong(TaskListFragment.LIST_ALL_ID_PREF_KEY, id)
.commit();
Toast.makeText(ActivityMain.this, R.string.new_default_set,
Toast.LENGTH_SHORT).show();
// openList(id);
return true;
} else {
return false;
}
});
// Load count of tasks in each list, to show the number next to the list's name
// Define the callback handler
final LoaderCallbacks callbacks =
new DrawerCursorLoader(this, extraData, adapter);
// Load actual data
LoaderManager
.getInstance(this)
.restartLoader(0, null, callbacks);
// special views
LoaderManager
.getInstance(this)
.restartLoader(TaskListFragment.LIST_ID_OVERDUE, null, callbacks);
LoaderManager
.getInstance(this)
.restartLoader(TaskListFragment.LIST_ID_TODAY, null, callbacks);
LoaderManager
.getInstance(this)
.restartLoader(TaskListFragment.LIST_ID_WEEK, null, callbacks);
}
@Override
public void onFragmentInteraction(final Uri taskUri, final long listId, final View origin) {
final Intent intent = new Intent()
.setAction(Intent.ACTION_EDIT)
.setClass(this, ActivityMain_.class)
.setData(taskUri)
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
.putExtra(TaskDetailFragment.ARG_ITEM_LIST_ID, listId);
// User clicked a task in the list
if (fragment2 != null) {
// tablet
// Set the intent here also so rotations open the same item
setIntent(intent);
var tmp1 = getSupportFragmentManager().beginTransaction();
if (mShouldAnimate) {
tmp1.setCustomAnimations(R.anim.slide_in_top, R.anim.slide_out_bottom);
}
tmp1.replace(R.id.fragment2, TaskDetailFragment_.getInstance(taskUri))
.commitAllowingStateLoss();
taskHint.setVisibility(View.GONE);
} else {
// phone
startActivity(intent);
}
}
@Override
public void addTaskInList(final String text, final long listId) {
if (listId < 1) {
// Cant add to invalid lists
// Snackbar.make(mFab, "Please create a list first", Snackbar.LENGTH_LONG).show();
return;
}
final Intent intent = new Intent()
.setAction(Intent.ACTION_INSERT)
.setClass(this, ActivityMain_.class)
.setData(Task.URI)
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
.putExtra(TaskDetailFragment.ARG_ITEM_LIST_ID, listId);
if (fragment2 != null) {
// Set intent to preserve state when rotating
setIntent(intent);
// Replace editor fragment
var tmp1 = getSupportFragmentManager().beginTransaction();
if (mShouldAnimate) {
tmp1.setCustomAnimations(R.anim.slide_in_top, R.anim.slide_out_bottom);
}
tmp1.replace(R.id.fragment2, TaskDetailFragment_.getInstance(text, listId), DETAILTAG)
.commitAllowingStateLoss();
taskHint.setVisibility(View.GONE);
} else {
// Open an activity
startActivity(intent);
}
}
@Override
public void closeFragment(final Fragment fragment) {
if (fragment2 != null) {
var tmp1 = getSupportFragmentManager().beginTransaction();
if (mShouldAnimate) {
tmp1.setCustomAnimations(R.anim.slide_in_top, R.anim.slide_out_bottom);
}
tmp1.remove(fragment).commitAllowingStateLoss();
taskHint.setAlpha(0f);
taskHint.setVisibility(View.VISIBLE);
taskHint.animate()
.alpha(1f)
.setStartDelay(500);
} else {
// Phone case, simulate back button
simulateBack();
}
}
private void simulateBack() {
if (getSupportFragmentManager().getBackStackEntryCount() <= 1) {
setIntent(new Intent(this, ActivityMain_.class));
}
if (!getSupportFragmentManager().popBackStackImmediate()) {
finish();
}
}
@Override
public boolean childItemsVisible() {
return isDrawerClosed;
}
@Override
public void onSharedPreferenceChanged(final SharedPreferences prefs, final String key) {
if (key == null) {
// it happens sometimes during Espresso tests
return;
}
if (key.equals(AppearancePrefs.KEY_THEME) || key.equals(getString(R.string.pref_locale))) {
shouldRestart = true;
} else if (key.startsWith("pref_restart")) {
shouldRestart = true;
}
}
// holds all the swipe-to-refresh layouts of the various TaskListFragments
private final ArrayList swpRefLayouts = new ArrayList<>();
/**
* every {@link TaskListFragment} has its own instance of a {@link SwipeRefreshLayout},
* so here they're all added to a private list. Then, the {@link ActivityMain} will update
* them all when necessary
*/
public void addSwipeRefreshLayoutToList(SwipeRefreshLayout newSwpRefLayout) {
// TODO do this Only if some sync is enabled
// Show the accent color on the arrow while loading
newSwpRefLayout.setColorSchemeResources(R.color.accent);
// TODO the swipe-to-refresh layouts have been disabled because they make it impossible
// to manually drag down the 1° note. When you find a solution for this, delete this line:
newSwpRefLayout.setEnabled(false);
// Sets up a Listener that is invoked when the user performs a swipe-to-refresh gesture.
newSwpRefLayout.setOnRefreshListener(
() -> {
Log.i("NNN", "onRefresh called from SwipeRefreshLayout");
// This method performs the actual data-refresh operation.
// The method must call setRefreshing(false) when it's finished.
handleSyncRequest();
}
);
swpRefLayouts.add(newSwpRefLayout);
}
/**
* sets the refreshing status of all {@link SwipeRefreshLayout} in this activity
*
* @param newState FALSE if they should stop the animation, TRUE if they should show it
*/
private void setRefreshOfAllSwipeLayoutsTo(boolean newState) {
for (SwipeRefreshLayout layout : swpRefLayouts) {
layout.setRefreshing(newState);
}
}
@Override
public void onSyncStartStop(final boolean isOngoing) {
// Notify PullToRefreshAttacher of the refresh state
this.runOnUiThread(() -> setRefreshOfAllSwipeLayoutsTo(isOngoing));
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/activities/main/ActivityMainHelper.java
================================================
package com.nononsenseapps.notepad.activities.main;
import android.content.Context;
import android.content.Intent;
import androidx.annotation.NonNull;
import com.nononsenseapps.helpers.ListHelper;
import com.nononsenseapps.notepad.database.LegacyDBHelper;
import com.nononsenseapps.notepad.database.Task;
import com.nononsenseapps.notepad.database.TaskList;
import com.nononsenseapps.notepad.fragments.TaskDetailFragment;
/**
* static methods that take some code away from {@link ActivityMain}. It's used only in that
* class, that's why it's package-private
*/
class ActivityMainHelper {
/**
* @param intent from code in {@link ActivityMain}
* @return a list id from an intent if it contains one, either as part of
* its URI or as an extra. Returns -1 if no id was contained, this includes insert actions
*/
static long getListId(final Intent intent) {
long retval = -1;
if (intent != null && intent.getData() != null &&
(Intent.ACTION_EDIT.equals(intent.getAction()) ||
Intent.ACTION_VIEW.equals(intent.getAction()) ||
Intent.ACTION_INSERT.equals(intent.getAction()))) {
String path = intent.getData().getPath();
if ((path.startsWith(LegacyDBHelper.NotePad.Lists.PATH_VISIBLE_LISTS) ||
path.startsWith(LegacyDBHelper.NotePad.Lists.PATH_LISTS) ||
path.startsWith(TaskList.URI.getPath()))) {
try {
retval = Long.parseLong(intent.getData().getLastPathSegment());
} catch (NumberFormatException ignored) {
// retval remains = -1
}
} else if (-1 != intent.getLongExtra(LegacyDBHelper.NotePad.Notes.COLUMN_NAME_LIST, -1)) {
retval = intent.getLongExtra(LegacyDBHelper.NotePad.Notes.COLUMN_NAME_LIST, -1);
} else if (-1 != intent.getLongExtra(TaskDetailFragment.ARG_ITEM_LIST_ID, -1)) {
retval = intent.getLongExtra(TaskDetailFragment.ARG_ITEM_LIST_ID, -1);
} else if (-1 != intent.getLongExtra(Task.Columns.DBLIST, -1)) {
retval = intent.getLongExtra(Task.Columns.DBLIST, -1);
}
}
return retval;
}
/**
* Returns the text that has been shared with the app. Does not check
* anything other than EXTRA_SUBJECT AND EXTRA_TEXT
*
* If it is a Google Now intent, will ignore the subject which is
* "Note to self"
*/
static String getNoteShareText(final Intent intent) {
if (intent == null || intent.getExtras() == null) {
return "";
}
StringBuilder retval = new StringBuilder();
// possible title
if (intent.getExtras().containsKey(Intent.EXTRA_SUBJECT) &&
!"com.google.android.gm.action.AUTO_SEND".equals(intent.getAction())) {
retval.append(intent.getExtras().get(Intent.EXTRA_SUBJECT));
}
// possible note
if (intent.getExtras().containsKey(Intent.EXTRA_TEXT)) {
if (retval.length() > 0) {
retval.append("\n");
}
retval.append(intent.getExtras().get(Intent.EXTRA_TEXT));
}
return retval.toString();
}
/**
* Returns a note id from an intent if it contains one, either as part of
* its URI or as an extra
*
* Returns -1 if no id was contained, this includes insert actions
*/
static long getNoteId(@NonNull final Intent intent) {
long retval = -1;
if (intent.getData() != null &&
(Intent.ACTION_EDIT.equals(intent.getAction()) ||
Intent.ACTION_VIEW.equals(intent.getAction()))) {
if (intent.getData().getPath().startsWith(TaskList.URI.getPath())) {
// Find it in the extras. See DashClock extension for an example
retval = intent.getLongExtra(Task.TABLE_NAME, -1);
} else if ((intent.getData().getPath().startsWith(
LegacyDBHelper.NotePad.Notes.PATH_VISIBLE_NOTES) ||
intent.getData().getPath().startsWith(
LegacyDBHelper.NotePad.Notes.PATH_NOTES) ||
intent.getData().getPath()
.startsWith(Task.URI.getPath()))) {
retval = Long.parseLong(intent.getData().getLastPathSegment());
}
}
return retval;
}
/**
* Returns true the intent URI targets a note. Either an edit/view or
* insert.
*/
static boolean isNoteIntent(final Intent intent) {
if (intent == null) {
return false;
}
if (Intent.ACTION_SEND.equals(intent.getAction()) ||
"com.google.android.gm.action.AUTO_SEND"
.equals(intent.getAction())) {
return true;
}
return intent.getData() != null &&
(Intent.ACTION_EDIT.equals(intent.getAction()) ||
Intent.ACTION_VIEW.equals(intent.getAction()) ||
Intent.ACTION_INSERT.equals(intent.getAction())) &&
(intent.getData().getPath().startsWith(LegacyDBHelper.NotePad.Notes.PATH_VISIBLE_NOTES) ||
intent.getData().getPath().startsWith(LegacyDBHelper.NotePad.Notes.PATH_NOTES) ||
intent.getData().getPath().startsWith(Task.URI.getPath())) &&
!intent.getData().getPath().startsWith(TaskList.URI.getPath());
}
/**
* If intent contains a list_id, returns that.
* Else, checks preferences for default list setting.
* Else, -1.
*/
static long getListIdToShow(final Intent intent, Context context) {
long result = ActivityMainHelper.getListId(intent);
return ListHelper.getAShowList(context, result);
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/activities/main/DrawerCursorLoader.java
================================================
package com.nononsenseapps.notepad.activities.main;
import android.content.Context;
import android.database.Cursor;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.loader.app.LoaderManager;
import androidx.loader.content.CursorLoader;
import androidx.loader.content.Loader;
import com.nononsenseapps.notepad.R;
import com.nononsenseapps.notepad.database.Task;
import com.nononsenseapps.notepad.database.TaskList;
import com.nononsenseapps.notepad.fragments.TaskListFragment;
import com.nononsenseapps.ui.ExtraTypesCursorAdapter;
import java.util.ArrayList;
/**
* Used only in {@link ActivityMain#loadLeftDrawer()}, so it's a package-private class
*/
class DrawerCursorLoader implements LoaderManager.LoaderCallbacks {
final String[] COUNTROWS = new String[] { "COUNT(1)" };
final String NOTCOMPLETED = Task.Columns.COMPLETED + " IS NULL ";
/**
* the instance of {@link ActivityMain} that hosts this loader object
*/
Context mContext;
/**
* this and {@link #mAdapter} are references to the variables in
* {@link ActivityMain#loadLeftDrawer()}, so it's exactly as if this code
* was copypasted in that function
*/
ArrayList> mExtraData;
ExtraTypesCursorAdapter mAdapter;
public DrawerCursorLoader(ActivityMain drawerHost, ArrayList> extraData,
ExtraTypesCursorAdapter adapter) {
mContext = drawerHost;
mExtraData = extraData;
mAdapter = adapter;
}
@NonNull
@Override
public Loader onCreateLoader(int id, Bundle arg1) {
// Normal lists
return switch (id) {
case TaskListFragment.LIST_ID_OVERDUE -> new CursorLoader(mContext, Task.URI, COUNTROWS,
NOTCOMPLETED + TaskListFragment.andWhereOverdue(),
null, null);
case TaskListFragment.LIST_ID_TODAY -> new CursorLoader(mContext, Task.URI, COUNTROWS,
NOTCOMPLETED + TaskListFragment.andWhereToday(),
null, null);
case TaskListFragment.LIST_ID_WEEK -> new CursorLoader(mContext, Task.URI, COUNTROWS,
NOTCOMPLETED + TaskListFragment.andWhereWeek(),
null, null);
default -> new CursorLoader(mContext,
TaskList.URI_WITH_COUNT,
new String[] { TaskList.Columns._ID, TaskList.Columns.TITLE,
TaskList.Columns.VIEW_COUNT },
null, null,
mContext.getResources()
.getString(R.string.const_as_alphabetic, TaskList.Columns.TITLE));
};
}
@Override
public void onLoadFinished(Loader l, Cursor c) {
switch (l.getId()) {
case TaskListFragment.LIST_ID_OVERDUE -> {
if (c.moveToFirst()) {
updateExtra(1, c.getInt(0));
}
}
case TaskListFragment.LIST_ID_TODAY -> {
if (c.moveToFirst()) {
updateExtra(2, c.getInt(0));
}
}
case TaskListFragment.LIST_ID_WEEK -> {
if (c.moveToFirst()) {
updateExtra(3, c.getInt(0));
}
}
default -> mAdapter.swapCursor(c);
}
}
private void updateExtra(final int pos, final int count) {
while (mExtraData.get(pos).size() < 2) {
// To avoid crashes
mExtraData.get(pos).add("0");
}
mExtraData.get(pos).set(1, Integer.toString(count));
mAdapter.notifyDataSetChanged();
}
@Override
public void onLoaderReset(Loader l) {
switch (l.getId()) {
case TaskListFragment.LIST_ID_OVERDUE:
case TaskListFragment.LIST_ID_TODAY:
case TaskListFragment.LIST_ID_WEEK:
break;
case 0:
default:
mAdapter.swapCursor(null);
}
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/android/provider/DummyProvider.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.android.provider;
import android.content.ContentProvider;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.net.Uri;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.nononsenseapps.notepad.BuildConfig;
import com.nononsenseapps.notepad.providercontract.ProviderContract;
import java.util.ArrayList;
import java.util.List;
public class DummyProvider extends ContentProvider {
// TODO change authority and add corresponding manifest entry
public static final String AUTHORITY = BuildConfig.APPLICATION_ID + ".DUMMYPROVIDER.AUTHORITY";
public static final String SCHEME = "content://";
private static final String TAG = "DummyProvider";
private static final String TYPE_NONONSENSENOTES_ITEM = "vnd.android.cursor.item/item";
private List mData;
public DummyProvider() {}
@Override
public boolean onCreate() {
// TODO change this to actual initialization code for your own data backend
mData = initDummyData();
return true;
}
/**
* @return initial dummy data
*/
private List initDummyData() {
ArrayList items = new ArrayList<>();
// The uri is simply the position in the array(s)
for (int i = 0; i < 3; i++) {
DummyItem top = new DummyItem("/" + i, "Top item " + i);
items.add(top);
for (int j = 0; j < 3; j++) {
DummyItem sub = new DummyItem(top.getPath() + "/" + j, "Sub item " + j);
top.children.add(sub);
for (int k = 0; k < 3; k++) {
DummyItem subsub = new DummyItem(
sub.getPath() + "/" + k,
"Subsub item " + k);
sub.children.add(subsub);
}
}
}
return items;
}
@Override
public String getType(@NonNull Uri uri) {
switch (ProviderHelper.matchUri(uri)) {
case ProviderHelper.URI_NOMATCH:
throw new IllegalArgumentException("Unknown path: " + uri.getPath());
default:
return TYPE_NONONSENSENOTES_ITEM;
}
}
@Override
public int delete(@NonNull Uri uri, String selection, String[] selectionArgs) {
// Implement this to handle requests to delete one or more rows.
throw new UnsupportedOperationException("Not yet implemented");
}
@Override
public Uri insert(@NonNull Uri uri, ContentValues values) {
String path;
switch (ProviderHelper.matchUri(uri)) {
case ProviderHelper.URI_LIST:
path = ProviderHelper.getRelativePath(uri);
final String parentPath;
final List list;
if ("/".equals(path)) {
parentPath = "/";
list = mData;
} else {
DummyItem parent = getNestedItem(path);
parentPath = parent.path;
list = parent.children;
}
DummyItem item = new DummyItem(ProviderHelper.join(parentPath, Integer.toString(list.size())),
values);
list.add(item);
notifyOnChange(uri);
return ProviderHelper.getDetailsUri(ProviderHelper.getBase(uri), item.path);
default:
throw new IllegalArgumentException("Can't perform insert at: " + uri);
}
}
@Override
public Cursor query(@NonNull Uri uri, String[] projection, String selection,
String[] selectionArgs, String sortOrder) {
Log.d(TAG, "Uri: " + uri);
String path;
MatrixCursor mc = new MatrixCursor(ProviderContract.sMainListProjection);
switch (ProviderHelper.matchUri(uri)) {
case ProviderHelper.URI_ROOT:
setNotificationUri(mc, ProviderHelper
.getListUri(ProviderHelper.getBase(uri), ""));
for (DummyItem item : mData) {
mc.addRow(item.asRow());
}
break;
case ProviderHelper.URI_LIST:
setNotificationUri(mc, uri);
path = ProviderHelper.getRelativePath(uri);
for (DummyItem item : getNestedList(path)) {
mc.addRow(item.asRow());
}
break;
case ProviderHelper.URI_DETAILS:
setNotificationUri(mc, uri);
path = ProviderHelper.getRelativePath(uri);
mc.addRow(getNestedItem(path).asRow());
break;
default:
throw new IllegalArgumentException("Unknown path: " + uri);
}
return mc;
}
/**
* Sets the notifcation uri on the cursor.
*/
protected void setNotificationUri(Cursor c, Uri uri) {
Context context = getContext();
if (context != null) {
c.setNotificationUri(context.getContentResolver(), uri);
}
}
/**
* Walk the tree, decomposing the path as we walk
*
* @param path like /1/2/3/4
* @return the list of children in item /1/2/3/4
*/
private List getNestedList(String path) {
List items = mData;
String first = ProviderHelper.firstPart(path);
path = ProviderHelper.restPart(path);
while (!first.isEmpty()) {
int index = Integer.parseInt(first);
items = items.get(index).children;
first = ProviderHelper.firstPart(path);
path = ProviderHelper.restPart(path);
}
return items;
}
/**
* Walk the tree, decomposing the path as we walk
*
* @param path like /1/2/3/4
* @return the item /1/2/3/4
*/
private DummyItem getNestedItem(String path) {
return getNestedItem(path, false);
}
/**
* Walk the tree, decomposing the path as we walk
*
* @param path like /1/2/3/4
* @param popItem true if item should be removed from its parent list also
* @return the item /1/2/3/4
*/
private DummyItem getNestedItem(String path, boolean popItem) {
List items = mData;
DummyItem item = null;
String first = ProviderHelper.firstPart(path);
path = ProviderHelper.restPart(path);
while (!first.isEmpty()) {
int index = Integer.parseInt(first);
item = items.get(index);
items = item.children;
first = ProviderHelper.firstPart(path);
path = ProviderHelper.restPart(path);
}
if (popItem) {
items.remove(item);
}
return item;
}
@Override
public int update(@NonNull Uri uri, ContentValues values, String selection,
String[] selectionArgs) {
String path;
switch (ProviderHelper.matchUri(uri)) {
case ProviderHelper.URI_DETAILS:
path = ProviderHelper.getRelativePath(uri);
// Any queries?
if (uri.getQuery().isEmpty()) {
// Update values
getNestedItem(path).update(values);
} else {
// Move query
String previous = uri.getQueryParameter(ProviderContract.QUERY_MOVE_PREVIOUS);
String parent = uri.getQueryParameter(ProviderContract.QUERY_MOVE_PARENT);
if (previous != null || parent != null) {
moveDummyItem(path, previous, parent);
}
}
notifyOnChange(uri);
break;
default:
throw new IllegalArgumentException("Can't perform insert at: " + uri);
}
// TODO: Implement this to handle requests to update one or more rows.
throw new UnsupportedOperationException("Not yet implemented");
}
/**
* @param path relativepath to item to move
* @param previous relativepath to sibling which should be placed before item
* @param parent relativepath to parent item
*/
private void moveDummyItem(String path, String previous, String parent) {
// Pop the item
DummyItem item = getNestedItem(path, true);
List parentList;
if (parent == null || parent.isEmpty()) {
parentList = mData;
} else {
parentList = getNestedList(parent);
}
int prevIndex = -1;
if (previous != null && !previous.isEmpty()) {
DummyItem prevItem = getNestedItem(previous);
prevIndex = parentList.indexOf(prevItem);
}
// Insert into parentList at correct position
parentList.add(prevIndex + 1, item);
}
/**
* Call this after the data changes
*
* @param uri to notify updates on
*/
protected void notifyOnChange(@NonNull Uri uri) {
Context context = getContext();
if (context != null) {
context.getContentResolver().notifyChange(uri, null);
}
}
/**
* Just some helpers item that represent the data backing this provider.
*/
private static class DummyItem {
public ArrayList children = new ArrayList<>();
protected String path;
protected long typemask = 0;
protected String title = "";
protected String description = null;
protected String status = null;
protected String due = null;
protected boolean deleted = false;
public DummyItem(@NonNull String path, @NonNull String title) {
this(path, title, ProviderContract.getTypeMask(ProviderContract.TYPE_DATA,
ProviderContract.TYPE_FOLDER));
}
public DummyItem(@NonNull String path, @NonNull String title, long bitmask) {
this.path = path;
this.title = title;
this.typemask = bitmask;
}
public DummyItem(@NonNull String path, @NonNull ContentValues values) {
this.path = path;
this.title = values.getAsString(ProviderContract.COLUMN_TITLE);
this.typemask = ProviderContract.getTypeMask(ProviderContract.TYPE_DATA,
ProviderContract.TYPE_FOLDER);
}
public Object[] asRow() {
// For insertion into matrixcursor
return new Object[] { path, typemask, title, description, status, due };
}
public String getTitle() {
return title;
}
public void setTitle(@NonNull String title) {
this.title = title;
}
public long getTypemask() {
return typemask;
}
public void setTypemask(long typemask) {
this.typemask = typemask;
}
public String getDescription() {
return description;
}
public void setDescription(@Nullable String description) {
this.description = description;
}
public String getStatus() {
return status;
}
public void setStatus(@Nullable String status) {
this.status = status;
}
public String getDue() {
return due;
}
public void setDue(@Nullable String due) {
this.due = due;
}
public boolean isDeleted() {
return deleted;
}
public void setDeleted(boolean deleted) {
this.deleted = deleted;
}
public String getPath() {
return path;
}
public void setPath(String path) {
this.path = path;
}
public void update(ContentValues values) {
this.title = values.getAsString(ProviderContract.COLUMN_TITLE);
}
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/android/provider/ProviderHelper.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.android.provider;
import android.net.Uri;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.Locale;
import java.util.Objects;
/**
* Helper functions related to provider operations.
*/
public final class ProviderHelper {
public static final int URI_NOMATCH = -1;
public static final int URI_ROOT = 101;
public static final int URI_LIST = 102;
public static final int URI_DETAILS = 103;
public static final String URI_LIST_PREFIX = "list";
public static final String URI_DETAILS_PREFIX = "details";
/**
* Returns a list uri given a base and relativePath
*
* @param base such as content://my.provider.authority
* *
* @param relativePath /foo/bar
* *
* @return the list uri: content://my.provider.authority/list/foo/bar
*/
public static Uri getListUri(@NonNull Uri base, @NonNull String relativePath) {
return Uri.withAppendedPath(Uri.withAppendedPath(base, "list"),
relativePath);
}
/**
* Returns a details uri given a base and relativePath
*
* @param base such as content://my.provider.authority
* *
* @param relativePath /foo/bar
* *
* @return the details uri: content://my.provider.authority/details/foo/bar
*/
@NonNull
public static Uri getDetailsUri(@NonNull Uri base, @NonNull String relativePath) {
return Uri.withAppendedPath(Uri.withAppendedPath(base, "details"),
relativePath);
}
/**
* Returns only the scheme and authority parts.
*
* @param uri like content://my.provider.authority/details/foo/bar
* *
* @return uri with only scheme and authority: content://my.provider.authority
*/
public static Uri getBase(@NonNull Uri uri) {
return Uri.parse(uri.getScheme() + "://" + uri.getAuthority());
}
/**
* Note that /ACTION will return "/".
*
* @param uri like content://my.provider.authority/ACTION/foo/bar
* *
* @return relative path without action part like /foo/bar
*/
@NonNull
public static String getRelativePath(@NonNull Uri uri) {
return getRelativePath(uri.getPath());
}
@NonNull
public static String getRelativePath(@NonNull String path) {
var i = path.indexOf("/");
if (i == 0) {
return getRelativePath(path.substring(1));
} else if (i < 0) {
return "/";
} else {
return path.substring(i);
}
}
/**
* @param path like /foo/bar
* *
* @return first part of path like foo
*/
@NonNull
public static String firstPart(@NonNull String path) {
var i = path.indexOf("/");
if (i == 0) {
return firstPart(path.substring(1));
} else if (i > 0) {
return path.substring(0, i);
} else {
// No slashes
return path;
}
}
/**
* If nothing remains, returns the empty string.
*
* @param path like /foo/bar/baz
* *
* @return the bit after first like bar/baz (without starting slash)
*/
@NonNull
public static String restPart(@NonNull String path) {
var i = path.indexOf("/");
if (i == 0) {
return restPart(path.substring(1));
} else if (i > 0) {
return path.substring(i + 1);
} else {
return "";
}
}
public static int matchUri(@NonNull Uri uri) {
return matchPath(uri.getPath());
}
/**
* Since UriMatcher isn't as good as it should be, this implements the matching I want.
*
* @param path like /foo/bar/baz
* *
* @return type of the path
*/
public static int matchPath(@Nullable String path) {
while (true) {
if (path != null && ((CharSequence) path).length() != 0) {
String var10000;
if (path.startsWith("/")) {
var10000 = path.substring(1);
path = var10000;
continue;
}
var10000 = firstPart(path).toLowerCase(Locale.ROOT);
String fp = var10000;
if (Objects.equals(fp, "list")) {
return 102;
}
if (Objects.equals(fp, "details")) {
if (((CharSequence) restPart(path)).length() == 0) {
return -1;
}
return 103;
}
return -1;
}
return 101;
}
}
/**
* Join two pieces together, separated by a /
*
* @param path1 like /foo
* *
* @param path2 like bar
* *
* @return /foo/bar
*/
@NonNull
public static String join(@NonNull String path1, @NonNull String path2) {
if (path1.endsWith("/")) {
if (path2.startsWith("/")) {
return path1 + path2.substring(1);
} else {
return path1 + path2;
}
} else {
if (path2.startsWith("/")) {
return path1 + path2;
} else {
return path1 + "/" + path2;
}
}
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/android/provider/ProviderManager.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.android.provider;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.pm.ProviderInfo;
import android.net.Uri;
import android.os.Bundle;
import androidx.annotation.NonNull;
import java.util.ArrayList;
import java.util.List;
/**
* This class handles things related to (possibly 3rd party) providers.
*/
public final class ProviderManager {
public final String METADATA_PROTOCOL_VERSION = "protocolVersion";
public final String METADATA_REQUIRES_CONFIG = "requiresConfig";
public final String METADATA_SETTINGS_ACTIVITY = "settingsActivity";
private final Context applicationContext;
public ProviderManager(@NonNull Context context) {
this.applicationContext = context.getApplicationContext();
}
/**
* @return a list of providers which are available for use/setup.
*/
@NonNull
public List getAvailableProviders() {
ArrayList availableUris = new ArrayList<>();
PackageManager pm = this.applicationContext.getPackageManager();
var var10000 = pm
.queryIntentContentProviders(
new Intent(com.nononsenseapps.notepad.providercontract.ProviderContract.ACTION_PROVIDER),
PackageManager.GET_META_DATA);
for (var resolveInfo : var10000) {
var metadata = resolveInfo.providerInfo.metaData;
if (providerHasValidMetadata(metadata)) {
availableUris.add(new Provider(pm, resolveInfo.providerInfo));
}
}
return availableUris;
}
/**
* @return a list of providers which are available for use. Note that a provider might
* appear more than once here, if it's been configured with different settings
* (different folders/user accounts, etc).
*/
// First get all providers which do not require configuration
// Instead of wrapping code in multiple ifs
// TODO include providers which have been setup by user
@NonNull
public ArrayList getConfiguredProviders() {
var availableUris = new ArrayList();
var pm = applicationContext.getPackageManager();
var resolveInfos = pm.queryIntentContentProviders(
new Intent(com.nononsenseapps.notepad.providercontract.ProviderContract.ACTION_PROVIDER),
PackageManager.GET_META_DATA);
for (var resolveInfo : resolveInfos) {
var metadata = resolveInfo.providerInfo.metaData;
if (providerHasValidMetadata(metadata) && !providerRequiresConfig(metadata)) {
availableUris.add(new Provider(pm, resolveInfo.providerInfo));
}
}
return availableUris;
}
/**
* Checks that a provider specifies correct metadata.
*
* @param metadata for provider
* *
* @return true or false
*/
public boolean providerHasValidMetadata(@NonNull Bundle metadata) {
// Only one protocol level atm
var result = 1 == metadata.getInt(METADATA_PROTOCOL_VERSION, -1);
// If config is required, then a settingsactivity must be specified
if (result && metadata.getBoolean(METADATA_REQUIRES_CONFIG, false)) {
result = metadata.containsKey(METADATA_SETTINGS_ACTIVITY);
}
return result;
}
/**
* @param metadata for a given provider
* *
* @return true if provider is valid and specifies no required config
*/
public boolean providerRequiresConfig(@NonNull Bundle metadata) {
return metadata.getBoolean(METADATA_REQUIRES_CONFIG, false);
}
public static final class Provider {
@NonNull
private final String authority;
@NonNull
private final Uri uriBase;
@NonNull
private final Uri uriList;
@NonNull
private final Uri uriDetails;
@NonNull
private final String label;
private final int icon;
public Provider(@NonNull PackageManager pm, @NonNull ProviderInfo providerInfo) {
this.label = providerInfo.loadLabel(pm).toString();
this.authority = providerInfo.authority;
this.uriBase = Uri.parse("content://" + this.authority);
this.uriList = Uri.withAppendedPath(this.uriBase, "/list");
this.uriDetails = Uri.withAppendedPath(this.uriBase, "/details");
this.icon = providerInfo.getIconResource();
if (null != providerInfo.metaData) {
// Optional stuff like settingsActivity and capabilities
// String settingsActivity = providerInfo.metaData.getString("settingsActivity");
}
}
@NonNull
public String getAuthority() {
return this.authority;
}
@NonNull
public Uri getUriBase() {
return this.uriBase;
}
@NonNull
public Uri getUriList() {
return this.uriList;
}
@NonNull
public Uri getUriDetails() {
return this.uriDetails;
}
@NonNull
public String getLabel() {
return this.label;
}
public int getIcon() {
return this.icon;
}
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/android/provider/TextFileProvider.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.android.provider;
import android.content.ContentProvider;
import android.content.ContentValues;
import android.content.UriMatcher;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.net.Uri;
import android.os.Environment;
import android.util.Log;
import androidx.annotation.NonNull;
import com.nononsenseapps.notepad.BuildConfig;
import com.nononsenseapps.notepad.providercontract.ProviderContract;
import java.io.File;
import java.io.FileFilter;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
public class TextFileProvider extends ContentProvider {
// TODO is this whole com.nononsenseapps.notepad.android.provider namespace useless ?
/**
* Corresponds to android:authorities="${applicationId}.TESTPROVIDER.AUTHORITY"
* in AndroidManifest.xml
*/
public static final String AUTHORITY = BuildConfig.APPLICATION_ID + ".TESTPROVIDER.AUTHORITY";
private static final String TAG = "TextFileProvider";
// This urimatcher converts incoming URIs to corresponding uricodes
private static final UriMatcher sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
private static final int URI_ROOT = 101;
private static final int URI_LIST = 102;
private static final int URI_DETAILS = 103;
private static final String TYPE_NONONSENSENOTES_ITEM = "vnd.android.cursor.item/vnd.nononsensenotes.item";
// Add uris to match (initial slash supported from JELLY_BEAN_MR2)
static {
// No item is specified, corresponds to listing all top-level items
sUriMatcher.addURI(AUTHORITY, "/list", URI_ROOT);
// List all items which are children of the URI (but not the URI-item itself)
sUriMatcher.addURI(AUTHORITY, "/list/*", URI_LIST);
// Return the single item at the specified URI
sUriMatcher.addURI(AUTHORITY, "/details/*", URI_DETAILS);
}
private String mRootPath;
private FileFilter mFileFilter;
public TextFileProvider() {
}
@Override
public int delete(@NonNull Uri uri, String selection, String[] selectionArgs) {
// Implement this to handle requests to delete one or more rows.
throw new UnsupportedOperationException("Not yet implemented");
}
@Override
public String getType(@NonNull Uri uri) {
// TODO: Implement this to handle requests for the MIME type of the data
// at the given URI.
throw new UnsupportedOperationException("Not yet implemented");
}
@Override
public Uri insert(@NonNull Uri uri, ContentValues values) {
// TODO: Implement this to handle requests to insert a new row.
throw new UnsupportedOperationException("Not yet implemented");
}
@Override
public boolean onCreate() {
mRootPath = Environment.getExternalStorageDirectory().getPath();
mFileFilter = new FileFilter() {
/**
* Indicating whether a specific file should be included in a pathname list.
*
* @param pathname the abstract file to check.
* @return {@code true} if the file should be included, {@code false}
* otherwise.
*/
@Override
public boolean accept(File pathname) {
if (pathname.isDirectory()) {
return true;
} else {
return true;
//return pathname.getName().toLowerCase().endsWith(".txt");
}
}
};
return true;
}
@Override
public Cursor query(Uri uri, String[] projection, String selection,
String[] selectionArgs, String sortOrder) {
Log.d(TAG, "Uri: " + uri.getAuthority() + ", " + uri.getPath() + ", " + uri.getQuery());
String relativePath = switch (sUriMatcher.match(uri)) {
case URI_ROOT -> "/";
case URI_LIST -> ProviderHelper.getRelativePath(uri);
default -> throw new IllegalArgumentException("Unknown path: " + uri);
};
final File filePath = new File(ProviderHelper.join(mRootPath, relativePath));
File[] files = filePath.listFiles(mFileFilter);
Log.d(TAG, "Listing: " + filePath.getPath() + ", files: " + (files == null ? 0 : files.length));
if (files == null) {
return null;
}
// Sort by name and path
List fileList = Arrays.asList(files);
Collections.sort(fileList);
// Projection is ProviderContract.sMainListProjection
MatrixCursor mc = new MatrixCursor(projection, fileList.size());
for (File file : fileList) {
mc.addRow(new Object[] { ProviderHelper.join(relativePath, file.getName()),
ProviderContract.getTypeMask(file.isDirectory() ? ProviderContract.TYPE_FOLDER : ProviderContract.TYPE_DATA,
ProviderContract.TYPE_DESCRIPTION),
file.getName(), null, null, null });
}
return mc;
}
@Override
public int update(@NonNull Uri uri, ContentValues values, String selection,
String[] selectionArgs) {
// TODO: Implement this to handle requests to update one or more rows.
throw new UnsupportedOperationException("Not yet implemented");
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/dashclock/DashclockPrefActivity.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.dashclock;
import android.os.Bundle;
import android.view.MenuItem;
import android.view.Window;
import androidx.appcompat.app.AppCompatActivity;
import com.nononsenseapps.notepad.R;
/**
* holds the preferences for dashclock integration. See {@link DashclockPrefsFragment}
*/
public class DashclockPrefActivity extends AppCompatActivity {
public void onCreate(Bundle savedInstanceState) {
supportRequestWindowFeature(Window.FEATURE_ACTION_BAR);
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_dashclock_settings);
if (getSupportActionBar() != null) {
getSupportActionBar().setIcon(R.drawable.ic_stat_notification_edit);
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
}
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
if (item.getItemId() == android.R.id.home) {
finish();
return true;
}
return super.onOptionsItemSelected(item);
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/dashclock/DashclockPrefsFragment.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.dashclock;
import android.content.Context;
import android.database.Cursor;
import android.os.Bundle;
import androidx.annotation.Nullable;
import androidx.preference.ListPreference;
import androidx.preference.Preference;
import androidx.preference.PreferenceFragmentCompat;
import androidx.preference.PreferenceManager;
import com.nononsenseapps.notepad.R;
import com.nononsenseapps.notepad.database.TaskList;
import java.util.ArrayList;
/**
* This app can be used with dashclock. This fragment shows the settings to configure
* the dashclock plug-in
*/
public class DashclockPrefsFragment extends PreferenceFragmentCompat {
/**
* A preference value change listener that updates the preference's summary
* to reflect its new value.
*/
private static final Preference.OnPreferenceChangeListener
sBindPreferenceSummaryToValueListener = (preference, value) -> {
String stringValue = value.toString();
if (preference instanceof ListPreference listPreference) {
// For list preferences, look up the correct display value in
// the preference's 'entries' list.
int index =
listPreference.findIndexOfValue(stringValue);
// Set the summary to reflect the new value.
preference.setSummary(index >= 0 ?
listPreference
.getEntries()[index] :
null);
} else {
// For all other preferences, set the summary to the value's
// simple string representation.
preference.setSummary(stringValue);
}
return true;
};
@Override
public void onCreatePreferences(@Nullable Bundle savInstState, String rootKey) {
addPreferencesFromResource(R.xml.dashclock_pref_general);
// Bind the summaries of EditText/List/Dialog/Ringtone preferences
// to their values. When their values change, their summaries are
// updated to reflect the new value, per the Android Design
// guidelines.
bindPreferenceSummaryToValue(findPreference("list_spinner"));
setEntries(getActivity(), findPreference("list_spinner"));
bindPreferenceSummaryToValue(findPreference("list_due_upper_limit"));
}
/**
* Binds a preference's summary to its value. More specifically, when the
* preference's value is changed, its summary (line of text below the
* preference title) is updated to reflect the value. The summary is also
* immediately updated upon calling this method. The exact display format
* is
* dependent on the type of preference.
*
* @see #sBindPreferenceSummaryToValueListener
*/
private static void bindPreferenceSummaryToValue(Preference preference) {
// Set the listener to watch for value changes.
preference.setOnPreferenceChangeListener(sBindPreferenceSummaryToValueListener);
// Trigger the listener immediately with the preference's
// current value.
sBindPreferenceSummaryToValueListener.onPreferenceChange(preference,
PreferenceManager
.getDefaultSharedPreferences(preference.getContext())
.getString(preference.getKey(), "")
);
}
/**
* Reads the lists from database. Also adds "All lists" as the first item.
*/
private static void setEntries(Context context, ListPreference listSpinner) {
ArrayList entries = new ArrayList<>();
ArrayList values = new ArrayList<>();
// Start with all lists
entries.add("All lists");
values.add("-1");
// Set it as the default value also
//listSpinner.setDefaultValue("-1");
Cursor cursor = context
.getContentResolver()
.query(TaskList.URI, TaskList.Columns.FIELDS, null, null,
TaskList.Columns.TITLE);
if (cursor != null) {
if (!cursor.isClosed() && !cursor.isAfterLast()) {
while (cursor.moveToNext()) {
entries.add(cursor.getString(1));
values.add(Long.toString(cursor.getLong(0)));
}
}
cursor.close();
}
// Set the values
if (listSpinner != null) {
listSpinner.setEntries(entries.toArray(new CharSequence[0]));
listSpinner.setEntryValues(values.toArray(new CharSequence[0]));
listSpinner.setSummary(listSpinner.getEntry());
}
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/dashclock/TasksExtension.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.dashclock;
import android.content.Intent;
import android.content.SharedPreferences;
import android.database.Cursor;
import androidx.preference.PreferenceManager;
import com.google.android.apps.dashclock.api.DashClockExtension;
import com.google.android.apps.dashclock.api.ExtensionData;
import com.nononsenseapps.notepad.R;
import com.nononsenseapps.notepad.database.Task;
import com.nononsenseapps.notepad.database.TaskList;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
import java.util.GregorianCalendar;
import java.util.stream.Stream;
public class TasksExtension extends DashClockExtension {
public static final String DUEDATE_SORT_TYPE = "CASE WHEN " +
Task.Columns.DUE + " IS NULL OR " +
Task.Columns.DUE + " IS '' THEN 1 ELSE 0 END, " +
Task.Columns.DUE;
private static final String WHERE_LIST_IS_AND = Task.Columns.DBLIST
+ " IS ? AND ";
private static final String WHERE_DATE_IS = Task.Columns.COMPLETED +
" IS NULL AND " +
Task.Columns.DUE + " IS NOT NULL AND " +
Task.Columns.DUE + " <= ? ";
private static final String WHERE_ALL_NOTDONE = Task.Columns.COMPLETED
+ " IS NULL";
private String[] toA(final String... args) {
return args;
}
private String[] appendTo(final String[] array, final String... items) {
return Stream.concat(Arrays.stream(array), Arrays.stream(items)).toArray(String[]::new);
}
final static String[] NOTEFIELDS = new String[] { "_id", "title", "note", "duedate" };
@Override
protected void onInitialize(boolean isReconnect) {
super.onInitialize(isReconnect);
// Watch the notes URI
addWatchContentUris(toA(TaskList.URI.toString(), Task.URI.toString()));
}
@Override
protected void onUpdateData(int reason) {
// Get preferences
final SharedPreferences prefs = PreferenceManager
.getDefaultSharedPreferences(this);
final long listId = Long.parseLong(prefs
.getString("list_spinner", "-1"));
final boolean showOverdue = prefs.getBoolean("show_overdue", true);
final String upperLimit = prefs.getString("list_due_upper_limit", getString(R.string.dashclock_pref_today));
final boolean showSingle = prefs.getBoolean("show_single_only", false);
final boolean showHeader = prefs.getBoolean("show_header", true);
final ArrayList notes = getNotesFromDB(listId, upperLimit);
// Show overdue?
if (!showOverdue) {
removeOverdue(notes);
}
if (showSingle && notes.size() > 1) {
final Task first = notes.get(0);
notes.clear();
notes.add(first);
}
if (notes.isEmpty()) {
publishUpdate(null);
} else {
final String short_header = getString(
R.string.dashclock_tasks_count, notes.size());
final String long_header;
// If no header is to be displayed, show title of first
if (showHeader) {
long_header = getHeader(listId);
} else {
long_header = notes.get(0).title;
}
final Intent noteIntent = new Intent();
if (notes.size() > 1) {
noteIntent
.setAction(Intent.ACTION_VIEW)
.setData(TaskList.getUri(notes.get(0).dblist))
.putExtra(Task.TABLE_NAME, notes.get(0)._id);
} else {
noteIntent
.setAction(Intent.ACTION_EDIT)
.setData(Task.getUri(notes.get(0)._id))
.putExtra(Task.Columns.DBLIST, notes.get(0).dblist.longValue());
}
// Publish the extension data update.
publishUpdate(new ExtensionData().visible(true)
.icon(R.drawable.ic_stat_notification_edit)
.status(short_header).expandedTitle(long_header)
.expandedBody(getBody(notes, showHeader))
.clickIntent(noteIntent));
}
}
@SuppressWarnings("unchecked")
private void removeOverdue(final ArrayList notes) {
for (Task note : (ArrayList) notes.clone()) {
if (note.due != null
&& note.due < Calendar.getInstance().getTimeInMillis())
notes.remove(note);
}
}
private String getBody(final ArrayList notes, final boolean showHeader) {
String result = "";
if (notes.size() == 1) {
if (showHeader) {
// Skip title if no header as the title is the header
result += notes.get(0).title;
result += "\n";
}
result += notes.get(0).note;
} else {
boolean first = true;
boolean skippable = true;
for (Task note : notes) {
if (!showHeader && skippable) {
// Skip first
skippable = false;
continue;
}
if (!first) result += "\n";
result += note.title;
first = false;
}
}
return result;
}
/**
* Return a list of notes respecting the constraints set in preferences.
*/
private ArrayList getNotesFromDB(final long list, final String upperLimit) {
// WHERE_LIST_IS, toA(list)
String where = "";
String[] whereArgs = new String[0];
if (list > -1) {
where += WHERE_LIST_IS_AND;
whereArgs = appendTo(whereArgs, Long.toString(list));
}
where += getUpperQueryLimitWhere(upperLimit);
whereArgs = getUpperQueryLimitWhereArgs(whereArgs, upperLimit);
final Cursor cursor = getContentResolver().query(Task.URI,
Task.Columns.FIELDS, where, whereArgs, DUEDATE_SORT_TYPE);
final ArrayList result = new ArrayList<>();
if (cursor != null) {
while (cursor.moveToNext()) {
result.add(new Task(cursor));
}
cursor.close();
}
return result;
}
/**
* Returns the list name, or "Tasks" if all lists are to be shown.
*/
private String getHeader(final long list) {
String header = getString(R.string.dashclock_tasks);
if (list > -1) {
final Cursor cursor = getContentResolver().query(TaskList.URI,
TaskList.Columns.FIELDS, TaskList.Columns._ID + " IS ?",
new String[] { Long.toString(list) }, null);
if (cursor != null) {
if (!cursor.isClosed() && !cursor.isAfterLast()) {
if (cursor.moveToNext()) {
header = cursor.getString(1);
}
}
cursor.close();
}
}
return header;
}
private String getUpperQueryLimitWhere(final String upperLimit) {
String where = WHERE_DATE_IS;
if (getString(R.string.dashclock_pref_none).equals(upperLimit)) {
where = WHERE_ALL_NOTDONE;
}
return where;
}
private String[] getUpperQueryLimitWhereArgs(final String[] whereArgs, final String upperLimit) {
final GregorianCalendar gc = new GregorianCalendar();
gc.set(GregorianCalendar.HOUR_OF_DAY, 23);
gc.set(GregorianCalendar.MINUTE, 59);
final long base = gc.getTimeInMillis();
final long day = 24 * 60 * 60 * 1000;
if (getString(R.string.dashclock_pref_today).equals(upperLimit)) {
return appendTo(whereArgs, Long.toString(gc.getTimeInMillis()));
} else if (getString(R.string.dashclock_pref_tomorrow).equals(upperLimit)) {
gc.setTimeInMillis(base + 1 * day);
return appendTo(whereArgs, Long.toString(gc.getTimeInMillis()));
} else if (getString(R.string.dashclock_pref_next7).equals(upperLimit)) {
gc.setTimeInMillis(base + 7 * day);
return appendTo(whereArgs, Long.toString(gc.getTimeInMillis()));
} else {
return whereArgs;
}
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/database/DAO.java
================================================
/*
* Copyright (c) 2015. Jonas Kalderstam
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.database;
import android.content.ContentValues;
import android.content.Context;
import android.database.SQLException;
import android.database.sqlite.SQLiteDatabase;
import android.net.Uri;
import android.provider.BaseColumns;
import com.mobeta.android.dslv.DragSortListView;
import com.nononsenseapps.helpers.NnnLogger;
import java.util.ArrayList;
import java.util.Arrays;
public abstract class DAO {
private static final String whereIdIs = "" + BaseColumns._ID + " IS ?";
/**
* Append where is id ? to string
*/
public static String whereIdIs(final String orgWhere) {
final StringBuilder sb = new StringBuilder();
if (orgWhere != null) {
sb.append("(");
sb.append(orgWhere);
sb.append(") AND ");
}
sb.append(BaseColumns._ID).append(" IS ?");
return sb.toString();
}
public String[] whereIdArg() {
return new String[] { Long.toString(_id) };
}
public static String[] whereIdArg(final long _id) {
return new String[] { Long.toString(_id) };
}
/**
* Append the id argument to array
*/
public static String[] whereIdArg(final long _id,
final String[] orgWhereArgs) {
if (orgWhereArgs == null) {
return whereIdArg(_id);
} else {
return joinArrays(orgWhereArgs, whereIdArg(_id));
}
}
public static String[] prefixArray(final String prefix, final String[] array) {
final String[] result = new String[array.length];
for (int i = 0; i < array.length; i++) {
result[i] = "" + prefix + array[i];
}
return result;
}
public static String[] joinArrays(final String[]... arrays) {
final ArrayList list = new ArrayList<>();
for (final String[] array : arrays) {
if (array != null) {
list.addAll(Arrays.asList(array));
}
}
return list.toArray(new String[0]);
}
/**
* Examples:
* [] -> ""
* [a] -> "a"
* [a, b] -> "a,b"
*/
public static String arrayToCommaString(final long... array) {
StringBuilder result = new StringBuilder();
for (final long val : array) {
final String txt = Long.toString(val);
if (result.length() > 0) result.append(",");
result.append(txt);
}
return result.toString();
}
public static String arrayToCommaString(final String... array) {
return arrayToCommaString("", array);
}
/**
* Example (prefix=t.): [] -> "" [a] -> "t.a" [a, b] -> "t.a,t.b"
*/
public static String arrayToCommaString(final String prefix,
final String[] array) {
return arrayToCommaString(prefix, array, "");
}
/**
* Example (prefix=t., suffix=.45): [] -> "" [a] -> "t.a.45" [a, b] ->
* "t.a.45,t.b.45"
*
* In addition, the txt itself can be referenced using %1$s in either prefix
* or suffix. The prefix can be referenced as %2$s in suffix, and
* vice-versa.
*
* So the following is valid:
*
* (prefix='t.', suffix=' AS %2$s%1$s')
*
* [listId] -> t.listId AS t.listId
*/
protected static String arrayToCommaString(final String pfx,
final String[] array, final String sfx) {
StringBuilder result = new StringBuilder();
for (final String txt : array) {
if (result.length() > 0) result.append(",");
result.append(String.format(pfx, txt, sfx));
result.append(txt);
result.append(String.format(sfx, txt, pfx));
}
return result.toString();
}
/**
* Second and Third value is wrapped in '' ticks, NOT the first.
*
* For example,
*
* So it is useful to return a name & value pair for the header of the {@link DragSortListView}
* when it is sorted by date. In that case you use this function to run a query that returns
* special values: see {@link Task#CREATE_SECTIONED_DATE_VIEW}
*/
protected static String asEmptyCommaStringExcept(final String[] asColumns,
final String exceptCol1, final String asValue1,
final String exceptCol2, final String asValue2,
final String exceptCol3, final String asValue3) {
StringBuilder result = new StringBuilder();
for (final String colName : asColumns) {
if (result.length() > 0) result.append(",");
if (colName.equals(exceptCol2)) {
result.append("'").append(asValue2).append("'");
} else if (colName.equals(exceptCol3)) {
result.append("'").append(asValue3).append("'");
} else if (colName.equals(exceptCol1)) {
result.append(asValue1);
} else {
result.append("null");
}
}
return result.toString();
}
/**
* Third and Fourth value is wrapped in '' ticks, NOT the first and second.
*/
protected static String asEmptyCommaStringExcept(final String[] asColumns,
final String exceptCol1, final String asValue1,
final String exceptCol2, final String asValue2,
final String exceptCol3, final String asValue3,
final String exceptCol4, final String asValue4) {
StringBuilder result = new StringBuilder();
for (final String colName : asColumns) {
if (result.length() > 0) result.append(",");
if (colName.equals(exceptCol3)) {
result.append("'").append(asValue3).append("'");
} else if (colName.equals(exceptCol4)) {
result.append("'").append(asValue4).append("'");
} else if (colName.equals(exceptCol2)) {
result.append(asValue2);
} else if (colName.equals(exceptCol1)) {
result.append(asValue1);
} else {
result.append("null");
}
}
return result.toString();
}
public Uri getUri() {
return Uri.withAppendedPath(getBaseUri(), Long.toString(_id));
}
public Uri getBaseUri() {
return Uri.withAppendedPath(
Uri.parse(MyContentProvider.SCHEME
+ MyContentProvider.AUTHORITY), getTableName());
}
public long _id = -1;
public synchronized boolean update(final Context context, final SQLiteDatabase db) {
int result = 0;
db.beginTransaction();
try {
if (_id > 0) {
result += db.update(getTableName(), getContent(), whereIdIs, whereIdArg());
}
if (result > 0) {
db.setTransactionSuccessful();
}
} catch (SQLException e) {
NnnLogger.exception(e);
throw e;
} finally {
db.endTransaction();
}
if (result > 0) {
notifyProviderOnChange(context);
}
return result > 0;
}
public synchronized Uri insert(final Context context, final SQLiteDatabase db) {
Uri retval;
db.beginTransaction();
try {
beforeInsert(context, db);
final long id = db.insert(getTableName(), null, getContent());
if (id == -1) {
throw new SQLException("Insert failed in " + getTableName());
} else {
_id = id;
afterInsert(context, db);
db.setTransactionSuccessful();
retval = getUri();
}
} catch (SQLException e) {
NnnLogger.exception(e);
throw e;
} finally {
db.endTransaction();
}
if (retval != null) {
notifyProviderOnChange(context);
}
return retval;
}
public synchronized int remove(final Context context,
final SQLiteDatabase db) {
final int result = db.delete(getTableName(), BaseColumns._ID + " IS ?",
new String[] { Long.toString(_id) });
if (result > 1) {
notifyProviderOnChange(context);
}
return result;
}
public static void notifyProviderOnChange(final Context context,
final Uri uri) {
try {
context.getContentResolver().notifyChange(uri, null, false);
} catch (UnsupportedOperationException e) {
// Catch this for test suite. Mock provider cant notify
}
}
protected void notifyProviderOnChange(final Context context) {
notifyProviderOnChange(context, getUri());
}
public void setId(final Uri uri) {
_id = Long.parseLong(uri.getLastPathSegment());
}
protected void beforeInsert(final Context context, final SQLiteDatabase db) {}
protected void afterInsert(final Context context, final SQLiteDatabase db) {}
protected DAO() {}
public abstract ContentValues getContent();
protected abstract String getTableName();
public abstract String getContentType();
/**
* Convenience method for normal operations. Updates "updated" field.
* Returns number of db-rows affected. Fail if < 1
*/
public abstract int save(final Context context);
/**
* Delete object from database
*
* @return the number of rows deleted, or 0
*/
public int delete(final Context context) {
if (_id > 0) {
return context.getContentResolver().delete(getUri(), null, null);
} else {
return 0;
}
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/database/DatabaseHandler.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.database;
import android.content.Context;
import android.database.Cursor;
import android.database.SQLException;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteException;
import android.database.sqlite.SQLiteOpenHelper;
import android.provider.BaseColumns;
import com.nononsenseapps.helpers.NnnLogger;
import com.nononsenseapps.helpers.RFC3339Date;
import com.nononsenseapps.notepad.R;
import com.nononsenseapps.notepad.prefs.Constants;
import com.nononsenseapps.notepad.sync.googleapi.GoogleTask;
import com.nononsenseapps.notepad.sync.googleapi.GoogleTaskList;
import java.util.Calendar;
import java.util.HashMap;
public class DatabaseHandler extends SQLiteOpenHelper {
private static DatabaseHandler singleton;
public static DatabaseHandler getInstance(final Context context) {
if (singleton == null) {
singleton = new DatabaseHandler(context);
}
return singleton;
}
private static final int DATABASE_VERSION = 15;
public static final String DATABASE_NAME = "nononsense_notes.db";
private final Context context;
private final String testPrefix;
/**
* Should use the singleton for normal cases
*/
private DatabaseHandler(Context context) {
this(context, "");
}
/**
* Use only for JUNIT tests
*/
public DatabaseHandler(Context context, String testPrefix) {
super(context, testPrefix + DATABASE_NAME, null, DATABASE_VERSION);
// Good idea to have the context that doesn't die with the window
this.context = context.getApplicationContext();
this.testPrefix = testPrefix;
}
@Override
public void onOpen(SQLiteDatabase db) {
super.onOpen(db);
if (!db.isReadOnly()) {
// Enable foreign key constraints
// This would require android16
// db.setForeignKeyConstraintsEnabled(true);
// This works everywhere
db.execSQL("PRAGMA foreign_keys=ON;");
}
}
@Override
public void onCreate(SQLiteDatabase db) {
db.execSQL(TaskList.CREATE_TABLE);
db.execSQL(Task.CREATE_TABLE);
db.execSQL(Task.CREATE_DELETE_TABLE);
db.execSQL(Task.CREATE_HISTORY_TABLE);
db.execSQL(Notification.CREATE_TABLE);
db.execSQL(RemoteTaskList.CREATE_TABLE);
db.execSQL(RemoteTask.CREATE_TABLE);
db.execSQL(Notification.CREATE_JOINED_VIEW);
db.execSQL(Task.TRIGGER_PRE_INSERT);
db.execSQL(Task.TRIGGER_PRE_DELETE);
db.execSQL(Task.TRIGGER_POST_DELETE);
db.execSQL(Task.TRIGGER_MOVE_LIST);
db.execSQL(Task.CREATE_HISTORY_INSERT_TRIGGER);
db.execSQL(Task.CREATE_HISTORY_UPDATE_TRIGGER);
db.execSQL(RemoteTask.TRIGGER_LISTDELETE_CASCADE);
// Mark as deleted when real item deleted
db.execSQL(RemoteTask.TRIGGER_REALDELETE_MARK);
db.execSQL(RemoteTaskList.TRIGGER_REALDELETE_MARK);
// Create move list trigger
db.execSQL(RemoteTask.TRIGGER_MOVE_LIST);
// Search tables
db.execSQL(Task.CREATE_FTS3_TABLE);
db.execSQL(Task.CREATE_FTS3_INSERT_TRIGGER);
db.execSQL(Task.CREATE_FTS3_UPDATE_TRIGGER);
db.execSQL(Task.CREATE_FTS3_DELETE_TRIGGER);
// Delete search tables
db.execSQL(Task.CREATE_FTS3_DELETE_TABLE);
db.execSQL(Task.CREATE_FTS3_DELETED_INSERT_TRIGGER);
db.execSQL(Task.CREATE_FTS3_DELETED_UPDATE_TRIGGER);
db.execSQL(Task.CREATE_FTS3_DELETED_DELETE_TRIGGER);
initializedDB(db);
}
public static Cursor getLegacyLists(final SQLiteDatabase legacyDB) {
return legacyDB.rawQuery("SELECT lists."
+ BaseColumns._ID
+ ",lists.title,gtasklists.googleid,gtasklists.googleaccount"
+ " FROM " + LegacyDBHelper.NotePad.Lists.TABLE_NAME
+ " LEFT OUTER JOIN "
+ LegacyDBHelper.NotePad.GTaskLists.TABLE_NAME + " ON ("
+ LegacyDBHelper.NotePad.Lists.TABLE_NAME + "."
+ LegacyDBHelper.NotePad.Lists._ID + " = "
+ LegacyDBHelper.NotePad.GTaskLists.TABLE_NAME + "."
+ LegacyDBHelper.NotePad.GTaskLists.COLUMN_NAME_DB_ID + ")"
+ " WHERE lists.deleted IS NOT 1", null);
}
public static Cursor getLegacyNotes(final SQLiteDatabase legacyDB) {
return legacyDB.rawQuery("SELECT notes."
+ BaseColumns._ID
+ ",notes.title,notes.note,notes.duedate,notes.gtaskstatus,notes.list,notes.modified,gtasks.googleid,gtasks.googleaccount"
+ " FROM "
+ LegacyDBHelper.NotePad.Notes.TABLE_NAME
+ " LEFT OUTER JOIN "
+ LegacyDBHelper.NotePad.GTasks.TABLE_NAME
+ " ON ("
+ LegacyDBHelper.NotePad.Notes.TABLE_NAME
+ "."
+ LegacyDBHelper.NotePad.Notes._ID
+ " = "
+ LegacyDBHelper.NotePad.GTasks.TABLE_NAME
+ "."
+ LegacyDBHelper.NotePad.GTasks.COLUMN_NAME_DB_ID
+ ")"
+ " WHERE notes.deleted IS NOT 1 AND notes.hiddenflag IS NOT 1",
null);
}
public static Cursor getLegacyNotifications(final SQLiteDatabase legacyDB) {
return legacyDB.query(LegacyDBHelper.NotePad.Notifications.TABLE_NAME,
new String[] { "time", "permanent", "noteid" }, null,
null, null, null, null);
}
private void initializedDB(final SQLiteDatabase db) throws SQLiteException {
// Load legacy DB if it exists
// Open database and copy information
// Remember to do try except
db.beginTransaction();
try {
final HashMap listIDMap = new HashMap<>();
final HashMap taskIDMap = new HashMap<>();
final LegacyDBHelper legacyDBHelper = new LegacyDBHelper(context, testPrefix);
final SQLiteDatabase legacyDB = legacyDBHelper.getReadableDatabase();
// First copy lists
// if there's no legacy DB, this call crashes and the whole try-block is skipped
Cursor c = getLegacyLists(legacyDB);
while (!c.isClosed() && c.moveToNext()) {
TaskList tl = new TaskList();
tl.title = c.getString(1);
tl.updated = Calendar.getInstance().getTimeInMillis();
// insert into db
tl.insert(context, db);
// remember id
listIDMap.put(c.getLong(0), tl._id);
// handle gtask info
GoogleTaskList rl;
if (c.getString(2) != null
&& !c.getString(2).isEmpty()
&& c.getString(3) != null
&& !c.getString(3).isEmpty()) {
rl = new GoogleTaskList(tl._id, c.getString(2), tl.updated,
c.getString(3));
rl.insert(context, db);
}
}
c.close();
// Then notes
if (!listIDMap.isEmpty()) {
// query
c = getLegacyNotes(legacyDB);
// iterate over notes
while (!c.isClosed() && c.moveToNext()) {
Task t = new Task();
t.title = c.getString(1);
t.note = c.getString(2);
if (t.note.contains("[locked]")) {
t.locked = true;
t.note = t.note.replace("[locked]", "");
}
try {
t.due = RFC3339Date
.parseRFC3339Date(c.getString(3))
.getTime();
} catch (Exception e) {
NnnLogger.warning(DatabaseHandler.class, "date error");
}
// completed must be converted
if (c.getString(4) != null
&& "completed".equals(c.getString(4))) {
t.setAsCompletedForLegacy();
}
t.dblist = listIDMap.get(c.getLong(5));
t.updated = c.getLong(6);
// insert
// Just make extra sure list exists
if (t.dblist != null) {
t.insert(context, db);
// put in idmap
taskIDMap.put(c.getLong(0), t._id);
}
// gtask
GoogleTask gt;
if (!c.isNull(7)
&& !c.getString(7).isEmpty()
&& !c.isNull(8)
&& !c.getString(8).isEmpty()) {
gt = new GoogleTask(t, c.getString(8));
gt.remoteId = c.getString(7);
gt.updated = t.updated;
gt.insert(context, db);
}
}
c.close();
}
// Then notifications
if (!taskIDMap.isEmpty()) {
c = getLegacyNotifications(legacyDB);
while (!c.isClosed() && c.moveToNext()) {
// Make sure id exists
if (taskIDMap.containsValue(c.getLong(2))) {
var n = new Notification(taskIDMap.get(c.getLong(2)));
n.time = c.getLong(0);
// permanent was not supported at the time
// insert
n.insert(context, db);
}
}
c.close();
}
// Complete, close the legacy db
legacyDB.close();
} catch (SQLException e) {
// Database must have been empty. Ignore it
}
// ------------
// If no lists, insert a list and example note.
// ------------
Cursor c = db.query(TaskList.TABLE_NAME, TaskList.Columns.FIELDS, null,
null, null, null, null);
if (!c.isClosed() && c.getCount() > 0) {
// there is already a database: don't add anything
} else {
// there wasn't a database: add a new list (called "tasks" in the user's language)
final TaskList tl = new TaskList();
tl.title = context.getString(R.string.tasks);
tl.insert(context, db);
// compose a note that is shown when the app is first installed
String welcomeNoteText =
// first the title. the \n separates it from the content
context.getString(R.string.welcome_note_title) + "\n"
// an hint visible also on the task list
+ context.getString(R.string.welcome_note_row_2) + "\n\n\n"
// when the user open the task, he is told to open the tutorial
+ context.getString(R.string.welcome_note_row_3) + " "
+ Constants.TUTORIAL_URL;
final Task task = new Task();
task.setText(welcomeNoteText);
task.dblist = tl._id;
try {
task.insert(context, db);
} catch (Exception e) {
// well, whatever, the note will not be added. I'm sure the user will find the
// tutorial anyway...
NnnLogger.exception(e);
}
}
c.close();
db.setTransactionSuccessful();
db.endTransaction();
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
if (oldVersion < 10) {
// Notification locations
// Add columns
String preName = "ALTER TABLE " + Notification.TABLE_NAME + " ADD COLUMN ";
String postText = " TEXT";
String postReal = " REAL";
db.execSQL(preName + Notification.Columns.LOCATIONNAME + postText);
db.execSQL(preName + Notification.Columns.LATITUDE + postReal);
db.execSQL(preName + Notification.Columns.LONGITUDE + postReal);
db.execSQL(preName + Notification.Columns.RADIUS + postReal);
// Drop view
db.execSQL("DROP VIEW IF EXISTS " + Notification.WITH_TASK_VIEW_NAME);
// Recreate view with additional tables
db.execSQL(Notification.CREATE_JOINED_VIEW);
}
if (oldVersion < 11) {
// Mark as deleted when real item deleted
db.execSQL(RemoteTask.TRIGGER_REALDELETE_MARK);
db.execSQL(RemoteTaskList.TRIGGER_REALDELETE_MARK);
}
if (oldVersion < 12) {
// Recreate trigger
db.execSQL("DROP TRIGGER IF EXISTS task_post_delete");
db.execSQL(Task.TRIGGER_POST_DELETE);
}
if (oldVersion < 13) {
// Create move list trigger
db.execSQL(RemoteTask.TRIGGER_MOVE_LIST);
// Create trigger to fix positions when moving lists
db.execSQL(Task.TRIGGER_MOVE_LIST);
}
if (oldVersion < 14) {
// Update history update trigger
db.execSQL("DROP TRIGGER IF EXISTS " + Task.HISTORY_UPDATE_TRIGGER_NAME);
db.execSQL(Task.CREATE_HISTORY_UPDATE_TRIGGER);
}
if (oldVersion < 15) {
// Drop view, changing to temporary view instead
db.execSQL("DROP VIEW IF EXISTS " + Notification.WITH_TASK_VIEW_NAME);
}
// TODO if you want to change the database, add code here to handle the upgrade!
}
/**
* Used by Espresso tests to remove the whole database
* when cleaning up after tests
*/
public static void resetDatabase(Context context) {
context.deleteDatabase(DatabaseHandler.DATABASE_NAME);
singleton = new DatabaseHandler(context);
DatabaseHandler.getInstance(context).getWritableDatabase();
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/database/LegacyDBHelper.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.database;
import android.app.SearchManager;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.SQLException;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
import android.net.Uri;
import android.provider.BaseColumns;
import android.util.Log;
import com.nononsenseapps.helpers.NnnLogger;
import com.nononsenseapps.helpers.RFC3339Date;
/**
* This class contains the code that has been called over the versions to
* upgrade the database. Upgrades should be saved here as plain text to enable a
* linear progression from 1.0 to current version without problems even if the
* entire database is changed.
*
* onUpgrade should be called first from the databaseopenhelper's onUpgrade
* method.
*/
public class LegacyDBHelper extends SQLiteOpenHelper {
public static final String LEGACY_DATABASE_NAME = "note_pad.db";
public static final int LEGACY_DATABASE_FINAL_VERSION = 8;
public LegacyDBHelper(Context context) {
this(context, "");
}
public LegacyDBHelper(Context context, String testPrefix) {
super(context.getApplicationContext(), testPrefix
+ LEGACY_DATABASE_NAME, null, LEGACY_DATABASE_FINAL_VERSION);
}
@Override
public void onCreate(SQLiteDatabase db) {
// Don't create anything if the database doesn't exist before.
}
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
NnnLogger.debug(LegacyDBHelper.class,
"onUpgrade " + "Upgrading database from version " + oldVersion + " to " + newVersion);
if (oldVersion < 3) {
// FIrst add columns to Notes table
String preName = "ALTER TABLE " + "notes" + " ADD COLUMN ";
// Don't want null values. Prefer empty String
String postText = " TEXT";
String postNameInt = " INTEGER";
// Add Columns to Notes DB
db.execSQL(preName + "list" + postNameInt);
db.execSQL(preName + "duedate" + postText);
db.execSQL(preName + "gtaskstatus" + postText);
db.execSQL(preName + "modifiedflag" + postNameInt);
db.execSQL(preName + "deleted" + postNameInt);
// Then create the 3 missing tables
db.execSQL("CREATE TABLE " + "lists" + " (" + BaseColumns._ID
+ " INTEGER PRIMARY KEY," + "title"
+ " TEXT DEFAULT '' NOT NULL," + "modifiedflag"
+ " INTEGER DEFAULT 0 NOT NULL," + "modified"
+ " INTEGER DEFAULT 0 NOT NULL," + "deleted"
+ " INTEGER DEFAULT 0 NOT NULL" + ");");
db.execSQL("CREATE TABLE " + "gtasks" + " (" + BaseColumns._ID
+ " INTEGER PRIMARY KEY," + "dbid"
+ " INTEGER UNIQUE NOT NULL REFERENCES " + "notes" + ","
+ "googleid" + " INTEGER NOT NULL," + "googleaccount"
+ " INTEGER NOT NULL," + "updated" + " TEXT," + "etag"
+ " TEXT" + ");");
db.execSQL("CREATE TABLE " + "gtasklists" + " (" + BaseColumns._ID
+ " INTEGER PRIMARY KEY," + "dbid"
+ " INTEGER UNIQUE NOT NULL REFERENCES " + "lists" + ","
+ "googleid" + " INTEGER NOT NULL," + "googleaccount"
+ " INTEGER NOT NULL," + "updated" + " TEXT," + "etag"
+ " TEXT" + ");");
// Now insert a default list
ContentValues values = new ContentValues();
values.put("title", "Tasks");
values.put("modifiedflag", 1);
values.put("deleted", 0);
long listId = db.insert("lists", null, values);
// Place all existing notes in this list
// And give them sensible values in the new columns
values.clear();
values.put("list", listId);
values.put("modifiedflag", 1);
values.put("deleted", 0);
values.put("duedate", "");
values.put("gtaskstatus", "needsAction");
db.update("notes", values, "list" + " IS NOT ?",
new String[] { Long.toString(listId) });
}
if (oldVersion < 4) {
String preName = "ALTER TABLE " + "notes" + " ADD COLUMN ";
String postText = " TEXT";
String postNameInt = " INTEGER";
// Add Columns to Notes DB
db.execSQL(preName + "gtasks_parent" + postText);
db.execSQL(preName + "gtasks_position" + postText);
db.execSQL(preName + "hiddenflag" + postNameInt);
// Give all notes sensible values
ContentValues values = new ContentValues();
values.put("gtasks_parent", "");
values.put("gtasks_position", "");
values.put("hiddenflag", 0);
db.update("notes", values, "hiddenflag" + " IS NOT ?",
new String[] { "0" });
}
if (oldVersion < 5) {
String preName = "ALTER TABLE " + "notes" + " ADD COLUMN ";
String postText = " TEXT DEFAULT ''";
String postNameInt = " INTEGER DEFAULT 0";
db.execSQL(preName + "possubsort" + postText);
db.execSQL(preName + "localhidden" + postNameInt);
}
if (oldVersion < 6) {
// Add Columns to Notes DB
String preName = "ALTER TABLE " + "notes" + " ADD COLUMN ";
String postNameInt = " INTEGER DEFAULT 0";
db.execSQL(preName + "indentlevel" + postNameInt);
db.execSQL(preName + "locked" + postNameInt);
// Mark all notes as modified to ensure we set the indents on
// next sync
ContentValues values = new ContentValues();
values.put("modifiedflag", 1);
db.update("notes", values, null, null);
}
if (oldVersion < 7) {
db.execSQL("CREATE TABLE " + "notification" + " ("
+ BaseColumns._ID + " INTEGER PRIMARY KEY," + "time"
+ " INTEGER NOT NULL DEFAULT 0," + "permanent"
+ " INTEGER NOT NULL DEFAULT 0," + "noteid" + " INTEGER,"
+ "FOREIGN KEY(" + "noteid" + ") REFERENCES " + "notes"
+ "(" + BaseColumns._ID + ") ON DELETE CASCADE" + ");");
}
if (oldVersion < 8) {
try {
db.execSQL("CREATE TRIGGER post_note_markdelete AFTER UPDATE ON "
+ "notes"
+ " WHEN new."
+ "deleted"
+ " = 1"
+ " BEGIN"
+ " DELETE FROM "
+ "notification"
+ " WHERE "
+ "notification"
+ "."
+ "noteid"
+ " = " + "new." + BaseColumns._ID + ";" + " END");
} catch (SQLException e) {
Log.d("NNN", "Creating trigger failed. It probably already existed:");
NnnLogger.exception(e);
}
try {
db.execSQL("CREATE TRIGGER post_note_actualdelete AFTER DELETE ON "
+ "notes"
+ " BEGIN"
+ " DELETE FROM "
+ "notification"
+ " WHERE "
+ "notification"
+ "."
+ "noteid"
+ " = "
+ "old."
+ BaseColumns._ID
+ ";"
+ " END");
} catch (SQLException e) {
NnnLogger.exception(e);
}
}
}
public static final class NotePad {
public static final String AUTHORITY = MyContentProvider.AUTHORITY;
// This class cannot be instantiated
private NotePad() {
}
/**
* Notes table contract
*/
public static final class Notes implements BaseColumns {
// This class cannot be instantiated
private Notes() {
}
/**
* The table name offered by this provider
*/
public static final String TABLE_NAME = "notes";
public static final String KEY_WORD = SearchManager.SUGGEST_COLUMN_TEXT_1;
/*
* URI definitions
*/
/**
* The scheme part for this provider's URI
*/
private static final String SCHEME = "content://";
// -----------------------
// Path parts for the URIs
// -----------------------
/**
* Path part for the Notes URI
*/
public static final String PATH_NOTES = "/notes";
public static final String NOTES = "notes";
// Visible notes
public static final String PATH_VISIBLE_NOTES = "/visiblenotes";
public static final String VISIBLE_NOTES = "visiblenotes";
// Complete note entry including stuff in GTasks table
private static final String PATH_JOINED_NOTES = "/joinednotes";
/**
* Path part for the Note ID URI
*/
public static final String PATH_NOTE_ID = "/notes/";
public static final String PATH_VISIBLE_NOTE_ID = "/visiblenotes/";
/**
* 0-relative position of a note ID segment in the path part of a
* note ID URI
*/
public static final int NOTE_ID_PATH_POSITION = 1;
/**
* The content:// style URL for this table
*/
public static final Uri CONTENT_URI = Uri.parse(SCHEME + AUTHORITY
+ PATH_NOTES);
public static final Uri CONTENT_VISIBLE_URI = Uri.parse(SCHEME
+ AUTHORITY + PATH_VISIBLE_NOTES);
public static final Uri CONTENT_JOINED_URI = Uri.parse(SCHEME
+ AUTHORITY + PATH_JOINED_NOTES);
/**
* The content URI base for a single note. Callers must append a
* numeric note id to this Uri to retrieve a note
*/
public static final Uri CONTENT_ID_URI_BASE = Uri.parse(SCHEME
+ AUTHORITY + PATH_NOTE_ID);
public static final Uri CONTENT_VISIBLE_ID_URI_BASE = Uri
.parse(SCHEME + AUTHORITY + PATH_VISIBLE_NOTE_ID);
/**
* The content URI match pattern for a single note, specified by its
* ID. Use this to match incoming URIs or to construct an Intent.
*/
public static final Uri CONTENT_ID_URI_PATTERN = Uri.parse(SCHEME
+ AUTHORITY + PATH_NOTE_ID + "/#");
public static final Uri CONTENT_VISIBLE_ID_URI_PATTERN = Uri
.parse(SCHEME + AUTHORITY + PATH_VISIBLE_NOTE_ID + "/#");
/*
* MIME type definitions
*/
/**
* The MIME type of a {@link #CONTENT_URI} sub-directory of a single
* note.
*/
public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/vnd.nononsenseapps.note";
/**
* The MIME type of {@link #CONTENT_URI} providing a directory of
* notes.
*/
public static final String CONTENT_TYPE = CONTENT_ITEM_TYPE;
/*
* Column definitions
*/
/**
* Column name for the title of the note
*
* Type: TEXT
*
*/
public static final String COLUMN_NAME_TITLE = "title";
/**
* Column name of the note content
*
* Type: TEXT
*
*/
public static final String COLUMN_NAME_NOTE = "note";
/**
* Column name for the creation timestamp
*
* Type: INTEGER (long from System.curentTimeMillis())
*
*/
public static final String COLUMN_NAME_CREATE_DATE = "created";
/**
* Column name for the modification timestamp
*
* Type: INTEGER (long from System.curentTimeMillis())
*
*/
public static final String COLUMN_NAME_MODIFICATION_DATE = "modified";
/**
* Due date of the task (as an RFC 3339 timestamp) formatted as
* String.
*/
public static final String COLUMN_NAME_DUE_DATE = "duedate";
/**
* Status of task, such as "completed"
*/
public static final String COLUMN_NAME_GTASKS_STATUS = "gtaskstatus";
/**
* INTEGER, id of entry in lists table
*/
public static final String COLUMN_NAME_LIST = "list";
/**
* Deleted flag
*/
public static final String COLUMN_NAME_DELETED = "deleted";
/**
* Modified flag
*/
public static final String COLUMN_NAME_MODIFIED = "modifiedflag";
// parent position hidden
public static final String COLUMN_NAME_PARENT = "gtasks_parent";
public static final String COLUMN_NAME_POSITION = "gtasks_position";
public static final String COLUMN_NAME_HIDDEN = "hiddenflag";
// server side sorting and local hiding
public static final String COLUMN_NAME_INDENTLEVEL = "indentlevel";
public static final String COLUMN_NAME_POSSUBSORT = "possubsort";
public static final String COLUMN_NAME_LOCALHIDDEN = "localhidden";
public static final String ALPHABETIC_SORT_TYPE = COLUMN_NAME_TITLE
+ " COLLATE NOCASE";
// We want items with no due dates to be placed at the end, hence the sql magic
// Coalesce returns the first non-null argument
public static final String MODIFICATION_SORT_TYPE = COLUMN_NAME_MODIFICATION_DATE;
public static final String DUEDATE_SORT_TYPE = "CASE WHEN "
+ COLUMN_NAME_DUE_DATE + " IS NULL OR "
+ COLUMN_NAME_DUE_DATE + " IS '' THEN 1 ELSE 0 END, "
+ COLUMN_NAME_DUE_DATE;
public static final String POSSUBSORT_SORT_TYPE = COLUMN_NAME_POSSUBSORT;
public static final String ASCENDING_SORT_ORDERING = "ASC";
public static final String DESCENDING_SORT_ORDERING = "DESC";
public static final String ALPHABETIC_ASC_ORDER = COLUMN_NAME_TITLE
+ " COLLATE NOCASE ASC";
/**
* The default sort order for this table
*/
public static final String DEFAULT_SORT_TYPE = POSSUBSORT_SORT_TYPE;
public static final String DEFAULT_SORT_ORDERING = ASCENDING_SORT_ORDERING;
public static String SORT_ORDER = ALPHABETIC_ASC_ORDER;
}
/**
* Lists table contract
*/
public static final class Lists implements BaseColumns {
// This class cannot be instantiated
private Lists() {
}
public static final String DEFAULT_LIST_NAME = "Notes";
/**
* The table name offered by this provider
*/
public static final String TABLE_NAME = "lists";
public static final String KEY_WORD = SearchManager.SUGGEST_COLUMN_TEXT_1;
/*
* URI definitions
*/
/**
* The scheme part for this provider's URI
*/
private static final String SCHEME = "content://";
// -----------------------
// Path parts for the URIs
// -----------------------
/**
* Path part for the Lists URI
*/
public static final String PATH_LISTS = "/lists";
public static final String LISTS = "lists";
public static final String PATH_VISIBLE_LISTS = "/visiblelists";
public static final String VISIBLE_LISTS = "visiblelists";
// Complete entry gotten with a join with GTasksLists table
private static final String PATH_JOINED_LISTS = "/joinedlists";
/**
* Path part for the List ID URI
*/
public static final String PATH_LIST_ID = "/lists/";
public static final String PATH_VISIBLE_LIST_ID = "/visiblelists/";
/**
* 0-relative position of a ID segment in the path part of a ID URI
*/
public static final int ID_PATH_POSITION = 1;
/**
* The content:// style URL for this table
*/
public static final Uri CONTENT_URI = Uri.parse(SCHEME + AUTHORITY
+ PATH_LISTS);
public static final Uri CONTENT_VISIBLE_URI = Uri.parse(SCHEME
+ AUTHORITY + PATH_VISIBLE_LISTS);
public static final Uri CONTENT_JOINED_URI = Uri.parse(SCHEME
+ AUTHORITY + PATH_JOINED_LISTS);
/**
* The content URI base for a single note. Callers must append a
* numeric note id to this Uri to retrieve a note
*/
public static final Uri CONTENT_ID_URI_BASE = Uri.parse(SCHEME
+ AUTHORITY + PATH_LIST_ID);
public static final Uri CONTENT_VISIBLE_ID_URI_BASE = Uri
.parse(SCHEME + AUTHORITY + PATH_VISIBLE_LIST_ID);
/**
* The content URI match pattern for a single note, specified by its
* ID. Use this to match incoming URIs or to construct an Intent.
*/
public static final Uri CONTENT_ID_URI_PATTERN = Uri.parse(SCHEME
+ AUTHORITY + PATH_LIST_ID + "/#");
public static final Uri CONTENT_VISIBLE_ID_URI_PATTERN = Uri
.parse(SCHEME + AUTHORITY + PATH_VISIBLE_LIST_ID + "/#");
/*
* MIME type definitions
*/
/**
* The MIME type of a {@link #CONTENT_URI} sub-directory of a single
* item.
*/
public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/vnd.nononsenseapps.list";
/**
* The MIME type of {@link #CONTENT_URI} providing a directory.
*/
public static final String CONTENT_TYPE = CONTENT_ITEM_TYPE;
/*
* Column definitions
*/
/**
* Column name for the title of the note
*
* Type: TEXT
*
*/
public static final String COLUMN_NAME_TITLE = "title";
/**
* Deleted flag
*/
public static final String COLUMN_NAME_DELETED = "deleted";
/**
* Modified flag
*/
public static final String COLUMN_NAME_MODIFIED = "modifiedflag";
/**
* Column name for the modification timestamp
*
* Type: INTEGER (long from System.curentTimeMillis())
*
*/
public static final String COLUMN_NAME_MODIFICATION_DATE = "modified";
/**
* The default sort order for this table
*/
public static final String DEFAULT_SORT_TYPE = COLUMN_NAME_MODIFICATION_DATE;
public static final String DEFAULT_SORT_ORDERING = "DESC";
public static final String MODIFIED_DESC_ORDER = COLUMN_NAME_MODIFICATION_DATE
+ " DESC";
public static final String ALPHABETIC_ASC_ORDER = COLUMN_NAME_TITLE
+ " COLLATE NOCASE ASC";
public static String SORT_ORDER = ALPHABETIC_ASC_ORDER;
}
/**
* GoogleTasks table contract
*/
public static final class GTasks implements BaseColumns {
// This class cannot be instantiated
private GTasks() {
}
/**
* The table name offered by this provider
*/
public static final String TABLE_NAME = "gtasks";
public static final String KEY_WORD = SearchManager.SUGGEST_COLUMN_TEXT_1;
/*
* URI definitions
*/
/**
* The scheme part for this provider's URI
*/
private static final String SCHEME = "content://";
// -----------------------
// Path parts for the URIs
// -----------------------
/**
* Path part for the Lists URI
*/
private static final String PATH = "/gtasks";
/**
* Path part for the List ID URI
*/
private static final String PATH_ID = "/gtasks/";
/**
* 0-relative position of a note ID segment in the path part of a
* note ID URI
*/
public static final int ID_PATH_POSITION = 1;
/**
* The content:// style URL for this table
*/
public static final Uri CONTENT_URI = Uri.parse(SCHEME + AUTHORITY
+ PATH);
/**
* The content URI base for a single note. Callers must append a
* numeric note id to this Uri to retrieve a note
*/
public static final Uri CONTENT_ID_URI_BASE = Uri.parse(SCHEME
+ AUTHORITY + PATH_ID);
/**
* The content URI match pattern for a single note, specified by its
* ID. Use this to match incoming URIs or to construct an Intent.
*/
public static final Uri CONTENT_ID_URI_PATTERN = Uri.parse(SCHEME
+ AUTHORITY + PATH_ID + "/#");
/*
* MIME type definitions
*/
/**
* The MIME type of {@link #CONTENT_URI} providing a directory of
* notes.
*/
public static final String CONTENT_TYPE = "vnd.android.cursor.dir/vnd.nononsenseapps.gtask";
/**
* The MIME type of a {@link #CONTENT_URI} sub-directory of a single
* note.
*/
public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/vnd.nononsenseapps.gtask";
/*
* Column definitions
*/
/**
*
* Type: INTEGER, database ID
*
*/
public static final String COLUMN_NAME_DB_ID = "dbid";
/**
*
* Type: TEXT
*
*/
public static final String COLUMN_NAME_GTASKS_ID = "googleid";
/**
*
* Type: TEXT
*
*/
public static final String COLUMN_NAME_GOOGLE_ACCOUNT = "googleaccount";
/**
*
* Type: TEXT
*
*/
public static final String COLUMN_NAME_ETAG = "etag";
/**
*
* Type: TEXT
*
*/
public static final String COLUMN_NAME_UPDATED = "updated";
}
/**
* GoogleTaskLists table contract
*/
public static final class GTaskLists implements BaseColumns {
// This class cannot be instantiated
private GTaskLists() {
}
/**
* The table name offered by this provider
*/
public static final String TABLE_NAME = "gtasklists";
public static final String KEY_WORD = SearchManager.SUGGEST_COLUMN_TEXT_1;
/*
* URI definitions
*/
/**
* The scheme part for this provider's URI
*/
private static final String SCHEME = "content://";
// -----------------------
// Path parts for the URIs
// -----------------------
/**
* Path part for the Lists URI
*/
private static final String PATH = "/gtasklists";
/**
* Path part for the List ID URI
*/
private static final String PATH_ID = "/gtasklists/";
/**
* 0-relative position of a note ID segment in the path part of a
* note ID URI
*/
public static final int ID_PATH_POSITION = 1;
/**
* The content:// style URL for this table
*/
public static final Uri CONTENT_URI = Uri.parse(SCHEME + AUTHORITY
+ PATH);
/**
* The content URI base for a single note. Callers must append a
* numeric note id to this Uri to retrieve a note
*/
public static final Uri CONTENT_ID_URI_BASE = Uri.parse(SCHEME
+ AUTHORITY + PATH_ID);
/**
* The content URI match pattern for a single note, specified by its
* ID. Use this to match incoming URIs or to construct an Intent.
*/
public static final Uri CONTENT_ID_URI_PATTERN = Uri.parse(SCHEME
+ AUTHORITY + PATH_ID + "/#");
/*
* MIME type definitions
*/
/**
* The MIME type of {@link #CONTENT_URI} providing a directory of
* notes.
*/
public static final String CONTENT_TYPE = "vnd.android.cursor.dir/vnd.nononsenseapps.gtasklist";
/**
* The MIME type of a {@link #CONTENT_URI} sub-directory of a single
* note.
*/
public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/vnd.nononsenseapps.gtasklist";
/*
* Column definitions
*/
/**
*
* Type: INTEGER, database ID
*
*/
public static final String COLUMN_NAME_DB_ID = "dbid";
/**
*
* Type: TEXT
*
*/
public static final String COLUMN_NAME_GTASKS_ID = "googleid";
/**
*
* Type: TEXT
*
*/
public static final String COLUMN_NAME_GOOGLE_ACCOUNT = "googleaccount";
/**
*
* Type: TEXT
*
*/
public static final String COLUMN_NAME_ETAG = "etag";
/**
*
* Type: TEXT
*
*/
public static final String COLUMN_NAME_UPDATED = "updated";
}
/**
* Notifications table contract
*/
public static final class Notifications implements BaseColumns {
// This class cannot be instantiated
private Notifications() {
}
/**
* The table name offered by this provider
*/
public static final String TABLE_NAME = "notification";
public static final String KEY_WORD = SearchManager.SUGGEST_COLUMN_TEXT_1;
/*
* Column definitions
*/
public static final String COLUMN_NAME_TIME = "time";
public static final String COLUMN_NAME_PERMANENT = "permanent";
public static final String COLUMN_NAME_NOTEID = "noteid";
public static final String JOINED_COLUMN_LIST_TITLE = NotePad.Lists.TABLE_NAME
+ "." + NotePad.Lists.COLUMN_NAME_TITLE;
/*
* URI definitions
*/
/**
* The scheme part for this provider's URI
*/
private static final String SCHEME = "content://";
// -----------------------
// Path parts for the URIs
// -----------------------
/**
* Path part for the Lists URI
*/
private static final String PATH = "/" + TABLE_NAME;
/**
* Path part for the List ID URI
*/
private static final String PATH_ID = PATH + "/";
/**
* 0-relative position of a note ID segment in the path part of a
* note ID URI
*/
public static final int ID_PATH_POSITION = 1;
private static final String PATH_JOINED_NOTIFICATIONS = "/joinednotifications";
public static final String PATH_NOTIFICATIONS_LISTID = "/notificationlists";
private static final String PATH_NOTIFICATIONS_LISTID_BASE = PATH_NOTIFICATIONS_LISTID
+ "/";
/**
* The content:// style URL for this table
*/
public static final Uri CONTENT_URI = Uri.parse(SCHEME + AUTHORITY
+ PATH);
public static final Uri CONTENT_JOINED_URI = Uri.parse(SCHEME
+ AUTHORITY + PATH_JOINED_NOTIFICATIONS);
public static final Uri CONTENT_LISTID_URI_BASE = Uri.parse(SCHEME
+ AUTHORITY + PATH_NOTIFICATIONS_LISTID);
/**
* The content URI base for a single note. Callers must append a
* numeric note id to this Uri to retrieve a note
*/
public static final Uri CONTENT_ID_URI_BASE = Uri.parse(SCHEME
+ AUTHORITY + PATH_ID);
/**
* The content URI match pattern for a single note, specified by its
* ID. Use this to match incoming URIs or to construct an Intent.
*/
public static final Uri CONTENT_ID_URI_PATTERN = Uri.parse(SCHEME
+ AUTHORITY + PATH_ID + "/#");
/*
* MIME type definitions
*/
/**
* The MIME type of {@link #CONTENT_URI} providing a directory of
* notes.
*/
public static final String CONTENT_TYPE = "vnd.android.cursor.dir/vnd.nononsenseapps."
+ TABLE_NAME;
/**
* The MIME type of a {@link #CONTENT_URI} sub-directory of a single
* note.
*/
public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/vnd.nononsenseapps."
+ TABLE_NAME;
}
}
/**
* Converts the columns names from the legacy URIs. However, the data must
* also be returned correctly!
*/
public static String[] convertLegacyColumns(final String[] legacyCols) {
String[] newCols = new String[legacyCols.length];
for (int i = 0; i < legacyCols.length; i++) {
String col = legacyCols[i];
String newCol = col;
// Lists
if (NotePad.Lists.COLUMN_NAME_TITLE.equals(col)) {
newCol = TaskList.Columns.TITLE;
}
// Tasks
else if (NotePad.Notes.COLUMN_NAME_TITLE.equals(col)) {
newCol = Task.Columns.TITLE;
} else if (NotePad.Notes.COLUMN_NAME_NOTE.equals(col)) {
newCol = Task.Columns.NOTE;
} else if (NotePad.Notes.COLUMN_NAME_LIST.equals(col)) {
newCol = Task.Columns.DBLIST;
} else if (NotePad.Notes.COLUMN_NAME_DUE_DATE.equals(col)) {
newCol = Task.Columns.DUE;
} else if (NotePad.Notes.COLUMN_NAME_GTASKS_STATUS.equals(col)) {
newCol = Task.Columns.COMPLETED;
}
//Log.d("nononsenseapps db", "legacy converted field:" + newCol);
newCols[i] = newCol;
}
return newCols;
}
/**
* Convert new values to old, but using old or new column names
*
* TaskProjection: new String[] { "_id", "title", "note", "list", "duedate",
* "gtaskstatus"};
*/
public static Object[] convertLegacyTaskValues(final Cursor cursor) {
Object[] retval = new Object[cursor.getColumnCount()];
for (int i = 0; i < cursor.getColumnCount(); i++) {
final String colName = cursor.getColumnName(i);
final Object val;
if (NotePad.Notes.COLUMN_NAME_DUE_DATE.equals(colName) ||
Task.Columns.DUE.equals(colName)) {
val = cursor.isNull(i) ? "" : RFC3339Date.asRFC3339(cursor.getLong(i));
} else if (NotePad.Notes.COLUMN_NAME_GTASKS_STATUS.equals(colName) ||
Task.Columns.COMPLETED.equals(colName)) {
val = cursor.isNull(i) ? "needsAction" : "completed";
} else {
val = switch (cursor.getType(i)) {
case Cursor.FIELD_TYPE_FLOAT -> cursor.getFloat(i);
case Cursor.FIELD_TYPE_INTEGER -> cursor.getLong(i);
case Cursor.FIELD_TYPE_STRING -> cursor.getString(i);
// the legacy DB did not have BLOBs, anyway
case Cursor.FIELD_TYPE_BLOB -> null;
case Cursor.FIELD_TYPE_NULL -> null;
default -> null;
};
}
//Log.d("nononsenseapps db", "legacy notes col: " + val);
retval[i] = val;
}
return retval;
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/database/MyContentProvider.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.database;
import android.app.SearchManager;
import android.content.ContentProvider;
import android.content.ContentValues;
import android.content.UriMatcher;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.database.SQLException;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteStatement;
import android.net.Uri;
import androidx.annotation.NonNull;
import com.nononsenseapps.helpers.NnnLogger;
import com.nononsenseapps.helpers.UpdateNotifier;
import com.nononsenseapps.notepad.BuildConfig;
import java.util.ArrayList;
import java.util.Objects;
public class MyContentProvider extends ContentProvider {
/**
* The authority of the content provider must be unique for each package (app) installed,
* and it must be equal to the values used in searchable.xml and AndroidManifest.xml
* This one is defined in build.gradle for each buildType. It's equal to @string/NnnAuthority_1
*/
public static final String AUTHORITY = BuildConfig.APPLICATION_ID + ".MyContentAuthority";
public static final String SCHEME = "content://";
private static final UriMatcher sURIMatcher = new UriMatcher(UriMatcher.NO_MATCH);
static {
TaskList.addMatcherUris(sURIMatcher);
Task.addMatcherUris(sURIMatcher);
Notification.addMatcherUris(sURIMatcher);
RemoteTaskList.addMatcherUris(sURIMatcher);
RemoteTask.addMatcherUris(sURIMatcher);
}
public MyContentProvider() {
}
@Override
public String getType(@NonNull Uri uri) {
switch (sURIMatcher.match(uri)) {
case Notification.BASEITEMCODE:
case Notification.BASEURICODE:
case Notification.WITHTASKQUERYCODE:
case Notification.WITHTASKQUERYITEMCODE:
return Notification.CONTENT_TYPE;
case TaskList.BASEITEMCODE:
case TaskList.BASEURICODE:
case TaskList.LEGACYBASEITEMCODE:
case TaskList.LEGACYBASEURICODE:
case TaskList.LEGACYVISIBLEITEMCODE:
case TaskList.LEGACYVISIBLEURICODE:
return TaskList.CONTENT_TYPE;
case Task.BASEITEMCODE:
case Task.BASEURICODE:
case Task.SECTIONEDDATEITEMCODE:
case Task.SECTIONEDDATEQUERYCODE:
case Task.LEGACYBASEITEMCODE:
case Task.LEGACYBASEURICODE:
case Task.LEGACYVISIBLEITEMCODE:
case Task.LEGACYVISIBLEURICODE:
case Task.SEARCHCODE:
case Task.SEARCHSUGGESTIONSCODE:
return Task.CONTENT_TYPE;
default:
// throw new IllegalArgumentException("Unknown URI " + uri);
}
// Legacy URIs, above didn't work for some reason
if (uri.toString().startsWith(LegacyDBHelper.NotePad.Lists.CONTENT_URI.toString())
|| uri.toString().startsWith(
LegacyDBHelper.NotePad.Lists.CONTENT_VISIBLE_URI.toString())) {
return TaskList.CONTENT_TYPE;
} else if (uri.toString().startsWith(LegacyDBHelper.NotePad.Notes.CONTENT_URI.toString())
|| uri.toString().startsWith(
LegacyDBHelper.NotePad.Notes.CONTENT_VISIBLE_URI.toString())) {
return Task.CONTENT_TYPE;
}
throw new IllegalArgumentException("Unknown URI " + uri);
}
@Override
public boolean onCreate() {
return true;
}
@Override
synchronized public Uri insert(@NonNull Uri uri, ContentValues values) {
final SQLiteDatabase db = DatabaseHandler.getInstance(getContext())
.getWritableDatabase();
Uri result = null;
db.beginTransaction();
// Do not add legacy URIs
try {
final DAO item = switch (sURIMatcher.match(uri)) {
case TaskList.BASEURICODE -> new TaskList(values);
case Task.BASEURICODE -> new Task(values);
case Notification.BASEURICODE, Notification.WITHTASKQUERYITEMCODE ->
new Notification(values);
case RemoteTaskList.BASEURICODE -> new RemoteTaskList(values);
case RemoteTask.BASEURICODE -> new RemoteTask(values);
default -> throw new IllegalArgumentException("Faulty insertURI provided: " + uri);
};
result = item.insert(getContext(), db);
db.setTransactionSuccessful();
} catch (SQLException e) {
// Crap...
} finally {
db.endTransaction();
}
if (result != null) {
Objects.requireNonNull(getContext());
DAO.notifyProviderOnChange(getContext(), uri);
DAO.notifyProviderOnChange(getContext(), TaskList.URI_WITH_COUNT);
UpdateNotifier.updateWidgets(getContext());
UpdateNotifier.notifyChangeList(getContext());
}
return result;
}
@Override
synchronized public int update(@NonNull Uri uri, ContentValues values,
String selection, String[] selectionArgs) {
final SQLiteDatabase db = DatabaseHandler.getInstance(getContext())
.getWritableDatabase();
int result = 0;
final Task t;
final SQLiteStatement stmt;
final String sql;
final ArrayList updateUris = new ArrayList<>();
db.beginTransaction();
try {
// Do not add legacy URIs
switch (sURIMatcher.match(uri)) {
case TaskList.BASEITEMCODE:
updateUris.add(TaskList.URI);
updateUris.add(TaskList.URI_WITH_COUNT);
final TaskList list = new TaskList(uri, values);
result += db.update(TaskList.TABLE_NAME, list.getContent(),
TaskList.whereIdIs(selection),
TaskList.whereIdArg(list._id, selectionArgs));
break;
case Task.MOVEITEMLEFTCODE:
updateUris.add(Task.URI);
t = new Task(values);
sql = t.getSQLMoveItemLeft(values);
if (sql != null) {
stmt = db.compileStatement(sql);
result += stmt.executeUpdateDelete();
}
break;
case Task.MOVEITEMRIGHTCODE:
updateUris.add(Task.URI);
t = new Task(values);
sql = t.getSQLMoveItemRight(values);
if (sql != null) {
stmt = db.compileStatement(sql);
result += stmt.executeUpdateDelete();
}
break;
case Task.BASEITEMCODE:
updateUris.add(Task.URI);
updateUris.add(Task.URI_SECTIONED_BY_DATE);
updateUris.add(Task.URI_TASK_HISTORY);
updateUris.add(TaskList.URI);
updateUris.add(TaskList.URI_WITH_COUNT);
// regular update
t = new Task(uri, values);
if (t.getContent().size() > 0) {
// Something changed in task
result += db.update(Task.TABLE_NAME, t.getContent(),
Task.whereIdIs(selection),
Task.whereIdArg(t._id, selectionArgs));
}
break;
case Task.BASEURICODE:
updateUris.add(Task.URI);
updateUris.add(TaskList.URI);
updateUris.add(TaskList.URI_WITH_COUNT);
// Batch. No checks made
result += db.update(Task.TABLE_NAME, values, selection, selectionArgs);
break;
case Notification.BASEITEMCODE:
case Notification.WITHTASKQUERYITEMCODE:
updateUris.add(Notification.URI);
updateUris.add(Notification.URI_WITH_TASK_PATH);
// final Notification n = new Notification(uri, values);
result += db.update(
Notification.TABLE_NAME,
values,
Notification.whereIdIs(selection),
Notification.whereIdArg(
Long.parseLong(uri.getLastPathSegment()), selectionArgs));
break;
case Notification.BASEURICODE:
updateUris.add(Notification.URI);
updateUris.add(Notification.URI_WITH_TASK_PATH);
// No checks
result += db.update(Notification.TABLE_NAME, values, selection, selectionArgs);
break;
case RemoteTaskList.BASEITEMCODE:
updateUris.add(RemoteTaskList.URI);
result += db.update(RemoteTaskList.TABLE_NAME, values,
RemoteTaskList.whereIdIs(selection),
RemoteTaskList.whereIdArg(
Long.parseLong(uri.getLastPathSegment()), selectionArgs)
);
break;
case RemoteTask.BASEITEMCODE:
updateUris.add(RemoteTask.URI);
result += db.update(
RemoteTask.TABLE_NAME,
values,
RemoteTask.whereIdIs(selection),
RemoteTask.whereIdArg(
Long.parseLong(uri.getLastPathSegment()), selectionArgs)
);
break;
default:
throw new IllegalArgumentException("Faulty URI provided: " + uri);
}
if (result >= 0) {
db.setTransactionSuccessful();
}
} finally {
db.endTransaction();
}
if (result >= 0) {
for (Uri u : updateUris) {
DAO.notifyProviderOnChange(getContext(), u);
}
UpdateNotifier.notifyChangeList(getContext());
}
return result;
}
synchronized private int safeDeleteItem(final SQLiteDatabase db,
final String tableName,
final Uri uri,
final String selection,
final String[] selectionArgs) {
db.beginTransaction();
int result = 0;
try {
result += db.delete(
tableName,
DAO.whereIdIs(selection),
DAO.joinArrays(selectionArgs,
new String[] { uri.getLastPathSegment() }));
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
return result;
}
@Override
synchronized public int delete(@NonNull Uri uri, String selection,
String[] selectionArgs) {
final SQLiteDatabase db = DatabaseHandler.getInstance(getContext())
.getWritableDatabase();
int result = 0;
// Do not add legacy URIs
switch (sURIMatcher.match(uri)) {
case TaskList.BASEITEMCODE:
result += safeDeleteItem(db, TaskList.TABLE_NAME, uri, selection,
selectionArgs);
break;
case TaskList.BASEURICODE:
result += db.delete(TaskList.TABLE_NAME, selection, selectionArgs);
break;
case Task.BASEITEMCODE:
result += safeDeleteItem(db, Task.TABLE_NAME, uri, selection,
selectionArgs);
break;
case Task.BASEURICODE:
result += db.delete(Task.TABLE_NAME, selection, selectionArgs);
break;
case Notification.BASEURICODE:
result += db.delete(Notification.TABLE_NAME, selection,
selectionArgs);
break;
case Notification.BASEITEMCODE:
case Notification.WITHTASKQUERYITEMCODE:
result += safeDeleteItem(db, Notification.TABLE_NAME, uri,
selection, selectionArgs);
break;
case RemoteTaskList.BASEURICODE:
result += db.delete(RemoteTaskList.TABLE_NAME, selection,
selectionArgs);
break;
case RemoteTaskList.BASEITEMCODE:
result += safeDeleteItem(db, RemoteTaskList.TABLE_NAME, uri,
selection, selectionArgs);
break;
case RemoteTask.BASEURICODE:
result += db
.delete(RemoteTask.TABLE_NAME, selection, selectionArgs);
break;
case RemoteTask.BASEITEMCODE:
result += safeDeleteItem(db, RemoteTask.TABLE_NAME, uri, selection,
selectionArgs);
break;
case Task.DELETEDQUERYCODE:
result += db.delete(Task.DELETE_TABLE_NAME, selection,
selectionArgs);
break;
case Task.DELETEDITEMCODE:
result += safeDeleteItem(db, Task.DELETE_TABLE_NAME, uri,
selection, selectionArgs);
break;
default:
throw new IllegalArgumentException("Faulty delete-URI provided: " + uri);
}
if (result > 0) {
Objects.requireNonNull(getContext());
DAO.notifyProviderOnChange(getContext(), uri);
DAO.notifyProviderOnChange(getContext(), TaskList.URI_WITH_COUNT);
UpdateNotifier.updateWidgets(getContext());
UpdateNotifier.notifyChangeList(getContext());
}
return result;
}
@Override
synchronized public Cursor query(@NonNull Uri uri, String[] projection, String selection,
String[] selectionArgs, String sortOrder) {
Cursor result;
final long id;
Objects.requireNonNull(getContext());
switch (sURIMatcher.match(uri)) {
case TaskList.BASEURICODE:
result = DatabaseHandler
.getInstance(getContext())
.getReadableDatabase()
.query(TaskList.TABLE_NAME, projection, selection,
selectionArgs, null, null, sortOrder);
result.setNotificationUri(getContext().getContentResolver(), TaskList.URI);
break;
case TaskList.BASEITEMCODE:
id = Long.parseLong(uri.getLastPathSegment());
result = DatabaseHandler
.getInstance(getContext())
.getReadableDatabase()
.query(TaskList.TABLE_NAME,
projection,
TaskList.whereIdIs(selection),
TaskList.joinArrays(selectionArgs,
new String[] { String.valueOf(id) }),
null, null, sortOrder);
result.setNotificationUri(getContext().getContentResolver(), uri);
break;
case TaskList.VIEWCOUNTCODE:
// Create view if not exists
DatabaseHandler
.getInstance(getContext())
.getWritableDatabase()
.execSQL(TaskList.CREATE_COUNT_VIEW);
result = DatabaseHandler
.getInstance(getContext())
.getReadableDatabase()
// may crash here. android.database.sqlite.SQLiteException: no such table: lists_with_count (code 1 SQLITE_ERROR): , while compiling: SELECT _id, title, count FROM lists_with_count ORDER BY title COLLATE NOCASE
.query(TaskList.VIEWCOUNT_NAME, projection, selection,
selectionArgs, null, null, sortOrder);
result.setNotificationUri(getContext().getContentResolver(), uri);
break;
case Task.DELETEDQUERYCODE:
final String[] query = sanitize(selectionArgs);
result = DatabaseHandler
.getInstance(getContext())
.getReadableDatabase()
.query(Task.DELETE_TABLE_NAME,
Task.Columns.DELETEFIELDS,
Task.Columns._ID + " IN (SELECT " + Task.Columns._ID
+ " FROM " + Task.FTS3_DELETE_TABLE_NAME
+ ((query[0].isEmpty() || query[0].equals("'*'")) ? ")"
: (" WHERE " + Task.FTS3_DELETE_TABLE_NAME + " MATCH ?)")),
(query[0].isEmpty() || query[0].equals("'*'")) ? null : query,
null, null, sortOrder);
result.setNotificationUri(getContext().getContentResolver(), Task.URI_DELETED_QUERY);
break;
case Task.BASEURICODE:
result = DatabaseHandler
.getInstance(getContext())
.getReadableDatabase()
.query(Task.TABLE_NAME, projection, selection,
selectionArgs, null, null, sortOrder);
result.setNotificationUri(getContext().getContentResolver(),
Task.URI);
break;
case Task.BASEITEMCODE:
id = Long.parseLong(uri.getLastPathSegment());
result = DatabaseHandler
.getInstance(getContext())
.getReadableDatabase()
.query(Task.TABLE_NAME,
projection,
Task.whereIdIs(selection),
Task.joinArrays(selectionArgs, new String[] { String.valueOf(id) }),
null, null, sortOrder);
result.setNotificationUri(getContext().getContentResolver(), uri);
break;
case Task.SECTIONEDDATEQUERYCODE:
// this branch runs when the user sorts notes by due date
// Add list null because that's what the headers will have
final String listId;
if (selectionArgs == null || selectionArgs.length == 0) {
listId = null;
// throw new SQLException("Need a listid as first arg at the moment for this view!");
} else {
listId = selectionArgs[0];
}
// Create view if not exists
// TODO as explained in issue #525, on older OS versions (API 34 emulator, ...)
// this function returns a query to make a view with a column "dblist" of type
// INTEGER, as expected. On the Google Pixel 8a with android 14, and on API 35
// emulators, the column "dblist" is of type BLOB, which is not correct
String NNN_DATE_VIEW_QUERY = Task.CREATE_SECTIONED_DATE_VIEW(listId);
DatabaseHandler
.getInstance(getContext())
.getWritableDatabase()
.execSQL(NNN_DATE_VIEW_QUERY);
// this cursor contains the real notes (read from the database)
// and some "artificial" records used as headers for the groups of notes
result = DatabaseHandler
.getInstance(getContext())
.getReadableDatabase()
.query(Task.getSECTION_DATE_VIEW_NAME(listId),
projection,
selection,
selectionArgs,
null,
null,
Task.SECRET_TYPEID + "," + Task.Columns.DUE + ","
+ Task.SECRET_TYPEID2);
// You can see that the cursor contains both fake records like "today+1"
// and real notes taken from the database
// String DBG_READABLE_CURSOR_DUMP = DatabaseUtils.dumpCursorToString(result);
result.setNotificationUri(getContext().getContentResolver(),
Task.URI);
break;
case Task.HISTORYQUERYCODE:
result = DatabaseHandler
.getInstance(getContext())
.getReadableDatabase()
.query(Task.HISTORY_TABLE_NAME, projection, selection,
selectionArgs, null, null,
Task.Columns.UPDATED + " ASC");
// SQLite timestamp in updated column.
result.setNotificationUri(getContext().getContentResolver(), uri);
break;
case Notification.BASEITEMCODE:
id = Long.parseLong(uri.getLastPathSegment());
result = DatabaseHandler
.getInstance(getContext())
.getReadableDatabase()
.query(Notification.TABLE_NAME,
projection,
Notification.whereIdIs(selection),
Notification.joinArrays(selectionArgs,
new String[] { String.valueOf(id) }), null,
null, sortOrder);
result.setNotificationUri(getContext().getContentResolver(), uri);
break;
case Notification.WITHTASKQUERYITEMCODE:
// Create view if not exists
DatabaseHandler.getInstance(getContext()).getWritableDatabase()
.execSQL(Notification.CREATE_JOINED_VIEW);
id = Long.parseLong(uri.getLastPathSegment());
result = DatabaseHandler
.getInstance(getContext())
.getReadableDatabase()
.query(Notification.WITH_TASK_VIEW_NAME,
projection,
Notification.whereIdIs(selection),
Notification.joinArrays(selectionArgs,
new String[] { String.valueOf(id) }), null,
null, sortOrder);
result.setNotificationUri(getContext().getContentResolver(), uri);
break;
case Notification.BASEURICODE:
result = DatabaseHandler
.getInstance(getContext())
.getReadableDatabase()
.query(Notification.TABLE_NAME, projection, selection,
selectionArgs, null, null, sortOrder);
result.setNotificationUri(getContext().getContentResolver(), uri);
break;
case Notification.WITHTASKQUERYCODE:
// Create view if not exists
DatabaseHandler.getInstance(getContext()).getWritableDatabase()
.execSQL(Notification.CREATE_JOINED_VIEW);
result = DatabaseHandler
.getInstance(getContext())
.getReadableDatabase()
.query(Notification.WITH_TASK_VIEW_NAME, projection,
selection, selectionArgs, null, null, sortOrder);
result.setNotificationUri(getContext().getContentResolver(), uri);
break;
case RemoteTaskList.BASEURICODE:
result = DatabaseHandler
.getInstance(getContext())
.getReadableDatabase()
.query(RemoteTaskList.TABLE_NAME, projection, selection,
selectionArgs, null, null, sortOrder);
result.setNotificationUri(getContext().getContentResolver(), uri);
break;
case RemoteTask.BASEURICODE:
result = DatabaseHandler
.getInstance(getContext())
.getReadableDatabase()
.query(RemoteTask.TABLE_NAME, projection, selection,
selectionArgs, null, null, sortOrder);
result.setNotificationUri(getContext().getContentResolver(), uri);
break;
case Task.SEARCHCODE:
result = DatabaseHandler
.getInstance(getContext())
.getReadableDatabase()
.query(Task.TABLE_NAME,
Task.Columns.FIELDS,
Task.Columns._ID + " IN (SELECT "
+ Task.Columns._ID + " FROM "
+ Task.FTS3_TABLE_NAME + " WHERE "
+ Task.FTS3_TABLE_NAME + " MATCH ?)",
sanitize(selectionArgs), null, null, sortOrder);
result.setNotificationUri(getContext().getContentResolver(),
Task.URI_SEARCH);
break;
case TaskList.LEGACYBASEURICODE:
case TaskList.LEGACYVISIBLEURICODE:
result = DatabaseHandler
.getInstance(getContext())
.getReadableDatabase()
.query(TaskList.TABLE_NAME,
LegacyDBHelper.convertLegacyColumns(projection),
null, null, null, null, sortOrder);
result.setNotificationUri(getContext().getContentResolver(),
TaskList.URI);
break;
case TaskList.LEGACYBASEITEMCODE:
case TaskList.LEGACYVISIBLEITEMCODE:
id = Long.parseLong(uri.getLastPathSegment());
result = DatabaseHandler
.getInstance(getContext())
.getReadableDatabase()
.query(TaskList.TABLE_NAME,
LegacyDBHelper.convertLegacyColumns(projection),
TaskList.whereIdIs(selection),
TaskList.joinArrays(selectionArgs,
new String[] { String.valueOf(id) }), null,
null, sortOrder);
result.setNotificationUri(getContext().getContentResolver(),
TaskList.getUri(id));
break;
case Task.LEGACYBASEURICODE:
case Task.LEGACYVISIBLEURICODE:
final Cursor c = DatabaseHandler
.getInstance(getContext())
.getReadableDatabase()
.query(Task.TABLE_NAME,
LegacyDBHelper.convertLegacyColumns(projection),
null, null, null, null, Task.Columns.DUE);
result = new MatrixCursor(projection);
while (c.moveToNext()) {
((MatrixCursor) result).addRow(LegacyDBHelper
.convertLegacyTaskValues(c));
}
c.close();
result.setNotificationUri(getContext().getContentResolver(),
Task.URI);
break;
case Task.SEARCHSUGGESTIONSCODE:
final String limit = uri
.getQueryParameter(SearchManager.SUGGEST_PARAMETER_LIMIT);
result = DatabaseHandler
.getInstance(getContext())
.getReadableDatabase()
.query(Task.FTS3_TABLE_NAME,
new String[] {
Task.Columns._ID,
Task.Columns._ID
+ " AS "
+ SearchManager.SUGGEST_COLUMN_INTENT_DATA_ID,
Task.Columns.TITLE
+ " AS "
+ SearchManager.SUGGEST_COLUMN_TEXT_1,
Task.Columns.NOTE
+ " AS "
+ SearchManager.SUGGEST_COLUMN_TEXT_2 },
Task.FTS3_TABLE_NAME + " MATCH ?",
sanitize(selectionArgs), null, null,
SearchManager.SUGGEST_COLUMN_TEXT_1, limit);
result.setNotificationUri(getContext().getContentResolver(),
Task.URI_SEARCH);
break;
// These legacy URIs will not be supported
case Task.LEGACYBASEITEMCODE:
case Task.LEGACYVISIBLEITEMCODE:
default:
NnnLogger.debug(MyContentProvider.class, "Faulty queryURI provided: " + uri);
return null;
}
return result;
}
private String[] sanitize(final String... args) {
if (args.length == 0) return new String[] { "" };
final StringBuilder result = new StringBuilder();
for (String query : args) {
// for (String part : query.split("\\s")) {
if (result.length() > 0) result.append(" AND ");
// Wrap each word in quotes and add star to the end
result.append("'").append(query).append("*'");
// }
}
return new String[] { result.toString() };
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/database/Notification.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.database;
import android.content.ContentValues;
import android.content.Context;
import android.content.UriMatcher;
import android.database.Cursor;
import android.net.Uri;
import android.provider.BaseColumns;
import android.view.View;
import androidx.annotation.Nullable;
import com.nononsenseapps.helpers.NotificationHelper;
import com.nononsenseapps.helpers.TimeFormatter;
import com.nononsenseapps.notepad.R;
import com.nononsenseapps.ui.WeekDaysView;
import org.json.JSONException;
import org.json.JSONObject;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.List;
import java.util.concurrent.Executors;
/**
* A model for the SQLite table where reminders are saved
*/
public class Notification extends DAO {
// TODO see if you can rename this to "Reminder" without ruining the database logic
// These match WeekDaysView's values
public static final int mon = 0x1;
public static final int tue = 0x10;
public static final int wed = 0x100;
public static final int thu = 0x1000;
public static final int fri = 0x10000;
public static final int sat = 0x100000;
public static final int sun = 0x1000000;
// TODO maybe with more flags we can add special types of repeat,
// but we have to use long instead of int:
// public static final long nextMonth = 0x100000000;
// TODO see if you can move these flags to an enum
// Location repeat, one left of sun
public static final int locationRepeat = 0x10000000;
// SQL convention says Table name should be "singular"
public static final String TABLE_NAME = "notification";
public static final String WITH_TASK_VIEW_NAME = "notification_with_tasks";
public static final String WITH_TASK_PATH = TABLE_NAME + "/with_task_info";
public static final String CONTENT_TYPE = "vnd.android.cursor.item/vnd.nononsenseapps."
+ TABLE_NAME;
public static final Uri URI = Uri.withAppendedPath(
Uri.parse(MyContentProvider.SCHEME + MyContentProvider.AUTHORITY), TABLE_NAME);
public static final Uri URI_WITH_TASK_PATH = Uri.withAppendedPath(
Uri.parse(MyContentProvider.SCHEME + MyContentProvider.AUTHORITY), WITH_TASK_PATH);
public static final int BASEURICODE = 301;
public static final int BASEITEMCODE = 302;
public static final int WITHTASKQUERYCODE = 303;
public static final int WITHTASKQUERYITEMCODE = 304;
public static void addMatcherUris(UriMatcher sURIMatcher) {
sURIMatcher.addURI(MyContentProvider.AUTHORITY, TABLE_NAME, BASEURICODE);
sURIMatcher.addURI(MyContentProvider.AUTHORITY, TABLE_NAME + "/#", BASEITEMCODE);
sURIMatcher.addURI(MyContentProvider.AUTHORITY, WITH_TASK_PATH, WITHTASKQUERYCODE);
sURIMatcher.addURI(MyContentProvider.AUTHORITY, WITH_TASK_PATH + "/#",
WITHTASKQUERYITEMCODE);
}
public static Uri getUri(final long id) {
return Uri.withAppendedPath(URI, Long.toString(id));
}
public static class Columns implements BaseColumns {
private Columns() {}
public static final String TIME = "time";
public static final String PERMANENT = "permanent";
public static final String TASKID = "taskid";
public static final String REPEATS = "repeats";
public static final String LATITUDE = "latitude";
public static final String LONGITUDE = "longitude";
public static final String RADIUS = "radius";
public static final String LOCATIONNAME = "locationname";
public static final String[] FIELDS = { _ID, TIME, PERMANENT, TASKID, REPEATS,
LOCATIONNAME, LATITUDE, LONGITUDE, RADIUS };
}
public static class ColumnsWithTask extends Columns {
private ColumnsWithTask() {}
// public static final String notificationPrefix = "n.";
public static final String taskPrefix = "t_";
public static final String listPrefix = "l_";
public static final String[] FIELDS = joinArrays(
// prefixArray(notificationPrefix, Columns.FIELDS),
Columns.FIELDS,
prefixArray(taskPrefix, Task.Columns.SHALLOWFIELDS),
prefixArray(listPrefix, TaskList.Columns.SHALLOWFIELDS));
}
/**
* Main table to store notification data
*/
public static final String CREATE_TABLE = "CREATE TABLE " +
TABLE_NAME +
"(" +
Columns._ID +
" INTEGER PRIMARY KEY," +
Columns.TIME +
" INTEGER," +
Columns.PERMANENT +
" INTEGER NOT NULL DEFAULT 0," +
Columns.TASKID +
" INTEGER," +
// Interpreted binary
Columns.REPEATS +
" INTEGER NOT NULL DEFAULT 0," +
// Location data
Columns.LOCATIONNAME + " TEXT," +
Columns.LATITUDE + " REAL, " +
Columns.LONGITUDE +
" REAL, " +
Columns.RADIUS +
" REAL, " +
// Foreign key for task
"FOREIGN KEY(" + Columns.TASKID +
") REFERENCES " + Task.TABLE_NAME + "(" +
Task.Columns._ID + ") ON DELETE CASCADE" +
")";
/**
* View that joins relevant data from tasks and lists tables
*/
public static final String CREATE_JOINED_VIEW = "CREATE TEMP VIEW IF NOT EXISTS " +
WITH_TASK_VIEW_NAME + " AS " + " SELECT " +
// Notifications as normal column names
arrayToCommaString(TABLE_NAME + ".", Columns.FIELDS) + "," +
// Rest gets prefixed
arrayToCommaString("t.", Task.Columns.SHALLOWFIELDS, " AS "
+ ColumnsWithTask.taskPrefix + "%1$s") + "," +
arrayToCommaString("l.", TaskList.Columns.SHALLOWFIELDS, " AS "
+ ColumnsWithTask.listPrefix + "%1$s") +
" FROM " + TABLE_NAME + "," + Task.TABLE_NAME + " AS t," + TaskList.TABLE_NAME
+ " AS l " + " WHERE " + TABLE_NAME + "." + Columns.TASKID + " = t."
+ Task.Columns._ID + " AND t." + Task.Columns.DBLIST + " = l." +
TaskList.Columns._ID + ";";
/**
* A {@link Task} can have reminders, which are {@link Notification} objects.
* They are used to show (android) notifications at a user-provided day and time.
* Here it is expressed in milliseconds since 1970-01-01 UTC.
*/
public Long time = null;
/**
* It's initialized, but never used. I think I'll use it for sticky reminder notifications!
*/
public boolean permanent = false;
/**
* The {@link Task#_id} of the {@link Task} that this reminder is for
*/
public Long taskID = null;
/**
* flags to indicate on which week days the note repeats. See {@link #sat} for example
*/
public long repeats = 0;
// TODO make "repeats" private, and use .isRepeating() instead
public String locationName = null;
public Double latitude = null;
public Double longitude = null;
public Double radius = null;
// Read only, fetched from VIEW
public String listTitle = null;
public Long listID = null;
public String taskTitle = null;
public String taskNote = null;
// Convenience for the editor
public View view = null;
/**
* Must be associated with a task
*/
public Notification(final long taskID) {
this.taskID = taskID;
}
public Notification(final Cursor c) {
_id = c.getLong(0);
time = c.isNull(1) ? null : c.getLong(1);
permanent = 1 == c.getLong(2);
taskID = c.isNull(3) ? null : c.getLong(3);
repeats = c.getLong(4);
locationName = c.isNull(5) ? null : c.getString(5);
latitude = c.isNull(6) ? null : c.getDouble(6);
longitude = c.isNull(7) ? null : c.getDouble(7);
radius = c.isNull(8) ? null : c.getDouble(8);
// if cursor has more fields, then assume it was constructed with
// the WITH_TASKS view query
if (c.getColumnCount() > 9) {
int idx_list = c.getColumnIndex(ColumnsWithTask.listPrefix + TaskList.Columns.TITLE);
int idx_id = c.getColumnIndex(ColumnsWithTask.listPrefix + TaskList.Columns._ID);
int idx_title = c.getColumnIndex(ColumnsWithTask.taskPrefix + Task.Columns.TITLE);
int idx_note = c.getColumnIndex(ColumnsWithTask.taskPrefix + Task.Columns.NOTE);
listTitle = c.getString(idx_list);
listID = c.getLong(idx_id);
taskTitle = c.getString(idx_title);
taskNote = c.getString(idx_note);
}
}
public Notification(final Uri uri, final ContentValues values) {
this(Long.parseLong(uri.getLastPathSegment()), values);
}
public Notification(final long id, final ContentValues values) {
this(values);
_id = id;
}
/**
* @param uri like content://com.nononsenseapps.NotePad/notification/1
* @return the {@link Notification} with the {@link #_id} in the given {@link Uri}, or NULL
* if it didn't find (exactly) one
*/
@Nullable
public static Notification fromUri(final Uri uri, final Context context) {
final Cursor c = context
.getContentResolver()
.query(uri, Columns.FIELDS, null, null, null);
assert c != null;
Notification n = null;
if (c.getCount() == 1) {
c.moveToFirst();
n = new Notification(c);
}
c.close();
return n;
}
public Notification(final JSONObject json) throws JSONException {
if (json.has(Columns.TIME))
time = json.getLong(Columns.TIME);
if (json.has(Columns.PERMANENT))
permanent = 1 == json.getLong(Columns.PERMANENT);
if (json.has(Columns.TASKID))
taskID = json.getLong(Columns.TASKID);
if (json.has(Columns.REPEATS))
repeats = json.getLong(Columns.REPEATS);
if (json.has(Columns.LOCATIONNAME))
locationName = json.getString(Columns.LOCATIONNAME);
if (json.has(Columns.LATITUDE))
latitude = json.getDouble(Columns.LATITUDE);
if (json.has(Columns.LONGITUDE))
longitude = json.getDouble(Columns.LONGITUDE);
if (json.has(Columns.RADIUS))
radius = json.getDouble(Columns.RADIUS);
}
public Notification(final ContentValues values) {
time = values.getAsLong(Columns.TIME);
permanent = 1 == values.getAsLong(Columns.PERMANENT);
taskID = values.getAsLong(Columns.TASKID);
repeats = values.getAsLong(Columns.REPEATS);
locationName = values.getAsString(Columns.LOCATIONNAME);
latitude = values.getAsDouble(Columns.LATITUDE);
longitude = values.getAsDouble(Columns.LONGITUDE);
radius = values.getAsDouble(Columns.RADIUS);
}
@Override
public ContentValues getContent() {
final ContentValues values = new ContentValues();
values.put(Columns.TIME, time);
values.put(Columns.TASKID, taskID);
values.put(Columns.PERMANENT, permanent ? 1 : 0);
values.put(Columns.REPEATS, repeats);
values.put(Columns.LOCATIONNAME, locationName);
values.put(Columns.LATITUDE, latitude);
values.put(Columns.LONGITUDE, longitude);
values.put(Columns.RADIUS, radius);
return values;
}
@Override
protected String getTableName() {
return TABLE_NAME;
}
@Override
public String getContentType() {
return CONTENT_TYPE;
}
/**
* Returns date and time formatted in text in local time zone
*/
public CharSequence getLocalDateTimeText(final Context context) {
return TimeFormatter.getLocalDateStringLong(context, time);
}
/**
* Returns time formatted in text in local time zone
*/
public CharSequence getLocalTimeText(final Context context) {
return TimeFormatter.getLocalTimeOnlyString(context, time);
}
/**
* Returns date formatted in text in local time zone
*/
public CharSequence getLocalDateText(final Context context) {
return TimeFormatter.getDateFormatter(context).format(new Date(time));
}
/**
* Calls {@link #insert} or performs an update, depending on the status of this
* {@link Notification} object
*/
@Override
public int save(final Context context) {
int result = 0;
if (_id < 1) {
result += insert(context);
} else {
result += context
.getContentResolver()
.update(getUri(), getContent(), null, null);
if (result < 1) {
// To allow editor to edit deleted notifications
result += insert(context);
}
}
return result;
}
/**
* Inserts this {@link Notification} as a new record into its SQLite table
* and sets its {@link #_id}
*
* @return 1 if it worked, 0 if it failed
*/
private int insert(final Context context) {
int result = 0;
final Uri uri = context.getContentResolver().insert(getBaseUri(), getContent());
if (uri != null) {
_id = Long.parseLong(uri.getLastPathSegment());
result++;
}
return result;
}
/**
* If true, will also schedule/notify android notifications
*/
public void save(final Context context, final boolean schedule) {
int result = save(context);
if (schedule) {
// First cancel any potentially old versions
NotificationHelper.cancelNotification(context, this);
// Then reschedule
NotificationHelper.schedule(context);
}
}
/**
* Delete the record of this {@link Notification} from the database and remove the
* associated {@link android.app.Notification} from the system's tray
*
* @return 1 if it was deleted, 0 otherwise
*/
@Override
public int delete(final Context context) {
// Make sure existing notifications are cancelled.
NotificationHelper.cancelNotification(context, this);
return super.delete(context);
}
public void saveInBackground(final Context context, final boolean schedule) {
Executors.newSingleThreadExecutor().execute(() -> save(context, schedule));
}
/**
* Starts a background task that removes all notifications associated with
* the specified tasks.
*/
public static void removeWithTaskIds(final Context context, final Long... ids) {
if (ids.length > 0) {
// replacement for AsyncTask<,,>
Executors.newSingleThreadExecutor().execute(() -> {
// Background work here
removeWithTaskIdsSynced(context, ids);
});
}
}
/**
* Removes all notifications associated with the specified tasks. Runs in
* the same thread as the caller.
*/
public static void removeWithTaskIdsSynced(final Context context, final Long... ids) {
String idStrings = "(";
ArrayList idsToClear = new ArrayList<>();
for (Long id : ids) {
idStrings += id + ",";
idsToClear.add(Long.toString(id));
}
idStrings = idStrings.substring(0, idStrings.length() - 1);
idStrings += ")";
final Cursor c = context
.getContentResolver()
.query(URI, Columns.FIELDS, Columns.TASKID + " IN " + idStrings,
null, null);
assert c != null;
while (c.moveToNext()) {
// Yes dont just call delete in database
// We have to remove geofences (in delete)
Notification n = new Notification(c);
n.delete(context);
}
c.close();
}
/**
* Delete or reschedule a specific notification.
*
* @param uri like content://com.nononsenseapps.NotePad/notification/1
*/
public static void deleteOrReschedule(final Context context, final Uri uri) {
final Cursor c = context
.getContentResolver()
.query(uri, Columns.FIELDS, null, null, null);
assert c != null;
while (c.moveToNext()) {
Notification n = new Notification(c);
n.deleteOrReschedule(context);
}
c.close();
}
/**
* Returns list of notifications coupled to specified task, sorted by time
*/
public static List getNotificationsOfTask(final Context context, final long taskId) {
return getNotificationsWithTasks(context, Columns.TASKID + " IS ?",
new String[] { Long.toString(taskId) }, Columns.TIME);
}
/**
* @return a list of notifications occurring after/before specified time,
* and which do not have a location (radius == null). Sorted by time
* ascending
*/
public static List getNotificationsWithTime(final Context context,
final long time,
final boolean before) {
final String comparison = before ? " <= ?" : " > ?";
return getNotificationsWithTasks(context,
Columns.TIME + comparison + " AND " + Columns.RADIUS + " IS NULL",
new String[] { Long.toString(time) }, Columns.TIME);
}
public static List getNotificationsWithTasks(final Context context,
final String where,
final String[] whereArgs,
final String sortOrder) {
ArrayList list = new ArrayList<>();
final Cursor c = context
.getContentResolver()
.query(URI_WITH_TASK_PATH, null, where, whereArgs, sortOrder);
if (c != null) {
while (c.moveToNext()) {
list.add(new Notification(c));
}
c.close();
}
return list;
}
/**
* Used for snooze, only for non-repeating reminders
*
* @param newTime from {@link NotificationHelper#getSnoozedReminderNewTimeMillis}
*/
public static void setTime(final Context context, final Uri uri, final long newTime) {
final ContentValues values = new ContentValues();
values.put(Columns.TIME, newTime);
// Use base ID to bypass type checks
context.getContentResolver()
.update(URI, values, Columns._ID + " IS ?",
new String[] { uri.getLastPathSegment() });
}
/**
* Used for snooze
*/
public static void setTimeForListAndBefore(final Context context, final long listId,
final long maxTime, final long newTime) {
// replacement for AsyncTask<,,>
Executors.newSingleThreadExecutor().execute(() -> {
// Background work here
// First get the list of tasks in that list
final Cursor c = context.getContentResolver()
.query(Task.URI, Task.Columns.FIELDS, Task.Columns.DBLIST
+ " IS ? AND "
+ com.nononsenseapps.notepad.database.Notification.Columns.RADIUS
+ " IS NULL", new String[] { Long.toString(listId) },
null);
assert c != null;
String idStrings = "(";
while (c.moveToNext()) {
idStrings += c.getLong(0) + ",";
}
c.close();
idStrings = idStrings.substring(0, idStrings.length() - 1);
idStrings += ")";
final ContentValues values = new ContentValues();
values.put(Columns.TIME, newTime);
context.getContentResolver().update(URI, values,
Columns.TIME + " <= " + maxTime + " AND " + Columns.TASKID + " IN "
+ idStrings, null);
});
}
/**
* Returns true if the notification repeats on the given day. Day of the
* week as given by Calendar.getField(DayOfWeek)
*/
public boolean repeatsOn(final int calendarDay) {
int day = switch (calendarDay) {
case Calendar.MONDAY -> WeekDaysView.mon;
case Calendar.TUESDAY -> WeekDaysView.tue;
case Calendar.WEDNESDAY -> WeekDaysView.wed;
case Calendar.THURSDAY -> WeekDaysView.thu;
case Calendar.FRIDAY -> WeekDaysView.fri;
case Calendar.SATURDAY -> WeekDaysView.sat;
case Calendar.SUNDAY -> WeekDaysView.sun;
default -> 0;
};
return (0 < (day & repeats));
}
/**
* If this {@link Notification} is a repeating reminder, set the {@link #time} to the next
* applicable day, A.K.A. reschedule it. If it is non-repeating, simply delete it.
*/
public void deleteOrReschedule(final Context context) {
if (!this.isRepeating() || time == null) {
// non-repeating reminder: just delete it
delete(context);
return;
}
// repeating reminder: re-schedule it
// Need to set the correct time, but using today as the date
// Because no sense in setting reminders in the past
GregorianCalendar gcWasFired = new GregorianCalendar();
gcWasFired.setTimeInMillis(time);
// Use today's date
GregorianCalendar gcToSchedule = new GregorianCalendar();
final long now = gcToSchedule.getTimeInMillis();
// With original time
gcToSchedule.set(GregorianCalendar.HOUR_OF_DAY, gcWasFired.get(GregorianCalendar.HOUR_OF_DAY));
gcToSchedule.set(GregorianCalendar.MINUTE, gcWasFired.get(GregorianCalendar.MINUTE));
// this variable saves a moment in unix milliseconds:
// * the day is today, when this function runs
// * the hour & minute are those in which the Notification was scheduled to appear
final long base = gcToSchedule.getTimeInMillis();
// Check today if the time is actually in the future.
// For example if this function runs at "now" = 19:30 to reschedule a reminder that was
// planned for "base" = 19:15, then "now" is > "base", therefore start = 1
final int start = now < base ? 0 : 1;
boolean done = false;
for (int i = start; i <= 7; i++) {
if (i!=0) {
// add a day to the hypotized new due date.
// It automatically handles the transitions between ST and DST
gcToSchedule.add(Calendar.DAY_OF_MONTH, 1);
}
// check if the reminder should repeat on this day
if (repeatsOn(gcToSchedule.get(GregorianCalendar.DAY_OF_WEEK))) {
// we found the 1° day in which the reminder needs to repeat: save that as the
// new "due time" in the database, and we're done.
done = true;
time = gcToSchedule.getTimeInMillis();
save(context);
break;
}
}
// Just in case of faulty repeat codes
if (!done) {
delete(context);
}
}
public String getRepeatAsText(final Context context) {
final StringBuilder sb = new StringBuilder();
SimpleDateFormat weekDayFormatter = TimeFormatter.getLocalFormatterWeekdayShort(context);
// 2013-05-13 was a monday
GregorianCalendar gc = new GregorianCalendar(2013, GregorianCalendar.MAY, 13);
final long base = gc.getTimeInMillis();
final long day = 24 * 60 * 60 * 1000;
for (int i = 0; i < 7; i++) {
gc.setTimeInMillis(base + i * day);
if (repeatsOn(gc.get(GregorianCalendar.DAY_OF_WEEK))) {
if (sb.length() > 0) {
sb.append(", ");
}
sb.append(weekDayFormatter.format(gc.getTime()));
}
}
return sb.toString();
}
/**
* Notifications (=Reminders) can be "repeating reminders": they are supposed to re-appear
* in one or more week days
*
* @return TRUE if this {@link Notification} is a repeating reminder
* @implNote See {@link #mon} and {@link #sun}
*/
public boolean isRepeating() {
// "repeats == 0x1000001" means that the note repeats on monday and sunday, for example
return this.repeats != 0;
}
/**
* a {@link Notification} belongs to a {@link Task} which belongs to a {@link TaskList}
* which can be of 2 types: "simple notes" or "checkable tasks"
*
* @return TRUE if this reminder is for a "checkable task", FALSE if it is for a "simple note",
* NULL if it could not be determined
*/
@Nullable
public Boolean belongsToNoteInListOfTasks(final Context context) {
Cursor cc = context
.getContentResolver()
.query(TaskList.URI, null, TaskList.Columns._ID + " IS ?",
new String[] { Long.toString(this.listID) }, null);
TaskList result = null;
assert cc != null;
if (cc.moveToFirst()) result = new TaskList(cc);
cc.close();
if (result == null) return null;
return context.getString(R.string.const_listtype_tasks).equals(result.listtype);
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/database/RemoteTask.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.database;
import android.content.ContentValues;
import android.content.Context;
import android.content.UriMatcher;
import android.database.Cursor;
import android.net.Uri;
import android.provider.BaseColumns;
import org.json.JSONException;
import org.json.JSONObject;
public class RemoteTask extends DAO {
// SQL convention says Table name should be "singular"
public static final String TABLE_NAME = "remotetask";
public static final String CONTENT_TYPE = "vnd.android.cursor.item/vnd.nononsenseapps."
+ TABLE_NAME;
public static final Uri URI = Uri.withAppendedPath(
Uri.parse(MyContentProvider.SCHEME + MyContentProvider.AUTHORITY),
TABLE_NAME);
public static final int BASEURICODE = 501;
public static final int BASEITEMCODE = 502;
public static void addMatcherUris(UriMatcher sURIMatcher) {
sURIMatcher
.addURI(MyContentProvider.AUTHORITY, TABLE_NAME, BASEURICODE);
sURIMatcher.addURI(MyContentProvider.AUTHORITY, TABLE_NAME + "/#",
BASEITEMCODE);
}
public static Uri getUri(final long id) {
return Uri.withAppendedPath(URI, Long.toString(id));
}
public static class Columns implements BaseColumns {
private Columns() {
}
public static final String SERVICE = "service";
public static final String ACCOUNT = "account";
public static final String REMOTEID = "remoteid";
public static final String UPDATED = "updated";
public static final String DBID = "dbid";
public static final String LISTDBID = "listdbid";
// Reserved columns, depending on what different services needs
public static final String DELETED = "field1";
public static final String FIELD2 = "field2";
public static final String FIELD3 = "field3";
public static final String FIELD4 = "field4";
public static final String FIELD5 = "field5";
public static final String[] FIELDS = { _ID, DBID, REMOTEID, UPDATED,
ACCOUNT, LISTDBID, DELETED, FIELD2, FIELD3, FIELD4, FIELD5, SERVICE };
}
/**
* Main table to store data
*/
public static final String CREATE_TABLE = "CREATE TABLE " +
TABLE_NAME + "(" + Columns._ID +
" INTEGER PRIMARY KEY," + Columns.ACCOUNT +
" TEXT NOT NULL," + Columns.SERVICE +
" TEXT NOT NULL," + Columns.DBID +
" INTEGER NOT NULL," + Columns.UPDATED +
" INTEGER NOT NULL," + Columns.REMOTEID +
" TEXT NOT NULL," + Columns.LISTDBID +
" INTEGER NOT NULL," + Columns.DELETED +
" TEXT," + Columns.FIELD2 + " TEXT," +
Columns.FIELD3 + " TEXT," + Columns.FIELD4 +
" TEXT," + Columns.FIELD5 + " TEXT" +
// Cant delete on cascade because we must sync before!
")";
/*
* Trigger to delete items when their list is deleted
*/
public static final String TRIGGER_LISTDELETE_CASCADE = "CREATE TRIGGER cascade_trigger_delete_" +
TABLE_NAME + " AFTER DELETE ON " +
RemoteTaskList.TABLE_NAME + " BEGIN " +
" DELETE FROM " + TABLE_NAME + " WHERE " +
Columns.LISTDBID + " IS old." +
RemoteTaskList.Columns.DBID + " AND " +
Columns.ACCOUNT + " IS old." +
RemoteTaskList.Columns.ACCOUNT + " AND " +
Columns.SERVICE + " IS old." +
RemoteTaskList.Columns.SERVICE + ";" + " END;";
/*
* Trigger to delete items when their real items are deleted
*/
public static final String TRIGGER_REALDELETE_MARK = "CREATE TRIGGER trigger_real_deletemark_" +
TABLE_NAME + " AFTER DELETE ON " +
Task.TABLE_NAME + " BEGIN " + " UPDATE " +
TABLE_NAME + " SET " + Columns.DELETED +
" = 'deleted' " + " WHERE " + Columns.DBID +
" IS old." + Task.Columns._ID + ";" +
" END;";
/*
* Trigger to move between lists
*/
public static final String TRIGGER_MOVE_LIST = "CREATE TRIGGER trigger_move_list_" + TABLE_NAME +
" AFTER UPDATE OF " + Task.Columns.DBLIST +
" ON " + Task.TABLE_NAME + " WHEN old." +
Task.Columns.DBLIST + " IS NOT new." +
Task.Columns.DBLIST + " BEGIN " + " UPDATE " +
TABLE_NAME + " SET " + Columns.DELETED +
" = 'deleted', " + Columns.DBID + " = -99 " +
" WHERE " + Columns.DBID + " IS old." +
Task.Columns._ID + ";" + " END;";
// milliseconds since 1970-01-01 UTC
public Long updated = null;
public Long dbid = null;
public Long listdbid = null;
public String account = null;
public String remoteId = null;
public String deleted = null;
public String field2 = null;
public String field3 = null;
public String field4 = null;
public String field5 = null;
public boolean isDeleted() {
return deleted != null && deleted.equals("deleted");
}
public void setDeleted(final boolean deleted) {
this.deleted = deleted ? "deleted" : null;
}
// Should be overwritten by children
public String service = null;
public RemoteTask() {
}
/**
* None of the fields may be null!
*/
public RemoteTask(final Long dbid, final Long listdbid,
final String remoteId, final Long updated, final String account) {
this.dbid = dbid;
this.listdbid = listdbid;
this.remoteId = remoteId;
this.updated = updated;
this.account = account;
}
public RemoteTask(final Cursor c) {
_id = c.getLong(0);
dbid = c.getLong(1);
remoteId = c.getString(2);
updated = c.getLong(3);
account = c.getString(4);
listdbid = c.getLong(5);
deleted = c.isNull(6) ? null : c.getString(6);
field2 = c.isNull(7) ? null : c.getString(7);
field3 = c.isNull(8) ? null : c.getString(8);
field4 = c.isNull(9) ? null : c.getString(9);
field5 = c.isNull(10) ? null : c.getString(10);
service = c.getString(11);
}
public RemoteTask(final Uri uri, final ContentValues values) {
this(Long.parseLong(uri.getLastPathSegment()), values);
}
public RemoteTask(final long id, final ContentValues values) {
this(values);
_id = id;
}
public RemoteTask(final JSONObject json) throws JSONException {
if (json.has(Columns.DBID))
dbid = json.getLong(Columns.DBID);
if (json.has(Columns.REMOTEID))
remoteId = json.getString(Columns.REMOTEID);
if (json.has(Columns.UPDATED))
updated = json.getLong(Columns.UPDATED);
if (json.has(Columns.ACCOUNT))
account = json.getString(Columns.ACCOUNT);
if (json.has(Columns.SERVICE))
service = json.getString(Columns.SERVICE);
if (json.has(Columns.LISTDBID))
listdbid = json.getLong(Columns.LISTDBID);
if (json.has(Columns.DELETED))
deleted = json.getString(Columns.DELETED);
if (json.has(Columns.FIELD2))
field2 = json.getString(Columns.FIELD2);
if (json.has(Columns.FIELD3))
field3 = json.getString(Columns.FIELD3);
if (json.has(Columns.FIELD4))
field4 = json.getString(Columns.FIELD4);
if (json.has(Columns.FIELD5))
field5 = json.getString(Columns.FIELD5);
}
public RemoteTask(final ContentValues values) {
dbid = values.getAsLong(Columns.DBID);
remoteId = values.getAsString(Columns.REMOTEID);
updated = values.getAsLong(Columns.UPDATED);
account = values.getAsString(Columns.ACCOUNT);
service = values.getAsString(Columns.SERVICE);
listdbid = values.getAsLong(Columns.LISTDBID);
deleted = values.getAsString(Columns.DELETED);
field2 = values.getAsString(Columns.FIELD2);
field3 = values.getAsString(Columns.FIELD3);
field4 = values.getAsString(Columns.FIELD4);
field5 = values.getAsString(Columns.FIELD5);
}
@Override
public ContentValues getContent() {
final ContentValues values = new ContentValues();
values.put(Columns.DBID, dbid);
values.put(Columns.LISTDBID, listdbid);
values.put(Columns.REMOTEID, remoteId);
values.put(Columns.UPDATED, updated);
values.put(Columns.ACCOUNT, account);
values.put(Columns.SERVICE, service);
values.put(Columns.DELETED, deleted);
values.put(Columns.FIELD2, field2);
values.put(Columns.FIELD3, field3);
values.put(Columns.FIELD4, field4);
values.put(Columns.FIELD5, field5);
return values;
}
@Override
protected String getTableName() {
return TABLE_NAME;
}
@Override
public String getContentType() {
return CONTENT_TYPE;
}
@Override
public int save(final Context context) {
int result = 0;
if (_id < 1) {
final Uri uri = context.getContentResolver().insert(getBaseUri(),
getContent());
if (uri != null) {
_id = Long.parseLong(uri.getLastPathSegment());
result++;
}
} else {
result += context.getContentResolver().update(getUri(),
getContent(), null, null);
}
return result;
}
/**
* Returns a where clause that can be used to fetch the task that is
* associated with this remote object. As argument, use remoteid, account
*/
public String getTaskWithRemoteClause() {
return Task.Columns.DBLIST + " IS ? AND " +
BaseColumns._ID + " IN (SELECT " +
Columns.DBID + " FROM " + TABLE_NAME +
" WHERE " + Columns.REMOTEID +
" IS ? AND " + Columns.ACCOUNT + " IS ?)";
}
public String[] getTaskWithRemoteArgs() {
return new String[] { Long.toString(listdbid), remoteId, account };
}
/**
* Returns a where clause that limits the tasklists to those that do not
* have a remote version.
*
* Combine with account
*/
public static String getTaskWithoutRemoteClause() {
return Task.Columns.DBLIST + " IS ? AND " +
BaseColumns._ID + " NOT IN (SELECT " +
Columns.DBID + " FROM " + TABLE_NAME +
" WHERE " + Columns.ACCOUNT + " IS ? AND " +
Columns.SERVICE + " IS ?)";
}
public static String[] getTaskWithoutRemoteArgs(final long listdbid,
final String account, final String service) {
return new String[] { Long.toString(listdbid), account, service };
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/database/RemoteTaskList.java
================================================
/*
* Copyright (c) 2015. Jonas Kalderstam
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.database;
import android.content.ContentValues;
import android.content.Context;
import android.content.UriMatcher;
import android.database.Cursor;
import android.net.Uri;
import android.provider.BaseColumns;
import org.json.JSONException;
import org.json.JSONObject;
public class RemoteTaskList extends DAO {
// SQL convention says Table name should be "singular"
public static final String TABLE_NAME = "remotetasklist";
public static final String CONTENT_TYPE = "vnd.android.cursor.item/vnd.nononsenseapps."
+ TABLE_NAME;
public static final Uri URI = Uri.withAppendedPath(
Uri.parse(MyContentProvider.SCHEME + MyContentProvider.AUTHORITY),
TABLE_NAME);
public static final int BASEURICODE = 401;
public static final int BASEITEMCODE = 402;
public static void addMatcherUris(UriMatcher sURIMatcher) {
sURIMatcher
.addURI(MyContentProvider.AUTHORITY, TABLE_NAME, BASEURICODE);
sURIMatcher.addURI(MyContentProvider.AUTHORITY, TABLE_NAME + "/#",
BASEITEMCODE);
}
public static Uri getUri(final long id) {
return Uri.withAppendedPath(URI, Long.toString(id));
}
public static class Columns implements BaseColumns {
private Columns() {
}
public static final String SERVICE = "service";
public static final String ACCOUNT = "account";
public static final String REMOTEID = "remoteid";
public static final String UPDATED = "updated";
public static final String DBID = "dbid";
// Reserved columns, depending on what different services needs
public static final String DELETED = "field1";
public static final String FIELD2 = "field2";
public static final String FIELD3 = "field3";
public static final String FIELD4 = "field4";
public static final String FIELD5 = "field5";
public static final String[] FIELDS = { _ID, DBID, REMOTEID,
UPDATED, ACCOUNT, DELETED, FIELD2,
FIELD3, FIELD4, FIELD5, SERVICE };
}
/**
* Main table to store data
*/
public static final String CREATE_TABLE = "CREATE TABLE " +
TABLE_NAME +
"(" + Columns._ID + " INTEGER PRIMARY KEY," +
Columns.ACCOUNT + " TEXT NOT NULL," +
Columns.SERVICE + " TEXT NOT NULL," +
Columns.DBID + " INTEGER NOT NULL," +
Columns.UPDATED + " INTEGER NOT NULL," +
Columns.REMOTEID + " TEXT NOT NULL," +
Columns.DELETED + " TEXT," +
Columns.FIELD2 + " TEXT," +
Columns.FIELD3 + " TEXT," +
Columns.FIELD4 + " TEXT," +
Columns.FIELD5 + " TEXT" +
// Cant delete on cascade, since then we cant remember to sync it!
")";
// milliseconds since 1970-01-01 UTC
public Long updated = null;
public Long dbid = null;
public String account = null;
public String remoteId = null;
public String deleted = null;
public String field2 = null;
public String field3 = null;
public String field4 = null;
public String field5 = null;
// Should be overwritten by children
public String service = null;
public RemoteTaskList() {
}
/**
* None of the fields may be null!
*/
public RemoteTaskList(final Long dbid, final String remoteId, final Long updated,
final String account) {
this.dbid = dbid;
this.remoteId = remoteId;
this.updated = updated;
this.account = account;
}
public RemoteTaskList(final Cursor c) {
_id = c.getLong(0);
dbid = c.getLong(1);
remoteId = c.getString(2);
updated = c.getLong(3);
account = c.getString(4);
deleted = c.isNull(5) ? null : c.getString(5);
field2 = c.isNull(6) ? null : c.getString(6);
field3 = c.isNull(7) ? null : c.getString(7);
field4 = c.isNull(8) ? null : c.getString(8);
field5 = c.isNull(9) ? null : c.getString(9);
service = c.getString(10);
}
public RemoteTaskList(final Uri uri, final ContentValues values) {
this(Long.parseLong(uri.getLastPathSegment()), values);
}
public RemoteTaskList(final long id, final ContentValues values) {
this(values);
_id = id;
}
public RemoteTaskList(final JSONObject json) throws JSONException {
if (json.has(Columns.DBID))
dbid = json.getLong(Columns.DBID);
if (json.has(Columns.REMOTEID))
remoteId = json.getString(Columns.REMOTEID);
if (json.has(Columns.UPDATED))
updated = json.getLong(Columns.UPDATED);
if (json.has(Columns.ACCOUNT))
account = json.getString(Columns.ACCOUNT);
if (json.has(Columns.SERVICE))
service = json.getString(Columns.SERVICE);
if (json.has(Columns.DELETED))
deleted = json.getString(Columns.DELETED);
if (json.has(Columns.FIELD2))
field2 = json.getString(Columns.FIELD2);
if (json.has(Columns.FIELD3))
field3 = json.getString(Columns.FIELD3);
if (json.has(Columns.FIELD4))
field4 = json.getString(Columns.FIELD4);
if (json.has(Columns.FIELD5))
field5 = json.getString(Columns.FIELD5);
}
public RemoteTaskList(final ContentValues values) {
dbid = values.getAsLong(Columns.DBID);
remoteId = values.getAsString(Columns.REMOTEID);
updated = values.getAsLong(Columns.UPDATED);
account = values.getAsString(Columns.ACCOUNT);
service = values.getAsString(Columns.SERVICE);
deleted = values.getAsString(Columns.DELETED);
field2 = values.getAsString(Columns.FIELD2);
field3 = values.getAsString(Columns.FIELD3);
field4 = values.getAsString(Columns.FIELD4);
field5 = values.getAsString(Columns.FIELD5);
}
public boolean isDeleted() {
return deleted != null && !deleted.isEmpty();
}
public void setDeleted(final boolean deleted) {
this.deleted = deleted ? "deleted" : null;
}
@Override
public ContentValues getContent() {
final ContentValues values = new ContentValues();
values.put(Columns.DBID, dbid);
values.put(Columns.REMOTEID, remoteId);
values.put(Columns.UPDATED, updated);
values.put(Columns.ACCOUNT, account);
values.put(Columns.SERVICE, service);
values.put(Columns.DELETED, deleted);
values.put(Columns.FIELD2, field2);
values.put(Columns.FIELD3, field3);
values.put(Columns.FIELD4, field4);
values.put(Columns.FIELD5, field5);
return values;
}
@Override
protected String getTableName() {
return TABLE_NAME;
}
@Override
public String getContentType() {
return CONTENT_TYPE;
}
@Override
public int save(final Context context) {
int result = 0;
if (_id < 1) {
final Uri uri = context.getContentResolver().insert(getBaseUri(),
getContent());
if (uri != null) {
_id = Long.parseLong(uri.getLastPathSegment());
result++;
}
} else {
result += context.getContentResolver().update(getUri(),
getContent(), null, null);
}
return result;
}
public int save(final Context context, final long updateTime) {
updated = updateTime;
return save(context);
}
/**
* Returns a where clause that can be used to fetch the tasklist that
* is associated with this remote object.
* As argument, use remoteid, account, service
*/
public String getTaskListWithRemoteClause() {
return BaseColumns._ID + " IN (SELECT " +
Columns.DBID + " FROM " + TABLE_NAME + " WHERE " +
Columns.REMOTEID + " IS ? AND " +
Columns.ACCOUNT + " IS ? AND " +
Columns.SERVICE + " IS ?)";
}
public String[] getTaskListWithRemoteArgs() {
return new String[] { remoteId, account, service };
}
/**
* Returns a where clause that limits the tasklists to those that do not
* have a remote version.
*
* Combine with account, service
*/
public static String getTaskListWithoutRemoteClause() {
return BaseColumns._ID + " NOT IN (SELECT " +
Columns.DBID + " FROM " + TABLE_NAME + " WHERE " +
Columns.ACCOUNT + " IS ? AND " +
Columns.SERVICE + " IS ?)";
}
public String[] getTaskListWithoutRemoteArgs() {
return new String[] { account, service };
}
/*
* Trigger to delete items when their list is deleted
*/
public static final String TRIGGER_REALDELETE_MARK = "CREATE TRIGGER trigger_real_deletemark_" + TABLE_NAME +
" AFTER DELETE ON " + TaskList.TABLE_NAME + " BEGIN " +
" UPDATE " + TABLE_NAME + " SET " + Columns.DELETED + " = 'deleted' " +
" WHERE " + Columns.DBID + " IS old." + TaskList.Columns._ID +
";" +
" END;";
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/database/Task.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.database;
import android.app.SearchManager;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.UriMatcher;
import android.database.Cursor;
import android.net.Uri;
import android.provider.BaseColumns;
import androidx.annotation.NonNull;
import com.mobeta.android.dslv.DragSortListView;
import com.nononsenseapps.helpers.TimeFormatter;
import com.nononsenseapps.notepad.R;
import org.json.JSONException;
import org.json.JSONObject;
import java.security.InvalidParameterException;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.Objects;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* An object that represents the task information contained in the database.
* Provides convenience methods for moving items.
*/
public class Task extends DAO {
// Used to separate tasks with due dates from completed and from tasks with
// no date
public static final String SECRET_TYPEID = "secret_typeid";
public static final String SECRET_TYPEID2 = "secret_typeid2";
// SQL convention says Table name should be "singular"
public static final String TABLE_NAME = "task";
public static final String DELETE_TABLE_NAME = "deleted_task";
public static final String FTS3_DELETE_TABLE_NAME = "fts3_deleted_task";
public static final String HISTORY_TABLE_NAME = "history";
private static final String SECTIONED_DATE_VIEW = "sectioned_date_view";
public static final String FTS3_TABLE_NAME = "fts3_task";
public static String getSECTION_DATE_VIEW_NAME(final String listId) {
// listId CAN be null. Hence the string concat hack
return SECTIONED_DATE_VIEW + "_" + listId;
}
// Used in sectioned view date
static final String FAR_FUTURE = "strftime('%s','3999-01-01') * 1000";
public static final String OVERDUE = "strftime('%s', '1970-01-01') * 1000";
// Today should be from NOW...
public static final String TODAY_START = "strftime('%s','now', 'utc') * 1000";
/**
* A constraint which is an unix time (in ms) representing midnight of the day that is
* "offset" days after today. For example, Running {@code TODAY_PLUS(4)} on 04 jan 2023
* will return a {@code strftime(...)} that SQLite will evaluate to "1673132400000", which
* represents 00:00 of 8 jan 2023 in the user's local timezone
*
* @param offset in days
* @return a SQL string that will eventually be evaluated to something like "1673132400000"
*/
public static String TODAY_PLUS(final int offset) {
return "strftime('%s','now','localtime','+" + offset
+ " days','start of day', 'utc') * 1000";
}
// Code used to decode title of date header
public static final String HEADER_KEY_TODAY = "today+0";
public static final String HEADER_KEY_PLUS1 = "today+1";
public static final String HEADER_KEY_PLUS2 = "today+2";
public static final String HEADER_KEY_PLUS3 = "today+3";
public static final String HEADER_KEY_PLUS4 = "today+4";
public static final String HEADER_KEY_NEXT_MONTH = "next_month";
public static final String HEADER_KEY_NEXT_YEAR = "next_year";
public static final String HEADER_KEY_OVERDUE = "overdue";
public static final String HEADER_KEY_LATER = "later";
public static final String HEADER_KEY_NODATE = "nodate";
public static final String HEADER_KEY_COMPLETE = "complete";
public static final String CONTENT_TYPE = "vnd.android.cursor.item/vnd.nononsenseapps.note";
public static final Uri URI = Uri.withAppendedPath(
Uri.parse(MyContentProvider.SCHEME + MyContentProvider.AUTHORITY),
TABLE_NAME);
public static Uri getUri(final long id) {
return Uri.withAppendedPath(URI, Long.toString(id));
}
public static final int BASEURICODE = 201;
public static final int BASEITEMCODE = 202;
public static final int DELETEDQUERYCODE = 209;
public static final int DELETEDITEMCODE = 210;
public static final int SECTIONEDDATEQUERYCODE = 211;
public static final int SECTIONEDDATEITEMCODE = 212;
public static final int HISTORYQUERYCODE = 213;
public static final int MOVEITEMLEFTCODE = 214;
public static final int MOVEITEMRIGHTCODE = 215;
// Legacy support, these also need to use legacy projections
public static final int LEGACYBASEURICODE = 221;
public static final int LEGACYBASEITEMCODE = 222;
public static final int LEGACYVISIBLEURICODE = 223;
public static final int LEGACYVISIBLEITEMCODE = 224;
// Search URI
public static final int SEARCHCODE = 299;
public static final int SEARCHSUGGESTIONSCODE = 298;
public static void addMatcherUris(UriMatcher sURIMatcher) {
sURIMatcher.addURI(MyContentProvider.AUTHORITY, TABLE_NAME, BASEURICODE);
sURIMatcher.addURI(MyContentProvider.AUTHORITY, TABLE_NAME + "/#", BASEITEMCODE);
sURIMatcher.addURI(MyContentProvider.AUTHORITY,
TABLE_NAME + "/" + MOVEITEMLEFT + "/#", MOVEITEMLEFTCODE);
sURIMatcher.addURI(MyContentProvider.AUTHORITY,
TABLE_NAME + "/" + MOVEITEMRIGHT + "/#", MOVEITEMRIGHTCODE);
sURIMatcher.addURI(MyContentProvider.AUTHORITY,
TABLE_NAME + "/" + DELETEDQUERY, DELETEDQUERYCODE);
sURIMatcher.addURI(MyContentProvider.AUTHORITY,
TABLE_NAME + "/" + DELETEDQUERY + "/#", DELETEDITEMCODE);
sURIMatcher.addURI(MyContentProvider.AUTHORITY,
TABLE_NAME + "/" + SECTIONED_DATE_VIEW, SECTIONEDDATEQUERYCODE);
sURIMatcher.addURI(MyContentProvider.AUTHORITY,
TABLE_NAME + "/" + SECTIONED_DATE_VIEW + "/#", SECTIONEDDATEITEMCODE);
sURIMatcher.addURI(MyContentProvider.AUTHORITY,
TABLE_NAME + "/" + HISTORY_TABLE_NAME, HISTORYQUERYCODE);
// Legacy URIs
sURIMatcher.addURI(MyContentProvider.AUTHORITY,
LegacyDBHelper.NotePad.Notes.NOTES, LEGACYBASEURICODE);
sURIMatcher.addURI(MyContentProvider.AUTHORITY,
LegacyDBHelper.NotePad.Notes.NOTES + "/#", LEGACYBASEITEMCODE);
sURIMatcher.addURI(MyContentProvider.AUTHORITY,
LegacyDBHelper.NotePad.Notes.VISIBLE_NOTES,
LEGACYVISIBLEURICODE);
sURIMatcher.addURI(MyContentProvider.AUTHORITY,
LegacyDBHelper.NotePad.Notes.VISIBLE_NOTES + "/#",
LEGACYVISIBLEITEMCODE);
// Search URI
sURIMatcher.addURI(MyContentProvider.AUTHORITY, FTS3_TABLE_NAME, SEARCHCODE);
sURIMatcher.addURI(MyContentProvider.AUTHORITY,
SearchManager.SUGGEST_URI_PATH_QUERY, SEARCHSUGGESTIONSCODE);
sURIMatcher.addURI(MyContentProvider.AUTHORITY,
SearchManager.SUGGEST_URI_PATH_QUERY + "/*", SEARCHSUGGESTIONSCODE);
}
public static final String TARGETPOS = "targetpos";
private static final String MOVEITEMLEFT = "moveitemleft";
private static final String MOVEITEMRIGHT = "moveitemright";
private static final String DELETEDQUERY = "deletedquery";
// Special URI to look at backup table
public static final Uri URI_DELETED_QUERY = Uri.withAppendedPath(URI, DELETEDQUERY);
// Query the view with date section headers
public static final Uri URI_SECTIONED_BY_DATE = Uri.withAppendedPath(URI, SECTIONED_DATE_VIEW);
// Query for history of tasks
public static final Uri URI_TASK_HISTORY = Uri.withAppendedPath(URI, HISTORY_TABLE_NAME);
// Search URI
public static final Uri URI_SEARCH = Uri.withAppendedPath(
Uri.parse(MyContentProvider.SCHEME + MyContentProvider.AUTHORITY),
FTS3_TABLE_NAME);
// Special URI to use when a move is requested
private static final Uri URI_WRITE_MOVEITEMLEFT = Uri.withAppendedPath(URI, MOVEITEMLEFT);
private static final Uri URI_WRITE_MOVEITEMRIGHT = Uri.withAppendedPath(URI, MOVEITEMRIGHT);
private Uri getMoveItemLeftUri() {
if (_id < 1) {
throw new InvalidParameterException("_ID of this object is not valid");
}
return Uri.withAppendedPath(URI_WRITE_MOVEITEMLEFT, Long.toString(_id));
}
private Uri getMoveItemRightUri() {
if (_id < 1) {
throw new InvalidParameterException("_ID of this object is not valid");
}
return Uri.withAppendedPath(URI_WRITE_MOVEITEMRIGHT, Long.toString(_id));
}
/**
* Contains each column of the SQLite table that contains {@link Task} objects,
* and functions to return them as lists
*/
public static class Columns implements BaseColumns {
private Columns() {}
public static final String TITLE = "title";
public static final String NOTE = "note";
public static final String DBLIST = "dblist";
public static final String COMPLETED = "completed";
public static final String DUE = "due";
public static final String UPDATED = "updated";
public static final String LOCKED = "locked";
public static final String LEFT = "lft";
public static final String RIGHT = "rgt";
public static final String[] FIELDS = { _ID, TITLE, NOTE, COMPLETED,
DUE, UPDATED, LEFT, RIGHT, DBLIST, LOCKED };
public static final String[] FIELDS_NO_ID = { TITLE, NOTE, COMPLETED,
DUE, UPDATED, LEFT, RIGHT, DBLIST, LOCKED };
public static final String[] SHALLOWFIELDS = { _ID, TITLE, NOTE,
DBLIST, COMPLETED, DUE, UPDATED, LOCKED };
public static final String TRIG_DELETED = "deletedtime";
public static final String HIST_TASK_ID = "taskid";
// Used to read the table. Deleted field set by database
public static final String[] DELETEFIELDS = { _ID, TITLE, NOTE,
COMPLETED, DUE, DBLIST, TRIG_DELETED };
// Used in trigger creation
private static final String[] DELETEFIELDS_TRIGGER = { TITLE, NOTE,
COMPLETED, DUE, DBLIST };
// accessible fields in history table
public static final String[] HISTORY_COLUMNS = { Columns.HIST_TASK_ID,
Columns.TITLE, Columns.NOTE };
public static final String[] HISTORY_COLUMNS_UPDATED = { Columns.HIST_TASK_ID,
Columns.TITLE, Columns.NOTE, Columns.UPDATED };
}
public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + "(" + Columns._ID +
" INTEGER PRIMARY KEY," + Columns.TITLE + " TEXT NOT NULL DEFAULT ''," +
Columns.NOTE + " TEXT NOT NULL DEFAULT ''," +
// These are all msec times
Columns.COMPLETED + " INTEGER DEFAULT NULL," + Columns.UPDATED +
" INTEGER DEFAULT NULL," + Columns.DUE + " INTEGER DEFAULT NULL," +
// boolean, 1 for locked, unlocked otherwise
Columns.LOCKED + " INTEGER NOT NULL DEFAULT 0," +
// position stuff
Columns.LEFT + " INTEGER NOT NULL DEFAULT 1," + Columns.RIGHT +
" INTEGER NOT NULL DEFAULT 2," + Columns.DBLIST + " INTEGER NOT NULL," +
// Positions must be positive and ordered!
" CHECK(" + Columns.LEFT + " > 0), " + " CHECK(" + Columns.RIGHT + " > 1), " +
// Each side's value should be unique in it's list
// Handled in trigger
// + " UNIQUE(" + Columns.LEFT + ", " + Columns.DBLIST + ")"
// + " UNIQUE(" + Columns.RIGHT + ", " + Columns.DBLIST + ")"
// Foreign key for list
"FOREIGN KEY(" + Columns.DBLIST + ") REFERENCES " + TaskList.TABLE_NAME + "(" +
TaskList.Columns._ID + ") ON DELETE CASCADE" + ")";
/**
* Delete table has no constraints. In fact, list values and positions should not even be
* thought of as valid
*/
public static final String CREATE_DELETE_TABLE = "CREATE TABLE " +
DELETE_TABLE_NAME + "(" +
Columns._ID + " INTEGER PRIMARY KEY," +
Columns.TITLE + " TEXT NOT NULL DEFAULT ''," +
Columns.NOTE + " TEXT NOT NULL DEFAULT ''," +
Columns.COMPLETED + " INTEGER DEFAULT NULL," +
Columns.DUE + " INTEGER DEFAULT NULL," +
Columns.DBLIST + " INTEGER DEFAULT NULL," +
Columns.TRIG_DELETED +
" TIMESTAMP NOT NULL DEFAULT current_timestamp" +
")";
/**
* Every change to a note gets saved here
*/
public static final String CREATE_HISTORY_TABLE = "CREATE TABLE " +
HISTORY_TABLE_NAME + "(" +
Columns._ID + " INTEGER PRIMARY KEY," +
Columns.HIST_TASK_ID + " INTEGER NOT NULL," +
Columns.TITLE + " TEXT NOT NULL DEFAULT ''," +
Columns.NOTE + " TEXT NOT NULL DEFAULT ''," +
Columns.UPDATED +
" TIMESTAMP NOT NULL DEFAULT current_timestamp," +
" FOREIGN KEY(" + Columns.HIST_TASK_ID +
" ) REFERENCES " + TABLE_NAME + " ( " +
Columns._ID + ") ON DELETE CASCADE " + " ) ";
static final String HISTORY_TRIGGER_BODY = " INSERT INTO " + HISTORY_TABLE_NAME + " (" +
arrayToCommaString(Columns.HISTORY_COLUMNS) + ")" + " VALUES (" +
arrayToCommaString("new.",
new String[] { Columns._ID, Columns.TITLE, Columns.NOTE }) + ");";
public static final String HISTORY_UPDATE_TRIGGER_NAME = "trigger_update_" + HISTORY_TABLE_NAME;
public static final String CREATE_HISTORY_UPDATE_TRIGGER = "CREATE TRIGGER " +
HISTORY_UPDATE_TRIGGER_NAME + " AFTER UPDATE OF " +
arrayToCommaString(Columns.TITLE, Columns.NOTE) + " ON " + TABLE_NAME + " WHEN old." +
Columns.TITLE + " IS NOT new." + Columns.TITLE + " OR old." + Columns.NOTE +
" IS NOT new." + Columns.NOTE + " BEGIN " + HISTORY_TRIGGER_BODY + " END;";
public static final String CREATE_HISTORY_INSERT_TRIGGER = "CREATE TRIGGER trigger_insert_" +
HISTORY_TABLE_NAME + " AFTER INSERT ON " + TABLE_NAME + " BEGIN " +
HISTORY_TRIGGER_BODY + " END;";
// Delete search table
public static final String CREATE_FTS3_DELETE_TABLE = "CREATE VIRTUAL TABLE "
+ FTS3_DELETE_TABLE_NAME + " USING FTS3(" + Columns._ID + ", "
+ Columns.TITLE + ", " + Columns.NOTE + ");";
public static final String CREATE_FTS3_DELETED_INSERT_TRIGGER =
"CREATE TRIGGER deletedtask_fts3_insert AFTER INSERT ON " + DELETE_TABLE_NAME +
" BEGIN " + " INSERT INTO " + FTS3_DELETE_TABLE_NAME + " (" +
arrayToCommaString(Columns._ID, Columns.TITLE, Columns.NOTE) + ") VALUES (" +
arrayToCommaString("new.",
new String[] { Columns._ID, Columns.TITLE, Columns.NOTE }) +
");" + " END;";
public static final String CREATE_FTS3_DELETED_UPDATE_TRIGGER =
"CREATE TRIGGER deletedtask_fts3_update AFTER UPDATE OF " +
arrayToCommaString(Columns.TITLE, Columns.NOTE) +
" ON " +
DELETE_TABLE_NAME +
" BEGIN " +
" UPDATE " +
FTS3_DELETE_TABLE_NAME +
" SET " +
Columns.TITLE + " = new." + Columns.TITLE +
"," + Columns.NOTE + " = new." +
Columns.NOTE + " WHERE " + Columns._ID +
" IS new." + Columns._ID + ";" + " END;";
public static final String CREATE_FTS3_DELETED_DELETE_TRIGGER =
"CREATE TRIGGER deletedtask_fts3_delete AFTER DELETE ON " +
DELETE_TABLE_NAME + " BEGIN " +
" DELETE FROM " + FTS3_DELETE_TABLE_NAME +
" WHERE " + Columns._ID + " IS old." +
Columns._ID + ";" + " END;";
// Search table
public static final String CREATE_FTS3_TABLE = "CREATE VIRTUAL TABLE "
+ FTS3_TABLE_NAME + " USING FTS3(" + Columns._ID + ", "
+ Columns.TITLE + ", " + Columns.NOTE + ");";
public static final String CREATE_FTS3_INSERT_TRIGGER =
"CREATE TRIGGER task_fts3_insert AFTER INSERT ON " +
TABLE_NAME +
" BEGIN " +
" INSERT INTO " +
FTS3_TABLE_NAME +
" (" +
arrayToCommaString(Columns._ID, Columns.TITLE, Columns.NOTE) +
") VALUES (" +
arrayToCommaString("new.", new String[] { Columns._ID,
Columns.TITLE, Columns.NOTE }) +
");" +
" END;";
public static final String CREATE_FTS3_UPDATE_TRIGGER =
"CREATE TRIGGER task_fts3_update AFTER UPDATE OF " +
arrayToCommaString(Columns.TITLE, Columns.NOTE) + " ON " + TABLE_NAME +
" BEGIN " + " UPDATE " + FTS3_TABLE_NAME + " SET " + Columns.TITLE + " = new." +
Columns.TITLE + "," + Columns.NOTE + " = new." + Columns.NOTE + " WHERE " +
Columns._ID + " IS new." + Columns._ID + ";" + " END;";
public static final String CREATE_FTS3_DELETE_TRIGGER =
"CREATE TRIGGER task_fts3_delete AFTER DELETE ON " + TABLE_NAME + " BEGIN " +
" DELETE FROM " + FTS3_TABLE_NAME + " WHERE " + Columns._ID + " IS old." +
Columns._ID + ";" + " END;";
/**
* This is a SQLite view which returns the tasks in the specified list with headers
* suitable for dates, if any tasks would be sorted under them. Headers are used
* in the {@link DragSortListView} when notes are ordered by date.
* Provider hardcodes the sort order for this.
*
* @param listId if it is null, the function will return (a query) for all lists
* @return a SQL query to create this view
*/
public static String CREATE_SECTIONED_DATE_VIEW(final String listId) {
// TODO on the API 35 emulator (and on the Google Pixel 8a), this function creates a view
// where the "dblist" column is (erroneously) of type BLOB, while on the API 34 emulator
// (and in previous android versions) "dblist" correctly maintains the INTEGER TYPE.
// I think it's because we supply sListId and listId with arrayToCommaString(), so
// we get '1' instead of 1. On older android versions this is ignored, but since API 35
// it becomes a problem.
final String sListId = listId == null ? " NOT NULL " : "'" + listId + "'";
String beginning = "CREATE TEMP VIEW IF NOT EXISTS " + getSECTION_DATE_VIEW_NAME(listId) +
// Tasks WITH dates NOT completed, secret 0
" AS SELECT " + arrayToCommaString(Columns.FIELDS) + ",0" + " AS " + SECRET_TYPEID +
",1" + " AS " + SECRET_TYPEID2 + " FROM " + TABLE_NAME + " WHERE " +
Columns.COMPLETED + " IS null " + " AND " + Columns.DUE + " IS NOT null " +
" UNION ALL " +
// Tasks NO dates NOT completed, secret 1
" SELECT " + arrayToCommaString(Columns.FIELDS) + ",1" + " AS " + SECRET_TYPEID +
",1" + " AS " + SECRET_TYPEID2 + " FROM " + TABLE_NAME + " WHERE " +
Columns.COMPLETED + " IS null " + " AND " + Columns.DUE + " IS null " +
" UNION ALL " +
// Tasks completed, secret 2 + 1
" SELECT " + arrayToCommaString(Columns.FIELDS) + ",3" + " AS " + SECRET_TYPEID +
",1" + " AS " + SECRET_TYPEID2 + " FROM " + TABLE_NAME + " WHERE " +
Columns.COMPLETED + " IS NOT null ";
String TODAY = " UNION ALL " + " SELECT -1," + asEmptyCommaStringExcept(Columns.FIELDS_NO_ID,
Columns.DUE, TODAY_START, Columns.TITLE, HEADER_KEY_TODAY, Columns.DBLIST, listId) +
",0,0" +
// Only show header if there are tasks under it
" WHERE EXISTS(SELECT _ID FROM " + TABLE_NAME + " WHERE " + Columns.COMPLETED +
" IS NULL " + " AND " + Columns.DBLIST + " IS " + sListId + " AND " + Columns.DUE +
" BETWEEN " + TODAY_START + " AND " + TODAY_PLUS(1) + ") ";
// TOMORROW = Today + 1
String PLUS_1 = " UNION ALL " + " SELECT -1," + asEmptyCommaStringExcept(Columns.FIELDS_NO_ID,
Columns.DUE, TODAY_PLUS(1), Columns.TITLE, HEADER_KEY_PLUS1, Columns.DBLIST,
listId) + ",0,0" +
// Only show header if there are tasks under it
" WHERE EXISTS(SELECT _ID FROM " + TABLE_NAME + " WHERE " + Columns.COMPLETED +
" IS NULL " + " AND " + Columns.DBLIST + " IS " + sListId + " AND " + Columns.DUE +
" BETWEEN " + TODAY_PLUS(1) + " AND " + TODAY_PLUS(2) + ") ";
// Today + 2
String PLUS_2 = " UNION ALL " + " SELECT -1," + asEmptyCommaStringExcept(Columns.FIELDS_NO_ID,
Columns.DUE, TODAY_PLUS(2), Columns.TITLE, HEADER_KEY_PLUS2, Columns.DBLIST,
listId) + ",0,0" +
// Only show header if there are tasks under it
" WHERE EXISTS(SELECT _ID FROM " + TABLE_NAME + " WHERE " + Columns.COMPLETED +
" IS NULL " + " AND " + Columns.DBLIST + " IS " + sListId + " AND " + Columns.DUE +
" BETWEEN " + TODAY_PLUS(2) + " AND " + TODAY_PLUS(3) + ") ";
// Today + 3
String PLUS_3 = " UNION ALL " + " SELECT -1," + asEmptyCommaStringExcept(Columns.FIELDS_NO_ID,
Columns.DUE, TODAY_PLUS(3), Columns.TITLE, HEADER_KEY_PLUS3, Columns.DBLIST,
listId) + ",0,0" +
// Only show header if there are tasks under it
" WHERE EXISTS(SELECT _ID FROM " + TABLE_NAME + " WHERE " + Columns.COMPLETED +
" IS NULL " + " AND " + Columns.DBLIST + " IS " + sListId + " AND " + Columns.DUE +
" BETWEEN " + TODAY_PLUS(3) + " AND " + TODAY_PLUS(4) + ") ";
// in this function you can add the code to create more headers for when the
// notes list is sorted by due date, but I think these are enough already
// Today + 4
String PLUS_4 = " UNION ALL " + " SELECT -1," + asEmptyCommaStringExcept(Columns.FIELDS_NO_ID,
Columns.DUE, TODAY_PLUS(4), Columns.TITLE, HEADER_KEY_PLUS4, Columns.DBLIST,
listId) + ",0,0" +
// Only show header if there are tasks under it
" WHERE EXISTS(SELECT _ID FROM " + TABLE_NAME + " WHERE " + Columns.COMPLETED +
" IS NULL " + " AND " + Columns.DBLIST + " IS " + sListId + " AND " + Columns.DUE +
" BETWEEN " + TODAY_PLUS(4) + " AND " + TODAY_PLUS(5) + ") ";
int daysUntilNextMonth = TimeFormatter.getHowManyDaysUntilFirstOfNextMonth();
// the next month will end in (...) days:
int toEndOfNextMonth = daysUntilNextMonth + TimeFormatter.getHowManyDaysInTheNextMonth();
// the goal of this is to add a "fake note" in the DATABASE view created by this function.
// This "fake note" will then show up in the drag-sort-listview (but not as an
// user-iteractable note, just a header) to show that the next month starts there.
// Any note after that is due after the next month begins
String nextMonth = " UNION ALL " + " SELECT -1," + asEmptyCommaStringExcept(Columns.FIELDS_NO_ID,
Columns.DUE, TODAY_PLUS(daysUntilNextMonth), Columns.TITLE, HEADER_KEY_NEXT_MONTH,
Columns.DBLIST, listId) + ",0,0" +
// Only show header if there are tasks under it, so if there are task with a due
// time in the next month
" WHERE EXISTS(SELECT _ID FROM " + TABLE_NAME + " WHERE " + Columns.COMPLETED +
" IS NULL " + " AND " + Columns.DBLIST + " IS " + sListId + " AND " + Columns.DUE +
" BETWEEN " + TODAY_PLUS(daysUntilNextMonth) + " AND " + TODAY_PLUS(toEndOfNextMonth)
+ ") ";
int daysUntilNextYear = TimeFormatter.getHowManyDaysUntilFirstOfNextYear();
int toEndOfNextYear = daysUntilNextYear + TimeFormatter.getHowManyDaysInNextYear();
String nextYear = " UNION ALL " + " SELECT -1," + asEmptyCommaStringExcept(Columns.FIELDS_NO_ID,
Columns.DUE, TODAY_PLUS(daysUntilNextYear), Columns.TITLE, HEADER_KEY_NEXT_YEAR,
Columns.DBLIST, listId) + ",0,0" +
// Only show header if there are tasks under it
" WHERE EXISTS(SELECT _ID FROM " + TABLE_NAME + " WHERE " + Columns.COMPLETED +
" IS NULL " + " AND " + Columns.DBLIST + " IS " + sListId + " AND " + Columns.DUE +
" BETWEEN " + TODAY_PLUS(daysUntilNextYear) + " AND " + TODAY_PLUS(toEndOfNextYear)
+ ") ";
// Overdue (0)
String overdue = " UNION ALL " + " SELECT -1," + asEmptyCommaStringExcept(Columns.FIELDS_NO_ID,
Columns.DUE, OVERDUE, Columns.TITLE, HEADER_KEY_OVERDUE, Columns.DBLIST, listId) +
",0,0" +
// Only show header if there are tasks under it
" WHERE EXISTS(SELECT _ID FROM " + TABLE_NAME + " WHERE " + Columns.COMPLETED +
" IS NULL " + " AND " + Columns.DBLIST + " IS " + sListId + " AND " + Columns.DUE +
" BETWEEN " + OVERDUE + " AND " + TODAY_START + ") ";
// Later. As of now, later = "after the end of the next year"
String later = " UNION ALL " + " SELECT -1," + asEmptyCommaStringExcept(Columns.FIELDS_NO_ID,
Columns.DUE, TODAY_PLUS(toEndOfNextYear), Columns.TITLE, HEADER_KEY_LATER, Columns.DBLIST,
listId) + ",0,0" +
// Only show header if there are tasks under it
" WHERE EXISTS(SELECT _ID FROM " + TABLE_NAME + " WHERE " + Columns.COMPLETED +
" IS NULL " + " AND " + Columns.DBLIST + " IS " + sListId + " AND " + Columns.DUE +
" >= " + TODAY_PLUS(toEndOfNextYear) + ") ";
// No date
String noDate = " UNION ALL " + " SELECT -1," + asEmptyCommaStringExcept(Columns.FIELDS_NO_ID,
Columns.DUE, "null", Columns.TITLE, HEADER_KEY_NODATE, Columns.DBLIST,
listId) + ",1,0" +
// Only show header if there are tasks under it
" WHERE EXISTS(SELECT _ID FROM " + TABLE_NAME + " WHERE " + Columns.DBLIST +
" IS " + sListId + " AND " + Columns.DUE + " IS null " + " AND " +
Columns.COMPLETED + " IS null " + ") ";
// Complete, overdue to catch all
// Set complete time to 1
String finalSql = " UNION ALL " + " SELECT -1," + asEmptyCommaStringExcept(Columns.FIELDS_NO_ID,
Columns.DUE, OVERDUE, Columns.COMPLETED, "1", Columns.TITLE,
HEADER_KEY_COMPLETE, Columns.DBLIST, listId) + ",2,0" +
// Only show header if there are tasks under it
" WHERE EXISTS(SELECT _ID FROM " + TABLE_NAME + " WHERE " + Columns.DBLIST +
" IS " + sListId + " AND " + Columns.COMPLETED + " IS NOT null " + ") " + ";";
return beginning + TODAY + PLUS_1 + PLUS_2 + PLUS_3 + PLUS_4 + nextMonth + nextYear +
overdue + later + noDate + finalSql;
}
/**
* Fields of this note
*/
public String title = null;
public String note = null;
/**
* When this Task was completed, in milliseconds since 1970-01-01 UTC
*/
public Long completed = null;
/**
* When this Task is due, in milliseconds since 1970-01-01 UTC
*/
public Long due = null;
/**
* When this Task was last updated, in milliseconds since 1970-01-01 UTC
*/
public Long updated = null;
/**
* If this {@link Task} is password-protected. Saved as integer in the database
*/
public boolean locked = false;
// position stuff
public Long left = null;
public Long right = null;
public Long dblist = null;
public Task() {}
/**
* Resets id and position values
*/
public void resetForInsertion() {
_id = -1;
left = null;
right = null;
}
/**
* Set task as completed. Only used when importing from the legacy DB.
* It seems the intent was to initialize the completed notes imported from the legacy DB
* with a completed timestamp
*/
public void setAsCompletedForLegacy() {
// TODO functions dealing with the legacy DB, including this one, should just be deleted:
// the legacy DB probably dates back to 2012 !
completed = Calendar.getInstance().getTimeInMillis();
}
/**
* @param text this function sets this {@link String}'s first line as title, the rest as
* content of this {@link Task}, so don't forget the {@code \n } !
*/
public void setText(@NonNull final String text) {
int titleEnd = text.indexOf("\n");
if (titleEnd < 0) {
titleEnd = text.length();
}
title = text.substring(0, titleEnd);
if (titleEnd + 1 < text.length()) {
note = text.substring(titleEnd + 1);
} else {
note = "";
}
}
/**
* Returns a text where first line is title, rest is note
*/
public String getText() {
String result = "";
if (title != null) {
result += title;
}
if (note != null && !note.isEmpty()) {
if (!result.isEmpty()) {
result += "\n";
}
result += note;
}
return result;
}
public Task(final Cursor c) {
this._id = c.getLong(0);
this.title = c.getString(1);
note = c.getString(2);
// msec times which can be null
if (!c.isNull(3)) completed = c.getLong(3);
if (!c.isNull(4)) due = c.getLong(4);
if (!c.isNull(5)) updated = c.getLong(5);
// enforced not to be null
left = c.getLong(6);
right = c.getLong(7);
dblist = c.getLong(8);
locked = c.getInt(9) == 1;
}
public Task(final long id, final ContentValues values) {
this(values);
this._id = id;
}
public Task(final Uri uri, final ContentValues values) {
this(Long.parseLong(uri.getLastPathSegment()), values);
}
public Task(final ContentValues values) {
if (values != null) {
if (values.containsKey(TARGETPOS)) {
// Content form getMoveValues
this.left = values.getAsLong(Columns.LEFT);
this.right = values.getAsLong(Columns.RIGHT);
this.dblist = values.getAsLong(Columns.DBLIST);
} else {
this.title = values.getAsString(Columns.TITLE);
this.note = values.getAsString(Columns.NOTE);
this.completed = values.getAsLong(Columns.COMPLETED);
this.due = values.getAsLong(Columns.DUE);
this.updated = values.getAsLong(Columns.UPDATED);
this.locked = values.getAsLong(Columns.LOCKED) == 1;
this.dblist = values.getAsLong(Columns.DBLIST);
this.left = values.getAsLong(Columns.LEFT);
this.right = values.getAsLong(Columns.RIGHT);
}
}
}
public Task(final JSONObject json) throws JSONException {
if (json.has(Columns.TITLE))
this.title = json.getString(Columns.TITLE);
if (json.has(Columns.NOTE))
this.note = json.getString(Columns.NOTE);
if (json.has(Columns.COMPLETED))
this.completed = json.getLong(Columns.COMPLETED);
if (json.has(Columns.DUE))
this.due = json.getLong(Columns.DUE);
if (json.has(Columns.UPDATED))
this.updated = json.getLong(Columns.UPDATED);
if (json.has(Columns.LOCKED))
this.locked = json.getLong(Columns.LOCKED) == 1;
if (json.has(Columns.DBLIST))
this.dblist = json.getLong(Columns.DBLIST);
if (json.has(Columns.LEFT))
this.left = json.getLong(Columns.LEFT);
if (json.has(Columns.RIGHT))
this.right = json.getLong(Columns.RIGHT);
}
/**
* A move operation should be performed alone. No other information should
* accompany such an update.
*/
public ContentValues getMoveValues(final long targetPos) {
final ContentValues values = new ContentValues();
values.put(TARGETPOS, targetPos);
values.put(Columns.LEFT, left);
values.put(Columns.RIGHT, right);
values.put(Columns.DBLIST, dblist);
return values;
}
/**
* Use this for regular updates of the task.
*/
@Override
public ContentValues getContent() {
final ContentValues values = new ContentValues();
// Note that ID is NOT included here
if (title != null) values.put(Columns.TITLE, title);
if (note != null) values.put(Columns.NOTE, note);
if (dblist != null) values.put(Columns.DBLIST, dblist);
values.put(Columns.UPDATED, updated);
values.put(Columns.DUE, due);
values.put(Columns.COMPLETED, completed);
values.put(Columns.LOCKED, locked ? 1 : 0);
return values;
}
/**
* Compares this task to another and returns true if their contents are the
* same. Content is defined as: title, note, duedate, completed != null
* Returns false if title or note are null.
* The intended usage is the editor where content and not id's or position
* are of importance.
*/
@Override
public boolean equals(Object o) {
boolean result;
if (o instanceof Task other) {
result = true;
result &= (title != null && title.equals(other.title));
result &= (note != null && note.equals(other.note));
result &= (Objects.equals(due, other.due));
result &= ((completed != null) == (other.completed != null));
} else {
result = super.equals(o);
}
return result;
}
/**
* Convenience method for normal operations. Updates "updated" field to
* specified Returns number of db-rows affected. Fail if < 1
*/
public int save(final Context context, final long updated) {
int result = 0;
this.updated = updated;
if (_id < 1) {
final Uri uri = context
.getContentResolver()
.insert(getBaseUri(), getContent());
if (uri != null) {
_id = Long.parseLong(uri.getLastPathSegment());
result++;
}
} else {
result += context
.getContentResolver()
.update(getUri(), getContent(), null, null);
}
return result;
}
/**
* Convenience method for normal operations. Updates "updated" field.
* Returns number of db-rows affected. Fail if < 1
*/
@Override
public int save(final Context context) {
return save(context, Calendar.getInstance().getTimeInMillis());
}
/**
* A reusable background thread
*/
private static final ExecutorService mTaskExecutor = Executors.newSingleThreadExecutor();
/**
* Convenience method to complete tasks in list view for example. Works in the background.
*/
public static void setCompleted(final Context context, final boolean completed,
final Long... ids) {
if (ids.length < 1) return;
mTaskExecutor.execute(() -> setCompletedSynced(context, completed, ids));
}
/**
* Convenience method to complete tasks. Runs on the thread that called it.
*/
public static void setCompletedSynced(final Context context, final boolean completed,
final Long... ids) {
if (ids.length < 1) return;
long thisInstant = Calendar.getInstance().getTimeInMillis();
final ContentValues values = new ContentValues();
values.put(Columns.COMPLETED, completed ? thisInstant : null);
values.put(Columns.UPDATED, thisInstant);
String idStrings = "(";
for (Long id : ids) {
idStrings += id + ",";
}
idStrings = idStrings.substring(0, idStrings.length() - 1);
idStrings += ")";
context.getContentResolver()
.update(URI, values, Columns._ID + " IN " + idStrings, null);
}
public int moveTo(final ContentResolver resolver, final Task targetTask) {
if (targetTask.dblist.equals(dblist)) {
if (targetTask.left < left) {
// moving left
return resolver.update(getMoveItemLeftUri(),
getMoveValues(targetTask.left), null, null);
} else if (targetTask.right > right) {
// moving right
return resolver.update(getMoveItemRightUri(),
getMoveValues(targetTask.right), null, null);
}
}
return 0;
}
@Override
protected String getTableName() {
return TABLE_NAME;
}
/**
* Can't use unique constraint on positions because SQLite checks
* constraints after every row is updated an not after each statement like
* it should. So have to do the check in a trigger instead.
*/
static String countVals(final String col, final String ver) {
return String.format("SELECT COUNT(DISTINCT %2$s)"
+ " AS ColCount FROM %1$s WHERE %3$s=%4$s.%3$s", TABLE_NAME,
col, Columns.DBLIST, ver);
}
// verify that left are unique
// count number of id and compare to number of left and right
static String posUniqueConstraint(final String ver, final String msg) {
return String.format(
" SELECT CASE WHEN ((%1$s) != (%2$s) OR (%1$s) != (%3$s)) THEN "
+ " RAISE (ABORT, '" + msg + "')" + " END;",
countVals(Columns._ID, ver), countVals(Columns.LEFT, ver),
countVals(Columns.RIGHT, ver));
}
// Makes a gap in the list where the task is being inserted
private static final String BUMP_TO_RIGHT =
" UPDATE %1$s SET %2$s = %2$s + 2, %3$s = %3$s + 2 WHERE %3$s >= new.%3$s AND %4$s IS new.%4$s;";
public static final String TRIGGER_PRE_INSERT = String.format(
"CREATE TRIGGER task_pre_insert BEFORE INSERT ON %s BEGIN ",
TABLE_NAME)
+ String.format(BUMP_TO_RIGHT, TABLE_NAME, Columns.RIGHT,
Columns.LEFT, Columns.DBLIST) + " END;";
public static final String TRIGGER_POST_INSERT = String.format(
"CREATE TRIGGER task_post_insert AFTER INSERT ON %s BEGIN ",
TABLE_NAME)
// Enforce integrity
+ posUniqueConstraint("new", "pos not unique post insert")
+ " END;";
// Upgrades children and closes the gap made from the delete
private static final String BUMP_TO_LEFT =
" UPDATE %1$s SET %2$s = %2$s - 2 WHERE %2$s > old.%3$s AND %4$s IS old.%4$s;";
public static final String TRIGGER_POST_DELETE = String.format(
"CREATE TRIGGER task_post_delete AFTER DELETE ON %s BEGIN ",
TABLE_NAME)
// + String.format(UPGRADE_CHILDREN, TABLE_NAME, Columns.LEFT,
// Columns.RIGHT, Columns.DBLIST)
+ String.format(BUMP_TO_LEFT, TABLE_NAME, Columns.LEFT,
Columns.RIGHT, Columns.DBLIST)
+ String.format(BUMP_TO_LEFT, TABLE_NAME, Columns.RIGHT,
Columns.RIGHT, Columns.DBLIST)
// Enforce integrity
+ posUniqueConstraint("old", "pos not unique post delete")
+ " END;";
public static final String TRIGGER_PRE_DELETE = String.format(
"CREATE TRIGGER task_pre_delete BEFORE DELETE ON %1$s BEGIN "
+ " INSERT INTO %2$s ("
+ arrayToCommaString("", Columns.DELETEFIELDS_TRIGGER, "")
+ ") "
+ " VALUES("
+ arrayToCommaString("old.", Columns.DELETEFIELDS_TRIGGER,
"") + "); "
+ " END;", TABLE_NAME, DELETE_TABLE_NAME);
public String getSQLMoveItemLeft(final ContentValues values) {
if (!values.containsKey(TARGETPOS) || values.getAsLong(TARGETPOS) >= left) {
return null;
}
return getSQLMoveItem(Columns.LEFT, values.getAsLong(TARGETPOS));
}
public String getSQLMoveItemRight(final ContentValues values) {
if (!values.containsKey(TARGETPOS) || values.getAsLong(TARGETPOS) <= right) {
return null;
}
return getSQLMoveItem(Columns.RIGHT, values.getAsLong(TARGETPOS));
}
/*
* Trigger to move between lists
*/
public static final String TRIGGER_MOVE_LIST = "CREATE TRIGGER trigger_post_move_list_" +
TABLE_NAME + " AFTER UPDATE OF " + Columns.DBLIST + " ON " + Task.TABLE_NAME +
" WHEN old." + Columns.DBLIST + " IS NOT new." + Columns.DBLIST + " BEGIN " +
// Bump everything to the right, except the item itself (in same list)
String.format("UPDATE %1$s SET %2$s = %2$s + 2, %3$s = %3$s + 2 WHERE %4$s IS new.%4$s AND %5$s IS NOT new.%5$s;",
TABLE_NAME, Columns.LEFT, Columns.RIGHT, Columns.DBLIST, Columns._ID) +
// Bump everything left in the old list, to the right of position
String.format("UPDATE %1$s SET %2$s = %2$s - 2, %3$s = %3$s - 2 WHERE %2$s > old.%3$s AND %4$s IS old.%4$s;",
TABLE_NAME, Columns.LEFT, Columns.RIGHT, Columns.DBLIST) +
// Set positions to 1 and 2 for item
String.format("UPDATE %1$s SET %2$s = 1, %3$s = 2 WHERE %4$s IS new.%4$s;",
TABLE_NAME, Columns.LEFT, Columns.RIGHT, Columns._ID) +
posUniqueConstraint("new", "Moving list, new positions not unique/ordered") +
posUniqueConstraint("old", "Moving list, old positions not unique/ordered") +
" END;";
/**
* If moving left, then edgeCol is left and vice-versa. Values should come
* from getMoveValues.
* 1 = table name 2 = left 3 = right 4 = edgecol 5 = old.left 6 = old.right
* 7 = target.pos (actually target.edgecol) 8 = dblist 9 = old.dblist
*/
private String getSQLMoveItem(final String edgeCol, final Long edgeVal) {
boolean movingLeft = Columns.LEFT.equals(edgeCol);
return String.format("UPDATE %1$s SET " +
// Left item follows Left = Left + ...
"%2$s = %2$s + " +
" CASE " +
// Moving item jumps to target pos
" WHEN %2$s IS %5$d " +
// ex: left = 5, target = 2, --> left = 5 + (2 - 5) == 2
// ex left = 5, target = 9(right), --> left = 5 + (9 - 5 - 1) = 8
" THEN " +
" (%7$d - %5$d" +
(movingLeft ? ") " : " -1) ") +
// Sub items take one step opposite
// Careful if moving inside subtree, which can only
// happen when moving right.
// Then only left position changes
" WHEN %2$s BETWEEN (%5$d + 1) AND (%6$d - 1) " +
" THEN " +
(movingLeft ? " 1 " : " -1 ") +
// Items in between from and to positions take two steps opposite
" WHEN %2$s BETWEEN " +
(movingLeft ? "%7$d" : "%6$d") +
" AND " +
(movingLeft ? "%5$d" : "%7$d") +
" THEN " +
(movingLeft ? " 2 " : " -2 ") +
// Not in target range, no change
" ELSE 0 END, " +
/*
* Right item follows Right = Right + ...
*/
" %3$s = %3$s + " +
" CASE " +
// Moving item jumps to target pos
" WHEN %3$s IS %6$d " +
// ex: right = 7, target = 3(left), --> right = 7 + (3 - 7 + 1) == 4
// ex right = 2, target = 9(right), --> right = 2 + (9 - 2) = 9
" THEN " +
" (%7$d - %6$d" +
(movingLeft ? " +1) " : ") ") +
// Sub items take one step opposite
" WHEN %3$s BETWEEN (%5$d + 1) AND (%6$d - 1) " +
" THEN " +
(movingLeft ? " 1 " : " -1 ") +
// Items in between from and to positions take two steps opposite
" WHEN %3$s BETWEEN " +
(movingLeft ? "%7$d" : "%6$d") + " AND " +
(movingLeft ? "%5$d" : "%7$d") + " THEN " +
(movingLeft ? " 2 " : " -2 ") +
// Not in target range, no change
" ELSE 0 END " +
// And limit to the list in question
" WHERE %8$s IS %9$d;", TABLE_NAME,
Columns.LEFT, Columns.RIGHT, edgeCol, left, right, edgeVal, Columns.DBLIST, dblist);
}
/*
* @SuppressLint("DefaultLocale") public String getSQLMoveSubTree(final
* ContentValues values) { return
* String.format("UPDATE %1$s SET %2$s = %2$s + " +
*
* " CASE " + // Tasks are moving left " WHEN (%4$d < %6$d) " +
*
* " THEN CASE " + " WHEN %2$s BETWEEN %4$d AND (%6$d - 1) " + // Then they
* must flow [width] to the right " THEN %7$d - %6$d + 1 " +
* " WHEN %2$s BETWEEN %6$d AND %7$d " + // Tasks in subtree jump to the
* left // targetleft - left " THEN %4$d - %6$d " + // Do nothing otherwise
* " ELSE 0 END " + // Tasks are moving right " WHEN (%4$d > %6$d) " +
* " THEN CASE " + " WHEN %2$s BETWEEN (%7$d + 1) AND %4$d " + // Then move
* them [width] to the left " THEN %6$d - %7$d - 1" +
* " WHEN %2$s BETWEEN %6$d AND %7$d " + // Tasks in subtree jump to the
* right // targetleft - left
*
* // Depends on if we are moving inside a task or // moving an entire one
* " THEN CASE WHEN %5$d > (%4$d + 1) " + " THEN %4$d - %7$d " +
* " ELSE %4$d - %7$d + 1 END " + // Do nothing otherwise " ELSE 0 END " +
* // No move actually performed. comma to do right next " ELSE 0 END, " +
*
* " %3$s = %3$s + " + " CASE " + // Tasks are moving left
* " WHEN (%4$d < %6$d) " +
*
* " THEN CASE " + // but only if right is left of originleft
* " WHEN %3$s BETWEEN %4$d AND (%6$d - 1)" + // Then they must flow [width]
* to the right " THEN %7$d - %6$d + 1" +
* " WHEN %2$s BETWEEN %6$d AND %7$d " + // Tasks in subtree jump to the
* left // targetleft - left " THEN %4$d - %6$d " + // Do nothing otherwise
* " ELSE 0 END " + // Tasks are moving right " WHEN (%4$d > %6$d) " +
* " THEN CASE " + // when right is between myright + 1 and targetleft + 1
* " WHEN %3$s BETWEEN (%7$d + 1) AND (%4$d + 1) " + // Then move them
* [width] to the left " THEN %6$d - %7$d - 1" +
* " WHEN %2$s BETWEEN %6$d AND %7$d " + // targetleft - left // Depends on
* if we are moving inside a task or // moving an entire one
* " THEN CASE WHEN %5$d > (%4$d + 1) " + " THEN %4$d - %7$d " +
* " ELSE %4$d - %7$d + 1 END " + // Do nothing otherwise " ELSE 0 END " +
* // No move actually performed. End update with semicolon " ELSE 0 END " +
* " WHERE %8$s IS %9$d; "
*
* //Enforce integrity + posUniqueConstraint("new",
* "pos not unique move sub tree") ,
*
* TABLE_NAME, Columns.LEFT, Columns.RIGHT, values.getAsLong(TARGETLEFT),
* values.getAsLong(TARGETRIGHT), left, right, Columns.DBLIST, dblist
*
* ); }
*/
@Override
public String getContentType() {
return CONTENT_TYPE;
}
/*
* commodity functions added after 2022
*/
/**
* These headers are just indicative. If there is no task due in 3 days, "tomorrow" will
* include all the current month, by design.
*
* @param input The {@link String} received from the {@link Cursor} which, I think,
* comes from the query on the view returned by
* {@link #CREATE_SECTIONED_DATE_VIEW}
* @param dueDateMillis the value of {@link Task.Columns#DUE} from the same {@link Cursor}
* that gave you the "input" parameter
* @return the name to show on a header of the {@link DragSortListView} when the notes are
* ordered by date. For example, "Tomorrow", or "Later", depending on when the note
* is due.
*/
public static String getHeaderNameForListSortedByDate(String input, long dueDateMillis,
@NonNull Context context) {
String sTemp;
if (Task.HEADER_KEY_OVERDUE.equals(input)) {
sTemp = context.getString(R.string.date_header_overdue);
} else if (Task.HEADER_KEY_TODAY.equals(input)) {
// if you want to show text like "next 15 days" in the drag-sort-listview, you would
// add your code in this class. For help, see where HEADER_KEY_PLUS4 is used
sTemp = context.getString(R.string.date_header_today);
} else if (Task.HEADER_KEY_PLUS1.equals(input)) {
sTemp = context.getString(R.string.date_header_tomorrow);
} else if (Task.HEADER_KEY_PLUS2.equals(input) || Task.HEADER_KEY_PLUS3.equals(input)
|| Task.HEADER_KEY_PLUS4.equals(input)) {
// Receives a "Date" and returns a string with the translated name of the week day
SimpleDateFormat weekdayFormatter = TimeFormatter.getLocalFormatterWeekday(context);
sTemp = weekdayFormatter.format(new Date(dueDateMillis));
} else if (Task.HEADER_KEY_NEXT_MONTH.equals(input)) {
sTemp = context.getString(R.string.next_month);
} else if (Task.HEADER_KEY_NEXT_YEAR.equals(input)) {
sTemp = context.getString(R.string.next_year);
} else if (Task.HEADER_KEY_LATER.equals(input)) {
sTemp = context.getString(R.string.date_header_future);
} else if (Task.HEADER_KEY_NODATE.equals(input)) {
sTemp = context.getString(R.string.date_header_none);
} else if (Task.HEADER_KEY_COMPLETE.equals(input)) {
sTemp = context.getString(R.string.date_header_completed);
} else {
// for compatibility with the old version, but i don't think it happens...
sTemp = input;
}
return sTemp;
}
/**
* @return TRUE if this task was set as "completed" by the user, FALSE otherwise
*/
public boolean isCompleted() {
return this.completed != null && this.completed > 0;
}
/**
* @param id the {@link Columns#_ID} of the {@link Task} to return
* @return the corresponding {@link Task} or NULL if it could not get one
*/
public static Task byId(long id, Context context) {
Cursor c = context
.getContentResolver()
.query(Task.URI, null, Task.Columns._ID + " = ?",
new String[] { Long.toString(id) }, null);
if (c == null || c.getCount() != 1) return null;
c.moveToFirst();
var x = new Task(c);
c.close();
return x;
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/database/TaskList.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.database;
import android.content.ContentValues;
import android.content.Context;
import android.content.UriMatcher;
import android.database.Cursor;
import android.net.Uri;
import android.provider.BaseColumns;
import com.nononsenseapps.notepad.R;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.Calendar;
public class TaskList extends DAO {
// SQL convention says Table name should be "singular"
public static final String TABLE_NAME = "tasklist";
public static final Uri URI = Uri.withAppendedPath(
Uri.parse(MyContentProvider.SCHEME + MyContentProvider.AUTHORITY),
TABLE_NAME);
public static final String VIEWCOUNT_NAME = "lists_with_count";
public static final Uri URI_WITH_COUNT = Uri.withAppendedPath(URI, VIEWCOUNT_NAME);
public static Uri getUri(final long id) {
return Uri.withAppendedPath(URI, Long.toString(id));
}
public static final String CONTENT_TYPE = "vnd.android.cursor.item/vnd.nononsenseapps.list";
public static final int BASEURICODE = 101;
public static final int BASEITEMCODE = 102;
public static final int VIEWCOUNTCODE = 103;
// Legacy support, these also need to use legacy projections
public static final int LEGACYBASEURICODE = 111;
public static final int LEGACYBASEITEMCODE = 112;
public static final int LEGACYVISIBLEURICODE = 113;
public static final int LEGACYVISIBLEITEMCODE = 114;
/**
* TaskList URIs start at 101, up to 199
*/
public static void addMatcherUris(UriMatcher sURIMatcher) {
sURIMatcher.addURI(MyContentProvider.AUTHORITY, TABLE_NAME, BASEURICODE);
sURIMatcher.addURI(MyContentProvider.AUTHORITY, TABLE_NAME + "/#", BASEITEMCODE);
sURIMatcher.addURI(MyContentProvider.AUTHORITY, TABLE_NAME + "/"
+ VIEWCOUNT_NAME, VIEWCOUNTCODE);
// Legacy URIs
sURIMatcher.addURI(MyContentProvider.AUTHORITY,
LegacyDBHelper.NotePad.Lists.LISTS, LEGACYBASEURICODE);
sURIMatcher.addURI(MyContentProvider.AUTHORITY,
LegacyDBHelper.NotePad.Lists.LISTS + "/#", LEGACYBASEITEMCODE);
sURIMatcher.addURI(MyContentProvider.AUTHORITY,
LegacyDBHelper.NotePad.Lists.VISIBLE_LISTS,
LEGACYVISIBLEURICODE);
sURIMatcher.addURI(MyContentProvider.AUTHORITY,
LegacyDBHelper.NotePad.Lists.VISIBLE_LISTS + "/#",
LEGACYVISIBLEITEMCODE);
}
public static class Columns implements BaseColumns {
private Columns() {}
public static final String TITLE = "title";
public static final String UPDATED = "updated";
public static final String LISTTYPE = "tasktype";
public static final String SORTING = "sorting";
public static final String VIEW_COUNT = "count";
public static final String[] FIELDS = { _ID, TITLE, UPDATED, LISTTYPE, SORTING };
// GTASKACCOUNT, GTASKID };
public static final String[] SHALLOWFIELDS = { _ID, TITLE, UPDATED };
}
public static final String CREATE_TABLE = "CREATE TABLE " +
TABLE_NAME + "(" + Columns._ID +
" INTEGER PRIMARY KEY," + Columns.TITLE +
" TEXT NOT NULL DEFAULT ''," + Columns.UPDATED +
" INTEGER," + Columns.LISTTYPE +
" TEXT DEFAULT NULL," + Columns.SORTING +
" TEXT DEFAULT NULL" + ")";
public static final String CREATE_COUNT_VIEW = "CREATE TEMP VIEW IF NOT EXISTS " +
VIEWCOUNT_NAME +
" AS SELECT " +
arrayToCommaString(Columns.FIELDS) +
"," +
Columns.VIEW_COUNT +
" FROM " +
TABLE_NAME +
" LEFT JOIN " +
// Select count statement
" (SELECT COUNT(1) AS " + Columns.VIEW_COUNT +
"," + Task.Columns.DBLIST + " FROM " +
Task.TABLE_NAME + " WHERE " +
Task.Columns.COMPLETED + " IS NULL " +
" GROUP BY " + Task.Columns.DBLIST + ") " +
" ON " + TABLE_NAME + "." + Columns._ID +
" = " + Task.Columns.DBLIST + ";";
public String title = "";
// milliseconds since 1970-01-01 UTC
public Long updated = null;
/**
* The database column is "tasktype". Either {@link R.string#const_listtype_notes},
* {@link R.string#const_listtype_tasks} or NULL.
* NULL means: use global preferences
*/
public String listtype = null;
public String sorting = null;
public TaskList() {}
public TaskList(final Cursor c) {
this._id = c.getLong(0);
this.title = c.getString(1);
this.updated = c.getLong(2);
this.listtype = c.getString(3);
this.sorting = c.getString(4);
}
public TaskList(final Uri uri, final ContentValues values) {
this(Long.parseLong(uri.getLastPathSegment()), values);
}
public TaskList(final long id, final ContentValues values) {
this(values);
this._id = id;
}
public TaskList(final JSONObject json) throws JSONException {
if (json.has(Columns.TITLE))
title = json.getString(Columns.TITLE);
if (json.has(Columns.UPDATED))
updated = json.getLong(Columns.UPDATED);
if (json.has(Columns.LISTTYPE))
listtype = json.getString(Columns.LISTTYPE);
if (json.has(Columns.SORTING))
sorting = json.getString(Columns.SORTING);
}
public TaskList(final ContentValues values) {
title = values.getAsString(Columns.TITLE);
updated = values.getAsLong(Columns.UPDATED);
listtype = values.getAsString(Columns.LISTTYPE);
sorting = values.getAsString(Columns.SORTING);
}
public ContentValues getContent() {
final ContentValues values = new ContentValues();
// Note that ID is NOT included here
values.put(Columns.TITLE, title);
values.put(Columns.UPDATED, updated);
values.put(Columns.LISTTYPE, listtype);
values.put(Columns.SORTING, sorting);
return values;
}
@Override
protected String getTableName() {
return TABLE_NAME;
}
@Override
public String getContentType() {
return CONTENT_TYPE;
}
@Override
public int save(final Context context) {
return save(context, Calendar.getInstance().getTimeInMillis());
}
/**
* Insert or update this {@link TaskList} in the database
*/
public int save(final Context context, final long updateTime) {
int result = 0;
updated = updateTime;
if (_id < 1) {
final Uri uri = context
.getContentResolver()
.insert(getBaseUri(), getContent());
if (uri != null) {
_id = Long.parseLong(uri.getLastPathSegment());
result++;
}
} else {
result += context
.getContentResolver()
.update(getUri(), getContent(), null, null);
}
return result;
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/fragments/DialogConfirmBase.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.fragments;
import android.app.AlertDialog;
import android.app.Dialog;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.fragment.app.DialogFragment;
/**
* Simple confirm dialog fragment.
*/
public abstract class DialogConfirmBase extends DialogFragment {
public interface DialogConfirmedListener {
void onConfirm();
}
DialogConfirmedListener listener;
public void setListener(final DialogConfirmedListener l) {
listener = l;
}
public DialogConfirmBase() {}
@NonNull
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
return new AlertDialog.Builder(getActivity())
.setTitle(getTitle())
.setMessage(getMessage())
.setPositiveButton(android.R.string.ok, (dialog, which) -> onOKClick())
.setNegativeButton(android.R.string.cancel, (dialog, which) -> dialog.dismiss())
.create();
}
public abstract int getTitle();
public abstract int getMessage();
public abstract void onOKClick();
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/fragments/DialogConfirmBaseV11.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.fragments;
import android.app.AlertDialog;
import android.app.Dialog;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.fragment.app.DialogFragment;
/**
* Simple confirm dialog fragment, extending from V11 fragment
*/
public abstract class DialogConfirmBaseV11 extends DialogFragment {
public interface DialogConfirmedListener {
void onConfirm();
}
DialogConfirmedListener listener;
public void setListener(final DialogConfirmedListener l) {
listener = l;
}
@NonNull
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
return new AlertDialog.Builder(getActivity())
.setTitle(getTitle())
.setMessage(getMessage())
.setPositiveButton(android.R.string.ok, (dialog, which) -> onOKClick())
.setNegativeButton(android.R.string.cancel, (dialog, which) -> dialog.dismiss())
.create();
}
public abstract int getTitle();
public abstract CharSequence getMessage();
public abstract void onOKClick();
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/fragments/DialogDeleteCompletedTasks.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.fragments;
import android.os.Bundle;
import android.widget.Toast;
import androidx.fragment.app.FragmentManager;
import com.nononsenseapps.notepad.R;
import com.nononsenseapps.notepad.database.Task;
/**
* Popup to confirm the user's choice to delete all completed tasks
*/
public class DialogDeleteCompletedTasks extends DialogDeleteTask {
public static void showDialog(final FragmentManager fm, final long listId,
final DialogConfirmedListener listener) {
DialogDeleteCompletedTasks d = new DialogDeleteCompletedTasks();
d.setListener(listener);
Bundle args = new Bundle();
args.putLong(ID, listId);
d.setArguments(args);
d.show(fm, TAG);
}
@Override
public int getMessage() {
return R.string.delete_completed_tasks_question;
}
@Override
public void onOKClick() {
String where = Task.Columns.COMPLETED + " IS NOT NULL";
String[] whereArgs = null;
switch ((int) getArguments().getLong(ID, -1)) {
case TaskListFragment.LIST_ID_ALL:
// Nothing to do. Take all completed
break;
case TaskListFragment.LIST_ID_OVERDUE:
where += TaskListFragment.andWhereOverdue();
break;
case TaskListFragment.LIST_ID_TODAY:
where += TaskListFragment.andWhereToday();
break;
case TaskListFragment.LIST_ID_WEEK:
where += TaskListFragment.andWhereWeek();
break;
default:
where += " AND " + Task.Columns.DBLIST + " IS ?";
whereArgs = new String[] { Long
.toString(getArguments().getLong(ID, -1)) };
break;
}
int rowsDeleted = getActivity().getContentResolver().delete(Task.URI, where, whereArgs);
if (0 < rowsDeleted) {
Toast.makeText(getActivity(), R.string.deleted, Toast.LENGTH_SHORT).show();
}
if (listener != null) {
listener.onConfirm();
}
getDialog().dismiss();
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/fragments/DialogDeleteList.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.fragments;
import android.os.Bundle;
import android.widget.Toast;
import androidx.fragment.app.FragmentManager;
import com.nononsenseapps.notepad.R;
import com.nononsenseapps.notepad.database.TaskList;
public class DialogDeleteList extends DialogConfirmBase {
static final String ID = "id";
static final String TAG = "deletelistok";
public static void showDialog(final FragmentManager fm, final long listId,
final DialogConfirmedListener listener) {
DialogDeleteList d = new DialogDeleteList();
d.setListener(listener);
Bundle args = new Bundle();
args.putLong(ID, listId);
d.setArguments(args);
d.show(fm, TAG);
}
@Override
public int getTitle() {
return R.string.delete_question;
}
@Override
public int getMessage() {
return R.string.delete_list_message;
}
@Override
public void onOKClick() {
if (getArguments().getLong(ID, -1) > 0) {
if (0 < getActivity().getContentResolver()
.delete(TaskList.getUri(getArguments().getLong(ID, -1)),
null, null)) {
Toast.makeText(getActivity(), R.string.deleted, Toast.LENGTH_SHORT).show();
}
}
if (listener != null) {
listener.onConfirm();
}
getDialog().dismiss();
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/fragments/DialogDeleteTask.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.fragments;
import android.os.Bundle;
import android.widget.Toast;
import androidx.fragment.app.FragmentManager;
import com.nononsenseapps.notepad.R;
import com.nononsenseapps.notepad.database.Task;
public class DialogDeleteTask extends DialogConfirmBase {
static final String ID = "id";
static final String TAG = "deletetaskok";
public static void showDialog(final FragmentManager fm, final long taskId,
final DialogConfirmedListener listener) {
DialogDeleteTask d = new DialogDeleteTask();
d.setListener(listener);
Bundle args = new Bundle();
args.putLong(ID, taskId);
d.setArguments(args);
d.show(fm, TAG);
}
@Override
public int getTitle() {
return R.string.delete_question;
}
@Override
public int getMessage() {
return R.string.delete_item_message;
}
@Override
public void onOKClick() {
if (getArguments().getLong(ID, -1) > 0) {
int rowsDeleted = getActivity()
.getContentResolver()
.delete(Task.getUri(getArguments().getLong(ID, -1)),
null, null);
if (0 < rowsDeleted) {
Toast.makeText(getActivity(), R.string.deleted, Toast.LENGTH_SHORT).show();
}
}
if (listener != null) {
listener.onConfirm();
}
getDialog().dismiss();
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/fragments/DialogEditList.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.fragments;
import android.database.Cursor;
import android.os.Bundle;
import android.text.Editable;
import android.text.TextWatcher;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.UiThread;
import androidx.fragment.app.DialogFragment;
import androidx.loader.app.LoaderManager;
import androidx.loader.app.LoaderManager.LoaderCallbacks;
import androidx.loader.content.CursorLoader;
import androidx.loader.content.Loader;
import androidx.preference.PreferenceManager;
import com.nononsenseapps.notepad.R;
import com.nononsenseapps.notepad.database.TaskList;
import com.nononsenseapps.notepad.databinding.FragmentDialogEditlistBinding;
public class DialogEditList extends DialogFragment {
public interface EditListDialogListener {
void onFinishEditDialog(long id);
}
static final String LIST_ID = "list_id";
private TaskList mTaskList;
private EditListDialogListener listener;
/**
* for {@link R.layout#fragment_dialog_editlist}
*/
private FragmentDialogEditlistBinding mBinding;
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
mBinding = FragmentDialogEditlistBinding.inflate(inflater, container, false);
return mBinding.getRoot();
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
// here you call methods with the old @AfterViews annotation
setup();
mBinding.titleField.addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {}
@Override
public void afterTextChanged(Editable s) {
// allow to press OK if there is a title
mBinding.buttons.dialogYes.setEnabled(mBinding.titleField.length() > 0);
}
});
mBinding.deleteButton.setOnClickListener(v -> deleteClicked());
mBinding.buttons.dialogNo.setOnClickListener(v -> dismiss());
mBinding.buttons.dialogYes.setOnClickListener(v -> okClicked());
}
@Override
public void onDestroyView() {
super.onDestroyView();
mBinding = null;
}
/**
* Use to create new list
*/
public static DialogEditList getInstance() {
DialogEditList dialog = new DialogEditList();
dialog.setArguments(new Bundle());
return dialog;
}
/**
* To edit an exising list
*/
public static DialogEditList getInstance(final long listid) {
DialogEditList dialog = new DialogEditList();
Bundle args = new Bundle();
args.putLong(LIST_ID, listid);
dialog.setArguments(args);
return dialog;
}
protected DialogEditList() {}
public void setListener(final EditListDialogListener listener) {
this.listener = listener;
}
void setup() {
// New item hides delete button and disables OK initially
if (getArguments().getLong(LIST_ID, -1) < 1) {
mBinding.deleteButton.setVisibility(View.GONE);
mBinding.buttons.dialogYes.setEnabled(false);
}
mBinding.modeSpinner.setAdapter(new ArrayAdapter<>(
getActivity(),
R.layout.spinner_item,
getActivity().getResources().getStringArray(R.array.show_list_as)));
mBinding.sortSpinner.setAdapter(new ArrayAdapter<>(
getActivity(),
R.layout.spinner_item,
getActivity().getResources().getStringArray(R.array.sort_list_by)));
if (getArguments().getLong(LIST_ID, -1) > 0) {
getDialog().setTitle(R.string.menu_managelists);
LoaderManager.getInstance(this).restartLoader(0, null,
new LoaderCallbacks() {
@NonNull
@Override
public Loader onCreateLoader(int arg0, Bundle arg1) {
return new CursorLoader(getActivity(),
TaskList.getUri(getArguments().getLong(LIST_ID, -1)),
TaskList.Columns.FIELDS, null, null,
null);
}
@Override
public void onLoadFinished(@NonNull Loader arg0, Cursor c) {
if (c.moveToFirst()) {
mTaskList = new TaskList(c);
DialogEditList.this.getActivity().runOnUiThread(() -> fillViews());
}
// Don't need it anymore
LoaderManager.getInstance(DialogEditList.this).destroyLoader(0);
}
@Override
public void onLoaderReset(@NonNull Loader arg0) {}
});
} else {
getDialog().setTitle(R.string.menu_createlist);
mTaskList = new TaskList();
}
}
@UiThread
void fillViews() {
mBinding.titleField.setText(mTaskList.title);
selectSortKey();
selectListTypeKey();
// Check if this is the default list
final long defList = Long.parseLong(PreferenceManager
.getDefaultSharedPreferences(getActivity())
.getString(getString(R.string.pref_defaultlist), "-1"));
if (mTaskList._id > 0 && defList == mTaskList._id) {
mBinding.defaultListBox.setChecked(true);
}
}
void deleteClicked() {
if (mTaskList._id > 0) {
DialogDeleteList.showDialog(getParentFragmentManager(), mTaskList._id, this::dismiss);
}
}
void okClicked() {
Toast.makeText(getActivity(), R.string.saved, Toast.LENGTH_SHORT).show();
mTaskList.title = mBinding.titleField.getText().toString();
mTaskList.sorting = getSortValue();
mTaskList.listtype = getListTypeValue();
mTaskList.save(getActivity());
if (mTaskList._id > 0 && mBinding.defaultListBox.isChecked()) {
PreferenceManager
.getDefaultSharedPreferences(getActivity())
.edit()
.putLong(getString(R.string.pref_defaultstartlist), mTaskList._id)
.putString(getString(R.string.pref_defaultlist), Long.toString(mTaskList._id))
.commit();
} else if (mTaskList._id > 0) {
// Remove pref if it is the default list currently
final long defList = Long
.parseLong(PreferenceManager
.getDefaultSharedPreferences(getActivity())
.getString(getString(R.string.pref_defaultlist), "-1"));
final long defStartList = PreferenceManager
.getDefaultSharedPreferences(getActivity())
.getLong(getString(R.string.pref_defaultstartlist), -1);
if (defList == mTaskList._id) {
PreferenceManager.getDefaultSharedPreferences(getActivity())
.edit()
.remove(getString(R.string.pref_defaultlist))
.commit();
}
if (defStartList == mTaskList._id) {
PreferenceManager.getDefaultSharedPreferences(getActivity())
.edit()
.remove(getString(R.string.pref_defaultstartlist))
.commit();
}
}
if (mTaskList._id > 0 && listener != null) {
listener.onFinishEditDialog(mTaskList._id);
}
// TODO save items if necessary
this.dismiss();
}
String getSortValue() {
// Default from global prefs
String result = null;
final String key = (String) mBinding.sortSpinner.getSelectedItem();
if (key.equals(getString(R.string.sort_list_alphabetical))) {
result = getString(R.string.const_alphabetic);
} else if (key.equals(getString(R.string.sort_list_due))) {
result = getString(R.string.const_duedate);
} else if (key.equals(getString(R.string.sort_list_manual))) {
result = getString(R.string.const_possubsort);
} else if (key.equals(getString(R.string.sort_list_updated))) {
result = getString(R.string.const_modified);
}
return result;
}
void selectSortKey() {
if (mTaskList == null) return;
if (mTaskList.sorting == null) {
mBinding.sortSpinner.setSelection(0);
} else if (mTaskList.sorting.equals(getString(R.string.const_alphabetic))) {
mBinding.sortSpinner.setSelection(1);
} else if (mTaskList.sorting.equals(getString(R.string.const_modified))) {
mBinding.sortSpinner.setSelection(2);
} else if (mTaskList.sorting.equals(getString(R.string.const_duedate))) {
mBinding.sortSpinner.setSelection(3);
} else if (mTaskList.sorting.equals(getString(R.string.const_possubsort))) {
mBinding.sortSpinner.setSelection(4);
}
}
String getListTypeValue() {
// Default from global prefs
String result = null;
final String key = (String) mBinding.modeSpinner.getSelectedItem();
if (key.equals(getString(R.string.show_items_as_notes))) {
result = getString(R.string.const_listtype_notes);
} else if (key.equals(getString(R.string.show_items_as_tasks))) {
result = getString(R.string.const_listtype_tasks);
}
return result;
}
void selectListTypeKey() {
if (mTaskList == null) return;
if (mTaskList.listtype == null) {
mBinding.modeSpinner.setSelection(0);
} else if (mTaskList.listtype.equals(getString(R.string.const_listtype_tasks))) {
mBinding.modeSpinner.setSelection(1);
} else if (mTaskList.listtype.equals(getString(R.string.const_listtype_notes))) {
mBinding.modeSpinner.setSelection(2);
}
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/fragments/DialogExportBackup.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.fragments;
import android.net.Uri;
import android.os.Bundle;
import androidx.fragment.app.FragmentManager;
import com.nononsenseapps.notepad.R;
import com.nononsenseapps.notepad.prefs.BackupPrefs;
public class DialogExportBackup extends DialogConfirmBaseV11 {
static final String ID = "id";
static final String TAG = "deletelistok";
public static void showDialog(final FragmentManager fm,
final DialogConfirmedListener listener) {
DialogExportBackup d = new DialogExportBackup();
d.setListener(listener);
d.setArguments(new Bundle());
d.show(fm, TAG);
}
@Override
public int getTitle() {
return R.string.backup_export;
}
@Override
public CharSequence getMessage() {
Uri dir = BackupPrefs.getSelectedBackupDirUri(this.getContext());
if (dir == null) return getString(R.string.unavailable_chose_directory);
// ask users if they want to export to this folder
return getString(R.string.backup_export_msg, "\n" + dir.getLastPathSegment());
}
@Override
public void onOKClick() {
if (listener != null) {
listener.onConfirm();
}
getDialog().dismiss();
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/fragments/DialogMoveToList.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.fragments;
import android.content.ContentValues;
import android.database.Cursor;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.AdapterView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.cursoradapter.widget.SimpleCursorAdapter;
import androidx.fragment.app.DialogFragment;
import androidx.loader.app.LoaderManager;
import androidx.loader.app.LoaderManager.LoaderCallbacks;
import androidx.loader.content.CursorLoader;
import androidx.loader.content.Loader;
import com.nononsenseapps.notepad.R;
import com.nononsenseapps.notepad.database.DAO;
import com.nononsenseapps.notepad.database.Task;
import com.nononsenseapps.notepad.database.TaskList;
import com.nononsenseapps.notepad.databinding.FragmentDialogMovetolistBinding;
import java.util.concurrent.Executors;
/**
* When you long-click a note, you can press a button on the actionbar to move it
* to anoter list. Then, this popup shows up to let the user choose the destination
*/
public class DialogMoveToList extends DialogFragment {
static final String TASK_IDS = "task_ids";
private TaskList mTaskList;
private long[] taskIds = null;
/**
* for {@link R.layout#fragment_dialog_movetolist}
*/
private FragmentDialogMovetolistBinding mBinding;
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
mBinding = FragmentDialogMovetolistBinding.inflate(inflater, container, false);
return mBinding.getRoot();
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
// here you call methods with the old @AfterViews annotation
setup();
mBinding.buttons.dialogNo.setOnClickListener(v -> dismiss());
mBinding.buttons.dialogYes.setOnClickListener(v -> okClicked());
}
@Override
public void onDestroyView() {
super.onDestroyView();
mBinding = null;
}
public static DialogMoveToList getInstance(final Long... tasks) {
long[] taskIds = new long[tasks.length];
for (int i = 0; i < tasks.length; i++) {
taskIds[i] = tasks[i];
}
return getInstance(taskIds);
}
public static DialogMoveToList getInstance(final long... taskIds) {
DialogMoveToList dialog = new DialogMoveToList();
Bundle args = new Bundle();
args.putLongArray(TASK_IDS, taskIds);
dialog.setArguments(args);
return dialog;
}
public DialogMoveToList() {}
void setup() {
if (!getArguments().containsKey(TASK_IDS)) {
dismiss();
}
this.taskIds = getArguments().getLongArray(TASK_IDS);
if (taskIds.length < 1) {
dismiss();
}
getDialog().setTitle(R.string.move_to);
// Must select item first
mBinding.buttons.dialogYes.setEnabled(false);
// Adapter for list titles and ids
final SimpleCursorAdapter adapter = new SimpleCursorAdapter(
getActivity(), R.layout.simple_light_list_item_activated_1,
null, new String[] { TaskList.Columns.TITLE },
new int[] { android.R.id.text1 }, 0);
// Set it to the view
mBinding.listView.setAdapter(adapter);
mBinding.listView.setOnItemClickListener(
(arg0, arg1, pos, id) -> mBinding.buttons.dialogYes.setEnabled(true));
// Load content
LoaderManager.getInstance(this).restartLoader(0, null,
new LoaderCallbacks() {
@NonNull
@Override
public Loader onCreateLoader(int arg0, Bundle arg1) {
return new CursorLoader(getActivity(), TaskList.URI,
TaskList.Columns.FIELDS, null, null,
getResources().getString(
R.string.const_as_alphabetic,
TaskList.Columns.TITLE));
}
@Override
public void onLoadFinished(@NonNull Loader arg0, Cursor c) {
adapter.swapCursor(c);
}
@Override
public void onLoaderReset(@NonNull Loader arg0) {
adapter.swapCursor(null);
}
});
}
void moveItems(final long toListId, final long[] taskIds) {
Executors.newSingleThreadExecutor().execute(() -> {
final ContentValues val = new ContentValues();
val.put(Task.Columns.DBLIST, toListId);
// where _ID in (1, 2, 3)
final String whereId = Task.Columns._ID + " IN (" + DAO.arrayToCommaString(taskIds) + ")";
getActivity().getContentResolver().update(Task.URI, val, whereId, null);
});
}
void okClicked() {
// move items
if (mBinding.listView.getCheckedItemPosition() == AdapterView.INVALID_POSITION) {
return;
}
final Cursor c = (Cursor) mBinding.listView
.getItemAtPosition(mBinding.listView.getCheckedItemPosition());
if (c != null) {
final long targetListId = c.getLong(0);
final String targetListTitle = c.getString(1);
if (taskIds.length > 0 && targetListId > 0) {
moveItems(targetListId, taskIds);
}
try {
Toast.makeText(getActivity(),
getString(R.string.moved_x_to_list, taskIds.length, targetListTitle),
Toast.LENGTH_SHORT).show();
} catch (Exception e) {
// Guard against translations
}
}
this.dismiss();
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/fragments/DialogPassword.java
================================================
/*
* Copyright (C) 2012 Jonas Kalderstam
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package com.nononsenseapps.notepad.fragments;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.animation.Animation;
import android.view.animation.AnimationUtils;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.DialogFragment;
import androidx.preference.PreferenceManager;
import com.nononsenseapps.helpers.PreferencesHelper;
import com.nononsenseapps.notepad.R;
import com.nononsenseapps.notepad.databinding.FragmentDialogPasswordBinding;
import com.nononsenseapps.notepad.prefs.PasswordPrefs;
public class DialogPassword extends DialogFragment {
PasswordConfirmedListener listener = null;
public interface PasswordConfirmedListener {
void onPasswordConfirmed();
}
public void setListener(final PasswordConfirmedListener listener) {
this.listener = listener;
}
/**
* for {@link R.layout#fragment_dialog_password}
*/
private FragmentDialogPasswordBinding mBinding;
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
final SharedPreferences settings = PreferenceManager
.getDefaultSharedPreferences(getActivity());
final String currentPassword = settings.getString(PasswordPrefs.KEY_PASSWORD, "");
if (currentPassword.isEmpty()) {
getDialog().setTitle(R.string.enter_new_password);
} else {
getDialog().setTitle(R.string.password_required);
}
mBinding = FragmentDialogPasswordBinding.inflate(inflater, container, false);
return mBinding.getRoot();
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
// here you call methods with the old @AfterViews annotation
showField();
mBinding.buttons.dialogNo.setOnClickListener(v -> dismiss());
mBinding.buttons.dialogYes.setOnClickListener(v -> confirm());
}
@Override
public void onDestroyView() {
super.onDestroyView();
mBinding = null;
}
public void showField() {
final SharedPreferences settings = PreferenceManager
.getDefaultSharedPreferences(getActivity());
final String currentPassword = settings.getString(PasswordPrefs.KEY_PASSWORD, "");
if (currentPassword.isEmpty()) {
mBinding.passwordVerificationField.setVisibility(View.VISIBLE);
} else {
mBinding.passwordVerificationField.setVisibility(View.GONE);
}
}
void confirm() {
final SharedPreferences settings = PreferenceManager
.getDefaultSharedPreferences(getActivity());
final String currentPassword = settings.getString(PasswordPrefs.KEY_PASSWORD,
"");
final String enteredPassword = mBinding.passwordField.getText().toString();
final String verifiedPassword = mBinding.passwordVerificationField.getText().toString();
if (currentPassword.isEmpty()) {
setPassword(enteredPassword, verifiedPassword);
} else {
// We want to return true or false, user has entered correct password
checkPassword(enteredPassword, currentPassword);
}
}
private void checkPassword(final String enteredPassword, final String currentPassword) {
if (currentPassword.equals(enteredPassword)) {
if (listener != null) {
listener.onPasswordConfirmed();
}
dismiss();
} else {
if (PreferencesHelper.areAnimationsEnabled(this.getContext())) {
// shake the dialog to show that the password is wrong
Animation shake = AnimationUtils.loadAnimation(getActivity(), R.anim.shake);
mBinding.passwordField.startAnimation(shake);
}
Toast.makeText(getActivity(), getText(R.string.password_incorrect),
Toast.LENGTH_SHORT).show();
}
}
private void setPassword(final String pass1, final String pass2) {
if (pass1 != null && !pass1.isEmpty() && pass1.equals(pass2)) {
PreferenceManager.getDefaultSharedPreferences(getActivity())
.edit()
.putString(PasswordPrefs.KEY_PASSWORD, pass1)
.commit();
if (listener != null) {
listener.onPasswordConfirmed();
}
dismiss();
} else {
if (PreferencesHelper.areAnimationsEnabled(this.getContext())) {
// shake the dialog to show that the password is wrong
Animation shake = AnimationUtils.loadAnimation(getActivity(), R.anim.shake);
mBinding.passwordVerificationField.startAnimation(shake);
}
Toast.makeText(getActivity(), getText(R.string.passwords_dont_match),
Toast.LENGTH_SHORT).show();
}
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/fragments/DialogPasswordV11.java
================================================
/*
* Copyright (C) 2012 Jonas Kalderstam
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.nononsenseapps.notepad.fragments;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.animation.Animation;
import android.view.animation.AnimationUtils;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.DialogFragment;
import androidx.preference.PreferenceManager;
import com.nononsenseapps.helpers.PreferencesHelper;
import com.nononsenseapps.notepad.R;
import com.nononsenseapps.notepad.databinding.FragmentDialogPasswordBinding;
import com.nononsenseapps.notepad.fragments.DialogPassword.PasswordConfirmedListener;
import com.nononsenseapps.notepad.prefs.PasswordPrefs;
/**
* Full copy of {@link DialogPassword}, but extending native fragment class
* {@link android.app.DialogFragment} instead.
* It is called when the user changes the existing password.
* It asks to input the old password.
*/
public class DialogPasswordV11 extends DialogFragment {
// TODO DialogPassword.java is better. Try to put the functions of this dialog back
// into that file, then delete this file
PasswordConfirmedListener listener = null;
public void setListener(final PasswordConfirmedListener listener) {
this.listener = listener;
}
/**
* for {@link R.layout#fragment_dialog_password}
*/
private FragmentDialogPasswordBinding mBinding;
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
@Nullable Bundle savInstState) {
getDialog().setTitle(R.string.password_required);
mBinding = FragmentDialogPasswordBinding.inflate(inflater, container, false);
return mBinding.getRoot();
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
// here you call methods with the old @AfterViews annotation
mBinding.buttons.dialogNo.setOnClickListener(v -> dismiss());
mBinding.buttons.dialogYes.setOnClickListener(v -> confirm());
}
@Override
public void onDestroyView() {
super.onDestroyView();
mBinding = null;
}
void confirm() {
final SharedPreferences settings = PreferenceManager
.getDefaultSharedPreferences(getActivity());
String currentPassword = settings.getString(PasswordPrefs.KEY_PASSWORD,
"");
String enteredPassword = mBinding.passwordField.getText().toString();
// We want to return true or false, user has entered correct
// password
checkPassword(enteredPassword, currentPassword);
}
private void checkPassword(String enteredPassword, String currentPassword) {
if ("".equals(currentPassword) || currentPassword.equals(enteredPassword)) {
if (listener != null) {
listener.onPasswordConfirmed();
}
dismiss();
} else {
if (PreferencesHelper.areAnimationsEnabled(this.getContext())) {
// shake the dialog to show that the password is wrong
Animation shake = AnimationUtils.loadAnimation(getActivity(), R.anim.shake);
mBinding.passwordField.startAnimation(shake);
}
Toast.makeText(getActivity(), getText(R.string.password_incorrect),
Toast.LENGTH_SHORT).show();
}
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/fragments/DialogRestore.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.fragments;
import android.database.Cursor;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.cursoradapter.widget.SimpleCursorAdapter;
import androidx.fragment.app.DialogFragment;
import androidx.loader.app.LoaderManager;
import androidx.loader.app.LoaderManager.LoaderCallbacks;
import androidx.loader.content.CursorLoader;
import androidx.loader.content.Loader;
import com.nononsenseapps.notepad.R;
import com.nononsenseapps.notepad.database.TaskList;
import com.nononsenseapps.notepad.databinding.FragmentDialogRestoreBinding;
public class DialogRestore extends DialogFragment {
public interface OnListSelectedListener {
void onListSelected(long listId);
}
private OnListSelectedListener listener;
/**
* for {@link R.layout#fragment_dialog_restore}
*/
private FragmentDialogRestoreBinding mBinding;
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
mBinding = FragmentDialogRestoreBinding.inflate(inflater, container, false);
return mBinding.getRoot();
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
// here you call methods with the old @AfterViews annotation
setup();
mBinding.buttons.dialogNo.setOnClickListener(v -> dismiss());
mBinding.buttons.dialogYes.setOnClickListener(v -> okClicked());
}
@Override
public void onDestroyView() {
super.onDestroyView();
mBinding = null;
}
/**
* Use to create new list
*/
public static DialogRestore getInstance() {
DialogRestore dialog = new DialogRestore();
dialog.setArguments(new Bundle());
return dialog;
}
public DialogRestore() {}
public void setListener(final OnListSelectedListener listener) {
this.listener = listener;
}
void setup() {
getDialog().setTitle(R.string.restore_to);
final SimpleCursorAdapter adapter =
new SimpleCursorAdapter(getActivity(),
R.layout.spinner_item, null,
new String[] { TaskList.Columns.TITLE },
new int[] { R.id.textViewSpinnerItem }, 0);
mBinding.listSpinner.setAdapter(adapter);
LoaderManager.getInstance(this).restartLoader(0, null,
new LoaderCallbacks() {
@NonNull
@Override
public Loader onCreateLoader(int arg0, Bundle arg1) {
return new CursorLoader(getActivity(),
TaskList.URI,
TaskList.Columns.FIELDS,
null,
null,
TaskList.Columns.TITLE);
}
@Override
public void onLoadFinished(@NonNull Loader arg0, Cursor c) {
adapter.swapCursor(c);
}
@Override
public void onLoaderReset(@NonNull Loader arg0) {
adapter.swapCursor(null);
}
});
}
void okClicked() {
Toast.makeText(getActivity(), R.string.saved, Toast.LENGTH_SHORT).show();
// TODO do something
if (listener != null) {
listener.onListSelected(mBinding.listSpinner.getSelectedItemId());
}
this.dismiss();
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/fragments/DialogRestoreBackup.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.fragments;
import android.net.Uri;
import android.os.Bundle;
import androidx.documentfile.provider.DocumentFile;
import androidx.fragment.app.FragmentManager;
import com.nononsenseapps.helpers.DocumentFileHelper;
import com.nononsenseapps.notepad.R;
import com.nononsenseapps.notepad.prefs.BackupPrefs;
public class DialogRestoreBackup extends DialogConfirmBaseV11 {
static final String ID = "id";
static final String TAG = "deletelistok";
public static void showDialog(final FragmentManager fm,
final DialogConfirmedListener listener) {
DialogRestoreBackup d = new DialogRestoreBackup();
d.setListener(listener);
d.setArguments(new Bundle());
d.show(fm, TAG);
}
@Override
public int getTitle() {
return R.string.backup_import;
}
@Override
public CharSequence getMessage() {
Uri dir = BackupPrefs.getSelectedBackupDirUri(this.getContext());
if (dir == null) return getString(R.string.unavailable_chose_directory);
DocumentFile file = DocumentFileHelper.getSelectedBackupJsonFile(this.getContext());
if (file == null) return getString(R.string.backup_file_not_found);
// file found. Ask users if they want to import from it
return getString(R.string.backup_import_msg,
"\n" + file.getUri().getLastPathSegment());
}
@Override
public void onOKClick() {
if (listener != null) {
listener.onConfirm();
}
getDialog().dismiss();
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/fragments/FragmentSearch.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.fragments;
import android.app.SearchManager;
import android.content.Intent;
import android.content.SharedPreferences;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.AdapterView.OnItemClickListener;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.widget.SearchView;
import androidx.cursoradapter.widget.SimpleCursorAdapter;
import androidx.cursoradapter.widget.SimpleCursorAdapter.ViewBinder;
import androidx.fragment.app.Fragment;
import androidx.loader.app.LoaderManager;
import androidx.loader.app.LoaderManager.LoaderCallbacks;
import androidx.loader.content.CursorLoader;
import androidx.loader.content.Loader;
import androidx.preference.PreferenceManager;
import com.nononsenseapps.notepad.R;
import com.nononsenseapps.notepad.database.Task;
import com.nononsenseapps.notepad.databinding.FragmentSearchBinding;
import com.nononsenseapps.ui.TitleNoteTextView;
/**
* This is used only in the "Archive" view, for deleted notes.
* For the search widget of the "main" view, see
* {@link TaskListViewPagerFragment#onCreateOptionsMenu}
*/
public class FragmentSearch extends Fragment {
public final static String QUERY = "query";
protected SimpleCursorAdapter mAdapter;
protected LoaderCallbacks mCallback;
protected String mQuery = "";
/**
* for {@link R.layout#fragment_search}
*/
protected FragmentSearchBinding mBinding;
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
mBinding = FragmentSearchBinding.inflate(inflater, container, false);
return mBinding.getRoot();
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
// here you call methods with the old @AfterViews annotation
setupAdapter();
}
@Override
public void onDestroyView() {
super.onDestroyView();
mBinding = null;
}
public static FragmentSearch getInstance(final String initialQuery) {
FragmentSearch f = new FragmentSearch();
Bundle args = new Bundle();
args.putString(QUERY, initialQuery);
f.setArguments(args);
return f;
}
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (getArguments() != null) {
if (getArguments().containsKey(QUERY))
mQuery = getArguments().getString(QUERY);
}
setHasOptionsMenu(true);
}
@Override
public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) {
super.onCreateOptionsMenu(menu, inflater);
// allow the user to search among the previously deleted notes
inflater.inflate(R.menu.fragment_search, menu);
// Get the SearchView and set the searchable configuration
SearchView searchView = (SearchView) menu
.findItem(R.id.menu_search)
.getActionView();
// Assumes current activity is the searchable activity
SearchManager sMan = this.getActivity().getSystemService(SearchManager.class);
searchView.setSearchableInfo(sMan.getSearchableInfo(getActivity().getComponentName()));
searchView.setIconifiedByDefault(false); // Do not iconify the widget;
// expand it by default
searchView.setQueryRefinementEnabled(false);
searchView.setSubmitButtonEnabled(false);
// Disable suggestions in "note archive" search activity
searchView.setSuggestionsAdapter(null);
searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() {
@Override
public boolean onQueryTextSubmit(final String query) {
doSearch(query);
return true;
}
@Override
public boolean onQueryTextChange(final String query) {
doSearch(query);
return true;
}
});
searchView.setQuery(mQuery, false);
}
void setupAdapter() {
mAdapter = getAdapter();
mAdapter.setViewBinder(getViewBinder());
// Set adapter
mBinding.list.setAdapter(mAdapter);
mBinding.list.setOnItemClickListener(getOnItemClickListener());
// Start loading data
mCallback = new LoaderCallbacks<>() {
@NonNull
@Override
public Loader onCreateLoader(int id, Bundle arg1) {
return new CursorLoader(getActivity(), getSearchUri(), getFields(),
null, new String[] { mQuery }, getSortOrder());
}
@Override
public void onLoadFinished(@NonNull Loader loader, Cursor c) {
mAdapter.swapCursor(c);
}
@Override
public void onLoaderReset(@NonNull Loader loader) {
mAdapter.swapCursor(null);
}
};
doSearch(mQuery);
}
protected void doSearch(final String query) {
mQuery = query == null ? "" : query;
// If not loaded yet, let it load
if (mCallback != null)
LoaderManager.getInstance(this).restartLoader(0, null, mCallback);
}
/**
* Override to give different search behaviour
*/
protected Uri getSearchUri() {
return Task.URI_SEARCH;
}
/**
* Override to give different search behaviour
*/
protected String[] getFields() {
return Task.Columns.FIELDS;
}
/**
* Override to give different search behaviour
*/
protected String getSortOrder() {
return Task.Columns.DUE;
}
/**
* Override to get different search behaviour
*/
protected SimpleCursorAdapter getAdapter() {
return new SimpleCursorAdapter(
getActivity(),
R.layout.tasklist_item_rich,
null,
new String[] { Task.Columns.TITLE, Task.Columns.NOTE, Task.Columns.DUE,
Task.Columns.COMPLETED, Task.Columns.LEFT, Task.Columns.RIGHT },
// in tasklist_idem_card_selection.xml, the due date is displayed in a DateView
// with ID = "date", so here Task.Columns.DUE is bound to R.id.date
new int[] { android.R.id.text1, android.R.id.text1, R.id.date, R.id.checkbox,
R.id.drag_handle, R.id.dragpadding },
0);
}
/**
* Override to give different search behaviour
*/
protected OnItemClickListener getOnItemClickListener() {
return (arg0, origin, pos, id)
-> startActivity(new Intent(Intent.ACTION_EDIT, Task.getUri(id)));
}
/**
* Override to give different search behaviour
*/
protected ViewBinder getViewBinder() {
// Get the global list settings
final SharedPreferences prefs = PreferenceManager
.getDefaultSharedPreferences(getActivity());
// Load pref for item height, or show 3 lines if it was not set
final int rowCount = prefs.getInt(getString(R.string.key_pref_item_max_height), 3);
return (view, c, colIndex) -> {
switch (colIndex) {
// Matches order in Task.Columns.Fields
case 1 -> {
// Title
String sTemp = c.getString(1);
// Set height of text for non-headers
if (rowCount == 1) {
((TitleNoteTextView) view).setSingleLine(true);
} else {
((TitleNoteTextView) view).setSingleLine(false);
((TitleNoteTextView) view).setMaxLines(rowCount);
}
// Change color based on complete status
((TitleNoteTextView) view).useSecondaryColor(!c.isNull(3));
((TitleNoteTextView) view).setTextTitle(sTemp);
return true;
}
case 2 -> {
// Note
// Only if task it not locked
if (c.getInt(9) != 1) {
((TitleNoteTextView) view).setTextRest(c.getString(colIndex));
} else {
((TitleNoteTextView) view).setTextRest("");
}
return true;
}
case 4 -> {
// DateView
if (!c.isNull(4)) {
// there IS a due date saved in the database for this note
long dueDate = c.getLong(4);
((com.nononsenseapps.ui.DateView) view).setTimeText(dueDate);
view.setVisibility(View.VISIBLE);
} else {
// visibility of the DateView defaults to GONE
// in tasklist_idem_card_selection.xml
}
return true;
}
default -> {
// Checkbox, DragGripView, DragPadding; as defined in getAdapter() in this file
view.setVisibility(View.GONE);
return true;
}
}
};
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/fragments/FragmentSearchDeleted.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.fragments;
import android.content.SharedPreferences;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.view.ActionMode;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.widget.AbsListView.MultiChoiceModeListener;
import android.widget.AdapterView.OnItemClickListener;
import android.widget.ListView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.UiThread;
import androidx.cursoradapter.widget.SimpleCursorAdapter;
import androidx.cursoradapter.widget.SimpleCursorAdapter.ViewBinder;
import androidx.preference.PreferenceManager;
import com.nononsenseapps.notepad.activities.ActivitySearchDeleted;
import com.nononsenseapps.notepad.R;
import com.nononsenseapps.notepad.database.DAO;
import com.nononsenseapps.notepad.database.Task;
import com.nononsenseapps.ui.TitleNoteTextView;
import java.util.HashSet;
/**
* The actual content of the "archive view" in {@link ActivitySearchDeleted }
*/
public class FragmentSearchDeleted extends FragmentSearch {
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
setSelection();
}
public static FragmentSearchDeleted getInstance(final String initialQuery) {
FragmentSearchDeleted f = new FragmentSearchDeleted();
Bundle args = new Bundle();
args.putString(QUERY, initialQuery);
f.setArguments(args);
return f;
}
private FragmentSearchDeleted() {}
void setSelection() {
mBinding.list.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE_MODAL);
mBinding.list.setMultiChoiceModeListener(new MultiChoiceModeListener() {
final HashSet selectedItems = new HashSet<>();
@Override
public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
return false;
}
@Override
public void onDestroyActionMode(ActionMode mode) {
selectedItems.clear();
}
@Override
public boolean onCreateActionMode(ActionMode mode, Menu menu) {
final MenuInflater inflater = getActivity().getMenuInflater();
inflater.inflate(R.menu.activity_deleted_context, menu);
return true;
}
String[] getIdArray() {
final String[] result = new String[selectedItems.size()];
int i = 0;
for (final long id : selectedItems) {
result[i] = Long.toString(id);
i++;
}
return result;
}
void deleteSelected(final ActionMode mode) {
String whereCondition = Task.Columns._ID + " IN ("
+ DAO.arrayToCommaString(getIdArray()) + ")";
getActivity()
.getContentResolver()
.delete(Task.URI_DELETED_QUERY, whereCondition, null);
selectedItems.clear();
// mode.finish() touches the views, so it MUST run on the UI thread
FragmentSearchDeleted.this.getActivity().runOnUiThread(mode::finish);
}
void restoreSelected(final ActionMode mode, final long listId) {
for (final Long id : selectedItems) {
final int pos = getPosOfId(id);
if (pos > -1) {
final Cursor c = (Cursor) mBinding.list.getItemAtPosition(pos);
// restore task
final Task t = new Task();
t.dblist = listId;
t.title = c.getString(1);
t.note = c.getString(2);
t.completed = c.isNull(3) ? null : c.getLong(3);
t.due = c.isNull(4) ? null : c.getLong(4);
t.save(getActivity());
}
}
notifySuccess();
deleteSelected(mode);
}
int getPosOfId(final long id) {
int length = mBinding.list.getCount();
int position;
boolean found = false;
for (position = 0; position < length; position++) {
if (id == mBinding.list.getItemIdAtPosition(position)) {
found = true;
break;
}
}
if (!found) {
// Happens both if list is empty
// and if id is -1
position = -1;
}
return position;
}
/** Show a {@link Toast} in a thread-safe way */
@UiThread
void notifySuccess() {
new Handler(Looper.getMainLooper()).post(() -> Toast.makeText(getActivity(),
R.string.saved, Toast.LENGTH_SHORT).show());
}
@Override
public boolean onActionItemClicked(final ActionMode mode, final MenuItem item) {
int itemId = item.getItemId();
if (itemId == R.id.menu_restore) {
DialogRestore d = DialogRestore.getInstance();
d.setListener(listId -> {
if (listId > 0) {
restoreSelected(mode, listId);
}
});
d.show(getParentFragmentManager(), "listselect");
return true;
} else if (itemId == R.id.menu_delete) {
DialogDeleteTask.showDialog(getParentFragmentManager(), -1,
() -> deleteSelected(mode));
return true;
} else if (itemId == R.id.menu_select_all) {
// Note: don't use .getChildCount(), that only reports the VISIBLE items,
// so it's NEVER more than ~9, considering the average screen height!
int howMany = mBinding.list.getAdapter().getCount();
for (int i = 0; i < howMany; i++) {
// select every list item
mBinding.list.setItemChecked(i, true);
}
}
return false;
}
@Override
public void onItemCheckedStateChanged(ActionMode mode, int position, long id,
boolean checked) {
if (checked) {
selectedItems.add(id);
} else {
selectedItems.remove(id);
}
}
});
}
@Override
protected Uri getSearchUri() {
return Task.URI_DELETED_QUERY;
}
@Override
protected String[] getFields() {
return Task.Columns.DELETEFIELDS;
}
@Override
protected String getSortOrder() {
return Task.Columns.TRIG_DELETED + " DESC";
}
@Override
protected OnItemClickListener getOnItemClickListener() {
return (arg0, origin, pos, id) -> mBinding.list.setItemChecked(pos, true);
}
@Override
protected SimpleCursorAdapter getAdapter() {
return new SimpleCursorAdapter(getActivity(),
R.layout.tasklist_item_rich,
null,
new String[] { Task.Columns.TITLE, Task.Columns.NOTE, Task.Columns.DUE,
Task.Columns.COMPLETED, Task.Columns.TRIG_DELETED,
Task.Columns.TRIG_DELETED },
new int[] { android.R.id.text1, android.R.id.text1, R.id.date, R.id.checkbox,
R.id.drag_handle, R.id.dragpadding },
0);
}
@Override
protected ViewBinder getViewBinder() {
// Get the global list settings
final SharedPreferences prefs = PreferenceManager
.getDefaultSharedPreferences(getActivity());
// Load preference for note height, or show 3 lines if it was not set
final int rowCount = prefs.getInt(getString(R.string.key_pref_item_max_height), 3);
return (view, c, colIndex) -> {
switch (colIndex) {
// the code here decides how the notes on the archive look like.
// Each number in the "case" instruction matches the order in Task.Columns.Fields,
// in fact c.getColumnNames() returns the fields of the note in the database:
// ["_id", "title", "note", "completed", "due", "dblist", "deletedtime" ]
case 1 -> {
// Title, in column "title"
String noteTitle = c.getString(colIndex);
// Set height of text for non-headers
if (rowCount == 1) {
((TitleNoteTextView) view).setSingleLine(true);
} else {
((TitleNoteTextView) view).setSingleLine(false);
((TitleNoteTextView) view).setMaxLines(rowCount);
}
// Change color based on complete status (column 3 is the "completed" status)
((TitleNoteTextView) view).useSecondaryColor(!c.isNull(3));
// TODO yes, completed note appear in dark gray in the archive view. I didn't
// know this. Make a TapTargetView to explain this to users. It could target
// the search icon, it doesn't matter. Just put it in onResume() or somewhere
// reasonable
((TitleNoteTextView) view).setTextTitle(noteTitle);
return true;
}
case 2 -> {
// Note content, in column "note". Let's show it even in the "Archive" view,
// so that the user can distinguish 2 deleted notes with the same title
String noteContent = c.getString(colIndex);
((TitleNoteTextView) view).setTextRest(noteContent);
return true;
}
case 4 -> {
// DateView
if (!c.isNull(4)) {
// there IS a due date saved in the database for this note
long dueDate = c.getLong(4);
((com.nononsenseapps.ui.DateView) view).setTimeText(dueDate);
view.setVisibility(View.VISIBLE);
} else {
// visibility of the DateView defaults to GONE
// in tasklist_idem_card_selection.xml
}
return true;
}
default -> {
// Checkbox, DragGripView, DragPadding; as defined in getAdapter() in this file
view.setVisibility(View.GONE);
return true;
}
}
};
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/fragments/TaskDetailFragment.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.fragments;
import android.app.Activity;
import android.app.DatePickerDialog;
import android.app.TimePickerDialog;
import android.content.Intent;
import android.content.SharedPreferences;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.text.format.DateFormat;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.view.inputmethod.InputMethodManager;
import android.widget.Button;
import android.widget.CheckBox;
import android.widget.DatePicker;
import android.widget.ImageButton;
import android.widget.LinearLayout;
import android.widget.ScrollView;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.app.ShareCompat;
import androidx.fragment.app.Fragment;
import androidx.loader.app.LoaderManager;
import androidx.loader.app.LoaderManager.LoaderCallbacks;
import androidx.loader.content.CursorLoader;
import androidx.loader.content.Loader;
import androidx.preference.PreferenceManager;
import com.nononsenseapps.helpers.ListHelper;
import com.nononsenseapps.helpers.NnnLogger;
import com.nononsenseapps.helpers.TimeFormatter;
import com.nononsenseapps.notepad.activities.main.ActivityMain;
import com.nononsenseapps.notepad.activities.ActivityTaskHistory;
import com.nononsenseapps.notepad.R;
import com.nononsenseapps.notepad.R.layout;
import com.nononsenseapps.notepad.activities.main.ActivityMain_;
import com.nononsenseapps.notepad.database.Notification;
import com.nononsenseapps.notepad.database.Task;
import com.nononsenseapps.notepad.database.TaskList;
import com.nononsenseapps.notepad.databinding.FragmentTaskDetailBinding;
import com.nononsenseapps.notepad.interfaces.MenuStateController;
import com.nononsenseapps.notepad.interfaces.OnFragmentInteractionListener;
import com.nononsenseapps.notepad.prefs.PrefsActivity;
import com.nononsenseapps.ui.NotificationItemHelper;
import com.nononsenseapps.ui.ShowcaseHelper;
import com.nononsenseapps.ui.StyledEditText;
import org.androidannotations.annotations.AfterViews;
import org.androidannotations.annotations.Click;
import org.androidannotations.annotations.EFragment;
import org.androidannotations.annotations.InstanceState;
import org.androidannotations.annotations.UiThread;
import org.androidannotations.annotations.UiThread.Propagation;
import org.androidannotations.annotations.ViewById;
import java.util.Calendar;
/**
* A fragment representing a single Note detail screen.
* Lifecycle (order of the functions):
* 1 - entering the fragment:
* ...
* 2 - exiting the fragment:
* 2.1 - going back to {@link ActivityMain}, pressing "+" or "undo":
* {@link #onPause()}
* {@link #onStop()}
* {@link #onDestroyView()}
* {@link #onDestroy()}
* {@link #onDetach()}
* 2.2 - opening either {@link PrefsActivity} or {@link ActivityTaskHistory}:
* {@link #onPause()}
* {@link #onStop()}
* 2.3 - share a note, opening the chooser panel
* {@link #onPause()}
* 2.3bis - then launch an activity to do something with the note being shared
* {@link #onStop()}
* 2.4 launch a popup, either "delete" or "password lock"
* (none of those)
*/
@EFragment
public class TaskDetailFragment extends Fragment {
public static final int LOADER_EDITOR_TASK = 3001;
public static final int LOADER_EDITOR_TASKLISTS = 3002;
public static final int LOADER_EDITOR_NOTIFICATIONS = 3003;
final LoaderCallbacks loaderCallbacks = new LoaderCallbacks<>() {
@Override
public Loader onCreateLoader(final int id, final Bundle args) {
if (LOADER_EDITOR_NOTIFICATIONS == id) {
return new CursorLoader(getActivity(), Notification.URI,
Notification.Columns.FIELDS,
Notification.Columns.TASKID + " IS ?",
new String[] { Long.toString(args.getLong(ARG_ITEM_ID,
-1)) }, Notification.Columns.TIME);
} else if (LOADER_EDITOR_TASK == id) {
return new CursorLoader(getActivity(), Task.getUri(args
.getLong(ARG_ITEM_ID, -1)), Task.Columns.FIELDS, null,
null, null);
} else if (LOADER_EDITOR_TASKLISTS == id) {
return new CursorLoader(getActivity(), TaskList.getUri(args
.getLong(ARG_ITEM_LIST_ID)), TaskList.Columns.FIELDS,
null, null, null);
} else {
return null;
}
}
@Override
public void onLoadFinished(Loader ldr, Cursor c) {
if (LOADER_EDITOR_TASK == ldr.getId()) {
if (c != null && c.moveToFirst()) {
if (mTask == null) {
mTask = new Task(c);
if (mTaskOrg == null) {
mTaskOrg = new Task(c);
}
fillUIFromTask();
// Don't want updates while editing
// getLoaderManager().destroyLoader(LOADER_EDITOR_TASK);
} else {
// Don't want updates while editing
// getLoaderManager().destroyLoader(LOADER_EDITOR_TASK);
// Only update the list if that changes
NnnLogger.debug(TaskDetailFragment.class,
"Updating list in task from " + mTask.dblist);
mTask.dblist = new Task(c).dblist;
NnnLogger.debug(TaskDetailFragment.class,
"Updating list in task to " + mTask.dblist);
if (mTaskOrg != null) {
mTaskOrg.dblist = mTask.dblist;
}
}
// Load the list to see if we should hide task bits
Bundle args = new Bundle();
args.putLong(ARG_ITEM_LIST_ID, mTask.dblist);
LoaderManager
.getInstance(TaskDetailFragment.this)
.restartLoader(LOADER_EDITOR_TASKLISTS, args, this);
args.clear();
args.putLong(ARG_ITEM_ID, getArguments().getLong(ARG_ITEM_ID, stateId));
LoaderManager
.getInstance(TaskDetailFragment.this)
.restartLoader(LOADER_EDITOR_NOTIFICATIONS, args, loaderCallbacks);
} else {
// Should kill myself maybe?
}
} else if (LOADER_EDITOR_NOTIFICATIONS == ldr.getId()) {
while (c != null && c.moveToNext()) {
// TODO this causes the "ghost reminder widgets" bug. See issue #412
// a shitty fix is provided in onStop(). The problem is that it runs
// too many times, and it duplicates notification views. As of now it
// happens only when locking a note from the menu
addNotification(new Notification(c));
}
// Don't update while editing
// TODO this allows updating of the location name etc
LoaderManager
.getInstance(TaskDetailFragment.this)
.destroyLoader(LOADER_EDITOR_NOTIFICATIONS);
} else if (LOADER_EDITOR_TASKLISTS == ldr.getId()) {
// At current only loading a single list
if (c != null && c.moveToFirst()) {
final TaskList list = new TaskList(c);
hideTaskParts(list);
}
}
}
@Override
public void onLoaderReset(@NonNull Loader arg0) {}
};
@ViewById(resName = "taskText")
StyledEditText taskText;
@ViewById(resName = "taskCompleted")
CheckBox taskCompleted;
@ViewById(resName = "dueDateBox")
Button dueDateBox;
@ViewById(resName = "dueCancelButton")
ImageButton dueCancelButton;
@ViewById(resName = "notificationAdd")
TextView notificationAdd;
/**
* holds a list of widgets, one for each reminder the user sets.
* It is below the "due date" row
*/
@ViewById(resName = "notificationList")
LinearLayout notificationList;
@ViewById(resName = "taskSection")
View taskSection;
@ViewById(resName = "editScrollView")
ScrollView editScrollView;
/**
* Id of task to open
*/
public static final String ARG_ITEM_ID = "item_id";
/**
* If no id is given, a string can be accepted as initial state
*/
public static final String ARG_ITEM_CONTENT = "item_text";
/**
* A list id is necessary
*/
public static final String ARG_ITEM_LIST_ID = "item_list_id";
/**
* preference key for the tutorial link message
*/
private static final String SHOWCASED_EDITOR = "showcased_tutorial_from_editor_window";
// To override intent values with
@InstanceState
long stateId = -1;
@InstanceState
long stateListId = -1;
/**
* Dao version of the object this fragment represents
*/
private Task mTask;
// Version when task was opened
private Task mTaskOrg;
/**
* To save orgState
*/
// TODO [use it in "] AND [" logic] with task.locked. If result is true, note is locked and
// has not been unlocked, otherwise good to show
private boolean mLocked = true;
private OnFragmentInteractionListener mListener;
/**
* If in tablet and added, rotating to portrait actually recreats the
* fragment even though it isn't visible. So if this is true, don't load anything.
*/
private boolean dontLoad = false;
/**
* Only calls other getter with the last segment parsed as long
*/
public static TaskDetailFragment_ getInstance(final Uri itemUri) {
return getInstance(Long.parseLong(itemUri.getLastPathSegment()));
}
/**
* Use to open an existing task
*/
public static TaskDetailFragment_ getInstance(final long itemId) {
Bundle arguments = new Bundle();
arguments.putLong(ARG_ITEM_ID, itemId);
TaskDetailFragment_ fragment = new TaskDetailFragment_();
fragment.setArguments(arguments);
return fragment;
}
/**
* Use to create a new task
*/
public static TaskDetailFragment_ getInstance(String text, final long listId) {
Bundle arguments = new Bundle();
arguments.putString(ARG_ITEM_CONTENT, text);
arguments.putLong(ARG_ITEM_LIST_ID, listId);
TaskDetailFragment_ fragment = new TaskDetailFragment_();
fragment.setArguments(arguments);
return fragment;
}
/**
* Mandatory empty constructor for the fragment manager to instantiate the
* fragment (e.g. upon screen orientation changes).
*/
public TaskDetailFragment() {
// Make sure arguments are non-null
if (getArguments() == null)
setArguments(new Bundle());
}
@Override
public void onCreate(Bundle savedInstanceState) {
// restoreSavedInstanceState_(savedInstanceState);
super.onCreate(savedInstanceState);
}
/**
* for {@link R.layout#fragment_task_detail}
*/
private FragmentTaskDetailBinding mBinding;
/* @Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
if (container == null) {
dontLoad = true;
return null;
}
setHasOptionsMenu(true);
mBinding = FragmentTaskDetailBinding.inflate(inflater, container, false);
return mBinding.getRoot();
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
// here you call methods with the old @AfterViews annotation
setListeners();
mBinding.dueDateBox.setOnClickListener(v -> onDateClick());
mBinding.notificationAdd.setOnClickListener(v -> onAddReminder());
mBinding.dueCancelButton.setOnClickListener(v -> onDueRemoveClick());
}
@Override
public void onDestroyView() {
super.onDestroyView();
mBinding = null;
}
*/
/**
* Must handle this manually because annotations do not return null if
* container is null
*/
@Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container,
Bundle savInstState) {
if (container == null) {
dontLoad = true;
return null;
}
setHasOptionsMenu(true); // needed, to have the actionbar menu (+, share, ...)
return inflater.inflate(layout.fragment_task_detail, container, false);
}
@Override
public void onActivityCreated(final Bundle state) {
super.onActivityCreated(state);
if (dontLoad) {
return;
}
boolean shouldOpenKeyBoard = false;
final Bundle args = new Bundle();
long argItemId = getArguments().getLong(ARG_ITEM_ID, stateId);
long argItemListId = getArguments().getLong(ARG_ITEM_LIST_ID, stateListId);
if (argItemId > 0) {
// existing note => Load data from database
args.putLong(ARG_ITEM_ID, argItemId);
LoaderManager
.getInstance(this)
.restartLoader(LOADER_EDITOR_TASK, args, loaderCallbacks);
} else {
// new note => create it
// If the given list is not valid, find a valid list
if (argItemListId < 1) {
getArguments().putLong(ARG_ITEM_LIST_ID,
ListHelper.getARealList(getActivity(), -1));
// then update the variable
argItemListId = getArguments().getLong(ARG_ITEM_LIST_ID, stateListId);
}
// Fail if the list is still not valid
if (argItemListId < 1) {
// simulate an exception
Toast.makeText(getActivity(), "Must specify a list id to create a note in!",
Toast.LENGTH_SHORT).show();
NnnLogger.error(TaskDetailFragment.class, "argItemListId < 1");
getActivity().finish();
}
args.putLong(ARG_ITEM_LIST_ID, argItemListId);
LoaderManager
.getInstance(this)
.restartLoader(LOADER_EDITOR_TASKLISTS, args, loaderCallbacks);
shouldOpenKeyBoard = true;
mTaskOrg = new Task();
mTask = new Task();
mTask.dblist = getArguments().getLong(ARG_ITEM_LIST_ID);
// New note but start with the text given
mTask.setText(getArguments().getString(ARG_ITEM_CONTENT, ""));
fillUIFromTask();
}
// showcase first time
final boolean showcasing = showcaseEditor();
if (!showcasing && shouldOpenKeyBoard) {
// Only show keyboard for new/empty notes,
// but not if the showcaseview is showing
taskText.requestFocus();
InputMethodManager imm = getContext().getSystemService(InputMethodManager.class);
imm.showSoftInput(taskText, InputMethodManager.SHOW_IMPLICIT);
}
}
/**
* Show the message to tell the user about our online tutorial
*
* @return true if showcase window is visible
*/
boolean showcaseEditor() {
final boolean alreadyShowcased = PreferenceManager
.getDefaultSharedPreferences(getActivity())
.getBoolean(SHOWCASED_EDITOR, false);
if (alreadyShowcased) return false;
ShowcaseHelper.showForOverflowMenu(this.getActivity(),
R.string.showcase_tutorial_title,
R.string.showcase_tutorial_description);
PreferenceManager.getDefaultSharedPreferences(getActivity())
.edit()
.putBoolean(SHOWCASED_EDITOR, true)
.commit();
return true;
}
@AfterViews
void setListeners() {
if (dontLoad) {
return;
}
// Set chosen attributes on the text field
final SharedPreferences prefs = PreferenceManager
.getDefaultSharedPreferences(getActivity());
taskText.setTitleRelativeLarger(prefs.getBoolean(
getString(R.string.pref_editor_biggertitles), true));
taskText.setTitleFontFamily(Integer.parseInt(prefs.getString(
getString(R.string.pref_editor_title_fontfamily), "2")));
taskText.setTitleFontStyle(Integer.parseInt(prefs.getString(
getString(R.string.pref_editor_title_fontstyle), "0")));
taskText.setBodyFontFamily(Integer.parseInt(prefs.getString(
getString(R.string.pref_editor_body_fontfamily), "0")));
taskText.setLinkify(prefs.getBoolean(
getString(R.string.pref_editor_links), true));
taskText.setTheTextSize(Integer.parseInt(prefs.getString(
getString(R.string.pref_editor_fontsize), "1")));
}
@Click(resName = "dueDateBox")
void onDateClick() {
final Calendar localTime = Calendar.getInstance();
if (mTask != null && mTask.due != null) {
//datePicker = DialogCalendar.getInstance(mTask.due);
localTime.setTimeInMillis(mTask.due);
}// else {
// datePicker = DialogCalendar.getInstance();
//}
//final DialogCalendar datePicker;
//datePicker.setListener(this);
//datePicker.show(getParentFragmentManager(), DATE_DIALOG_TAG);
// configure and show a popup with a date-picker calendar view
var dpDiag = new DatePickerDialog(
this.getActivity(),
// ThemeHelper.getPickerDialogTheme(this.getContext()),
this::onDateSet,
localTime.get(Calendar.YEAR),
localTime.get(Calendar.MONTH),
localTime.get(Calendar.DAY_OF_MONTH));
dpDiag.setTitle(R.string.select_date);
dpDiag.show();
}
private void onDateSet(DatePicker dialog, int year, int monthOfYear, int dayOfMonth) {
final Calendar localTime = Calendar.getInstance();
if (mTask.due != null) {
localTime.setTimeInMillis(mTask.due);
}
localTime.set(Calendar.YEAR, year);
localTime.set(Calendar.MONTH, monthOfYear);
localTime.set(Calendar.DAY_OF_MONTH, dayOfMonth);
// set to 23:59 to be more or less consistent with earlier date only implementation
localTime.set(Calendar.HOUR_OF_DAY, 23);
localTime.set(Calendar.MINUTE, 59);
mTask.due = localTime.getTimeInMillis();
setDueText();
/* TODO if you want the user to set a due time (we only set the due date for now)
then simply uncomment this code (and code referenced in other TODOs like this)
// then ask for due time
getTimePickerDialog(localTime, (theWidget, hourOfDay, minute) -> {
localTime.set(Calendar.HOUR_OF_DAY, hourOfDay);
localTime.set(Calendar.MINUTE, minute);
mTask.due = localTime.getTimeInMillis();
setDueText();
}).show();
*/
}
private void setDueText() {
if (mTask.due == null) {
dueDateBox.setText("");
} else {
// Due date
dueDateBox.setText(TimeFormatter.getLocalDateOnlyStringLong(getActivity(), mTask.due));
// TODO if you want to let the user set a "due time" (as of now we have only
// the due date) replace the function above with TimeFormatter.getLocalDateStringLong()
}
}
@Click(resName = "dueCancelButton")
void onDueRemoveClick() {
if (!isLocked()) {
if (mTask != null) {
mTask.due = null;
}
setDueText();
}
}
@Click(resName = "notificationAdd")
void onAddReminder() {
if (mTask != null && !isLocked()) {
// IF no id, have to save first
if (mTask._id < 1) {
saveTask();
}
// Only allow if save succeeded
if (mTask._id < 1) {
Toast.makeText(getActivity(),
R.string.please_type_before_reminder,
Toast.LENGTH_SHORT).show();
return;
}
final Notification not = new Notification(mTask._id);
not.save(getActivity(), true);
// add item to UI
addNotification(not);
// And scroll to bottom. takes 300ms for item to appear.
editScrollView.postDelayed(
() -> editScrollView.fullScroll(ScrollView.FOCUS_DOWN),
300);
}
}
/**
* "not having a password in the app" is different from
* "the note being saved as un/locked in the database", so this function does not check for a
* password. If the note is locked and a password is not set, a popup will launch and ask
* the user to set a new, app-wide password.
*
* @return TRUE if {@link #mTask} is password-protected, by evaluating "task.locked & mLocked"
*/
public boolean isLocked() {
if (mTask != null) {
return mTask.locked & mLocked;
}
return false;
}
/**
* @implNote this method MUST be annotated with {@link UiThread.Propagation#REUSE}
* and not simply {@link UiThread}, to ensure that this runs before
* {@link #onPause()} when called from {@link #onActivityCreated}. This avoids
* a bug that deletes the note when receiving shared text (a link from google
* chrome, for example). The bug could be seen in API 23 (LineageOS 13) with
* NoNonsenseNotes 7.1.0, for example.
*/
@UiThread(propagation = Propagation.REUSE)
void fillUIFromTask() {
if (taskText == null || taskCompleted == null) {
// it gets triggered ONLY in espresso tests!
NnnLogger.error(TaskDetailFragment.class, "taskText or taskCompleted is null");
return;
}
NnnLogger.debug(TaskDetailFragment.class, "fillUI, activity: " + getActivity());
if (isLocked()) {
taskText.setText(mTask.title);
DialogPassword pflock = new DialogPassword();
pflock.setListener(() -> {
mLocked = false;
fillUIFromTask();
});
// show the password popup if needed
final String PASSW_TAG = "read_verify";
Fragment oldDiag = this.getParentFragmentManager().findFragmentByTag(PASSW_TAG);
if (oldDiag == null) {
// the password dialog is not among the active fragments
// => you have to launch it
pflock.show(getParentFragmentManager(), PASSW_TAG);
} else {
// there is already one visible => don't spam them, one is enough
}
} else {
taskText.setText(mTask.getText());
}
setDueText();
taskCompleted.setChecked(mTask.completed != null);
taskCompleted.setOnCheckedChangeListener((buttonView, isChecked) -> {
if (isChecked)
mTask.completed = Calendar.getInstance().getTimeInMillis();
else
mTask.completed = null;
});
// Lock fields
setFieldStatus();
}
/**
* Set fields to enabled/disabled depending on wether the note is locked
*/
void setFieldStatus() {
final boolean status = !isLocked();
taskText.setEnabled(status);
taskCompleted.setEnabled(status);
dueDateBox.setEnabled(status);
dueCancelButton.setEnabled(status);
notificationAdd.setEnabled(status);
notificationList.setEnabled(status);
// by default it does not gray out the icons, and it's tricky to do it in code.
// It does not matter because we block the click callbacks
// in NotificationItemHelper.setup()
}
void hideTaskParts(final TaskList list) {
String type;
if (list.listtype == null) {
type = PreferenceManager
.getDefaultSharedPreferences(getActivity())
.getString(getString(R.string.pref_listtype), getString(R.string.default_listtype));
} else {
type = list.listtype;
}
taskSection.setVisibility(
type.equals(getString(R.string.const_listtype_notes)) ? View.GONE : View.VISIBLE);
}
@Override
public void onCreateOptionsMenu(@NonNull Menu menu, MenuInflater inflater) {
inflater.inflate(R.menu.fragment_tasks_detail, menu);
super.onCreateOptionsMenu(menu, inflater);
}
/**
* @return an {@link Intent} that creates a panel to choose an app,
* to share the note being edited in this {@link TaskDetailFragment},
* or NULL if the note was not in a valid state
*/
@Nullable
private Intent getShareIntent() {
if (taskText == null || taskText.getText() == null) return null;
String text = taskText.getText().toString();
int titleEnd = text.indexOf("\n");
if (titleEnd < 0) {
titleEnd = text.length();
}
String noteTitle = text.substring(0, titleEnd);
return new ShareCompat
.IntentBuilder(this.getContext())
.setType("text/plain")
.setText(text)
.setSubject(noteTitle) // for email apps
.setChooserTitle(noteTitle) // for the chooser panel
.createChooserIntent();
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
int itemId = item.getItemId();
if (itemId == R.id.menu_add) {
// TODO should not call if in tablet mode
if (mListener != null && mTask != null && mTask.dblist > 0) {
mListener.addTaskInList("", mTask.dblist);
}
return true;
} else if (itemId == R.id.menu_revert) {
// set to null to prevent modifications
mTask = null;
// Request a close from activity
if (mListener != null) {
mListener.closeFragment(this);
}
return true;
} else if (itemId == R.id.menu_timemachine) {
if (mTask != null && mTask._id > 0) {
Intent timeIntent = new Intent(getActivity(), ActivityTaskHistory.class);
timeIntent.putExtra(Task.Columns._ID, mTask._id);
startActivityForResult(timeIntent, 1);
// ActivityTaskHistory.start(getActivity(), mTask._id);
}
return true;
} else if (itemId == R.id.menu_delete) {
if (mTask != null) {
if (mTask.locked) {
DialogPassword delpf = new DialogPassword();
delpf.setListener(this::deleteAndClose);
delpf.show(getParentFragmentManager(), "delete_verify");
} else {
deleteAndClose();
}
}
return true;
} else if (itemId == R.id.menu_lock) {
DialogPassword pflock = new DialogPassword();
pflock.setListener(() -> {
if (mTask != null) {
mLocked = true;
mTask.locked = true;
mTask.save(getActivity());
fillUIFromTask();
Toast.makeText(getActivity(), R.string.locked, Toast.LENGTH_SHORT).show();
}
});
pflock.show(getParentFragmentManager(), "lock_verify");
return true;
} else if (itemId == R.id.menu_unlock) {
DialogPassword pf = new DialogPassword();
pf.setListener(() -> {
if (mTask != null) {
mTask.locked = false;
Toast.makeText(getActivity(), R.string.unlocked, Toast.LENGTH_SHORT).show();
if (mLocked) {
mLocked = false;
fillUIFromTask();
}
}
});
pf.show(getParentFragmentManager(), "unlock_verify");
return true;
} else if (itemId == R.id.menu_share) {
// open the chooser panel to share the note text with an app
Intent si = getShareIntent();
if (si != null) this.startActivity(si);
return true;
}
return super.onOptionsItemSelected(item);
}
@Override
public void onPrepareOptionsMenu(Menu menu) {
menu.findItem(R.id.menu_timemachine)
.setEnabled(mTask != null && mTask._id > 0 && !isLocked());
menu.findItem(R.id.menu_lock).setVisible(mTask != null && !mTask.locked);
menu.findItem(R.id.menu_unlock).setVisible(mTask != null && mTask.locked);
menu.findItem(R.id.menu_share).setEnabled(!isLocked());
if (getActivity() instanceof MenuStateController) {
final boolean visible = ((MenuStateController) getActivity()).childItemsVisible();
menu.setGroupVisible(R.id.editor_menu_group, visible);
// Outside group to allow for action bar placement
if (menu.findItem(R.id.menu_delete) != null)
menu.findItem(R.id.menu_delete).setVisible(visible);
if (menu.findItem(R.id.menu_revert) != null)
menu.findItem(R.id.menu_revert).setVisible(visible);
if (menu.findItem(R.id.menu_share) != null)
menu.findItem(R.id.menu_share).setVisible(visible);
if (menu.findItem(R.id.menu_lock) != null)
menu.findItem(R.id.menu_lock)
.setVisible(visible && mTask != null && !mTask.locked);
if (menu.findItem(R.id.menu_unlock) != null)
menu.findItem(R.id.menu_unlock)
.setVisible(visible && mTask != null && mTask.locked);
}
}
@Override
public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
if (requestCode == 1) {
// on time travel result
if (resultCode == Activity.RESULT_OK) {
onTimeTravel(data);
}
}
super.onActivityResult(requestCode, resultCode, data);
}
private void deleteAndClose() {
if (mTask != null && mTask._id > 0 && !isLocked()) {
DialogDeleteTask.showDialog(getParentFragmentManager(), mTask._id, () -> {
// Prevents save attempts
mTask = null;
// Request a close from activity
if (mListener != null) {
mListener.closeFragment(TaskDetailFragment.this);
}
});
} else {
// Prevents save attempts
mTask = null;
// Request a close from activity
if (mListener != null) {
mListener.closeFragment(this);
}
}
}
/**
* Save mTask to database
*/
private void saveTask() {
// if mTask is null, the task has been deleted or cancelled
// If the task is locked, editing is disabled
if (mTask == null || isLocked()) {
return;
}
// Needed for comparison
mTask.setText(taskText.getText().toString());
// if new item, only save if something has been entered
if ((mTask._id > 0 && !mTask.equals(mTaskOrg)) || (mTask._id == -1 && isThereContent())) {
// mTask.setText(taskText.getText().toString());
mTask.save(getActivity());
// Set the intent to open the task.
// So we dont create a new one on rotation for example
fixIntent();
// TODO, should restart notification loader for new tasks
}
}
void fixIntent() {
stateId = mTask._id;
stateListId = mTask.dblist;
if (getActivity() == null) return;
final Intent orgIntent = getActivity().getIntent();
if (orgIntent == null || orgIntent.getAction() == null
|| !orgIntent.getAction().equals(Intent.ACTION_INSERT))
return;
if (mTask == null || mTask._id < 1) return;
final Intent intent = new Intent()
.setAction(Intent.ACTION_EDIT)
.setClass(getActivity(), ActivityMain_.class)
.setData(mTask.getUri())
.putExtra(TaskDetailFragment.ARG_ITEM_LIST_ID, mTask.dblist);
getActivity().setIntent(intent);
}
boolean isThereContent() {
boolean result = false;
result |= taskText.getText().length() > 0;
result |= dueDateBox.getText().length() > 0;
result |= (mTask.locked != mTaskOrg.locked);
return result;
}
@Override
public void onPause() {
super.onPause();
if (dontLoad) return;
saveTask();
// Set locked again
mLocked = true;
// If task is actually locked, remove text
if (isLocked() && mTask != null && taskText != null) {
taskText.setText(mTask.title);
}
}
@Override
public void onStop() {
// Always call the superclass method first
super.onStop();
// TODO lazy fix for #412 -> instead, you should stop onLoadFinished()
// when it tries to load reminders that are already there
// remove all reminders from the list. Next time this Fragment is loaded,
// onLoadFinished() will add them back. It MUST be here in onStop(). See
// the big comment on top of this file to understand why
if (notificationList != null) notificationList.removeAllViews();
}
@Override
public void onAttach(@NonNull Activity activity) {
super.onAttach(activity);
if (dontLoad) return;
try {
mListener = (OnFragmentInteractionListener) activity;
} catch (ClassCastException e) {
// the activity must implement OnFragmentInteractionListener!
}
}
@Override
public void onDetach() {
super.onDetach();
mListener = null;
}
/* @Override
public void onSaveInstanceState(@NonNull final Bundle bundle_) {
super.onSaveInstanceState(bundle_);
bundle_.putLong("stateId", stateId);
bundle_.putLong("stateListId", stateListId);
}
private void restoreSavedInstanceState_(Bundle savedInstanceState) {
if (savedInstanceState == null) return;
stateId = savedInstanceState.getLong("stateId");
stateListId = savedInstanceState.getLong("stateListId");
}*/
/**
* Inserts a notification item in the UI
*/
@UiThread(propagation = Propagation.REUSE)
void addNotification(final Notification not) {
if (getActivity() == null) return;
// TODO maybe here check if we already have this notification shown, if possible,
// and then either refuse to add this or update the existing one
View nv = LayoutInflater
.from(getActivity())
.inflate(R.layout.notification_view, null);
// So we can update the view later
not.view = nv;
// Setup all the listeners, etc...
NotificationItemHelper.setup(this, notificationList, nv, not, mTask);
notificationList.addView(nv);
}
@Override
public void onResume() {
super.onResume();
if (dontLoad) return;
// Hide data from snoopers
if (mTask != null && isLocked()) {
fillUIFromTask();
}
}
public void onTimeTravel(Intent data) {
String restoredText = data.getStringExtra(ActivityTaskHistory.RESULT_TEXT_KEY);
if (taskText != null) taskText.setText(restoredText);
// Need to set here also for password to work
if (mTask != null) mTask.setText(restoredText);
}
/**
* Returns an appropriately themed {@link TimePickerDialog}, which will be shown
* in a popup, also setting the callback and desired starting time through the
* given parameters. An alternative is
* {@link com.google.android.material.timepicker.MaterialTimePicker}, which is 99%
* identical, but it requires an app theme with parent="Theme.MaterialComponents",
* which does not work in our app, due to the auto-generated code of the annotations
* library
*/
public TimePickerDialog getTimePickerDialog(Calendar localTime,
TimePickerDialog.OnTimeSetListener listener) {
boolean shouldShowIn24HourMode = DateFormat.is24HourFormat(getActivity());
return new TimePickerDialog(
this.getActivity(),
listener, // set the callback for when the user chooses a time
localTime.get(Calendar.HOUR_OF_DAY), // set the initial hour & minute
localTime.get(Calendar.MINUTE),
shouldShowIn24HourMode);
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/fragments/TaskListFragment.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.fragments;
import android.app.Activity;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.SharedPreferences.OnSharedPreferenceChangeListener;
import android.content.res.Configuration;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.util.Log;
import android.view.ActionMode;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.AbsListView.MultiChoiceModeListener;
import android.widget.CompoundButton.OnCheckedChangeListener;
import android.widget.ListView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.fragment.app.Fragment;
import androidx.loader.app.LoaderManager;
import androidx.loader.app.LoaderManager.LoaderCallbacks;
import androidx.loader.content.CursorLoader;
import androidx.loader.content.Loader;
import androidx.preference.PreferenceManager;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import com.google.android.material.snackbar.Snackbar;
import com.mobeta.android.dslv.DragSortListView;
import com.mobeta.android.dslv.DragSortListView.DropListener;
import com.mobeta.android.dslv.DragSortListView.RemoveListener;
import com.mobeta.android.dslv.SimpleDragSortCursorAdapter;
import com.mobeta.android.dslv.SimpleDragSortCursorAdapter.ViewBinder;
import com.nononsenseapps.helpers.ListHelper;
import com.nononsenseapps.helpers.NnnLogger;
import com.nononsenseapps.helpers.PreferencesHelper;
import com.nononsenseapps.notepad.R;
import com.nononsenseapps.notepad.activities.main.ActivityMain_;
import com.nononsenseapps.notepad.database.Task;
import com.nononsenseapps.notepad.database.TaskList;
import com.nononsenseapps.notepad.databinding.FragmentTaskListBinding;
import com.nononsenseapps.notepad.fragments.DialogPassword.PasswordConfirmedListener;
import com.nononsenseapps.notepad.interfaces.MenuStateController;
import com.nononsenseapps.notepad.interfaces.OnFragmentInteractionListener;
import com.nononsenseapps.ui.DateView;
import com.nononsenseapps.ui.NoteCheckBox;
import com.nononsenseapps.ui.TitleNoteTextView;
import org.androidannotations.annotations.AfterViews;
import org.androidannotations.annotations.Background;
import org.androidannotations.annotations.EFragment;
import org.androidannotations.annotations.ViewById;
import java.security.InvalidParameterException;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.Executors;
@EFragment(R.layout.fragment_task_list)
public class TaskListFragment extends Fragment implements OnSharedPreferenceChangeListener {
// Must be less than -1
public static final String LIST_ALL_ID_PREF_KEY = "show_all_tasks_choice_id";
public static final int LIST_ID_ALL = -2;
public static final int LIST_ID_OVERDUE = -3;
public static final int LIST_ID_TODAY = -4;
public static final int LIST_ID_WEEK = -5;
public static final String LIST_ID = "list_id";
/**
* {@link android.R.id#list }
*/
@ViewById(resName = "list")
DragSortListView listView;
SimpleSectionsAdapter mAdapter;
private long mListId = -1;
private OnFragmentInteractionListener mListener;
private String mSortType = null;
private int mRowCount = 3;
private boolean mHideCheckbox = false;
private String mListType = null;
private LoaderCallbacks mCallback = null;
private ActionMode mMode;
private boolean mDeleteWasUndone = false;
/**
* for {@link R.layout#fragment_task_list}
*/
private FragmentTaskListBinding mBinding;
/*@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
mBinding = FragmentTaskListBinding.inflate(inflater, container, false);
return mBinding.getRoot();
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
// here you call methods with the old @AfterViews annotation
loadList();
setupPullToRefresh();
}
@Override
public void onDestroyView() {
super.onDestroyView();
mBinding = null;
}
*/
public static TaskListFragment_ getInstance(final long listId) {
TaskListFragment_ f = new TaskListFragment_();
Bundle args = new Bundle();
args.putLong(LIST_ID, listId);
f.setArguments(args);
return f;
}
public TaskListFragment() {
super();
}
@Override
public void onCreate(Bundle savedState) {
super.onCreate(savedState);
setHasOptionsMenu(true);
if (getArguments().getLong(LIST_ID, -1) == -1) {
throw new InvalidParameterException("Must designate a list to open!");
}
mListId = getArguments().getLong(LIST_ID, -1);
// Start loading data
mAdapter = new SimpleSectionsAdapter(getActivity(),
R.layout.tasklist_item_rich,
R.layout.tasklist_header,
null,
new String[] { Task.Columns.TITLE, Task.Columns.NOTE,
Task.Columns.DUE, Task.Columns.COMPLETED,
Task.Columns.LEFT, Task.Columns.RIGHT },
new int[] { android.R.id.text1, android.R.id.text1, R.id.date,
R.id.checkbox, R.id.drag_handle, R.id.dragpadding },
0);
// Set a drag listener
mAdapter.setDropListener((from, to) -> {
Log.d("nononsenseapps drag", "Position from " + from + " to " + to);
final Task fromTask = new Task((Cursor) mAdapter.getItem(from));
final Task toTask = new Task((Cursor) mAdapter.getItem(to));
fromTask.moveTo(getActivity().getContentResolver(), toTask);
});
/*
* listAdapter.setRemoveListener(new RemoveListener() {
*
* @Override public void remove(int which) { Log.d(TAG, "Remove pos: " +
* which); Log.d(TAG, "Remove id: " + listAdapter.getItemId(which));
*
* getActivity().getContentResolver().delete(
* Uri.withAppendedPath(Task.URI, "" + listAdapter.getItemId(which)),
* null, null); }
*
* });
*/
mAdapter.setViewBinder(new ViewBinder() {
boolean isHeader = false;
final String manualsort = getString(R.string.const_possubsort);
final String notetype = getString(R.string.const_listtype_notes);
String sTemp = "";
final OnCheckedChangeListener checkBoxListener =
(buttonView, isChecked) -> Task.setCompleted(
getActivity(), isChecked, ((NoteCheckBox) buttonView).getNoteId());
@Override
public boolean setViewValue(View view, Cursor c, int colIndex) {
// Check for headers: unlike notes, headers have invalid ids
isHeader = c.getLong(0) == -1;
switch (colIndex) {
// Matches order in Task.Columns.Fields
case 1:
// Title
sTemp = c.getString(1);
if (isHeader) {
long dueDateMillis = c.getLong(4);
sTemp = Task.getHeaderNameForListSortedByDate(sTemp, dueDateMillis,
getActivity());
} else {
// Set height of text for non-headers
((TitleNoteTextView) view).setMaxLines(mRowCount);
// if (mRowCount == 1) {
// ((TitleNoteTextView) view).setSingleLine(true);
// }
// else {
// ((TitleNoteTextView) view).setSingleLine(false);
// }
// Change color based on complete status
((TitleNoteTextView) view).useSecondaryColor(!c.isNull(3));
}
((TitleNoteTextView) view).setTextTitle(sTemp);
return true;
case 2:
// Note
if (!isHeader) {
// Only if task it not locked
// or only one line
if (c.getInt(9) != 1 && mRowCount > 1) {
((TitleNoteTextView) view).setTextRest(c.getString(colIndex));
} else {
((TitleNoteTextView) view).setTextRest("");
}
}
return true;
case 3:
// Checkbox
if (!isHeader) {
((NoteCheckBox) view).setOnCheckedChangeListener(null);
((NoteCheckBox) view).setChecked(!c.isNull(colIndex));
((NoteCheckBox) view).setNoteId(c.getLong(0));
((NoteCheckBox) view).setOnCheckedChangeListener(checkBoxListener);
if (mHideCheckbox
|| (mListType != null && mListType.equals(notetype))) {
view.setVisibility(View.GONE);
} else {
view.setVisibility(View.VISIBLE);
}
}
return true;
case 4:
// Due date
if (!isHeader) {
// Always hide for note type
if (mListType != null && mListType.equals(notetype)) {
view.setVisibility(View.GONE);
}
// Show for tasks if present
else {
if (c.isNull(colIndex)) {
view.setVisibility(View.GONE);
} else {
view.setVisibility(View.VISIBLE);
((DateView) view).setTimeText(c.getLong(colIndex));
}
}
}
return true;
case 6:
// left, handle
case 7:
// right, padding
if (!isHeader) {
if (mSortType != null && mSortType.equals(manualsort)) {
view.setVisibility(View.VISIBLE);
} else {
view.setVisibility(View.GONE);
}
}
return true;
default:
break;
}
return false;
}
});
}
@Override
public void onActivityCreated(final Bundle state) {
super.onActivityCreated(state);
// Get the global list settings
final SharedPreferences prefs = PreferenceManager
.getDefaultSharedPreferences(getActivity());
// Load pref for item height
mRowCount = prefs.getInt(getString(R.string.key_pref_item_max_height), 3);
mHideCheckbox = prefs.getBoolean(getString(R.string.pref_hidecheckboxes), false);
mCallback = new LoaderCallbacks<>() {
@NonNull
@Override
public Loader onCreateLoader(int id, Bundle arg1) {
if (id == 0 /* LOADER_CURRENT_LIST */) {
return new CursorLoader(getActivity(), TaskList.getUri(mListId),
TaskList.Columns.FIELDS, null, null, null);
}
// id != 0 => load stuff
// What sorting to use
Uri targetUri;
String sortSpec;
if (mListType == null) {
mListType = prefs.getString(getString(R.string.pref_listtype),
getString(R.string.default_listtype));
}
if (mSortType == null) {
mSortType = prefs.getString(getString(R.string.pref_sorttype),
getString(R.string.default_sorttype));
}
// analyze the note sorting type chosen by the user
if (mSortType.equals(getString(R.string.const_alphabetic))) {
targetUri = Task.URI;
sortSpec = getString(R.string.const_as_alphabetic, Task.Columns.TITLE);
} else if (mSortType.equals(getString(R.string.const_duedate))) {
targetUri = Task.URI_SECTIONED_BY_DATE;
sortSpec = null;
} else if (mSortType.equals(getString(R.string.const_modified))) {
targetUri = Task.URI;
sortSpec = Task.Columns.UPDATED + " DESC";
} else {
// manual sorting
targetUri = Task.URI;
sortSpec = Task.Columns.LEFT;
}
String where;
String[] whereArgs;
if (mListId > 0) {
// Fix for issue #525 which is caused by some android versions (API 35
// emulator, Google Pixel 8a on Android 14, ...) incorrectly generating
// the dblist column (=Task.Columns.DBLIST) as BLOB instead of INTEGER.
// So we cast its value to INTEGER to restore the (expected) behavior
// of older android versions
where = "CAST(" + Task.Columns.DBLIST + " AS INTEGER) IS ?";
whereArgs = new String[] { Long.toString(mListId) };
} else {
targetUri = Task.URI;
sortSpec = Task.Columns.DUE;
whereArgs = null;
where = Task.Columns.COMPLETED + " IS NULL";
switch ((int) mListId) {
case LIST_ID_OVERDUE:
where += andWhereOverdue();
break;
case LIST_ID_TODAY:
where += andWhereToday();
break;
case LIST_ID_WEEK:
// TODO "week" and "all" are not on the drawer menu. add them ?
where += andWhereWeek();
break;
case LIST_ID_ALL:
default:
// Show completed also in this case
where = null;
break;
}
}
return new CursorLoader(getActivity(), targetUri,
Task.Columns.FIELDS, where, whereArgs, sortSpec);
}
@Override
public void onLoadFinished(@NonNull Loader loader, Cursor c) {
if (loader.getId() == 0) {
if (c != null && c.moveToFirst()) {
final TaskList list = new TaskList(c);
mSortType = list.sorting;
mListType = list.listtype;
// Reload tasks with new sorting
LoaderManager.getInstance(TaskListFragment.this)
.restartLoader(1, null, this);
}
} else { // loader.getId() == LOADER_TASKS
mAdapter.swapCursor(c);
}
}
@Override
public void onLoaderReset(@NonNull Loader loader) {
if (loader.getId() == 0) {
// Nothing to do
} else {
mAdapter.swapCursor(null);
}
}
};
if (mListId > 0) {
LoaderManager.getInstance(this).restartLoader(0, null, mCallback);
} else {
// Setting sort types for all tasks always to due date
mSortType = getString(R.string.const_duedate);
LoaderManager.getInstance(this).restartLoader(1, null, mCallback);
}
}
public static String whereOverDue() {
return Task.Columns.DUE + " BETWEEN " + Task.OVERDUE + " AND " + Task.TODAY_START;
}
public static String andWhereOverdue() {
return " AND " + whereOverDue();
}
public static String whereToday() {
return Task.Columns.DUE + " BETWEEN " + Task.TODAY_START + " AND "
+ Task.TODAY_PLUS(1);
}
public static String andWhereToday() {
return " AND " + whereToday();
}
public static String whereWeek() {
return Task.Columns.DUE + " BETWEEN " + Task.TODAY_START + " AND ("
+ Task.TODAY_PLUS(5) + " -1)";
}
public static String andWhereWeek() {
return " AND " + whereWeek();
}
@AfterViews
void setupPullToRefresh() {
// every list gets its own instance of the swipetorefresh layout
var ptrL = (SwipeRefreshLayout) this.getView().findViewById(R.id.ptrLayout);
// The pull-to-refresh layout is defined in fragment_task_list.xml
// now we add it to ActivityMain, which will take care of it
((ActivityMain_) getActivity()).addSwipeRefreshLayoutToList(ptrL);
}
@AfterViews
void loadList() {
listView.setAdapter(mAdapter);
listView.setOnItemClickListener((arg0, origin, pos, id) -> {
if (mListener != null && id > 0) {
mListener.onFragmentInteraction(Task.getUri(id), mListId, origin);
}
});
listView.setOnItemLongClickListener((arg0, view, pos, id) -> {
listView.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE_MODAL);
// Also select the item in question
listView.setItemChecked(pos, true);
return true;
});
// TODO this MultiChoiceModeListener occupies the next 220 lines.
// It handles the logic for when multiple notes are selected and a button on
// the action bar is pressed. Put this class in its own java file
listView.setMultiChoiceModeListener(new MultiChoiceModeListener() {
final HashMap tasks = new HashMap<>();
/**
* Delete tasks and display a snackbar with an undo action
*/
private void deleteTasks(final Map taskMap) {
final Task[] tasks = taskMap.values().toArray(new Task[0]);
// If any are locked, ask for password first
final boolean locked = PreferencesHelper.isPasswordSet(getActivity());
// Reset undo flag
mDeleteWasUndone = false;
// Dismiss callback
final Snackbar.Callback dismissCallback = new Snackbar.Callback() {
@Override
public void onDismissed(Snackbar snackbar, int event) {
// Do nothing if dismissed because action was pressed
// Dismiss wil be called more than once if undo is pressed
if (Snackbar.Callback.DISMISS_EVENT_ACTION != event && !mDeleteWasUndone) {
// Delete them
Executors.newSingleThreadExecutor().execute(() -> {
// Background work here
for (Task t : tasks) {
try {
t.delete(getActivity());
} catch (Exception ignored) {}
}
});
}
}
};
}
// Undo callback
final View.OnClickListener undoListener = new View.OnClickListener() {
@Override
public void onClick(View v) {
mDeleteWasUndone = true;
// Returns removed items to view
mAdapter.reset();
}
};
final PasswordConfirmedListener pListener = new PasswordConfirmedListener() {
@Override
@Background
public void onPasswordConfirmed() {
for (final Task t : tasks.values()) {
try {
t.delete(getActivity());
} catch (Exception e) {
NnnLogger.warning(TaskListFragment.class, "Can't delete task");
}
}
String msg;
try {
msg = getResources().getQuantityString(R.plurals.notedeleted_msg,
tasks.size(), tasks.size());
} catch (Exception e) {
// Protect against faulty translations
msg = getResources().getString(R.string.deleted);
}
// TODO should use a Snackbar instead of Toasts
// Snackbar
// .make(mFab, msg, Snackbar.LENGTH_LONG)
// .setAction(R.string.undo, listener)
// .setCallback(dismissCallback)
// .show();
Toast.makeText(getActivity(), msg, Toast.LENGTH_SHORT).show();
if (mMode != null) mMode.finish();
}
};
@Override
public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
// Here you can perform updates to the CAB due to
// an invalidate() request
return false;
}
@Override
public void onDestroyActionMode(ActionMode mode) {
// Here you can make any necessary updates to the activity when
// the CAB is removed. By default, selected items are
// deselected/unchecked.
tasks.clear();
}
@Override
public boolean onCreateActionMode(ActionMode mode, Menu menu) {
// Must setup the contextual action menu
getActivity().getMenuInflater().inflate(R.menu.fragment_tasklist_context, menu);
// Must clear for reuse
tasks.clear();
// For password
mMode = mode;
return true;
}
/**
* When the user presses a button on the action bar, this function decides what to do
* with the selected notes: copy, delete, share, or move to another list
*/
@Override
public boolean onActionItemClicked(final ActionMode mode, MenuItem item) {
// Respond to clicks on the actions in the CAB
boolean finish = false;
int itemId = item.getItemId();
if (itemId == R.id.menu_copy) {
final ClipboardManager clipboard = (ClipboardManager) getActivity()
.getSystemService(Context.CLIPBOARD_SERVICE);
clipboard.setPrimaryClip(ClipData.newPlainText(
getString(R.string.app_name_short), getShareText()));
try {
Toast.makeText(getActivity(), getResources().getQuantityString(
R.plurals.notecopied_msg, tasks.size(), tasks.size()),
Toast.LENGTH_SHORT).show();
} catch (Exception e) {
// Protect against faulty translations
}
finish = true;
} else if (itemId == R.id.menu_delete) {
boolean locked = false;
for (final Task t : tasks.values()) {
if (t.locked) {
locked = true;
break;
}
}
if (locked) {
DialogPassword delpf = new DialogPassword();
delpf.setListener(pListener);
delpf.show(getParentFragmentManager(), "multi_delete_verify");
} else {
DialogDeleteTask.showDialog(getParentFragmentManager(), -1,
pListener::onPasswordConfirmed);
}
} else if (itemId == R.id.menu_switch_list) {
// show move to list dialog
DialogMoveToList.getInstance(
tasks.keySet().toArray(new Long[0]))
.show(getParentFragmentManager(), "move_to_list_dialog");
finish = true;
} else if (itemId == R.id.menu_share) {
startActivity(getShareIntent());
finish = true;
} else {
finish = false;
}
if (finish) mode.finish(); // Action picked, so close the CAB
return finish;
}
@Override
public void onItemCheckedStateChanged(ActionMode mode,
int position, long id, boolean checked) {
if (checked) {
tasks.put(id, new Task((Cursor) listView.getAdapter()
.getItem(position)));
} else {
tasks.remove(id);
}
try {
// Only show the title string on screens that are wide enough,
// for example large screens or if you are in landscape
final Configuration conf = getResources()
.getConfiguration();
if (conf.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE)
|| conf.orientation == Configuration.ORIENTATION_LANDSCAPE) {
mode.setTitle(getResources().getQuantityString(
R.plurals.mode_choose, tasks.size(),
tasks.size()));
}
} catch (Exception e) {
// Protect against faulty translations
}
}
String getShareText() {
final StringBuilder sb = new StringBuilder();
for (Task t : tasks.values()) {
if (sb.length() > 0) {
sb.append("\n\n");
}
if (t.locked) {
sb.append(t.title);
} else {
sb.append(t.getText());
}
}
return sb.toString();
}
// when sharing many notes from the list view,
// we send a list of their titles as subject
String getShareSubject() {
StringBuilder result = new StringBuilder();
for (Task t : tasks.values()) {
result.append(", ").append(t.title);
}
// if necessary, remove the first ", "
return (result.length() == 0) ? "" : result.substring(2);
}
/**
* When you select multiple notes, and presses 'share' on the menu,
* this function creates the intent to call Android's app picker to choose
* who will receive the shared notes' content
*/
Intent getShareIntent() {
final Intent shareIntent = new Intent(Intent.ACTION_SEND);
shareIntent.setType("text/plain");
shareIntent.putExtra(Intent.EXTRA_TEXT, getShareText());
shareIntent.putExtra(Intent.EXTRA_SUBJECT, getShareSubject());
shareIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT);
return shareIntent;
}
});
}
@Override
public void onCreateOptionsMenu(@NonNull Menu menu, MenuInflater inflater) {
inflater.inflate(R.menu.fragment_tasklist, menu);
}
@Override
public void onPrepareOptionsMenu(@NonNull Menu menu) {
if (getActivity() instanceof MenuStateController) {
final boolean visible = ((MenuStateController) getActivity())
.childItemsVisible();
menu.setGroupVisible(R.id.list_menu_group, visible);
if (!visible) {
if (mMode != null) mMode.finish();
}
}
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
int itemId = item.getItemId();
if (itemId == R.id.menu_add) {
if (mListener != null && mListId > 0) {
mListener.addTaskInList("", mListId);
} else if (mListener != null) {
mListener.addTaskInList("", ListHelper.getARealList(getActivity(), -1));
}
return true;
} else if (itemId == R.id.menu_clearcompleted) {
if (mListId != -1) {
DialogDeleteCompletedTasks
.showDialog(getParentFragmentManager(), mListId, null);
}
return true;
} else if (itemId == R.id.menu_sort_title) {
// TODO reorder the notes like we do in DialogEditList
Toast.makeText(this.getContext(), R.string.feature_is_WIP, Toast.LENGTH_SHORT).show();
// SharedPreferencesHelper.setSortingAlphabetic(this);
return true;
} else if (itemId == R.id.menu_sort_due) {
Toast.makeText(this.getContext(), R.string.feature_is_WIP, Toast.LENGTH_SHORT).show();
// SharedPreferencesHelper.setSortingDue(this);
return true;
} else if (itemId == R.id.menu_sort_manual) {
Toast.makeText(this.getContext(), R.string.feature_is_WIP, Toast.LENGTH_SHORT).show();
// SharedPreferencesHelper.setSortingManual(this);
return true;
} else {
return false;
}
}
@Override
public void onSaveInstanceState(@NonNull Bundle outState) {
super.onSaveInstanceState(outState);
}
@Override
public void onDestroy() {
super.onDestroy();
LoaderManager.getInstance(this).destroyLoader(0);
}
@Override
public void onAttach(@NonNull Activity activity) {
super.onAttach(activity);
try {
mListener = (OnFragmentInteractionListener) activity;
} catch (ClassCastException e) {
// the activity must implement OnFragmentInteractionListener!
}
// We want to be notified of future changes to auto refresh
PreferenceManager.getDefaultSharedPreferences(getActivity())
.registerOnSharedPreferenceChangeListener(this);
}
@Override
public void onDetach() {
mListener = null;
PreferenceManager.getDefaultSharedPreferences(getActivity())
.unregisterOnSharedPreferenceChangeListener(this);
super.onDetach();
}
static class SimpleSectionsAdapter extends SimpleDragSortCursorAdapter {
DropListener dropListener = null;
RemoveListener removeListener = null;
final int mItemLayout;
final int mHeaderLayout;
final static int itemType = 0;
final static int headerType = 1;
final SharedPreferences prefs;
final Context context;
public SimpleSectionsAdapter(Context context, int layout,
int headerLayout, Cursor c, String[] from, int[] to, int flags) {
super(context, layout, c, from, to, flags);
this.context = context;
mItemLayout = layout;
mHeaderLayout = headerLayout;
prefs = PreferenceManager.getDefaultSharedPreferences(context);
}
int getViewLayout(final int position) {
if (itemType == getItemViewType(position)) {
return mItemLayout;
} else {
return mHeaderLayout;
}
}
@Override
public void remove(int which) {
if (removeListener != null) removeListener.remove(which);
super.remove(which);
}
@Override
public void drop(int from, int to) {
// Call any listener that has been defined
if (dropListener != null) dropListener.drop(from, to);
// Call super to handle UI mapping (for smoothness)
super.drop(from, to);
}
public void setDropListener(DropListener dropListener) {
this.dropListener = dropListener;
}
public void setRemoveListener(RemoveListener removeListener) {
this.removeListener = removeListener;
}
@Override
public int getViewTypeCount() {
return 2;
}
@Override
public int getItemViewType(int position) {
if (position == -1) {
// there was an error in drag-sort-listview: the cached view for dragging
// the note is too high (because the note is too long: ~90 lines).
// c.getLong(0) will crash anyway, because -1 is an invalid index.
// the fix is in SimpleFloatViewManager.onCreateFloatView()
NnnLogger.error(TaskListFragment.class, "Invalid index -1, now I'll crash");
}
final Cursor c = (Cursor) getItem(position);
// If the id is invalid, it's a header
if (c.getLong(0) < 1) {
return headerType;
} else {
return itemType;
}
}
@Override
public View getView(int position, View convertView, ViewGroup parent) {
if (convertView == null) {
convertView = LayoutInflater
.from(this.context)
.inflate(getViewLayout(position), parent, false);
if (itemType == getItemViewType(position)) {
setPrefsOnView(convertView.findViewById(android.R.id.text1));
}
}
return super.getView(position, convertView, parent);
}
private void setPrefsOnView(final TitleNoteTextView view) {
String fontPref1 = prefs.getString(context.getString(R.string.pref_list_title_fontfamily), "1");
view.setTitleFontFamily(Integer.parseInt(fontPref1));
String fontPref2 = prefs.getString(context.getString(R.string.pref_list_title_fontstyle), "1");
view.setTitleFontStyle(Integer.parseInt(fontPref2));
String fontPref3 = prefs.getString(context.getString(R.string.pref_list_body_fontfamily), "0");
view.setBodyFontFamily(Integer.parseInt(fontPref3));
boolean shouldShowLinks = prefs.getBoolean(context.getString(R.string.pref_list_links), true);
view.setLinkify(shouldShowLinks);
String fontPref4 = prefs.getString(context.getString(R.string.pref_list_fontsize), "1");
view.setTheTextSize(Integer.parseInt(fontPref4));
}
}
@Override
public void onSharedPreferenceChanged(final SharedPreferences prefs, final String key) {
if (isDetached()) {
// Fix crash report
return;
}
if (key == null) {
// it happens sometimes during Espresso tests
return;
}
try {
boolean reload = false;
if (key.equals(getString(R.string.pref_sorttype))) {
mSortType = null;
reload = true;
} else if (key.equals(getString(R.string.key_pref_item_max_height))) {
mRowCount = prefs.getInt(key, 3);
reload = true;
} else if (key.equals(getString(R.string.pref_hidecheckboxes))) {
mHideCheckbox = prefs.getBoolean(key, false);
reload = true;
} else if (key.equals(getString(R.string.pref_listtype))) {
mListType = null;
reload = true;
}
if (reload && mCallback != null) {
LoaderManager.getInstance(this).restartLoader(0, null, mCallback);
}
} catch (IllegalStateException ignored) {
// Fix crash report
// Might get a race condition where fragment is detached when getString is called
}
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/fragments/TaskListViewPagerFragment.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.fragments;
import android.app.SearchManager;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.SharedPreferences.OnSharedPreferenceChangeListener;
import android.database.Cursor;
import android.database.DataSetObserver;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.AutoCompleteTextView;
import androidx.annotation.NonNull;
import androidx.appcompat.widget.SearchView;
import androidx.cursoradapter.widget.CursorAdapter;
import androidx.cursoradapter.widget.SimpleCursorAdapter;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentPagerAdapter;
import androidx.loader.app.LoaderManager;
import androidx.loader.app.LoaderManager.LoaderCallbacks;
import androidx.loader.content.CursorLoader;
import androidx.loader.content.Loader;
import androidx.preference.PreferenceManager;
import androidx.viewpager.widget.ViewPager;
import com.nononsenseapps.helpers.NnnLogger;
import com.nononsenseapps.helpers.PreferencesHelper;
import com.nononsenseapps.notepad.R;
import com.nononsenseapps.notepad.activities.ActivitySearchDeleted;
import com.nononsenseapps.notepad.activities.main.ActivityMain;
import com.nononsenseapps.notepad.database.TaskList;
import com.nononsenseapps.notepad.databinding.FragmentTasklistViewpagerBinding;
import com.nononsenseapps.notepad.fragments.DialogEditList.EditListDialogListener;
import com.nononsenseapps.notepad.interfaces.ListOpener;
import com.nononsenseapps.notepad.interfaces.MenuStateController;
import com.nononsenseapps.ui.ViewsHelper;
import org.androidannotations.annotations.AfterViews;
import org.androidannotations.annotations.EFragment;
import org.androidannotations.annotations.ViewById;
import java.util.Objects;
/**
* Displays many listfragments across a viewpager. Supports selecting a certain one on startup
*/
@EFragment(R.layout.fragment_tasklist_viewpager)
public class TaskListViewPagerFragment extends Fragment implements
EditListDialogListener, ListOpener {
public static final String START_LIST_ID = "start_list_id";
@ViewById(resName = "pager")
ViewPager pager;
private SectionsPagerAdapter mSectionsPagerAdapter;
SimpleCursorAdapter mTaskListsAdapter;
// boolean firstLoad = true;
private long mListIdToSelect = -1;
/**
* If transitions between note lists should be animated, with smooth scrolling
* The value is regularly updated by {@link TaskListViewPagerFragment#onResume()}
*/
private boolean mShouldAnimate = true;
/**
* for {@link R.layout#fragment_tasklist_viewpager}
*/
private FragmentTasklistViewpagerBinding mBinding;
/*@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
mBinding = FragmentTasklistViewpagerBinding.inflate(inflater, container, false);
return mBinding.getRoot();
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
// here you call methods with the old @AfterViews annotation
setAdapter();
}
@Override
public void onDestroyView() {
super.onDestroyView();
mBinding = null;
}
*/
public static TaskListViewPagerFragment getInstance() {
return getInstance(-1);
}
public static TaskListViewPagerFragment getInstance(final long startListId) {
TaskListViewPagerFragment_ f = new TaskListViewPagerFragment_();
Bundle args = new Bundle();
args.putLong(START_LIST_ID, startListId);
f.setArguments(args);
return f;
}
public TaskListViewPagerFragment() {
super();
}
public SectionsPagerAdapter getSectionsPagerAdapter() {
return mSectionsPagerAdapter;
}
@Override
public void onResume() {
super.onResume();
mShouldAnimate = PreferencesHelper.areAnimationsEnabled(this.getContext());
}
@Override
public void onCreate(Bundle savedState) {
super.onCreate(savedState);
setHasOptionsMenu(true);
mListIdToSelect = getArguments().getLong(START_LIST_ID, -1);
NnnLogger.debug(TaskDetailFragment.class, "onCreate: " + savedState);
if (savedState != null) {
mListIdToSelect = savedState.getLong(START_LIST_ID);
}
// Adapter for list titles and ids
mTaskListsAdapter = new SimpleCursorAdapter(getActivity(),
android.R.layout.simple_dropdown_item_1line, null,
new String[] { TaskList.Columns.TITLE },
new int[] { android.R.id.text1 }, 0);
// Adapter for view pager
mSectionsPagerAdapter = new SectionsPagerAdapter(
getChildFragmentManager(), mTaskListsAdapter);
}
@Override
public void onActivityCreated(final Bundle state) {
super.onActivityCreated(state);
LoaderCallbacks loaderCallbacks = new LoaderCallbacks<>() {
@NonNull
@Override
public Loader onCreateLoader(int arg0, Bundle arg1) {
return new CursorLoader(getActivity(), TaskList.URI,
new String[] { TaskList.Columns._ID,
TaskList.Columns.TITLE }, null, null,
getResources().getString(R.string.const_as_alphabetic,
TaskList.Columns.TITLE));
}
@Override
public void onLoadFinished(@NonNull Loader arg0, Cursor c) {
mTaskListsAdapter.swapCursor(c);
final int pos;
if (mListIdToSelect != -1) {
pos = mSectionsPagerAdapter.getItemPosition(mListIdToSelect);
} else {
pos = -1;
}
if (pos >= 0) {
pager.setCurrentItem(pos, mShouldAnimate);
mListIdToSelect = -1;
}
}
@Override
public void onLoaderReset(@NonNull Loader arg0) {
mTaskListsAdapter.swapCursor(null);
}
};
// Load actual data
LoaderManager.getInstance(this).restartLoader(0, null, loaderCallbacks);
}
@AfterViews
void setAdapter() {
// Set space between fragments
pager.setPageMargin(ViewsHelper.convertDip2Pixels(getActivity(), 16));
// Set adapters
pager.setAdapter(mSectionsPagerAdapter);
}
/**
* Create the {@link SearchView} to find notes while browsing {@link ActivityMain}
*/
@Override
public void onCreateOptionsMenu(@NonNull Menu menu, MenuInflater inflater) {
inflater.inflate(R.menu.fragment_tasklists_viewpager, menu);
if (menu.findItem(R.id.menu_search) == null) {
return;
}
SearchView searchView = (SearchView) menu.findItem(R.id.menu_search).getActionView();
if (searchView == null) {
return;
}
// Assumes current activity is the searchable activity
SearchManager sMan = this.requireActivity().getSystemService(SearchManager.class);
searchView.setSearchableInfo(sMan.getSearchableInfo(requireActivity().getComponentName()));
// expand the searchview by default when the user clicks on the icon
searchView.setIconifiedByDefault(false);
searchView.setQueryRefinementEnabled(false);
searchView.setSubmitButtonEnabled(false);
// widen the suggestions popup so that it occupies the whole screen
var autoCompTxtVi = (AutoCompleteTextView) searchView
.findViewById(androidx.appcompat.R.id.search_src_text);
final View dropDownSugg = searchView.findViewById(autoCompTxtVi.getDropDownAnchor());
if (dropDownSugg == null) return;
dropDownSugg.addOnLayoutChangeListener(
(v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> {
// gives more horizontal space, to show more text of a search suggestion item
autoCompTxtVi.setDropDownWidth(ViewGroup.LayoutParams.MATCH_PARENT);
});
}
@Override
public void onPrepareOptionsMenu(@NonNull Menu menu) {
if (!(getActivity() instanceof MenuStateController)) {
return;
}
final boolean visible = ((MenuStateController) getActivity()).childItemsVisible();
menu.setGroupVisible(R.id.viewpager_menu_group, visible);
// Outside group to allow for action bar placement
if (menu.findItem(R.id.menu_search) != null)
menu.findItem(R.id.menu_search).setVisible(visible);
if (menu.findItem(R.id.menu_sync) != null)
menu.findItem(R.id.menu_sync).setVisible(visible);
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
int itemId = item.getItemId();
if (itemId == R.id.menu_search) {
// Always visible, but do this if not visible
// getActivity().onSearchRequested();
return true;
} else if (itemId == R.id.menu_deletedtasks) {
startActivity(new Intent(getActivity(), ActivitySearchDeleted.class));
return true;
} else {
return false;
}
}
@Override
public void onFinishEditDialog(final long id) {
openList(id);
}
@Override
public void openList(final long id) {
// If it fails, will load on refresh
mListIdToSelect = id;
NnnLogger.debug(TaskListViewPagerFragment.class, "openList: " + mListIdToSelect);
if (mSectionsPagerAdapter != null) {
final int pos;
if (id < 1)
pos = 0;
else
pos = mSectionsPagerAdapter.getItemPosition(id);
if (pos > -1) {
pager.setCurrentItem(pos, mShouldAnimate);
mListIdToSelect = -1;
}
}
}
@Override
public void onSaveInstanceState(@NonNull Bundle outState) {
super.onSaveInstanceState(outState);
// even if 'pager' is tagged @NonNull, but it may actually be null, for example when
// you load the task history activity
if (mTaskListsAdapter != null && Objects.nonNull(pager)) {
long id = mTaskListsAdapter.getItemId(pager.getCurrentItem());
outState.putLong(START_LIST_ID, id);
NnnLogger.debug(TaskListViewPagerFragment.class, "Saved state, id=" + id);
}
}
@Override
public void onDestroy() {
if (mSectionsPagerAdapter != null) {
mSectionsPagerAdapter.destroy();
}
LoaderManager.getInstance(this).destroyLoader(0);
super.onDestroy();
}
public class SectionsPagerAdapter extends FragmentPagerAdapter {
private final CursorAdapter wrappedAdapter;
private final DataSetObserver subObserver;
private final OnSharedPreferenceChangeListener prefListener;
private long all_id = -2;
public SectionsPagerAdapter(final FragmentManager fm,
final CursorAdapter wrappedAdapter) {
super(fm);
this.wrappedAdapter = wrappedAdapter;
subObserver = new DataSetObserver() {
@Override
public void onChanged() {
notifyDataSetChanged();
}
@Override
public void onInvalidated() {
// Probably destroying the loader
}
};
if (wrappedAdapter != null)
wrappedAdapter.registerDataSetObserver(subObserver);
// also monitor changes of all tasks choice
final SharedPreferences prefs = PreferenceManager
.getDefaultSharedPreferences(getActivity());
prefListener = (sharedPreferences, key) -> {
if (TaskListFragment.LIST_ALL_ID_PREF_KEY.equals(key)) {
all_id = prefs.getLong(
TaskListFragment.LIST_ALL_ID_PREF_KEY,
TaskListFragment.LIST_ID_WEEK);
notifyDataSetChanged();
}
};
prefs.registerOnSharedPreferenceChangeListener(prefListener);
// Set all value
all_id = prefs.getLong(TaskListFragment.LIST_ALL_ID_PREF_KEY,
TaskListFragment.LIST_ID_WEEK);
}
public void destroy() {
if (wrappedAdapter != null) {
wrappedAdapter.unregisterDataSetObserver(subObserver);
}
if (prefListener != null) {
PreferenceManager
.getDefaultSharedPreferences(getActivity())
.unregisterOnSharedPreferenceChangeListener(prefListener);
}
}
@NonNull
@Override
public Fragment getItem(int pos) {
long id = getItemId(pos);
// if (id < 0) return null;
return TaskListFragment_.getInstance(id);
}
@Override
public long getItemId(int position) {
long id = all_id;
if (wrappedAdapter != null && position > 0) {
Cursor c = (Cursor) wrappedAdapter.getItem(position - 1);
if (c != null && !c.isAfterLast() && !c.isBeforeFirst()) {
id = c.getLong(0);
}
}
return id;
}
@Override
public int getCount() {
if (wrappedAdapter != null)
return 1 + wrappedAdapter.getCount();
else
return 1;
}
@Override
public CharSequence getPageTitle(int position) {
if (position >= getCount()) return null;
CharSequence title = "";
if (position == 0) {
switch ((int) all_id) {
case TaskListFragment.LIST_ID_OVERDUE:
title = getString(R.string.date_header_overdue);
break;
case TaskListFragment.LIST_ID_TODAY:
title = getString(R.string.date_header_today);
break;
case TaskListFragment.LIST_ID_WEEK:
title = getString(R.string.next_5_days);
break;
case TaskListFragment.LIST_ID_ALL:
default:
title = getString(R.string.all_tasks);
break;
}
} else if (wrappedAdapter != null) {
Cursor c = (Cursor) wrappedAdapter.getItem(position - 1);
if (c != null && !c.isAfterLast() && !c.isBeforeFirst()) {
title = c.getString(1);
}
}
return title;
}
/**
* {@inheritDoc}
*
* Called when the host view is attempting to determine if an item's
* position has changed. Returns POSITION_UNCHANGED if the position of
* the given item has not changed or POSITION_NONE if the item is no
* longer present in the adapter.
*
* Argument is the object previously returned by instantiateItem
*/
@Override
public int getItemPosition(@NonNull Object object) {
Fragment f = (Fragment) object;
long listId = f.getArguments().getLong(TaskListFragment.LIST_ID);
return getItemPosition(listId);
}
/**
* Returns a negative number if id wasn't found in adapter
*/
public int getItemPosition(final long listId) {
int length = getCount();
int result = POSITION_NONE;
int position;
for (position = 0; position < length; position++) {
if (listId == getItemId(position)) {
result = position;
break;
}
}
return result;
}
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/interfaces/ListOpener.java
================================================
package com.nononsenseapps.notepad.interfaces;
import com.nononsenseapps.notepad.activities.main.ActivityMain;
import com.nononsenseapps.notepad.fragments.TaskListViewPagerFragment;
/**
* Allows interactions between {@link ActivityMain} and {@link TaskListViewPagerFragment}
*/
public interface ListOpener {
void openList(final long id);
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/interfaces/MenuStateController.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.interfaces;
/**
* Used to control the menu items for the navigation drawer.
*/
public interface MenuStateController {
/**
* If true, menu items should be hidden/removed. Items relevant to the
* navigation drawer should be visible
*/
boolean childItemsVisible();
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/interfaces/OnFragmentInteractionListener.java
================================================
package com.nononsenseapps.notepad.interfaces;
import android.net.Uri;
import android.view.View;
import androidx.fragment.app.Fragment;
/**
* This interface must be implemented by activities that contain
* fragments to allow an interaction in this fragment to be communicated to
* the activity and potentially other fragments contained in that activity.
*/
public interface OnFragmentInteractionListener {
void onFragmentInteraction(final Uri uri, final long listId, final View origin);
void addTaskInList(final String text, final long listId);
void closeFragment(final Fragment fragment);
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/prefs/AboutPrefs.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.prefs;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import com.nononsenseapps.notepad.BuildConfig;
import com.nononsenseapps.notepad.R;
import com.nononsenseapps.notepad.databinding.AppPrefAboutLayoutBinding;
public class AboutPrefs extends Fragment {
/**
* for {@link R.layout#app_pref_about_layout}
*/
private AppPrefAboutLayoutBinding mBinding;
@Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container,
Bundle savInstState) {
mBinding = AppPrefAboutLayoutBinding.inflate(inflater, container, false);
return mBinding.getRoot();
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
mBinding.appVersionRow.setText(getString(R.string.version, BuildConfig.VERSION_NAME));
mBinding.tvDonations.setText(getString(R.string.app_about_donations,
getString(R.string.sponsor_this_project),
getString(R.string.github_repo_url)
));
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/prefs/AppearancePrefs.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.prefs;
import android.content.Context;
import android.os.Bundle;
import androidx.annotation.Nullable;
import androidx.preference.ListPreference;
import androidx.preference.PreferenceFragmentCompat;
import androidx.preference.PreferenceManager;
import com.nononsenseapps.helpers.TimeFormatter;
import com.nononsenseapps.notepad.R;
import java.util.ArrayList;
import java.util.GregorianCalendar;
import java.util.Locale;
/**
* Settings about how notes will look like: Theme, language, text size, ...
*/
public class AppearancePrefs extends PreferenceFragmentCompat {
public static final String KEY_THEME = "key_current_theme";
public static final String SANS = "Sans";
public static final String SERIF = "Serif";
public static final String MONOSPACE = "Monospace";
public static final String WEEK_START_DEFAULT = "-1";
public static final String WEEK_START_SATURDAY = "7";
public static final String WEEK_START_SUNDAY = "1";
public static final String WEEK_START_MONDAY = "2";
@Override
public void onCreatePreferences(@Nullable Bundle savInstState, String rootKey) {
// Load the preferences from an XML resource
addPreferencesFromResource(R.xml.app_pref_main);
// Fill listpreferences
setLangEntries(findPreference(getString(R.string.pref_locale)), getContext());
setDateEntries(findPreference(getString(R.string.key_pref_dateformat_short)), R.array.dateformat_short_values);
setDateEntries(findPreference(getString(R.string.key_pref_dateformat_long)), R.array.dateformat_long_values);
PrefsActivity
.bindSummaryToValue(findPreference(getString(R.string.key_pref_dateformat_long)));
PrefsActivity
.bindSummaryToValue(findPreference(getString(R.string.key_pref_dateformat_short)));
PrefsActivity
.bindSummaryToValue(findPreference(getString(R.string.pref_editor_title_fontfamily)));
PrefsActivity
.bindSummaryToValue(findPreference(getString(R.string.pref_editor_title_fontstyle)));
PrefsActivity
.bindSummaryToValue(findPreference(getString(R.string.pref_editor_body_fontfamily)));
PrefsActivity
.bindSummaryToValue(findPreference(getString(R.string.pref_editor_fontsize)));
// when theme or language changes, restart the PrefsActivity
initializeRestartingPrefWithKey(KEY_THEME);
initializeRestartingPrefWithKey(getString(R.string.pref_locale));
}
/**
* Initialize a preference with the given key so that we restart the
* {@link PrefsActivity} when it changes, to see immediately the change.
* Warning: only keys of {@link ListPreference} instances!
*/
private void initializeRestartingPrefWithKey(String prefKey) {
ListPreference listPref = findPreference(prefKey);
// like "light_ab" or "it_IT"
String prefVal = PreferenceManager
.getDefaultSharedPreferences(listPref.getContext())
.getString(listPref.getKey(), null);
int index = listPref.findIndexOfValue(prefVal);
// like "Light" or "italiano"
CharSequence valueToSet = index >= 0 ? listPref.getEntries()[index] : null;
listPref.setSummary(valueToSet);
listPref.setOnPreferenceChangeListener((samePref, val) -> {
// reload activity to apply immediately the new theme or language
this.getActivity().recreate();
return true;
});
}
private void setDateEntries(ListPreference prefDate, int array) {
final String[] values = getResources().getStringArray(array);
final ArrayList entries = new ArrayList<>();
final GregorianCalendar cal = new GregorianCalendar(
2099, 2, 27, 0, 59);
for (final String val : values) {
entries.add(TimeFormatter.getLocalDateString(
getActivity(), val, cal.getTimeInMillis()));
}
prefDate.setEntries(entries.toArray(new CharSequence[0]));
prefDate.setEntryValues(values);
}
private static void setLangEntries(ListPreference prefLang, Context context) {
ArrayList entries = new ArrayList<>();
ArrayList values = new ArrayList<>();
entries.add(context.getString(R.string.localedefault));
values.add("");
String[] langs = context
.getResources()
.getStringArray(R.array.translated_langs);
for (String lang : langs) {
Locale l;
if (lang.length() == 5) {
l = new Locale(lang.substring(0, 2), lang.substring(3, 5));
} else {
l = new Locale(lang.substring(0, 2));
}
entries.add(l.getDisplayName(l));
values.add(lang);
}
prefLang.setEntries(entries.toArray(new CharSequence[0]));
prefLang.setEntryValues(values.toArray(new CharSequence[0]));
// Set summary
prefLang.setSummary(prefLang.getEntry());
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/prefs/BackupPrefs.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.prefs;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.preference.Preference;
import androidx.preference.PreferenceFragmentCompat;
import androidx.preference.PreferenceManager;
import com.nononsenseapps.helpers.FilePickerHelper;
import com.nononsenseapps.helpers.NnnLogger;
import com.nononsenseapps.notepad.R;
import com.nononsenseapps.notepad.fragments.DialogExportBackup;
import com.nononsenseapps.notepad.fragments.DialogRestoreBackup;
import com.nononsenseapps.notepad.sync.files.JSONBackup;
import java.io.FileNotFoundException;
import java.util.concurrent.Executors;
public class BackupPrefs extends PreferenceFragmentCompat {
// settings IDs from app_pref_backup.xml
private static final String KEY_IMPORT = "backup_import";
private static final String KEY_EXPORT = "backup_export";
private static final String KEY_BACKUP_DIR_URI = "key_backup_dir_uri";
private JSONBackup mTool;
/**
* the folder that contains the backup json file
*/
Preference dirUriPref;
@Override
public void onCreatePreferences(@Nullable Bundle savInstState, String rootKey) {
// Load the preferences from an XML resource
addPreferencesFromResource(R.xml.app_pref_backup);
mTool = new JSONBackup(getActivity());
findPreference(KEY_IMPORT).setOnPreferenceClickListener(pref -> {
DialogRestoreBackup.showDialog(getParentFragmentManager(),
// callback when confirmed:
() -> runBackupOrRestore(true));
return true;
});
findPreference(KEY_EXPORT).setOnPreferenceClickListener(pref -> {
DialogExportBackup.showDialog(getParentFragmentManager(),
() -> runBackupOrRestore(false));
return true;
});
dirUriPref = findPreference(KEY_BACKUP_DIR_URI);
dirUriPref.setOnPreferenceClickListener(pref -> {
// open the file picker on click
Uri initialDir = getSelectedBackupDirUri(this.getContext());
FilePickerHelper.showFolderPickerActivity(this, initialDir);
// tell android to update the preference value
return true;
});
// initialize
onUriDirPrefChange(dirUriPref);
}
/**
* Updates the description of "directoryUriPreference"
* with the newly selected backup directory Uri
*/
private static void onUriDirPrefChange(Preference directoryUriPreference) {
Uri uri = getSelectedBackupDirUri(directoryUriPreference.getContext());
String summary = uri != null
? uri.getPath() // shows a pretty representation of the URI's destination
: directoryUriPreference.getContext().getString(R.string.not_selected_yet);
directoryUriPreference.setSummary(summary);
}
/**
* @return the Uri of the folder that the user chose for saving Json backups,
* or NULL if none is chosen
*/
@Nullable
public static Uri getSelectedBackupDirUri(Context context) {
var sharPrefs = PreferenceManager.getDefaultSharedPreferences(context);
String uriVal = sharPrefs.getString(KEY_BACKUP_DIR_URI, null);
if (uriVal == null) return null;
return Uri.parse(uriVal);
}
@Override
public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
// it was cancelled by the user. Let's ignore it
if (resultCode != Activity.RESULT_OK) return;
if (requestCode == FilePickerHelper.REQ_CODE) {
// "data" contains the URI for the user-selected directory, A.K.A. the "document tree"
FilePickerHelper.onUriPicked(data, this.getContext(), KEY_BACKUP_DIR_URI);
onUriDirPrefChange(dirUriPref);
}
super.onActivityResult(requestCode, resultCode, data);
}
/**
* Run the backup (or restore) in the background. Locking the UI-thread for up to a few
* seconds is not nice...
*/
private void runBackupOrRestore(boolean isRestoring) {
// get them in this thread
Handler handler = new Handler(Looper.getMainLooper());
Context context = this.getContext();
if (getSelectedBackupDirUri(this.getContext()) == null) {
// the user tried to make a backup without having selected
// a folder first. The dialogs warn of this. Here, we just
// have to cancel the operation
return;
}
// replacement for AsyncTask<,,>
Executors.newSingleThreadExecutor().execute(() -> {
// Background work here
int result = asyncTask_doInBackground(isRestoring, mTool);
handler.post(() -> {
// UI Thread work here
asyncTask_onPostExecute(context, isRestoring, result);
});
});
}
/**
* the backup/restore work for the background thread
*
* @param isRestoring TRUE if this task should RESTORE a backup from a file,
* FALSE if it should CREATE a backup file
* @return a result code used by {@link #asyncTask_onPostExecute(Context, boolean, int)}
*/
private static int asyncTask_doInBackground(boolean isRestoring, JSONBackup backupMaker) {
try {
if (isRestoring) backupMaker.restoreBackup();
else backupMaker.writeBackup();
return 0;
} catch (FileNotFoundException e) {
return 1;
} catch (SecurityException e) {
// can't read from that folder: missing permission ?
return 2;
} catch (Exception e) {
NnnLogger.exception(e);
return 3;
}
}
/**
* after the backup/restore is finished, show a toast on the UI thread
*
* @param isRestoring FALSE if it is "save backup" operation
* @param result from {@link #asyncTask_doInBackground(boolean, JSONBackup)}
*/
private static void asyncTask_onPostExecute(@NonNull Context mContext,
boolean isRestoring, int result) {
int msgId;
switch (result) {
case 0 -> msgId = isRestoring
? R.string.backup_import_success
: R.string.backup_export_success;
case 1 -> msgId = R.string.backup_file_not_found;
case 2 ->
// can't read from / write to that folder: missing permission ?
msgId = R.string.permission_denied;
case 3 -> msgId = isRestoring
? R.string.backup_import_failed
: R.string.backup_export_failed;
default -> {
// won't happen, anyway
return;
}
}
Toast.makeText(mContext, msgId, Toast.LENGTH_SHORT).show();
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/prefs/Constants.java
================================================
package com.nononsenseapps.notepad.prefs;
/**
* Key names of the preferences.
*/
public final class Constants {
// TODO replace calls to SyncPrefs.KEY_SYNC_ENABLE with calls to Constants.KEY_SYNC_ENABLE,
// which makes more sense. Also ensure that these actually correspond to the values in the
// XML files
public static final String KEY_SYNC_ENABLE = "syncEnablePref";
public static final String KEY_ACCOUNT = "accountPref";
// public static final String KEY_SYNC_FREQ = "syncFreq";
public static final String KEY_FULLSYNC = "syncFull";
public static final String KEY_SYNC_ON_START = "syncOnStart";
public static final String KEY_SYNC_ON_CHANGE = "syncOnChange";
public static final String KEY_BACKGROUND_SYNC = "syncInBackground";
// Used for sync on start and on change
public static final String KEY_LAST_SYNC = "lastSync";
// SD sync
public static final String KEY_SD_ENABLE = "pref_sync_sd_enabled";
// Dropbox sync
public static final String KEY_DROPBOX_ENABLE = "pref_sync_dropbox_enabled";
public static final String KEY_DROPBOX_DIR = "pref_sync_dropbox_dir";
public static final String KEY_THEME = "preference_theme";
/**
* Location of the app tutorial web page
*/
public static final String TUTORIAL_URL =
"https://github.com/spacecowboy/NotePad/blob/master/documents/TUTORIAL.md";
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/prefs/IndexPrefs.java
================================================
package com.nononsenseapps.notepad.prefs;
import android.os.Bundle;
import androidx.annotation.Nullable;
import androidx.preference.PreferenceFragmentCompat;
import com.nononsenseapps.notepad.R;
/**
* Holds all preference categories, it's the "main" settings page
*/
public class IndexPrefs extends PreferenceFragmentCompat {
@Override
public void onCreatePreferences(@Nullable Bundle savInstState, String rootKey) {
addPreferencesFromResource(R.xml.app_pref_headers);
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/prefs/ListPrefs.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.prefs;
import android.database.Cursor;
import android.os.Bundle;
import androidx.annotation.Nullable;
import androidx.preference.ListPreference;
import androidx.preference.Preference;
import androidx.preference.PreferenceFragmentCompat;
import androidx.preference.PreferenceManager;
import com.nononsenseapps.notepad.R;
import com.nononsenseapps.notepad.database.TaskList;
import java.util.ArrayList;
public class ListPrefs extends PreferenceFragmentCompat {
@Override
public void onCreatePreferences(@Nullable Bundle savInstState, String rootKey) {
// Load the preferences from an XML resource
addPreferencesFromResource(R.xml.app_pref_list);
// Fill listpreferences
setEntries(findPreference(getString(R.string.pref_defaultlist)));
// Bind summaries
PrefsActivity.bindSummaryToValue(findPreference(getString(R.string.pref_sorttype)));
PrefsActivity.bindSummaryToValue(findPreference(getString(R.string.pref_defaultlist)));
PrefsActivity.bindSummaryToValue(
findPreference(getString(R.string.pref_list_title_fontfamily)));
PrefsActivity.bindSummaryToValue(
findPreference(getString(R.string.pref_list_title_fontstyle)));
PrefsActivity.bindSummaryToValue(
findPreference(getString(R.string.pref_list_body_fontfamily)));
PrefsActivity.bindSummaryToValue(findPreference(getString(R.string.pref_list_fontsize)));
//PrefsActivity
// .bindPreferenceSummaryToValue(findPreference(getString(R.string.pref_listtype)));
// Make the show checkbox dependant on the list type preference
final Preference hideCheckboxes = findPreference(getString(R.string.pref_hidecheckboxes));
Preference.OnPreferenceChangeListener listener = (preference, value) -> {
String stringValue = value.toString();
if (preference instanceof ListPreference listPreference) {
// For list preferences, look up the correct display value in
// the preference's 'entries' list.
int index = listPreference.findIndexOfValue(stringValue);
// Set the summary to reflect the new value.
preference.setSummary(index >= 0 ? listPreference.getEntries()[index] : null);
} else {
// For all other preferences, set the summary to the value's
// simple string representation.
preference.setSummary(stringValue);
}
hideCheckboxes.setEnabled(stringValue.equals(getString(R.string.const_listtype_tasks)));
return true;
};
final Preference listtype = findPreference(getString(R.string.pref_listtype));
listtype.setOnPreferenceChangeListener(listener);
listener.onPreferenceChange(listtype,
PreferenceManager
.getDefaultSharedPreferences(listtype.getContext())
.getString(listtype.getKey(), ""));
}
/**
* Reads the lists from database. Also adds "All lists" as the first item.
*/
private void setEntries(ListPreference listSpinner) {
ArrayList entries = new ArrayList<>();
ArrayList values = new ArrayList<>();
// TODO fix from old version
// listSpinner.setDefaultValue(Long.toString(MainActivity.getAList(getActivity(), -1)));
Cursor cursor = getActivity()
.getContentResolver()
.query(TaskList.URI,
new String[] { TaskList.Columns._ID, TaskList.Columns.TITLE },
null, null, TaskList.Columns.TITLE);
if (cursor != null) {
if (!cursor.isClosed() && !cursor.isAfterLast()) {
while (cursor.moveToNext()) {
entries.add(cursor.getString(1));
values.add(Long.toString(cursor.getLong(0)));
}
}
cursor.close();
}
// Set the values
if (listSpinner != null) {
listSpinner.setEntries(entries.toArray(new CharSequence[0]));
listSpinner.setEntryValues(values.toArray(new CharSequence[0]));
listSpinner.setSummary(listSpinner.getEntry());
}
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/prefs/NotificationPrefs.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.prefs;
import android.app.Activity;
import android.app.NotificationManager;
import android.content.Context;
import android.content.Intent;
import android.media.Ringtone;
import android.media.RingtoneManager;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.PowerManager;
import android.provider.Settings;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import androidx.core.content.IntentCompat;
import androidx.core.content.PackageManagerCompat;
import androidx.core.content.UnusedAppRestrictionsConstants;
import androidx.preference.Preference;
import androidx.preference.PreferenceCategory;
import androidx.preference.PreferenceFragmentCompat;
import androidx.preference.PreferenceManager;
import com.google.common.util.concurrent.ListenableFuture;
import com.nononsenseapps.helpers.NnnLogger;
import com.nononsenseapps.helpers.NotificationHelper;
import com.nononsenseapps.notepad.BuildConfig;
import com.nononsenseapps.notepad.R;
public class NotificationPrefs extends PreferenceFragmentCompat {
private static final int REQUEST_CODE_ALERT_RINGTONE = 1;
@Override
public void onCreatePreferences(@Nullable Bundle savInstState, String rootKey) {
// Load the preferences from an XML resource
addPreferencesFromResource(R.xml.app_pref_notifications);
PrefsActivity.bindSummaryToValue(
findPreference(getString(R.string.key_pref_prio)));
// show the initial value of the selected ringtone
updateRingtonePrefSummary(
findPreference(getString(R.string.key_pref_ringtone)),
this.getContext());
// the "Preferences for older Android devices" category
PreferenceCategory prefCat = findPreference(getString(R.string.key_pref_cat_notif_old));
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// newer androids have a dedicated settings page for the notification channel
// => use that, also because the channel overwrites the individual notifications
prefCat.setEnabled(false);
} else {
// older androids don't have the notification channel => we keep using our
// notification preferences => expand their category
prefCat.setInitialExpandedChildrenCount(Integer.MAX_VALUE);
}
}
@Override
public boolean onPreferenceTreeClick(Preference preference) {
final String key = preference.getKey();
// if the user clicks a title or a pref. without a key, don't do anything
if (key == null) return false;
final String ringtonePrefKey = getString(R.string.key_pref_ringtone);
final String allowExactRemindersKey = getString(R.string.key_pref_allow_exact_reminders);
final String ignoreBatteryOptimizationKey = getString(R.string.key_pref_ignore_battery_optimizations);
final String openNotifChannelKey = getString(R.string.key_pref_notif_channel_settings);
final String disableHibernation = getString(R.string.key_pref_disable_hibernation);
final String notificVisibility = getString(R.string.key_pref_notif_visibility);
if (key.equals(ringtonePrefKey)) {
// the pseudo-ringtonePreference was clicked => open a system page to pick a ringtone
Intent intent = new Intent(RingtoneManager.ACTION_RINGTONE_PICKER)
.putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE,
RingtoneManager.TYPE_NOTIFICATION)
.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true)
.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, true)
.putExtra(RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI,
Settings.System.DEFAULT_NOTIFICATION_URI);
String existingValue = PreferenceManager
.getDefaultSharedPreferences(this.getContext())
.getString(ringtonePrefKey, null);
if (existingValue != null) {
Uri existing = existingValue.isEmpty()
? null // Select "Silent"
: Uri.parse(existingValue);
intent.putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, existing);
} else {
// No ringtone has been selected, set to the default
intent.putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI,
Settings.System.DEFAULT_NOTIFICATION_URI);
}
startActivityForResult(intent, REQUEST_CODE_ALERT_RINGTONE);
return true;
} else if (key.equals(allowExactRemindersKey)) {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
// open a settings page to enable exact reminders for this app.
// they're enabled by default in the Android 12 emulator
Intent i = new Intent()
.setAction(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM)
.setData(Uri.parse("package:" + getContext().getPackageName()));
startActivity(i);
} else {
// not needed before android S
}
// we don't care about the value
return false;
} else if (key.equals(ignoreBatteryOptimizationKey)) {
// open the battery settings when clicked
Intent i = new Intent()
.setAction(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS);
startActivity(i);
// the value of this preference is never used,
// it's just something the user can click to open a settings page
return false;
} else if (key.equals(openNotifChannelKey)) {
openNotificationSettings(this.getContext());
return false;
} else if (key.equals(notificVisibility)) {
// open the app settings to let the user change app permissions
Intent i = new Intent(
android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.parse("package:" + BuildConfig.APPLICATION_ID));
startActivity(i);
return false;
} else if (key.equals(disableHibernation)) {
showHibernationPageIfNeeded(this);
return false;
} else {
return super.onPreferenceTreeClick(preference);
}
}
@Override
public void onActivityResult(int requestCode, int resultCode, Intent data) {
if (resultCode != Activity.RESULT_OK || data == null) {
// canceled by the user
super.onActivityResult(requestCode, resultCode, data);
return;
}
if (requestCode == REQUEST_CODE_ALERT_RINGTONE) {
// the user picked a ringtone => save it
Uri ringtone = data.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI);
String ringtonePrefKey = getString(R.string.key_pref_ringtone);
Preference pref = findPreference(ringtonePrefKey);
// ringtone == null means that "Silent" was selected in the picker
String newPrefVal = ringtone == null ? null : ringtone.toString();
// save the new value
PreferenceManager
.getDefaultSharedPreferences(this.getContext())
.edit()
.putString(ringtonePrefKey, newPrefVal)
.commit();
// show it
updateRingtonePrefSummary(pref, this.getContext());
} else {
super.onActivityResult(requestCode, resultCode, data);
}
}
/**
* Get the ringtone name and show it in the summary
*
* @param theRingtonePref a reference to the {@link Preference} object of the ringtone
*/
private static void updateRingtonePrefSummary(Preference theRingtonePref, Context con) {
// get the URI saved in the preferences
String ringtonePrefKey = con.getString(R.string.key_pref_ringtone);
final String ringtonePrefVal = PreferenceManager
.getDefaultSharedPreferences(con)
.getString(ringtonePrefKey, null);
final Uri newVal = ringtonePrefVal == null ? null : Uri.parse(ringtonePrefVal);
// look up the correct display value using RingtoneManager
if (newVal == null) {
// Empty values correspond to 'silent' (no ringtone)
theRingtonePref.setSummary(R.string.silent);
} else {
Ringtone ringtone = RingtoneManager.getRingtone(con, newVal);
if (ringtone == null) {
// Clear the summary if there was a lookup error
theRingtonePref.setSummary(null);
} else {
// Set the summary to reflect the new ringtone display name
String name = ringtone.getTitle(con);
theRingtonePref.setSummary(name);
}
}
}
@Override
public void onResume() {
super.onResume();
// check if battery optimizations are enabled and show it in the summary
var pm = (PowerManager) getContext().getSystemService(Context.POWER_SERVICE);
int summaryResId1 = pm.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID)
? R.string.battery_optimizations_inactive
: R.string.battery_optimizations_active;
findPreference(getString(R.string.key_pref_ignore_battery_optimizations))
.setSummary(summaryResId1);
var nm = (NotificationManager) getContext().getSystemService(Context.NOTIFICATION_SERVICE);
int summaryResId2 = NotificationHelper.areNotificationsVisible(nm)
? R.string.notifications_enabled
: R.string.notifications_blocked;
findPreference(getString(R.string.key_pref_notif_visibility))
.setSummary(summaryResId2);
}
/**
* opens a system settings page dedicated to notification preferences for
* - our only notification channel (only devices on Oreo or newer)
* - the app as a whole (only devices on API 23, 24 or 25)
* In android Oreo and newer, these settings overwrite those of the old preferences,
* which now are in the {@link PreferenceCategory} "key_pref_cat_notif_old"
*/
private static void openNotificationSettings(Context context) {
Intent intent;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
intent = new Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
.putExtra(Settings.EXTRA_APP_PACKAGE, context.getPackageName())
.putExtra(Settings.EXTRA_CHANNEL_ID, NotificationHelper.CHANNEL_ID);
} else {
// it works on a tablet with API 23. But it's not as complete as the
// notification channel preference page on API 32 devices, for example
intent = new Intent("android.settings.APP_NOTIFICATION_SETTINGS")
.putExtra("app_package", context.getPackageName())
.putExtra("app_uid", context.getApplicationInfo().uid);
}
context.startActivity(intent);
}
// TODO test the app in doze mode: see
// https://developer.android.com/training/monitoring-device-state/doze-standby#testing_doze
// command: $ adb shell dumpsys alarm
// in particular, ensure that the notification arrive at a reasonable time
/**
* If the user doesn't start the app for a few months, the system will
* place restrictions on it. See the {@link UnusedAppRestrictionsConstants} for details.
* This function shows the settings page where the user can disable this behavior
*/
static void showHibernationPageIfNeeded(@NonNull PreferenceFragmentCompat owner) {
var context = owner.getContext();
ListenableFuture lfi = PackageManagerCompat
.getUnusedAppRestrictionsStatus(context);
lfi.addListener(() -> {
// if we're going to show the settings page to disable hibernation
boolean showPage;
try {
int appRestrictionsStatus = lfi.get();
switch (appRestrictionsStatus) {
case UnusedAppRestrictionsConstants.API_30_BACKPORT:
case UnusedAppRestrictionsConstants.API_30:
case UnusedAppRestrictionsConstants.API_31:
// restriction enabled => show settings page to let users disable it
showPage = true;
break;
case UnusedAppRestrictionsConstants.ERROR:
case UnusedAppRestrictionsConstants.FEATURE_NOT_AVAILABLE:
case UnusedAppRestrictionsConstants.DISABLED:
default:
// restriction not enabled => don't show settings page
showPage = false;
break;
}
} catch (Exception ex) {
NnnLogger.exception(ex);
return;
}
if (showPage) {
// ask the user to disable these restrictions: redirect the user to
// the page in system settings to disable the feature.
String pkgName = context.getPackageName();
Intent i = IntentCompat.createManageUnusedAppRestrictionsIntent(context, pkgName);
// You must use startActivityForResult(), not startActivity(), even if
// you don't use the result code returned in onActivityResult().
owner.startActivityForResult(i, 12345);
} else {
// tell the user that hibernation is already OFF
Toast.makeText(context, R.string.msg_hibernation_already_off, Toast.LENGTH_SHORT)
.show();
}
}, ContextCompat.getMainExecutor(context));
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/prefs/PasswordPrefs.java
================================================
/*
* Copyright (C) 2012 Jonas Kalderstam
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.nononsenseapps.notepad.prefs;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.animation.Animation;
import android.view.animation.AnimationUtils;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.preference.PreferenceManager;
import com.nononsenseapps.helpers.PreferencesHelper;
import com.nononsenseapps.notepad.R;
import com.nononsenseapps.notepad.databinding.AppPrefPasswordLayoutBinding;
import com.nononsenseapps.notepad.fragments.DialogPasswordV11;
public class PasswordPrefs extends Fragment {
public static final String KEY_PASSWORD = "secretPassword";
// TODO copy from DialogPasswordSettings.java and delete that file
/**
* for {@link R.layout#app_pref_password_layout}
*/
private AppPrefPasswordLayoutBinding mBinding;
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater,
@Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
mBinding = AppPrefPasswordLayoutBinding
.inflate(inflater, container, false);
return mBinding.getRoot();
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
// here you call methods with the old @AfterViews annotation
mBinding.applyPassword.setOnClickListener(v -> applyPassword());
mBinding.clearPassword.setOnClickListener(v -> clearPassword());
}
private void applyPassword() {
String passw1 = mBinding.tempPassword1.getText().toString();
String passw2 = mBinding.tempPassword2.getText().toString();
if (passw1.equals(passw2)) {
// They are the same
SharedPreferences settings = PreferenceManager
.getDefaultSharedPreferences(this.getContext());
String currentPassword = settings.getString(KEY_PASSWORD, "");
if (currentPassword.isEmpty()) {
// it's new => Save the password directly
settings.edit()
.putString(KEY_PASSWORD, passw1)
.commit();
Toast.makeText(this.getContext(), getText(R.string.password_set),
Toast.LENGTH_SHORT).show();
} else {
// confirm with existing password first
showPasswordDialog(passw1);
}
} else {
if (PreferencesHelper.areAnimationsEnabled(this.getContext())) {
// shake the dialog to show that the password is wrong
Animation shake = AnimationUtils.loadAnimation(this.getContext(), R.anim.shake);
mBinding.tempPassword2.startAnimation(shake);
}
// Show a toast so the user knows he did something wrong
Toast.makeText(this.getContext(), getText(R.string.passwords_dont_match),
Toast.LENGTH_SHORT).show();
}
}
private void clearPassword() {
SharedPreferences settings = PreferenceManager
.getDefaultSharedPreferences(this.getContext());
String currentPassword = settings.getString(KEY_PASSWORD, "");
if (currentPassword.isEmpty()) {
// Save the (empty) password directly
settings.edit()
.putString(KEY_PASSWORD, "")
.commit();
Toast.makeText(this.getContext(), R.string.password_cleared,
Toast.LENGTH_SHORT).show();
} else {
// confirm with existing password first
showPasswordDialog("");
}
}
private void showPasswordDialog(final String newPassword) {
final DialogPasswordV11 pd = new DialogPasswordV11();
pd.setListener(() -> {
PreferenceManager
.getDefaultSharedPreferences(this.getContext())
.edit()
.putString(PasswordPrefs.KEY_PASSWORD, newPassword)
.commit();
Toast.makeText(getActivity(),
"".equals(newPassword) ? R.string.password_cleared : R.string.password_set,
Toast.LENGTH_SHORT).show();
});
pd.show(getParentFragmentManager(), "pw-verify");
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/prefs/PrefsActivity.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.prefs;
import android.app.backup.BackupManager;
import android.os.Bundle;
import android.view.MenuItem;
import androidx.activity.OnBackPressedCallback;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentContainerView;
import androidx.preference.ListPreference;
import androidx.preference.Preference;
import androidx.preference.PreferenceFragmentCompat;
import androidx.preference.PreferenceManager;
import com.nononsenseapps.helpers.ActivityHelper;
import com.nononsenseapps.helpers.NnnLogger;
import com.nononsenseapps.helpers.NotificationHelper;
import com.nononsenseapps.helpers.ThemeHelper;
import com.nononsenseapps.notepad.R;
/**
* The preferences page, holds a list of all preference categories
*/
public class PrefsActivity extends AppCompatActivity implements
PreferenceFragmentCompat.OnPreferenceStartFragmentCallback {
private boolean isTabletInLandscape = false;
@Override
protected void onCreate(Bundle savedInstanceState) {
ThemeHelper.setTheme(this);
ActivityHelper.setSelectedLanguage(this);
super.onCreate(savedInstanceState);
// Add the arrow to go back
if (getSupportActionBar() != null) {
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
// the title updates when the user chooses a new language setting,
// but ONLY if we set it here
getSupportActionBar().setTitle(R.string.menu_preferences);
}
// inflates a layout with a fragmentcontainerview, which will
// automatically start an instance of IndexPrefs
setContentView(R.layout.activity_settings);
// this exists only in the tablet-landscape layout file
FragmentContainerView fragmentSpot2 = this.findViewById(R.id.fragmentRightForTablets);
isTabletInLandscape = fragmentSpot2 != null;
getSupportFragmentManager().addOnBackStackChangedListener(() -> {
int numActiveFrags = getSupportFragmentManager().getBackStackEntryCount();
if (numActiveFrags == 1) {
// it's opening a settings category => there is nothing to do
} else if (numActiveFrags == 0) {
// it's going back to the "main menu" => remove the subtitle
if (getSupportActionBar() != null) {
getSupportActionBar().setSubtitle(null);
}
} else {
NnnLogger.warning(PrefsActivity.class,
"unexpected numActiveFrags = " + numActiveFrags);
}
});
// when pressing the physical back button, navigate between fragments by removing the
// subtitle
getOnBackPressedDispatcher().addCallback(this,
new OnBackPressedCallback(true) {
@Override
public void handleOnBackPressed() {
// replicate super.onBackPressed() behavior
if (getSupportFragmentManager().getBackStackEntryCount() > 0) {
getSupportFragmentManager().popBackStack();
} else {
finish();
}
}
});
}
/**
* called when a settings category is clicked. It opens the appropriate
* preference fragment. From:
* here.
*/
@Override
public boolean onPreferenceStartFragment(@NonNull PreferenceFragmentCompat caller,
Preference pref) {
// Instantiate the new Fragment
final Bundle args = pref.getExtras();
final Fragment fragment = getSupportFragmentManager()
.getFragmentFactory()
.instantiate(getClassLoader(), pref.getFragment());
fragment.setArguments(args);
fragment.setTargetFragment(caller, 0);
if (isTabletInLandscape) {
// for tablets in landscape mode, 2 fragments are shown (=> 2 pane view):
// the "main menu" remains on the left, the preference page list opens on the right
getSupportFragmentManager()
.beginTransaction()
.replace(R.id.fragmentRightForTablets, fragment)
// don't call .addToBackStack(null), so the back button will immediately exit
.commit();
} else {
// for phones & tablets in portrait mode, there is only 1 fragment shown:
// Replace the existing "main menu" Fragment with the new "category" Fragment
getSupportFragmentManager()
.beginTransaction()
.replace(R.id.fragment, fragment)
.addToBackStack(null)
.commit();
}
if (getSupportActionBar() != null) {
getSupportActionBar().setSubtitle(pref.getTitle());
}
return true;
}
@Override
protected void onDestroy() {
// Request a backup in case prefs changed. Safe to call multiple times
new BackupManager(this).dataChanged();
// show reminders notifications. Useful when the user re-enables notifications
// permissions: in this case, any overdue notification should be shown immediately
NotificationHelper.schedule(this);
super.onDestroy();
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
if (item.getItemId() == android.R.id.home) {
// This ID represents the Home or Up button. In this activity, the Up button is shown.
// To get a consistent behavior, both pressing "back" and clicking the Up arrow
// will navigate back, so if a preference category is shown, pressing the Up
// button won't close the settings, it will go back to the Index
getOnBackPressedDispatcher().onBackPressed();
return true;
}
return super.onOptionsItemSelected(item);
}
/**
* A preference value change listener that updates the preference's summary
* to reflect its new value. Handles the {@link ListPreference} specially.
*/
private static final Preference.OnPreferenceChangeListener
sBindPreferenceSummaryToValueListener = (preference, value) -> {
final String stringValue = value.toString();
if (preference instanceof ListPreference listPreference) {
// For list preferences, look up the correct display value in
// the preference's 'entries' list.
int index = listPreference.findIndexOfValue(stringValue);
// Set the summary to reflect the new value, if possible
preference.setSummary(index >= 0 ? listPreference.getEntries()[index] : null);
} else {
// For all other preferences, set the summary to the value's
// simple string representation.
preference.setSummary(stringValue);
}
return true;
};
/**
* Binds a preference's summary to its value. When the preference's value is changed,
* its summary (text below the preference title) is updated to reflect the value.
* The summary is also updated upon calling this method. The exact display format is
* dependent on the type of preference.
*
* @see #sBindPreferenceSummaryToValueListener
*/
public static void bindSummaryToValue(Preference preference) {
// Set the listener to watch for value changes.
preference.setOnPreferenceChangeListener(sBindPreferenceSummaryToValueListener);
// Trigger the listener immediately with the preference's current value.
sBindPreferenceSummaryToValueListener.onPreferenceChange(preference,
PreferenceManager
.getDefaultSharedPreferences(preference.getContext())
.getString(preference.getKey(), ""));
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/prefs/SyncPrefs.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.prefs;
import android.accounts.Account;
import android.accounts.AccountManagerFuture;
import android.app.Activity;
import android.content.ContentResolver;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.SharedPreferences.OnSharedPreferenceChangeListener;
import android.os.Bundle;
import android.widget.Toast;
import androidx.annotation.Nullable;
import androidx.preference.PreferenceFragmentCompat;
import androidx.preference.PreferenceManager;
import com.nononsenseapps.helpers.FileHelper;
import com.nononsenseapps.helpers.NnnLogger;
import com.nononsenseapps.helpers.PreferencesHelper;
import com.nononsenseapps.notepad.R;
import com.nononsenseapps.notepad.database.MyContentProvider;
import com.nononsenseapps.notepad.sync.orgsync.OrgSyncService;
public class SyncPrefs extends PreferenceFragmentCompat
implements OnSharedPreferenceChangeListener {
/**
* Used for sync on start and on change
*/
public static final String KEY_LAST_SYNC = "lastSync";
private static final int PICK_ACCOUNT_CODE = 2;
// SD sync
public static final String KEY_SD_ENABLE = "pref_sync_sd_enabled";
public static final String KEY_SD_SYNC_INFO = "pref_sdcard_sync_info";
@Override
public void onCreatePreferences(@Nullable Bundle savInstState, String rootKey) {
// Load the preferences from an XML resource
addPreferencesFromResource(R.xml.app_pref_sync);
final SharedPreferences sharedPrefs = PreferenceManager
.getDefaultSharedPreferences(this.getContext());
// Set up a listener whenever a key changes
sharedPrefs.registerOnSharedPreferenceChangeListener(this);
findPreference(KEY_SD_ENABLE).setOnPreferenceClickListener(p -> {
// if the ORG dir is inaccessible, disable SD sync
String dir = FileHelper.getUserSelectedOrgDir(this.getContext());
if (dir == null) {
PreferencesHelper.disableSdCardSync(this.getContext());
NnnLogger.warning(SyncPrefs.class, "Can't access org dir");
return false;
} else
return true;
});
// write the folder path on the summary
String orgdirpath = FileHelper.getUserSelectedOrgDir(this.getContext());
String sdInfoSummary = this.getString(R.string.directory_summary_msg, orgdirpath);
findPreference(KEY_SD_SYNC_INFO).setSummary(sdInfoSummary);
}
@Override
public void onDestroy() {
super.onDestroy();
PreferenceManager
.getDefaultSharedPreferences(this.getContext())
.unregisterOnSharedPreferenceChangeListener(this);
}
/**
* Called when a shared preference is changed, added, or removed. This
* may be called even if a preference is set to its existing value.
*
*
This callback will be run on your main thread.
*
* @param prefs The {@link SharedPreferences} that received the change.
* @param key The key of the preference that was changed, added, or removed
*/
public void onSharedPreferenceChanged(SharedPreferences prefs, String key) {
NnnLogger.debug(SyncPrefs.class, "onChanged");
final String keySyncMaster = this.getString(R.string.key_pref_sync_enabled_master);
try {
if (this.getActivity().isFinishing()) {
// Setting the summary now would crash it with
// IllegalStateException since we are not attached to a view
return;
}
// => now we can safely continue
if (KEY_SD_ENABLE.equals(key)) {
// Restart the sync service
OrgSyncService.stop(getActivity());
} else if (keySyncMaster.equals(key)) {
// TODO force stop / re-enable all (user selected) sync services
}
} catch (IllegalStateException e) {
// This is just in case the "isFinishing" wouldn't be enough
// The isFinishing will try to prevent us from doing something stupid
// This catch prevents the app from crashing if we do something stupid
}
}
@Override
public void onActivityResult(int requestCode, int resultCode, Intent data) {
if (resultCode != Activity.RESULT_OK) {
// it was cancelled by the user. Let's ignore it in both cases
return;
}
if (requestCode == PICK_ACCOUNT_CODE) {
// the user has confirmed with a valid account on the account picker
// String chosenAccountName = data.getStringExtra(AccountManager.KEY_ACCOUNT_NAME);
// then make and call something like userChoseAnAccountWithName(chosenAccountName);
}
super.onActivityResult(requestCode, resultCode, data);
}
/**
* Called when the user has selected an account when pressing the enable sync
* switch. User wants to select an account to sync with. If we get an approval,
* activate sync and set periodicity also.
*/
private void afterGettingAuthToken(AccountManagerFuture future, Account account) {
try {
NnnLogger.debug(SyncPrefs.class, "step two");
if (account != null) {
// Also mark enabled as true, as the dialog was shown from enable button
NnnLogger.debug(SyncPrefs.class, "step three: " + account.name);
SharedPreferences customSharedPreference = PreferenceManager
.getDefaultSharedPreferences(this.getContext());
customSharedPreference
.edit()
.putString("pref_key_for_the_account", account.name)
.putBoolean("pref_to_enable_this_sync", true)
.commit();
// Set it syncable
ContentResolver
.setSyncAutomatically(account, MyContentProvider.AUTHORITY, true);
ContentResolver
.setIsSyncable(account, MyContentProvider.AUTHORITY, 1);
}
} catch (Exception e) {
// OperationCanceledException:
// * if the request was canceled for any reason
// AuthenticatorException:
// * if there was an error communicating with the authenticator or
// * if the authenticator returned an invalid response or
// * if the user did not register on the api console
// IOException:
// * if the authenticator returned an error response that
// * indicates that it encountered an IOException while
// * communicating with the authentication server
String errMsg = e.getClass().getSimpleName() + ": " + e.getMessage();
Toast.makeText(this.getContext(), errMsg, Toast.LENGTH_SHORT).show();
}
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/sync/SyncAdapter.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.sync;
/**
* this used to do google tasks sync. Now it only provides constants
*/
public final class SyncAdapter {
private SyncAdapter() {}
public static final String SYNC_STARTED = "com.nononsenseapps.notepad.sync.SYNC_STARTED";
public static final String SYNC_FINISHED = "com.nononsenseapps.notepad.sync.SYNC_FINISHED";
public static final String SYNC_RESULT = "com.nononsenseapps.notepad.sync.SYNC_RESULT";
public static final int SUCCESS = 0;
public static final int LOGIN_FAIL = 1;
public static final int ERROR = 2;
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/sync/files/JSONBackup.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.sync.files;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.util.Log;
import com.nononsenseapps.helpers.DocumentFileHelper;
import com.nononsenseapps.helpers.NnnLogger;
import com.nononsenseapps.helpers.NotificationHelper;
import com.nononsenseapps.notepad.database.Notification;
import com.nononsenseapps.notepad.database.RemoteTask;
import com.nononsenseapps.notepad.database.RemoteTaskList;
import com.nononsenseapps.notepad.database.Task;
import com.nononsenseapps.notepad.database.TaskList;
import com.nononsenseapps.notepad.prefs.BackupPrefs;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.List;
public class JSONBackup {
private static final String KEY_REMINDERS = "reminders";
private static final String KEY_TASKS = "tasks";
private static final String KEY_REMOTES = "remotes";
private static final String KEY_LISTS = "lists";
private final Context context;
public JSONBackup(final Context context) {
this.context = context;
}
private List getTaskLists() {
final ArrayList taskLists = new ArrayList<>();
final Cursor c = context
.getContentResolver()
.query(TaskList.URI, TaskList.Columns.FIELDS,
null, null, TaskList.Columns.TITLE);
while (c != null && c.moveToNext()) {
taskLists.add(new TaskList(c));
}
if (c != null)
c.close();
return taskLists;
}
private List getRemotesOf(final TaskList list) {
final ArrayList remotes = new ArrayList<>();
final Cursor c = context.getContentResolver().query(RemoteTaskList.URI,
RemoteTaskList.Columns.FIELDS,
RemoteTaskList.Columns.DBID + " IS ?",
new String[] { Long.toString(list._id) },
RemoteTaskList.Columns.SERVICE);
while (c != null && c.moveToNext()) {
remotes.add(new RemoteTaskList(c));
}
if (c != null)
c.close();
return remotes;
}
private List getTasksIn(final TaskList list) {
final ArrayList tasks = new ArrayList<>();
// Reverse order because adding stuff is always done at the top
final Cursor c = context.getContentResolver().query(Task.URI,
Task.Columns.FIELDS, Task.Columns.DBLIST + " IS ?",
new String[] { Long.toString(list._id) },
Task.Columns.LEFT + " DESC");
while (c != null && c.moveToNext()) {
tasks.add(new Task(c));
}
if (c != null)
c.close();
return tasks;
}
private List getRemotesOf(final Task task) {
final ArrayList remotes = new ArrayList<>();
final Cursor c = context
.getContentResolver()
.query(RemoteTask.URI,
RemoteTask.Columns.FIELDS,
RemoteTask.Columns.DBID + " IS ?",
new String[] { Long.toString(task._id) },
RemoteTask.Columns.SERVICE);
while (c != null && c.moveToNext()) {
remotes.add(new RemoteTask(c));
}
if (c != null)
c.close();
return remotes;
}
private List getRemindersFor(final Task task) {
final ArrayList reminders = new ArrayList<>();
final Cursor c = context
.getContentResolver()
.query(Notification.URI,
Notification.Columns.FIELDS,
Notification.Columns.TASKID + " IS ?",
new String[] { Long.toString(task._id) },
Notification.Columns.TIME);
while (c != null && c.moveToNext()) {
reminders.add(new Notification(c));
}
if (c != null)
c.close();
return reminders;
}
private JSONObject getJSONBackup() throws JSONException {
final JSONArray listarray = new JSONArray();
for (final TaskList list : getTaskLists()) {
final JSONObject jsonlist = new JSONObject();
jsonlist.put(TaskList.Columns._ID, list._id);
addAllContentToJSON(list.getContent(), jsonlist);
jsonlist.put(KEY_REMOTES, getJSONRemotesFor(list));
jsonlist.put(KEY_TASKS, getJSONTasksFor(list));
// Add tasklist to array
listarray.put(jsonlist);
}
final JSONObject backup = new JSONObject();
backup.put(KEY_LISTS, listarray);
return backup;
}
private void addAllContentToJSON(final ContentValues content,
final JSONObject json) throws JSONException {
for (String key : content.keySet()) {
json.put(key, content.get(key));
}
}
private JSONArray getJSONRemotesFor(final TaskList list)
throws JSONException {
final JSONArray remotelistarray = new JSONArray();
for (final RemoteTaskList remote : getRemotesOf(list)) {
final JSONObject jsonremote = new JSONObject();
jsonremote.put(RemoteTaskList.Columns._ID, remote._id);
addAllContentToJSON(remote.getContent(), jsonremote);
remotelistarray.put(jsonremote);
}
return remotelistarray;
}
private JSONArray getJSONTasksFor(final TaskList list) throws JSONException {
final JSONArray taskarray = new JSONArray();
for (final Task task : getTasksIn(list)) {
final JSONObject jsontask = new JSONObject();
jsontask.put(Task.Columns._ID, task._id);
addAllContentToJSON(task.getContent(), jsontask);
jsontask.put(Task.Columns.LEFT, task.left);
jsontask.put(Task.Columns.RIGHT, task.right);
jsontask.put(KEY_REMOTES, getJSONRemotesFor(task));
jsontask.put(KEY_REMINDERS, getJSONRemindersFor(task));
taskarray.put(jsontask);
}
return taskarray;
}
private JSONArray getJSONRemotesFor(final Task task) throws JSONException {
final JSONArray remotetaskarray = new JSONArray();
for (final RemoteTask remote : getRemotesOf(task)) {
final JSONObject jsonremote = new JSONObject();
jsonremote.put(RemoteTask.Columns._ID, remote._id);
addAllContentToJSON(remote.getContent(), jsonremote);
remotetaskarray.put(jsonremote);
}
return remotetaskarray;
}
private JSONArray getJSONRemindersFor(final Task task) throws JSONException {
final JSONArray reminderarray = new JSONArray();
for (final Notification reminder : getRemindersFor(task)) {
final JSONObject jsonreminder = new JSONObject();
jsonreminder.put(Notification.Columns._ID, reminder._id);
addAllContentToJSON(reminder.getContent(), jsonreminder);
reminderarray.put(jsonreminder);
}
return reminderarray;
}
/**
* Backs up the entire database to a JSON file. The location and name of the
* file are hardcoded.
*/
public void writeBackup() throws JSONException, IOException, SecurityException {
// Create JSON object
final JSONObject backup = getJSONBackup();
var uri = BackupPrefs.getSelectedBackupDirUri(this.context);
// user didn't choose a folder. This is checked before this function runs
if (uri == null) throw new IOException();
var newFile = DocumentFileHelper.createBackupJsonFile(this.context);
if (newFile == null || !newFile.exists() || !newFile.canWrite()) {
// it isn't a matter of permissions, the S.A.F. doesn't need permissions
NnnLogger.error(JSONBackup.class, "Can't access documentfile");
throw new IOException();
}
String json = backup.toString(2);
DocumentFileHelper.write(json, newFile, this.context);
}
/**
* Clears the database and restores the backup. Throws exceptions on
* failure.
*/
public void restoreBackup() throws SecurityException, JSONException, IOException {
final JSONObject backup = readBackup();
// Only if backup exists will we clear the database
clearDatabase();
final JSONArray listsarray = backup.getJSONArray(KEY_LISTS);
for (int i = 0; i < listsarray.length(); i++) {
final JSONObject jsonlist = listsarray.getJSONObject(i);
final TaskList tasklist = new TaskList(jsonlist);
if (tasklist.updated != null)
tasklist.save(context, tasklist.updated);
else
tasklist.save(context);
if (!jsonlist.isNull(KEY_REMOTES)) {
restoreRemotes(tasklist, jsonlist.getJSONArray(KEY_REMOTES));
} else {
Log.d("JONAS", "Remotes was null");
}
if (!jsonlist.isNull(KEY_TASKS)) {
restoreTasks(tasklist, jsonlist.getJSONArray(KEY_TASKS));
}
}
// Schedule notifications
NotificationHelper.schedule(context);
}
private void clearDatabase() {
context.getContentResolver().delete(RemoteTask.URI, null, null);
context.getContentResolver().delete(RemoteTaskList.URI, null, null);
context.getContentResolver().delete(TaskList.URI, null, null);
context.getContentResolver().delete(Task.URI, null, null);
context.getContentResolver().delete(Notification.URI, null, null);
}
private JSONObject readBackup() throws JSONException, IOException, SecurityException {
var fileDoc = DocumentFileHelper.getSelectedBackupJsonFile(this.context);
if (fileDoc == null || !fileDoc.exists() || !fileDoc.canRead()) {
// it isn't a matter of permissions, the S.A.F. doesn't need permissions
NnnLogger.error(JSONBackup.class, "Can't access the documentfile");
throw new IOException("Can't access the documentfile");
}
InputStream inSt = this.context
.getContentResolver()
.openInputStream(fileDoc.getUri());
final StringBuilder sb = new StringBuilder();
String line;
BufferedReader reader = new BufferedReader(new InputStreamReader(inSt));
while ((line = reader.readLine()) != null) {
sb.append(line);
}
return new JSONObject(sb.toString());
}
private void restoreRemotes(final TaskList tasklist,
final JSONArray jsonArray) throws JSONException {
Log.d("JONAS", "Remote length: " + jsonArray.length());
for (int i = 0; i < jsonArray.length(); i++) {
final JSONObject json = jsonArray.getJSONObject(i);
final RemoteTaskList remote = new RemoteTaskList(json);
remote.dbid = tasklist._id;
remote.save(context);
Log.d("JONAS", "RemoteL restored: " + remote._id);
}
}
private void restoreTasks(final TaskList list, final JSONArray tasksarray)
throws JSONException {
for (int i = 0; i < tasksarray.length(); i++) {
final JSONObject jsontask = tasksarray.getJSONObject(i);
final Task task = new Task(jsontask);
task.dblist = list._id;
if (task.updated != null)
task.save(context, task.updated);
else
task.save(context);
if (!jsontask.isNull(KEY_REMOTES)) {
restoreRemotes(task, jsontask.getJSONArray(KEY_REMOTES));
}
if (!jsontask.isNull(KEY_REMINDERS)) {
restoreReminders(task, jsontask.getJSONArray(KEY_REMINDERS));
}
}
}
private void restoreRemotes(final Task task, final JSONArray jsonArray)
throws JSONException {
for (int i = 0; i < jsonArray.length(); i++) {
final JSONObject json = jsonArray.getJSONObject(i);
final RemoteTask remote = new RemoteTask(json);
remote.dbid = task._id;
remote.listdbid = task.dblist;
remote.save(context);
Log.d("JONAS", "RemoteT restored: " + remote._id);
}
}
private void restoreReminders(final Task task, final JSONArray jsonArray)
throws JSONException {
for (int i = 0; i < jsonArray.length(); i++) {
final JSONObject json = jsonArray.getJSONObject(i);
final Notification not = new Notification(json);
not.taskID = task._id;
not.save(context);
}
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/sync/googleapi/GoogleTask.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.sync.googleapi;
import com.nononsenseapps.helpers.RFC3339Date;
import com.nononsenseapps.notepad.database.RemoteTask;
import com.nononsenseapps.notepad.database.Task;
public class GoogleTask extends RemoteTask {
public static final String ID = "id";
public static final String TITLE = "title";
public static final String UPDATED = "updated";
public static final String NOTES = "notes";
public static final String STATUS = "status";
public static final String DUE = "due";
public static final String DELETED = "deleted";
public static final String COMPLETED = "completed";
public static final String NEEDSACTION = "needsAction";
public static final String PARENT = "parent";
public static final String POSITION = "position";
public static final String HIDDEN = "hidden";
// all of these should be changed to methods like getTitle() { return this.title; }
// and setTitle(String new) { this.title = new; } but as of now google task is
// not even used by the app...
public String title = null;
public String notes = null;
public String status = null;
public String dueDate = null;
public String parent = null;
public String position = null;
public boolean remotelydeleted = false;
public final String possort = "";
public GoogleTask(final Task dbTask, final String accountName) {
super();
this.service = GoogleTaskList.SERVICENAME;
account = accountName;
if (dbTask != null)
fillFrom(dbTask);
}
public void fillFrom(final Task dbTask) {
title = dbTask.title;
notes = dbTask.note;
dueDate = RFC3339Date.asRFC3339ZuluDate(dbTask.due);
status = dbTask.completed != null ? GoogleTask.COMPLETED
: GoogleTask.NEEDSACTION;
remotelydeleted = false;
deleted = null;
dbid = dbTask._id;
listdbid = dbTask.dblist;
}
/**
* Returns true if the task has the same remote id or same database id.
*/
@Override
public boolean equals(Object o) {
boolean equal = false;
if (o instanceof GoogleTask task) {
// It's a list!
if (dbid != -1 && dbid.equals(task.dbid)) {
equal = true;
}
if (remoteId != null && remoteId.equals(task.remoteId)) {
equal = true;
}
}
return equal;
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/sync/googleapi/GoogleTaskList.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.sync.googleapi;
import com.nononsenseapps.notepad.database.RemoteTaskList;
import com.nononsenseapps.notepad.database.TaskList;
public class GoogleTaskList extends RemoteTaskList {
public static final String SERVICENAME = "googletasks";
public String title = null;
public GoogleTaskList(final TaskList dbList, final String accountName) {
super();
this.title = dbList.title;
this.dbid = dbList._id;
this.account = accountName;
this.service = SERVICENAME;
}
public GoogleTaskList(final Long dbid, final String remoteId, final Long updated, final String account) {
super(dbid, remoteId, updated, account);
this.service = SERVICENAME;
}
/**
* Returns true if the TaskList has the same remote id or the same database
* id.
*/
@Override
public boolean equals(Object o) {
boolean equal = false;
if (o instanceof GoogleTaskList list) {
// It's a list!
if (dbid != -1 && dbid.equals(list.dbid)) {
equal = true;
}
if (remoteId != null && remoteId.equals(list.remoteId)) {
equal = true;
}
}
return equal;
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/sync/orgsync/BackgroundSyncScheduler.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.sync.orgsync;
import android.app.AlarmManager;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.os.SystemClock;
import androidx.annotation.NonNull;
import com.nononsenseapps.helpers.NnnLogger;
public class BackgroundSyncScheduler extends BroadcastReceiver {
// Unique ID for schedule
private final static int scheduleCode = 2832;
public BackgroundSyncScheduler() {}
@Override
public void onReceive(Context context, @NonNull Intent intent) {
NnnLogger.debug(BackgroundSyncScheduler.class,
"Received intent with action = " + intent.getAction());
final boolean enabled = OrgSyncService.areAnyEnabled(context);
if (enabled && Intent.ACTION_RUN.equals(intent.getAction())) {
// Run sync
OrgSyncService.start(context);
} else {
scheduleSync(context);
}
}
/**
* Schedule a synchronization for later.
*/
public static void scheduleSync(final Context context) {
final AlarmManager alarmManager = (AlarmManager) context
.getSystemService(Context.ALARM_SERVICE);
final Intent action = new Intent(context, BackgroundSyncScheduler.class) // EXPLICIT intent
.setAction(Intent.ACTION_RUN);
final PendingIntent operation = PendingIntent.getBroadcast(context, scheduleCode, action,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
if (OrgSyncService.areAnyEnabled(context)) {
// Schedule syncs
// Repeat at inexact intervals and do NOT wake the device up.
alarmManager.setInexactRepeating(AlarmManager.ELAPSED_REALTIME,
SystemClock.elapsedRealtime(),
AlarmManager.INTERVAL_HALF_HOUR, // gets ignored anyway
operation);
} else {
// Remove schedule
alarmManager.cancel(operation);
}
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/sync/orgsync/DBSyncBase.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.sync.orgsync;
import android.content.ContentResolver;
import android.content.Context;
import android.database.Cursor;
import android.util.Log;
import android.util.Pair;
import com.nononsenseapps.notepad.database.RemoteTask;
import com.nononsenseapps.notepad.database.RemoteTaskList;
import com.nononsenseapps.notepad.database.Task;
import com.nononsenseapps.notepad.database.TaskList;
import org.cowboyprogrammer.org.OrgFile;
import org.cowboyprogrammer.org.OrgNode;
import org.cowboyprogrammer.org.OrgTimestamp;
import org.cowboyprogrammer.org.parser.RegexParser;
import java.io.BufferedReader;
import java.io.IOException;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
/**
* This class is suitable for synchronizers to inherit from. It contains the
* necessary logic to handle the database communication and conversions.
*/
public abstract class DBSyncBase implements SynchronizerInterface {
protected final Context context;
private final ContentResolver resolver;
public DBSyncBase(final Context context) {
this.context = context;
this.resolver = context.getContentResolver();
}
/**
* Reads the database and the OrgFile. Returns the matching Tasks and Nodes.
*
* TODO
* For gods' sake, test me!
*
* @param file The OrgFile containing all the tasks
* @param list The TaskList corresponding to the OrgFile.
* @return A list of all task-related objects necessary for synchronization.
*/
protected List>> getNodesAndDBEntries(
OrgFile file, TaskList list) {
final List>> result = new ArrayList<>();
final HashMap tasks = getTasks(list);
final HashMap remotes = getValidRemoteTasks(list);
final List remotesDeleted = getInvalidRemoteTasks(list);
final HashMap nodes = getNodes(file);
// Start with tasks
for (long dbid : tasks.keySet()) {
Task task = tasks.get(dbid);
RemoteTask remote = remotes.remove(dbid);
OrgNode node = null;
// Can be null
if (remote != null) {
node = nodes.remove(remote.remoteId.toUpperCase());
}
result.add(new Pair<>(node,
new Pair<>(remote, task)));
}
// Follow with remaining remotes where task is null
for (RemoteTask remote : remotes.values()) {
OrgNode node = nodes.remove(remote.remoteId.toUpperCase());
result.add(new Pair<>(node, new Pair<>(remote, null)));
}
for (RemoteTask remote : remotesDeleted) {
OrgNode node = nodes.remove(remote.remoteId.toUpperCase());
result.add(new Pair<>(node, new Pair<>(remote, null)));
}
// Last, nodes with no database connections
for (OrgNode node : nodes.values()) {
result.add(new Pair<>(node, new Pair<>(/*task=*/ null, /*remote=*/ null)));
}
return result;
}
private HashMap getNodes(final OrgFile file) {
final HashMap map = new HashMap<>();
for (OrgNode node : file.getSubNodes()) {
addNodeToMap(node, map);
}
return map;
}
/**
* By convention, all generated ids are stored in uppercase.
*/
private void addNodeToMap(final OrgNode node,
final HashMap map) {
String key = OrgConverter.getNodeId(node);
Log.d(Synchronizer.TAG, "Key: " + key + ", node: " + node.getComments());
if (key == null) {
// This key won't necessarily be used later.
key = OrgConverter.generateId();
}
map.put(key.toUpperCase(), node);
for (OrgNode subnode : node.getSubNodes()) {
addNodeToMap(subnode, map);
}
}
private HashMap getValidRemoteTasks(final TaskList list) {
final HashMap map = new HashMap<>();
try (Cursor c = resolver.query(
RemoteTask.URI,
RemoteTask.Columns.FIELDS,
RemoteTask.Columns.SERVICE + " IS ? AND "
+ RemoteTask.Columns.ACCOUNT + " IS ? AND "
+ RemoteTask.Columns.LISTDBID + " IS ? AND "
+ RemoteTask.Columns.DBID + " > 0",
new String[] { getServiceName(), getAccountName(),
Long.toString(list._id) }, null)) {
while (c.moveToNext()) {
RemoteTask remote = new RemoteTask(c);
map.put(remote.dbid, remote);
}
}
return map;
}
/**
* These remote tasks are no longer connected to a task.
* This typically happens when a task is
* deleted or moved to another list.
*/
private List getInvalidRemoteTasks(final TaskList list) {
final ArrayList remoteList = new ArrayList<>();
try (Cursor c = resolver.query(
RemoteTask.URI,
RemoteTask.Columns.FIELDS,
RemoteTask.Columns.SERVICE + " IS ? AND "
+ RemoteTask.Columns.ACCOUNT + " IS ? AND "
+ RemoteTask.Columns.LISTDBID + " IS ? AND "
+ RemoteTask.Columns.DBID + " < 1",
new String[] { getServiceName(), getAccountName(),
Long.toString(list._id) }, null)) {
while (c.moveToNext()) {
RemoteTask remote = new RemoteTask(c);
remoteList.add(remote);
}
}
return remoteList;
}
private HashMap getTasks(final TaskList list) {
final HashMap map = new HashMap<>();
try (Cursor c = resolver.query(Task.URI, Task.Columns.FIELDS,
Task.Columns.DBLIST + " IS ?",
new String[] { Long.toString(list._id) }, null)) {
while (c.moveToNext()) {
Task task = new Task(c);
map.put(task._id, task);
}
}
return map;
}
/**
* Reads the database and the remote source.
*
* @return The matching TaskList and OrgFiles.
*/
protected List>> getFilesAndDBEntries()
throws IOException, ParseException {
final List>> result = new ArrayList<>();
// get all lists
final HashMap lists = getLists();
// get all db entries
final HashMap remotes = getRemoteTaskLists();
// get all files
final HashSet filenames = getRemoteFilenames();
for (String filename : filenames) {
Log.d(Synchronizer.TAG, "Get Filename: " + filename);
}
// Construct pairs from lists first. This removes entries as it goes.
for (Long dbid : lists.keySet()) {
TaskList list = lists.get(dbid);
RemoteTaskList remote = remotes.remove(dbid);
OrgFile file = null;
// Can be null
if (remote != null && filenames.remove(remote.remoteId)) {
final BufferedReader br = getRemoteFile(remote.remoteId);
if (br != null) {
file = OrgFile.createFromBufferedReader(
new RegexParser(), remote.remoteId, br);
}
}
// list title, if available
String l = list == null ? null : list.title;
String r = null;
if (remote != null) r = remote.remoteId;
String f = null;
if (file != null) f = file.getFilename();
Log.d(Synchronizer.TAG, "Pair:" + l + ", " + r + ", " + f);
result.add(new Pair<>(file, new Pair<>(remote, list)));
}
// Add remotes that no longer have a list
for (RemoteTaskList remote : remotes.values()) {
OrgFile file = null;
// Can be null
if (remote != null && filenames.remove(remote.remoteId)) {
final BufferedReader br = getRemoteFile(remote.remoteId);
if (br != null) {
file = OrgFile.createFromBufferedReader(
new RegexParser(), remote.remoteId, br);
}
}
String r = null;
if (remote != null)
r = remote.remoteId;
String f = null;
if (file != null)
f = file.getFilename();
Log.d(Synchronizer.TAG, "Pair:" + "(null)" + ", " + r + ", " + f);
result.add(new Pair<>(file,
new Pair<>(remote, null)));
}
// Add files that do not exist in database
for (String filename : filenames) {
OrgFile file = null;
final BufferedReader br = getRemoteFile(filename);
if (br != null) {
file = OrgFile.createFromBufferedReader(new RegexParser(), filename, br);
}
String f;
// An obvious precaution. If everything is null, there's nothing to add.
if (file != null) {
f = file.getFilename();
Log.d(Synchronizer.TAG, "Pair:" + "(null)" + ", " + "(null)" + ", " + f);
result.add(new Pair<>(file, new Pair<>(/*remote=*/null, /*list=*/null)));
}
}
return result;
}
/**
* @return a map from list-dbid to RemoteTaskList
*/
private HashMap getRemoteTaskLists() {
final HashMap map = new HashMap<>();
try (Cursor c = resolver.query(RemoteTaskList.URI,
RemoteTaskList.Columns.FIELDS, RemoteTaskList.Columns.SERVICE
+ " IS ? AND " + RemoteTask.Columns.ACCOUNT + " IS ?",
new String[] { getServiceName(), getAccountName() }, null)) {
while (c.moveToNext()) {
RemoteTaskList remote = new RemoteTaskList(c);
Log.d(Synchronizer.TAG, "Get remote: " + remote.remoteId);
map.put(remote.dbid, remote);
}
}
return map;
}
/**
* @return a map from list-dbid to TaskList
*/
private HashMap getLists() {
final HashMap map = new HashMap<>();
try (Cursor c = resolver.query(TaskList.URI, TaskList.Columns.FIELDS,
null, null, null)) {
while (c.moveToNext()) {
TaskList list = new TaskList(c);
Log.d(Synchronizer.TAG, "Get list: " + list.title);
map.put(list._id, list);
}
}
return map;
}
/**
* Make sure notifications are synchronized from node to database.
*/
protected void replaceNotifications(final Task task, final OrgNode node) {
// TODO Auto-generated method stub
// Remove existing notifications
// Add new notifications
for (OrgTimestamp ts : node.getTimestamps()) {
if (!ts.isInactive()) {
}
}
}
protected boolean wasRenamed(final TaskList list, final OrgFile file) {
return !(OrgConverter.getTitleAsFilename(list)).equals(file.getFilename());
}
/**
* (re)Names a file to match the DB version's current name.
*
* @param list Current version in the database
* @param dbEntry Current remote version in the database which will also be
* renamed.
* @param file File to rename.
*/
protected void renameFile(final TaskList list,
final RemoteTaskList dbEntry, final OrgFile file) {
if (list.title != null && !list.title.isEmpty()) {
file.setFilename(OrgConverter.getTitleAsFilename(list));
}
dbEntry.remoteId = file.getFilename();
dbEntry.save(context);
}
/**
* Delete remote versions of tasks to current service.
*
* @param listdbid List they belong to.
*/
private void deleteRemoteTasksIn(final long listdbid) {
context.getContentResolver().delete(
RemoteTask.URI,
RemoteTask.Columns.SERVICE + " IS ? AND " + RemoteTask.Columns.ACCOUNT +
" IS ? AND " + RemoteTask.Columns.LISTDBID + " IS ?",
new String[] { getServiceName(), getAccountName(), Long.toString(listdbid) });
}
/**
* Deletes a list and all tasks and related entries (to current service).
* Call this when remote file has been deleted.
*
* @param list List to delete. Can be null.
* @param dbEntry RemoteEntry in DB to delete. Can be null.
*/
protected void deleteLocal(final TaskList list, final RemoteTaskList dbEntry) {
long listdbid = -1;
if (list != null) {
list.delete(context);
listdbid = list._id;
}
if (dbEntry != null) {
dbEntry.delete(context);
listdbid = dbEntry.dbid;
}
// Tasks are deleted automatically, but not the
// remote-versions
deleteRemoteTasksIn(listdbid);
}
/**
* Deletes a task and dbEntry from database.
*
* @param task Task to delete, can be null.
* @param dbEntry dbEntry to delete, can be null.
*/
protected void deleteLocal(final Task task, final RemoteTask dbEntry) {
if (task != null) {
task.delete(context);
}
if (dbEntry != null) {
dbEntry.delete(context);
}
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/sync/orgsync/Monitor.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.sync.orgsync;
/**
* An interface which defines a "Monitor". A monitor is an object which
* monitors a specific sync source for changes, such as a FileMonitor.
*/
public interface Monitor {
/**
* Start monitoring. Call handler on changes.
*/
void startMonitor(final OrgSyncService.SyncHandler handler);
/**
* Pausing, it might be restarted later.
*/
void pauseMonitor();
/**
* Service is destroying itself. Remove any references.
*/
void terminate();
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/sync/orgsync/OrgConverter.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.sync.orgsync;
import android.annotation.SuppressLint;
import androidx.annotation.Nullable;
import com.nononsenseapps.notepad.database.Notification;
import com.nononsenseapps.notepad.database.RemoteTask;
import com.nononsenseapps.notepad.database.RemoteTaskList;
import com.nononsenseapps.notepad.database.Task;
import com.nononsenseapps.notepad.database.TaskList;
import org.cowboyprogrammer.org.OrgFile;
import org.cowboyprogrammer.org.OrgNode;
import org.cowboyprogrammer.org.OrgTimestamp;
import java.util.Calendar;
import java.util.Date;
import java.util.List;
import java.util.Random;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* This class handles conversion from the internal database format to org-mode
* fileformat
*/
public class OrgConverter {
private static final String LISTSTYLECOMMENT = "# NONSENSESTYLE: ";
private static final String LISTSORTCOMMENT = "# NONSENSESORTING: ";
private static final String TASKNODEID = "# NONSENSEID: ";
private static final Pattern PatternStyle = Pattern.compile(
"#\\s*NONSENSESTYLE:\\s*(.+)\\s*?", Pattern.CASE_INSENSITIVE);
private static final Pattern PatternSorting = Pattern
.compile("#\\s*NONSENSESORTING:\\s*(.+)\\s*?",
Pattern.CASE_INSENSITIVE);
// Ending white space used when removed
private static final String NonsenseIdPattern = "#\\s*NONSENSEID:\\s*(\\w+)\\s*";
private static final Pattern PatternId = Pattern.compile(NonsenseIdPattern,
Pattern.CASE_INSENSITIVE);
private static final String TAG = "OrgConverter";
private static Random rand;
/**
* Generates an id for RemoteTask(List) objects.
*/
public static String generateId() {
final int len = 8;
if (rand == null) {
rand = new Random();
}
String hex = Integer.toHexString(rand.nextInt());
// Pad with zeros if too short
while (hex.length() < len) {
hex = "0".concat(hex);
}
return hex.substring(0, len);
}
/**
* Fill in all the properties of the file that should go in the TaskList
* object.
*/
public static void toListFromFile(final TaskList list, final OrgFile file) {
// Minus .org extension
list.title = file.getFilename().substring(0,
file.getFilename().length() - 4);
list.sorting = getListSortingFromMeta(file);
list.listtype = getListTypeFromMeta(file);
}
/**
* Reads comment section of file. Returns null if not found.
*/
public static String getListTypeFromMeta(final OrgFile file) {
final Matcher m = PatternStyle.matcher(file.getComments());
if (m.find()) {
return m.group(1);
} else {
return null;
}
}
/**
* Reads comment section of file. Returns null if not found.
*/
public static String getListSortingFromMeta(final OrgFile file) {
final Matcher m = PatternSorting.matcher(file.getComments());
if (m.find()) {
return m.group(1);
} else {
return null;
}
}
/**
* Fill in all the properties of the file that should go in the
* RemoteTaskList object.
*/
public static void toRemoteFromFile(final RemoteTaskList entry,
final OrgFile file) {
entry.remoteId = file.getFilename();
RemoteTaskListFile.setSorting(entry, getListSortingFromMeta(file));
RemoteTaskListFile.setListType(entry, getListTypeFromMeta(file));
entry.updated = Calendar.getInstance().getTimeInMillis();
}
/**
* Fill in all the properties of the node that should go in the Task object.
*/
public static void toTaskFromNode(final Task task, final OrgNode node) {
task.title = node.getTitle();
task.due = getDeadline(node);
task.completed = getCompleted(node);
task.note = node.getBody();
/*
* It's not possible to differentiate if the user added a trailing
* newline or the sync logic did. I will assume that the sync logic did.
*/
if (task.note != null && task.note.endsWith("\n")) {
task.note = task.note.substring(0, task.note.length() - 1);
}
}
/**
* Fill in all the properties of the nodes from the task object.
*/
public static void toNodeFromTask(final Task task, final OrgNode node) {
node.setLevel(1);
node.setTitle(task.title);
node.setBody(task.note);
setTodo(node, task.completed);
removeTimestamps(node);
setDeadline(node, task.due);
}
private static void setNotifications(final OrgNode node, final List reminders) {
if (reminders == null)
return;
for (Notification reminder : reminders) {
if (reminder.radius == null && reminder.time != null) {
OrgTimestamp ts = new OrgTimestamp(reminder.time, true);
ts.setInactive(false);
node.getTimestamps().add(ts);
}
}
}
/**
* Returns NOW as completed if node is DONE. Else null.
*/
private static Long getCompleted(final OrgNode node) {
if ("DONE".equals(node.getTodo())) {
return new Date().getTime();
} else {
return null;
}
}
private static void setTodo(final OrgNode node, final Long completed) {
if (completed == null) {
node.setTodo("TODO");
} else {
node.setTodo("DONE");
}
}
/**
* Return the (first) deadline of the object, or null
*/
public static Long getDeadline(final OrgNode node) {
for (OrgTimestamp ts : node.getTimestamps()) {
if (OrgTimestamp.Type.DEADLINE == ts.getType()) {
return ts.getDate().toDate().getTime();
}
}
return null;
}
private static void removeTimestamps(final OrgNode node) {
node.getTimestamps().clear();
}
public static void setDeadline(final OrgNode node, final Long due) {
node.getTimestamps().clear();
// Add deadline if not null
if (due != null) {
OrgTimestamp ts = new OrgTimestamp(due, false);
ts.setType(OrgTimestamp.Type.DEADLINE);
node.getTimestamps().add(ts);
}
}
/**
* Remove a possible comment containing a nonsenseid
*/
private static String getOrgBodySansId(final OrgNode node) {
return node.getOrgBody().replaceFirst(NonsenseIdPattern, "");
}
/**
* Fill in all the properties of the node that should go in the RemoteTask
* object. If no ID is set, then an ID is added to both the entry and the
* node. This method returns true if an id was added to the node, and it
* should be updated in file.
*/
public static boolean toRemoteFromNode(final RemoteTask dbEntry,
final OrgNode node) {
boolean addedToNode = false;
if (dbEntry.remoteId == null) {
String id = getNodeId(node);
if (id == null) {
id = generateId();
addIdToNode(id, node);
addedToNode = true;
}
dbEntry.remoteId = id;
}
dbEntry.updated = Calendar.getInstance().getTimeInMillis();
RemoteTaskNode.setTitle(dbEntry, node.getTitle());
RemoteTaskNode.setBody(dbEntry, node.getBody());
final Long t = getDeadline(node);
String s = null;
if (t != null)
s = Long.toString(t);
RemoteTaskNode.setDueTime(dbEntry, s);
RemoteTaskNode.setTodo(dbEntry, node.getTodo());
return addedToNode;
}
/**
* Add an id to the meta-section of a node.
*/
@SuppressLint("DefaultLocale")
private static void addIdToNode(final String id, final OrgNode node) {
node.setComments(TASKNODEID + id.toUpperCase() + "\n");
}
/**
* Set the meta section to be only the id. This does not overwrite rest of
* comments since they are stored in the task and sent to the body.
*/
@SuppressLint("DefaultLocale")
public static void toNodeFromRemote(final OrgNode node,
final RemoteTask dbEntry) {
node.setComments(TASKNODEID + dbEntry.remoteId.toUpperCase() + "\n");
}
/**
* Returns the id from the meta-section, if present. Null otherwise.
*/
@SuppressLint("DefaultLocale")
@Nullable
public static String getNodeId(final OrgNode node) {
final Matcher m = PatternId.matcher(node.getComments());
if (m.find()) {
return m.group(1).toUpperCase();
} else {
return null;
}
}
/**
* Fills in the information in the file from the list
*/
public static void toFileFromList(final TaskList list, final OrgFile file) {
setSortingOnFile(list, file);
setListTypeOnFile(list, file);
}
public static String getTitleAsFilename(TaskList list) {
return list.title + ".org";
}
public static void setListTypeOnFile(TaskList list, OrgFile file) {
final StringBuilder comments = new StringBuilder();
if (list.listtype != null) {
comments.append(LISTSTYLECOMMENT)
.append(list.listtype)
.append("\n");
}
comments.append(PatternStyle.matcher(file.getComments()).replaceAll
("").trim());
file.setComments(comments.toString());
}
public static void setSortingOnFile(final TaskList list, final OrgFile file) {
final StringBuilder comments = new StringBuilder();
if (list.sorting != null) {
comments.append(LISTSORTCOMMENT).append(list.sorting).append("\n");
}
comments.append(PatternSorting.matcher(file.getComments()).replaceAll
("").trim());
file.setComments(comments.toString());
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/sync/orgsync/OrgProvider.java
================================================
package com.nononsenseapps.notepad.sync.orgsync;
import android.content.ContentProvider;
import android.content.ContentValues;
import android.content.UriMatcher;
import android.database.Cursor;
import android.net.Uri;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.nononsenseapps.helpers.FileHelper;
import com.nononsenseapps.helpers.PreferencesHelper;
import org.cowboyprogrammer.org.OrgFile;
import org.cowboyprogrammer.org.OrgNode;
import org.cowboyprogrammer.org.parser.OrgParser;
import org.cowboyprogrammer.org.parser.RegexParser;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.text.ParseException;
public class OrgProvider extends ContentProvider {
public static final String KEY_TITLE = "title";
public static final String AUTHORITY = "com.nononsenseapps.notepad.orgprovider";
public static final String SCHEME = "content://";
public static final Uri BASE_URI = Uri.parse(SCHEME + AUTHORITY + "/file");
private static final UriMatcher uriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
private static final int CODE_FILE = 101;
private static final int CODE_FILE_ID = 102;
static {
uriMatcher.addURI(AUTHORITY, "file", CODE_FILE);
uriMatcher.addURI(AUTHORITY, "file/*", CODE_FILE_ID);
}
private static final OrgParser orgParser = new RegexParser();
public OrgProvider() {}
private int deleteItem(Uri uri) {
final String title = uri.getLastPathSegment();
final String filename = title + ".org";
final File file = new File(getDir(), filename);
final String itemid = getFragment(uri);
try {
OrgFile orgFile = OrgFile.createFromFile(orgParser, file);
OrgNode toDelete = null;
for (OrgNode node : orgFile.getSubNodes()) {
String nodeId = OrgConverter.getNodeId(node);
if (nodeId != null && nodeId.equalsIgnoreCase(itemid)) {
toDelete = node;
break;
}
}
if (toDelete != null) {
orgFile.getSubNodes().remove(toDelete);
writeToFile(file, orgFile);
return 1;
}
} catch (IOException | ParseException e) {
throw new RuntimeException(e);
}
return -1;
}
private static void writeToFile(File file, OrgFile orgFile) throws IOException {
final BufferedWriter bw = new BufferedWriter(new FileWriter(file));
bw.write(orgFile.treeToString());
bw.close();
}
/**
* @return 1 = the number of items affected
*/
private int deleteFile(Uri uri) {
final String title = uri.getLastPathSegment();
final String filename = title + ".org";
final File file = new File(getDir(), filename);
if (file.isFile() && file.exists()) {
if (file.delete()) {
return 1;
}
}
throw new RuntimeException("Failed to delete " + file);
}
@Nullable
private String getFragment(@NonNull Uri uri) {
String fragment = uri.getFragment();
if (fragment != null && fragment.isEmpty()) {
throw new UnsupportedOperationException("Empty URI fragments are now allowed");
}
return fragment;
}
private Uri insertItem(Uri uri, ContentValues values) {
// todo
throw new UnsupportedOperationException("Not yet implemented");
}
private Uri insertFile(Uri uri, ContentValues values) {
final String title = values.getAsString(KEY_TITLE);
if (title.contains("/")) {
throw new IllegalArgumentException("Filenames cannot contain slashes");
}
if (title.isEmpty()) {
throw new IllegalArgumentException("Filenames cannot be empty");
}
final String filename = title + ".org";
final File file = new File(getDir(), filename);
try {
if (!file.createNewFile()) {
throw new IllegalArgumentException("Failed to create file: " + filename);
}
} catch (IOException e) {
throw new RuntimeException(e);
}
return Uri.withAppendedPath(BASE_URI, title);
}
private String getDir() {
return FileHelper.getUserSelectedOrgDir(getContext());
}
@Override
public boolean onCreate() {
return true;
}
@Override
public Cursor query(@NonNull Uri uri, String[] projection, String selection,
String[] selectionArgs, String sortOrder) {
if (!PreferencesHelper.isSdSyncEnabled(getContext())) {
return null;
}
// TODO: Implement this to handle query requests from clients.
// Nullable
String fragment = getFragment(uri);
switch (uriMatcher.match(uri)) {
case CODE_FILE -> {
return queryFiles(uri, projection, selection, selectionArgs, sortOrder);
}
case CODE_FILE_ID -> {
return fragment == null ?
queryFile(uri, projection, selection, selectionArgs, sortOrder) :
queryItem(uri, projection, selection, selectionArgs, sortOrder);
}
}
throw new UnsupportedOperationException("Not yet implemented");
}
@Override
public String getType(@NonNull Uri uri) {
throw new UnsupportedOperationException("Not yet implemented");
}
@Override
public Uri insert(@NonNull Uri uri, ContentValues values) {
if (!PreferencesHelper.isSdSyncEnabled(getContext())) {
return null;
}
// TODO: Implement this to handle requests to insert a new row.
switch (uriMatcher.match(uri)) {
case CODE_FILE -> {
return insertFile(uri, values);
}
case CODE_FILE_ID -> {
return insertItem(uri, values);
}
}
throw new UnsupportedOperationException("Not yet implemented");
}
@Override
public int delete(@NonNull Uri uri, String selection, String[] selectionArgs) {
if (!PreferencesHelper.isSdSyncEnabled(getContext())) {
return -1;
}
// Implement this to handle requests to delete one or more rows.
String fragment = getFragment(uri);
if (uriMatcher.match(uri) == CODE_FILE_ID) {
return fragment == null ? deleteFile(uri) : deleteItem(uri);
}
throw new UnsupportedOperationException("Delete not supported for " + uri);
}
@Override
public int update(@NonNull Uri uri, ContentValues values, String selection,
String[] selectionArgs) {
if (!PreferencesHelper.isSdSyncEnabled(getContext())) {
return -1;
}
// TODO: Implement this to handle requests to update one or more rows.
throw new UnsupportedOperationException("Not yet implemented");
}
private Cursor queryItem(Uri uri, String[] projection, String selection,
String[] selectionArgs, String sortOrder) {
throw new UnsupportedOperationException("Not yet implemented");
}
private Cursor queryFile(Uri uri, String[] projection, String selection,
String[] selectionArgs, String sortOrder) {
throw new UnsupportedOperationException("Not yet implemented");
}
private Cursor queryFiles(Uri uri, String[] projection, String selection,
String[] selectionArgs, String sortOrder) {
throw new UnsupportedOperationException("Not yet implemented");
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/sync/orgsync/OrgSyncService.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.sync.orgsync;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.database.ContentObserver;
import android.net.Uri;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.IBinder;
import android.os.Looper;
import android.os.Message;
import android.os.Process;
import androidx.annotation.NonNull;
import androidx.preference.PreferenceManager;
import com.nononsenseapps.helpers.NnnLogger;
import com.nononsenseapps.helpers.PreferencesHelper;
import com.nononsenseapps.notepad.BuildConfig;
import com.nononsenseapps.notepad.database.Task;
import com.nononsenseapps.notepad.database.TaskList;
import com.nononsenseapps.notepad.prefs.SyncPrefs;
import com.nononsenseapps.notepad.sync.SyncAdapter;
import java.io.IOException;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.Calendar;
public class OrgSyncService extends Service {
public static final String ACTION_START = BuildConfig.APPLICATION_ID + ".sync.START";
public static final String ACTION_PAUSE = BuildConfig.APPLICATION_ID + ".sync.PAUSE";
// Msg arguments
public static final int TWO_WAY_SYNC = 1;
public static final int SYNC_QUEUE = 2;
public static final int SYNC_RUN = 3;
private static final int DELAY_MSECS = 30000;
private SyncHandler serviceHandler;
private final ArrayList monitors;
private final ArrayList synchronizers;
public static void start(Context context) {
if (!PreferencesHelper.isSincEnabledAtAll(context)) {
// not starting: sync is disabled in the prefs
return;
}
context.startService(new Intent(context, OrgSyncService.class)
.setAction(ACTION_START));
}
// TODO this service crashes in API 23 - default image on github
public static void pause(Context context) {
context.startService(new Intent(context, OrgSyncService.class)
.setAction(ACTION_PAUSE));
}
public static void stop(Context context) {
context.stopService(new Intent(context, OrgSyncService.class));
}
public static boolean areAnyEnabled(Context context) {
if (!PreferencesHelper.isSincEnabledAtAll(context)) return false;
if (!PreferencesHelper.isSdSyncEnabled(context)) return false;
return true;
}
public OrgSyncService() {
monitors = new ArrayList<>();
synchronizers = new ArrayList<>();
}
/**
* Will only return Synchronizers which have been configured.
*
* @return configured Synchronizers
*/
public ArrayList getSynchronizers() {
ArrayList syncers = new ArrayList<>();
// Try SD
SynchronizerInterface sd = new SDSynchronizer(this);
if (sd.isConfigured()) {
syncers.add(sd);
}
// TODO if we add another synchronization service, add code here
return syncers;
}
@Override
public void onCreate() {
// Start up the thread running the service. Note that we create a
// separate thread because the service normally runs in the process's
// main thread, which we don't want to block. We also make it
// background priority so CPU-intensive work will not disrupt our UI.
HandlerThread thread = new HandlerThread("ServiceStartArguments",
Process.THREAD_PRIORITY_BACKGROUND);
thread.start();
// Get the HandlerThread's Looper and use it for our Handler
Looper serviceLooper = thread.getLooper();
serviceHandler = new SyncHandler(serviceLooper);
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
if (intent != null && intent.getAction() != null &&
ACTION_PAUSE.equals(intent.getAction())) {
pause();
} else {
final Message msg = serviceHandler.obtainMessage();
msg.arg1 = TWO_WAY_SYNC;
serviceHandler.sendMessage(msg);
}
// If we get killed, after returning from here, restart
return START_STICKY;
}
private void pause() {
// Pause monitors
for (Monitor monitor : monitors) {
monitor.pauseMonitor();
}
}
@Override
public void onDestroy() {
// Unregister observers
for (Monitor monitor : monitors) {
monitor.terminate();
}
}
@Override
public IBinder onBind(Intent intent) {
return null;
}
// Handler that receives messages from the thread
public final class SyncHandler extends Handler {
private int changeId = 0;
private int lastChangeId;
public SyncHandler(Looper looper) {
super(looper);
}
public void onMonitorChange() {
NnnLogger.debug(OrgSyncService.class, "OnMonitorChange");
// Increment the changeId
changeId++;
// First queue the operation
final Message q = obtainMessage();
q.arg1 = SYNC_QUEUE;
q.arg2 = changeId;
sendMessage(q);
// Next, schedule a run in a short delay.
// Only the run number matching a queue number will run (last one)
final Message r = obtainMessage();
r.arg1 = SYNC_RUN;
r.arg2 = changeId;
sendMessageDelayed(r, DELAY_MSECS);
}
@Override
public void handleMessage(@NonNull Message msg) {
if (synchronizers.isEmpty()) {
synchronizers.addAll(getSynchronizers());
}
// Get monitors if empty
if (monitors.isEmpty()) {
// First db watcher
monitors.add(new DBWatcher(this));
// Then remote sources
for (final SynchronizerInterface syncer : synchronizers) {
final Monitor monitor = syncer.getMonitor();
if (monitor != null) {
monitors.add(monitor);
}
}
}
try {
/*
* Queues are used to delay operations until subsequent updates
* are complete.
*/
switch (msg.arg1) {
case SYNC_QUEUE:
NnnLogger.debug(OrgSyncService.class, "Sync-Queue: " + msg.arg2);
lastChangeId = msg.arg2;
break;
case SYNC_RUN:
NnnLogger.debug(OrgSyncService.class, "Sync-Run: " + msg.arg2);
if (msg.arg2 != lastChangeId) {
// Wait...
return;
}
// Falling through
case TWO_WAY_SYNC:
NnnLogger.debug(OrgSyncService.class, "Sync-Two-Way: " + msg.arg2);
// Pause monitors
for (final Monitor monitor : monitors) {
monitor.pauseMonitor();
}
// Sync each
for (final SynchronizerInterface syncer : synchronizers) {
sendBroadcast(new Intent(SyncAdapter.SYNC_STARTED));
syncer.fullSync();
syncer.postSynchronize();
}
sendBroadcast(new Intent(SyncAdapter.SYNC_FINISHED));
// Restart monitors
for (final Monitor monitor : monitors) {
monitor.startMonitor(this);
}
// Save last sync time
PreferenceManager
.getDefaultSharedPreferences(OrgSyncService.this)
.edit()
.putLong(SyncPrefs.KEY_LAST_SYNC,
Calendar.getInstance().getTimeInMillis())
.commit();
break;
}
} catch (IOException e) {
NnnLogger.exception(e);
} catch (ParseException ignored) {}
}
}
private final class DBWatcher extends ContentObserver implements Monitor {
private final SyncHandler handler;
// Giving it the service handler, onChange will run on that thread
public DBWatcher(SyncHandler handler) {
super(handler);
this.handler = handler;
}
public boolean deliverSelfNotifications() {
return true;
}
@Override
public void onChange(boolean selfChange) {
onChange(selfChange, null);
}
@Override
public void onChange(boolean selfChange, Uri uri) {
handler.onMonitorChange();
}
@Override
public void startMonitor(final SyncHandler handler) {
// Monitor both lists and tasks
getContentResolver()
.registerContentObserver(TaskList.URI, true, this);
getContentResolver()
.registerContentObserver(Task.URI, true, this);
}
@Override
public void pauseMonitor() {
getContentResolver().unregisterContentObserver(this);
}
@Override
public void terminate() {
pauseMonitor();
}
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/sync/orgsync/RemoteTaskListFile.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.sync.orgsync;
import com.nononsenseapps.notepad.database.RemoteTaskList;
public class RemoteTaskListFile {
public static String getSorting(final RemoteTaskList remote) {
return remote.field2;
}
public static String getListType(final RemoteTaskList remote) {
return remote.field3;
}
public static void setSorting(final RemoteTaskList remote, final String s) {
remote.field2 = s;
}
public static void setListType(final RemoteTaskList remote, final String s) {
remote.field3 = s;
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/sync/orgsync/RemoteTaskNode.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.sync.orgsync;
import com.nononsenseapps.notepad.database.RemoteTask;
public class RemoteTaskNode {
public static String getTitle(final RemoteTask remote) {
return remote.field2;
}
public static String getBody(final RemoteTask remote) {
return remote.field3;
}
public static String getDueTime(final RemoteTask remote) {
return remote.field4;
}
public static String getTodo(final RemoteTask remote) {
return remote.field5;
}
public static void setTitle(final RemoteTask remote, final String title) {
remote.field2 = title;
}
public static void setBody(final RemoteTask remote, final String body) {
remote.field3 = body;
}
public static void setDueTime(final RemoteTask remote, final String s) {
remote.field4 = s;
}
public static void setTodo(final RemoteTask remote, final String s) {
remote.field5 = s;
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/sync/orgsync/SDSynchronizer.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.sync.orgsync;
import android.annotation.SuppressLint;
import android.content.Context;
import android.os.FileObserver;
import android.widget.Toast;
import com.nononsenseapps.helpers.FileHelper;
import com.nononsenseapps.helpers.NnnLogger;
import com.nononsenseapps.helpers.PreferencesHelper;
import com.nononsenseapps.notepad.R;
import org.cowboyprogrammer.org.OrgFile;
import org.cowboyprogrammer.org.parser.OrgParser;
import org.cowboyprogrammer.org.parser.RegexParser;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.util.HashSet;
/**
* A synchronizer that that uses an external directory on the SD-card as
* destination.
*/
public class SDSynchronizer extends Synchronizer implements SynchronizerInterface {
private final static String SERVICENAME = "SDORG";
/**
* if SD sync is enabled
*/
protected final boolean configured;
/**
* Filesystem path of the folder where files are kept. User changeable in preferences.
*/
private final String ORG_DIR;
public SDSynchronizer(Context context) {
super(context);
// use the user-chosen folder to save ORG files
ORG_DIR = FileHelper.getUserSelectedOrgDir(context);
boolean permitted = ORG_DIR != null;
if (permitted) {
// we CAN save files in the external storage
configured = PreferencesHelper.isSdSyncEnabled(context);
} else {
configured = false;
PreferencesHelper.disableSdCardSync(context);
}
}
/**
* @return A unique name for this service. Should be descriptive, like
* SDOrg or SSHOrg.
*/
@Override
public String getAccountName() {
return SERVICENAME;
}
/**
* @return The username of the configured service. Likely an e-mail.
*/
@Override
public String getServiceName() {
return SERVICENAME;
}
/**
* Returns true if the synchronizer has been configured. This is called
* before synchronization. It will be true if the user has selected an
* account, folder etc...
*/
@Override
public boolean isConfigured() {
// TODO handle errors
if (this.configured) {
File d = new File(ORG_DIR);
if (!d.isDirectory()) d.mkdir();
return d.isDirectory();
} else {
return false;
}
}
/**
* Returns an OrgFile object with a filename set that is guaranteed to
* not already exist. Use this method to avoid having multiple objects
* pointing to the same file. Also prevents names with slashes.
*
* @param orgdesiredName The name you'd want. If it exists,
* it will be used as the base in desiredName1,
* desiredName2, etc. Limited to 99.
* @return an OrgFile guaranteed not to exist.
*/
@Override
public OrgFile getNewFile(final String orgdesiredName) throws
IOException, IllegalArgumentException {
OrgParser orgParser = new RegexParser();
// Replace slashes with underscores
String desiredName = orgdesiredName.replace("/", "_");
String filename;
for (int i = 0; i < 100; i++) {
if (i == 0) {
filename = desiredName + ".org";
} else {
filename = desiredName + i + ".org";
}
File f = new File(ORG_DIR, filename);
if (!f.exists()) {
return new OrgFile(orgParser, filename);
}
}
throw new IllegalArgumentException("Filename not accessible");
}
/**
* Replaces the file on the remote end with the given content. It needs the org file to have
* write permission, so "r" is wrong but "rw" is fine
*
* @param orgFile The file to save. Uses the filename stored in the object.
*/
@Override
public void putRemoteFile(OrgFile orgFile) throws IOException {
final String orgfname = orgFile.getFilename();
final File file = new File(ORG_DIR, orgfname);
try {
final BufferedWriter bw = new BufferedWriter(new FileWriter(file));
bw.write(orgFile.treeToString());
bw.close();
} catch (FileNotFoundException e) {
// if you upload an org file with android studio's "device file explorer" tool,
// it will be in readonly mode (only "r"), but we need it to be (also) in write
// mode (so at least "rw"), because sync is 2-way. Amaze file manager can show
// this property of files.
NnnLogger.warning(SDSynchronizer.class, "Read-only files like "
+ orgfname + " are not supported! Please set this file as " +
"writable. If you don't know how, just delete it and replace it by moving " +
"it with an android file-manager app, which will probably fix it. This " +
"caused the following exception:");
NnnLogger.exception(e);
String msg = context.getString(R.string.unsupported_readonly_file, orgfname);
Toast.makeText(this.context, msg, Toast.LENGTH_SHORT).show();
}
}
/**
* Delete the file on the remote end.
*
* @param orgFile The file to delete.
*/
@Override
public void deleteRemoteFile(OrgFile orgFile) {
if (orgFile != null && orgFile.getFilename() != null) {
final File file = new File(ORG_DIR, orgFile.getFilename());
FileHelper.tryDeleteFile(file, context);
}
}
/**
* Rename the file on the remote end.
*
* @param oldName The name it is currently stored as on the remote end.
*/
@Override
public void renameRemoteFile(String oldName, OrgFile orgFile) {
if (orgFile == null || orgFile.getFilename() == null) {
throw new NullPointerException("No new filename");
}
final File oldFile = new File(ORG_DIR, oldName);
final File newFile = new File(ORG_DIR, orgFile.getFilename());
oldFile.renameTo(newFile);
}
/**
* Returns a BufferedReader to the remote file. Null if it doesn't exist.
*
* @param filename Name of the file, without path
*/
@Override
public BufferedReader getRemoteFile(String filename) {
final File file = new File(ORG_DIR, filename);
BufferedReader br = null;
if (file.exists()) {
try {
br = new BufferedReader(new FileReader(file));
} catch (FileNotFoundException ignored) {
// br remains = null;
}
}
return br;
}
/**
* @return a set of all remote files.
*/
@SuppressLint("DefaultLocale")
@Override
public HashSet getRemoteFilenames() {
final HashSet filenames = new HashSet<>();
final File dir = new File(ORG_DIR);
final File[] files = dir.listFiles((dir1, name) -> name.toLowerCase().endsWith(".org"));
if (files != null) {
for (File f : files) {
filenames.add(f.getName());
}
}
return filenames;
}
/**
* Use this to disconnect from any services and cleanup.
*/
@Override
public void postSynchronize() {
// Nothing to do
}
@Override
public Monitor getMonitor() {
return new FileWatcher(ORG_DIR);
}
public static class FileWatcher extends FileObserver implements Monitor {
public OrgSyncService.SyncHandler handler;
public FileWatcher(String path) {
super(path, FileObserver.CREATE | FileObserver.DELETE
| FileObserver.DELETE_SELF | FileObserver.MODIFY
| FileObserver.MOVE_SELF | FileObserver.MOVED_FROM
| FileObserver.MOVED_TO);
}
@Override
public void onEvent(int event, String path) {
if (handler != null) {
handler.onMonitorChange();
}
}
@Override
public void startMonitor(final OrgSyncService.SyncHandler handler) {
this.handler = handler;
startWatching();
}
@Override
public void pauseMonitor() {
stopWatching();
handler = null;
}
@Override
public void terminate() {
stopWatching();
}
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/sync/orgsync/Synchronizer.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.sync.orgsync;
import android.content.Context;
import android.util.Pair;
import com.nononsenseapps.notepad.database.RemoteTask;
import com.nononsenseapps.notepad.database.RemoteTaskList;
import com.nononsenseapps.notepad.database.Task;
import com.nononsenseapps.notepad.database.TaskList;
import org.cowboyprogrammer.org.OrgFile;
import org.cowboyprogrammer.org.OrgNode;
import org.cowboyprogrammer.org.parser.RegexParser;
import java.io.IOException;
import java.text.ParseException;
import java.util.Calendar;
import java.util.List;
import java.util.Objects;
public abstract class Synchronizer extends DBSyncBase implements SynchronizerInterface {
public static final int SAVENONE = 0x0;
public static final int SAVEDB = 0x01;
public static final int SAVEORG = 0x10;
public static final String TAG = "OrgSynchronizer";
public Synchronizer(Context context) {
super(context);
}
/**
* Performs a full 2-way sync between the DB and the remote source.
*/
public void fullSync() throws IOException, ParseException {
// For all pairs of files and db entries
final List>> pairs = getFilesAndDBEntries();
for (Pair> pair : pairs) {
OrgFile file = pair.first;
RemoteTaskList dbEntry = pair.second.first;
TaskList list = pair.second.second;
if (dbEntry == null) {
if (file == null) {
// NEW CREATE FILE
// Create file
file = getNewFile(list.title);
OrgConverter.toFileFromList(list, file);
// Add tasks to File
syncTasks(context, list, file);
// Save file
putRemoteFile(file);
// If name was not available, rename list as well
if (!file.getFilename().equals(OrgConverter
.getTitleAsFilename(list))) {
list.title = file.getFilename().substring(0,
file.getFilename().length() - 4);
list.save(context);
}
// Create DbEntry
dbEntry = new RemoteTaskList();
dbEntry.dbid = list._id;
dbEntry.account = getAccountName();
dbEntry.service = getServiceName();
OrgConverter.toRemoteFromFile(dbEntry, file);
dbEntry.save(context);
} else {
// NEW CREATE DB LIST
// Create TaskList
list = new TaskList();
OrgConverter.toListFromFile(list, file);
list.save(context, file.lastModified());
// Create DbEntry
dbEntry = new RemoteTaskList();
dbEntry.dbid = list._id;
dbEntry.account = getAccountName();
dbEntry.service = getServiceName();
OrgConverter.toRemoteFromFile(dbEntry, file);
dbEntry.save(context);
// Now do the tasks
if (syncTasks(context, list, file)) {
// Something changed in the file.
putRemoteFile(file);
}
}
} else {
if (list == null) {
// DELETE FILE DB
deleteRemoteFile(file);
deleteLocal(/*list=*/null, dbEntry);
} else {
if (file == null) {
// DELETE DB LIST
// List and entry
deleteLocal(list, dbEntry);
} else {
// UPDATE EXISTING LIST, IF CHANGED
boolean shouldSaveFile = false;
if (wasRenamed(list, file)) {
final String oldName = file.getFilename();
renameFile(list, dbEntry, file);
renameRemoteFile(oldName, file);
}
// Merge information in database and file
final int shouldSave = merge(list, dbEntry, file);
if (0 < (shouldSave & SAVEORG)) {
// UPDATE FILE DB
shouldSaveFile = true;
}
if (0 < (shouldSave & SAVEDB)) {
// UPDATE LIST DB
list.save(context);
}
if (shouldSave != SAVENONE) {
OrgConverter.toRemoteFromFile(dbEntry, file);
dbEntry.updated = Calendar.getInstance()
.getTimeInMillis();
dbEntry.save(context);
}
// In both cases, sync tasks
if (syncTasks(context, list, file) || shouldSaveFile) {
// Something changed in the file.
putRemoteFile(file);
}
}
}
}
}
}
/**
* Merge the list and file. Fields considered are the listtype and
* listsorting which are stored as comments in the file.
*
* @return an integer denoting which should be saved. 0 for none, 0x01 for
* task, 0x10 for node. 0x11 for both.
*/
private int merge(final TaskList list, final RemoteTaskList dbEntry, final OrgFile file) {
int shouldSave = SAVENONE;
shouldSave |= mergeSorting(list, dbEntry, file);
shouldSave |= mergeListType(list, dbEntry, file);
return shouldSave;
}
private int mergeSorting(final TaskList list, final RemoteTaskList dbEntry,
final OrgFile file) {
final int shouldSave;
final String filesorting = OrgConverter.getListSortingFromMeta(file);
if (list.sorting == null
&& RemoteTaskListFile.getSorting(dbEntry) != null
|| list.sorting != null
&& !list.sorting.equals(RemoteTaskListFile.getSorting(dbEntry))) {
shouldSave = SAVEORG;
OrgConverter.setSortingOnFile(list, file);
} else if (filesorting == null
&& RemoteTaskListFile.getSorting(dbEntry) != null
|| filesorting != null
&& !filesorting.equals(RemoteTaskListFile.getSorting(dbEntry))) {
shouldSave = SAVEORG;
list.sorting = filesorting;
} else {
shouldSave = SAVENONE;
}
return shouldSave;
}
private int mergeListType(final TaskList list, final RemoteTaskList dbEntry,
final OrgFile file) {
final int shouldSave;
final String filelisttype = OrgConverter.getListTypeFromMeta(file);
if (list.listtype == null
&& RemoteTaskListFile.getListType(dbEntry) != null
|| list.listtype != null
&& !list.listtype.equals(RemoteTaskListFile.getListType(dbEntry))) {
shouldSave = SAVEORG;
OrgConverter.setListTypeOnFile(list, file);
} else if (filelisttype == null
&& RemoteTaskListFile.getListType(dbEntry) != null
|| filelisttype != null
&& !filelisttype.equals(RemoteTaskListFile.getListType(dbEntry))) {
shouldSave = SAVEORG;
list.listtype = filelisttype;
} else {
shouldSave = SAVENONE;
}
return shouldSave;
}
private boolean syncTasks(final Context context, final TaskList list, final OrgFile file) {
final List>> pairs = getNodesAndDBEntries(file, list);
boolean shouldUpdateFile = false;
OrgNode prevNode = null;
for (Pair> pair : pairs) {
OrgNode node = pair.first;
RemoteTask dbEntry = pair.second.first;
Task task = pair.second.second;
if (dbEntry == null) {
if (node == null) {
// CREATE NODE DB
//Log.d(TAG, "CREATE NODE DB");
node = new OrgNode(new RegexParser());
node.setLevel(1);
node.setParent(file);
int idx = -1;
if (prevNode != null) {
idx = file.getSubNodes().indexOf(prevNode);
}
file.getSubNodes().add(idx + 1, node);
OrgConverter.toNodeFromTask(task, node);
dbEntry = new RemoteTask();
dbEntry.dbid = task._id;
dbEntry.listdbid = list._id;
dbEntry.account = getAccountName();
dbEntry.service = getServiceName();
OrgConverter.toRemoteFromNode(dbEntry, node);
dbEntry.save(context);
shouldUpdateFile = true;
} else {
// CREATE TASK DB
//Log.d(TAG, "CREATE TASK DB");
task = new Task();
task.dblist = list._id;
OrgConverter.toTaskFromNode(task, node);
task.save(context);
dbEntry = new RemoteTask();
dbEntry.dbid = task._id;
dbEntry.listdbid = list._id;
dbEntry.account = getAccountName();
dbEntry.service = getServiceName();
shouldUpdateFile = OrgConverter.toRemoteFromNode(dbEntry, node);
dbEntry.save(context);
replaceNotifications(task, node);
}
} else {
if (task == null) {
// DELETE NODE DB
//Log.d(TAG, "DELETE NODE DB");
deleteLocal(/*task=*/null, dbEntry);
if (node != null) {
deleteNode(node);
shouldUpdateFile = true;
}
} else {
if (node == null) {
// DELETE DB TASK
//Log.d(TAG, "DELETE TASK DB");
deleteLocal(task, dbEntry);
} else {
// TODO need to check notifications also
//Log.d(TAG, "MERGE TASKS");
final int shouldSave = merge(task, dbEntry, node);
if (0 < (shouldSave & SAVEORG)) {
// UPDATE NODE DB
OrgConverter.toNodeFromRemote(node, dbEntry);
shouldUpdateFile = true;
}
if (0 < (shouldSave & SAVEDB)) {
task.save(context);
}
if (0 < shouldSave) {
// Remember this version for later
OrgConverter.toRemoteFromNode(dbEntry, node);
dbEntry.save(context);
}
}
}
}
// Remember the previous next time for positioning
if (node != null) {
prevNode = node;
}
}
return shouldUpdateFile;
}
/**
* @param node to delete from the tree structure. Preserves sub nodes.
*/
private void deleteNode(final OrgNode node) {
final OrgNode parent = node.getParent();
// If no parent, nothing to do
if (parent == null)
return;
// If sub nodes, transfer to root
if (!node.getSubNodes().isEmpty()) {
final int i = parent.getSubNodes().indexOf(node);
parent.getSubNodes().addAll(i, node.getSubNodes());
}
// Remove the node
parent.getSubNodes().remove(node);
}
/**
* Merges the task and node. The fields considered are title, body,
* completed and deadline.
*
* @return an integer denoting which should be saved. 0 for none, 0x01 for
* task, 0x10 for node. 0x11 for both.
*/
protected int merge(final Task task, final RemoteTask remote, final OrgNode node) {
if (task == null || remote == null || node == null) {
throw new NullPointerException("A merge operation can't have null parties!");
}
// 0x01 if task should be saved
// 0x10 if node should be saved
// 0x11 if both should be saved
// 0x00 if nothing needs to be saved
int shouldSave = SAVENONE;
shouldSave |= mergeTitles(task, remote, node);
shouldSave |= mergeBodies(task, remote, node);
shouldSave |= mergeTodo(task, remote, node);
shouldSave |= mergeTimestamps(task, remote, node);
return shouldSave;
}
private int mergeTodo(final Task task, final RemoteTask remote, final OrgNode node) {
final int shouldSave;
final String taskTodo;
if (task.completed != null)
taskTodo = "DONE";
else
taskTodo = "TODO";
if (!taskTodo.equals(RemoteTaskNode.getTodo(remote))) {
shouldSave = SAVEORG;
node.setTodo(taskTodo);
} else if (RemoteTaskNode.getTodo(remote) != null
&& !RemoteTaskNode.getTodo(remote).equals(node.getTodo())) {
shouldSave = SAVEDB;
if ("DONE".equals(node.getTodo())) {
task.completed = Calendar.getInstance().getTimeInMillis();
} else {
task.completed = null;
}
} else {
shouldSave = SAVENONE;
}
return shouldSave;
}
private int mergeTimestamps(final Task task, final RemoteTask remote, final OrgNode node) {
final int shouldSave;
Long basedue = null;
if (RemoteTaskNode.getDueTime(remote) != null
&& !RemoteTaskNode.getDueTime(remote).isEmpty()) {
basedue = Long.parseLong(RemoteTaskNode.getDueTime(remote));
}
final Long nodedue = OrgConverter.getDeadline(node);
if (!Objects.equals(task.due, basedue)) {
shouldSave = SAVEORG;
OrgConverter.setDeadline(node, task.due);
} else if (!Objects.equals(nodedue, basedue)) {
shouldSave = SAVEDB;
task.due = nodedue;
} else {
shouldSave = SAVENONE;
}
return shouldSave;
}
private int mergeBodies(final Task task, final RemoteTask remote, final OrgNode node) {
final int shouldSave;
boolean taskChanged = !task.note.equals(RemoteTaskNode.getBody(remote));
// Check with trailing newline also
if (taskChanged) {
taskChanged = !(task.note + "\n").equals(RemoteTaskNode.getBody(remote));
}
if (taskChanged) {
shouldSave = SAVEORG;
node.setBody(task.note);
} else if (!node.getBody().equals(RemoteTaskNode.getBody(remote))) {
shouldSave = SAVEDB;
task.note = node.getBody();
/*
* It's not possible to differentiate if the user added a trailing
* newline or the sync logic did. I will assume that the sync logic did.
*/
if (task.note != null && task.note.endsWith("\n")) {
task.note = task.note.substring(0, task.note.length() - 1);
}
} else {
shouldSave = SAVENONE;
}
return shouldSave;
}
private int mergeTitles(final Task task, final RemoteTask remote, final OrgNode node) {
final int shouldSave;
if (!task.title.equals(RemoteTaskNode.getTitle(remote))) {
shouldSave = SAVEORG;
node.setTitle(task.title);
} else if (!node.getTitle().equals(RemoteTaskNode.getTitle(remote))) {
shouldSave = SAVEDB;
task.title = node.getTitle();
} else {
shouldSave = SAVENONE;
}
return shouldSave;
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/sync/orgsync/SynchronizerInterface.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.sync.orgsync;
import org.cowboyprogrammer.org.OrgFile;
import java.io.BufferedReader;
import java.io.IOException;
import java.text.ParseException;
import java.util.HashSet;
/**
* This interface defines an Org-Mode synchronizer.
*/
public interface SynchronizerInterface {
/**
* @return A unique name for this service. Should be descriptive, like
* SDOrg or SSHOrg.
*/
String getServiceName();
/**
* @return The username of the configured service. Likely an e-mail.
*/
String getAccountName();
/**
* Returns true if the synchronizer has been configured. This is called
* before synchronization. It will be true if the user has selected an
* account, folder etc...
*/
boolean isConfigured();
/**
* Returns an OrgFile object with a filename set that is guaranteed to
* not already exist. Use this method to avoid having multiple objects
* pointing to the same file.
*
* @param desiredName The name you'd want. If it exists,
* it will be used as the base in desiredName1,
* desiredName2, etc. Limited to 99.
* @return an OrgFile guaranteed not to exist.
*/
OrgFile getNewFile(final String desiredName) throws IOException, IllegalArgumentException;
/**
* Replaces the file on the remote end with the given content.
*
* @param orgFile The file to save. Uses the filename stored in the object.
*/
void putRemoteFile(final OrgFile orgFile) throws IOException;
/**
* Delete the file on the remote end.
*
* @param orgFile The file to delete.
*/
void deleteRemoteFile(final OrgFile orgFile) throws IOException;
/**
* Rename the file on the remote end.
*
* @param oldName The name it is currently stored as on the remote end.
* @param orgFile This contains the new name.
*/
void renameRemoteFile(final String oldName, final OrgFile orgFile) throws IOException;
/**
* Returns a BufferedReader to the remote file. Null if it doesn't exist.
*
* @param filename Name of the file, without path
*/
BufferedReader getRemoteFile(final String filename) throws IOException;
/**
* @return a set of all remote files.
*/
HashSet getRemoteFilenames() throws IOException;
/**
* Do a full 2-way sync.
*/
void fullSync() throws IOException, ParseException;
/**
* Use this to disconnect from any services and cleanup.
*/
void postSynchronize();
/**
* @return a Monitor for this source. May be null.
*/
Monitor getMonitor();
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/widget/list/ListWidgetConfig.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.widget.list;
import android.appwidget.AppWidgetManager;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Adapter;
import android.widget.AdapterView;
import android.widget.AdapterView.OnItemSelectedListener;
import android.widget.ImageButton;
import android.widget.SeekBar;
import android.widget.SeekBar.OnSeekBarChangeListener;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.content.ContextCompat;
import androidx.cursoradapter.widget.SimpleCursorAdapter;
import androidx.cursoradapter.widget.SimpleCursorAdapter.ViewBinder;
import androidx.loader.app.LoaderManager;
import androidx.loader.app.LoaderManager.LoaderCallbacks;
import androidx.loader.content.CursorLoader;
import androidx.loader.content.Loader;
import com.nononsenseapps.helpers.NnnLogger;
import com.nononsenseapps.helpers.ThemeHelper;
import com.nononsenseapps.helpers.TimeFormatter;
import com.nononsenseapps.notepad.R;
import com.nononsenseapps.notepad.database.Task;
import com.nononsenseapps.notepad.database.TaskList;
import com.nononsenseapps.notepad.databinding.ActivityWidgetConfigBinding;
import com.nononsenseapps.ui.ExtrasCursorAdapter;
import com.nononsenseapps.ui.TitleNoteTextView;
import java.text.SimpleDateFormat;
import java.util.Date;
/**
* The activity where the user can configure the "list widget". It also shows a preview!
*/
public class ListWidgetConfig extends AppCompatActivity {
public static final String KEY_LIST = "widget1_key_list";
public static final String KEY_LIST_TITLE = "widget1_key_list_title";
public static final String KEY_SORT_TYPE = "widget1_key_sort_type";
public static final String KEY_THEME = "widget1_key_current_theme";
public static final String KEY_TEXTPRIMARY = "widget1_key_primary_text";
public static final String KEY_TEXTSECONDARY = "widget1_key_secondary_text";
public static final String KEY_HIDDENHEADER = "widget1_key_hiddenheader";
public static final String KEY_SHADE_COLOR = "widget1_key_shadecolor";
public static final String KEY_HIDDENDATE = "widget1_key_hiddendate";
public static final String KEY_HIDDENCHECKBOX = "widget1_key_hiddencheckbox";
public static final String KEY_TITLEROWS = "widget1_key_titlerows";
/**
* If the widget should show completed notes instead of hiding them
*/
public static final String KEY_SHOWCOMPLETED = "widget1_key_showcompleted";
/**
* Used in widget service/provider
*/
public static final String KEY_LOCKSCREEN = "widget1_key_lockscreen";
public final static int THEME_DARK = 0;
public final static int THEME_LIGHT = 1;
// These are the default widget values
public final static int DEFAULT_THEME = THEME_DARK;
// 75% translucent black
public final static int DEFAULT_SHADE = 0xC0000000;
// White (android primary dark)
public final static int DEFAULT_TEXTPRIMARY = 0xff000000;
// Greyish (android secondary dark)
public final static int DEFAULT_TEXTSECONDARY = 0xffbebebe;
// Number of rows
public final static int DEFAULT_ROWS = 3;
// All lists id
public final static int ALL_LISTS_ID = -2;
private int appWidgetId;
private SimpleWidgetPreviewAdapter mNotesAdapter;
private LoaderCallbacks mCallback;
private ExtrasCursorAdapter mListAdapter;
/**
* for {@link R.layout#activity_widget_config}
*/
private ActivityWidgetConfigBinding mBinding;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
mBinding = ActivityWidgetConfigBinding.inflate(getLayoutInflater());
setContentView(mBinding.getRoot());
setResult(RESULT_CANCELED);
Intent intent = getIntent();
if (intent != null && intent.getExtras() != null) {
appWidgetId = intent.getExtras().getInt(AppWidgetManager.EXTRA_APPWIDGET_ID,
AppWidgetManager.INVALID_APPWIDGET_ID);
} else {
appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID;
NnnLogger.debug(ListWidgetConfig.class, "Invalid ID given in the intent");
Intent resultValue = new Intent();
resultValue.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID,
appWidgetId);
setResult(RESULT_CANCELED, resultValue);
finish();
}
setupPreview();
setupActionBar();
setupConfig();
}
void setupPreview() {
final WidgetPrefs widgetPrefs = new WidgetPrefs(this, appWidgetId);
mNotesAdapter = new SimpleWidgetPreviewAdapter(this,
R.layout.widgetlist_item, R.layout.widgetlist_header, null,
new String[] { Task.Columns.TITLE, Task.Columns.DUE,
Task.Columns.COMPLETED, Task.Columns.COMPLETED,
Task.Columns.COMPLETED }, new int[] {
android.R.id.text1, R.id.dueDate,
R.id.completedCheckBoxDark, R.id.itemSpacer,
R.id.completedCheckBoxLight }, 0);
mNotesAdapter.setViewBinder(new ViewBinder() {
final WidgetPrefs widgetPrefs = new WidgetPrefs(ListWidgetConfig.this, appWidgetId);
boolean isHeader = false;
String sTemp = "";
final SimpleDateFormat dateFormatter = TimeFormatter
.getLocalFormatterMicro(ListWidgetConfig.this);
@Override
public boolean setViewValue(View view, Cursor c, int colIndex) {
// Check for headers, they have invalid ids
isHeader = c.getLong(0) == -1;
switch (colIndex) {
case 1:
// title
if (isHeader) {
sTemp = c.getString(1);
long dueDateMillis = c.getLong(4);
sTemp = Task.getHeaderNameForListSortedByDate(sTemp, dueDateMillis,
ListWidgetConfig.this);
((TextView) view).setText(sTemp);
} else {
((TextView) view).setText(TitleNoteTextView.getStyledText(
c.getString(1), c.getString(2),
1.0f, 1, 1));
final int rows = widgetPrefs.getInt(KEY_TITLEROWS, DEFAULT_ROWS);
((TextView) view).setMaxLines(Math.max(rows, 1));
}
// Set color
((TextView) view).setTextColor(
widgetPrefs.getInt(KEY_TEXTPRIMARY, DEFAULT_TEXTPRIMARY));
return true;
case 2:
// already done.
return true;
case 3:
// Complete checkbox => decide if it will be visible
boolean visible;
// see widgetlist_item.xml
if (view.getId() == R.id.completedCheckBoxLight) {
assert view instanceof ImageButton;
// show one of the 2 imagebuttons depending on the chosen theme
visible = THEME_LIGHT == widgetPrefs.getInt(KEY_THEME, DEFAULT_THEME);
// > 0 if it user completed the task, 0 otherwise
long millisOfCompletion = c.getLong(3);
if (millisOfCompletion > 0) {
// this is an imageview, not a checkbox. I change its state
// by swapping the image source
((ImageButton) view).setImageResource(R.drawable.ic_checkbox_checked);
} else {
((ImageButton) view).setImageResource(R.drawable.ic_checkbox_unchecked);
}
// set the color of the checkbox: use the system's accent color
int color = ThemeHelper.getThemeAccentColor(ListWidgetConfig.this);
((ImageButton) view).setColorFilter(color);
} else if (view.getId() == R.id.completedCheckBoxDark) {
assert view instanceof ImageButton;
visible = THEME_DARK == widgetPrefs.getInt(KEY_THEME, DEFAULT_THEME);
// see above
long millisOfCompletion = c.getLong(3);
if (millisOfCompletion > 0) {
((ImageButton) view).setImageResource(R.drawable.ic_checkbox_checked);
} else {
((ImageButton) view).setImageResource(R.drawable.ic_checkbox_unchecked);
}
int color = ThemeHelper.getThemeAccentColor(ListWidgetConfig.this);
((ImageButton) view).setColorFilter(color);
} else {
// Spacer
assert view.getId() == R.id.itemSpacer;
visible = true;
}
visible &= !widgetPrefs.getBoolean(KEY_HIDDENCHECKBOX, false);
view.setVisibility(visible ? View.VISIBLE : View.GONE);
return true;
case 4:
// Date
view.setVisibility(widgetPrefs.getBoolean(KEY_HIDDENDATE, false)
? View.GONE : View.VISIBLE);
if (c.isNull(colIndex)) {
((TextView) view).setText("");
} else {
((TextView) view).setText(
dateFormatter.format(new Date(c.getLong(colIndex))));
}
((TextView) view).setTextColor(
widgetPrefs.getInt(KEY_TEXTPRIMARY, DEFAULT_TEXTPRIMARY));
return true;
default:
return false;
}
}
});
mBinding.widgetPreviewWrapper.widgetPreview.notesList.setAdapter(mNotesAdapter);
mCallback = new LoaderCallbacks<>() {
/**
* This is for the preview of the widget that appears in the config activity,
* but most of the code is taken from
* {@link ListWidgetService.ListRemoteViewsFactory#onDataSetChanged()}
*/
@NonNull
@Override
public Loader onCreateLoader(int id, Bundle arg1) {
if (id == 1) {
return new CursorLoader(ListWidgetConfig.this,
TaskList.URI, TaskList.Columns.FIELDS, null, null,
getString(R.string.const_as_alphabetic, TaskList.Columns.TITLE));
}
// TODO maybe ListWidgetService.onDataSetChanged() and this function share some
// common code. Try to understand and refactor it
final Uri targetUri;
final long listId = widgetPrefs.getLong(KEY_LIST, ALL_LISTS_ID);
final String sortSpec;
final String sortType = widgetPrefs
.getString(KEY_SORT_TYPE, getString(R.string.default_sorttype));
boolean isShowingCompleted = widgetPrefs
.getBoolean(ListWidgetConfig.KEY_SHOWCOMPLETED, false);
if (sortType.equals(getString(R.string.const_possubsort)) && listId > 0) {
targetUri = Task.URI;
sortSpec = Task.Columns.LEFT;
} else if (sortType.equals(getString(R.string.const_modified))) {
targetUri = Task.URI;
sortSpec = Task.Columns.UPDATED + " DESC";
} else if (sortType.equals(getString(R.string.const_duedate))) {
// due date sorting
targetUri = Task.URI_SECTIONED_BY_DATE;
sortSpec = null;
} else {
// Alphabetic
targetUri = Task.URI;
sortSpec = getString(R.string.const_as_alphabetic, Task.Columns.TITLE);
}
String listWhere;
String[] listArg;
if (listId > 0) {
// only get notes in that list id
listArg = new String[] { Long.toString(listId) };
// if user does not want to also show completed tasks in widget, the query
// will filter away database records with a "completed" unix time
listWhere = isShowingCompleted
? "CAST(" + Task.Columns.DBLIST + " AS INTEGER) IS ?"
: "CAST(" + Task.Columns.DBLIST + " AS INTEGER) IS ? AND " + Task.Columns.COMPLETED + " IS NULL";
} else {
// all list ids
listArg = null;
// if user wants to show completed tasks, since here it shows from all lists,
// then this "where" should show everything. In android logic, that means
// sending "null" to CursorLoader() here below
listWhere = isShowingCompleted
? null
: Task.Columns.COMPLETED + " IS NULL";
}
return new CursorLoader(ListWidgetConfig.this, targetUri,
Task.Columns.FIELDS, listWhere, listArg, sortSpec);
}
@Override
public void onLoadFinished(@NonNull Loader l, Cursor c) {
if (l.getId() == 1) {
mListAdapter.swapCursor(c);
final int pos = getListPositionOf(mListAdapter,
widgetPrefs.getLong(KEY_LIST, ALL_LISTS_ID));
//if (c.getCount() > 0) {
// Set current item
mBinding.widgetConfWrapper.listSpinner.setSelection(pos);
//}
} else {
mNotesAdapter.swapCursor(c);
}
}
@Override
public void onLoaderReset(@NonNull Loader l) {
if (l.getId() == 1) {
mListAdapter.swapCursor(null);
} else {
mNotesAdapter.swapCursor(null);
}
}
};
LoaderManager.getInstance(this).restartLoader(1, null, mCallback);
}
/**
* Calling {@link SimpleWidgetPreviewAdapter#notifyDataSetChanged} on
* {@link #mNotesAdapter} can only refresh the views, but it doesn't change the
* cursor, so it can't toggle between "do/dont show completed notes". This function
* can, because it updates the underlying cursor
*/
void reloadTasks() {
LoaderManager.getInstance(this).restartLoader(0, null, mCallback);
}
void setupActionBar() {
final WidgetPrefs widgetPrefs = new WidgetPrefs(this, appWidgetId);
LayoutInflater inflater = (LayoutInflater) getSupportActionBar()
.getThemedContext()
.getSystemService(LAYOUT_INFLATER_SERVICE);
final View customActionBarView = inflater
.inflate(R.layout.actionbar_custom_view_done, null);
customActionBarView.findViewById(R.id.actionbar_done).setOnClickListener(v -> {
// "Done"
// // Set success
widgetPrefs.setPresent();
Intent resultValue = new Intent();
resultValue.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId);
setResult(RESULT_OK, resultValue);
// Build/Update widget
AppWidgetManager appWidgetManager = AppWidgetManager
.getInstance(getApplicationContext());
// Log.d(TAG, "finishing WidgetId " + appWidgetId);
appWidgetManager.updateAppWidget(appWidgetId, ListWidgetProvider.buildRemoteViews(
getApplicationContext(), appWidgetManager, appWidgetId, widgetPrefs));
// Update list items
appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetId, R.id.notesList);
// Destroy activity
finish();
});
// Show the custom action bar view and hide the normal Home icon and title.
getSupportActionBar().setDisplayOptions(ActionBar.DISPLAY_SHOW_CUSTOM,
ActionBar.DISPLAY_SHOW_CUSTOM | ActionBar.DISPLAY_SHOW_HOME |
ActionBar.DISPLAY_SHOW_TITLE);
getSupportActionBar().setCustomView(customActionBarView);
}
void setupConfig() {
final WidgetPrefs widgetPrefs = new WidgetPrefs(this, appWidgetId);
final String[] sortTypeValues = getResources()
.getStringArray(R.array.sortingvalues_preference);
final String[] themeValues = getResources()
.getStringArray(R.array.widget_themevalues_preference);
if (themeValues == null) {
NnnLogger.debug(ListWidgetConfig.class, "themevalues null");
} else {
for (String s : themeValues) {
NnnLogger.debug(ListWidgetConfig.class, "themevalue: " + s);
}
}
mBinding.widgetConfWrapper.sortingSpinner
.setOnItemSelectedListener(new OnItemSelectedListener() {
@Override
public void onItemSelected(AdapterView> parent, View view,
int pos, long id) {
widgetPrefs.putString(KEY_SORT_TYPE, sortTypeValues[pos]);
// Need to recreate loader for this
reloadTasks();
}
@Override
public void onNothingSelected(AdapterView> parent) {}
});
mBinding.widgetConfWrapper.sortingSpinner.setSelection(getArrayPositionOf(
sortTypeValues,
widgetPrefs.getString(KEY_SORT_TYPE, getString(R.string.default_sorttype))));
mBinding.widgetConfWrapper.themeSpinner
.setOnItemSelectedListener(new OnItemSelectedListener() {
@Override
public void onItemSelected(AdapterView> parent, View view,
int pos, long id) {
final String theme = parent.getItemAtPosition(pos).toString();
final int mTheme;
final int primaryTextColor;
final int secondaryTextColor;
if (theme.equals(getString(R.string.settings_summary_theme_light))) {
mTheme = THEME_LIGHT;
primaryTextColor = ContextCompat.getColor(ListWidgetConfig.this,
android.R.color.primary_text_light);
secondaryTextColor = ContextCompat.getColor(ListWidgetConfig.this,
android.R.color.secondary_text_light);
} else {
mTheme = THEME_DARK;
primaryTextColor = ContextCompat.getColor(ListWidgetConfig.this,
android.R.color.primary_text_dark);
secondaryTextColor = ContextCompat.getColor(ListWidgetConfig.this,
android.R.color.secondary_text_dark);
}
widgetPrefs.putInt(KEY_THEME, mTheme);
widgetPrefs.putInt(KEY_TEXTPRIMARY, primaryTextColor);
widgetPrefs.putInt(KEY_TEXTSECONDARY, secondaryTextColor);
updateTheme(mTheme, widgetPrefs);
}
@Override
public void onNothingSelected(AdapterView> arg0) {}
});
final String currentThemeString;
if (widgetPrefs.getInt(KEY_THEME, DEFAULT_THEME) == THEME_LIGHT) {
currentThemeString = getString(R.string.settings_summary_theme_light);
} else {
currentThemeString = getString(R.string.settings_summary_theme_dark);
}
mBinding.widgetConfWrapper.themeSpinner.setSelection(getSpinnerPositionOf(
mBinding.widgetConfWrapper.themeSpinner.getAdapter(), currentThemeString));
mBinding.widgetConfWrapper.itemRowsSeekBar
.setOnSeekBarChangeListener(new OnSeekBarChangeListener() {
@Override
public void onStopTrackingTouch(SeekBar seekBar) {}
@Override
public void onStartTrackingTouch(SeekBar seekBar) {}
@Override
public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
// Plus one since seekbars start at zero
widgetPrefs.putInt(KEY_TITLEROWS, progress + 1);
// Only need to reload existing loader
if (mNotesAdapter != null) {
mNotesAdapter.notifyDataSetChanged();
}
}
});
mBinding.widgetConfWrapper.itemRowsSeekBar
.setProgress(widgetPrefs.getInt(KEY_TITLEROWS, DEFAULT_ROWS) - 1);
mBinding.widgetConfWrapper.transparencySeekBar
.setOnSeekBarChangeListener(new OnSeekBarChangeListener() {
@Override
public void onStopTrackingTouch(SeekBar seekBar) {}
@Override
public void onStartTrackingTouch(SeekBar seekBar) {}
@Override
public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
final int color = getHomescreenBackgroundColor(progress,
widgetPrefs.getInt(KEY_SHADE_COLOR, DEFAULT_SHADE));
widgetPrefs.putInt(KEY_SHADE_COLOR, color);
updateBG(color);
}
});
// Set current item
int opacity = widgetPrefs.getInt(KEY_SHADE_COLOR, DEFAULT_SHADE);
// Isolate the alpha
opacity = opacity >> 24;
opacity &= 0xff;
// Get percentage
opacity = (100 * opacity) / 0xff;
mBinding.widgetConfWrapper.transparencySeekBar.setProgress(opacity);
mBinding.widgetConfWrapper.listSpinner
.setOnItemSelectedListener(new OnItemSelectedListener() {
@Override
public void onItemSelected(AdapterView> adapter, View arg1, int pos, long id) {
widgetPrefs.putLong(KEY_LIST, id);
try {
widgetPrefs.putString(KEY_LIST_TITLE, ((Cursor) adapter
.getItemAtPosition(pos)).getString(1));
} catch (ClassCastException e) {
// Its the all lists item
widgetPrefs.putString(KEY_LIST_TITLE,
((String) adapter.getItemAtPosition(pos)));
}
// Need to reload tasks
reloadTasks();
// And set title
mBinding.widgetPreviewWrapper.widgetPreview.titleButton
.setText(widgetPrefs.getString(KEY_LIST_TITLE, ""));
}
@Override
public void onNothingSelected(AdapterView> arg0) {}
});
mListAdapter = new ExtrasCursorAdapter(this,
android.R.layout.simple_spinner_dropdown_item, null,
new String[] { TaskList.Columns.TITLE },
new int[] { android.R.id.text1 }, new int[] { ALL_LISTS_ID },
new int[] { R.string.show_from_all_lists },
android.R.layout.simple_spinner_dropdown_item);
mBinding.widgetConfWrapper.listSpinner.setAdapter(mListAdapter);
// for each checkbox, set listener & initialize value
mBinding.widgetConfWrapper.transparentHeaderCheckBox
.setOnCheckedChangeListener((buttonView, isChecked) -> {
mBinding.widgetPreviewWrapper.widgetPreview.widgetHeader
.setVisibility(isChecked ? View.GONE : View.VISIBLE);
widgetPrefs.putBoolean(KEY_HIDDENHEADER, isChecked);
});
mBinding.widgetConfWrapper.transparentHeaderCheckBox
.setChecked(widgetPrefs.getBoolean(KEY_HIDDENHEADER, false));
mBinding.widgetConfWrapper.showCompletedCheckBox
.setOnCheckedChangeListener((btn, isChecked) -> {
widgetPrefs.putBoolean(KEY_SHOWCOMPLETED, isChecked);
reloadTasks();
});
mBinding.widgetConfWrapper.showCompletedCheckBox
.setChecked(widgetPrefs.getBoolean(KEY_SHOWCOMPLETED, false));
mBinding.widgetConfWrapper.hideCheckBox
.setOnCheckedChangeListener((buttonView, isChecked) -> {
widgetPrefs.putBoolean(KEY_HIDDENCHECKBOX, isChecked);
if (mNotesAdapter != null) mNotesAdapter.notifyDataSetChanged();
});
mBinding.widgetConfWrapper.hideCheckBox
.setChecked(widgetPrefs.getBoolean(KEY_HIDDENCHECKBOX, false));
mBinding.widgetConfWrapper.hideDateCheckBox
.setOnCheckedChangeListener((buttonView, isChecked) -> {
widgetPrefs.putBoolean(KEY_HIDDENDATE, isChecked);
if (mNotesAdapter != null) mNotesAdapter.notifyDataSetChanged();
});
mBinding.widgetConfWrapper.hideDateCheckBox
.setChecked(widgetPrefs.getBoolean(KEY_HIDDENDATE, false));
}
private static int getListPositionOf(final Adapter adapter, final long id) {
if (adapter == null || adapter.getCount() == 0) return 0;
int pos = 0;
for (int i = 0; i < adapter.getCount(); i++) {
if (adapter.getItemId(i) == id) {
pos = i;
break;
}
}
return pos;
}
private static int getSpinnerPositionOf(final Adapter adapter, final String entry) {
if (adapter == null || adapter.getCount() == 0) return 0;
int pos = 0;
for (int i = 0; i < adapter.getCount(); i++) {
if (adapter.getItem(i).toString().equals(entry)) {
pos = i;
break;
}
}
return pos;
}
private static int getArrayPositionOf(final String[] array, final String entry) {
if (array == null || array.length == 0) return 0;
int pos = 0;
for (int i = 0; i < array.length; i++) {
if (array[i].equals(entry)) {
pos = i;
break;
}
}
return pos;
}
void updateBG(final int color) {
mBinding.widgetPreviewWrapper.widgetPreview.shade.setBackgroundColor(color);
mBinding.widgetPreviewWrapper.widgetPreview.shade
.setVisibility((color & 0xff000000) == 0 ? View.GONE : View.VISIBLE);
}
void updateTheme(final int theme, final WidgetPrefs widgetPrefs) {
int color;
int alpha = widgetPrefs.getInt(KEY_SHADE_COLOR, DEFAULT_SHADE);
// Isolate alpha channel
alpha = 0xff000000 & alpha;
switch (theme) {
case THEME_LIGHT:
// WHITE
color = 0xffffff;
break;
case THEME_DARK:
default:
color = 0;
break;
}
// Add alpha
color = alpha | color;
widgetPrefs.putInt(KEY_SHADE_COLOR, color);
updateBG(color);
mNotesAdapter.notifyDataSetChanged();
}
/**
* Returns black, with the opacity specified
*
* @param opacity should be a number between 0 and 100
*/
public static int getHomescreenBackgroundColor(final int opacity) {
if (opacity >= 100) {
return 0xff000000;
} else if (opacity <= 0) {
return 0;
} else {
return (opacity * 256 / 100) << 24;
}
}
/**
* Returns the specified color, with the opacity specified. The color will
* have its alpha overwritten.
*/
public static int getHomescreenBackgroundColor(final int opacity, final int color) {
// Get rid of possible alpha
int retColor = color & 0x00ffffff;
return getHomescreenBackgroundColor(opacity) | retColor;
}
static class SimpleWidgetPreviewAdapter extends SimpleCursorAdapter {
final int mItemLayout;
final int mHeaderLayout;
final static int itemType = 0;
final static int headerType = 1;
final Context mContext;
public SimpleWidgetPreviewAdapter(Context context, int layout, int headerLayout,
Cursor c, String[] from, int[] to, int flags) {
super(context, layout, c, from, to, flags);
mItemLayout = layout;
mHeaderLayout = headerLayout;
mContext = context;
}
int getViewLayout(final int position) {
if (itemType == getItemViewType(position)) {
return mItemLayout;
} else {
return mHeaderLayout;
}
}
@Override
public int getViewTypeCount() {
return 2;
}
@Override
public int getItemViewType(int position) {
final Cursor c = (Cursor) getItem(position);
// If the id is invalid, it's a header
if (c.getLong(0) < 1) {
return headerType;
} else {
return itemType;
}
}
@Override
public View getView(int position, View convertView, ViewGroup parent) {
if (convertView == null) {
final LayoutInflater inflater = LayoutInflater.from(this.mContext);
convertView = inflater.inflate(getViewLayout(position), parent, false);
}
return super.getView(position, convertView, parent);
}
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/widget/list/ListWidgetProvider.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.widget.list;
import android.annotation.SuppressLint;
import android.app.PendingIntent;
import android.appwidget.AppWidgetManager;
import android.appwidget.AppWidgetProvider;
import android.appwidget.AppWidgetProviderInfo;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Build;
import android.view.View;
import android.widget.RemoteViews;
import com.nononsenseapps.helpers.NnnLogger;
import com.nononsenseapps.notepad.activities.main.ActivityMain_;
import com.nononsenseapps.notepad.NotePadBroadcastReceiver;
import com.nononsenseapps.notepad.R;
import com.nononsenseapps.notepad.database.Task;
import com.nononsenseapps.notepad.database.TaskList;
import com.nononsenseapps.notepad.fragments.TaskDetailFragment;
import java.util.Objects;
/**
* The note-list widget's AppWidgetProvider.
*/
public class ListWidgetProvider extends AppWidgetProvider {
// private static final String TAG = "WIDGETPROVIDER";
public static final String COMPLETE_ACTION = "com.nononsenseapps.notepad.widget.COMPLETE";
public static final String CLICK_ACTION = "com.nononsenseapps.notepad.widget.CLICK";
public static final String OPEN_ACTION = "com.nononsenseapps.notepad.widget.OPENAPP";
public static final String CONFIG_ACTION = "com.nononsenseapps.notepad.widget.CONFIG";
public static final String CREATE_ACTION = "com.nononsenseapps.notepad.widget.CREATE";
public static final String EXTRA_NOTE_ID = "com.nononsenseapps.notepad.widget.note_id";
public static final String EXTRA_LIST_ID = "com.nononsenseapps.notepad.widget.list_id";
// called by android.app.AppComponentFactory
public ListWidgetProvider() {}
/**
* When the user touches the checkbox on a list item in the widget, an intent is launched.
* This receives that signal. In other words, Complete note calls go here
*/
@Override
public void onReceive(Context context, Intent intent) {
String action = intent.getAction();
Objects.requireNonNull(action);
if (action.equals(CLICK_ACTION)) {
NnnLogger.debug(ListWidgetProvider.class, "CLICK ACTION RECEIVED");
long noteId = intent.getLongExtra(EXTRA_NOTE_ID, -1);
if (noteId > -1) {
Intent appIntent = new Intent()
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK)
.setAction(Intent.ACTION_EDIT)
.setData(Task.getUri(noteId))
.putExtra(TaskDetailFragment.ARG_ITEM_LIST_ID,
intent.getLongExtra(EXTRA_LIST_ID, -1));
context.startActivity(appIntent);
}
} else if (action.equals(COMPLETE_ACTION)) {
// Should send broadcast here
NnnLogger.debug(ListWidgetProvider.class, "COMPLETE ACTION RECEIVED");
long noteId = intent.getLongExtra(EXTRA_NOTE_ID, -1);
// This will complete the note
if (noteId > -1) {
// choose if you have to set the note as complete or incomplete
String action2 = Task.byId(noteId, context).isCompleted()
? NotePadBroadcastReceiver.SET_NOTE_INCOMPLETE
: NotePadBroadcastReceiver.SET_NOTE_COMPLETE;
Intent bintent = new Intent(context, NotePadBroadcastReceiver.class)
.setAction(action2)
.putExtra(Task.Columns._ID, noteId);
context.sendBroadcast(bintent);
}
}
super.onReceive(context, intent);
}
@Override
public void onEnabled(Context context) {
/*
* Register for external updates to the data to trigger an update of the
* widget. When using content providers, the data is often updated via a
* background service, or in response to user interaction in the main
* app. To ensure that the widget always reflects the current state of
* the data, we must listen for changes and update ourselves
* accordingly.
*/
}
@Override
public void onDeleted(Context context, int[] appWidgetIds) {
super.onDeleted(context, appWidgetIds);
NnnLogger.debug(ListWidgetProvider.class,
"onDeleted, appWidgetIds.length = " + appWidgetIds.length);
for (int widgetId : appWidgetIds) {
WidgetPrefs.delete(context, widgetId);
}
}
@Override
public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
// This is not called on start up if we are using a configuration activity
// Update each of the widgets with the remote adapter
for (int widgetId : appWidgetIds) {
// Load widget prefs
final WidgetPrefs prefs = new WidgetPrefs(context, widgetId);
// Build view update
RemoteViews updateViews = buildRemoteViews(context, appWidgetManager, widgetId, prefs);
// Tell the AppWidgetManager to perform an update
appWidgetManager.updateAppWidget(widgetId, updateViews);
}
}
public static RemoteViews buildRemoteViews(final Context context,
final AppWidgetManager appWidgetManager,
final int appWidgetId,
final WidgetPrefs settings) {
// Hack: We must set this widget's id in the URI to prevent the situation
// where the last widget added will be used for everything
final Uri data = Uri.withAppendedPath(Uri.parse("STUPIDWIDGETS"
+ "://widget/id/"), String.valueOf(appWidgetId));
// Get the value of OPTION_APPWIDGET_HOST_CATEGORY
int category = appWidgetManager
.getAppWidgetOptions(appWidgetId)
.getInt(AppWidgetManager.OPTION_APPWIDGET_HOST_CATEGORY, -1);
// If the value is WIDGET_CATEGORY_KEYGUARD, it's a lockscreen widget
boolean isKeyguard = category == AppWidgetProviderInfo.WIDGET_CATEGORY_KEYGUARD;
if (isKeyguard) {
settings.putBoolean(ListWidgetConfig.KEY_LOCKSCREEN, true);
}
// Specify the service to provide data for the collection widget. Note that we need to
// embed the appWidgetId via the data otherwise it will be ignored.
final Intent intent = new Intent(context, ListWidgetService.class);
intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId);
intent.setData(Uri.parse(intent.toUri(Intent.URI_INTENT_SCHEME)));
RemoteViews rv = new RemoteViews(context.getPackageName(), R.layout.widget_layout);
rv.setRemoteAdapter(R.id.notesList, intent);
// Set the empty view to be displayed if the collection is empty. It must be a sibling
// view of the collection view.
rv.setEmptyView(R.id.notesList, R.id.empty_view);
final long listId = settings.getLong(ListWidgetConfig.KEY_LIST, -1);
final String listTitle = settings.getString(ListWidgetConfig.KEY_LIST_TITLE,
context.getString(R.string.app_name_short));
rv.setTextViewText(R.id.titleButton, listTitle);
// Hide header if we should
rv.setViewVisibility(R.id.widgetHeader,
settings.getBoolean(ListWidgetConfig.KEY_HIDDENHEADER, false)
? View.GONE : View.VISIBLE);
// Set background color
final int color = settings
.getInt(ListWidgetConfig.KEY_SHADE_COLOR, ListWidgetConfig.DEFAULT_SHADE);
rv.setInt(R.id.shade, "setBackgroundColor", color);
rv.setViewVisibility(R.id.shade, (color & 0xff000000) == 0 ? View.GONE : View.VISIBLE);
/*
* Bind a click listener template for the contents of the list. Note
* that we need to update the intent's data if we set an extra, since
* the extras will be ignored otherwise.
*/
if (isKeyguard) {
final Intent itemIntent = new Intent(context, ActivityMain_.class);
itemIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
PendingIntent onClickPendingIntent = getThePendingIntentForActivity(itemIntent, context);
rv.setPendingIntentTemplate(R.id.notesList, onClickPendingIntent);
} else {
// To handle complete, we use broadcasts
Intent onClickIntent = new Intent(context, ListWidgetProvider.class);
onClickIntent
.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
.putExtra(Task.Columns.DBLIST, listId)
.setData(data);
PendingIntent onClickPendingIntent =
getThePendingIntentForBroadcast(onClickIntent, context);
rv.setPendingIntentTemplate(R.id.notesList, onClickPendingIntent);
}
final Intent appIntent = new Intent();
appIntent
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK)
.setClass(context, ActivityMain_.class)
.setAction(Intent.ACTION_VIEW)
.setData(TaskList.getUri(listId));
PendingIntent openAppPendingIntent = getThePendingIntentForActivity(appIntent, context);
rv.setOnClickPendingIntent(R.id.titleButton, openAppPendingIntent);
final Intent configIntent = new Intent();
configIntent
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK)
.setClass(context, ListWidgetConfig.class)
.setData(data)
.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId);
PendingIntent openConfigPendingIntent = getThePendingIntentForActivity(configIntent, context);
rv.setOnClickPendingIntent(R.id.widgetConfigButton, openConfigPendingIntent);
// + button to create a new note from the widget
final Intent createIntent = new Intent();
createIntent
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK)
.setClass(context, ActivityMain_.class)
// Append a dummy path so we don't override this intent on 2nd, 3rd, etc, widgets.
.setAction(Intent.ACTION_INSERT)
.setData(Uri.withAppendedPath(Task.URI, "/widget/" + appWidgetId + "/-1"))
.putExtra(TaskDetailFragment.ARG_ITEM_LIST_ID, listId);
if (listId > 0) {
rv.setViewVisibility(R.id.createNoteButton, View.VISIBLE);
PendingIntent createPendingIntent = getThePendingIntentForActivity(createIntent, context);
rv.setOnClickPendingIntent(R.id.createNoteButton, createPendingIntent);
} else {
// the widget is showing notes from all lists: hide the + button,
// because it would not find a valid list to add a note to
rv.setViewVisibility(R.id.createNoteButton, View.GONE);
}
return rv;
}
/**
* This uses getActivity(), not getBroadcast() !
*
* @return a properly configured {@link PendingIntent} for the given {@link Intent}
*/
@SuppressLint("UnspecifiedImmutableFlag")
private static PendingIntent getThePendingIntentForActivity(Intent i, Context c) {
PendingIntent pi;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// on API 31 and higher the mutability must be explicitly set
pi = PendingIntent.getActivity(c, 0, i,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE);
} else {
// on lower API levels, the mutability is implied
pi = PendingIntent.getActivity(c, 0, i, PendingIntent.FLAG_UPDATE_CURRENT);
}
return pi;
}
/**
* Like {@link ListWidgetProvider#getThePendingIntentForActivity(Intent, Context)}
* but this one uses getBroadcast(), not getActivity() !
*/
@SuppressLint("UnspecifiedImmutableFlag")
private static PendingIntent getThePendingIntentForBroadcast(Intent i, Context c) {
PendingIntent pi;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
pi = PendingIntent.getBroadcast(c, 0, i,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE);
} else {
pi = PendingIntent.getBroadcast(c, 0, i, PendingIntent.FLAG_UPDATE_CURRENT);
}
return pi;
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/widget/list/ListWidgetService.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.widget.list;
import android.appwidget.AppWidgetManager;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.net.Uri;
import android.os.Binder;
import android.view.View;
import android.widget.RemoteViews;
import android.widget.RemoteViewsService;
import com.nononsenseapps.helpers.ThemeHelper;
import com.nononsenseapps.helpers.TimeFormatter;
import com.nononsenseapps.notepad.R;
import com.nononsenseapps.notepad.database.Task;
import com.nononsenseapps.notepad.fragments.TaskDetailFragment;
import com.nononsenseapps.ui.TitleNoteTextView;
import java.text.SimpleDateFormat;
import java.util.Date;
/**
* This is the service that provides the factory to be bound to the collection service
*/
public class ListWidgetService extends RemoteViewsService {
@Override
public RemoteViewsFactory onGetViewFactory(Intent intent) {
return new ListRemoteViewsFactory(this.getApplicationContext(), intent);
}
/**
* This is the factory that will provide data to the collection widget
*/
static class ListRemoteViewsFactory implements RemoteViewsService.RemoteViewsFactory {
/**
* column names of this cursor are in {@link Task.Columns#FIELDS}
*/
private Cursor mCursor;
final private Context mContext;
final private int mAppWidgetId;
// these 2 must be reloaded every time, to react to changes in locale preferences
private SimpleDateFormat mDateFormatter = null;
public ListRemoteViewsFactory(Context context, Intent intent) {
mContext = context;
mAppWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID,
AppWidgetManager.INVALID_APPWIDGET_ID);
}
@Override
public void onCreate() {}
@Override
public void onDestroy() {
if (mCursor == null) return;
mCursor.close();
}
@Override
public int getCount() {
if (mCursor == null) return 0;
return mCursor.getCount();
}
@Override
public RemoteViews getViewAt(int position) {
// Get widget settings
final WidgetPrefs widgetPrefs = new WidgetPrefs(mContext, mAppWidgetId);
if (!widgetPrefs.isPresent()) {
// basically "return null", but that started crashing reccently,
// so we return an empty meaningless view
return new RemoteViews(mContext.getPackageName(), R.layout.widgetlist_header);
}
// load date formatter if not present
if (mDateFormatter == null) {
mDateFormatter = TimeFormatter.getLocalFormatterMicro(mContext);
}
final long listId = widgetPrefs
.getLong(ListWidgetConfig.KEY_LIST, ListWidgetConfig.ALL_LISTS_ID);
final int theme = widgetPrefs
.getInt(ListWidgetConfig.KEY_THEME, ListWidgetConfig.DEFAULT_THEME);
final int primaryTextColor = widgetPrefs
.getInt(ListWidgetConfig.KEY_TEXTPRIMARY, ListWidgetConfig.DEFAULT_TEXTPRIMARY);
final int rows = widgetPrefs
.getInt(ListWidgetConfig.KEY_TITLEROWS, ListWidgetConfig.DEFAULT_ROWS);
final boolean isCheckboxHidden = widgetPrefs
.getBoolean(ListWidgetConfig.KEY_HIDDENCHECKBOX, false);
boolean isDateHidden = widgetPrefs
.getBoolean(ListWidgetConfig.KEY_HIDDENDATE, false);
// TODO rest
RemoteViews rv = null;
if (mCursor.moveToPosition(position)) {
boolean isHeader = mCursor.getLong(0) < 1;
if (isHeader) {
rv = new RemoteViews(mContext.getPackageName(), R.layout.widgetlist_header);
rv.setTextColor(android.R.id.text1, primaryTextColor);
String sTemp = mCursor.getString(1);
long dueDateMillis = mCursor.getLong(4);
sTemp = Task.getHeaderNameForListSortedByDate(sTemp, dueDateMillis, mContext);
// Set text
rv.setTextViewText(android.R.id.text1, sTemp);
// if you don't see the update, but a "Loading..." message instead, you may
// have made a mistake (in the layout xml file) that the widget doesn't forgive
} else {
rv = new RemoteViews(mContext.getPackageName(), R.layout.widgetlist_item);
// "Complete" checkbox. RemoteViews limitations:
// * this ImageButton simulates a checkbox for android widgets
// * you can't use the actual CheckBox in API < 31
// * Used in widgetlist_item.xml
// * we can't call setChecked() on ImageButtons, so we change the drawable
// * we also can't use setSelected, setActivated, setChecked on the widget
final int visibleCheckBox;
final int hiddenCheckBox;
if (theme == ListWidgetConfig.THEME_LIGHT) {
// show only the "light" imagebutton for the light theme
hiddenCheckBox = R.id.completedCheckBoxDark;
visibleCheckBox = R.id.completedCheckBoxLight;
} else {
hiddenCheckBox = R.id.completedCheckBoxLight;
visibleCheckBox = R.id.completedCheckBoxDark;
}
// 0 if user did not complete the task, > 0 otherwise
long millisOfCompletion = mCursor.getLong(3);
if (millisOfCompletion > 0) {
rv.setImageViewResource(visibleCheckBox, R.drawable.ic_checkbox_checked);
} else {
rv.setImageViewResource(visibleCheckBox, R.drawable.ic_checkbox_unchecked);
}
// use the accent color to tint the checkboxes.
// You could also use primaryTextColor, if users complain ...
int checkboxColor = ThemeHelper.getThemeAccentColor(mContext);
rv.setInt(visibleCheckBox, "setColorFilter", checkboxColor);
rv.setViewVisibility(hiddenCheckBox, View.GONE);
rv.setViewVisibility(visibleCheckBox,
isCheckboxHidden ? View.GONE : View.VISIBLE);
// Spacer
rv.setViewVisibility(R.id.itemSpacer,
isCheckboxHidden ? View.GONE : View.VISIBLE);
// Date
if (mCursor.isNull(4)) {
rv.setTextViewText(R.id.dueDate, "");
isDateHidden = true;
} else {
rv.setTextViewText(R.id.dueDate, mDateFormatter
.format(new Date(mCursor.getLong(4))));
}
rv.setViewVisibility(R.id.dueDate, isDateHidden ? View.GONE : View.VISIBLE);
rv.setTextColor(R.id.dueDate, primaryTextColor);
// Text
rv.setTextColor(android.R.id.text1, primaryTextColor);
rv.setInt(android.R.id.text1, "setMaxLines", rows);
// Only if task it not locked
if (mCursor.getInt(9) != 1) {
rv.setTextViewText(android.R.id.text1, TitleNoteTextView.getStyledText(
mCursor.getString(1), mCursor.getString(2),
1.0f, 1, 0));
} else {
// Just title
rv.setTextViewText(android.R.id.text1, TitleNoteTextView.getStyledText(
mCursor.getString(1), 1.0f, 1, 0));
}
// Set the click intent
if (widgetPrefs.getBoolean(ListWidgetConfig.KEY_LOCKSCREEN, false)) {
final Intent clickIntent = new Intent()
.setAction(Intent.ACTION_EDIT)
.setData(Task.getUri(mCursor.getLong(0)))
.putExtra(TaskDetailFragment.ARG_ITEM_LIST_ID, listId);
rv.setOnClickFillInIntent(R.id.widget_item, clickIntent);
} else {
// on the launcher, not on the lock screen
final Intent fillInIntent = new Intent()
.setAction(ListWidgetProvider.CLICK_ACTION)
.putExtra(ListWidgetProvider.EXTRA_NOTE_ID, mCursor.getLong(0))
.putExtra(ListWidgetProvider.EXTRA_LIST_ID, listId);
rv.setOnClickFillInIntent(R.id.widget_item, fillInIntent);
}
// Set complete broadcast
final Intent completeIntent = new Intent();
if (widgetPrefs.getBoolean(ListWidgetConfig.KEY_LOCKSCREEN, false)) {
// on lock screen => have to open note
completeIntent
.setAction(Intent.ACTION_EDIT)
.setData(Task.getUri(mCursor.getLong(0)))
.putExtra(TaskDetailFragment.ARG_ITEM_LIST_ID, listId);
} else {
// the pseudo-checkbox of a note was pressed while on the launcher
// => not on lock screen => send broadcast to complete.
completeIntent
.setAction(ListWidgetProvider.COMPLETE_ACTION)
.putExtra(ListWidgetProvider.EXTRA_NOTE_ID,
mCursor.getLong(0));
}
rv.setOnClickFillInIntent(R.id.completedCheckBoxDark, completeIntent);
rv.setOnClickFillInIntent(R.id.completedCheckBoxLight, completeIntent);
}
}
return rv;
}
/**
* We aren't going to return a custom loading view, so the OS will show some text like
* "Loading..." or "Caricamento..." depending on the system locale.
*/
@Override
public RemoteViews getLoadingView() {
return null;
}
@Override
public int getViewTypeCount() {
return 2;
}
@Override
public long getItemId(int position) {
return position;
}
@Override
public boolean hasStableIds() {
return true;
}
@Override
public void onDataSetChanged() {
// Revert back to our process' identity so we can work with our content provider
final long identityToken = Binder.clearCallingIdentity();
// Refresh the cursor
if (mCursor != null) {
mCursor.close();
}
// (re)load dateformatter in case preferences changed
mDateFormatter = TimeFormatter.getLocalFormatterMicro(mContext);
// Get widget settings
final WidgetPrefs widgetPrefs = new WidgetPrefs(mContext, mAppWidgetId);
final Uri targetUri;
final long listId = widgetPrefs.getLong(ListWidgetConfig.KEY_LIST,
ListWidgetConfig.ALL_LISTS_ID);
final String sortSpec;
final String sortType = widgetPrefs.getString(ListWidgetConfig.KEY_SORT_TYPE,
mContext.getString(R.string.default_sorttype));
boolean isShowingCompleted = widgetPrefs
.getBoolean(ListWidgetConfig.KEY_SHOWCOMPLETED, false);
if (sortType.equals(mContext.getString(R.string.const_possubsort)) && listId > 0) {
targetUri = Task.URI;
sortSpec = Task.Columns.LEFT;
} else if (sortType.equals(mContext.getString(R.string.const_modified))) {
targetUri = Task.URI;
sortSpec = Task.Columns.UPDATED + " DESC";
} else if (sortType.equals(mContext.getString(R.string.const_duedate))) {
// due date sorting
targetUri = Task.URI_SECTIONED_BY_DATE;
sortSpec = null;
} else {
// Alphabetic
targetUri = Task.URI;
sortSpec = mContext.getString(R.string.const_as_alphabetic, Task.Columns.TITLE);
}
String listWhere;
String[] listArg;
if (listId > 0) {
// only get notes in that list id
listArg = new String[] { Long.toString(listId) };
// if user does not want to also show completed tasks in widget, the query
// will filter away database records with a "completed" unix time
listWhere = isShowingCompleted
? "CAST(" + Task.Columns.DBLIST + " AS INTEGER) IS ?"
: "CAST(" + Task.Columns.DBLIST + " AS INTEGER) IS ? AND " + Task.Columns.COMPLETED + " IS NULL";
} else {
// all list ids
listArg = null;
// if user wants to show completed tasks, since here it shows from all lists,
// then this "where" should show everything. In android logic, that means
// sending "null" to .query() here below
listWhere = isShowingCompleted
? null
: Task.Columns.COMPLETED + " IS NULL";
}
// TODO this is a very slow query, it takes 40 seconds. See #574. Of these, 20
// can be shaved off by removing CAST() from listWhere in this function, but
// that would cause notes to disappear from the list widget when sorting by
// due date and showing only one note list, see #560
mCursor = mContext
.getContentResolver()
.query(targetUri, Task.Columns.FIELDS, listWhere, listArg, sortSpec);
// Restore the identity - not sure if it's needed since we're going
// to return right here, but it just *seems* cleaner
Binder.restoreCallingIdentity(identityToken);
}
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/widget/list/WidgetPrefs.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.notepad.widget.list;
import android.content.Context;
import android.content.SharedPreferences;
/**
* An helper class to interact with shared preferences related to the "list widget"
*/
public class WidgetPrefs {
public final static String PREFS_KEY = "NotesListWidget";
public final static String WIDGET_PRESENT_KEY = "WidgetPresent";
public final static boolean WIDGET_PRESENT_DEFAULT = false;
private final int widgetId;
private final SharedPreferences prefs;
private SharedPreferences.Editor prefsEditor = null;
public static void delete(final Context context, final int widgetId) {
SharedPreferences prefs = context.getSharedPreferences(PREFS_KEY, Context.MODE_PRIVATE);
if (prefs != null) {
SharedPreferences.Editor edit = prefs.edit();
if (edit != null) {
edit.remove(keyWrap(WIDGET_PRESENT_KEY, widgetId)).commit();
}
}
}
public WidgetPrefs(final Context context, final int widgetId) {
this.widgetId = widgetId;
prefs = context.getSharedPreferences(PREFS_KEY, Context.MODE_PRIVATE);
}
public String keyWrap(final String originalKey) {
return keyWrap(originalKey, widgetId);
}
public static String keyWrap(final String originalKey, final int widgetId) {
return originalKey + widgetId;
}
public boolean isPresent() {
if (prefs != null) {
return prefs.getBoolean(keyWrap(WIDGET_PRESENT_KEY), WIDGET_PRESENT_DEFAULT);
}
return false;
}
public void setPresent() {
putBoolean(WIDGET_PRESENT_KEY, true);
}
public boolean putBoolean(String key, boolean value) {
if (prefs != null && prefsEditor == null) {
prefsEditor = prefs.edit();
}
if (prefsEditor != null) {
prefsEditor.putBoolean(keyWrap(key), value).commit();
return true;
}
return false;
}
public boolean getBoolean(String key, boolean defValue) {
if (prefs != null) {
try {
return prefs.getBoolean(keyWrap(key), defValue);
} catch (ClassCastException e) {
// Return default value
}
}
return false;
}
public void putString(String key, String value) {
if (prefs != null && prefsEditor == null) {
prefsEditor = prefs.edit();
}
if (prefsEditor != null) {
prefsEditor.putString(keyWrap(key), value).commit();
}
}
public String getString(String key, String defValue) {
if (prefs != null) {
try {
return prefs.getString(keyWrap(key), defValue);
} catch (ClassCastException e) {
// Return default value
}
}
return defValue;
}
public int getInt(String key, int defValue) {
if (prefs != null) {
try {
return prefs.getInt(keyWrap(key), defValue);
} catch (ClassCastException e) {
// Return default value
}
}
return defValue;
}
public void putInt(String key, int value) {
if (prefs != null && prefsEditor == null) {
prefsEditor = prefs.edit();
}
if (prefsEditor != null) {
prefsEditor.putInt(keyWrap(key), value).commit();
}
}
public void putLong(String key, long value) {
if (prefs != null && prefsEditor == null) {
prefsEditor = prefs.edit();
}
if (prefsEditor != null) {
prefsEditor.putLong(keyWrap(key), value).commit();
}
}
public long getLong(String key, long defValue) {
if (prefs != null) {
try {
return prefs.getLong(keyWrap(key), defValue);
} catch (ClassCastException e) {
// Return default value
}
}
return defValue;
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/notepad/widget/shortcut/ShortcutConfig.java
================================================
package com.nononsenseapps.notepad.widget.shortcut;
import android.content.Intent;
import android.content.pm.ShortcutInfo;
import android.content.pm.ShortcutManager;
import android.database.Cursor;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.drawable.AdaptiveIconDrawable;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.Icon;
import android.os.Build;
import android.os.Bundle;
import android.widget.SimpleCursorAdapter;
import android.widget.Spinner;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.loader.app.LoaderManager;
import androidx.loader.content.CursorLoader;
import androidx.loader.content.Loader;
import com.nononsenseapps.helpers.ActivityHelper;
import com.nononsenseapps.helpers.NnnLogger;
import com.nononsenseapps.helpers.ThemeHelper;
import com.nononsenseapps.notepad.R;
import com.nononsenseapps.notepad.activities.main.ActivityMain_;
import com.nononsenseapps.notepad.database.Task;
import com.nononsenseapps.notepad.database.TaskList;
import com.nononsenseapps.notepad.databinding.ActivityShortcutConfigBinding;
/**
* Shows a window to configure the app's smaller widget, letting the user choose which note list
* will be opened
*/
public class ShortcutConfig extends AppCompatActivity {
/**
* for {@link R.layout#activity_shortcut_config}
*/
private ActivityShortcutConfigBinding mBinding;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
// Must do this before super.onCreate
ThemeHelper.setTheme(this);
ActivityHelper.setSelectedLanguage(this);
super.onCreate(savedInstanceState);
mBinding = ActivityShortcutConfigBinding.inflate(getLayoutInflater());
setContentView(mBinding.getRoot());
mBinding.ok.setOnClickListener(x -> onOK());
// Default result is fail
setResult(RESULT_CANCELED);
setListEntries(mBinding.listSpinner);
}
/**
* @return a {@link Bitmap} representing the given {@link Drawable}. Supports also
* {@link AdaptiveIconDrawable}, so you can build a bitmap of an adaptive icon
*/
@NonNull
private static Bitmap getBitmapFromDrawable(@NonNull Drawable drawable) {
final Bitmap bmp = Bitmap.createBitmap(
drawable.getIntrinsicWidth(),
drawable.getIntrinsicHeight(),
Bitmap.Config.ARGB_8888);
final Canvas canvas = new Canvas(bmp);
drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight());
drawable.draw(canvas);
return bmp;
}
void onOK() {
// newer android versions have stricter limits on nested Intents
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ShortcutManager shortcutManager = this.getSystemService(ShortcutManager.class);
String shortcutTitle = "";
final Intent intent = new Intent();
if (mBinding.createNoteSwitch.isChecked()) {
String listName = null;
final Cursor c = (Cursor) mBinding.listSpinner.getSelectedItem();
if (c != null && !c.isClosed() && !c.isAfterLast()) {
listName = c.getString(1);
}
if (listName == null) {
NnnLogger.error(ShortcutConfig.class, "Unexpected null in listName in ShortcutConfig.java");
}
shortcutTitle = ShortcutConfig.this.getString(R.string.title_create) + " - " + listName;
intent.setClass(ShortcutConfig.this, ActivityMain_.class)
.setData(Task.URI)
.setAction(Intent.ACTION_INSERT)
.putExtra(Task.Columns.DBLIST, mBinding.listSpinner.getSelectedItemId());
} else {
// this shortcut widget shows a list of notes
final Cursor c = (Cursor) mBinding.listSpinner.getSelectedItem();
if (c != null && !c.isClosed() && !c.isAfterLast()) {
shortcutTitle = c.getString(1);
}
intent.setClass(ShortcutConfig.this, ActivityMain_.class)
.setAction(Intent.ACTION_VIEW)
.setData(TaskList.getUri(mBinding.listSpinner.getSelectedItemId()));
}
// widget IDs must be unique. We use unique titles for all widget combinations
String shortcutId = shortcutTitle;
ShortcutInfo shortcut = new ShortcutInfo.Builder(this, shortcutId)
.setShortLabel(shortcutTitle)
.setLongLabel(shortcutTitle)
.setIcon(Icon.createWithResource(this, R.drawable.app_icon))
.setIntent(intent)
.build();
shortcutManager.requestPinShortcut(shortcut, null);
setResult(RESULT_OK);
return;
}
// legacy code that still works for API 23 and 24
final Intent shortcutIntent = new Intent();
// Set the icon for the shortcut widget
Drawable iconDrawable = AppCompatResources.getDrawable(this, R.drawable.app_icon);
// we have to give it a bitmap, or else the icon does not appear in simple launcher
// https://github.com/SimpleMobileTools/Simple-Launcher
shortcutIntent.putExtra(Intent.EXTRA_SHORTCUT_ICON, getBitmapFromDrawable(iconDrawable));
String shortcutTitle = "";
final Intent intent = new Intent();
if (mBinding.createNoteSwitch.isChecked()) {
shortcutTitle = ShortcutConfig.this.getString(R.string.title_create);
intent.setClass(ShortcutConfig.this, ActivityMain_.class)
.setData(Task.URI)
.setAction(Intent.ACTION_INSERT)
.putExtra(Task.Columns.DBLIST, mBinding.listSpinner.getSelectedItemId());
} else {
// this shortcut widget shows a list of notes
final Cursor c = (Cursor) mBinding.listSpinner.getSelectedItem();
if (c != null && !c.isClosed() && !c.isAfterLast()) {
shortcutTitle = c.getString(1);
}
shortcutIntent.putExtra(Intent.EXTRA_SHORTCUT_NAME,
"" + mBinding.listSpinner.getSelectedItem());
intent.setClass(ShortcutConfig.this, ActivityMain_.class)
.setAction(Intent.ACTION_VIEW)
.setData(TaskList.getUri(mBinding.listSpinner.getSelectedItemId()));
}
shortcutIntent.putExtra(Intent.EXTRA_SHORTCUT_INTENT, intent);
shortcutIntent.putExtra(Intent.EXTRA_SHORTCUT_NAME, shortcutTitle);
setResult(RESULT_OK, shortcutIntent);
// Destroy activity
finish();
}
private void setListEntries(final Spinner listSpinner) {
final SimpleCursorAdapter mSpinnerAdapter = new SimpleCursorAdapter(
this, android.R.layout.simple_spinner_dropdown_item, null,
new String[] { TaskList.Columns.TITLE },
new int[] { android.R.id.text1 }, 0);
listSpinner.setAdapter(mSpinnerAdapter);
LoaderManager
.getInstance(this)
.restartLoader(0, null, new LoaderManager.LoaderCallbacks() {
@NonNull
@Override
public Loader onCreateLoader(int id, Bundle args) {
return new CursorLoader(ShortcutConfig.this,
TaskList.URI,
new String[] { TaskList.Columns._ID, TaskList.Columns.TITLE },
null,
null,
TaskList.Columns.TITLE);
}
@Override
public void onLoadFinished(@NonNull Loader arg0, Cursor c) {
mSpinnerAdapter.swapCursor(c);
}
@Override
public void onLoaderReset(@NonNull Loader arg0) {
mSpinnerAdapter.swapCursor(null);
}
});
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/ui/DateView.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.ui;
import android.content.Context;
import android.text.format.DateFormat;
import android.util.AttributeSet;
import androidx.appcompat.widget.AppCompatTextView;
import com.nononsenseapps.helpers.TimeFormatter;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.Locale;
import java.util.TimeZone;
/**
* A simple textview that can display time.
*/
public class DateView extends AppCompatTextView {
// TODO everything in this "ui" namespace should be moved to its own gradle module
private static final int SECONDS_PER_DAY = 3600;
SimpleDateFormat mDateFormatter;
public DateView(Context context) {
super(context);
// TODO if you want to also show a "due time" on the note, use this instead:
// mDateFormatter = TimeFormatter.getLocalFormatterShort(context);
// as of now we only show the date, which for me is good enough.
mDateFormatter = TimeFormatter.getLocalFormatterShortDateOnly(context);
}
public DateView(Context context, AttributeSet attrs) {
super(context, attrs);
try {
mDateFormatter = TimeFormatter.getLocalFormatterShortDateOnly(context);
} catch (Exception e) {
// return a simple fallback formatter, just to show something in the view
mDateFormatter = new SimpleDateFormat("E d MMM yyyy, HH:ss", Locale.US);
}
}
public DateView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
mDateFormatter = TimeFormatter.getLocalFormatterShortDateOnly(context);
}
public void setTimeText(final long time) {
super.setText(mDateFormatter.format(new Date(time)));
}
public static CharSequence toDate(String format, long msecs) {
// TODO remove this
// String format = day;
try {
Calendar c = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
c.setTimeInMillis(msecs);
return DateFormat.format(format, c);
} catch (Exception e) {
return "";
}
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/ui/DelegateFrame.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.ui;
import android.content.Context;
import android.content.res.TypedArray;
import android.util.AttributeSet;
import android.view.View;
import android.view.View.OnClickListener;
import android.widget.RelativeLayout;
import com.nononsenseapps.helpers.NnnLogger;
import com.nononsenseapps.notepad.R;
/**
* This class is designed to act as a simple version of the touch delegate. E.g.
* it is intended to enlarge the touch area for a specified child view.
*
* Define it entirely in XML as the following example demonstrates:
*
*
*
* It's important to add android:clickable="true" and
* app:enlargedView="@+id/YOURIDHERE"
*/
public class DelegateFrame extends RelativeLayout implements OnClickListener {
// TODO is this useless ? it wraps 2 checkboxes. try to delete it
private final int enlargedViewId;
private View cachedView;
private static final int UNDEFINED = -1;
public DelegateFrame(Context context) {
this(context, null);
}
public DelegateFrame(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public DelegateFrame(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
// set values from XML
TypedArray a = this.getContext().obtainStyledAttributes(attrs, R.styleable.DelegateFrame);
enlargedViewId = a.getResourceId(R.styleable.DelegateFrame_enlargedView, UNDEFINED);
// enlargedViewId = attrs.getAttributeResourceValue("http://nononsenseapps.com", "enlargedView", UNDEFINED);
// NnnLogger.debug(DelegateFrame.class, "setting xml values! view: " + enlargedViewId);
setOnClickListener(this);
a.recycle();
}
@Override
public void onClick(View v) {
if (cachedView == null && enlargedViewId != UNDEFINED) {
cachedView = findViewById(enlargedViewId);
}
NnnLogger.debug(DelegateFrame.class,
"onTouchEvent! view is null?: " + (cachedView == null));
if (cachedView != null) {
cachedView.performClick();
}
}
}
================================================
FILE: app/src/main/java/com/nononsenseapps/ui/ExtraTypesCursorAdapter.java
================================================
/*
* Copyright (c) 2015 Jonas Kalderstam.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.nononsenseapps.ui;
import android.content.Context;
import android.database.Cursor;
import com.nononsenseapps.notepad.activities.main.ActivityMain;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
/**
* It's for something in the drawer in {@link ActivityMain}
*/
public class ExtraTypesCursorAdapter extends ExtrasCursorAdapter {
protected final int[] extraTypes;
protected final int[] extraLayouts;
protected ArrayList> extraData = null;
private final int typeCount;
/**
* Extra types should be numbered from 1-length-1. Use 0 if you want the standard layout.
*
* Extra layouts should correspond to type, e.g. index 0 = type 1, index 1 = type 2.
*/
public ExtraTypesCursorAdapter(Context context, int layout, Cursor c,
String[] from, int[] to, int[] extraIds, int[] extraLabels, int[] extraTypes, int[] extraLayouts) {
super(context, layout, c, from, to, extraIds, extraLabels, layout);
this.extraTypes = extraTypes;
this.extraLayouts = extraLayouts;
typeCount = countTypes();
}
private int countTypes() {
HashSet types = new HashSet<>();
for (int type : extraTypes) {
types.add(type);
}
// Default layout
types.add(0);
return types.size();
}
@Override
public int getViewTypeCount() {
return typeCount;
}
@Override
public int getItemViewType(final int position) {
if (position < extraIds.length) {
return extraTypes[position];
} else {
return 0;
}
}
@Override
protected int getItemLayout(final int position) {
final int type = getItemViewType(position);
if (position < extraIds.length && type > 0) {
return extraLayouts[type - 1];
} else {
return layout;
}
}
@Override
protected void setExtraText(final ViewHolder viewHolder, final int position) {
if (extraData == null || extraData.isEmpty()) {
super.setExtraText(viewHolder, position);
}
// set all fields
final List