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 [![Android build](https://github.com/spacecowboy/NotePad/actions/workflows/android_build.yml/badge.svg)](https://github.com/spacecowboy/NotePad/actions/workflows/android_build.yml) [![Android tests](https://github.com/spacecowboy/NotePad/actions/workflows/android_tests.yml/badge.svg)](https://github.com/spacecowboy/NotePad/actions/workflows/android_tests.yml) [![Translation status](https://hosted.weblate.org/widgets/no-nonsense-notes/-/android-strings/svg-badge.svg)](https://hosted.weblate.org/engage/no-nonsense-notes/) \ \ _A note-taking app for Android with reminders, since 2012_ [Get it on F-Droid](https://f-droid.org/repository/browse/?fdid=com.nononsenseapps.notepad) [Get it on Google Play](https://play.google.com/store/apps/details?id=com.nononsenseapps.notepad.play) Phone UI _ Tablet UI ## Translation Help translate the app on [Hosted Weblate](https://hosted.weblate.org/projects/no-nonsense-notes/) \ Translation status ## 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,

asEmptyCommaStringExcept(new String[] { "a", "b", "c", "d" , "e" },"b",
	 * "HELLO","c","WORLD","d","!!!!")
will return
null,HELLO,'WORLD','!!!!',null
* * 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 dataRow = extraData.get(position); Object col; int i; for (i = 0; i < viewHolder.texts.length && i < dataRow.size(); i++) { col = dataRow.get(i); if (col instanceof Integer) { viewHolder.texts[i].setText(context.getText((Integer) col)); } else { viewHolder.texts[i].setText(col.toString()); } } } public void setExtraData(ArrayList> extras) { this.extraData = extras; } } ================================================ FILE: app/src/main/java/com/nononsenseapps/ui/ExtrasCursorAdapter.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 android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.ResourceCursorAdapter; import android.widget.TextView; /** * Mimics the SimpleCursorAdapter, but also allows extra items to be injected at * the beginning. When asked for id for the extra items, the defined ids are returned. * Make sure to set them to negative values (< -1) in order not to confuse them with * database IDs. * * @author Jonas */ public class ExtrasCursorAdapter extends ResourceCursorAdapter { // private static final String TAG = "ExtrasCursorAdapter"; private Cursor cursor; protected final Context context; protected final int[] extraIds; protected final int[] extraLabels; protected final String[] from; protected final int[] to; protected final int layout; protected final int dropdownlayout; /** * Same as a cursoradapter except two extra arrays are taken (and a layout). * The first is an array of what IDs you want to assign your items so you * can identify them later. Second is an array of ids to the String * resources to use as labels. */ public ExtrasCursorAdapter(Context context, int layout, Cursor c, String[] from, int[] to, int[] extraIds, int[] extraLabels, int dropdownlayout) { super(context, layout, c, 0); this.cursor = c; this.extraIds = extraIds; this.extraLabels = extraLabels; this.context = context; this.from = from; this.to = to; this.layout = layout; this.dropdownlayout = dropdownlayout; } /** * Same as a cursoradapter except two extra arrays are taken (and a layout). * The first is an array of what IDs you want to assign your items so you * can identify them later. Second is an array of ids to the String * resources to use as labels. */ public ExtrasCursorAdapter(Context context, int layout, Cursor c, int flags, String[] from, int[] to, int[] extraIds, int[] extraLabels, int dropdownlayout) { super(context, layout, c, flags); this.cursor = c; this.extraIds = extraIds; this.extraLabels = extraLabels; this.context = context; this.from = from; this.to = to; this.layout = layout; this.dropdownlayout = dropdownlayout; } @Override public void bindView(View view, Context context, Cursor cursor) { int i; ViewHolder viewHolder = (ViewHolder) view.getTag(); if (viewHolder == null) { viewHolder = setViewHolder(view); } // Fetch from database for (i = 0; i < from.length; i++) { final int colIndex = cursor.getColumnIndex(from[i]); String txt = cursor.getString(colIndex); viewHolder.texts[i].setText(txt); } } /** * Initializes the viewholder according to the specified from/to arrays. */ private ViewHolder setViewHolder(View view) { ViewHolder viewHolder = new ViewHolder(); viewHolder.texts = new TextView[from.length]; int i; for (i = 0; i < from.length; i++) { viewHolder.texts[i] = view.findViewById(to[i]); } view.setTag(viewHolder); return viewHolder; } @Override public Cursor swapCursor(Cursor newCursor) { this.cursor = newCursor; return super.swapCursor(newCursor); } @Override public View getView(int position, View convertView, ViewGroup parent) { if (cursor != null && position >= extraLabels.length) return super.getView(position - extraLabels.length, convertView, parent); ViewHolder viewHolder = null; if (convertView == null) { // Make a new view LayoutInflater mInflater = LayoutInflater.from(context); convertView = mInflater.inflate(getItemLayout(position), parent, false); } else { viewHolder = (ViewHolder) convertView.getTag(); } if (viewHolder == null) { viewHolder = setViewHolder(convertView); } setExtraText(viewHolder, position); return convertView; } /** * Only sets the first field */ protected void setExtraText(final ViewHolder viewHolder, final int position) { viewHolder.texts[0].setText(context.getText(extraLabels[position])); } // TODO method to update extra labels @Override public View getDropDownView(int position, View convertView, ViewGroup parent) { if (cursor != null && position >= extraLabels.length) return super.getDropDownView(position - extraLabels.length, convertView, parent); ViewHolder viewHolder = null; if (convertView == null) { // Make a new view LayoutInflater mInflater = LayoutInflater.from(context); convertView = mInflater.inflate(dropdownlayout, parent, false); } else { viewHolder = (ViewHolder) convertView.getTag(); } if (viewHolder == null) { viewHolder = setViewHolder(convertView); } setExtraText(viewHolder, position); return convertView; } protected int getItemLayout(final int position) { return layout; } @Override public long getItemId(int position) { if (position < extraIds.length) { return extraIds[position]; } else { // TODO it crashed here in the tests once (tries to open a closed cursor) // what could this function return to represent an INVALID id ? long id = super.getItemId(position - extraIds.length); return id; } } @Override public Object getItem(int position) { if (position < extraIds.length) { return getExtraItem(position); } else { return super.getItem(position - extraIds.length); } } @Override public int getCount() { if (extraIds != null) return super.getCount() + extraIds.length; else return super.getCount(); } /** * Should be a number >= count of the wrapped cursor */ public CharSequence getExtraItem(int realPos) { if (extraLabels.length == 0 || realPos < -1 || realPos > extraLabels.length) return null; else return context.getText(extraLabels[realPos]); } static class ViewHolder { TextView[] texts; } } ================================================ FILE: app/src/main/java/com/nononsenseapps/ui/GreyableToggleButton.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.ColorStateList; import android.content.res.TypedArray; import android.util.AttributeSet; import androidx.appcompat.widget.AppCompatToggleButton; import androidx.core.content.ContextCompat; import com.nononsenseapps.notepad.R; /** * A Checkbox like textview. It displays its 2 states by toggling between 2 text * colors: the primary text color and grey. Used in {@link WeekDaysView} */ public class GreyableToggleButton extends AppCompatToggleButton { // text colors for checked & unchecked status private final ColorStateList primaryColor; private final int secondaryColor; /** * See also "GreyableButtonToggle" in styles.xml */ public GreyableToggleButton(Context context, AttributeSet attrs) { super(context, attrs); primaryColor = getTextColorPrimary(context); secondaryColor = ContextCompat.getColor(this.getContext(), R.color.uncheckedGrey); } /** * Correct and mandatory way to get the text color for the "selected" state */ private static ColorStateList getTextColorPrimary(Context context) { TypedArray a = context .obtainStyledAttributes(new int[] { android.R.attr.textColorPrimary }); ColorStateList color = a.getColorStateList(a.getIndex(0)); a.recycle(); return color; } @Override public void setChecked(boolean checked) { super.setChecked(checked); // Set correct text color if (checked) { super.setTextColor(primaryColor); } else { super.setTextColor(secondaryColor); } // note that the code that reacts to days being pressed in the reminder view is // not here, it's in NotificationItemHelper.java and WeekDaysView.java } } ================================================ FILE: app/src/main/java/com/nononsenseapps/ui/NoteCheckBox.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.util.AttributeSet; import androidx.appcompat.widget.AppCompatCheckBox; /** * Convenience class to use in listviews. Bind the id to the checkbox in order to * use a onCheckedChangeListener more easily. */ public class NoteCheckBox extends AppCompatCheckBox { private long noteId = -1; public NoteCheckBox(Context context) { super(context); } public NoteCheckBox(Context context, AttributeSet attrs) { super(context, attrs); } public NoteCheckBox(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); } public long getNoteId() { return noteId; } public void setNoteId(long noteId) { this.noteId = noteId; } } ================================================ FILE: app/src/main/java/com/nononsenseapps/ui/NotificationItemHelper.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.app.DatePickerDialog; import android.app.TimePickerDialog; import android.content.Context; import android.view.View; import android.widget.DatePicker; import android.widget.LinearLayout; import android.widget.TextView; import android.widget.Toast; import com.nononsenseapps.helpers.TimeFormatter; import com.nononsenseapps.notepad.R; import com.nononsenseapps.notepad.database.Notification; import com.nononsenseapps.notepad.database.Task; import com.nononsenseapps.notepad.fragments.TaskDetailFragment; import java.util.Calendar; import java.util.Date; import java.util.GregorianCalendar; /** * Handle setting up all the listeners for a Notification list item */ public final class NotificationItemHelper { private static String getDateString(final Context context, final long time) { return TimeFormatter.getDateFormatter(context).format(new Date(time)); } private static void switchToTime(final View nv) { showViews(nv.findViewById(R.id.notificationTime), nv.findViewById(R.id.notificationDate), nv.findViewById(R.id.notificationTypeTime), nv.findViewById(R.id.weekdays)); // hide this view nv.findViewById(R.id.repeatSwitch).setVisibility(View.GONE); } private static void showViews(final View... views) { for (View v : views) { v.setVisibility(View.VISIBLE); } } private static void setTime(final Context context, final Notification not, final Task mTask) { final GregorianCalendar cal = TimeFormatter.getLocalCalendar(context); // Start with date, either due date or today (default) // If due date is in the past, default to today + 1hour if (mTask.due != null && mTask.due > cal.getTimeInMillis()) { cal.setTimeInMillis(mTask.due); } else { // Default to today, set time one hour from now cal.add(Calendar.HOUR_OF_DAY, 1); } // And set time on notification not.time = cal.getTimeInMillis(); } /** * add a new reminder and show it in the note detail page * * @param fragment the {@link TaskDetailFragment} that will host this reminder widget * @param notificationList the list in {@link TaskDetailFragment} that contains this reminder * widget */ public static void setup(final TaskDetailFragment fragment, final LinearLayout notificationList, final View nv, final Notification not, final Task mTask) { switchToTime(nv); // Set time on notification if not set already if (not.time == null) { setTime(fragment.getActivity(), not, mTask); } // Set time text final TextView notTimeButton = nv.findViewById(R.id.notificationTime); notTimeButton.setText(not.getLocalTimeText(fragment.getActivity())); // Set date text final TextView notDateButton = nv.findViewById(R.id.notificationDate); notDateButton.setText(getDateString(fragment.getActivity(), not.time)); final View notRemoveButton = nv.findViewById(R.id.notificationRemove); // Remove button notRemoveButton.setOnClickListener(v -> { if (fragment.isLocked()) { Toast.makeText(fragment.getContext(), R.string.canceled_note_locked, Toast.LENGTH_SHORT).show(); return; } // Remove row from UI notificationList.removeView((View) v.getParent()); // Remove from database and renotify not.delete(fragment.getActivity()); }); // Date button notDateButton.setOnClickListener(v -> { if (fragment.isLocked()) { Toast.makeText(fragment.getContext(), R.string.canceled_note_locked, Toast.LENGTH_SHORT).show(); return; } final Calendar localTime = Calendar.getInstance(); if (not.time != null) { localTime.setTimeInMillis(not.time); } var onDateSetListnr = new DatePickerDialog.OnDateSetListener() { @Override public void onDateSet(DatePicker dialog, int year, int monthOfYear, int dayOfMonth) { localTime.set(Calendar.YEAR, year); localTime.set(Calendar.MONTH, monthOfYear); localTime.set(Calendar.DAY_OF_MONTH, dayOfMonth); not.time = localTime.getTimeInMillis(); notDateButton.setText(not.getLocalDateText(fragment.getActivity())); not.save(fragment.getActivity(), true); } }; // configure and show a popup with a date-picker calendar view final DatePickerDialog datedialog = new DatePickerDialog( fragment.requireContext(), onDateSetListnr, localTime.get(Calendar.YEAR), localTime.get(Calendar.MONTH), localTime.get(Calendar.DAY_OF_MONTH)); datedialog.setTitle(R.string.select_date); datedialog.show(); }); // Time button notTimeButton.setOnClickListener(v -> { if (fragment.isLocked()) { Toast.makeText(fragment.getContext(), R.string.canceled_note_locked, Toast.LENGTH_SHORT).show(); return; } // Display time picker final Calendar localTime = Calendar.getInstance(); if (not.time != null) { localTime.setTimeInMillis(not.time); } TimePickerDialog.OnTimeSetListener onTimeSetListener = (view, hourOfDay, minute) -> { localTime.set(Calendar.HOUR_OF_DAY, hourOfDay); localTime.set(Calendar.MINUTE, minute); not.time = localTime.getTimeInMillis(); notTimeButton.setText(not.getLocalTimeText(fragment.getActivity())); not.save(fragment.getActivity(), true); }; final TimePickerDialog timedialog = fragment .getTimePickerDialog(localTime, onTimeSetListener); timedialog.setTitle(R.string.time); timedialog.show(); }); // week days button strip WeekDaysView days = nv.findViewById(R.id.weekdays); days.setCheckedDays(not.repeats); days.setOnCheckedDaysChangedListener(checkedDays -> { if (fragment.isLocked()) { Toast.makeText(fragment.getContext(), R.string.canceled_note_locked, Toast.LENGTH_SHORT).show(); return false; // return the button to the previous state } not.repeats = checkedDays; not.saveInBackground(fragment.getActivity(), true); return true; // all ok, it can proceed }); } } ================================================ FILE: app/src/main/java/com/nononsenseapps/ui/ShowcaseHelper.java ================================================ package com.nononsenseapps.ui; import android.view.View; import androidx.annotation.NonNull; import androidx.annotation.StringRes; import androidx.appcompat.widget.Toolbar; import androidx.fragment.app.FragmentActivity; import com.getkeepsafe.taptargetview.TapTarget; import com.getkeepsafe.taptargetview.TapTargetView; import com.nononsenseapps.helpers.NnnLogger; import com.nononsenseapps.helpers.ThemeHelper; /** * Holds all code related to the showcase view, which for now is provided * by the {@link TapTargetView} library */ public final class ShowcaseHelper { /** * Create, configure and show a view to highlight the overflow menu, using a library. * The view is shown above the given {@link FragmentActivity} and features a * title and a short description */ public static void showForOverflowMenu(@NonNull FragmentActivity activity, @StringRes int titleStringId, @StringRes int descriptionStringId) { // get the toolbar from the activity Toolbar tBar = activity.findViewById(androidx.appcompat.R.id.action_bar); // always a good idea to check if (tBar == null) { NnnLogger.error(ShowcaseHelper.class, "Can't show the TapTargetView, the Toolbar is unavailable"); return; } var target2 = TapTarget.forToolbarOverflow(tBar, activity.getString(titleStringId), activity.getString(descriptionStringId)); finishConfiguringAndShow(target2, activity); } /** * All functions in this class share this common configuration for the TapTargetView, but to * highlight a {@link View} on the {@link FragmentActivity} you would implement an alternative * to {@link #showForOverflowMenu} */ private static void finishConfiguringAndShow(TapTarget target, FragmentActivity activity) { // TODO can *you* make it prettier ? See also https://github.com/KeepSafe/TapTargetView target.outerCircleAlpha(0.9f) .drawShadow(true) .cancelable(true) // tap outside the circle to dismiss the showcaseView .tintTarget(false) // TODO not good for Material YOU themes. shows green text on a green circle, for example .textColorInt(ThemeHelper.getThemeAccentColor(activity)); // this listener will always dismiss the taptargetview, regardless of where you click. // It's less frustrating to use, and above all it makes the espresso tests work. var listener = new TapTargetView.Listener() { @Override public void onTargetClick(TapTargetView view) { view.dismiss(true); } @Override public void onTargetLongClick(TapTargetView view) { this.onTargetClick(view); } @Override public void onTargetCancel(TapTargetView view) { view.dismiss(false); } @Override public void onOuterCircleClick(TapTargetView view) { // this is probably the only important method to override, the rest is boilerplate view.dismiss(false); } @Override public void onTargetDismissed(TapTargetView view, boolean userInitiated) { // don't .dismiss(), because that causes a NullPointerException // in android.view.View.dispatchDetachedFromWindow(). See // https://github.com/KeepSafe/TapTargetView/issues/395#issuecomment-987952528 view.setVisibility(View.GONE); super.onTargetDismissed(view, userInitiated); } }; TapTargetView.showFor(activity, target, listener); } } ================================================ FILE: app/src/main/java/com/nononsenseapps/ui/StyledEditText.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.annotation.SuppressLint; import android.content.Context; import android.content.res.TypedArray; import android.text.Editable; import android.text.Layout; import android.text.Selection; import android.text.Spannable; import android.text.TextWatcher; import android.text.method.ArrowKeyMovementMethod; import android.text.style.ClickableSpan; import android.text.style.RelativeSizeSpan; import android.text.style.StyleSpan; import android.text.style.TypefaceSpan; import android.text.util.Linkify; import android.util.AttributeSet; import android.view.MotionEvent; import android.view.View; import androidx.appcompat.widget.AppCompatEditText; import com.nononsenseapps.notepad.R; /** * An EditText field that highlights the first line and makes links clickable in * the text. The text is still selectable, movable etc. */ public class StyledEditText extends AppCompatEditText { Object titleStyleSpan; Object titleSizeSpan; Object titleFamilySpan; Object bodyFamilySpan; private final float mTitleRelativeSize; private boolean mLinkify; private boolean mTitleLarger = true; public StyledEditText(Context context, AttributeSet attrs) { super(context, attrs); TypedArray a = context .getTheme() .obtainStyledAttributes(attrs, R.styleable.StyledTextView, 0, 0); int mTitleFontFamily; int mBodyFontFamily; int mTitleFontStyle; try { mTitleRelativeSize = a .getFloat(R.styleable.StyledTextView_titleRelativeSize, 1.0f); mTitleFontFamily = a .getInteger(R.styleable.StyledTextView_titleFontFamily, 0); mTitleFontStyle = a .getInteger(R.styleable.StyledTextView_titleFontStyle, 0); mLinkify = a .getBoolean(R.styleable.StyledTextView_linkify, false); mBodyFontFamily = a .getInteger(R.styleable.StyledTextView_bodyFontFamily, 0); } finally { a.recycle(); } setTitleRelativeLarger(mTitleLarger); setTitleFontFamily(mTitleFontFamily); setTitleFontStyle(mTitleFontStyle); setBodyFontFamily(mBodyFontFamily); // Style on change addTextChangedListener(new TextWatcher() { @Override public void onTextChanged(CharSequence s, int start, int before, int count) {} @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {} @Override public void afterTextChanged(Editable s) { spannify(s); if (mLinkify) { Linkify.addLinks(StyledEditText.this, Linkify.ALL); // Links shouldnt steal click focus // But text must still be selectable etc setMovementMethod(new ArrowKeyMovementMethod()); } } }); } /** * @param larger true will use layout defined size, else same size */ public void setTitleRelativeLarger(final boolean larger) { mTitleLarger = larger; titleSizeSpan = new RelativeSizeSpan(larger ? mTitleRelativeSize : 1.0f); } /** * @param family matches order defined in xml */ public void setTitleFontFamily(final int family) { switch (family) { case 1 -> titleFamilySpan = new TypefaceSpan("sans-serif-condensed"); case 2 -> titleFamilySpan = new TypefaceSpan("sans-serif-light"); case 3 -> titleFamilySpan = new TypefaceSpan("sans-serif-thin"); default -> titleFamilySpan = new TypefaceSpan("sans-serif"); } } /** * @param style matches order defined in xml */ public void setTitleFontStyle(final int style) { switch (style) { case 1 -> titleStyleSpan = new StyleSpan(android.graphics.Typeface.BOLD); case 2 -> titleStyleSpan = new StyleSpan(android.graphics.Typeface.ITALIC); default -> titleStyleSpan = new StyleSpan(android.graphics.Typeface.NORMAL); } } /** * @param family matches order defined in xml */ public void setBodyFontFamily(final int family) { switch (family) { case 1 -> bodyFamilySpan = new TypefaceSpan("sans-serif-condensed"); case 2 -> bodyFamilySpan = new TypefaceSpan("sans-serif-light"); case 3 -> bodyFamilySpan = new TypefaceSpan("sans-serif-thin"); default -> bodyFamilySpan = new TypefaceSpan("sans-serif"); } } /** * @param size 0, 1 or 2 representing small/medium/large */ public void setTheTextSize(final int size) { switch (size) { case 0 -> // small super.setTextSize(14.0f); case 2 -> // large super.setTextSize(22.0f); default -> // medium super.setTextSize(18.0f); } } /** * @param clickable if links should be clickable */ public void setLinkify(final boolean clickable) { this.mLinkify = clickable; } private void spannify(final Spannable s) { // Clear this first, or it will multiply! for (RelativeSizeSpan rs : s.getSpans(0, s.length(), RelativeSizeSpan.class)) { s.removeSpan(rs); } int titleEnd = s.toString().indexOf("\n"); if (titleEnd < 0) { titleEnd = s.toString().length(); } if (titleEnd > 0) { s.setSpan(titleStyleSpan, 0, titleEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); s.setSpan(titleSizeSpan, 0, titleEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); s.setSpan(titleFamilySpan, 0, titleEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); if (titleEnd < s.toString().length()) { s.setSpan(bodyFamilySpan, titleEnd, s.toString().length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); } } } /** * either opens the note or opens a link in the browser. It does not call * {@link View#performClick()} because there's no need to, the default * behavior is fine. */ @SuppressLint("ClickableViewAccessibility") @Override public boolean onTouchEvent(MotionEvent event) { if (this.getText() == null) { return super.onTouchEvent(event); } Spannable buffer = this.getText(); int action = event.getAction(); if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) { int x = (int) event.getX(); int y = (int) event.getY(); x -= this.getTotalPaddingLeft(); y -= this.getTotalPaddingTop(); x += this.getScrollX(); y += this.getScrollY(); Layout layout = this.getLayout(); int line = layout.getLineForVertical(y); int off = layout.getOffsetForHorizontal(line, x); ClickableSpan[] link = buffer.getSpans(off, off, ClickableSpan.class); // Cant click to the right of a span, if the line ends with the span! if (x > layout.getLineRight(line)) { // Don't call the span } else if (link.length != 0) { if (action == MotionEvent.ACTION_UP) { // TODO the same click trick of TitleNoteTextview.java link[0].onClick(this); } else if (action == MotionEvent.ACTION_DOWN) { Selection.setSelection(buffer, buffer.getSpanStart(link[0]), buffer.getSpanEnd(link[0])); } return true; } } return super.onTouchEvent(event); } } ================================================ FILE: app/src/main/java/com/nononsenseapps/ui/TitleNoteTextView.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.annotation.SuppressLint; import android.content.ActivityNotFoundException; import android.content.Context; import android.content.Intent; import android.content.res.TypedArray; import android.net.Uri; import android.text.Layout; import android.text.Selection; import android.text.Spannable; import android.text.SpannableString; import android.text.Spanned; import android.text.style.ClickableSpan; import android.text.style.RelativeSizeSpan; import android.text.style.StyleSpan; import android.text.style.TypefaceSpan; import android.text.style.URLSpan; import android.text.util.Linkify; import android.util.AttributeSet; import android.view.MotionEvent; import android.view.View; import androidx.appcompat.widget.AppCompatTextView; import com.nononsenseapps.helpers.NnnLogger; import com.nononsenseapps.notepad.R; /** * A TextView that highlights the first line and makes links clickable. The text * is not selectable. This is intended to be used in a ListView where the text * on items is not intended to be selectable. */ public class TitleNoteTextView extends AppCompatTextView { Object titleStyleSpan; Object titleSizeSpan; Object titleFamilySpan; Object bodyFamilySpan; private final int primaryColor; private final int secondaryColor; private int mBodyFontFamily; private int mTitleFontFamily; private int mTitleFontStyle; private boolean mLinkify; private String mStyledText; private String mTitle = ""; private String mRest = ""; public TitleNoteTextView(Context context, AttributeSet attrs) { super(context, attrs); TypedArray attributes = context .getTheme() .obtainStyledAttributes(attrs, R.styleable.StyledTextView, 0, 0); float mTitleRelativeSize; try { mTitleRelativeSize = attributes .getFloat(R.styleable.StyledTextView_titleRelativeSize, 1.0f); mTitleFontFamily = attributes .getInteger(R.styleable.StyledTextView_titleFontFamily, 0); mTitleFontStyle = attributes .getInteger(R.styleable.StyledTextView_titleFontStyle, 0); mBodyFontFamily = attributes .getInteger(R.styleable.StyledTextView_bodyFontFamily, 0); mStyledText = attributes.getString(R.styleable.StyledTextView_styledText); mLinkify = attributes.getBoolean(R.styleable.StyledTextView_linkify, false); primaryColor = super.getCurrentTextColor(); secondaryColor = attributes.getColor(R.styleable.StyledTextView_secondaryColor, primaryColor); } finally { attributes.recycle(); } titleSizeSpan = new RelativeSizeSpan(mTitleRelativeSize); setTitleFontFamily(mTitleFontFamily); setTitleFontStyle(mTitleFontStyle); setBodyFontFamily(mBodyFontFamily); } /** * Useful method for widgets where only default textviews may be used. Use * this method in your widget adapter to get the functionality this view * would have offered. *

* First argument is a relative size of the first line, like 1.3 *

* Second argument is of type: android.graphics.Typeface.BOLD, ITALIC etc *

* Third is: 0 (normal), 1 (condensed), 2 (light), 3 (thin) */ public static CharSequence getStyledText(final String title, final String rest, final float titleRelSize, final int face, final int font) { final StringBuilder textBuilder = new StringBuilder(title); if (!rest.isEmpty()) { textBuilder.append("\n").append(rest); } return getStyledText(textBuilder.toString(), titleRelSize, face, font); } /** * Useful method for widgets where only default textviews may be used. Use * this method in your widget adapter to get the functionality this view * would have offered. *

* First argument is a relative size of the first line, like 1.3 *

* Second argument is of type: android.graphics.Typeface.BOLD, ITALIC etc *

* Third is: 0 (normal), 1 (condensed), 2 (light), 3 (thin) */ public static CharSequence getStyledText(final String text, final float titleRelSize, final int face, final int font) { if (text == null) return null; int titleEnd = text.indexOf("\n"); if (titleEnd < 0) { titleEnd = text.length(); } TypefaceSpan fontSpan = switch (font) { case 1 -> new TypefaceSpan("sans-serif-condensed"); case 2 -> new TypefaceSpan("sans-serif-light"); case 3 -> new TypefaceSpan("sans-serif-thin"); default -> new TypefaceSpan("sans-serif"); }; SpannableString ss = new SpannableString(text); if (titleEnd > 0) { ss.setSpan(new StyleSpan(face), 0, titleEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); ss.setSpan(new RelativeSizeSpan(titleRelSize), 0, titleEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); ss.setSpan(fontSpan, 0, titleEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); } return ss; } /** * @param family matches order defined in xml */ public void setTitleFontFamily(final int family) { if (mTitleFontFamily == family) return; mTitleFontFamily = family; switch (family) { case 1 -> titleFamilySpan = new TypefaceSpan("sans-serif-condensed"); case 2 -> titleFamilySpan = new TypefaceSpan("sans-serif-light"); case 3 -> titleFamilySpan = new TypefaceSpan("sans-serif-thin"); default -> titleFamilySpan = new TypefaceSpan("sans-serif"); } } /** * @param style matches order defined in xml */ public void setTitleFontStyle(final int style) { if (mTitleFontStyle == style) return; mTitleFontStyle = style; switch (style) { case 1 -> titleStyleSpan = new StyleSpan(android.graphics.Typeface.BOLD); case 2 -> titleStyleSpan = new StyleSpan(android.graphics.Typeface.ITALIC); default -> titleStyleSpan = new StyleSpan(android.graphics.Typeface.NORMAL); } } /** * @param family matches order defined in xml */ public void setBodyFontFamily(final int family) { if (mBodyFontFamily == family) return; mBodyFontFamily = family; switch (family) { case 1 -> bodyFamilySpan = new TypefaceSpan("sans-serif-condensed"); case 2 -> bodyFamilySpan = new TypefaceSpan("sans-serif-light"); case 3 -> bodyFamilySpan = new TypefaceSpan("sans-serif-thin"); default -> bodyFamilySpan = new TypefaceSpan("sans-serif"); } } public void useSecondaryColor(final boolean useSecondary) { if (secondaryColor != primaryColor) { if (useSecondary) { super.setTextColor(secondaryColor); } else { super.setTextColor(primaryColor); } } } public String getStyledText() { return mStyledText; } public void setStyledText(final String styledText) { if (styledText != null) { this.mStyledText = styledText; try { int titleEnd = mStyledText.indexOf("\n"); if (titleEnd < 0) { titleEnd = mStyledText.length(); } // Need to link first so we can avoid the title if (titleEnd > 0) { SpannableString text = new SpannableString(mStyledText); text.setSpan(titleStyleSpan, 0, titleEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); text.setSpan(titleSizeSpan, 0, titleEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); text.setSpan(titleFamilySpan, 0, titleEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); if (titleEnd < mStyledText.length()) { text.setSpan(bodyFamilySpan, titleEnd, mStyledText.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); } setText(text, BufferType.SPANNABLE); if (mLinkify) { // this makes the links clickable in the notes in the lists. Linkify.addLinks(this, Linkify.ALL); // Make sure links dont steal click focus everywhere setMovementMethod(null); } } else { // Emtpy string setText(new SpannableString(mStyledText), BufferType.SPANNABLE); } } catch (NullPointerException miuibug) { /* * A bug reported on Miui Android 4.4. NullPointerException inside setText method * due to some nullpointer in android.text.SpannableStringInternal.equals method. * See crash log in issue #291 */ setText(mStyledText); } } } /** * either opens the note or opens a link in the browser. It does not call * {@link View#performClick()} because there's no need to, the default * behavior is fine. */ @SuppressLint("ClickableViewAccessibility") @Override public boolean onTouchEvent(MotionEvent event) { if (!(this.getText() instanceof Spanned)) { return false; } int action = event.getAction(); if (action != MotionEvent.ACTION_UP && action != MotionEvent.ACTION_DOWN) { return false; } int x = (int) event.getX(); int y = (int) event.getY(); x -= this.getTotalPaddingLeft(); y -= this.getTotalPaddingTop(); x += this.getScrollX(); y += this.getScrollY(); Layout layout = this.getLayout(); int line = layout.getLineForVertical(y); int off = layout.getOffsetForHorizontal(line, x); Spannable buffer = (Spannable) this.getText(); ClickableSpan[] link = buffer.getSpans(off, off, ClickableSpan.class); // Cant click to the right of a span, if the line ends with the span! if (x > layout.getLineRight(line)) { // Don't call the span } else if (link.length != 0) { switch (action) { case MotionEvent.ACTION_UP -> // the user touched a link => fire a custom onClick() method onClickableSpanClicked(link[0]); case MotionEvent.ACTION_DOWN -> // select text Selection.setSelection(buffer, buffer.getSpanStart(link[0]), buffer.getSpanEnd(link[0])); default -> { } // 100% impossible to reach this. } // ONLY in this case we can say that the event was handled return true; } return false; } /** * When the user clicks on a link (in a {@link ClickableSpan}) this view will fire an intent * to open the browser, using this function * * @param cs the {@link ClickableSpan} that the user clicked */ private void onClickableSpanClicked(ClickableSpan cs) { if (cs instanceof URLSpan) { // it's 99% similar to the code on URLSpan.java in the Android SDK, but they set // Browser.EXTRA_APPLICATION_ID, which tells the browser to open links always on // the same tab. By NOT setting it, every clicked link will be opened in a new // browser tab, which I prefer. // TODO If anyone reading this dislikes this behavior, we can add a setting to the // preferences page, just open an issue on github and explain Uri uri = Uri.parse(((URLSpan) cs).getURL()); Intent intent = new Intent(Intent.ACTION_VIEW, uri); try { this.getContext().startActivity(intent); } catch (ActivityNotFoundException e) { NnnLogger.warning(TitleNoteTextView.class, "Could not find a browser to open the url: " + uri.toString()); } } else { NnnLogger.warning(TitleNoteTextView.class, "ClickableSpan was not an UrlSpan"); // it should not happen. Anyway, just call the old code from version 5.7.1 cs.onClick(this); } } public String getTextRest() { return mRest; } public void setTextRest(final String rest) { if (rest != null) { this.mRest = rest; // Make sure it starts with a new line if (!mRest.isEmpty()) { mRest = (rest.startsWith("\n") ? "" : "\n") + rest; } setStyledText(mTitle + mRest); } } public String getTextTitle() { return mTitle; } public void setTextTitle(final String title) { if (title != null) { // Make sure it does not end with a newline this.mTitle = (title.endsWith("\n") ? title.substring(0, title.length() - 1) : title); setStyledText(mTitle + mRest); } } /** * @param clickable if links should be clickable */ public void setLinkify(final boolean clickable) { this.mLinkify = clickable; } /** * @param size 0, 1 or 2 representing small/medium/large */ public void setTheTextSize(final int size) { switch (size) { // small case 0 -> super.setTextSize(12.0f); // large case 2 -> super.setTextSize(18.0f); // medium default -> super.setTextSize(14.0f); } } } ================================================ FILE: app/src/main/java/com/nononsenseapps/ui/ViewsHelper.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.util.TypedValue; public final class ViewsHelper { /** * Convert DPs to Pixels for the current screen density */ public static int convertDip2Pixels(Context context, int dip) { return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dip, context.getResources().getDisplayMetrics()); } } ================================================ FILE: app/src/main/java/com/nononsenseapps/ui/WeekDaysView.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.util.AttributeSet; import android.view.LayoutInflater; import android.view.View; import android.widget.LinearLayout; import com.nononsenseapps.helpers.ActivityHelper; import com.nononsenseapps.helpers.TimeFormatter; import com.nononsenseapps.notepad.R; import com.nononsenseapps.notepad.databinding.WeekdaysLayoutBinding; import java.text.SimpleDateFormat; import java.util.GregorianCalendar; import java.util.Locale; /** * Show a row of 7 days, each can be toggled ON & OFF */ public class WeekDaysView extends LinearLayout { public interface onCheckedDaysChangeListener { /** * react to these days being selected * * @return FALSE if the action was rejected, indicating that you have to put back the * day button in its former state, or TRUE if the action succeeded */ boolean onChange(long checkedDays); } 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; GreyableToggleButton monday; GreyableToggleButton tuesday; GreyableToggleButton wednesday; GreyableToggleButton thursday; GreyableToggleButton friday; GreyableToggleButton saturday; GreyableToggleButton sunday; /** * for {@link R.layout#weekdays_layout} */ WeekdaysLayoutBinding mBinding; private onCheckedDaysChangeListener listener = null; private Locale mLocale; public WeekDaysView(Context context, AttributeSet attrs) { super(context, attrs); LayoutInflater mInflater = context.getSystemService(LayoutInflater.class); mInflater.inflate(R.layout.weekdays_layout, this, true); // TODO use view bindings also here ? // mBinding = WeekdaysLayoutBinding.inflate(mInflater, this, true); // TO DO (useless): respect locale settings regarding first day of week try { mLocale = ActivityHelper.getUserLocale(context); SimpleDateFormat dayFormat = 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; monday = findViewById(R.id.day1); // mBinding.day1 initializeToggleButton(dayFormat.format(gc.getTime()), monday); tuesday = findViewById(R.id.day2); gc.setTimeInMillis(base + 1 * day); initializeToggleButton(dayFormat.format(gc.getTime()), tuesday); wednesday = findViewById(R.id.day3); gc.setTimeInMillis(base + 2 * day); initializeToggleButton(dayFormat.format(gc.getTime()), wednesday); thursday = findViewById(R.id.day4); gc.setTimeInMillis(base + 3 * day); initializeToggleButton(dayFormat.format(gc.getTime()), thursday); friday = findViewById(R.id.day5); gc.setTimeInMillis(base + 4 * day); initializeToggleButton(dayFormat.format(gc.getTime()), friday); saturday = findViewById(R.id.day6); gc.setTimeInMillis(base + 5 * day); initializeToggleButton(dayFormat.format(gc.getTime()), saturday); sunday = findViewById(R.id.day7); gc.setTimeInMillis(base + 6 * day); initializeToggleButton(dayFormat.format(gc.getTime()), sunday); } catch (Exception e) { // For UI editor's sake mLocale = Locale.getDefault(); } } void initializeToggleButton(final String text, final GreyableToggleButton button) { button.setText(text.toUpperCase(mLocale)); button.setTextOn(text.toUpperCase(mLocale)); button.setTextOff(text.toUpperCase(mLocale)); button.setOnClickListener(this::onDayButtonClicked); } public long getCheckedDays() { long checkedDays = 0; if (monday.isChecked()) checkedDays |= mon; if (tuesday.isChecked()) checkedDays |= tue; if (wednesday.isChecked()) checkedDays |= wed; if (thursday.isChecked()) checkedDays |= thu; if (friday.isChecked()) checkedDays |= fri; if (saturday.isChecked()) checkedDays |= sat; if (sunday.isChecked()) checkedDays |= sun; return checkedDays; } public void setCheckedDays(long checkedDays) { monday.setChecked(0 < (checkedDays & mon)); tuesday.setChecked(0 < (checkedDays & tue)); wednesday.setChecked(0 < (checkedDays & wed)); thursday.setChecked(0 < (checkedDays & thu)); friday.setChecked(0 < (checkedDays & fri)); saturday.setChecked(0 < (checkedDays & sat)); sunday.setChecked(0 < (checkedDays & sun)); } /** * Reacts to one of the 7 {@link GreyableToggleButton} being touched (== "toggled"). * onCheckedChanged(CompoundButton, boolean) can't be used because it would call * {@link GreyableToggleButton#setChecked} in an infinite loop. Note that before * calling this, Android calls {@link GreyableToggleButton#setChecked}, so here we * can either confirm or undo the "checking" action. */ private void onDayButtonClicked(View v) { if (listener == null) return; boolean Ok = listener.onChange(getCheckedDays()); var btn = (GreyableToggleButton) v; if (!Ok) { // the listener returned false, to indicate that the operation // was rejected => revert the toggle button to its former state. // Don't worry: doing this does NOT touch the data of the note // for the database. btn.toggle(); } } /** * set the function to call when one of the 7 buttons is clicked. * See {@link onCheckedDaysChangeListener} */ public void setOnCheckedDaysChangedListener(onCheckedDaysChangeListener listener) { this.listener = listener; } } ================================================ FILE: app/src/main/res/anim/activity_slide_in_right.xml ================================================ ================================================ FILE: app/src/main/res/anim/activity_slide_out_right_full.xml ================================================ ================================================ FILE: app/src/main/res/anim/cycle_7.xml ================================================ ================================================ FILE: app/src/main/res/anim/shake.xml ================================================ ================================================ FILE: app/src/main/res/anim/slide_in_bottom.xml ================================================ ================================================ FILE: app/src/main/res/anim/slide_in_top.xml ================================================ ================================================ FILE: app/src/main/res/anim/slide_out_bottom.xml ================================================ ================================================ FILE: app/src/main/res/anim/slide_out_top.xml ================================================ ================================================ FILE: app/src/main/res/drawable/btn_toggle_bg.xml ================================================ ================================================ FILE: app/src/main/res/drawable/folder_move_24dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/folder_plus_24dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_add_24dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_alarm_24dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_alarm_add_24dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_archive_24dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_brightness_6_24dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_check_24dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_checkbox_checked.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_checkbox_unchecked.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_clear_24dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_copy_24dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_delete_24dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_export.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_folder_24dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_help_24dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_import.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_info_24dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_lock_closed_24dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_lock_open_24dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_notebook_minus_24dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_refresh_24dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_sd_storage_24dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_search_24dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_select_all.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_settings_24dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_share_24dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_sort_24dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_stat_notification_edit.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_underline_accent.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_undo_24dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/img_default_selector_dark.xml ================================================ ================================================ FILE: app/src/main/res/drawable/img_default_selector_light.xml ================================================ ================================================ FILE: app/src/main/res/drawable/tasklist_item_blackclassic_bg.xml ================================================ ================================================ FILE: app/src/main/res/drawable/tasklist_item_darkcard_bg.xml ================================================ ================================================ FILE: app/src/main/res/drawable/tasklist_item_lightcard_bg.xml ================================================ ================================================ FILE: app/src/main/res/drawable/tasklist_item_lightclassic_bg.xml ================================================ ================================================ FILE: app/src/main/res/drawable-anydpi-v26/app_icon.xml ================================================ ================================================ FILE: app/src/main/res/drawable-anydpi-v26/icon_foreground_symbol.xml ================================================ ================================================ FILE: app/src/main/res/drawable-anydpi-v26/icon_gradient.xml ================================================ ================================================ FILE: app/src/main/res/drawable-anydpi-v26/icon_monochrome.xml ================================================ ================================================ FILE: app/src/main/res/layout/actionbar_custom_view_done.xml ================================================ ================================================ FILE: app/src/main/res/layout/actionbar_custom_view_done_discard.xml ================================================ ================================================ FILE: app/src/main/res/layout/actionbar_discard_button.xml ================================================ ================================================ FILE: app/src/main/res/layout/actionbar_done_button.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_dashclock_settings.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_main.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_settings.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_shortcut_config.xml ================================================