Repository: TBog/TBLauncher Branch: master Commit: f7fbfcc03dd9 Files: 546 Total size: 2.5 MB Directory structure: gitextract_ggifh6nm/ ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── feature_request.md │ ├── dependabot.yml │ └── workflows/ │ ├── android.yml │ ├── deploy.yml │ └── release.yml ├── .gitignore ├── .idea/ │ ├── codeStyles/ │ │ ├── Project.xml │ │ └── codeStyleConfig.xml │ ├── compiler.xml │ ├── google-java-format.xml │ ├── inspectionProfiles/ │ │ └── Project_Default.xml │ ├── jarRepositories.xml │ ├── migrations.xml │ ├── palantir-java-format.xml │ └── render.experimental.xml ├── Gemfile ├── LICENSE.md ├── Privacy-Policy.md ├── README.md ├── _config.yml ├── _layouts/ │ ├── default.html │ └── simple.html ├── app/ │ ├── .gitignore │ ├── build.gradle │ ├── proguard-rules.pro │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── java/ │ │ ├── net/ │ │ │ └── mm2d/ │ │ │ └── color/ │ │ │ └── chooser/ │ │ │ ├── ColorChooserDialog.kt │ │ │ ├── ColorChooserView.kt │ │ │ ├── ColorLiveDataOwner.kt │ │ │ ├── ColorObserverDelegate.kt │ │ │ ├── ControlView.kt │ │ │ ├── HsvView.kt │ │ │ ├── PaletteView.kt │ │ │ ├── SliderView.kt │ │ │ ├── ViewPagerAdapter.kt │ │ │ ├── element/ │ │ │ │ ├── ColorSliderView.kt │ │ │ │ ├── HueView.kt │ │ │ │ ├── PaletteCell.kt │ │ │ │ ├── PreviewView.kt │ │ │ │ └── SvView.kt │ │ │ └── util/ │ │ │ ├── AttrExtentions.kt │ │ │ ├── CanvasExtensions.kt │ │ │ ├── ColorUtils.kt │ │ │ └── ResourceExtensions.kt │ │ └── rocks/ │ │ └── tbog/ │ │ └── tblauncher/ │ │ ├── Behaviour.java │ │ ├── CustomizeUI.java │ │ ├── DeviceAdmin.java │ │ ├── DrawableCache.java │ │ ├── DummyLauncherActivity.java │ │ ├── EditTagsDialog.java │ │ ├── LauncherState.java │ │ ├── LiveWallpaper.java │ │ ├── MimeTypeCache.java │ │ ├── Permission.java │ │ ├── PermissionsManager.java │ │ ├── PinShortcutConfirm.java │ │ ├── SettingsActivity.java │ │ ├── TBApplication.java │ │ ├── TBLauncherActivity.java │ │ ├── TagsManager.java │ │ ├── WallpaperSnapAnim.java │ │ ├── WorkAsync/ │ │ │ ├── AsyncTask.java │ │ │ ├── RunnableTask.java │ │ │ └── TaskRunner.java │ │ ├── broadcast/ │ │ │ ├── IncomingCallHandler.java │ │ │ ├── LocaleChangedReceiver.java │ │ │ └── PackageAddedRemovedHandler.java │ │ ├── calculator/ │ │ │ ├── Calculator.java │ │ │ ├── Result.java │ │ │ ├── ShuntingYard.java │ │ │ └── Tokenizer.java │ │ ├── customicon/ │ │ │ ├── ButtonHelper.java │ │ │ ├── CustomShapePage.java │ │ │ ├── DefaultButtonPage.java │ │ │ ├── IconAdapter.java │ │ │ ├── IconData.java │ │ │ ├── IconPackPage.java │ │ │ ├── IconSelectDialog.java │ │ │ ├── IconViewHolder.java │ │ │ ├── PageAdapter.java │ │ │ ├── ShortcutPage.java │ │ │ ├── StaticEntryPage.java │ │ │ └── SystemPage.java │ │ ├── dataprovider/ │ │ │ ├── ActionProvider.java │ │ │ ├── AppCacheProvider.java │ │ │ ├── AppProvider.java │ │ │ ├── CalculatorProvider.java │ │ │ ├── ContactsProvider.java │ │ │ ├── DBProvider.java │ │ │ ├── DialProvider.java │ │ │ ├── EntryToResultUtils.java │ │ │ ├── FilterProvider.java │ │ │ ├── IProvider.java │ │ │ ├── ModProvider.java │ │ │ ├── Provider.java │ │ │ ├── QuickListProvider.java │ │ │ ├── SearchProvider.java │ │ │ ├── ShortcutsProvider.java │ │ │ ├── SimpleProvider.java │ │ │ ├── TagsProvider.java │ │ │ └── UpdateFromModsLoader.java │ │ ├── db/ │ │ │ ├── AppRecord.java │ │ │ ├── DB.java │ │ │ ├── DBHelper.java │ │ │ ├── ExportedData.java │ │ │ ├── FlagsRecord.java │ │ │ ├── ModRecord.java │ │ │ ├── PlaceholderWidgetRecord.java │ │ │ ├── ShortcutRecord.java │ │ │ ├── ValuedHistoryRecord.java │ │ │ ├── WidgetRecord.java │ │ │ ├── XmlExport.java │ │ │ └── XmlImport.java │ │ ├── drawable/ │ │ │ ├── CodePointDrawable.java │ │ │ ├── DrawableUtils.java │ │ │ ├── FourCodePointDrawable.java │ │ │ ├── LoadingDrawable.java │ │ │ ├── SizeWrappedDrawable.java │ │ │ ├── SquareDrawable.java │ │ │ ├── TextDrawable.java │ │ │ └── TwoCodePointDrawable.java │ │ ├── entry/ │ │ │ ├── ActionEntry.java │ │ │ ├── AppEntry.java │ │ │ ├── CalculatorEntry.java │ │ │ ├── ContactEntry.java │ │ │ ├── DialContactEntry.java │ │ │ ├── EntryItem.java │ │ │ ├── EntryWithTags.java │ │ │ ├── FilterEntry.java │ │ │ ├── ICustomIconEntry.java │ │ │ ├── OpenUrlEntry.java │ │ │ ├── PlaceholderEntry.java │ │ │ ├── ResultRelevance.java │ │ │ ├── SearchEngineEntry.java │ │ │ ├── SearchEntry.java │ │ │ ├── ShortcutEntry.java │ │ │ ├── StaticEntry.java │ │ │ ├── TagEntry.java │ │ │ └── UrlEntry.java │ │ ├── handler/ │ │ │ ├── AppsHandler.java │ │ │ ├── DataHandler.java │ │ │ ├── IconsHandler.java │ │ │ └── TagsHandler.java │ │ ├── icons/ │ │ │ ├── CalendarDrawable.java │ │ │ ├── DrawableInfo.java │ │ │ ├── IconPack.java │ │ │ ├── IconPackCache.java │ │ │ ├── IconPackXML.java │ │ │ ├── LazyLoadDrawable.java │ │ │ ├── SimpleDrawable.java │ │ │ └── SystemIconPack.java │ │ ├── loader/ │ │ │ ├── LoadAppEntry.java │ │ │ ├── LoadCacheApps.java │ │ │ ├── LoadContactsEntry.java │ │ │ ├── LoadEntryItem.java │ │ │ └── LoadShortcutsEntryItem.java │ │ ├── normalizer/ │ │ │ ├── IntSequenceBuilder.java │ │ │ ├── PhoneNormalizer.java │ │ │ └── StringNormalizer.java │ │ ├── preference/ │ │ │ ├── BaseListPreferenceDialog.java │ │ │ ├── BaseMultiSelectListPreferenceDialog.java │ │ │ ├── BasePreferenceDialog.java │ │ │ ├── ConfirmDialog.java │ │ │ ├── ContentLoadHelper.java │ │ │ ├── CustomDialogPreference.java │ │ │ ├── EditAddResetEditor.java │ │ │ ├── EditAddResetPreferenceDialog.java │ │ │ ├── EditSearchEnginesPreferenceDialog.java │ │ │ ├── EditSearchHintPreferenceDialog.java │ │ │ ├── IconListPreferenceDialog.java │ │ │ ├── MarginDialog.java │ │ │ ├── MultiDependencies.java │ │ │ ├── MultiDependenciesSwitchPreference.java │ │ │ ├── OrderListPreferenceDialog.java │ │ │ ├── PreferenceColorDialog.java │ │ │ ├── PreviewImagePreference.java │ │ │ ├── QuickListPreferenceDialog.java │ │ │ ├── SeekBarChangeListener.java │ │ │ ├── ShadowDialog.java │ │ │ ├── SliderDialog.java │ │ │ └── TagOrderListPreferenceDialog.java │ │ ├── quicklist/ │ │ │ ├── DockRecycleLayoutManager.java │ │ │ ├── DragAndDropInfo.java │ │ │ ├── EditQuickList.java │ │ │ ├── EditQuickListDialog.java │ │ │ ├── PagedScrollListener.java │ │ │ ├── QuickList.java │ │ │ ├── RecycleAdapter.java │ │ │ └── ViewPagerAdapter.java │ │ ├── result/ │ │ │ ├── AsyncSetEntryDrawable.java │ │ │ ├── CustomRecycleLayoutManager.java │ │ │ ├── EntryAdapter.java │ │ │ ├── LoadDataForAdapter.java │ │ │ ├── RecycleAdapter.java │ │ │ ├── RecycleAdapterBase.java │ │ │ ├── RecycleScrollListener.java │ │ │ ├── ResultHelper.java │ │ │ ├── ResultItemDecoration.java │ │ │ ├── ResultViewHelper.java │ │ │ └── ReversibleAdapterRecyclerLayoutManager.java │ │ ├── searcher/ │ │ │ ├── HistorySearcher.java │ │ │ ├── ISearchActivity.java │ │ │ ├── ISearcher.java │ │ │ ├── QuerySearcher.java │ │ │ ├── ResultBuffer.java │ │ │ ├── Searcher.java │ │ │ ├── TagList.java │ │ │ └── TagSearcher.java │ │ ├── shortcut/ │ │ │ ├── SaveSingleOreoShortcutAsync.java │ │ │ └── ShortcutUtil.java │ │ ├── ui/ │ │ │ ├── BlockableListView.java │ │ │ ├── BottomPullEffectView.java │ │ │ ├── CenteredImageSpan.java │ │ │ ├── CustomizeMarginView.java │ │ │ ├── CustomizeShadowView.java │ │ │ ├── CutoutFactory.java │ │ │ ├── DialogFragment.java │ │ │ ├── DialogWrapper.java │ │ │ ├── ICutout.java │ │ │ ├── KeyboardHandler.java │ │ │ ├── LinearAdapter.java │ │ │ ├── LinearAdapterPlus.java │ │ │ ├── ListPopup.java │ │ │ ├── RecyclerList.java │ │ │ ├── SearchEditText.java │ │ │ ├── SquareImageView.java │ │ │ ├── TagsMenuUtils.java │ │ │ ├── ViewStubPreview.java │ │ │ ├── WindowInsetsHelper.java │ │ │ └── dialog/ │ │ │ ├── ConfirmDialog.java │ │ │ ├── EditTextDialog.java │ │ │ ├── PleaseWaitDialog.java │ │ │ └── TagsManagerDialog.java │ │ ├── utils/ │ │ │ ├── ArrayHelper.java │ │ │ ├── ClipboardUtils.java │ │ │ ├── ColorFilterHelper.java │ │ │ ├── DebugInfo.java │ │ │ ├── DebugString.java │ │ │ ├── DeviceUtils.java │ │ │ ├── DialogHelper.java │ │ │ ├── EdgeGlowHelper.java │ │ │ ├── FileUtils.java │ │ │ ├── FuzzyScore.java │ │ │ ├── GestureDetectorHelper.java │ │ │ ├── GoogleCalendarIcon.java │ │ │ ├── ISparseArray.java │ │ │ ├── KeyboardToggleHelper.java │ │ │ ├── KeyboardTriggerBehaviour.java │ │ │ ├── MapCompat.java │ │ │ ├── MimeTypeUtils.java │ │ │ ├── PackageManagerUtils.java │ │ │ ├── PrefCache.java │ │ │ ├── PrefOrderedListHelper.java │ │ │ ├── RootHandler.java │ │ │ ├── SimpleTextWatcher.java │ │ │ ├── SimpleXmlWriter.java │ │ │ ├── SparseArrayWrapper.java │ │ │ ├── SystemUiVisibility.java │ │ │ ├── Timer.java │ │ │ ├── UIColors.java │ │ │ ├── UISizes.java │ │ │ ├── UITheme.java │ │ │ ├── UserHandleCompat.java │ │ │ ├── Utilities.java │ │ │ ├── ViewHolderAdapter.java │ │ │ └── ViewHolderListAdapter.java │ │ └── widgets/ │ │ ├── ItemTitle.java │ │ ├── ItemWidget.java │ │ ├── LoadWidgetsAsync.java │ │ ├── MenuItem.java │ │ ├── PickAppWidgetActivity.java │ │ ├── WidgetInfo.java │ │ ├── WidgetLayout.java │ │ ├── WidgetListAdapter.java │ │ ├── WidgetManager.java │ │ └── WidgetView.java │ └── res/ │ ├── anim/ │ │ ├── popup_in_bottom.xml │ │ ├── popup_in_top.xml │ │ └── popup_out.xml │ ├── color/ │ │ ├── accent_text_selector.xml │ │ ├── accent_text_selector_black.xml │ │ ├── accent_text_selector_deep_blues.xml │ │ ├── accent_text_selector_white.xml │ │ ├── primary_text_selector_darkbg.xml │ │ ├── primary_text_selector_lightbg.xml │ │ ├── secondary_text_selector_darkbg.xml │ │ ├── settings_primary_selector_black.xml │ │ ├── settings_primary_selector_darkbg.xml │ │ ├── settings_primary_selector_deep_blues.xml │ │ ├── settings_primary_selector_default.xml │ │ ├── settings_primary_selector_lightbg.xml │ │ ├── settings_secondary_selector_black.xml │ │ ├── settings_secondary_selector_deep_blues.xml │ │ ├── settings_secondary_selector_default.xml │ │ └── settings_secondary_selector_lightbg.xml │ ├── drawable/ │ │ ├── button_bar_background.xml │ │ ├── button_bar_background_deep_blues.xml │ │ ├── button_bar_background_default.xml │ │ ├── button_bar_background_light.xml │ │ ├── dialog_background.xml │ │ ├── dialog_background_black.xml │ │ ├── dialog_background_dark.xml │ │ ├── dialog_background_deep_blues.xml │ │ ├── dialog_background_default.xml │ │ ├── dialog_background_light.xml │ │ ├── handle_background.xml │ │ ├── ic_add_tag.xml │ │ ├── ic_android.xml │ │ ├── ic_apps.xml │ │ ├── ic_apps_grid_az.xml │ │ ├── ic_apps_grid_za.xml │ │ ├── ic_apps_list_az.xml │ │ ├── ic_apps_list_za.xml │ │ ├── ic_arrow_back.xml │ │ ├── ic_backup.xml │ │ ├── ic_behaviour.xml │ │ ├── ic_browse_add_icon.xml │ │ ├── ic_bug.xml │ │ ├── ic_clear.xml │ │ ├── ic_contact_placeholder.xml │ │ ├── ic_contacts.xml │ │ ├── ic_contacts_az.xml │ │ ├── ic_contacts_za.xml │ │ ├── ic_dots.xml │ │ ├── ic_edit.xml │ │ ├── ic_eye_crossed.xml │ │ ├── ic_favorites.xml │ │ ├── ic_features.xml │ │ ├── ic_functions.xml │ │ ├── ic_gesture.xml │ │ ├── ic_grid.xml │ │ ├── ic_handle_move.xml │ │ ├── ic_handle_resize_bl.xml │ │ ├── ic_handle_resize_l.xml │ │ ├── ic_history.xml │ │ ├── ic_icon.xml │ │ ├── ic_keyboard.xml │ │ ├── ic_list.xml │ │ ├── ic_loading_arrows.xml │ │ ├── ic_loading_pulse.xml │ │ ├── ic_memory.xml │ │ ├── ic_message.xml │ │ ├── ic_phone.xml │ │ ├── ic_phone_ui.xml │ │ ├── ic_popup.xml │ │ ├── ic_quick.xml │ │ ├── ic_refresh.xml │ │ ├── ic_remove_tag.xml │ │ ├── ic_search.xml │ │ ├── ic_search_bar.xml │ │ ├── ic_send.xml │ │ ├── ic_settings.xml │ │ ├── ic_shortcuts.xml │ │ ├── ic_shortcuts_az.xml │ │ ├── ic_shortcuts_za.xml │ │ ├── ic_tags.xml │ │ ├── ic_undo.xml │ │ ├── ic_untagged.xml │ │ ├── ic_wallpaper.xml │ │ ├── launcher_pill.xml │ │ ├── launcher_pill_background.xml │ │ ├── launcher_white.xml │ │ ├── list_separator_dark.xml │ │ ├── list_separator_deep_blues.xml │ │ ├── list_separator_default.xml │ │ ├── list_separator_light.xml │ │ ├── mm2d_cc_ic_check.xml │ │ ├── notification_bar_background.xml │ │ ├── notification_dot.xml │ │ ├── popup_background.xml │ │ ├── tab_background_black.xml │ │ ├── tab_background_deep_blues.xml │ │ ├── tab_background_default.xml │ │ ├── tab_background_light.xml │ │ ├── window_title_background.xml │ │ ├── window_title_background_deep_blues.xml │ │ ├── window_title_background_default.xml │ │ └── window_title_background_light.xml │ ├── drawable-v23/ │ │ ├── button_bar_background.xml │ │ └── window_title_background.xml │ ├── layout/ │ │ ├── activity_fullscreen.xml │ │ ├── activity_settings.xml │ │ ├── add_search_engine.xml │ │ ├── add_search_hint.xml │ │ ├── custom_icon_item.xml │ │ ├── dialog_custom_shape_icon_select_page.xml │ │ ├── dialog_edit_tags.xml │ │ ├── dialog_icon_select.xml │ │ ├── dialog_icon_select_page.xml │ │ ├── dialog_preference_color_chooser.xml │ │ ├── dialog_rename.xml │ │ ├── dialog_title.xml │ │ ├── edit_search_engines.xml │ │ ├── edit_tag_item.xml │ │ ├── item_app.xml │ │ ├── item_builtin.xml │ │ ├── item_contact.xml │ │ ├── item_dock.xml │ │ ├── item_dock_shortcut.xml │ │ ├── item_grid.xml │ │ ├── item_grid_shortcut.xml │ │ ├── item_shortcut.xml │ │ ├── mm2d_cc_color_chooser.xml │ │ ├── mm2d_cc_item_palette.xml │ │ ├── mm2d_cc_view_control.xml │ │ ├── mm2d_cc_view_dialog.xml │ │ ├── mm2d_cc_view_hsv.xml │ │ ├── mm2d_cc_view_slider.xml │ │ ├── ok_cancel_button_bar.xml │ │ ├── order_list_item.xml │ │ ├── pin_shortcut_confirm.xml │ │ ├── popup_divider.xml │ │ ├── popup_list_item.xml │ │ ├── popup_list_item_icon.xml │ │ ├── popup_list_text.xml │ │ ├── popup_title.xml │ │ ├── pref_alpha_preview.xml │ │ ├── pref_amount_preview.xml │ │ ├── pref_color_preview.xml │ │ ├── pref_confirm.xml │ │ ├── pref_margin_offset.xml │ │ ├── pref_matrix_preview.xml │ │ ├── pref_offset_preview.xml │ │ ├── pref_shadow.xml │ │ ├── pref_shadow_preview.xml │ │ ├── pref_size_preview.xml │ │ ├── pref_slider.xml │ │ ├── preference_switch.xml │ │ ├── quick_list.xml │ │ ├── quick_list_editor.xml │ │ ├── quick_list_editor_page.xml │ │ ├── result_list.xml │ │ ├── search_bar.xml │ │ ├── search_pill.xml │ │ ├── tags_manager.xml │ │ ├── tags_manager_item.xml │ │ ├── tags_manager_item_deleted.xml │ │ ├── widget_handle.xml │ │ ├── widget_picker.xml │ │ └── widget_placeholder.xml │ ├── mipmap-anydpi-v26/ │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ ├── values/ │ │ ├── arrays.xml │ │ ├── attrs.xml │ │ ├── colors.xml │ │ ├── default_tags.xml │ │ ├── dimens.xml │ │ ├── ids.xml │ │ ├── pref_default.xml │ │ ├── strings.xml │ │ ├── styles.xml │ │ └── themes.xml │ ├── values-de/ │ │ └── strings.xml │ ├── values-de-v26/ │ │ └── strings.xml │ ├── values-fr/ │ │ └── strings.xml │ ├── values-fr-v26/ │ │ └── strings.xml │ ├── values-h400dp/ │ │ └── dimens.xml │ ├── values-in/ │ │ └── strings.xml │ ├── values-it/ │ │ └── strings.xml │ ├── values-it-v26/ │ │ └── strings.xml │ ├── values-ja/ │ │ └── strings.xml │ ├── values-nb-rNO/ │ │ └── strings.xml │ ├── values-nb-rNO-v26/ │ │ └── strings.xml │ ├── values-pl/ │ │ └── strings.xml │ ├── values-pl-v26/ │ │ └── strings.xml │ ├── values-pt/ │ │ └── strings.xml │ ├── values-pt-rBR/ │ │ └── strings.xml │ ├── values-pt-rBR-v26/ │ │ └── strings.xml │ ├── values-pt-rPT/ │ │ └── strings.xml │ ├── values-pt-rPT-v26/ │ │ └── strings.xml │ ├── values-ro/ │ │ └── strings.xml │ ├── values-ro-v26/ │ │ └── strings.xml │ ├── values-ru/ │ │ └── strings.xml │ ├── values-tr/ │ │ └── strings.xml │ ├── values-tr-v26/ │ │ └── strings.xml │ ├── values-v21/ │ │ └── pref_default.xml │ ├── values-v26/ │ │ └── strings.xml │ ├── values-zh-rCN/ │ │ └── strings.xml │ └── xml/ │ ├── backup_descriptor.xml │ ├── data_extraction_rules.xml │ ├── file_paths.xml │ ├── policies.xml │ ├── preference_features.xml │ ├── preferences.xml │ └── search_pill_scene.xml ├── build.gradle ├── fastlane/ │ ├── Appfile │ ├── Fastfile │ ├── README.md │ └── metadata/ │ └── android/ │ ├── de-DE/ │ │ ├── changelogs/ │ │ │ └── 40.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── en-US/ │ │ ├── changelogs/ │ │ │ ├── 31.txt │ │ │ ├── 32.txt │ │ │ ├── 33.txt │ │ │ ├── 34.txt │ │ │ ├── 35.txt │ │ │ ├── 36.txt │ │ │ ├── 37.txt │ │ │ ├── 38.txt │ │ │ ├── 39.txt │ │ │ ├── 40.txt │ │ │ ├── 41.txt │ │ │ ├── 42.txt │ │ │ └── 43.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ ├── title.txt │ │ └── video.txt │ ├── fr/ │ │ ├── changelogs/ │ │ │ ├── 31.txt │ │ │ ├── 32.txt │ │ │ ├── 33.txt │ │ │ ├── 34.txt │ │ │ ├── 35.txt │ │ │ └── 36.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── nb-NO/ │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── nb_NO-V26/ │ │ └── title.txt │ ├── pt-BR/ │ │ ├── changelogs/ │ │ │ └── 31.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── pt-PT/ │ │ ├── changelogs/ │ │ │ ├── 31.txt │ │ │ ├── 36.txt │ │ │ └── 40.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── ro/ │ │ └── title.txt │ └── tr/ │ ├── changelogs/ │ │ ├── 36.txt │ │ ├── 39.txt │ │ └── 40.txt │ ├── full_description.txt │ ├── short_description.txt │ └── title.txt ├── gradle/ │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat └── settings.gradle ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: "Short descriptive title" labels: bug assignees: '' --- # Description # ## Context ## ## Steps to Reproduce ## To reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error ## Expected behavior ## ### Screenshots ### ## Device info ## * Device: * OS: * TinyBit Launcher version: ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: "Great idea title" labels: enhancement assignees: '' --- **Description** **Solution** **Alternative solutions** **Additional context** ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: gradle directory: "/" schedule: interval: daily time: "03:00" open-pull-requests-limit: 10 ================================================ FILE: .github/workflows/android.yml ================================================ name: Android CI on: push: branches: [ master ] pull_request: branches: [ master ] jobs: build: runs-on: ubuntu-latest steps: - name: checkout uses: actions/checkout@v4 - name: set up JDK 17 uses: actions/setup-java@v4 with: distribution: 'temurin' java-version: '17' - name: Build with Gradle run: bash ./gradlew build --stacktrace ================================================ FILE: .github/workflows/deploy.yml ================================================ name: Deploy to Playstore beta on: workflow_dispatch: jobs: distribute: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: actions/setup-ruby@v1 with: ruby-version: '2.6' - name: Install bundle run: | bundle config path vendor/bundle bundle install --jobs 4 --retry 3 - name: Configure Keystore run: | echo "$ANDROID_KEYSTORE_FILE" > keystore.jks.b64 base64 -d -i keystore.jks.b64 > app/keystore.jks echo "storeFile=keystore.jks" >> keystore.properties echo "keyAlias=$KEYSTORE_KEY_ALIAS" >> keystore.properties echo "storePassword=$KEYSTORE_STORE_PASSWORD" >> keystore.properties echo "keyPassword=$KEYSTORE_KEY_PASSWORD" >> keystore.properties env: ANDROID_KEYSTORE_FILE: ${{ secrets.PLAYSTORE_KEYSTORE }} KEYSTORE_KEY_ALIAS: ${{ secrets.PLAYSTORE_KEY_ALIAS }} KEYSTORE_KEY_PASSWORD: ${{ secrets.PLAYSTORE_KEY_PASSWORD }} KEYSTORE_STORE_PASSWORD: ${{ secrets.PLAYSTORE_STORE_PASSWORD }} - name: Create Google Play Config file run : | echo "$PLAY_CONFIG_JSON" > play_config.json.b64 base64 -d -i play_config.json.b64 > play_config.json env: PLAY_CONFIG_JSON: ${{ secrets.PLAYSTORE_API_JSON }} - name: Distribute app to Beta track 🚀 run: bundle exec fastlane beta ================================================ FILE: .github/workflows/release.yml ================================================ # This is a basic workflow to help you get started with Actions name: Android Release on: push: branches: - 'release*' # Allows you to run this workflow manually from the Actions tab workflow_dispatch: # A workflow run is made up of one or more jobs that can run sequentially or in parallel jobs: # This workflow contains a single job apk: name: Generate APK runs-on: ubuntu-latest # Steps represent a sequence of tasks that will be executed as part of the job steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - uses: actions/checkout@v4 # Runs a single command using the runners shell - name: set up JDK 17 uses: actions/setup-java@v4 with: distribution: 'temurin' java-version: '17' - name: Build debug APK run: bash ./gradlew assembleGithubDebug --stacktrace - name: Upload APK uses: actions/upload-artifact@v4 with: name: app path: app/build/outputs/apk/github/debug/app-github-debug.apk ================================================ FILE: .gitignore ================================================ # Built application files *.apk *.ap_ *.aab # Files for the ART/Dalvik VM *.dex # Java class files *.class # Generated files bin/ gen/ out/ release/ # Gradle files .gradle/ build/ # Local configuration file (sdk path, etc) local.properties # Proguard folder generated by Eclipse proguard/ # Log Files *.log # Android Studio Navigation editor temp files .navigation/ # Android Studio captures folder captures/ # IntelliJ *.iml .idea/workspace.xml .idea/tasks.xml .idea/gradle.xml .idea/assetWizardSettings.xml .idea/dictionaries .idea/libraries # Android Studio 3 in .gitignore file. .idea/caches .idea/modules.xml # Comment next line if keeping position of elements in Navigation Editor is relevant for you .idea/navEditor.xml # TBog: Keeps changing when I use the IDE .idea/misc.xml .idea/deploymentTargetDropDown.xml .idea/kotlinc.xml # Keystore files # Uncomment the following lines if you do not want to check your keystore files in. #*.jks #*.keystore # External native build folder generated in Android Studio 2.2 and later .externalNativeBuild # Google Services (e.g. APIs or Firebase) # google-services.json # Freeline freeline.py freeline/ freeline_project_description.json # fastlane fastlane/report.xml fastlane/Preview.html fastlane/screenshots fastlane/test_output fastlane/readme.md # Version control vcs.xml # lint lint/intermediates/ lint/generated/ lint/outputs/ lint/tmp/ # lint/reports/ Gemfile.lock .idea/deploymentTargetSelector.xml ================================================ FILE: .idea/codeStyles/Project.xml ================================================
xmlns:android ^$
xmlns:.* ^$ BY_NAME
.*:id http://schemas.android.com/apk/res/android
.*:name http://schemas.android.com/apk/res/android
name ^$
style ^$
.* ^$ BY_NAME
.* http://schemas.android.com/apk/res/android ANDROID_ATTRIBUTE_ORDER
.* .* BY_NAME
================================================ FILE: .idea/codeStyles/codeStyleConfig.xml ================================================ ================================================ FILE: .idea/compiler.xml ================================================ ================================================ FILE: .idea/google-java-format.xml ================================================ ================================================ FILE: .idea/inspectionProfiles/Project_Default.xml ================================================ ================================================ FILE: .idea/jarRepositories.xml ================================================ ================================================ FILE: .idea/migrations.xml ================================================ ================================================ FILE: .idea/palantir-java-format.xml ================================================ ================================================ FILE: .idea/render.experimental.xml ================================================ ================================================ FILE: Gemfile ================================================ source "https://rubygems.org" gem "fastlane" gem "rake" ================================================ FILE: LICENSE.md ================================================ 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: {project} Copyright (C) {year} {fullname} 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: Privacy-Policy.md ================================================ --- title: TinyBit launcher layout: simple --- # Privacy policy This privacy policy ("Policy") describes how the personally identifiable information ("Personal Information") you may provide in the "TBLauncher" mobile application ("Mobile Application" or "Service") and any of its related products and services (collectively, "Services") is collected, protected and used. It also describes the choices available to you regarding our use of your Personal Information and how you can access and update this information. This Policy is a legally binding agreement between you ("User", "you" or "your") and this Mobile Application developer ("Operator", "we", "us" or "our"). By accessing and using the Mobile Application and Services, you acknowledge that you have read, understood, and agree to be bound by the terms of this Policy. This Policy does not apply to the practices of companies that we do not own or control, or to individuals that we do not employ or manage. ## Collection of information Our top priority is customer data security and, as such, we exercise the no logs policy. We may process only minimal user data, only as much as it is absolutely necessary to maintain the Mobile Application and Services. Information collected automatically is used only to identify potential cases of abuse and establish statistical information regarding the usage of the Mobile Application and Services. This statistical information is not otherwise aggregated in such a way that would identify any particular user of the system. ## Use and processing of collected information In order to make the Mobile Application and Services available to you, or to meet a legal obligation, we may need to collect and use certain Personal Information. If you do not provide the information that we request, we may not be able to provide you with the requested products or services. Any of the information we collect from you may be used for the following purposes: * Respond to inquiries and offer support * Improve user experience * Run and operate the Mobile Application and Services Processing your Personal Information depends on how you interact with the Mobile Application and Services, where you are located in the world and if one of the following applies: (i) you have given your consent for one or more specific purposes; this, however, does not apply, whenever the processing of Personal Information is subject to California Consumer Privacy Act or European data protection law; (ii) provision of information is necessary for the performance of an agreement with you and/or for any pre-contractual obligations thereof; (iii) processing is necessary for compliance with a legal obligation to which you are subject; (iv) processing is related to a task that is carried out in the public interest or in the exercise of official authority vested in us; (v) processing is necessary for the purposes of the legitimate interests pursued by us or by a third party. Note that under some legislations we may be allowed to process information until you object to such processing (by opting out), without having to rely on consent or any other of the following legal bases below. In any case, we will be happy to clarify the specific legal basis that applies to the processing, and in particular whether the provision of Personal Information is a statutory or contractual requirement, or a requirement necessary to enter into a contract. ## Disclosure of information Depending on the requested Services or as necessary to complete any transaction or provide any service you have requested, we may share your information with your consent with our trusted third parties that work with us, any other affiliates and subsidiaries we rely upon to assist in the operation of the Mobile Application and Services available to you. We do not share Personal Information with unaffiliated third parties. These service providers are not authorized to use or disclose your information except as necessary to perform services on our behalf or comply with legal requirements. We may share your Personal Information for these purposes only with third parties whose privacy policies are consistent with ours or who agree to abide by our policies with respect to Personal Information. These third parties are given Personal Information they need only in order to perform their designated functions, and we do not authorize them to use or disclose Personal Information for their own marketing or other purposes. ## Retention of information We will retain and use your Personal Information for the period necessary to comply with our legal obligations, resolve disputes, and enforce our agreements unless a longer retention period is required or permitted by law. We may use any aggregated data derived from or incorporating your Personal Information after you update or delete it, but not in a manner that would identify you personally. Once the retention period expires, Personal Information shall be deleted. Therefore, the right to access, the right to erasure, the right to rectification and the right to data portability cannot be enforced after the expiration of the retention period. ## Transfer of information Depending on your location, data transfers may involve transferring and storing your information in a country other than your own. You are entitled to learn about the legal basis of information transfers to a country outside the European Union or to any international organization governed by public international law or set up by two or more countries, such as the UN, and about the security measures taken by us to safeguard your information. If any such transfer takes place, you can find out more by checking the relevant sections of this Policy or inquire with us using the information provided in the contact section. ## The rights of users You may exercise certain rights regarding your information processed by us. In particular, you have the right to do the following: (i) you have the right to withdraw consent where you have previously given your consent to the processing of your information; (ii) you have the right to object to the processing of your information if the processing is carried out on a legal basis other than consent; (iii) you have the right to learn if information is being processed by us, obtain disclosure regarding certain aspects of the processing and obtain a copy of the information undergoing processing; (iv) you have the right to verify the accuracy of your information and ask for it to be updated or corrected; (v) you have the right, under certain circumstances, to restrict the processing of your information, in which case, we will not process your information for any purpose other than storing it; (vi) you have the right, under certain circumstances, to obtain the erasure of your Personal Information from us; (vii) you have the right to receive your information in a structured, commonly used and machine readable format and, if technically feasible, to have it transmitted to another controller without any hindrance. This provision is applicable provided that your information is processed by automated means and that the processing is based on your consent, on a contract which you are part of or on pre-contractual obligations thereof. ## The right to object to processing Where Personal Information is processed for the public interest, in the exercise of an official authority vested in us or for the purposes of the legitimate interests pursued by us, you may object to such processing by providing a ground related to your particular situation to justify the objection. ## Data protection rights under GDPR If you are a resident of the European Economic Area (EEA), you have certain data protection rights and the Operator aims to take reasonable steps to allow you to correct, amend, delete, or limit the use of your Personal Information. If you wish to be informed what Personal Information we hold about you and if you want it to be removed from our systems, please contact us. In certain circumstances, you have the following data protection rights: * You have the right to request access to your Personal Information that we store and have the ability to access your Personal Information. * You have the right to request that we correct any Personal Information you believe is inaccurate. You also have the right to request us to complete the Personal Information you believe is incomplete. * You have the right to request the erase your Personal Information under certain conditions of this Policy. * You have the right to object to our processing of your Personal Information. * You have the right to seek restrictions on the processing of your Personal Information. When you restrict the processing of your Personal Information, we may store it but will not process it further. * You have the right to be provided with a copy of the information we have on you in a structured, machine-readable and commonly used format. * You also have the right to withdraw your consent at any time where the Operator relied on your consent to process your Personal Information. You have the right to complain to a Data Protection Authority about our collection and use of your Personal Information. For more information, please contact your local data protection authority in the European Economic Area (EEA). ## California privacy rights In addition to the rights as explained in this Policy, California residents who provide Personal Information (as defined in the statute) to obtain products or services for personal, family, or household use are entitled to request and obtain from us, once a calendar year, information about the Personal Information we shared, if any, with other businesses for marketing uses. If applicable, this information would include the categories of Personal Information and the names and addresses of those businesses with which we shared such personal information for the immediately prior calendar year (e.g., requests made in the current year will receive information about the prior year). To obtain this information please contact us. ## How to exercise these rights Any requests to exercise your rights can be directed to the Operator through the contact details provided in this document. Please note that we may ask you to verify your identity before responding to such requests. Your request must provide sufficient information that allows us to verify that you are the person you are claiming to be or that you are the authorized representative of such person. You must include sufficient details to allow us to properly understand the request and respond to it. We cannot respond to your request or provide you with Personal Information unless we first verify your identity or authority to make such a request and confirm that the Personal Information relates to you. ## Privacy of children We do not knowingly collect any Personal Information from children. We encourage all children to never submit any Personal Information through the Mobile Application and Services. We encourage parents and legal guardians to monitor their children's Internet usage and to help enforce this Policy by instructing their children never to provide Personal Information through the Mobile Application and Services without their permission. If you have reason to believe that a child has provided Personal Information to us through the Mobile Application and Services, please contact us. You must also be at least 16 years of age to consent to the processing of your Personal Information in your country (in some countries we may allow your parent or guardian to do so on your behalf). ## Links to other resources The Mobile Application and Services contain links to other resources that are not owned or controlled by us. Please be aware that we are not responsible for the privacy practices of such other resources or third parties. We encourage you to be aware when you leave the Mobile Application and Services and to read the privacy statements of each and every resource that may collect Personal Information. ## Information security We secure information you provide on computer servers in a controlled, secure environment, protected from unauthorized access, use, or disclosure. We maintain reasonable administrative, technical, and physical safeguards in an effort to protect against unauthorized access, use, modification, and disclosure of Personal Information in its control and custody. However, no data transmission over the Internet or wireless network can be guaranteed. Therefore, while we strive to protect your Personal Information, you acknowledge that (i) there are security and privacy limitations of the Internet which are beyond our control; (ii) the security, integrity, and privacy of any and all information and data exchanged between you and the Mobile Application and Services cannot be guaranteed; and (iii) any such information and data may be viewed or tampered with in transit by a third party, despite best efforts. ## Data breach In the event we become aware that the security of the Mobile Application and Services has been compromised or users Personal Information has been disclosed to unrelated third parties as a result of external activity, including, but not limited to, security attacks or fraud, we reserve the right to take reasonably appropriate measures, including, but not limited to, investigation and reporting, as well as notification to and cooperation with law enforcement authorities. In the event of a data breach, we will make reasonable efforts to notify affected individuals if we believe that there is a reasonable risk of harm to the user as a result of the breach or if notice is otherwise required by law. When we do, we will post a notice in the Mobile Application. ## Changes and amendments We reserve the right to modify this Policy or its terms relating to the Mobile Application and Services from time to time in our discretion and will notify you of any material changes to the way in which we treat Personal Information. When we do, we will revise the updated date at the bottom of this page. We may also provide notice to you in other ways in our discretion, such as through contact information you have provided. Any updated version of this Policy will be effective immediately upon the posting of the revised Policy unless otherwise specified. Your continued use of the Mobile Application and Services after the effective date of the revised Policy (or such other act specified at that time) will constitute your consent to those changes. However, we will not, without your consent, use your Personal Information in a manner materially different than what was stated at the time your Personal Information was collected. ## Acceptance of this policy You acknowledge that you have read this Policy and agree to all its terms and conditions. By accessing and using the Mobile Application and Services you agree to be bound by this Policy. If you do not agree to abide by the terms of this Policy, you are not authorized to access or use the Mobile Application and Services. ## Contacting us If you would like to contact us to understand more about this Policy or wish to contact us concerning any matter relating to individual rights and your Personal Information, you may send an email to privacy@tbog.rocks. This document was last updated on April 8, 2021 ================================================ FILE: README.md ================================================ # TinyBit Launcher [Android CI](https://github.com/TBog/TBLauncher/actions/) [Codacy Badge](https://www.codacy.com/gh/TBog/TBLauncher/dashboard?utm_source=github.com&utm_medium=referral&utm_content=TBog/TBLauncher&utm_campaign=Badge_Grade) [GitHub Releases](https://github.com/TBog/TBLauncher/releases) [F-Droid Releases](https://f-droid.org/packages/rocks.tbog.tblauncher/) [Playstore](https://play.google.com/store/apps/details?id=rocks.tbog.tblauncher) ## This is _the_ launcher used and developed by TBog Motives for developing ~~a new~~ my own launcher: - Clean main screen to enjoy the wallpaper - Fast access to apps by searching - Icon Pack compatible - Ability to customize colors and behaviors ### How it looks like | Homescreen | Search `tbl` | Edit QuickList | Launcher settings | Customize any icon | | :---: | :---: | :---: | :---: | :---: | | [![Homescreen](https://imgur.com/Idkhx5v.png)](https://imgur.com/Idkhx5v) | ![Search `tbl`](https://raw.githubusercontent.com/TBog/TBLauncher/master/fastlane/metadata/android/en-US/images/phoneScreenshots/2_en-US.jpeg) | ![Edit QuickList](https://raw.githubusercontent.com/TBog/TBLauncher/master/fastlane/metadata/android/en-US/images/phoneScreenshots/4_en-US.jpeg) | [![Settings](https://imgur.com/J8EslbJ.png)](https://imgur.com/J8EslbJ) | [![Customize any icon](https://imgur.com/jxvRmzV.png)](https://imgur.com/jxvRmzV) | ### Where one can get it [Get it on F-Droid](https://f-droid.org/packages/rocks.tbog.tblauncher/) [Get it on Google Play](https://play.google.com/store/apps/details?id=rocks.tbog.tblauncher) [Get it on Github](https://github.com/TBog/TBLauncher/releases) ### Translation [Translation status](https://hosted.weblate.org/engage/tblauncher/) ================================================ FILE: _config.yml ================================================ description: Android launcher with icon pack support, search to launch apps and contacts, color and behaviour customizable show_downloads: true theme: jekyll-theme-hacker github: apk_url: https://github.com/TBog/TBLauncher/releases/latest/download/app-release.apk ================================================ FILE: _layouts/default.html ================================================ {% include head-custom.html %} {% seo %}

{{ site.title | default: site.github.repository_name }}

{{ site.description | default: site.github.project_tagline }}

{% if site.show_downloads %} Download sources as .zip Download sources as .tar.gz Download release apk {% endif %} View project on GitHub
{{ content }}
================================================ FILE: _layouts/simple.html ================================================ {% include head-custom.html %} {% seo %}

{{ page.title | default: site.github.repository_name }}

{{ content }}
================================================ FILE: app/.gitignore ================================================ /build ================================================ FILE: app/build.gradle ================================================ apply plugin: 'com.android.application' apply plugin: 'kotlin-android' apply plugin: 'com.getkeepsafe.dexcount' apply plugin: 'com.dipien.byebyejetifier' android { namespace 'rocks.tbog.tblauncher' compileSdk 34 defaultConfig { applicationId "rocks.tbog.tblauncher" minSdk 19 targetSdk 33 versionCode 43 versionName "7.5" vectorDrawables.useSupportLibrary = true multiDexEnabled true } buildTypes { release { minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' resValue "string", "app_name_dynamic", "@string/app_name" } debug { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' applicationIdSuffix '.debug' versionNameSuffix '-dbg' resValue "string", "app_name_dynamic", "@string/app_name_debug" } } flavorDimensions = ["store"] productFlavors { playstore { dimension "store" buildConfigField 'boolean', 'SHOW_PRIVACY_POLICY', 'true' buildConfigField 'boolean', 'SHOW_RATE_APP', 'true' } fdroid { dimension "store" buildConfigField 'boolean', 'SHOW_PRIVACY_POLICY', 'false' buildConfigField 'boolean', 'SHOW_RATE_APP', 'true' } github { dimension "store" buildConfigField 'boolean', 'SHOW_PRIVACY_POLICY', 'false' buildConfigField 'boolean', 'SHOW_RATE_APP', 'false' } } compileOptions { coreLibraryDesugaringEnabled true sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } kotlinOptions { jvmTarget = JavaVersion.VERSION_17.toString() } buildFeatures { viewBinding true buildConfig = true } lint { // disable quantity translation errors disable 'ImpliedQuantity' } } configurations { compileClasspath { resolutionStrategy.force 'com.github.yalantis:ucrop:2.2.8' } } dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) //noinspection KtxExtensionAvailable implementation("androidx.preference:preference:$preference_version") { exclude group: 'androidx.lifecycle', module: 'lifecycle-viewmodel' exclude group: 'androidx.lifecycle', module: 'lifecycle-viewmodel-ktx' } implementation "ch.acra:acra-mail:$acra_version" implementation "ch.acra:acra-dialog:$acra_version" implementation "ch.acra:acra-core:$acra_version" implementation "androidx.asynclayoutinflater:asynclayoutinflater:1.0.0" implementation 'androidx.annotation:annotation:1.8.0' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.core:core-ktx:1.13.1' implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version" implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version" implementation 'com.google.android.material:material:1.12.0' implementation('com.github.dhaval2404:imagepicker:2.1', { exclude group: 'com.squareup.okhttp3' }) implementation "androidx.multidex:multidex:$multidex_version" // 2.0.0 not supported, as "Unsupported desugared library configuration version, please upgrade the D8/R8 compiler." Android Gradle Plugin would need to be 7.4.0-alpha10 coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4' } repositories { mavenCentral() } //configurations.all { // resolutionStrategy.eachDependency { DependencyResolveDetails details -> // def requested = details.requested // if (requested.group == 'org.jetbrains.kotlin' // && (requested.name == 'kotlin-reflect' // || requested.name.startsWith('kotlin-stdlib')) // ) { // details.useVersion kotlin_version // } // } //} //allprojects { // tasks.withType(JavaCompile) { // options.compilerArgs << "-Xlint:unchecked" << "-Xlint:deprecation" // } //} //tasks.whenTaskAdded { task -> // if (task.name == 'assembleDebug') { // task.dependsOn lint // task.mustRunAfter lint // } //} ================================================ FILE: app/proguard-rules.pro ================================================ # Add project specific ProGuard rules here. # You can control the set of applied configuration files using the # proguardFiles setting in build.gradle. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html # If your project uses WebView with JS, uncomment the following # and specify the fully qualified class name to the JavaScript interface # class: #-keepclassmembers class fqcn.of.javascript.interface.for.webview { # public *; #} # Uncomment this to preserve the line number information for # debugging stack traces. -keepattributes SourceFile,LineNumberTable # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile # Allow the access modifiers of classes and class members to be modified, while optimizing. -allowaccessmodification -dontobfuscate ## If we are obfuscating we need to keep the dataprovider class names #-keepnames class rocks.tbog.tblauncher.dataprovider.* # ## If we are obfuscating check rocks/tbog/tblauncher/utils/EdgeGlowHelper.java #-keepnames class android.** #-keepclassmembernames class android.** { # private ; #} #-keepnames class androidx.** #-keepclassmembernames class androidx.** { # private ; #} # Need to keep constructor of worker -keepclassmembers class * extends rocks.tbog.tblauncher.WorkAsync.AsyncTask { (...); } # Keep constructor of ViewHolder -keepclassmembers class * extends rocks.tbog.tblauncher.utils.ViewHolderAdapter$ViewHolder { (...); } # We don't use okhttp3 from com.github.dhaval2404:imagepicker so don't warn that it's missing -dontwarn okhttp3.** -dontwarn okio.BufferedSource -dontwarn okio.Okio -dontwarn okio.Sink # From https://github.com/Yalantis/uCrop -dontwarn com.yalantis.ucrop** -keep class com.yalantis.ucrop** { *; } -keep interface com.yalantis.ucrop** { *; } # ACRA -keepattributes *Annotation* -dontwarn javax.annotation.processing.AbstractProcessor -dontwarn javax.annotation.processing.SupportedOptions -keep class javax.annotation.processing.** { *; } -keep interface javax.annotation.processing.** { *; } ================================================ FILE: app/src/main/AndroidManifest.xml ================================================ ================================================ FILE: app/src/main/java/net/mm2d/color/chooser/ColorChooserDialog.kt ================================================ /* * Copyright (c) 2018 大前良介 (OHMAE Ryosuke) * * This software is released under the MIT License. * http://opensource.org/licenses/MIT */ package net.mm2d.color.chooser import android.app.Dialog import android.content.DialogInterface import android.graphics.Color import android.os.Bundle import androidx.annotation.ColorInt import androidx.appcompat.app.AlertDialog import androidx.core.os.bundleOf import androidx.fragment.app.DialogFragment import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentManager import androidx.lifecycle.LifecycleOwner import rocks.tbog.tblauncher.databinding.Mm2dCcColorChooserBinding /** * Color chooser dialog */ object ColorChooserDialog { private const val KEY_REQUEST_KEY = "KEY_REQUEST_KEY" const val KEY_INITIAL_COLOR = "KEY_INITIAL_COLOR" private const val KEY_WITH_ALPHA = "KEY_WITH_ALPHA" const val KEY_INITIAL_TAB = "KEY_INITIAL_TAB" private const val RESULT_KEY_COLOR = "RESULT_KEY_COLOR" private const val RESULT_KEY_CANCEL = "RESULT_KEY_CANCEL" private const val TAG = "ColorChooserDialog" const val TAB_PALETTE: Int = 0 const val TAB_HSV: Int = 1 const val TAB_RGB: Int = 2 /** * Register result listener. * * Call at the timing of onCreate of activity. * * @param activity Caller fragment activity * @param requestKey Request Key, pass the same value to the `show` * @param onSelect Listener receiving the result * @param onCancel Listener receiving a cancel event */ fun registerListener( activity: FragmentActivity, requestKey: String, onSelect: (color: Int) -> Unit, onCancel: (() -> Unit)? = null, ) { registerListener( activity.supportFragmentManager, requestKey, activity, onSelect, onCancel, ) } /** * Register result listener. * * Call at the timing of onViewCreated of fragment. * * @param fragment Caller fragment * @param requestKey Request Key, pass the same value to the `show` * @param onSelect Listener receiving the result * @param onCancel Listener receiving a cancel event */ fun registerListener( fragment: Fragment, requestKey: String, onSelect: (color: Int) -> Unit, onCancel: (() -> Unit)? = null, ) { registerListener( fragment.childFragmentManager, requestKey, fragment.viewLifecycleOwner, onSelect, onCancel, ) } private fun registerListener( manager: FragmentManager, requestKey: String, lifecycleOwner: LifecycleOwner, onSelect: (color: Int) -> Unit, onCancel: (() -> Unit)?, ) { manager.setFragmentResultListener(requestKey, lifecycleOwner) { _, result -> if (result.getBoolean(RESULT_KEY_CANCEL)) { onCancel?.invoke() } else { onSelect.invoke(result.getInt(RESULT_KEY_COLOR)) } } } /** * Show dialog. * * @param activity FragmentActivity * @param requestKey Request Key used for registration with registerListener * @param initialColor initial color * @param withAlpha if true, alpha section is enabled * @param initialTab initial tab, TAB_PALETTE/TAB_HSV/TAB_RGB */ fun show( activity: FragmentActivity, requestKey: String, @ColorInt initialColor: Int = Color.WHITE, withAlpha: Boolean = false, initialTab: Int = TAB_PALETTE ) { show( activity.supportFragmentManager, bundleOf( KEY_REQUEST_KEY to requestKey, KEY_INITIAL_COLOR to initialColor, KEY_WITH_ALPHA to withAlpha, KEY_INITIAL_TAB to initialTab, ) ) } /** * Show dialog. * * @param fragment Fragment * @param requestKey Request Key used for registration with registerListener * @param initialColor initial color * @param withAlpha if true, alpha section is enabled * @param initialTab initial tab, TAB_PALETTE/TAB_HSV/TAB_RGB */ fun show( fragment: Fragment, requestKey: String, @ColorInt initialColor: Int = Color.WHITE, withAlpha: Boolean = false, initialTab: Int = TAB_PALETTE ) { show( fragment.childFragmentManager, bundleOf( KEY_REQUEST_KEY to requestKey, KEY_INITIAL_COLOR to initialColor, KEY_WITH_ALPHA to withAlpha, KEY_INITIAL_TAB to initialTab, ) ) } private fun show(manager: FragmentManager, arguments: Bundle) { if (manager.findFragmentByTag(TAG) != null) return if (manager.isStateSaved) return ColorChooserDialogImpl().also { it.arguments = arguments }.show(manager, TAG) } internal class ColorChooserDialogImpl : DialogFragment() { private lateinit var colorChooserView: ColorChooserView override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val activity = requireActivity() colorChooserView = Mm2dCcColorChooserBinding.inflate(activity.layoutInflater).root if (savedInstanceState != null) { val tab = savedInstanceState.getInt(KEY_INITIAL_TAB, 0) colorChooserView.setCurrentItem(tab) val color = savedInstanceState.getInt(KEY_INITIAL_COLOR, 0) colorChooserView.init(color) } else { val arguments = requireArguments() val tab = arguments.getInt(KEY_INITIAL_TAB, 0) colorChooserView.setCurrentItem(tab) val color = arguments.getInt(KEY_INITIAL_COLOR, 0) colorChooserView.init(color) } colorChooserView.setWithAlpha(requireArguments().getBoolean(KEY_WITH_ALPHA)) return AlertDialog.Builder(activity) .setView(colorChooserView) .setPositiveButton("OK") { _, _ -> notifySelect() } .setNegativeButton("Cancel") { dialog, _ -> dialog.cancel() } .create() } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putInt(KEY_INITIAL_TAB, colorChooserView.getCurrentItem()) outState.putInt(KEY_INITIAL_COLOR, colorChooserView.color) } override fun onCancel(dialog: DialogInterface) { val key = requireArguments().getString(KEY_REQUEST_KEY) ?: return parentFragmentManager.setFragmentResult( key, bundleOf(RESULT_KEY_CANCEL to true) ) } private fun notifySelect() { val key = requireArguments().getString(KEY_REQUEST_KEY) ?: return parentFragmentManager.setFragmentResult( key, bundleOf( RESULT_KEY_CANCEL to false, RESULT_KEY_COLOR to colorChooserView.color, ) ) } } } ================================================ FILE: app/src/main/java/net/mm2d/color/chooser/ColorChooserView.kt ================================================ /* * Copyright (c) 2018 大前良介 (OHMAE Ryosuke) * * This software is released under the MIT License. * http://opensource.org/licenses/MIT */ package net.mm2d.color.chooser import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater import android.view.View import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.graphics.alpha import androidx.core.view.doOnLayout import androidx.lifecycle.MutableLiveData import com.google.android.material.tabs.TabLayoutMediator import rocks.tbog.tblauncher.databinding.Mm2dCcViewDialogBinding import net.mm2d.color.chooser.util.toOpacity internal class ColorChooserView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : ConstraintLayout(context, attrs, defStyleAttr), ColorLiveDataOwner { private val colorLiveData: MutableLiveData = MutableLiveData() private val binding: Mm2dCcViewDialogBinding = Mm2dCcViewDialogBinding.inflate(LayoutInflater.from(context), this) val color: Int get() = binding.controlView.color fun init(color: Int) { colorLiveData.value = color.toOpacity() binding.controlView.setAlpha(color.alpha) val pageTitles: List = listOf("palette", "hsv", "rgb") val pageViews: List = listOf( PaletteView(context), HsvView(context), SliderView(context), ) binding.viewPager.adapter = ViewPagerAdapter(pageViews) TabLayoutMediator(binding.tabLayout, binding.viewPager) { tab, position -> tab.text = pageTitles[position] }.attach() } fun setCurrentItem(position: Int) { binding.viewPager.doOnLayout { binding.viewPager.post { binding.viewPager.setCurrentItem(position, false) } } } fun getCurrentItem(): Int = binding.viewPager.currentItem fun setWithAlpha(withAlpha: Boolean) { binding.controlView.setWithAlpha(withAlpha) } override fun getColorLiveData(): MutableLiveData = colorLiveData } ================================================ FILE: app/src/main/java/net/mm2d/color/chooser/ColorLiveDataOwner.kt ================================================ package net.mm2d.color.chooser import android.view.View import androidx.lifecycle.MutableLiveData internal interface ColorLiveDataOwner { fun getColorLiveData(): MutableLiveData } internal fun View.findColorLiveDataOwner(): ColorLiveDataOwner? { if (this is ColorLiveDataOwner) return this val parent = parent return if (parent !is View) null else parent.findColorLiveDataOwner() } ================================================ FILE: app/src/main/java/net/mm2d/color/chooser/ColorObserverDelegate.kt ================================================ package net.mm2d.color.chooser import android.view.View import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Observer import androidx.lifecycle.distinctUntilChanged internal class ColorObserverDelegate( private val target: T ) where T : View, T : Observer { private var colorLiveData: MutableLiveData? = null fun onAttachedToWindow() { val owner = target.findColorLiveDataOwner() ?: throw IllegalStateException("parent is not ColorLiveDataOwner") val liveData = owner.getColorLiveData() liveData.distinctUntilChanged() .observeForever(target) colorLiveData = liveData } fun onDetachedFromWindow() { colorLiveData?.removeObserver(target) colorLiveData = null } fun post(color: Int) { colorLiveData?.postValue(color) } } ================================================ FILE: app/src/main/java/net/mm2d/color/chooser/ControlView.kt ================================================ /* * Copyright (c) 2018 大前良介 (OHMAE Ryosuke) * * This software is released under the MIT License. * http://opensource.org/licenses/MIT */ package net.mm2d.color.chooser import android.annotation.SuppressLint import android.content.Context import android.content.res.ColorStateList import android.graphics.Color import android.text.* import android.text.InputFilter.LengthFilter import android.util.AttributeSet import android.view.LayoutInflater import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.graphics.alpha import androidx.core.view.ViewCompat import androidx.core.view.isVisible import androidx.lifecycle.Observer import net.mm2d.color.chooser.util.resolveColor import net.mm2d.color.chooser.util.setAlpha import net.mm2d.color.chooser.util.toOpacity import com.google.android.material.R import rocks.tbog.tblauncher.databinding.Mm2dCcViewControlBinding import java.util.* internal class ControlView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : ConstraintLayout(context, attrs, defStyleAttr), Observer { private val delegate = ColorObserverDelegate(this) private val normalTint = ColorStateList.valueOf(context.resolveColor(R.attr.colorAccent, Color.BLUE)) private val errorTint = ColorStateList.valueOf(context.resolveColor(R.attr.colorError, Color.RED)) private var changeHexTextByUser = true private var hasAlpha: Boolean = true private val rgbFilter = arrayOf(HexadecimalFilter(), LengthFilter(6)) private val argbFilter = arrayOf(HexadecimalFilter(), LengthFilter(8)) private val binding: Mm2dCcViewControlBinding = Mm2dCcViewControlBinding.inflate(LayoutInflater.from(context), this) var color: Int = Color.BLACK private set init { binding.colorPreview.setColor(color) binding.seekAlpha.setValue(color.alpha) binding.seekAlpha.onValueChanged = { value, fromUser -> binding.textAlpha.text = value.toString() if (fromUser) { setAlpha(value) } } binding.editHex.filters = argbFilter binding.editHex.addTextChangedListener(object : TextWatcher { override fun afterTextChanged(s: Editable?) = Unit override fun beforeTextChanged(s: CharSequence?, start: Int, c: Int, a: Int) = Unit override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { if (!changeHexTextByUser) { return } if (s.isNullOrEmpty()) { setError() return } try { color = Color.parseColor("#$s") clearError() binding.colorPreview.setColor(color) binding.seekAlpha.setValue(color.alpha) delegate.post(color.toOpacity()) } catch (e: IllegalArgumentException) { setError() } } }) } override fun onAttachedToWindow() { super.onAttachedToWindow() delegate.onAttachedToWindow() } override fun onDetachedFromWindow() { super.onDetachedFromWindow() delegate.onDetachedFromWindow() } fun setAlpha(alpha: Int) { binding.seekAlpha.setValue(alpha) color = color.setAlpha(alpha) binding.colorPreview.setColor(color) setColorToHexText() } fun setWithAlpha(withAlpha: Boolean) { hasAlpha = withAlpha binding.seekAlpha.isVisible = withAlpha binding.textAlpha.isVisible = withAlpha if (withAlpha) { binding.editHex.filters = argbFilter } else { binding.editHex.filters = rgbFilter setAlpha(0xff) } } private fun setError() { ViewCompat.setBackgroundTintList(binding.editHex, errorTint) } private fun clearError() { ViewCompat.setBackgroundTintList(binding.editHex, normalTint) } override fun onChanged(value: Int) { if (this.color.toOpacity() == value) return this.color = value.setAlpha(binding.seekAlpha.value) binding.colorPreview.setColor(this.color) setColorToHexText() binding.seekAlpha.setMaxColor(value) } @SuppressLint("SetTextI18n") private fun setColorToHexText() { changeHexTextByUser = false if (hasAlpha) { binding.editHex.setText("%08X".format(color)) } else { binding.editHex.setText("%06X".format(color and 0xffffff)) } clearError() changeHexTextByUser = true } private class HexadecimalFilter : InputFilter { override fun filter( source: CharSequence?, start: Int, end: Int, dest: Spanned?, dstart: Int, dend: Int ): CharSequence? { val converted = source.toString() .replace("[^0-9a-fA-F]".toRegex(), "") .uppercase(Locale.ENGLISH) if (source.toString() == converted) return null if (source !is Spanned) return converted return SpannableString(converted).also { TextUtils.copySpansFrom(source, 0, converted.length, null, it, 0) } } } } ================================================ FILE: app/src/main/java/net/mm2d/color/chooser/HsvView.kt ================================================ /* * Copyright (c) 2018 大前良介 (OHMAE Ryosuke) * * This software is released under the MIT License. * http://opensource.org/licenses/MIT */ package net.mm2d.color.chooser import android.content.Context import android.graphics.Color import android.util.AttributeSet import android.view.LayoutInflater import androidx.constraintlayout.widget.ConstraintLayout import androidx.lifecycle.Observer import rocks.tbog.tblauncher.databinding.Mm2dCcViewHsvBinding import net.mm2d.color.chooser.util.ColorUtils internal class HsvView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : ConstraintLayout(context, attrs, defStyleAttr), Observer { private val delegate = ColorObserverDelegate(this) private var color: Int = Color.BLACK private val binding: Mm2dCcViewHsvBinding = Mm2dCcViewHsvBinding.inflate(LayoutInflater.from(context), this) init { binding.svView.onColorChanged = { color = it delegate.post(color) } binding.hueView.onHueChanged = { color = ColorUtils.hsvToColor(it, binding.svView.saturation, binding.svView.value) binding.svView.setHue(it) delegate.post(color) } } override fun onAttachedToWindow() { super.onAttachedToWindow() delegate.onAttachedToWindow() } override fun onDetachedFromWindow() { super.onDetachedFromWindow() delegate.onDetachedFromWindow() } override fun onChanged(value: Int) { if (this.color == value) return this.color = value binding.svView.setColor(value) binding.hueView.setColor(value) } } ================================================ FILE: app/src/main/java/net/mm2d/color/chooser/PaletteView.kt ================================================ /* * Copyright (c) 2018 大前良介 (OHMAE Ryosuke) * * This software is released under the MIT License. * http://opensource.org/licenses/MIT */ package net.mm2d.color.chooser import android.annotation.SuppressLint import android.content.Context import android.content.res.Resources import android.content.res.TypedArray import android.graphics.Color import android.util.AttributeSet import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.annotation.ArrayRes import androidx.core.content.res.getColorOrThrow import androidx.core.content.res.getResourceIdOrThrow import androidx.core.content.res.use import androidx.core.view.children import androidx.lifecycle.Observer import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import net.mm2d.color.chooser.element.PaletteCell import net.mm2d.color.chooser.util.getPixels import rocks.tbog.tblauncher.R import java.lang.ref.SoftReference import kotlin.collections.forEach as kForEach internal class PaletteView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : RecyclerView(context, attrs, defStyleAttr), Observer { private val delegate = ColorObserverDelegate(this) private val cellHeight = getPixels(R.dimen.mm2d_cc_palette_cell_height) private val cellAdapter = CellAdapter(context) private val linearLayoutManager = LinearLayoutManager(context) init { val padding = resources.getDimensionPixelSize(R.dimen.mm2d_cc_palette_padding) setPadding(0, padding, 0, padding) clipToPadding = false setHasFixedSize(true) overScrollMode = View.OVER_SCROLL_NEVER itemAnimator = null layoutManager = linearLayoutManager adapter = cellAdapter isVerticalFadingEdgeEnabled = true setFadingEdgeLength(padding) cellAdapter.onColorChanged = { delegate.post(it) } } override fun onAttachedToWindow() { super.onAttachedToWindow() delegate.onAttachedToWindow() } override fun onDetachedFromWindow() { super.onDetachedFromWindow() delegate.onDetachedFromWindow() } override fun isPaddingOffsetRequired(): Boolean = true override fun getTopPaddingOffset(): Int = -paddingTop override fun getBottomPaddingOffset(): Int = paddingBottom override fun onChanged(value: Int) { cellAdapter.setColor(value) } override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { super.onSizeChanged(w, h, oldw, oldh) if (oldh == 0 && h > 0) { linearLayoutManager.scrollToPositionWithOffset(cellAdapter.index, (h - cellHeight) / 2) } } private class CellAdapter(context: Context) : Adapter() { private val inflater: LayoutInflater = LayoutInflater.from(context) private val list: List = createPalette(context) private var color: Int = 0 var onColorChanged: ((color: Int) -> Unit)? = null var index: Int = -1 private set override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CellHolder = CellHolder(inflater.inflate(R.layout.mm2d_cc_item_palette, parent, false)) .also { holder -> holder.onColorChanged = { onColorChanged?.invoke(it) } } override fun onBindViewHolder(holder: CellHolder, position: Int) = holder.apply(list[position], color) override fun getItemCount(): Int = list.size fun setColor(newColor: Int) { if (color == newColor) return color = newColor val newIndex = list.indexOfFirst { it.contains(newColor) } val lastIndex = index index = newIndex if (lastIndex >= 0) notifyItemChanged(lastIndex) if (newIndex >= 0) notifyItemChanged(newIndex) } } private class CellHolder(itemView: View) : ViewHolder(itemView) { private val viewList: List = (itemView as ViewGroup).children .map { it as PaletteCell } .toList() var onColorChanged: ((color: Int) -> Unit)? = null init { viewList.kForEach { it.setOnClickListener(::performOnColorChanged) } } private fun performOnColorChanged(view: View) { onColorChanged?.invoke(view.tag as? Int ?: return) } fun apply(colors: IntArray, selected: Int) { viewList.withIndex().kForEach { (i, view) -> val color = if (i < colors.size) colors[i] else Color.TRANSPARENT view.tag = color view.setColor(color) view.checked = color == selected } } } companion object { private var cache: SoftReference> = SoftReference>(null) @SuppressLint("Recycle") private fun Resources.useTypedArray(@ArrayRes id: Int, block: TypedArray.() -> R): R = obtainTypedArray(id).use { it.block() } private fun createPalette(context: Context): List { cache.get()?.let { return it } val resources = context.resources return resources.useTypedArray(R.array.material_colors) { (0 until length()).map { resources.readColorArray(getResourceIdOrThrow(it)) } }.also { cache = SoftReference(it) } } private fun Resources.readColorArray(id: Int): IntArray = useTypedArray(id) { IntArray(length()) { getColorOrThrow(it) } } } } ================================================ FILE: app/src/main/java/net/mm2d/color/chooser/SliderView.kt ================================================ /* * Copyright (c) 2018 大前良介 (OHMAE Ryosuke) * * This software is released under the MIT License. * http://opensource.org/licenses/MIT */ package net.mm2d.color.chooser import android.content.Context import android.graphics.Color import android.util.AttributeSet import android.view.LayoutInflater import androidx.constraintlayout.widget.ConstraintLayout import androidx.lifecycle.Observer import rocks.tbog.tblauncher.databinding.Mm2dCcViewSliderBinding internal class SliderView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : ConstraintLayout(context, attrs, defStyleAttr), Observer { private val delegate = ColorObserverDelegate(this) private val binding: Mm2dCcViewSliderBinding = Mm2dCcViewSliderBinding.inflate(LayoutInflater.from(context), this) init { binding.seekRed.onValueChanged = { value, fromUser -> binding.textRed.text = value.toString() updateBySeekBar(fromUser) } binding.seekGreen.onValueChanged = { value, fromUser -> binding.textGreen.text = value.toString() updateBySeekBar(fromUser) } binding.seekBlue.onValueChanged = { value, fromUser -> binding.textBlue.text = value.toString() updateBySeekBar(fromUser) } } override fun onAttachedToWindow() { super.onAttachedToWindow() delegate.onAttachedToWindow() } override fun onDetachedFromWindow() { super.onDetachedFromWindow() delegate.onDetachedFromWindow() } override fun onChanged(value: Int) { binding.seekRed.setValue(Color.red(value)) binding.seekGreen.setValue(Color.green(value)) binding.seekBlue.setValue(Color.blue(value)) } private fun updateBySeekBar(fromUser: Boolean) { if (!fromUser) return val color = Color.rgb( binding.seekRed.value, binding.seekGreen.value, binding.seekBlue.value ) delegate.post(color) } } ================================================ FILE: app/src/main/java/net/mm2d/color/chooser/ViewPagerAdapter.kt ================================================ /* * Copyright (c) 2018 大前良介 (OHMAE Ryosuke) * * This software is released under the MIT License. * http://opensource.org/licenses/MIT */ package net.mm2d.color.chooser import android.view.View import android.view.ViewGroup import androidx.core.view.ViewCompat import androidx.recyclerview.widget.RecyclerView import net.mm2d.color.chooser.ViewPagerAdapter.ViewHolder internal class ViewPagerAdapter( pageViews: List ) : RecyclerView.Adapter() { private val pageViews = pageViews.toList().onEach { it.id = ViewCompat.generateViewId() it.layoutParams = RecyclerView.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT ) } class ViewHolder(val view: View) : RecyclerView.ViewHolder(view) override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder = ViewHolder(pageViews[viewType]) override fun onBindViewHolder(holder: ViewHolder, position: Int) = Unit override fun getItemViewType(position: Int): Int = position override fun getItemCount(): Int = pageViews.size } ================================================ FILE: app/src/main/java/net/mm2d/color/chooser/element/ColorSliderView.kt ================================================ /* * Copyright (c) 2019 大前良介 (OHMAE Ryosuke) * * This software is released under the MIT License. * http://opensource.org/licenses/MIT */ package net.mm2d.color.chooser.element import android.annotation.SuppressLint import android.content.Context import android.graphics.* import android.graphics.Paint.Style import android.util.AttributeSet import android.view.MotionEvent import android.view.View import androidx.core.content.withStyledAttributes import net.mm2d.color.chooser.util.* import rocks.tbog.tblauncher.R import kotlin.math.max internal class ColorSliderView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : View(context, attrs, defStyleAttr) { private val paint = Paint().also { it.isAntiAlias = true } private val sampleRadius = getDimension(R.dimen.mm2d_cc_sample_radius) private val sampleFrameRadius = sampleRadius + getDimension(R.dimen.mm2d_cc_sample_frame) private val sampleShadowRadius = sampleFrameRadius + getDimension(R.dimen.mm2d_cc_sample_shadow) private val frameLineWidth = getDimension(R.dimen.mm2d_cc_sample_frame) private val shadowLineWidth = getDimension(R.dimen.mm2d_cc_sample_shadow) private val requestPaddingH = max(getPixels(R.dimen.mm2d_cc_panel_margin), sampleShadowRadius.toInt()) private val requestPaddingV = max(getPixels(R.dimen.mm2d_cc_panel_margin), (frameLineWidth * 2 + shadowLineWidth).toInt()) private val requestWidth = getPixels(R.dimen.mm2d_cc_slider_width) + requestPaddingH * 2 private val requestHeight = getPixels(R.dimen.mm2d_cc_slider_height) + requestPaddingV * 2 private val gradationRect = Rect(0, 0, RANGE, 1) private val targetRect = Rect() private val colorSampleFrame = getColor(R.color.mm2d_cc_sample_frame) private val colorSampleShadow = getColor(R.color.mm2d_cc_sample_shadow) private var checker: Bitmap? = null private var floatValue: Float = 0f private var maxColor: Int = Color.WHITE private var gradation: Bitmap private var baseColor: Int = Color.BLACK private var alphaMode: Boolean = true var onValueChanged: ((value: Int, fromUser: Boolean) -> Unit)? = null val value: Int get() = (floatValue * MAX).toInt() init { context.withStyledAttributes(attrs, R.styleable.ColorSliderView) { maxColor = getColor(R.styleable.ColorSliderView_maxColor, Color.WHITE) baseColor = getColor(R.styleable.ColorSliderView_baseColor, Color.BLACK) alphaMode = getBoolean(R.styleable.ColorSliderView_alphaMode, true) } gradation = createGradation(maxColor) updateChecker() } fun setMaxColor(maxColor: Int) { this.maxColor = maxColor.toOpacity() gradation = createGradation(this.maxColor) invalidate() } fun setValue(value: Int) { floatValue = (value / MAX.toFloat()).coerceIn(0f, 1f) onValueChanged?.invoke(value, false) invalidate() } private fun updateChecker() { checker = if (alphaMode) { createChecker( getPixels(R.dimen.mm2d_cc_checker_size), getPixels(R.dimen.mm2d_cc_slider_height), getColor(R.color.mm2d_cc_checker_light), getColor(R.color.mm2d_cc_checker_dark) ) } else { null } } @SuppressLint("ClickableViewAccessibility") override fun onTouchEvent(event: MotionEvent): Boolean { if (event.action == MotionEvent.ACTION_DOWN) { parent.requestDisallowInterceptTouchEvent(true) } floatValue = ((event.x - targetRect.left) / targetRect.width().toFloat()).coerceIn(0f, 1f) onValueChanged?.invoke(value, true) invalidate() return true } override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { targetRect.set( paddingLeft + requestPaddingH, paddingTop + requestPaddingV, width - paddingRight - requestPaddingH, height - paddingBottom - requestPaddingV ) } override fun onDraw(canvas: Canvas) { paint.style = Style.STROKE paint.color = colorSampleShadow paint.strokeWidth = shadowLineWidth val shadow = frameLineWidth + shadowLineWidth / 2 canvas.drawRectWithOffset(targetRect, shadow, paint) paint.color = colorSampleFrame paint.strokeWidth = frameLineWidth val frame = frameLineWidth / 2 canvas.drawRectWithOffset(targetRect, frame, paint) paint.style = Style.FILL if (alphaMode) { val checker = checker ?: return canvas.save() canvas.clipRect(targetRect) val top = targetRect.top.toFloat() for (left in targetRect.left until targetRect.right step checker.width) { canvas.drawBitmap(checker, left.toFloat(), top, paint) } canvas.restore() } else { paint.color = baseColor canvas.drawRect(targetRect, paint) } canvas.drawBitmap(gradation, gradationRect, targetRect, paint) val x = floatValue * targetRect.width() + targetRect.left val y = targetRect.centerY().toFloat() paint.color = colorSampleShadow canvas.drawCircle(x, y, sampleShadowRadius, paint) paint.color = colorSampleFrame canvas.drawCircle(x, y, sampleFrameRadius, paint) paint.color = baseColor canvas.drawCircle(x, y, sampleRadius, paint) paint.color = maxColor.setAlpha(value) canvas.drawCircle(x, y, sampleRadius, paint) } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { setMeasuredDimension( getDefaultSize( maxOf(requestWidth + paddingLeft + paddingRight, suggestedMinimumWidth), widthMeasureSpec ), resolveSizeAndState( maxOf(requestHeight + paddingTop + paddingBottom, suggestedMinimumHeight), heightMeasureSpec, MeasureSpec.UNSPECIFIED ) ) } companion object { private const val MAX = 255 private const val RANGE = 256 private fun createGradation(color: Int): Bitmap { val pixels = IntArray(RANGE) { color.setAlpha(it) } return Bitmap.createBitmap(pixels, RANGE, 1, Bitmap.Config.ARGB_8888) } private fun createChecker(step: Int, height: Int, color1: Int, color2: Int): Bitmap { val width = step * 4 val pixels = IntArray(width * height) for (y in 0 until height) { for (x in 0 until width) { pixels[x + y * width] = if ((x / step + y / step) % 2 == 0) color1 else color2 } } return Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888) } } } ================================================ FILE: app/src/main/java/net/mm2d/color/chooser/element/HueView.kt ================================================ /* * Copyright (c) 2018 大前良介 (OHMAE Ryosuke) * * This software is released under the MIT License. * http://opensource.org/licenses/MIT */ package net.mm2d.color.chooser.element import android.annotation.SuppressLint import android.content.Context import android.graphics.* import android.util.AttributeSet import android.view.MotionEvent import android.view.View import androidx.annotation.ColorInt import net.mm2d.color.chooser.util.ColorUtils import net.mm2d.color.chooser.util.getColor import net.mm2d.color.chooser.util.getDimension import net.mm2d.color.chooser.util.getPixels import rocks.tbog.tblauncher.R import kotlin.math.max internal class HueView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : View(context, attrs, defStyleAttr) { @ColorInt private var color: Int = Color.RED private val paint = Paint() private val bitmap: Bitmap = createMaskBitmap() private val sampleRadius = getDimension(R.dimen.mm2d_cc_sample_radius) private val sampleFrameRadius = sampleRadius + getDimension(R.dimen.mm2d_cc_sample_frame) private val sampleShadowRadius = sampleFrameRadius + getDimension(R.dimen.mm2d_cc_sample_shadow) private val requestPaddingH = getPixels(R.dimen.mm2d_cc_panel_margin) private val requestPaddingV = max(getPixels(R.dimen.mm2d_cc_panel_margin), sampleShadowRadius.toInt()) private val requestWidth = getPixels(R.dimen.mm2d_cc_hue_width) + requestPaddingH * 2 private val requestHeight = getPixels(R.dimen.mm2d_cc_hsv_size) + requestPaddingV * 2 private val bitmapRect = Rect(0, 0, 1, RANGE) private val targetRect = Rect() private var hue: Float = 0f private val colorSampleFrame = getColor(R.color.mm2d_cc_sample_frame) private val colorSampleShadow = getColor(R.color.mm2d_cc_sample_shadow) var onHueChanged: ((hue: Float) -> Unit)? = null fun setColor(@ColorInt color: Int) { updateHue(ColorUtils.hue(color)) } private fun updateHue(h: Float, fromUser: Boolean = false) { if (hue == h) return hue = h color = ColorUtils.hsvToColor(hue, 1f, 1f) invalidate() if (fromUser) { onHueChanged?.invoke(hue) } } @SuppressLint("ClickableViewAccessibility") override fun onTouchEvent(event: MotionEvent): Boolean { updateHue(((event.y - targetRect.top) / targetRect.height()).coerceIn(0f, 1f), true) return true } override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { targetRect.set( paddingLeft + requestPaddingH, paddingTop + requestPaddingV, width - paddingRight - requestPaddingH, height - paddingBottom - requestPaddingV ) } override fun onDraw(canvas: Canvas) { canvas.drawBitmap(bitmap, bitmapRect, targetRect, paint) val x = targetRect.centerX().toFloat() val y = hue * targetRect.height() + targetRect.top paint.color = colorSampleShadow canvas.drawCircle(x, y, sampleShadowRadius, paint) paint.color = colorSampleFrame canvas.drawCircle(x, y, sampleFrameRadius, paint) paint.color = color canvas.drawCircle(x, y, sampleRadius, paint) } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { setMeasuredDimension( resolveSizeAndState( max(requestWidth + paddingLeft + paddingRight, suggestedMinimumWidth), widthMeasureSpec, MeasureSpec.UNSPECIFIED ), resolveSizeAndState( max(requestHeight + paddingTop + paddingBottom, suggestedMinimumHeight), heightMeasureSpec, MeasureSpec.UNSPECIFIED ) ) } companion object { private const val RANGE = 360 private fun createMaskBitmap(): Bitmap { val pixels = IntArray(RANGE) { ColorUtils.hsvToColor(it.toFloat() / RANGE, 1f, 1f) } return Bitmap.createBitmap(pixels, 1, RANGE, Bitmap.Config.ARGB_8888) } } } ================================================ FILE: app/src/main/java/net/mm2d/color/chooser/element/PaletteCell.kt ================================================ /* * Copyright (c) 2018 大前良介 (OHMAE Ryosuke) * * This software is released under the MIT License. * http://opensource.org/licenses/MIT */ package net.mm2d.color.chooser.element import android.content.Context import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.Paint.Style import android.graphics.drawable.Drawable import android.util.AttributeSet import android.view.View import androidx.appcompat.content.res.AppCompatResources import androidx.core.graphics.drawable.DrawableCompat import rocks.tbog.tblauncher.R.drawable import net.mm2d.color.chooser.util.ColorUtils import kotlin.math.min internal class PaletteCell @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : View(context, attrs, defStyleAttr) { private val icon: Drawable = loadIcon(context) private var color: Int = Color.TRANSPARENT private val paint: Paint = Paint().also { it.style = Style.FILL_AND_STROKE } var checked: Boolean = false fun setColor(color: Int) { this.color = color paint.color = color isEnabled = color != Color.TRANSPARENT invalidate() } override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { val size = min(min(width, height), icon.intrinsicWidth) icon.setBounds((w - size) / 2, (h - size) / 2, (w + size) / 2, (h + size) / 2) } override fun onDraw(canvas: Canvas) { if (color == Color.TRANSPARENT) return canvas.drawColor(color) if (checked) { DrawableCompat.setTint(icon, selectForeground(color)) icon.draw(canvas) } } companion object { private var icon: Drawable? = null private fun loadIcon(context: Context): Drawable = icon ?: loadIconInner(context).also { icon = it } private fun loadIconInner(context: Context): Drawable = AppCompatResources.getDrawable(context, drawable.mm2d_cc_ic_check)!!.wrap() private fun Drawable.wrap(): Drawable = DrawableCompat.wrap(this) fun selectForeground(background: Int): Int = if (ColorUtils.shouldUseWhiteForeground(background)) Color.WHITE else Color.BLACK } } ================================================ FILE: app/src/main/java/net/mm2d/color/chooser/element/PreviewView.kt ================================================ /* * Copyright (c) 2019 大前良介 (OHMAE Ryosuke) * * This software is released under the MIT License. * http://opensource.org/licenses/MIT */ package net.mm2d.color.chooser.element import android.content.Context import android.graphics.* import android.graphics.Paint.Style import android.util.AttributeSet import android.view.View import rocks.tbog.tblauncher.R import net.mm2d.color.chooser.util.drawRectWithOffset import net.mm2d.color.chooser.util.getColor import net.mm2d.color.chooser.util.getDimension import net.mm2d.color.chooser.util.getPixels import kotlin.math.max internal class PreviewView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : View(context, attrs, defStyleAttr) { private val paint = Paint().also { it.isAntiAlias = true } private val requestWidth = getPixels(R.dimen.mm2d_cc_preview_width) private val requestHeight = getPixels(R.dimen.mm2d_cc_preview_height) private val frameLineWidth = getDimension(R.dimen.mm2d_cc_sample_frame) private val shadowLineWidth = getDimension(R.dimen.mm2d_cc_sample_shadow) private val colorSampleFrame = getColor(R.color.mm2d_cc_sample_frame) private val colorSampleShadow = getColor(R.color.mm2d_cc_sample_shadow) private val checkerRect = Rect() private val targetRect = Rect() private val checkerSize = getPixels(R.dimen.mm2d_cc_checker_size) private val colorCheckerLight = getColor(R.color.mm2d_cc_checker_light) private val colorCheckerDark = getColor(R.color.mm2d_cc_checker_dark) private var checker: Bitmap? = null var color: Int = Color.BLACK private set override fun onDraw(canvas: Canvas) { paint.style = Style.STROKE paint.color = colorSampleShadow paint.strokeWidth = shadowLineWidth val shadow = frameLineWidth + shadowLineWidth / 2 canvas.drawRectWithOffset(targetRect, shadow, paint) paint.color = colorSampleFrame paint.strokeWidth = frameLineWidth val frame = frameLineWidth / 2 canvas.drawRectWithOffset(targetRect, frame, paint) val checker = checker ?: return canvas.drawBitmap(checker, checkerRect, targetRect, paint) paint.style = Style.FILL paint.color = color canvas.drawRect(targetRect, paint) } fun setColor(color: Int) { this.color = color invalidate() } override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { val border = (frameLineWidth + shadowLineWidth).toInt() targetRect.set( paddingLeft + border, paddingTop + border, width - paddingRight - border, height - paddingBottom - border ) checkerRect.set(0, 0, targetRect.width(), targetRect.height()) checker = createChecker( checkerSize, checkerRect.width(), checkerRect.height(), colorCheckerLight, colorCheckerDark ) } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { setMeasuredDimension( resolveSizeAndState( max(requestWidth, suggestedMinimumWidth), widthMeasureSpec, MeasureSpec.UNSPECIFIED ), resolveSizeAndState( max(requestHeight, suggestedMinimumHeight), heightMeasureSpec, MeasureSpec.UNSPECIFIED ) ) } companion object { private fun createChecker( step: Int, width: Int, height: Int, color1: Int, color2: Int ): Bitmap { val pixels = IntArray(width * height) for (y in 0 until height) { for (x in 0 until width) { pixels[x + y * width] = if ((x / step + y / step) % 2 == 0) color1 else color2 } } return Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888) } } } ================================================ FILE: app/src/main/java/net/mm2d/color/chooser/element/SvView.kt ================================================ /* * Copyright (c) 2018 大前良介 (OHMAE Ryosuke) * * This software is released under the MIT License. * http://opensource.org/licenses/MIT */ package net.mm2d.color.chooser.element import android.annotation.SuppressLint import android.content.Context import android.graphics.* import android.util.AttributeSet import android.view.MotionEvent import android.view.View import androidx.annotation.ColorInt import net.mm2d.color.chooser.util.ColorUtils import net.mm2d.color.chooser.util.getColor import net.mm2d.color.chooser.util.getDimension import net.mm2d.color.chooser.util.getPixels import rocks.tbog.tblauncher.R import kotlin.math.abs import kotlin.math.max import kotlin.math.min internal class SvView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : View(context, attrs, defStyleAttr) { @ColorInt private var color: Int = Color.BLACK private var maxColor: Int = Color.RED private var maskBitmap: Bitmap? = null private val paint = Paint().also { it.isAntiAlias = true } private val sampleRadius = getDimension(R.dimen.mm2d_cc_sample_radius) private val sampleFrameRadius = sampleRadius + getDimension(R.dimen.mm2d_cc_sample_frame) private val sampleShadowRadius = sampleFrameRadius + getDimension(R.dimen.mm2d_cc_sample_shadow) private val requestPadding = max(getPixels(R.dimen.mm2d_cc_panel_margin), sampleShadowRadius.toInt()) private val requestWidth = getPixels(R.dimen.mm2d_cc_hsv_size) + requestPadding * 2 private val requestHeight = getPixels(R.dimen.mm2d_cc_hsv_size) + requestPadding * 2 private val maskRect = Rect(0, 0, TONE_SIZE, TONE_SIZE) private val targetRect = Rect() private var hue: Float = 0f private val colorSampleFrame = getColor(R.color.mm2d_cc_sample_frame) private val colorSampleShadow = getColor(R.color.mm2d_cc_sample_shadow) private val hsvCache = FloatArray(3) var saturation: Float = 0f private set var value: Float = 0f private set var onColorChanged: ((color: Int) -> Unit)? = null init { Thread { maskBitmap = createMaskBitmap() postInvalidate() }.start() } fun setColor(@ColorInt color: Int) { this.color = color ColorUtils.colorToHsv(color, hsvCache) updateHue(hsvCache[0]) updateSv(hsvCache[1], hsvCache[2]) } fun setHue(h: Float) { color = ColorUtils.hsvToColor(h, saturation, value) updateHue(h) } private fun updateHue(h: Float) { if (hue == h) { return } hue = h maxColor = ColorUtils.hsvToColor(hue, 1f, 1f) invalidate() } private fun updateSv(s: Float, v: Float, fromUser: Boolean = false) { if (saturation == s && value == v) { return } saturation = s value = v invalidate() if (fromUser) { onColorChanged?.invoke(color) } } @SuppressLint("ClickableViewAccessibility") override fun onTouchEvent(event: MotionEvent): Boolean { if (event.action == MotionEvent.ACTION_DOWN) { parent.requestDisallowInterceptTouchEvent(true) } val s = ((event.x - targetRect.left) / targetRect.width()).coerceIn(0f, 1f) val v = ((targetRect.bottom - event.y) / targetRect.height()).coerceIn(0f, 1f) color = ColorUtils.hsvToColor(hue, s, v) updateSv(s, v, true) return true } override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { targetRect.set( paddingLeft + requestPadding, paddingTop + requestPadding, width - paddingRight - requestPadding, height - paddingBottom - requestPadding ) } override fun onDraw(canvas: Canvas) { val mask = maskBitmap ?: return paint.color = maxColor canvas.drawRect(targetRect, paint) canvas.drawBitmap(mask, maskRect, targetRect, paint) val x = saturation * targetRect.width() + targetRect.left val y = (1f - value) * targetRect.height() + targetRect.top paint.color = colorSampleShadow canvas.drawCircle(x, y, sampleShadowRadius, paint) paint.color = colorSampleFrame canvas.drawCircle(x, y, sampleFrameRadius, paint) paint.color = color canvas.drawCircle(x, y, sampleRadius, paint) } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { val paddingHorizontal = paddingLeft + paddingRight val paddingVertical = paddingTop + paddingBottom val resizeWidth = MeasureSpec.getMode(widthMeasureSpec) != MeasureSpec.EXACTLY val resizeHeight = MeasureSpec.getMode(heightMeasureSpec) != MeasureSpec.EXACTLY if (!resizeWidth && !resizeHeight) { setMeasuredDimension( resolveSizeAndState( max(requestWidth + paddingHorizontal, suggestedMinimumWidth), widthMeasureSpec, MeasureSpec.UNSPECIFIED ), resolveSizeAndState( max(requestHeight + paddingVertical, suggestedMinimumHeight), heightMeasureSpec, MeasureSpec.UNSPECIFIED ) ) return } var widthSize = resolveAdjustedSize(requestWidth + paddingHorizontal, widthMeasureSpec) var heightSize = resolveAdjustedSize(requestHeight + paddingVertical, heightMeasureSpec) val actualAspect = (widthSize - paddingHorizontal).toFloat() / (heightSize - paddingVertical) if (abs(actualAspect - 1f) < 0.0000001) { setMeasuredDimension(widthSize, heightSize) return } if (resizeWidth) { val newWidth = heightSize - paddingVertical + paddingHorizontal if (!resizeHeight) { widthSize = resolveAdjustedSize(newWidth, widthMeasureSpec) } if (newWidth <= widthSize) { widthSize = newWidth setMeasuredDimension(widthSize, heightSize) return } } if (resizeHeight) { val newHeight = widthSize - paddingHorizontal + paddingVertical if (!resizeWidth) { heightSize = resolveAdjustedSize(newHeight, heightMeasureSpec) } if (newHeight <= heightSize) { heightSize = newHeight } } setMeasuredDimension(widthSize, heightSize) } private fun resolveAdjustedSize(desiredSize: Int, measureSpec: Int): Int { val specMode = MeasureSpec.getMode(measureSpec) val specSize = MeasureSpec.getSize(measureSpec) return when (specMode) { MeasureSpec.UNSPECIFIED -> desiredSize MeasureSpec.AT_MOST -> min(desiredSize, specSize) MeasureSpec.EXACTLY -> specSize else -> desiredSize } } companion object { private const val TONE_MAX = 255f private const val TONE_SIZE = 256 private fun createMaskBitmap(): Bitmap { val pixels = IntArray(TONE_SIZE * TONE_SIZE) for (y in 0 until TONE_SIZE) { for (x in 0 until TONE_SIZE) { pixels[x + y * TONE_SIZE] = ColorUtils.svToMask(x / TONE_MAX, (TONE_MAX - y) / TONE_MAX) } } return Bitmap.createBitmap(pixels, TONE_SIZE, TONE_SIZE, Bitmap.Config.ARGB_8888) } } } ================================================ FILE: app/src/main/java/net/mm2d/color/chooser/util/AttrExtentions.kt ================================================ /* * Copyright (c) 2019 大前良介 (OHMAE Ryosuke) * * This software is released under the MIT License. * http://opensource.org/licenses/MIT */ package net.mm2d.color.chooser.util import android.annotation.SuppressLint import android.content.Context import androidx.annotation.AttrRes import androidx.annotation.ColorInt import androidx.annotation.StyleRes import androidx.core.content.res.use @ColorInt internal fun Context.resolveColor( @AttrRes attr: Int, @ColorInt defaultColor: Int ): Int = resolveColor(0, attr, defaultColor) @SuppressLint("Recycle") @ColorInt internal fun Context.resolveColor( @StyleRes style: Int, @AttrRes attr: Int, @ColorInt defaultColor: Int ): Int = obtainStyledAttributes(style, intArrayOf(attr)) .use { it.getColor(0, defaultColor) } ================================================ FILE: app/src/main/java/net/mm2d/color/chooser/util/CanvasExtensions.kt ================================================ /* * Copyright (c) 2020 大前良介 (OHMAE Ryosuke) * * This software is released under the MIT License. * http://opensource.org/licenses/MIT */ package net.mm2d.color.chooser.util import android.graphics.Canvas import android.graphics.Paint import android.graphics.Rect internal fun Canvas.drawRectWithOffset(rect: Rect, offset: Float, paint: Paint) = drawRect( rect.left - offset, rect.top - offset, rect.right + offset, rect.bottom + offset, paint ) ================================================ FILE: app/src/main/java/net/mm2d/color/chooser/util/ColorUtils.kt ================================================ /* * Copyright (c) 2018 大前良介 (OHMAE Ryosuke) * * This software is released under the MIT License. * http://opensource.org/licenses/MIT */ package net.mm2d.color.chooser.util import androidx.core.graphics.blue import androidx.core.graphics.green import androidx.core.graphics.red import kotlin.math.pow /** * HSVやRGBの色空間表現を扱う上でのメソッド */ internal object ColorUtils { /** * Convert given HSV [0.0f, 1.0f] to color * * @param h Hue * @param s Saturation * @param v Value * @return color */ fun hsvToColor(h: Float, s: Float, v: Float): Int { if (s <= 0f) return toColor(v, v, v) val hue = h * 6f // [0.0f, 6.0f] val i = hue.toInt() // hueの整数部 val d = hue - i // hueの小数部 var r = v var g = v var b = v when (i) { 0 -> { // h:[0.0f, 1.0f) g *= 1f - s * (1f - d) b *= 1f - s } 1 -> { // h:[1.0f, 2.0f) r *= 1f - s * d b *= 1f - s } 2 -> { // h:[2.0f, 3.0f) r *= 1f - s b *= 1f - s * (1f - d) } 3 -> { // h:[3.0f, 4.0f) r *= 1f - s g *= 1f - s * d } 4 -> { // h:[4.0f, 5.0f) r *= 1f - s * (1f - d) g *= 1f - s } 5 -> { // h:[5.0f, 6.0f) g *= 1f - s b *= 1f - s * d } else -> { g *= 1f - s * (1f - d) b *= 1f - s } } return toColor(r, g, b) } /** * Convert given SV [0.0f, 1.0f] to monochrome + alpha mask * * @param s Saturation * @param v Value * @return pixel value of mask */ fun svToMask(s: Float, v: Float): Int { val a = 1f - (s * v) val g = if (a == 0f) 0f else (v * (1f - s) / a).coerceIn(0f, 1f) return toColor(a, g, g, g) } /** * Convert given color to HSV [0.0f, 1.0f] array * * @param color color * @param outHsv hsv buffer if not specify or null, allocate new array * @return hsv array */ fun colorToHsv(color: Int, outHsv: FloatArray? = null): FloatArray { val r = color.red / 255f val g = color.green / 255f val b = color.blue / 255f val max = max(r, g, b) val min = min(r, g, b) val hsv = outHsv ?: FloatArray(3) hsv[0] = hue(r, g, b, max, min) hsv[1] = saturation(max, min) hsv[2] = max return hsv } /** * Calculate hue value * * @param color color * @return hue */ fun hue(color: Int): Float { val r = color.red / 255f val g = color.green / 255f val b = color.blue / 255f val max = max(r, g, b) val min = min(r, g, b) return hue(r, g, b, max, min) } private fun max(v1: Float, v2: Float, v3: Float): Float = maxOf(maxOf(v1, v2), v3) private fun min(v1: Float, v2: Float, v3: Float): Float = minOf(minOf(v1, v2), v3) private fun hue(r: Float, g: Float, b: Float, max: Float, min: Float): Float { val range = max - min if (range == 0f) return 0f val hue = when (max) { r -> ((g - b) / range).let { if (it < 0f) it + 6f else it } g -> (b - r) / range + 2f else -> (r - g) / range + 4f } return (hue / 6f).coerceIn(0f, 1f) } private fun saturation(max: Float, min: Float): Float = if (max != 0.0f) (max - min) / max else 0f /** * Convert [0.0f, 1.0f] ARGB value to color * * @param r Red value * @param g Green value * @param b Blue value * @return color */ private fun toColor(r: Float, g: Float, b: Float): Int = toColor(r.to8bit(), g.to8bit(), b.to8bit()) /** * Convert [0, 255] RGB value to color * * @param r Red value * @param g Green value * @param b Blue value * @return color */ private fun toColor(r: Int, g: Int, b: Int): Int = (0xff shl 24) or (0xff and r shl 16) or (0xff and g shl 8) or (0xff and b) /** * Convert [0.0f, 1.0f] ARGB value to color * * @param a Alpha value * @param r Red value * @param g Green value * @param b Blue value * @return color */ private fun toColor(a: Float, r: Float, g: Float, b: Float): Int = toColor( a.to8bit(), r.to8bit(), g.to8bit(), b.to8bit() ) /** * Convert [0, 255] ARGB value to color * * @param a Alpha value * @param r Red value * @param g Green value * @param b Blue value * @return color */ private fun toColor(a: Int, r: Int, g: Int, b: Int): Int = (0xff and a shl 24) or (0xff and r shl 16) or (0xff and g shl 8) or (0xff and b) /** * Calculate luminance based on ITU-R BT.709 and sRGB * * https://www.w3.org/TR/WCAG20/#relativeluminancedef * * @param r Red ratio * @param g Green ratio * @param b Blue ratio * @return luminance */ fun luminance(r: Float, g: Float, b: Float): Float = r * 0.2126f + g * 0.7152f + b * 0.0722f /** * Minimum contrast for large text based on W3C guideline * * https://www.w3.org/TR/WCAG20/#visual-audio-contrast-contrast */ private const val MINIMUM_CONTRAST_FOR_LARGE_TEXT = 3f /** * Determine whether sufficient contrast can be secured with white foreground. * * @param color * @return if true, should use white foreground, else avoid white foreground */ fun shouldUseWhiteForeground(color: Int): Boolean = color.contrastWithWhite() > MINIMUM_CONTRAST_FOR_LARGE_TEXT } /** * Overwrite alpha value of color * * @receiver color * @param alpha Alpha * @return alpha applied color */ internal fun Int.setAlpha(alpha: Int): Int = this and 0xffffff or (alpha shl 24) /** * Overwrite alpha value to completely opaque */ internal fun Int.toOpacity(): Int = setAlpha(0xff) /** * Convert [0, 255] to [0.0f, 1.0f] * * @receiver [0, 255] * @return [0.0f, 1.0f] */ internal fun Int.toRatio(): Float = this / 255f /** * Convert [0.0f, 1.0f] to [0, 255] * * @receiver [0.0f, 1.0f] * @return [0, 255] */ internal fun Float.to8bit(): Int = (this * 255f + 0.5f).toInt().coerceIn(0, 255) /** * Normalize value of primary color luminance to calculate sRGB luminance of color * * https://www.w3.org/TR/WCAG20/#relativeluminancedef * * @receiver primary color luminance * @return normalized luminance */ internal fun Float.normalizeForSrgb(): Float = if (this < 0.03928f) this / 12.92f else ((this + 0.055) / 1.055).pow(2.4).toFloat() /** * Normalize value of primary color luminance to calculate sRGB luminance of color * * https://www.w3.org/TR/WCAG20/#relativeluminancedef * * @receiver primary color luminance * @return normalized luminance */ internal fun Int.normalizeForSrgb(): Float = toRatio().normalizeForSrgb() /** * Calculate sRGB luminance of color * * @receiver color * @return sRGB luminance */ internal fun Int.relativeLuminance(): Float { return ColorUtils.luminance( red.normalizeForSrgb(), green.normalizeForSrgb(), blue.normalizeForSrgb() ) } /** * Calculate contrast between given color and pure white (#ffffff) * * @receiver color * @return contrast [1, 21] */ internal fun Int.contrastWithWhite(): Float { return 1.05f / (relativeLuminance() + 0.05f) } ================================================ FILE: app/src/main/java/net/mm2d/color/chooser/util/ResourceExtensions.kt ================================================ /* * Copyright (c) 2020 大前良介 (OHMAE Ryosuke) * * This software is released under the MIT License. * http://opensource.org/licenses/MIT */ package net.mm2d.color.chooser.util import android.content.Context import android.util.TypedValue import android.view.View import androidx.annotation.ColorInt import androidx.annotation.ColorRes import androidx.annotation.DimenRes import androidx.annotation.Dimension import androidx.core.content.ContextCompat @ColorInt internal fun View.getColor(@ColorRes id: Int): Int = ContextCompat.getColor(context, id) @Dimension internal fun View.getDimension(@DimenRes id: Int): Float = resources.getDimension(id) @Dimension internal fun View.getPixels(@DimenRes id: Int): Int = resources.getDimensionPixelSize(id) @Dimension internal fun Int.toPixelsAsDp(context: Context): Int = TypedValue.complexToDimensionPixelSize(this, context.resources.displayMetrics) ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/Behaviour.java ================================================ package rocks.tbog.tblauncher; import static rocks.tbog.tblauncher.entry.EntryItem.LAUNCHED_FROM_GESTURE; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.app.Activity; import android.content.ActivityNotFoundException; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.ActivityInfo; import android.content.pm.LauncherApps; import android.graphics.drawable.Animatable; import android.graphics.drawable.Drawable; import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.os.Message; import android.provider.Settings; import android.text.Editable; import android.text.TextUtils; import android.text.TextWatcher; import android.util.Log; import android.view.View; import android.view.ViewGroup; import android.view.Window; import android.view.animation.AccelerateInterpolator; import android.view.animation.DecelerateInterpolator; import android.view.animation.LinearInterpolator; import android.widget.EditText; import android.widget.ImageView; import android.widget.TextView; import android.widget.Toast; import androidx.annotation.DrawableRes; import androidx.annotation.IdRes; import androidx.annotation.LayoutRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.StringRes; import androidx.appcompat.app.ActionBar; import androidx.lifecycle.Lifecycle; import androidx.preference.PreferenceManager; import androidx.recyclerview.widget.RecyclerView; import java.lang.reflect.Constructor; import java.text.DateFormat; import java.util.ArrayList; import java.util.Collections; import java.util.Date; import java.util.List; import java.util.Random; import java.util.Set; import rocks.tbog.tblauncher.customicon.ButtonHelper; import rocks.tbog.tblauncher.customicon.IconSelectDialog; import rocks.tbog.tblauncher.dataprovider.IProvider; import rocks.tbog.tblauncher.dataprovider.TagsProvider; import rocks.tbog.tblauncher.entry.ActionEntry; import rocks.tbog.tblauncher.entry.AppEntry; import rocks.tbog.tblauncher.entry.DialContactEntry; import rocks.tbog.tblauncher.entry.EntryItem; import rocks.tbog.tblauncher.entry.EntryWithTags; import rocks.tbog.tblauncher.entry.SearchEntry; import rocks.tbog.tblauncher.entry.ShortcutEntry; import rocks.tbog.tblauncher.entry.StaticEntry; import rocks.tbog.tblauncher.handler.DataHandler; import rocks.tbog.tblauncher.quicklist.EditQuickListDialog; import rocks.tbog.tblauncher.result.CustomRecycleLayoutManager; import rocks.tbog.tblauncher.result.RecycleAdapter; import rocks.tbog.tblauncher.result.RecycleScrollListener; import rocks.tbog.tblauncher.result.ResultHelper; import rocks.tbog.tblauncher.result.ResultItemDecoration; import rocks.tbog.tblauncher.searcher.ISearchActivity; import rocks.tbog.tblauncher.searcher.QuerySearcher; import rocks.tbog.tblauncher.searcher.Searcher; import rocks.tbog.tblauncher.shortcut.ShortcutUtil; import rocks.tbog.tblauncher.ui.DialogFragment; import rocks.tbog.tblauncher.ui.KeyboardHandler; import rocks.tbog.tblauncher.ui.LinearAdapter; import rocks.tbog.tblauncher.ui.ListPopup; import rocks.tbog.tblauncher.ui.RecyclerList; import rocks.tbog.tblauncher.ui.ViewStubPreview; import rocks.tbog.tblauncher.ui.WindowInsetsHelper; import rocks.tbog.tblauncher.ui.dialog.TagsManagerDialog; import rocks.tbog.tblauncher.utils.KeyboardToggleHelper; import rocks.tbog.tblauncher.utils.KeyboardTriggerBehaviour; import rocks.tbog.tblauncher.utils.PrefCache; import rocks.tbog.tblauncher.utils.SystemUiVisibility; import rocks.tbog.tblauncher.utils.UISizes; import rocks.tbog.tblauncher.utils.UITheme; import rocks.tbog.tblauncher.utils.UserHandleCompat; import rocks.tbog.tblauncher.utils.Utilities; /** * Behaviour of the launcher, when are stuff hidden, animation, user interaction responses */ public class Behaviour implements ISearchActivity { public static final int LAUNCH_DELAY = 100; static final String DIALOG_CUSTOM_ICON = "custom_icon_dialog"; static final String DIALOG_EDIT_TAGS = "edit_tags_dialog"; static final String DIALOG_EDIT_QUICK_LIST = "edit_quick_list_dialog"; static final String DIALOG_TAGS_MANAGER = "tags_manager_dialog"; private static final int UI_ANIMATION_DELAY = 300; // time to wait for the keyboard to show up private static final int KEYBOARD_ANIMATION_DELAY = 100; private static final int UI_ANIMATION_DURATION = 200; private static final String TAG = Behaviour.class.getSimpleName(); private TBLauncherActivity mTBLauncherActivity = null; private DialogFragment mFragmentDialog = null; private View mResultLayout; private RecyclerList mResultList; private RecycleAdapter mResultAdapter; private EditText mSearchEditText; private View mSearchBarContainer; private View mWidgetContainer; private View mClearButton; private View mMenuButton; private TextView mLauncherTime = null; private final Runnable mUpdateTime = new Runnable() { @Override public void run() { if (mLauncherTime == null || !mLauncherTime.isAttachedToWindow() || !mTBLauncherActivity.getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.STARTED)) return; Date date = new Date(); mLauncherTime.setText(DateFormat.getDateTimeInstance().format(date)); long delay = 1000 - date.getTime() % 1000; mLauncherTime.postDelayed(mUpdateTime, delay); } }; private boolean mLaunchMostRelevantResult = false; private final TextWatcher mSearchTextWatcher = new TextWatcher() { @NonNull String lastText = ""; public void afterTextChanged(Editable s) { //Log.i(TAG, "afterTextChanged `" + s + "`"); // left-trim text. final int length = s.length(); int spaceEnd = 0; while (spaceEnd < length && s.charAt(spaceEnd) == ' ') spaceEnd += 1; if (spaceEnd > 0) { // delete and wait for the next call to afterTextChanged generated by the delete s.delete(0, spaceEnd); } else { String text = s.toString(); if (lastText.equals(text) || mLaunchMostRelevantResult) return; if (TextUtils.isEmpty(text)) clearAdapter(); else updateSearchRecords(false, text); updateClearButton(); } } public void beforeTextChanged(CharSequence s, int start, int count, int after) { lastText = (s != null) ? s.toString() : ""; } public void onTextChanged(CharSequence s, int start, int before, int count) { // do nothing } }; private ImageView mLauncherButton; private View mDecorView; private Handler mHandler; private final Runnable mHidePart2Runnable = new Runnable() { @Override public void run() { // if (TBApplication.state().isKeyboardVisible()) { // // if keyboard is visible, the notification bar is also visible // return; // } // Delayed hide UI elements ActionBar actionBar = mTBLauncherActivity != null ? mTBLauncherActivity.getSupportActionBar() : null; if (actionBar != null) { actionBar.hide(); } SystemUiVisibility.setFullscreen(mDecorView); //mTBLauncherActivity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS); } }; private final Runnable mShowPart2Runnable = new Runnable() { @Override public void run() { // Delayed display of UI elements ActionBar actionBar = mTBLauncherActivity != null ? mTBLauncherActivity.getSupportActionBar() : null; if (actionBar != null) { actionBar.show(); } SystemUiVisibility.clearFullscreen(mDecorView); //mTBLauncherActivity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS); } }; private final Runnable mShowKeyboardRunnable = () -> { if (WindowInsetsHelper.isKeyboardVisible(mSearchEditText)) this.mKeyboardHandler.mRequestOpen = false; else this.mKeyboardHandler.showKeyboard(); }; private final Runnable mOnKeyboardClosedByUser = () -> { Log.i(TAG, "on keyboard closed by user"); if (dismissPopup()) return; LauncherState state = TBApplication.state(); if (LauncherState.Desktop.SEARCH == state.getDesktop()) { if (PrefCache.linkCloseKeyboardToBackButton(this.mPref)) onBackPressed(); } if (LauncherState.Desktop.SEARCH == state.getDesktop()) { if (state.isKeyboardHidden() && PrefCache.modeSearchFullscreen(this.mPref)) enableFullscreen(0); } }; private View mNotificationBackground; private KeyboardToggleHelper mKeyboardHandler = null; private RecycleScrollListener mRecycleScrollListener; private SharedPreferences mPref; private static void launchIntent(@NonNull Behaviour behaviour, @NonNull View view, @NonNull Intent intent) { behaviour.beforeLaunchOccurred(); view.postDelayed(() -> { Activity activity = Utilities.getActivity(view); if (activity == null) return; Utilities.setIntentSourceBounds(intent, view); Bundle startActivityOptions = Utilities.makeStartActivityOptions(view); try { activity.startActivity(intent, startActivityOptions); } catch (ActivityNotFoundException ignored) { return; } behaviour.afterLaunchOccurred(); }, LAUNCH_DELAY); } private void initResultLayout() { mResultLayout = inflateViewStub(R.id.resultLayout); mResultList = mResultLayout.findViewById(R.id.resultList); if (mResultList == null) throw new IllegalStateException("mResultList==null"); mRecycleScrollListener = new RecycleScrollListener(new KeyboardHandler() { @Override public void showKeyboard() { mKeyboardHandler.showKeyboard(); } @Override public void hideKeyboard() { mKeyboardHandler.hideKeyboard(); mKeyboardHandler.mHiddenByScrolling = true; } }); mResultAdapter = new RecycleAdapter(getContext(), new ArrayList<>()); mResultList.setHasFixedSize(true); mResultList.setAdapter(mResultAdapter); mResultList.addOnScrollListener(mRecycleScrollListener); // mResultList.addOnLayoutChangeListener(recycleScrollListener); int vertical = getContext().getResources().getDimensionPixelSize(R.dimen.result_margin_vertical); mResultList.addItemDecoration(new ResultItemDecoration(0, vertical, true)); setListLayout(); } private void initSearchBarContainer() { int layout = PrefCache.getSearchBarLayout(mPref); if (PrefCache.searchBarAtBottom(mPref)) { mSearchBarContainer = inflateViewStub(R.id.stubSearchBottom, layout); } else { mSearchBarContainer = inflateViewStub(R.id.stubSearchTop, layout); } if (mSearchBarContainer == null) throw new IllegalStateException("mSearchBarContainer==null"); mLauncherButton = mSearchBarContainer.findViewById(R.id.launcherButton); mSearchEditText = mSearchBarContainer.findViewById(R.id.launcherSearch); mClearButton = mSearchBarContainer.findViewById(R.id.clearButton); mMenuButton = mSearchBarContainer.findViewById(R.id.menuButton); // when pill search bar expanded, show keyboard mTBLauncherActivity.customizeUI.setExpandedSearchPillListener(this::showKeyboard); } private void initLauncherButtons() { final ListPopup buttonMenu; if (PrefCache.getSearchBarLayout(mPref) == R.layout.search_pill) buttonMenu = getButtonPopup(getContext(), ButtonHelper.BTN_ID_LAUNCHER_PILL, R.drawable.launcher_pill); else buttonMenu = getButtonPopup(getContext(), ButtonHelper.BTN_ID_LAUNCHER_WHITE, R.drawable.launcher_white); mLauncherButton.setOnClickListener((v) -> executeButtonAction("button-launcher")); mLauncherButton.setOnLongClickListener((v) -> ButtonHelper.showButtonPopup(v, buttonMenu)); // menu button / 3 dot button actions mMenuButton.setOnClickListener(v -> { Context ctx = v.getContext(); ListPopup menu = getMenuPopup(ctx); registerPopup(menu); menu.showCenter(v); }); mMenuButton.setOnLongClickListener(v -> { Context ctx = v.getContext(); ListPopup menu = getMenuPopup(ctx); // check if menu contains elements and if yes show it if (!menu.getAdapter().isEmpty()) { registerPopup(menu); menu.show(v, 0f); return true; } return false; }); // clear button actions mClearButton.setOnClickListener(v -> clearSearch()); mClearButton.setOnLongClickListener(v -> { clearSearch(); Context ctx = v.getContext(); ListPopup menu = getMenuPopup(ctx); // check if menu contains elements and if yes show it if (!menu.getAdapter().isEmpty()) { registerPopup(menu); menu.show(v); return true; } return false; }); } private void setSearchHint() { Set selectedHints = mPref.getStringSet("selected-search-hints", null); if (selectedHints != null && !selectedHints.isEmpty()) { int random = new Random().nextInt(selectedHints.size()); for (String selectedHint : selectedHints) { if (--random < 0) { mSearchEditText.setHint(selectedHint); break; } } } } private void initLauncherSearchEditText() { setSearchHint(); mSearchEditText.setTextIsSelectable(false); mSearchEditText.addTextChangedListener(mSearchTextWatcher); // On validate, launch first record mSearchEditText.setOnEditorActionListener((view, actionId, event) -> { // Return true if you have consumed the action, else false. // if keyboard close action issued if (actionId == android.R.id.closeButton) { LauncherState state = TBApplication.state(); // Fix for #238 state.syncKeyboardVisibility(view); if (state.isKeyboardHidden()) { Log.i(TAG, "Keyboard - closeButton while keyboard hidden"); return false; } if (state.isSearchBarVisible() && PrefCache.linkKeyboardAndSearchBar(mPref)) { // consume action to avoid closing the keyboard Log.i(TAG, "Keyboard - closeButton - linkKeyboardAndSearchBar"); return true; } // close the keyboard Log.i(TAG, "Keyboard - closeButton - close"); return false; } // launch most relevant result if (TBApplication.hasSearchTask(getContext())) { mLaunchMostRelevantResult = true; return true; } else { final int mostRelevantIdx = mResultList.getAdapterFirstItemIdx(); if (mostRelevantIdx >= 0 && mostRelevantIdx < mResultAdapter.getItemCount()) { RecyclerView.ViewHolder holder = mResultList.findViewHolderForAdapterPosition(mostRelevantIdx); mResultAdapter.onClick(mostRelevantIdx, holder != null ? holder.itemView : view); return true; } } return false; }); } private KeyboardToggleHelper newKeyboardHandler() { return new KeyboardToggleHelper(mSearchEditText) { @Override public void showKeyboard() { LauncherState state = TBApplication.state(); if (TBApplication.activityInvalid(mTBLauncherActivity)) { Log.e(TAG, "[activityInvalid] showKeyboard"); return; } if (state.isSearchBarVisible() && PrefCache.modeSearchFullscreen(mPref)) { showSystemBars(); disableFullscreen(); } Log.i(TAG, "Keyboard - SHOW"); removeCallback(mOnKeyboardClosedByUser); dismissPopup(); mSearchEditText.requestFocus(); super.showKeyboard(); } @Override public void hideKeyboard() { if (TBApplication.activityInvalid(mTBLauncherActivity)) { Log.e(TAG, "[activityInvalid] hideKeyboard"); return; } if (TBApplication.state().isSearchBarVisible() && PrefCache.modeSearchFullscreen(mPref)) { //hideSystemBars(); enableFullscreen(0); } Log.i(TAG, "Keyboard - HIDE"); dismissPopup(); View focus = mTBLauncherActivity.getCurrentFocus(); if (focus != null) focus.clearFocus(); mSearchEditText.clearFocus(); super.hideKeyboard(); } }; } private void removeCallback(@NonNull Runnable callback) { mHandler.removeCallbacks(callback); } private void postDelayedCallbackOnce(@NonNull Runnable callback, long delayMillis) { mHandler.removeCallbacks(callback); mHandler.postDelayed(callback, delayMillis); } private void postDelayedRunnableOnce(@NonNull Runnable runnable, @NonNull Object token, long delayMillis) { mHandler.removeCallbacksAndMessages(token); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { mHandler.postDelayed(runnable, token, delayMillis); } else { Message msg = Message.obtain(mHandler, runnable); msg.obj = token; mHandler.sendMessageDelayed(msg, delayMillis); } } public void onCreateActivity(TBLauncherActivity tbLauncherActivity) { mHandler = new Handler(tbLauncherActivity.getMainLooper()); mTBLauncherActivity = tbLauncherActivity; mPref = PreferenceManager.getDefaultSharedPreferences(mTBLauncherActivity); Window window = mTBLauncherActivity.getWindow(); mDecorView = window.getDecorView(); KeyboardTriggerBehaviour keyboardListener = new KeyboardTriggerBehaviour(mTBLauncherActivity); keyboardListener.observe(mTBLauncherActivity, status -> { LauncherState state = TBApplication.state(); if (status == KeyboardTriggerBehaviour.Status.CLOSED) { //-->> keyboard CLOSED event <<--// boolean keyboardClosedByUser = true; if (state.getSearchBarVisibility() == LauncherState.AnimatedVisibility.ANIM_TO_VISIBLE) { Log.i(TAG, "keyboard closed - app start"); // don't call onKeyboardClosed() when we start the app keyboardClosedByUser = false; } else if (mKeyboardHandler != null && mKeyboardHandler.mHiddenByScrolling) { Log.i(TAG, "keyboard closed - scrolling results"); // keyboard closed because the result list was scrolled keyboardClosedByUser = false; } else if (isFragmentDialogVisible()) { Log.i(TAG, "keyboard closed - fragment dialog"); // don't send keyboard close event while we have a dialog open keyboardClosedByUser = false; } else if (mKeyboardHandler != null && mKeyboardHandler.mLaunchedApp) { Log.i(TAG, "keyboard closed - launched app"); // don't send keyboard close event after start intent keyboardClosedByUser = false; } if (keyboardClosedByUser) { if (mKeyboardHandler != null && mKeyboardHandler.mRequestOpen) { Log.i(TAG, "keyboard closed - while mRequestOpen true"); mKeyboardHandler.mRequestOpen = false; } else { Log.i(TAG, "keyboard closed - user"); // delay keyboard closed event to make sure the keyboard is not just glitching postDelayedCallbackOnce(mOnKeyboardClosedByUser, UI_ANIMATION_DURATION); } } // collapse search pill if (state.isSearchBarVisible()) { int duration = 0; if (mPref.getBoolean("search-bar-animation", true)) duration = UI_ANIMATION_DURATION; mTBLauncherActivity.customizeUI.collapseSearchPill(duration); } } else { //-->> keyboard OPEN event <<--// if (mKeyboardHandler != null) { // request to open fulfilled mKeyboardHandler.mRequestOpen = false; // reset HiddenByScrolling flag when keyboard opens mKeyboardHandler.mHiddenByScrolling = false; } // don't call the keyboard closed event if keyboard opened removeCallback(mOnKeyboardClosedByUser); // expand search pill if (state.isSearchBarVisible()) { int duration = 0; if (mPref.getBoolean("search-bar-animation", true)) duration = UI_ANIMATION_DURATION; mTBLauncherActivity.customizeUI.expandSearchPill(duration); mSearchEditText.requestFocus(); } } }); initResultLayout(); initSearchBarContainer(); // KeyboardHandler needs SearchEditText initialized mKeyboardHandler = newKeyboardHandler(); mNotificationBackground = findViewById(R.id.notificationBackground); mWidgetContainer = findViewById(R.id.widgetContainer); initLauncherButtons(); initLauncherSearchEditText(); } public void onStart() { // don't let the close keyboard event trigger mKeyboardHandler.mLaunchedApp = true; String initialDesktop = mPref.getString("initial-desktop", null); if ("none".equals(initialDesktop)) { if (TBApplication.state().getDesktop() != null) { return; } Log.d(TAG, "desktop is null"); } if (executeAction(initialDesktop, null)) return; switchToDesktop(LauncherState.Desktop.EMPTY); } private ListPopup getMenuPopup(Context ctx) { LinearAdapter adapter = new LinearAdapter(); ListPopup menu = ListPopup.create(ctx, adapter); adapter.add(new LinearAdapter.ItemTitle(ctx, R.string.menu_popup_title)); adapter.add(new LinearAdapter.Item(ctx, R.string.change_wallpaper)); adapter.add(new LinearAdapter.Item(ctx, R.string.menu_widget_add)); if (TBApplication.widgetManager(ctx).widgetCount() > 0) adapter.add(new LinearAdapter.Item(ctx, R.string.menu_widget_remove)); adapter.add(new LinearAdapter.ItemTitle(ctx, R.string.menu_popup_title_settings)); adapter.add(new LinearAdapter.Item(ctx, R.string.menu_popup_launcher_settings)); adapter.add(new LinearAdapter.Item(ctx, R.string.menu_popup_tags_manager)); adapter.add(new LinearAdapter.Item(ctx, R.string.menu_popup_tags_menu)); adapter.add(new LinearAdapter.Item(ctx, R.string.menu_popup_android_settings)); menu.setOnItemClickListener((a, v, pos) -> { LinearAdapter.MenuItem item = ((LinearAdapter) a).getItem(pos); @StringRes int stringId = 0; if (item instanceof LinearAdapter.Item) { stringId = ((LinearAdapter.Item) a.getItem(pos)).stringId; } Context c = mTBLauncherActivity; if (stringId == R.string.menu_popup_tags_manager) { launchTagsManagerDialog(mTBLauncherActivity); } else if (stringId == R.string.menu_popup_tags_menu) { executeAction("showTagsMenu", "button-menu"); } else if (stringId == R.string.menu_popup_launcher_settings) { Intent intent = new Intent(mClearButton.getContext(), SettingsActivity.class); launchIntent(this, mClearButton, intent); } else if (stringId == R.string.change_wallpaper) { Intent intent = new Intent(Intent.ACTION_SET_WALLPAPER); intent = Intent.createChooser(intent, c.getString(R.string.change_wallpaper)); launchIntent(this, mClearButton, intent); } else if (stringId == R.string.menu_widget_add) { TBApplication.widgetManager(c).showSelectWidget(mTBLauncherActivity); } else if (stringId == R.string.menu_widget_remove) { TBApplication.widgetManager(c).showRemoveWidgetPopup(); } else if (stringId == R.string.menu_popup_android_settings) { Intent intent = new Intent(Settings.ACTION_SETTINGS); launchIntent(this, mClearButton, intent); } }); return menu; } public void launchIntent(@NonNull View view, @NonNull Intent intent) { launchIntent(this, view, intent); } @SuppressWarnings("TypeParameterUnusedInFormals") private T findViewById(@IdRes int id) { return mTBLauncherActivity.findViewById(id); } @SuppressWarnings("unchecked") private T inflateViewStub(@IdRes int id) { View stub = mTBLauncherActivity.findViewById(id); return (T) ViewStubPreview.inflateStub(stub); } @SuppressWarnings("unchecked") private T inflateViewStub(@IdRes int id, @LayoutRes int layoutRes) { View stub = mTBLauncherActivity.findViewById(id); return (T) ViewStubPreview.inflateStub(stub, layoutRes); } private void updateClearButton() { if (mSearchEditText.getText().length() > 0 || TBApplication.state().isResultListVisible()) { mClearButton.setVisibility(View.VISIBLE); mMenuButton.setVisibility(View.INVISIBLE); } else { mClearButton.setVisibility(View.INVISIBLE); mMenuButton.setVisibility(View.VISIBLE); } } public void switchToDesktop(@NonNull LauncherState.Desktop mode) { // get current mode @Nullable LauncherState.Desktop currentMode = TBApplication.state().getDesktop(); Log.d(TAG, "desktop changed " + currentMode + " -> " + mode); if (mode.equals(currentMode)) { // no change, maybe refresh? if (TBApplication.state().isResultListVisible() && mResultAdapter.getItemCount() == 0) showDesktop(mode); return; } // hide current mode if (currentMode != null) { switch (currentMode) { case SEARCH: resetTask(); hideSearchBar(); break; case WIDGET: hideWidgets(); break; case EMPTY: default: break; } } // show next mode showDesktop(mode); } private void showDesktop(LauncherState.Desktop mode) { if (TBApplication.activityInvalid(mTBLauncherActivity)) { Log.e(TAG, "[activityInvalid] showDesktop " + mode); return; } TBApplication.state().setDesktop(mode); switch (mode) { case SEARCH: // show the SearchBar showSearchBar(); // hide/show result list final String openResult = PrefCache.modeSearchOpenResult(mPref); if ("none".equals(openResult)) { // hide result hideResultList(false); } else { // try to execute the action postDelayedRunnableOnce(() -> TBApplication.dataHandler(getContext()).runAfterLoadOver(() -> { LauncherState state = TBApplication.state(); if (!state.isResultListVisible() && state.getDesktop() == LauncherState.Desktop.SEARCH) executeAction(openResult, "dm-search-open-result"); }), mLauncherButton, KEYBOARD_ANIMATION_DELAY); } // hide/show the QuickList TBApplication.quickList(getContext()).updateVisibility(); // enable/disable fullscreen (status and navigation bar) if (TBApplication.state().isKeyboardHidden() && PrefCache.modeSearchFullscreen(mPref)) enableFullscreen(UI_ANIMATION_DELAY); else disableFullscreen(); break; case WIDGET: // show widgets showWidgets(); // hide/show the QuickList TBApplication.quickList(getContext()).updateVisibility(); // enable/disable fullscreen (status and navigation bar) if (PrefCache.modeWidgetFullscreen(mPref)) enableFullscreen(UI_ANIMATION_DELAY); else disableFullscreen(); break; case EMPTY: default: // hide/show the QuickList TBApplication.quickList(getContext()).updateVisibility(); // enable/disable fullscreen (status and navigation bar) if (PrefCache.modeEmptyFullscreen(mPref)) enableFullscreen(UI_ANIMATION_DELAY); else disableFullscreen(); break; } } /** * Hide status and notification bar * * @param startDelay milliseconds of delay */ private void enableFullscreen(int startDelay) { boolean animate = !SystemUiVisibility.isFullscreenSet(mDecorView) || TBApplication.state().isNotificationBarVisible(); Log.i(TAG, "enableFullscreen delay=" + startDelay + " anim=" + animate); // Schedule a runnable to remove the status and navigation bar after a delay removeCallback(mShowPart2Runnable); postDelayedCallbackOnce(mHidePart2Runnable, startDelay); // hide notification background final int statusHeight = UISizes.getStatusBarSize(getContext()); if (TBApplication.state().getNotificationBarVisibility() != LauncherState.AnimatedVisibility.ANIM_TO_HIDDEN) mNotificationBackground.animate().cancel(); if (animate) { mNotificationBackground.animate() .translationY(-statusHeight) .setStartDelay(startDelay) .setDuration(UI_ANIMATION_DURATION) .setInterpolator(new AccelerateInterpolator()) .setListener(new AnimatorListenerAdapter() { @Override public void onAnimationStart(Animator animation) { TBApplication.state().setNotificationBar(LauncherState.AnimatedVisibility.ANIM_TO_HIDDEN); } @Override public void onAnimationEnd(Animator animation) { TBApplication.state().setNotificationBar(LauncherState.AnimatedVisibility.HIDDEN); } }) .start(); } else { TBApplication.state().setNotificationBar(LauncherState.AnimatedVisibility.HIDDEN); mNotificationBackground.setTranslationY(-statusHeight); } } /** * Show status and notification bar */ private void disableFullscreen() { boolean animate = SystemUiVisibility.isFullscreenSet(mDecorView) || !TBApplication.state().isNotificationBarVisible(); // Schedule a runnable to display UI elements after a delay removeCallback(mHidePart2Runnable); postDelayedCallbackOnce(mShowPart2Runnable, UI_ANIMATION_DELAY); // show notification background final int statusHeight = UISizes.getStatusBarSize(getContext()); if (!TBApplication.state().isNotificationBarVisible()) mNotificationBackground.setTranslationY(-statusHeight); if (TBApplication.state().getNotificationBarVisibility() != LauncherState.AnimatedVisibility.ANIM_TO_VISIBLE) mNotificationBackground.animate().cancel(); if (animate) { mNotificationBackground.animate() .translationY(0f) .setStartDelay(0) .setDuration(UI_ANIMATION_DURATION) .setInterpolator(new LinearInterpolator()) .setListener(new AnimatorListenerAdapter() { @Override public void onAnimationStart(Animator animation) { TBApplication.state().setNotificationBar(LauncherState.AnimatedVisibility.ANIM_TO_VISIBLE); } @Override public void onAnimationEnd(Animator animation) { TBApplication.state().setNotificationBar(LauncherState.AnimatedVisibility.VISIBLE); } }) .start(); } else { TBApplication.state().setNotificationBar(LauncherState.AnimatedVisibility.VISIBLE); mNotificationBackground.setTranslationY(0f); } } private void showSearchBar() { mSearchEditText.setEnabled(true); setSearchHint(); UITheme.applySearchBarTextShadow(mSearchEditText); if (TBApplication.state().getSearchBarVisibility() != LauncherState.AnimatedVisibility.ANIM_TO_VISIBLE) mSearchBarContainer.animate().cancel(); mSearchBarContainer.setVisibility(View.VISIBLE); mSearchBarContainer.animate() .setStartDelay(0) .alpha(1f) .translationY(0f) .setDuration(UI_ANIMATION_DURATION) .setInterpolator(new DecelerateInterpolator()) .setListener(new AnimatorListenerAdapter() { @Override public void onAnimationStart(Animator animation) { TBApplication.state().setSearchBar(LauncherState.AnimatedVisibility.ANIM_TO_VISIBLE); } @Override public void onAnimationEnd(Animator animation) { LauncherState state = TBApplication.state(); state.setSearchBar(LauncherState.AnimatedVisibility.VISIBLE); if (PrefCache.linkKeyboardAndSearchBar(mPref)) showKeyboard(); else { // sync keyboard state state.syncKeyboardVisibility(mSearchEditText); } } }) .start(); mSearchEditText.requestFocus(); } private void hideWidgets() { TBApplication.state().setWidgetScreen(LauncherState.AnimatedVisibility.HIDDEN); mWidgetContainer.setVisibility(View.GONE); } private void hideSearchBar() { boolean animate = !TBApplication.state().isSearchBarVisible(); hideSearchBar(animate); } private void hideSearchBar(boolean animate) { clearSearchText(); clearAdapter(); if (mSearchBarContainer.getVisibility() == View.VISIBLE) { final float translationY; if (PrefCache.searchBarAtBottom(mPref)) translationY = mSearchBarContainer.getHeight() * 2f; else translationY = mSearchBarContainer.getHeight() * -2f; if (TBApplication.state().getSearchBarVisibility() != LauncherState.AnimatedVisibility.ANIM_TO_HIDDEN) mSearchBarContainer.animate().cancel(); if (animate) { mSearchBarContainer.setTranslationY(0f); mSearchBarContainer.animate() .alpha(0f) .translationY(translationY) .setDuration(UI_ANIMATION_DURATION) .setInterpolator(new AccelerateInterpolator()) .setListener(new AnimatorListenerAdapter() { @Override public void onAnimationStart(Animator animation) { TBApplication.state().setSearchBar(LauncherState.AnimatedVisibility.ANIM_TO_HIDDEN); } @Override public void onAnimationEnd(Animator animation) { TBApplication.state().setSearchBar(LauncherState.AnimatedVisibility.HIDDEN); mSearchBarContainer.setVisibility(View.GONE); } }) .start(); } else { TBApplication.state().setSearchBar(LauncherState.AnimatedVisibility.HIDDEN); mSearchBarContainer.setAlpha(0f); mSearchBarContainer.setTranslationY(translationY); mSearchBarContainer.setVisibility(View.GONE); } } else { Log.d(TAG, "mSearchBarContainer not VISIBLE, setting state to HIDDEN"); TBApplication.state().setResultList(LauncherState.AnimatedVisibility.HIDDEN); } if (PrefCache.linkKeyboardAndSearchBar(mPref)) hideKeyboard(); // disabling mSearchEditText will most probably also close the keyboard mSearchEditText.setEnabled(false); } private void showWidgets() { boolean animate = !TBApplication.state().isWidgetScreenVisible(); showWidgets(animate); } private void showWidgets(boolean animate) { if (TBApplication.state().getWidgetScreenVisibility() != LauncherState.AnimatedVisibility.ANIM_TO_VISIBLE) mSearchBarContainer.animate().cancel(); mWidgetContainer.setVisibility(View.VISIBLE); if (animate) { mWidgetContainer.setAlpha(0f); mWidgetContainer.animate() .setStartDelay(UI_ANIMATION_DURATION) .alpha(1f) .setDuration(UI_ANIMATION_DURATION) .setInterpolator(new DecelerateInterpolator()) .setListener(new AnimatorListenerAdapter() { @Override public void onAnimationStart(Animator animation) { TBApplication.state().setWidgetScreen(LauncherState.AnimatedVisibility.ANIM_TO_VISIBLE); } @Override public void onAnimationEnd(Animator animation) { TBApplication.state().setWidgetScreen(LauncherState.AnimatedVisibility.VISIBLE); } }) .start(); } else { mWidgetContainer.animate().cancel(); TBApplication.state().setWidgetScreen(LauncherState.AnimatedVisibility.VISIBLE); mWidgetContainer.setAlpha(1f); } hideResultList(animate); } public void showKeyboard() { mKeyboardHandler.mRequestOpen = true; mKeyboardHandler.showKeyboard(); // UI_ANIMATION_DURATION should be the exact time the full-screen animation ends postDelayedCallbackOnce(mShowKeyboardRunnable, UI_ANIMATION_DELAY); } public void hideKeyboard() { mKeyboardHandler.mRequestOpen = false; removeCallback(mShowKeyboardRunnable); mKeyboardHandler.hideKeyboard(); } @Override public void displayLoader(boolean running) { if (mLauncherButton == null) return; Drawable loadingDrawable = mLauncherButton.getDrawable(); if (loadingDrawable instanceof Animatable) { if (running) ((Animatable) loadingDrawable).start(); else ((Animatable) loadingDrawable).stop(); } } @NonNull @Override public Context getContext() { return mTBLauncherActivity; } @Override public void resetTask() { TBApplication.resetTask(getContext()); } @Override public void clearAdapter() { mResultAdapter.clear(); TBApplication.quickList(getContext()).adapterCleared(); if (TBApplication.state().isResultListVisible()) hideResultList(true); updateClearButton(); } public boolean showProviderEntries(@Nullable IProvider provider) { return showProviderEntries(provider, null); } public boolean showProviderEntries(@Nullable IProvider provider, @Nullable java.util.Comparator comparator) { if (TBApplication.state().getDesktop() != LauncherState.Desktop.SEARCH) { // TODO: switchToDesktop might show the result list, we may need to prevent this as an optimization switchToDesktop(LauncherState.Desktop.SEARCH); clearAdapter(); } List entries = provider != null ? provider.getPojos() : null; if (entries != null && entries.size() > 0) { // reset relevance. This is normally done by a Searcher. for (EntryItem entry : entries) entry.resetResultInfo(); // // copy list in order to change it // entries = new ArrayList<>(entries); // // remove actions and filters from the result list // for (Iterator iterator = entries.iterator(); iterator.hasNext(); ) { // EntryItem entry = iterator.next(); // if (entry instanceof FilterEntry) // iterator.remove(); // } if (comparator != null) { // copy list in order to change it entries = new ArrayList<>(entries); //TODO: do we need this on another thread? Collections.sort(entries, comparator); } updateAdapter(entries, false); return true; } return false; } @Override public void updateAdapter(@NonNull List results, boolean isRefresh) { Log.d(TAG, "updateAdapter " + results.size() + " result(s); isRefresh=" + isRefresh); if (!isFragmentDialogVisible()) { LauncherState state = TBApplication.state(); if (!state.isResultListVisible() && state.getDesktop() == LauncherState.Desktop.SEARCH) showResultList(false); } mResultAdapter.updateItems(results); if (!isRefresh) { // Make sure the first item is visible when we search mResultList.scrollToFirstItem(); } mTBLauncherActivity.quickList.adapterUpdated(); mClearButton.setVisibility(View.VISIBLE); mMenuButton.setVisibility(View.INVISIBLE); if (mLaunchMostRelevantResult) { mLaunchMostRelevantResult = false; // get any view View view = mResultList.getLayoutManager() != null ? mResultList.getLayoutManager().getChildAt(0) : null; final int mostRelevantIdx = mResultList.getAdapterFirstItemIdx(); // try to get view of the most relevant item from adapter if (mostRelevantIdx >= 0 && mostRelevantIdx < mResultAdapter.getItemCount()) { RecyclerView.ViewHolder holder = mResultList.findViewHolderForAdapterPosition(mostRelevantIdx); if (holder != null) view = holder.itemView; } if (view != null) mResultAdapter.onClick(mostRelevantIdx, view); } } @Override public void removeResult(@NonNull EntryItem result) { // Do not reset scroll, we want the remaining items to still be in view mResultAdapter.removeItem(result); } @Override public void filterResults(String text) { mResultAdapter.getFilter().filter(text); } public void handleRemoveApp(String packageName) { int count = mResultAdapter.getItemCount(); for (int idx = count - 1; idx >= 0; idx -= 1) { EntryItem entryItem = mResultAdapter.getItem(idx); if (entryItem.id.contains(packageName)) removeResult(entryItem); } } public void runSearcher(@NonNull String query, @NonNull Class searcherClass) { if (TBApplication.state().getDesktop() != LauncherState.Desktop.SEARCH) { // TODO: switchToDesktop might show the result list, we may need to prevent this as an optimization switchToDesktop(LauncherState.Desktop.SEARCH); clearAdapter(); } clearSearchText(); Searcher searcher = null; try { Constructor constructor = searcherClass.getConstructor(ISearchActivity.class, String.class); searcher = constructor.newInstance(this, query); } catch (ReflectiveOperationException e) { Log.e(TAG, "new ", e); } if (searcher != null) updateSearchRecords(false, searcher); } public void clearSearchText() { if (mSearchEditText == null) return; mSearchEditText.removeTextChangedListener(mSearchTextWatcher); mSearchEditText.setText(""); mSearchEditText.addTextChangedListener(mSearchTextWatcher); } public void clearSearch() { clearSearchText(); clearAdapter(); updateClearButton(); } public void refreshSearchRecords() { if (mResultList != null) { mResultList.getRecycledViewPool().clear(); } if (mResultAdapter != null) { mResultAdapter.setGridLayout(getContext(), isGridLayout()); mResultAdapter.refresh(); } } public void refreshSearchRecord(EntryItem entry) { mResultAdapter.notifyItemChanged(entry); } private void showResultList(boolean animate) { Log.d(TAG, "showResultList (anim " + animate + ")"); if (TBApplication.state().getResultListVisibility() != LauncherState.AnimatedVisibility.ANIM_TO_VISIBLE) mResultLayout.animate().cancel(); mResultLayout.setVisibility(View.VISIBLE); if (animate) { TBApplication.state().setResultList(LauncherState.AnimatedVisibility.ANIM_TO_VISIBLE); mResultLayout.setAlpha(0f); mResultLayout.animate() .alpha(1f) .setDuration(UI_ANIMATION_DURATION) .setListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { TBApplication.state().setResultList(LauncherState.AnimatedVisibility.VISIBLE); } }) .start(); } else { TBApplication.state().setResultList(LauncherState.AnimatedVisibility.VISIBLE); mResultLayout.setAlpha(1f); } } private void hideResultList(boolean animate) { Log.d(TAG, "hideResultList (anim " + animate + ")"); if (TBApplication.state().getResultListVisibility() != LauncherState.AnimatedVisibility.ANIM_TO_HIDDEN) mResultLayout.animate().cancel(); if (mResultLayout.getVisibility() != View.VISIBLE) { Log.d(TAG, "mResultLayout not VISIBLE, setting state to HIDDEN"); TBApplication.state().setResultList(LauncherState.AnimatedVisibility.HIDDEN); return; } if (animate) { TBApplication.state().setResultList(LauncherState.AnimatedVisibility.ANIM_TO_HIDDEN); mResultLayout.animate() .alpha(0f) .setDuration(UI_ANIMATION_DURATION) .setListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { TBApplication.state().setResultList(LauncherState.AnimatedVisibility.HIDDEN); Log.d(TAG, "mResultLayout set INVISIBLE"); mResultLayout.setVisibility(View.INVISIBLE); } }) .start(); } else { TBApplication.state().setResultList(LauncherState.AnimatedVisibility.HIDDEN); Log.d(TAG, "mResultLayout set INVISIBLE"); mResultLayout.setVisibility(View.INVISIBLE); } } public void updateSearchRecords() { Editable searchText = mSearchEditText.getText(); if (searchText.length() > 0) { String text = searchText.toString(); updateSearchRecords(true, text); } else { refreshSearchRecords(); } } /** * This function gets called on query changes. * It will ask all the providers for data * This function is not called for non search-related changes! Have a look at onDataSetChanged() if that's what you're looking for :) * * @param isRefresh whether the query is refreshing the existing result, or is a completely new query * @param query the query on which to search */ private void updateSearchRecords(boolean isRefresh, @NonNull String query) { updateSearchRecords(isRefresh, new QuerySearcher(this, query)); } private void updateSearchRecords(boolean isRefresh, @NonNull Searcher searcher) { searcher.setRefresh(isRefresh); resetTask(); dismissPopup(); TBApplication.runTask(getContext(), searcher); boolean animate = !TBApplication.state().isResultListVisible(); showResultList(animate); } public void beforeLaunchOccurred() { RecycleScrollListener.setListLayoutHeight(mResultList, mResultList.getHeight()); // don't call the keyboard closed event mKeyboardHandler.mLaunchedApp = true; hideKeyboard(); } public void afterLaunchOccurred() { postDelayedRunnableOnce(() -> { RecycleScrollListener.setListLayoutHeight(mResultList, ViewGroup.LayoutParams.MATCH_PARENT); if (PrefCache.clearSearchAfterLaunch(mPref)) { // We selected an item on the list, now we can cleanup the filter: if (mSearchEditText.getText().length() > 0) { mSearchEditText.setText(""); } else if (TBApplication.state().isResultListVisible()) { clearAdapter(); } } if (PrefCache.showWidgetScreenAfterLaunch(mPref)) { // show widgets when we return to the launcher switchToDesktop(LauncherState.Desktop.WIDGET); } }, mSearchEditText, UI_ANIMATION_DELAY); } public void showContextMenu() { mMenuButton.performClick(); } public void setListLayout() { // update adapter draw flags mResultAdapter.setGridLayout(getContext(), false); // get layout manager RecyclerView.LayoutManager layoutManager = mResultList.getLayoutManager(); if (!(layoutManager instanceof CustomRecycleLayoutManager)) { mResultList.setLayoutManager(layoutManager = new CustomRecycleLayoutManager()); ((CustomRecycleLayoutManager) layoutManager).setOverScrollListener(mRecycleScrollListener); } CustomRecycleLayoutManager lm = (CustomRecycleLayoutManager) layoutManager; lm.setBottomToTop(PrefCache.firstAtBottom(mPref)); lm.setColumns(1, false); } public void setGridLayout() { setGridLayout(3); } public void setGridLayout(int columnCount) { // update adapter draw flags mResultAdapter.setGridLayout(getContext(), true); // get layout manager RecyclerView.LayoutManager layoutManager = mResultList.getLayoutManager(); if (!(layoutManager instanceof CustomRecycleLayoutManager)) { mResultList.setLayoutManager(layoutManager = new CustomRecycleLayoutManager()); ((CustomRecycleLayoutManager) layoutManager).setOverScrollListener(mRecycleScrollListener); } CustomRecycleLayoutManager lm = (CustomRecycleLayoutManager) layoutManager; lm.setBottomToTop(PrefCache.firstAtBottom(mPref)); lm.setRightToLeft(PrefCache.rightToLeft(mPref)); lm.setColumns(columnCount, false); } public boolean isGridLayout() { RecyclerView.LayoutManager layoutManager = mResultList.getLayoutManager(); if (layoutManager instanceof CustomRecycleLayoutManager) return ((CustomRecycleLayoutManager) layoutManager).getColumnCount() > 1; return false; } /** * Handle the back button press. Returns true if action handled. * * @return returns true if action handled */ public boolean onBackPressed() { if (closeFragmentDialog()) return true; Log.i(TAG, "onBackPressed query=" + mSearchEditText.getText()); mSearchEditText.setText(""); LauncherState.Desktop desktop = TBApplication.state().getDesktop(); if (desktop != null) { switch (desktop) { case SEARCH: executeButtonAction("dm-search-back"); break; case WIDGET: executeButtonAction("dm-widget-back"); break; case EMPTY: default: executeButtonAction("dm-empty-back"); break; } } // Calling super.onBackPressed() will quit the launcher, only do this if this is not the user's default home. // Action not handled (return false) if not the default launcher. return TBApplication.isDefaultLauncher(mTBLauncherActivity); } @NonNull public static ListPopup getButtonPopup(@NonNull Context ctx, @NonNull String buttonId, @DrawableRes int defaultButtonIcon) { LinearAdapter adapter = new LinearAdapter(); adapter.add(new LinearAdapter.ItemTitle(ctx, R.string.popup_title_customize)); adapter.add(new LinearAdapter.Item(ctx, R.string.menu_custom_icon)); return ListPopup.create(ctx, adapter).setOnItemClickListener((a, view, pos) -> { LinearAdapter.MenuItem menuItem = ((LinearAdapter) a).getItem(pos); @StringRes int id = 0; if (menuItem instanceof LinearAdapter.Item) { id = ((LinearAdapter.Item) a.getItem(pos)).stringId; } if (id == R.string.menu_custom_icon) { TBApplication.behaviour(ctx).launchCustomIconDialog(buttonId, defaultButtonIcon, () -> { // refresh search bar preferences to reload the icon TBApplication.ui(ctx).refreshSearchBar(); }); } }); } @NonNull public static IconSelectDialog getCustomIconDialog(@NonNull Context ctx, boolean hideResultList) { IconSelectDialog dialog = new IconSelectDialog(); //openFragmentDialog(dialog, DIALOG_CUSTOM_ICON); if (hideResultList) { // If results are visible if (TBApplication.state().isResultListVisible()) { final Behaviour behaviour = TBApplication.behaviour(ctx); behaviour.mResultLayout.setVisibility(View.INVISIBLE); // OnDismiss: We restore mResultLayout visibility dialog.setOnDismissListener(dlg -> behaviour.mResultLayout.setVisibility(View.VISIBLE)); } } //dialog.show(mTBLauncherActivity.getSupportFragmentManager(), DIALOG_CUSTOM_ICON); return dialog; } public void launchCustomIconDialog(AppEntry appEntry) { IconSelectDialog dialog = getCustomIconDialog(getContext(), false); dialog .putArgString("componentName", appEntry.getUserComponentName()) .putArgLong("customIcon", appEntry.getCustomIcon()) .putArgString("entryName", appEntry.getName()); dialog.setOnConfirmListener(drawable -> { TBApplication app = TBApplication.getApplication(getContext()); if (drawable == null) app.iconsHandler().restoreDefaultIcon(appEntry); else app.iconsHandler().changeIcon(appEntry, drawable); // force a result refresh to update the icon in the view refreshSearchRecord(appEntry); mTBLauncherActivity.queueDockReload(); }); showDialog(dialog, DIALOG_CUSTOM_ICON); } public void launchCustomIconDialog(ShortcutEntry shortcutEntry) { IconSelectDialog dialog = getCustomIconDialog(getContext(), true); dialog .putArgString("packageName", shortcutEntry.packageName) .putArgString("shortcutData", shortcutEntry.shortcutData) .putArgString("shortcutId", shortcutEntry.id); dialog.setOnConfirmListener(drawable -> { final TBApplication app = TBApplication.getApplication(mTBLauncherActivity); if (drawable == null) app.iconsHandler().restoreDefaultIcon(shortcutEntry); else app.iconsHandler().changeIcon(shortcutEntry, drawable); // force a result refresh to update the icon in the view refreshSearchRecord(shortcutEntry); mTBLauncherActivity.queueDockReload(); }); showDialog(dialog, DIALOG_CUSTOM_ICON); } public void launchCustomIconDialog(@NonNull StaticEntry staticEntry) { launchCustomIconDialog(staticEntry, null); } public void launchCustomIconDialog(@NonNull StaticEntry staticEntry, @Nullable Runnable afterConfirmation) { IconSelectDialog dialog = getCustomIconDialog(getContext(), true); dialog.putArgString("entryId", staticEntry.id); dialog.setOnConfirmListener(drawable -> { final TBApplication app = TBApplication.getApplication(mTBLauncherActivity); if (drawable == null) app.iconsHandler().restoreDefaultIcon(staticEntry); else app.iconsHandler().changeIcon(staticEntry, drawable); // force a result refresh to update the icon in the view refreshSearchRecord(staticEntry); mTBLauncherActivity.queueDockReload(); if (afterConfirmation != null) afterConfirmation.run(); }); showDialog(dialog, DIALOG_CUSTOM_ICON); } public void launchCustomIconDialog(@NonNull SearchEntry searchEntry, @Nullable Runnable afterConfirmation) { IconSelectDialog dialog = getCustomIconDialog(getContext(), true); dialog .putArgString("searchEntryId", searchEntry.id) .putArgString("searchName", searchEntry.getName()); dialog.setOnConfirmListener(drawable -> { final TBApplication app = TBApplication.getApplication(mTBLauncherActivity); if (drawable == null) app.iconsHandler().restoreDefaultIcon(searchEntry); else app.iconsHandler().changeIcon(searchEntry, drawable); // force a result refresh to update the icon in the view refreshSearchRecord(searchEntry); mTBLauncherActivity.queueDockReload(); if (afterConfirmation != null) afterConfirmation.run(); }); showDialog(dialog, DIALOG_CUSTOM_ICON); } /** * Change the icon for the "Dial" contact * * @param dialEntry entry that currently holds the "Dial" icon */ public void launchCustomIconDialog(@NonNull DialContactEntry dialEntry) { IconSelectDialog dialog = getCustomIconDialog(getContext(), true); dialog .putArgString("contactEntryId", dialEntry.id) .putArgString("contactName", dialEntry.getName()); dialog.setOnConfirmListener(drawable -> { final TBApplication app = TBApplication.getApplication(getContext()); if (drawable == null) app.iconsHandler().restoreDefaultIcon(dialEntry); else app.iconsHandler().changeIcon(dialEntry, drawable); // force a result refresh to update the icon in the view refreshSearchRecord(dialEntry); mTBLauncherActivity.queueDockReload(); }); showDialog(dialog, DIALOG_CUSTOM_ICON); } public void launchCustomIconDialog(@NonNull String buttonId, int defaultButtonIcon, @Nullable Runnable afterConfirmation) { IconSelectDialog dialog = getCustomIconDialog(getContext(), true); dialog .putArgString("buttonId", buttonId) .putArgInt("defaultIcon", defaultButtonIcon); dialog.setOnConfirmListener(drawable -> { var iconsHandler = TBApplication.iconsHandler(getContext()); if (drawable == null) iconsHandler.restoreDefaultIcon(buttonId); else iconsHandler.changeIcon(buttonId, drawable); if (afterConfirmation != null) afterConfirmation.run(); }); showDialog(dialog, DIALOG_CUSTOM_ICON); } public void launchEditTagsDialog(EntryWithTags entry) { EditTagsDialog dialog = new EditTagsDialog(); openFragmentDialog(dialog, DIALOG_EDIT_TAGS); // set args { Bundle args = new Bundle(); args.putString("entryId", entry.id); args.putString("entryName", entry.getName()); dialog.setArguments(args); } dialog.setOnConfirmListener(newTags -> { TBApplication.tagsHandler(getContext()).setTags(entry, newTags); refreshSearchRecord(entry); }); dialog.show(mTBLauncherActivity.getSupportFragmentManager(), DIALOG_EDIT_TAGS); } public void launchEditQuickListDialog(Context context) { showDialog(context, new EditQuickListDialog(), DIALOG_EDIT_QUICK_LIST); } public void launchTagsManagerDialog(Context context) { showDialog(context, new TagsManagerDialog(), DIALOG_TAGS_MANAGER); } private boolean isFragmentDialogVisible() { return mFragmentDialog != null && mFragmentDialog.isVisible(); } /** * Keep track of the last dialog. Use context to find a SupportFragmentManager * * @param context to get the FragmentActivity from * @param dialog to open * @param tag name to keep track of */ public static void showDialog(Context context, DialogFragment dialog, String tag) { if (TBApplication.activityInvalid(context)) { Log.e(TAG, "[activityInvalid] showDialog " + tag); return; } TBApplication.behaviour(context).showDialog(dialog, tag); } private void showDialog(@NonNull DialogFragment dialog, @Nullable String tag) { openFragmentDialog(dialog, tag); dialog.show(mTBLauncherActivity.getSupportFragmentManager(), tag); } private void openFragmentDialog(DialogFragment dialog, @Nullable String tag) { closeFragmentDialog(tag); mFragmentDialog = dialog; } public boolean closeFragmentDialog() { return closeFragmentDialog(null); } private boolean closeFragmentDialog(@Nullable String tag) { if (mFragmentDialog != null && mFragmentDialog.isVisible()) { if (tag != null && tag.equals(mFragmentDialog.getTag())) { mFragmentDialog.dismiss(); return true; } else if (tag == null) { mFragmentDialog.dismiss(); mFragmentDialog = null; return true; } } mFragmentDialog = null; return false; } private void registerPopup(ListPopup menu) { TBApplication.getApplication(getContext()).registerPopup(menu); } private boolean dismissPopup() { return TBApplication.getApplication(getContext()).dismissPopup(); } public void onResume() { Log.i(TAG, "onResume"); mKeyboardHandler.mLaunchedApp = false; LauncherState.Desktop desktop = TBApplication.state().getDesktop(); showDesktop(desktop); mLauncherTime = null; if (PrefCache.searchBarHasTimer(mPref)) { mLauncherTime = mSearchBarContainer.findViewById(R.id.launcherTime); UITheme.applySearchBarTextShadow(mLauncherTime); mLauncherTime.post(mUpdateTime); } } public void onNewIntent() { if (mTBLauncherActivity == null) return; LauncherState state = TBApplication.state(); Log.i(TAG, "onNewIntent desktop=" + state.getDesktop()); closeFragmentDialog(); Intent intent = mTBLauncherActivity.getIntent(); if (intent != null) { final String action = intent.getAction(); if (LauncherApps.ACTION_CONFIRM_PIN_SHORTCUT.equals(action)) { // Save single shortcut via a pin request ShortcutUtil.addShortcut(mTBLauncherActivity, intent); return; } // Pasting shared text from Sharesheet via intent-filter into kiss search bar if (Intent.ACTION_SEND.equals(action) && "text/plain".equals(intent.getType())) { String sharedText = intent.getStringExtra(Intent.EXTRA_TEXT); // making sure the shared text is not an empty string if (sharedText != null && sharedText.trim().length() > 0) { mSearchEditText.setText(sharedText); return; } else { //Toast.makeText(this, R.string.shared_text_empty, Toast.LENGTH_SHORT).show(); } } } executeButtonAction("button-home"); } public void onWindowFocusChanged(boolean hasFocus) { Log.i(TAG, "onWindowFocusChanged " + hasFocus); LauncherState state = TBApplication.state(); if (hasFocus) { if (mKeyboardHandler.mLaunchedApp) { Log.d(TAG, "skip focus change; LaunchedApp = true"); } else { if (state.getDesktop() == LauncherState.Desktop.SEARCH) { if (state.isSearchBarVisible() && PrefCache.linkKeyboardAndSearchBar(mPref)) { Log.d(TAG, "SearchBarVisible and linkKeyboardAndSearchBar"); showKeyboard(); } else { //TODO: find why keyboard gets hidden after onWindowFocusChanged Log.d(TAG, "state().isKeyboardHidden=" + TBApplication.state().isKeyboardHidden() + " mRequestOpen=" + mKeyboardHandler.mRequestOpen); if (mKeyboardHandler.mRequestOpen) { showKeyboard(); } } if (TBApplication.state().isResultListVisible() && mResultAdapter.getItemCount() == 0) showDesktop(TBApplication.state().getDesktop()); else if (TBApplication.state().isResultListVisible()) { showResultList(false); } else hideResultList(true); } else { if (state.getDesktop() == LauncherState.Desktop.WIDGET) { hideKeyboard(); } } } } } public static void setActivityOrientation(@NonNull Activity act, @NonNull SharedPreferences pref) { if (pref.getBoolean("lock-portrait", true)) { if (pref.getBoolean("sensor-orientation", true)) act.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT); else act.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT); } else { if (pref.getBoolean("sensor-orientation", true)) act.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR); else act.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_FULL_USER); } } private boolean launchStaticEntry(@NonNull String entryId) { Context ctx = getContext(); DataHandler dataHandler = TBApplication.dataHandler(ctx); EntryItem item = dataHandler.getPojo(entryId); if (item == null) { item = TagsProvider.newTagEntryCheckId(entryId); } if (item instanceof StaticEntry) { if (TBApplication.state().getDesktop() != LauncherState.Desktop.SEARCH) { // TODO: switchToDesktop might show the result list, we may need to prevent this as an optimization switchToDesktop(LauncherState.Desktop.SEARCH); clearAdapter(); } // make sure the QuickList will not toggle off TBApplication.quickList(ctx).adapterCleared(); item.doLaunch(mLauncherButton, LAUNCHED_FROM_GESTURE); return true; } else { Toast.makeText(ctx, ctx.getString(R.string.entry_not_found, entryId), Toast.LENGTH_SHORT).show(); } return false; } private boolean launchActionEntry(@NonNull String action) { return launchStaticEntry(ActionEntry.SCHEME + action); } private boolean launchAppEntry(@NonNull String userComponentName) { Context ctx = getContext(); UserHandleCompat user = UserHandleCompat.fromComponentName(ctx, userComponentName); ComponentName component = UserHandleCompat.unflattenComponentName(userComponentName); String appId = AppEntry.generateAppId(component, user); EntryItem item = TBApplication.dataHandler(ctx).getPojo(appId); if (item instanceof AppEntry) { ResultHelper.launch(mLauncherButton, item, LAUNCHED_FROM_GESTURE); return true; } Toast.makeText(ctx, ctx.getString(R.string.application_not_found, appId), Toast.LENGTH_SHORT).show(); return false; } private boolean launchEntryById(@NonNull String entryId) { Context ctx = getContext(); DataHandler dataHandler = TBApplication.dataHandler(ctx); EntryItem item = dataHandler.getPojo(entryId); if (item == null) { Toast.makeText(ctx, ctx.getString(R.string.entry_not_found, entryId), Toast.LENGTH_SHORT).show(); return false; } item.doLaunch(mLauncherButton, LAUNCHED_FROM_GESTURE); return true; } private void executeButtonAction(@Nullable String button) { if (mPref != null) executeAction(mPref.getString(button, null), button); } private boolean executeGestureAction(@Nullable String gesture) { if (mPref != null) return executeAction(mPref.getString(gesture, null), gesture); return false; } private boolean executeAction(@Nullable String action, @Nullable String source) { if (action == null) return false; if (TBApplication.activityInvalid(mTBLauncherActivity)) { Log.e(TAG, "[activityInvalid] executeAction " + action); // only do stuff if we are the current activity return false; } Log.d(TAG, "executeAction( action=" + action + " source=" + source + " )"); switch (action) { case "lockScreen": if (DeviceAdmin.isAdminActive(mTBLauncherActivity)) { DeviceAdmin.lockScreen(mTBLauncherActivity); } else { Toast.makeText(getContext(), R.string.device_admin_required, Toast.LENGTH_SHORT).show(); } return true; case "expandNotificationsPanel": Utilities.expandNotificationsPanel(mTBLauncherActivity); return true; case "expandSettingsPanel": Utilities.expandSettingsPanel(mTBLauncherActivity); return true; case "showSearchBar": switchToDesktop(LauncherState.Desktop.SEARCH); return true; case "showSearchBarAndKeyboard": switchToDesktop(LauncherState.Desktop.SEARCH); showKeyboard(); return true; case "showWidgets": switchToDesktop(LauncherState.Desktop.WIDGET); return true; case "showWidgetsCenter": switchToDesktop(LauncherState.Desktop.WIDGET); mTBLauncherActivity.liveWallpaper.resetPosition(); return true; case "showEmpty": switchToDesktop(LauncherState.Desktop.EMPTY); return true; case "toggleSearchAndWidget": if (TBApplication.state().getDesktop() == LauncherState.Desktop.SEARCH) switchToDesktop(LauncherState.Desktop.WIDGET); else switchToDesktop(LauncherState.Desktop.SEARCH); return true; case "toggleSearchWidgetEmpty": { final LauncherState.Desktop desktop = TBApplication.state().getDesktop(); if (desktop == LauncherState.Desktop.SEARCH) switchToDesktop(LauncherState.Desktop.WIDGET); else if (desktop == LauncherState.Desktop.WIDGET) switchToDesktop(LauncherState.Desktop.EMPTY); else switchToDesktop(LauncherState.Desktop.SEARCH); return true; } case "reloadProviders": TBApplication.dataHandler(getContext()).reloadProviders(); return true; case "showAllAppsAZ": return launchActionEntry("show/apps/byName"); case "toggleGrid": return launchActionEntry("toggle/grid"); case "showAllAppsZA": return launchActionEntry("show/apps/byNameReversed"); case "showContactsAZ": return launchActionEntry("show/contacts/byName"); case "showContactsZA": return launchActionEntry("show/contacts/byNameReversed"); case "showShortcutsAZ": return launchActionEntry("show/shortcuts/byName"); case "showShortcutsZA": return launchActionEntry("show/shortcuts/byNameReversed"); case "showFavorites": return launchActionEntry("show/favorites/byName"); case "showHistoryByRecency": return launchActionEntry("show/history/recency"); case "showHistoryByFrequency": return launchActionEntry("show/history/frequency"); case "showHistoryByFrecency": return launchActionEntry("show/history/frecency"); case "showHistoryByAdaptive": return launchActionEntry("show/history/adaptive"); case "showUntagged": return launchActionEntry("show/untagged"); case "showTagsList": return launchActionEntry("show/tags/list"); case "showTagsListReversed": return launchActionEntry("show/tags/listReversed"); case "showTagsMenu": { View anchor = null; if ("button-launcher".equals(source)) anchor = mLauncherButton; Context ctx = mLauncherButton.getContext(); ListPopup menu = TBApplication.tagsHandler(ctx).getTagsMenu(ctx); registerPopup(menu); if (anchor != null) menu.show(anchor); else menu.showCenter(mLauncherButton); } return true; case "runApp": { String runApp = mPref.getString(source + "-app-to-run", null); if (runApp != null) return launchAppEntry(runApp); break; } case "runShortcut": { String runApp = mPref.getString(source + "-shortcut-to-run", null); if (runApp != null) return launchEntryById(runApp); break; } case "showEntry": { String entryToShow = mPref.getString(source + "-entry-to-show", null); if (entryToShow != null) return launchStaticEntry(entryToShow); break; } default: // do nothing break; } return false; } public boolean onFlingDownLeft() { return executeGestureAction("gesture-fling-down-left"); } public boolean onFlingDownRight() { return executeGestureAction("gesture-fling-down-right"); } public boolean onFlingUp() { return executeGestureAction("gesture-fling-up"); } public boolean onFlingLeft() { return executeGestureAction("gesture-fling-left"); } public boolean onFlingRight() { return executeGestureAction("gesture-fling-right"); } public boolean onClick() { return executeGestureAction("gesture-click"); } public boolean hasDoubleClick() { if (mPref == null) return false; String action = mPref.getString("gesture-double-click", null); return action != null && !action.isEmpty() && !action.equals("none"); } public boolean onDoubleClick() { return executeGestureAction("gesture-double-click"); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/CustomizeUI.java ================================================ package rocks.tbog.tblauncher; import static rocks.tbog.tblauncher.customicon.ButtonHelper.BTN_ID_LAUNCHER_PILL; import static rocks.tbog.tblauncher.customicon.ButtonHelper.BTN_ID_LAUNCHER_WHITE; import android.content.Context; import android.content.SharedPreferences; import android.content.res.ColorStateList; import android.content.res.Resources; import android.content.res.TypedArray; import android.graphics.Color; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; import android.graphics.drawable.GradientDrawable; import android.graphics.drawable.RippleDrawable; import android.graphics.drawable.StateListDrawable; import android.os.Build; import android.provider.Settings; import android.text.InputType; import android.util.TypedValue; import android.view.View; import android.view.ViewGroup; import android.widget.AbsListView; import android.widget.EditText; import android.widget.ImageView; import androidx.annotation.IdRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.constraintlayout.motion.widget.MotionLayout; import androidx.core.content.res.ResourcesCompat; import androidx.core.view.ViewCompat; import androidx.core.view.WindowInsetsControllerCompat; import androidx.preference.PreferenceManager; import rocks.tbog.tblauncher.drawable.LoadingDrawable; import rocks.tbog.tblauncher.result.ResultViewHelper; import rocks.tbog.tblauncher.ui.RecyclerList; import rocks.tbog.tblauncher.ui.SearchEditText; import rocks.tbog.tblauncher.utils.EdgeGlowHelper; import rocks.tbog.tblauncher.utils.PrefCache; import rocks.tbog.tblauncher.utils.UIColors; import rocks.tbog.tblauncher.utils.UISizes; import rocks.tbog.tblauncher.utils.Utilities; public class CustomizeUI { private TBLauncherActivity mTBLauncherActivity; private SharedPreferences mPref = null; private ImageView mNotificationBackground; private ViewGroup mSearchBarContainer; private SearchEditText mSearchBar; private ImageView mLauncherButton; private ImageView mMenuButton; private ImageView mClearButton; private WindowInsetsControllerCompat mWindowHelper; /** * InputType that behaves as if the consuming IME is a standard-obeying * soft-keyboard *

* *Auto Complete* means "we're handling auto-completion ourselves". Then * we ignore whatever the IME thinks we should display. */ private final static int INPUT_TYPE_STANDARD = InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_AUTO_COMPLETE | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS; /** * InputType that behaves as if the consuming IME is SwiftKey *

* *Visible Password* fields will break many non-Latin IMEs and may show * unexpected behaviour in numerous ways. (#454, #517) */ private final static int INPUT_TYPE_WORKAROUND = InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD | InputType.TYPE_TEXT_FLAG_AUTO_CORRECT; private final View.OnLayoutChangeListener updateResultFadeOut = new UpdateResultFadeOut(); private final MotionTransitionListener mSearchBarTransition = new MotionTransitionListener(); private static final class UpdateResultFadeOut implements View.OnLayoutChangeListener { @Override public void onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, int oldTop, int oldRight, int oldBottom) { SharedPreferences pref = TBApplication.getApplication(v.getContext()).preferences(); boolean fadeOut = PrefCache.getResultFadeOut(pref); if (!fadeOut) { v.removeOnLayoutChangeListener(this); return; } int oldHeight = oldBottom - oldTop; if (oldHeight != v.getHeight()) { int backgroundColor = UIColors.getResultListBackground(pref); if (!setResultListGradientFade(v, backgroundColor)) v.removeOnLayoutChangeListener(this); } } } public static final class MotionTransitionListener implements MotionLayout.TransitionListener { private Runnable transitionToEndListener = null; public enum TransitionType {STARTED, CHANGE, COMPLETED, TRIGGER} public void setTransitionToEndListener(Runnable listener) { transitionToEndListener = listener; } @Override public void onTransitionStarted(MotionLayout motionLayout, int startId, int endId) { // do nothing } @Override public void onTransitionChange(MotionLayout motionLayout, int startId, int endId, float progress) { // do nothing } @Override public void onTransitionCompleted(MotionLayout motionLayout, int currentId) { if (transitionToEndListener != null && motionLayout.getEndState() == currentId) transitionToEndListener.run(); } @Override public void onTransitionTrigger(MotionLayout motionLayout, int triggerId, boolean positive, float progress) { // do nothing } } @SuppressWarnings("TypeParameterUnusedInFormals") private T findViewById(@IdRes int id) { return mTBLauncherActivity.findViewById(id); } public void onCreateActivity(TBLauncherActivity tbLauncherActivity) { mTBLauncherActivity = tbLauncherActivity; mPref = PreferenceManager.getDefaultSharedPreferences(tbLauncherActivity); mNotificationBackground = findViewById(R.id.notificationBackground); mSearchBarContainer = findViewById(R.id.searchBarContainer); mSearchBar = mSearchBarContainer.findViewById(R.id.launcherSearch); mLauncherButton = mSearchBarContainer.findViewById(R.id.launcherButton); mMenuButton = mSearchBarContainer.findViewById(R.id.menuButton); mClearButton = mSearchBarContainer.findViewById(R.id.clearButton); if (mSearchBarContainer instanceof MotionLayout) ((MotionLayout) mSearchBarContainer).addTransitionListener(mSearchBarTransition); mWindowHelper = ViewCompat.getWindowInsetsController(mSearchBarContainer); } public void onStart() { refreshSearchBar(); View resultLayout = findViewById(R.id.resultLayout); setResultListPref(resultLayout, true); resultLayout.addOnLayoutChangeListener(updateResultFadeOut); updateResultFadeOut.onLayoutChange(resultLayout, resultLayout.getLeft(), resultLayout.getTop(), resultLayout.getRight(), resultLayout.getBottom(), 0, 0, 0, 0); adjustInputType(mSearchBar); setNotificationBarColor(); setNavigationBarColor(); } private void setNotificationBarColor() { int argb = UIColors.getColor(mPref, "notification-bar-argb"); boolean gradient = mPref.getBoolean("notification-bar-gradient", true); if (gradient) { int size = UISizes.getStatusBarSize(getContext()); ViewGroup.LayoutParams params = mNotificationBackground.getLayoutParams(); if (params != null) { params.height = size; mNotificationBackground.setLayoutParams(params); } Utilities.setColorFilterMultiply(mNotificationBackground, argb); UIColors.setStatusBarColor(mTBLauncherActivity, 0x00000000); } else { mNotificationBackground.setVisibility(View.GONE); UIColors.setStatusBarColor(mTBLauncherActivity, argb); } // Notification drawer icon color mWindowHelper.setAppearanceLightStatusBars(mPref.getBoolean("black-notification-icons", false)); } private void setNavigationBarColor() { int argb = UIColors.getColor(mPref, "navigation-bar-argb"); UIColors.setNavigationBarColor(mTBLauncherActivity, argb, UIColors.setAlpha(argb, 0xFF)); // Navigation bar icon color mWindowHelper.setAppearanceLightNavigationBars(UIColors.isColorLight(argb)); } public void refreshSearchBar() { if (PrefCache.getSearchBarLayout(mPref) == R.layout.search_pill) setSearchPillPref(); else setSearchBarPref(); } private void setSearchBarPref() { final Context ctx = getContext(); final Resources resources = mSearchBarContainer.getResources(); // size int barHeight = mPref.getInt("search-bar-height", 0); if (barHeight <= 1) barHeight = resources.getInteger(R.integer.default_search_bar_height); barHeight = UISizes.dp2px(ctx, barHeight); int textSize = mPref.getInt("search-bar-text-size", 0); if (textSize <= 1) textSize = resources.getInteger(R.integer.default_size_text); // layout height and margins { ViewGroup.LayoutParams params = mSearchBarContainer.getLayoutParams(); if (params instanceof ViewGroup.MarginLayoutParams) { params.height = barHeight; int hMargin = UISizes.getSearchBarMarginHorizontal(ctx); int vMargin = UISizes.getSearchBarMarginVertical(ctx); ((ViewGroup.MarginLayoutParams)params).setMargins(hMargin, vMargin, hMargin, vMargin); mSearchBarContainer.setLayoutParams(params); } else { throw new IllegalStateException("mSearchBarContainer has the wrong layout params"); } } // text size { mSearchBar.setTextSize(TypedValue.COMPLEX_UNIT_SP, textSize); } final int searchBarRipple = UIColors.setAlpha(UIColors.getColor(mPref, "search-bar-ripple-color"), 0xFF); final int searchIconColor = UIColors.setAlpha(UIColors.getColor(mPref, "search-bar-icon-color"), 0xFF); final int argbBackground = UIColors.getColor(mPref, "search-bar-argb"); // text color { int searchTextCursor = UIColors.getColor(mPref, "search-bar-cursor-argb"); int searchTextHighlight = UIColors.setAlpha(searchTextCursor, 0x7F); int searchTextColor = UIColors.getSearchTextColor(ctx); int searchHintColor = UIColors.setAlpha(searchTextColor, 0xBB); mSearchBar.setTextColor(searchTextColor); mSearchBar.setHintTextColor(searchHintColor); // set color for selection background mSearchBar.setHighlightColor(searchTextHighlight); Utilities.setTextCursorColor(mSearchBar, searchTextCursor); Utilities.setTextSelectHandleColor(mSearchBar, searchBarRipple); } // icon ResultViewHelper.setButtonIconAsync(mLauncherButton, BTN_ID_LAUNCHER_WHITE, context -> { Drawable drawable = new LoadingDrawable(); Utilities.setColorFilterMultiply(drawable, searchIconColor); return drawable; }); // icon color { Utilities.setColorFilterMultiply(mMenuButton, searchIconColor); Utilities.setColorFilterMultiply(mClearButton, searchIconColor); mLauncherButton.setBackground(getSelectorDrawable(mLauncherButton, searchBarRipple, true)); mMenuButton.setBackground(getSelectorDrawable(mMenuButton, searchBarRipple, true)); mClearButton.setBackground(getSelectorDrawable(mClearButton, searchBarRipple, true)); } // set background boolean isGradient = mPref.getBoolean("search-bar-gradient", true); int cornerRadius = UISizes.getSearchBarRadius(ctx); if (isGradient || cornerRadius > 0) { GradientDrawable drawable; if (isGradient) { final GradientDrawable.Orientation orientation; if (PrefCache.searchBarAtBottom(mPref)) orientation = GradientDrawable.Orientation.TOP_BOTTOM; else orientation = GradientDrawable.Orientation.BOTTOM_TOP; int alpha = Color.alpha(argbBackground); int c1 = UIColors.setAlpha(argbBackground, 0); int c2 = UIColors.setAlpha(argbBackground, alpha * 3 / 4); int c3 = UIColors.setAlpha(argbBackground, alpha); drawable = new GradientDrawable(orientation, new int[]{c1, c2, c3}); } else { drawable = new GradientDrawable(); drawable.setColor(argbBackground); } drawable.setCornerRadius(cornerRadius); mSearchBarContainer.setBackground(drawable); } else { mSearchBarContainer.setBackground(new ColorDrawable(argbBackground)); } } private void setSearchPillPref() { final Context ctx = getContext(); final Resources resources = mSearchBarContainer.getResources(); // size int barHeight = mPref.getInt("search-bar-height", 0); if (barHeight <= 1) barHeight = resources.getInteger(R.integer.default_search_bar_height); barHeight = UISizes.dp2px(ctx, barHeight); int textSize = mPref.getInt("search-bar-text-size", 0); if (textSize <= 1) textSize = resources.getInteger(R.integer.default_size_text); // layout height and margins { ViewGroup.LayoutParams params = mSearchBarContainer.getLayoutParams(); if (params instanceof ViewGroup.MarginLayoutParams) { params.height = barHeight; int hMargin = UISizes.getSearchBarMarginHorizontal(ctx); int vMargin = UISizes.getSearchBarMarginVertical(ctx); // left must be touching the margin to look good ((ViewGroup.MarginLayoutParams) params).setMargins(0, vMargin, hMargin, vMargin); mSearchBarContainer.setLayoutParams(params); } else { throw new IllegalStateException("mSearchBarContainer has the wrong layout params"); } } // text size { mSearchBar.setTextSize(TypedValue.COMPLEX_UNIT_SP, textSize); } final int searchBarRipple = UIColors.setAlpha(UIColors.getColor(mPref, "search-bar-ripple-color"), 0xFF); final int searchIconColor = UIColors.setAlpha(UIColors.getColor(mPref, "search-bar-icon-color"), 0xFF); final int argbBackground = UIColors.getColor(mPref, "search-bar-argb"); // text color { int searchTextCursor = UIColors.getColor(mPref, "search-bar-cursor-argb"); int searchTextHighlight = UIColors.setAlpha(searchTextCursor, 0x7F); int searchTextColor = UIColors.getSearchTextColor(ctx); int searchHintColor = UIColors.setAlpha(searchTextColor, 0xBB); mSearchBar.setTextColor(searchTextColor); mSearchBar.setHintTextColor(searchHintColor); // set color for selection background mSearchBar.setHighlightColor(searchTextHighlight); Utilities.setTextCursorColor(mSearchBar, searchTextCursor); Utilities.setTextSelectHandleColor(mSearchBar, searchBarRipple); } // set icon { ResultViewHelper.setButtonIconAsync(mLauncherButton, BTN_ID_LAUNCHER_PILL, context -> { Drawable drawable = ResourcesCompat.getDrawable(context.getResources(), R.drawable.launcher_pill, null); Utilities.setColorFilterMultiply(drawable, searchIconColor); return drawable; }); } // icon color { Utilities.setColorFilterMultiply(mMenuButton, searchIconColor); Utilities.setColorFilterMultiply(mClearButton, searchIconColor); mLauncherButton.setBackground(getSelectorDrawable(mLauncherButton, searchBarRipple, true)); mMenuButton.setBackground(getSelectorDrawable(mMenuButton, searchBarRipple, true)); mClearButton.setBackground(getSelectorDrawable(mClearButton, searchBarRipple, true)); } // set text bar background boolean isGradient = mPref.getBoolean("search-bar-gradient", true); if (isGradient) { GradientDrawable drawable; final GradientDrawable.Orientation orientation; orientation = GradientDrawable.Orientation.LEFT_RIGHT; int alpha = Color.alpha(argbBackground); int c1 = UIColors.setAlpha(argbBackground, 0); int c2 = UIColors.setAlpha(argbBackground, alpha * 3 / 4); int c3 = UIColors.setAlpha(argbBackground, alpha); drawable = new GradientDrawable(orientation, new int[]{c1, c2, c3}); mSearchBar.setBackground(drawable); } else { mSearchBar.setBackground(new ColorDrawable(argbBackground)); } // set color for the pill background ImageView bkgBehindButton = mSearchBarContainer.findViewById(R.id.bkgBehindButton); if (bkgBehindButton != null) Utilities.setColorFilterMultiply(bkgBehindButton, argbBackground); // set menu button background int cornerRadius = UISizes.getSearchBarRadius(ctx); if (isGradient || cornerRadius > 0) { GradientDrawable drawable; if (isGradient) { final GradientDrawable.Orientation orientation; orientation = GradientDrawable.Orientation.RIGHT_LEFT; int alpha = Color.alpha(argbBackground); int c1 = UIColors.setAlpha(argbBackground, 0); int c2 = UIColors.setAlpha(argbBackground, alpha * 3 / 4); int c3 = UIColors.setAlpha(argbBackground, alpha); drawable = new GradientDrawable(orientation, new int[]{c1, c2, c3}); } else { drawable = new GradientDrawable(); drawable.setColor(argbBackground); } drawable.setCornerRadius(cornerRadius); mMenuButton.setBackground(drawable); mClearButton.setBackground(drawable); } else { mMenuButton.setBackground(new ColorDrawable(argbBackground)); mClearButton.setBackground(new ColorDrawable(argbBackground)); } // set margin between the pill and menu button { ViewGroup.LayoutParams params = mLauncherButton.getLayoutParams(); if (params instanceof ViewGroup.MarginLayoutParams) { int hMargin = UISizes.getSearchBarMarginHorizontal(ctx); // left must be touching the margin to look good ((ViewGroup.MarginLayoutParams) params).setMargins(0, 0, hMargin, 0); mLauncherButton.setLayoutParams(params); } else { throw new IllegalStateException("mMenuButton has the wrong layout params"); } } } public static void setResultListPref(View resultLayout) { setResultListPref(resultLayout, false); } private static boolean setResultListGradientFade(@NonNull View resultLayout, int backgroundColor) { Drawable bg = resultLayout.getBackground(); if (bg instanceof GradientDrawable) { GradientDrawable drawable = (GradientDrawable) bg; drawable.setGradientType(GradientDrawable.LINEAR_GRADIENT); drawable.setOrientation(GradientDrawable.Orientation.TOP_BOTTOM); int color = backgroundColor & 0x00ffffff; int alpha = Color.alpha(backgroundColor); int c1 = UIColors.setAlpha(color, 0); int c2 = UIColors.setAlpha(color, alpha * 3 / 4); int c3 = UIColors.setAlpha(color, alpha); // compute fade percentage of height float p = UISizes.getResultIconSize(resultLayout.getContext()) * .5f / resultLayout.getHeight(); // if height is too small, fade only on 66% of the height (33% fade in and 33% fade out) p = Math.min(p, .33f); return Utilities.setGradientDrawableColors(drawable, new int[]{c1, c2, c3, c3, c2, c1}, new float[]{0f, p * .5f, p, 1f - p, 1f - (p * .5f), 1f}); } return false; } public static void setResultListPref(View resultLayout, boolean setMargin) { Context ctx = resultLayout.getContext(); SharedPreferences pref = TBApplication.getApplication(ctx).preferences(); if (setMargin) { ViewGroup.LayoutParams params = resultLayout.getLayoutParams(); if (params instanceof ViewGroup.MarginLayoutParams) { final var margin = UISizes.getResultListMargin(ctx); ((ViewGroup.MarginLayoutParams) params).setMargins(margin.left, margin.top, margin.right, margin.bottom); } } boolean fadeOut = PrefCache.getResultFadeOut(pref); int backgroundColor = UIColors.getResultListBackground(pref); int cornerRadius = UISizes.getResultListRadius(ctx); if (cornerRadius > 0 || fadeOut) { final GradientDrawable drawable = new GradientDrawable(); drawable.setCornerRadius(cornerRadius); resultLayout.setBackground(drawable); if (fadeOut) setResultListGradientFade(resultLayout, backgroundColor); else drawable.setColor(backgroundColor); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { // clip list content to rounded corners resultLayout.setClipToOutline(true); } } else { resultLayout.setBackgroundColor(backgroundColor); } int overscrollColor = UIColors.getResultListRipple(ctx); overscrollColor = UIColors.setAlpha(overscrollColor, 0x7F); if (resultLayout instanceof AbsListView) { setListViewSelectorPref((AbsListView) resultLayout, true); setListViewScrollbarPref(resultLayout); EdgeGlowHelper.setEdgeGlowColor((AbsListView) resultLayout, overscrollColor); if (setMargin) setFadingEdge(resultLayout, fadeOut); } else { View list = resultLayout.findViewById(R.id.resultList); if (list instanceof AbsListView) { setListViewSelectorPref((AbsListView) list, false); setListViewScrollbarPref(list); EdgeGlowHelper.setEdgeGlowColor((AbsListView) list, overscrollColor); } else if (list instanceof RecyclerList) { setListViewScrollbarPref(list); EdgeGlowHelper.setEdgeGlowColor((RecyclerList) list, overscrollColor); } if (setMargin) setFadingEdge(list, fadeOut); } } private static void setFadingEdge(@Nullable View view, boolean enabled) { if (view == null) return; if (enabled) view.setFadingEdgeLength(UISizes.getResultIconSize(view.getContext())); view.setVerticalFadingEdgeEnabled(enabled); } private void adjustInputType(EditText searchEditText) { int currentInputType = searchEditText.getInputType(); int requiredInputType; if (isSuggestionsEnabled()) { requiredInputType = InputType.TYPE_CLASS_TEXT; } else { if (isNonCompliantKeyboard()) { requiredInputType = INPUT_TYPE_WORKAROUND; } else { requiredInputType = INPUT_TYPE_STANDARD; } } if (currentInputType != requiredInputType) { searchEditText.setInputType(requiredInputType); } } public static void setListViewSelectorPref(AbsListView listView, boolean borderless) { int touchColor = UIColors.getResultListRipple(listView.getContext()); Drawable selector = getSelectorDrawable(listView, touchColor, borderless); listView.setSelector(selector); } public static Drawable getSelectorDrawable(View view, int color, boolean borderless) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { Drawable mask = borderless ? null : new ColorDrawable(Color.WHITE); Drawable content = borderless ? null : view.getBackground(); return new RippleDrawable(ColorStateList.valueOf(color), content, mask); } else { ColorDrawable stateColor = new ColorDrawable(color); StateListDrawable stateListDrawable = new StateListDrawable(); stateListDrawable.addState(new int[]{android.R.attr.state_selected}, stateColor); stateListDrawable.addState(new int[]{android.R.attr.state_focused}, stateColor); stateListDrawable.addState(new int[]{android.R.attr.state_pressed}, stateColor); stateListDrawable.addState(new int[]{}, new ColorDrawable(Color.TRANSPARENT)); stateListDrawable.setEnterFadeDuration(300); stateListDrawable.setExitFadeDuration(100); return stateListDrawable; } } public static void setListViewScrollbarPref(View listView) { int color = UIColors.getResultListRipple(listView.getContext()); setListViewScrollbarPref(listView, UIColors.setAlpha(color, 0x7F)); } public static void setListViewScrollbarPref(View listView, int color) { GradientDrawable drawable = new GradientDrawable(GradientDrawable.Orientation.LEFT_RIGHT, new int[]{color, color}); drawable.setCornerRadius(UISizes.dp2px(listView.getContext(), 3)); drawable.setSize(UISizes.dp2px(listView.getContext(), 4), drawable.getIntrinsicHeight()); Utilities.setVerticalScrollbarThumbDrawable(listView, drawable); } public Context getContext() { return mTBLauncherActivity; } @NonNull public static Drawable getPopupBackgroundDrawable(@NonNull Context ctx) { int border = UISizes.dp2px(ctx, 1); int radius = UISizes.getPopupCornerRadius(ctx); GradientDrawable gradient = new GradientDrawable(); gradient.setCornerRadius(radius); gradient.setStroke(border, UIColors.getPopupBorderColor(ctx)); gradient.setColor(UIColors.getPopupBackgroundColor(ctx)); return gradient; } public static Drawable getDialogButtonBarBackgroundDrawable(@NonNull Resources.Theme theme) { TypedValue typedValue = new TypedValue(); if (theme.resolveAttribute(android.R.attr.buttonBarStyle, typedValue, true)) { TypedArray a = theme.obtainStyledAttributes(typedValue.resourceId, new int[]{android.R.attr.background}); Drawable background = a.getDrawable(0); a.recycle(); return background; } return null; } /** * Should we force the keyboard not to display suggestions? * (swiftkey is broken, see https://github.com/Neamar/KISS/issues/44) * (same for flesky: https://github.com/Neamar/KISS/issues/1263) */ private boolean isNonCompliantKeyboard() { String currentKeyboard = Settings.Secure.getString(mTBLauncherActivity.getContentResolver(), Settings.Secure.DEFAULT_INPUT_METHOD).toLowerCase(); return currentKeyboard.contains("swiftkey") || currentKeyboard.contains("flesky") || currentKeyboard.endsWith(".latinime"); } /** * Should the keyboard autocomplete and suggest options */ private boolean isSuggestionsEnabled() { return mPref.getBoolean("enable-suggestions-keyboard", false); } public void expandSearchPill(int duration) { if (mSearchBarContainer instanceof MotionLayout) { ((MotionLayout) mSearchBarContainer).setTransitionDuration(duration); ((MotionLayout) mSearchBarContainer).transitionToEnd(); } } public void collapseSearchPill(int duration) { if (mSearchBarContainer instanceof MotionLayout) { ((MotionLayout) mSearchBarContainer).setTransitionDuration(duration); ((MotionLayout) mSearchBarContainer).transitionToStart(); } } public void setExpandedSearchPillListener(Runnable listener) { mSearchBarTransition.setTransitionToEndListener(listener); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/DeviceAdmin.java ================================================ package rocks.tbog.tblauncher; import android.app.admin.DeviceAdminReceiver; import android.app.admin.DevicePolicyManager; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import androidx.annotation.NonNull; import androidx.preference.PreferenceManager; public class DeviceAdmin extends DeviceAdminReceiver { @Override public void onEnabled(@NonNull Context context, @NonNull Intent intent) { super.onEnabled(context, intent); SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context); pref.edit().putBoolean("device-admin", true).apply(); } @Override public void onDisabled(@NonNull Context context, @NonNull Intent intent) { super.onDisabled(context, intent); SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context); pref.edit().putBoolean("device-admin", false).apply(); } @NonNull public static ComponentName getAdminComponent(@NonNull Context context) { return new ComponentName(context, DeviceAdmin.class); } public static boolean isAdminActive(@NonNull Context context) { Object service = context.getSystemService(Context.DEVICE_POLICY_SERVICE); if (service instanceof DevicePolicyManager) { DevicePolicyManager dpm = (DevicePolicyManager) service; return dpm.isAdminActive(getAdminComponent(context)); } return false; } public static void removeActiveAdmin(@NonNull Context context) { Object service = context.getSystemService(Context.DEVICE_POLICY_SERVICE); if (service instanceof DevicePolicyManager) { DevicePolicyManager dpm = (DevicePolicyManager) service; dpm.removeActiveAdmin(getAdminComponent(context)); } } public static void lockScreen(@NonNull Context context) { Object service = context.getSystemService(Context.DEVICE_POLICY_SERVICE); if (service instanceof DevicePolicyManager) { DevicePolicyManager dpm = (DevicePolicyManager) service; dpm.lockNow(); } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/DrawableCache.java ================================================ package rocks.tbog.tblauncher; import android.content.Context; import android.content.SharedPreferences; import android.graphics.drawable.Drawable; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.collection.LruCache; import java.util.Calendar; import java.util.Collection; public class DrawableCache { private static final String TAG = "DrawCache"; private boolean mEnabled = true; private final LruCache mCache = new LruCache<>(16); public void setSize(int maxSize) { mCache.resize(maxSize); } public void setCalendar(String cacheId) { synchronized (mCache) { DrawableInfo info = mCache.get(cacheId); if (info != null) info.setToday(); } } @Nullable public Drawable getCachedDrawable(@NonNull String cacheId) { synchronized (mCache) { DrawableInfo info = mCache.get(cacheId); if (info != null) { if (info.isToday()) return info.drawable; mCache.remove(cacheId); } } return null; } public void cacheDrawable(@NonNull String cacheId, @Nullable Drawable drawable) { synchronized (mCache) { if (drawable == null) { mCache.remove(cacheId); return; } if (!mEnabled) return; DrawableInfo info = new DrawableInfo(drawable); mCache.put(cacheId, info); } } public void clearCache() { synchronized (mCache) { mCache.evictAll(); } } public void onPrefChanged(Context ctx, SharedPreferences pref) { boolean enabled = pref.getBoolean("cache-drawable", true); if (enabled != mEnabled) { mEnabled = enabled; clearCache(); } boolean halfSize = pref.getBoolean("cache-half-apps", true); Collection apps = TBApplication.appsHandler(ctx).getAllApps(); int size = apps.size(); size = size < 16 ? 16 : halfSize ? (size / 2) : (size * 115 / 100); Log.i(TAG, "Cache size: " + size); synchronized (mCache) { mCache.resize(size); } } public static class DrawableInfo { public final Drawable drawable; public int dayOfMonth = 0; public DrawableInfo(Drawable drawable) { this.drawable = drawable; } /** * Set day for cached drawable. This is a number indicating the day of the month. * The first day of the month has value 1. */ public void setToday() { dayOfMonth = Calendar.getInstance().get(Calendar.DAY_OF_MONTH); } public boolean isToday() { if (dayOfMonth == 0) return true; return dayOfMonth == Calendar.getInstance().get(Calendar.DAY_OF_MONTH); } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/DummyLauncherActivity.java ================================================ package rocks.tbog.tblauncher; import android.app.Activity; public class DummyLauncherActivity extends Activity { } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/EditTagsDialog.java ================================================ package rocks.tbog.tblauncher; import android.app.Dialog; import android.content.Context; import android.os.Build; import android.os.Bundle; import android.text.Editable; import android.text.TextWatcher; import android.util.Log; import android.view.KeyEvent; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.view.inputmethod.EditorInfo; import android.view.inputmethod.InputMethodManager; import android.widget.ArrayAdapter; import android.widget.AutoCompleteTextView; import android.widget.BaseAdapter; import android.widget.GridView; import android.widget.ImageView; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.collection.ArraySet; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Set; import rocks.tbog.tblauncher.entry.EntryItem; import rocks.tbog.tblauncher.handler.TagsHandler; import rocks.tbog.tblauncher.ui.DialogFragment; import rocks.tbog.tblauncher.ui.DialogWrapper; public class EditTagsDialog extends DialogFragment> { private static final String TAG = EditTagsDialog.class.getSimpleName(); private final ArraySet mTagList = new ArraySet<>(); private TagsAdapter mAdapter; private AutoCompleteTextView mNewTag; @Override protected int layoutRes() { return R.layout.dialog_edit_tags; } @Nullable @Override public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { Context context = requireDialog().getContext(); setupDefaultButtonOkCancel(context); Bundle args = getArguments() != null ? getArguments() : new Bundle(); // make sure we use the dialog context LayoutInflater dialogInflater = inflater.cloneInContext(context); ViewGroup root = (ViewGroup) super.onCreateView(dialogInflater, container, savedInstanceState); assert root != null; // make a layout for the entry we are changing String entryId = args.getString("entryId", ""); TBApplication app = TBApplication.getApplication(context); EntryItem entry = app.getDataHandler().getPojo(entryId); ViewGroup wrapper = root.findViewById(R.id.previewWrapper); if (wrapper == null) wrapper = root; if (entry != null) { int drawFlags = EntryItem.FLAG_DRAW_LIST | EntryItem.FLAG_DRAW_NAME | EntryItem.FLAG_DRAW_ICON; View entryView = dialogInflater.inflate(entry.getResultLayout(drawFlags), wrapper, false); entryView.setId(R.id.iconPreview); wrapper.addView(entryView, 0); CustomizeUI.setResultListPref(entryView); } return root; } @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); Context context = view.getContext(); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { view.setClipToOutline(true); } Bundle args = getArguments() != null ? getArguments() : new Bundle(); String entryId = args.getString("entryId", ""); String entryName = args.getString("entryName", ""); // show the app we are changing EntryItem entry = TBApplication.getApplication(context).getDataHandler().getPojo(entryId); if (entry == null) { dismiss(); return; } int drawFlags = EntryItem.FLAG_DRAW_LIST | EntryItem.FLAG_DRAW_NAME | EntryItem.FLAG_DRAW_ICON; entry.displayResult(view.findViewById(R.id.iconPreview), drawFlags); // prepare the grid with all the tags mAdapter = new TagsAdapter(mTagList); GridView gridView = view.findViewById(R.id.grid); gridView.setAdapter(mAdapter); mAdapter.setOnItemClickListener((adapter, v, position) -> removeTag(adapter.getItem(position))); // initialize new tag EditView mNewTag = view.findViewById(R.id.newTag); mNewTag.addTextChangedListener(new TextWatcher() { public void afterTextChanged(Editable s) { // Auto left-trim text. if (s.length() > 0 && s.charAt(0) == ' ') s.delete(0, 1); } public void beforeTextChanged(CharSequence s, int start, int count, int after) { } public void onTextChanged(CharSequence s, int start, int before, int count) { } }); mNewTag.setOnEditorActionListener((v, actionId, event) -> { if (event == null) { if (actionId != EditorInfo.IME_ACTION_NONE) { String tag = mNewTag.getText().toString(); if (tag.isEmpty()) { onConfirm(mTagList); dismiss(); return true; } addTag(tag); return true; } } else if (event.getKeyCode() == KeyEvent.KEYCODE_ENTER) { if (event.getAction() == KeyEvent.ACTION_UP) { String tag = mNewTag.getText().toString(); addTag(tag); } return true; } return false; }); // set the auto complete list { List allTags = new ArrayList<>(TBApplication.tagsHandler(context).getValidTags()); Collections.sort(allTags); mNewTag.setAdapter(new ArrayAdapter<>(context, android.R.layout.simple_list_item_1, allTags)); } // initialize add tag button ImageView addTag = view.findViewById(R.id.addTag); addTag.setOnClickListener(v -> { String tag = mNewTag.getText().toString(); addTag(tag); }); } @Override public void onButtonClick(@NonNull Button button) { if (button == Button.POSITIVE) { String tag = mNewTag.getText().toString(); addTag(tag); onConfirm(mTagList); } super.onButtonClick(button); } @Override public void onStart() { super.onStart(); Dialog dialog = getDialog(); if (dialog instanceof DialogWrapper) { ((DialogWrapper) dialog).setOnWindowFocusChanged((dlg, hasFocus) -> { if (hasFocus) { dlg.setOnWindowFocusChanged(null); showKeyboard(dlg, mNewTag); } }); } } private static void showKeyboard(@NonNull Dialog dialog, @NonNull TextView textView) { Log.i(TAG, "Keyboard - SHOW"); textView.requestFocus(); InputMethodManager mgr = (InputMethodManager) dialog.getContext().getSystemService(Context.INPUT_METHOD_SERVICE); assert mgr != null; mgr.showSoftInput(textView, InputMethodManager.SHOW_IMPLICIT); } private void addTag(String tag) { tag = tag.trim(); if (tag.length() == 0) return; mTagList.add(tag); mAdapter.notifyDataSetChanged(); mNewTag.setText(""); } private void removeTag(String tag) { mTagList.remove(tag); mAdapter.notifyDataSetChanged(); } @Override public void onActivityCreated(@Nullable Bundle savedInstanceState) { super.onActivityCreated(savedInstanceState); Context context = getActivity(); assert context != null; Bundle args = getArguments() != null ? getArguments() : new Bundle(); String entryId = args.getString("entryId", ""); TagsHandler tagsHandler = TBApplication.tagsHandler(context); mTagList.clear(); mTagList.addAll(tagsHandler.getTags(entryId)); mAdapter.notifyDataSetChanged(); } static class TagsAdapter extends BaseAdapter { private final ArraySet mTags; private OnItemClickListener mOnItemClickListener = null; public interface OnItemClickListener { void onItemClick(TagsAdapter adapter, View view, int position); } TagsAdapter(@NonNull ArraySet tags) { mTags = tags; } void setOnItemClickListener(OnItemClickListener listener) { mOnItemClickListener = listener; } @Override public int getCount() { return mTags.size(); } @Override public String getItem(int position) { return mTags.valueAt(position); } @Override public long getItemId(int position) { return getItem(position).hashCode(); } @Override public View getView(int position, View convertView, ViewGroup parent) { final View view; if (convertView == null) { view = LayoutInflater.from(parent.getContext()).inflate(R.layout.edit_tag_item, parent, false); } else { view = convertView; } ViewHolder holder = view.getTag() instanceof ViewHolder ? (ViewHolder) view.getTag() : new ViewHolder(view); String content = getItem(position); holder.setContent(content); holder.buttonView.setOnClickListener(v -> { if (mOnItemClickListener != null) mOnItemClickListener.onItemClick(TagsAdapter.this, v, position); }); return view; } static class ViewHolder { TextView textView; View buttonView; ViewHolder(View itemView) { itemView.setTag(this); textView = itemView.findViewById(android.R.id.text1); buttonView = itemView.findViewById(android.R.id.button1); } public void setContent(CharSequence content) { textView.setText(content); } } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/LauncherState.java ================================================ package rocks.tbog.tblauncher; import android.view.View; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import rocks.tbog.tblauncher.ui.WindowInsetsHelper; public class LauncherState { public enum AnimatedVisibility { HIDDEN, ANIM_TO_HIDDEN, ANIM_TO_VISIBLE, VISIBLE, } public enum Desktop { SEARCH, WIDGET, EMPTY, } private AnimatedVisibility quickList = AnimatedVisibility.HIDDEN; private AnimatedVisibility searchBar = AnimatedVisibility.HIDDEN; private AnimatedVisibility resultList = AnimatedVisibility.HIDDEN; private AnimatedVisibility notificationBar = AnimatedVisibility.HIDDEN; private AnimatedVisibility widgetScreen = AnimatedVisibility.HIDDEN; private AnimatedVisibility clearScreen = AnimatedVisibility.HIDDEN; private AnimatedVisibility keyboard = AnimatedVisibility.HIDDEN; private Desktop desktop = null; private static boolean isVisible(AnimatedVisibility state) { return state == AnimatedVisibility.ANIM_TO_VISIBLE || state == AnimatedVisibility.VISIBLE; } public boolean isQuickListVisible() { return isVisible(quickList); } public boolean isSearchBarVisible() { return isVisible(searchBar); } public boolean isResultListVisible() { return isVisible(resultList); } public boolean isNotificationBarVisible() { return isVisible(notificationBar); } public boolean isWidgetScreenVisible() { return isVisible(widgetScreen); } public boolean isClearScreenVisible() { return isVisible(clearScreen); } public boolean isKeyboardHidden() { return !isVisible(keyboard); } public void syncKeyboardVisibility(View anyView) { if (WindowInsetsHelper.isKeyboardVisible(anyView)) setKeyboard(AnimatedVisibility.VISIBLE); else setKeyboard(AnimatedVisibility.HIDDEN); } @Nullable public Desktop getDesktop() { return desktop; } public void setNotificationBar(@NonNull AnimatedVisibility state) { notificationBar = state; } public void setSearchBar(@NonNull AnimatedVisibility state) { searchBar = state; } public void setResultList(@NonNull AnimatedVisibility state) { resultList = state; } public void setQuickList(@NonNull AnimatedVisibility state) { quickList = state; } public void setWidgetScreen(@NonNull AnimatedVisibility state) { widgetScreen = state; } public void setKeyboard(@NonNull AnimatedVisibility state) { keyboard = state; } public void setDesktop(@NonNull Desktop mode) { desktop = mode; } @NonNull public AnimatedVisibility getSearchBarVisibility() { return searchBar; } @NonNull public AnimatedVisibility getResultListVisibility() { return resultList; } @NonNull public AnimatedVisibility getNotificationBarVisibility() { return notificationBar; } @NonNull public AnimatedVisibility getWidgetScreenVisibility() { return widgetScreen; } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/LiveWallpaper.java ================================================ package rocks.tbog.tblauncher; import android.app.WallpaperManager; import android.content.Context; import android.content.SharedPreferences; import android.graphics.Point; import android.graphics.PointF; import android.graphics.Rect; import android.os.Build; import android.util.Log; import android.view.GestureDetector; import android.view.Gravity; import android.view.HapticFeedbackConstants; import android.view.MotionEvent; import android.view.VelocityTracker; import android.view.View; import android.view.ViewConfiguration; import android.view.WindowMetrics; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.preference.PreferenceManager; import java.util.Locale; import rocks.tbog.tblauncher.ui.ListPopup; import rocks.tbog.tblauncher.utils.GestureDetectorHelper; import rocks.tbog.tblauncher.utils.UISizes; public class LiveWallpaper { private static final String TAG = "LWP"; private static final int FLING_DELTA_ANGLE = 33; private static final int GD_TOUCH_SLOP_DP = 16; private TBLauncherActivity mTBLauncherActivity = null; private WallpaperManager mWallpaperManager; private final Point mWindowSize = new Point(1, 1); private View mContentView; private final PointF mFirstTouchOffset = new PointF(); private final PointF mFirstTouchPos = new PointF(); private final PointF mLastTouchPos = new PointF(); private final PointF mWallpaperOffset = new PointF(.5f, .5f); private WallpaperSnapAnim mSnapAnimation; private VelocityTracker mVelocityTracker; public static int SCREEN_COUNT_HORIZONTAL = Integer.parseInt("3"); public static int SCREEN_COUNT_VERTICAL = Integer.parseInt("1"); // not tested with values != 1 private boolean lwpScrollPages = true; private boolean lwpTouch = true; private boolean lwpDrag = false; private boolean wpDragAnimate = true; private boolean wpReturnCenter = true; private boolean wpStickToSides = false; private GestureDetector gestureDetector = null; private final GestureDetector.SimpleOnGestureListener onGestureListener = new GestureDetector.SimpleOnGestureListener() { @Override public void onLongPress(@NonNull MotionEvent e) { if (!TBApplication.state().isWidgetScreenVisible()) return; View view = mTBLauncherActivity.findViewById(R.id.root_layout); onLongClick(view); } @Override public boolean onFling(@Nullable MotionEvent e1, @NonNull MotionEvent e2, float velocityX, float velocityY) { long deltaTimeMs = (e1 != null) ? (e2.getEventTime() - e1.getEventTime()) : 0; if (deltaTimeMs > ViewConfiguration.getDoubleTapTimeout()) return false; View view = mTBLauncherActivity.findViewById(R.id.root_layout); float xMove = velocityX; float yMove = velocityY; if (e1 != null) { xMove = e1.getRawX() - e2.getRawX(); yMove = e1.getRawY() - e2.getRawY(); } return LiveWallpaper.this.onFling(view, xMove, yMove, velocityX, velocityY); } @Override public boolean onDoubleTapEvent(@NonNull MotionEvent e) { if (e.getActionMasked() == MotionEvent.ACTION_UP) { View view = mTBLauncherActivity.findViewById(R.id.root_layout); return onDoubleClick(view); } return false; } @Override public boolean onSingleTapUp(@NonNull MotionEvent e) { // if we have a double tap listener, wait for onSingleTapConfirmed if (mTBLauncherActivity.behaviour.hasDoubleClick()) return true; View view = mTBLauncherActivity.findViewById(R.id.root_layout); return onClick(view); } @Override public boolean onSingleTapConfirmed(MotionEvent e) { // if we have both a double tap and click, handle click here if (mTBLauncherActivity.behaviour.hasDoubleClick()) { View view = mTBLauncherActivity.findViewById(R.id.root_layout); return onClick(view); } return false; } }; LiveWallpaper() { // TypedValue typedValue = new TypedValue(); // mainActivity.getTheme().resolveAttribute(android.R.attr.windowShowWallpaper, typedValue, true); // TypedArray a = mainActivity.obtainStyledAttributes(typedValue.resourceId, new int[]{android.R.attr.windowShowWallpaper}); // wallpaperIsVisible = a.getBoolean(0, true); // a.recycle(); } @NonNull public PointF getWallpaperOffset() { return mWallpaperOffset; } @NonNull public Point getWindowSize() { return mWindowSize; } public void scroll(MotionEvent e1, MotionEvent e2) { cacheWindowSize(); mFirstTouchPos.set(e1.getRawX(), e1.getRawY()); mLastTouchPos.set(e2.getRawX(), e2.getRawY()); float xMove = (mFirstTouchPos.x - mLastTouchPos.x) / mWindowSize.x; float yMove = (mFirstTouchPos.y - mLastTouchPos.y) / mWindowSize.y; float offsetX = mFirstTouchOffset.x + xMove * 1.01f; float offsetY = mFirstTouchOffset.y + yMove * 1.01f; updateWallpaperOffset(offsetX, offsetY); } private int prefGetInt(@NonNull SharedPreferences prefs, @NonNull String key, int defaultValue) { String value = prefs.getString(key, null); if (value != null) { try { return Integer.parseInt(value); } catch (NumberFormatException ignored) { } } return defaultValue; } public void onCreateActivity(TBLauncherActivity mainActivity) { mTBLauncherActivity = mainActivity; // load preferences { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(mainActivity); lwpScrollPages = prefs.getBoolean("lwp-scroll-pages", true); lwpTouch = prefs.getBoolean("lwp-touch", true); lwpDrag = prefs.getBoolean("lwp-drag", false); wpDragAnimate = prefs.getBoolean("wp-drag-animate", false); wpReturnCenter = prefs.getBoolean("wp-animate-center", true); wpStickToSides = prefs.getBoolean("wp-animate-sides", false); SCREEN_COUNT_VERTICAL = prefGetInt(prefs, "lwp-page-count-vertical", SCREEN_COUNT_VERTICAL); SCREEN_COUNT_HORIZONTAL = prefGetInt(prefs, "lwp-page-count-horizontal", SCREEN_COUNT_HORIZONTAL); } mWallpaperManager = (WallpaperManager) mainActivity.getSystemService(Context.WALLPAPER_SERVICE); assert mWallpaperManager != null; // set mContentView before we call updateWallpaperOffset mContentView = mainActivity.findViewById(android.R.id.content); resetPageCount(); mSnapAnimation = new WallpaperSnapAnim(this); mVelocityTracker = null; View root = mainActivity.findViewById(R.id.root_layout); root.setOnTouchListener(this::onRootTouch); gestureDetector = new GestureDetector(mainActivity, onGestureListener); gestureDetector.setIsLongpressEnabled(true); GestureDetectorHelper.setGestureDetectorTouchSlop(gestureDetector, UISizes.dp2px(mainActivity, GD_TOUCH_SLOP_DP)); } public void resetPosition() { resetPageCount(); } private void resetPageCount() { Log.i(TAG, "resetPageCount " + SCREEN_COUNT_HORIZONTAL + "x" + SCREEN_COUNT_VERTICAL); float xStep = (SCREEN_COUNT_HORIZONTAL > 1) ? (1.f / (SCREEN_COUNT_HORIZONTAL - 1)) : 0.f; float yStep = (SCREEN_COUNT_VERTICAL > 1) ? (1.f / (SCREEN_COUNT_VERTICAL - 1)) : 0.f; mWallpaperManager.setWallpaperOffsetSteps(xStep, yStep); if (isPreferenceLWPScrollPages()) { mTBLauncherActivity.widgetManager.setPageCount(SCREEN_COUNT_HORIZONTAL, SCREEN_COUNT_VERTICAL); } int centerScreenX = SCREEN_COUNT_HORIZONTAL / 2; int centerScreenY = SCREEN_COUNT_VERTICAL / 2; updateWallpaperOffset(centerScreenX * xStep, centerScreenY * yStep); } private static boolean onClick(View view) { if (!view.isAttachedToWindow()) return false; return TBApplication.behaviour(view.getContext()).onClick(); } private static boolean onDoubleClick(View view) { if (!view.isAttachedToWindow()) return false; return TBApplication.behaviour(view.getContext()).onDoubleClick(); } private static int computeAngle(float x, float y) { return (int) (.5 + Math.toDegrees(Math.atan2(y, x))); } private boolean onFling(View view, float xMove, float yMove, float xVel, float yVel) { if (!view.isAttachedToWindow()) return false; final Behaviour behaviour = mTBLauncherActivity.behaviour; final int angle; // if (-minMovement < xMove && xMove < minMovement && -minMovement < yMove && yMove < minMovement) { // // too little movement, use velocity // angle = computeAngle(xVel, yVel); // } else { angle = computeAngle(xMove, yMove); // } // fling upwards if ((90 + FLING_DELTA_ANGLE) > angle && angle > (90 - FLING_DELTA_ANGLE)) { Log.d(TAG, String.format(Locale.US, "Angle=%d - fling upward", angle)); return behaviour.onFlingUp(); } // fling downwards else if ((90 + FLING_DELTA_ANGLE) > -angle && -angle > (90 - FLING_DELTA_ANGLE)) { Log.d(TAG, String.format(Locale.US, "Angle=%d - fling downward", angle)); final int posX = (int) mFirstTouchPos.x; if (posX < (mWindowSize.x / 2)) return behaviour.onFlingDownLeft(); else return behaviour.onFlingDownRight(); } // fling left else if (FLING_DELTA_ANGLE > angle && angle > -FLING_DELTA_ANGLE) { Log.d(TAG, String.format(Locale.US, "Angle=%d - fling left", angle)); return behaviour.onFlingLeft(); } // fling right else if ((180 - FLING_DELTA_ANGLE) < angle || angle < (-180 + FLING_DELTA_ANGLE)) { Log.d(TAG, String.format(Locale.US, "Angle=%d - fling right", angle)); return behaviour.onFlingRight(); } Log.d(TAG, String.format(Locale.US, "Angle=%d - fling direction uncertain", angle)); return false; } private void onLongClick(View view) { if (!view.isAttachedToWindow()) { return; } view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS); ListPopup menu = mTBLauncherActivity.widgetManager.getConfigPopup(mTBLauncherActivity); TBApplication.getApplication(mTBLauncherActivity).registerPopup(menu); int x = (int) (mLastTouchPos.x + .5f); int y = (int) (mLastTouchPos.y + .5f); menu.showAtLocation(view, Gravity.START | Gravity.TOP, x, y); } private void cacheWindowSize() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { WindowMetrics windowMetrics = mTBLauncherActivity.getWindowManager().getCurrentWindowMetrics(); //Insets insets = windowMetrics.getWindowInsets().getInsetsIgnoringVisibility(WindowInsets.Type.systemBars()); Rect windowBound = windowMetrics.getBounds(); int width = windowBound.width();// - insets.left - insets.right; int height = windowBound.height();// - insets.top - insets.bottom; mWindowSize.set(width, height); } else { mTBLauncherActivity.getWindowManager() .getDefaultDisplay() .getSize(mWindowSize); } } private boolean initializeSnapAnimation() { return mSnapAnimation.init(mVelocityTracker); } boolean onRootTouch(View view, MotionEvent event) { if (!view.isAttachedToWindow()) { return false; } Log.d(TAG, "onRootTouch\r\n" + event); int actionMasked = event.getActionMasked(); boolean eventConsumed = false; switch (actionMasked) { case MotionEvent.ACTION_DOWN: { mFirstTouchPos.set(event.getRawX(), event.getRawY()); mLastTouchPos.set(mFirstTouchPos); mFirstTouchOffset.set(mWallpaperOffset); cacheWindowSize(); if (isScrollEnabled()) { mContentView.clearAnimation(); } if (mVelocityTracker != null) mVelocityTracker.recycle(); mVelocityTracker = VelocityTracker.obtain(); mVelocityTracker.addMovement(event); //send touch event to the LWP if (isPreferenceLWPTouch()) sendTouchEvent(view, event); eventConsumed = true; break; } case MotionEvent.ACTION_MOVE: { mLastTouchPos.set(event.getRawX(), event.getRawY()); float xMove = (mFirstTouchPos.x - mLastTouchPos.x) / mWindowSize.x; float yMove = (mFirstTouchPos.y - mLastTouchPos.y) / mWindowSize.y; if (mVelocityTracker != null) mVelocityTracker.addMovement(event); if (isScrollEnabled()) { float offsetX = mFirstTouchOffset.x + xMove * 1.01f; float offsetY = mFirstTouchOffset.y + yMove * 1.01f; updateWallpaperOffset(offsetX, offsetY); } //send move/drag event to the LWP if (isPreferenceLWPDrag()) sendTouchEvent(view, event); if (isScrollEnabled()) eventConsumed = true; break; } case MotionEvent.ACTION_UP: { // was this a click? float xMove = (mFirstTouchPos.x - mLastTouchPos.x) / mWindowSize.x; float yMove = (mFirstTouchPos.y - mLastTouchPos.y) / mWindowSize.y; if (mVelocityTracker == null) { Log.d(TAG, String.format(Locale.US, "Move=(%.3f, %.3f)", xMove, yMove)); } else { mVelocityTracker.addMovement(event); mVelocityTracker.computeCurrentVelocity(1000 / 30); // 1000 provides px per second float xVel = mVelocityTracker.getXVelocity();// / mWindowSize.x; float yVel = mVelocityTracker.getYVelocity();// / mWindowSize.y; Log.d(TAG, String.format(Locale.US, "Velocity=(%.3f, %.3f)\u2248%d\u00b0 Move=(%.3f, %.3f)\u2248%d\u00b0", xVel, yVel, computeAngle(xVel, yVel), xMove, yMove, computeAngle(xMove, yMove))); // snap position if needed if (isScrollEnabled() && initializeSnapAnimation()) mContentView.startAnimation(mSnapAnimation); } } // fallthrough case MotionEvent.ACTION_CANCEL: if (isScrollEnabled()) { if (mVelocityTracker != null) { mVelocityTracker.addMovement(event); mVelocityTracker.computeCurrentVelocity(1000 / 30); // 1000 provides px per second if (initializeSnapAnimation()) mContentView.startAnimation(mSnapAnimation); mVelocityTracker.recycle(); mVelocityTracker = null; } else { if (initializeSnapAnimation()) mContentView.startAnimation(mSnapAnimation); } eventConsumed = true; } break; } eventConsumed = gestureDetector.onTouchEvent(event) || eventConsumed; Log.d(TAG, "onRootTouch event " + (eventConsumed ? "" : "NOT ") + "consumed"); return eventConsumed; } public Context getContext() { return mTBLauncherActivity; } public void onPrefChanged(SharedPreferences prefs, String key) { switch (key) { case "lwp-scroll-pages": lwpScrollPages = prefs.getBoolean("lwp-scroll-pages", true); break; case "lwp-touch": lwpTouch = prefs.getBoolean("lwp-touch", true); break; case "lwp-drag": lwpDrag = prefs.getBoolean("lwp-drag", false); break; case "wp-drag-animate": wpDragAnimate = prefs.getBoolean("wp-drag-animate", false); break; case "wp-animate-center": wpReturnCenter = prefs.getBoolean("wp-animate-center", true); break; case "wp-animate-sides": wpStickToSides = prefs.getBoolean("wp-animate-sides", false); break; case "lwp-page-count-vertical": { int count = prefGetInt(prefs, "lwp-page-count-vertical", SCREEN_COUNT_VERTICAL); if (SCREEN_COUNT_VERTICAL != count) { SCREEN_COUNT_VERTICAL = count; resetPageCount(); } break; } case "lwp-page-count-horizontal": { int count = prefGetInt(prefs, "lwp-page-count-horizontal", SCREEN_COUNT_HORIZONTAL); if (SCREEN_COUNT_HORIZONTAL != count) { SCREEN_COUNT_HORIZONTAL = count; resetPageCount(); } break; } } } private boolean isScrollEnabled() { return lwpScrollPages || wpDragAnimate; } private boolean isPreferenceLWPScrollPages() { return lwpScrollPages; } private boolean isPreferenceLWPTouch() { return lwpTouch; } private boolean isPreferenceLWPDrag() { return lwpDrag; } public boolean isPreferenceWPDragAnimate() { return wpDragAnimate; } public boolean isPreferenceWPReturnCenter() { return wpReturnCenter; } public boolean isPreferenceWPStickToSides() { return wpStickToSides; } private android.os.IBinder getWindowToken() { return mContentView != null && mContentView.isAttachedToWindow() ? mContentView.getWindowToken() : null; } public void updateWallpaperOffset(float offsetX, float offsetY) { offsetX = Math.max(0.f, Math.min(1.f, offsetX)); offsetY = Math.max(0.f, Math.min(1.f, offsetY)); mWallpaperOffset.set(offsetX, offsetY); if (isPreferenceLWPScrollPages()) { mTBLauncherActivity.widgetManager.scroll(offsetX, offsetY); } if (isPreferenceWPDragAnimate()) { android.os.IBinder iBinder = getWindowToken(); if (iBinder != null) { mWallpaperManager.setWallpaperOffsets(iBinder, offsetX, offsetY); } } } private void sendTouchEvent(int x, int y, int index) { android.os.IBinder iBinder = getWindowToken(); if (iBinder != null) { String command = index == 0 ? WallpaperManager.COMMAND_TAP : WallpaperManager.COMMAND_SECONDARY_TAP; try { mWallpaperManager.sendWallpaperCommand(iBinder, command, x, y, 0, null); } catch (Exception e) { Log.e(TAG, "sendTouchEvent (" + x + "," + y + ") idx=" + index, e); } } } private void sendTouchEvent(View view, MotionEvent event) { int pointerCount = event.getPointerCount(); int[] viewOffset = {0, 0}; // this will not account for a rotated view view.getLocationOnScreen(viewOffset); // get index of first finger int pointerIndex = event.findPointerIndex(0); if (pointerIndex >= 0 && pointerIndex < pointerCount) { sendTouchEvent((int) event.getX(pointerIndex) + viewOffset[0], (int) event.getY(pointerIndex) + viewOffset[1], pointerIndex); } // get index of second finger pointerIndex = event.findPointerIndex(1); if (pointerIndex >= 0 && pointerIndex < pointerCount) { sendTouchEvent((int) event.getX(pointerIndex) + viewOffset[0], (int) event.getY(pointerIndex) + viewOffset[1], pointerIndex); } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/MimeTypeCache.java ================================================ package rocks.tbog.tblauncher; import android.accounts.AccountManager; import android.accounts.AuthenticatorDescription; import android.annotation.SuppressLint; import android.content.ComponentName; import android.content.ContentResolver; import android.content.Context; import android.content.Intent; import android.content.SyncAdapterType; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.content.pm.ServiceInfo; import android.content.res.XmlResourceParser; import android.provider.ContactsContract; import android.util.Log; import androidx.annotation.NonNull; import androidx.collection.ArraySet; import org.xmlpull.v1.XmlPullParser; import org.xmlpull.v1.XmlPullParserException; import java.io.IOException; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import rocks.tbog.tblauncher.utils.MimeTypeUtils; import rocks.tbog.tblauncher.utils.PackageManagerUtils; import rocks.tbog.tblauncher.utils.Timer; import rocks.tbog.tblauncher.utils.Utilities; public class MimeTypeCache { private static final String CONTACTS_DATA_KIND = "ContactsDataKind"; private static final String CONTACT_ATTR_MIME_TYPE = "mimeType"; private static final String CONTACT_ATTR_DETAIL_COLUMN = "detailColumn"; private static final String[] METADATA_CONTACTS_NAMES = new String[]{ "android.provider.ALTERNATE_CONTACTS_STRUCTURE", "android.provider.CONTACTS_STRUCTURE" }; private static final String TAG = "MTCache"; // Cached componentName private final Map componentNames = new HashMap<>(); // Cached label private final Map labels = new HashMap<>(); // Cached detail columns private Map mDetailColumnsCache = null; public synchronized void clearCache() { this.componentNames.clear(); this.labels.clear(); this.mDetailColumnsCache = null; } /** * @param context so we can get the label * @param mimeType to look for * @return label for best matching app by mimetype */ public String getLabel(Context context, String mimeType) { if (labels.containsKey(mimeType)) { return labels.get(mimeType); } final Intent intent = MimeTypeUtils.getIntentByMimeType(mimeType, -1, ""); String label = PackageManagerUtils.getLabel(context, intent); labels.put(mimeType, label); return label; } public ComponentName getComponentName(Context context, String mimeType) { if (componentNames.containsKey(mimeType)) { return componentNames.get(mimeType); } final Intent intent = MimeTypeUtils.getIntentByMimeType(mimeType, -1, ""); ComponentName componentName = PackageManagerUtils.getComponentName(context, intent); this.componentNames.put(mimeType, componentName); return componentName; } /** * @param context to get the Account system service and PackageManager * @return all mime types and related data columns from contact sync adapters */ public Map fetchDetailColumns(Context context) { synchronized (this) { if (mDetailColumnsCache != null) return mDetailColumnsCache; } Timer timer = Timer.startNano(); HashMap detailColumns = new HashMap<>(); // add data columns for known mime types detailColumns.put(ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE, ContactsContract.CommonDataKinds.Email.ADDRESS); detailColumns.put(ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE, ContactsContract.CommonDataKinds.Phone.NUMBER); final Set contactSyncableTypes = new HashSet<>(); SyncAdapterType[] syncAdapterTypes = ContentResolver.getSyncAdapterTypes(); for (SyncAdapterType type : syncAdapterTypes) { if (type.authority.equals(ContactsContract.AUTHORITY)) { contactSyncableTypes.add(type.accountType); } } AuthenticatorDescription[] authenticatorDescriptions = ((AccountManager) context.getSystemService(Context.ACCOUNT_SERVICE)).getAuthenticatorTypes(); for (AuthenticatorDescription auth : authenticatorDescriptions) { if (contactSyncableTypes.contains(auth.type)) { XmlResourceParser parser = loadContactsXml(context, auth.packageName); if (parser != null) { try { while (parser.next() != XmlPullParser.END_DOCUMENT) { if (CONTACTS_DATA_KIND.equals(parser.getName())) { String foundMimeType = null; String foundDetailColumn = null; int attributeCount = parser.getAttributeCount(); for (int i = 0; i < attributeCount; i++) { String attr = parser.getAttributeName(i); String value = parser.getAttributeValue(i); if (CONTACT_ATTR_MIME_TYPE.equals(attr)) { foundMimeType = value; } else if (CONTACT_ATTR_DETAIL_COLUMN.equals(attr)) { foundDetailColumn = value; } } if (foundMimeType != null) { detailColumns.put(foundMimeType, foundDetailColumn); } } } } catch (IOException | XmlPullParserException e) { Log.w(TAG, "type=" + auth.type + " package=" + auth.packageName, e); } } } } Log.i("time", timer + " to fetch detail data columns"); synchronized (this) { return mDetailColumnsCache = detailColumns; } } /** * Loads contact description from other sync providers, search for ContactsAccountType or ContactsSource * detailed description can be found here https://developer.android.com/guide/topics/providers/contacts-provider * * @param context to get the PackageManager * @param packageName AuthenticatorDescription.packageName * @return XmlResourceParser for contacts.xml, null if nothing found */ @SuppressLint("WrongConstant") public XmlResourceParser loadContactsXml(Context context, String packageName) { final PackageManager pm = context.getPackageManager(); final Intent intent = new Intent("android.content.SyncAdapter").setPackage(packageName); final List intentServices = pm.queryIntentServices(intent, PackageManager.GET_META_DATA | PackageManager.GET_SERVICES); if (intentServices != null) { for (final ResolveInfo resolveInfo : intentServices) { final ServiceInfo serviceInfo = resolveInfo.serviceInfo; if (serviceInfo == null) { continue; } for (String metadataName : METADATA_CONTACTS_NAMES) { final XmlResourceParser parser = serviceInfo.loadXmlMetaData( pm, metadataName); if (parser != null) { return parser; } } } } return null; } /** * @param context * @param mimeType * @return related detail data column for mime type */ public String getDetailColumn(Context context, String mimeType) { Map detailColumns = fetchDetailColumns(context); return detailColumns.get(mimeType); } private static String greatestCommonPrefix(@NonNull String a, @NonNull String b) { int minLength = Math.min(a.length(), b.length()); for (int i = 0; i < minLength; i++) { if (a.charAt(i) != b.charAt(i)) { return a.substring(0, i); } } return a.substring(0, minLength); } /** * Generates unique labels for given mime types, appends mimeType itself if an app supports multiple mime types * * @param context * @param mimeTypes * @return labels for given mime types */ public Map getUniqueLabels(Context context, Set mimeTypes) { Map uniqueLabels = new HashMap<>(mimeTypes.size()); // get labels for mime types Map> mappedMimeTypes = new HashMap<>(); for (String mimeType : mimeTypes) { String label = getLabel(context, mimeType); Set mimeTypesPerLabel = mappedMimeTypes.get(label); if (mimeTypesPerLabel == null) { mimeTypesPerLabel = new ArraySet<>(); mappedMimeTypes.put(label, mimeTypesPerLabel); } mimeTypesPerLabel.add(mimeType); } int layoutDirection = context.getResources().getConfiguration().getLayoutDirection(); // check supported mime types and make labels unique for (String mimeType : mimeTypes) { String label = getLabel(context, mimeType); Set mimeTypesPerLabel = mappedMimeTypes.get(label); if (mimeTypesPerLabel != null && mimeTypesPerLabel.size() > 1) { String prefix = null; for (String labelMimeType : mimeTypesPerLabel) { if (labelMimeType != null) { if (prefix == null) { prefix = labelMimeType; } else { prefix = greatestCommonPrefix(prefix, labelMimeType); } } } if (prefix != null) { // assume dot separated words int pos = prefix.lastIndexOf('.'); if (pos == -1) { // no dot found, remove whole prefix pos = prefix.length(); } else { // remove words before the dot pos += 1; } label = Utilities.appendString(label, " ", "(" + mimeType.substring(pos) + ")", layoutDirection); } else { // no prefix !? label = Utilities.appendString(label, " ", "(" + MimeTypeUtils.getShortMimeType(mimeType) + ")", layoutDirection); } } uniqueLabels.put(mimeType, label); } return uniqueLabels; } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/Permission.java ================================================ package rocks.tbog.tblauncher; import android.Manifest; import android.app.Activity; import android.content.Context; import android.content.pm.PackageManager; import android.os.Build; import androidx.annotation.NonNull; import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.ListIterator; public class Permission { public static final int PERMISSION_READ_CONTACTS = 0; public static final int PERMISSION_CALL_PHONE = 1; public static final int PERMISSION_READ_PHONE_STATE = 2; private static final String[] permissions = { Manifest.permission.READ_CONTACTS, Manifest.permission.CALL_PHONE, Manifest.permission.READ_PHONE_STATE, }; // Static weak reference to the linked activity, this is sadly required // to ensure classes requesting permission can access activity.requestPermission() private static WeakReference currentActivity = new WeakReference<>(null); private static ArrayList permissionListeners = null; public static boolean checkPermission(Context context, int permission) { return Build.VERSION.SDK_INT < Build.VERSION_CODES.M || context.checkSelfPermission(permissions[permission]) == PackageManager.PERMISSION_GRANTED; } public static void askPermission(int permission, PermissionResultListener listener) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { return; } if (listener != null) { listener.permission = permission; if (permissionListeners == null) { permissionListeners = new ArrayList<>(); } permissionListeners.add(listener); } Activity activity = Permission.currentActivity.get(); if (activity != null) { activity.requestPermissions(new String[]{permissions[permission]}, permission); } } public Permission(Activity activity) { // Store the latest reference to a MainActivity currentActivity = new WeakReference<>(activity); } public void onRequestPermissionsResult(int requestCode, @NonNull String permissions[], @NonNull int[] grantResults) { if (grantResults.length == 0) { return; } if (permissionListeners != null) { // Iterator allows to remove while iterating ListIterator it = permissionListeners.listIterator(); PermissionResultListener permissionListener; while (it.hasNext()) { permissionListener = it.next(); if (permissionListener.permission == requestCode) { if (grantResults[0] == PackageManager.PERMISSION_GRANTED) { permissionListener.onGranted(); } else { permissionListener.onDenied(); } it.remove(); } } } } public static class PermissionResultListener { public int permission = 0; public void onGranted() { } public void onDenied() { } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/PermissionsManager.java ================================================ package rocks.tbog.tblauncher; import androidx.annotation.NonNull; import androidx.appcompat.app.AppCompatActivity; public interface PermissionsManager { enum PermissionGroup { Calendar, Location, Contacts, ExternalStorage, Notifications, AppShortcuts, } void requestPermission(AppCompatActivity context, PermissionGroup permissionGroup); /** * Check if this permission is granted right now without receiving further updates * about the granted state. * @return true if the given permission group is fully granted */ Boolean checkPermissionOnce(PermissionGroup permissionGroup); void onRequestPermissionsResult( int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults ); void onResume(); Boolean hasPermission(PermissionGroup permissionGroup); /** * Special function for the Notification listener to report its status. * May not be called by anything else. */ void reportNotificationListenerState(Boolean running); } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/PinShortcutConfirm.java ================================================ /* * Copyright (C) 2016 The Android Open Source Project * * 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 rocks.tbog.tblauncher; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.LauncherApps; import android.content.pm.ShortcutInfo; import android.graphics.drawable.Drawable; import android.os.Build; import android.os.Bundle; import android.text.Html; import android.util.Log; import android.view.View; import android.view.View.OnClickListener; import android.view.Window; import android.view.WindowManager; import android.widget.EditText; import android.widget.ImageView; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.RequiresApi; import androidx.appcompat.app.AppCompatActivity; import androidx.preference.PreferenceManager; import rocks.tbog.tblauncher.db.ShortcutRecord; import rocks.tbog.tblauncher.entry.EntryItem; import rocks.tbog.tblauncher.entry.ShortcutEntry; import rocks.tbog.tblauncher.shortcut.ShortcutUtil; import rocks.tbog.tblauncher.utils.DebugInfo; import rocks.tbog.tblauncher.utils.Utilities; @RequiresApi(api = Build.VERSION_CODES.O) public class PinShortcutConfirm extends AppCompatActivity implements OnClickListener { private static final String TAG = "ShortcutConfirm"; protected LauncherApps mLauncherApps; private EditText mShortcutName; private LauncherApps.PinItemRequest mRequest; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); requestWindowFeature(Window.FEATURE_NO_TITLE); Window window = getWindow(); if (window != null) { window.setDimAmount(0.7f); window.addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND); } setContentView(R.layout.pin_shortcut_confirm); mLauncherApps = getSystemService(LauncherApps.class); mRequest = mLauncherApps.getPinItemRequest(getIntent()); final ShortcutInfo shortcutInfo = mRequest.getShortcutInfo(); if (shortcutInfo == null) { Log.e(TAG, "No shortcut info provided"); finish(); return; } if (prefs.getBoolean("pin-auto-confirm", false)) { acceptShortcut(); finish(); return; } initViews(shortcutInfo); } private void initViews(@NonNull ShortcutInfo shortcutInfo) { // OK button { TextView button1 = findViewById(android.R.id.button1); button1.setOnClickListener(this); button1.setText(android.R.string.ok); } // Cancel button { TextView button2 = findViewById(android.R.id.button2); button2.setOnClickListener(this); button2.setText(android.R.string.cancel); } // Other button { TextView button3 = findViewById(android.R.id.button3); button3.setVisibility(View.GONE); View spacer = findViewById(R.id.spacer); if (spacer != null) spacer.setVisibility(View.GONE); } // Label { mShortcutName = findViewById(R.id.shortcutName); String packageName = packageNameHeuristic(this, shortcutInfo); String appName = ShortcutUtil.getAppNameFromPackageName(this, packageName); CharSequence label = shortcutInfo.getLongLabel(); if (label == null) label = shortcutInfo.getShortLabel(); if (label == null) label = shortcutInfo.getPackage(); if (!appName.isEmpty()) mShortcutName.setText(getString(R.string.shortcut_with_appName, appName, label)); else mShortcutName.setText(label); } // Description if (DebugInfo.widgetAdd(this)) { TextView description = findViewById(R.id.shortcutDetails); ComponentName activity = shortcutInfo.getActivity(); String htmlString = String.format( "

Shortcut details:

" + "Long label: %s
" + "Short label: %s
" + "Activity: %s
" + "Publisher: %s
" + "ID: %s", shortcutInfo.getLongLabel(), shortcutInfo.getShortLabel(), activity != null ? activity.flattenToShortString() : null, shortcutInfo.getPackage(), // publisher app package shortcutInfo.getId() ); description.setText(Html.fromHtml(htmlString, Html.FROM_HTML_MODE_COMPACT)); description.setVisibility(View.VISIBLE); } else { findViewById(R.id.shortcutDetails).setVisibility(View.GONE); } { View view = findViewById(R.id.image); TextView nameView = view.findViewById(android.R.id.text1); nameView.setVisibility(View.GONE); ImageView icon1 = view.findViewById(android.R.id.icon1); setIconsAsync(icon1, shortcutInfo, (ctx) -> mLauncherApps.getShortcutIconDrawable(shortcutInfo, 0)); } { View view = findViewById(R.id.imageWithBadge); TextView nameView = view.findViewById(android.R.id.text1); nameView.setVisibility(View.GONE); ImageView icon1 = view.findViewById(android.R.id.icon1); setIconsAsync(icon1, shortcutInfo, (ctx) -> mLauncherApps.getShortcutBadgedIconDrawable(shortcutInfo, 0)); } } private static void setIconsAsync(ImageView icon, ShortcutInfo shortcutInfo, Utilities.GetDrawable getIcon) { new Utilities.AsyncSetDrawable(icon) { Drawable appDrawable; @Override protected Drawable getDrawable(Context context) { appDrawable = ShortcutEntry.getAppDrawable(context, shortcutInfo.getId(), shortcutInfo.getPackage(), shortcutInfo, true); return getIcon.getDrawable(context); } @Override protected void onPostExecute(Drawable drawable) { ImageView icon1 = (ImageView) weakView.get(); super.onPostExecute(drawable); if (icon1 != null) { int drawFlags = EntryItem.FLAG_DRAW_ICON | EntryItem.FLAG_DRAW_ICON_BADGE; ShortcutEntry.setIcons(drawFlags, icon1, drawable, appDrawable); } } }.execute(); } @NonNull public static String packageNameHeuristic(@NonNull Context context, @NonNull ShortcutInfo shortcutInfo) { Intent intent = shortcutInfo.getIntent(); ComponentName activity = intent != null ? intent.getComponent() : null; String packageName; if (activity == null) { // try to parse the ID to get the package name String id = shortcutInfo.getId(); int schemePos = id.indexOf("://"); int startPos = schemePos >= 0 ? schemePos + 3 : 0; int endPos = id.indexOf("/", startPos); if (endPos == -1) endPos = id.indexOf("#", startPos); if (endPos == -1) endPos = id.length(); packageName = id.substring(startPos, endPos); String appName = ShortcutUtil.getAppNameFromPackageName(context, packageName); if (appName.isEmpty()) packageName = null; if (packageName == null) { if (shortcutInfo.getActivity() != null) packageName = shortcutInfo.getActivity().getPackageName(); else packageName = shortcutInfo.getPackage(); } } else { packageName = activity.getPackageName(); } return packageName; } @Override public void onClick(View v) { switch (v.getId()) { case android.R.id.button1: acceptShortcut(); finish(); break; case android.R.id.button2: finish(); break; } } private void acceptShortcut() { final ShortcutInfo shortcutInfo = mRequest.getShortcutInfo(); if (shortcutInfo == null) { Log.e(TAG, "shortcut info is null"); return; } final boolean result = mRequest.accept(); Log.i(TAG, "Accept returned: " + result); ShortcutRecord record = ShortcutUtil.createShortcutRecord(this, shortcutInfo, false); if (record != null) { if (mShortcutName.getText().length() > 0) record.displayName = mShortcutName.getText().toString(); TBApplication.getApplication(this).getDataHandler().addShortcut(record); } mRequest = null; } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/SettingsActivity.java ================================================ package rocks.tbog.tblauncher; import android.annotation.SuppressLint; import android.app.Activity; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.graphics.PorterDuff; import android.graphics.PorterDuffColorFilter; import android.graphics.drawable.Drawable; import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.text.Spannable; import android.text.SpannableString; import android.text.Spanned; import android.text.style.ForegroundColorSpan; import android.util.Log; import android.util.Pair; import android.view.Menu; import android.view.MenuItem; import android.view.View; import android.widget.Toast; import androidx.annotation.LayoutRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.content.res.AppCompatResources; import androidx.collection.ArraySet; import androidx.core.graphics.drawable.DrawableCompat; import androidx.fragment.app.DialogFragment; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentTransaction; import androidx.preference.ListPreference; import androidx.preference.MultiSelectListPreference; import androidx.preference.Preference; import androidx.preference.PreferenceFragmentCompat; import androidx.preference.PreferenceGroup; import androidx.preference.PreferenceManager; import androidx.preference.PreferenceScreen; import androidx.preference.SwitchPreference; import java.io.File; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Set; import rocks.tbog.tblauncher.dataprovider.ShortcutsProvider; import rocks.tbog.tblauncher.dataprovider.TagsProvider; import rocks.tbog.tblauncher.db.ExportedData; import rocks.tbog.tblauncher.db.XmlImport; import rocks.tbog.tblauncher.drawable.SizeWrappedDrawable; import rocks.tbog.tblauncher.entry.AppEntry; import rocks.tbog.tblauncher.entry.EntryItem; import rocks.tbog.tblauncher.entry.StaticEntry; import rocks.tbog.tblauncher.entry.TagEntry; import rocks.tbog.tblauncher.handler.IconsHandler; import rocks.tbog.tblauncher.preference.BaseListPreferenceDialog; import rocks.tbog.tblauncher.preference.BaseMultiSelectListPreferenceDialog; import rocks.tbog.tblauncher.preference.ConfirmDialog; import rocks.tbog.tblauncher.preference.ContentLoadHelper; import rocks.tbog.tblauncher.preference.CustomDialogPreference; import rocks.tbog.tblauncher.preference.EditSearchEnginesPreferenceDialog; import rocks.tbog.tblauncher.preference.EditSearchHintPreferenceDialog; import rocks.tbog.tblauncher.preference.IconListPreferenceDialog; import rocks.tbog.tblauncher.preference.MarginDialog; import rocks.tbog.tblauncher.preference.OrderListPreferenceDialog; import rocks.tbog.tblauncher.preference.PreferenceColorDialog; import rocks.tbog.tblauncher.preference.QuickListPreferenceDialog; import rocks.tbog.tblauncher.preference.ShadowDialog; import rocks.tbog.tblauncher.preference.SliderDialog; import rocks.tbog.tblauncher.preference.TagOrderListPreferenceDialog; import rocks.tbog.tblauncher.ui.dialog.PleaseWaitDialog; import rocks.tbog.tblauncher.utils.FileUtils; import rocks.tbog.tblauncher.utils.MimeTypeUtils; import rocks.tbog.tblauncher.utils.PrefCache; import rocks.tbog.tblauncher.utils.PrefOrderedListHelper; import rocks.tbog.tblauncher.utils.SystemUiVisibility; import rocks.tbog.tblauncher.utils.UIColors; import rocks.tbog.tblauncher.utils.UISizes; import rocks.tbog.tblauncher.utils.UITheme; import rocks.tbog.tblauncher.utils.Utilities; public class SettingsActivity extends AppCompatActivity implements PreferenceFragmentCompat.OnPreferenceStartScreenCallback/*, PreferenceFragmentCompat.OnPreferenceStartFragmentCallback*/ { private final static String INTENT_EXTRA_BACK_STACK_TAGS = "backStackTagList"; private final static ArraySet PREF_THAT_REQUIRE_LAYOUT_UPDATE = new ArraySet<>(Arrays.asList( "result-list-argb", "result-ripple-color", "result-list-radius", "result-list-row-height", "notification-bar-argb", "notification-bar-gradient", "black-notification-icons", "navigation-bar-argb", "search-bar-height", "search-bar-text-size", "search-bar-radius", "search-bar-gradient", "search-bar-at-bottom", "search-bar-argb", "search-bar-text-color", "search-bar-icon-color", "search-bar-ripple-color", "search-bar-cursor-argb", "enable-suggestions-keyboard", "lock-portrait", "sensor-orientation", "search-bar-layout", "quick-list-position" )); private final static ArraySet PREF_LISTS_WITH_DEPENDENCY = new ArraySet<>(Arrays.asList( "gesture-click", "gesture-double-click", "gesture-fling-down-left", "gesture-fling-down-right", "gesture-fling-up", "gesture-fling-left", "gesture-fling-right", "button-launcher", "button-home", "dm-empty-back", "dm-search-back", "dm-widget-back", "dm-search-open-result" )); private static final int FILE_SELECT_XML_SET = 63; private static final int FILE_SELECT_XML_OVERWRITE = 62; private static final int FILE_SELECT_XML_APPEND = 61; public static final int ENABLE_DEVICE_ADMIN = 60; private static final String TAG = "SettAct"; @Override protected void onCreate(@Nullable Bundle savedInstanceState) { int theme = UITheme.getSettingsTheme(this); if (theme != UITheme.ID_NULL) setTheme(theme); super.onCreate(savedInstanceState); setContentView(R.layout.activity_settings); if (savedInstanceState == null) { // Create the fragment only when the activity is created for the first time. // ie. not after orientation changes Fragment fragment = getSupportFragmentManager().findFragmentByTag(SettingsFragment.FRAGMENT_TAG); if (fragment == null) { fragment = new SettingsFragment(); } getSupportFragmentManager() .beginTransaction() .replace(R.id.settings_container, fragment, SettingsFragment.FRAGMENT_TAG) .commit(); restoreBackStack(); } ActionBar actionBar = getSupportActionBar(); if (actionBar != null) { actionBar.setDisplayHomeAsUpEnabled(true); } } private void restoreBackStack() { Intent intent = getIntent(); if (intent == null) return; ArrayList backStackEntryList = intent.getStringArrayListExtra(INTENT_EXTRA_BACK_STACK_TAGS); if (backStackEntryList != null) for (String key : backStackEntryList) if (key != null) addToBackStack(key); } private void addToBackStack(@NonNull String key) { FragmentTransaction ft = getSupportFragmentManager().beginTransaction(); SettingsFragment fragment = new SettingsFragment(); Bundle args = new Bundle(); args.putString(PreferenceFragmentCompat.ARG_PREFERENCE_ROOT, key); fragment.setArguments(args); ft.replace(R.id.settings_container, fragment, key); ft.addToBackStack(key); ft.commit(); } @Override public boolean onCreateOptionsMenu(Menu menu) { String[] themeNames = getResources().getStringArray(R.array.settingsThemeEntries); for (String name : themeNames) menu.add(name); return true; } @SuppressLint("ApplySharedPref") @Override public boolean onOptionsItemSelected(MenuItem item) { if (item.getTitle() != null) { String itemName = item.getTitle().toString(); String[] themeNames = getResources().getStringArray(R.array.settingsThemeEntries); String[] themeValues = getResources().getStringArray(R.array.settingsThemeValues); for (int themeIdx = 0; themeIdx < themeNames.length; themeIdx++) { String name = themeNames[themeIdx]; if (itemName.equals(name)) { SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this); sharedPreferences.edit().putString("settings-theme", themeValues[themeIdx]).commit(); restart(); return true; } } } return super.onOptionsItemSelected(item); } private void restart() { // save backstack FragmentManager fm = getSupportFragmentManager(); int backStackEntryCount = fm.getBackStackEntryCount(); ArrayList backStackTags = null; if (backStackEntryCount > 0) { backStackTags = new ArrayList<>(backStackEntryCount); for (int idx = 0; idx < backStackEntryCount; idx += 1) { FragmentManager.BackStackEntry entry = fm.getBackStackEntryAt(idx); String tag = entry.getName(); backStackTags.add(tag); } } // close current activity finish(); // start new activity Intent activityIntent = new Intent(this, getClass()); if (backStackTags != null) { // remember the back stack pages so we can restore them activityIntent.putStringArrayListExtra(INTENT_EXTRA_BACK_STACK_TAGS, backStackTags); } startActivity(activityIntent); // set transition animation overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out); } @Override protected void onTitleChanged(CharSequence title, int color) { super.onTitleChanged(title, color); ActionBar actionBar = getSupportActionBar(); if (actionBar != null) { if (color != 0 && !(title instanceof Spannable)) { SpannableString ss = new SpannableString(title); ss.setSpan(new ForegroundColorSpan(color), 0, title.length(), Spanned.SPAN_INCLUSIVE_EXCLUSIVE); actionBar.setTitle(ss); } else { actionBar.setTitle(title); } } } @Override public boolean onSupportNavigateUp() { if (getSupportFragmentManager().popBackStackImmediate()) { final int count = getSupportFragmentManager().getBackStackEntryCount(); CharSequence title = null; if (count > 0) { String tag = getSupportFragmentManager().getBackStackEntryAt(count - 1).getName(); if (tag != null) { Fragment fragment = getSupportFragmentManager().findFragmentByTag(SettingsFragment.FRAGMENT_TAG); if (fragment instanceof SettingsFragment) { Preference preference = ((SettingsFragment) fragment).findPreference(tag); if (preference != null) title = preference.getTitle(); } } } if (title != null) setTitle(title); else setTitle(R.string.menu_popup_launcher_settings); return true; } return super.onSupportNavigateUp(); } @Override public boolean onPreferenceStartScreen(PreferenceFragmentCompat caller, PreferenceScreen preferenceScreen) { final String key = preferenceScreen.getKey(); addToBackStack(key); return true; } @Override protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { super.onActivityResult(requestCode, resultCode, data); Log.d(TAG, "onActivityResult request=" + requestCode + " result=" + resultCode); if (requestCode == ENABLE_DEVICE_ADMIN) { if (resultCode != RESULT_OK) { Toast.makeText(this, "Failed!", Toast.LENGTH_SHORT).show(); } } else if (resultCode == RESULT_OK) { ExportedData.Method method = null; switch (requestCode) { case FILE_SELECT_XML_APPEND: method = ExportedData.Method.APPEND; break; case FILE_SELECT_XML_OVERWRITE: method = ExportedData.Method.OVERWRITE; break; case FILE_SELECT_XML_SET: method = ExportedData.Method.SET; break; } if (method != null) { Uri uri = data != null ? data.getData() : null; File importedFile = FileUtils.copyFile(this, uri, "imported.xml"); if (importedFile != null) { PleaseWaitDialog dialog = new PleaseWaitDialog(); // set args { Bundle args = new Bundle(); //args.putString(PleaseWaitDialog.ARG_TITLE, getString(R.string.import_dialog_title)); args.putString(PleaseWaitDialog.ARG_DESCRIPTION, getString(R.string.import_dialog_description)); dialog.setArguments(args); } final ExportedData.Method importMethod = method; dialog.setWork(() -> { Activity activity = Utilities.getActivity(dialog.getContext()); if (activity != null) { if (!XmlImport.settingsXml(activity, importedFile, importMethod)) { Toast.makeText(activity, R.string.error_fail_import, Toast.LENGTH_LONG).show(); dialog.dismiss(); } } dialog.onWorkFinished(); }); dialog.show(getSupportFragmentManager(), "load_imported"); } else { Toast.makeText(this, R.string.error_fail_import, Toast.LENGTH_LONG).show(); } } } } public static class SettingsFragment extends PreferenceFragmentCompat implements SharedPreferences.OnSharedPreferenceChangeListener { private static final String FRAGMENT_TAG = SettingsFragment.class.getName(); private static final String DIALOG_FRAGMENT_TAG = "androidx.preference.PreferenceFragment.DIALOG"; private static final String TAG = "Settings"; private static Pair AppToRunListContent = null; private static Pair ShortcutToRunListContent = null; private static Pair EntryToShowListContent = null; private static ContentLoadHelper.OrderedMultiSelectListData TagsMenuContent = null; private static ContentLoadHelper.OrderedMultiSelectListData ResultPopupContent = null; private static Pair MimeTypeListContent = null; public SettingsFragment() { super(); } @Override public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { if (rootKey != null && rootKey.startsWith("feature-")) setPreferencesFromResource(R.xml.preference_features, rootKey); else setPreferencesFromResource(R.xml.preferences, rootKey); if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { removePreference("black-notification-icons"); } if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { removePreference("pin-auto-confirm"); } if (!BuildConfig.SHOW_RATE_APP) { removePreference("rate-app"); } if (!BuildConfig.SHOW_PRIVACY_POLICY) { removePreference("privacy-policy"); } if (!BuildConfig.DEBUG) { removePreference("crash-app"); } // set app name and version { Preference appVer = findPreference("app-version"); if (appVer != null) { var version = appVer.getContext().getString(R.string.app_version, BuildConfig.VERSION_NAME); var appName = appVer.getContext().getText(R.string.app_name); String appStore; switch (BuildConfig.FLAVOR) { case "playstore": appStore = "Google Play"; break; case "fdroid": appStore = "F-Droid"; break; case "github": appStore = "GitHub"; break; default: throw new IllegalStateException("Undefined flavor"); } var summary = appVer.getContext().getString(R.string.app_version_summary, appName, appStore); appVer.setTitle(version); appVer.setSummary(summary); // add link to the launcher webpage if app not installed from a store if (!BuildConfig.SHOW_RATE_APP) { appVer.setEnabled(true); } } } final Activity activity = requireActivity(); // set activity title as the preference screen title activity.setTitle(getPreferenceScreen().getTitle()); ActionBar actionBar = ((SettingsActivity) activity).getSupportActionBar(); if (actionBar != null) { // we can change the theme from the options menu removePreference("settings-theme"); } setupButtonActions(activity); final Context context = requireContext(); tintPreferenceIcons(getPreferenceScreen(), UIColors.getThemeColor(context, com.google.android.material.R.attr.colorAccent)); SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context); // quick-list { Preference pref = findPreference("quick-list-enabled"); // if we don't have the toggle in this screen we need to apply dependency by hand if (pref == null) { // only show the category if we use the quick list Preference section = findPreference("quick-list-section"); if (section != null) section.setVisible(sharedPreferences.getBoolean("quick-list-enabled", true)); } } onCreateAsyncLoad(context, sharedPreferences, savedInstanceState); } private void setupButtonActions(@NonNull Activity activity) { // import settings { Preference pref = findPreference("import-settings-set"); if (pref != null) pref.setOnPreferenceClickListener(preference -> { FileUtils.chooseSettingsFile(activity, FILE_SELECT_XML_SET); return true; }); pref = findPreference("import-settings-overwrite"); if (pref != null) pref.setOnPreferenceClickListener(preference -> { FileUtils.chooseSettingsFile(activity, FILE_SELECT_XML_OVERWRITE); return true; }); pref = findPreference("import-settings-append"); if (pref != null) pref.setOnPreferenceClickListener(preference -> { FileUtils.chooseSettingsFile(activity, FILE_SELECT_XML_APPEND); return true; }); } } private void onCreateAsyncLoad(@NonNull Context context, @NonNull SharedPreferences sharedPreferences, @Nullable Bundle savedInstanceState) { if (savedInstanceState == null) { initAppToRunLists(context, sharedPreferences); initShortcutToRunLists(context, sharedPreferences); initEntryToShowLists(context, sharedPreferences); initTagsMenuList(context, sharedPreferences); initResultPopupList(context, sharedPreferences); initMimeTypes(context); } else { synchronized (SettingsFragment.class) { if (AppToRunListContent == null) AppToRunListContent = generateAppToRunListContent(context); if (ShortcutToRunListContent == null) ShortcutToRunListContent = generateShortcutToRunListContent(context); if (EntryToShowListContent == null) EntryToShowListContent = generateEntryToShowListContent(context); if (TagsMenuContent == null) TagsMenuContent = ContentLoadHelper.generateTagsMenuContent(context, sharedPreferences); if (ResultPopupContent == null) ResultPopupContent = ContentLoadHelper.generateResultPopupContent(context, sharedPreferences); if (MimeTypeListContent == null) MimeTypeListContent = generateMimeTypeListContent(context); for (String gesturePref : PREF_LISTS_WITH_DEPENDENCY) { updateAppToRunList(sharedPreferences, gesturePref); updateShortcutToRunList(sharedPreferences, gesturePref); updateEntryToShowList(sharedPreferences, gesturePref); } TagsMenuContent.setMultiListValues(findPreference("tags-menu-list")); TagsMenuContent.setOrderedListValues(findPreference("tags-menu-order")); ResultPopupContent.setOrderedListValues(findPreference("result-popup-order")); ContentLoadHelper.setMultiListValues(findPreference("selected-contact-mime-types"), MimeTypeListContent, null); } } final ListPreference iconsPack = findPreference("icons-pack"); if (iconsPack != null) { iconsPack.setEnabled(false); if (savedInstanceState == null) { // Run asynchronously to open settings fast Utilities.runAsync(getLifecycle(), t -> SettingsFragment.this.setListPreferenceIconsPacksData(iconsPack), t -> iconsPack.setEnabled(true)); } else { // Run synchronously to ensure preferences can be restored from state SettingsFragment.this.setListPreferenceIconsPacksData(iconsPack); iconsPack.setEnabled(true); } } } private void initAppToRunLists(@NonNull Context context, @NonNull SharedPreferences sharedPreferences) { final Runnable updateLists = () -> { for (String gesturePref : PREF_LISTS_WITH_DEPENDENCY) updateAppToRunList(sharedPreferences, gesturePref); }; if (AppToRunListContent == null) { Utilities.runAsync(getLifecycle(), t -> { Pair content = generateAppToRunListContent(context); synchronized (SettingsFragment.class) { if (AppToRunListContent == null) AppToRunListContent = content; } }, t -> updateLists.run()); } else { updateLists.run(); } } private void initShortcutToRunLists(@NonNull Context context, @NonNull SharedPreferences sharedPreferences) { final Runnable updateLists = () -> { for (String gesturePref : PREF_LISTS_WITH_DEPENDENCY) updateShortcutToRunList(sharedPreferences, gesturePref); }; if (ShortcutToRunListContent == null) { Utilities.runAsync(getLifecycle(), t -> { Pair content = generateShortcutToRunListContent(context); synchronized (SettingsFragment.this) { if (ShortcutToRunListContent == null) ShortcutToRunListContent = content; } }, t -> updateLists.run()); } else { updateLists.run(); } } private void initEntryToShowLists(@NonNull Context context, @NonNull SharedPreferences sharedPreferences) { final Runnable updateLists = () -> { for (String gesturePref : PREF_LISTS_WITH_DEPENDENCY) updateEntryToShowList(sharedPreferences, gesturePref); }; if (EntryToShowListContent == null) { Utilities.runAsync(getLifecycle(), t -> { Pair content = generateEntryToShowListContent(context); synchronized (SettingsFragment.class) { if (EntryToShowListContent == null) EntryToShowListContent = content; } }, t -> updateLists.run()); } else { updateLists.run(); } } private void initTagsMenuList(@NonNull Context context, @NonNull SharedPreferences sharedPreferences) { final Runnable setTagsMenuValues = () -> { synchronized (SettingsFragment.class) { if (TagsMenuContent != null) { TagsMenuContent.setMultiListValues(findPreference("tags-menu-list")); TagsMenuContent.setOrderedListValues(findPreference("tags-menu-order")); } } }; if (TagsMenuContent == null) { Utilities.runAsync(getLifecycle(), t -> { ContentLoadHelper.OrderedMultiSelectListData content = ContentLoadHelper.generateTagsMenuContent(context, sharedPreferences); synchronized (SettingsFragment.class) { if (TagsMenuContent == null) { TagsMenuContent = content; } } }, t -> setTagsMenuValues.run()); } else { setTagsMenuValues.run(); } } private void initResultPopupList(@NonNull Context context, @NonNull SharedPreferences sharedPreferences) { final Runnable setResultPopupValues = () -> { synchronized (SettingsFragment.class) { if (ResultPopupContent != null) ResultPopupContent.setOrderedListValues(findPreference("result-popup-order")); } }; if (ResultPopupContent == null) { Utilities.runAsync(getLifecycle(), t -> { ContentLoadHelper.OrderedMultiSelectListData content = ContentLoadHelper.generateResultPopupContent(context, sharedPreferences); synchronized (SettingsFragment.class) { if (ResultPopupContent == null) { ResultPopupContent = content; } } }, t -> setResultPopupValues.run()); } else { setResultPopupValues.run(); } } private void initMimeTypes(@NonNull Context context) { // get all supported mime types final Runnable setMimeTypeValues = () -> { synchronized (SettingsFragment.class) { if (MimeTypeListContent != null) ContentLoadHelper.setMultiListValues(findPreference("selected-contact-mime-types"), MimeTypeListContent, null); } }; if (MimeTypeListContent == null) { Utilities.runAsync(getLifecycle(), t -> { Pair content = generateMimeTypeListContent(context); synchronized (SettingsFragment.class) { if (MimeTypeListContent == null) MimeTypeListContent = content; } }, t -> setMimeTypeValues.run()); } else { setMimeTypeValues.run(); } } private void tintPreferenceIcons(Preference preference, int color) { Drawable icon = preference.getIcon(); if (icon != null) { // workaround to set drawable size { int size = UISizes.getResultIconSize(preference.getContext()); icon = new SizeWrappedDrawable(icon, size); } icon.setColorFilter(new PorterDuffColorFilter(color, PorterDuff.Mode.MULTIPLY)); preference.setIcon(icon); } if (preference instanceof PreferenceGroup) { PreferenceGroup group = ((PreferenceGroup) preference); for (int i = 0; i < group.getPreferenceCount(); i++) { tintPreferenceIcons(group.getPreference(i), color); } } } private void removePreference(String key) { Preference pref = findPreference(key); if (pref != null && pref.getParent() != null) pref.getParent().removePreference(pref); } private void setListPreferenceIconsPacksData(ListPreference lp) { Context context = getContext(); if (context == null) return; IconsHandler iph = TBApplication.getApplication(context).iconsHandler(); CharSequence[] entries = new CharSequence[iph.getIconPackNames().size() + 1]; CharSequence[] entryValues = new CharSequence[iph.getIconPackNames().size() + 1]; int i = 0; entries[0] = this.getString(R.string.icons_pack_default_name); entryValues[0] = "default"; for (String packageIconsPack : iph.getIconPackNames().keySet()) { entries[++i] = iph.getIconPackNames().get(packageIconsPack); entryValues[i] = packageIconsPack; } lp.setEntries(entries); lp.setDefaultValue("default"); lp.setEntryValues(entryValues); } @Override public void onResume() { super.onResume(); SharedPreferences sharedPreferences = getPreferenceScreen().getSharedPreferences(); sharedPreferences.registerOnSharedPreferenceChangeListener(this); applyNotificationBarColor(sharedPreferences, requireContext()); } @Override public void onPause() { super.onPause(); getPreferenceScreen().getSharedPreferences().unregisterOnSharedPreferenceChangeListener(this); } @Override public void onDestroy() { super.onDestroy(); synchronized (SettingsFragment.class) { AppToRunListContent = null; ShortcutToRunListContent = null; EntryToShowListContent = null; TagsMenuContent = null; ResultPopupContent = null; } } @Override public void onDisplayPreferenceDialog(@NonNull Preference preference) { String key = preference.getKey(); // Try if the preference is one of our custom Preferences DialogFragment dialogFragment; if (preference instanceof CustomDialogPreference) { // Create a new instance of CustomDialog with the key of the related Preference Log.d(TAG, "onDisplayPreferenceDialog " + key); switch (key) { case "quick-list-content": dialogFragment = QuickListPreferenceDialog.newInstance(key); break; case "reset-search-engines": case "edit-search-engines": case "add-search-engine": dialogFragment = EditSearchEnginesPreferenceDialog.newInstance(key); break; case "reset-search-hint": case "edit-search-hint": case "add-search-hint": dialogFragment = EditSearchHintPreferenceDialog.newInstance(key); break; default: dialogFragment = null; } if (dialogFragment == null) { @LayoutRes int dialogLayout = ((CustomDialogPreference) preference).getDialogLayoutResource(); if (dialogLayout == 0) { if (key.endsWith("-color") || key.endsWith("-argb")) dialogFragment = PreferenceColorDialog.newInstance(key); } else if (R.layout.pref_slider == dialogLayout) { dialogFragment = SliderDialog.newInstance(key); } else if (R.layout.pref_shadow == dialogLayout) { dialogFragment = ShadowDialog.newInstance(key); } else if (R.layout.pref_margin_offset == dialogLayout) { dialogFragment = MarginDialog.newInstance(key); } else if (R.layout.pref_confirm == dialogLayout) { dialogFragment = ConfirmDialog.newInstance(key); } } if (dialogFragment == null) throw new IllegalArgumentException("CustomDialogPreference \"" + key + "\" has no dialog defined"); } else if (preference instanceof ListPreference) { switch (key) { case "adaptive-shape": case "contacts-shape": case "shortcut-shape": case "icons-pack": dialogFragment = IconListPreferenceDialog.newInstance(key); break; default: dialogFragment = BaseListPreferenceDialog.newInstance(key); break; } } else if (preference instanceof MultiSelectListPreference) { if ("tags-menu-order".equals(key)) { dialogFragment = TagOrderListPreferenceDialog.newInstance(key); } else if ("result-popup-order".equals(key)) { dialogFragment = OrderListPreferenceDialog.newInstance(key); } else { dialogFragment = BaseMultiSelectListPreferenceDialog.newInstance(key); } } else { Log.i(TAG, "Preference \"" + key + "\" has no custom dialog defined"); dialogFragment = null; } // If it was one of our custom Preferences, show its dialog if (dialogFragment != null) { final FragmentManager fm = this.getParentFragmentManager(); // check if dialog is already showing if (fm.findFragmentByTag(DIALOG_FRAGMENT_TAG) != null) { return; } dialogFragment.setTargetFragment(this, 0); dialogFragment.show(fm, DIALOG_FRAGMENT_TAG); } // Could not be handled here. Try with the super method. else { super.onDisplayPreferenceDialog(preference); } } private static Pair generateAppToRunListContent(@NonNull Context context) { List appEntryList = TBApplication.appsHandler(context).getApplications(); Collections.sort(appEntryList, AppEntry.NAME_COMPARATOR); final int appCount = appEntryList.size(); CharSequence[] entries = new CharSequence[appCount]; CharSequence[] entryValues = new CharSequence[appCount]; for (int idx = 0; idx < appCount; idx++) { AppEntry appEntry = appEntryList.get(idx); entries[idx] = appEntry.getName(); entryValues[idx] = appEntry.getUserComponentName(); } return new Pair<>(entries, entryValues); } private static Pair generateShortcutToRunListContent(@NonNull Context context) { ShortcutsProvider shortcutsProvider = TBApplication.dataHandler(context).getShortcutsProvider(); List shortcutList = shortcutsProvider == null ? null : shortcutsProvider.getPojos(); if (shortcutList == null) return new Pair<>(new CharSequence[0], new CharSequence[0]); // copy list in order to sort it shortcutList = new ArrayList<>(shortcutList); Collections.sort(shortcutList, EntryItem.NAME_COMPARATOR); final int entryCount = shortcutList.size(); CharSequence[] entries = new CharSequence[entryCount]; CharSequence[] entryValues = new CharSequence[entryCount]; for (int idx = 0; idx < entryCount; idx++) { EntryItem shortcutEntry = shortcutList.get(idx); entries[idx] = shortcutEntry.getName(); entryValues[idx] = shortcutEntry.id; } return new Pair<>(entries, entryValues); } private static Pair generateEntryToShowListContent(@NonNull Context context) { final List tagList; final TBApplication app = TBApplication.getApplication(context); final TagsProvider tagsProvider = app.getDataHandler().getTagsProvider(); if (tagsProvider != null) { ArrayList tagNames = new ArrayList<>(app.tagsHandler().getValidTags()); Collections.sort(tagNames); tagList = new ArrayList<>(tagNames.size()); for (String tagName : tagNames) { TagEntry tagEntry = tagsProvider.getTagEntry(tagName); tagList.add(tagEntry); } } else { tagList = Collections.emptyList(); } final CharSequence[] entries; final CharSequence[] entryValues; if (tagList.isEmpty()) { entries = new CharSequence[]{context.getString(R.string.no_tags)}; entryValues = new CharSequence[]{""}; } else { return ContentLoadHelper.generateStaticEntryList(context, tagList); } return new Pair<>(entries, entryValues); } private static Pair generateMimeTypeListContent(@NonNull Context context) { Set supportedMimeTypes = MimeTypeUtils.getSupportedMimeTypes(context); Map labels = TBApplication.mimeTypeCache(context).getUniqueLabels(context, supportedMimeTypes); String[] mimeTypes = labels.keySet().toArray(new String[0]); Arrays.sort(mimeTypes); CharSequence[] mimeLabels = new CharSequence[mimeTypes.length]; for (int index = 0; index < mimeTypes.length; index += 1) { mimeLabels[index] = labels.get(mimeTypes[index]); } return new Pair<>(mimeTypes, mimeLabels); } private void updateListPrefDependency(@NonNull String dependOnKey, @Nullable String dependOnValue, @NonNull String enableValue, @NonNull String listKey, @Nullable Pair listContent) { Preference prefEntryToRun = findPreference(listKey); if (prefEntryToRun instanceof ListPreference) { synchronized (SettingsFragment.class) { if (listContent != null) { CharSequence[] entries = listContent.first; CharSequence[] entryValues = listContent.second; ((ListPreference) prefEntryToRun).setEntries(entries); ((ListPreference) prefEntryToRun).setEntryValues(entryValues); prefEntryToRun.setVisible(enableValue.equals(dependOnValue)); return; } } } if (prefEntryToRun == null) { // the ListPreference for selecting an app is missing. Remove the option to run an app. Preference pref = findPreference(dependOnKey); if (pref instanceof ListPreference) { removeEntryValueFromListPreference(enableValue, (ListPreference) pref); } } else { Log.w(TAG, "ListPreference `" + listKey + "` can't be updated"); prefEntryToRun.setVisible(false); } } private void updateAppToRunList(@NonNull SharedPreferences sharedPreferences, String key) { updateListPrefDependency(key, sharedPreferences.getString(key, null), "runApp", key + "-app-to-run", AppToRunListContent); } private void updateShortcutToRunList(@NonNull SharedPreferences sharedPreferences, String key) { updateListPrefDependency(key, sharedPreferences.getString(key, null), "runShortcut", key + "-shortcut-to-run", ShortcutToRunListContent); } private void updateEntryToShowList(@NonNull SharedPreferences sharedPreferences, String key) { updateListPrefDependency(key, sharedPreferences.getString(key, null), "showEntry", key + "-entry-to-show", EntryToShowListContent); } private static void removeEntryValueFromListPreference(@NonNull String entryValueToRemove, ListPreference listPref) { CharSequence[] entryValues = listPref.getEntryValues(); int indexToRemove = -1; for (int idx = 0, entryValuesLength = entryValues.length; idx < entryValuesLength; idx++) { CharSequence entryValue = entryValues[idx]; if (entryValueToRemove.contentEquals(entryValue)) { indexToRemove = idx; break; } } if (indexToRemove == -1) return; CharSequence[] entries = listPref.getEntries(); final int size = entries.length; final int newSize = size - 1; CharSequence[] newEntries = new CharSequence[newSize]; CharSequence[] newEntryValues = new CharSequence[newSize]; if (indexToRemove > 0) { System.arraycopy(entries, 0, newEntries, 0, indexToRemove); System.arraycopy(entryValues, 0, newEntryValues, 0, indexToRemove); } if (indexToRemove < newSize) { System.arraycopy(entries, indexToRemove + 1, newEntries, indexToRemove, newSize - indexToRemove); System.arraycopy(entryValues, indexToRemove + 1, newEntryValues, indexToRemove, newSize - indexToRemove); } listPref.setEntries(newEntries); listPref.setEntryValues(newEntryValues); } @Override public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { SettingsActivity activity = (SettingsActivity) getActivity(); if (activity == null || key == null) return; if (PREF_LISTS_WITH_DEPENDENCY.contains(key)) { updateAppToRunList(sharedPreferences, key); updateShortcutToRunList(sharedPreferences, key); updateEntryToShowList(sharedPreferences, key); } // rebind and relayout all visible views because I can't find how to rebind only the current view getListView().getAdapter().notifyDataSetChanged(); SettingsActivity.onSharedPreferenceChanged(activity, sharedPreferences, key); synchronized (SettingsFragment.class) { if (TagsMenuContent != null) { if ("tags-menu-list".equals(key) || "tags-menu-order".equals(key)) { TagsMenuContent.reloadOrderedValues(sharedPreferences, this, "tags-menu-order"); } else if ("result-popup-order".equals(key)) { ResultPopupContent.reloadOrderedValues(sharedPreferences, this, "result-popup-order"); } } } } } @SuppressWarnings("deprecation") private static void setActionBarTextColor(Activity activity, int color) { ActionBar actionBar = activity instanceof AppCompatActivity ? ((AppCompatActivity) activity).getSupportActionBar() : null; CharSequence title = actionBar != null ? actionBar.getTitle() : null; if (title == null) return; activity.setTitleColor(color); Drawable arrow = AppCompatResources.getDrawable(activity, R.drawable.ic_arrow_back); if (arrow != null) { arrow = DrawableCompat.wrap(arrow); DrawableCompat.setTint(arrow, color); actionBar.setHomeAsUpIndicator(arrow); } SpannableString text = new SpannableString(title); ForegroundColorSpan[] spansToRemove = text.getSpans(0, text.length(), ForegroundColorSpan.class); for (ForegroundColorSpan span : spansToRemove) { text.removeSpan(span); } text.setSpan(new ForegroundColorSpan(color), 0, text.length(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE); actionBar.setTitle(text); } private static void applyNotificationBarColor(@NonNull SharedPreferences sharedPreferences, @Nullable Context context) { int color = UIColors.getColor(sharedPreferences, "notification-bar-argb"); // keep the bars opaque to avoid white text on white background by mistake int alpha = 0xFF;//UIColors.getAlpha(sharedPreferences, "notification-bar-alpha"); Activity activity = Utilities.getActivity(context); if (activity instanceof SettingsActivity) UIColors.setStatusBarColor((SettingsActivity) activity, UIColors.setAlpha(color, alpha)); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { View view = activity != null ? activity.findViewById(android.R.id.content) : null; if (view == null && activity != null) view = activity.getWindow() != null ? activity.getWindow().getDecorView() : null; if (view != null) { if (sharedPreferences.getBoolean("black-notification-icons", false)) { SystemUiVisibility.setLightStatusBar(view); } else { SystemUiVisibility.clearLightStatusBar(view); } } } setActionBarTextColor(activity, UIColors.getTextContrastColor(color)); } private static void applyNavigationBarColor(@NonNull SharedPreferences sharedPreferences, @Nullable Context context) { int color = UIColors.getColor(sharedPreferences, "navigation-bar-argb"); Activity activity = Utilities.getActivity(context); if (activity instanceof SettingsActivity) UIColors.setNavigationBarColor((SettingsActivity) activity, color, color); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { View view = activity != null ? activity.findViewById(android.R.id.content) : null; if (view == null && activity != null) view = activity.getWindow() != null ? activity.getWindow().getDecorView() : null; if (view != null) { if (UIColors.isColorLight(color)) { SystemUiVisibility.setLightNavigationBar(view); } else { SystemUiVisibility.clearLightNavigationBar(view); } } } } public static void onSharedPreferenceChanged(Context context, SharedPreferences sharedPreferences, String key) { TBApplication app = TBApplication.getApplication(context); if (PREF_THAT_REQUIRE_LAYOUT_UPDATE.contains(key)) app.requireLayoutUpdate(); TBLauncherActivity activity = app.launcherActivity(); if (activity != null) activity.liveWallpaper.onPrefChanged(sharedPreferences, key); switch (key) { case "notification-bar-argb": case "black-notification-icons": applyNotificationBarColor(sharedPreferences, context); break; case "navigation-bar-argb": applyNavigationBarColor(sharedPreferences, context); break; case "icon-scale-red": case "icon-scale-green": case "icon-scale-blue": case "icon-scale-alpha": case "icon-hue": case "icon-contrast": case "icon-brightness": case "icon-saturation": case "icon-background-argb": case "matrix-contacts": case "icons-visible": TBApplication.drawableCache(context).clearCache(); if (activity != null) activity.refreshSearchRecords(); // fallthrough case "quick-list-argb": case "quick-list-ripple-color": // static entities will change color based on luminance // fallthrough case "quick-list-toggle-color": // toggle animation is also caching the color if (activity != null) activity.queueDockReload(); // fallthrough case "result-list-argb": case "result-ripple-color": case "result-highlight-color": case "result-text-color": case "result-text2-color": case "result-shadow-color": case "contact-action-color": if (activity != null) activity.refreshSearchRecords(); // fallthrough case "search-bar-text-color": case "search-bar-shadow-color": case "popup-background-argb": case "popup-border-argb": case "popup-ripple-color": case "popup-text-color": case "popup-title-color": case "popup-shadow-color": UIColors.resetCache(); break; case "quick-list-icon-size": if (activity != null) activity.queueDockReload(); // fallthrough case "result-text-size": case "result-text2-size": case "result-icon-size": case "result-shadow-radius": case "result-shadow-dx": case "result-shadow-dy": case "result-list-row-height": if (activity != null) activity.refreshSearchRecords(); // fallthrough case "tags-menu-icon-size": case "search-bar-shadow-dx": case "search-bar-shadow-dy": case "search-bar-shadow-radius": case "popup-corner-radius": case "popup-shadow-dx": case "popup-shadow-dy": case "popup-shadow-radius": UISizes.resetCache(); break; case "result-history-size": case "result-history-adaptive": case "fuzzy-search-tags": case "result-search-cap": case "tags-menu-icons": case "loading-icon": case "tags-menu-untagged": case "tags-menu-untagged-index": case "result-popup-order": PrefCache.resetCache(); break; case "adaptive-shape": case "force-adaptive": case "force-shape": case "icons-pack": case "contact-pack-mask": case "contacts-shape": case "shortcut-pack-mask": case "shortcut-shape": case "shortcut-pack-badge-mask": TBApplication.iconsHandler(context).onPrefChanged(sharedPreferences); TBApplication.drawableCache(context).clearCache(); if (activity != null) activity.queueDockReload(); break; case "tags-enabled": { boolean useTags = sharedPreferences.getBoolean("tags-enabled", true); Activity settingsActivity = Utilities.getActivity(context); Fragment fragment = null; if (settingsActivity instanceof SettingsActivity) fragment = ((SettingsActivity) settingsActivity).getSupportFragmentManager().findFragmentByTag(SettingsFragment.FRAGMENT_TAG); SwitchPreference preference = null; if (fragment instanceof SettingsFragment) preference = ((SettingsFragment) fragment).findPreference("fuzzy-search-tags"); if (preference != null) preference.setChecked(useTags); else sharedPreferences.edit().putBoolean("fuzzy-search-tags", useTags).apply(); break; } case "quick-list-enabled": case "quick-list-text-visible": case "quick-list-icons-visible": case "quick-list-show-badge": case "quick-list-columns": case "quick-list-rows": case "quick-list-rtl": if (activity != null) activity.queueDockReload(); break; case "cache-drawable": case "cache-half-apps": TBApplication.drawableCache(context).onPrefChanged(context, sharedPreferences); break; case "enable-search": case "enable-url": case "enable-calculator": case "enable-dial": case "enable-contacts": case "selected-contact-mime-types": case "shortcut-dynamic-in-results": TBApplication.dataHandler(context).reloadProviders(); break; case "root-mode": if (sharedPreferences.getBoolean("root-mode", false) && !TBApplication.rootHandler(context).isRootAvailable()) { //show error dialog new AlertDialog.Builder(context).setMessage(R.string.root_mode_error) .setPositiveButton(android.R.string.ok, (dialog, which) -> { sharedPreferences.edit().putBoolean("root-mode", false).apply(); }).show(); } TBApplication.rootHandler(context).resetRootHandler(sharedPreferences); break; case "tags-menu-list": PrefOrderedListHelper.syncOrderedList(sharedPreferences, "tags-menu-list", "tags-menu-order"); break; } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/TBApplication.java ================================================ package rocks.tbog.tblauncher; import android.app.Activity; import android.app.Application; import android.content.ComponentCallbacks2; import android.content.ComponentName; import android.content.Context; import android.content.ContextWrapper; import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.database.sqlite.SQLiteDatabase; import android.util.Log; import android.view.View; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.lifecycle.Lifecycle; import androidx.preference.PreferenceManager; import org.acra.ACRA; import org.acra.config.CoreConfigurationBuilder; import org.acra.config.DialogConfigurationBuilder; import org.acra.config.MailSenderConfigurationBuilder; import org.acra.data.StringFormat; import java.lang.ref.WeakReference; import java.util.ConcurrentModificationException; import java.util.Iterator; import java.util.LinkedList; import rocks.tbog.tblauncher.handler.AppsHandler; import rocks.tbog.tblauncher.handler.DataHandler; import rocks.tbog.tblauncher.handler.IconsHandler; import rocks.tbog.tblauncher.handler.TagsHandler; import rocks.tbog.tblauncher.icons.IconPackCache; import rocks.tbog.tblauncher.quicklist.QuickList; import rocks.tbog.tblauncher.searcher.Searcher; import rocks.tbog.tblauncher.ui.ListPopup; import rocks.tbog.tblauncher.utils.PrefCache; import rocks.tbog.tblauncher.utils.RootHandler; import rocks.tbog.tblauncher.utils.Utilities; import rocks.tbog.tblauncher.widgets.WidgetManager; public class TBApplication extends Application { private static final String TAG = "APP"; /** * The state of certain launcher features */ @NonNull private static final LauncherState mState = new LauncherState(); private DataHandler dataHandler = null; private IconsHandler iconsPackHandler = null; private TagsHandler tagsHandler = null; private AppsHandler appsHandler = null; private SharedPreferences mSharedPreferences = null; private ListPopup mPopup = null; /** * List of running launcher activities */ private final LinkedList> mActivities = new LinkedList<>(); /** * Task launched on text change */ private Searcher mSearchTask; /** * We store a number of drawables in memory for fast redraw */ private final DrawableCache mDrawableCache = new DrawableCache(); /** * We store a number of icon packs so we don't have to parse the XML */ private final IconPackCache mIconPackCache = new IconPackCache(); /** * We store a number of icon packs so we don't have to parse the XML */ private final MimeTypeCache mMimeTypeCache = new MimeTypeCache(); /** * Root handler - su */ private RootHandler mRootHandler = null; @Override protected void attachBaseContext(Context base) { super.attachBaseContext(base); //MultiDex.install(this); ACRA.init(this, new CoreConfigurationBuilder() .withBuildConfigClass(BuildConfig.class) .withReportFormat(StringFormat.JSON) .withPluginConfigurations(new MailSenderConfigurationBuilder() .withMailTo("tblauncher.acra@tbog.rocks") .withReportAsFile(false) .build() , new DialogConfigurationBuilder() .withTitle(getString(R.string.crash_title)) .withText(getString(R.string.crash_text)) .withPositiveButtonText(getString(R.string.crash_send_email)) .withResTheme(R.style.TitleDialogTheme) .build())); } @NonNull public static TBApplication getApplication(@NonNull Context context) { Context appContext = context.getApplicationContext(); if (appContext instanceof TBApplication) return (TBApplication) appContext; throw new IllegalStateException("appContext " + appContext + " not of type " + TBApplication.class.getSimpleName()); } @NonNull private TBLauncherActivity validateActivity(@NonNull Context context) { Activity activity = Utilities.getActivity(context); if (activity == null) throw new IllegalStateException("context " + context + " null activity"); TBLauncherActivity foundActivity = null; for (WeakReference ref : mActivities) { TBLauncherActivity launcherActivity = ref.get(); if (launcherActivity == activity) foundActivity = launcherActivity; } if (foundActivity == null) throw new IllegalStateException("activity " + activity + " not registered"); return foundActivity; } @NonNull private TBLauncherActivity getActivity() { WeakReference ref = mActivities.peekFirst(); if (ref == null) throw new IllegalStateException("no activity registered"); TBLauncherActivity launcherActivity = ref.get(); while (launcherActivity == null) { if (!mActivities.remove(ref)) throw new ConcurrentModificationException(); ref = mActivities.peekFirst(); if (ref == null) throw new IllegalStateException("all registered activities released"); launcherActivity = ref.get(); } if (launcherActivity.getLifecycle().getCurrentState().compareTo(Lifecycle.State.DESTROYED) == 0) throw new IllegalStateException("activity destroyed"); return launcherActivity; } /** * There should be only one activity, but for short periods of time there can be: * - none when launcher got shut down for memory reasons * - two when the activity gets recreated (user pressed the "home" button for example) * * @return most recently registered launcher activity or null */ @Nullable public TBLauncherActivity launcherActivity() { WeakReference ref = mActivities.peekFirst(); TBLauncherActivity launcherActivity = ref == null ? null : ref.get(); if (launcherActivity != null && launcherActivity.getLifecycle().getCurrentState().compareTo(Lifecycle.State.DESTROYED) == 0) return null; Log.d(TAG, "launcherActivity=" + launcherActivity); return launcherActivity; } /** * Same as the getting application from context then calling launcherActivity() * * @param context to get application from * @return most recently registered launcher activity or null */ @Nullable public static TBLauncherActivity launcherActivity(@NonNull Context context) { return getApplication(context).launcherActivity(); } public static boolean activityInvalid(@Nullable View view) { if (view != null && view.isAttachedToWindow()) return activityInvalid(view.getContext()); return false; } public static boolean activityInvalid(@Nullable Context ctx) { return !activityValid(ctx); } public static boolean activityValid(@Nullable Context context) { Context ctx = context; while (ctx instanceof ContextWrapper) { if (ctx instanceof Activity) { Activity act = (Activity) ctx; if (act.isFinishing() || act.isDestroyed()) { // activity is no more return false; } TBApplication app = getApplication(act); for (WeakReference ref : app.mActivities) { TBLauncherActivity launcherActivity = ref.get(); if (act.equals(launcherActivity)) { Lifecycle.State state = launcherActivity.getLifecycle().getCurrentState(); return state.isAtLeast(Lifecycle.State.INITIALIZED); } } // activity not registered return false; } ctx = ((ContextWrapper) ctx).getBaseContext(); } // context is null return false; } public void onCreateActivity(TBLauncherActivity activity) { // clean list for (Iterator> iterator = mActivities.iterator(); iterator.hasNext(); ) { WeakReference ref = iterator.next(); TBLauncherActivity launcherActivity = ref.get(); if (launcherActivity == null) iterator.remove(); } // add to list mActivities.push(new WeakReference<>(activity)); Log.d(TAG, "activities.size=" + mActivities.size()); } @NonNull public SharedPreferences preferences() { return mSharedPreferences; } public static Behaviour behaviour(@NonNull Context context) { TBApplication app = getApplication(context); return app.validateActivity(context).behaviour; } @NonNull public Behaviour behaviour() { return getActivity().behaviour; } @NonNull public static LiveWallpaper liveWallpaper(Context context) { TBApplication app = getApplication(context); return app.validateActivity(context).liveWallpaper; } public static QuickList quickList(Context context) { TBApplication app = getApplication(context); return app.validateActivity(context).quickList; } public static CustomizeUI ui(Context context) { TBApplication app = getApplication(context); return app.validateActivity(context).customizeUI; } @NonNull public static WidgetManager widgetManager(Context context) { TBApplication app = getApplication(context); return app.validateActivity(context).widgetManager; } @NonNull public static DrawableCache drawableCache(Context context) { return getApplication(context).mDrawableCache; } @NonNull public static IconPackCache iconPackCache(Context context) { return getApplication(context).mIconPackCache; } @NonNull public static MimeTypeCache mimeTypeCache(Context context) { return getApplication(context).mMimeTypeCache; } @NonNull public static TagsHandler tagsHandler(Context context) { return getApplication(context).tagsHandler(); } @NonNull public static AppsHandler appsHandler(Context context) { return getApplication(context).appsHandler(); } @NonNull public static DataHandler dataHandler(Context context) { return getApplication(context).getDataHandler(); } @NonNull public static RootHandler rootHandler(Context context) { return getApplication(context).rootHandler(); } @NonNull public static LauncherState state() { return mState; } public static void onDestroyActivity(TBLauncherActivity activity) { TBApplication app = getApplication(activity); Activity popupActivity = null; if (app.mPopup != null) popupActivity = Utilities.getActivity(app.mPopup.getContentView()); if (popupActivity == null && app.dismissPopup()) Log.i(TAG, "Popup dismissed in onDestroyActivity"); for (Iterator> iterator = app.mActivities.iterator(); iterator.hasNext(); ) { WeakReference ref = iterator.next(); TBLauncherActivity launcherActivity = ref.get(); if (launcherActivity == null || launcherActivity == activity) { if (activity == popupActivity && app.dismissPopup()) Log.i(TAG, "Popup dismissed in onDestroyActivity " + activity); iterator.remove(); } } } public static void runTask(Context context, Searcher task) { resetTask(context); getApplication(context).mSearchTask = task; task.execute(); } public static void resetTask(Context context) { TBApplication app = getApplication(context); if (app.mSearchTask != null) { app.mSearchTask.cancel(true); app.mSearchTask = null; } } public static boolean hasSearchTask(Context context) { TBApplication app = getApplication(context); return app.mSearchTask != null; } public static IconsHandler iconsHandler(Context ctx) { return getApplication(ctx).iconsHandler(); } public static boolean isDefaultLauncher(Context context) { String homePackage; try { Intent i = new Intent(Intent.ACTION_MAIN); i.addCategory(Intent.CATEGORY_HOME); PackageManager pm = context.getPackageManager(); final ResolveInfo mInfo = pm.resolveActivity(i, PackageManager.MATCH_DEFAULT_ONLY); homePackage = mInfo == null ? "null" : mInfo.activityInfo.packageName; } catch (Exception e) { homePackage = "unknown"; } return homePackage.equals(context.getPackageName()); } public static void resetDefaultLauncherAndOpenChooser(Context context) { PackageManager packageManager = context.getPackageManager(); ComponentName componentName = new ComponentName(context, DummyLauncherActivity.class); packageManager.setComponentEnabledSetting(componentName, PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP); Intent selector = new Intent(Intent.ACTION_MAIN); selector.addCategory(Intent.CATEGORY_HOME); selector.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); context.startActivity(selector); packageManager.setComponentEnabledSetting(componentName, PackageManager.COMPONENT_ENABLED_STATE_DEFAULT, PackageManager.DONT_KILL_APP); } @Override public void onCreate() { super.onCreate(); PreferenceManager.setDefaultValues(this, R.xml.preferences, true); PreferenceManager.setDefaultValues(this, R.xml.preference_features, true); mSharedPreferences = PreferenceManager.getDefaultSharedPreferences(this); if (PrefCache.isMigrateRequired(mSharedPreferences) && PrefCache.migratePreferences(this, mSharedPreferences)) { Log.i(TAG, "Preferences migration done."); } // SharedPreferences.Editor editor = mSharedPreferences.edit(); // for (Map.Entry entry : mSharedPreferences.getAll().entrySet() ) // { // if (entry.getKey().startsWith("gesture-")) { // Log.d("Pref", entry.getKey() + "=" + entry.getValue()); // editor.putString(entry.getKey(), "none"); // } // } // editor.commit(); mDrawableCache.onPrefChanged(this, mSharedPreferences); } @Override public void onTerminate() { TBLauncherActivity launcherActivity = launcherActivity(); if (launcherActivity != null) launcherActivity.widgetManager.stop(); super.onTerminate(); } @NonNull public TagsHandler tagsHandler() { if (tagsHandler == null) tagsHandler = new TagsHandler(this); return tagsHandler; } @NonNull public AppsHandler appsHandler() { if (appsHandler == null) appsHandler = new AppsHandler(this); return appsHandler; } @NonNull public DataHandler getDataHandler() { synchronized (this) { if (dataHandler == null) { dataHandler = new DataHandler(this); } } return dataHandler; } @NonNull public DrawableCache drawableCache() { return mDrawableCache; } public void initDataHandler() { synchronized (this) { if (dataHandler == null) { dataHandler = new DataHandler(this); } } if (dataHandler.fullLoadOverSent()) { // Already loaded! We still need to fire the FULL_LOAD event DataHandler.sendBroadcast(this, TBLauncherActivity.FULL_LOAD_OVER, TAG); } } @NonNull public IconsHandler iconsHandler() { if (iconsPackHandler == null) { iconsPackHandler = new IconsHandler(this); } return iconsPackHandler; } public void resetIconsHandler() { iconsPackHandler = new IconsHandler(this); } @NonNull public RootHandler rootHandler() { if (mRootHandler == null) mRootHandler = new RootHandler(mSharedPreferences); return mRootHandler; } public void requireLayoutUpdate() { for (WeakReference ref : mActivities) { TBLauncherActivity launcherActivity = ref.get(); if (launcherActivity != null) launcherActivity.requireLayoutUpdate(); } } public void registerPopup(ListPopup popup) { if (mPopup == popup) return; dismissPopup(); mPopup = popup; popup.setOnDismissListener(() -> mPopup = null); } public boolean dismissPopup() { if (mPopup != null) { mPopup.dismiss(); return true; } return false; } @Nullable public ListPopup getPopup() { return mPopup; } /** * Release memory when the UI becomes hidden or when system resources become low. * * @param level the memory-related event that was raised. */ @Override public void onTrimMemory(int level) { super.onTrimMemory(level); if (level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) { // the process had been showing a user interface, and is no longer doing so mDrawableCache.clearCache(); } if (level >= ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN) { // this is called every time the screen is off SQLiteDatabase.releaseMemory(); mIconPackCache.clearCache(this); if (mSharedPreferences.getBoolean("screen-off-cache-clear", false)) mDrawableCache.clearCache(); mMimeTypeCache.clearCache(); } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/TBLauncherActivity.java ================================================ package rocks.tbog.tblauncher; import android.content.BroadcastReceiver; import android.content.Context; import android.content.ContextWrapper; import android.content.Intent; import android.content.IntentFilter; import android.content.SharedPreferences; import android.content.res.Configuration; import android.os.Build; import android.os.Bundle; import android.util.Log; import android.view.KeyEvent; import android.view.MotionEvent; import android.view.View; import android.view.WindowManager; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; import androidx.core.app.ActivityCompat; import androidx.core.content.ContextCompat; import androidx.preference.PreferenceManager; import rocks.tbog.tblauncher.quicklist.QuickList; import rocks.tbog.tblauncher.ui.ListPopup; import rocks.tbog.tblauncher.utils.DebugInfo; import rocks.tbog.tblauncher.utils.DeviceUtils; import rocks.tbog.tblauncher.widgets.WidgetManager; public class TBLauncherActivity extends AppCompatActivity implements ActivityCompat.OnRequestPermissionsResultCallback { private static final String TAG = "TBL"; public static final String START_LOAD = "rocks.tbog.provider.START_LOAD"; public static final String LOAD_OVER = "rocks.tbog.provider.LOAD_OVER"; public static final String FULL_LOAD_OVER = "rocks.tbog.provider.FULL_LOAD_OVER"; public static final String INTENT_DATA = "rocks.tbog.provider.INTENT_DATA"; /** * Receive events from providers */ private final BroadcastReceiver mReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { Log.d(TAG, "BroadcastReceiver action=`" + intent.getAction() + "` extra=`" + intent.getStringExtra(INTENT_DATA) + "`"); if (START_LOAD.equalsIgnoreCase(intent.getAction())) { behaviour.displayLoader(true); } else if (LOAD_OVER.equalsIgnoreCase(intent.getAction())) { behaviour.updateSearchRecords(); } else if (FULL_LOAD_OVER.equalsIgnoreCase(intent.getAction())) { Log.v(TAG, "All providers are done loading."); TBApplication app = TBApplication.getApplication(TBLauncherActivity.this); app.getDataHandler().executeAfterLoadOverTasks(); behaviour.displayLoader(false); SharedPreferences prefs = app.preferences(); // we need to set drawable cache preferences after we load all the apps app.drawableCache().onPrefChanged(TBLauncherActivity.this, prefs); // make sure we load the icon pack as early as possible app.iconsHandler().onPrefChanged(prefs); // Run GC once to free all the garbage accumulated during provider initialization System.gc(); } updateTextView(debugTextView); } }; private Permission permissionManager; private TextView debugTextView; /** * Everything that has to do with the UI behaviour */ public final Behaviour behaviour = new Behaviour(); /** * Manage live wallpaper interaction */ public final LiveWallpaper liveWallpaper = new LiveWallpaper(); /** * The dock / quick access bar */ public final QuickList quickList = new QuickList(); /** * Everything that has to do with the UI customization (drawables and colors) */ public final CustomizeUI customizeUI = new CustomizeUI(); /** * Manage widgets */ public final WidgetManager widgetManager = new WidgetManager(); private boolean bLayoutUpdateRequired = false; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); widgetManager.start(this); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { getWindow().getAttributes().layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES; } final TBApplication app = TBApplication.getApplication(this); app.onCreateActivity(this); /* * Initialize preferences */ PreferenceManager.setDefaultValues(this, R.xml.preferences, false); var prefs = PreferenceManager.getDefaultSharedPreferences(this); Behaviour.setActivityOrientation(this, prefs); /* * Permission Manager */ permissionManager = new Permission(this); /* * Initialize data handler and start loading providers */ IntentFilter intentFilter = new IntentFilter(); intentFilter.addAction(START_LOAD); intentFilter.addAction(LOAD_OVER); intentFilter.addAction(FULL_LOAD_OVER); ActivityCompat.registerReceiver(this, mReceiver, intentFilter, ContextCompat.RECEIVER_NOT_EXPORTED); // init DataHandler after we register the receiver app.initDataHandler(); setContentView(R.layout.activity_fullscreen); debugTextView = findViewById(R.id.debugText); if (BuildConfig.DEBUG) { DeviceUtils.showDeviceInfo("TBLauncher", this); } Log.d(TAG, "onCreateActivity(" + this + ")"); // call after all views are set behaviour.onCreateActivity(this); customizeUI.onCreateActivity(this); quickList.onCreateActivity(this); liveWallpaper.onCreateActivity(this); widgetManager.onCreateActivity(this); } @Override public boolean dispatchKeyEvent(KeyEvent event) { Log.d(TAG, "dispatchKeyEvent " + event); return super.dispatchKeyEvent(event); } @Override protected void onStart() { Log.d(TAG, "onStart(" + this + ")"); super.onStart(); if (DebugInfo.providerStatus(this)) { debugTextView.setVisibility(View.VISIBLE); } behaviour.onStart(); customizeUI.onStart(); quickList.onStart(); } @Override protected void onStop() { Log.d(TAG, "onStop(" + this + ")"); super.onStop(); } @Override protected void onRestart() { Log.d(TAG, "onRestart(" + this + ")"); super.onRestart(); } @Override protected void onDestroy() { Log.d(TAG, "onDestroy(" + this + ")"); if (behaviour.closeFragmentDialog()) { Log.i(TAG, "closed dialog from onDestroy " + this); } TBApplication.onDestroyActivity(this); unregisterReceiver(mReceiver); widgetManager.stop(); super.onDestroy(); } @Override public void onConfigurationChanged(@NonNull Configuration newConfig) { //TBApplication.behaviour(this).onConfigurationChanged(this, newConfig); Log.d(TAG, "onConfigurationChanged" + " orientation=" + newConfig.orientation + " keyboard=" + newConfig.keyboard + " keyboardHidden=" + newConfig.keyboardHidden); super.onConfigurationChanged(newConfig); } public boolean isLayoutUpdateRequired() { return bLayoutUpdateRequired; } public void requireLayoutUpdate(boolean require) { bLayoutUpdateRequired = require; } public void requireLayoutUpdate() { bLayoutUpdateRequired = true; } @Override protected void onResume() { Log.d(TAG, "onResume(" + this + ")"); super.onResume(); if (isLayoutUpdateRequired()) { requireLayoutUpdate(false); Log.i(TAG, "Restarting app after setting changes"); // Restart current activity to refresh view, since some preferences may require using a new UI //getWindow().getDecorView().post(TBLauncherActivity.this::recreate); Log.d(TAG, "finish(" + this + ")"); finish(); startActivity(new Intent(this, getClass())); overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out); return; } behaviour.onResume(); } @Override protected void onNewIntent(Intent intent) { Log.d(TAG, "onNewIntent(" + this + ")"); setIntent(intent); super.onNewIntent(intent); // This is called when the user press Home again while already browsing MainActivity // onResume() will be called right after, hiding the kissbar if any. // http://developer.android.com/reference/android/app/Activity.html#onNewIntent(android.content.Intent) // Animation can't happen in this method, since the activity is not resumed yet, so they'll happen in the onResume() // https://github.com/Neamar/KISS/issues/569 behaviour.onNewIntent(); } @Override protected void onSaveInstanceState(@NonNull Bundle outState) { Log.i(TAG, "onSaveInstanceState " + Integer.toHexString(outState.hashCode()) + " " + this); super.onSaveInstanceState(outState); outState.clear(); } @Override public boolean onKeyDown(int keycode, KeyEvent e) { // For devices with a physical menu button, we still want to display *our* contextual menu if (keycode == KeyEvent.KEYCODE_MENU) { behaviour.showContextMenu(); return true; } return super.onKeyDown(keycode, e); } @Override public void onBackPressed() { if (TBApplication.getApplication(this).dismissPopup()) return; if (behaviour.onBackPressed()) return; super.onBackPressed(); } @Override public void onWindowFocusChanged(boolean hasFocus) { super.onWindowFocusChanged(hasFocus); behaviour.onWindowFocusChanged(hasFocus); } // /** // * Schedules a call to hide() in delay milliseconds, canceling any // * previously scheduled calls. // */ // private void delayedHide(int delayMillis) { // mHideHandler.removeCallbacks(mHideRunnable); // mHideHandler.postDelayed(mHideRunnable, delayMillis); // } public void queueDockReload() { quickList.reload(); } public void refreshSearchRecords() { behaviour.refreshSearchRecords(); } @Override public boolean dispatchTouchEvent(MotionEvent event) { boolean shouldDismissPopup = false; ListPopup listPopup = TBApplication.getApplication(this).getPopup(); if (listPopup != null) { int action = event.getActionMasked(); if (action == MotionEvent.ACTION_DOWN) { // this check is not needed // we'll not receive the event if it happened inside the popup int x = (int) (event.getRawX() + .5f); int y = (int) (event.getRawY() + .5f); if (!listPopup.isInsideViewBounds(x, y)) shouldDismissPopup = true; } } if (shouldDismissPopup && TBApplication.getApplication(this).dismissPopup()) return true; return super.dispatchTouchEvent(event); } @Override public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { super.onRequestPermissionsResult(requestCode, permissions, grantResults); permissionManager.onRequestPermissionsResult(requestCode, permissions, grantResults); } @Override protected void attachBaseContext(Context newBase) { super.attachBaseContext(newBase); Context c = this; while (null != c) { Log.d(TAG, "Ctx: " + c.toString() + " | Res: " + c.getResources().toString()); if (c instanceof ContextWrapper) c = ((ContextWrapper) c).getBaseContext(); else c = null; } } @Override protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { if (widgetManager.onActivityResult(this, requestCode, resultCode, data)) return; super.onActivityResult(requestCode, resultCode, data); } private void updateTextView(TextView debugTextView) { if (debugTextView == null) return; StringBuilder text = new StringBuilder(); TBApplication app = TBApplication.getApplication(this); app.getDataHandler().appendDebugText(text); debugTextView.setText(text); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/TagsManager.java ================================================ package rocks.tbog.tblauncher; import android.app.Activity; import android.content.Context; import android.graphics.Paint; import android.graphics.Typeface; import android.graphics.drawable.Drawable; import android.util.Log; import android.view.View; import android.view.ViewTreeObserver; import android.widget.ImageView; import android.widget.ListView; import android.widget.TextView; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.content.res.AppCompatResources; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.Comparator; import java.util.Objects; import rocks.tbog.tblauncher.WorkAsync.RunnableTask; import rocks.tbog.tblauncher.WorkAsync.TaskRunner; import rocks.tbog.tblauncher.customicon.IconSelectDialog; import rocks.tbog.tblauncher.dataprovider.IProvider; import rocks.tbog.tblauncher.dataprovider.TagsProvider; import rocks.tbog.tblauncher.drawable.CodePointDrawable; import rocks.tbog.tblauncher.drawable.DrawableUtils; import rocks.tbog.tblauncher.entry.ActionEntry; import rocks.tbog.tblauncher.entry.EntryItem; import rocks.tbog.tblauncher.entry.StaticEntry; import rocks.tbog.tblauncher.entry.TagEntry; import rocks.tbog.tblauncher.handler.AppsHandler; import rocks.tbog.tblauncher.handler.DataHandler; import rocks.tbog.tblauncher.handler.IconsHandler; import rocks.tbog.tblauncher.handler.TagsHandler; import rocks.tbog.tblauncher.result.ResultViewHelper; import rocks.tbog.tblauncher.utils.DialogHelper; import rocks.tbog.tblauncher.utils.Utilities; import rocks.tbog.tblauncher.utils.ViewHolderAdapter; import rocks.tbog.tblauncher.utils.ViewHolderListAdapter; public class TagsManager { private static final String TAG = "TagMgr"; private final ArrayList mTagList = new ArrayList<>(); private ListView mListView; private TagsAdapter mAdapter; public interface OnItemClickListener { void onItemClickListener(@NonNull View view, @NonNull TagInfo tagInfo); } public boolean hasChangesMade() { for (TagInfo tagInfo : mTagList) { if (tagInfo.action != TagInfo.Action.NONE) return true; if (tagInfo.icon != null && tagInfo.staticEntry != null) return true; } return false; } public void applyChanges(@NonNull Context context) { TagsHandler tagsHandler = TBApplication.tagsHandler(context); IconsHandler iconsHandler = TBApplication.iconsHandler(context); TBLauncherActivity launcherActivity = TBApplication.launcherActivity(context); boolean changesMade = false; for (TagInfo tagInfo : mTagList) { if (tagInfo.staticEntry instanceof ActionEntry) { // can't delete actions (it's the show untagged action) if (tagInfo.action == TagInfo.Action.RENAME) { TBApplication.dataHandler(context).renameStaticEntry(tagInfo.staticEntry, tagInfo.name); changesMade = true; } } else { switch (tagInfo.action) { case RENAME: if (tagsHandler.renameTag(tagInfo.tagName, tagInfo.name)) changesMade = true; break; case DELETE: if (tagInfo.staticEntry != null) iconsHandler.restoreDefaultIcon(tagInfo.staticEntry); tagInfo.icon = null; if (tagsHandler.removeTag(tagInfo.tagName)) changesMade = true; break; } } if (tagInfo.icon != null && tagInfo.staticEntry != null) { iconsHandler.changeIcon(tagInfo.staticEntry, tagInfo.icon); if (launcherActivity != null) { // force a result refresh to update the icon in the view launcherActivity.behaviour.refreshSearchRecord(tagInfo.staticEntry); } } } // make sure we're in sync if (changesMade) { if (launcherActivity != null) launcherActivity.queueDockReload(); afterChangesMade(context); } } public static void afterChangesMade(@NonNull Context context) { TBApplication.drawableCache(context).clearCache(); DataHandler dataHandler = TBApplication.dataHandler(context); dataHandler.reloadProviders(IProvider.LOAD_STEP_2); RunnableTask afterProviders = TaskRunner.newTask(task -> { TBApplication app = TBApplication.getApplication(context); AppsHandler.setTagsForApps(app.appsHandler().getAllApps(), app.tagsHandler()); }, task -> { Log.d(TAG, "tags and fav providers should have loaded by now"); TBLauncherActivity activity = TBApplication.launcherActivity(context); if (activity != null) { activity.refreshSearchRecords(); activity.queueDockReload(); } }); DataHandler.EXECUTOR_PROVIDERS.execute(afterProviders); } public void bindView(@NonNull View view, @Nullable OnItemClickListener listener) { final Context context = view.getContext(); mListView = view.findViewById(android.R.id.list); if (listener != null) { mListView.setOnItemClickListener((parent, v, pos, id) -> { Object objItem = parent.getAdapter().getItem(pos); if (objItem instanceof TagInfo) { listener.onItemClickListener(v, (TagInfo) objItem); } }); } // prepare the grid with all the tags mAdapter = new TagsAdapter(mTagList); mAdapter.setOnRemoveListener((adapter, v, position) -> { TagInfo info = adapter.getItem(position); if (info.action == TagInfo.Action.DELETE) { info.action = info.tagName.equals(info.name) ? TagInfo.Action.NONE : TagInfo.Action.RENAME; } else { info.action = TagInfo.Action.DELETE; } mAdapter.notifyDataSetChanged(); }); mAdapter.setOnRenameListener((adapter, v, position) -> { TagInfo info = adapter.getItem(position); launchRenameDialog(v.getContext(), info); }); mAdapter.setOnEditIconListener((adapter, v, position) -> { TagInfo info = adapter.getItem(position); launchCustomTagIconDialog(v.getContext(), info); }); mAdapter.newLoadAsyncList(() -> { Activity activity = Utilities.getActivity(context); if (activity == null) return null; TagsHandler tagsHandler = TBApplication.tagsHandler(activity); TagsProvider tagsProvider = TBApplication.dataHandler(activity).getTagsProvider(); Collection validTags = tagsHandler.getValidTags(); ArrayList tags = new ArrayList<>(validTags.size() + 1); for (String tagName : validTags) { TagEntry tagEntry = tagsProvider != null ? tagsProvider.getTagEntry(tagName) : null; TagInfo tagInfo = tagEntry != null ? new TagInfo(tagEntry) : new TagInfo(tagName); tagInfo.setInfo(tagName, tagsHandler.getValidEntryIds(tagName).size()); tags.add(tagInfo); } Collections.sort(tags, Comparator.comparing(lhs -> lhs.tagName)); EntryItem untaggedEntry = TBApplication.dataHandler(context).getPojo(ActionEntry.SCHEME + "show/untagged"); if (untaggedEntry instanceof ActionEntry) { TagInfo tagInfo = new TagInfo((ActionEntry) untaggedEntry); tagInfo.setInfo(untaggedEntry.getName(), -1); tags.add(0, tagInfo); } return tags; }).execute(); } private void launchRenameDialog(Context ctx, TagInfo info) { DialogHelper.makeRenameDialog(ctx, info.name, (dialog, newName) -> { boolean isValid = true; for (TagInfo tagInfo : mTagList) { if (tagInfo == info) continue; if (tagInfo.tagName.equals(newName) || tagInfo.name.equals(newName)) { isValid = false; break; } } if (!isValid) { Toast.makeText(ctx, ctx.getString(R.string.invalid_rename_tag, newName), Toast.LENGTH_LONG).show(); return; } // Set new name info.name = newName; info.action = info.tagName.equals(info.name) ? TagInfo.Action.NONE : TagInfo.Action.RENAME; mAdapter.notifyDataSetChanged(); }) .setTitle(R.string.title_rename_tag) .setHint(R.string.hint_rename_tag) .show(); } private void launchCustomTagIconDialog(Context ctx, TagInfo info) { DataHandler dh = TBApplication.dataHandler(ctx); final StaticEntry staticEntry; if (info.staticEntry != null) { staticEntry = info.staticEntry; } else { TagsProvider tagsProvider = dh.getTagsProvider(); if (tagsProvider == null) return; TagEntry tagEntry = tagsProvider.getTagEntry(info.tagName); // add this tag to the provider before launchCustomIconDialog, in case it isn't already tagsProvider.addTagEntry(tagEntry); staticEntry = tagEntry; } // make sure we have the tag in the provider or else IconSelectDialog will not find it if (staticEntry instanceof TagEntry) { TagsProvider tagsProvider = dh.getTagsProvider(); if (tagsProvider != null) tagsProvider.addTagEntry((TagEntry) staticEntry); } IconSelectDialog dlg = Behaviour.getCustomIconDialog(ctx, false); dlg.putArgString("entryId", staticEntry.id); dlg.setOnConfirmListener(drawable -> { int pos = mTagList.indexOf(info); if (pos == -1) return; // update tag info if (staticEntry.hasCustomIcon() && drawable == null) info.icon = staticEntry.getDefaultDrawable(ctx); else info.icon = drawable; mAdapter.notifyDataSetChanged(); }); Behaviour.showDialog(ctx, dlg, Behaviour.DIALOG_CUSTOM_ICON); } public void onStart() { // Set list adapter after the view inflated // This is a workaround to fix listview items not having the correct width mListView.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() { @Override public boolean onPreDraw() { mListView.getViewTreeObserver().removeOnPreDrawListener(this); mListView.setAdapter(mAdapter); return false; } }); //mListView.post(() -> mListView.setAdapter(mAdapter)); } static class TagsAdapter extends ViewHolderListAdapter { private OnItemClickListener mOnRemoveListener = null; private OnItemClickListener mOnRenameListener = null; private OnItemClickListener mOnEditIconListener = null; public interface OnItemClickListener { void onClick(TagsAdapter adapter, View view, int position); } TagsAdapter(@NonNull ArrayList tags) { super(TagViewHolder.class, R.layout.tags_manager_item, tags); } void setOnRemoveListener(OnItemClickListener listener) { mOnRemoveListener = listener; } void setOnRenameListener(OnItemClickListener listener) { mOnRenameListener = listener; } void setOnEditIconListener(OnItemClickListener listener) { mOnEditIconListener = listener; } @Override protected int getItemViewTypeLayout(int viewType) { if (viewType == 1) return R.layout.tags_manager_item_deleted; return super.getItemViewTypeLayout(viewType); } public int getItemViewType(int position) { return getItem(position).action == TagInfo.Action.DELETE ? 1 : 0; } public int getViewTypeCount() { return 2; } } public static class TagViewHolder extends ViewHolderAdapter.ViewHolder { ImageView iconView; TextView text1View; TextView text2View; View removeBtnView; View renameBtnView; View changeIconBtnView; public TagViewHolder(View itemView) { super(itemView); iconView = itemView.findViewById(android.R.id.icon); text1View = itemView.findViewById(android.R.id.text1); text2View = itemView.findViewById(android.R.id.text2); removeBtnView = itemView.findViewById(android.R.id.button1); renameBtnView = itemView.findViewById(android.R.id.button2); changeIconBtnView = itemView.findViewById(android.R.id.button3); } @Override protected void setContent(TagInfo content, int position, @NonNull ViewHolderAdapter> adapter) { TagsAdapter tagsAdapter = (TagsAdapter) adapter; text1View.setText(content.name); text1View.setTypeface(null, content.action == TagInfo.Action.RENAME ? Typeface.BOLD : Typeface.NORMAL); if (content.staticEntry instanceof ActionEntry) { // this is the untagged entry removeBtnView.setVisibility(View.GONE); Context context = text1View.getContext(); Drawable untagged = AppCompatResources.getDrawable(context, R.drawable.ic_untagged); if (untagged != null) { int iconSize = text1View.getHeight(); if (iconSize <= 0) iconSize = context.getResources().getDimensionPixelSize(R.dimen.icon_preview_size); untagged.setBounds(0, 0, iconSize, iconSize); int dir = context.getResources().getConfiguration().getLayoutDirection(); CharSequence text = Utilities.addDrawableAfterString(content.name, untagged, dir); text1View.setText(text); } } else { removeBtnView.setVisibility(View.VISIBLE); removeBtnView.setOnClickListener(v -> { if (tagsAdapter.mOnRemoveListener != null) tagsAdapter.mOnRemoveListener.onClick(tagsAdapter, v, position); }); } if (content.action == TagInfo.Action.DELETE) { text1View.setPaintFlags(text1View.getPaintFlags() | Paint.STRIKE_THRU_TEXT_FLAG); // the rest of the views are null, exit now return; } int count = content.entryCount; if (count >= 0) { text2View.setVisibility(View.VISIBLE); text2View.setText(text2View.getResources().getQuantityString(R.plurals.tag_entry_count, count, count)); } else { // we can't have a negative count text2View.setVisibility(View.GONE); } if (content.icon == null) { if (content.staticEntry != null) { int drawFlags = EntryItem.FLAG_DRAW_ICON | EntryItem.FLAG_DRAW_NO_CACHE; ResultViewHelper.setIconAsync(drawFlags, content.staticEntry, iconView, StaticEntry.AsyncSetEntryIcon.class, StaticEntry.class); } else { Drawable icon = new CodePointDrawable(content.name); icon = DrawableUtils.applyIconMaskShape(iconView.getContext(), icon, DrawableUtils.SHAPE_SQUIRCLE, false); iconView.setImageDrawable(icon); } } else { iconView.setImageDrawable(content.icon); } renameBtnView.setOnClickListener(v -> { if (tagsAdapter.mOnRenameListener != null) tagsAdapter.mOnRenameListener.onClick(tagsAdapter, v, position); }); changeIconBtnView.setOnClickListener(v -> { if (tagsAdapter.mOnEditIconListener != null) tagsAdapter.mOnEditIconListener.onClick(tagsAdapter, v, position); }); } } public static class TagInfo { public final StaticEntry staticEntry; public final String tagName; private String name; private Drawable icon = null; private int entryCount; private Action action = Action.NONE; public enum Action {NONE, DELETE, RENAME} public TagInfo(String name) { staticEntry = null; tagName = name; } public TagInfo(StaticEntry entry) { staticEntry = entry; tagName = entry.getName(); } public void setInfo(String name, int count) { this.name = name; this.entryCount = count; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; TagInfo tagInfo = (TagInfo) o; return Objects.equals(staticEntry, tagInfo.staticEntry) && Objects.equals(tagName, tagInfo.tagName) && Objects.equals(name, tagInfo.name) && Objects.equals(icon, tagInfo.icon) && action == tagInfo.action; } @Override public int hashCode() { return Objects.hash(staticEntry, tagName, name, icon, action); } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/WallpaperSnapAnim.java ================================================ package rocks.tbog.tblauncher; import android.graphics.Point; import android.graphics.PointF; import android.view.VelocityTracker; import android.view.animation.Animation; import android.view.animation.DecelerateInterpolator; import android.view.animation.Transformation; import androidx.annotation.Nullable; class WallpaperSnapAnim extends Animation { private final LiveWallpaper liveWallpaper; final PointF mStartOffset = new PointF(); final PointF mDeltaOffset = new PointF(); final PointF mVelocity = new PointF(); WallpaperSnapAnim(LiveWallpaper liveWallpaper) { super(); this.liveWallpaper = liveWallpaper; setDuration(500); setInterpolator(new DecelerateInterpolator()); } boolean init(@Nullable VelocityTracker velocityTracker) { if (velocityTracker == null) { mVelocity.set(0.f, 0.f); } else { mVelocity.set(velocityTracker.getXVelocity(), velocityTracker.getYVelocity()); } //Log.d(TAG, "mVelocity=" + String.format(Locale.US, "%.2f", mVelocity)); mStartOffset.set(liveWallpaper.getWallpaperOffset()); //Log.d(TAG, "mStartOffset=" + String.format(Locale.US, "%.2f", mStartOffset)); final Point windowSize = liveWallpaper.getWindowSize(); final float expectedPosX = -Math.min(Math.max(mVelocity.x / windowSize.x, -.5f), .5f) + mStartOffset.x; final float expectedPosY = -Math.min(Math.max(mVelocity.y / windowSize.y, -.5f), .5f) + mStartOffset.y; //Log.d(TAG, "expectedPos=" + String.format(Locale.US, "%.2f %.2f", expectedPosX, expectedPosY)); SnapInfo si = new SnapInfo(liveWallpaper.isPreferenceWPStickToSides(), liveWallpaper.isPreferenceWPReturnCenter()); si.init(expectedPosX, expectedPosY); si.removeDiagonals(expectedPosX, expectedPosY); // compute offset based on stick location if (si.stickToTop) mDeltaOffset.y = 0.f - mStartOffset.y; else if (si.stickToBottom) mDeltaOffset.y = 1.f - mStartOffset.y; else if (si.stickToCenter) mDeltaOffset.y = .5f - mStartOffset.y; if (si.stickToLeft) mDeltaOffset.x = 0.f - mStartOffset.x; else if (si.stickToRight) mDeltaOffset.x = 1.f - mStartOffset.x; else if (si.stickToCenter) mDeltaOffset.x = .5f - mStartOffset.x; return si.stickToLeft || si.stickToTop || si.stickToRight || si.stickToBottom || si.stickToCenter; } @Override protected void applyTransformation(float interpolatedTime, Transformation t) { float offsetX = mStartOffset.x + mDeltaOffset.x * interpolatedTime; float offsetY = mStartOffset.y + mDeltaOffset.y * interpolatedTime; float velocityInterpolator = (float) Math.sqrt(interpolatedTime) * 3.f; final Point windowSize = liveWallpaper.getWindowSize(); if (velocityInterpolator < 1.f) { offsetX -= mVelocity.x / windowSize.x * velocityInterpolator; offsetY -= mVelocity.y / windowSize.y * velocityInterpolator; } else { offsetX -= mVelocity.x / windowSize.x * (1.f - 0.5f * (velocityInterpolator - 1.f)); offsetY -= mVelocity.y / windowSize.y * (1.f - 0.5f * (velocityInterpolator - 1.f)); } liveWallpaper.updateWallpaperOffset(offsetX, offsetY); } private static class SnapInfo { public final boolean stickToSides; public final boolean stickToCenter; public boolean stickToLeft; public boolean stickToTop; public boolean stickToRight; public boolean stickToBottom; public SnapInfo(boolean sidesSnap, boolean centerSnap) { stickToSides = sidesSnap; stickToCenter = centerSnap; } public void init(float x, float y) { // if we stick only to the center float leftStickPercent = -1.f; float topStickPercent = -1.f; float rightStickPercent = 2.f; float bottomStickPercent = 2.f; if (stickToSides && stickToCenter) { // if we stick to the left, right and center leftStickPercent = .2f; topStickPercent = .2f; rightStickPercent = .8f; bottomStickPercent = .8f; } else if (stickToSides) { // if we stick only to the center leftStickPercent = .5f; topStickPercent = .5f; rightStickPercent = .5f; bottomStickPercent = .5f; } stickToLeft = x <= leftStickPercent; stickToTop = y <= topStickPercent; stickToRight = x >= rightStickPercent; stickToBottom = y >= bottomStickPercent; } public void removeDiagonals(float x, float y) { if (stickToTop) { // don't stick to the top-left or top-right corner if (stickToLeft) { stickToLeft = x < y; stickToTop = !stickToLeft; } else if (stickToRight) { stickToRight = (1.f - x) < y; stickToTop = !stickToRight; } } else if (stickToBottom) { // don't stick to the bottom-left or bottom-right corner if (stickToLeft) { stickToLeft = x < y; stickToBottom = !stickToLeft; } else if (stickToRight) { stickToRight = (1.f - x) < y; stickToBottom = !stickToRight; } } } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/WorkAsync/AsyncTask.java ================================================ package rocks.tbog.tblauncher.WorkAsync; import android.util.Log; import androidx.annotation.MainThread; import androidx.annotation.WorkerThread; import java.util.concurrent.Callable; import java.util.concurrent.ExecutionException; import java.util.concurrent.FutureTask; public abstract class AsyncTask extends FutureTask { private static final String TAG = "AsyncT"; In input = null; protected AsyncTask() { this(new BackgroundWorker<>()); } private AsyncTask(BackgroundWorker worker) { super(worker); worker.task = this; } @MainThread protected void onPreExecute() { } @WorkerThread protected abstract Out doInBackground(In input); @Override protected void done() { TaskRunner.runOnUiThread(() -> { if (isCancelled()) onCancelled(); else { Out result = null; try { result = get(); } catch (ExecutionException | InterruptedException e) { Log.e(TAG, "AsyncTask " + AsyncTask.this, e); } onPostExecute(result); } }); } @MainThread protected void onPostExecute(Out output) { } @MainThread protected void onCancelled() { } private static class BackgroundWorker implements Callable { private AsyncTask task = null; @Override public Out call() { Out output = task.doInBackground(task.input); task.input = null; return output; } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/WorkAsync/RunnableTask.java ================================================ package rocks.tbog.tblauncher.WorkAsync; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.lifecycle.Lifecycle; import java.util.concurrent.Callable; import java.util.concurrent.FutureTask; public final class RunnableTask extends FutureTask { private TaskRunner.AsyncRunnable whenDone = null; private Lifecycle lifecycle = null; public void cancel() { cancel(false); } protected RunnableTask(@NonNull TaskRunner.AsyncRunnable worker, @Nullable TaskRunner.AsyncRunnable main, @Nullable Lifecycle lifecycle) { this(new BackgroundWorker(worker)); whenDone = main; this.lifecycle = lifecycle; } protected RunnableTask(@NonNull TaskRunner.AsyncRunnable worker, @Nullable TaskRunner.AsyncRunnable main) { this(worker, main, null); } private RunnableTask(@NonNull BackgroundWorker background) { super(background); background.task = this; } @Override protected void done() { if (whenDone != null) { TaskRunner.runOnUiThread(() -> { if (lifecycle == null || lifecycle.getCurrentState().isAtLeast(Lifecycle.State.STARTED)) whenDone.run(this); }); } } private static class BackgroundWorker implements Callable { private RunnableTask task = null; private final TaskRunner.AsyncRunnable worker; private BackgroundWorker(TaskRunner.AsyncRunnable worker) { this.worker = worker; } @Override public RunnableTask call() { worker.run(task); return task; } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/WorkAsync/TaskRunner.java ================================================ package rocks.tbog.tblauncher.WorkAsync; import android.os.Handler; import android.os.Looper; import androidx.annotation.MainThread; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.lifecycle.Lifecycle; import java.util.concurrent.ExecutorService; public class TaskRunner { private final static Handler handler = new Handler(Looper.getMainLooper()); public interface AsyncRunnable { void run(@NonNull RunnableTask task); } public static boolean runOnUiThread(Runnable runnable) { return handler.post(runnable); } @NonNull public static RunnableTask newTask(@NonNull Lifecycle lifecycle, @NonNull AsyncRunnable worker, @Nullable AsyncRunnable main) { return new RunnableTask(worker, main, lifecycle); } @NonNull public static RunnableTask newTask(@NonNull AsyncRunnable worker, @Nullable AsyncRunnable main) { return new RunnableTask(worker, main); } @MainThread public static > void executeOnExecutor(@NonNull ExecutorService executor, @NonNull T task) { executeOnExecutor(executor, task, null); } @MainThread public static void executeOnExecutor(@NonNull ExecutorService executor, @NonNull AsyncTask task, @Nullable In input) { task.onPreExecute(); task.input = input; executor.submit(task); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/broadcast/IncomingCallHandler.java ================================================ package rocks.tbog.tblauncher.broadcast; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.telephony.TelephonyManager; import android.util.Log; import rocks.tbog.tblauncher.handler.DataHandler; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.dataprovider.ContactsProvider; import rocks.tbog.tblauncher.entry.ContactEntry; public class IncomingCallHandler extends BroadcastReceiver { private static final String TAG = IncomingCallHandler.class.getSimpleName(); @Override public void onReceive(final Context context, Intent intent) { // Only handle calls received if (!"android.intent.action.PHONE_STATE".equals(intent.getAction())) { return; } try { DataHandler dataHandler = TBApplication.getApplication(context).getDataHandler(); ContactsProvider contactsProvider = dataHandler.getContactsProvider(); // Stop if contacts are not enabled if (contactsProvider == null) { return; } if (TelephonyManager.EXTRA_STATE_RINGING.equals(intent.getStringExtra(TelephonyManager.EXTRA_STATE))) { String phoneNumber = intent.getStringExtra(TelephonyManager.EXTRA_INCOMING_NUMBER); if (phoneNumber == null) { // Skipping (private call) return; } ContactEntry contactEntry = contactsProvider.findByPhone(phoneNumber); if (contactEntry != null) { dataHandler.addToHistory(contactEntry.getHistoryId()); } } } catch (Exception e) { Log.e(TAG, "Phone Receive Error", e); } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/broadcast/LocaleChangedReceiver.java ================================================ package rocks.tbog.tblauncher.broadcast; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.dataprovider.AppProvider; public class LocaleChangedReceiver extends BroadcastReceiver { @Override @SuppressWarnings("CatchAndPrintStackTrace") public void onReceive(Context ctx, Intent intent) { // Only handle system broadcasts if (!"android.intent.action.LOCALE_CHANGED".equals(intent.getAction())) { return; } try { // If new locale, then reset tags to load the correct aliases TBApplication.tagsHandler(ctx).loadFromDB(true); } catch (IllegalStateException e) { // Since Android 8.1, we're not allowed to create a new service // when the app is not running e.printStackTrace(); } // Reload application list final AppProvider provider = TBApplication.getApplication(ctx).getDataHandler().getAppProvider(); if (provider != null) { provider.reload(true); } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/broadcast/PackageAddedRemovedHandler.java ================================================ package rocks.tbog.tblauncher.broadcast; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.util.Log; import androidx.preference.PreferenceManager; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.TBLauncherActivity; import rocks.tbog.tblauncher.dataprovider.AppProvider; import rocks.tbog.tblauncher.dataprovider.ShortcutsProvider; import rocks.tbog.tblauncher.entry.AppEntry; import rocks.tbog.tblauncher.handler.DataHandler; import rocks.tbog.tblauncher.utils.UserHandleCompat; /** * This class gets called when an application is created or removed on the * system *

* We then recreate our data set. * * @author dorvaryn */ public class PackageAddedRemovedHandler extends BroadcastReceiver { public static void handleEvent(Context ctx, String action, String packageName, UserHandleCompat user, boolean replacing) { DataHandler dataHandler = TBApplication.getApplication(ctx).getDataHandler(); Log.i("Pack", action + " " + packageName + " isCurrentUser:" + user.isCurrentUser()); if (PreferenceManager.getDefaultSharedPreferences(ctx).getBoolean("enable-app-history", true)) { // Insert into history new packages (not updated ones) if (Intent.ACTION_PACKAGE_ADDED.equals(action) && !replacing) { // Add new package to history Intent launchIntent = ctx.getPackageManager().getLaunchIntentForPackage(packageName); if (launchIntent == null || launchIntent.getComponent() == null) { //for some plugin app return; } final String appId = AppEntry.generateAppId(launchIntent.getComponent(), user); dataHandler.addToHistory(appId); } } if ("android.intent.action.PACKAGE_REMOVED".equals(action) && !replacing) { // Remove all installed shortcuts dataHandler.removeShortcuts(packageName); TBLauncherActivity launcherActivity = TBApplication.launcherActivity(ctx); if (launcherActivity != null) launcherActivity.behaviour.handleRemoveApp(packageName); // dataHandler.removeFromExcluded(packageName); } // This may be an icon pack, reload packs TBApplication.getApplication(ctx).resetIconsHandler(); // Reload application list { final AppProvider provider = dataHandler.getAppProvider(); if (provider != null) provider.reload(true); } // Reload shortcuts list { final ShortcutsProvider provider = dataHandler.getShortcutsProvider(); if (provider != null) provider.reload(true); } } @Override public void onReceive(Context ctx, Intent intent) { String packageName = intent.getData() != null ? intent.getData().getSchemeSpecificPart() : null; if (packageName == null || packageName.equalsIgnoreCase(ctx.getPackageName())) { // When running locally, sending a new version of the APK immediately triggers a "package removed" // There is no need to handle this event. // Discarding it makes startup time much faster locally as apps don't have to be loaded twice. return; } handleEvent(ctx, intent.getAction(), packageName, UserHandleCompat.CURRENT_USER, intent.getBooleanExtra(Intent.EXTRA_REPLACING, false) ); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/calculator/Calculator.java ================================================ package rocks.tbog.tblauncher.calculator; import android.os.Build; import java.math.BigDecimal; import java.math.MathContext; import java.util.ArrayDeque; public class Calculator { public static Result calculateExpression(ArrayDeque expression) { try { return calculateExpressionThrowing(expression); } catch (ArithmeticException e) { return Result.arithmeticalError(); } } // Implements the Shunting Yard algorithm // https://en.wikipedia.org/wiki/Shunting-yard_algorithm private static Result calculateExpressionThrowing(ArrayDeque expression) throws ArithmeticException { ArrayDeque stack = new ArrayDeque<>(); for (Tokenizer.Token token : expression) { BigDecimal operand2 = null; BigDecimal operand1 = null; switch (token.type) { case Tokenizer.Token.NUMBER_TOKEN: stack.push(token.number); break; case Tokenizer.Token.UNARY_PLUS_TOKEN: if (errorInExpression(true, stack)) { return Result.syntacticalError(); } //redundant: stack.push(stack.pop()); break; case Tokenizer.Token.UNARY_MINUS_TOKEN: if (errorInExpression(true, stack)) { return Result.syntacticalError(); } stack.push(stack.pop().negate()); break; case Tokenizer.Token.SUM_TOKEN: if (errorInExpression(false, stack)) { return Result.syntacticalError(); } operand2 = stack.pop(); operand1 = stack.pop(); stack.push(operand1.add(operand2)); break; case Tokenizer.Token.SUBTRACT_TOKEN: if (errorInExpression(false, stack)) { return Result.syntacticalError(); } operand2 = stack.pop(); operand1 = stack.pop(); stack.push(operand1.subtract(operand2)); break; case Tokenizer.Token.MULTIPLY_TOKEN: if (errorInExpression(false, stack)) { return Result.syntacticalError(); } operand2 = stack.pop(); operand1 = stack.pop(); stack.push(operand1.multiply(operand2)); break; case Tokenizer.Token.DIVIDE_TOKEN: if (errorInExpression(false, stack)) { return Result.syntacticalError(); } operand2 = stack.pop(); operand1 = stack.pop(); stack.push(operand1.divide(operand2, MathContext.DECIMAL32)); break; case Tokenizer.Token.EXP_TOKEN: if (errorInExpression(false, stack)) { return Result.syntacticalError(); } operand2 = stack.pop(); operand1 = stack.pop(); double pow = StrictMath.pow(operand1.doubleValue(), operand2.doubleValue()); if(!isFinite(pow)) { throw new ArithmeticException("Not finite result: " + operand1.toString() + "^" + operand2.toString() + " = " + pow); } stack.push(new BigDecimal(pow)); break; } } if(stack.size() != 1) { return Result.syntacticalError(); } return Result.result(stack.pop()); } private static boolean errorInExpression(boolean isUnary, final ArrayDeque stack) { boolean error = false; if(isUnary) { error = error || stack.size() < 1; } else { error = error || stack.size() < 2; } return error; } /** * Returns {@code true} if the argument is a finite floating-point * value; returns {@code false} otherwise (for NaN and infinity * arguments). * * @param d the {@code double} value to be tested * @return {@code true} if the argument is a finite * floating-point value, {@code false} otherwise. */ public static boolean isFinite(double d) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { return Double.isFinite(d); } else { return Math.abs(d) <= Double.MAX_VALUE; } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/calculator/Result.java ================================================ package rocks.tbog.tblauncher.calculator; import androidx.annotation.NonNull; public final class Result { static Result syntacticalError() { return new Result<>(true); } static Result arithmeticalError() { return new Result<>(false); } static Result result(@NonNull T result) { return new Result<>(result); } public final T result; public final boolean syntacticalError; public final boolean arithmeticalError; private Result(boolean isSyntactical) { this.syntacticalError = isSyntactical; this.arithmeticalError = !isSyntactical; this.result = null; } private Result(@NonNull T result) { this.result = result; this.syntacticalError = this.arithmeticalError = false; } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/calculator/ShuntingYard.java ================================================ package rocks.tbog.tblauncher.calculator; import java.util.ArrayDeque; public class ShuntingYard { public static Result> infixToPostfix(ArrayDeque infix) { ArrayDeque outQueue = new ArrayDeque<>(); ArrayDeque operatorStack = new ArrayDeque<>(); for (Tokenizer.Token token : infix) { switch (token.type) { case Tokenizer.Token.NUMBER_TOKEN: outQueue.add(token); break; case Tokenizer.Token.UNARY_PLUS_TOKEN: case Tokenizer.Token.UNARY_MINUS_TOKEN: case Tokenizer.Token.SUM_TOKEN: case Tokenizer.Token.SUBTRACT_TOKEN: case Tokenizer.Token.MULTIPLY_TOKEN: case Tokenizer.Token.DIVIDE_TOKEN: case Tokenizer.Token.EXP_TOKEN: if (operatorStack.isEmpty()) { operatorStack.push(token); } else { while (!operatorStack.isEmpty()) { int prec1 = token.getPrecedence(); int prec2 = operatorStack.peek().getPrecedence(); if ((token.isLeftAssociative() && prec1 <= prec2) || (token.isRightAssociative() && prec1 < prec2)) { outQueue.add(operatorStack.pop()); } else { break; } } operatorStack.push(token); } break; case Tokenizer.Token.PARENTHESIS_OPEN_TOKEN: operatorStack.push(token); break; case Tokenizer.Token.PARENTHESIS_CLOSE_TOKEN: if(operatorStack.isEmpty()) { return Result.syntacticalError(); } // until '(' on stack, pop operators. while (operatorStack.peek().type != Tokenizer.Token.PARENTHESIS_OPEN_TOKEN) { outQueue.add(operatorStack.pop()); if(operatorStack.isEmpty()) { return Result.syntacticalError(); } } operatorStack.pop(); break; } } while (!operatorStack.isEmpty()) { outQueue.addLast(operatorStack.pop()); } return Result.result(outQueue); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/calculator/Tokenizer.java ================================================ package rocks.tbog.tblauncher.calculator; import java.math.BigDecimal; import java.text.DecimalFormat; import java.text.DecimalFormatSymbols; import java.util.Locale; import java.text.ParseException; import java.util.ArrayDeque; import androidx.annotation.NonNull; public class Tokenizer { public static final class Token { public static final int SUM_TOKEN = 0; public static final int SUBTRACT_TOKEN = 1; public static final int MULTIPLY_TOKEN = 2; public static final int DIVIDE_TOKEN = 3; public static final int EXP_TOKEN = 4; public static final int UNARY_PLUS_TOKEN = 8; public static final int UNARY_MINUS_TOKEN = 9; public static final int NUMBER_TOKEN = 16; public static final int PARENTHESIS_OPEN_TOKEN = 17; public static final int PARENTHESIS_CLOSE_TOKEN = 18; public final int type; public final BigDecimal number; public Token(int type) { if(type != SUM_TOKEN && type != SUBTRACT_TOKEN && type != MULTIPLY_TOKEN && type != DIVIDE_TOKEN && type != EXP_TOKEN && type != UNARY_PLUS_TOKEN && type != UNARY_MINUS_TOKEN) { throw new IllegalArgumentException("Wrong constructor!"); } this.type = type; number = null; } public Token(@NonNull BigDecimal number) { this.type = NUMBER_TOKEN; this.number = number; } public Token(boolean isParenthesisOpen) { this.type = isParenthesisOpen? PARENTHESIS_OPEN_TOKEN : PARENTHESIS_CLOSE_TOKEN; this.number = null; } public final int getPrecedence() { switch (type) { case UNARY_PLUS_TOKEN: case UNARY_MINUS_TOKEN: return 4; case SUM_TOKEN: case SUBTRACT_TOKEN: return 1; case MULTIPLY_TOKEN: case DIVIDE_TOKEN: return 2; case EXP_TOKEN: return 3; default: return -1; } } public final boolean isRightAssociative() { switch (type) { case UNARY_PLUS_TOKEN: case UNARY_MINUS_TOKEN: return true; case SUM_TOKEN: case SUBTRACT_TOKEN: case MULTIPLY_TOKEN: case DIVIDE_TOKEN: case EXP_TOKEN: return false; default: throw new IllegalStateException(); } } public final boolean isLeftAssociative() { return !isRightAssociative(); } } public static Result> tokenize(String expression) { ArrayDeque tokens = new ArrayDeque<>(); for (int i = 0; i < expression.length(); i++) { char operator = expression.charAt(i); Token token = null; switch (operator) { case '+': if(!tokens.isEmpty() && (tokens.peekLast().type == Token.NUMBER_TOKEN || tokens.peekLast().type == Token.PARENTHESIS_CLOSE_TOKEN)) { token = new Token(Token.SUM_TOKEN); } else { token = new Token(Token.UNARY_PLUS_TOKEN); } break; case '-': if(!tokens.isEmpty() && (tokens.peekLast().type == Token.NUMBER_TOKEN || tokens.peekLast().type == Token.PARENTHESIS_CLOSE_TOKEN)) { token = new Token(Token.SUBTRACT_TOKEN); } else { token = new Token(Token.UNARY_MINUS_TOKEN); } break; case '*': if(expression.length() > i+1 && expression.charAt(i+1) == '*') {// '**' i++; token = new Token(Token.EXP_TOKEN); } else { token = new Token(Token.MULTIPLY_TOKEN); } break; case '×': case 'x': token = new Token(Token.MULTIPLY_TOKEN); break; case '/': case '÷': token = new Token(Token.DIVIDE_TOKEN); break; case '^': token = new Token(Token.EXP_TOKEN); break; case '(': token = new Token(true); break; case ')': token = new Token(false); break; default: //Numbers StringBuilder numberBuilder = new StringBuilder(); if(checkOperatorIsPartOfNumber(operator)) { numberBuilder.append(operator); while (i+1 < expression.length()) { operator = expression.charAt(i+1); if(checkOperatorIsPartOfNumber(operator)) { numberBuilder.append(operator); i++; } else { break; } } } if(numberBuilder.length() != 0) { DecimalFormat decimalFormat = (DecimalFormat) DecimalFormat.getInstance(); decimalFormat.setDecimalFormatSymbols(new DecimalFormatSymbols(Locale.US)); decimalFormat.setParseBigDecimal(true); BigDecimal number = null; String numberStr = numberBuilder.toString().replace(",","."); if (numberStr.matches("[^.]*\\.[^.]*\\..*")) { return Result.syntacticalError(); // multiple decimal points in one token } try { number = (BigDecimal) decimalFormat.parse(numberStr); } catch (ParseException e) { return Result.syntacticalError(); } token = new Token(number); } } if(token == null) { continue; } tokens.addLast(token); } return Result.result(tokens); } private static boolean checkOperatorIsPartOfNumber(char operator) { return Character.isDigit(operator) || operator == '.' || operator == ',' || operator == 'E' || operator == ' ' || operator == '\''; } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/customicon/ButtonHelper.java ================================================ package rocks.tbog.tblauncher.customicon; import android.content.Context; import android.view.View; import androidx.annotation.NonNull; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.ui.ListPopup; public class ButtonHelper { public static final String BTN_ID_PHONE = "button://item_contact_action_phone"; public static final String BTN_ID_MESSAGE = "button://item_contact_action_message"; public static final String BTN_ID_OPEN = "button://item_contact_action_open"; public static final String BTN_ID_LAUNCHER_PILL = "button://launcher_pill"; public static final String BTN_ID_LAUNCHER_WHITE = "button://launcher_white"; private ButtonHelper() { // don't instantiate a namespace } public static boolean showButtonPopup(@NonNull View view, @NonNull ListPopup buttonMenu) { final Context ctx = view.getContext(); // check if menu contains elements and if yes show it if (!buttonMenu.getAdapter().isEmpty()) { TBApplication.getApplication(ctx).registerPopup(buttonMenu); buttonMenu.show(view); return true; } return false; } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/customicon/CustomShapePage.java ================================================ package rocks.tbog.tblauncher.customicon; import android.app.Activity; import android.content.Context; import android.graphics.Color; import android.graphics.drawable.Animatable; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; import android.text.Editable; import android.text.TextWatcher; import android.view.View; import android.view.ViewTreeObserver; import android.widget.GridView; import android.widget.ImageView; import android.widget.SeekBar; import android.widget.TextView; import androidx.annotation.IdRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.StringRes; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.content.res.AppCompatResources; import androidx.fragment.app.DialogFragment; import com.github.dhaval2404.imagepicker.ImagePicker; import com.github.dhaval2404.imagepicker.constant.ImageProvider; import net.mm2d.color.chooser.ColorChooserDialog; import java.io.File; import java.util.ArrayList; import java.util.Iterator; import java.util.List; import java.util.ListIterator; import java.util.Objects; import kotlin.Unit; import rocks.tbog.tblauncher.CustomizeUI; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.drawable.CodePointDrawable; import rocks.tbog.tblauncher.drawable.DrawableUtils; import rocks.tbog.tblauncher.drawable.FourCodePointDrawable; import rocks.tbog.tblauncher.drawable.TextDrawable; import rocks.tbog.tblauncher.drawable.TwoCodePointDrawable; import rocks.tbog.tblauncher.handler.IconsHandler; import rocks.tbog.tblauncher.utils.UIColors; import rocks.tbog.tblauncher.utils.UISizes; import rocks.tbog.tblauncher.utils.Utilities; import rocks.tbog.tblauncher.utils.ViewHolderAdapter; import rocks.tbog.tblauncher.utils.ViewHolderListAdapter; class CustomShapePage extends PageAdapter.Page { protected ShapedIconAdapter mShapesAdapter; protected ShapedIconAdapter mShapedIconAdapter; protected TextView mLettersView; protected int mShape; protected float mScale = 1.f; protected int mBackground; private int mLetters; CustomShapePage(CharSequence name, View view) { super(name, view); final Context ctx = view.getContext(); mShape = TBApplication.iconsHandler(ctx).getSystemIconPack().getAdaptiveShape(); mLetters = UIColors.getContactActionColor(ctx); mBackground = UIColors.getIconBackground(ctx); if (mShape == DrawableUtils.SHAPE_NONE) mShape = DrawableUtils.SHAPE_SQUARE; } @Override void setupView(@NonNull DialogFragment dialogFragment, @Nullable OnItemClickListener iconClickListener, @Nullable OnItemClickListener iconLongClickListener) { Context context = dialogFragment.requireContext(); mLettersView = pageView.findViewById(R.id.letters); mLettersView.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) { generateTextIcons(s); } @Override public void afterTextChanged(Editable s) { } }); // shape list toggle setupToggle(R.id.shapeGridToggle, R.id.shapeGrid); // scale bar toggle setupToggle(R.id.scaleBarToggle, R.id.scaleBar); // letters toggle setupToggle(R.id.lettersToggle, R.id.lettersGroup); addShapesList(); addIconsList(iconClickListener); addScaleBar(); // add icon picker { PickedIconInfo iconInfo = new PickedIconInfo(AppCompatResources.getDrawable(context, R.drawable.ic_browse_add_icon), R.string.browse_add_icon); mShapedIconAdapter.addItem(iconInfo); } final float colorPreviewRadius = dialogFragment.getResources().getDimension(R.dimen.color_preview_radius); final int colorPreviewBorder = UISizes.dp2px(context, 1); final int colorPreviewSize = dialogFragment.getResources().getDimensionPixelSize(R.dimen.color_preview_size); addBackgroundColorChooser(colorPreviewRadius, colorPreviewBorder, colorPreviewSize); addLetterColorChooser(colorPreviewRadius, colorPreviewBorder, colorPreviewSize); generateShapes(context); } private void addShapesList() { GridView shapeGridView = pageView.findViewById(R.id.shapeGrid); mShapesAdapter = new ShapedIconAdapter(); shapeGridView.setAdapter(mShapesAdapter); shapeGridView.setOnItemClickListener((parent, view, position, id) -> { Activity activity = Utilities.getActivity(view); if (activity == null) return; Object objItem = parent.getAdapter().getItem(position); if (!(objItem instanceof NamedIconInfo) || ((NamedIconInfo) objItem).getPreview() == null) return; CharSequence name = ((NamedIconInfo) objItem).name; for (int shape : DrawableUtils.SHAPE_LIST) { if (name.equals(DrawableUtils.shapeName(activity, shape))) { mShape = shape; break; } } reshapeIcons(activity); }); CustomizeUI.setResultListPref(shapeGridView); } private void addIconsList(@Nullable OnItemClickListener iconClickListener) { GridView gridView = pageView.findViewById(R.id.iconGrid); mShapedIconAdapter = new ShapedIconAdapter(); gridView.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() { @Override public boolean onPreDraw() { gridView.getViewTreeObserver().removeOnPreDrawListener(this); gridView.setAdapter(mShapedIconAdapter); return false; } }); //gridView.setAdapter(mShapedIconAdapter); if (iconClickListener != null) gridView.setOnItemClickListener((parent, view, position, id) -> { Object item = parent.getAdapter().getItem(position); if (item instanceof ShapedIconInfo && ((ShapedIconInfo) item).getPreview() != null) iconClickListener.onItemClick(parent.getAdapter(), view, position); }); CustomizeUI.setResultListPref(gridView); } private void addScaleBar() { SeekBar seekBar = pageView.findViewById(R.id.scaleBar); seekBar.setMax(200); seekBar.setProgress((int) (100.f * mScale)); final Runnable updateIcons = () -> { mScale = 0.01f * seekBar.getProgress(); reshapeIcons(seekBar.getContext()); }; seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { @Override public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { seekBar.removeCallbacks(updateIcons); seekBar.post(updateIcons); } @Override public void onStartTrackingTouch(SeekBar seekBar) { } @Override public void onStopTrackingTouch(SeekBar seekBar) { seekBar.removeCallbacks(updateIcons); seekBar.post(updateIcons); } }); } private void addBackgroundColorChooser(float colorPreviewRadius, int colorPreviewBorder, int colorPreviewSize) { TextView colorView = pageView.findViewById(R.id.backgroundColor); { Drawable drawable = UIColors.getPreviewDrawable(mBackground, colorPreviewBorder, colorPreviewRadius); drawable.setBounds(0, 0, colorPreviewSize, colorPreviewSize); colorView.setCompoundDrawables(null, null, drawable, null); } colorView.setOnClickListener(v -> { Context ctx = v.getContext(); launchCustomColorDialog(ctx, mBackground, color -> { mBackground = color; Activity activity = Utilities.getActivity(v); if (activity == null) return; Drawable drawable = UIColors.getPreviewDrawable(mBackground, colorPreviewBorder, colorPreviewRadius); drawable.setBounds(0, 0, colorPreviewSize, colorPreviewSize); colorView.setCompoundDrawables(null, null, drawable, null); generateShapes(activity); reshapeIcons(activity); }); }); } private void addLetterColorChooser(float colorPreviewRadius, int colorPreviewBorder, int colorPreviewSize) { TextView colorView = pageView.findViewById(R.id.lettersColor); { Drawable drawable = UIColors.getPreviewDrawable(mLetters, colorPreviewBorder, colorPreviewRadius); drawable.setBounds(0, 0, colorPreviewSize, colorPreviewSize); colorView.setCompoundDrawables(null, null, drawable, null); } colorView.setOnClickListener(v -> { Context ctx = v.getContext(); launchCustomColorDialog(ctx, mLetters, color -> { mLetters = color; Activity activity = Utilities.getActivity(v); if (activity == null) return; Drawable drawable = UIColors.getPreviewDrawable(mLetters, colorPreviewBorder, colorPreviewRadius); drawable.setBounds(0, 0, colorPreviewSize, colorPreviewSize); colorView.setCompoundDrawables(null, null, drawable, null); generateTextIcons(mLettersView.getText()); }); }); } @Override public void addPickedIcon(@NonNull Drawable pickedImage, String filename) { mShapedIconAdapter.addItem(new PickedIconInfo(pickedImage, filename)); } private void setupToggle(@IdRes int toggleTextView, @IdRes int viewToToggle) { TextView textView = pageView.findViewById(toggleTextView); textView.setOnClickListener(v -> { View view = pageView.findViewById(viewToToggle); if ("hide".equals(v.getTag())) { view.setVisibility(View.GONE); ((TextView) v).setCompoundDrawablesWithIntrinsicBounds(0, 0, android.R.drawable.arrow_down_float, 0); v.setTag("show"); } else { view.setVisibility(View.VISIBLE); view.requestFocus(); ((TextView) v).setCompoundDrawablesWithIntrinsicBounds(0, 0, android.R.drawable.arrow_up_float, 0); v.setTag("hide"); } }); if (textView.getTag() == null) { textView.setTag("hide"); textView.performClick(); } } public void addIcon(@NonNull String name, @NonNull Drawable drawable) { Context context = pageView.getContext(); Drawable shapedDrawable = DrawableUtils.applyIconMaskShape(context, drawable, mShape, mScale, mBackground); NamedIconInfo iconInfo = new NamedIconInfo(name, shapedDrawable, drawable); mShapedIconAdapter.addItem(iconInfo); } private void addTextIcon(CharSequence name, @NonNull TextDrawable icon) { final Context ctx = pageView.getContext(); final ShapedIconAdapter adapter = mShapedIconAdapter; icon.setTextColor(mLetters); Drawable shapedIcon = DrawableUtils.applyIconMaskShape(ctx, icon, mShape, mScale, mBackground); adapter.addItem(new LetterIconInfo(name, shapedIcon, icon)); } private void generateTextIcons(@Nullable CharSequence text) { final ShapedIconAdapter adapter = mShapedIconAdapter; // remove all TextDrawable icons for (Iterator iterator = adapter.getList().iterator(); iterator.hasNext(); ) { ShapedIconInfo info = iterator.next(); if (info instanceof LetterIconInfo) iterator.remove(); } adapter.notifyDataSetChanged(); final StringBuilder name = new StringBuilder(); final int length = Utilities.codePointsLength(text); int pos = 0; if (length >= 1) { name.appendCodePoint(Character.codePointAt(text, pos)); TextDrawable icon = new CodePointDrawable(text); addTextIcon(name.toString(), icon); } // two characters if (length >= 2) { pos = Utilities.getNextCodePointIndex(text, pos); name.appendCodePoint(Character.codePointAt(text, pos)); TextDrawable icon = TwoCodePointDrawable.fromText(text, false); addTextIcon(name.toString(), icon); } if (length >= 2) { TextDrawable icon = TwoCodePointDrawable.fromText(text, true); addTextIcon(name.toString(), icon); } // three characters if (length >= 3) { pos = Utilities.getNextCodePointIndex(text, pos); name.appendCodePoint(Character.codePointAt(text, pos)); TextDrawable icon = FourCodePointDrawable.fromText(text, true); addTextIcon(name.toString(), icon); } // four characters if (length >= 4) { pos = Utilities.getNextCodePointIndex(text, pos); name.appendCodePoint(Character.codePointAt(text, pos)); } if (length >= 3) { TextDrawable icon = FourCodePointDrawable.fromText(text, false); addTextIcon(name.toString(), icon); } } private void generateShapes(Context context) { final ShapedIconAdapter adapter = mShapesAdapter; adapter.getList().clear(); adapter.notifyDataSetChanged(); Drawable drawable = new ColorDrawable(mBackground); for (int shape : DrawableUtils.SHAPE_LIST) { String name = DrawableUtils.shapeName(context, shape); Drawable shapedDrawable; if (shape == DrawableUtils.SHAPE_NONE) { shapedDrawable = new ColorDrawable(Color.TRANSPARENT); } else { shapedDrawable = DrawableUtils.applyIconMaskShape(context, drawable, shape); } NamedIconInfo iconInfo = new NamedIconInfo(name, shapedDrawable, null); adapter.addItem(iconInfo); } } private void reshapeIcons(Context context) { //generateTextIcons(null); for (ListIterator iterator = mShapedIconAdapter.getList().listIterator(); iterator.hasNext(); ) { ShapedIconInfo iconInfo = iterator.next(); if (iconInfo.textId == R.string.icon_pack_loading) continue; if (iconInfo.textId == R.string.default_icon) continue; ShapedIconInfo newInfo = iconInfo.reshape(context, mShape, mScale, mBackground); iterator.set(newInfo); } mShapedIconAdapter.notifyDataSetChanged(); //generateTextIcons(mLettersView.getText()); } interface OnColorChanged { void onColorChanged(int color); } private static void launchCustomColorDialog(@Nullable Context context, int selectedColor, @NonNull OnColorChanged listener) { Activity activity = Utilities.getActivity(context); if (!(activity instanceof AppCompatActivity)) return; ColorChooserDialog.INSTANCE.registerListener((AppCompatActivity) activity, "request color", color -> { listener.onColorChanged(color); return Unit.INSTANCE; }, null); ColorChooserDialog.INSTANCE.show((AppCompatActivity) activity, "request color", selectedColor, true, ColorChooserDialog.TAB_PALETTE); // Context themeWrapper = UITheme.getDialogThemedContext(context); // DialogView dialogView = new DialogView(themeWrapper); // // dialogView.init(selectedColor, (AppCompatActivity) activity); // dialogView.setWithAlpha(true); // // DialogInterface.OnClickListener buttonListener = (dialog, which) -> { // if (which == DialogInterface.BUTTON_POSITIVE) { // listener.onColorChanged(dialogView.getColor()); // } // dialog.dismiss(); // }; // // final AlertDialog.Builder builder = new AlertDialog.Builder(themeWrapper) // .setPositiveButton(android.R.string.ok, buttonListener) // .setNegativeButton(android.R.string.cancel, buttonListener); // builder.setView(dialogView); // DialogHelper.setButtonBarBackground(builder.show()); } static class LetterIconInfo extends NamedIconInfo { LetterIconInfo(CharSequence name, Drawable icon, Drawable text) { super(name, icon, text); } @Override protected ShapedIconInfo reshape(Context context, int shape, float scale, int background) { Drawable drawable = DrawableUtils.applyIconMaskShape(context, originalDrawable, shape, scale, background); return new LetterIconInfo(name, drawable, originalDrawable); } } static class DefaultIconInfo extends ShapedIconInfo { DefaultIconInfo(IconsHandler.IconInfo icon) { super(icon.getDrawable(), icon.getDrawable()); } @Override public Drawable getIcon() { return null; } } static class NamedIconInfo extends ShapedIconInfo { final CharSequence name; NamedIconInfo(CharSequence name, Drawable icon, Drawable origin) { super(icon, origin); this.name = name; } @Override protected ShapedIconInfo reshape(Context context, int shape, float scale, int background) { Drawable drawable = DrawableUtils.applyIconMaskShape(context, originalDrawable, shape, scale, background); return new NamedIconInfo(name, drawable, originalDrawable); } @Nullable @Override CharSequence getText() { return name; } } public static class ShapedIconInfo { protected final Drawable originalDrawable; protected final Drawable iconDrawable; @StringRes protected int textId; public ShapedIconInfo(Drawable icon, Drawable origin) { iconDrawable = icon; originalDrawable = origin; } protected ShapedIconInfo reshape(Context context, int shape, float scale, int background) { Drawable drawable = DrawableUtils.applyIconMaskShape(context, originalDrawable, shape, scale, background); ShapedIconInfo shapedIconInfo = new ShapedIconInfo(drawable, originalDrawable); shapedIconInfo.textId = textId; return shapedIconInfo; } public Drawable getIcon() { return iconDrawable; } public Drawable getPreview() { return iconDrawable; } @Nullable CharSequence getText() { return null; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; ShapedIconInfo that = (ShapedIconInfo) o; return Objects.equals(iconDrawable, that.iconDrawable) && Objects.equals(textId, that.textId); } @Override public int hashCode() { return Objects.hash(iconDrawable, textId); } } public static class ShapedIconVH extends ViewHolderAdapter.ViewHolder { View root; ImageView icon; TextView text1; public ShapedIconVH(View view) { super(view); root = view; icon = view.findViewById(android.R.id.icon); text1 = view.findViewById(android.R.id.text1); } @Override protected void setContent(ShapedIconInfo content, int position, @NonNull ViewHolderAdapter> adapter) { // set icon Drawable preview = content.getPreview(); icon.setImageDrawable(preview); icon.setVisibility(preview == null ? View.GONE : View.VISIBLE); if (preview instanceof Animatable) ((Animatable) preview).start(); //set text CharSequence text = content.getText(); if (text != null) text1.setText(text); else if (content.textId != 0) text1.setText(content.textId); else text1.setText("null"); } } static class ShapedIconAdapter extends ViewHolderListAdapter { protected ShapedIconAdapter() { super(ShapedIconVH.class, R.layout.item_grid, new ArrayList<>()); } List getList() { return mList; } void removeItem(ShapedIconInfo item) { mList.remove(item); notifyDataSetChanged(); } } public static class PickedIconInfo extends ShapedIconInfo { @Nullable String text = null; public PickedIconInfo(Drawable icon, @StringRes int textId) { super(icon, icon); this.textId = textId; } public PickedIconInfo(Drawable icon, @Nullable String text) { super(icon, icon); this.text = text; } public PickedIconInfo(Drawable shaped, Drawable original, @Nullable String text) { super(shaped, original); this.text = text; } @Nullable CharSequence getText() { return text; } @Override protected ShapedIconInfo reshape(Context context, int shape, float scale, int background) { if (textId != 0) return this; Drawable drawable = DrawableUtils.applyIconMaskShape(context, originalDrawable, shape, scale, background); return new PickedIconInfo(drawable, originalDrawable, text); } public boolean launchPicker(@NonNull IconSelectDialog iconSelectDialog, @NonNull View v) { if (textId == 0) return false; Context ctx = v.getContext(); int size = UISizes.dp2px(ctx, R.dimen.icon_size) * 2; ImagePicker .with(iconSelectDialog) .cropSquare() .provider(ImageProvider.GALLERY) .maxResultSize(size, size) .saveDir(new File(ctx.getCacheDir(), "ImagePicker")) .createIntent(intent -> { iconSelectDialog.imagePickerResult.launch(intent); return Unit.INSTANCE; }); return true; } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/customicon/DefaultButtonPage.java ================================================ package rocks.tbog.tblauncher.customicon; import android.content.Context; import android.graphics.drawable.Drawable; import android.view.View; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.StringRes; import androidx.core.content.ContextCompat; import androidx.fragment.app.DialogFragment; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.drawable.DrawableUtils; import rocks.tbog.tblauncher.handler.IconsHandler; public class DefaultButtonPage extends CustomShapePage { final private int mDefaultIcon; @StringRes final private int mDefaultName; final private String mEntryName; DefaultButtonPage(CharSequence name, View view, String entryName, int defaultIcon, @StringRes int defaultName) { super(name, view); mEntryName = entryName; mDefaultIcon = defaultIcon; mDefaultName = defaultName; mScale = DrawableUtils.getScaleToFit(mShape); } @Override void setupView(@NonNull DialogFragment dialogFragment, @Nullable OnItemClickListener iconClickListener, @Nullable OnItemClickListener iconLongClickListener) { Context context = dialogFragment.requireContext(); super.setupView(dialogFragment, iconClickListener, iconLongClickListener); final Drawable originalDrawable; // default icon { originalDrawable = ContextCompat.getDrawable(context, mDefaultIcon); IconsHandler.IconInfo iconHandlerIconInfo = new IconsHandler.IconInfo().setNonAdaptiveIcon(originalDrawable); ShapedIconInfo iconInfo = new DefaultIconInfo(dialogFragment.getString(mDefaultName), iconHandlerIconInfo); mShapedIconAdapter.addItem(iconInfo); } // customizable default icon { Drawable shapedDrawable = DrawableUtils.applyIconMaskShape(context, originalDrawable, mShape, mScale, mBackground); ShapedIconInfo iconInfo = new NamedIconInfo(mEntryName, shapedDrawable, originalDrawable); mShapedIconAdapter.addItem(iconInfo); } // this will call generateTextIcons mLettersView.setText(mEntryName); } static class DefaultIconInfo extends CustomShapePage.DefaultIconInfo { final String name; DefaultIconInfo(@NonNull String name, IconsHandler.IconInfo icon) { super(icon); this.name = name; textId = R.string.default_icon; } @Nullable @Override CharSequence getText() { return name; } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/customicon/IconAdapter.java ================================================ package rocks.tbog.tblauncher.customicon; import androidx.annotation.NonNull; import java.util.List; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.utils.ViewHolderListAdapter; class IconAdapter extends ViewHolderListAdapter { IconAdapter(@NonNull List objects) { super(IconViewHolder.class, R.layout.custom_icon_item, objects); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/customicon/IconData.java ================================================ package rocks.tbog.tblauncher.customicon; import android.graphics.drawable.Drawable; import rocks.tbog.tblauncher.icons.DrawableInfo; import rocks.tbog.tblauncher.icons.IconPackXML; class IconData { final DrawableInfo drawableInfo; final IconPackXML iconPack; IconData(IconPackXML iconPack, DrawableInfo drawableInfo) { this.iconPack = iconPack; this.drawableInfo = drawableInfo; } Drawable getIcon() { return iconPack.getDrawable(drawableInfo); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/customicon/IconPackPage.java ================================================ package rocks.tbog.tblauncher.customicon; import android.app.Activity; import android.content.Context; import android.content.pm.PackageManager; import android.graphics.drawable.Drawable; import android.text.Editable; import android.text.TextWatcher; import android.util.Log; import android.view.View; import android.widget.BaseAdapter; import android.widget.GridView; import android.widget.ProgressBar; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.collection.ArraySet; import androidx.fragment.app.DialogFragment; import java.util.ArrayList; import java.util.Collection; import rocks.tbog.tblauncher.CustomizeUI; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.icons.DrawableInfo; import rocks.tbog.tblauncher.icons.IconPackXML; import rocks.tbog.tblauncher.normalizer.StringNormalizer; import rocks.tbog.tblauncher.utils.FuzzyScore; import rocks.tbog.tblauncher.utils.UISizes; import rocks.tbog.tblauncher.utils.Utilities; class IconPackPage extends PageAdapter.Page { private static final String TAG = IconPackPage.class.getSimpleName(); final ArrayList iconDataList = new ArrayList<>(); final String packageName; private ProgressBar mIconLoadingBar; private GridView mGridView; private TextView mSearch; private IconPackXML mIconPack = null; private final ArraySet mInvalidDrawables = new ArraySet<>(0); IconPackPage(CharSequence name, String packPackageName, View view) { super(name, view); packageName = packPackageName; } @Override void setupView(@NonNull DialogFragment dialogFragment, @Nullable OnItemClickListener iconClickListener, @Nullable OnItemClickListener iconLongClickListener) { Context context = dialogFragment.requireContext(); mIconLoadingBar = pageView.findViewById(R.id.iconLoadingBar); Drawable packIcon = null; // set page title TextView textView = pageView.findViewById(android.R.id.text1); textView.setText(dialogFragment.getResources().getString(R.string.icon_pack_content_list, packageName)); try { packIcon = context.getPackageManager().getApplicationIcon(packageName); } catch (PackageManager.NameNotFoundException ignored) { } if (packIcon != null) { int size = UISizes.getResultIconSize(context); packIcon.setBounds(0, 0, size, size); textView.setCompoundDrawables(packIcon, null, null, null); } // set page icon grid mGridView = pageView.findViewById(R.id.iconGrid); IconAdapter iconAdapter = new IconAdapter(iconDataList); mGridView.setAdapter(iconAdapter); if (iconClickListener != null) { mGridView.setOnItemClickListener((parent, view, position, id) -> iconClickListener.onItemClick(parent.getAdapter(), view, position)); } if (iconLongClickListener != null) { mGridView.setOnItemLongClickListener((parent, view, position, id) -> { iconLongClickListener.onItemClick(parent.getAdapter(), view, position); return true; }); } CustomizeUI.setResultListPref(mGridView); // set page search bar mSearch = pageView.findViewById(R.id.search); mSearch.addTextChangedListener(new TextWatcher() { public void afterTextChanged(Editable s) { // Auto left-trim text. if (s.length() > 0 && s.charAt(0) == ' ') s.delete(0, 1); } public void beforeTextChanged(CharSequence s, int start, int count, int after) { } public void onTextChanged(CharSequence s, int start, int before, int count) { mSearch.post(() -> refreshList()); } }); mSearch.requestFocus(); // show it's loading while we parse the icon pack XML mIconLoadingBar.setVisibility(View.VISIBLE); mGridView.setVisibility(View.GONE); } @Override void loadData() { super.loadData(); final ArraySet invalidDrawables = new ArraySet<>(0); // load the new pack final IconPackXML pack = TBApplication.iconPackCache(pageView.getContext()).getIconPack(packageName); Utilities.runAsync((t) -> { Activity activity = Utilities.getActivity(pageView); if (activity != null) { pack.loadDrawables(activity.getPackageManager()); Collection drawables = pack.getDrawableList(); for (DrawableInfo info : drawables) { if (info.getDrawableResId(pack) == 0) { invalidDrawables.add(info.getDrawableName()); } } } }, (t) -> { Activity activity = Utilities.getActivity(pageView); if (activity != null) { mIconPack = pack; mInvalidDrawables.clear(); mInvalidDrawables.addAll(invalidDrawables); int invalidDrawablesSize = mInvalidDrawables.size(); if (invalidDrawablesSize > 0) { Log.w(TAG, "icon pack `" + mIconPack.getPackPackageName() + "` has " + invalidDrawablesSize + " drawable(s) without resource id"); } refreshList(); } else mIconPack = null; }); } private void refreshList() { iconDataList.clear(); if (mIconPack != null) { Collection drawables = mIconPack.getDrawableList(); StringNormalizer.Result normalized = StringNormalizer.normalizeWithResult(mSearch.getText(), true); FuzzyScore fuzzyScore = new FuzzyScore(normalized.codePoints); for (DrawableInfo info : drawables) { if (mInvalidDrawables.contains(info.getDrawableName())) continue; if (fuzzyScore.match(info.getDrawableName()).match) iconDataList.add(new IconData(mIconPack, info)); } } mIconLoadingBar.setVisibility(View.GONE); boolean showGridAndSearch = !iconDataList.isEmpty() || (mSearch.length() > 0); mSearch.setVisibility(showGridAndSearch ? View.VISIBLE : View.GONE); mGridView.setVisibility(showGridAndSearch ? View.VISIBLE : View.GONE); ((BaseAdapter) mGridView.getAdapter()).notifyDataSetChanged(); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/customicon/IconSelectDialog.java ================================================ package rocks.tbog.tblauncher.customicon; import android.app.Activity; import android.app.Dialog; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.database.Cursor; import android.graphics.drawable.Drawable; import android.net.Uri; import android.os.Bundle; import android.provider.OpenableColumns; import android.util.Pair; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.TextView; import android.widget.Toast; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.StringRes; import androidx.core.content.res.ResourcesCompat; import androidx.viewpager.widget.ViewPager; import com.github.dhaval2404.imagepicker.ImagePicker; import java.io.InputStream; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; import rocks.tbog.tblauncher.CustomizeUI; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.db.DBHelper; import rocks.tbog.tblauncher.db.ShortcutRecord; import rocks.tbog.tblauncher.entry.EntryItem; import rocks.tbog.tblauncher.entry.SearchEntry; import rocks.tbog.tblauncher.entry.ShortcutEntry; import rocks.tbog.tblauncher.entry.StaticEntry; import rocks.tbog.tblauncher.handler.IconsHandler; import rocks.tbog.tblauncher.icons.IconPack; import rocks.tbog.tblauncher.result.ResultViewHelper; import rocks.tbog.tblauncher.ui.DialogFragment; import rocks.tbog.tblauncher.ui.DialogWrapper; import rocks.tbog.tblauncher.ui.LinearAdapter; import rocks.tbog.tblauncher.ui.ListPopup; import rocks.tbog.tblauncher.utils.UISizes; import rocks.tbog.tblauncher.utils.UserHandleCompat; import rocks.tbog.tblauncher.utils.Utilities; public class IconSelectDialog extends DialogFragment { private Drawable mSelectedDrawable = null; private ViewPager mViewPager; private CustomShapePage mCustomShapePage = null; private TextView mPreviewLabel; ActivityResultLauncher imagePickerResult; @Override public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); imagePickerResult = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> { Context context = IconSelectDialog.this.requireContext(); int resultCode = result.getResultCode(); Intent data = result.getData(); Uri imageUri = data != null ? data.getData() : null; if (resultCode == Activity.RESULT_OK && imageUri != null) { Drawable imageDrawable; try (InputStream is = context.getContentResolver().openInputStream(imageUri)) { imageDrawable = Drawable.createFromStream(is, imageUri.toString()); } catch (Throwable ignore) { imageDrawable = null; } String filename = getFileName(context, imageUri); if (imageDrawable != null) IconSelectDialog.this.addPickedIcon(imageDrawable, filename); } else if (resultCode == ImagePicker.RESULT_ERROR) { Toast.makeText(context, ImagePicker.getError(data), Toast.LENGTH_SHORT).show(); } }); } public static String getFileName(@NonNull Context context, @NonNull Uri uri) { String result = null; if (uri.getScheme().equals("content")) { try (Cursor cursor = context.getContentResolver().query(uri, null, null, null, null)) { if (cursor != null && cursor.moveToFirst()) { int columnIdx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); if (columnIdx != -1) result = cursor.getString(columnIdx); } } } if (result == null) { result = uri.getPath(); int cut = result.lastIndexOf('/'); if (cut != -1) { result = result.substring(cut + 1); } } return result; } private void addPickedIcon(@NonNull Drawable pickedImage, String filename) { if (!(mViewPager.getAdapter() instanceof PageAdapter)) return; PageAdapter pageAdapter = (PageAdapter) mViewPager.getAdapter(); for (PageAdapter.Page page : pageAdapter.getPageIterable()) { page.addPickedIcon(pickedImage, filename); } } @Override protected int layoutRes() { return R.layout.dialog_icon_select; } @Nullable @Override public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { // make sure we use the dialog context inflater = inflater.cloneInContext(requireDialog().getContext()); View view = super.onCreateView(inflater, container, savedInstanceState); if (view == null) return null; mPreviewLabel = view.findViewById(R.id.previewLabel); mViewPager = view.findViewById(R.id.viewPager); CustomizeUI.setResultListPref(mPreviewLabel); ResultViewHelper.applyResultItemShadow(mPreviewLabel); PageAdapter pageAdapter = new PageAdapter(); mViewPager.setAdapter(pageAdapter); // add system icons addSystemIcons(inflater); // add icon packs ArrayList> iconPacks = addIconPacks(inflater); pageAdapter.notifyDataSetChanged(); pageAdapter.setupPageView(this); if (mCustomShapePage instanceof SystemPage) { ((SystemPage) mCustomShapePage).loadIconPackIcons(iconPacks); } return view; } /** * Add ViewPager pages for every icon pack * * @param inflater used for inflating the page view * @return a list of pairs with the icon pack package name and icon pack name */ @NonNull private ArrayList> addIconPacks(LayoutInflater inflater) { Context context = inflater.getContext(); IconsHandler iconsHandler = TBApplication.iconsHandler(context); Map iconPackNames = iconsHandler.getIconPackNames(); ArrayList> iconPacks = new ArrayList<>(iconPackNames.size()); for (Map.Entry packInfo : iconPackNames.entrySet()) iconPacks.add(new Pair<>(packInfo.getKey(), packInfo.getValue())); IconPack iconPack = iconsHandler.getCustomIconPack(); String selectedPackPackageName = iconPack != null ? iconPack.getPackPackageName() : ""; Collections.sort(iconPacks, (o1, o2) -> { if (selectedPackPackageName.equals(o1.first)) return -1; if (selectedPackPackageName.equals(o2.first)) return 1; return o1.second.compareTo(o2.second); }); for (Pair packInfo : iconPacks) { String packPackageName = packInfo.first; String packName = packInfo.second; if (selectedPackPackageName.equals(packPackageName)) packName = context.getString(R.string.selected_pack, packName); // add page to ViewPager addIconPackPage(inflater, mViewPager, packName, packPackageName); } return iconPacks; } /** * Add ViewPager page for system icons * * @param inflater used for inflating the page view */ private void addSystemIcons(LayoutInflater inflater) { Context context = inflater.getContext(); Bundle args = getArguments() != null ? getArguments() : new Bundle(); if (args.containsKey("componentName")) { String name = args.getString("componentName", ""); String entryName = args.getString("entryName", ""); String pageName = context.getString(R.string.tab_app_icons, entryName); ComponentName cn = UserHandleCompat.unflattenComponentName(name); UserHandleCompat userHandle = UserHandleCompat.fromComponentName(context, name); mCustomShapePage = addSystemPage(inflater, cn, userHandle, pageName); } else if (args.containsKey("entryId")) { String entryId = args.getString("entryId", ""); EntryItem entryItem = TBApplication.dataHandler(context).getPojo(entryId); if (!(entryItem instanceof StaticEntry)) { dismiss(); Toast.makeText(Utilities.getActivity(context), context.getString(R.string.entry_not_found, entryId), Toast.LENGTH_LONG).show(); } else { StaticEntry staticEntry = (StaticEntry) entryItem; String pageName = context.getString(R.string.tab_static_icons); mCustomShapePage = addStaticEntryPage(inflater, staticEntry, pageName); } } else if (args.containsKey("shortcutId")) { String packageName = args.getString("packageName", ""); String shortcutData = args.getString("shortcutData", ""); ShortcutRecord shortcutRecord = null; List shortcutRecordList = DBHelper.getShortcutsNoIcons(context, packageName); for (ShortcutRecord rec : shortcutRecordList) if (shortcutData.equals(rec.infoData)) { shortcutRecord = rec; break; } if (shortcutRecord == null) { dismiss(); String shortcutId = args.getString("shortcutId", ""); Toast.makeText(Utilities.getActivity(context), context.getString(R.string.entry_not_found, shortcutId), Toast.LENGTH_LONG).show(); } else { mCustomShapePage = addShortcutPage(inflater, shortcutRecord, shortcutRecord.displayName); } } else if (args.containsKey("searchEntryId")) { String entryName = args.getString("searchName", ""); String pageName = context.getString(R.string.tab_search_icon); mCustomShapePage = addSearchEntryPage(inflater, entryName, pageName); } else if (args.containsKey("buttonId")) { int defaultIcon = args.getInt("defaultIcon"); String pageName = context.getString(R.string.tab_button_icon); mCustomShapePage = addButtonPage(inflater, defaultIcon, pageName); } } @Override public void onStart() { super.onStart(); Dialog dialog = getDialog(); if (dialog instanceof DialogWrapper) { ((DialogWrapper) dialog).setOnWindowFocusChanged((dlg, hasFocus) -> { if (hasFocus) { dlg.setOnWindowFocusChanged(null); //hack: fix the height of the dialog so it doesn't flicker setFixedHeight(getView()); } }); } } private void setFixedHeight(View view) { ViewGroup.LayoutParams params = view.getLayoutParams(); params.height = view.getMeasuredHeight(); view.setLayoutParams(params); } public void setSelectedDrawable(Drawable selected, Drawable preview) { Context context = mViewPager.getContext(); mSelectedDrawable = selected; @StringRes int label = mSelectedDrawable == null ? R.string.default_icon_preview_label : R.string.custom_icon_preview_label; mPreviewLabel.setText(label); int size = UISizes.getResultIconSize(context); Drawable icon = preview.getConstantState().newDrawable(context.getResources()); icon.setBounds(0, 0, size, size); mPreviewLabel.setCompoundDrawables(null, null, icon, null); } private void addIconPackPage(@NonNull LayoutInflater inflater, ViewGroup container, String packName, String packPackageName) { View view = inflater.inflate(R.layout.dialog_icon_select_page, container, false); IconPackPage page = new IconPackPage(packName, packPackageName, view); PageAdapter adapter = (PageAdapter) mViewPager.getAdapter(); if (adapter != null) adapter.addPage(page); } private CustomShapePage addCustomShapePage(CustomShapePage page) { PageAdapter adapter = (PageAdapter) mViewPager.getAdapter(); if (adapter != null) adapter.addPage(page); return page; } private CustomShapePage addSystemPage(LayoutInflater inflater, ComponentName cn, UserHandleCompat userHandle, String pageName) { View view = inflater.inflate(R.layout.dialog_custom_shape_icon_select_page, mViewPager, false); CustomShapePage page = new SystemPage(pageName, view, cn, userHandle); return addCustomShapePage(page); } private CustomShapePage addStaticEntryPage(LayoutInflater inflater, StaticEntry staticEntry, String pageName) { View view = inflater.inflate(R.layout.dialog_custom_shape_icon_select_page, mViewPager, false); CustomShapePage page = new StaticEntryPage(pageName, view, staticEntry); return addCustomShapePage(page); } private CustomShapePage addSearchEntryPage(LayoutInflater inflater, String entryName, String pageName) { View view = inflater.inflate(R.layout.dialog_custom_shape_icon_select_page, mViewPager, false); CustomShapePage page = new DefaultButtonPage(pageName, view, entryName, R.drawable.ic_search, R.string.default_static_icon); return addCustomShapePage(page); } private CustomShapePage addShortcutPage(LayoutInflater inflater, ShortcutRecord shortcutRecord, String pageName) { View view = inflater.inflate(R.layout.dialog_custom_shape_icon_select_page, mViewPager, false); CustomShapePage page = new ShortcutPage(pageName, view, shortcutRecord); return addCustomShapePage(page); } private CustomShapePage addButtonPage(LayoutInflater inflater, int defaultIcon, String pageName) { View view = inflater.inflate(R.layout.dialog_custom_shape_icon_select_page, mViewPager, false); CustomShapePage page = new DefaultButtonPage(pageName, view, "", defaultIcon, R.string.default_icon); return addCustomShapePage(page); } public ListPopup getIconPackMenu(IconData iconData) { final Context ctx = requireContext(); LinearAdapter adapter = new LinearAdapter(); adapter.add(new LinearAdapter.ItemTitle(iconData.drawableInfo.getDrawableName())); adapter.add(new LinearAdapter.Item(ctx, R.string.choose_icon_menu_add)); adapter.add(new LinearAdapter.Item(ctx, R.string.choose_icon_menu_add2)); return ListPopup.create(ctx, adapter) .setModal(true) .setOnItemClickListener((a, v, pos) -> { LinearAdapter.MenuItem item = ((LinearAdapter) a).getItem(pos); @StringRes int stringId = 0; if (item instanceof LinearAdapter.Item) { stringId = ((LinearAdapter.Item) a.getItem(pos)).stringId; } if (stringId == R.string.choose_icon_menu_add2) { if (mCustomShapePage != null) mCustomShapePage.addIcon(iconData.drawableInfo.getDrawableName(), iconData.getIcon()); // set the first page as current mViewPager.setCurrentItem(0); } else if (stringId == R.string.choose_icon_menu_add) { if (mCustomShapePage != null) mCustomShapePage.addIcon(iconData.drawableInfo.getDrawableName(), iconData.getIcon()); } }); } @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); Bundle args = getArguments() != null ? getArguments() : new Bundle(); if (args.containsKey("componentName")) customIconApp(args); else if (args.containsKey("entryId")) customIconStaticEntry(args); else if (args.containsKey("shortcutId")) customIconShortcut(args); else if (args.containsKey("searchEntryId")) customIconSearchEntry(args); else if (args.containsKey("buttonId")) customIconButton(args); else { dismiss(); Context ctx = requireContext(); Toast.makeText(Utilities.getActivity(ctx), ctx.getString(R.string.entry_not_found, ""), Toast.LENGTH_LONG).show(); return; } // OK button { View button = view.findViewById(android.R.id.button1); button.setOnClickListener(v -> { onConfirm(mSelectedDrawable); dismiss(); }); } // CANCEL button { View button = view.findViewById(android.R.id.button2); button.setOnClickListener(v -> dismiss()); } } private void customIconApp(Bundle args) { Context context = requireContext(); String name = args.getString("componentName", ""); long customIcon = args.getLong("customIcon", 0); if (name.isEmpty()) { dismiss(); String entryName = args.getString("entryName", ""); Toast.makeText(Utilities.getActivity(context), context.getString(R.string.entry_not_found, entryName), Toast.LENGTH_LONG).show(); return; } IconsHandler iconsHandler = TBApplication.getApplication(context).iconsHandler(); ComponentName cn = UserHandleCompat.unflattenComponentName(name); UserHandleCompat userHandle = UserHandleCompat.fromComponentName(context, name); // Preview initPreviewIcon(mPreviewLabel, ctx -> { Drawable drawable = customIcon != 0 ? iconsHandler.getCustomIcon(name) : null; if (drawable == null) drawable = iconsHandler.getDrawableIconForPackage(cn, userHandle); return drawable; }); } private static void initPreviewIcon(TextView preview, Utilities.GetDrawable asyncGet) { Utilities.setViewAsync(preview, asyncGet, (view, drawable) -> { Context ctx = view.getContext(); int size = UISizes.getResultIconSize(ctx); Drawable icon = drawable.mutate(); icon.setBounds(0, 0, size, size); ((TextView) view).setCompoundDrawables(null, null, icon, null); int radius = UISizes.getResultListRadius(ctx); int paddingTop = view.getPaddingTop(); int paddingBottom = view.getPaddingBottom(); view.setPadding(radius / 2, paddingTop, radius / 2, paddingBottom); }); } private void customIconStaticEntry(Bundle args) { Context context = requireContext(); String entryId = args.getString("entryId", ""); EntryItem entryItem = TBApplication.dataHandler(context).getPojo(entryId); if (!(entryItem instanceof StaticEntry)) { dismiss(); Toast.makeText(Utilities.getActivity(context), context.getString(R.string.entry_not_found, entryId), Toast.LENGTH_LONG).show(); return; } StaticEntry staticEntry = (StaticEntry) entryItem; // Preview initPreviewIcon(mPreviewLabel, staticEntry::getIconDrawable); } private void customIconSearchEntry(Bundle args) { Context context = requireContext(); String entryId = args.getString("searchEntryId", ""); EntryItem entryItem = TBApplication.dataHandler(context).getPojo(entryId); if (!(entryItem instanceof SearchEntry)) { dismiss(); Toast.makeText(Utilities.getActivity(context), context.getString(R.string.entry_not_found, entryId), Toast.LENGTH_LONG).show(); return; } SearchEntry searchEntry = (SearchEntry) entryItem; // Preview initPreviewIcon(mPreviewLabel, searchEntry::getIconDrawable); } private void customIconShortcut(Bundle args) { Context context = requireContext(); String shortcutId = args.getString("shortcutId", ""); EntryItem entryItem = TBApplication.dataHandler(context).getPojo(shortcutId); if (!(entryItem instanceof ShortcutEntry)) { dismiss(); Toast.makeText(Utilities.getActivity(context), context.getString(R.string.entry_not_found, shortcutId), Toast.LENGTH_LONG).show(); return; } ShortcutEntry shortcutEntry = (ShortcutEntry) entryItem; // Preview initPreviewIcon(mPreviewLabel, shortcutEntry::getIcon); } private void customIconButton(Bundle args) { final int defaultIcon = args.getInt("defaultIcon", 0); final String buttonId = args.getString("buttonId", null); initPreviewIcon(mPreviewLabel, ctx -> { if (buttonId != null) { Drawable buttonIcon = TBApplication.iconsHandler(ctx).getButtonIcon(buttonId); if (buttonIcon != null) return buttonIcon; } return ResourcesCompat.getDrawable(getResources(), defaultIcon, null); }); } @Override public void onActivityCreated(@Nullable Bundle savedInstanceState) { super.onActivityCreated(savedInstanceState); PageAdapter adapter = (PageAdapter) mViewPager.getAdapter(); if (adapter != null) { int selectedPage = mViewPager.getCurrentItem(); // allow the adapter to load as needed mViewPager.addOnPageChangeListener(adapter); // make sure we load the selected page adapter.onPageSelected(selectedPage); } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/customicon/IconViewHolder.java ================================================ package rocks.tbog.tblauncher.customicon; import android.graphics.drawable.Drawable; import android.util.Log; import android.view.View; import android.widget.ImageView; import androidx.annotation.NonNull; import java.lang.ref.WeakReference; import rocks.tbog.tblauncher.WorkAsync.AsyncTask; import rocks.tbog.tblauncher.WorkAsync.TaskRunner; import rocks.tbog.tblauncher.result.ResultViewHelper; import rocks.tbog.tblauncher.utils.ViewHolderAdapter; public class IconViewHolder extends ViewHolderAdapter.ViewHolder { private final ImageView icon; private AsyncLoad loader = null; public IconViewHolder(View view) { super(view); icon = view.findViewById(android.R.id.icon); } @Override protected void setContent(IconData content, int position, @NonNull ViewHolderAdapter> adapter) { if (loader != null) loader.cancel(false); loader = new AsyncLoad(this); loader.execute(content); } static class AsyncLoad extends AsyncTask { private static final String TAG = AsyncLoad.class.getSimpleName(); private final WeakReference holder; protected AsyncLoad(IconViewHolder holder) { super(); this.holder = new WeakReference<>(holder); } @Override protected void onPreExecute() { IconViewHolder h = holder.get(); if (h == null || h.loader != this) return; h.icon.setImageDrawable(null); } @Override protected Drawable doInBackground(IconData iconData) { Drawable drawable = iconData.getIcon(); if (drawable == null) Log.w(TAG, "drawable `" + iconData.drawableInfo.getDrawableName() + "` from icon pack `" + iconData.iconPack.getPackPackageName() + "` doesn't load"); return drawable; } @Override protected void onPostExecute(Drawable drawable) { if (drawable == null) return; IconViewHolder h = holder.get(); if (h == null || h.loader != this) return; h.loader = null; h.icon.setImageDrawable(drawable); h.icon.setScaleX(0f); h.icon.setScaleY(0f); h.icon.setRotation((drawable.hashCode() & 1) == 1 ? 180f : -180f); h.icon.animate().scaleX(1f).scaleY(1f).rotation(0f).start(); } public void execute(IconData content) { TaskRunner.executeOnExecutor(ResultViewHelper.EXECUTOR_LOAD_ICON, this, content); } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/customicon/PageAdapter.java ================================================ package rocks.tbog.tblauncher.customicon; import android.graphics.drawable.Drawable; import android.view.View; import android.view.ViewGroup; import android.widget.Adapter; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.DialogFragment; import androidx.viewpager.widget.ViewPager; import java.util.ArrayList; class PageAdapter extends androidx.viewpager.widget.PagerAdapter implements ViewPager.OnPageChangeListener { private final ArrayList pageList = new ArrayList<>(0); private int mScrollState = ViewPager.SCROLL_STATE_IDLE; void addPage(Page page) { pageList.add(page); } @NonNull Iterable getPageIterable() { return pageList; } public void setupPageView(@NonNull IconSelectDialog iconSelectDialog) { // touch listener Page.OnItemClickListener iconClickListener = (adapter, v, position) -> { if (adapter instanceof IconAdapter) { IconData item = ((IconAdapter) adapter).getItem(position); Drawable icon = item.getIcon(); iconSelectDialog.setSelectedDrawable(icon, icon); } else if (adapter instanceof CustomShapePage.ShapedIconAdapter) { CustomShapePage.ShapedIconInfo item = ((CustomShapePage.ShapedIconAdapter) adapter).getItem(position); if (item instanceof SystemPage.PickedIconInfo) { if (((SystemPage.PickedIconInfo) item).launchPicker(iconSelectDialog, v)) return; } iconSelectDialog.setSelectedDrawable(item.getIcon(), item.getPreview()); } }; // long touch listener Page.OnItemClickListener iconLongClickListener = (adapter, v, position) -> { if (adapter instanceof IconAdapter) { IconData item = ((IconAdapter) adapter).getItem(position); iconSelectDialog.getIconPackMenu(item).show(v); } }; // setup pages for (Page page : getPageIterable()) page.setupView(iconSelectDialog, iconClickListener, iconLongClickListener); } @Override public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { //Log.d("ISDialog", String.format("onPageScrolled %d %.2f", position, positionOffset)); if (mScrollState != ViewPager.SCROLL_STATE_SETTLING) { Page pageLeft = pageList.get(position); if (!pageLeft.bDataLoaded) pageLeft.loadData(); if ((position + 1) < pageList.size()) { Page pageRight = pageList.get(position + 1); if (!pageRight.bDataLoaded) pageRight.loadData(); } } } @Override public void onPageSelected(int position) { //Log.d("ISDialog", String.format("onPageSelected %d", position)); Page page = pageList.get(position); if (!page.bDataLoaded) page.loadData(); } @Override public void onPageScrollStateChanged(int state) { //Log.d("ISDialog", String.format("onPageScrollStateChanged %d", state)); mScrollState = state; } static abstract class Page { final CharSequence pageName; final View pageView; boolean bDataLoaded = false; public interface OnItemClickListener { void onItemClick(Adapter adapter, View view, int position); } Page(CharSequence name, View view) { pageName = name; pageView = view; } abstract void setupView(@NonNull DialogFragment dialogFragment, @Nullable OnItemClickListener iconClickListener, @Nullable OnItemClickListener iconLongClickListener); public void addPickedIcon(@NonNull Drawable pickedImage, String filename) { // do nothing in the base class, override to handle image picked from gallery } void loadData() { bDataLoaded = true; } } @Override public int getCount() { return pageList.size(); } @Override public boolean isViewFromObject(@NonNull View view, @NonNull Object object) { if (!(object instanceof Page)) throw new IllegalStateException("WTF?"); return ((Page) object).pageView == view; } @Nullable @Override public CharSequence getPageTitle(int position) { return pageList.get(position).pageName; } @NonNull @Override public Object instantiateItem(@NonNull ViewGroup container, int position) { Page page = pageList.get(position); container.addView(page.pageView); return page; } @Override public void destroyItem(@NonNull ViewGroup container, int position, @NonNull Object object) { if (!(object instanceof Page)) throw new IllegalStateException("WTF?"); Page page = (Page) object; container.removeView(page.pageView); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/customicon/ShortcutPage.java ================================================ package rocks.tbog.tblauncher.customicon; import android.content.Context; import android.graphics.Bitmap; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; import android.view.View; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.DialogFragment; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.db.ShortcutRecord; import rocks.tbog.tblauncher.handler.IconsHandler; import rocks.tbog.tblauncher.shortcut.ShortcutUtil; import rocks.tbog.tblauncher.drawable.DrawableUtils; public class ShortcutPage extends CustomShapePage { private final ShortcutRecord mShortcutRecord; ShortcutPage(CharSequence name, View view, ShortcutRecord shortcutRecord) { super(name, view); mShortcutRecord = shortcutRecord; } @Override void setupView(@NonNull DialogFragment dialogFragment, @Nullable OnItemClickListener iconClickListener, @Nullable OnItemClickListener iconLongClickListener) { Context context = dialogFragment.requireContext(); super.setupView(dialogFragment, iconClickListener, iconLongClickListener); final Drawable defaultIcon; // default icon { Bitmap bitmap = ShortcutUtil.getInitialIcon(context, mShortcutRecord.dbId); defaultIcon = new BitmapDrawable(dialogFragment.getResources(), bitmap); Drawable drawable = TBApplication.iconsHandler(context).applyShortcutMask(context, bitmap); IconsHandler.IconInfo iconHandlerIconInfo = new IconsHandler.IconInfo().setNonAdaptiveIcon(drawable); ShapedIconInfo iconInfo = new StaticEntryPage.DefaultIconInfo(dialogFragment.getString(R.string.default_static_icon, mShortcutRecord.displayName), iconHandlerIconInfo); iconInfo.textId = R.string.default_icon; mShapedIconAdapter.addItem(iconInfo); } // add background { Drawable shapedDrawable = DrawableUtils.applyIconMaskShape(context, defaultIcon, mShape, mScale, mBackground); ShapedIconInfo iconInfo = new NamedIconInfo("", shapedDrawable, defaultIcon); mShapedIconAdapter.addItem(iconInfo); } // this will call generateTextIcons mLettersView.setText(mShortcutRecord.displayName); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/customicon/StaticEntryPage.java ================================================ package rocks.tbog.tblauncher.customicon; import android.content.Context; import android.graphics.drawable.Drawable; import android.view.View; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.DialogFragment; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.entry.StaticEntry; import rocks.tbog.tblauncher.drawable.DrawableUtils; import rocks.tbog.tblauncher.handler.IconsHandler; public class StaticEntryPage extends CustomShapePage { private final StaticEntry mStaticEntry; StaticEntryPage(CharSequence name, View view, StaticEntry staticEntry) { super(name, view); mStaticEntry = staticEntry; mScale = DrawableUtils.getScaleToFit(mShape); } @Override void setupView(@NonNull DialogFragment dialogFragment, @Nullable OnItemClickListener iconClickListener, @Nullable OnItemClickListener iconLongClickListener) { Context context = dialogFragment.requireContext(); super.setupView(dialogFragment, iconClickListener, iconLongClickListener); final Drawable originalDrawable; // default icon { originalDrawable = mStaticEntry.getDefaultDrawable(context); IconsHandler.IconInfo iconHandlerIconInfo = new IconsHandler.IconInfo().setNonAdaptiveIcon(originalDrawable); ShapedIconInfo iconInfo = new DefaultIconInfo(dialogFragment.getString(R.string.default_static_icon, mStaticEntry.getName()), iconHandlerIconInfo); mShapedIconAdapter.addItem(iconInfo); } // customizable default icon { Drawable shapedDrawable = DrawableUtils.applyIconMaskShape(context, originalDrawable, mShape, mScale, mBackground); ShapedIconInfo iconInfo = new NamedIconInfo(mStaticEntry.getName(), shapedDrawable, originalDrawable); mShapedIconAdapter.addItem(iconInfo); } // this will call generateTextIcons mLettersView.setText(mStaticEntry.getName()); } static class DefaultIconInfo extends CustomShapePage.DefaultIconInfo { final String name; DefaultIconInfo(@NonNull String name, IconsHandler.IconInfo icon) { super(icon); this.name = name; textId = R.string.default_icon; } @Nullable @Override CharSequence getText() { return name; } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/customicon/SystemPage.java ================================================ package rocks.tbog.tblauncher.customicon; import android.app.Activity; import android.content.ComponentName; import android.content.Context; import android.content.pm.LauncherActivityInfo; import android.content.pm.LauncherApps; import android.content.pm.PackageManager; import android.graphics.Bitmap; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; import android.os.Build; import android.util.Pair; import android.view.View; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.StringRes; import androidx.collection.ArraySet; import androidx.fragment.app.DialogFragment; import java.util.ArrayList; import java.util.List; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.drawable.DrawableUtils; import rocks.tbog.tblauncher.handler.IconsHandler; import rocks.tbog.tblauncher.icons.DrawableInfo; import rocks.tbog.tblauncher.icons.IconPackXML; import rocks.tbog.tblauncher.utils.UserHandleCompat; import rocks.tbog.tblauncher.utils.Utilities; public class SystemPage extends CustomShapePage { private final ComponentName componentName; private final UserHandleCompat userHandle; SystemPage(CharSequence name, View view, ComponentName cn, UserHandleCompat uh) { super(name, view); componentName = cn; userHandle = uh; } @Override void setupView(@NonNull DialogFragment dialogFragment, @Nullable OnItemClickListener iconClickListener, @Nullable OnItemClickListener iconLongClickListener) { super.setupView(dialogFragment, iconClickListener, iconLongClickListener); addSystemIcons(dialogFragment.getContext(), mShapedIconAdapter); // this will call generateTextIcons //mLettersView.setText(pageName); } private void addSystemIcons(Context context, ShapedIconAdapter adapter) { ArraySet dSet = new ArraySet<>(3); // add default icon { IconsHandler iconsHandler = TBApplication.getApplication(context).iconsHandler(); IconsHandler.IconInfo icon = iconsHandler.getIconForPackage(componentName, userHandle); //checkDuplicateDrawable(dSet, drawable); ShapedIconInfo iconInfo = new DefaultIconInfo(icon); iconInfo.textId = R.string.default_icon; adapter.addItem(iconInfo); } // add getActivityIcon(componentName) { Drawable drawable = null; try { drawable = context.getPackageManager().getActivityIcon(componentName); } catch (PackageManager.NameNotFoundException ignored) { } if (drawable != null) { if (checkDuplicateDrawable(dSet, drawable)) { { Drawable shapedDrawable = DrawableUtils.applyIconMaskShape(context, drawable, mShape, mScale, mBackground); addQuickOption(R.string.custom_icon_activity, shapedDrawable, drawable, adapter); } if (DrawableUtils.isAdaptiveIconDrawable(drawable)) { Drawable noBackground = DrawableUtils.applyAdaptiveIconBackgroundShape(context, drawable, DrawableUtils.SHAPE_SQUARE, true); Drawable shapedDrawable = DrawableUtils.applyIconMaskShape(context, noBackground, mShape, mScale, mBackground); addQuickOption(R.string.custom_icon_activity_adaptive_no_background, shapedDrawable, noBackground, adapter); } } } } // add getApplicationIcon(packageName) { Drawable drawable = null; try { drawable = context.getPackageManager().getApplicationIcon(componentName.getPackageName()); } catch (PackageManager.NameNotFoundException ignored) { } if (drawable != null) { if (checkDuplicateDrawable(dSet, drawable)) { Drawable shapedDrawable = DrawableUtils.applyIconMaskShape(context, drawable, mShape, mScale, mBackground); addQuickOption(R.string.custom_icon_application, shapedDrawable, drawable, adapter); } } } // add Activity BadgedIcon if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { LauncherApps launcher = (LauncherApps) context.getSystemService(Context.LAUNCHER_APPS_SERVICE); assert launcher != null; List icons = launcher.getActivityList(componentName.getPackageName(), userHandle.getRealHandle()); for (LauncherActivityInfo info : icons) { Drawable drawable = info.getBadgedIcon(0); if (drawable != null) { if (checkDuplicateDrawable(dSet, drawable)) { Drawable shapedDrawable = DrawableUtils.applyIconMaskShape(context, drawable, mShape, mScale, mBackground); addQuickOption(R.string.custom_icon_badged, shapedDrawable, drawable, adapter); } } } } } private boolean checkDuplicateDrawable(ArraySet set, Drawable drawable) { Bitmap b = null; if (drawable instanceof BitmapDrawable) b = ((BitmapDrawable) drawable).getBitmap(); if (set.contains(b)) return false; set.add(b); return true; } private static void addQuickOption(@StringRes int textId, Drawable shapedDrawable, Drawable drawable, ShapedIconAdapter adapter) { if (!(shapedDrawable instanceof BitmapDrawable)) return; ShapedIconInfo iconInfo = new ShapedIconInfo(shapedDrawable, drawable); iconInfo.textId = textId; adapter.addItem(iconInfo); } public void loadIconPackIcons(List> iconPacks) { if (iconPacks.isEmpty()) return; final Context ctx = pageView.getContext(); final ShapedIconInfo placeholderItem = new ShapedIconInfo(DrawableUtils.getProgressBarIndeterminate(ctx), null); placeholderItem.textId = R.string.icon_pack_loading; { mShapedIconAdapter.addItem(placeholderItem); } final ArrayList options = new ArrayList<>(); Utilities.runAsync((t) -> { for (Pair packInfo : iconPacks) { String packPackageName = packInfo.first; String packName = packInfo.second; Activity activity = Utilities.getActivity(pageView); if (activity != null) { IconPackXML pack = TBApplication.iconPackCache(activity).getIconPack(packPackageName); pack.load(activity.getPackageManager()); DrawableInfo info = pack.getComponentDrawable(activity, componentName, userHandle); Drawable drawable = pack.getDrawable(info); if (drawable != null) { Drawable shapedDrawable = DrawableUtils.applyIconMaskShape(activity, drawable, mShape, mScale, mBackground); NamedIconInfo iconInfo = new NamedIconInfo(packName, shapedDrawable, drawable); options.add(iconInfo); } } else { break; } } }, (t) -> { Activity activity = Utilities.getActivity(pageView); if (activity != null) { final ShapedIconAdapter adapter = mShapedIconAdapter; adapter.removeItem(placeholderItem); adapter.addItems(options); } }); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/dataprovider/ActionProvider.java ================================================ package rocks.tbog.tblauncher.dataprovider; import android.content.Context; import android.view.View; import androidx.annotation.NonNull; import androidx.annotation.StringRes; import java.util.Collections; import java.util.Iterator; import java.util.List; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.TBLauncherActivity; import rocks.tbog.tblauncher.entry.ActionEntry; import rocks.tbog.tblauncher.entry.EntryItem; import rocks.tbog.tblauncher.handler.DataHandler; import rocks.tbog.tblauncher.searcher.HistorySearcher; import rocks.tbog.tblauncher.searcher.Searcher; import rocks.tbog.tblauncher.searcher.TagList; import rocks.tbog.tblauncher.ui.ListPopup; import rocks.tbog.tblauncher.utils.DebugInfo; public class ActionProvider extends DBProvider { private static final ActionEntry[] s_entries = new ActionEntry[19]; @StringRes private static final int[] s_names = new int[19]; static { int cnt = 0; { String id = ActionEntry.SCHEME + "toggle/grid"; ActionEntry actionEntry = new ActionEntry(id, R.drawable.ic_grid); actionEntry.setAction((v, flags) -> { TBLauncherActivity act = TBApplication.launcherActivity(v.getContext()); if (act == null) return; // toggle grid/list layout if (act.behaviour.isGridLayout()) act.behaviour.setListLayout(); else act.behaviour.setGridLayout(); }); s_names[cnt] = R.string.action_toggle_grid; s_entries[cnt++] = actionEntry; } // show apps sorted by name { String id = ActionEntry.SCHEME + "show/apps/byName"; ActionEntry actionEntry = new ActionEntry(id, R.drawable.ic_apps_list_az); actionEntry.setAction((v, flags) -> { TBApplication app = TBApplication.getApplication(v.getContext()); TBLauncherActivity act = app.launcherActivity(); if (act == null) return; Provider provider = app.getDataHandler().getAppProvider(); act.quickList.toggleProvider(v, provider, EntryItem.NAME_COMPARATOR); act.behaviour.setListLayout(); }); s_names[cnt] = R.string.action_show_apps; s_entries[cnt++] = actionEntry; } // show apps sorted by name in reverse order { String id = ActionEntry.SCHEME + "show/apps/byNameReversed"; ActionEntry actionEntry = new ActionEntry(id, R.drawable.ic_apps_list_za); actionEntry.setAction((v, flags) -> { TBApplication app = TBApplication.getApplication(v.getContext()); TBLauncherActivity act = app.launcherActivity(); if (act == null) return; Provider provider = app.getDataHandler().getAppProvider(); act.quickList.toggleProvider(v, provider, Collections.reverseOrder(EntryItem.NAME_COMPARATOR)); act.behaviour.setListLayout(); }); s_names[cnt] = R.string.action_show_apps_reversed; s_entries[cnt++] = actionEntry; } // show apps in a 4 column grid sorted by name { String id = ActionEntry.SCHEME + "show/grid4c/apps/byName"; ActionEntry actionEntry = new ActionEntry(id, R.drawable.ic_apps_grid_az); actionEntry.setAction((v, flags) -> { TBApplication app = TBApplication.getApplication(v.getContext()); TBLauncherActivity act = app.launcherActivity(); if (act == null) return; Provider provider = app.getDataHandler().getAppProvider(); act.quickList.toggleProvider(v, provider, EntryItem.NAME_COMPARATOR); act.behaviour.setGridLayout(4); }); s_names[cnt] = R.string.action_show_apps_grid4; s_entries[cnt++] = actionEntry; } // show apps in a 4 column grid sorted by name in reverse order { String id = ActionEntry.SCHEME + "show/grid4c/apps/byNameReversed"; ActionEntry actionEntry = new ActionEntry(id, R.drawable.ic_apps_grid_za); actionEntry.setAction((v, flags) -> { TBApplication app = TBApplication.getApplication(v.getContext()); TBLauncherActivity act = app.launcherActivity(); if (act == null) return; Provider provider = app.getDataHandler().getAppProvider(); act.quickList.toggleProvider(v, provider, Collections.reverseOrder(EntryItem.NAME_COMPARATOR)); act.behaviour.setGridLayout(4); }); s_names[cnt] = R.string.action_show_apps_grid4_reversed; s_entries[cnt++] = actionEntry; } // show contacts sorted by name { String id = ActionEntry.SCHEME + "show/contacts/byName"; ActionEntry actionEntry = new ActionEntry(id, R.drawable.ic_contacts_az); actionEntry.setAction((v, flags) -> { TBApplication app = TBApplication.getApplication(v.getContext()); TBLauncherActivity act = app.launcherActivity(); if (act == null) return; Provider provider = app.getDataHandler().getContactsProvider(); act.quickList.toggleProvider(v, provider, EntryItem.NAME_COMPARATOR); }); s_names[cnt] = R.string.action_show_contacts; s_entries[cnt++] = actionEntry; } // show contacts sorted by name in reverse order { String id = ActionEntry.SCHEME + "show/contacts/byNameReversed"; ActionEntry actionEntry = new ActionEntry(id, R.drawable.ic_contacts_za); actionEntry.setAction((v, flags) -> { TBApplication app = TBApplication.getApplication(v.getContext()); TBLauncherActivity act = app.launcherActivity(); if (act == null) return; Provider provider = app.getDataHandler().getContactsProvider(); act.quickList.toggleProvider(v, provider, Collections.reverseOrder(EntryItem.NAME_COMPARATOR)); }); s_names[cnt] = R.string.action_show_contacts_reversed; s_entries[cnt++] = actionEntry; } // show shortcuts sorted by name { String id = ActionEntry.SCHEME + "show/shortcuts/byName"; ActionEntry actionEntry = new ActionEntry(id, R.drawable.ic_shortcuts_az); actionEntry.setAction((v, flags) -> { TBApplication app = TBApplication.getApplication(v.getContext()); TBLauncherActivity act = app.launcherActivity(); if (act == null) return; Provider provider = app.getDataHandler().getShortcutsProvider(); act.quickList.toggleProvider(v, provider, EntryItem.NAME_COMPARATOR); }); s_names[cnt] = R.string.action_show_shortcuts; s_entries[cnt++] = actionEntry; } // show shortcuts sorted by name in reverse order { String id = ActionEntry.SCHEME + "show/shortcuts/byNameReversed"; ActionEntry actionEntry = new ActionEntry(id, R.drawable.ic_shortcuts_za); actionEntry.setAction((v, flags) -> { TBApplication app = TBApplication.getApplication(v.getContext()); TBLauncherActivity act = app.launcherActivity(); if (act == null) return; Provider provider = app.getDataHandler().getShortcutsProvider(); act.quickList.toggleProvider(v, provider, Collections.reverseOrder(EntryItem.NAME_COMPARATOR)); }); s_names[cnt] = R.string.action_show_shortcuts_reversed; s_entries[cnt++] = actionEntry; } // show favorites sorted by name (removed by load task if not enabled) { String id = ActionEntry.SCHEME + "show/favorites/byName"; ActionEntry actionEntry = new ActionEntry(id, R.drawable.ic_favorites); actionEntry.setAction((v, flags) -> { TBApplication app = TBApplication.getApplication(v.getContext()); TBLauncherActivity act = app.launcherActivity(); if (act == null) return; ModProvider provider = app.getDataHandler().getModProvider(); act.quickList.toggleProvider(v, provider, EntryItem.NAME_COMPARATOR); }); s_names[cnt] = R.string.action_show_favorites; s_entries[cnt++] = actionEntry; } // show history sorted by how recent it was accessed { String id = ActionEntry.SCHEME + "show/history/recency"; ActionEntry actionEntry = new ActionEntry(id, R.drawable.ic_history); actionEntry.setAction((v, f) -> toggleSearch(v, "recency", HistorySearcher.class)); s_names[cnt] = R.string.action_show_history_recency; s_entries[cnt++] = actionEntry; } // show history sorted by how frequent it was accessed { String id = ActionEntry.SCHEME + "show/history/frequency"; ActionEntry actionEntry = new ActionEntry(id, R.drawable.ic_history); actionEntry.setAction((v, f) -> toggleSearch(v, "frequency", HistorySearcher.class)); s_names[cnt] = R.string.action_show_history_frequency; s_entries[cnt++] = actionEntry; } // show history sorted based on frequency * recency // frequency = #launches_for_app / #all_launches // recency = 1 / position_of_app_in_normal_history { String id = ActionEntry.SCHEME + "show/history/frecency"; ActionEntry actionEntry = new ActionEntry(id, R.drawable.ic_history); actionEntry.setAction((v, f) -> toggleSearch(v, "frecency", HistorySearcher.class)); s_names[cnt] = R.string.action_show_history_frecency; s_entries[cnt++] = actionEntry; } // show history sorted by how frequent it was accessed in the last 36 hours { String id = ActionEntry.SCHEME + "show/history/adaptive"; ActionEntry actionEntry = new ActionEntry(id, R.drawable.ic_history); actionEntry.setAction((v, f) -> toggleSearch(v, "adaptive", HistorySearcher.class)); s_names[cnt] = R.string.action_show_history_adaptive; s_entries[cnt++] = actionEntry; } { String id = ActionEntry.SCHEME + "show/untagged"; ActionEntry actionEntry = new ActionEntry(id, R.drawable.ic_untagged); actionEntry.setAction((v, f) -> toggleSearch(v, "untagged", TagList.class)); s_names[cnt] = R.string.action_show_untagged; s_entries[cnt++] = actionEntry; } { String id = ActionEntry.SCHEME + "show/tags/menu"; ActionEntry actionEntry = new ActionEntry(id, R.drawable.ic_tags); actionEntry.setAction((v, flags) -> { Context ctx = v.getContext(); TBApplication app = TBApplication.getApplication(ctx); ListPopup menu = app.tagsHandler().getTagsMenu(ctx); app.registerPopup(menu); menu.show(v); }); s_names[cnt] = R.string.show_tags_menu; s_entries[cnt++] = actionEntry; } { String id = ActionEntry.SCHEME + "show/tags/list"; ActionEntry actionEntry = new ActionEntry(id, R.drawable.ic_tags); actionEntry.setAction((v, f) -> toggleSearch(v, "list", TagList.class)); s_names[cnt] = R.string.show_tags_list; s_entries[cnt++] = actionEntry; } { String id = ActionEntry.SCHEME + "show/tags/listReversed"; ActionEntry actionEntry = new ActionEntry(id, R.drawable.ic_tags); actionEntry.setAction((v, f) -> toggleSearch(v, "listReversed", TagList.class)); s_names[cnt] = R.string.show_tags_list_reversed; s_entries[cnt++] = actionEntry; } { String id = ActionEntry.SCHEME + "reload/providers"; ActionEntry actionEntry = new ActionEntry(id, R.drawable.ic_refresh); actionEntry.setAction((v, flags) -> { Context ctx = v.getContext(); TBApplication.dataHandler(ctx).reloadProviders(); }); s_names[cnt] = R.string.action_reload; s_entries[cnt++] = actionEntry; } //noinspection ConstantConditions if (cnt != s_entries.length || cnt != s_names.length) throw new IllegalStateException("ActionEntry static list size"); } private static void toggleSearch(@NonNull View v, @NonNull String query, @NonNull Class searcherClass) { TBLauncherActivity act = TBApplication.launcherActivity(v.getContext()); if (act != null) act.quickList.toggleSearch(v, query, searcherClass); } public ActionProvider(@NonNull Context context) { super(context); } @Override protected DBLoader newLoadTask() { return new UpdateFromModsLoader(this, s_entries, s_names) { @Override public List getEntryItems(DataHandler dataHandler) { List entries = super.getEntryItems(dataHandler); Context context = dataHandler.getContext(); if (context == null || !DebugInfo.enableFavorites(context)) { // remove debug entry for (Iterator iterator = entries.iterator(); iterator.hasNext(); ) { ActionEntry entry = iterator.next(); if (entry.id.endsWith("show/favorites/byName")) iterator.remove(); } } return entries; } }; } @Override public boolean mayFindById(@NonNull String id) { return id.startsWith(ActionEntry.SCHEME); } @NonNull public String getDefaultName(@NonNull String id) { for (int idx = 0; idx < s_entries.length; idx += 1) { if (id.equals(s_entries[idx].id)) return context.getString(s_names[idx]); } return "null"; } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/dataprovider/AppCacheProvider.java ================================================ package rocks.tbog.tblauncher.dataprovider; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; import java.util.Collection; import java.util.List; import java.util.concurrent.CountDownLatch; import rocks.tbog.tblauncher.entry.AppEntry; import rocks.tbog.tblauncher.handler.AppsHandler; import rocks.tbog.tblauncher.normalizer.StringNormalizer; import rocks.tbog.tblauncher.searcher.ISearcher; import rocks.tbog.tblauncher.utils.FuzzyScore; import rocks.tbog.tblauncher.utils.Timer; public class AppCacheProvider implements IProvider { final static String TAG = "AppCP"; final private AppsHandler appsHandler; public AppCacheProvider(@NonNull AppsHandler handler) { appsHandler = handler; } @WorkerThread @Override public void requestResults(String query, ISearcher searcher) { StringNormalizer.Result queryNormalized = StringNormalizer.normalizeWithResult(query, false); if (queryNormalized.codePoints.length == 0) { return; } final CountDownLatch latch = new CountDownLatch(1); // notify that the tags are loaded appsHandler.runWhenLoaded(latch::countDown); // wait for the tags to load try { latch.await(); } catch (InterruptedException e) { Log.e(TAG, "waiting for TagsHandler", e); } final Collection entries = appsHandler.getAllApps(); FuzzyScore fuzzyScore = new FuzzyScore(queryNormalized.codePoints); EntryToResultUtils.tagsCheckResults(entries, fuzzyScore, searcher); } public void reload(boolean cancelCurrentLoadTask) { } @Override public boolean isLoaded() { return true; } @Override public Timer getLoadDuration() { return null; } @Override public void setDirty() { // do nothing, we already have the full list of items } @Override public int getLoadStep() { return LOAD_STEP_1; } @Override public boolean mayFindById(@NonNull String id) { return false; } @Override public AppEntry findById(@NonNull String id) { return null; } @Nullable @Override public List getPojos() { return null; } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/dataprovider/AppProvider.java ================================================ package rocks.tbog.tblauncher.dataprovider; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.content.pm.LauncherApps; import android.os.Build; import android.os.Process; import android.os.UserManager; import androidx.annotation.NonNull; import androidx.annotation.RequiresApi; import androidx.annotation.WorkerThread; import androidx.core.app.ActivityCompat; import androidx.core.content.ContextCompat; import java.util.ArrayList; import java.util.Objects; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.broadcast.PackageAddedRemovedHandler; import rocks.tbog.tblauncher.entry.AppEntry; import rocks.tbog.tblauncher.loader.LoadAppEntry; import rocks.tbog.tblauncher.loader.LoadCacheApps; import rocks.tbog.tblauncher.searcher.ISearcher; import rocks.tbog.tblauncher.utils.UserHandleCompat; public class AppProvider extends Provider { boolean mInitialLoad = true; AppsCallback mAppsCallback = null; final BroadcastReceiver mProfileReceiver = new BroadcastReceiver() { @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) @Override public void onReceive(Context context, Intent intent) { if (Objects.equals(intent.getAction(), Intent.ACTION_MANAGED_PROFILE_ADDED)) { AppProvider.this.reload(true); } else if (Objects.equals(intent.getAction(), Intent.ACTION_MANAGED_PROFILE_REMOVED)) { // android.os.UserHandle profile = intent.getParcelableExtra(Intent.EXTRA_USER); // final UserManager manager = (UserManager) AppProvider.this.getSystemService(Context.USER_SERVICE); // assert manager != null; // UserHandleCompat user = new UserHandleCompat(manager.getSerialNumberForUser(profile), profile); // DataHandler dataHandler = TBApplication.getApplication(context).getDataHandler(); // dataHandler.removeFromExcluded(user); // dataHandler.removeFromMods(user); AppProvider.this.reload(true); } } }; final PackageAddedRemovedHandler mPackageAddedRemovedHandler = new PackageAddedRemovedHandler(); @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) static class AppsCallback extends LauncherApps.Callback { private final Context context; AppsCallback(Context context) { this.context = context; } @Override public void onPackageAdded(String packageName, android.os.UserHandle user) { handleEvent(Intent.ACTION_PACKAGE_ADDED, packageName, user, false); } @Override public void onPackageChanged(String packageName, android.os.UserHandle user) { handleEvent(Intent.ACTION_PACKAGE_CHANGED, packageName, user, true); } @Override public void onPackageRemoved(String packageName, android.os.UserHandle user) { handleEvent(Intent.ACTION_PACKAGE_REMOVED, packageName, user, false); } @Override public void onPackagesAvailable(String[] packageNames, android.os.UserHandle user, boolean replacing) { handleEvent(Intent.ACTION_MEDIA_MOUNTED, null, user, replacing); } @Override public void onPackagesUnavailable(String[] packageNames, android.os.UserHandle user, boolean replacing) { handleEvent(Intent.ACTION_MEDIA_UNMOUNTED, null, user, replacing); } @Override public void onPackagesSuspended(String[] packageNames, android.os.UserHandle user) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { handleEvent(Intent.ACTION_PACKAGES_SUSPENDED, null, user, false); } } @Override public void onPackagesUnsuspended(String[] packageNames, android.os.UserHandle user) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { handleEvent(Intent.ACTION_PACKAGES_UNSUSPENDED, null, user, false); } } private void handleEvent(String action, String packageName, android.os.UserHandle user, boolean replacing) { if (!Process.myUserHandle().equals(user)) { final UserManager manager = (UserManager) context.getSystemService(Context.USER_SERVICE); PackageAddedRemovedHandler.handleEvent(context, action, packageName, new UserHandleCompat(manager.getSerialNumberForUser(user), user), replacing ); } } } @Override public void onCreate() { if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { // Package install/uninstall events for the main // profile are still handled using PackageAddedRemovedHandler itself final LauncherApps launcher = (LauncherApps) this.getSystemService(Context.LAUNCHER_APPS_SERVICE); assert launcher != null; mAppsCallback = new AppsCallback(this); launcher.registerCallback(mAppsCallback); // Try to clean up app-related data when profile is removed IntentFilter filter = new IntentFilter(); filter.addAction(Intent.ACTION_MANAGED_PROFILE_ADDED); filter.addAction(Intent.ACTION_MANAGED_PROFILE_REMOVED); ActivityCompat.registerReceiver(this, mProfileReceiver, filter, ContextCompat.RECEIVER_EXPORTED); } // Get notified when app changes on standard user profile IntentFilter appChangedFilter = new IntentFilter(); appChangedFilter.addAction(Intent.ACTION_PACKAGE_ADDED); appChangedFilter.addAction(Intent.ACTION_PACKAGE_CHANGED); appChangedFilter.addAction(Intent.ACTION_PACKAGE_REMOVED); appChangedFilter.addAction(Intent.ACTION_MEDIA_MOUNTED); appChangedFilter.addAction(Intent.ACTION_MEDIA_REMOVED); appChangedFilter.addAction(Intent.ACTION_EXTERNAL_APPLICATIONS_AVAILABLE); appChangedFilter.addAction(Intent.ACTION_EXTERNAL_APPLICATIONS_UNAVAILABLE); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { appChangedFilter.addAction(Intent.ACTION_PACKAGES_SUSPENDED); appChangedFilter.addAction(Intent.ACTION_PACKAGES_UNSUSPENDED); } appChangedFilter.addDataScheme("package"); appChangedFilter.addDataScheme("file"); ActivityCompat.registerReceiver(this, mPackageAddedRemovedHandler, appChangedFilter, ContextCompat.RECEIVER_EXPORTED); super.onCreate(); } @Override public void onDestroy() { unregisterReceiver(mProfileReceiver); unregisterReceiver(mPackageAddedRemovedHandler); if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP) { LauncherApps launcher = (LauncherApps) this.getSystemService(Context.LAUNCHER_APPS_SERVICE); assert launcher != null; launcher.unregisterCallback(mAppsCallback); } super.onDestroy(); } public void reload(boolean cancelCurrentLoadTask) { super.reload(cancelCurrentLoadTask); if (!isLoaded() && !isLoading()) { if (mInitialLoad) { // Use DB cache to speed things up. We'll reload after. this.initialize(new LoadCacheApps(this)); } else { this.initialize(new LoadAppEntry(this)); } } } @Override public void loadOver(ArrayList results) { super.loadOver(results); if (mInitialLoad) { mInitialLoad = false; // Got DB cache. Do a reload later. TBApplication.dataHandler(this).runAfterLoadOver(() -> { this.reload(false); }); } else { TBApplication.appsHandler(this).setAppCache(results); } } /** * @param query The string to search for * @param searcher The receiver of results */ @WorkerThread @Override public void requestResults(String query, ISearcher searcher) { for (AppEntry pojo : pojos) pojo.resetResultInfo(); EntryToResultUtils.recursiveWordCheck(pojos, query, searcher, EntryToResultUtils::tagsCheckResults, AppEntry.class); } /** * Return a Pojo * * @param id we're looking for * @return an AppEntry, or null */ @Override public AppEntry findById(@NonNull String id) { for (AppEntry pojo : pojos) { if (pojo.id.equals(id)) { return pojo; } } return null; } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/dataprovider/CalculatorProvider.java ================================================ package rocks.tbog.tblauncher.dataprovider; import java.math.BigDecimal; import java.text.NumberFormat; import java.util.ArrayDeque; import java.util.regex.Matcher; import java.util.regex.Pattern; import rocks.tbog.tblauncher.calculator.Calculator; import rocks.tbog.tblauncher.calculator.Result; import rocks.tbog.tblauncher.calculator.ShuntingYard; import rocks.tbog.tblauncher.calculator.Tokenizer; import rocks.tbog.tblauncher.entry.CalculatorEntry; import rocks.tbog.tblauncher.searcher.ISearcher; public class CalculatorProvider extends SimpleProvider { private final Pattern computableRegexp; // A regexp to detect plain numbers (including phone numbers) private final Pattern numberOnlyRegexp; private final NumberFormat LOCALIZED_NUMBER_FORMATTER = NumberFormat.getInstance(); public CalculatorProvider() { //This should try to match as much as possible without going out of the expression, //even if the expression is not actually a computable operation. computableRegexp = Pattern.compile("^[\\-.,\\d+*×x/÷^'()]+$"); numberOnlyRegexp = Pattern.compile("^\\+?[.,()\\d]+$"); } @Override public void requestResults(String query, ISearcher searcher) { String spacelessQuery = query.replaceAll("\\s+", ""); // Now create matcher object. Matcher m = computableRegexp.matcher(spacelessQuery); if (m.find()) { if (numberOnlyRegexp.matcher(spacelessQuery).find()) { return; } String operation = m.group(); Result> tokenized = Tokenizer.tokenize(operation); String readableResult; if (tokenized.syntacticalError) { return; } else if (tokenized.arithmeticalError) { return; } else { Result> posfixed = ShuntingYard.infixToPostfix(tokenized.result); if (posfixed.syntacticalError) { return; } else if (posfixed.arithmeticalError) { return; } else { Result result = Calculator.calculateExpression(posfixed.result); if (result.syntacticalError) { return; } else if (result.arithmeticalError) { return; } else { String localizedNumber = LOCALIZED_NUMBER_FORMATTER.format(result.result); readableResult = " = " + localizedNumber; } } } String queryProcessed = operation + readableResult; CalculatorEntry pojo = new CalculatorEntry(queryProcessed); pojo.setRelevance(pojo.normalizedName, null); pojo.boostRelevance(19); searcher.addResult(pojo); } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/dataprovider/ContactsProvider.java ================================================ package rocks.tbog.tblauncher.dataprovider; import android.content.SharedPreferences; import android.database.ContentObserver; import android.provider.ContactsContract; import android.util.Log; import androidx.annotation.WorkerThread; import androidx.preference.PreferenceManager; import java.util.Collection; import rocks.tbog.tblauncher.Permission; import rocks.tbog.tblauncher.entry.ContactEntry; import rocks.tbog.tblauncher.loader.LoadContactsEntry; import rocks.tbog.tblauncher.normalizer.PhoneNormalizer; import rocks.tbog.tblauncher.normalizer.StringNormalizer; import rocks.tbog.tblauncher.searcher.ISearcher; import rocks.tbog.tblauncher.utils.FuzzyScore; public class ContactsProvider extends Provider { private final static String TAG = "ContactsProvider"; private final ContentObserver cObserver = new ContentObserver(null) { @Override public void onChange(boolean selfChange) { //reload contacts Log.i(TAG, "Contacts changed, reloading provider."); reload(true); } }; public void reload(boolean cancelCurrentLoadTask) { super.reload(cancelCurrentLoadTask); if (!isLoaded() && !isLoading()) this.initialize(new LoadContactsEntry(this)); } @Override public void onCreate() { super.onCreate(); // register content observer if we have permission if (Permission.checkPermission(this, Permission.PERMISSION_READ_CONTACTS)) { getContentResolver().registerContentObserver(ContactsContract.Contacts.CONTENT_URI, false, cObserver); } else { Permission.askPermission(Permission.PERMISSION_READ_CONTACTS, new Permission.PermissionResultListener() { @Override public void onGranted() { // Great! Reload the contact provider. We're done :) reload(true); } @Override public void onDenied() { SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(ContactsProvider.this); pref.edit().putBoolean("enable-contacts", false).apply(); } }); } } @Override public void onDestroy() { super.onDestroy(); //deregister content observer getContentResolver().unregisterContentObserver(cObserver); } @Override public void requestResults(String query, ISearcher searcher) { for (ContactEntry pojo : pojos) pojo.resetResultInfo(); EntryToResultUtils.recursiveWordCheck(pojos, query, searcher, ContactsProvider::checkResults, ContactEntry.class); } @WorkerThread public static void checkResults(Collection entries, FuzzyScore fuzzyScore, ISearcher searcher) { Log.d(TAG, "checkResults count=" + entries.size() + " " + fuzzyScore); for (ContactEntry entry : entries) { FuzzyScore.MatchInfo scoreInfo = fuzzyScore.match(entry.normalizedName.codePoints); StringNormalizer.Result matchedText = entry.normalizedName; FuzzyScore.MatchInfo matchedInfo = FuzzyScore.MatchInfo.copyOrNewInstance(scoreInfo, null); if (entry.normalizedNickname != null) { scoreInfo = fuzzyScore.match(entry.normalizedNickname.codePoints); if (scoreInfo.match && (!matchedInfo.match || scoreInfo.score > matchedInfo.score)) { matchedText = entry.normalizedNickname; matchedInfo = FuzzyScore.MatchInfo.copyOrNewInstance(scoreInfo, matchedInfo); } } if (!matchedInfo.match && entry.normalizedPhone != null && fuzzyScore.getPatternLength() > 2) { // search for the phone number scoreInfo = fuzzyScore.match(entry.normalizedPhone.codePoints); if (scoreInfo.match && scoreInfo.score > matchedInfo.score) { matchedText = entry.normalizedPhone; matchedInfo = FuzzyScore.MatchInfo.copyOrNewInstance(scoreInfo, matchedInfo); } } entry.addResultMatch(matchedText, matchedInfo); if (matchedInfo.match) { int boost = Math.min(30, entry.getTimesContacted()); if (entry.isStarred()) { boost += 40; } entry.boostRelevance(boost); if (!searcher.addResult(entry)) return; } } } /** * Find a ContactsPojo from a phoneNumber * If many contacts match, the one most often contacted will be returned * * @param phoneNumber phone number to find (will be normalized) * @return a contactpojo, or null. */ public ContactEntry findByPhone(String phoneNumber) { StringNormalizer.Result simplifiedPhoneNumber = PhoneNormalizer.simplifyPhoneNumber(phoneNumber); for (ContactEntry pojo : pojos) { if (pojo.normalizedPhone.equals(simplifiedPhoneNumber)) { return pojo; } } return null; } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/dataprovider/DBProvider.java ================================================ package rocks.tbog.tblauncher.dataprovider; import android.content.Context; import android.util.Log; import androidx.annotation.MainThread; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.Collections; import java.util.List; import rocks.tbog.tblauncher.BuildConfig; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.TBLauncherActivity; import rocks.tbog.tblauncher.WorkAsync.AsyncTask; import rocks.tbog.tblauncher.WorkAsync.TaskRunner; import rocks.tbog.tblauncher.entry.EntryItem; import rocks.tbog.tblauncher.handler.DataHandler; import rocks.tbog.tblauncher.searcher.ISearcher; import rocks.tbog.tblauncher.utils.Timer; public abstract class DBProvider implements IProvider { final Context context; protected List entryList = new ArrayList<>(); private boolean mIsLoaded = false; private DBLoader mLoadTask = null; protected final Timer mTimer = new Timer(); public DBProvider(Context context) { this.context = context; } @Override public void requestResults(String query, ISearcher searcher) { } @Override public void reload(boolean cancelCurrentLoadTask) { if (!cancelCurrentLoadTask && mLoadTask != null) return; setDirty(); Log.i(Provider.TAG, "Starting provider: " + this.getClass().getSimpleName()); mTimer.start(); mLoadTask = newLoadTask(); mLoadTask.execute(); } protected abstract DBLoader newLoadTask(); @Override public boolean isLoaded() { return mIsLoaded; } @Override public Timer getLoadDuration() { return mTimer; } protected void setLoaded() { mIsLoaded = true; } @Override public void setDirty() { // mark this as not loaded and wait for DataHandler to call reload mIsLoaded = false; if (mLoadTask != null) mLoadTask.cancel(true); mLoadTask = null; } @Override public int getLoadStep() { return LOAD_STEP_2; } /** * Whether or not this provider may be able to find a pojo with the specified id * * @param id id we're looking for * @return true if the provider can handle the query; does not guarantee it will! */ @Override public boolean mayFindById(@NonNull String id) { return false; } /** * Try to find a record by its id * * @param id id we're looking for * @return null if not found */ @Override public T findById(@NonNull String id) { for (T entryItem : entryList) { if (entryItem.id.equals(id)) { return entryItem; } } return null; } @Nullable @Override public List getPojos() { if (BuildConfig.DEBUG) return Collections.unmodifiableList(entryList); return entryList; } protected abstract static class DBLoader extends AsyncTask> { protected final WeakReference> weakProvider; public DBLoader(DBProvider provider) { super(); weakProvider = new WeakReference<>(provider); } @Nullable protected Context getContext() { DBProvider provider = weakProvider.get(); return provider != null ? provider.context : null; } @WorkerThread @Override protected List doInBackground(Void param) { Context ctx = getContext(); if (ctx == null) return null; DataHandler dataHandler = TBApplication.getApplication(ctx).getDataHandler(); return getEntryItems(dataHandler); } @WorkerThread abstract List getEntryItems(DataHandler dataHandler); @MainThread @Override protected void onPostExecute(List entryItems) { DBProvider provider = weakProvider.get(); if (entryItems == null || provider == null || provider.mLoadTask != this) return; // get the result provider.entryList = entryItems; // mark the provider as loaded provider.setLoaded(); provider.mLoadTask = null; provider.mTimer.stop(); Log.i("time", "Time to load " + provider.getClass().getSimpleName() + ": " + provider.mTimer); DataHandler.sendBroadcast(provider.context, TBLauncherActivity.LOAD_OVER, provider.getClass().getSimpleName()); } public void execute() { TaskRunner.executeOnExecutor(DataHandler.EXECUTOR_PROVIDERS, this); } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/dataprovider/DialProvider.java ================================================ package rocks.tbog.tblauncher.dataprovider; import androidx.annotation.NonNull; import java.util.regex.Pattern; import rocks.tbog.tblauncher.entry.ContactEntry; import rocks.tbog.tblauncher.entry.DialContactEntry; import rocks.tbog.tblauncher.searcher.ISearcher; public class DialProvider extends SimpleProvider { // See https://github.com/Neamar/KISS/issues/1137 private final Pattern phonePattern; private final DialContactEntry resultEntry; public DialProvider() { phonePattern = Pattern.compile("^[*+0-9# ]{3,}$"); resultEntry = new DialContactEntry(); } @Override public boolean mayFindById(@NonNull String id) { return id.startsWith(DialContactEntry.SCHEME); } @Override public DialContactEntry findById(@NonNull String id) { if (resultEntry.id.equals(id)) return resultEntry; return null; } @Override public void requestResults(String query, ISearcher searcher) { // Append an item only if query looks like a phone number and device has phone capabilities if (phonePattern.matcher(query).find()) { searcher.addResult(getResult(query)); } } /** * @param phoneNumber phone number to use in the result * @return a result that may have a fake id. */ private ContactEntry getResult(String phoneNumber) { DialContactEntry pojo = resultEntry; pojo.setPhone(phoneNumber); pojo.setName(phoneNumber, false); pojo.setRelevance(pojo.normalizedName, null); String phoneNumberAfterFirstCharacter = phoneNumber.substring(1); if (!phoneNumberAfterFirstCharacter.contains("*") && !phoneNumberAfterFirstCharacter.contains("+")) { // No * and no + (except maybe as a first character), likely to be a phone number and not a Calculator expression pojo.boostRelevance(20); } else { // Query may be a phone number or a calculator expression, more likely to be an expression // Calculator expressions have a relevance of 19, so use something lower pojo.boostRelevance(15); } return pojo; } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/dataprovider/EntryToResultUtils.java ================================================ package rocks.tbog.tblauncher.dataprovider; import android.util.Log; import androidx.annotation.WorkerThread; import java.util.Collection; import rocks.tbog.tblauncher.entry.EntryItem; import rocks.tbog.tblauncher.entry.EntryWithTags; import rocks.tbog.tblauncher.normalizer.StringNormalizer; import rocks.tbog.tblauncher.searcher.ISearcher; import rocks.tbog.tblauncher.searcher.ResultBuffer; import rocks.tbog.tblauncher.utils.FuzzyScore; public class EntryToResultUtils { final static String TAG = "E2R"; interface CheckResults { void checkResults(Collection entries, FuzzyScore fuzzyScore, ISearcher searcher); } @WorkerThread public static void recursiveWordCheck(Collection entries, String query, ISearcher searcher, CheckResults action, Class typeClass) { int pos = query.lastIndexOf(' '); if (pos > 0) { String queryLeft = query.substring(0, pos).trim(); String queryRight = query.substring(pos + 1).trim(); StringNormalizer.Result queryNormalizedRight = StringNormalizer.normalizeWithResult(queryRight, false); if (queryNormalizedRight.codePoints.length > 0) { ResultBuffer buffer = new ResultBuffer<>(searcher.tagsEnabled(), typeClass); recursiveWordCheck(entries, queryLeft, buffer, action, typeClass); FuzzyScore fuzzyScoreRight = new FuzzyScore(queryNormalizedRight.codePoints); action.checkResults(buffer.getEntryItems(), fuzzyScoreRight, searcher); return; } } StringNormalizer.Result queryNormalized = StringNormalizer.normalizeWithResult(query, false); if (queryNormalized.codePoints.length == 0) return; FuzzyScore fuzzyScore = new FuzzyScore(queryNormalized.codePoints); action.checkResults(entries, fuzzyScore, searcher); } @WorkerThread public static void tagsCheckResults(Collection entries, FuzzyScore fuzzyScore, ISearcher searcher) { Log.d(TAG, "tagsCheckResults count=" + entries.size() + " " + fuzzyScore); for (EntryWithTags entry : entries) { if (entry.isHiddenByUser()) { continue; } FuzzyScore.MatchInfo scoreInfo = fuzzyScore.match(entry.normalizedName.codePoints); StringNormalizer.Result matchedText = entry.normalizedName; FuzzyScore.MatchInfo matchedInfo = FuzzyScore.MatchInfo.copyOrNewInstance(scoreInfo, null); if (searcher.tagsEnabled()) { // check relevance for tags for (EntryWithTags.TagDetails tag : entry.getTags()) { // fuzzyScore.match will return the same object scoreInfo = fuzzyScore.match(tag.normalized.codePoints); if (scoreInfo.match && (!matchedInfo.match || scoreInfo.score > matchedInfo.score)) { matchedText = tag.normalized; matchedInfo = FuzzyScore.MatchInfo.copyOrNewInstance(scoreInfo, matchedInfo); } } } entry.addResultMatch(matchedText, matchedInfo); if (matchedInfo.match && !searcher.addResult(entry)) { return; } } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/dataprovider/FilterProvider.java ================================================ package rocks.tbog.tblauncher.dataprovider; import android.content.Context; import androidx.annotation.NonNull; import androidx.annotation.StringRes; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.entry.AppEntry; import rocks.tbog.tblauncher.entry.ContactEntry; import rocks.tbog.tblauncher.entry.FilterEntry; import rocks.tbog.tblauncher.entry.ShortcutEntry; public class FilterProvider extends DBProvider { private static final FilterEntry[] s_entries = new FilterEntry[3]; @StringRes private static final int[] s_names = new int[3]; static { int cnt = 0; // apps filter { String id = FilterEntry.SCHEME + "applications"; FilterEntry filter = new FilterEntry(id, R.drawable.ic_apps, AppEntry.SCHEME); filter.setOnClickListener(v -> { Context ctx = v.getContext(); AppProvider provider = TBApplication.getApplication(ctx).getDataHandler().getAppProvider(); TBApplication.quickList(ctx).toggleFilter(v, provider); }); s_names[cnt] = R.string.filter_apps; s_entries[cnt++] = filter; } // contacts filter { String id = FilterEntry.SCHEME + "contacts"; FilterEntry filter = new FilterEntry(id, R.drawable.ic_contacts, ContactEntry.SCHEME); filter.setOnClickListener(v -> { Context ctx = v.getContext(); ContactsProvider provider = TBApplication.dataHandler(ctx).getContactsProvider(); TBApplication.quickList(ctx).toggleFilter(v, provider); }); s_names[cnt] = R.string.filter_contacts; s_entries[cnt++] = filter; } // pinned shortcuts filter { String id = FilterEntry.SCHEME + "shortcuts"; FilterEntry filter = new FilterEntry(id, R.drawable.ic_shortcuts, ShortcutEntry.SCHEME); filter.setOnClickListener(v -> { Context ctx = v.getContext(); ShortcutsProvider provider = TBApplication.dataHandler(ctx).getShortcutsProvider(); TBApplication.quickList(ctx).toggleFilter(v, provider); }); s_names[cnt] = R.string.filter_shortcuts; s_entries[cnt++] = filter; } //noinspection ConstantConditions if (cnt != s_entries.length || cnt != s_names.length) throw new IllegalStateException("FilterEntry static list size"); } public FilterProvider(Context context) { super(context); } @Override protected DBLoader newLoadTask() { return new UpdateFromModsLoader<>(this, s_entries, s_names); } @Override public boolean mayFindById(@NonNull String id) { return id.startsWith(FilterEntry.SCHEME); } @NonNull public String getDefaultName(@NonNull String id) { for (int idx = 0; idx < s_entries.length; idx += 1) { if (id.equals(s_entries[idx].id)) return context.getString(s_names[idx]); } return "null"; } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/dataprovider/IProvider.java ================================================ package rocks.tbog.tblauncher.dataprovider; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; import java.util.List; import rocks.tbog.tblauncher.entry.EntryItem; import rocks.tbog.tblauncher.searcher.ISearcher; import rocks.tbog.tblauncher.utils.Timer; /** * Public interface exposed by every KISS data provider */ public interface IProvider { int LOAD_STEP_1 = 0; int LOAD_STEP_2 = 1; int LOAD_STEP_3 = 2; int[] LOAD_STEPS = new int[] {LOAD_STEP_1, LOAD_STEP_2, LOAD_STEP_3}; /** * Post search results for the given query string to the searcher * @param query Some string query (usually provided by the user) * @param searcher The receiver of results */ @WorkerThread void requestResults(String query, ISearcher searcher); /** * Reload the data stored in this provider *

* `LOAD_OVER` will be emitted once the reload is complete. The data provider * will stay usable (using it's old data) during the reload. * @param cancelCurrentLoadTask pass true to stop current loading task and start another; * pass false to do nothing if already loading */ void reload(boolean cancelCurrentLoadTask); /** * Indicate whether this provider has already loaded it's data *

* If this method returns `false` then the client may listen for the * `LOAD_OVER` intent for notification of when the provider is ready. * * @return Is the provider ready to process search results? */ boolean isLoaded(); /** * User for debug, this is the last load duration * * @return amount of time it took for this provider to load. null if */ @Nullable Timer getLoadDuration(); /** * Indicate that some providers have reloaded and this one may need to also reload */ void setDirty(); /** * Return the loading step for this provider * @return one of the LOAD_STEPS */ int getLoadStep(); /** * Tells whether or not this provider may be able to find the pojo with * specified id * * @param id id we're looking for * @return true if the provider can handle the query ; does not guarantee it * will! */ boolean mayFindById(@NonNull String id); /** * Try to find a record by its id * * @param id id we're looking for * @return null if not found */ T findById(@NonNull String id); /** * Get a list of all pojos, do not modify this list! * * @return list of all entries */ @Nullable List getPojos(); } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/dataprovider/ModProvider.java ================================================ package rocks.tbog.tblauncher.dataprovider; import android.content.Context; import java.util.ArrayList; import java.util.List; import rocks.tbog.tblauncher.db.ModRecord; import rocks.tbog.tblauncher.entry.EntryItem; import rocks.tbog.tblauncher.entry.ICustomIconEntry; import rocks.tbog.tblauncher.handler.DataHandler; /** * This provider is loaded last and is responsible for setting custom names and icons */ public class ModProvider extends DBProvider { public ModProvider(Context context) { super(context); } @Override public int getLoadStep() { return LOAD_STEP_3; } @Override protected DBLoader newLoadTask() { return new FavLoader(this); } private static class FavLoader extends DBProvider.DBLoader { public FavLoader(DBProvider provider) { super(provider); } @Override List getEntryItems(DataHandler dataHandler) { List list = dataHandler.getMods(); ArrayList favList = new ArrayList<>(list.size()); // get EntryItem from ModRecord for (ModRecord fav : list) { EntryItem entry = dataHandler.getPojo(fav.record); if (entry == null) continue; else if (entry instanceof ICustomIconEntry) { if (fav.hasCustomIcon() && !((ICustomIconEntry) entry).hasCustomIcon()) ((ICustomIconEntry) entry).setCustomIcon(); } if (fav.hasCustomName()) entry.setName(fav.displayName); favList.add(entry); } return favList; } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/dataprovider/Provider.java ================================================ package rocks.tbog.tblauncher.dataprovider; import android.app.Service; import android.content.Intent; import android.os.Binder; import android.os.IBinder; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import java.util.ArrayList; import java.util.Collections; import java.util.List; import rocks.tbog.tblauncher.BuildConfig; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.TBLauncherActivity; import rocks.tbog.tblauncher.entry.EntryItem; import rocks.tbog.tblauncher.handler.DataHandler; import rocks.tbog.tblauncher.loader.LoadEntryItem; import rocks.tbog.tblauncher.utils.Timer; public abstract class Provider extends Service implements IProvider { final static String TAG = "Provider"; /** * Binder given to clients */ private final IBinder binder = new LocalBinder(); /** * Storage for search items used by this provider */ protected List pojos = Collections.emptyList(); private boolean loaded = false; private LoadEntryItem loader = null; /** * Scheme used to build ids for the pojos created by this provider */ @NonNull private String pojoScheme = "(none)://"; private final Timer mTimer = new Timer(); /** * (Re-)load the providers resources when the provider has been completely initialized * by the Android system */ @Override public void onCreate() { super.onCreate(); TBApplication.dataHandler(this).onProviderRecreated(this); this.reload(true); } protected boolean isLoading() { return loader != null; } protected void initialize(@NonNull LoadEntryItem loader) { mTimer.start(); if (this.loader != null) this.loader.cancel(false); Log.i(TAG, "Starting provider: " + this.getClass().getSimpleName()); loader.setProvider(this); this.loader = loader; this.pojoScheme = loader.getScheme(); this.loader.execute(); } public void reload(boolean cancelCurrentLoadTask) { if (!cancelCurrentLoadTask && loader != null) return; loaded = false; // Handled at subclass level if (pojos.size() > 0) { Log.v(TAG, "Reloading provider: " + this.getClass().getSimpleName()); } } @Override public void setDirty() { // do nothing, we don't depend on any other provider } @Override public boolean isLoaded() { return this.loaded; } @Nullable @Override public Timer getLoadDuration() { return mTimer; } @Override public int getLoadStep() { return LOAD_STEP_1; } public void loadOver(ArrayList results) { mTimer.stop(); Log.i(TAG, "Time to load " + this.getClass().getSimpleName() + ": " + mTimer); // Store results this.pojos = results; this.loaded = true; this.loader = null; // Broadcast this event DataHandler.sendBroadcast(this, TBLauncherActivity.LOAD_OVER, getClass().getSimpleName()); } @NonNull public String getScheme() { return pojoScheme; } /** * Tells whether or not this provider may be able to find the pojo with * specified id * * @param id id we're looking for * @return true if the provider can handle the query ; does not guarantee it * will! */ public boolean mayFindById(@NonNull String id) { return id.startsWith(pojoScheme); } /** * Try to find a record by its id * * @param id id we're looking for * @return null if not found */ public T findById(@NonNull String id) { for (T pojo : pojos) { if (pojo.id.equals(id)) { return pojo; } } return null; } @Nullable @Override public List getPojos() { if (BuildConfig.DEBUG) return Collections.unmodifiableList(pojos); return pojos; } @Override public int onStartCommand(Intent intent, int flags, int startId) { // We want this service to continue running until it is explicitly // stopped, so return sticky. return START_STICKY; } @Override public IBinder onBind(Intent intent) { return this.binder; } /** * Class used for the client Binder. Because we know this service always * runs in the same process as its clients, we don't need to deal with IPC. */ public class LocalBinder extends Binder { public IProvider getService() { // Return this instance of the provider so that clients can call public methods return Provider.this; } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/dataprovider/QuickListProvider.java ================================================ package rocks.tbog.tblauncher.dataprovider; import android.content.Context; import android.util.Log; import java.util.ArrayList; import java.util.Collections; import java.util.List; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.TBLauncherActivity; import rocks.tbog.tblauncher.db.DBHelper; import rocks.tbog.tblauncher.db.ModRecord; import rocks.tbog.tblauncher.entry.EntryItem; import rocks.tbog.tblauncher.entry.PlaceholderEntry; import rocks.tbog.tblauncher.handler.DataHandler; public class QuickListProvider extends DBProvider { private final static String TAG = QuickListProvider.class.getSimpleName(); public QuickListProvider(Context context) { super(context); } @Override public int getLoadStep() { return LOAD_STEP_1; } // @Override // public List getPojos() { // boolean needsSorting = false; // // Collection recordIds = mQuickListFavRecords.values(); // boolean remakeEntries = false; // if (entryList.size() == recordIds.size()) { // for (EntryItem entryItem : entryList) { // if (!mQuickListFavRecords.containsKey(entryItem.id)) { // Log.d(TAG, "remake: not found " + entryItem.id); // remakeEntries = true; // break; // } // } // } else { // Log.d(TAG, "remake: " + entryList.size() + " \u2260 " + recordIds.size()); // remakeEntries = true; // } // if (remakeEntries) { // needsSorting = true; // entryList.clear(); // // make them all placeholders, we'll replace later // for (ModRecord fav : recordIds) { // PlaceholderEntry entry = new PlaceholderEntry(fav.record, fav.position); // entry.setName(fav.displayName); // if (fav.hasCustomIcon()) // entry.setCustomIcon(); // entryList.add(entry); // } // } // // ArrayList toAdd = new ArrayList<>(); // DataHandler dataHandler = TBApplication.dataHandler(context); // // // replace placeholders with the correct entry // for (Iterator iterator = entryList.iterator(); iterator.hasNext(); ) { // EntryItem entryItem = iterator.next(); // if (entryItem instanceof PlaceholderEntry) { // needsSorting = true; // entryItem = dataHandler.getPojo(entryItem.id); // if (entryItem != null) { // toAdd.add(entryItem); // iterator.remove(); // } // } // } // entryList.addAll(toAdd); // // if we have replaced some PlaceholderEntry then we need to sort again // if (needsSorting) { // // sort entryList // Collections.sort(entryList, (o1, o2) -> { // ModRecord p1 = mQuickListFavRecords.get(o1.id); // ModRecord p2 = mQuickListFavRecords.get(o2.id); // if (p1 == null || p1.position == null || p2 == null || p2.position == null) // return 0; // return p1.position.compareTo(p2.position); // }); // } // return super.getPojos(); // } private void fixPlaceholders() { DataHandler dataHandler = TBApplication.dataHandler(context); int replaceCount = 0; for (int idx = 0; idx < entryList.size(); idx += 1) { EntryItem entryItem = entryList.get(idx); if (entryItem instanceof PlaceholderEntry) { entryItem = dataHandler.getPojo(entryItem.id); if (entryItem != null) { entryList.set(idx, entryItem); replaceCount += 1; } } } Log.i(TAG, "replaced " + replaceCount + "/" + entryList.size() + " placeholder(s)"); } @Override protected DBLoader newLoadTask() { return new QuickListLoader(this); } private static class QuickListLoader extends DBProvider.DBLoader { public QuickListLoader(DBProvider provider) { super(provider); } @Override List getEntryItems(DataHandler dataHandler) { Context context = getContext(); if (context == null) return null; ArrayList records = DBHelper.getMods(context); int quickListSize = 0; // count only items in the QuickList for (ModRecord rec : records) { if (rec.isInQuickList()) quickListSize += 1; } ArrayList quickList = new ArrayList<>(quickListSize); // get EntryItem from ModRecord for (ModRecord fav : records) { if (!fav.isInQuickList()) continue; PlaceholderEntry entry = new PlaceholderEntry(fav.record, fav.position); entry.setName(fav.displayName); if (fav.hasCustomIcon()) entry.setCustomIcon(); quickList.add(entry); } Collections.sort(quickList, (o1, o2) -> { String p1 = ((PlaceholderEntry) o1).position; String p2 = ((PlaceholderEntry) o2).position; if (p1 == null || p2 == null) return 0; return p1.compareTo(p2); }); return quickList; } @Override protected void onPostExecute(List entryItems) { super.onPostExecute(entryItems); Context context = getContext(); if (context == null) return; TBApplication.dataHandler(context).runAfterLoadOver(() -> { DBProvider provider = weakProvider.get(); if (provider instanceof QuickListProvider) { ((QuickListProvider) provider).fixPlaceholders(); TBLauncherActivity launcherActivity = TBApplication.launcherActivity(provider.context); if (launcherActivity != null) launcherActivity.queueDockReload(); } }); } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/dataprovider/SearchProvider.java ================================================ package rocks.tbog.tblauncher.dataprovider; import android.content.Context; import android.content.SharedPreferences; import android.webkit.URLUtil; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.collection.ArraySet; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; import rocks.tbog.tblauncher.BuildConfig; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.entry.OpenUrlEntry; import rocks.tbog.tblauncher.entry.SearchEngineEntry; import rocks.tbog.tblauncher.entry.SearchEntry; import rocks.tbog.tblauncher.normalizer.StringNormalizer; import rocks.tbog.tblauncher.searcher.ISearcher; import rocks.tbog.tblauncher.utils.FuzzyScore; public class SearchProvider extends SimpleProvider { private static final String URL_REGEX = "^(?:[a-z]+://)?(?:[a-z0-9-]|[^\\x00-\\x7F])+(?:[.](?:[a-z0-9-]|[^\\x00-\\x7F])+)+.*$"; public static final Pattern urlPattern = Pattern.compile(URL_REGEX); private final SharedPreferences prefs; private final ArrayList searchEngines = new ArrayList<>(); private final Context context; @NonNull public static Set getDefaultSearchProviders(Context context) { String[] defaultSearchProviders = context.getResources().getStringArray(R.array.defaultSearchProviders); return new ArraySet<>(Arrays.asList(defaultSearchProviders)); } @NonNull public static Set getAvailableSearchProviders(Context context, SharedPreferences prefs) { Set availableProviders = prefs.getStringSet("available-search-providers", null); if (availableProviders == null) availableProviders = SearchProvider.getDefaultSearchProviders(context); if (BuildConfig.DEBUG) return Collections.unmodifiableSet(availableProviders); return availableProviders; } @NonNull public static Set getSelectedProviderNames(Context context, SharedPreferences prefs) { Set selectedProviders = prefs.getStringSet("selected-search-provider-names", null); if (selectedProviders == null) { Set availableProviders = getAvailableSearchProviders(context, prefs); selectedProviders = new ArraySet<>(availableProviders.size()); for (String availableProvider : availableProviders) selectedProviders.add(getProviderName(availableProvider)); } return selectedProviders; } @NonNull public static String sanitizeProviderName(@Nullable String name) { if (name == null) return "[name]"; while (name.contains("|")) name = name.replace('|', ' '); return name; } @NonNull public static String sanitizeProviderUrl(@Nullable String url) { if (url == null) return "%s"; if (!url.contains("%s")) return url + "%s"; return url; } public SearchProvider(Context context, SharedPreferences sharedPreferences) { super(); this.context = context.getApplicationContext(); this.prefs = sharedPreferences; reload(false); } @Override public void reload(boolean cancelCurrentLoadTask) { searchEngines.clear(); Set availableSearchProviders = SearchProvider.getAvailableSearchProviders(context, prefs); Set selectedProviderNames = SearchProvider.getSelectedProviderNames(context, prefs); for (String searchProvider : availableSearchProviders) { String name = getProviderName(searchProvider); if (selectedProviderNames.contains(name)) { String url = getProviderUrl(searchProvider); SearchEngineEntry entry = new SearchEngineEntry(name, url); if (url != null) searchEngines.add(entry); } } } @Override public boolean mayFindById(@NonNull String id) { return id.startsWith(SearchEngineEntry.SCHEME); } @Override public SearchEntry findById(@NonNull String id) { for (SearchEngineEntry entry : searchEngines) if (entry.id.equals(id)) return entry; return null; } @Override public void requestResults(String query, ISearcher searcher) { searcher.addResult(getResults(query).toArray(new SearchEntry[0])); } @NonNull private ArrayList getResults(String query) { ArrayList records = new ArrayList<>(); StringNormalizer.Result queryNormalized = StringNormalizer.normalizeWithResult(query, false); if (queryNormalized.codePoints.length == 0) { return records; } if (prefs.getBoolean("enable-search", true)) { // Get default search engine String defaultSearchEngine = prefs.getString("default-search-provider", "Google"); for (SearchEngineEntry entry : searchEngines) { entry.setQuery(query); entry.setRelevance(entry.normalizedName, null); // Super low relevance, should never be displayed before anything entry.boostRelevance(-500); if (entry.getName().equals(defaultSearchEngine)) // Display default search engine slightly higher entry.boostRelevance(100); records.add(entry); } } if (prefs.getBoolean("enable-url", true)) { FuzzyScore fuzzyScore = new FuzzyScore(queryNormalized.codePoints); // Open URLs directly (if I type http://something.com for instance) Matcher m = urlPattern.matcher(query); if (m.find()) { String guessedUrl = URLUtil.guessUrl(query); if (URLUtil.isHttpUrl(guessedUrl)) guessedUrl = "https://" + guessedUrl.substring(7); if (URLUtil.isValidUrl(guessedUrl)) { SearchEntry pojo = new OpenUrlEntry(query, guessedUrl); pojo.setName(guessedUrl); FuzzyScore.MatchInfo matchInfo = fuzzyScore.match(pojo.normalizedName.codePoints); pojo.setRelevance(pojo.normalizedName, matchInfo); records.add(pojo); } } } return records; } @Nullable public static String getProviderUrl(@NonNull String searchProvider) { int pos = searchProvider.indexOf("|"); if (pos >= 0) return searchProvider.substring(pos + 1); if (URLUtil.isValidUrl(searchProvider)) return searchProvider; return null; } @NonNull public static String getProviderName(@NonNull String searchProvider) { int pos = searchProvider.indexOf("|"); if (pos >= 0) return searchProvider.substring(0, pos); return "null"; } @NonNull public static String makeProvider(@NonNull String name, @NonNull String url) { return sanitizeProviderName(name) + "|" + sanitizeProviderUrl(url); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/dataprovider/ShortcutsProvider.java ================================================ package rocks.tbog.tblauncher.dataprovider; import android.content.BroadcastReceiver; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.content.pm.LauncherApps; import android.content.pm.ShortcutInfo; import android.graphics.Bitmap; import android.graphics.drawable.Drawable; import android.os.Build; import android.os.Parcelable; import android.os.UserHandle; import android.util.Log; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.RequiresApi; import androidx.annotation.WorkerThread; import androidx.core.app.ActivityCompat; import androidx.core.content.ContextCompat; import java.util.List; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.db.ShortcutRecord; import rocks.tbog.tblauncher.entry.ShortcutEntry; import rocks.tbog.tblauncher.loader.LoadShortcutsEntryItem; import rocks.tbog.tblauncher.searcher.ISearcher; import rocks.tbog.tblauncher.shortcut.ShortcutUtil; import rocks.tbog.tblauncher.utils.Utilities; public class ShortcutsProvider extends Provider { private static boolean notifiedKissNotDefaultLauncher = false; private static final String ACTION_INSTALL_SHORTCUT = "com.android.launcher.action.INSTALL_SHORTCUT"; AppsCallback appsCallback = null; final BroadcastReceiver mProfileReceiver = new BroadcastReceiver() { @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) @Override public void onReceive(Context context, Intent intent) { if (Intent.ACTION_MANAGED_PROFILE_ADDED.equals(intent.getAction())) { // ShortcutsProvider.this.reload(); } else if (Intent.ACTION_MANAGED_PROFILE_REMOVED.equals(intent.getAction())) { // android.os.UserHandle profile = intent.getParcelableExtra(Intent.EXTRA_USER); // // final UserManager manager = (UserManager) ShortcutsProvider.this.getSystemService(Context.USER_SERVICE); // assert manager != null; // UserHandleCompat user = new UserHandleCompat(manager.getSerialNumberForUser(profile), profile); // // DataHandler dataHandler = TBApplication.getApplication(context).getDataHandler(); // dataHandler.removeFromExcluded(user); // dataHandler.removeFromFavorites(user); // ShortcutsProvider.this.reload(); } else if (ACTION_INSTALL_SHORTCUT.equals(intent.getAction())) { Intent i = intent.getParcelableExtra(Intent.EXTRA_SHORTCUT_INTENT); String name = intent.getStringExtra(Intent.EXTRA_SHORTCUT_NAME); Parcelable bitmap = intent.getParcelableExtra(Intent.EXTRA_SHORTCUT_ICON); if (i == null) { Log.w("SHC", "Shortcut intent is null " + name); return; } Drawable icon = null; if (bitmap instanceof Bitmap) { icon = Utilities.createIconDrawable((Bitmap) bitmap, context); } else { Parcelable extra = intent.getParcelableExtra(Intent.EXTRA_SHORTCUT_ICON_RESOURCE); if (extra instanceof Intent.ShortcutIconResource) { icon = Utilities.createIconDrawable((Intent.ShortcutIconResource) extra, context); } } if (icon == null) { icon = TBApplication.getApplication(context).iconsHandler().getDefaultActivityIcon(context); } ShortcutRecord record = new ShortcutRecord(); record.displayName = name; record.infoData = i.toUri(Intent.URI_INTENT_SCHEME); record.iconPng = ShortcutUtil.getIconBlob(icon); record.packageName = i.getPackage(); if (record.packageName == null) record.packageName = i.getComponent() != null ? i.getComponent().getPackageName() : ""; if (!TBApplication.getApplication(context).getDataHandler().addShortcut(record)) Log.w("SHC", "Failed to add shortcut " + name); } } }; @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) static class AppsCallback extends LauncherApps.Callback { private final Context context; AppsCallback(Context context) { this.context = context; } @Override public void onPackageRemoved(String packageName, UserHandle user) { } @Override public void onPackageAdded(String packageName, UserHandle user) { } @Override public void onPackageChanged(String packageName, UserHandle user) { } @Override public void onPackagesAvailable(String[] packageNames, UserHandle user, boolean replacing) { } @Override public void onPackagesUnavailable(String[] packageNames, UserHandle user, boolean replacing) { } @RequiresApi(api = Build.VERSION_CODES.O) @Override public void onShortcutsChanged(@NonNull String packageName, @NonNull List shortcuts, @NonNull UserHandle user) { super.onShortcutsChanged(packageName, shortcuts, user); for (ShortcutInfo info : shortcuts) { String action = null; ComponentName component = null; String intentPackage = null; Intent i = info.getIntent(); if (i != null) { action = i.getAction(); component = i.getComponent(); intentPackage = i.getPackage(); } Log.i("SHC", "Shortcut changed for `" + packageName + "`" + "\naction " + action + "\ncomponent " + component + "\nintentPack " + intentPackage + "\nshortLabel " + info.getShortLabel() + "\nlongLabel " + info.getLongLabel() + "\nisImmutable " + info.isImmutable() + "\nisEnabled " + info.isEnabled() + "\nisPinned " + info.isPinned() + "\ninManifest " + info.isDeclaredInManifest() + "\nisDynamic " + info.isDynamic()); } } } @Override public void onCreate() { if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { // Package install/uninstall events for the main // profile are still handled using PackageAddedRemovedHandler itself // final LauncherApps launcher = (LauncherApps) this.getSystemService(Context.LAUNCHER_APPS_SERVICE); // assert launcher != null; // // appsCallback = new AppsCallback(this); // launcher.registerCallback(appsCallback); // Try to clean up app-related data when profile is removed IntentFilter filter = new IntentFilter(); filter.addAction(Intent.ACTION_MANAGED_PROFILE_ADDED); filter.addAction(Intent.ACTION_MANAGED_PROFILE_REMOVED); filter.addAction(ACTION_INSTALL_SHORTCUT); ActivityCompat.registerReceiver(this, mProfileReceiver, filter, ContextCompat.RECEIVER_EXPORTED); } super.onCreate(); } @Override public void onDestroy() { unregisterReceiver(mProfileReceiver); if (appsCallback != null) { if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP) { LauncherApps launcher = (LauncherApps) this.getSystemService(Context.LAUNCHER_APPS_SERVICE); assert launcher != null; launcher.unregisterCallback(appsCallback); } } super.onDestroy(); } @Override public void reload(boolean cancelCurrentLoadTask) { super.reload(cancelCurrentLoadTask); if (!isLoaded() && !isLoading()) { try { // If the user tries to add a new shortcut, but KISS isn't the default launcher // AND the services are not running (low memory), then we won't be able to // spawn a new service on Android 8.1+. this.initialize(new LoadShortcutsEntryItem(this)); } catch (IllegalStateException e) { if (!notifiedKissNotDefaultLauncher) { // Only display this message once per process Toast.makeText(this, R.string.unable_to_initialize_shortcuts, Toast.LENGTH_LONG).show(); } notifiedKissNotDefaultLauncher = true; e.printStackTrace(); } } } @WorkerThread @Override public void requestResults(String query, ISearcher searcher) { for (ShortcutEntry pojo : pojos) pojo.resetResultInfo(); EntryToResultUtils.recursiveWordCheck(pojos, query, searcher, EntryToResultUtils::tagsCheckResults, ShortcutEntry.class); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/dataprovider/SimpleProvider.java ================================================ package rocks.tbog.tblauncher.dataprovider; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import java.util.List; import rocks.tbog.tblauncher.entry.EntryItem; import rocks.tbog.tblauncher.searcher.ISearcher; import rocks.tbog.tblauncher.utils.Timer; /** * Unlike normal providers, simple providers are not Android Services but classic Android class * Android Services are expensive to create, and use a lot of memory, * so whenever we can, we avoid using them. */ public abstract class SimpleProvider implements IProvider { @Override public void requestResults(String query, ISearcher searcher) { } @Override public void reload(boolean cancelCurrentLoadTask) { } @Override public final boolean isLoaded() { return true; } @Nullable @Override public Timer getLoadDuration() { return null; } @Override public void setDirty() { } @Override public int getLoadStep() { return LOAD_STEP_1; } @Override public boolean mayFindById(@NonNull String id) { return false; } @Override public T findById(@NonNull String id) { return null; } @Nullable @Override public List getPojos() { return null; } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/dataprovider/TagsProvider.java ================================================ package rocks.tbog.tblauncher.dataprovider; import android.content.Context; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import java.util.ArrayList; import java.util.List; import rocks.tbog.tblauncher.db.ModRecord; import rocks.tbog.tblauncher.entry.TagEntry; import rocks.tbog.tblauncher.handler.DataHandler; public class TagsProvider extends DBProvider { public TagsProvider(Context context) { super(context); } @Override protected DBLoader newLoadTask() { return new FavLoader(this); } @Nullable public static TagEntry newTagEntryCheckId(String id) { if (id.startsWith(TagEntry.SCHEME)) { TagEntry tagEntry = new TagEntry(id); String tagName = id.substring(TagEntry.SCHEME.length()); tagEntry.setName(tagName); return tagEntry; } return null; } @NonNull public static String getTagId(@NonNull String tagName) { return TagEntry.SCHEME + tagName; } @NonNull private static TagEntry newTagEntry(@NonNull String id, @NonNull String tagName) { TagEntry tagEntry = new TagEntry(id); tagEntry.setName(tagName); return tagEntry; } @Override public boolean mayFindById(@NonNull String id) { return id.startsWith(TagEntry.SCHEME); } @NonNull public TagEntry getTagEntry(String tagName) { String id = getTagId(tagName); TagEntry entryItem = findById(id); if (entryItem == null) return newTagEntry(id, tagName); return entryItem; } public void addTagEntry(TagEntry tagEntry) { if (null == findById(tagEntry.id)) entryList.add(tagEntry); } private static class FavLoader extends DBProvider.DBLoader { public FavLoader(DBProvider provider) { super(provider); } @Override List getEntryItems(DataHandler dataHandler) { ArrayList tagList = new ArrayList<>(); List mods = dataHandler.getMods(); // get TagEntry from ModRecord for (ModRecord mod : mods) { TagEntry entry = newTagEntryCheckId(mod.record); if (entry == null) continue; if (mod.hasCustomIcon()) entry.setCustomIcon(); tagList.add(entry); } return tagList; } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/dataprovider/UpdateFromModsLoader.java ================================================ package rocks.tbog.tblauncher.dataprovider; import android.content.Context; import java.util.ArrayList; import java.util.List; import rocks.tbog.tblauncher.handler.DataHandler; import rocks.tbog.tblauncher.db.ModRecord; import rocks.tbog.tblauncher.entry.StaticEntry; public class UpdateFromModsLoader extends DBProvider.DBLoader { private final T[] mEntries; private final int[] mNames; public UpdateFromModsLoader(DBProvider provider, T[] entries, int[] names) { super(provider); this.mEntries = entries; this.mNames = names; } @Override public List getEntryItems(DataHandler dataHandler) { DBProvider provider = weakProvider.get(); if (provider == null) return null; Context context = provider.context; ArrayList output = new ArrayList<>(mEntries.length); // copy static entries to returned list, also update the names for (int idx = 0; idx < mEntries.length; idx++) { T entry = mEntries[idx]; entry.setName(context.getString(mNames[idx])); output.add(entry); } List mods = dataHandler.getMods(); // update custom settings from favorites for (ModRecord mod : mods) { T entry = null; if (provider.mayFindById(mod.record)) { for (T e : output) { if (e.id.equals(mod.record)) { entry = e; break; } } } if (entry != null) { if (mod.hasCustomName()) entry.setName(mod.displayName); if (mod.hasCustomIcon()) entry.setCustomIcon(); } } return output; } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/db/AppRecord.java ================================================ package rocks.tbog.tblauncher.db; public final class AppRecord extends FlagsRecord { public static final int FLAG_DEFAULT_NAME = 0x000001; public static final int FLAG_CUSTOM_NAME = 0x000002; public static final int FLAG_CUSTOM_ICON = 0x000004; public static final int FLAG_APP_HIDDEN = 0x000008; private static final int MASK_SAVE_DB_FLAGS = FLAG_DEFAULT_NAME | FLAG_CUSTOM_NAME | FLAG_CUSTOM_ICON | FLAG_APP_HIDDEN; public static final int FLAG_VALIDATED = 0x080000; public long dbId = -1; public String displayName; public String componentName; public AppRecord() { flags = FLAG_DEFAULT_NAME; } @Override public int getFlagsDB() { return flags & MASK_SAVE_DB_FLAGS; } public boolean hasCustomName() { return (flags & FLAG_CUSTOM_NAME) == FLAG_CUSTOM_NAME; } public boolean hasCustomIcon() { return (flags & FLAG_CUSTOM_ICON) == FLAG_CUSTOM_ICON; } public boolean isHidden() { return (flags & FLAG_APP_HIDDEN) == FLAG_APP_HIDDEN; } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/db/DB.java ================================================ package rocks.tbog.tblauncher.db; import android.content.ContentValues; import android.content.Context; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteOpenHelper; import android.util.Log; import rocks.tbog.tblauncher.entry.FilterEntry; class DB extends SQLiteOpenHelper { private final static String DB_NAME = "kiss.s3db"; private final static int DB_VERSION = 13; DB(Context context) { super(context, DB_NAME, null, DB_VERSION); } @Override public void onCreate(SQLiteDatabase database) { createHistory(database); createTags(database); addAppsTable(database); createShortcutsTable(database); createFavoritesTable(database, true); createWidgetsTable(database); } void createHistory(SQLiteDatabase database) { database.execSQL("CREATE TABLE history ( _id INTEGER PRIMARY KEY AUTOINCREMENT, \"query\" TEXT, \"record\" TEXT NOT NULL, \"timeStamp\" INTEGER DEFAULT 0 NOT NULL)"); } void createTags(SQLiteDatabase database) { database.execSQL("CREATE TABLE \"tags\" (\"tag\" TEXT NOT NULL, \"record\" TEXT NOT NULL)"); database.execSQL("CREATE INDEX idx_tags_record ON \"tags\"(\"record\");"); } private void addTimeStamps(SQLiteDatabase database) { database.execSQL("ALTER TABLE \"history\" ADD COLUMN \"timeStamp\" INTEGER DEFAULT 0 NOT NULL"); } private void addAppsTable(SQLiteDatabase db) { db.execSQL("CREATE TABLE \"apps\" ( _id INTEGER PRIMARY KEY AUTOINCREMENT, display_name TEXT NOT NULL DEFAULT '', component_name TEXT NOT NULL UNIQUE, custom_flags INTEGER DEFAULT 0, custom_icon BLOB DEFAULT NULL, cached_icon BLOB DEFAULT NULL)"); db.execSQL("CREATE INDEX \"index_component\" ON \"apps\"(component_name);"); } private void createShortcutsTable(SQLiteDatabase db) { db.execSQL("CREATE TABLE \"shortcuts\" ( _id INTEGER PRIMARY KEY AUTOINCREMENT, \"name\" TEXT NOT NULL, \"package\" TEXT, \"info_data\" TEXT, \"icon_png\" BLOB, \"custom_flags\" INTEGER DEFAULT 0)"); } void createFavoritesTable(SQLiteDatabase db, boolean generateDefaults) { db.execSQL("CREATE TABLE \"favorites\" ( \"record\" TEXT NOT NULL UNIQUE, \"position\" TEXT NOT NULL, \"custom_flags\" INTEGER DEFAULT 0, \"name\" TEXT DEFAULT NULL, \"custom_icon\" BLOB DEFAULT NULL )"); if (!generateDefaults) return; // generate default values ContentValues values = new ContentValues(); { values.put("record", FilterEntry.SCHEME + "applications"); values.put("position", "0"); values.put("custom_flags", ModRecord.FLAG_SHOW_IN_QUICK_LIST); db.insertWithOnConflict("favorites", null, values, SQLiteDatabase.CONFLICT_IGNORE); } { values.put("record", FilterEntry.SCHEME + "contacts"); values.put("position", "1"); values.put("custom_flags", ModRecord.FLAG_SHOW_IN_QUICK_LIST); db.insertWithOnConflict("favorites", null, values, SQLiteDatabase.CONFLICT_IGNORE); } { values.put("record", FilterEntry.SCHEME + "shortcuts"); values.put("position", "2"); values.put("custom_flags", ModRecord.FLAG_SHOW_IN_QUICK_LIST); db.insertWithOnConflict("favorites", null, values, SQLiteDatabase.CONFLICT_IGNORE); } } private void createWidgetsTable(SQLiteDatabase db) { db.execSQL("CREATE TABLE \"widgets\" (_id INTEGER PRIMARY KEY AUTOINCREMENT, \"appWidgetId\" INTEGER NOT NULL, \"properties\" TEXT)"); } @Override public void onUpgrade(SQLiteDatabase database, int oldVersion, int newVersion) { Log.d("onUpgrade", "Updating database from version " + oldVersion + " to version " + newVersion); // See // http://www.drdobbs.com/database/using-sqlite-on-android/232900584 if (oldVersion < newVersion) { switch (oldVersion) { case 1: case 2: case 3: database.execSQL("CREATE TABLE shortcuts ( _id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, package TEXT," + "icon TEXT, intent_uri TEXT NOT NULL, icon_blob BLOB)"); // fall through case 4: createTags(database); // fall through case 5: addTimeStamps(database); addAppsTable(database); // fall through case 6: database.execSQL("DROP TABLE \"shortcuts\""); createShortcutsTable(database); database.execSQL("DROP TABLE \"tags\""); createTags(database); // fall through case 7: createFavoritesTable(database, true); // fall through case 8: database.execSQL("ALTER TABLE \"apps\" ADD COLUMN \"custom_icon\" BLOB DEFAULT NULL"); // fall through case 9: createWidgetsTable(database); // fall through case 10: database.execSQL("ALTER TABLE \"favorites\" ADD COLUMN \"name\" TEXT DEFAULT NULL"); database.execSQL("ALTER TABLE \"favorites\" ADD COLUMN \"custom_icon\" BLOB DEFAULT NULL"); // fall through case 11: database.execSQL("PRAGMA foreign_keys=off"); database.beginTransaction(); try { database.execSQL("DROP TABLE IF EXISTS \"widgets_old\""); database.execSQL("ALTER TABLE \"widgets\" RENAME TO \"widgets_old\""); createWidgetsTable(database); database.execSQL("INSERT INTO \"widgets\" SELECT * FROM \"widgets_old\""); database.execSQL("DROP TABLE \"widgets_old\""); database.setTransactionSuccessful(); } finally { database.endTransaction(); database.execSQL("PRAGMA foreign_keys=on"); } // fall through case 12: database.execSQL("ALTER TABLE \"apps\" ADD COLUMN \"cached_icon\" BLOB DEFAULT NULL"); // fall through default: break; } } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/db/DBHelper.java ================================================ package rocks.tbog.tblauncher.db; import android.content.ContentValues; import android.content.Context; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteStatement; import android.text.TextUtils; import android.util.Log; import android.util.Pair; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import rocks.tbog.tblauncher.entry.AppEntry; import rocks.tbog.tblauncher.entry.EntryItem; import rocks.tbog.tblauncher.entry.ShortcutEntry; import rocks.tbog.tblauncher.entry.TagEntry; import rocks.tbog.tblauncher.utils.PrefCache; public class DBHelper { private static final String TAG = DBHelper.class.getSimpleName(); private static DB database = null; private static final String[] TABLE_COLUMNS_APPS = new String[]{"_id", "display_name", "component_name", "custom_flags"};//, "custom_icon", "cached_icon"}; private static final String[] TABLE_APPS_CUSTOM_ICON = new String[]{"custom_icon"}; private static final String[] TABLE_APPS_CACHED_ICON = new String[]{"cached_icon"}; private static final String[] TABLE_MODS_CUSTOM_ICON = new String[]{"custom_icon"}; private static final String[] TABLE_COLUMNS_MODS = new String[]{"record", "position", "custom_flags", "name"};//, "custom_icon"}; private static final String[] TABLE_COLUMNS_SHORTCUTS = new String[]{"_id", "name", "package", "info_data", "icon_png", "custom_flags"}; private static final String[] TABLE_COLUMNS_SHORTCUTS_NO_ICON = new String[]{"_id", "name", "package", "info_data", "custom_flags"}; private DBHelper() { } private static SQLiteDatabase getDatabase(Context context) { synchronized (DBHelper.class) { if (database == null) { database = new DB(context); } return database.getReadableDatabase(); } } private static ArrayList readCursor(Cursor cursor) { cursor.moveToFirst(); ArrayList records = new ArrayList<>(cursor.getCount()); while (!cursor.isAfterLast()) { ValuedHistoryRecord entry = new ValuedHistoryRecord(); entry.record = cursor.getString(0); entry.value = cursor.getLong(1); records.add(entry); cursor.moveToNext(); } cursor.close(); return records; } /** * Insert new item into history * * @param context android context * @param query query to insert * @param record record to insert */ public static void insertHistory(Context context, String query, String record) { SQLiteDatabase db = getDatabase(context); ContentValues values = new ContentValues(); values.put("query", query); values.put("record", record); values.put("timeStamp", System.currentTimeMillis()); long rowId = db.insert("history", null, values); Log.d(TAG, "insertHistory rowId " + rowId); if (Math.random() <= 0.005) { // Roughly every 200 inserts, clean up the history of items older than 3 months long monthsAgo = 7776000000L; // 1000 * 60 * 60 * 24 * 30 * 3; db.delete("history", "timeStamp < ?", new String[]{Long.toString(System.currentTimeMillis() - monthsAgo)}); // And vacuum the DB for speed db.execSQL("VACUUM"); } } public static void removeFromHistory(Context context, String record) { SQLiteDatabase db = getDatabase(context); db.delete("history", "record = ?", new String[]{record}); } public static void clearHistory(Context context) { SQLiteDatabase db = getDatabase(context); db.delete("history", "", null); } public static void setHistory(Context context, Collection history) { SQLiteDatabase db = getDatabase(context); db.beginTransaction(); try { db.execSQL("DROP TABLE IF EXISTS \"history\""); database.createHistory(db); ContentValues values = new ContentValues(3); for (ValuedHistoryRecord rec : history) { values.put("record", rec.record); values.put("query", rec.name); values.put("timeStamp", rec.value); db.insert("history", null, values); } db.setTransactionSuccessful(); } finally { db.endTransaction(); } } private static Cursor getHistoryByFrecency(SQLiteDatabase db, int limit) { // Since smart history sql uses a group by we don't use the whole history but a limit of recent apps int historyWindowSize = limit * 30; // order history based on frequency * recency // frequency = #launches_for_app / #all_launches // recency = 1 / position_of_app_in_normal_history String sql = "SELECT record, count(*) FROM " + " (" + " SELECT * FROM history ORDER BY _id DESC " + " LIMIT " + historyWindowSize + "" + " ) small_history " + " GROUP BY record " + " ORDER BY " + " count(*) * 1.0 / (select count(*) from history LIMIT " + historyWindowSize + ") / ((SELECT _id FROM history ORDER BY _id DESC LIMIT 1) - max(_id) + 0.001) " + " DESC " + " LIMIT " + limit; return db.rawQuery(sql, null); } private static Cursor getHistoryByFrequency(SQLiteDatabase db, int limit) { // order history based on frequency String sql = "SELECT record, count(*) FROM history" + " GROUP BY record " + " ORDER BY count(*) DESC " + " LIMIT " + limit; return db.rawQuery(sql, null); } private static Cursor getHistoryByRecency(SQLiteDatabase db, int limit) { return db.query(true, "history", new String[]{"record", "1"}, null, null, null, null, "_id DESC", Integer.toString(limit)); } /** * Get the most used history items adaptively based on a set period of time * * @param db The SQL db * @param hours How many hours back we want to test frequency against * @param limit Maximum result size * @return Cursor */ private static Cursor getHistoryByAdaptive(SQLiteDatabase db, int hours, int limit) { // order history based on frequency String sql = "SELECT record, count(*) FROM history " + "WHERE timeStamp >= 0 " + "AND timeStamp >" + (System.currentTimeMillis() - (hours * 3600000)) + " GROUP BY record " + " ORDER BY count(*) DESC " + " LIMIT " + limit; return db.rawQuery(sql, null); } @NonNull static ArrayList getHistoryRaw(@NonNull Context context) { SQLiteDatabase db = getDatabase(context); ArrayList records; try (Cursor cursor = db.query("history", new String[]{"record", "query", "timeStamp"}, null, null, null, null, "\"_id\" ASC")) { records = new ArrayList<>(cursor.getCount()); while (cursor.moveToNext()) { ValuedHistoryRecord entry = new ValuedHistoryRecord(); entry.record = cursor.getString(0); entry.name = cursor.getString(1); entry.value = cursor.getLong(2); records.add(entry); } } return records; } public enum HistoryMode { RECENCY, FRECENCY, FREQUENCY, ADAPTIVE, } /** * Retrieve previous query history * * @param context android context * @param limit max number of items to retrieve * @return records with number of use */ @NonNull public static List getHistory(Context context, int limit, HistoryMode historyMode) { List records = null; SQLiteDatabase db = getDatabase(context); Cursor cursor = null; switch (historyMode) { case FRECENCY: cursor = getHistoryByFrecency(db, limit); break; case FREQUENCY: cursor = getHistoryByFrequency(db, limit); break; case ADAPTIVE: cursor = getHistoryByAdaptive(db, PrefCache.getHistoryAdaptive(context), limit); break; case RECENCY: cursor = getHistoryByRecency(db, limit); break; } if (cursor != null) { records = readCursor(cursor); cursor.close(); } if (records == null) records = Collections.emptyList(); return records; } /** * Retrieve history size * * @param context android context * @return total number of use for the application */ public static int getHistoryLength(Context context) { SQLiteDatabase db = getDatabase(context); int historyLength = 0; // Cursor query (boolean distinct, String table, String[] columns, // String selection, String[] selectionArgs, String groupBy, String // having, String orderBy, String limit) try (Cursor cursor = db.query(false, "history", new String[]{"COUNT(*)"}, null, null, null, null, null, null)) { cursor.moveToFirst(); historyLength = cursor.getInt(0); } return historyLength; } /** * Retrieve previously selected items for the query * * @param context android context * @param query query to run * @return records with number of use */ public static ArrayList getPreviousResultsForQuery(Context context, String query) { ArrayList records; SQLiteDatabase db = getDatabase(context); // Cursor query (String table, String[] columns, String selection, // String[] selectionArgs, String groupBy, String having, String // orderBy) try (Cursor cursor = db.query("history", new String[]{"record", "COUNT(*) AS count"}, "query LIKE ?", new String[]{query + "%"}, "record", null, "COUNT(*) DESC", "10")) { records = readCursor(cursor); } return records; } public static boolean insertApp(Context context, AppEntry entry) { SQLiteDatabase db = getDatabase(context); ContentValues values = new ContentValues(); values.put("name", entry.getName()); values.put("component_name", entry.getUserComponentName()); return -1 != db.insert("apps", null, values); } public static boolean insertShortcut(@NonNull Context context, @NonNull ShortcutRecord shortcut) { SQLiteDatabase db = getDatabase(context); // Do not add duplicate shortcuts try (Cursor cursor = db.query("shortcuts", new String[]{"package", "info_data"}, "package = ? AND info_data = ?", new String[]{shortcut.packageName, shortcut.infoData}, null, null, null, null)) { // cursor contains duplicates if (cursor.getCount() > 0) { return false; } } ContentValues values = new ContentValues(); values.put("name", shortcut.displayName); values.put("package", shortcut.packageName); values.put("info_data", shortcut.infoData); values.put("icon_png", shortcut.iconPng); values.put("custom_flags", shortcut.getFlagsDB()); return -1 != db.insert("shortcuts", null, values); } public static void removeShortcut(@NonNull Context context, @NonNull ShortcutEntry shortcut) { SQLiteDatabase db = getDatabase(context); db.delete("shortcuts", "package = ? AND info_data = ?", new String[]{shortcut.packageName, shortcut.shortcutData}); } public static void removeShortcut(@NonNull Context context, long dbId) { SQLiteDatabase db = getDatabase(context); db.delete("shortcuts", "_id=?", new String[]{String.valueOf(dbId)}); } public static void renameShortcut(@NonNull Context context, @NonNull ShortcutEntry shortcut, String newName) { SQLiteDatabase db = getDatabase(context); String sql = "UPDATE \"shortcuts\" SET \"name\"=? WHERE \"package\"=? AND \"info_data\"=?"; try { SQLiteStatement statement = db.compileStatement(sql); statement.bindString(1, newName); statement.bindString(2, shortcut.packageName); statement.bindString(3, shortcut.shortcutData); int count = statement.executeUpdateDelete(); if (count != 1) { Log.e(TAG, "Update name count = " + count); } statement.close(); } catch (Exception e) { Log.e(TAG, "rename shortcut", e); } } /** * Retrieve a list of all shortcuts for current package name, without icons. * Useful when we remove an app and need to also remove the shortcuts for it. */ @NonNull public static List getShortcutsNoIcons(@NonNull Context context, @NonNull String packageName) { SQLiteDatabase db = getDatabase(context); ArrayList records; try (Cursor cursor = db.query("shortcuts", TABLE_COLUMNS_SHORTCUTS_NO_ICON, "package = ?", new String[]{packageName}, null, null, null)) { records = new ArrayList<>(cursor.getCount()); while (cursor.moveToNext()) { ShortcutRecord entry = new ShortcutRecord(); entry.dbId = cursor.getLong(0); entry.displayName = cursor.getString(1); entry.packageName = cursor.getString(2); entry.infoData = cursor.getString(3); entry.flags = cursor.getInt(4); records.add(entry); } } return records; } /** * Retrieve a list of all shortcuts, without icons. */ @NonNull public static ArrayList getShortcutsNoIcons(@NonNull Context context) { SQLiteDatabase db = getDatabase(context); ArrayList records; try (Cursor cursor = db.query("shortcuts", TABLE_COLUMNS_SHORTCUTS_NO_ICON, null, null, null, null, null)) { records = new ArrayList<>(cursor.getCount()); while (cursor.moveToNext()) { ShortcutRecord entry = new ShortcutRecord(); entry.dbId = cursor.getLong(0); entry.displayName = cursor.getString(1); entry.packageName = cursor.getString(2); entry.infoData = cursor.getString(3); entry.flags = cursor.getInt(4); records.add(entry); } } return records; } @Nullable public static byte[] getShortcutIcon(@NonNull Context context, long dbId) { SQLiteDatabase db = getDatabase(context); byte[] iconBlob = null; try (Cursor cursor = db.query("shortcuts", new String[]{"icon_png"}, "_id = ?", new String[]{Long.toString(dbId)}, null, null, null)) { if (cursor.moveToNext()) { iconBlob = cursor.getBlob(0); } } return iconBlob; } /** * Remove shortcuts for a given package name */ public static void removeShortcuts(Context context, String packageName) { SQLiteDatabase db = getDatabase(context); // remove shortcuts db.delete("shortcuts", "package = ?", new String[]{packageName}); } public static void removeAllShortcuts(Context context) { SQLiteDatabase db = getDatabase(context); // delete whole table db.delete("shortcuts", null, null); //db.execSQL("vacuum"); //https://www.sqlitetutorial.net/sqlite-vacuum/ } /** * Insert new tag for given {@link rocks.tbog.tblauncher.entry.EntryItem} * * @param context android context * @param tag tag name to insert * @param entry EntryItem */ public static void addTag(Context context, String tag, EntryItem entry) { SQLiteDatabase db = getDatabase(context); ContentValues values = new ContentValues(); values.put("tag", tag); values.put("record", entry.id); db.insert("tags", null, values); } /** * Delete a tag from a given {@link rocks.tbog.tblauncher.entry.EntryItem} * * @param context android context * @param tag tag name to remove * @param entryId EntryItem.id * @return number of records affected */ public static int removeTag(Context context, String tag, String entryId) { SQLiteDatabase db = getDatabase(context); return db.delete("tags", "tag = ? AND record = ?", new String[]{tag, entryId}); } /** * @param context android context * @param tagName what tag to rename * @param newName the new name of the tag * @return number of records affected */ public static int renameTag(Context context, String tagName, String newName, @Nullable TagEntry tagEntry, @Nullable TagEntry newEntry) { SQLiteDatabase db = getDatabase(context); ContentValues values = new ContentValues(); if (tagEntry != null && newEntry != null) { values.put("record", newEntry.id); int count = db.updateWithOnConflict("favorites", values, "record = ?", new String[]{tagEntry.id}, SQLiteDatabase.CONFLICT_REPLACE); if (count != 1) { Log.e(TAG, "Update favorites in rename tag; count = " + count); } } values.clear(); values.put("tag", newName); return db.update("tags", values, "tag = ?", new String[]{tagName}); } /** * @param context android context * @return HashMap with EntryItem id as key and an ArrayList of tags for each */ @NonNull public static Map> loadTags(Context context) { Map> records; SQLiteDatabase db = getDatabase(context); try (Cursor cursor = db.query("tags", new String[]{"record", "tag"}, null, null, null, null, null)) { records = new HashMap<>(cursor.getCount()); while (cursor.moveToNext()) { String id = cursor.getString(0); String tag = cursor.getString(1); List tagList = records.get(id); if (tagList == null) records.put(id, tagList = new ArrayList<>()); tagList.add(tag); } } return records; } /** * Drop all previous tags and set the ones provided in the map * * @param context android context * @param tagsMap map with tag names as key and a list of ids as value */ public static void setTagsMap(Context context, Map> tagsMap) { SQLiteDatabase db = getDatabase(context); db.beginTransaction(); try { //db.delete("tags", null, null); db.execSQL("DROP TABLE IF EXISTS \"tags\""); database.createTags(db); ContentValues values = new ContentValues(2); for (Map.Entry> entry : tagsMap.entrySet()) { values.put("tag", entry.getKey()); for (String record : entry.getValue()) { values.put("record", record); db.insert("tags", null, values); } } db.setTransactionSuccessful(); } finally { db.endTransaction(); } } /** * Add all tags from the provided map values * * @param context android context * @param tags map with record names as key and a list of tags as value */ public static void addTags(Context context, Map> tags) { SQLiteDatabase db = getDatabase(context); db.beginTransaction(); try { ContentValues values = new ContentValues(2); for (Map.Entry> entry : tags.entrySet()) { values.put("record", entry.getKey()); for (String tag : entry.getValue()) { values.put("tag", tag); db.insert("tags", null, values); } } db.setTransactionSuccessful(); } finally { db.endTransaction(); } } /** * @param context android context * @return List of all tags */ @NonNull public static List loadTagList(Context context) { List tags; SQLiteDatabase db = getDatabase(context); try (Cursor cursor = db.query("tags", new String[]{"tag"}, null, null, "tag", null, null)) { tags = new ArrayList<>(cursor.getCount()); while (cursor.moveToNext()) { String tag = cursor.getString(0); tags.add(tag); } } return tags; } /** * @param context android context * @param record the id of the app * @return HashMap with EntryItem id as key and an ArrayList of tags for each */ @NonNull public static List loadTags(Context context, String record) { List tagList; SQLiteDatabase db = getDatabase(context); try (Cursor cursor = db.query("tags", new String[]{"tag"}, "record=?", new String[]{record}, null, null, null)) { tagList = new ArrayList<>(cursor.getCount()); while (cursor.moveToNext()) { String tag = cursor.getString(0); tagList.add(tag); } } return tagList; } @NonNull public static HashMap getAppsData(Context context) { HashMap records; SQLiteDatabase db = getDatabase(context); try (Cursor cursor = db.query("apps", TABLE_COLUMNS_APPS, null, null, null, null, null)) { records = new HashMap<>(cursor.getCount()); while (cursor.moveToNext()) { AppRecord entry = new AppRecord(); entry.dbId = cursor.getLong(0); entry.displayName = cursor.getString(1); entry.componentName = cursor.getString(2); entry.flags = cursor.getInt(3); records.put(entry.componentName, entry); } } return records; } public static void insertOrUpdateApps(Context context, ArrayList appRecords) { SQLiteDatabase db = getDatabase(context); db.beginTransaction(); ContentValues values = new ContentValues(); try { for (AppRecord app : appRecords) { values.put("display_name", app.displayName); values.put("component_name", app.componentName); values.put("custom_flags", app.getFlagsDB()); if (app.dbId == -1) { // insert db.insertWithOnConflict("apps", null, values, SQLiteDatabase.CONFLICT_IGNORE); } else { // update db.updateWithOnConflict("apps", values, "_id=" + app.dbId, null, SQLiteDatabase.CONFLICT_IGNORE); } } db.setTransactionSuccessful(); } finally { db.endTransaction(); } } public static void deleteApps(Context context, ArrayList appRecords) { SQLiteDatabase db = getDatabase(context); String[] list = new String[appRecords.size()]; for (int i = 0; i < appRecords.size(); i++) { AppRecord rec = appRecords.get(i); list[i] = String.valueOf(rec.dbId); } String whereClause = String.format("_id IN (%s)", TextUtils.join(",", Collections.nCopies(list.length, "?"))); db.delete("apps", whereClause, list); } public static void setCustomAppName(Context context, String componentName, String newName) { SQLiteDatabase db = getDatabase(context); String sql = "UPDATE apps SET display_name=?,custom_flags=custom_flags|? WHERE component_name=?"; try { SQLiteStatement statement = db.compileStatement(sql); statement.bindString(1, newName); statement.bindLong(2, AppRecord.FLAG_CUSTOM_NAME); statement.bindString(3, componentName); int count = statement.executeUpdateDelete(); if (count != 1) { Log.e(TAG, "Update name; count = " + count); } statement.close(); } catch (Exception e) { Log.e(TAG, "Insert or Update custom app name", e); } } public static boolean setAppHidden(Context context, String componentName) { boolean ret = false; SQLiteDatabase db = getDatabase(context); String sql = "UPDATE \"apps\" SET \"custom_flags\"=\"custom_flags\"|? WHERE \"component_name\"=?"; try { SQLiteStatement statement = db.compileStatement(sql); statement.bindLong(1, AppRecord.FLAG_APP_HIDDEN); statement.bindString(2, componentName); int count = statement.executeUpdateDelete(); if (count == 1) { ret = true; } else { Log.e(TAG, "Update name; count = " + count); } statement.close(); } catch (Exception e) { Log.e(TAG, "Insert or Update custom app name", e); } return ret; } public static boolean removeAppHidden(Context context, String componentName) { boolean ret = false; SQLiteDatabase db = getDatabase(context); String sql = "UPDATE \"apps\" SET \"custom_flags\"=\"custom_flags\"&~? WHERE \"component_name\"=?"; try { SQLiteStatement statement = db.compileStatement(sql); statement.bindLong(1, AppRecord.FLAG_APP_HIDDEN); statement.bindString(2, componentName); int count = statement.executeUpdateDelete(); if (count == 1) { ret = true; } else { Log.e(TAG, "Update name; count = " + count); } statement.close(); } catch (Exception e) { Log.e(TAG, "Insert or Update custom app name", e); } return ret; } public static void setCustomStaticEntryName(Context context, String entryId, String newName) { SQLiteDatabase db = getDatabase(context); int count; String sql = "UPDATE favorites SET name=?,custom_flags=custom_flags|? WHERE record=?"; try { SQLiteStatement statement = db.compileStatement(sql); statement.bindString(1, newName); statement.bindLong(2, ModRecord.FLAG_CUSTOM_NAME); statement.bindString(3, entryId); count = statement.executeUpdateDelete(); if (count != 1) { Log.e(TAG, "Update name for `" + entryId + "`; count = " + count); } statement.close(); } catch (Exception e) { Log.e(TAG, "Update custom static entry name for `" + entryId + "`", e); count = -1; } if (count < 1) { ContentValues values = new ContentValues(); values.put("record", entryId); values.put("position", ""); values.put("name", newName); values.put("custom_flags", ModRecord.FLAG_CUSTOM_NAME); db.insertWithOnConflict("favorites", null, values, SQLiteDatabase.CONFLICT_REPLACE); } } public static void removeCustomAppName(Context context, String componentName, String defaultName) { SQLiteDatabase db = getDatabase(context); String sql = "UPDATE apps SET display_name=?,custom_flags=custom_flags&~? WHERE component_name=?"; try { SQLiteStatement statement = db.compileStatement(sql); statement.bindString(1, defaultName); statement.bindLong(2, AppRecord.FLAG_CUSTOM_NAME); statement.bindString(3, componentName); int count = statement.executeUpdateDelete(); if (count != 1) { Log.e(TAG, "Reset name; count = " + count); } statement.close(); } catch (Exception e) { Log.e(TAG, "Insert or Update custom app name", e); } } @Nullable private static AppRecord getAppRecord(SQLiteDatabase db, String componentName) { String[] selArgs = new String[]{componentName}; try (Cursor cursor = db.query("apps", TABLE_COLUMNS_APPS, "component_name=?", selArgs, null, null, null)) { if (cursor.moveToNext()) { AppRecord entry = new AppRecord(); entry.dbId = cursor.getLong(0); entry.displayName = cursor.getString(1); entry.componentName = cursor.getString(2); entry.flags = cursor.getInt(3); return entry; } } return null; } public static boolean setCachedAppIcon(Context context, String componentName, byte[] icon) { SQLiteDatabase db = getDatabase(context); String sql = "UPDATE apps SET cached_icon=? WHERE component_name=?"; try { SQLiteStatement statement = db.compileStatement(sql); statement.bindBlob(1, icon); statement.bindString(2, componentName); int count = statement.executeUpdateDelete(); if (count != 1) { Log.e(TAG, "setCachedAppIcon; count = " + count); } statement.close(); } catch (Exception e) { Log.e(TAG, "Insert or Update cached app icon `" + componentName + "`", e); return false; } return true; } public static AppRecord setCustomAppIcon(Context context, String componentName, byte[] icon) { SQLiteDatabase db = getDatabase(context); String sql = "UPDATE apps SET custom_flags=custom_flags|?, custom_icon=? WHERE component_name=?"; try { SQLiteStatement statement = db.compileStatement(sql); statement.bindLong(1, AppRecord.FLAG_CUSTOM_ICON); statement.bindBlob(2, icon); statement.bindString(3, componentName); int count = statement.executeUpdateDelete(); if (count != 1) { Log.e(TAG, "Update icon; count = " + count); } statement.close(); } catch (Exception e) { Log.e(TAG, "Insert or Update custom app icon `" + componentName + "`", e); } return getAppRecord(db, componentName); } public static void setCustomStaticEntryIcon(Context context, String entryId, byte[] icon) { SQLiteDatabase db = getDatabase(context); int count; String sql = "UPDATE favorites SET custom_flags=custom_flags|?, custom_icon=? WHERE record=?"; try { SQLiteStatement statement = db.compileStatement(sql); statement.bindLong(1, ModRecord.FLAG_CUSTOM_ICON); statement.bindBlob(2, icon); statement.bindString(3, entryId); count = statement.executeUpdateDelete(); if (count != 1) { Log.w(TAG, "Update icon for `" + entryId + "`; count = " + count); } statement.close(); } catch (Exception e) { Log.e(TAG, "Update custom fav icon `" + entryId + "`", e); count = -1; } if (count < 1) { ContentValues values = new ContentValues(); values.put("record", entryId); values.put("position", ""); values.put("custom_icon", icon); values.put("custom_flags", ModRecord.FLAG_CUSTOM_ICON); db.insertWithOnConflict("favorites", null, values, SQLiteDatabase.CONFLICT_REPLACE); } } public static AppRecord removeCustomAppIcon(Context context, String componentName) { SQLiteDatabase db = getDatabase(context); String sql = "UPDATE apps SET custom_flags=custom_flags&~?, custom_icon=NULL WHERE component_name=?"; try { SQLiteStatement statement = db.compileStatement(sql); statement.bindLong(1, AppRecord.FLAG_CUSTOM_ICON); statement.bindString(2, componentName); int count = statement.executeUpdateDelete(); if (count != 1) { Log.e(TAG, "Reset icon; count = " + count); } statement.close(); } catch (Exception e) { Log.e(TAG, "Insert or Update custom app name", e); } return getAppRecord(db, componentName); } public static void removeCustomStaticEntryIcon(Context context, String entryId) { SQLiteDatabase db = getDatabase(context); String sql = "UPDATE favorites SET custom_flags=custom_flags&~?, custom_icon=NULL WHERE record=?"; try { SQLiteStatement statement = db.compileStatement(sql); statement.bindLong(1, ModRecord.FLAG_CUSTOM_ICON); statement.bindString(2, entryId); int count = statement.executeUpdateDelete(); if (count != 1) { Log.e(TAG, "Reset `" + entryId + "` icon; count = " + count); } statement.close(); } catch (Exception e) { Log.e(TAG, "Reset custom entry `" + entryId + "` icon", e); } } public static void removeCustomStaticEntryName(Context context, String entryId) { SQLiteDatabase db = getDatabase(context); String sql = "UPDATE favorites SET custom_flags=custom_flags&~?, name=NULL WHERE record=?"; try { SQLiteStatement statement = db.compileStatement(sql); statement.bindLong(1, ModRecord.FLAG_CUSTOM_NAME); statement.bindString(2, entryId); int count = statement.executeUpdateDelete(); if (count != 1) { Log.e(TAG, "Reset `" + entryId + "` name; count = " + count); } statement.close(); } catch (Exception e) { Log.e(TAG, "Reset custom entry `" + entryId + "` name", e); } } @Nullable private static byte[] getAppIcon(Context context, String componentName, String[] dbColumn) { SQLiteDatabase db = getDatabase(context); String[] selArgs = new String[]{componentName}; try (Cursor cursor = db.query("apps", dbColumn, "component_name=?", selArgs, null, null, null)) { if (cursor.moveToNext()) { return cursor.getBlob(0); } } return null; } @Nullable public static byte[] getCachedAppIcon(Context context, String componentName) { return getAppIcon(context, componentName, TABLE_APPS_CACHED_ICON); } @Nullable public static byte[] getCustomAppIcon(Context context, String componentName) { return getAppIcon(context, componentName, TABLE_APPS_CUSTOM_ICON); } @Nullable public static byte[] getCustomFavIcon(Context context, String record) { SQLiteDatabase db = getDatabase(context); String[] selArgs = new String[]{record}; try (Cursor cursor = db.query("favorites", TABLE_MODS_CUSTOM_ICON, "record=?", selArgs, null, null, null)) { if (cursor.moveToNext()) { return cursor.getBlob(0); } } return null; } public static void setMod(Context context, ModRecord fav) { SQLiteDatabase db = getDatabase(context); ContentValues values = new ContentValues(); values.put("record", fav.record); values.put("position", fav.position); values.put("custom_flags", fav.getFlagsDB()); int rows = db.update("favorites", values, "record=?", new String[]{fav.record}); if (rows == 0) db.insertWithOnConflict("favorites", null, values, SQLiteDatabase.CONFLICT_REPLACE); // try { // db.replaceOrThrow("favorites", null, values); // } catch (SQLException e) { // Log.e(TAG, "setFavorite " + fav.record); // } } public static void setMods(Context context, Collection> favRecords) { SQLiteDatabase db = getDatabase(context); db.beginTransaction(); try { db.execSQL("DROP TABLE IF EXISTS \"favorites\""); database.createFavoritesTable(db, false); ContentValues values = new ContentValues(); for (Pair pair : favRecords) { ModRecord fav = pair.first; byte[] icon = pair.second; values.put("record", fav.record); values.put("position", fav.position == null ? "" : fav.position); values.put("custom_flags", fav.getFlagsDB()); values.put(TABLE_MODS_CUSTOM_ICON[0], icon); db.insertWithOnConflict("favorites", null, values, SQLiteDatabase.CONFLICT_REPLACE); } db.setTransactionSuccessful(); } finally { db.endTransaction(); } } public static boolean removeMod(Context context, String record) { SQLiteDatabase db = getDatabase(context); if (0 == db.delete("favorites", "record=?", new String[]{record})) { Log.e(TAG, "removeFavorite " + record); return false; } return true; } @NonNull public static ArrayList getMods(@NonNull Context context) { ArrayList list; SQLiteDatabase db = getDatabase(context); try (Cursor c = db.query("favorites", TABLE_COLUMNS_MODS, null, null, null, null, "position")) { list = new ArrayList<>(c.getCount()); while (c.moveToNext()) { ModRecord fav = new ModRecord(); fav.record = c.getString(0); fav.position = c.getString(1); fav.setFlags(c.getInt(2)); fav.displayName = c.getString(3); list.add(fav); } } return list; } public static boolean updateQuickListPosition(@NonNull Context context, String record, String position) { SQLiteDatabase db = getDatabase(context); String sql = "UPDATE \"favorites\" SET \"custom_flags\"=(\"custom_flags\"|?), \"position\"=? WHERE \"record\"=?"; try { SQLiteStatement statement = db.compileStatement(sql); statement.bindLong(1, ModRecord.FLAG_SHOW_IN_QUICK_LIST); statement.bindString(2, position); statement.bindString(3, record); int count = statement.executeUpdateDelete(); if (count != 1) { Log.e(TAG, "Update position; count = " + count); return false; } statement.close(); } catch (Exception e) { Log.e(TAG, "set flag and position", e); return false; } return true; } public static ArrayList getWidgets(@NonNull Context context) { SQLiteDatabase db = getDatabase(context); ArrayList list; try (Cursor c = db.query("widgets", new String[]{"appWidgetId", "properties"}, null, null, null, null, null)) { list = new ArrayList<>(c.getCount()); while (c.moveToNext()) { int appWidgetId = c.getInt(0); String properties = c.getString(1); WidgetRecord rec = WidgetRecord.loadFromDB(appWidgetId, properties); list.add(rec); } } return list; } public static void addWidget(@NonNull Context context, WidgetRecord rec) { SQLiteDatabase db = getDatabase(context); ContentValues values = new ContentValues(); values.put("appWidgetId", rec.appWidgetId); values.put("properties", rec.packedProperties()); db.insert("widgets", null, values); } public static void removeWidget(@NonNull Context context, int appWidgetId) { SQLiteDatabase db = getDatabase(context); db.delete("widgets", "appWidgetId=?", new String[]{String.valueOf(appWidgetId)}); } public static void removeWidgetPlaceholder(@NonNull Context context, int appWidgetId, String provider) { SQLiteDatabase db = getDatabase(context); //TODO: escape the provider String[] whereArgs = new String[]{String.valueOf(appWidgetId), "" + provider + ""}; db.delete("widgets", "appWidgetId=? AND properties LIKE '%' || ? || '%'", whereArgs); } public static void setWidgetProperties(@NonNull Context context, WidgetRecord rec) { SQLiteDatabase db = getDatabase(context); ContentValues values = new ContentValues(); values.put("properties", rec.packedProperties()); int affectedRows = db.update("widgets", values, "appWidgetId=?", new String[]{String.valueOf(rec.appWidgetId)}); if (affectedRows == 0) addWidget(context, rec); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/db/ExportedData.java ================================================ package rocks.tbog.tblauncher.db; import android.annotation.SuppressLint; import android.content.ComponentName; import android.content.Context; import android.content.SharedPreferences; import android.util.Log; import android.util.Pair; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.collection.ArraySet; import androidx.preference.PreferenceManager; import org.xmlpull.v1.XmlPullParser; import org.xmlpull.v1.XmlPullParserException; import java.io.IOException; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.SettingsActivity; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.TBLauncherActivity; import rocks.tbog.tblauncher.widgets.WidgetManager; import rocks.tbog.tblauncher.handler.TagsHandler; import rocks.tbog.tblauncher.utils.PrefCache; import rocks.tbog.tblauncher.utils.Utilities; public class ExportedData { private static final String TAG = "XParse"; // XTN = xml tag name public static final String XTN_TAG_LIST = "taglist"; public static final String XTN_TAG_LIST_ITEM = "tag"; public static final String XTN_TAG_LIST_ITEM_ID = "item"; public static final String XTN_MOD_LIST = "favlist"; // modification list public static final String XTN_MOD_LIST_ITEM = "favorite"; // modification entry public static final String XTN_MOD_LIST_ITEM_ID = "id"; public static final String XTN_APP_LIST = "applist"; public static final String XTN_APP_LIST_ITEM = "app"; public static final String XTN_APP_LIST_ITEM_ID = "component"; public static final String XTN_UI_LIST = "interface"; public static final String XTN_PREF_LIST = "preferences"; public static final String XTN_PREF_LIST_ITEM = "preference"; public static final String XTN_WIDGET_LIST = "widgets"; public static final String XTN_WIDGET_LIST_ITEM = "widget"; public static final String XTN_HISTORY_LIST = "history"; public static final String XTN_HISTORY_LIST_ITEM = "item"; // HashMap with tag name as key and an ArrayList of records for each private final HashMap> mTags = new HashMap<>(); private final HashMap mPreferences = new HashMap<>(); private final ArrayList mMods = new ArrayList<>(); private final ArrayList mApplications = new ArrayList<>(); private final ArrayList mWidgets = new ArrayList<>(); private final HashMap mIcons = new HashMap<>(); private final ArrayList mHistory = new ArrayList<>(); private boolean bTagListLoaded = false; private boolean bModListLoaded = false; private boolean bAppListLoaded = false; private boolean bPrefListLoaded = false; private boolean bWidgetListLoaded = false; private boolean bHistoryListLoaded = false; void parseTagList(@NonNull XmlPullParser xpp, int eventType) throws IOException, XmlPullParserException { String currentTag = null; boolean bTagItem = false; boolean bTagListFinished = false; while (eventType != XmlPullParser.END_DOCUMENT) { switch (eventType) { case XmlPullParser.START_TAG: int attrCount = xpp.getAttributeCount(); switch (xpp.getName()) { case XTN_TAG_LIST_ITEM: for (int attrIdx = 0; attrIdx < attrCount; attrIdx += 1) { String attrName = xpp.getAttributeName(attrIdx); if ("name".equals(attrName)) { currentTag = xpp.getAttributeValue(attrIdx); } } break; case XTN_TAG_LIST_ITEM_ID: bTagItem = currentTag != null; break; case XTN_TAG_LIST: for (int attrIdx = 0; attrIdx < attrCount; attrIdx += 1) { String attrName = xpp.getAttributeName(attrIdx); if ("version".equals(attrName)) { String tagListVersion = xpp.getAttributeValue(attrIdx); Log.d(TAG, "tagList version " + tagListVersion); } } break; default: Log.d(TAG, "ignored " + xpp.getName()); } break; case XmlPullParser.END_TAG: switch (xpp.getName()) { case XTN_TAG_LIST_ITEM: currentTag = null; // fall-through case XTN_TAG_LIST_ITEM_ID: bTagItem = false; break; case XTN_TAG_LIST: bTagListFinished = true; break; } break; case XmlPullParser.TEXT: if (bTagItem && currentTag != null) { addRecordTag(xpp.getText(), currentTag); } break; } if (bTagListFinished) break; eventType = xpp.next(); } bTagListLoaded = true; } public void parseFavorites(XmlPullParser xpp, int eventType) throws IOException, XmlPullParserException { ModRecord currentFav = null; boolean bFavListFinished = false; String lastTag = null; String iconEncoding = null; while (eventType != XmlPullParser.END_DOCUMENT) { switch (eventType) { case XmlPullParser.START_TAG: int attrCount = xpp.getAttributeCount(); switch (xpp.getName()) { case XTN_MOD_LIST_ITEM: currentFav = new ModRecord(); lastTag = null; break; case XTN_MOD_LIST_ITEM_ID: case "flags": case "name": case "quicklist": if (currentFav != null) lastTag = xpp.getName(); break; case "icon": if (currentFav != null) lastTag = xpp.getName(); iconEncoding = null; for (int attrIdx = 0; attrIdx < attrCount; attrIdx += 1) { String attrName = xpp.getAttributeName(attrIdx); if ("encoding".equals(attrName)) { iconEncoding = xpp.getAttributeValue(attrIdx); } } break; case XTN_MOD_LIST: for (int attrIdx = 0; attrIdx < attrCount; attrIdx += 1) { String attrName = xpp.getAttributeName(attrIdx); if ("version".equals(attrName)) { String tagListVersion = xpp.getAttributeValue(attrIdx); Log.d(TAG, "favList version " + tagListVersion); } } break; default: Log.d(TAG, "ignored " + xpp.getName()); } break; case XmlPullParser.END_TAG: switch (xpp.getName()) { case XTN_MOD_LIST_ITEM: if (currentFav != null && currentFav.record != null) mMods.add(currentFav); currentFav = null; // fall-through case XTN_MOD_LIST_ITEM_ID: case "flags": case "name": case "icon": case "quicklist": lastTag = null; break; case XTN_MOD_LIST: bFavListFinished = true; break; } break; case XmlPullParser.TEXT: if (lastTag != null && currentFav != null) { switch (lastTag) { case XTN_MOD_LIST_ITEM_ID: currentFav.record = xpp.getText(); if (currentFav.record.isEmpty()) currentFav.record = null; break; case "flags": try { currentFav.setFlags(Integer.parseInt(xpp.getText())); } catch (NumberFormatException ignored) { currentFav.setFlags(0); } break; case "name": currentFav.displayName = xpp.getText(); break; case "icon": addIcon(currentFav, xpp.getText(), iconEncoding); break; case "quicklist": currentFav.position = xpp.getText(); break; } } break; } if (bFavListFinished) break; eventType = xpp.next(); } bModListLoaded = true; } public void parseApplications(XmlPullParser xpp, int eventType) throws IOException, XmlPullParserException { AppRecord currentApp = null; boolean bAppListFinished = false; String lastTag = null; String iconEncoding = null; while (eventType != XmlPullParser.END_DOCUMENT) { switch (eventType) { case XmlPullParser.START_TAG: int attrCount = xpp.getAttributeCount(); switch (xpp.getName()) { case XTN_APP_LIST_ITEM: currentApp = new AppRecord(); lastTag = null; break; case XTN_APP_LIST_ITEM_ID: case "flags": case "name": if (currentApp != null) lastTag = xpp.getName(); break; case "icon": if (currentApp != null) lastTag = xpp.getName(); iconEncoding = null; for (int attrIdx = 0; attrIdx < attrCount; attrIdx += 1) { String attrName = xpp.getAttributeName(attrIdx); if ("encoding".equals(attrName)) { iconEncoding = xpp.getAttributeValue(attrIdx); } } break; case XTN_APP_LIST: for (int attrIdx = 0; attrIdx < attrCount; attrIdx += 1) { String attrName = xpp.getAttributeName(attrIdx); if ("version".equals(attrName)) { String tagListVersion = xpp.getAttributeValue(attrIdx); Log.d(TAG, "appList version " + tagListVersion); } } break; default: Log.d(TAG, "ignored " + xpp.getName()); } break; case XmlPullParser.END_TAG: switch (xpp.getName()) { case XTN_APP_LIST_ITEM: if (currentApp != null && currentApp.componentName != null) mApplications.add(currentApp); currentApp = null; // fall-through case XTN_APP_LIST_ITEM_ID: case "flags": case "name": case "icon": lastTag = null; break; case XTN_APP_LIST: bAppListFinished = true; break; } break; case XmlPullParser.TEXT: if (lastTag != null && currentApp != null) { switch (lastTag) { case XTN_APP_LIST_ITEM_ID: currentApp.componentName = xpp.getText(); if (currentApp.componentName.isEmpty()) currentApp.componentName = null; break; case "flags": try { currentApp.setFlags(Integer.parseInt(xpp.getText())); } catch (NumberFormatException ignored) { currentApp.setFlags(0); } break; case "name": currentApp.displayName = xpp.getText(); break; case "icon": addIcon(currentApp, xpp.getText(), iconEncoding); break; } } break; } if (bAppListFinished) break; try { eventType = xpp.next(); } catch (IOException e) { if (currentApp != null) Log.e(TAG, "currentApp " + currentApp.componentName + " " + currentApp.displayName); throw new IOException("app xpp.next", e); } } bAppListLoaded = true; } void parsePreferences(@NonNull XmlPullParser xpp, int eventType) throws IOException, XmlPullParserException { String prefName = null; Object prefValue = null; boolean addTextToValueSet = false; boolean bPrefListFinished = false; while (eventType != XmlPullParser.END_DOCUMENT) { switch (eventType) { case XmlPullParser.START_TAG: int attrCount = xpp.getAttributeCount(); switch (xpp.getName()) { case XTN_PREF_LIST_ITEM: for (int attrIdx = 0; attrIdx < attrCount; attrIdx += 1) { String attrName = xpp.getAttributeName(attrIdx); switch (attrName) { case "key": prefName = xpp.getAttributeValue(attrIdx); break; case "value": prefValue = xpp.getAttributeValue(attrIdx); break; case "bool": prefValue = Boolean.parseBoolean(xpp.getAttributeValue(attrIdx)); break; case "int": try { prefValue = Integer.parseInt(xpp.getAttributeValue(attrIdx)); } catch (NumberFormatException ignored) { prefValue = 0; } break; case "color": try { String str = xpp.getAttributeValue(attrIdx).substring(1); int length = str.length(); if (length > 6) str = str.substring(length - 6); prefValue = Integer.parseInt(str, 16); } catch (NumberFormatException ignored) { prefValue = 0; } break; case "argb": try { String str = xpp.getAttributeValue(attrIdx).substring(1); long parsed = Long.parseLong(str, 16); prefValue = (int) parsed; } catch (NumberFormatException ignored) { prefValue = 0; } break; case "set": // we get Strings from the XML parser, no need to keep Objects prefValue = new ArraySet(); addTextToValueSet = false; break; default: Log.d(TAG, "ignored attribute " + xpp.getAttributeValue(attrIdx) + " from tag " + xpp.getName()); } } break; case XTN_UI_LIST: case XTN_PREF_LIST: for (int attrIdx = 0; attrIdx < attrCount; attrIdx += 1) { String attrName = xpp.getAttributeName(attrIdx); if ("version".equals(attrName)) { String prefListVersion = xpp.getAttributeValue(attrIdx); Log.d(TAG, "prefList version " + prefListVersion); } } break; case "item": if (prefValue instanceof ArraySet) addTextToValueSet = true; else Log.d(TAG, "expected Set, found " + prefValue); break; default: Log.d(TAG, "ignored " + xpp.getName()); } break; case XmlPullParser.END_TAG: switch (xpp.getName()) { case XTN_PREF_LIST_ITEM: if (prefName != null && prefValue != null) mPreferences.put(prefName, prefValue); prefName = null; prefValue = null; break; case XTN_UI_LIST: case XTN_PREF_LIST: bPrefListFinished = true; break; case "item": addTextToValueSet = false; break; } break; case XmlPullParser.TEXT: if (addTextToValueSet) { @SuppressWarnings("unchecked") ArraySet set = (ArraySet) prefValue; set.add(xpp.getText()); } else if (prefName != null) Log.d(TAG, "preference `" + prefName + "` has text `" + xpp.getText() + "`"); break; } if (bPrefListFinished) break; eventType = xpp.next(); } bPrefListLoaded = true; } public void parseWidgets_v1(XmlPullParser xpp, int eventType) throws IOException, XmlPullParserException { PlaceholderWidgetRecord currentWidget = null; boolean bWidgetListFinished = false; String lastTag = null; String iconEncoding = null; while (eventType != XmlPullParser.END_DOCUMENT) { switch (eventType) { case XmlPullParser.START_TAG: int attrCount = xpp.getAttributeCount(); switch (xpp.getName()) { case XTN_WIDGET_LIST_ITEM: currentWidget = new PlaceholderWidgetRecord(); lastTag = null; for (int attrIdx = 0; attrIdx < attrCount; attrIdx += 1) { String attrName = xpp.getAttributeName(attrIdx); if ("id".equals(attrName)) { try { currentWidget.appWidgetId = Integer.parseInt(xpp.getAttributeValue(attrIdx)); } catch (NumberFormatException ignored) { currentWidget.appWidgetId = WidgetManager.INVALID_WIDGET_ID; } } } break; case "name": case "provider": if (currentWidget != null) lastTag = xpp.getName(); break; case "preview": if (currentWidget != null) lastTag = xpp.getName(); iconEncoding = null; for (int attrIdx = 0; attrIdx < attrCount; attrIdx += 1) { String attrName = xpp.getAttributeName(attrIdx); if ("encoding".equals(attrName)) { iconEncoding = xpp.getAttributeValue(attrIdx); } } break; case "properties": if (currentWidget != null) { WidgetRecord widgetRecord = new WidgetRecord(); widgetRecord.appWidgetId = currentWidget.appWidgetId; widgetRecord.parseProperties(xpp, eventType); currentWidget.copyFrom(widgetRecord); } break; case XTN_WIDGET_LIST: for (int attrIdx = 0; attrIdx < attrCount; attrIdx += 1) { String attrName = xpp.getAttributeName(attrIdx); if ("version".equals(attrName)) { String listVersion = xpp.getAttributeValue(attrIdx); Log.d(TAG, "widgetList version " + listVersion); } } break; default: Log.d(TAG, "ignored " + xpp.getName()); } break; case XmlPullParser.END_TAG: switch (xpp.getName()) { case XTN_WIDGET_LIST_ITEM: if (currentWidget != null) mWidgets.add(currentWidget); currentWidget = null; // fall-through case "name": case "provider": case "preview": lastTag = null; break; case XTN_WIDGET_LIST: bWidgetListFinished = true; break; } break; case XmlPullParser.TEXT: if (lastTag != null && currentWidget != null) switch (lastTag) { case "name": currentWidget.name = xpp.getText(); break; case "provider": currentWidget.provider = ComponentName.unflattenFromString(xpp.getText()); break; case "preview": currentWidget.preview = Utilities.decodeIcon(xpp.getText(), iconEncoding); break; } break; } if (bWidgetListFinished) break; eventType = xpp.next(); } bWidgetListLoaded = true; } public void parseWidgets_v2(XmlPullParser xpp, int eventType) throws IOException, XmlPullParserException { PlaceholderWidgetRecord currentWidget = null; boolean bWidgetListFinished = false; while (eventType != XmlPullParser.END_DOCUMENT) { switch (eventType) { case XmlPullParser.START_TAG: int attrCount = xpp.getAttributeCount(); switch (xpp.getName()) { case XTN_WIDGET_LIST_ITEM: currentWidget = new PlaceholderWidgetRecord(); for (int attrIdx = 0; attrIdx < attrCount; attrIdx += 1) { String attrName = xpp.getAttributeName(attrIdx); if ("id".equals(attrName)) { try { currentWidget.appWidgetId = Integer.parseInt(xpp.getAttributeValue(attrIdx)); } catch (NumberFormatException ignored) { currentWidget.appWidgetId = WidgetManager.INVALID_WIDGET_ID; } } } break; case "properties": if (currentWidget != null) currentWidget.parseProperties(xpp, eventType); break; case XTN_WIDGET_LIST: for (int attrIdx = 0; attrIdx < attrCount; attrIdx += 1) { String attrName = xpp.getAttributeName(attrIdx); if ("version".equals(attrName)) { String prefListVersion = xpp.getAttributeValue(attrIdx); Log.d(TAG, "widgetList version " + prefListVersion); } } break; default: Log.d(TAG, "ignored " + xpp.getName()); } break; case XmlPullParser.END_TAG: switch (xpp.getName()) { case XTN_WIDGET_LIST_ITEM: if (currentWidget != null) mWidgets.add(currentWidget); currentWidget = null; break; case XTN_WIDGET_LIST: bWidgetListFinished = true; break; } break; } if (bWidgetListFinished) break; eventType = xpp.next(); } bWidgetListLoaded = true; } public void parseHistory(XmlPullParser xpp, int eventType) throws IOException, XmlPullParserException { ValuedHistoryRecord currentRecord = null; boolean bHistoryListFinished = false; String lastTag = null; while (eventType != XmlPullParser.END_DOCUMENT) { switch (eventType) { case XmlPullParser.START_TAG: int attrCount = xpp.getAttributeCount(); switch (xpp.getName()) { case XTN_HISTORY_LIST_ITEM: lastTag = null; currentRecord = new ValuedHistoryRecord(); for (int attrIdx = 0; attrIdx < attrCount; attrIdx += 1) { String attrName = xpp.getAttributeName(attrIdx); if ("time".equals(attrName)) { try { currentRecord.value = Long.parseLong(xpp.getAttributeValue(attrIdx)); } catch (NumberFormatException ignored) { currentRecord.value = 0; } } } break; case "id": case "query": if (currentRecord != null) lastTag = xpp.getName(); break; case XTN_HISTORY_LIST: for (int attrIdx = 0; attrIdx < attrCount; attrIdx += 1) { String attrName = xpp.getAttributeName(attrIdx); if ("version".equals(attrName)) { String listVersion = xpp.getAttributeValue(attrIdx); Log.d(TAG, "historyList version " + listVersion); } } break; default: Log.d(TAG, "ignored " + xpp.getName()); } break; case XmlPullParser.TEXT: if (lastTag != null && currentRecord != null) switch (lastTag) { case "id": currentRecord.record = xpp.getText(); break; case "query": currentRecord.name = xpp.getText(); break; } break; case XmlPullParser.END_TAG: switch (xpp.getName()) { case XTN_HISTORY_LIST_ITEM: if (currentRecord != null) mHistory.add(currentRecord); currentRecord = null; // fall-through case "id": case "query": lastTag = null; break; case XTN_HISTORY_LIST: bHistoryListFinished = true; break; } break; } if (bHistoryListFinished) break; eventType = xpp.next(); } bHistoryListLoaded = true; } private void addIcon(@NonNull FlagsRecord rec, String text, @Nullable String encoding) { if (text == null) { mIcons.remove(rec); return; } byte[] icon = Utilities.decodeIcon(text, encoding); if (icon != null) mIcons.put(rec, icon); } private void addRecordTag(@Nullable String record, @NonNull String tagName) { if (record == null) return; record = record.trim(); if (record.isEmpty()) return; List records = mTags.get(tagName); if (records == null) mTags.put(tagName, records = new ArrayList<>()); records.add(record); } public void saveToDB(@NonNull Context context, @NonNull Method method) { saveTags(context, method); saveMods(context, method); saveApplications(context, method); savePreferences(context, method); restoreWidgets(context, method); saveHistory(context, method); TBApplication.dataHandler(context).reloadProviders(); } private void saveTags(@NonNull Context context, Method method) { if (!bTagListLoaded) return; HashMap> tags = new HashMap<>(); if (method == Method.OVERWRITE || method == Method.APPEND) { // load from DB first Map> tagsDB = DBHelper.loadTags(context); for (Map.Entry> entry : tagsDB.entrySet()) { Set entryIdSet = tags.get(entry.getKey()); if (entryIdSet == null) tags.put(entry.getKey(), entryIdSet = new ArraySet<>(0)); entryIdSet.addAll(entry.getValue()); } } for (Map.Entry> entry : mTags.entrySet()) { Set entryIdSet = null; if (method == Method.APPEND) { entryIdSet = tags.get(entry.getKey()); } if (entryIdSet == null) tags.put(entry.getKey(), entryIdSet = new ArraySet<>(0)); entryIdSet.addAll(entry.getValue()); } // filter out tags for apps we don't have installed TagsHandler.validateTags(context, tags); // store the tags in the DB DBHelper.setTagsMap(context, tags); // reload from DB to emulate a fresh start TBApplication.tagsHandler(context).loadFromDB(true); } private void saveMods(Context context, Method method) { if (!bModListLoaded) return; HashMap> mods = new HashMap<>(); if (method == Method.OVERWRITE || method == Method.APPEND) { List modDB = DBHelper.getMods(context); for (ModRecord rec : modDB) mods.put(rec.record, new Pair<>(rec, mIcons.get(rec))); } for (ModRecord modRecord : mMods) { if (method == Method.APPEND && mods.containsKey(modRecord.record)) continue; mods.put(modRecord.record, new Pair<>(modRecord, mIcons.get(modRecord))); } DBHelper.setMods(context, mods.values()); } private void saveApplications(Context context, Method method) { if (!bAppListLoaded) return; Map cachedApps = TBApplication.appsHandler(context).getAppRecords(context); if (method == Method.OVERWRITE || method == Method.SET) { // make sure the validate flag is off for (AppRecord rec : cachedApps.values()) rec.clearFlags(AppRecord.FLAG_VALIDATED); for (AppRecord importedRec : mApplications) { AppRecord rec = cachedApps.get(importedRec.componentName); if (rec == null) continue; // validate apps that are found in the imported list rec.setFlags(AppRecord.FLAG_VALIDATED); // overwrite if (rec.isHidden() && !importedRec.isHidden()) DBHelper.removeAppHidden(context, rec.componentName); if (rec.hasCustomName() && !importedRec.hasCustomName()) { String name = importedRec.displayName != null ? importedRec.displayName : ""; DBHelper.removeCustomAppName(context, rec.componentName, name); } if (rec.hasCustomIcon() && importedRec.hasCustomIcon()) DBHelper.removeCustomAppIcon(context, rec.componentName); } if (method == Method.SET) { // clean apps that don't appear in the import for (AppRecord rec : cachedApps.values()) { if (rec.isFlagSet(AppRecord.FLAG_VALIDATED)) continue; if (rec.isHidden()) DBHelper.removeAppHidden(context, rec.componentName); if (rec.hasCustomName()) { String name = rec.displayName != null ? rec.displayName : ""; DBHelper.removeCustomAppName(context, rec.componentName, name); } if (rec.hasCustomIcon()) DBHelper.removeCustomAppIcon(context, rec.componentName); } } } for (AppRecord importedRec : mApplications) { AppRecord rec = cachedApps.get(importedRec.componentName); // if app not found (on device) there no need to customize it if (rec == null) continue; if (importedRec.isHidden()) DBHelper.setAppHidden(context, importedRec.componentName); if (importedRec.hasCustomName()) DBHelper.setCustomAppName(context, importedRec.componentName, importedRec.displayName); if (importedRec.hasCustomIcon()) DBHelper.setCustomAppIcon(context, importedRec.componentName, mIcons.get(importedRec)); } } @SuppressLint("ApplySharedPref") private void savePreferences(Context context, Method method) { if (!bPrefListLoaded || method == Method.APPEND) return; SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context); SharedPreferences.Editor editor = preferences.edit(); if (method == Method.SET) { editor.clear().commit(); PreferenceManager.setDefaultValues(context, R.xml.preferences, true); PreferenceManager.setDefaultValues(context, R.xml.preference_features, true); editor = preferences.edit(); } for (Map.Entry entry : mPreferences.entrySet()) { Object value = entry.getValue(); if (value instanceof String) editor.putString(entry.getKey(), (String) value); else if (value instanceof Integer) editor.putInt(entry.getKey(), (Integer) value); else if (value instanceof Boolean) editor.putBoolean(entry.getKey(), (Boolean) value); else if (value instanceof ArraySet) { @SuppressWarnings("unchecked") ArraySet set = (ArraySet) value; editor.putStringSet(entry.getKey(), set); } } if (PrefCache.migratePreferences(context, mPreferences, editor)) Log.i(TAG, "Preferences migration done."); editor.commit(); for (String key : mPreferences.keySet()) SettingsActivity.onSharedPreferenceChanged(context, preferences, key); } private void restoreWidgets(Context context, Method method) { if (!bWidgetListLoaded) return; TBLauncherActivity launcherActivity = TBApplication.launcherActivity(context); if (launcherActivity == null) return; WidgetManager wm = launcherActivity.widgetManager; wm.onBeforeRestoreFromBackup(method != Method.APPEND); final boolean append = method == Method.APPEND; for (PlaceholderWidgetRecord widget : mWidgets) { wm.restoreFromBackup(append, widget); } wm.onAfterRestoreFromBackup(method == Method.SET); } private void saveHistory(Context context, Method method) { if (!bHistoryListLoaded) return; List history; if (method == Method.SET) { history = mHistory; } else { // load from DB first history = DBHelper.getHistoryRaw(context); long time = history.isEmpty() ? 0 : history.get(history.size() - 1).value; for (ValuedHistoryRecord rec : mHistory) { if (rec.value > time) history.add(rec); } } DBHelper.setHistory(context, history); } public enum Method {OVERWRITE, APPEND, SET} } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/db/FlagsRecord.java ================================================ package rocks.tbog.tblauncher.db; public abstract class FlagsRecord { protected int flags = 0; public abstract int getFlagsDB(); public void setFlags(int flags) { this.flags = flags; } public void addFlags(int flags) { this.flags |= flags; } public void clearFlags(int flags) { this.flags &= ~flags; } public boolean isFlagSet(int flag) { return (flags & flag) == flag; } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/db/ModRecord.java ================================================ package rocks.tbog.tblauncher.db; public class ModRecord extends FlagsRecord { public static final int FLAG_SHOW_IN_QUICK_LIST = 1; public static final int FLAG_CUSTOM_NAME = 1 << 1; public static final int FLAG_CUSTOM_ICON = 1 << 2; private static final int MASK_SAVE_DB_FLAGS = FLAG_SHOW_IN_QUICK_LIST | FLAG_CUSTOM_NAME | FLAG_CUSTOM_ICON; public static final int FLAG_VALIDATED = 1 << 3; public String record; public String position; public String displayName; @Override public int getFlagsDB() { return flags & MASK_SAVE_DB_FLAGS; } public boolean isInQuickList() { return (flags & FLAG_SHOW_IN_QUICK_LIST) == FLAG_SHOW_IN_QUICK_LIST; } public boolean hasCustomName() { return (flags & FLAG_CUSTOM_NAME) == FLAG_CUSTOM_NAME; } public boolean hasCustomIcon() { return (flags & FLAG_CUSTOM_ICON) == FLAG_CUSTOM_ICON; } public boolean canBeCulled() { return getFlagsDB() == 0; } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/db/PlaceholderWidgetRecord.java ================================================ package rocks.tbog.tblauncher.db; import android.content.ComponentName; import android.util.Base64; import android.util.Log; import androidx.annotation.NonNull; import org.xmlpull.v1.XmlPullParser; import org.xmlpull.v1.XmlPullParserException; import java.io.IOException; import rocks.tbog.tblauncher.utils.SimpleXmlWriter; import rocks.tbog.tblauncher.utils.Utilities; public class PlaceholderWidgetRecord extends WidgetRecord { private static final String TAG = "PWRec"; public String name; public ComponentName provider; public byte[] preview; @Override protected void copyFrom(@NonNull T o) { super.copyFrom(o); if (o instanceof PlaceholderWidgetRecord) { PlaceholderWidgetRecord p = (PlaceholderWidgetRecord) o; name = p.name; provider = p.provider; preview = p.preview; } } @Override public void parseProperties(@NonNull XmlPullParser xpp, int eventType) throws IOException, XmlPullParserException { boolean bPlaceholderFinished = false; String lastTag = null; String iconEncoding = null; while (eventType != XmlPullParser.END_DOCUMENT) { switch (eventType) { case XmlPullParser.START_TAG: int attrCount = xpp.getAttributeCount(); switch (xpp.getName()) { case "widget": super.parseProperties(xpp, eventType); break; case "preview": iconEncoding = null; for (int attrIdx = 0; attrIdx < attrCount; attrIdx += 1) { String attrName = xpp.getAttributeName(attrIdx); if ("encoding".equals(attrName)) { iconEncoding = xpp.getAttributeValue(attrIdx); } } //fall-through case "name": case "provider": lastTag = xpp.getName(); break; default: Log.d(TAG, "ignored " + xpp.getName()); } break; case XmlPullParser.END_TAG: switch (xpp.getName()) { case "name": case "provider": case "preview": lastTag = null; break; case "placeholder": // importing from DB case "properties": // importing from backup bPlaceholderFinished = true; break; } case XmlPullParser.TEXT: if (lastTag != null) switch (lastTag) { case "name": name = xpp.getText(); break; case "provider": provider = ComponentName.unflattenFromString(xpp.getText()); break; case "preview": preview = Utilities.decodeIcon(xpp.getText(), iconEncoding); break; } break; } if (bPlaceholderFinished) break; eventType = xpp.next(); } } @Override public void writeProperties(@NonNull SimpleXmlWriter simpleXmlWriter, boolean addRoot) throws IOException { if (addRoot) simpleXmlWriter.startTag("placeholder"); super.writeProperties(simpleXmlWriter, true); simpleXmlWriter .startTag("name") .content(name) .endTag("name") .startTag("provider") .content(provider != null ? provider.flattenToString() : "") .endTag("provider"); if (preview != null) { byte[] base64enc = Base64.encode(preview, Base64.NO_WRAP); simpleXmlWriter.startTag("preview") .attribute("encoding", "base64") .content(base64enc) .endTag("preview"); } if (addRoot) simpleXmlWriter.endTag("placeholder"); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/db/ShortcutRecord.java ================================================ package rocks.tbog.tblauncher.db; public class ShortcutRecord extends FlagsRecord { public static final int FLAG_HIDE_BADGE = 1; public static final int FLAG_OREO = 1 << 1; private static final int MASK_SAVE_DB_FLAGS = FLAG_HIDE_BADGE | FLAG_OREO; public static final int FLAG_VALIDATED = 1 << 3; public long dbId = -1; public String displayName; public String packageName; public String infoData; public byte[] iconPng; @Override public int getFlagsDB() { return flags & MASK_SAVE_DB_FLAGS; } public boolean isOreo() { return (flags & FLAG_OREO) == FLAG_OREO; } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/db/ValuedHistoryRecord.java ================================================ package rocks.tbog.tblauncher.db; public class ValuedHistoryRecord { /** * ID for the record */ public String record; /** * Name for the record */ public String name; /** * Context dependant value, e.g. number of access */ public long value; } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/db/WidgetRecord.java ================================================ package rocks.tbog.tblauncher.db; import android.appwidget.AppWidgetHostView; import android.util.Log; import android.util.Xml; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import org.xmlpull.v1.XmlPullParser; import org.xmlpull.v1.XmlPullParserException; import java.io.IOException; import java.io.StringReader; import java.io.StringWriter; import rocks.tbog.tblauncher.widgets.WidgetManager; import rocks.tbog.tblauncher.widgets.WidgetLayout; import rocks.tbog.tblauncher.utils.SimpleXmlWriter; public class WidgetRecord { private static final String TAG = "WRec"; public int appWidgetId; public int width; public int height; public int left; public int top; public int screen; protected String packedProperties = null; public WidgetRecord() { } public WidgetRecord(@Nullable WidgetRecord rec) { this(); if (rec != null) copyFrom(rec); } protected void copyFrom(@NonNull T o) { appWidgetId = o.appWidgetId; width = o.width; height = o.height; left = o.left; top = o.top; screen = o.screen; packedProperties = o.packedProperties; } @NonNull public static WidgetRecord loadFromDB(int widgetId, String properties) { WidgetRecord rec; if (widgetId == WidgetManager.INVALID_WIDGET_ID) rec = new PlaceholderWidgetRecord(); else rec = new WidgetRecord(); XmlPullParser xpp = Xml.newPullParser(); try { xpp.setInput(new StringReader(properties)); int eventType = xpp.getEventType(); rec.parseProperties(xpp, eventType); } catch (Exception e) { Log.e(TAG, "parse XML properties", e); } rec.appWidgetId = widgetId; return rec; } public void parseProperties(@NonNull XmlPullParser xpp, int eventType) throws IOException, XmlPullParserException { boolean bWidgetFinished = false; while (eventType != XmlPullParser.END_DOCUMENT) { switch (eventType) { case XmlPullParser.START_TAG: int attrCount = xpp.getAttributeCount(); switch (xpp.getName()) { case "size": for (int attrIdx = 0; attrIdx < attrCount; attrIdx += 1) { String attrName = xpp.getAttributeName(attrIdx); switch (attrName) { case "width": width = parseInt(xpp.getAttributeValue(attrIdx)); break; case "height": height = parseInt(xpp.getAttributeValue(attrIdx)); break; } } break; case "position": for (int attrIdx = 0; attrIdx < attrCount; attrIdx += 1) { String attrName = xpp.getAttributeName(attrIdx); switch (attrName) { case "left": left = parseInt(xpp.getAttributeValue(attrIdx)); break; case "top": top = parseInt(xpp.getAttributeValue(attrIdx)); break; case "screen": screen = parseInt(xpp.getAttributeValue(attrIdx)); break; } } break; case "widget": // reading from DB case "properties": // importing from backup break; default: Log.d(TAG, "ignored " + xpp.getName()); } break; case XmlPullParser.END_TAG: switch (xpp.getName()) { case "widget": // reading from DB case "properties": // importing from backup bWidgetFinished = true; } } if (bWidgetFinished) break; eventType = xpp.next(); } } public void writeProperties(@NonNull SimpleXmlWriter simpleXmlWriter, boolean addRoot) throws IOException { if (addRoot) simpleXmlWriter.startTag("widget"); simpleXmlWriter .startTag("size") .attribute("width", width) .attribute("height", height) .endTag("size") .startTag("position") .attribute("left", left) .attribute("top", top) .attribute("screen", screen) .endTag("position"); if (addRoot) simpleXmlWriter.endTag("widget"); } static int parseInt(String value) { try { return Integer.parseInt(value); } catch (NumberFormatException ignored) { return 0; } } public String packedProperties() { if (packedProperties == null) { //TODO: use a writer that extends BufferedWriter because that's what KXmlSerializer expects StringWriter writer = new StringWriter(); SimpleXmlWriter sx = SimpleXmlWriter.getNewInstance(); try { sx.setOutput(writer); writeProperties(sx, true); sx.endDocument(); } catch (Exception e) { Log.e(TAG, "pack properties", e); } packedProperties = writer.toString(); } return packedProperties; } public void saveProperties(AppWidgetHostView view) { packedProperties = null; final WidgetLayout.PageLayoutParams lp = (WidgetLayout.PageLayoutParams) view.getLayoutParams(); width = lp.width; height = lp.height; left = lp.leftMargin; top = lp.topMargin; screen = lp.screenPage; } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/db/XmlExport.java ================================================ package rocks.tbog.tblauncher.db; import android.appwidget.AppWidgetProviderInfo; import android.content.Context; import android.graphics.drawable.Drawable; import android.util.Base64; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.preference.Preference; import androidx.preference.PreferenceGroup; import java.io.IOException; import java.io.Writer; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Set; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.handler.TagsHandler; import rocks.tbog.tblauncher.widgets.WidgetManager; import rocks.tbog.tblauncher.shortcut.ShortcutUtil; import rocks.tbog.tblauncher.utils.SimpleXmlWriter; public class XmlExport { private static final String TAG = "XExport"; public static void tagsXml(@NonNull Context context, @NonNull Writer writer) throws IOException { SimpleXmlWriter sx = SimpleXmlWriter.getNewInstance(); sx.setOutput(writer); sx.setIndentation(true); sx.startDocument(); tagsXml(context, sx); sx.endDocument(); } public static void tagsXml(@NonNull Context context, @NonNull SimpleXmlWriter sx) throws IOException { sx.startTag(ExportedData.XTN_TAG_LIST).attribute("version", "1"); TagsHandler tagsHandler = TBApplication.tagsHandler(context); Set tags = tagsHandler.getAllTags(); for (String tagName : tags) { sx.startTag(ExportedData.XTN_TAG_LIST_ITEM).attribute("name", tagName); for (String idName : tagsHandler.getAllEntryIds(tagName)) { sx.startTag(ExportedData.XTN_TAG_LIST_ITEM_ID).content(idName).endTag(ExportedData.XTN_TAG_LIST_ITEM_ID); } sx.endTag(ExportedData.XTN_TAG_LIST_ITEM); } sx.endTag(ExportedData.XTN_TAG_LIST); } public static void modificationsXml(@NonNull Context context, @NonNull Writer writer) throws IOException { SimpleXmlWriter sx = SimpleXmlWriter.getNewInstance(); sx.setOutput(writer); sx.setIndentation(true); sx.startDocument(); modificationsXml(context, sx); sx.endDocument(); } public static void modificationsXml(@NonNull Context context, @NonNull SimpleXmlWriter sx) throws IOException { sx.startTag(ExportedData.XTN_MOD_LIST).attribute("version", "1"); List modRecords = TBApplication.dataHandler(context).getMods(); for (ModRecord fav : modRecords) { sx.startTag(ExportedData.XTN_MOD_LIST_ITEM) .startTag(ExportedData.XTN_MOD_LIST_ITEM_ID).content(fav.record).endTag(ExportedData.XTN_MOD_LIST_ITEM_ID) .startTag("flags").content(fav.getFlagsDB()).endTag("flags"); if (fav.hasCustomName() && fav.displayName != null) { sx.startTag("name") .content(fav.displayName) .endTag("name"); } if (fav.hasCustomIcon()) { byte[] favIcon = DBHelper.getCustomFavIcon(context, fav.record); if (favIcon != null) { byte[] base64enc = Base64.encode(favIcon, Base64.NO_WRAP); sx.startTag("icon") .attribute("encoding", "base64") .content(base64enc) .endTag("icon"); } } if (fav.isInQuickList()) { sx.startTag("quicklist") .content(fav.position) .endTag("quicklist"); } sx.endTag(ExportedData.XTN_MOD_LIST_ITEM); } sx.endTag(ExportedData.XTN_MOD_LIST); } public static void applicationsXml(@NonNull Context context, @NonNull Writer writer) throws IOException { SimpleXmlWriter sx = SimpleXmlWriter.getNewInstance(); sx.setOutput(writer); sx.setIndentation(true); sx.startDocument(); applicationsXml(context, sx); sx.endDocument(); } public static void applicationsXml(@NonNull Context context, @NonNull SimpleXmlWriter sx) throws IOException { sx.startTag(ExportedData.XTN_APP_LIST).attribute("version", "1"); Map cachedApps = TBApplication.appsHandler(context).getAppRecords(context); for (AppRecord app : cachedApps.values()) { // if there is no custom settings, skip this app if (app.getFlagsDB() == AppRecord.FLAG_DEFAULT_NAME) continue; sx.startTag(ExportedData.XTN_APP_LIST_ITEM) .startTag(ExportedData.XTN_APP_LIST_ITEM_ID).content(app.componentName).endTag(ExportedData.XTN_APP_LIST_ITEM_ID) .startTag("flags").content(app.getFlagsDB()).endTag("flags"); if (app.displayName != null && !app.displayName.isEmpty()) { sx.startTag("name") .content(app.displayName) .endTag("name"); } if (app.hasCustomIcon()) { byte[] appIcon = DBHelper.getCustomAppIcon(context, app.componentName); if (appIcon != null) { byte[] base64enc = Base64.encode(appIcon, Base64.NO_WRAP); sx.startTag("icon") .attribute("encoding", "base64") .content(base64enc) .endTag("icon"); } } sx.endTag(ExportedData.XTN_APP_LIST_ITEM); } sx.endTag(ExportedData.XTN_APP_LIST); } public static void interfaceXml(@NonNull PreferenceGroup rootPref, @NonNull Writer writer) throws IOException { SimpleXmlWriter sx = SimpleXmlWriter.getNewInstance(); sx.setOutput(writer); sx.setIndentation(true); sx.startDocument(); interfaceXml(rootPref, sx); sx.endDocument(); } public static void interfaceXml(@NonNull PreferenceGroup rootPref, @NonNull SimpleXmlWriter sx) throws IOException { sx.startTag(ExportedData.XTN_UI_LIST).attribute("version", "1"); // we remove the key from the map after it's exported to avoid duplicates Map prefMap = new HashMap<>(rootPref.getSharedPreferences().getAll()); Preference pref; // do not export the following prefMap.remove("pin-auto-confirm"); pref = rootPref.findPreference("ui-holder"); if ((pref instanceof PreferenceGroup)) recursiveWritePreferences(sx, (PreferenceGroup) pref, prefMap); pref = rootPref.findPreference("quick-list-section"); if ((pref instanceof PreferenceGroup)) recursiveWritePreferences(sx, (PreferenceGroup) pref, prefMap); pref = rootPref.findPreference("shortcut-section"); if ((pref instanceof PreferenceGroup)) recursiveWritePreferences(sx, (PreferenceGroup) pref, prefMap); pref = rootPref.findPreference("tags-section"); if ((pref instanceof PreferenceGroup)) recursiveWritePreferences(sx, (PreferenceGroup) pref, prefMap); sx.endTag(ExportedData.XTN_UI_LIST); } public static void preferencesXml(@NonNull PreferenceGroup rootPref, @NonNull Writer writer) throws IOException { SimpleXmlWriter sx = SimpleXmlWriter.getNewInstance(); sx.setOutput(writer); sx.setIndentation(true); sx.startDocument(); preferencesXml(rootPref, sx); sx.endDocument(); } public static void preferencesXml(@NonNull PreferenceGroup rootPref, @NonNull SimpleXmlWriter sx) throws IOException { sx.startTag(ExportedData.XTN_PREF_LIST).attribute("version", "1"); // we remove the key from the map after it's exported to avoid duplicates Map prefMap = new HashMap<>(rootPref.getSharedPreferences().getAll()); recursiveWritePreferences(sx, rootPref, prefMap); sx.endTag(ExportedData.XTN_PREF_LIST); for (Map.Entry entry : prefMap.entrySet()) { Log.w(TAG, "not saved pref `" + entry.getKey() + "` with value " + entry.getValue()); } } public static void widgetsXml(@NonNull Context context, @NonNull Writer writer) throws IOException { SimpleXmlWriter sx = SimpleXmlWriter.getNewInstance(); sx.setOutput(writer); sx.setIndentation(true); sx.startDocument(); widgetsXml(context, sx); sx.endDocument(); } public static void widgetsXml(@NonNull Context context, @NonNull SimpleXmlWriter sx) throws IOException { sx.startTag(ExportedData.XTN_WIDGET_LIST).attribute("version", "2"); //TBApplication.widgetManager(context). List widgets = DBHelper.getWidgets(context); for (WidgetRecord widget : widgets) { AppWidgetProviderInfo appWidgetProviderInfo = WidgetManager.getWidgetProviderInfo(context, widget.appWidgetId); sx.startTag(ExportedData.XTN_WIDGET_LIST_ITEM).attribute("id", widget.appWidgetId); // we use PlaceholderWidgetRecord because it has the info we need to restore PlaceholderWidgetRecord widgetRecord = new PlaceholderWidgetRecord(); widgetRecord.copyFrom(widget); if (appWidgetProviderInfo != null) { widgetRecord.name = WidgetManager.getWidgetName(context, appWidgetProviderInfo); widgetRecord.provider = appWidgetProviderInfo.provider; Drawable preview = WidgetManager.getWidgetPreview(context, appWidgetProviderInfo); widgetRecord.preview = ShortcutUtil.getIconBlob(preview); } { sx.startTag("properties"); widgetRecord.writeProperties(sx, false); sx.endTag("properties"); } sx.endTag(ExportedData.XTN_WIDGET_LIST_ITEM); } sx.endTag(ExportedData.XTN_WIDGET_LIST); } public static void historyXml(@NonNull Context context, @NonNull Writer writer) throws IOException { SimpleXmlWriter sx = SimpleXmlWriter.getNewInstance(); sx.setOutput(writer); sx.setIndentation(true); sx.startDocument(); historyXml(context, sx); sx.endDocument(); } public static void historyXml(@NonNull Context context, @NonNull SimpleXmlWriter sx) throws IOException { sx.startTag(ExportedData.XTN_HISTORY_LIST).attribute("version", "1"); List history = DBHelper.getHistoryRaw(context); for (ValuedHistoryRecord historyRecord : history) { String query = historyRecord.name; sx.startTag(ExportedData.XTN_HISTORY_LIST_ITEM).attribute("time", historyRecord.value); sx.startTag("id").content(historyRecord.record).endTag("id"); if (query != null) sx.startTag("query").content(historyRecord.name).endTag("query"); sx.endTag(ExportedData.XTN_HISTORY_LIST_ITEM); } sx.endTag(ExportedData.XTN_HISTORY_LIST); } public static void backupXml(@NonNull PreferenceGroup rootPref, @NonNull Writer writer) throws IOException { Context context = rootPref.getContext().getApplicationContext(); SimpleXmlWriter sx = SimpleXmlWriter.getNewInstance(); sx.setOutput(writer); sx.setIndentation(true); sx.startDocument(); sx.startTag("backup"); tagsXml(context, sx); modificationsXml(context, sx); applicationsXml(context, sx); preferencesXml(rootPref, sx); widgetsXml(context, sx); historyXml(context, sx); sx.endTag("backup"); sx.endDocument(); } private static void recursiveWritePreferences(@NonNull SimpleXmlWriter sx, @NonNull PreferenceGroup prefGroup, @NonNull Map prefMap) throws IOException { int prefCount = prefGroup.getPreferenceCount(); for (int prefIdx = 0; prefIdx < prefCount; prefIdx += 1) { Preference pref = prefGroup.getPreference(prefIdx); if (pref instanceof PreferenceGroup) { //Log.d(TAG, "recursiveWritePreferences " + pref.getKey()); recursiveWritePreferences(sx, (PreferenceGroup) pref, prefMap); continue; } final String key = pref.getKey(); // write preference and remove the key to prevent duplicates writePreference(sx, key, prefMap.remove(key)); } } private static void writePreference(@NonNull SimpleXmlWriter sx, @NonNull String key, @Nullable Object value) throws IOException { if (value == null) { // skip this as we don't have a value } else if (value instanceof String) sx.startTag(ExportedData.XTN_PREF_LIST_ITEM) .attribute("key", key) .attribute("value", (String) value) .endTag(ExportedData.XTN_PREF_LIST_ITEM); else if (value instanceof Integer) { sx.startTag(ExportedData.XTN_PREF_LIST_ITEM) .attribute("key", key); if (key.contains("-color")) sx.attribute("color", String.format("#%06x", ((Integer) value) & 0xffffff)); else if (key.contains("-argb")) sx.attribute("argb", String.format("#%08x", (Integer) value)); else sx.attribute("int", ((Integer) value).toString()); sx.endTag(ExportedData.XTN_PREF_LIST_ITEM); } else if (value instanceof Boolean) sx.startTag(ExportedData.XTN_PREF_LIST_ITEM) .attribute("key", key) .attribute("bool", ((Boolean) value).toString()) .endTag(ExportedData.XTN_PREF_LIST_ITEM); else if (value instanceof Set) { Set set = (Set) value; // find contained object type String type = "object"; { Iterator iterator = set.iterator(); if (iterator.hasNext()) { Object item = iterator.next(); if (item instanceof String) type = "string"; } } sx.startTag(ExportedData.XTN_PREF_LIST_ITEM) .attribute("key", key) .attribute("set", type); for (Object item : set) { sx.startTag("item") .content(item.toString()) .endTag("item"); } sx.endTag(ExportedData.XTN_PREF_LIST_ITEM); } else { Log.d(TAG, "skipped pref `" + key + "` with value " + value); } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/db/XmlImport.java ================================================ package rocks.tbog.tblauncher.db; import android.content.Context; import android.util.Log; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import org.xmlpull.v1.XmlPullParser; import org.xmlpull.v1.XmlPullParserException; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.utils.FileUtils; public class XmlImport { private static final String TAG = "XImport"; public static boolean settingsXml(@NonNull Context context, @NonNull File file, @NonNull ExportedData.Method method) { boolean ok = false; try (InputStream inputStream = new FileInputStream(file)) { ok = settingsXml(context, FileUtils.getXmlParser(context, inputStream), method); } catch (Exception e) { Log.e(TAG, "new FileInputStream " + file.toString(), e); } return ok; } public static boolean settingsXml(@NonNull Context context, @Nullable XmlPullParser xpp, @NonNull ExportedData.Method method) { if (xpp == null) return false; ExportedData settings = new ExportedData(); try { int eventType = xpp.getEventType(); while (eventType != XmlPullParser.END_DOCUMENT) { int attrCount = xpp.getAttributeCount(); if (eventType == XmlPullParser.START_TAG) { switch (xpp.getName()) { case ExportedData.XTN_TAG_LIST: settings.parseTagList(xpp, eventType); break; case ExportedData.XTN_MOD_LIST: settings.parseFavorites(xpp, eventType); break; case ExportedData.XTN_APP_LIST: settings.parseApplications(xpp, eventType); break; case ExportedData.XTN_UI_LIST: case ExportedData.XTN_PREF_LIST: settings.parsePreferences(xpp, eventType); break; case ExportedData.XTN_WIDGET_LIST: String widgetListVersion = null; for (int attrIdx = 0; attrIdx < attrCount; attrIdx += 1) { String attrName = xpp.getAttributeName(attrIdx); if ("version".equals(attrName)) { widgetListVersion = xpp.getAttributeValue(attrIdx); } } if ("1".equals(widgetListVersion)) { settings.parseWidgets_v1(xpp, eventType); } else { settings.parseWidgets_v2(xpp, eventType); } break; case ExportedData.XTN_HISTORY_LIST: settings.parseHistory(xpp, eventType); break; default: Log.d(TAG, "ignored " + xpp.getName()); } } eventType = xpp.next(); } } catch (XmlPullParserException | IOException e) { Log.e(TAG, "parsing settingsXml", e); Toast.makeText(context, R.string.error_fail_import, Toast.LENGTH_LONG).show(); return false; } settings.saveToDB(context, method); return true; } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/drawable/CodePointDrawable.java ================================================ package rocks.tbog.tblauncher.drawable; import android.graphics.drawable.Drawable; import androidx.annotation.NonNull; import androidx.annotation.Nullable; public class CodePointDrawable extends TextDrawable { private final State mState; public CodePointDrawable(int codePoint) { this(new State(codePoint)); } public CodePointDrawable(CharSequence text) { this(Character.codePointAt(text, 0)); } public CodePointDrawable(State state) { super(); mState = state; } @Nullable @Override public ConstantState getConstantState() { return mState; } @Override protected char[] getText(int line) { return Character.toChars(mState.mCodePoint); } protected static class State extends ConstantState { final int mCodePoint; protected State(int cp) { mCodePoint = cp; } @NonNull @Override public Drawable newDrawable() { return new CodePointDrawable(this); } @Override public int getChangingConfigurations() { return 0; } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/drawable/DrawableUtils.java ================================================ package rocks.tbog.tblauncher.drawable; import android.annotation.SuppressLint; import android.content.Context; import android.content.res.Resources; import android.content.res.TypedArray; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.BitmapShader; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; import android.graphics.Path; import android.graphics.PointF; import android.graphics.RectF; import android.graphics.Shader.TileMode; import android.graphics.drawable.AdaptiveIconDrawable; import android.graphics.drawable.Animatable; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; import android.os.Build; import android.util.TypedValue; import android.widget.ImageView; import androidx.annotation.ColorInt; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.StyleableRes; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.utils.UIColors; public class DrawableUtils { public static final int SHAPE_NONE = 0; public static final int SHAPE_CIRCLE = 1; public static final int SHAPE_SQUARE = 2; public static final int SHAPE_SQUIRCLE = 3; public static final int SHAPE_ROUND_RECT = 4; private static final int SHAPE_TEARDROP_BR = 5; private static final int SHAPE_TEARDROP_BL = 6; private static final int SHAPE_TEARDROP_TL = 7; private static final int SHAPE_TEARDROP_TR = 8; private static final int SHAPE_TEARDROP_RND = 9; public static final int SHAPE_HEXAGON = 10; public static final int SHAPE_OCTAGON = 11; public static final int SHAPE_ROUND_HEXAGON = 12; public static final int SHAPE_ROUND_OCTAGON = 13; public static final int[] SHAPE_LIST = { SHAPE_NONE, SHAPE_CIRCLE, SHAPE_SQUARE, SHAPE_SQUIRCLE, SHAPE_ROUND_RECT, SHAPE_TEARDROP_BR, SHAPE_TEARDROP_BL, SHAPE_TEARDROP_TL, SHAPE_TEARDROP_TR, SHAPE_HEXAGON, SHAPE_OCTAGON, SHAPE_ROUND_HEXAGON, SHAPE_ROUND_OCTAGON, }; private static final Paint PAINT = new Paint(); private static final Path SHAPE_PATH = new Path(); private static final RectF RECT_F = new RectF(); @NonNull public static String shapeName(Context context, int shape) { Resources res = context.getResources(); String[] values = res.getStringArray(R.array.adaptiveValues); String strShape = String.valueOf(shape); for (int i = 0; i < values.length; i += 1) { if (strShape.equals(values[i])) { String[] names = res.getStringArray(R.array.adaptiveEntries); return names[i]; } } return ""; } // https://stackoverflow.com/questions/3035692/how-to-convert-a-drawable-to-a-bitmap @NonNull public static Bitmap drawableToBitmap(@NonNull Drawable drawable, int width, int height) { if (drawable instanceof BitmapDrawable) { BitmapDrawable bitmapDrawable = (BitmapDrawable) drawable; if (bitmapDrawable.getBitmap() != null) { return bitmapDrawable.getBitmap(); } } Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); // Single color bitmap will be created of 1x1 pixel Canvas canvas = new Canvas(bitmap); drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); drawable.draw(canvas); return bitmap; } public static float getScaleToFit(int shape) { return 1.f / (1.f + 2.f * getMarginToFit(shape)); } public static Drawable applyIconMaskShape(Context ctx, Drawable icon, int shape) { return applyIconMaskShape(ctx, icon, shape, 1.f, Color.TRANSPARENT); } public static Drawable applyIconMaskShape(Context ctx, Drawable icon, int shape, boolean fitInside) { if (!fitInside || isAdaptiveIconDrawable(icon)) return applyIconMaskShape(ctx, icon, shape); final int color = UIColors.getIconBackground(ctx); final float scale = getScaleToFit(shape); return applyIconMaskShape(ctx, icon, shape, scale, color); } // public static Drawable applyIconMaskShape(Context ctx, Drawable icon, int shape, @ColorInt int backgroundColor) { // return applyIconMaskShape(ctx, icon, shape, 1.f, backgroundColor); // } /** * Get percent of icon to use as margin. We use this to avoid clipping the image. * * @param shape from SHAPE_* * @return margin size */ private static float getMarginToFit(int shape) { switch (shape) { case SHAPE_CIRCLE: case SHAPE_TEARDROP_BR: case SHAPE_TEARDROP_BL: case SHAPE_TEARDROP_TL: case SHAPE_TEARDROP_TR: return 0.2071f; // (sqrt(2)-1)/2 to make a square fit in a circle case SHAPE_SQUIRCLE: return 0.1f; case SHAPE_ROUND_RECT: return 0.05f; case SHAPE_ROUND_HEXAGON: case SHAPE_HEXAGON: return 0.26f; case SHAPE_ROUND_OCTAGON: case SHAPE_OCTAGON: return 0.25f; } return 0.f; } /** * Get size of bitmap used when applying a shape on an icon * * @param ctx android context to get resources * @param shape from SHAPE_* * @return icon size */ private static int getIconSize(@NonNull Context ctx, int shape) { int iconSize = ctx.getResources().getDimensionPixelSize(R.dimen.icon_size); return (shape == SHAPE_NONE || shape == SHAPE_SQUARE) ? iconSize : (2 * iconSize); } @SuppressLint("NewApi") public static Drawable applyAdaptiveIconBackgroundShape(Context ctx, Drawable icon, int shape, boolean onlyForeground) { if (!isAdaptiveIconDrawable(icon)) return null; AdaptiveIconDrawable adaptiveIcon = (AdaptiveIconDrawable) icon; int layerSize = Math.round(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 108f, ctx.getResources().getDisplayMetrics())); int iconSize = Math.round(layerSize / (1 + 2 * AdaptiveIconDrawable.getExtraInsetFraction())); int layerOffset = (layerSize - iconSize) / 2; // Create a bitmap of the icon to use it as the shader of the outputBitmap Bitmap iconBitmap = Bitmap.createBitmap(iconSize, iconSize, Bitmap.Config.ARGB_8888); Canvas iconCanvas = new Canvas(iconBitmap); if (!onlyForeground) { Drawable bgDrawable = adaptiveIcon.getBackground(); if (bgDrawable != null) { // Stretch adaptive layers because they are 108dp and the icon size is 48dp bgDrawable.setBounds(-layerOffset, -layerOffset, iconSize + layerOffset, iconSize + layerOffset); bgDrawable.draw(iconCanvas); } } Drawable fgDrawable = adaptiveIcon.getForeground(); if (fgDrawable != null) { fgDrawable.setBounds(-layerOffset, -layerOffset, iconSize + layerOffset, iconSize + layerOffset); fgDrawable.draw(iconCanvas); } Bitmap outputBitmap; Canvas outputCanvas; final Paint outputPaint = PAINT; outputPaint.reset(); outputPaint.setFlags(Paint.ANTI_ALIAS_FLAG); outputBitmap = Bitmap.createBitmap(iconSize, iconSize, Bitmap.Config.ARGB_8888); outputCanvas = new Canvas(outputBitmap); outputPaint.setShader(new BitmapShader(iconBitmap, TileMode.CLAMP, TileMode.CLAMP)); cropIconShape(outputCanvas, outputPaint, shape); outputPaint.setShader(null); return new BitmapDrawable(ctx.getResources(), outputBitmap); } /** * Handle adaptive icons for compatible devices */ @SuppressLint("NewApi") public static Drawable applyIconMaskShape(Context ctx, Drawable icon, int shape, float scale, @ColorInt int backgroundColor) { if (shape == SHAPE_NONE) return icon; if (shape == SHAPE_TEARDROP_RND) shape = SHAPE_TEARDROP_BR + (icon.hashCode() % 4); Bitmap outputBitmap; Canvas outputCanvas; final Paint outputPaint = PAINT; outputPaint.reset(); outputPaint.setFlags(Paint.ANTI_ALIAS_FLAG); if (isAdaptiveIconDrawable(icon)) { AdaptiveIconDrawable adaptiveIcon = (AdaptiveIconDrawable) icon; int layerSize = Math.round(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 108f, ctx.getResources().getDisplayMetrics())); int iconSize = Math.round(layerSize / (1 + 2 * AdaptiveIconDrawable.getExtraInsetFraction())); int layerOffset = (layerSize - iconSize) / 2; // Create a bitmap of the icon to use it as the shader of the outputBitmap Bitmap iconBitmap = Bitmap.createBitmap(iconSize, iconSize, Bitmap.Config.ARGB_8888); Canvas iconCanvas = new Canvas(iconBitmap); { Drawable bgDrawable = adaptiveIcon.getBackground(); if (bgDrawable != null) { // Stretch adaptive layers because they are 108dp and the icon size is 48dp bgDrawable.setBounds(-layerOffset, -layerOffset, iconSize + layerOffset, iconSize + layerOffset); bgDrawable.draw(iconCanvas); } Drawable fgDrawable = adaptiveIcon.getForeground(); if (fgDrawable != null) { int iconOffset = (int) (layerSize * (scale - 1.f) * .5f + .5f); layerOffset += iconOffset; fgDrawable.setBounds(-layerOffset, -layerOffset, iconSize + layerOffset, iconSize + layerOffset); fgDrawable.draw(iconCanvas); } } outputBitmap = Bitmap.createBitmap(iconSize, iconSize, Bitmap.Config.ARGB_8888); outputCanvas = new Canvas(outputBitmap); outputPaint.setShader(new BitmapShader(iconBitmap, TileMode.CLAMP, TileMode.CLAMP)); cropIconShape(outputCanvas, outputPaint, shape); outputPaint.setShader(null); } // If icon is not adaptive, put it in a white canvas to make it have a unified shape else if (icon != null) { // make icon bigger than required when we crop (cropping makes jagged edges) int iconSize = getIconSize(ctx, shape); // compute shrink factor and icon position to fit inside the shape int iconOffset = (int) (iconSize * (1.f - scale) * .5f + .5f); if (iconOffset >= iconSize / 2) iconOffset = iconSize / 2 - 1; outputBitmap = Bitmap.createBitmap(iconSize, iconSize, Bitmap.Config.ARGB_8888); outputCanvas = new Canvas(outputBitmap); outputPaint.setColor(backgroundColor); // Shrink icon so that it fits the shape int bottomRightCorner = iconSize - iconOffset; icon.setBounds(iconOffset, iconOffset, bottomRightCorner, bottomRightCorner); cropIconShape(outputCanvas, outputPaint, shape); icon.draw(outputCanvas); } else { int iconSize = getIconSize(ctx, shape); outputBitmap = Bitmap.createBitmap(iconSize, iconSize, Bitmap.Config.ARGB_8888); outputCanvas = new Canvas(outputBitmap); outputPaint.setColor(0xFF000000); cropIconShape(outputCanvas, outputPaint, shape); } return new BitmapDrawable(ctx.getResources(), outputBitmap); } /** * Set the shape of adaptive icons * * @param shape type of shape: DrawableUtils.SHAPE_* */ private static void cropIconShape(Canvas canvas, Paint paint, int shape) { final float iconSize = canvas.getHeight(); final Path path = SHAPE_PATH; path.rewind(); switch (shape) { case SHAPE_CIRCLE: { int radius = (int) iconSize / 2; canvas.drawCircle(radius, radius, radius, paint); path.addCircle(radius, radius, radius, Path.Direction.CCW); break; } case SHAPE_SQUIRCLE: { int h = (int) iconSize / 2; float c = iconSize / 2.333f; path.moveTo(h, 0f); path.cubicTo(h + c, 0, iconSize, h - c, iconSize, h); path.cubicTo(iconSize, h + c, h + c, iconSize, h, iconSize); path.cubicTo(h - c, iconSize, 0, h + c, 0, h); path.cubicTo(0, h - c, h - c, 0, h, 0); path.close(); canvas.drawPath(path, paint); break; } case SHAPE_SQUARE: canvas.drawRect(0f, 0f, iconSize, iconSize, paint); path.addRect(0f, 0f, iconSize, iconSize, Path.Direction.CCW); break; case SHAPE_ROUND_RECT: RECT_F.set(0f, 0f, iconSize, iconSize); canvas.drawRoundRect(RECT_F, iconSize / 8f, iconSize / 12f, paint); path.addRoundRect(RECT_F, iconSize / 8f, iconSize / 12f, Path.Direction.CCW); break; case SHAPE_TEARDROP_RND: // this is handled before we get here case SHAPE_TEARDROP_BR: RECT_F.set(0f, 0f, iconSize, iconSize); path.addArc(RECT_F, 90, 270); path.lineTo(iconSize, iconSize * 0.70f); RECT_F.set(iconSize * 0.70f, iconSize * 0.70f, iconSize, iconSize); path.arcTo(RECT_F, 0, 90, false); path.close(); canvas.drawPath(path, paint); break; case SHAPE_TEARDROP_BL: RECT_F.set(0f, 0f, iconSize, iconSize); path.addArc(RECT_F, 180, 270); path.lineTo(iconSize * .3f, iconSize); RECT_F.set(0f, iconSize * .7f, iconSize * .3f, iconSize); path.arcTo(RECT_F, 90, 90, false); path.close(); canvas.drawPath(path, paint); break; case SHAPE_TEARDROP_TL: RECT_F.set(0f, 0f, iconSize, iconSize); path.addArc(RECT_F, 270, 270); path.lineTo(0, iconSize * .3f); RECT_F.set(0f, 0f, iconSize * .3f, iconSize * .3f); path.arcTo(RECT_F, 180, 90, false); path.close(); canvas.drawPath(path, paint); break; case SHAPE_TEARDROP_TR: RECT_F.set(0f, 0f, iconSize, iconSize); path.addArc(RECT_F, 0, 270); path.lineTo(iconSize * .7f, 0f); RECT_F.set(iconSize * .7f, 0f, iconSize, iconSize * .3f); path.arcTo(RECT_F, 270, 90, false); path.close(); canvas.drawPath(path, paint); break; case SHAPE_HEXAGON: for (int deg = 0; deg < 360; deg += 60) { double rad = Math.toRadians(deg); float x = ((float) Math.cos(rad) * .5f + .5f) * iconSize; float y = ((float) Math.sin(rad) * .5f + .5f) * iconSize; if (deg == 0) path.moveTo(x, y); else path.lineTo(x, y); } path.close(); canvas.drawPath(path, paint); break; case SHAPE_OCTAGON: for (int deg = 22; deg < 360; deg += 45) { double rad = Math.toRadians(deg + .5); float x = ((float) Math.cos(rad) * .5f + .5f) * iconSize; float y = ((float) Math.sin(rad) * .5f + .5f) * iconSize; // scale it up to fill the rectangle x = x * 1.0824f - x * 0.0824f; y = y * 1.0824f - y * 0.0824f; if (deg == 22) path.moveTo(x, y); else path.lineTo(x, y); } path.close(); canvas.drawPath(path, paint); break; case SHAPE_ROUND_HEXAGON: { final PointProvider gen = (i, p) -> { int deg = i * 60; double rad = Math.toRadians(deg); p.x = ((float) Math.cos(rad) * .5f + .5f) * iconSize; p.y = ((float) Math.sin(rad) * .5f + .5f) * iconSize; }; roundedPolyPath(path, gen, 6, iconSize * .16f); path.close(); canvas.drawPath(path, paint); break; } case SHAPE_ROUND_OCTAGON: { final PointProvider gen = (i, p) -> { int deg = 22 + i * 45; double rad = Math.toRadians(deg + .5); p.x = ((float) Math.cos(rad) * .5f + .5f) * iconSize; p.y = ((float) Math.sin(rad) * .5f + .5f) * iconSize; }; roundedPolyPath(path, gen, 8, iconSize * .2f); path.close(); canvas.drawPath(path, paint); break; } } // make sure we don't draw outside the shape canvas.clipPath(path); } /** * polygon vertices provider */ interface PointProvider { /** * Generate vertex position for index * * @param in_pointIdx input vertex index * @param out_point output vertex position */ void get(int in_pointIdx, @NonNull PointF out_point); } /** * Helper class to store vector information */ static class Vector2D { float x, y; // position double len; // magnitude double nx, ny; // normalized double ang; // direction /** * Compute vector information from two points * * @param A vector from * @param B vector to */ void set(PointF A, PointF B) { // x,y as vec x = B.x - A.x; y = B.y - A.y; // length of vec len = Math.sqrt(x * x + y * y); // normalised nx = x / len; ny = y / len; // direction of vec ang = Math.atan2(ny, nx); } } /** * Source: https://riptutorial.com/html5-canvas/example/18766/render-a-rounded-polygon- * Adds from the `point` provider to `path` rounded corners of radius. If the corner angle is too small to fit * the radius or the distance between corners does not allow room the corners radius is reduced to a best fit. * * @param path geometric contour to add the polygon to * @param point provider of polygon vertices positions * @param pointCount vertices count * @param radius desired circle radius to use for rounding */ private static void roundedPolyPath(@NonNull Path path, PointProvider point, int pointCount, float radius) { final PointF p1 = new PointF(); final PointF p2 = new PointF(); final PointF p3 = new PointF(); final Vector2D v1 = new Vector2D(); final Vector2D v2 = new Vector2D(); point.get(pointCount - 1, p1); // start at end of path path.moveTo(p1.x, p1.y); for (int i = 0; i < pointCount; i += 1) { point.get(i, p2); // the corner point that is being rounded point.get((i + 1) % pointCount, p3); // get the corner as vectors out away from corner v1.set(p2, p1); // vec back from corner point v2.set(p2, p3); // vec forward from corner point // get corners cross product (arc sin of angle) final double sinA = v1.nx * v2.ny - v1.ny * v2.nx; // cross product // get cross product of first line and perpendicular second line final double sinA90 = v1.nx * v2.nx - v1.ny * -v2.ny; // cross product to normal of line 2 double angle = Math.asin(sinA); // get the angle int radDirection = 1; // may need to reverse the radius boolean anticlockwise = false; // find the correct quadrant for circle center if (sinA90 < 0.0) { if (angle < 0.0) { angle = Math.PI + angle; // add 180 to move us to the 3 quadrant } else { angle = Math.PI - angle; // move back into the 2nd quadrant radDirection = -1; anticlockwise = true; } } else { if (angle > 0.0) { radDirection = -1; anticlockwise = true; } } final double halfAngle = angle / 2.0; // get distance from corner to point where round corner touches line double lenOut = Math.abs(Math.cos(halfAngle) * radius / Math.sin(halfAngle)); final double cRadius; final double minHalfLineLength = Math.min(v1.len * .5, v2.len * .5); if (lenOut > minHalfLineLength) { // fix if longer than half line length lenOut = minHalfLineLength; // adjust the radius of corner rounding to fit cRadius = Math.abs(lenOut * Math.sin(halfAngle) / Math.cos(halfAngle)); } else { cRadius = radius; } // move out from corner along second line to point where rounded circle touches double x = p2.x + v2.nx * lenOut; double y = p2.y + v2.ny * lenOut; // move away from line to circle center x += -v2.ny * cRadius * radDirection; y += v2.nx * cRadius * radDirection; // x,y is the rounded corner circle center RECT_F.set((float) (x - cRadius), (float) (y - cRadius), (float) (x + cRadius), (float) (y + cRadius)); double startAngle = v1.ang + Math.PI / 2.0 * radDirection; double endAngle = v2.ang - Math.PI / 2.0 * radDirection; if (!anticlockwise && startAngle > endAngle) endAngle += Math.PI * 2.0; else if (anticlockwise && startAngle < endAngle) endAngle -= Math.PI * 2.0; final float sweepAngle = (float) Math.toDegrees(endAngle - startAngle); path.arcTo(RECT_F, (float) Math.toDegrees(startAngle), sweepAngle, false); // draw the arc clockwise p1.set(p2); } } public static boolean isAdaptiveIconDrawable(Drawable drawable) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { return drawable instanceof AdaptiveIconDrawable; } return false; } @Nullable public static Drawable getProgressBarIndeterminate(Context context) { @SuppressLint("ResourceType") @StyleableRes final int[] attrs = {android.R.attr.indeterminateDrawable}; final int attrs_indeterminateDrawable_index = 0; TypedArray a = context.obtainStyledAttributes(android.R.style.Widget_ProgressBar, attrs); try { Drawable drawable = a.getDrawable(attrs_indeterminateDrawable_index); if (drawable instanceof Animatable) ((Animatable) drawable).start(); return drawable; } catch (Exception ignored) { return null; } finally { a.recycle(); } } public static boolean setImageDrawable(@Nullable ImageView icon, @Nullable byte[] bitmap) { if (icon == null) return false; BitmapDrawable drawable = getBitmapDrawable(icon.getContext(), bitmap); icon.setImageDrawable(drawable); return drawable != null; } @Nullable public static BitmapDrawable getBitmapDrawable(@NonNull Context context, @Nullable byte[] bitmap) { if (bitmap != null) { Bitmap decodedBitmap = BitmapFactory.decodeByteArray(bitmap, 0, bitmap.length); if (decodedBitmap != null) { return new BitmapDrawable(context.getResources(), decodedBitmap); } } return null; } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/drawable/FourCodePointDrawable.java ================================================ package rocks.tbog.tblauncher.drawable; import android.graphics.drawable.Drawable; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import rocks.tbog.tblauncher.utils.Utilities; public class FourCodePointDrawable extends TextDrawable { private final State mState; public FourCodePointDrawable(int cp1, int cp2, int cp3, int cp4) { this(new State(cp1, cp2, cp3, cp4)); } public FourCodePointDrawable(State state) { super(); mState = state; } @NonNull public static FourCodePointDrawable fromText(CharSequence text, boolean soloFirstLine) { int idx = 0; int cp1 = Character.codePointAt(text, idx); idx = Utilities.getNextCodePointIndex(text, idx); int cp2 = 0; if (!soloFirstLine) { cp2 = Character.codePointAt(text, idx); idx = Utilities.getNextCodePointIndex(text, idx); } int cp3 = Character.codePointAt(text, idx); idx = Utilities.getNextCodePointIndex(text, idx); int cp4 = idx < text.length() ? Character.codePointAt(text, idx) : 0; return new FourCodePointDrawable(cp1, cp2, cp3, cp4); } @Nullable @Override public ConstantState getConstantState() { return mState; } @Override protected int getLineCount() { return 2; } @Override protected char[] getText(int line) { int i = line * 2; int cp1 = mState.mCodePoint[i]; int cp2 = mState.mCodePoint[i + 1]; if (cp2 == 0) return Character.toChars(cp1); int cc1 = Character.charCount(cp1); int cc2 = Character.charCount(cp2); char[] result = new char[cc1 + cc2]; System.arraycopy(Character.toChars(cp1), 0, result, 0, cc1); System.arraycopy(Character.toChars(cp2), 0, result, cc1, cc2); return result; } protected static class State extends ConstantState { final int[] mCodePoint = new int[4]; protected State(int cp1, int cp2, int cp3, int cp4) { mCodePoint[0] = cp1; mCodePoint[1] = cp2; mCodePoint[2] = cp3; mCodePoint[3] = cp4; } @NonNull @Override public Drawable newDrawable() { return new FourCodePointDrawable(this); } @Override public int getChangingConfigurations() { return 0; } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/drawable/LoadingDrawable.java ================================================ package rocks.tbog.tblauncher.drawable; import android.animation.ValueAnimator; import android.graphics.Canvas; import android.graphics.Matrix; import android.graphics.Path; import android.graphics.Rect; import android.graphics.drawable.Animatable; import android.view.animation.LinearInterpolator; import androidx.annotation.NonNull; import java.util.ArrayList; /** * This drawable is best used when the view is set to have * adjustViewBounds="true" * scaleType="fitCenter" * either width or height have a size or match_parent * Set Intrinsic size to 1x1 if you want to use scaleType="fitXY" in the view */ public class LoadingDrawable extends SquareDrawable implements Animatable, ValueAnimator.AnimatorUpdateListener { private final ArrayList mShapeList = new ArrayList<>(0); private final Path mShapePath; private ValueAnimator mShapeListAnimator = null; final static private float SHAPE_SIZE_PERCENT = 0.22f; final static private float CORNER_SMOOTHING_PERCENT = 0.05f; public LoadingDrawable() { super(); mShapePath = new Path(); } @Override public void draw(@NonNull Canvas canvas) { // mPaint.setColor(0x7F0000ff); // canvas.drawRect(0, 0, (float)canvas.getWidth(), (float)canvas.getHeight(), mPaint); // mPaint.setColor(0x7Fff0000); // canvas.drawRect(mRect, mPaint); // mPaint.setColor(0x7F00ff00); //mPaint.setPathEffect(new CornerPathEffect(mRect.width() * CORNER_SMOOTHING_PERCENT)); canvas.drawPath(mShapePath, mPaint); } @Override protected void onBoundsChange(Rect bounds) { super.onBoundsChange(bounds); Rect rect = getCenterRect(bounds); // generate shapes mShapeList.clear(); //if (mShapeList.isEmpty()) { int size = rect.width(); int shapeSize = (int) (size * SHAPE_SIZE_PERCENT); int padding = (size - 3 * shapeSize) / 6; mShapeList.ensureCapacity(3 * 3); int posY = rect.top + padding; for (int x = 0; x < 3; x += 1) { int posX = rect.left + padding; for (int y = 0; y < 3; y += 1) { mShapeList.add(new Shape(shapeSize, shapeSize, posX, posY)); posX += padding + padding + shapeSize; } posY += padding + padding + shapeSize; } updatePath(0f); } } @Override public void start() { if (mShapeListAnimator == null) { mShapeListAnimator = ValueAnimator.ofFloat(0, 360); mShapeListAnimator.setDuration(3000); mShapeListAnimator.addUpdateListener(this); mShapeListAnimator.setRepeatCount(ValueAnimator.INFINITE); mShapeListAnimator.setInterpolator(new LinearInterpolator()); } if (mShapeListAnimator.isRunning()) return; // if (mAnimator.isPaused()) // mAnimator.resume(); // else mShapeListAnimator.start(); mPaint.setAntiAlias(true); } @Override public void stop() { if (mShapeListAnimator != null) mShapeListAnimator.end(); mPaint.setAntiAlias(false); invalidateSelf(); } @Override public boolean isRunning() { if (mShapeListAnimator == null) return false; return mShapeListAnimator.isRunning(); } @Override public void onAnimationUpdate(ValueAnimator animation) { float value = (Float) animation.getAnimatedValue(); updatePath(value); } private void updatePath(float value) { mShapePath.reset(); for (Shape shape : mShapeList) shape.addToPath(mShapePath, value); invalidateSelf(); } private static class Shape { final Rect mRect; final Matrix mat = new Matrix(); final float[] mPoints = new float[8]; Shape(int width, int height, int posX, int posY) { mRect = new Rect(0, 0, width, height); mRect.offset(posX, posY); } void addToPath(Path path, float angle) { // top-left mPoints[0] = mRect.left; mPoints[1] = mRect.top; // top-right mPoints[2] = mRect.right; mPoints[3] = mRect.top; // bottom-right mPoints[4] = mRect.right; mPoints[5] = mRect.bottom; // bottom-left mPoints[6] = mRect.left; mPoints[7] = mRect.bottom; //mat.reset(); mat.setRotate(angle, mRect.centerX(), mRect.centerY()); mat.mapPoints(mPoints); path.moveTo(mPoints[0], mPoints[1]); path.lineTo(mPoints[2], mPoints[3]); path.lineTo(mPoints[4], mPoints[5]); path.lineTo(mPoints[6], mPoints[7]); path.close(); } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/drawable/SizeWrappedDrawable.java ================================================ package rocks.tbog.tblauncher.drawable; import android.content.res.ColorStateList; import android.graphics.BlendMode; import android.graphics.Canvas; import android.graphics.ColorFilter; import android.graphics.Outline; import android.graphics.PorterDuff; import android.graphics.Rect; import android.graphics.drawable.Drawable; import android.os.Build; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; public class SizeWrappedDrawable extends Drawable { @NonNull private final Drawable mDrawable; private final int mSize; public SizeWrappedDrawable(@NonNull Drawable drawable, int size) { mDrawable = drawable; mSize = size; } @Override public void draw(@NonNull Canvas canvas) { mDrawable.draw(canvas); } @Override public void setAlpha(int alpha) { mDrawable.setAlpha(alpha); } @Override public void setColorFilter(@Nullable ColorFilter colorFilter) { mDrawable.setColorFilter(colorFilter); } @SuppressWarnings("deprecation") @Override public int getOpacity() { return mDrawable.getOpacity(); } @Override public int getIntrinsicWidth() { return mSize; } @Override public int getIntrinsicHeight() { return mSize; } @Override protected void onBoundsChange(Rect bounds) { mDrawable.setBounds(bounds); } @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) @Override public void setTint(int tintColor) { mDrawable.setTint(tintColor); } @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) @Override public void setTintList(@Nullable ColorStateList tint) { mDrawable.setTintList(tint); } @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) @Override public void setTintMode(@Nullable PorterDuff.Mode tintMode) { mDrawable.setTintMode(tintMode); } @RequiresApi(api = Build.VERSION_CODES.Q) @Override public void setTintBlendMode(@Nullable BlendMode blendMode) { mDrawable.setTintBlendMode(blendMode); } @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) @Override public void setHotspot(float x, float y) { mDrawable.setHotspot(x, y); } @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) @Override public void setHotspotBounds(int left, int top, int right, int bottom) { mDrawable.setHotspotBounds(left, top, right, bottom); } @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) @Override public void getOutline(@NonNull Outline outline) { mDrawable.getOutline(outline); } @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) @NonNull @Override public Rect getDirtyBounds() { return mDrawable.getDirtyBounds(); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/drawable/SquareDrawable.java ================================================ package rocks.tbog.tblauncher.drawable; import android.graphics.ColorFilter; import android.graphics.Paint; import android.graphics.PixelFormat; import android.graphics.Rect; import android.graphics.drawable.Drawable; import androidx.annotation.NonNull; import androidx.annotation.Nullable; /** * This drawable is best used when the view is set to have * adjustViewBounds="true" * scaleType="fitCenter" * either width or height have a size or match_parent * Set Intrinsic size to 1x1 if you want to use scaleType="fitXY" in the view */ public abstract class SquareDrawable extends Drawable { protected final Paint mPaint; // @Override // public int getIntrinsicWidth() { // return 1; // } // // @Override // public int getIntrinsicHeight() { // return 1; // } public SquareDrawable() { super(); mPaint = new Paint(); mPaint.setColor(0xffffffff); mPaint.setStyle(Paint.Style.FILL); mPaint.setAntiAlias(false); } @Override public void setAlpha(int alpha) { mPaint.setAlpha(alpha); } @Override public void setColorFilter(@Nullable ColorFilter colorFilter) { mPaint.setColorFilter(colorFilter); } @SuppressWarnings("deprecation") @Override public int getOpacity() { return PixelFormat.TRANSLUCENT; } protected Rect getCenterRect(@NonNull Rect bounds) { Rect rect = new Rect(); rect.set(bounds); // make it a square and center the content if (rect.width() != rect.height()) { int size = Math.min(rect.width(), rect.height()); int rad = size / 2; // compute width rect.left = rect.centerX() - rad; rect.right = rect.left + size; // compute height rect.top = rect.centerY() - rad; rect.bottom = rect.top + size; } return rect; } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/drawable/TextDrawable.java ================================================ package rocks.tbog.tblauncher.drawable; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; import android.graphics.PointF; import android.graphics.Rect; import androidx.annotation.NonNull; import androidx.annotation.Nullable; public abstract class TextDrawable extends SquareDrawable { protected PointF[] cachedLinePos = null; protected float[] cachedLineSize = null; protected char[][] cachedText = null; protected int mTextColor = Color.WHITE; public TextDrawable() { mPaint.setTextAlign(Paint.Align.LEFT); mPaint.setAntiAlias(true); } @Nullable @Override public abstract ConstantState getConstantState(); public void setTextColor(int color) { mTextColor = color; } protected int getLineCount() { return 1; } protected abstract char[] getText(int line); @Override protected void onBoundsChange(Rect bounds) { super.onBoundsChange(bounds); Rect rect = getCenterRect(bounds); precacheTextPosAndSize(rect); } protected void precacheTextPosAndSize(Rect rect) { final int lineCount = getLineCount(); final float cHeight = rect.height(); final float cWidth = rect.width(); // cache text char[][] text = new char[lineCount][]; for (int line = 0; line < lineCount; line += 1) text[line] = getText(line); cachedText = text; cachedLinePos = new PointF[lineCount]; cachedLineSize = new float[lineCount]; mPaint.setTextSize(cHeight); Rect[] lineRect = new Rect[lineCount]; float heightSum = 0f; for (int line = 0; line < lineCount; line += 1) { lineRect[line] = new Rect(); mPaint.getTextBounds(text[line], 0, text[line].length, lineRect[line]); heightSum += lineRect[line].height(); } float[] expectedSize = new float[lineCount]; // find size for each line to fill the height for (int line = 0; line < lineCount; line += 1) { expectedSize[line] = lineRect[line].height() / heightSum * cHeight; // use binary search to find a text size to fit the expectedSize float minTextSize = 0.f; float maxTextSize = expectedSize[line] * 2.f; while (minTextSize < maxTextSize) { mPaint.setTextSize((minTextSize + maxTextSize) * .5f); mPaint.getTextBounds(text[line], 0, text[line].length, lineRect[line]); if (lineRect[line].height() < expectedSize[line]) minTextSize = (int) mPaint.getTextSize() + 1; else maxTextSize = (int) (mPaint.getTextSize() - .01f); } cachedLineSize[line] = maxTextSize; } // find size for each line to fill the width for (int line = 0; line < lineCount; line += 1) { // use binary search to find a text size to fit the width float minTextSize = 0.f; float maxTextSize = cachedLineSize[line]; while (minTextSize < maxTextSize) { mPaint.setTextSize((minTextSize + maxTextSize) * .5f); mPaint.getTextBounds(text[line], 0, text[line].length, lineRect[line]); if (lineRect[line].width() < cWidth) minTextSize = mPaint.getTextSize() + 1.f; else maxTextSize = mPaint.getTextSize() - 1.f; } cachedLineSize[line] = maxTextSize; } // set line position float lineOffset = 0f; for (int line = 0; line < lineCount; line += 1) { // center text inspired from https://stackoverflow.com/a/32081250 float x = cWidth * .5f - lineRect[line].width() * .5f - lineRect[line].left; float y = expectedSize[line] * .5f + lineRect[line].height() * .5f - lineRect[line].bottom; y += lineOffset; cachedLinePos[line] = new PointF(rect.left + x, rect.top + y); lineOffset += expectedSize[line]; } } @Override public void draw(@NonNull Canvas canvas) { // precacheTextPosAndSize may not be called before the first draw if (cachedText == null) return; final int lineCount = getLineCount(); for (int line = 0; line < lineCount; line += 1) { char[] text = cachedText[line]; float x = cachedLinePos[line].x; float y = cachedLinePos[line].y; mPaint.setTextSize(cachedLineSize[line]); mPaint.setStyle(Paint.Style.FILL); mPaint.setColor(mTextColor); canvas.drawText(text, 0, text.length, x, y, mPaint); mPaint.setStyle(Paint.Style.STROKE); mPaint.setStrokeWidth(0.f); // 0 = hairline, always draws a single pixel independent of the canvas's matrix mPaint.setColor(Color.BLACK); canvas.drawText(text, 0, text.length, x, y, mPaint); } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/drawable/TwoCodePointDrawable.java ================================================ package rocks.tbog.tblauncher.drawable; import android.graphics.drawable.Drawable; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import rocks.tbog.tblauncher.utils.Utilities; public class TwoCodePointDrawable extends TextDrawable { private final State mState; private boolean bVertical = false; public TwoCodePointDrawable(int cp1, int cp2) { this(new State(cp1, cp2)); } public TwoCodePointDrawable(State state) { super(); mState = state; } @NonNull public static TwoCodePointDrawable fromText(CharSequence text, boolean vertical) { int cp1 = Character.codePointAt(text, 0); int cp2 = Character.codePointAt(text, Utilities.getNextCodePointIndex(text, 0)); TwoCodePointDrawable drawable = new TwoCodePointDrawable(cp1, cp2); drawable.setVertical(vertical); return drawable; } public void setVertical(boolean vertical) { bVertical = vertical; } @Nullable @Override public ConstantState getConstantState() { return mState; } @Override protected int getLineCount() { return bVertical ? 2 : 1; } @Override protected char[] getText(int line) { final int cp1 = mState.mCodePoint1; final int cp2 = mState.mCodePoint2; if (bVertical) return line == 0 ? Character.toChars(cp1) : Character.toChars(cp2); int c1 = Character.charCount(cp1); int c2 = Character.charCount(cp2); char[] result = new char[c1 + c2]; System.arraycopy(Character.toChars(cp1), 0, result, 0, c1); System.arraycopy(Character.toChars(cp2), 0, result, c1, c2); return result; } protected static class State extends ConstantState { final int mCodePoint1; final int mCodePoint2; protected State(int cp1, int cp2) { mCodePoint1 = cp1; mCodePoint2 = cp2; } @NonNull @Override public Drawable newDrawable() { return new TwoCodePointDrawable(this); } @Override public int getChangingConfigurations() { return 0; } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/entry/ActionEntry.java ================================================ package rocks.tbog.tblauncher.entry; import android.content.Context; import android.graphics.drawable.Drawable; import android.view.View; import android.widget.Toast; import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import rocks.tbog.tblauncher.BuildConfig; import rocks.tbog.tblauncher.R; public class ActionEntry extends StaticEntry { public static final String SCHEME = "action://"; private DoAction action = null; private Drawable icon = null; public interface DoAction { void doAction(View view, int flags); } public ActionEntry(@NonNull String id, @NonNull Drawable icon) { super(id, 0); if (BuildConfig.DEBUG && !id.startsWith(SCHEME)) { throw new IllegalStateException("Invalid " + ActionEntry.class.getSimpleName() + " id `" + id + "`"); } this.icon = icon; } public ActionEntry(@NonNull String id, @DrawableRes int icon) { super(id, icon); if (BuildConfig.DEBUG && !id.startsWith(SCHEME)) { throw new IllegalStateException("Invalid " + ActionEntry.class.getSimpleName() + " id `" + id + "`"); } } @Override public void displayResult(@NonNull View view, int drawFlags) { super.displayResult(view, drawFlags); view.setTag(R.id.tag_actionId, id); } @Override public void doLaunch(@NonNull View view, int flags) { if (action == null) { Toast.makeText(view.getContext(), "`" + id + "` not implemented", Toast.LENGTH_LONG).show(); return; } action.doAction(view, flags); } public void setAction(@Nullable DoAction action) { this.action = action; } @Override public Drawable getDefaultDrawable(Context context) { if (icon != null) return icon; return super.getDefaultDrawable(context); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/entry/AppEntry.java ================================================ package rocks.tbog.tblauncher.entry; import static android.content.pm.LauncherApps.ShortcutQuery.FLAG_MATCH_DYNAMIC; import static android.content.pm.LauncherApps.ShortcutQuery.FLAG_MATCH_MANIFEST; import android.annotation.TargetApi; import android.app.Activity; import android.content.ActivityNotFoundException; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.pm.ApplicationInfo; import android.content.pm.LauncherActivityInfo; import android.content.pm.LauncherApps; import android.content.pm.PackageManager; import android.content.pm.ShortcutInfo; import android.graphics.ColorFilter; import android.graphics.Rect; import android.graphics.drawable.Drawable; import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.provider.Settings; import android.view.View; import android.view.ViewGroup; import android.widget.ImageView; import android.widget.TextView; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.RequiresApi; import androidx.annotation.StringRes; import androidx.annotation.WorkerThread; import java.util.List; import rocks.tbog.tblauncher.Behaviour; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.handler.IconsHandler; import rocks.tbog.tblauncher.preference.ContentLoadHelper; import rocks.tbog.tblauncher.result.AsyncSetEntryDrawable; import rocks.tbog.tblauncher.result.ResultViewHelper; import rocks.tbog.tblauncher.shortcut.ShortcutUtil; import rocks.tbog.tblauncher.ui.LinearAdapter; import rocks.tbog.tblauncher.ui.ListPopup; import rocks.tbog.tblauncher.utils.DebugInfo; import rocks.tbog.tblauncher.utils.DialogHelper; import rocks.tbog.tblauncher.utils.PrefCache; import rocks.tbog.tblauncher.utils.RootHandler; import rocks.tbog.tblauncher.utils.UserHandleCompat; import rocks.tbog.tblauncher.utils.Utilities; public final class AppEntry extends EntryWithTags { public static final String SCHEME = "app://"; private static final int[] RESULT_LAYOUT = {R.layout.item_app, R.layout.item_grid, R.layout.item_dock}; @NonNull public final ComponentName componentName; @NonNull private final UserHandleCompat userHandle; private final IconInfo iconInfo = new IconInfo(); private boolean hiddenByUser = false; private boolean excludedFromHistory = false; private static class IconInfo { public boolean isDynamic = false; public Boolean fitInside = null; public long customIcon = 0; public int cacheIconId = 0; public void setIconInfo(IconsHandler.IconInfo icon) { isDynamic = icon.isDynamic(); fitInside = icon.getFitInside(); } public void setCustomIcon(long dbId) { customIcon = dbId; cacheIconId += 1; isDynamic = false; fitInside = null; } public void clearCustomIcon() { customIcon = 0; cacheIconId = 0; } } public AppEntry(@NonNull ComponentName component, @NonNull UserHandleCompat user) { this(component.getPackageName(), component.getClassName(), user); } public AppEntry(@NonNull String packageName, @NonNull String activityName, @NonNull UserHandleCompat user) { super(generateAppId(packageName, activityName, user)); componentName = new ComponentName(packageName, activityName); userHandle = user; } /** * Generate a unique {@link AppEntry} id from {@link ComponentName} and {@link UserHandleCompat} * * @param component component {@link ComponentName} * @param user user handle * @return unique id with SCHEME prefix */ @NonNull public static String generateAppId(@NonNull ComponentName component, @NonNull UserHandleCompat user) { return SCHEME + user.getUserComponentName(component); } @NonNull public static String generateAppId(@NonNull String packageName, @NonNull String activityName, @NonNull UserHandleCompat user) { return SCHEME + user.getUserComponentName(packageName, activityName); } @NonNull @Override public String getIconCacheId() { return id + iconInfo.cacheIconId; } public String getUserComponentName() { return userHandle.getUserComponentName(componentName); } protected String getPackageName() { return componentName.getPackageName(); } @Override public boolean isHiddenByUser() { return hiddenByUser; } public void setHiddenByUser(boolean hiddenByUser) { this.hiddenByUser = hiddenByUser; } @Override public boolean isExcludedFromHistory() { return excludedFromHistory; } public void setExcludedFromHistory(boolean excludedFromHistory) { this.excludedFromHistory = excludedFromHistory; } public boolean canUninstall() { return userHandle.isCurrentUser(); } @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) protected List getActivityList(LauncherApps launcher) { return launcher.getActivityList(componentName.getPackageName(), userHandle.getRealHandle()); } @WorkerThread public Drawable getIconDrawable(Context context) { IconsHandler iconsHandler = TBApplication.getApplication(context).iconsHandler(); if (iconInfo.customIcon != 0) { Drawable drawable = iconsHandler.getCustomIcon(getUserComponentName()); if (drawable != null) return drawable; else iconsHandler.restoreDefaultIcon(this); } IconsHandler.IconInfo icon = iconsHandler.getIconForPackage(componentName, userHandle); iconInfo.setIconInfo(icon); return icon.getDrawable(); } @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1) public android.os.UserHandle getRealHandle() { return userHandle.getRealHandle(); } public void setCustomIcon(long dbId) { iconInfo.setCustomIcon(dbId); } public void clearCustomIcon() { iconInfo.clearCustomIcon(); } public long getCustomIcon() { return iconInfo.customIcon; } /////////////////////////////////////////////////////////////////////////////////////////////// // Result methods /////////////////////////////////////////////////////////////////////////////////////////////// public static int[] getResultLayout() { return RESULT_LAYOUT; } @Override public int getResultLayout(int drawFlags) { return Utilities.checkFlag(drawFlags, FLAG_DRAW_LIST) ? RESULT_LAYOUT[0] : (Utilities.checkFlag(drawFlags, FLAG_DRAW_GRID) ? RESULT_LAYOUT[1] : RESULT_LAYOUT[2]); } @Override public void displayResult(@NonNull View view, int drawFlags) { if (Utilities.checkFlag(drawFlags, FLAG_DRAW_LIST)) { displayListResult(view, drawFlags); ResultViewHelper.applyListRowPreferences((ViewGroup) view); } else { displayGridResult(view, drawFlags); } } private void displayGridResult(@NonNull View view, int drawFlags) { TextView nameView = view.findViewById(android.R.id.text1); if (Utilities.checkFlag(drawFlags, FLAG_DRAW_NAME)) { ResultViewHelper.displayHighlighted(relevance, normalizedName, getName(), nameView); nameView.setVisibility(View.VISIBLE); } else { nameView.setText(getName()); nameView.setVisibility(View.GONE); } ImageView appIcon = view.findViewById(android.R.id.icon); ImageView bottomRightIcon = view.findViewById(android.R.id.icon2); if (Utilities.checkFlag(drawFlags, FLAG_DRAW_ICON)) { ColorFilter colorFilter = ResultViewHelper.setIconColorFilter(appIcon, drawFlags); appIcon.setVisibility(View.VISIBLE); ResultViewHelper.setIconAsync(drawFlags, this, appIcon, AsyncSetEntryIcon.class, AppEntry.class); if (bottomRightIcon != null) { if (isHiddenByUser()) { bottomRightIcon.setVisibility(View.VISIBLE); bottomRightIcon.setImageResource(R.drawable.ic_eye_crossed); bottomRightIcon.setColorFilter(colorFilter); } else { bottomRightIcon.setVisibility(View.GONE); } } } else { appIcon.setImageDrawable(null); appIcon.setVisibility(View.GONE); if (bottomRightIcon != null) bottomRightIcon.setVisibility(View.GONE); } ResultViewHelper.applyPreferences(drawFlags, nameView, appIcon); } private void displayListResult(@NonNull View view, int drawFlags) { final Context context = view.getContext(); TextView nameView = view.findViewById(R.id.item_app_name); ResultViewHelper.displayHighlighted(relevance, normalizedName, getName(), nameView); TextView tagsView = view.findViewById(R.id.item_app_tag); // Hide tags view if tags are empty if (getTags().isEmpty()) { tagsView.setVisibility(View.GONE); } else if (ResultViewHelper.displayHighlighted(relevance, getTags(), tagsView, context) || Utilities.checkFlag(drawFlags, FLAG_DRAW_TAGS)) { tagsView.setVisibility(View.VISIBLE); ResultViewHelper.applyResultItemShadow(tagsView); } else { tagsView.setVisibility(View.GONE); } ImageView appIcon = view.findViewById(android.R.id.icon); ImageView bottomRightIcon = view.findViewById(android.R.id.icon2); if (Utilities.checkFlag(drawFlags, FLAG_DRAW_ICON)) { ColorFilter colorFilter = ResultViewHelper.setIconColorFilter(appIcon, drawFlags); appIcon.setVisibility(View.VISIBLE); ResultViewHelper.setIconAsync(drawFlags, this, appIcon, AsyncSetEntryIcon.class, AppEntry.class); if (isHiddenByUser()) { bottomRightIcon.setColorFilter(colorFilter); bottomRightIcon.setVisibility(View.VISIBLE); bottomRightIcon.setImageResource(R.drawable.ic_eye_crossed); } else { bottomRightIcon.setVisibility(View.GONE); } } else { appIcon.setImageDrawable(null); appIcon.setVisibility(View.GONE); bottomRightIcon.setVisibility(View.GONE); } //TODO: enable notification badges // if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { // SharedPreferences notificationPrefs = context.getSharedPreferences(NotificationListener.NOTIFICATION_PREFERENCES_NAME, Context.MODE_PRIVATE); // ImageView notificationView = view.findViewById(R.id.item_notification_dot); // notificationView.setVisibility(notificationPrefs.contains(getPackageName()) ? View.VISIBLE : View.GONE); // notificationView.setTag(getPackageName()); // // int primaryColor = UIColors.getPrimaryColor(context); // notificationView.setColorFilter(primaryColor); // } ResultViewHelper.applyPreferences(drawFlags, nameView, tagsView, appIcon); } static class ShortcutItem extends LinearAdapter.ItemString { @NonNull ShortcutInfo shortcutInfo; public ShortcutItem(@NonNull String string, @NonNull ShortcutInfo info) { super(string); shortcutInfo = info; } } @Override protected ListPopup buildPopupMenu(Context context, LinearAdapter adapter, View parentView, int flags) { List categoryTitle = PrefCache.getResultPopupOrder(context); for (ContentLoadHelper.CategoryItem categoryItem : categoryTitle) { int titleStringId = categoryItem.textId; if (titleStringId == R.string.popup_title_hist_fav) { adapter.add(new LinearAdapter.ItemTitle(context, R.string.popup_title_hist_fav)); //adapter.add(new LinearAdapter.Item(context, R.string.menu_exclude)); adapter.add(new LinearAdapter.Item(context, R.string.menu_remove_history)); adapter.add(new LinearAdapter.Item(context, R.string.menu_quick_list_add)); adapter.add(new LinearAdapter.Item(context, R.string.menu_quick_list_remove)); if (isHiddenByUser()) adapter.add(new LinearAdapter.Item(context, R.string.menu_show)); else adapter.add(new LinearAdapter.Item(context, R.string.menu_hide)); } else if (titleStringId == R.string.popup_title_customize) { adapter.add(new LinearAdapter.ItemTitle(context, R.string.popup_title_customize)); if (getTags().isEmpty()) adapter.add(new LinearAdapter.Item(context, R.string.menu_tags_add)); else adapter.add(new LinearAdapter.Item(context, R.string.menu_tags_edit)); adapter.add(new LinearAdapter.Item(context, R.string.menu_app_rename)); adapter.add(new LinearAdapter.Item(context, R.string.menu_custom_icon)); } else if (titleStringId == R.string.popup_title_link) { adapter.add(new LinearAdapter.ItemTitle(context, R.string.popup_title_link)); adapter.add(new LinearAdapter.Item(context, R.string.menu_app_details)); adapter.add(new LinearAdapter.Item(context, R.string.menu_app_store)); try { // app installed under /system can't be uninstalled ApplicationInfo ai; if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { LauncherApps launcher = (LauncherApps) context.getSystemService(Context.LAUNCHER_APPS_SERVICE); assert launcher != null; //LauncherActivityInfo info = launcher.getActivityList(this.appPojo().packageName, this.appPojo().userHandle.getRealHandle()).get(0); LauncherActivityInfo info = getActivityList(launcher).get(0); ai = info.getApplicationInfo(); } else { ai = context.getPackageManager().getApplicationInfo(getPackageName(), 0); } if ((ai.flags & ApplicationInfo.FLAG_SYSTEM) == 0 && canUninstall()) { adapter.add(new LinearAdapter.Item(context, R.string.menu_app_uninstall)); } } catch (PackageManager.NameNotFoundException | IndexOutOfBoundsException e) { // should not happen } // append root menu if available RootHandler rootHandler = TBApplication.rootHandler(context); if (rootHandler.isRootActivated() && rootHandler.isRootAvailable()) { adapter.add(new LinearAdapter.Item(context, R.string.menu_app_hibernate)); } } else if (titleStringId == R.string.popup_title_shortcut_dynamic) { int shortcutCount = 0; if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { List list = ShortcutUtil.getShortcut(context, getPackageName(), FLAG_MATCH_MANIFEST | FLAG_MATCH_DYNAMIC); for (ShortcutInfo info : list) { CharSequence label = info.getLongLabel(); if (label == null) label = info.getShortLabel(); if (label == null) continue; if (shortcutCount == 0) adapter.add(new LinearAdapter.ItemTitle(context, R.string.popup_title_shortcut_dynamic)); adapter.add(new ShortcutItem(label.toString(), info)); shortcutCount += 1; } } } else if (titleStringId == R.string.popup_title_debug) { if (DebugInfo.itemIconInfo(context)) { adapter.add(new LinearAdapter.ItemTitle(context, R.string.popup_title_debug)); adapter.add(new LinearAdapter.ItemString("icon custom: " + getCustomIcon())); adapter.add(new LinearAdapter.ItemString("cacheIconId: " + iconInfo.cacheIconId)); adapter.add(new LinearAdapter.ItemString("icon dynamic: " + iconInfo.isDynamic)); adapter.add(new LinearAdapter.ItemString("icon fitInside: " + iconInfo.fitInside)); } } } if (Utilities.checkFlag(flags, LAUNCHED_FROM_QUICK_LIST)) { adapter.add(new LinearAdapter.ItemTitle(context, R.string.menu_popup_title_settings)); adapter.add(new LinearAdapter.Item(context, R.string.menu_popup_quick_list_customize)); } return inflatePopupMenu(context, adapter); } @Override protected boolean popupMenuClickHandler(@NonNull final View view, @NonNull LinearAdapter.MenuItem item, int stringId, View parentView) { Context ctx = view.getContext(); if (item instanceof ShortcutItem) { TBApplication.behaviour(ctx).beforeLaunchOccurred(); final ShortcutInfo shortcutInfo = ((ShortcutItem) item).shortcutInfo; parentView.postDelayed(() -> { Activity activity = Utilities.getActivity(parentView); if (activity == null) return; ShortcutEntry.doOreoLaunch(activity, parentView, shortcutInfo); TBApplication.behaviour(activity).afterLaunchOccurred(); }, Behaviour.LAUNCH_DELAY); return true; } if (stringId == R.string.menu_app_details) { launchAppDetails(ctx, parentView); return true; } else if (stringId == R.string.menu_app_store) { launchAppStore(ctx, parentView); return true; } else if (stringId == R.string.menu_app_uninstall) { launchUninstall(ctx); return true; } else if (stringId == R.string.menu_app_hibernate) { hibernate(ctx); return true; // case R.string.menu_app_hibernate: // hibernate(context, appPojo); // return true; } else if (stringId == R.string.menu_exclude) { LinearAdapter adapter = new LinearAdapter(); ListPopup menu = ListPopup.create(ctx, adapter); adapter.add(new LinearAdapter.Item(ctx, R.string.menu_exclude_history)); adapter.add(new LinearAdapter.Item(ctx, R.string.menu_exclude_kiss)); menu.setOnItemClickListener((a, v, pos) -> { LinearAdapter.MenuItem menuItem = ((LinearAdapter) a).getItem(pos); @StringRes int id = 0; if (menuItem instanceof LinearAdapter.Item) { id = ((LinearAdapter.Item) a.getItem(pos)).stringId; } if (id == R.string.menu_exclude_history) { //excludeFromHistory(v.getContext(), appPojo()); Toast.makeText(ctx, "Not Implemented", Toast.LENGTH_LONG).show(); } else if (id == R.string.menu_exclude_kiss) { //excludeFromKiss(v.getContext(), appPojo(), parent); Toast.makeText(ctx, "Work in progress", Toast.LENGTH_LONG).show(); } }); menu.show(parentView); TBApplication.getApplication(ctx).registerPopup(menu); return true; } else if (stringId == R.string.menu_hide) { if (TBApplication.dataHandler(ctx).addToHidden(this)) { setHiddenByUser(true); TBApplication.behaviour(ctx).refreshSearchRecord(this); //Toast.makeText(ctx, "App "+getName()+" hidden from search", Toast.LENGTH_LONG).show(); } } else if (stringId == R.string.menu_show) { if (TBApplication.dataHandler(ctx).removeFromHidden(this)) { setHiddenByUser(false); TBApplication.behaviour(ctx).refreshSearchRecord(this); //Toast.makeText(ctx, "App "+getName()+" shown in searches", Toast.LENGTH_LONG).show(); } } else if (stringId == R.string.menu_tags_add || stringId == R.string.menu_tags_edit) { TBApplication.behaviour(ctx).launchEditTagsDialog(this); return true; } else if (stringId == R.string.menu_app_rename) { launchRenameDialog(ctx); return true; } else if (stringId == R.string.menu_custom_icon) { TBApplication.behaviour(ctx).launchCustomIconDialog(this); return true; } return super.popupMenuClickHandler(view, item, stringId, parentView); } @Override public void doLaunch(@NonNull View v, int flags) { Context context = v.getContext(); // If AppResult, find the icon View potentialIcon = v.findViewById(android.R.id.icon); try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { LauncherApps launcher = (LauncherApps) context.getSystemService(Context.LAUNCHER_APPS_SERVICE); assert launcher != null; // We're on a modern Android and can display activity animations Bundle startActivityOptions = Utilities.makeStartActivityOptions(potentialIcon); Rect sourceBounds = Utilities.getOnScreenRect(potentialIcon); launcher.startMainActivity(componentName, getRealHandle(), sourceBounds, startActivityOptions); } else { Intent intent = new Intent(Intent.ACTION_MAIN); intent.addCategory(Intent.CATEGORY_LAUNCHER); intent.setComponent(componentName); intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED); Utilities.setIntentSourceBounds(intent, v); Bundle startActivityOptions = Utilities.makeStartActivityOptions(potentialIcon); context.startActivity(intent, startActivityOptions); } } catch (ActivityNotFoundException | NullPointerException | SecurityException e) { // Application was just removed? // (null pointer exception can be thrown on Lollipop+ when app is missing) Toast.makeText(context, context.getString(R.string.application_not_found, componentName.flattenToShortString()), Toast.LENGTH_LONG).show(); } } private void launchRenameDialog(@NonNull Context ctx) { DialogHelper.makeRenameDialog(ctx, getName(), (dialog, name) -> { // Set new name setName(name); Context context = dialog.getContext(); TBApplication app = TBApplication.getApplication(context); app.getDataHandler().renameApp(getUserComponentName(), name); app.behaviour().refreshSearchRecord(AppEntry.this); // Show toast message String msg = context.getResources().getString(R.string.app_rename_confirmation, getName()); Toast.makeText(context, msg, Toast.LENGTH_SHORT).show(); }) .setTitle(R.string.title_app_rename) .setNeutralButton(R.string.custom_name_set_default, (dialog, which) -> { Context context = dialog.getContext(); String name = null; PackageManager pm = context.getPackageManager(); try { ApplicationInfo applicationInfo = pm.getApplicationInfo(getPackageName(), 0); name = applicationInfo.loadLabel(pm).toString(); } catch (PackageManager.NameNotFoundException ignored) { } if (name != null) { setName(name); TBApplication app = TBApplication.getApplication(context); app.getDataHandler().removeRenameApp(getUserComponentName(), name); app.behaviour().refreshSearchRecord(AppEntry.this); // Show toast message String msg = context.getString(R.string.app_rename_confirmation, getName()); Toast.makeText(context, msg, Toast.LENGTH_SHORT).show(); } dialog.dismiss(); }) .show(); } /** * Open an activity displaying details regarding the current package */ private void launchAppDetails(Context context, View view) { if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { TBApplication.behaviour(context).beforeLaunchOccurred(); view.postDelayed(() -> { Activity activity = Utilities.getActivity(view); if (activity == null) return; LauncherApps launcher = (LauncherApps) activity.getSystemService(Context.LAUNCHER_APPS_SERVICE); assert launcher != null; Rect bounds = Utilities.getOnScreenRect(view); Bundle opts = Utilities.makeStartActivityOptions(view); launcher.startAppDetailsActivity(componentName, userHandle.getRealHandle(), bounds, opts); TBApplication.behaviour(activity).afterLaunchOccurred(); }, Behaviour.LAUNCH_DELAY); } else { Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", getPackageName(), null)); TBApplication.behaviour(context).launchIntent(view, intent); } } private void launchAppStore(Context context, View view) { TBApplication.behaviour(context).beforeLaunchOccurred(); view.postDelayed(() -> { Activity activity = Utilities.getActivity(view); if (activity == null) return; Rect bound = Utilities.getOnScreenRect(view); Bundle startActivityOptions = Utilities.makeStartActivityOptions(view); Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=" + getPackageName())); try { intent.setSourceBounds(bound); activity.startActivity(intent, startActivityOptions); } catch (ActivityNotFoundException ignored) { intent = new Intent(Intent.ACTION_VIEW, Uri.parse("https://play.google.com/store/apps/details?id=" + getPackageName())); intent.setSourceBounds(bound); activity.startActivity(intent, startActivityOptions); } TBApplication.behaviour(activity).afterLaunchOccurred(); }, Behaviour.LAUNCH_DELAY); } /** * Open an activity to uninstall the app package */ private void launchUninstall(Context context) { Intent intent = new Intent(Intent.ACTION_DELETE, Uri.fromParts("package", getPackageName(), null)); context.startActivity(intent); } private void hibernate(Context context) { String msg = context.getResources().getString(R.string.toast_hibernate_completed); if (!TBApplication.rootHandler(context).hibernateApp(getPackageName())) { msg = context.getResources().getString(R.string.toast_hibernate_error); // } else { // TBApplication.dataHandler(context).getAppProvider().reload(false); } Toast.makeText(context, String.format(msg, getName()), Toast.LENGTH_SHORT).show(); } public static class AsyncSetEntryIcon extends AsyncSetEntryDrawable { public AsyncSetEntryIcon(@NonNull ImageView image, int drawFlags, @NonNull AppEntry entryItem) { super(image, drawFlags, entryItem); } @Override public Drawable getDrawable(Context context) { return entryItem.getIconDrawable(context); } @Override protected void setDrawable(ImageView image, Drawable drawable) { super.setDrawable(image, drawable); if (entryItem.iconInfo.isDynamic) TBApplication.drawableCache(image.getContext()).setCalendar(cacheId); } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/entry/CalculatorEntry.java ================================================ package rocks.tbog.tblauncher.entry; import android.content.Context; import android.text.Spannable; import android.text.SpannableString; import android.text.style.ForegroundColorSpan; import android.view.View; import android.view.ViewGroup; import android.widget.ImageView; import android.widget.TextView; import android.widget.Toast; import androidx.annotation.NonNull; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.result.ResultViewHelper; import rocks.tbog.tblauncher.utils.ClipboardUtils; import rocks.tbog.tblauncher.utils.UIColors; import rocks.tbog.tblauncher.utils.Utilities; public final class CalculatorEntry extends SearchEntry { public static final String SCHEME = "calculator://"; public CalculatorEntry(String query) { super(SCHEME + query); setName(query, false); } @Override public String getHistoryId() { // Search POJO should not appear in history return ""; } @Override public void displayResult(@NonNull View view, int drawFlags) { Context context = view.getContext(); TextView nameView = view.findViewById(android.R.id.text1); nameView.setTextColor(UIColors.getResultTextColor(view.getContext())); if (Utilities.checkFlag(drawFlags, FLAG_DRAW_NAME)) { String text = getName(); int pos = text.indexOf("="); if (pos >= 0) { int color = UIColors.getResultHighlightColor(context); SpannableString enriched = new SpannableString(text); enriched.setSpan( new ForegroundColorSpan(color), pos + 1, text.length(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE ); nameView.setText(enriched); } else { nameView.setText(text); } nameView.setVisibility(View.VISIBLE); } else { nameView.setVisibility(View.GONE); } ImageView appIcon = view.findViewById(android.R.id.icon); if (Utilities.checkFlag(drawFlags, FLAG_DRAW_ICON)) { ResultViewHelper.setIconColorFilter(appIcon, drawFlags); appIcon.setVisibility(View.VISIBLE); appIcon.setImageResource(R.drawable.ic_functions); } else { appIcon.setImageDrawable(null); appIcon.setVisibility(View.GONE); } ResultViewHelper.applyPreferences(drawFlags, nameView, appIcon); if (Utilities.checkFlag(drawFlags, FLAG_DRAW_LIST)) ResultViewHelper.applyListRowPreferences((ViewGroup) view); } @Override public void doLaunch(@NonNull View v, int flags) { String text = getName(); if (!text.isEmpty()) { String result = text.substring(text.indexOf("=") + 1).trim(); Context context = v.getContext(); ClipboardUtils.setClipboard(context, result); Toast.makeText(context, context.getString(R.string.copy_confirmation, result), Toast.LENGTH_SHORT).show(); } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/entry/ContactEntry.java ================================================ package rocks.tbog.tblauncher.entry; import static rocks.tbog.tblauncher.customicon.ButtonHelper.BTN_ID_MESSAGE; import static rocks.tbog.tblauncher.customicon.ButtonHelper.BTN_ID_OPEN; import static rocks.tbog.tblauncher.customicon.ButtonHelper.BTN_ID_PHONE; import android.content.ComponentName; import android.content.Context; import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; import android.net.Uri; import android.text.TextUtils; import android.view.View; import android.view.ViewGroup; import android.widget.ImageButton; import android.widget.ImageView; import android.widget.TextView; import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.annotation.StringRes; import androidx.annotation.WorkerThread; import androidx.appcompat.content.res.AppCompatResources; import androidx.core.content.res.ResourcesCompat; import androidx.preference.PreferenceManager; import java.io.IOException; import java.io.InputStream; import rocks.tbog.tblauncher.BuildConfig; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.customicon.ButtonHelper; import rocks.tbog.tblauncher.handler.IconsHandler; import rocks.tbog.tblauncher.normalizer.PhoneNormalizer; import rocks.tbog.tblauncher.normalizer.StringNormalizer; import rocks.tbog.tblauncher.result.AsyncSetEntryDrawable; import rocks.tbog.tblauncher.result.ResultHelper; import rocks.tbog.tblauncher.result.ResultViewHelper; import rocks.tbog.tblauncher.ui.LinearAdapter; import rocks.tbog.tblauncher.ui.ListPopup; import rocks.tbog.tblauncher.utils.PackageManagerUtils; import rocks.tbog.tblauncher.utils.PrefCache; import rocks.tbog.tblauncher.utils.UIColors; import rocks.tbog.tblauncher.utils.UserHandleCompat; import rocks.tbog.tblauncher.utils.Utilities; public class ContactEntry extends EntryItem { public static final String SCHEME = "contact://"; private static final int[] RESULT_LAYOUT = {R.layout.item_contact, R.layout.item_grid, R.layout.item_dock}; private static final String BTN_ACTION_PHONE = "phone"; private static final String BTN_ACTION_MESSAGE = "message"; private static final String BTN_ACTION_OPEN = "open"; public String lookupKey; protected String phone; //phone without special characters public StringNormalizer.Result normalizedPhone; protected Uri iconUri = null; // Is this a primary phone? protected boolean primary = false; // How many times did we phone this contact? protected int timesContacted = 0; // Is this contact starred ? protected boolean starred = false; // Is this number a home (local / landline) number? We can't send messages to this. protected boolean homeNumber = false; public StringNormalizer.Result normalizedNickname = null; protected String nickname = ""; protected ImData imData; public ContactEntry(String id) { super(id); if (BuildConfig.DEBUG && !id.startsWith(SCHEME)) { throw new IllegalStateException("Invalid " + ContactEntry.class.getSimpleName() + " id `" + id + "`"); } } protected void setNickname(String nickname) { if (nickname != null) { // Set the actual user-friendly name this.nickname = nickname; this.normalizedNickname = StringNormalizer.normalizeWithResult(this.nickname, false); } else { this.nickname = null; this.normalizedNickname = null; } } public boolean isPrimary() { return primary; } public boolean isStarred() { return starred; } public ImData getImData() { return imData; } public boolean isHomeNumber() { return homeNumber; } public int getTimesContacted() { return timesContacted; } public String getPhone() { return phone != null ? phone : ""; } /////////////////////////////////////////////////////////////////////////////////////////////// // Result methods /////////////////////////////////////////////////////////////////////////////////////////////// public static int[] getResultLayout() { return RESULT_LAYOUT; } @Override public int getResultLayout(int drawFlags) { return Utilities.checkFlag(drawFlags, FLAG_DRAW_LIST) ? RESULT_LAYOUT[0] : (Utilities.checkFlag(drawFlags, FLAG_DRAW_GRID) ? RESULT_LAYOUT[1] : RESULT_LAYOUT[2]); } @WorkerThread protected Drawable getIconDrawable(Context ctx) { Drawable drawable = null; if (iconUri != null) try (InputStream inputStream = ctx.getContentResolver().openInputStream(iconUri)) { drawable = Drawable.createFromStream(inputStream, iconUri.toString()); } catch (IOException ignored) { } if (drawable == null) { drawable = AppCompatResources.getDrawable(ctx, R.drawable.ic_contact_placeholder); if (drawable == null) drawable = new ColorDrawable(UIColors.getDefaultColor(ctx)); } return TBApplication.iconsHandler(ctx).applyContactMask(ctx, drawable); } @Override public void displayResult(@NonNull View view, int drawFlags) { if (Utilities.checkFlag(drawFlags, FLAG_DRAW_LIST)) { displayListResult(view, drawFlags); ResultViewHelper.applyListRowPreferences((ViewGroup) view); } else { displayGridResult(view, drawFlags); } } private void displayGridResult(@NonNull View view, int drawFlags) { final Context context = view.getContext(); // Contact name TextView nameView = view.findViewById(android.R.id.text1); nameView.setTextColor(UIColors.getResultTextColor(context)); if (Utilities.checkFlag(drawFlags, FLAG_DRAW_NAME)) { ResultViewHelper.displayHighlighted(relevance, normalizedName, getName(), nameView); nameView.setVisibility(View.VISIBLE); } else { nameView.setText(getName()); nameView.setVisibility(View.GONE); } // Contact photo ImageView contactIcon = view.findViewById(android.R.id.icon); if (Utilities.checkFlag(drawFlags, FLAG_DRAW_ICON)) { if (PrefCache.modulateContactIcons(context)) ResultViewHelper.setIconColorFilter(contactIcon, drawFlags); else ResultViewHelper.removeIconColorFilter(contactIcon); contactIcon.setVisibility(View.VISIBLE); ResultViewHelper.setIconAsync(drawFlags, this, contactIcon, SetContactIconAsync.class, ContactEntry.class); } else { contactIcon.setImageDrawable(null); contactIcon.setVisibility(View.GONE); } ResultViewHelper.applyPreferences(drawFlags, nameView, contactIcon); } private void displayListResult(@NonNull View view, int drawFlags) { final Context context = view.getContext(); // Contact name TextView contactName = view.findViewById(R.id.item_contact_name); contactName.setTextColor(UIColors.getResultTextColor(context)); ResultViewHelper.displayHighlighted(relevance, normalizedName, getName(), contactName); // Contact phone TextView contactPhone = view.findViewById(R.id.item_contact_phone); if (phone != null) { contactPhone.setVisibility(View.VISIBLE); contactPhone.setTextColor(UIColors.getResultText2Color(context)); ResultViewHelper.displayHighlighted(relevance, normalizedPhone, phone, contactPhone); ResultViewHelper.applyResultItemShadow(contactPhone); } else if (getImData() != null && getImData().label != null) { contactPhone.setVisibility(View.VISIBLE); contactPhone.setTextColor(UIColors.getResultText2Color(context)); contactPhone.setText(getImData().label); ResultViewHelper.applyResultItemShadow(contactPhone); } else { contactPhone.setVisibility(View.GONE); } displayNickname(view); // Contact photo ImageView contactIcon = view.findViewById(android.R.id.icon); if (Utilities.checkFlag(drawFlags, FLAG_DRAW_ICON)) { if (PrefCache.modulateContactIcons(context)) ResultViewHelper.setIconColorFilter(contactIcon, drawFlags); else ResultViewHelper.removeIconColorFilter(contactIcon); contactIcon.setVisibility(View.VISIBLE); ResultViewHelper.setIconAsync(drawFlags, this, contactIcon, SetContactIconAsync.class, ContactEntry.class); } else { contactIcon.setImageDrawable(null); contactIcon.setVisibility(View.GONE); } final PackageManager pm = context.getPackageManager(); boolean hasPhone = phone != null && pm.hasSystemFeature(PackageManager.FEATURE_TELEPHONY); displayActions(view, hasPhone); // App icon { final ImageView appIcon = view.findViewById(android.R.id.icon2); if (getImData() != null) { appIcon.setVisibility(View.VISIBLE); // bypass cache or else the app icon is cached as the contact icon ResultViewHelper.setIconAsync(drawFlags | FLAG_RELOAD | FLAG_DRAW_NO_CACHE, this, appIcon, SetAppIconAsync.class, ContactEntry.class); } else { appIcon.setVisibility(View.GONE); } } ResultViewHelper.applyPreferences(drawFlags, contactName, contactPhone, contactIcon); } private void displayNickname(View root) { Context context = root.getContext(); // Contact nickname TextView contactNickname = root.findViewById(R.id.item_contact_nickname); contactNickname.setTextColor(UIColors.getResultTextColor(context)); if (TextUtils.isEmpty(nickname)) { contactNickname.setVisibility(View.GONE); } else { contactNickname.setVisibility(View.VISIBLE); ResultViewHelper.displayHighlighted(relevance, normalizedNickname, nickname, contactNickname); ResultViewHelper.applyResultItemShadow(contactNickname); } } private void displayActions(View root, boolean hasPhone) { Context context = root.getContext(); final int contactActionColor = UIColors.getContactActionColor(context); // Phone action { ImageButton phoneButton = root.findViewById(R.id.item_contact_action_phone); if (hasPhone) { phoneButton.setVisibility(View.VISIBLE); phoneButton.clearColorFilter(); ResultViewHelper.setButtonIconAsync(phoneButton, BTN_ID_PHONE, ctx -> { Drawable drawable = ResourcesCompat.getDrawable(ctx.getResources(), R.drawable.ic_phone, null); Utilities.setColorFilterMultiply(drawable, contactActionColor); return drawable; }); phoneButton.setOnClickListener(v -> { ResultHelper.recordLaunch(this, context); ResultHelper.launchCall(v.getContext(), v, phone); }); phoneButton.setOnLongClickListener(v -> showButtonPopup(v, BTN_ID_PHONE, R.drawable.ic_phone)); } else { phoneButton.setVisibility(View.GONE); } } // Message action { ImageButton messageButton = root.findViewById(R.id.item_contact_action_message); if (hasPhone && !isHomeNumber()) { messageButton.setVisibility(View.VISIBLE); messageButton.clearColorFilter(); ResultViewHelper.setButtonIconAsync(messageButton, BTN_ID_MESSAGE, ctx -> { Drawable drawable = ResourcesCompat.getDrawable(ctx.getResources(), R.drawable.ic_message, null); Utilities.setColorFilterMultiply(drawable, contactActionColor); return drawable; }); messageButton.setOnClickListener(v -> { ResultHelper.recordLaunch(this, context); ResultHelper.launchMessaging(this, v); }); messageButton.setOnLongClickListener(v -> showButtonPopup(v, BTN_ID_MESSAGE, R.drawable.ic_message)); } else { messageButton.setVisibility(View.GONE); } } // Open action { ImageButton openButton = root.findViewById(R.id.item_contact_action_open); if (getImData() != null) { openButton.setVisibility(View.VISIBLE); openButton.clearColorFilter(); ResultViewHelper.setButtonIconAsync(openButton, BTN_ID_OPEN, ctx -> { Drawable drawable = ResourcesCompat.getDrawable(ctx.getResources(), R.drawable.ic_send, null); Utilities.setColorFilterMultiply(drawable, contactActionColor); return drawable; }); openButton.setOnClickListener(v -> { ResultHelper.recordLaunch(this, context); ResultHelper.launchIm(getImData(), v); }); openButton.setOnLongClickListener(v -> showButtonPopup(v, BTN_ID_OPEN, R.drawable.ic_send)); } else { openButton.setVisibility(View.GONE); } } } @Override public void doLaunch(@NonNull View v, int flags) { Context context = v.getContext(); ResultHelper.recordLaunch(this, context); SharedPreferences settingPrefs = PreferenceManager.getDefaultSharedPreferences(v.getContext()); String btnAction = settingPrefs.getString("default-contact-action", ""); switch (btnAction) { case BTN_ACTION_PHONE: if (phone != null) ResultHelper.launchCall(context, v, phone); else if (getImData() != null) ResultHelper.launchIm(getImData(), v); break; case BTN_ACTION_MESSAGE: ResultHelper.launchMessaging(this, v); break; case BTN_ACTION_OPEN: if (getImData() != null) ResultHelper.launchIm(getImData(), v); else if (phone != null) ResultHelper.launchCall(context, v, phone); break; default: ResultHelper.launchContactView(this, context, v); break; } } @NonNull private static String getButtonIdFromAction(@NonNull String btnPref) { switch (btnPref) { case BTN_ACTION_PHONE: return BTN_ID_PHONE; case BTN_ACTION_MESSAGE: return BTN_ID_MESSAGE; case BTN_ACTION_OPEN: return BTN_ACTION_OPEN; default: return ""; } } private static boolean showButtonPopup(@NonNull View view, @NonNull String buttonId, @DrawableRes int defaultButtonIcon) { final Context context = view.getContext(); ListPopup buttonMenu = getButtonPopup(context, buttonId, defaultButtonIcon); return ButtonHelper.showButtonPopup(view, buttonMenu); } @NonNull public static ListPopup getButtonPopup(Context ctx, @NonNull String buttonId, @DrawableRes int defaultButtonIcon) { String btnAction = PreferenceManager.getDefaultSharedPreferences(ctx).getString("default-contact-action", ""); String defaultBtnId = getButtonIdFromAction(btnAction); LinearAdapter adapter = new LinearAdapter(); adapter.add(new LinearAdapter.Item(ctx, R.string.menu_custom_icon)); if (!defaultBtnId.equals(buttonId)) adapter.add(new LinearAdapter.Item(ctx, R.string.contact_button_set_default)); else adapter.add(new LinearAdapter.Item(ctx, R.string.contact_button_reset_default)); return ListPopup.create(ctx, adapter).setOnItemClickListener((a, view, pos) -> { LinearAdapter.MenuItem menuItem = ((LinearAdapter) a).getItem(pos); @StringRes int id = 0; if (menuItem instanceof LinearAdapter.Item) { id = ((LinearAdapter.Item) a.getItem(pos)).stringId; } if (id == R.string.menu_custom_icon) { TBApplication.behaviour(ctx).launchCustomIconDialog(buttonId, defaultButtonIcon, () -> { // force a result refresh to update the icons from all contact buttons var activity = TBApplication.launcherActivity(ctx); if (activity != null) activity.refreshSearchRecords(); }); } else if (id == R.string.contact_button_set_default) { SharedPreferences settingPrefs = PreferenceManager.getDefaultSharedPreferences(ctx); var editor = settingPrefs.edit(); switch (buttonId) { case BTN_ID_PHONE: editor.putString("default-contact-action", BTN_ACTION_PHONE).apply(); //editor.putBoolean("call-contact-on-click", true).apply(); break; case BTN_ID_MESSAGE: editor.putString("default-contact-action", BTN_ACTION_MESSAGE).apply(); break; case BTN_ID_OPEN: editor.putString("default-contact-action", BTN_ACTION_OPEN).apply(); break; default: editor.putString("default-contact-action", "").apply(); break; } } else if (id == R.string.contact_button_reset_default) { PreferenceManager.getDefaultSharedPreferences(ctx) .edit() .putString("default-contact-action", "") .apply(); } }); } public static class SetContactIconAsync extends AsyncSetEntryDrawable { public SetContactIconAsync(@NonNull ImageView image, int drawFlags, @NonNull ContactEntry contactEntry) { super(image, drawFlags, contactEntry); } @Override protected Drawable getDrawable(Context ctx) { return entryItem.getIconDrawable(ctx); } } public static class SetAppIconAsync extends AsyncSetEntryDrawable { public SetAppIconAsync(@NonNull ImageView image, int drawFlags, @NonNull ContactEntry contactEntry) { super(image, drawFlags, contactEntry); } @Override protected Drawable getDrawable(Context context) { IconsHandler iconsHandler = TBApplication.iconsHandler(context); ImData imData = entryItem.getImData(); Drawable appDrawable; ComponentName componentName = TBApplication.mimeTypeCache(context).getComponentName(context, imData.getMimeType()); if (componentName != null) { appDrawable = iconsHandler.getDrawableIconForPackage(PackageManagerUtils.getLaunchingComponent(context, componentName), UserHandleCompat.CURRENT_USER); } else { // This should never happen, let's just return the generic activity icon appDrawable = context.getPackageManager().getDefaultActivityIcon(); } return appDrawable; } } // TODO: move to separate class, which package? public static class ImData { private final long id; private final String mimeType; private final String label; private String identifier; public ImData(String mimeType, long id, String label) { this.mimeType = mimeType; this.id = id; this.label = label; } public String getIdentifier() { return identifier; } public void setIdentifier(String identifier) { this.identifier = identifier; } public String getMimeType() { return mimeType; } public long getId() { return id; } } public static class Builder { private String name = null; private String phone = null; private String nickname = null; private Uri iconUri = null; private ImData imData = null; private String shortMimeType = null; private String lookupKey = null; private boolean primary = false; private boolean starred = false; private long contactId = 0; private long contentId = 0; public Builder setContactId(long contactId) { this.contactId = contactId; return this; } public Builder setPhone(String phone) { this.phone = phone; return this; } public Builder setMimeInfo(long contentId, @NonNull String shortMimeType) { this.contentId = contentId; this.shortMimeType = shortMimeType; return this; } public Builder setIconUri(Uri iconUri) { this.iconUri = iconUri; return this; } public Builder setPrimary(boolean primary) { this.primary = primary; return this; } public Builder setStarred(boolean starred) { this.starred = starred; return this; } public Builder setLookupKey(String lookupKey) { this.lookupKey = lookupKey; return this; } public Builder setName(@NonNull String name) { this.name = name; return this; } public Builder setNickname(@NonNull String nickname) { this.nickname = nickname; return this; } public Builder setImData(@NonNull ImData imData) { this.imData = imData; return this; } public ContactEntry getContact() { final String entryId; if (shortMimeType != null) { // this is a general contact. No phone number. entryId = SCHEME + contactId + '/' + shortMimeType + '/' + contentId; //entry = new ContactEntry(entryId, lookupKey, icon, primary, 0, starred, false); } else { // phone contact entryId = SCHEME + contactId + '/' + phone; //entry = new ContactEntry(entryId, lookupKey, phone, normalizedPhone, icon, primary, 0, starred, false); } ContactEntry entry = new ContactEntry(entryId); entry.lookupKey = lookupKey; if (phone != null) { entry.phone = phone; entry.normalizedPhone = PhoneNormalizer.simplifyPhoneNumber(phone); } if (iconUri != null) entry.iconUri = iconUri; entry.primary = primary; entry.starred = starred; entry.setName(name); entry.setNickname(nickname); if (imData != null) entry.imData = imData; return entry; } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/entry/DialContactEntry.java ================================================ package rocks.tbog.tblauncher.entry; import android.content.Context; import android.graphics.drawable.Drawable; import android.view.View; import androidx.annotation.NonNull; import java.util.List; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.handler.IconsHandler; import rocks.tbog.tblauncher.normalizer.PhoneNormalizer; import rocks.tbog.tblauncher.preference.ContentLoadHelper; import rocks.tbog.tblauncher.ui.LinearAdapter; import rocks.tbog.tblauncher.ui.ListPopup; import rocks.tbog.tblauncher.utils.PrefCache; import rocks.tbog.tblauncher.utils.Utilities; public class DialContactEntry extends ContactEntry implements ICustomIconEntry{ public static final String SCHEME = ContactEntry.SCHEME + "dial/"; private int customIcon = 0; public DialContactEntry() { super(SCHEME); } @Override public String getHistoryId() { // Dial number should not appear in history return ""; } @NonNull @Override public String getIconCacheId() { // use same id for any dialed phone return id + "/ic" + customIcon; } @Override public void setCustomIcon() { customIcon += 1; } @Override public void clearCustomIcon() { customIcon = 0; } @Override public boolean hasCustomIcon() { return customIcon > 0; } @Override protected Drawable getIconDrawable(Context ctx) { if (hasCustomIcon()) { IconsHandler iconsHandler = TBApplication.getApplication(ctx).iconsHandler(); Drawable drawable = iconsHandler.getCustomIcon(this); if (drawable != null) return drawable; else iconsHandler.restoreDefaultIcon(this); } return super.getIconDrawable(ctx); } @Override protected ListPopup buildPopupMenu(Context context, LinearAdapter adapter, View parentView, int flags) { List categoryTitle = PrefCache.getResultPopupOrder(context); for (ContentLoadHelper.CategoryItem categoryItem : categoryTitle) { final int titleStringId = categoryItem.textId; if (titleStringId == R.string.popup_title_customize) { adapter.add(new LinearAdapter.Item(context, R.string.menu_custom_icon)); } } if (Utilities.checkFlag(flags, LAUNCHED_FROM_QUICK_LIST)) { adapter.add(new LinearAdapter.ItemTitle(context, R.string.menu_popup_title_settings)); adapter.add(new LinearAdapter.Item(context, R.string.menu_popup_quick_list_customize)); } return inflatePopupMenu(context, adapter); } @Override protected boolean popupMenuClickHandler(@NonNull final View view, @NonNull LinearAdapter.MenuItem item, int stringId, View parentView) { if (stringId == R.string.menu_custom_icon) { Context ctx = view.getContext(); TBApplication.behaviour(ctx).launchCustomIconDialog(this); return true; } return super.popupMenuClickHandler(view, item, stringId, parentView); } public void setPhone(String phone) { if (phone != null) { this.phone = phone; this.normalizedPhone = PhoneNormalizer.simplifyPhoneNumber(phone); } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/entry/EntryItem.java ================================================ package rocks.tbog.tblauncher.entry; import android.content.Context; import android.view.View; import androidx.annotation.CallSuper; import androidx.annotation.LayoutRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.StringRes; import java.util.Collections; import java.util.List; import java.util.Objects; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.dataprovider.QuickListProvider; import rocks.tbog.tblauncher.normalizer.StringNormalizer; import rocks.tbog.tblauncher.result.ResultHelper; import rocks.tbog.tblauncher.ui.LinearAdapter; import rocks.tbog.tblauncher.ui.ListPopup; import rocks.tbog.tblauncher.utils.DebugInfo; import rocks.tbog.tblauncher.utils.FuzzyScore; import rocks.tbog.tblauncher.utils.Utilities; public abstract class EntryItem { public static final RelevanceComparator RELEVANCE_COMPARATOR = new RelevanceComparator(); public static final NameComparator NAME_COMPARATOR = new NameComparator(); /** * the layout will be used in a ListView */ public static final int FLAG_DRAW_LIST = 0x0001; // 1 << 0 /** * the layout will be used in a GridView */ public static final int FLAG_DRAW_GRID = 0x0002; // 1 << 1 /** * the layout will be used in a horizontal LinearLayout */ public static final int FLAG_DRAW_QUICK_LIST = 0x0004; // 1 << 2 /** * layout should display an icon */ public static final int FLAG_DRAW_ICON = 0x0008; // 1 << 3 /** * layout may display a badge (shortcut sub-icon) if appropriate */ public static final int FLAG_DRAW_ICON_BADGE = 0x0010; // 1 << 4 /** * layout should display a text/name */ public static final int FLAG_DRAW_NAME = 0x0020; // 1 << 5 /** * layout should display tags */ public static final int FLAG_DRAW_TAGS = 0x0040; // 1 << 6 /** * do not use cache, generate new drawable */ public static final int FLAG_DRAW_NO_CACHE = 0x0080; // 1 << 7 /** * the item will be drawn on a while background */ public static final int FLAG_DRAW_WHITE_BG = 0x0100; // 1 << 8 /** * use cache but also run the load task * Note: used for shortcuts as we don't have a way to cache multiple icons for the same entry id */ public static final int FLAG_RELOAD = 0x0200; // 1 << 9 // Used when generating Popup menu and calling doLaunch public static final int LAUNCHED_FROM_RESULT_LIST = 0x01; public static final int LAUNCHED_FROM_QUICK_LIST = 0x02; public static final int LAUNCHED_FROM_GESTURE = 0x04; // Globally unique ID. // Usually starts with provider scheme, e.g. "app://" or "contact://" to // ensure unique constraint @NonNull public final String id; // normalized name, for faster search public StringNormalizer.Result normalizedName = null; // Name for this Entry, e.g. app name @NonNull private String name = ""; protected final ResultRelevance relevance = new ResultRelevance(); public EntryItem(@NonNull String id) { this.id = id; } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof EntryItem)) return false; EntryItem entryItem = (EntryItem) o; return id.equals(entryItem.id); } @Override public int hashCode() { return Objects.hash(id); } @NonNull public String getName() { return name; } /** * Set the user-displayable name of this container *

* When this method a searchable version of the name will be generated for the name and stored * as `nameNormalized`. Additionally a mapping from the positions in the searchable name * to the positions in the displayable name will be stored (as `namePositionMap`). * * @param name User-friendly name of this container */ public void setName(String name) { if (name != null) { // Set the actual user-friendly name this.name = name; this.normalizedName = StringNormalizer.normalizeWithResult(this.name, false); } else { this.name = "null"; this.normalizedName = null; } } public void setName(String name, boolean generateNormalization) { if (generateNormalization) { setName(name); } else { this.name = name; this.normalizedName = null; } } public int getRelevance() { return relevance.getRelevance(); } public void addResultMatch(@NonNull StringNormalizer.Result normalizedName, @Nullable FuzzyScore.MatchInfo matchInfo) { relevance.addMatchInfo(normalizedName, matchInfo); } public void setRelevance(@NonNull StringNormalizer.Result normalizedName, @Nullable FuzzyScore.MatchInfo matchInfo) { relevance.setMatchInfo(normalizedName, matchInfo); } public void boostRelevance(int boost) { relevance.boostRelevance(boost); } public void resetResultInfo() { relevance.resetRelevance(); } /** * ID to use in the history * (may be different from the one used in the adapter for display) */ public String getHistoryId() { return this.id; } public boolean isExcludedFromHistory() { return false; } /////////////////////////////////////////////////////////////////////////////////////////////// // Result methods /////////////////////////////////////////////////////////////////////////////////////////////// @LayoutRes public abstract int getResultLayout(int drawFlags); public abstract void displayResult(@NonNull View view, int drawFlags); @NonNull public String getIconCacheId() { return id; } public static class RelevanceComparator implements java.util.Comparator { @Override public int compare(EntryItem lhs, EntryItem rhs) { int difference = rhs.relevance.compareTo(lhs.relevance); if (difference != 0) return difference; return rhs.name.compareTo(lhs.name); } } public static class NameComparator implements java.util.Comparator { @Override public int compare(EntryItem lhs, EntryItem rhs) { if (lhs.normalizedName != null && rhs.normalizedName != null) return rhs.normalizedName.compareTo(lhs.normalizedName); return rhs.name.compareTo(lhs.name); } } /** * Default popup menu implementation, can be overridden by children class to display a more specific menu * * @return an inflated, listener-free PopupMenu */ protected ListPopup buildPopupMenu(Context context, LinearAdapter adapter, View parentView, int flags) { adapter.add(new LinearAdapter.ItemTitle(context, R.string.popup_title_hist_fav)); adapter.add(new LinearAdapter.Item(context, R.string.menu_remove_history)); adapter.add(new LinearAdapter.Item(context, R.string.menu_quick_list_add)); if (Utilities.checkFlag(flags, LAUNCHED_FROM_QUICK_LIST)) { adapter.add(new LinearAdapter.ItemTitle(context, R.string.menu_popup_title_settings)); adapter.add(new LinearAdapter.Item(context, R.string.menu_popup_quick_list_customize)); } return inflatePopupMenu(context, adapter); } ListPopup inflatePopupMenu(@NonNull Context context, @NonNull LinearAdapter adapter) { ListPopup menu = ListPopup.create(context, adapter); // boolean foundInQuickList = false; // ArrayList favRecords = TBApplication.dataHandler(context).getFavorites(); // for (ModRecord fav : favRecords) { // if (id.equals(fav.record) && fav.isInQuickList()) { // foundInQuickList = true; // break; // } // } QuickListProvider provider = TBApplication.dataHandler(context).getQuickListProvider(); // get current Quick List content List list = provider != null ? provider.getPojos() : Collections.emptyList(); boolean foundInQuickList = list.contains(this); if (foundInQuickList) { // if already in quick list, remove the "Add to QuickList" option for (int i = 0; i < adapter.getCount(); i += 1) { LinearAdapter.MenuItem item = adapter.getItem(i); if (item instanceof LinearAdapter.Item) { if (((LinearAdapter.Item) item).stringId == R.string.menu_quick_list_add) adapter.remove(item); } } } else { // if not in quick list, remove the "Remove from QuickList" option for (int i = 0; i < adapter.getCount(); i += 1) { LinearAdapter.MenuItem item = adapter.getItem(i); if (item instanceof LinearAdapter.Item) { if (((LinearAdapter.Item) item).stringId == R.string.menu_quick_list_remove) adapter.remove(item); } } } if (DebugInfo.itemRelevance(context)) { String debugTitle = context.getString(R.string.popup_title_debug); int pos = -1; // find title for (int i = 0; i < adapter.getCount(); i += 1) { if (debugTitle.equals(adapter.getItem(i).toString())) { pos = i + 1; break; } } // if title not found, add title if (pos == -1) { adapter.add(new LinearAdapter.ItemTitle(debugTitle)); pos = adapter.getCount(); } // add debug data after title adapter.add(pos, new LinearAdapter.ItemString("Relevance: " + getRelevance())); } return menu; } /** * How to display the popup menu * * @return a PopupMenu object */ @NonNull public ListPopup getPopupMenu(final View parentView, int flags) { final Context context = parentView.getContext(); LinearAdapter menuAdapter = new LinearAdapter(); ListPopup menu = buildPopupMenu(context, menuAdapter, parentView, flags); menu.setOnItemClickListener((adapter, view, position) -> { LinearAdapter.MenuItem item = ((LinearAdapter) adapter).getItem(position); @StringRes int stringId = 0; if (item instanceof LinearAdapter.Item) { stringId = ((LinearAdapter.Item) adapter.getItem(position)).stringId; } popupMenuClickHandler(view, item, stringId, parentView); }); return menu; } @NonNull public ListPopup getPopupMenu(final View parentView) { return getPopupMenu(parentView, LAUNCHED_FROM_RESULT_LIST); } /** * Handler for popup menu action. * Default implementation only handle remove from history action. * * @return Works in the same way as onOptionsItemSelected, return true if the action has been handled, false otherwise */ @CallSuper boolean popupMenuClickHandler(@NonNull View view, @NonNull LinearAdapter.MenuItem item, @StringRes int stringId, View parentView) { Context context = parentView.getContext(); if (R.string.menu_remove_history == stringId) { ResultHelper.removeFromResultsAndHistory(this, context); return true; } else if (R.string.menu_quick_list_add == stringId) { ResultHelper.launchAddToQuickList(context, this); return true; } else if (R.string.menu_quick_list_remove == stringId) { ResultHelper.launchRemoveFromQuickList(context, this); return true; } else if (R.string.menu_popup_quick_list_customize == stringId) { TBApplication.behaviour(context).launchEditQuickListDialog(context); return true; } // FullscreenActivity mainActivity = (FullscreenActivity) context; // // Update favorite bar // mainActivity.onFavoriteChange(); // mainActivity.launchOccurred(); // // Update Search to reflect favorite add, if the "exclude favorites" option is active // if (mainActivity.prefs.getBoolean("exclude-favorites", false) && mainActivity.isViewingSearchResults()) { // mainActivity.updateSearchRecords(true); // } return false; } public void doLaunch(@NonNull View view, int flags) { throw new IllegalStateException("No launch action defined for " + getClass().getSimpleName()); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/entry/EntryWithTags.java ================================================ package rocks.tbog.tblauncher.entry; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.collection.ArraySet; import java.util.List; import java.util.Objects; import rocks.tbog.tblauncher.normalizer.StringNormalizer; public abstract class EntryWithTags extends EntryItem { // Tags assigned to this pojo private final ArraySet tags = new ArraySet<>(0); public boolean isHiddenByUser() { return false; } public static class TagDetails { @NonNull public final String name; public final StringNormalizer.Result normalized; @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; TagDetails that = (TagDetails) o; return name.equals(that.name); } @Override public int hashCode() { return Objects.hash(name); } public TagDetails(@NonNull String name) { this(name, StringNormalizer.normalizeWithResult(name, true)); } public TagDetails(@NonNull String name, StringNormalizer.Result normalized) { this.name = name; this.normalized = normalized; } } EntryWithTags(@NonNull String id) { super(id); } @NonNull public ArraySet getTags() { return tags; } public void setTags(@Nullable List tags) { this.tags.clear(); if (tags != null) { for (String tag : tags) this.tags.add(new TagDetails(tag)); } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/entry/FilterEntry.java ================================================ package rocks.tbog.tblauncher.entry; import android.view.View; import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import rocks.tbog.tblauncher.BuildConfig; import rocks.tbog.tblauncher.R; public class FilterEntry extends StaticEntry { public static final String SCHEME = "filter://"; private View.OnClickListener listener = null; private final String filterScheme; public FilterEntry(@NonNull String id, @DrawableRes int icon, String filterScheme) { super(id, icon); if (BuildConfig.DEBUG && !id.startsWith(SCHEME)) { throw new IllegalStateException("Invalid " + FilterEntry.class.getSimpleName() + " id `" + id + "`"); } this.filterScheme = filterScheme; } @Override public void displayResult(@NonNull View view, int drawFlags) { super.displayResult(view, drawFlags); // this is used for the toggle animation view.setTag(R.id.tag_actionId, id); view.setTag(R.id.tag_filterText, filterScheme); } @Override public void doLaunch(@NonNull View view, int flags) { listener.onClick(view); } public void setOnClickListener(@Nullable View.OnClickListener listener) { this.listener = listener; } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/entry/ICustomIconEntry.java ================================================ package rocks.tbog.tblauncher.entry; public interface ICustomIconEntry { void setCustomIcon(); void clearCustomIcon(); boolean hasCustomIcon(); } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/entry/OpenUrlEntry.java ================================================ package rocks.tbog.tblauncher.entry; import android.content.ActivityNotFoundException; import android.content.Context; import android.content.Intent; import android.net.Uri; import android.util.Log; import android.view.View; import androidx.annotation.NonNull; import rocks.tbog.tblauncher.R; public final class OpenUrlEntry extends UrlEntry { public static final String SCHEME = "url://"; public OpenUrlEntry(String query, String url) { super(SCHEME + url, url); this.query = query; } /////////////////////////////////////////////////////////////////////////////////////////////// // Result methods /////////////////////////////////////////////////////////////////////////////////////////////// @Override protected String getResultText(Context context) { return String.format(context.getString(R.string.ui_item_visit), getName()); } @Override public void doLaunch(@NonNull View v, int flags) { Context context = v.getContext(); Uri uri = Uri.parse(url); Intent search = new Intent(Intent.ACTION_VIEW, uri); search.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); try { context.startActivity(search); } catch (ActivityNotFoundException e) { Log.w("SearchResult", "Unable to run search for url: " + url); } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/entry/PlaceholderEntry.java ================================================ package rocks.tbog.tblauncher.entry; import android.content.Context; import android.graphics.drawable.Drawable; import android.view.View; import androidx.annotation.NonNull; import androidx.appcompat.content.res.AppCompatResources; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.ui.LinearAdapter; import rocks.tbog.tblauncher.ui.ListPopup; import rocks.tbog.tblauncher.utils.PrefCache; import rocks.tbog.tblauncher.utils.Utilities; public class PlaceholderEntry extends StaticEntry { public final String position; public PlaceholderEntry(@NonNull String id, String position) { super(id, R.drawable.ic_loading_arrows); this.position = position; } @Override protected ListPopup buildPopupMenu(Context context, LinearAdapter adapter, View parentView, int flags) { if (Utilities.checkFlag(flags, LAUNCHED_FROM_QUICK_LIST)) { adapter.add(new LinearAdapter.ItemTitle(context, R.string.menu_popup_title_settings)); adapter.add(new LinearAdapter.Item(context, R.string.menu_popup_quick_list_customize)); } return inflatePopupMenu(context, adapter); } @Override public Drawable getDefaultDrawable(Context context) { int loadingIconRes = PrefCache.getLoadingIconRes(context); return AppCompatResources.getDrawable(context, loadingIconRes); } @Override public void doLaunch(@NonNull View view, int flags) { // do nothing } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/entry/ResultRelevance.java ================================================ package rocks.tbog.tblauncher.entry; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.function.Consumer; import rocks.tbog.tblauncher.normalizer.StringNormalizer; import rocks.tbog.tblauncher.utils.FuzzyScore; public class ResultRelevance implements Comparable { private final List infoList = Collections.synchronizedList(new ArrayList<>(2)); private int scoreBoost = 0; public int getRelevance() { synchronized (infoList) { int score = scoreBoost; for (ResultInfo info : infoList) score += info.relevance.score; return score; } } public void addMatchInfo(@NonNull StringNormalizer.Result matchedText, @Nullable FuzzyScore.MatchInfo matchInfo) { final ResultInfo resultInfo; if (matchInfo == null) resultInfo = new ResultInfo(matchedText, new FuzzyScore.MatchInfo()); else resultInfo = new ResultInfo(matchedText, new FuzzyScore.MatchInfo(matchInfo)); infoList.add(resultInfo); } public void setMatchInfo(@NonNull StringNormalizer.Result normalizedName, @Nullable FuzzyScore.MatchInfo matchInfo) { resetRelevance(); addMatchInfo(normalizedName, matchInfo); } public void boostRelevance(int boost) { scoreBoost += boost; } public void resetRelevance() { infoList.clear(); scoreBoost = 0; } public void forEach(Consumer action) { synchronized (infoList) { infoList.forEach(action); } } @Override public int compareTo(ResultRelevance o) { synchronized (infoList) { int difference = getRelevance() - o.getRelevance(); if (difference == 0) { difference = scoreBoost - o.scoreBoost; if (difference == 0) { StringNormalizer.Result rSource = infoList.size() > 0 ? infoList.get(0).relevanceSource : null; StringNormalizer.Result o_rSource = o.infoList.size() > 0 ? o.infoList.get(0).relevanceSource : null; if (rSource != null && o_rSource != null) return rSource.compareTo(o_rSource); } } return difference; } } @Override public boolean equals(Object o) { if (!(o instanceof ResultRelevance)) return false; return compareTo((ResultRelevance) o) == 0; } public static class ResultInfo { // How relevant is this record? The higher, the most probable it will be displayed @NonNull public final FuzzyScore.MatchInfo relevance; // Pointer to the normalizedName that the above relevance was calculated, used for highlighting @NonNull public final StringNormalizer.Result relevanceSource; private ResultInfo(@NonNull StringNormalizer.Result relevanceSource, @NonNull FuzzyScore.MatchInfo relevance) { this.relevance = relevance; this.relevanceSource = relevanceSource; } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/entry/SearchEngineEntry.java ================================================ package rocks.tbog.tblauncher.entry; import android.app.SearchManager; import android.content.ActivityNotFoundException; import android.content.Context; import android.content.Intent; import android.net.Uri; import android.util.Log; import android.view.View; import androidx.annotation.NonNull; import androidx.preference.PreferenceManager; import java.io.UnsupportedEncodingException; import java.net.URLEncoder; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.ui.LinearAdapter; public final class SearchEngineEntry extends UrlEntry { public static final String SCHEME = "search-engine://"; public SearchEngineEntry(String engineName, String engineUrl) { super(SCHEME + engineName, engineUrl); setName(engineName, false); } /////////////////////////////////////////////////////////////////////////////////////////////// // Result methods /////////////////////////////////////////////////////////////////////////////////////////////// @Override protected String getResultText(Context context) { return String.format(context.getString(R.string.ui_item_search), getName(), query); } @Override protected void buildPopupMenuCategory(Context context, @NonNull LinearAdapter adapter, int titleStringId) { if (titleStringId == R.string.popup_title_hist_fav) { String defaultSearchProvider = PreferenceManager .getDefaultSharedPreferences(context) .getString("default-search-provider", "Google"); if (!defaultSearchProvider.equals(getName())) adapter.add(new LinearAdapter.Item(context, R.string.search_engine_set_default)); } super.buildPopupMenuCategory(context, adapter, titleStringId); } @Override protected boolean popupMenuClickHandler(@NonNull View view, @NonNull LinearAdapter.MenuItem item, int stringId, View parentView) { if (stringId == R.string.search_engine_set_default) { PreferenceManager .getDefaultSharedPreferences(view.getContext()) .edit() .putString("default-search-provider", getName()) .apply(); return true; } return super.popupMenuClickHandler(view, item, stringId, parentView); } @Override public void doLaunch(@NonNull View v, int flags) { Context context = v.getContext(); if (isGoogleSearch(url)) { try { Intent intent = new Intent(Intent.ACTION_WEB_SEARCH); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); intent.putExtra(SearchManager.QUERY, query); // query contains search string context.startActivity(intent); return; } catch (ActivityNotFoundException e) { // Google app not found, fall back to default method } } String encodedQuery; try { encodedQuery = URLEncoder.encode(query, "UTF-8"); } catch (UnsupportedEncodingException e) { encodedQuery = URLEncoder.encode(query); } String urlWithQuery = url.replaceAll("%s|\\{q\\}", encodedQuery); Uri uri = Uri.parse(urlWithQuery); Intent search = new Intent(Intent.ACTION_VIEW, uri); search.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); try { context.startActivity(search); } catch (ActivityNotFoundException e) { Log.w("SearchResult", "Unable to run search for url: " + url); } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/entry/SearchEntry.java ================================================ package rocks.tbog.tblauncher.entry; import android.content.Context; import android.graphics.drawable.Drawable; import android.view.View; import androidx.annotation.NonNull; import androidx.annotation.WorkerThread; import androidx.appcompat.content.res.AppCompatResources; import java.util.List; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.handler.IconsHandler; import rocks.tbog.tblauncher.preference.ContentLoadHelper; import rocks.tbog.tblauncher.ui.LinearAdapter; import rocks.tbog.tblauncher.ui.ListPopup; import rocks.tbog.tblauncher.utils.PrefCache; import rocks.tbog.tblauncher.utils.Utilities; public abstract class SearchEntry extends EntryItem implements ICustomIconEntry { private static final int[] RESULT_LAYOUT = {R.layout.item_builtin, R.layout.item_grid, R.layout.item_dock}; protected String query; private int customIcon; public SearchEntry(String id) { super(id); } public void setQuery(@NonNull String query) { this.query = query; } @Override public String getHistoryId() { // Search POJO should not appear in history return ""; } @NonNull @Override public String getIconCacheId() { return id + "/ic" + customIcon; } @Override public void setCustomIcon() { customIcon += 1; } @Override public void clearCustomIcon() { customIcon = 0; } @Override public boolean hasCustomIcon() { return customIcon > 0; } /////////////////////////////////////////////////////////////////////////////////////////////// // Result methods /////////////////////////////////////////////////////////////////////////////////////////////// @WorkerThread public Drawable getIconDrawable(Context context) { if (hasCustomIcon()) { IconsHandler iconsHandler = TBApplication.getApplication(context).iconsHandler(); Drawable drawable = iconsHandler.getCustomIcon(this); if (drawable != null) return drawable; else iconsHandler.restoreDefaultIcon(this); } return getDefaultDrawable(context); } public Drawable getDefaultDrawable(Context context) { return AppCompatResources.getDrawable(context, R.drawable.ic_search); } public static int[] getResultLayout() { return RESULT_LAYOUT; } @Override public int getResultLayout(int drawFlags) { return Utilities.checkFlag(drawFlags, FLAG_DRAW_LIST) ? RESULT_LAYOUT[0] : (Utilities.checkFlag(drawFlags, FLAG_DRAW_GRID) ? RESULT_LAYOUT[1] : RESULT_LAYOUT[2]); } @Override protected ListPopup buildPopupMenu(Context context, LinearAdapter adapter, View parentView, int flags) { List categoryTitle = PrefCache.getResultPopupOrder(context); for (ContentLoadHelper.CategoryItem categoryItem : categoryTitle) { int pos = adapter.getCount(); buildPopupMenuCategory(context, adapter, categoryItem.textId); if (pos != adapter.getCount()) adapter.add(pos, new LinearAdapter.ItemTitle(context, categoryItem.textId)); } if (Utilities.checkFlag(flags, LAUNCHED_FROM_QUICK_LIST)) { adapter.add(new LinearAdapter.ItemTitle(context, R.string.menu_popup_title_settings)); buildPopupMenuCategory(context, adapter, R.string.menu_popup_title_settings); } return inflatePopupMenu(context, adapter); } protected void buildPopupMenuCategory(Context context, @NonNull LinearAdapter adapter, int titleStringId) { if (titleStringId == R.string.popup_title_customize) { adapter.add(new LinearAdapter.Item(context, R.string.menu_custom_icon)); } else if (titleStringId == R.string.menu_popup_title_settings) { adapter.add(new LinearAdapter.Item(context, R.string.menu_popup_quick_list_customize)); } } @Override protected boolean popupMenuClickHandler(@NonNull final View view, @NonNull LinearAdapter.MenuItem item, int stringId, View parentView) { if (stringId == R.string.menu_custom_icon) { Context ctx = view.getContext(); TBApplication.behaviour(ctx).launchCustomIconDialog(this, null); return true; } return super.popupMenuClickHandler(view, item, stringId, parentView); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/entry/ShortcutEntry.java ================================================ package rocks.tbog.tblauncher.entry; import android.annotation.TargetApi; import android.content.ActivityNotFoundException; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.pm.LauncherApps; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.content.pm.ShortcutInfo; import android.graphics.Bitmap; import android.graphics.ColorFilter; import android.graphics.Rect; import android.graphics.drawable.Drawable; import android.os.Build; import android.os.Bundle; import android.util.Log; import android.view.View; import android.view.ViewGroup; import android.widget.ImageView; import android.widget.TextView; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import androidx.annotation.WorkerThread; import androidx.appcompat.content.res.AppCompatResources; import java.net.URISyntaxException; import java.util.List; import java.util.Locale; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.db.ShortcutRecord; import rocks.tbog.tblauncher.handler.IconsHandler; import rocks.tbog.tblauncher.preference.ContentLoadHelper; import rocks.tbog.tblauncher.result.AsyncSetEntryDrawable; import rocks.tbog.tblauncher.result.ResultViewHelper; import rocks.tbog.tblauncher.shortcut.ShortcutUtil; import rocks.tbog.tblauncher.ui.LinearAdapter; import rocks.tbog.tblauncher.ui.ListPopup; import rocks.tbog.tblauncher.utils.DialogHelper; import rocks.tbog.tblauncher.utils.PrefCache; import rocks.tbog.tblauncher.utils.UIColors; import rocks.tbog.tblauncher.utils.UserHandleCompat; import rocks.tbog.tblauncher.utils.Utilities; public final class ShortcutEntry extends EntryWithTags { public static final String SCHEME = "shortcut://"; private static final String TAG = "shortcut"; private static final int[] RESULT_LAYOUT = {R.layout.item_shortcut, R.layout.item_grid_shortcut, R.layout.item_dock_shortcut}; @NonNull public final String packageName; @NonNull public final String shortcutData; @Nullable public final ShortcutInfo mShortcutInfo; private final long dbId; protected int customIcon = 0; public ShortcutEntry(@NonNull String id, long dbId, @NonNull String packageName, @NonNull String shortcutData) { super(id); this.dbId = dbId; this.packageName = packageName; this.shortcutData = shortcutData; mShortcutInfo = null; } @RequiresApi(api = Build.VERSION_CODES.N_MR1) public ShortcutEntry(long dbId, @NonNull ShortcutInfo shortcutInfo) { super(ShortcutEntry.SCHEME + shortcutInfo.getId()); this.dbId = dbId; packageName = shortcutInfo.getPackage(); shortcutData = shortcutInfo.getId(); mShortcutInfo = shortcutInfo; } /** * @return shortcut id generated from ShortcutRecord */ public static String generateShortcutId(@NonNull ShortcutRecord rec) { return SCHEME + rec.dbId + "/" + rec.packageName.toLowerCase(Locale.ROOT); } public static int[] getResultLayout() { return RESULT_LAYOUT; } public static void doShortcutLaunch(@NonNull Context context, @NonNull View view, @NonNull String shortcutData) { View potentialIcon = view.findViewById(android.R.id.icon1); Bundle startActivityOptions = Utilities.makeStartActivityOptions(potentialIcon); // Non-oreo shortcuts try { Intent intent = Intent.parseUri(shortcutData, Intent.URI_INTENT_SCHEME); Utilities.setIntentSourceBounds(intent, potentialIcon); context.startActivity(intent, startActivityOptions); } catch (Exception e) { // Application was just removed? Toast.makeText(context, context.getString(R.string.entry_not_found, shortcutData), Toast.LENGTH_LONG).show(); } } @TargetApi(Build.VERSION_CODES.O) public static void doOreoLaunch(@NonNull Context context, @NonNull View v, @Nullable ShortcutInfo shortcutInfo) { final LauncherApps launcherApps = (LauncherApps) context.getSystemService(Context.LAUNCHER_APPS_SERVICE); assert launcherApps != null; // Only the default launcher is allowed to start shortcuts if (!launcherApps.hasShortcutHostPermission()) { Toast.makeText(context, context.getString(R.string.shortcuts_no_host_permission), Toast.LENGTH_LONG).show(); return; } View potentialIcon = v.findViewById(android.R.id.icon); Bundle startActivityOptions = Utilities.makeStartActivityOptions(potentialIcon); Rect sourceBounds = Utilities.getOnScreenRect(potentialIcon); if (shortcutInfo != null) { try { launcherApps.startShortcut(shortcutInfo, sourceBounds, startActivityOptions); return; } catch (ActivityNotFoundException e) { Log.e(TAG, "startShortcut", e); } } // Application removed? Invalid shortcut? Shortcut to an app on an unmounted SD card? Toast.makeText(context, context.getString(R.string.application_not_found, shortcutInfo), Toast.LENGTH_LONG).show(); } @WorkerThread public static Drawable getAppDrawable(@NonNull Context context, @NonNull String shortcutData, @NonNull String packageName, @Nullable ShortcutInfo shortcutInfo, boolean isBadge) { Drawable appDrawable = null; final PackageManager packageManager = context.getPackageManager(); List activities = null; if (shortcutInfo == null) { try { Intent intent = Intent.parseUri(shortcutData, 0); activities = packageManager.queryIntentActivities(intent, 0); } catch (URISyntaxException e) { Log.e("Shortcut", "parse `" + shortcutData + "`", e); } } final IconsHandler iconsHandler = TBApplication.iconsHandler(context); if (activities != null && !activities.isEmpty()) { ResolveInfo mainPackage = activities.get(0); String packName = mainPackage.activityInfo.applicationInfo.packageName; String actName = mainPackage.activityInfo.name; ComponentName className = new ComponentName(packName, actName); appDrawable = isBadge ? iconsHandler.getDrawableBadgeForPackage(className, UserHandleCompat.CURRENT_USER) : iconsHandler.getDrawableIconForPackage(className, UserHandleCompat.CURRENT_USER); } if (appDrawable == null && shortcutInfo != null) { // Can't make sense of the intent URI (Oreo shortcut, or a shortcut from an activity that was removed from an installed app) // Retrieve app icon if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { UserHandleCompat user = new UserHandleCompat(context, shortcutInfo.getUserHandle()); ComponentName componentName = shortcutInfo.getActivity(); appDrawable = isBadge ? iconsHandler.getDrawableBadgeForPackage(componentName, user) : iconsHandler.getDrawableIconForPackage(componentName, user); if (appDrawable == null) try { appDrawable = packageManager.getActivityIcon(componentName); } catch (PackageManager.NameNotFoundException e) { Log.e(TAG, "Unable to find activity icon " + componentName.toString(), e); } } } if (appDrawable == null) { try { appDrawable = packageManager.getApplicationIcon(packageName); } catch (PackageManager.NameNotFoundException e) { Log.e(TAG, "get app shortcut icon", e); return null; } appDrawable = iconsHandler.getIconPack().applyBackgroundAndMask(context, appDrawable, true); } return appDrawable; } /////////////////////////////////////////////////////////////////////////////////////////////// // Result methods /////////////////////////////////////////////////////////////////////////////////////////////// public static void setIcons(int drawFlags, @NonNull ImageView icon1, Drawable shortcutDrawable, Drawable appDrawable) { if (Utilities.checkFlag(drawFlags, FLAG_DRAW_ICON_BADGE)) { if (icon1.getParent() instanceof View) { ImageView icon2 = ((View) icon1.getParent()).findViewById(android.R.id.icon2); if (shortcutDrawable != null) { icon2.setImageDrawable(appDrawable); } else { // If no icon found for this shortcut, use app icon icon1.setImageDrawable(appDrawable); icon2.setImageResource(R.drawable.ic_send); } } } else { if (shortcutDrawable == null) { // If no icon found for this shortcut, use app icon icon1.setImageDrawable(appDrawable); } } } @NonNull @Override public String getIconCacheId() { return id + customIcon; } /** * Oreo shortcuts do not have a real intentUri, instead they have a shortcut id * and the Android system is responsible for safekeeping the Intent */ public boolean isOreoShortcut() { return mShortcutInfo != null; } public String getOreoId() { // Oreo shortcuts encode their id in the unused intentUri field return shortcutData; } public Drawable getIcon(@NonNull Context context) { if (customIcon > 0) { IconsHandler iconsHandler = TBApplication.getApplication(context).iconsHandler(); Drawable drawable = iconsHandler.getCustomIcon(this); if (drawable != null) return drawable; else iconsHandler.restoreDefaultIcon(this); } Bitmap bitmap = ShortcutUtil.getInitialIcon(context, dbId); if (bitmap == null) return null; return TBApplication.iconsHandler(context).applyShortcutMask(context, bitmap); } @Override public int getResultLayout(int drawFlags) { return Utilities.checkFlag(drawFlags, FLAG_DRAW_LIST) ? RESULT_LAYOUT[0] : (Utilities.checkFlag(drawFlags, FLAG_DRAW_GRID) ? RESULT_LAYOUT[1] : RESULT_LAYOUT[2]); } @Override public void displayResult(@NonNull View view, int drawFlags) { if (Utilities.checkFlag(drawFlags, FLAG_DRAW_LIST)) { displayListResult(view, drawFlags); ResultViewHelper.applyListRowPreferences((ViewGroup) view); } else { displayGridResult(view, drawFlags); } } private void displayGridResult(@NonNull View view, int drawFlags) { final Context context = view.getContext(); drawFlags |= FLAG_RELOAD; TextView nameView = view.findViewById(android.R.id.text1); nameView.setTextColor(UIColors.getResultTextColor(context)); if (Utilities.checkFlag(drawFlags, FLAG_DRAW_NAME)) { ResultViewHelper.displayHighlighted(relevance, normalizedName, getName(), nameView); nameView.setVisibility(View.VISIBLE); } else { nameView.setText(getName()); nameView.setVisibility(View.GONE); } ImageView icon1 = view.findViewById(android.R.id.icon1); ImageView icon2 = view.findViewById(android.R.id.icon2); if (Utilities.checkFlag(drawFlags, FLAG_DRAW_ICON)) { icon1.setVisibility(View.VISIBLE); icon2.setVisibility(View.VISIBLE); ColorFilter colorFilter = ResultViewHelper.setIconColorFilter(icon1, drawFlags); icon2.setColorFilter(colorFilter); ResultViewHelper.setIconAsync(drawFlags, this, icon1, AsyncSetEntryIcon.class, ShortcutEntry.class); } else { icon1.setImageDrawable(null); icon2.setImageDrawable(null); icon1.setVisibility(View.GONE); icon2.setVisibility(View.GONE); } ResultViewHelper.applyPreferences(drawFlags, nameView, icon1); } private void displayListResult(@NonNull View view, int drawFlags) { drawFlags |= FLAG_RELOAD; Context context = view.getContext(); TextView shortcutName = view.findViewById(R.id.item_app_name); shortcutName.setTextColor(UIColors.getResultTextColor(context)); ResultViewHelper.displayHighlighted(relevance, normalizedName, getName(), shortcutName); TextView tagsView = view.findViewById(R.id.item_app_tag); tagsView.setTextColor(UIColors.getResultText2Color(context)); // Hide tags view if tags are empty if (getTags().isEmpty()) { tagsView.setVisibility(View.GONE); } else if (ResultViewHelper.displayHighlighted(relevance, getTags(), tagsView, context) || Utilities.checkFlag(drawFlags, FLAG_DRAW_TAGS)) { tagsView.setVisibility(View.VISIBLE); ResultViewHelper.applyResultItemShadow(tagsView); } else { tagsView.setVisibility(View.GONE); } final ImageView shortcutIcon = view.findViewById(android.R.id.icon1); final ImageView appIcon = view.findViewById(android.R.id.icon2); if (Utilities.checkFlag(drawFlags, FLAG_DRAW_ICON)) { shortcutIcon.setVisibility(View.VISIBLE); appIcon.setVisibility(View.VISIBLE); ResultViewHelper.setIconAsync(drawFlags, this, shortcutIcon, AsyncSetEntryIcon.class, ShortcutEntry.class); ColorFilter colorFilter = ResultViewHelper.setIconColorFilter(shortcutIcon, drawFlags); appIcon.setColorFilter(colorFilter); } else { shortcutIcon.setImageDrawable(null); appIcon.setImageDrawable(null); shortcutIcon.setVisibility(View.GONE); appIcon.setVisibility(View.GONE); } ResultViewHelper.applyPreferences(drawFlags, shortcutName, tagsView, shortcutIcon); } @Override public void doLaunch(@NonNull View view, int flags) { Context context = view.getContext(); if (isOreoShortcut()) { // Oreo shortcuts doOreoLaunch(context, view, mShortcutInfo); } else { doShortcutLaunch(context, view, shortcutData); } } @Override protected ListPopup buildPopupMenu(Context context, LinearAdapter adapter, View parentView, int flags) { List categoryTitle = PrefCache.getResultPopupOrder(context); for (ContentLoadHelper.CategoryItem categoryItem : categoryTitle) { int titleStringId = categoryItem.textId; if (titleStringId == R.string.popup_title_hist_fav) { adapter.add(new LinearAdapter.ItemTitle(context, R.string.popup_title_hist_fav)); adapter.add(new LinearAdapter.Item(context, R.string.menu_remove_history)); adapter.add(new LinearAdapter.Item(context, R.string.menu_remove_shortcut)); adapter.add(new LinearAdapter.Item(context, R.string.menu_quick_list_add)); adapter.add(new LinearAdapter.Item(context, R.string.menu_quick_list_remove)); } else if (titleStringId == R.string.popup_title_customize) { adapter.add(new LinearAdapter.ItemTitle(context, R.string.popup_title_customize)); if (getTags().isEmpty()) adapter.add(new LinearAdapter.Item(context, R.string.menu_tags_add)); else adapter.add(new LinearAdapter.Item(context, R.string.menu_tags_edit)); adapter.add(new LinearAdapter.Item(context, R.string.menu_shortcut_rename)); adapter.add(new LinearAdapter.Item(context, R.string.menu_custom_icon)); } } if (Utilities.checkFlag(flags, LAUNCHED_FROM_QUICK_LIST)) { adapter.add(new LinearAdapter.ItemTitle(context, R.string.menu_popup_title_settings)); adapter.add(new LinearAdapter.Item(context, R.string.menu_popup_quick_list_customize)); } return inflatePopupMenu(context, adapter); } @Override boolean popupMenuClickHandler(@NonNull View view, @NonNull LinearAdapter.MenuItem item, int stringId, View parentView) { Context ctx = view.getContext(); if (stringId == R.string.menu_remove_shortcut) { TBApplication app = TBApplication.getApplication(ctx); app.getDataHandler().removeShortcut(this); app.behaviour().removeResult(this); //Toast.makeText(ctx, "Shortcut `" + getName() + "` removed.", Toast.LENGTH_LONG).show(); return true; } else if (stringId == R.string.menu_tags_add || stringId == R.string.menu_tags_edit) { TBApplication.behaviour(ctx).launchEditTagsDialog(this); return true; } else if (stringId == R.string.menu_shortcut_rename) { launchRenameDialog(ctx); return true; } else if (stringId == R.string.menu_custom_icon) { TBApplication.behaviour(ctx).launchCustomIconDialog(this); return true; } return super.popupMenuClickHandler(view, item, stringId, parentView); } private void launchRenameDialog(@NonNull Context ctx) { DialogHelper.makeRenameDialog(ctx, getName(), (dialog, newName) -> { Context context = dialog.getContext(); setName(newName); TBApplication app = TBApplication.getApplication(context); app.getDataHandler().renameShortcut(this, newName); app.behaviour().refreshSearchRecord(ShortcutEntry.this); // Show toast message String msg = context.getString(R.string.shortcut_rename_confirmation, getName()); Toast.makeText(context, msg, Toast.LENGTH_SHORT).show(); }) .setTitle(R.string.title_shortcut_rename) .show(); } public void setCustomIcon() { customIcon += 1; } public void clearCustomIcon() { customIcon = 0; } public static class AsyncSetEntryIcon extends AsyncSetEntryDrawable { Drawable subIcon = null; public AsyncSetEntryIcon(@NonNull ImageView image, int drawFlags, @NonNull ShortcutEntry shortcutEntry) { super(image, drawFlags, shortcutEntry); } @Override public Drawable getDrawable(Context context) { ShortcutEntry shortcutEntry = entryItem; Drawable icon = shortcutEntry.getIcon(context); if (icon == null) { subIcon = AppCompatResources.getDrawable(context, R.drawable.ic_send); return getAppDrawable(context, shortcutEntry.shortcutData, shortcutEntry.packageName, shortcutEntry.mShortcutInfo, false); } else { subIcon = getAppDrawable(context, shortcutEntry.shortcutData, shortcutEntry.packageName, shortcutEntry.mShortcutInfo, true); } return icon; } @Override protected void onPostExecute(Drawable drawable) { // get ImageView before calling super ImageView icon1 = getImageView(); super.onPostExecute(drawable); if (icon1 != null) setIcons(drawFlags, icon1, drawable, subIcon); } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/entry/StaticEntry.java ================================================ package rocks.tbog.tblauncher.entry; import android.content.Context; import android.graphics.Color; import android.graphics.drawable.Drawable; import android.view.View; import android.view.ViewGroup; import android.widget.ImageView; import android.widget.TextView; import android.widget.Toast; import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.annotation.WorkerThread; import androidx.appcompat.content.res.AppCompatResources; import androidx.core.graphics.drawable.DrawableCompat; import java.util.List; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.handler.DataHandler; import rocks.tbog.tblauncher.handler.IconsHandler; import rocks.tbog.tblauncher.preference.ContentLoadHelper; import rocks.tbog.tblauncher.result.AsyncSetEntryDrawable; import rocks.tbog.tblauncher.result.ResultViewHelper; import rocks.tbog.tblauncher.ui.LinearAdapter; import rocks.tbog.tblauncher.ui.ListPopup; import rocks.tbog.tblauncher.utils.DialogHelper; import rocks.tbog.tblauncher.utils.PrefCache; import rocks.tbog.tblauncher.utils.UIColors; import rocks.tbog.tblauncher.utils.Utilities; public abstract class StaticEntry extends EntryItem implements ICustomIconEntry { private static final int[] RESULT_LAYOUT = {R.layout.item_builtin, R.layout.item_grid, R.layout.item_dock}; @DrawableRes protected int iconResource; protected int customIcon; public StaticEntry(@NonNull String id, @DrawableRes int icon) { super(id); iconResource = icon; } @NonNull @Override public String getIconCacheId() { return id + customIcon; } @Override protected ListPopup buildPopupMenu(Context context, LinearAdapter adapter, View parentView, int flags) { List categoryTitle = PrefCache.getResultPopupOrder(context); for (ContentLoadHelper.CategoryItem categoryItem : categoryTitle) { int titleStringId = categoryItem.textId; if (titleStringId == R.string.popup_title_customize) { adapter.add(new LinearAdapter.ItemTitle(context, R.string.popup_title_customize)); adapter.add(new LinearAdapter.Item(context, R.string.menu_action_rename)); adapter.add(new LinearAdapter.Item(context, R.string.menu_custom_icon)); } } if (Utilities.checkFlag(flags, LAUNCHED_FROM_QUICK_LIST)) { adapter.add(new LinearAdapter.ItemTitle(context, R.string.menu_popup_title_settings)); adapter.add(new LinearAdapter.Item(context, R.string.menu_quick_list_remove)); adapter.add(new LinearAdapter.Item(context, R.string.menu_popup_quick_list_customize)); } return inflatePopupMenu(context, adapter); } @Override boolean popupMenuClickHandler(@NonNull View view, @NonNull LinearAdapter.MenuItem item, int stringId, View parentView) { Context ctx = view.getContext(); if (stringId == R.string.menu_action_rename) { launchRenameDialog(ctx); return true; } else if (stringId == R.string.menu_custom_icon) { TBApplication.behaviour(ctx).launchCustomIconDialog(this); return true; } return super.popupMenuClickHandler(view, item, stringId, parentView); } private void launchRenameDialog(@NonNull Context c) { DialogHelper.makeRenameDialog(c, getName(), (dialog, newName) -> { Context ctx = dialog.getContext(); // Set new name setName(newName); TBApplication app = TBApplication.getApplication(ctx); app.getDataHandler().renameStaticEntry(this, newName); app.behaviour().refreshSearchRecord(StaticEntry.this); // Show toast message String msg = ctx.getString(R.string.entry_rename_confirmation, getName()); Toast.makeText(ctx, msg, Toast.LENGTH_SHORT).show(); }) .setTitle(R.string.title_static_rename) .setNeutralButton(R.string.custom_name_set_default, (dialog, which) -> { Context ctx = dialog.requireContext(); TBApplication app = TBApplication.getApplication(ctx); DataHandler dataHandler = app.getDataHandler(); // restore default name String name = dataHandler.renameStaticEntry(this, null); if (name != null) { app.behaviour().refreshSearchRecord(StaticEntry.this); // Show toast message String msg = ctx.getString(R.string.entry_rename_confirmation, getName()); Toast.makeText(ctx, msg, Toast.LENGTH_SHORT).show(); } dialog.dismiss(); }) .show(); } /////////////////////////////////////////////////////////////////////////////////////////////// // Result methods /////////////////////////////////////////////////////////////////////////////////////////////// public static int[] getResultLayout() { return RESULT_LAYOUT; } @Override public int getResultLayout(int drawFlags) { return Utilities.checkFlag(drawFlags, FLAG_DRAW_LIST) ? RESULT_LAYOUT[0] : (Utilities.checkFlag(drawFlags, FLAG_DRAW_GRID) ? RESULT_LAYOUT[1] : RESULT_LAYOUT[2]); } @Override public void displayResult(@NonNull View view, int drawFlags) { TextView nameView = view.findViewById(android.R.id.text1); nameView.setTextColor(UIColors.getResultTextColor(view.getContext())); nameView.setText(getName()); if (Utilities.checkFlag(drawFlags, FLAG_DRAW_NAME)) nameView.setVisibility(View.VISIBLE); else nameView.setVisibility(View.GONE); ImageView appIcon = view.findViewById(android.R.id.icon); if (Utilities.checkFlag(drawFlags, FLAG_DRAW_ICON)) { ResultViewHelper.setIconColorFilter(appIcon, drawFlags); appIcon.setVisibility(View.VISIBLE); ResultViewHelper.setIconAsync(drawFlags, this, appIcon, AsyncSetEntryIcon.class, StaticEntry.class); } else { appIcon.setImageDrawable(null); appIcon.setVisibility(View.GONE); } ResultViewHelper.applyPreferences(drawFlags, nameView, appIcon); if (Utilities.checkFlag(drawFlags, FLAG_DRAW_LIST)) ResultViewHelper.applyListRowPreferences((ViewGroup) view); } @Override public void setCustomIcon() { customIcon += 1; } @Override public void clearCustomIcon() { customIcon = 0; } @Override public boolean hasCustomIcon() { return customIcon > 0; } @Override public boolean isExcludedFromHistory() { return true; } @WorkerThread public Drawable getIconDrawable(Context context) { if (hasCustomIcon()) { IconsHandler iconsHandler = TBApplication.getApplication(context).iconsHandler(); Drawable drawable = iconsHandler.getCustomIcon(this); if (drawable != null) return drawable; else iconsHandler.restoreDefaultIcon(this); } return getDefaultDrawable(context); } public Drawable getDefaultDrawable(@NonNull Context context) { return AppCompatResources.getDrawable(context, iconResource); } public static class AsyncSetEntryIcon extends AsyncSetEntryDrawable { public AsyncSetEntryIcon(@NonNull ImageView image, int drawFlags, @NonNull StaticEntry staticEntry) { super(image, drawFlags, staticEntry); } @Override public Drawable getDrawable(Context context) { Drawable drawable = entryItem.getIconDrawable(context); if (!entryItem.hasCustomIcon()) { drawable = DrawableCompat.wrap(drawable); int color = Utilities.checkFlag(drawFlags, FLAG_DRAW_WHITE_BG) ? Color.BLACK : Color.WHITE; DrawableCompat.setTint(drawable, color); } return drawable; } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/entry/TagEntry.java ================================================ package rocks.tbog.tblauncher.entry; import android.content.Context; import android.graphics.drawable.Drawable; import android.view.View; import androidx.annotation.NonNull; import rocks.tbog.tblauncher.BuildConfig; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.TagsManager; import rocks.tbog.tblauncher.drawable.CodePointDrawable; import rocks.tbog.tblauncher.searcher.TagSearcher; import rocks.tbog.tblauncher.ui.LinearAdapter; import rocks.tbog.tblauncher.utils.DialogHelper; public class TagEntry extends StaticEntry { public static final String SCHEME = "tag://"; public TagEntry(@NonNull String id) { super(id, 0); if (BuildConfig.DEBUG && !id.startsWith(SCHEME)) { throw new IllegalStateException("Invalid " + TagEntry.class.getSimpleName() + " id `" + id + "`"); } } @Override public void setName(String name) { if (name != null) { if (!id.endsWith(name)) throw new IllegalStateException("tags can't have the display name different from the tag name"); super.setName(name); } else { super.setName(id.substring(SCHEME.length())); } } @Override boolean popupMenuClickHandler(@NonNull View view, @NonNull LinearAdapter.MenuItem item, int stringId, View parentView) { if (stringId == R.string.menu_action_rename) { Context ctx = view.getContext(); launchRenameDialog(ctx); return true; } return super.popupMenuClickHandler(view, item, stringId, parentView); } @Override public void doLaunch(@NonNull View v, int flags) { if (TBApplication.activityInvalid(v)) return; Context ctx = v.getContext(); TBApplication.quickList(ctx).toggleSearch(v, getName(), TagSearcher.class); } @Override public void displayResult(@NonNull View view, int drawFlags) { super.displayResult(view, drawFlags); view.setTag(R.id.tag_actionId, id); } @Override public Drawable getDefaultDrawable(Context context) { return new CodePointDrawable(getName()); } private void launchRenameDialog(@NonNull Context c) { DialogHelper.makeRenameDialog(c, getName(), (dialog, newName) -> { Context ctx = dialog.getContext(); String oldName = getName(); TBApplication app = TBApplication.getApplication(ctx); app.tagsHandler().renameTag(oldName, newName); app.behaviour().refreshSearchRecord(TagEntry.this); // update providers and refresh views TagsManager.afterChangesMade(ctx); }) .setTitle(R.string.title_rename_tag) .setHint(R.string.hint_rename_tag) .show(); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/entry/UrlEntry.java ================================================ package rocks.tbog.tblauncher.entry; import android.content.Context; import android.content.pm.PackageManager; import android.graphics.drawable.Drawable; import android.text.Spannable; import android.text.SpannableString; import android.text.style.ForegroundColorSpan; import android.util.Pair; import android.view.View; import android.view.ViewGroup; import android.widget.ImageView; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import java.util.ArrayList; import rocks.tbog.tblauncher.result.AsyncSetEntryDrawable; import rocks.tbog.tblauncher.result.ResultViewHelper; import rocks.tbog.tblauncher.utils.UIColors; import rocks.tbog.tblauncher.utils.Utilities; public abstract class UrlEntry extends SearchEntry { public final String url; private static final ArrayList> APP4URL; static { APP4URL = new ArrayList<>(5); APP4URL.add(new Pair<>("https://encrypted.google.com", "com.google.android.googlequicksearchbox")); APP4URL.add(new Pair<>("https://play.google.com/store", "com.android.vending")); APP4URL.add(new Pair<>("https://start.duckduckgo.com", "com.duckduckgo.mobile.android")); APP4URL.add(new Pair<>("https://www.google.com/maps", "com.google.android.apps.maps")); APP4URL.add(new Pair<>("https://www.youtube.com", "com.google.android.youtube")); } public UrlEntry(@NonNull String id, @NonNull String url) { super(id); this.url = url; } @Override public String getHistoryId() { // Search POJO should not appear in history return ""; } @Nullable protected static Drawable getApplicationIconForUrl(@NonNull Context context, @Nullable String url) { if (url == null || url.isEmpty()) return null; for (Pair pair : APP4URL) { if (url.startsWith(pair.first)) { try { return context.getPackageManager().getApplicationIcon(pair.second); } catch (PackageManager.NameNotFoundException ignored) { } } } return null; } protected static boolean isGoogleSearch(String url) { return url.startsWith("https://encrypted.google.com"); } /////////////////////////////////////////////////////////////////////////////////////////////// // Result methods /////////////////////////////////////////////////////////////////////////////////////////////// protected abstract String getResultText(Context context); @Override public Drawable getDefaultDrawable(Context context) { Drawable appIcon = getApplicationIconForUrl(context, url); if (appIcon != null) return appIcon; return super.getDefaultDrawable(context); } @Override public void displayResult(@NonNull View view, int drawFlags) { Context context = view.getContext(); TextView nameView = view.findViewById(android.R.id.text1); nameView.setTextColor(UIColors.getResultTextColor(view.getContext())); if (Utilities.checkFlag(drawFlags, FLAG_DRAW_NAME)) { String text = getResultText(context); int pos = text.lastIndexOf(query); if (pos >= 0) { int color = UIColors.getResultHighlightColor(context); SpannableString enriched = new SpannableString(text); enriched.setSpan( new ForegroundColorSpan(color), pos, pos + query.length(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE ); nameView.setText(enriched); } else { nameView.setText(text); } nameView.setVisibility(View.VISIBLE); } else { nameView.setVisibility(View.GONE); } ImageView appIcon = view.findViewById(android.R.id.icon); if (Utilities.checkFlag(drawFlags, FLAG_DRAW_ICON)) { ResultViewHelper.setIconColorFilter(appIcon, drawFlags); appIcon.setVisibility(View.VISIBLE); ResultViewHelper.setIconAsync(drawFlags, this, appIcon, AsyncSetUrlEntryIcon.class, UrlEntry.class); } else { appIcon.setImageDrawable(null); appIcon.setVisibility(View.GONE); } ResultViewHelper.applyPreferences(drawFlags, nameView, appIcon); if (Utilities.checkFlag(drawFlags, FLAG_DRAW_LIST)) ResultViewHelper.applyListRowPreferences((ViewGroup) view); } public static class AsyncSetUrlEntryIcon extends AsyncSetEntryDrawable { public AsyncSetUrlEntryIcon(@NonNull ImageView image, int drawFlags, @NonNull UrlEntry urlEntry) { super(image, drawFlags, urlEntry); } @Override public Drawable getDrawable(Context context) { return entryItem.getIconDrawable(context); } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/handler/AppsHandler.java ================================================ package rocks.tbog.tblauncher.handler; import android.content.ComponentName; import android.content.Context; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.Map; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.dataprovider.AppCacheProvider; import rocks.tbog.tblauncher.db.AppRecord; import rocks.tbog.tblauncher.db.DBHelper; import rocks.tbog.tblauncher.entry.AppEntry; import rocks.tbog.tblauncher.utils.Timer; import rocks.tbog.tblauncher.utils.UserHandleCompat; import rocks.tbog.tblauncher.utils.Utilities; public class AppsHandler { private static final String TAG = AppsHandler.class.getSimpleName(); private final TBApplication mApplication; private final HashMap mAppsCache = new HashMap<>(); private boolean mIsLoaded = false; private final ArrayDeque mAfterLoadedTasks = new ArrayDeque<>(2); public AppsHandler(TBApplication application) { this.mApplication = application; loadFromDB(false); } public void loadFromDB(boolean wait) { Log.d(TAG, "loadFromDB(wait= " + wait + " )"); synchronized (this) { mIsLoaded = false; } final Timer timer = Timer.startMilli(); final HashMap apps = new HashMap<>(); final Runnable load = () -> { TagsHandler tagsHandler = mApplication.tagsHandler(); Context context = getContext(); Map dbApps = DBHelper.getAppsData(context); apps.clear(); // convert from AppRecord to AppEntry for (AppRecord rec : dbApps.values()) { AppEntry appEntry = record2app(context, rec); apps.put(appEntry.id, appEntry); } setTagsForApps(apps.values(), tagsHandler); }; final Runnable apply = () -> { synchronized (AppsHandler.this) { mAppsCache.clear(); mAppsCache.putAll(apps); mIsLoaded = true; timer.stop(); Log.d("time", "Time to load all DB apps: " + timer); // run and remove tasks Runnable task; while (null != (task = mAfterLoadedTasks.poll())) task.run(); } }; if (wait) { load.run(); apply.run(); } else Utilities.runAsync((t) -> load.run(), (t) -> apply.run()); } public void runWhenLoaded(@NonNull Runnable task) { synchronized (this) { if (mIsLoaded) task.run(); else mAfterLoadedTasks.add(task); } } @WorkerThread public static void setTagsForApps(@NonNull Collection apps, @NonNull TagsHandler tagsHandler) { tagsHandler.runWhenLoaded(() -> { Utilities.runAsync(() -> { Log.d(TAG, "set " + apps.size() + " cached app(s) tags"); for (AppEntry appEntry : apps) appEntry.setTags(tagsHandler.getTags(appEntry.id)); }); }); } @NonNull private static AppEntry record2app(@NonNull Context context, @NonNull AppRecord rec) { UserHandleCompat user = UserHandleCompat.fromComponentName(context, rec.componentName); ComponentName cn = UserHandleCompat.unflattenComponentName(rec.componentName); AppEntry appEntry = new AppEntry(cn, user); if (rec.hasCustomName()) appEntry.setName(rec.displayName); else appEntry.setName(user.getBadgedLabelForUser(context, rec.displayName)); if (rec.hasCustomIcon()) appEntry.setCustomIcon(rec.dbId); return appEntry; } private Context getContext() { return mApplication; } /** * Get an unmodifiable collection with the applications * @return an empty list if not loaded yet */ @NonNull public Collection getAllApps() { synchronized (AppsHandler.this) { if (!mIsLoaded) return Collections.emptyList(); return Collections.unmodifiableCollection(mAppsCache.values()); } } /** * Get an ArrayList of the application collection. * `AppEntry.resetRelevance` is called before returning list * @return a new instance of ArrayList with all apps */ @NonNull public ArrayList getApplications() { ArrayList records = new ArrayList<>(mAppsCache.size()); synchronized (AppsHandler.this) { if (mIsLoaded) { for (AppEntry appEntry : mAppsCache.values()) { appEntry.resetResultInfo(); records.add(appEntry); } } } return records; } public AppCacheProvider getCacheProvider() { return new AppCacheProvider(this); } @NonNull public Map getAppRecords(@NonNull Context context) { return DBHelper.getAppsData(context); } public void updateAppCache(@Nullable ArrayList insertOrUpdate, @Nullable ArrayList remove) { if (insertOrUpdate != null && insertOrUpdate.size() > 0) { DBHelper.insertOrUpdateApps(getContext(), insertOrUpdate); } if (remove != null && remove.size() > 0) { DBHelper.deleteApps(getContext(), remove); } } public void setAppCache(@Nullable ArrayList list) { if (list == null || list.isEmpty()) return; synchronized (AppsHandler.this) { mIsLoaded = true; mAppsCache.clear(); for (AppEntry appEntry : list) mAppsCache.put(appEntry.id, appEntry); } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/handler/DataHandler.java ================================================ package rocks.tbog.tblauncher.handler; import android.app.Application; import android.app.KeyguardManager; import android.content.BroadcastReceiver; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.content.ServiceConnection; import android.content.SharedPreferences; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.os.Build; import android.os.Handler; import android.os.IBinder; import android.os.Looper; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; import androidx.core.app.ActivityCompat; import androidx.core.content.ContextCompat; import androidx.preference.PreferenceManager; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Comparator; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.concurrent.ExecutorService; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.TBLauncherActivity; import rocks.tbog.tblauncher.dataprovider.ActionProvider; import rocks.tbog.tblauncher.dataprovider.AppProvider; import rocks.tbog.tblauncher.dataprovider.CalculatorProvider; import rocks.tbog.tblauncher.dataprovider.ContactsProvider; import rocks.tbog.tblauncher.dataprovider.DialProvider; import rocks.tbog.tblauncher.dataprovider.FilterProvider; import rocks.tbog.tblauncher.dataprovider.IProvider; import rocks.tbog.tblauncher.dataprovider.ModProvider; import rocks.tbog.tblauncher.dataprovider.Provider; import rocks.tbog.tblauncher.dataprovider.QuickListProvider; import rocks.tbog.tblauncher.dataprovider.SearchProvider; import rocks.tbog.tblauncher.dataprovider.ShortcutsProvider; import rocks.tbog.tblauncher.dataprovider.TagsProvider; import rocks.tbog.tblauncher.db.AppRecord; import rocks.tbog.tblauncher.db.DBHelper; import rocks.tbog.tblauncher.db.ModRecord; import rocks.tbog.tblauncher.db.ShortcutRecord; import rocks.tbog.tblauncher.db.ValuedHistoryRecord; import rocks.tbog.tblauncher.entry.AppEntry; import rocks.tbog.tblauncher.entry.EntryItem; import rocks.tbog.tblauncher.entry.ShortcutEntry; import rocks.tbog.tblauncher.entry.StaticEntry; import rocks.tbog.tblauncher.searcher.Searcher; import rocks.tbog.tblauncher.shortcut.ShortcutUtil; import rocks.tbog.tblauncher.utils.Timer; import rocks.tbog.tblauncher.utils.Utilities; public class DataHandler extends BroadcastReceiver implements SharedPreferences.OnSharedPreferenceChangeListener { final static private String TAG = "DataHandler"; public static final ExecutorService EXECUTOR_PROVIDERS; static { /* corePoolSize: the number of threads to keep in the pool. maximumPoolSize: the maximum number of threads to allow in the pool. keepAliveTime: if the pool currently has more than corePoolSize threads, excess threads will be terminated if they have been idle for more than keepAliveTime. unit: the time unit for the keepAliveTime argument. Can be NANOSECONDS, MILLISECONDS, SECONDS, MINUTES, HOURS and DAYS. workQueue: the queue used for holding tasks before they are executed. Default choices are SynchronousQueue for multi-threaded pools and LinkedBlockingQueue for single-threaded pools. */ ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor( 1, 1, 1, TimeUnit.SECONDS, new LinkedBlockingQueue<>()); threadPoolExecutor.allowCoreThreadTimeOut(true); EXECUTOR_PROVIDERS = threadPoolExecutor; } /** * Package the providers reside in */ final static private String PROVIDER_PREFIX = IProvider.class.getPackage().getName() + "."; /** * List all known complex providers, that are defined as Android services */ final static private List PROVIDER_NAMES = Arrays.asList( "app" , "contacts" , "shortcuts" ); @NonNull private final Application mApplication; private String currentQuery; private final Map providers = new LinkedHashMap<>(); // preserve insert order private boolean mFullLoadOverSent = false; private final ArrayDeque mAfterLoadOverTasks = new ArrayDeque<>(2); private final Timer mTimer = new Timer(); /** * Initialize all providers */ public DataHandler(@NonNull Application app) { // Make sure we are in the context of the main application // (otherwise we might receive an exception about broadcast listeners not being able // to bind to services) mApplication = app; Context ctx = app.getApplicationContext(); mTimer.start(); IntentFilter intentFilter = new IntentFilter(TBLauncherActivity.LOAD_OVER); ActivityCompat.registerReceiver(ctx, this, intentFilter, ContextCompat.RECEIVER_NOT_EXPORTED); sendBroadcast(ctx, TBLauncherActivity.START_LOAD, TAG); // Monitor changes for service preferences (to automatically start and stop services) SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(ctx); prefs.registerOnSharedPreferenceChangeListener(this); // add DB providers basicProviders(); // add providers that may be toggled by preferences toggleableProviders(prefs); // start STEP_1 providers for (ProviderEntry entry : providers.values()) { if (entry.provider == null) return; if (IProvider.LOAD_STEP_1 == entry.provider.getLoadStep() && !entry.provider.isLoaded()) { entry.provider.reload(false); } } // Connect to complex providers // Those are the complex providers, that are defined as Android services // to survive even if the app's UI is killed // (this way, we don't need to reload the app list as often) for (String providerName : PROVIDER_NAMES) { if (prefs.getBoolean("enable-" + providerName, true)) { this.connectToProvider(providerName, 0); } } } public static void sendBroadcast(@NonNull Context context, @NonNull String action, @Nullable String data) { Intent msg = new Intent(action) .setPackage(context.getPackageName()) .putExtra(TBLauncherActivity.INTENT_DATA, data); context.sendBroadcast(msg); } @NonNull public Context getContext() { return mApplication.getApplicationContext(); } /* * Some basic providers are defined directly, as we don't need the overhead of a service * for them. These providers don't expose a service connection, and you can't bind / unbind * to them dynamically. */ private void basicProviders() { Context context = mApplication; // Filters { ProviderEntry providerEntry = new ProviderEntry(); providerEntry.provider = new FilterProvider(context); providers.put("filters", providerEntry); } // Actions { ProviderEntry providerEntry = new ProviderEntry(); providerEntry.provider = new ActionProvider(context); providers.put("actions", providerEntry); } // Tag provider { ProviderEntry providerEntry = new ProviderEntry(); providerEntry.provider = new TagsProvider(context); providers.put("tags", providerEntry); } // Favorites { ProviderEntry providerEntry = new ProviderEntry(); providerEntry.provider = new ModProvider(context); providers.put("mods", providerEntry); } // QuickList { ProviderEntry providerEntry = new ProviderEntry(); providerEntry.provider = new QuickListProvider(context); providers.put("quickList", providerEntry); } } private void toggleableProviders(SharedPreferences prefs) { final Context context = getContext(); // Search engine provider, { String providerName = "search"; if (prefs.getBoolean("enable-search", true) || prefs.getBoolean("enable-url", true)) { ProviderEntry providerEntry = new ProviderEntry(); providerEntry.provider = new SearchProvider(context, prefs); providers.put(providerName, providerEntry); } else { providers.remove(providerName); } } // Calculator provider, may be toggled by preference { String providerName = "calculator"; if (prefs.getBoolean("enable-" + providerName, true)) { ProviderEntry providerEntry = new ProviderEntry(); providerEntry.provider = new CalculatorProvider(); providers.put(providerName, providerEntry); } else { providers.remove(providerName); } } // Dial phone provider, may be toggled by preference { String providerName = "dial"; if (prefs.getBoolean("enable-" + providerName, true)) { ProviderEntry providerEntry = new ProviderEntry(); providerEntry.provider = new DialProvider(); providers.put(providerName, providerEntry); } else { providers.remove(providerName); } } } @Override public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { if (key != null && key.startsWith("enable-")) { String providerName = key.substring(7); if (PROVIDER_NAMES.contains(providerName)) { if (sharedPreferences.getBoolean(key, true)) { this.connectToProvider(providerName, 0); } else { this.disconnectFromProvider(providerName); } } } } /** * Generate an intent that can be used to start or stop the given provider * * @param name The name of the provider * @return Android intent for this provider */ private Intent providerName2Intent(@NonNull Context context, String name) { // Build expected fully-qualified provider class name StringBuilder className = new StringBuilder(50); className.append(PROVIDER_PREFIX); className.append(Character.toUpperCase(name.charAt(0))); className.append(name.substring(1).toLowerCase(Locale.ROOT)); className.append("Provider"); // Try to create reflection class instance for class name try { return new Intent(context, Class.forName(className.toString())); } catch (ClassNotFoundException e) { e.printStackTrace(); return null; } } /** * Require the data handler to be connected to the data provider with the given name * * @param name Data provider name (i.e.: `ContactsProvider` → `"contacts"`) */ private void connectToProvider(final String name, final int counter) { final Context context = getContext(); // Do not continue if this provider has already been connected to if (this.providers.containsKey(name)) { return; } Log.v(TAG, "Connecting to " + name); // Find provider class for the given service name final Intent intent = this.providerName2Intent(context, name); if (intent == null) { return; } if (!startService(context, intent, name, counter)) return; final ProviderEntry entry = new ProviderEntry(); // Add empty provider object to list of providers this.providers.put(name, entry); // Connect and bind to provider service context.bindService(intent, new ServiceConnection() { @Override public void onServiceConnected(ComponentName className, IBinder service) { Log.i(TAG, "onServiceConnected " + className); // We've bound to LocalService, cast the IBinder and get LocalService instance Provider.LocalBinder binder = (Provider.LocalBinder) service; IProvider provider = binder.getService(); // Update provider info so that it contains something useful entry.provider = provider; entry.connection = this; if (provider.isLoaded()) { handleProviderLoaded(); } } @Override public void onServiceDisconnected(ComponentName arg0) { Log.i(TAG, "onServiceDisconnected " + arg0); } }, Context.BIND_AUTO_CREATE); } private boolean startService(Context context, Intent intent, String name, int counter) { try { // Send "start service" command first so that the service can run independently // of the activity context.startService(intent); } catch (IllegalStateException e) { // When KISS is the default launcher, // the system will try to start KISS in the background after a reboot // however at this point we're not allowed to start services, and an IllegalStateException will be thrown // We'll then add a broadcast receiver for the next time the user turns his screen on // (or passes the lockscreen) to retry at this point // https://github.com/Neamar/KISS/issues/1130 // https://github.com/Neamar/KISS/issues/1154 Log.w(TAG, "Unable to start service for " + name + ". KISS is probably not in the foreground. Service will automatically be started when KISS gets to the foreground."); if (counter > 20) { Log.e(TAG, "Already tried and failed twenty times to start service. Giving up."); return false; } // Add a receiver to get notified next time the screen is on // or next time the users successfully dismisses his lock screen IntentFilter intentFilter = new IntentFilter(); intentFilter.addAction(Intent.ACTION_SCREEN_ON); intentFilter.addAction(Intent.ACTION_USER_PRESENT); ActivityCompat.registerReceiver(context, new BroadcastReceiver() { @Override public void onReceive(final Context context, Intent intent) { // Is there a lockscreen still visible to the user? // If yes, we can't start background services yet, so we'll need to wait until we get ACTION_USER_PRESENT KeyguardManager myKM = (KeyguardManager) context.getSystemService(Context.KEYGUARD_SERVICE); assert myKM != null; boolean isPhoneLocked = myKM.isKeyguardLocked(); if (!isPhoneLocked) { context.unregisterReceiver(this); final Handler handler = new Handler(Looper.getMainLooper()); // Even when all the stars are aligned, // starting the service needs to be slightly delayed because the Intent is fired *before* the app is considered in the foreground. // Each new release of Android manages to make the developer life harder. // Can't wait for the next one. handler.postDelayed(new Runnable() { @Override public void run() { Log.i(TAG, "Screen turned on or unlocked, retrying to start background services"); connectToProvider(name, counter + 1); } }, 10); } } }, intentFilter, ContextCompat.RECEIVER_EXPORTED); // Stop here for now, the Receiver will re-trigger the whole flow when services can be started. return false; } return true; } /** * Terminate any connection between the data handler and the data provider with the given name * * @param name Data provider name (i.e.: `AppProvider` → `"app"`) */ private void disconnectFromProvider(String name) { final Context context = getContext(); // Skip already disconnected services ProviderEntry entry = this.providers.get(name); if (entry == null) { return; } // Disconnect from provider service context.unbindService(entry.connection); // Stop provider service context.stopService(new Intent(context, entry.provider.getClass())); // Remove provider from list this.providers.remove(name); } private boolean allProvidersHaveLoaded() { final Context context = getContext(); for (ProviderEntry entry : this.providers.values()) if (entry.provider == null || !entry.provider.isLoaded()) return false; SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); for (String providerName : PROVIDER_NAMES) { if (prefs.getBoolean("enable-" + providerName, true)) { if (!providers.containsKey(providerName)) return false; } } return true; } private boolean providersHaveLoaded(int step) { for (ProviderEntry entry : this.providers.values()) { if (entry.provider == null) { return false; } if (step == entry.provider.getLoadStep() && !entry.provider.isLoaded()) { return false; } } return true; } /** * Called when some event occurred that makes us believe that all data providers * might be ready now */ private void handleProviderLoaded() { if (mFullLoadOverSent) { return; } for (int step : IProvider.LOAD_STEPS) { boolean stepLoaded = true; for (ProviderEntry entry : this.providers.values()) { if (entry.provider == null) return; if (step == entry.provider.getLoadStep() && !entry.provider.isLoaded()) { stepLoaded = false; entry.provider.reload(false); } } if (!stepLoaded) return; } if (!allProvidersHaveLoaded()) return; mTimer.stop(); Log.v(TAG, "Time to load all providers: " + mTimer); mFullLoadOverSent = true; final Context context = getContext(); // Broadcast the fact that the new providers list is ready try { context.unregisterReceiver(this); sendBroadcast(context, TBLauncherActivity.FULL_LOAD_OVER, TAG); } catch (IllegalArgumentException e) { Log.e(TAG, "send FULL_LOAD_OVER", e); } } @Override public void onReceive(Context context, Intent intent) { // A provider finished loading and contacted us this.handleProviderLoaded(); } public void appendDebugText(StringBuilder text) { text.append("Providers: "); boolean first = true; for (int step : IProvider.LOAD_STEPS) { if (first) first = false; else text.append(", "); if (providersHaveLoaded(step)) text.append("S#") // step number .append(step); } if (mFullLoadOverSent) text.append(",done in ") .append(mTimer); text.append("\n"); ArrayList sortedProviders = new ArrayList<>(providers.size()); sortedProviders.addAll(providers.values()); Collections.sort(sortedProviders, (o1, o2) -> { Timer t1 = o1.provider == null ? null : o1.provider.getLoadDuration(); Timer t2 = o2.provider == null ? null : o2.provider.getLoadDuration(); return Timer.STOP_TIME_COMPARATOR.compare(t1, t2); }); first = true; for (ProviderEntry entry : sortedProviders) { if (entry.provider == null) continue; if (entry.provider.isLoaded()) { Timer timer = entry.provider.getLoadDuration(); if (timer == null) continue; if (first) first = false; else text.append(" | "); text.append(entry.provider.getLoadStep()) .append(".") .append(entry.provider.getClass().getSimpleName()) .append(":") .append(timer); } } text.append("\n"); } /** * Get records for this query. * * @param query query to run * @param searcher the searcher currently running */ @WorkerThread public void requestResults(String query, Searcher searcher) { currentQuery = query; for (Map.Entry setEntry : this.providers.entrySet()) { if (searcher.isCancelled()) break; IProvider provider = setEntry.getValue().provider; if (provider == null || !provider.isLoaded()) { Context context = searcher.getContext(); // if the apps provider has not finished yet, return the cached ones if ("app".equals(setEntry.getKey()) && context != null) provider = TBApplication.appsHandler(context).getCacheProvider(); else continue; } // Retrieve results for query: provider.requestResults(query, searcher); } } /** * Get records for this query. * * @param searcher the searcher currently running */ public void requestAllRecords(Searcher searcher) { for (ProviderEntry entry : this.providers.values()) { if (entry.provider == null) continue; List pojos = entry.provider.getPojos(); if (pojos == null) continue; boolean accept = searcher.addResult(pojos.toArray(new EntryItem[0])); // if searcher will not accept any more results, exit if (!accept) break; } } @NonNull public static DBHelper.HistoryMode getHistoryMode(String historyMode) { switch (historyMode) { case "frecency": return DBHelper.HistoryMode.FRECENCY; case "frequency": return DBHelper.HistoryMode.FREQUENCY; case "adaptive": return DBHelper.HistoryMode.ADAPTIVE; default: return DBHelper.HistoryMode.RECENCY; } } /** * Return previously selected items.
* May return null if no items were ever selected (app first use)
* May return an empty set if the providers are not done building records, * in this case it is probably a good idea to call this function 500ms after * * @param itemCount max number of items to retrieve, total number may be less (search or calls are not returned for instance) * @param historyMode Recency vs Frecency vs Frequency vs Adaptive * @param sortHistory Sort history entries alphabetically * @param itemsToExcludeById Items to exclude from history by their id * @return pojos in recent history */ public List getHistory(int itemCount, DBHelper.HistoryMode historyMode, boolean sortHistory, Set itemsToExcludeById) { // Max sure that we get enough items, regardless of how many may be excluded int extendedItemCount = itemCount + itemsToExcludeById.size(); // Read history final Context context = getContext(); List ids = DBHelper.getHistory(context, extendedItemCount, historyMode); // Pre-allocate array slots that are likely to be used ArrayList history = new ArrayList<>(ids.size()); // Find associated items for (int i = 0; i < ids.size(); i++) { // Ask all providers if they know this id EntryItem pojo = getPojo(ids.get(i).record); if (pojo == null) continue; if (itemsToExcludeById.contains(pojo.id)) continue; history.add(pojo); } // sort the list if needed if (sortHistory) Collections.sort(history, Comparator.comparing(EntryItem::getName)); // enforce item count after the sort operation if (history.size() > itemCount) history.subList(itemCount, history.size()).clear(); return history; } public boolean addShortcut(ShortcutRecord record) { final Context context = getContext(); Log.d(TAG, "Adding shortcut " + record.displayName + " for " + record.packageName); if (DBHelper.insertShortcut(context, record)) { ShortcutsProvider provider = getShortcutsProvider(); if (provider != null) provider.reload(true); return true; } return false; } public void removeShortcut(ShortcutEntry shortcut) { final Context context = getContext(); // Also remove shortcut from mods removeFromMods(shortcut); DBHelper.removeShortcut(context, shortcut); if (shortcut.mShortcutInfo != null) { ShortcutUtil.removeShortcut(context, shortcut.mShortcutInfo); } if (this.getShortcutsProvider() != null) { this.getShortcutsProvider().reload(true); } } public void removeShortcuts(String packageName) { final Context context = getContext(); if (android.os.Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { return; } // Remove all shortcuts from mods for given package name List shortcutsList = DBHelper.getShortcutsNoIcons(context, packageName); for (ShortcutRecord shortcut : shortcutsList) { String id = ShortcutEntry.generateShortcutId(shortcut); EntryItem entry = getPojo(id); if (entry != null) removeFromMods(entry); } DBHelper.removeShortcuts(context, packageName); if (this.getShortcutsProvider() != null) { this.getShortcutsProvider().reload(true); } } public boolean addToHidden(AppEntry entry) { final Context context = getContext(); return DBHelper.setAppHidden(context, entry.getUserComponentName()); } public boolean removeFromHidden(AppEntry entry) { final Context context = getContext(); return DBHelper.removeAppHidden(context, entry.getUserComponentName()); } @Nullable public ContactsProvider getContactsProvider() { ProviderEntry entry = this.providers.get("contacts"); return (entry != null) ? ((ContactsProvider) entry.provider) : null; } @Nullable public ShortcutsProvider getShortcutsProvider() { ProviderEntry entry = this.providers.get("shortcuts"); return (entry != null) ? ((ShortcutsProvider) entry.provider) : null; } @Nullable public AppProvider getAppProvider() { ProviderEntry entry = this.providers.get("app"); return (entry != null) ? ((AppProvider) entry.provider) : null; } @Nullable public ModProvider getModProvider() { ProviderEntry entry = this.providers.get("mods"); return (entry != null) ? ((ModProvider) entry.provider) : null; } @Nullable public FilterProvider getFilterProvider() { ProviderEntry entry = this.providers.get("filters"); return (entry != null) ? ((FilterProvider) entry.provider) : null; } @Nullable public ActionProvider getActionProvider() { ProviderEntry entry = this.providers.get("actions"); return (entry != null) ? ((ActionProvider) entry.provider) : null; } @Nullable public TagsProvider getTagsProvider() { ProviderEntry entry = this.providers.get("tags"); return (entry != null) ? ((TagsProvider) entry.provider) : null; } @Nullable public QuickListProvider getQuickListProvider() { ProviderEntry entry = this.providers.get("quickList"); return (entry != null) ? ((QuickListProvider) entry.provider) : null; } /** * Return a list of records that have modifications (custom icon, name or flags) * * @return list of {@link ModRecord} */ @NonNull public List getMods() { final Context context = getContext(); return DBHelper.getMods(context); } public void removeFromMods(EntryItem entry) { final Context context = getContext(); if (DBHelper.removeMod(context, entry.id)) { ModProvider modProvider = getModProvider(); if (modProvider != null) modProvider.reload(true); } } /** * Insert specified ID (probably a pojo.id) into history * * @param id pojo.id of item to record */ public void addToHistory(String id) { if (id.isEmpty()) { return; } final Context context = getContext(); DBHelper.insertHistory(context, currentQuery, id); } @Nullable public EntryItem getPojo(@NonNull String id) { // Ask all providers if they know this id for (ProviderEntry entry : this.providers.values()) { if (entry.provider != null && entry.provider.mayFindById(id)) { return entry.provider.findById(id); } } return null; } public void renameApp(String componentName, String newName) { final Context context = getContext(); DBHelper.setCustomAppName(context, componentName, newName); } /** * Rename an action or a tag in the DB, refresh providers, update {@link EntryItem} * * @param entry static entry to operate on * @param newName new name or null to restore default * @return The name after we rename or null in case of error */ @Nullable public String renameStaticEntry(@NonNull StaticEntry entry, @Nullable String newName) { final Context context = getContext(); final String entryId = entry.id; if (newName == null) { // we need to restore the default name DBHelper.removeCustomStaticEntryName(context, entryId); String name = null; { ActionProvider actionProvider = getActionProvider(); if (actionProvider != null && actionProvider.mayFindById(entryId)) name = actionProvider.getDefaultName(entryId); } { FilterProvider filterProvider = getFilterProvider(); if (filterProvider != null && filterProvider.mayFindById(entryId)) name = filterProvider.getDefaultName(entryId); } if (name != null) { entry.setName(name); } else { // can't find the default name. Reload providers and hope to get the name reloadProviders(); } return name; } else { DBHelper.setCustomStaticEntryName(context, entryId, newName); return newName; } } public void removeRenameApp(String componentName, String defaultName) { final Context context = getContext(); DBHelper.removeCustomAppName(context, componentName, defaultName); } public void setCachedAppIcon(String componentName, Bitmap bitmap) { byte[] array = Utilities.bitmapToByteArray(bitmap); if (array == null) { Log.e(TAG, "bitmapToByteArray failed for `" + componentName + "` with bitmap " + bitmap); return; } final Context context = getContext(); if (!DBHelper.setCachedAppIcon(context, componentName, array)) { Log.w(TAG, "setCachedAppIcon failed for `" + componentName + "` with bitmap " + bitmap); } } @Nullable public AppRecord setCustomAppIcon(String componentName, Bitmap bitmap) { byte[] array = Utilities.bitmapToByteArray(bitmap); if (array == null) { Log.e(TAG, "bitmapToByteArray failed for `" + componentName + "` with bitmap " + bitmap); return null; } final Context context = getContext(); return DBHelper.setCustomAppIcon(context, componentName, array); } public void setCustomStaticEntryIcon(String entryId, Bitmap bitmap) { final Context context = getContext(); byte[] array = Utilities.bitmapToByteArray(bitmap); if (array != null) { DBHelper.setCustomStaticEntryIcon(context, entryId, array); // reload provider to make sure we're up to date ModProvider modProvider = getModProvider(); if (modProvider != null) modProvider.reload(true); } } public void setCustomButtonIcon(String buttonId, Bitmap bitmap) { final Context context = getContext(); byte[] array = Utilities.bitmapToByteArray(bitmap); if (array != null) { DBHelper.setCustomStaticEntryIcon(context, buttonId, array); // we expect calling function to refresh buttons } } public Bitmap getCachedAppIcon(String componentName) { final Context context = getContext(); byte[] bytes = DBHelper.getCachedAppIcon(context, componentName); if (bytes == null) return null; return BitmapFactory.decodeByteArray(bytes, 0, bytes.length); } public Bitmap getCustomAppIcon(String componentName) { final Context context = getContext(); byte[] bytes = DBHelper.getCustomAppIcon(context, componentName); if (bytes == null) return null; return BitmapFactory.decodeByteArray(bytes, 0, bytes.length); } public AppRecord removeCustomAppIcon(String componentName) { final Context context = getContext(); return DBHelper.removeCustomAppIcon(context, componentName); } public void removeCustomStaticEntryIcon(String entryId) { final Context context = getContext(); DBHelper.removeCustomStaticEntryIcon(context, entryId); } public void removeCustomButtonIcon(String buttonId) { final Context context = getContext(); DBHelper.removeMod(context, buttonId); } public Bitmap getCustomEntryIconById(@NonNull String entryId) { final Context context = getContext(); byte[] bytes = DBHelper.getCustomFavIcon(context, entryId); if (bytes == null) return null; return BitmapFactory.decodeByteArray(bytes, 0, bytes.length); } public void renameShortcut(ShortcutEntry shortcutEntry, String newName) { final Context context = getContext(); DBHelper.renameShortcut(context, shortcutEntry, newName); } public void onProviderRecreated(Provider provider) { mFullLoadOverSent = false; final Context context = getContext(); IntentFilter intentFilter = new IntentFilter(TBLauncherActivity.LOAD_OVER); ActivityCompat.registerReceiver(context, this, intentFilter, ContextCompat.RECEIVER_NOT_EXPORTED); sendBroadcast(context, TBLauncherActivity.START_LOAD, provider.getClass().getSimpleName()); // reload providers for the next steps for (int step : IProvider.LOAD_STEPS) { if (step <= provider.getLoadStep()) continue; for (ProviderEntry entry : this.providers.values()) { if (entry.provider != null && step == entry.provider.getLoadStep()) entry.provider.setDirty(); } } } /** * Reload all providers with load step equal or greater * * @param loadStep to compare */ public void reloadProviders(int loadStep) { mFullLoadOverSent = false; final Context context = getContext(); mTimer.start(); IntentFilter intentFilter = new IntentFilter(TBLauncherActivity.LOAD_OVER); ActivityCompat.registerReceiver(context, this, intentFilter, ContextCompat.RECEIVER_NOT_EXPORTED); sendBroadcast(context, TBLauncherActivity.START_LOAD, "reload_" + loadStep); for (int step : IProvider.LOAD_STEPS) { if (step < loadStep) continue; for (ProviderEntry entry : providers.values()) { if (entry.provider != null && step == entry.provider.getLoadStep()) entry.provider.reload(true); } } } public void reloadProviders() { mFullLoadOverSent = false; final Context context = getContext(); mTimer.start(); IntentFilter intentFilter = new IntentFilter(TBLauncherActivity.LOAD_OVER); ActivityCompat.registerReceiver(context, this, intentFilter, ContextCompat.RECEIVER_NOT_EXPORTED); sendBroadcast(context, TBLauncherActivity.START_LOAD, "reload"); SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); toggleableProviders(prefs); for (String providerName : PROVIDER_NAMES) { if (prefs.getBoolean("enable-" + providerName, true)) { connectToProvider(providerName, 0); } } for (int step : IProvider.LOAD_STEPS) { for (ProviderEntry entry : providers.values()) { if (entry.provider != null && step == entry.provider.getLoadStep()) entry.provider.reload(true); } } } public void checkServices() { final Context context = getContext(); SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); for (String providerName : PROVIDER_NAMES) { if (!providers.containsKey(providerName) && prefs.getBoolean("enable-" + providerName, true)) { reloadProviders(); break; } } } public void setQuickList(Iterable records) { final Context context = getContext(); List oldFav = getMods(); int pos = 1; for (String record : records) { // remove from oldFav the current record for (Iterator iterator = oldFav.iterator(); iterator.hasNext(); ) { ModRecord modRecord = iterator.next(); if (modRecord.record.equals(record)) iterator.remove(); } String position = String.format("%08x", pos); if (!DBHelper.updateQuickListPosition(context, record, position)) { ModRecord modRecord = new ModRecord(); modRecord.record = record; modRecord.addFlags(ModRecord.FLAG_SHOW_IN_QUICK_LIST); modRecord.position = position; DBHelper.setMod(context, modRecord); } pos += 11; } // keep only entries that have mods and remove from quick list flag from oldFav for (ModRecord modRecord : oldFav) { if (modRecord.isInQuickList()) { modRecord.clearFlags(ModRecord.FLAG_SHOW_IN_QUICK_LIST); if (modRecord.canBeCulled()) DBHelper.removeMod(context, modRecord.record); else DBHelper.setMod(context, modRecord); } else if (modRecord.canBeCulled()) { DBHelper.removeMod(context, modRecord.record); } } // refresh relevant providers { IProvider provider = getModProvider(); if (provider != null) provider.reload(true); } { IProvider provider = getTagsProvider(); if (provider != null) provider.reload(true); } { IProvider provider = getQuickListProvider(); if (provider != null) provider.reload(true); } } public boolean fullLoadOverSent() { return mFullLoadOverSent; } public void runAfterLoadOver(@NonNull Runnable task) { synchronized (this) { if (mFullLoadOverSent) task.run(); else mAfterLoadOverTasks.add(task); } } public void executeAfterLoadOverTasks() { synchronized (this) { checkServices(); if (!mFullLoadOverSent) { Log.e(TAG, "executeAfterLoadOverTasks called before mFullLoadOverSent==true"); return; } Log.d(TAG, "executeAfterLoadOverTasks size=" + mAfterLoadOverTasks.size()); // run and remove tasks int count = 0; Runnable task; while (null != (task = mAfterLoadOverTasks.poll())) { task.run(); count += 1; } Log.d(TAG, "executeAfterLoadOverTasks count=" + count); } } static final class ProviderEntry { public IProvider provider = null; ServiceConnection connection = null; } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/handler/IconsHandler.java ================================================ package rocks.tbog.tblauncher.handler; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.ApplicationInfo; import android.content.pm.PackageInfo; import android.content.pm.PackageManager; import android.content.pm.PackageManager.NameNotFoundException; import android.content.pm.ResolveInfo; import android.content.res.Resources; import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.Path; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; import android.os.Build; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; import androidx.core.content.pm.PackageInfoCompat; import androidx.core.content.res.ResourcesCompat; import androidx.preference.PreferenceManager; import java.util.Collection; import java.util.HashMap; import java.util.List; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.TBLauncherActivity; import rocks.tbog.tblauncher.WorkAsync.RunnableTask; import rocks.tbog.tblauncher.db.AppRecord; import rocks.tbog.tblauncher.drawable.DrawableUtils; import rocks.tbog.tblauncher.drawable.TextDrawable; import rocks.tbog.tblauncher.entry.AppEntry; import rocks.tbog.tblauncher.entry.ContactEntry; import rocks.tbog.tblauncher.entry.DialContactEntry; import rocks.tbog.tblauncher.entry.SearchEntry; import rocks.tbog.tblauncher.entry.ShortcutEntry; import rocks.tbog.tblauncher.entry.StaticEntry; import rocks.tbog.tblauncher.icons.DrawableInfo; import rocks.tbog.tblauncher.icons.IconPack; import rocks.tbog.tblauncher.icons.IconPackXML; import rocks.tbog.tblauncher.icons.SystemIconPack; import rocks.tbog.tblauncher.utils.Timer; import rocks.tbog.tblauncher.utils.UIColors; import rocks.tbog.tblauncher.utils.UISizes; import rocks.tbog.tblauncher.utils.UserHandleCompat; import rocks.tbog.tblauncher.utils.Utilities; /** * Inspired from http://stackoverflow.com/questions/31490630/how-to-load-icon-from-icon-pack */ public class IconsHandler { private static final String TAG = "IconsHandler"; // map with available icons packs private final HashMap mIconPackNames = new HashMap<>(); private final Context ctx; private int mContactsShape = DrawableUtils.SHAPE_NONE; private int mShortcutsShape = DrawableUtils.SHAPE_NONE; private IconPackXML mIconPack = null; private SystemIconPack mSystemPack = new SystemIconPack(); private boolean mForceAdaptive; private boolean mForceShape; private boolean mContactPackMask; private boolean mShortcutPackMask; private boolean mShortcutBadgePackMask; private RunnableTask mLoadIconsPackTask = null; public IconsHandler(Context ctx) { super(); this.ctx = ctx; loadAvailableIconsPacks(); onPrefChanged(PreferenceManager.getDefaultSharedPreferences(ctx)); } /** * Set values from preferences */ public void onPrefChanged(SharedPreferences pref) { loadIconsPack(pref.getString("icons-pack", null)); mSystemPack.setAdaptiveShape(getAdaptiveShape(pref, "adaptive-shape")); mForceAdaptive = pref.getBoolean("force-adaptive", true); mForceShape = pref.getBoolean("force-shape", true); mContactPackMask = pref.getBoolean("contact-pack-mask", true); mContactsShape = getAdaptiveShape(pref, "contacts-shape"); mShortcutPackMask = pref.getBoolean("shortcut-pack-mask", true); mShortcutsShape = getAdaptiveShape(pref, "shortcut-shape"); mShortcutBadgePackMask = pref.getBoolean("shortcut-pack-badge-mask", true); } private static int getAdaptiveShape(SharedPreferences pref, String key) { try { return Integer.parseInt(pref.getString(key, null)); } catch (Exception ignored) { } return DrawableUtils.SHAPE_NONE; } /** * Parse icons pack metadata * * @param packageName Android package ID of the package to parse */ private void loadIconsPack(@Nullable String packageName) { // system icons, nothing to do if (packageName == null || packageName.equalsIgnoreCase("default")) { mIconPack = null; return; } // don't reload the icon pack if (mIconPack == null || !mIconPack.getPackPackageName().equals(packageName) || (mLoadIconsPackTask == null && !mIconPack.isLoaded())) { if (mLoadIconsPackTask != null) { mLoadIconsPackTask.cancel(); mLoadIconsPackTask = null; } final IconPackXML iconPack = TBApplication.iconPackCache(ctx).getIconPack(packageName); // timer start Timer timer = Timer.startMilli(); Log.i(TAG, "[start] loading default icon pack: " + packageName); // set the current icon pack mIconPack = iconPack; // start async loading mLoadIconsPackTask = Utilities.runAsync((task) -> { if (task == mLoadIconsPackTask) iconPack.load(ctx.getPackageManager()); if (iconPack.isLoaded()) { PackageInfo packageInfo; try { packageInfo = ctx.getPackageManager().getPackageInfo(iconPack.getPackPackageName(), 0); } catch (NameNotFoundException ignored) { packageInfo = null; } long version = packageInfo != null ? PackageInfoCompat.getLongVersionCode(packageInfo) : 0; TBApplication.dataHandler(ctx).runAfterLoadOver(() -> Utilities.runAsync(() -> cacheAppIcons(version))); } }, (task) -> { // timer end timer.stop(); if (!task.isCancelled() && task == mLoadIconsPackTask) { Log.i(TAG, "[end] loading default icon pack: " + packageName); Log.i("time", timer + " to load icon pack " + packageName); mLoadIconsPackTask = null; TBLauncherActivity activity = TBApplication.launcherActivity(ctx); if (activity != null) { activity.refreshSearchRecords(); activity.queueDockReload(); } } }); } } private void cacheAppIcons(long cacheVersion) { if (mIconPack == null) { Log.e(TAG, "mIconPack==null and we want to cache icons?"); return; } if (!mIconPack.isLoaded()) { Log.e(TAG, "icon pack `" + mIconPack.getPackPackageName() + "` not loaded and we want to cache icons?"); return; } SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(ctx); final long version = prefs.getLong("cached-app-icons-version", -1); final String packName = prefs.getString("cached-app-icons-pack", ""); // check icon pack name and version if (version == cacheVersion && packName.equals(mIconPack.getPackPackageName())) { Log.i(TAG, "cached app icons `" + packName + "` v" + version + " found. Skip cache build."); return; } // we add it to the run queue to make sure we run it synchronized TBApplication.appsHandler(ctx).runWhenLoaded(() -> { Collection appEntries = TBApplication.appsHandler(ctx).getAllApps(); DataHandler dataHandler = TBApplication.dataHandler(ctx); // build the cache for (AppEntry appEntry : appEntries) { Drawable drawable = getDrawableIconForPackage(appEntry.componentName, UserHandleCompat.CURRENT_USER); Bitmap bitmap = getIconBitmap(ctx, drawable); dataHandler.setCachedAppIcon(appEntry.getUserComponentName(), bitmap); } // save icon pack name and version prefs.edit() .putLong("cached-app-icons-version", cacheVersion) .putString("cached-app-icons-pack", mIconPack.getPackPackageName()) .apply(); Log.i(TAG, "cached app icons changed from " + "`" + packName + "` v" + version + " to " + "`" + mIconPack.getPackPackageName() + "` v" + cacheVersion); }); } public void resetCachedAppIcons() { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(ctx); prefs.edit() .putLong("cached-app-icons-version", 0) .putString("cached-app-icons-pack", "") .apply(); } /** * Get or generate icon for an app */ @WorkerThread @NonNull public IconInfo getIconForPackage(ComponentName componentName, UserHandleCompat userHandle) { IconInfo icon = new IconInfo(); // check the icon pack for a resource if (mIconPack != null) { // just checking will make this thread wait for the icon pack to load if (!mIconPack.isLoaded()) { String componentString = componentName.toString(); if (mLoadIconsPackTask == null) { Log.w(TAG, "icon pack `" + mIconPack.getPackPackageName() + "` not loaded, reload"); SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(ctx); loadIconsPack(pref.getString("icons-pack", null)); return icon.setCachedAppIcon(getCachedAppIcon(componentString)); } Drawable cachedIcon = getCachedAppIcon(componentString); if (cachedIcon != null) { Log.i(TAG, "icon pack `" + mIconPack.getPackPackageName() + "` not loaded, cached icon used"); return icon.setCachedAppIcon(cachedIcon); } Log.w(TAG, "icon pack `" + mIconPack.getPackPackageName() + "` not loaded, wait"); try { mLoadIconsPackTask.wait(); } catch (Exception ignored) { } if (!mIconPack.isLoaded()) { Log.e(TAG, "icon pack `" + mIconPack.getPackPackageName() + "` waiting failed to load"); return icon; } } String componentString = componentName.toString(); DrawableInfo info = mIconPack.getComponentDrawable(componentString); if (info != null && info.isDynamic()) icon.setDynamic(); Drawable drawable = mIconPack.getDrawable(info); if (drawable != null) { if (DrawableUtils.isAdaptiveIconDrawable(drawable) || mForceAdaptive) { int shape = mSystemPack.getAdaptiveShape(); drawable = DrawableUtils.applyIconMaskShape(ctx, drawable, shape, true); return icon.setAdaptiveIcon(drawable); } else { //drawable = mIconPack.applyBackgroundAndMask(ctx, drawable, false); return icon.setFitInside(false).setNonAdaptiveIcon(drawable); } } } // if icon pack doesn't have the drawable, use system drawable Drawable systemIcon = mSystemPack.getComponentDrawable(ctx, componentName, userHandle); if (systemIcon == null) return icon; if (mSystemPack.isComponentDynamic(componentName)) icon.setDynamic(); // if the icon pack has a mask, use that instead of the adaptive shape if (mIconPack != null && mIconPack.hasMask() && !mForceShape) { Drawable drawable = mIconPack.applyBackgroundAndMask(ctx, systemIcon, false); return icon.setPackMask().setFitInside(false).setNonAdaptiveIcon(drawable); } boolean fitInside = mForceAdaptive || !mForceShape; Drawable drawable = mSystemPack.applyBackgroundAndMask(ctx, systemIcon, fitInside); return icon.setFitInside(fitInside).setNonAdaptiveIcon(drawable); } /** * Get or generate icon for an app */ @WorkerThread @Nullable public Drawable getDrawableIconForPackage(ComponentName componentName, UserHandleCompat userHandle) { IconInfo icon = getIconForPackage(componentName, userHandle); return icon.getDrawable(); } /** * Get or generate icon to use as a badge for an app */ @WorkerThread public Drawable getDrawableBadgeForPackage(ComponentName componentName, UserHandleCompat userHandle) { // check the icon pack for a resource if (mIconPack != null) { // just checking will make this thread wait for the icon pack to load if (!mIconPack.isLoaded()) return null; String componentString = componentName.toString(); DrawableInfo info = mIconPack.getComponentDrawable(componentString); Drawable drawable = mIconPack.getDrawable(info); if (drawable != null) { if (DrawableUtils.isAdaptiveIconDrawable(drawable) || mForceAdaptive) { int shape = mSystemPack.getAdaptiveShape(); return DrawableUtils.applyIconMaskShape(ctx, drawable, shape, true); } else return mIconPack.applyBackgroundAndMask(ctx, drawable, false); } } // if icon pack doesn't have the drawable, use system drawable Drawable systemIcon = mSystemPack.getComponentDrawable(ctx, componentName, userHandle); if (systemIcon == null) return null; // if the icon pack has a mask, use that instead of the adaptive shape if (mShortcutBadgePackMask && mIconPack != null && mIconPack.hasMask()) return mIconPack.applyBackgroundAndMask(ctx, systemIcon, false); boolean fitInside = mForceAdaptive || !mForceShape; return mSystemPack.applyBackgroundAndMask(ctx, systemIcon, fitInside); } /** * Scan for installed icons packs */ private void loadAvailableIconsPacks() { PackageManager pm = ctx.getPackageManager(); List launcherThemes = pm.queryIntentActivities(new Intent("org.adw.launcher.THEMES"), PackageManager.GET_META_DATA); for (ResolveInfo ri : launcherThemes) { String packageName = ri.activityInfo.packageName; try { ApplicationInfo ai = pm.getApplicationInfo(packageName, PackageManager.GET_META_DATA); String name = pm.getApplicationLabel(ai).toString(); mIconPackNames.put(packageName, name); } catch (NameNotFoundException e) { // shouldn't happen Log.e(TAG, "Unable to find package " + packageName, e); } } } public HashMap getIconPackNames() { return mIconPackNames; } @Nullable public IconPackXML getCustomIconPack() { return mIconPack; } @NonNull public SystemIconPack getSystemIconPack() { return mSystemPack; } @NonNull public IconPack getIconPack() { return mIconPack != null ? mIconPack : mSystemPack; } public Drawable getCustomIcon(StaticEntry staticEntry) { Bitmap bitmap = TBApplication.dataHandler(ctx).getCustomEntryIconById(staticEntry.id); if (bitmap != null) return new BitmapDrawable(ctx.getResources(), bitmap); Log.e(TAG, "Unable to get custom icon for " + staticEntry.id); return null; } public Drawable getCustomIcon(SearchEntry searchEntry) { Bitmap bitmap = TBApplication.dataHandler(ctx).getCustomEntryIconById(searchEntry.id); if (bitmap != null) return new BitmapDrawable(ctx.getResources(), bitmap); Log.e(TAG, "Unable to get custom icon for " + searchEntry.id); return null; } public Drawable getCustomIcon(ShortcutEntry shortcutEntry) { Bitmap bitmap = TBApplication.dataHandler(ctx).getCustomEntryIconById(shortcutEntry.id); if (bitmap != null) return new BitmapDrawable(ctx.getResources(), bitmap); Log.e(TAG, "Unable to get custom icon for " + shortcutEntry.id); return null; } public Drawable getCustomIcon(ContactEntry contactEntry) { Bitmap bitmap = TBApplication.dataHandler(ctx).getCustomEntryIconById(contactEntry.id); if (bitmap != null) return new BitmapDrawable(ctx.getResources(), bitmap); Log.e(TAG, "Unable to get custom icon for " + contactEntry.id); return null; } @Nullable public Drawable getButtonIcon(@NonNull String buttonId) { Bitmap bitmap = TBApplication.dataHandler(ctx).getCustomEntryIconById(buttonId); if (bitmap != null) return new BitmapDrawable(ctx.getResources(), bitmap); return null; } @WorkerThread public Drawable getCachedAppIcon(String componentName) { Bitmap bitmap = TBApplication.dataHandler(ctx).getCachedAppIcon(componentName); if (bitmap != null) return new BitmapDrawable(ctx.getResources(), bitmap); Log.e(TAG, "Unable to get cached app icon for " + componentName); return null; } @WorkerThread public Drawable getCustomIcon(String componentName) { Bitmap bitmap = TBApplication.dataHandler(ctx).getCustomAppIcon(componentName); if (bitmap != null) return new BitmapDrawable(ctx.getResources(), bitmap); Log.e(TAG, "Unable to get custom icon for " + componentName); return null; } private static Bitmap getIconBitmap(Context ctx, Drawable drawable) { if (drawable instanceof TextDrawable) { int size = UISizes.getResultIconSize(ctx); return DrawableUtils.drawableToBitmap(drawable, size, size); } return Utilities.drawableToBitmap(drawable); } public void changeIcon(AppEntry appEntry, Drawable drawable) { Bitmap bitmap = getIconBitmap(ctx, drawable); TBApplication app = TBApplication.getApplication(ctx); AppRecord appRecord = app.getDataHandler().setCustomAppIcon(appEntry.getUserComponentName(), bitmap); app.drawableCache().cacheDrawable(appEntry.getIconCacheId(), null); appEntry.setCustomIcon(appRecord.dbId); app.drawableCache().cacheDrawable(appEntry.getIconCacheId(), drawable); } public void changeIcon(ShortcutEntry shortcutEntry, Drawable drawable) { Bitmap bitmap = getIconBitmap(ctx, drawable); TBApplication app = TBApplication.getApplication(ctx); app.getDataHandler().setCustomStaticEntryIcon(shortcutEntry.id, bitmap); app.drawableCache().cacheDrawable(shortcutEntry.getIconCacheId(), null); shortcutEntry.setCustomIcon(); app.drawableCache().cacheDrawable(shortcutEntry.getIconCacheId(), drawable); } public void changeIcon(StaticEntry staticEntry, Drawable drawable) { Bitmap bitmap = getIconBitmap(ctx, drawable); TBApplication app = TBApplication.getApplication(ctx); app.getDataHandler().setCustomStaticEntryIcon(staticEntry.id, bitmap); app.drawableCache().cacheDrawable(staticEntry.getIconCacheId(), null); staticEntry.setCustomIcon(); app.drawableCache().cacheDrawable(staticEntry.getIconCacheId(), drawable); } public void changeIcon(SearchEntry searchEntry, Drawable drawable) { Bitmap bitmap = getIconBitmap(ctx, drawable); TBApplication app = TBApplication.getApplication(ctx); app.getDataHandler().setCustomStaticEntryIcon(searchEntry.id, bitmap); app.drawableCache().cacheDrawable(searchEntry.getIconCacheId(), null); searchEntry.setCustomIcon(); app.drawableCache().cacheDrawable(searchEntry.getIconCacheId(), drawable); } public void changeIcon(DialContactEntry dialContactEntry, Drawable drawable) { Bitmap bitmap = getIconBitmap(ctx, drawable); TBApplication app = TBApplication.getApplication(ctx); app.getDataHandler().setCustomStaticEntryIcon(DialContactEntry.SCHEME, bitmap); app.drawableCache().cacheDrawable(dialContactEntry.getIconCacheId(), null); dialContactEntry.setCustomIcon(); app.drawableCache().cacheDrawable(dialContactEntry.getIconCacheId(), drawable); } public void changeIcon(@NonNull String buttonId, Drawable drawable) { Bitmap bitmap = getIconBitmap(ctx, drawable); TBApplication app = TBApplication.getApplication(ctx); app.getDataHandler().setCustomButtonIcon(buttonId, bitmap); // we expect calling function to refresh buttons app.drawableCache().cacheDrawable(buttonId, drawable); } public void restoreDefaultIcon(AppEntry appEntry) { TBApplication app = TBApplication.getApplication(ctx); app.getDataHandler().removeCustomAppIcon(appEntry.getUserComponentName()); app.drawableCache().cacheDrawable(appEntry.getIconCacheId(), null); appEntry.clearCustomIcon(); } public void restoreDefaultIcon(ShortcutEntry shortcutEntry) { TBApplication app = TBApplication.getApplication(ctx); app.getDataHandler().removeCustomStaticEntryIcon(shortcutEntry.id); app.drawableCache().cacheDrawable(shortcutEntry.getIconCacheId(), null); shortcutEntry.clearCustomIcon(); } public void restoreDefaultIcon(StaticEntry staticEntry) { TBApplication app = TBApplication.getApplication(ctx); app.getDataHandler().removeCustomStaticEntryIcon(staticEntry.id); app.drawableCache().cacheDrawable(staticEntry.getIconCacheId(), null); staticEntry.clearCustomIcon(); } public void restoreDefaultIcon(SearchEntry searchEntry) { TBApplication app = TBApplication.getApplication(ctx); app.getDataHandler().removeCustomStaticEntryIcon(searchEntry.id); app.drawableCache().cacheDrawable(searchEntry.getIconCacheId(), null); searchEntry.clearCustomIcon(); } public void restoreDefaultIcon(DialContactEntry dialContactEntry) { TBApplication app = TBApplication.getApplication(ctx); app.getDataHandler().removeCustomStaticEntryIcon(DialContactEntry.SCHEME); app.drawableCache().cacheDrawable(dialContactEntry.getIconCacheId(), null); dialContactEntry.clearCustomIcon(); } public void restoreDefaultIcon(@NonNull String buttonId) { TBApplication app = TBApplication.getApplication(ctx); app.getDataHandler().removeCustomButtonIcon(buttonId); app.drawableCache().cacheDrawable(buttonId, null); } public Drawable applyContactMask(@NonNull Context ctx, @NonNull Drawable drawable) { if (!mContactPackMask) return DrawableUtils.applyIconMaskShape(ctx, drawable, mContactsShape, false); if (mIconPack != null && mIconPack.hasMask()) return mIconPack.applyBackgroundAndMask(ctx, drawable, false); // if pack has no mask, make it a circle int size = ctx.getResources().getDimensionPixelSize(R.dimen.icon_size); Bitmap b = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888); Canvas c = new Canvas(b); Path path = new Path(); int h = size / 2; path.addCircle(h, h, h, Path.Direction.CCW); c.clipPath(path); drawable.setBounds(0, 0, c.getWidth(), c.getHeight()); drawable.draw(c); return new BitmapDrawable(ctx.getResources(), b); } public Drawable applyShortcutMask(@NonNull Context ctx, Bitmap bitmap) { Drawable drawable = new BitmapDrawable(ctx.getResources(), bitmap); if (!mShortcutPackMask) return DrawableUtils.applyIconMaskShape(ctx, drawable, mShortcutsShape, true); if (mIconPack != null && mIconPack.hasMask()) return mIconPack.applyBackgroundAndMask(ctx, drawable, false); return drawable; } @NonNull public Drawable getDefaultActivityIcon(Context context) { Resources resources = context.getResources(); int iconId = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) ? android.R.drawable.sym_def_app_icon : android.R.mipmap.sym_def_app_icon; Drawable d = null; try { d = ResourcesCompat.getDrawable(resources, iconId, context.getTheme()); } catch (Resources.NotFoundException ignored) { } return (d == null) ? new ColorDrawable(UIColors.getDefaultColor(context)) : d; } public static class IconInfo { private Drawable drawable = null; private boolean isDynamic = false; private Boolean fitInside = null; public void setDynamic() { isDynamic = true; } public boolean isDynamic() { return isDynamic; } public IconInfo setCachedAppIcon(Drawable cachedAppIcon) { drawable = cachedAppIcon; return this; } public Drawable getDrawable() { return drawable; } public IconInfo setAdaptiveIcon(Drawable drawable) { this.drawable = drawable; return this; } public IconInfo setNonAdaptiveIcon(Drawable drawable) { this.drawable = drawable; return this; } public IconInfo setPackMask() { return this; } public IconInfo setFitInside(boolean fitInside) { this.fitInside = fitInside; return this; } public Boolean getFitInside() { return fitInside; } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/handler/TagsHandler.java ================================================ package rocks.tbog.tblauncher.handler; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.content.res.Resources; import android.os.Build; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.preference.PreferenceManager; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Set; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.dataprovider.TagsProvider; import rocks.tbog.tblauncher.db.DBHelper; import rocks.tbog.tblauncher.entry.AppEntry; import rocks.tbog.tblauncher.entry.EntryItem; import rocks.tbog.tblauncher.entry.EntryWithTags; import rocks.tbog.tblauncher.entry.TagEntry; import rocks.tbog.tblauncher.ui.ListPopup; import rocks.tbog.tblauncher.ui.TagsMenuUtils; import rocks.tbog.tblauncher.utils.PrefOrderedListHelper; import rocks.tbog.tblauncher.utils.Timer; import rocks.tbog.tblauncher.utils.UserHandleCompat; import rocks.tbog.tblauncher.utils.Utilities; public class TagsHandler { private static final String TAG = TagsHandler.class.getSimpleName(); private final TBApplication mApplication; // HashMap with EntryItem id as key and an ArrayList of tags for each private final HashMap> mTagsCache = new HashMap<>(); private boolean mIsLoaded = false; private final ArrayDeque mAfterLoadedTasks = new ArrayDeque<>(2); public TagsHandler(TBApplication application) { mApplication = application; loadFromDB(false); } @Nullable public void loadFromDB(boolean wait) { Log.d(TAG, "loadFromDB(wait= " + wait + " )"); synchronized (this) { mIsLoaded = false; } final Timer timer = Timer.startMilli(); final HashMap> tags = new HashMap<>(); final Runnable load = () -> { Map> dbTags = DBHelper.loadTags(getContext()); tags.clear(); tags.putAll(dbTags); }; final Runnable apply = () -> { if (tags.isEmpty()) { mTagsCache.clear(); addDefaultAliases(); mTagsCache.put(".", Collections.singletonList("")); DBHelper.addTags(getContext(), mTagsCache); tags.putAll(mTagsCache); } synchronized (TagsHandler.this) { mTagsCache.clear(); mTagsCache.putAll(tags); mIsLoaded = true; timer.stop(); Log.d("time", "Time to load all tags: " + timer); // run and remove tasks Runnable task; while (null != (task = mAfterLoadedTasks.poll())) task.run(); } }; if (wait) { load.run(); apply.run(); } else { Utilities.runAsync( (t) -> load.run(), (t) -> apply.run()); } } public void runWhenLoaded(@NonNull Runnable task) { synchronized (this) { if (mIsLoaded) task.run(); else mAfterLoadedTasks.add(task); } } private Context getContext() { return mApplication; } public void addTag(EntryItem entry, String tag) { // add to db DBHelper.addTag(getContext(), tag, entry); // add to cache List tags = mTagsCache.get(entry.id); if (tags == null) mTagsCache.put(entry.id, tags = new ArrayList<>()); tags.add(tag); } private boolean removeTag(String entryId, String tag) { boolean changesMade = false; // remove from DB if (DBHelper.removeTag(getContext(), tag, entryId) > 0) changesMade = true; // remove from cache List tags = mTagsCache.get(entryId); if (tags != null) { tags.remove(tag); changesMade = true; } return changesMade; } public boolean removeTag(String tag) { List entries = getEntries(tag); for (EntryWithTags entry : entries) { if (removeTag(entry.id, tag)) { entry.setTags(getTags(entry.id)); return true; } } return false; } @NonNull public List getTags(String entryId) { List tags = mTagsCache.get(entryId); if (tags == null) { return Collections.emptyList(); } return Collections.unmodifiableList(tags); } /** * Get tags currently used * * @return a set of tags */ @NonNull public Set getValidTags() { Set tags = new HashSet<>(); DataHandler dataHandler = TBApplication.dataHandler(getContext()); for (Map.Entry> entry : mTagsCache.entrySet()) { EntryItem entryItem = dataHandler.getPojo(entry.getKey()); if (entryItem != null) tags.addAll(entry.getValue()); } tags.remove(""); return tags; } /** * Filter out entry ids not found from each set. Remove unused tags from the map. * * @param context used for getting DataHandler to check if entryIds are found. * @param tags map with tag names as keys. */ public static void validateTags(@NonNull Context context, Map> tags) { tags.remove(""); DataHandler dataHandler = TBApplication.dataHandler(context); for (Iterator>> iteratorTagsMap = tags.entrySet().iterator(); iteratorTagsMap.hasNext(); ) { Map.Entry> tagsMapEntry = iteratorTagsMap.next(); String tagName = tagsMapEntry.getKey(); Set entryIdSet = tagsMapEntry.getValue(); for (Iterator iteratorEntryId = entryIdSet.iterator(); iteratorEntryId.hasNext(); ) { String entryId = iteratorEntryId.next(); EntryItem entryItem = dataHandler.getPojo(entryId); if (entryItem == null) iteratorEntryId.remove(); } if (tagsMapEntry.getValue().isEmpty()) { Log.i(TAG, "Dropped tag `" + tagName + "`"); iteratorTagsMap.remove(); } } } /** * Get all tags from DB, even if not used * * @return a set of tags */ @NonNull public Set getAllTags() { Set allTags = new HashSet<>(); for (List tags : mTagsCache.values()) { allTags.addAll(tags); } allTags.remove(""); return allTags; } @NonNull public List getValidEntryIds(String tagName) { ArrayList ids = new ArrayList<>(); DataHandler dataHandler = TBApplication.dataHandler(getContext()); for (Map.Entry> entry : mTagsCache.entrySet()) { if (entry.getValue().contains(tagName)) { EntryItem entryItem = dataHandler.getPojo(entry.getKey()); if (entryItem != null) ids.add(entryItem.id); } } return ids; } @NonNull public List getAllEntryIds(String tagName) { ArrayList ids = new ArrayList<>(); for (Map.Entry> entry : mTagsCache.entrySet()) { if (entry.getValue().contains(tagName)) ids.add(entry.getKey()); } return ids; } @NonNull public List getEntries(String tagName) { ArrayList entries = new ArrayList<>(); DataHandler dataHandler = TBApplication.dataHandler(getContext()); for (Map.Entry> mapEntry : mTagsCache.entrySet()) { if (mapEntry.getValue().contains(tagName)) { EntryItem entryItem = dataHandler.getPojo(mapEntry.getKey()); if (entryItem instanceof EntryWithTags) entries.add((EntryWithTags) entryItem); } } return entries; } @NonNull public ListPopup getTagsMenu(Context ctx) { SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(ctx); ArrayList tagList; List tagOrder = PrefOrderedListHelper.getOrderedList(pref, "tags-menu-list", "tags-menu-order"); if (tagOrder.isEmpty()) { tagList = new ArrayList<>(5); TagsHandler tagsHandler = TBApplication.tagsHandler(ctx); Set validTags = tagsHandler.getValidTags(); for (String tagName : validTags) { if (tagList.size() >= 5) break; tagList.add(tagName); } } else { tagList = new ArrayList<>(tagOrder.size()); for (String orderValue : tagOrder) tagList.add(PrefOrderedListHelper.getOrderedValueName(orderValue)); } return TagsMenuUtils.createTagsMenu(ctx, tagList); } @NonNull public Collection getAllEntryIds() { return Collections.unmodifiableSet(mTagsCache.keySet()); } private void addDefaultAliases() { Context context = getContext(); final PackageManager pm = context.getPackageManager(); final Resources res = context.getResources(); // keep all changes here and apply them after we do all the checks Map> pendingTags = new HashMap<>(); String phoneApp = getApp(pm, Intent.ACTION_DIAL); if (phoneApp != null) { String phoneAlias = res.getString(R.string.alias_phone); addAliasesToEntry(phoneAlias, phoneApp, pendingTags); } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH_MR1) { String contactApp = getAppByCategory(pm, Intent.CATEGORY_APP_CONTACTS); if (contactApp != null) { String contactAlias = res.getString(R.string.alias_contacts); addAliasesToEntry(contactAlias, contactApp, pendingTags); } String browserApp = getAppByCategory(pm, Intent.CATEGORY_APP_BROWSER); if (browserApp != null) { String webAlias = res.getString(R.string.alias_web); addAliasesToEntry(webAlias, browserApp, pendingTags); } String mailApp = getAppByCategory(pm, Intent.CATEGORY_APP_EMAIL); if (mailApp != null) { String mailAlias = res.getString(R.string.alias_mail); addAliasesToEntry(mailAlias, mailApp, pendingTags); } String marketApp = getAppByCategory(pm, Intent.CATEGORY_APP_MARKET); if (marketApp != null) { String marketAlias = res.getString(R.string.alias_market); addAliasesToEntry(marketAlias, marketApp, pendingTags); } String messagingApp = getAppByCategory(pm, Intent.CATEGORY_APP_MESSAGING); if (messagingApp != null) { String messagingAlias = res.getString(R.string.alias_messaging); addAliasesToEntry(messagingAlias, messagingApp, pendingTags); } String clockApp = getClockApp(pm); if (clockApp != null) { String clockAlias = res.getString(R.string.alias_clock); addAliasesToEntry(clockAlias, clockApp, pendingTags); } } // apply all pending changes in the cache for (Map.Entry> entry : pendingTags.entrySet()) { String entryId = entry.getKey(); List tags = mTagsCache.get(entryId); if (tags == null) mTagsCache.put(entryId, tags = new ArrayList<>()); tags.addAll(entry.getValue()); } } private void addAliasesToEntry(String aliases, String entryId, Map> pendingTags) { //add aliases only if they haven't overridden by the user (not in db) if (!mTagsCache.containsKey(entryId)) { //aliases.replace(",", " ") String[] arr = aliases.split(","); List tags = pendingTags.get(entryId); if (tags == null) pendingTags.put(entryId, tags = new ArrayList<>()); tags.addAll(Arrays.asList(arr)); } } private String getApp(PackageManager pm, String action) { Intent lookingFor = new Intent(action, null); return getApp(pm, lookingFor); } private String getAppByCategory(PackageManager pm, String category) { Intent lookingFor = new Intent(Intent.ACTION_MAIN, null); lookingFor.addCategory(category); return getApp(pm, lookingFor); } private String getApp(PackageManager pm, Intent lookingFor) { List list = pm.queryIntentActivities(lookingFor, 0); if (list.size() == 0) { return null; } else { String packageName = list.get(0).activityInfo.applicationInfo.packageName; String className = list.get(0).activityInfo.name; UserHandleCompat user = UserHandleCompat.CURRENT_USER; return AppEntry.generateAppId(packageName, className, user); } } private String getClockApp(PackageManager pm) { Intent alarmClockIntent = new Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER); // Known clock implementations // See http://stackoverflow.com/questions/3590955/intent-to-launch-the-clock-application-on-android String[][] clockImpls = { // Nexus {"com.android.deskclock", "com.android.deskclock.DeskClock"}, // Samsung {"com.sec.android.app.clockpackage", "com.sec.android.app.clockpackage.ClockPackage"}, // HTC {"com.htc.android.worldclock", "com.htc.android.worldclock.WorldClockTabControl"}, // Standard Android {"com.android.deskclock", "com.android.deskclock.AlarmClock"}, // New Android versions {"com.google.android.deskclock", "com.android.deskclock.AlarmClock"}, // Froyo {"com.google.android.deskclock", "com.android.deskclock.DeskClock"}, // Motorola {"com.motorola.blur.alarmclock", "com.motorola.blur.alarmclock.AlarmClock"}, // Sony {"com.sonyericsson.organizer", "com.sonyericsson.organizer.Organizer_WorldClock"}, // ASUS Tablets {"com.asus.deskclock", "com.asus.deskclock.DeskClock"} }; UserHandleCompat user = UserHandleCompat.CURRENT_USER; for (String[] clockImpl : clockImpls) { String packageName = clockImpl[0]; String className = clockImpl[1]; try { ComponentName cn = new ComponentName(packageName, className); pm.getActivityInfo(cn, PackageManager.GET_META_DATA); alarmClockIntent.setComponent(cn); return AppEntry.generateAppId(cn, user); } catch (PackageManager.NameNotFoundException ignored) { // Try next suggestion, this one does not exists on the phone. } } return null; } public void setTags(EntryWithTags entry, Set tags) { if (tags == null || tags.isEmpty()) { ArrayList tagsToRemove = new ArrayList<>(getTags(entry.id)); for (String tag : tagsToRemove) removeTag(entry.id, tag); } else { List oldTags = DBHelper.loadTags(getContext(), entry.id); // tags that need to be removed { ArrayList tagsToRemove = new ArrayList<>(); for (String tag : oldTags) if (!tags.contains(tag)) tagsToRemove.add(tag); for (String tag : tagsToRemove) removeTag(entry.id, tag); } // add new tags for (String tag : tags) { if (oldTags.contains(tag)) continue; addTag(entry, tag); } } entry.setTags(getTags(entry.id)); } public boolean renameTag(String tagName, String newName) { // rename tags from mTagsCache DataHandler dataHandler = mApplication.getDataHandler(); for (Map.Entry> entry : mTagsCache.entrySet()) { int pos = entry.getValue().indexOf(tagName); if (pos >= 0) { entry.getValue().set(pos, newName); EntryItem entryItem = dataHandler.getPojo(entry.getKey()); if (entryItem instanceof EntryWithTags) ((EntryWithTags) entryItem).setTags(entry.getValue()); } } // rename tags from tags menu SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(getContext()); SharedPreferences.Editor editor = pref.edit(); HashSet tagsMenuSet = new HashSet<>(pref.getStringSet("tags-menu-list", Collections.emptySet())); if (tagsMenuSet.remove(tagName)) { tagsMenuSet.add(newName); editor.putStringSet("tags-menu-list", tagsMenuSet); } int order = -1; HashSet tagsMenuOrderSet = new HashSet<>(pref.getStringSet("tags-menu-order", Collections.emptySet())); for (Iterator iterator = tagsMenuOrderSet.iterator(); iterator.hasNext(); ) { String orderedValue = iterator.next(); String value = PrefOrderedListHelper.getOrderedValueName(orderedValue); if (value.equals(tagName)) { order = PrefOrderedListHelper.getOrderedValueIndex(orderedValue); iterator.remove(); break; } } if (order >= 0) { tagsMenuOrderSet.add(PrefOrderedListHelper.makeOrderedValue(newName, order)); editor.putStringSet("tags-menu-order", tagsMenuOrderSet); } editor.apply(); // rename tag from favorites TagEntry tagEntry = null; TagEntry newEntry = null; TagsProvider tagsProvider = dataHandler.getTagsProvider(); if (tagsProvider != null) { tagEntry = tagsProvider.getTagEntry(tagName); if (tagEntry.hasCustomIcon()) { newEntry = tagsProvider.getTagEntry(newName); } } // rename tags from database return DBHelper.renameTag(getContext(), tagName, newName, tagEntry, newEntry) > 0; } /** * Remove all tags from the Entry. * We keep the DB as is, maybe later we'll reinstall the app. * * @param entryId what Entry */ public void removeAllTags(String entryId) { // remove from cache List tags = mTagsCache.remove(entryId); // remove from DB // if (tags != null) { // for (String tag : tags) // DBHelper.removeTag(getContext(), tag, entryId); // } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/icons/CalendarDrawable.java ================================================ package rocks.tbog.tblauncher.icons; import android.annotation.SuppressLint; import android.content.res.Resources; import android.graphics.drawable.Drawable; import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.content.res.ResourcesCompat; import java.util.Arrays; import java.util.Calendar; public class CalendarDrawable extends DrawableInfo { private final int[] drawableForDay; private final boolean[] drawableIdCached; protected CalendarDrawable(@NonNull String drawableName) { super(drawableName); drawableForDay = new int[31]; drawableIdCached = new boolean[31]; Arrays.fill(drawableIdCached, false); } @SuppressLint("DiscouragedApi") @Override @DrawableRes public int getDrawableResId(@NonNull IconPackXML iconPack) { int dayOfMonthIdx = Calendar.getInstance().get(Calendar.DAY_OF_MONTH) - 1; return getDayDrawableId(iconPack, dayOfMonthIdx); } @SuppressLint("DiscouragedApi") @DrawableRes private int getDayDrawableId(@NonNull IconPackXML iconPack, int dayOfMonthIdx) { Resources res = iconPack.getResources(); if (res == null) return drawableForDay[dayOfMonthIdx]; if (!drawableIdCached[dayOfMonthIdx]) { String drawableName = getDrawableName() + (1 + dayOfMonthIdx); drawableForDay[dayOfMonthIdx] = res.getIdentifier(drawableName, "drawable", iconPack.getPackPackageName()); drawableIdCached[dayOfMonthIdx] = true; } return drawableForDay[dayOfMonthIdx]; } @Override public boolean isDynamic() { return true; } @Nullable @Override public Drawable getDrawable(@NonNull IconPackXML iconPack, @Nullable Resources.Theme theme) { Resources res = iconPack.getResources(); if (res == null) return null; int dayOfMonthIdx = Calendar.getInstance().get(Calendar.DAY_OF_MONTH) - 1; int drawableId = getDayDrawableId(iconPack, dayOfMonthIdx); try { return ResourcesCompat.getDrawable(res, drawableId, theme); } catch (Resources.NotFoundException ignored) { return null; } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/icons/DrawableInfo.java ================================================ package rocks.tbog.tblauncher.icons; import android.content.res.Resources; import android.graphics.drawable.Drawable; import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import java.util.Objects; public abstract class DrawableInfo { @NonNull private final String drawableName; protected DrawableInfo(@NonNull String drawableName) { this.drawableName = drawableName; } @NonNull public String getDrawableName() { return drawableName; } public boolean isDynamic() { return false; } @DrawableRes public abstract int getDrawableResId(@NonNull IconPackXML iconPack); @Nullable public abstract Drawable getDrawable(@NonNull IconPackXML iconPack, @Nullable Resources.Theme theme); @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof DrawableInfo)) return false; DrawableInfo that = (DrawableInfo) o; return drawableName.equals(that.drawableName); } @Override public int hashCode() { return Objects.hash(drawableName); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/icons/IconPack.java ================================================ package rocks.tbog.tblauncher.icons; import android.content.ComponentName; import android.content.Context; import android.content.pm.PackageManager; import android.graphics.drawable.Drawable; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import java.util.Collection; import rocks.tbog.tblauncher.utils.UserHandleCompat; public interface IconPack { @NonNull String getPackPackageName(); void load(PackageManager packageManager); boolean isLoaded(); @Nullable DrawableInfo getComponentDrawable(@NonNull Context ctx, @NonNull ComponentName componentName, @NonNull UserHandleCompat userHandle); boolean isComponentDynamic(@NonNull ComponentName componentName); @NonNull Drawable applyBackgroundAndMask(@NonNull Context ctx, @NonNull Drawable defaultBitmap, boolean fitInside); @NonNull Collection getDrawableList(); @Nullable Drawable getDrawable(@Nullable DrawableInfo drawableInfo); } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/icons/IconPackCache.java ================================================ package rocks.tbog.tblauncher.icons; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.collection.LruCache; import java.lang.ref.SoftReference; import java.util.HashMap; import rocks.tbog.tblauncher.TBApplication; public class IconPackCache { private final SoftReferenceCache mCache = new SoftReferenceCache<>(); @NonNull public IconPackXML getIconPack(String packageName) { IconPackXML pack = mCache.get(packageName); if (pack == null) { pack = new IconPackXML(packageName); mCache.put(packageName, pack); } return pack; } public void clearCache(TBApplication app) { mCache.evictAll(); IconPackXML customIconPack = app.iconsHandler().getCustomIconPack(); if (customIconPack != null) mCache.put(customIconPack.getPackPackageName(), customIconPack); } /** * SoftReferenceCache * * @param The type of the key's. * @param The type of the value's. */ static class SoftReferenceCache { private final HashMap> mCache = new HashMap<>(); /** * Put a new item in the cache. This item can be gone after a GC run. * * @param key The key of the value. * @param value The value to store. */ public void put(K key, V value) { mCache.put(key, new SoftReference<>(value)); } /** * Retrieve a value from the cache (if available). * * @param key The key to look for. * @return The value if it's found. Return null if the key-value pair is not stored yet or the GC has removed the value from memory. */ @Nullable public V get(K key) { V value = null; SoftReference reference = mCache.get(key); if (reference != null) { value = reference.get(); } if (value == null) mCache.remove(key); return value; } public void evictAll() { mCache.clear(); } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/icons/IconPackXML.java ================================================ package rocks.tbog.tblauncher.icons; import android.annotation.SuppressLint; import android.content.ComponentName; import android.content.Context; import android.content.pm.PackageManager; import android.content.res.Resources; import android.content.res.XmlResourceParser; import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.Matrix; import android.graphics.Paint; import android.graphics.PorterDuff; import android.graphics.PorterDuffXfermode; import android.graphics.drawable.AdaptiveIconDrawable; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; import android.os.Build; import android.text.TextUtils; import android.util.ArrayMap; import android.util.Log; import android.util.Pair; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.collection.ArraySet; import org.xmlpull.v1.XmlPullParser; import org.xmlpull.v1.XmlPullParserException; import org.xmlpull.v1.XmlPullParserFactory; import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.LinkedHashSet; import java.util.Map; import java.util.Random; import rocks.tbog.tblauncher.drawable.DrawableUtils; import rocks.tbog.tblauncher.utils.UserHandleCompat; import rocks.tbog.tblauncher.utils.Utilities; public class IconPackXML implements IconPack { private final static String TAG = IconPackXML.class.getSimpleName(); private final Map> drawablesByComponent = new ArrayMap<>(0); private final LinkedHashSet drawableList = new LinkedHashSet<>(0); // instance of a resource object of an icon pack private Resources packResources; // package name of the icons pack @NonNull private final String iconPackPackageName; // list of back images available on an icons pack private final ArrayList backImages = new ArrayList<>(); // bitmap mask of an icons pack private DrawableInfo maskImage = null; // front image of an icons pack private DrawableInfo frontImage = null; // scale factor of an icons pack private float factor = 1.0f; private final Random random = new Random(); private final Matrix matScale = new Matrix(); private boolean loaded; public IconPackXML(@NonNull String packageName) { iconPackPackageName = packageName; loaded = false; } @Override public synchronized boolean isLoaded() { return loaded; } @Override public synchronized void load(PackageManager packageManager) { if (loaded) return; try { packResources = packageManager.getResourcesForApplication(iconPackPackageName); } catch (PackageManager.NameNotFoundException e) { Log.e(TAG, "get icon pack resources" + iconPackPackageName, e); } parseAppFilterXML(); loaded = true; } public synchronized void loadDrawables(PackageManager packageManager) { if (!loaded) load(packageManager); try { packResources = packageManager.getResourcesForApplication(iconPackPackageName); } catch (PackageManager.NameNotFoundException e) { Log.e(TAG, "get icon pack resources" + iconPackPackageName, e); } parseDrawableXML(); } public boolean hasMask() { return maskImage != null; } @NonNull @Override public Collection getDrawableList() { return Collections.unmodifiableCollection(drawableList); } @Override @Nullable public DrawableInfo getComponentDrawable(@NonNull Context ctx, @NonNull ComponentName componentName, @NonNull UserHandleCompat userHandle) { return getComponentDrawable(componentName.toString()); } @Override public boolean isComponentDynamic(@NonNull ComponentName componentName) { return getCalendarDrawable(componentName.toString()) != null; } @Nullable private CalendarDrawable getCalendarDrawable(@Nullable String componentName) { ArraySet drawables = drawablesByComponent.get(componentName); if (drawables != null) for (DrawableInfo info : drawables) if (info instanceof CalendarDrawable) return (CalendarDrawable) info; return null; } @Nullable public DrawableInfo getComponentDrawable(String componentName) { CalendarDrawable calendar = getCalendarDrawable(componentName); if (calendar != null) return calendar; ArraySet drawables = drawablesByComponent.get(componentName); return drawables != null ? drawables.valueAt(0) : null; } @Nullable @Override public Drawable getDrawable(@Nullable DrawableInfo drawableInfo) { if (drawableInfo != null) { return drawableInfo.getDrawable(this, null); } return null; } @NonNull private Bitmap getBitmap(@NonNull DrawableInfo drawableInfo) { Drawable drawable = getDrawable(drawableInfo); return Utilities.drawableToBitmap(drawable); } @NonNull @Override public Drawable applyBackgroundAndMask(@NonNull Context ctx, @NonNull Drawable systemIcon, boolean fitInside) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (systemIcon instanceof AdaptiveIconDrawable) systemIcon = DrawableUtils.applyIconMaskShape(ctx, systemIcon, DrawableUtils.SHAPE_SQUARE, fitInside); } if (systemIcon instanceof BitmapDrawable) { return generateBitmap((BitmapDrawable) systemIcon); } Bitmap bitmap; if (systemIcon.getIntrinsicWidth() <= 0 || systemIcon.getIntrinsicHeight() <= 0) bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888); // Single color bitmap will be created of 1x1 pixel else bitmap = Bitmap.createBitmap(systemIcon.getIntrinsicWidth(), systemIcon.getIntrinsicHeight(), Bitmap.Config.ARGB_8888); systemIcon.setBounds(0, 0, bitmap.getWidth(), bitmap.getHeight()); systemIcon.draw(new Canvas(bitmap)); return generateBitmap(new BitmapDrawable(ctx.getResources(), bitmap)); } @NonNull private BitmapDrawable generateBitmap(@NonNull BitmapDrawable defaultBitmap) { // if no support images in the icon pack return the bitmap itself if (backImages.size() == 0) { return defaultBitmap; } // select a random background image int backImageInd = random.nextInt(backImages.size()); Bitmap backImage = getBitmap(backImages.get(backImageInd)); int w = backImage.getWidth(); int h = backImage.getHeight(); // create a bitmap for the result Bitmap result = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888); Canvas canvas = new Canvas(result); canvas.setDensity(Bitmap.DENSITY_NONE); // draw the background first canvas.drawBitmap(backImage, 0, 0, null); // scale original icon Bitmap scaledBitmap = Bitmap.createScaledBitmap(defaultBitmap.getBitmap(), (int) (w * factor), (int) (h * factor), false); scaledBitmap.setDensity(Bitmap.DENSITY_NONE); int offsetLeft = (w - scaledBitmap.getWidth()) / 2; int offsetTop = (h - scaledBitmap.getHeight()) / 2; if (maskImage != null) { // draw the scaled bitmap with mask Bitmap mask = getBitmap(maskImage); // paint the bitmap with mask into the result Paint paint = new Paint(Paint.FILTER_BITMAP_FLAG | Paint.ANTI_ALIAS_FLAG); paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_OUT)); canvas.drawBitmap(scaledBitmap, offsetLeft, offsetTop, null); matScale.setScale(w / (float) mask.getWidth(), h / (float) mask.getHeight()); canvas.drawBitmap(mask, matScale, paint); paint.setXfermode(null); } else { // draw the scaled bitmap without mask canvas.drawBitmap(scaledBitmap, offsetLeft, offsetTop, null); } // paint the front if (frontImage != null) { canvas.drawBitmap(getBitmap(frontImage), 0, 0, null); } return new BitmapDrawable(packResources, result); } @SuppressLint("DiscouragedApi") private void parseDrawableXML() { XmlResourceParser xpp = null; // search drawable.xml into icons pack apk resource folder @SuppressLint("DiscouragedApi") int drawableXmlId = packResources.getIdentifier("drawable", "xml", iconPackPackageName); if (drawableXmlId > 0) { xpp = packResources.getXml(drawableXmlId); } if (xpp == null) return; try { int eventType = xpp.getEventType(); while (eventType != XmlPullParser.END_DOCUMENT) { if (eventType == XmlPullParser.START_TAG) { int attrCount = xpp.getAttributeCount(); switch (xpp.getName()) { case "item": for (int attrIdx = 0; attrIdx < attrCount; attrIdx += 1) { String attrName = xpp.getAttributeName(attrIdx); if (attrName.equals("drawable")) { String drawableName = xpp.getAttributeValue(attrIdx); if (!TextUtils.isEmpty(drawableName)) { drawableList.add(new LazyLoadDrawable(drawableName)); } } } break; case "category": break; default: Log.d(TAG, "ignored " + xpp.getName()); } } eventType = xpp.next(); } } catch (XmlPullParserException | IOException e) { Log.e(TAG, "parsing drawable.xml", e); } finally { xpp.close(); } } @NonNull private Pair findAppFilterXml() throws XmlPullParserException { XmlPullParser parser = null; InputStream inputStream = null; // search appfilter.xml in icon pack's apk resource folder for xml files try { inputStream = packResources.getAssets().open("appfilter.xml"); XmlPullParserFactory factory = XmlPullParserFactory.newInstance(); parser = factory.newPullParser(); parser.setInput(inputStream, "UTF-8"); } catch (Exception e) { if (inputStream != null) { try { inputStream.close(); } catch (IOException ignored) { } } inputStream = null; // Catch any exception since we want to fall back to parsing the xml/resource in all cases @SuppressLint("DiscouragedApi") int appFilterIdXml = packResources.getIdentifier("appfilter", "xml", iconPackPackageName); if (appFilterIdXml > 0) { parser = packResources.getXml(appFilterIdXml); } } if (parser == null) { // search appfilter.xml in icon pack's apk resource folder for raw files (supporting icon pack studio) @SuppressLint("DiscouragedApi") int appFilterIdRaw = packResources.getIdentifier("appfilter", "raw", iconPackPackageName); if (appFilterIdRaw > 0) { inputStream = packResources.openRawResource(appFilterIdRaw); XmlPullParserFactory factory = XmlPullParserFactory.newInstance(); parser = factory.newPullParser(); parser.setInput(inputStream, "UTF-8"); } } return Pair.create(parser, inputStream); } @SuppressLint("DiscouragedApi") private void parseAppFilterXML() { if (packResources == null) return; XmlPullParser xpp = null; InputStream inputStream = null; Map calendarDrawablesByPrefix = new ArrayMap<>(0); try { var appFilterXml = findAppFilterXml(); xpp = appFilterXml.first; inputStream = appFilterXml.second; if (xpp != null) { int eventType = xpp.getEventType(); while (eventType != XmlPullParser.END_DOCUMENT) { if (eventType == XmlPullParser.START_TAG) { String componentName = null; String drawableName = null; int drawableId; switch (xpp.getName()) { //parse xml tags used as background of generated icons case "iconback": for (int i = 0; i < xpp.getAttributeCount(); i++) { if (xpp.getAttributeName(i).startsWith("img")) { drawableName = xpp.getAttributeValue(i); drawableId = packResources.getIdentifier(drawableName, "drawable", iconPackPackageName); if (drawableId != 0) backImages.add(new SimpleDrawable(drawableName, drawableId)); } } break; //parse xml tags used as mask of generated icons case "iconmask": if (xpp.getAttributeCount() > 0 && xpp.getAttributeName(0).equals("img1")) { drawableName = xpp.getAttributeValue(0); drawableId = packResources.getIdentifier(drawableName, "drawable", iconPackPackageName); if (drawableId != 0) maskImage = new SimpleDrawable(drawableName, drawableId); } break; //parse xml tags used as front image of generated icons case "iconupon": if (xpp.getAttributeCount() > 0 && xpp.getAttributeName(0).equals("img1")) { drawableName = xpp.getAttributeValue(0); drawableId = packResources.getIdentifier(drawableName, "drawable", iconPackPackageName); if (drawableId != 0) frontImage = new SimpleDrawable(drawableName, drawableId); } break; //parse xml tags used as scale factor of original bitmap icon case "scale": if (xpp.getAttributeCount() > 0 && xpp.getAttributeName(0).equals("factor")) factor = Float.parseFloat(xpp.getAttributeValue(0)); break; //parse xml tags for custom icons case "item": for (int i = 0; i < xpp.getAttributeCount(); i++) { if (xpp.getAttributeName(i).equals("component")) { componentName = xpp.getAttributeValue(i); } else if (xpp.getAttributeName(i).equals("drawable")) { drawableName = xpp.getAttributeValue(i); } } if (!TextUtils.isEmpty(drawableName) && !TextUtils.isEmpty(componentName)) { DrawableInfo drawableInfo = new LazyLoadDrawable(drawableName); drawableList.add(drawableInfo); ArraySet infoSet = drawablesByComponent.get(componentName); if (infoSet == null) drawablesByComponent.put(componentName, infoSet = new ArraySet<>(1)); infoSet.add(drawableInfo); } break; case "calendar": String prefix = null; for (int i = 0; i < xpp.getAttributeCount(); i++) { if (xpp.getAttributeName(i).equals("component")) { componentName = xpp.getAttributeValue(i); } else if (xpp.getAttributeName(i).equals("prefix")) { prefix = xpp.getAttributeValue(i); } } if (!TextUtils.isEmpty(prefix) && !TextUtils.isEmpty(componentName)) { CalendarDrawable calendarDrawable = calendarDrawablesByPrefix.get(prefix); if (calendarDrawable == null) { calendarDrawable = new CalendarDrawable(prefix); calendarDrawablesByPrefix.put(prefix, calendarDrawable); } ArraySet infoSet = drawablesByComponent.get(componentName); if (infoSet == null) drawablesByComponent.put(componentName, infoSet = new ArraySet<>(1)); infoSet.add(calendarDrawable); } break; default: // ignore break; } } eventType = xpp.next(); } } } catch (Exception e) { Log.e(TAG, "Error parsing appfilter.xml ", e); } finally { if (xpp instanceof XmlResourceParser) { ((XmlResourceParser) xpp).close(); } else if (inputStream != null) { try { inputStream.close(); } catch (IOException e) { Log.e(TAG, "Error closing appfilter.xml ", e); } } } } @NonNull @Override public String getPackPackageName() { return iconPackPackageName; } @Nullable public Resources getResources() { return packResources; } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/icons/LazyLoadDrawable.java ================================================ package rocks.tbog.tblauncher.icons; import android.annotation.SuppressLint; import android.content.res.Resources; import android.graphics.drawable.Drawable; import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.content.res.ResourcesCompat; public class LazyLoadDrawable extends DrawableInfo { @DrawableRes private int drawableId = 0; private boolean drawableIdCached = false; protected LazyLoadDrawable(@NonNull String drawableName) { super(drawableName); } @SuppressLint("DiscouragedApi") @Override @DrawableRes public int getDrawableResId(@NonNull IconPackXML iconPack) { Resources res = iconPack.getResources(); if (res == null) return drawableId; if (!drawableIdCached) { drawableId = res.getIdentifier(getDrawableName(), "drawable", iconPack.getPackPackageName()); drawableIdCached = true; } return drawableId; } @SuppressLint("DiscouragedApi") @Nullable @Override public Drawable getDrawable(@NonNull IconPackXML iconPack, @Nullable Resources.Theme theme) { Resources res = iconPack.getResources(); if (res == null) return null; if (!drawableIdCached) { drawableId = res.getIdentifier(getDrawableName(), "drawable", iconPack.getPackPackageName()); drawableIdCached = true; } try { return ResourcesCompat.getDrawable(res, drawableId, theme); } catch (Resources.NotFoundException ignored) { return null; } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/icons/SimpleDrawable.java ================================================ package rocks.tbog.tblauncher.icons; import android.content.res.Resources; import android.graphics.drawable.Drawable; import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.content.res.ResourcesCompat; public class SimpleDrawable extends DrawableInfo { @DrawableRes private final int drawableId; public SimpleDrawable(@NonNull String drawableName, @DrawableRes int drawableId) { super(drawableName); this.drawableId = drawableId; } @Override @DrawableRes public int getDrawableResId(@NonNull IconPackXML iconPack) { return drawableId; } @Nullable @Override public Drawable getDrawable(@NonNull IconPackXML iconPack, @Nullable Resources.Theme theme) { Resources res = iconPack.getResources(); if (res == null) return null; try { return ResourcesCompat.getDrawable(res, drawableId, theme); } catch (Resources.NotFoundException ignored) { return null; } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/icons/SystemIconPack.java ================================================ package rocks.tbog.tblauncher.icons; import android.content.ComponentName; import android.content.Context; import android.content.pm.LauncherActivityInfo; import android.content.pm.LauncherApps; import android.content.pm.PackageManager; import android.graphics.drawable.Drawable; import android.os.Build; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import java.util.Collection; import java.util.Collections; import java.util.List; import rocks.tbog.tblauncher.drawable.DrawableUtils; import rocks.tbog.tblauncher.utils.GoogleCalendarIcon; import rocks.tbog.tblauncher.utils.UserHandleCompat; public class SystemIconPack implements IconPack { private static final String TAG = SystemIconPack.class.getSimpleName(); private int mAdaptiveShape = DrawableUtils.SHAPE_NONE; @NonNull @Override public String getPackPackageName() { return "default"; } @Override public boolean isLoaded() { return true; } @Override public void load(PackageManager packageManager) { } public int getAdaptiveShape() { return mAdaptiveShape; } public void setAdaptiveShape(int shape) { mAdaptiveShape = shape; } @Nullable @Override public Drawable getComponentDrawable(@NonNull Context ctx, @NonNull ComponentName componentName, @NonNull UserHandleCompat userHandle) { Drawable drawable = null; if (isComponentDynamic(componentName)) { drawable = GoogleCalendarIcon.getDrawable(ctx, componentName.getClassName()); } if (drawable == null) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { LauncherApps launcher = (LauncherApps) ctx.getSystemService(Context.LAUNCHER_APPS_SERVICE); assert launcher != null; List icons = launcher.getActivityList(componentName.getPackageName(), userHandle.getRealHandle()); for (LauncherActivityInfo info : icons) { if (info.getComponentName().equals(componentName)) { drawable = info.getBadgedIcon(0); break; } } // This should never happen, let's just return the first icon if (drawable == null && !icons.isEmpty()) drawable = icons.get(0).getBadgedIcon(0); } } if (drawable == null) { try { drawable = ctx.getPackageManager().getActivityIcon(componentName); } catch (PackageManager.NameNotFoundException e) { Log.e(TAG, "Unable to find activity icon " + componentName.toString(), e); } } if (drawable == null) { try { drawable = ctx.getPackageManager().getApplicationIcon(componentName.getPackageName()); } catch (PackageManager.NameNotFoundException e) { Log.e(TAG, "Unable to find app icon " + componentName.toString(), e); } } if (drawable == null) Log.e(TAG, "Unable to find component drawable " + componentName.toString()); return drawable; } @Override public boolean isComponentDynamic(@NonNull ComponentName componentName) { return GoogleCalendarIcon.GOOGLE_CALENDAR.equals(componentName.getPackageName()); } @NonNull @Override public Drawable applyBackgroundAndMask(@NonNull Context ctx, @NonNull Drawable icon, boolean fitInside) { return DrawableUtils.applyIconMaskShape(ctx, icon, mAdaptiveShape, fitInside); } @NonNull @Override public Collection getDrawableList() { return Collections.emptyList(); } @Nullable @Override public Drawable getDrawable(@Nullable Drawable drawable) { return drawable; } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/loader/LoadAppEntry.java ================================================ package rocks.tbog.tblauncher.loader; import android.content.Context; import android.content.Intent; import android.content.pm.ApplicationInfo; import android.content.pm.LauncherActivityInfo; import android.content.pm.LauncherApps; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.os.Build; import android.os.UserManager; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import java.util.ArrayList; import java.util.List; import java.util.Map; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.db.AppRecord; import rocks.tbog.tblauncher.entry.AppEntry; import rocks.tbog.tblauncher.handler.AppsHandler; import rocks.tbog.tblauncher.utils.Timer; import rocks.tbog.tblauncher.utils.UserHandleCompat; public class LoadAppEntry extends LoadEntryItem { public LoadAppEntry(Context context) { super(context); } @NonNull @Override public String getScheme() { return AppEntry.SCHEME; } @Override protected ArrayList doInBackground(Void param) { SystemAppLoader loader = new SystemAppLoader(context.get()); //List currentApplications = TBApplication.dataHandler(context.get()).getApplications(); // timer start Timer timer = Timer.startMilli(); // function to time ArrayList apps = loader.getAppList(); // timer end timer.stop(); Log.i("time", timer + " to list apps"); return apps; } public static class SystemAppLoader { private Map dbApps = null; private ArrayList pendingChanges = null; @Nullable private final Context ctx; SystemAppLoader(@Nullable Context context) { ctx = context; } @NonNull public ArrayList getAppList() { ArrayList apps = new ArrayList<>(0); if (ctx == null) { return apps; } AppsHandler appsHandler = TBApplication.appsHandler(ctx); dbApps = appsHandler.getAppRecords(ctx); pendingChanges = new ArrayList<>(0); if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { UserManager manager = (UserManager) ctx.getSystemService(Context.USER_SERVICE); LauncherApps launcher = (LauncherApps) ctx.getSystemService(Context.LAUNCHER_APPS_SERVICE); if (manager != null && launcher != null) { // Handle multi-profile support introduced in Android 5 (#542) for (android.os.UserHandle profile : manager.getUserProfiles()) { UserHandleCompat user = new UserHandleCompat(manager.getSerialNumberForUser(profile), profile); List activityList = launcher.getActivityList(null, profile); apps.ensureCapacity(apps.size() + activityList.size()); Log.i("App", "getActivityList(" + profile + ") found " + activityList.size() + " app(s)"); for (LauncherActivityInfo activityInfo : activityList) { ApplicationInfo appInfo = activityInfo.getApplicationInfo(); String displayName = activityInfo.getLabel().toString(); if (displayName.equals(appInfo.packageName)) displayName = activityInfo.getName(); AppEntry app = processApp(displayName, appInfo.packageName, activityInfo.getName(), user); apps.add(app); } } } } else { PackageManager manager = ctx.getPackageManager(); Intent mainIntent = new Intent(Intent.ACTION_MAIN, null); mainIntent.addCategory(Intent.CATEGORY_LAUNCHER); List activityList = manager.queryIntentActivities(mainIntent, 0); apps.ensureCapacity(apps.size() + activityList.size()); Log.i("App", "queryIntentActivities found " + activityList.size() + " app(s)"); for (ResolveInfo info : activityList) { UserHandleCompat user = UserHandleCompat.CURRENT_USER; ApplicationInfo appInfo = info.activityInfo.applicationInfo; String displayName = info.loadLabel(manager).toString(); AppEntry app = processApp(displayName, appInfo.packageName, info.activityInfo.name, user); apps.add(app); } } Log.i("App", "LoadAppPojos found " + apps.size() + " app(s)"); // add new apps to database appsHandler.updateAppCache(pendingChanges, null); pendingChanges.clear(); for (Map.Entry entry : dbApps.entrySet()) { AppRecord rec = entry.getValue(); if (rec.isFlagSet(AppRecord.FLAG_VALIDATED)) continue; pendingChanges.add(rec); } // remove apps from database appsHandler.updateAppCache(null, pendingChanges); pendingChanges = null; dbApps = null; AppsHandler.setTagsForApps(apps, TBApplication.tagsHandler(ctx)); return apps; } @NonNull private AppEntry processApp(String appName, String packageName, String activityName, UserHandleCompat user) { String componentName = user.getUserComponentName(packageName, activityName); AppRecord rec = dbApps.get(componentName); if (rec == null) { rec = new AppRecord(); rec.componentName = componentName; rec.displayName = appName; pendingChanges.add(rec); } if (!rec.hasCustomName() && !appName.equals(rec.displayName)) { rec.displayName = appName; pendingChanges.add(rec); } rec.addFlags(AppRecord.FLAG_VALIDATED); AppEntry app = new AppEntry(packageName, activityName, user); if (rec.hasCustomName()) app.setName(rec.displayName); else app.setName(user.getBadgedLabelForUser(ctx, appName)); if (rec.hasCustomIcon()) app.setCustomIcon(rec.dbId); app.setHiddenByUser(rec.isHidden()); return app; } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/loader/LoadCacheApps.java ================================================ package rocks.tbog.tblauncher.loader; import android.content.Context; import android.util.Log; import androidx.annotation.NonNull; import java.util.ArrayList; import java.util.Collection; import java.util.concurrent.CountDownLatch; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.entry.AppEntry; import rocks.tbog.tblauncher.handler.AppsHandler; import rocks.tbog.tblauncher.utils.Timer; public class LoadCacheApps extends LoadEntryItem { private final static String TAG = "LCApps"; private final AppsHandler appsHandler; public LoadCacheApps(Context context) { super(context); TBApplication app = TBApplication.getApplication(context); // call this here in case the AppsHandler is not yet loaded appsHandler = app.appsHandler(); } @NonNull @Override public String getScheme() { return AppEntry.SCHEME; } @Override protected ArrayList doInBackground(Void param) { Log.d(TAG, "doInBackground"); final Context context = this.context.get(); // timer start Timer timer = Timer.startMilli(); final CountDownLatch latch = new CountDownLatch(1); // notify that the tags are loaded appsHandler.runWhenLoaded(latch::countDown); // wait for the tags to load try { latch.await(); } catch (InterruptedException e) { Log.e(TAG, "waiting for TagsHandler", e); } // function to time final ArrayList pojos; if (context != null) pojos = getApps(context, appsHandler); else pojos = new ArrayList<>(0); // timer end timer.stop(); Log.i("time", timer + " to load (" + pojos.size() + ") cached apps"); return pojos; } @NonNull private static ArrayList getApps(@NonNull Context context, @NonNull AppsHandler appsHandler) { Collection appEntries = appsHandler.getAllApps(); Log.d(TAG, "appsHandler.getAllApps.size=" + appEntries.size()); if (appEntries.isEmpty()) { // cache is empty, load system apps now LoadAppEntry.SystemAppLoader loader = new LoadAppEntry.SystemAppLoader(context); return loader.getAppList(); } return new ArrayList<>(appEntries); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/loader/LoadContactsEntry.java ================================================ package rocks.tbog.tblauncher.loader; import android.content.ContentResolver; import android.content.ContentUris; import android.content.Context; import android.database.Cursor; import android.net.Uri; import android.provider.ContactsContract; import android.util.Log; import androidx.annotation.NonNull; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import rocks.tbog.tblauncher.MimeTypeCache; import rocks.tbog.tblauncher.Permission; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.entry.ContactEntry; import rocks.tbog.tblauncher.utils.MimeTypeUtils; import rocks.tbog.tblauncher.utils.Timer; public class LoadContactsEntry extends LoadEntryItem { private static final String TAG = "LoadContacts"; public LoadContactsEntry(Context context) { super(context); } @NonNull @Override public String getScheme() { return ContactEntry.SCHEME; } @Override protected ArrayList doInBackground(Void param) { Timer timer = Timer.startNano(); ArrayList contacts = new ArrayList<>(); Context ctx = context.get(); if (ctx == null) { return contacts; } // Skip if we don't have permission to list contacts yet:( if (!Permission.checkPermission(ctx, Permission.PERMISSION_READ_CONTACTS)) { return contacts; } // Skip if we don't have any mime types to be shown Set mimeTypes = MimeTypeUtils.getActiveMimeTypes(ctx); if (mimeTypes.isEmpty()) { return contacts; } final ContentResolver contentResolver = ctx.getContentResolver(); Map basicContacts = loadBasicContacts(contentResolver); Map basicRawContacts = loadRawContacts(contentResolver); // Retrieve contacts' nicknames Cursor nickCursor = contentResolver.query( ContactsContract.Data.CONTENT_URI, new String[]{ ContactsContract.CommonDataKinds.Nickname.NAME, ContactsContract.Data.LOOKUP_KEY}, ContactsContract.Data.MIMETYPE + "=?", new String[]{ContactsContract.CommonDataKinds.Nickname.CONTENT_ITEM_TYPE}, null); if (nickCursor != null) { if (nickCursor.getCount() > 0) { int lookupKeyIndex = nickCursor.getColumnIndex(ContactsContract.Data.LOOKUP_KEY); int nickNameIndex = nickCursor.getColumnIndex(ContactsContract.CommonDataKinds.Nickname.NAME); while (nickCursor.moveToNext()) { String lookupKey = nickCursor.getString(lookupKeyIndex); String nick = nickCursor.getString(nickNameIndex); if (nick != null && lookupKey != null) { BasicContact basicContact = basicContacts.get(lookupKey); if (basicContact != null) { basicContact.setNickName(nick); } } } } nickCursor.close(); } // get mime type labels Map mimeLabels = TBApplication.mimeTypeCache(ctx).getUniqueLabels(ctx, mimeTypes); // Query all mime types for (String mimeType : mimeTypes) { Timer timerMimeType = Timer.startNano(); int sizeBefore = contacts.size(); if (ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE.equals(mimeType)) { contacts.addAll(createPhoneContacts(contentResolver, basicContacts, basicRawContacts)); } else { String mimeLabel = mimeLabels.get(mimeType); contacts.addAll(createGenericContacts(mimeType, basicContacts, basicRawContacts, mimeLabel)); } int sizeAfter = contacts.size(); Log.i("time", timerMimeType + " to list " + (sizeAfter - sizeBefore) + " contact(s) for " + mimeType); } Log.i("time", timer + " to list " + contacts.size() + " contact(s)"); return contacts; } @NonNull private static Map loadBasicContacts(@NonNull ContentResolver contentResolver) { // Run query Cursor contactCursor = contentResolver.query( ContactsContract.CommonDataKinds.Phone.CONTENT_URI, new String[]{ ContactsContract.Contacts.LOOKUP_KEY, ContactsContract.Contacts._ID, ContactsContract.Contacts.DISPLAY_NAME_PRIMARY, ContactsContract.Contacts.PHOTO_ID, ContactsContract.Contacts.PHOTO_URI}, null, null, null); if (contactCursor == null) return Collections.emptyMap(); if (contactCursor.getCount() == 0) { contactCursor.close(); return Collections.emptyMap(); } // Query basic contact information and keep in memory to prevent duplicates Map basicContacts = new HashMap<>(contactCursor.getCount()); int lookupIndex = contactCursor.getColumnIndex(ContactsContract.Contacts.LOOKUP_KEY); int contactIdIndex = contactCursor.getColumnIndex(ContactsContract.Contacts._ID); int displayNameIndex = contactCursor.getColumnIndex(ContactsContract.Contacts.DISPLAY_NAME_PRIMARY); int photoIdIndex = contactCursor.getColumnIndex(ContactsContract.Contacts.PHOTO_ID); int photoUriIndex = contactCursor.getColumnIndex(ContactsContract.Contacts.PHOTO_URI); while (contactCursor.moveToNext()) { BasicContact basicContact = new BasicContact( contactCursor.getString(lookupIndex), contactCursor.getLong(contactIdIndex), contactCursor.getString(displayNameIndex), contactCursor.getString(photoIdIndex), contactCursor.getString(photoUriIndex) ); basicContacts.put(basicContact.getLookupKey(), basicContact); } contactCursor.close(); return basicContacts; } private static Map loadRawContacts(@NonNull ContentResolver contentResolver) { // Query raw contact information and keep in memory to prevent duplicates Cursor rawContactCursor = contentResolver.query( ContactsContract.RawContacts.CONTENT_URI, new String[]{ContactsContract.RawContacts._ID, ContactsContract.RawContacts.ACCOUNT_TYPE, ContactsContract.RawContacts.STARRED}, null, null, null); if (rawContactCursor == null) { return Collections.emptyMap(); } if (rawContactCursor.getCount() == 0) { rawContactCursor.close(); return Collections.emptyMap(); } Map basicRawContacts = new HashMap<>(rawContactCursor.getCount()); int rawContactIdIndex = rawContactCursor.getColumnIndex(ContactsContract.RawContacts._ID); int accountTypeIndex = rawContactCursor.getColumnIndex(ContactsContract.RawContacts.ACCOUNT_TYPE); int starredIndex = rawContactCursor.getColumnIndex(ContactsContract.RawContacts.STARRED); while (rawContactCursor.moveToNext()) { BasicRawContact basicRawContact = new BasicRawContact( rawContactCursor.getLong(rawContactIdIndex), rawContactCursor.getString(accountTypeIndex), rawContactCursor.getInt(starredIndex) != 0 ); basicRawContacts.put(basicRawContact.getId(), basicRawContact); } rawContactCursor.close(); return basicRawContacts; } private static ArrayList createPhoneContacts(@NonNull ContentResolver contentResolver, Map basicContacts, Map basicRawContacts) { // Query all phone numbers Cursor phoneCursor = contentResolver.query( ContactsContract.CommonDataKinds.Phone.CONTENT_URI, new String[]{ContactsContract.Contacts.LOOKUP_KEY, ContactsContract.CommonDataKinds.Phone.RAW_CONTACT_ID, ContactsContract.CommonDataKinds.Phone.NUMBER, ContactsContract.CommonDataKinds.Phone.IS_PRIMARY}, null, null, null); // Prevent duplicates by keeping in memory encountered contacts. Map> mapContacts = new HashMap<>(); if (phoneCursor != null) { if (phoneCursor.getCount() > 0) { int lookupIndex = phoneCursor.getColumnIndex(ContactsContract.Contacts.LOOKUP_KEY); int rawContactIdIndex = phoneCursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.RAW_CONTACT_ID); int numberIndex = phoneCursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER); int isPrimaryIndex = phoneCursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.IS_PRIMARY); while (phoneCursor.moveToNext()) { String lookupKey = phoneCursor.getString(lookupIndex); BasicContact basicContact = basicContacts.get(lookupKey); long rawContactId = phoneCursor.getLong(rawContactIdIndex); BasicRawContact basicRawContact = basicRawContacts.get(rawContactId); if (basicContact != null && basicRawContact != null) { String phone = phoneCursor.getString(numberIndex); if (phone == null) { phone = ""; } ContactEntry contact = new ContactEntry.Builder() .setContactId(basicContact.getContactId()) .setPhone(phone) .setPrimary(phoneCursor.getInt(isPrimaryIndex) != 0) .setLookupKey(lookupKey) .setStarred(basicRawContact.isStarred()) .setIconUri(basicContact.getIcon()) .setName(basicContact.getDisplayName()) .setNickname(basicContact.getNickName()) .getContact(); addContactToMap(contact, mapContacts); } } } phoneCursor.close(); } return getFilteredContacts(mapContacts, contact -> contact.normalizedPhone); } @NonNull private List createGenericContacts(String mimeType, Map basicContacts, Map basicRawContacts, String mimeLabel) { // Prevent duplicates by keeping in memory encountered contacts. Map> mapContacts = new HashMap<>(); List columns = new ArrayList<>(); columns.add(ContactsContract.Data.LOOKUP_KEY); columns.add(ContactsContract.Data.RAW_CONTACT_ID); columns.add(ContactsContract.Data._ID); columns.add(ContactsContract.Data.IS_PRIMARY); Context ctx = context.get(); if (ctx == null) { Log.w(TAG, "null context in createGenericContacts"); return Collections.emptyList(); } final MimeTypeCache mimeTypeCache = TBApplication.mimeTypeCache(ctx); String detailColumn = mimeTypeCache.getDetailColumn(ctx, mimeType); if (detailColumn != null && !columns.contains(detailColumn)) { columns.add(detailColumn); } // Query all entries by mimeType Cursor mimeTypeCursor = ctx.getContentResolver().query( ContactsContract.Data.CONTENT_URI, columns.toArray(new String[]{}), ContactsContract.Data.MIMETYPE + "= ?", new String[]{mimeType}, null); if (mimeTypeCursor != null) { if (mimeTypeCursor.getCount() > 0) { int lookupIndex = mimeTypeCursor.getColumnIndex(ContactsContract.Data.LOOKUP_KEY); int rawContactIdIndex = mimeTypeCursor.getColumnIndex(ContactsContract.Data.RAW_CONTACT_ID); int idIndex = mimeTypeCursor.getColumnIndex(ContactsContract.Data._ID); int isPrimaryIndex = mimeTypeCursor.getColumnIndex(ContactsContract.Data.IS_PRIMARY); int detailColumnIndex = -1; if (detailColumn != null) { detailColumnIndex = mimeTypeCursor.getColumnIndex(detailColumn); } while (mimeTypeCursor.moveToNext()) { String lookupKey = mimeTypeCursor.getString(lookupIndex); BasicContact basicContact = basicContacts.get(lookupKey); long rawContactId = mimeTypeCursor.getLong(rawContactIdIndex); BasicRawContact basicRawContact = basicRawContacts.get(rawContactId); if (basicContact != null && basicRawContact != null) { long id = mimeTypeCursor.getLong(idIndex); String label = null; if (detailColumnIndex >= 0) { label = mimeTypeCursor.getString(detailColumnIndex); } if (label == null) { label = mimeTypeCache.getLabel(ctx, mimeType); } ContactEntry.ImData imData = new ContactEntry.ImData(mimeType, id, mimeLabel); imData.setIdentifier(label); ContactEntry contact = new ContactEntry.Builder() .setContactId(basicContact.getContactId()) .setMimeInfo(id, MimeTypeUtils.getShortMimeType(mimeType)) .setPrimary(mimeTypeCursor.getInt(isPrimaryIndex) != 0) .setLookupKey(lookupKey) .setStarred(basicRawContact.isStarred()) .setIconUri(basicContact.getIcon()) .setName(basicContact.getDisplayName()) .setNickname(basicContact.getNickName()) .setImData(imData) .getContact(); addContactToMap(contact, mapContacts); } } } mimeTypeCursor.close(); } return getFilteredContacts(mapContacts, contact -> contact.getImData().getIdentifier()); } /** * add contact to mapContacts, grouped by lookup key * * @param contact * @param mapContacts */ private static void addContactToMap(@NonNull ContactEntry contact, @NonNull Map> mapContacts) { Set mimeTypes = mapContacts.get(contact.lookupKey); if (mimeTypes == null) { mimeTypes = new HashSet<>(1); mapContacts.put(contact.lookupKey, mimeTypes); } mimeTypes.add(contact); } /** * Filter all contacts dependent of fields. * Return primary contacts if available. * If no primary contacts are available all contacts are returned. * * @param mapContacts all contacts grouped by lookup key * @param idSupplier id supplier for identifying duplicates * @return filtered contacts */ private static ArrayList getFilteredContacts(Map> mapContacts, IdSupplier idSupplier) { ArrayList contacts = new ArrayList<>(); // Add phone numbers for (Set mappedContacts : mapContacts.values()) { // Find primary phone and add this one. boolean hasPrimary = false; for (ContactEntry contact : mappedContacts) { if (contact.isPrimary()) { contacts.add(contact); hasPrimary = true; break; } } // If no primary available, add all (excluding duplicates). if (!hasPrimary) { HashSet added = new HashSet<>(mappedContacts.size()); for (ContactEntry contact : mappedContacts) { Object id = idSupplier.getId(contact); if (id == null) { contacts.add(contact); } else if (!added.contains(id)) { added.add(id); contacts.add(contact); } } } } return contacts; } // TODO: move to separate class, which package? @FunctionalInterface public interface IdSupplier { Object getId(ContactEntry contact); } // TODO: move to separate class, which package? private static class BasicContact { private final String lookupKey; private final long contactId; private final String displayName; private final String photoId; private final String photoUri; private String nickName; private String mimeTypeLabel; private BasicContact(String lookupKey, long contactId, String displayName, String photoId, String photoUri) { this.lookupKey = lookupKey; this.contactId = contactId; this.displayName = displayName; this.photoId = photoId; this.photoUri = photoUri; } public String getLookupKey() { return lookupKey; } public long getContactId() { return contactId; } public String getDisplayName() { return displayName; } public String getNickName() { return nickName; } public void setNickName(String nickName) { this.nickName = nickName; } public void setLabel(String label) { mimeTypeLabel = label; } public Uri getIcon() { if (photoUri != null) { return Uri.parse(photoUri); } if (photoId != null) { return ContentUris.withAppendedId(ContactsContract.Data.CONTENT_URI, Long.parseLong(photoId)); } return null; } } // TODO: move to separate class, which package? private static class BasicRawContact { private final long id; private final String accountType; private final boolean starred; private BasicRawContact(long id, String accountType, boolean starred) { this.id = id; this.accountType = accountType; this.starred = starred; } public long getId() { return id; } public String getAccountType() { return accountType; } public boolean isStarred() { return starred; } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/loader/LoadEntryItem.java ================================================ package rocks.tbog.tblauncher.loader; import android.content.Context; import androidx.annotation.NonNull; import java.lang.ref.WeakReference; import java.util.ArrayList; import rocks.tbog.tblauncher.handler.DataHandler; import rocks.tbog.tblauncher.WorkAsync.AsyncTask; import rocks.tbog.tblauncher.WorkAsync.TaskRunner; import rocks.tbog.tblauncher.dataprovider.Provider; import rocks.tbog.tblauncher.entry.EntryItem; public abstract class LoadEntryItem extends AsyncTask> { final WeakReference context; private WeakReference> weakProvider; LoadEntryItem(Context context) { super(); this.context = new WeakReference<>(context); } public void setProvider(Provider provider) { this.weakProvider = new WeakReference<>(provider); } @NonNull public abstract String getScheme(); protected abstract ArrayList doInBackground(Void param); protected void onPostExecute(ArrayList result) { Provider provider = weakProvider.get(); if (provider != null) { provider.loadOver(result); } } public void execute() { TaskRunner.executeOnExecutor(DataHandler.EXECUTOR_PROVIDERS, this); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/loader/LoadShortcutsEntryItem.java ================================================ package rocks.tbog.tblauncher.loader; import android.content.Context; import android.content.pm.LauncherApps; import android.content.pm.LauncherApps.ShortcutQuery; import android.content.pm.ShortcutInfo; import android.os.Build; import android.os.Process; import androidx.annotation.NonNull; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.db.DBHelper; import rocks.tbog.tblauncher.db.ModRecord; import rocks.tbog.tblauncher.db.ShortcutRecord; import rocks.tbog.tblauncher.entry.ShortcutEntry; import rocks.tbog.tblauncher.handler.TagsHandler; public class LoadShortcutsEntryItem extends LoadEntryItem { private final TagsHandler tagsHandler; private final LauncherApps mLauncherApps; public LoadShortcutsEntryItem(Context context) { super(context); tagsHandler = TBApplication.tagsHandler(context); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { mLauncherApps = (LauncherApps) context.getSystemService(Context.LAUNCHER_APPS_SERVICE); } else { mLauncherApps = null; } } @NonNull @Override public String getScheme() { return ShortcutEntry.SCHEME; } @Override protected ArrayList doInBackground(Void arg) { Context ctx = context.get(); if (ctx == null) { return new ArrayList<>(); } final HashMap favorites; { ArrayList favList = DBHelper.getMods(ctx); favorites = new HashMap<>(); for (ModRecord fav : favList) favorites.put(fav.record, fav); } List records = DBHelper.getShortcutsNoIcons(ctx); ArrayList pojos = new ArrayList<>(records.size()); HashMap oreoMap = new HashMap<>(); for (ShortcutRecord shortcutRecord : records) { if (shortcutRecord.isOreo()) { oreoMap.put(shortcutRecord.infoData, shortcutRecord); continue; } final String id = ShortcutEntry.generateShortcutId(shortcutRecord); final ShortcutEntry pojo = new ShortcutEntry(id, shortcutRecord.dbId, shortcutRecord.packageName, shortcutRecord.infoData); pojo.setName(shortcutRecord.displayName); ModRecord modRecord = favorites.get(pojo.id); if (modRecord != null && modRecord.hasCustomIcon()) pojo.setCustomIcon(); pojos.add(pojo); } if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { List shortcutInfos = null; ShortcutQuery q = new ShortcutQuery(); if (TBApplication.getApplication(ctx).preferences().getBoolean("shortcut-dynamic-in-results", false)) { q.setQueryFlags(ShortcutQuery.FLAG_MATCH_PINNED | ShortcutQuery.FLAG_MATCH_MANIFEST | ShortcutQuery.FLAG_MATCH_DYNAMIC | ShortcutQuery.FLAG_MATCH_CACHED); } else { q.setQueryFlags(ShortcutQuery.FLAG_MATCH_PINNED); } if (mLauncherApps.hasShortcutHostPermission()) shortcutInfos = mLauncherApps.getShortcuts(q, Process.myUserHandle()); if (shortcutInfos == null) { shortcutInfos = Collections.emptyList(); } for (ShortcutInfo shortcutInfo : shortcutInfos) { ShortcutRecord record = oreoMap.remove(shortcutInfo.getId()); long dbId = 0; String name = null; if (record != null) { dbId = record.dbId; name = record.displayName; } // if no name found, try the shortcut text if (name == null || name.isEmpty()) { CharSequence label = shortcutInfo.getLongLabel(); if (label != null) name = label.toString(); } // if no name found, try the shortcut title if (name == null || name.isEmpty()) { CharSequence label = shortcutInfo.getShortLabel(); if (label != null) name = label.toString(); } ShortcutEntry pojo = new ShortcutEntry(dbId, shortcutInfo); pojo.setName(name); ModRecord modRecord = favorites.get(pojo.id); if (modRecord != null && modRecord.hasCustomIcon()) pojo.setCustomIcon(); pojos.add(pojo); } // clear remaining shortcuts for (ShortcutRecord record : oreoMap.values()) { DBHelper.removeShortcut(ctx, record.dbId); //tagsHandler.removeAllTags(ShortcutEntry.SCHEME + record.infoData); } } tagsHandler.runWhenLoaded(() -> { for (ShortcutEntry shortcutEntry : pojos) shortcutEntry.setTags(tagsHandler.getTags(shortcutEntry.id)); }); return pojos; } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/normalizer/IntSequenceBuilder.java ================================================ package rocks.tbog.tblauncher.normalizer; /** * Simple integer sequence class that allows adding individual elements and exporting those * elements to an integer array. *

* Created by Alexander Schlarb on 17.08.15. */ class IntSequenceBuilder { private int[] data; private int size; /** * @param capacity The initial size of the internal storage array */ public IntSequenceBuilder(int capacity) { // Create new storage array of requested size this.data = new int[capacity]; this.size = 0; } /** * Add a new element to this builder * * @param element The value of the element to add */ public void add(int element) { // Resize storage array larger if required if ((this.size + 1) >= this.data.length) { int[] data = this.data; this.data = new int[(this.data.length * 3) / 2 + 1]; System.arraycopy(data, 0, this.data, 0, this.size); } // Add element to storage array this.data[this.size] = element; // Increment stored element number counter this.size++; } /** * Export an array with the current data stored in this builder * * @return Copy of the elements of the internal storage array */ public int[] toArray() { // Copy the actual number of stored elements to a new array int[] data = new int[this.size]; System.arraycopy(this.data, 0, data, 0, this.size); return data; } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/normalizer/PhoneNormalizer.java ================================================ package rocks.tbog.tblauncher.normalizer; public class PhoneNormalizer { public static StringNormalizer.Result simplifyPhoneNumber(String phoneNumber) { // This is done manually for performance reason, // But the algorithm is just a regexp replacement of "[-.():/ ]" with "" int numCodePoints = Character.codePointCount(phoneNumber, 0, phoneNumber.length()); IntSequenceBuilder codePoints = new IntSequenceBuilder(numCodePoints); IntSequenceBuilder resultMap = new IntSequenceBuilder(numCodePoints); int i = 0; for (int iterCodePoint = 0; iterCodePoint < numCodePoints; iterCodePoint += 1) { int c = Character.codePointAt(phoneNumber, i); if (c != ' ' && c != '-' && c != '.' && c != '(' && c != ')' && c != ':' && c != '/') { codePoints.add(c); resultMap.add(i); } i += Character.charCount(c); } return new StringNormalizer.Result(phoneNumber.length(), codePoints.toArray(), resultMap.toArray()); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/normalizer/StringNormalizer.java ================================================ package rocks.tbog.tblauncher.normalizer; import java.nio.CharBuffer; import java.text.Normalizer; import java.util.Arrays; /** * String utils to handle accented characters for search and highlighting */ public class StringNormalizer { private StringNormalizer() { } /** * Make the given string easier to compare by performing a number of simplifications on it *

* 1. Decompose combination characters into their respective parts (see below) * 2. Strip all combining character marks (see below) * 3. Strip some other common-but-not-very-useful characters (such as dashes) * 4. Lower-case the string *

* Combination characters are characters that (essentially) have the same meaning as one or * more other, more common, characters. Examples for these include: * Roman numerals (`Ⅱ` → `II`) and half-width katakana (`ミ` → `ミ`) *

* Combining character marks are diacritics and other extra strokes that are often found as * part of many characters in non-English roman scripts. Examples for these include: * Diaereses (`ë` → `e`), acutes (`á` → `a`) and macrons (`ō` → `o`) * * @param input string input, with accents and anything else you can think of * @param makeLowercase make all characters lowercase * @return normalized string and list that maps each result string position to its source * string position */ public static Result normalizeWithResult(CharSequence input, boolean makeLowercase) { int numCodePoints = Character.codePointCount(input, 0, input.length()); IntSequenceBuilder codePoints = new IntSequenceBuilder(numCodePoints); IntSequenceBuilder resultMap = new IntSequenceBuilder(numCodePoints); CharBuffer buffer = CharBuffer.allocate(2); int i = 0; for (int iterCodePoint = 0; iterCodePoint < numCodePoints; iterCodePoint += 1) { int codepoint = Character.codePointAt(input, i); String decomposedCharString; // Is it within the basic latin range? // If so, we can skip the expensive call to Normalizer.normalize if (codepoint < 'z') { // Ascii range, no need to normalize! // Add directly if it's not a dash // (HYPHEN-MINUS is the only character before 'z' in one of the // NON_SPACING_MARK / COMBINING_SPACING_MARK / DASH_PUNCTUATION // category, so we can skip the Character.getType() and explicitly check for it) if (codepoint != '-') { codePoints.add(makeLowercase ? Character.toLowerCase(codepoint) : codepoint); resultMap.add(i); } } else { // Otherwise, we'll need to normalize the code point to a letter and potential accentuation buffer.put(Character.toChars(codepoint)); buffer.flip(); decomposedCharString = Normalizer.normalize(buffer, Normalizer.Form.NFKD); buffer.clear(); // `inputChar` codepoint may be decomposed to four (or maybe even more) new code points int decomposedCharOffset = 0; while (decomposedCharOffset < decomposedCharString.length()) { int resultChar = decomposedCharString.codePointAt(decomposedCharOffset); // Skip characters for some unicode character classes, including: // * combining characters produced by the NFKD normalizer above // * dashes // See the method's description for more information switch (Character.getType(resultChar)) { case Character.NON_SPACING_MARK: case Character.COMBINING_SPACING_MARK: // Some combining character found // See http://www.fileformat.info/info/unicode/category/Mn/list.htm // And http://www.fileformat.info/info/unicode/category/Mc/list.htm break; case Character.DASH_PUNCTUATION: // We skip dashes too // (standard HYPHEN-MINUS was skipped above, but dashes are a large family!) // see http://www.fileformat.info/info/unicode/category/Pd/list.htm break; default: codePoints.add(makeLowercase ? Character.toLowerCase(resultChar) : resultChar); resultMap.add(i); } decomposedCharOffset += Character.charCount(resultChar); } } i += Character.charCount(codepoint); } return new Result(input.length(), codePoints.toArray(), resultMap.toArray()); } public static class Result implements Comparable { private final int originalInputLastCharPosition; public final int[] codePoints; private final int[] mapPositions; Result(final int originalInputLastCharPosition, final int[] codePoints, final int[] mapPositions) { if (codePoints.length != mapPositions.length) throw new IllegalStateException("Each codepoint needs a mapped position"); this.originalInputLastCharPosition = originalInputLastCharPosition; this.codePoints = codePoints; this.mapPositions = mapPositions; } public int length() { return this.codePoints.length; } /** * Map a position in the normalized string to a position in the original string * * @param position Position in normalized string * @return Position in non-normalized string */ public int mapPosition(int position) { if (position < mapPositions.length) return mapPositions[position]; // We are behind the last character, return the position of the end of the original input return originalInputLastCharPosition; } @Override public int compareTo(Result that) { // this optimization is usually worthwhile, and can always be added if (this == that) return 0; if (that == null) return 1; int minLength = Math.min(this.codePoints.length, that.codePoints.length); for (int i = 0; i < minLength; i += 1) { final int cmp = Character.toLowerCase(this.codePoints[i]) - Character.toLowerCase(that.codePoints[i]); if (cmp != 0) return cmp; } if (this.codePoints.length != that.codePoints.length) return this.codePoints.length - that.codePoints.length; // equal return 0; } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Result)) return false; Result result = (Result) o; return Arrays.equals(codePoints, result.codePoints); } @Override public int hashCode() { return Arrays.hashCode(codePoints); } @Override public String toString() { // Since we stripped all combining Unicode characters in the // normalization function there should be no combining character // remaining in the string and the composed and decomposed // versions of the string should be equivalent. This also means // we do not need to convert the string back to composed Unicode // before returning it. StringBuilder sb = new StringBuilder(codePoints.length); for (int codePoint : codePoints) sb.appendCodePoint(codePoint); return sb.toString(); } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/preference/BaseListPreferenceDialog.java ================================================ package rocks.tbog.tblauncher.preference; import android.os.Bundle; import androidx.appcompat.app.AlertDialog; import androidx.preference.ListPreferenceDialogFragmentCompat; import rocks.tbog.tblauncher.utils.DialogHelper; public class BaseListPreferenceDialog extends ListPreferenceDialogFragmentCompat { public static BaseListPreferenceDialog newInstance(String key) { final BaseListPreferenceDialog fragment = new BaseListPreferenceDialog(); final Bundle b = new Bundle(1); b.putString(ARG_KEY, key); fragment.setArguments(b); return fragment; } @Override protected void onPrepareDialogBuilder(AlertDialog.Builder builder) { super.onPrepareDialogBuilder(builder); DialogHelper.setCustomTitle(builder, getPreference().getDialogTitle()); } @Override public void onStart() { super.onStart(); DialogHelper.setButtonBarBackground(requireDialog()); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/preference/BaseMultiSelectListPreferenceDialog.java ================================================ package rocks.tbog.tblauncher.preference; import android.os.Bundle; import androidx.appcompat.app.AlertDialog; import androidx.preference.MultiSelectListPreferenceDialogFragmentCompat; import rocks.tbog.tblauncher.utils.DialogHelper; public class BaseMultiSelectListPreferenceDialog extends MultiSelectListPreferenceDialogFragmentCompat { public static BaseMultiSelectListPreferenceDialog newInstance(String key) { final BaseMultiSelectListPreferenceDialog fragment = new BaseMultiSelectListPreferenceDialog(); final Bundle b = new Bundle(1); b.putString(ARG_KEY, key); fragment.setArguments(b); return fragment; } @Override protected void onPrepareDialogBuilder(AlertDialog.Builder builder) { super.onPrepareDialogBuilder(builder); DialogHelper.setCustomTitle(builder, getPreference().getDialogTitle()); } @Override public void onStart() { super.onStart(); DialogHelper.setButtonBarBackground(requireDialog()); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/preference/BasePreferenceDialog.java ================================================ package rocks.tbog.tblauncher.preference; import android.view.View; import android.view.ViewGroup; import android.view.ViewParent; import androidx.annotation.NonNull; import androidx.appcompat.app.AlertDialog; import androidx.lifecycle.Lifecycle; import androidx.lifecycle.LifecycleOwner; import androidx.lifecycle.LifecycleRegistry; import androidx.preference.PreferenceDialogFragmentCompat; import rocks.tbog.tblauncher.utils.DialogHelper; public abstract class BasePreferenceDialog extends PreferenceDialogFragmentCompat { private View mDialogView = null; private DialogLifecycleOwner mDialogLifecycleOwner = new DialogLifecycleOwner(); public LifecycleOwner getDialogLifecycleOwner() { return mDialogLifecycleOwner; } @Override protected void onBindDialogView(View view) { super.onBindDialogView(view); mDialogView = view; mDialogLifecycleOwner.onCreate(); } @Override protected void onPrepareDialogBuilder(AlertDialog.Builder builder) { super.onPrepareDialogBuilder(builder); DialogHelper.setCustomTitle(builder, getPreference().getDialogTitle()); } @Override public void onStart() { super.onStart(); // hack to have the LinearLayout weight work ViewParent parent = mDialogView != null ? mDialogView.getParent() : null; while (parent instanceof ViewGroup) { ViewGroup layout = (ViewGroup) parent; ViewGroup.LayoutParams params = layout.getLayoutParams(); if (params.height != ViewGroup.LayoutParams.MATCH_PARENT) { params.width = ViewGroup.LayoutParams.MATCH_PARENT; params.height = ViewGroup.LayoutParams.MATCH_PARENT; layout.setLayoutParams(params); } if (layout.getId() == android.R.id.content) break; parent = parent.getParent(); } DialogHelper.setButtonBarBackground(requireDialog()); mDialogLifecycleOwner.onStart(); } @Override public void onStop() { super.onStop(); mDialogLifecycleOwner.onStop(); } @Override public void onDestroyView() { mDialogView = null; super.onDestroyView(); mDialogLifecycleOwner.onDestroy(); } @Override public void onResume() { super.onResume(); mDialogLifecycleOwner.onResume(); } @Override public void onPause() { super.onPause(); mDialogLifecycleOwner.onPause(); } protected static class DialogLifecycleOwner implements LifecycleOwner { LifecycleRegistry lifecycleRegistry; public DialogLifecycleOwner() { lifecycleRegistry = new LifecycleRegistry(this); } public void onCreate() { lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE); } public void onStart() { lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START); } public void onResume() { lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME); } public void onPause() { lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE); } public void onStop() { lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP); } public void onDestroy() { lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY); } @NonNull @Override public Lifecycle getLifecycle() { return lifecycleRegistry; } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/preference/ConfirmDialog.java ================================================ package rocks.tbog.tblauncher.preference; import android.annotation.SuppressLint; import android.app.Activity; import android.app.Dialog; import android.app.admin.DevicePolicyManager; import android.content.Context; import android.content.DialogInterface; import android.content.Intent; import android.content.SharedPreferences; import android.os.Bundle; import android.os.Looper; import android.util.Log; import android.view.View; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.WorkerThread; import androidx.appcompat.app.AlertDialog; import androidx.preference.PreferenceGroup; import androidx.preference.PreferenceManager; import rocks.tbog.tblauncher.DeviceAdmin; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.SettingsActivity; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.TBLauncherActivity; import rocks.tbog.tblauncher.WorkAsync.TaskRunner; import rocks.tbog.tblauncher.db.XmlExport; import rocks.tbog.tblauncher.utils.FileUtils; import rocks.tbog.tblauncher.utils.UITheme; import rocks.tbog.tblauncher.utils.Utilities; public class ConfirmDialog extends BasePreferenceDialog { private static final String TAG = "Dialog"; public static ConfirmDialog newInstance(String key) { ConfirmDialog fragment = new ConfirmDialog(); final Bundle b = new Bundle(1); b.putString(ARG_KEY, key); fragment.setArguments(b); return fragment; } @SuppressLint("ApplySharedPref") @Override public void onDialogClosed(boolean positiveResult) { if (!positiveResult) return; CustomDialogPreference preference = (CustomDialogPreference) getPreference(); final String key = preference.getKey(); switch (key) { case "device-admin": { final Context context = requireContext(); if (DeviceAdmin.isAdminActive(context)) { DeviceAdmin.removeActiveAdmin(context); } else { Activity activity = requireActivity(); Intent intent = new Intent(); intent.setAction(DevicePolicyManager.ACTION_ADD_DEVICE_ADMIN); intent.putExtra(DevicePolicyManager.EXTRA_DEVICE_ADMIN, DeviceAdmin.getAdminComponent(context)); intent.putExtra(DevicePolicyManager.EXTRA_ADD_EXPLANATION, getString(R.string.device_admin_explanation)); activity.startActivityForResult(intent, SettingsActivity.ENABLE_DEVICE_ADMIN); } break; } case "generate-theme-simple": { UITheme.applyColorsThemeSimple(requireContext()); break; } case "generate-theme-highlight": { UITheme.applyColorsThemeHighlight(requireContext()); break; } case "reset-matrix": { final Context context = requireContext(); preference .getPreferenceManager() .getSharedPreferences() .edit() .remove("icon-scale-red") .remove("icon-scale-green") .remove("icon-scale-blue") .remove("icon-scale-alpha") .remove("icon-hue") .remove("icon-contrast") .remove("icon-brightness") .remove("icon-saturation") .commit(); PreferenceManager.setDefaultValues(context, R.xml.preferences, true); TBApplication.drawableCache(context).clearCache(); TBLauncherActivity launcherActivity = TBApplication.launcherActivity(context); if (launcherActivity != null) { launcherActivity.refreshSearchRecords(); launcherActivity.queueDockReload(); } break; } case "reset-preferences": preference.getPreferenceManager().getSharedPreferences().edit().clear().commit(); PreferenceManager.setDefaultValues(requireContext(), R.xml.preferences, true); PreferenceManager.setDefaultValues(requireContext(), R.xml.preference_features, true); break; case "reset-cached-app-icons": TBApplication.iconsHandler(getContext()).resetCachedAppIcons(); break; case "exit-app": //getActivity().finishAffinity(); System.exit(0); break; case "reset-default-launcher": TBApplication.resetDefaultLauncherAndOpenChooser(requireContext()); break; case "export-tags": FileUtils.sendSettingsFile(requireActivity(), "tags"); break; case "export-modifications": FileUtils.sendSettingsFile(requireActivity(), "modifications"); break; case "export-apps": FileUtils.sendSettingsFile(requireActivity(), "applications"); break; case "export-interface": FileUtils.sendSettingsFile(requireActivity(), "interface"); break; case "export-preferences": FileUtils.sendSettingsFile(requireActivity(), "settings"); break; case "export-widgets": FileUtils.sendSettingsFile(requireActivity(), "widgets"); break; case "export-history": FileUtils.sendSettingsFile(requireActivity(), "history"); break; case "export-backup": FileUtils.sendSettingsFile(requireActivity(), "backup"); break; case "unlimited-search-cap": { SharedPreferences pref = preference.getPreferenceManager().getSharedPreferences(); pref.edit().putInt("result-search-cap", 0).apply(); break; } default: Log.w(TAG, "Unexpected key `" + key + "`"); } } @Override protected void onBindDialogView(View view) { super.onBindDialogView(view); CustomDialogPreference preference = (CustomDialogPreference) getPreference(); final String key = preference.getKey(); switch (key) { case "device-admin": ((TextView) view.findViewById(android.R.id.text1)).setText(R.string.device_admin_disable); ((TextView) view.findViewById(android.R.id.text2)).setVisibility(View.GONE); break; case "generate-theme-simple": case "generate-theme-highlight": ((TextView) view.findViewById(android.R.id.text1)).setText(R.string.generate_theme_confirm); ((TextView) view.findViewById(android.R.id.text2)).setText(R.string.generate_theme_description); break; case "reset-matrix": ((TextView) view.findViewById(android.R.id.text1)).setText(R.string.reset_matrix_confirm); ((TextView) view.findViewById(android.R.id.text2)).setText(R.string.reset_matrix_description); break; case "reset-preferences": ((TextView) view.findViewById(android.R.id.text1)).setText(R.string.reset_preferences_confirm); ((TextView) view.findViewById(android.R.id.text2)).setText(R.string.reset_preferences_description); break; case "reset-cached-app-icons": ((TextView) view.findViewById(android.R.id.text1)).setText(R.string.reset_cached_app_icons_confirm); ((TextView) view.findViewById(android.R.id.text2)).setText(R.string.reset_cached_app_icons_description); break; case "crash-app": throw new IllegalStateException("Debug crash"); case "exit-app": ((TextView) view.findViewById(android.R.id.text1)).setText(R.string.exit_the_app_confirm); ((TextView) view.findViewById(android.R.id.text2)).setText(R.string.exit_the_app_description); break; case "reset-default-launcher": ((TextView) view.findViewById(android.R.id.text1)).setText(R.string.reset_default_launcher_confirm); ((TextView) view.findViewById(android.R.id.text2)).setText(R.string.reset_default_launcher_description); break; case "export-tags": case "export-modifications": case "export-apps": case "export-interface": case "export-widgets": case "export-history": case "export-backup": ((TextView) view.findViewById(android.R.id.text1)).setText(R.string.export_xml); ((TextView) view.findViewById(android.R.id.text2)).setText(R.string.export_description); break; } } @WorkerThread @SuppressLint("RestrictedApi") private static PreferenceGroup loadAllPreferences(@NonNull Context context) { boolean looperCreated = false; if (Looper.myLooper() == null) { //because inflateFromResource needs a looper and we don't have one, we make one Looper.prepare(); looperCreated = true; } // load the preference XML PreferenceManager manager = new PreferenceManager(context); PreferenceGroup root = manager.inflateFromResource(context, R.xml.preferences, null); // add `R.xml.preference_features` to rootPreference even if it means we'll get some duplicated key errors // it's easier to handle only one root manager.inflateFromResource(context, R.xml.preference_features, root.findPreference("feature-holder")); // we don't need the looper anymore if (looperCreated) { Looper.myLooper().quitSafely(); Looper.loop(); } return root; } @Override public void onStart() { super.onStart(); CustomDialogPreference preference = (CustomDialogPreference) getPreference(); TaskRunner.AsyncRunnable asyncWrite = null; final String key = preference.getKey(); switch (key) { case "device-admin": { if (!DeviceAdmin.isAdminActive(requireContext())) { ((AlertDialog) requireDialog()).getButton(DialogInterface.BUTTON_POSITIVE).performClick(); } break; } case "export-tags": asyncWrite = t -> { final Activity activity = Utilities.getActivity(getContext()); if (activity != null) FileUtils.writeSettingsFile(activity, "tags", w -> XmlExport.tagsXml(activity, w)); }; break; case "export-modifications": asyncWrite = t -> { final Activity activity = Utilities.getActivity(getContext()); if (activity != null) FileUtils.writeSettingsFile(activity, "modifications", w -> XmlExport.modificationsXml(activity, w)); }; break; case "export-apps": asyncWrite = t -> { final Activity activity = Utilities.getActivity(getContext()); if (activity != null) FileUtils.writeSettingsFile(activity, "applications", w -> XmlExport.applicationsXml(activity, w)); }; break; case "export-interface": { asyncWrite = t -> { final Activity activity = Utilities.getActivity(getContext()); if (activity != null) { final PreferenceGroup rootPreference = loadAllPreferences(activity); FileUtils.writeSettingsFile(activity, "interface", w -> XmlExport.interfaceXml(rootPreference, w)); } }; break; } case "export-preferences": { asyncWrite = t -> { final Activity activity = Utilities.getActivity(getContext()); if (activity != null) { final PreferenceGroup rootPreference = loadAllPreferences(activity); FileUtils.writeSettingsFile(activity, "settings", w -> XmlExport.preferencesXml(rootPreference, w)); } }; break; } case "export-widgets": { asyncWrite = t -> { final Activity activity = Utilities.getActivity(getContext()); if (activity != null) FileUtils.writeSettingsFile(activity, "widgets", w -> XmlExport.widgetsXml(activity, w)); }; break; } case "export-history": { asyncWrite = t -> { final Activity activity = Utilities.getActivity(getContext()); if (activity != null) FileUtils.writeSettingsFile(activity, "history", w -> XmlExport.historyXml(activity, w)); }; break; } case "export-backup": { asyncWrite = t -> { final Activity activity = Utilities.getActivity(getContext()); if (activity != null) { final PreferenceGroup rootPreference = loadAllPreferences(activity); FileUtils.writeSettingsFile(activity, "backup", w -> XmlExport.backupXml(rootPreference, w)); } }; break; } } if (asyncWrite != null) { { Dialog dialog = getDialog(); // disable positive button while we generate the file if (dialog instanceof AlertDialog) ((AlertDialog) dialog).getButton(DialogInterface.BUTTON_POSITIVE).setEnabled(false); } Utilities.runAsync(getLifecycle(), asyncWrite, (t) -> { Activity activity = Utilities.getActivity(getContext()); Dialog dialog = getDialog(); // enable positive button after we generate the file if (activity != null && dialog instanceof AlertDialog) ((AlertDialog) dialog).getButton(DialogInterface.BUTTON_POSITIVE).setEnabled(true); }); } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/preference/ContentLoadHelper.java ================================================ package rocks.tbog.tblauncher.preference; import android.content.Context; import android.content.SharedPreferences; import android.graphics.drawable.Drawable; import android.text.SpannableString; import android.util.Log; import android.util.Pair; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.StringRes; import androidx.core.graphics.drawable.DrawableCompat; import androidx.preference.MultiSelectListPreference; import androidx.preference.Preference; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Set; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.SettingsActivity; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.dataprovider.TagsProvider; import rocks.tbog.tblauncher.drawable.TextDrawable; import rocks.tbog.tblauncher.entry.ActionEntry; import rocks.tbog.tblauncher.entry.FilterEntry; import rocks.tbog.tblauncher.entry.StaticEntry; import rocks.tbog.tblauncher.entry.TagEntry; import rocks.tbog.tblauncher.handler.TagsHandler; import rocks.tbog.tblauncher.utils.PrefOrderedListHelper; import rocks.tbog.tblauncher.utils.UIColors; import rocks.tbog.tblauncher.utils.Utilities; public class ContentLoadHelper { public static final CategoryItem[] RESULT_POPUP_CATEGORIES = { new CategoryItem(R.string.popup_title_shortcut_dynamic, "dyn_shortcut"), new CategoryItem(R.string.popup_title_hist_fav, "prefs"), new CategoryItem(R.string.popup_title_customize, "customize"), new CategoryItem(R.string.popup_title_link, "links"), new CategoryItem(R.string.popup_title_debug, "debug"), }; public static OrderedMultiSelectListData generateResultPopupContent(@NonNull Context context, @NonNull SharedPreferences sharedPreferences) { final HashSet values = new HashSet<>(RESULT_POPUP_CATEGORIES.length); // get default values for (CategoryItem categoryItem : RESULT_POPUP_CATEGORIES) { categoryItem.updateText(context); values.add(categoryItem.value); } // get values from previous order final ArrayList orderedValues; { Set order = sharedPreferences.getStringSet("result-popup-order", Collections.emptySet()); orderedValues = new ArrayList<>(order); Collections.sort(orderedValues); } // sync current categories with previous order ArrayList newOrder = new ArrayList<>(RESULT_POPUP_CATEGORIES.length); for (String orderValue : orderedValues) { String valueName = PrefOrderedListHelper.getOrderedValueName(orderValue); if (values.remove(valueName)) newOrder.add(valueName); } for (CategoryItem categoryItem : RESULT_POPUP_CATEGORIES) { if (values.remove(categoryItem.value)) newOrder.add(categoryItem.value); } // make new order values orderedValues.clear(); orderedValues.addAll(PrefOrderedListHelper.getOrderedArrayList(newOrder)); // initialize entries using the ordered values CharSequence[] entries = new CharSequence[orderedValues.size()]; CharSequence[] entryValues = new CharSequence[orderedValues.size()]; for (int i = 0; i < orderedValues.size(); i += 1) { String orderValue = orderedValues.get(i); String value = PrefOrderedListHelper.getOrderedValueName(orderValue); for (CategoryItem categoryItem : RESULT_POPUP_CATEGORIES) { if (categoryItem.value.equals(value)) { entries[i] = categoryItem.text; entryValues[i] = categoryItem.value; break; } } } return new OrderedMultiSelectListData(entries, entryValues, null, orderedValues); } public static OrderedMultiSelectListData generateTagsMenuContent(@NonNull Context context, @NonNull SharedPreferences sharedPreferences) { TagsHandler tagsHandler = TBApplication.tagsHandler(context); Set validTags = tagsHandler.getValidTags(); TagsProvider tagsProvider = TBApplication.dataHandler(context).getTagsProvider(); Set tagsMenuListValues = sharedPreferences.getStringSet("tags-menu-list", Collections.emptySet()); ArrayList prefEntries = new ArrayList<>(validTags); // make sure we have the selected values as entries (so the user can remove them) for (String tagName : tagsMenuListValues) { if (!validTags.contains(tagName)) prefEntries.add(0, tagName); } // sort entries Collections.sort(prefEntries, String.CASE_INSENSITIVE_ORDER); int layoutDirection = context.getResources().getConfiguration().getLayoutDirection(); int size = context.getResources().getDimensionPixelSize(R.dimen.icon_preview_size); // set preference entries int count = prefEntries.size(); CharSequence[] entries = new CharSequence[count]; for (int idx = 0; idx < count; idx += 1) { String tagName = prefEntries.get(idx); if (tagsProvider != null) { TagEntry tagEntry = tagsProvider.getTagEntry(tagName); Drawable tagIcon = tagEntry.getIconDrawable(context); tagIcon.setBounds(0, 0, size, size); SpannableString name = Utilities.addDrawableBeforeString(tagName, tagIcon, layoutDirection); entries[idx] = name; } else { entries[idx] = tagName; } } // set preference values CharSequence[] entryValues = prefEntries.toArray(new String[0]); // set default values if we need them HashSet defaultValues = new HashSet<>(); for (String tagName : validTags) { if (defaultValues.size() >= 5) break; defaultValues.add(tagName); } Set orderedValues = sharedPreferences.getStringSet("tags-menu-order", null); return new OrderedMultiSelectListData(entries, entryValues, defaultValues, orderedValues); } public static Pair generateStaticEntryList(@NonNull Context context, @NonNull List entryToShowList) { final int size = entryToShowList.size(); final CharSequence[] entries = new CharSequence[size]; final CharSequence[] entryValues = new CharSequence[size]; int layoutDirection = context.getResources().getConfiguration().getLayoutDirection(); int iconSize = context.getResources().getDimensionPixelSize(R.dimen.icon_preview_size); int tintColor = UIColors.getThemeColor(context, com.google.android.material.R.attr.colorAccent); for (int idx = 0; idx < size; idx++) { StaticEntry entry = entryToShowList.get(idx); if (entry instanceof TagEntry) { Drawable tagIcon = entry.getIconDrawable(context); if (tagIcon instanceof TextDrawable) ((TextDrawable) tagIcon).setTextColor(tintColor); tagIcon.setBounds(0, 0, iconSize, iconSize); SpannableString name = Utilities.addDrawableBeforeString(entry.getName(), tagIcon, layoutDirection); entries[idx] = name; } else if (entry instanceof ActionEntry || entry instanceof FilterEntry) { Drawable iconAction = entry.getDefaultDrawable(context); if (iconAction == null) { entries[idx] = entry.getName(); } else { DrawableCompat.setTint(iconAction, tintColor); iconAction.setBounds(0, 0, iconSize, iconSize); SpannableString name = Utilities.addDrawableBeforeString(entry.getName(), iconAction, layoutDirection); entries[idx] = name; } } else { entries[idx] = entry.getName(); } entryValues[idx] = entry.id; } return new Pair<>(entries, entryValues); } public static void setMultiListValues(@Nullable Preference preference, @NonNull Pair values, @Nullable Set selected) { if (!(preference instanceof MultiSelectListPreference)) return; MultiSelectListPreference multiSelectList = (MultiSelectListPreference) preference; multiSelectList.setEntryValues(values.first); multiSelectList.setEntries(values.second); if (selected != null) multiSelectList.setValues(selected); } public static class CategoryItem { /** * String resource used when inflating the popup menu. * Currently we use this to generate the preference menu as well */ @StringRes public final int textId; /** * Value stored in the preference. Must not change with language. */ public final String value; /** * String to be used for the preference menu. */ private String text = null; public CategoryItem(int textId, String value) { this.textId = textId; this.value = value; } /** * Using context generate the string for the preference menu * * @param context so we can get the string from the resource id */ public void updateText(@NonNull Context context) { text = context.getString(textId); } } public static class OrderedMultiSelectListData { private final CharSequence[] entries; private final CharSequence[] entryValues; private final Set defaultValues; private final ArrayList orderedValues; public OrderedMultiSelectListData(CharSequence[] entries, CharSequence[] entryValues, Set defaultValues, @Nullable Collection orderedValues) { this.entries = entries; this.entryValues = entryValues; this.defaultValues = defaultValues; if (orderedValues == null || orderedValues.isEmpty()) { // if no order found this.orderedValues = PrefOrderedListHelper.getOrderedArrayList(entryValues); } else { this.orderedValues = new ArrayList<>(orderedValues); // sort entries Collections.sort(this.orderedValues); } } public void reloadOrderedValues(@NonNull SharedPreferences sharedPreferences, @NonNull SettingsActivity.SettingsFragment settings, String orderKey) { orderedValues.clear(); orderedValues.addAll(sharedPreferences.getStringSet(orderKey, Collections.emptySet())); Collections.sort(orderedValues); setOrderedListValues(settings.findPreference(orderKey)); } public void setMultiListValues(@Nullable Preference preference) { if (!(preference instanceof MultiSelectListPreference)) return; MultiSelectListPreference multiSelectList = (MultiSelectListPreference) preference; if (entries != null) multiSelectList.setEntries(entries); if (entryValues != null) multiSelectList.setEntryValues(entryValues); if (defaultValues != null && multiSelectList.getValues().isEmpty()) multiSelectList.setValues(defaultValues); Log.d("pref", "setMultiListValues " + preference.getKey() + "\n entries=" + Arrays.toString(entries) + "\n values=" + Arrays.toString(entryValues)); } public void setOrderedListValues(@Nullable Preference preference) { if (!(preference instanceof MultiSelectListPreference)) return; MultiSelectListPreference listPref = (MultiSelectListPreference) preference; ArrayList orderedEntries = new ArrayList<>(orderedValues.size()); ArrayList orderedEntryValues = new ArrayList<>(orderedValues.size()); for (String orderedValue : orderedValues) { String value = PrefOrderedListHelper.getOrderedValueName(orderedValue); for (int i = 0; i < entryValues.length; i += 1) { if (entryValues[i].equals(value)) { orderedEntries.add(entries[i]); orderedEntryValues.add(entryValues[i]); break; } } } listPref.setEntries(orderedEntries.toArray(new CharSequence[0])); listPref.setEntryValues(orderedEntryValues.toArray(new CharSequence[0])); Log.d("pref", "setOrderedListValues " + listPref.getKey() + "\n entries=" + orderedEntries + "\n values=" + orderedValues); } public List getOrderedListValues() { return orderedValues; } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/preference/CustomDialogPreference.java ================================================ package rocks.tbog.tblauncher.preference; import android.content.Context; import android.content.res.TypedArray; import android.graphics.drawable.Drawable; import android.util.AttributeSet; import android.view.View; import android.widget.ImageView; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.preference.PreferenceViewHolder; import java.util.Collections; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.utils.PrefCache; import rocks.tbog.tblauncher.utils.UIColors; import rocks.tbog.tblauncher.utils.UISizes; public class CustomDialogPreference extends androidx.preference.DialogPreference { private Object mValue = null; public CustomDialogPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); } public CustomDialogPreference(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); } public CustomDialogPreference(Context context, AttributeSet attrs) { super(context, attrs); } public CustomDialogPreference(Context context) { super(context); } public Object getValue() { return mValue; } public void setValue(Object value) { mValue = value; } public boolean persistValue() { Object value = getValue(); if (value instanceof String) return persistString((String) value); else if (value instanceof Integer) return persistInt((Integer) value); else if (value instanceof Float) return persistFloat((Float) value); return false; } public boolean persistValueIfAllowed() { if (callChangeListener(getValue())) { return persistValue(); } return false; } public boolean persistValueIfAllowed(Object value) { if (callChangeListener(value)) { setValue(value); return persistValue(); } return false; } @Override protected void onSetInitialValue(@Nullable Object defaultValue) { if (getValue() == null) setValue(defaultValue); persistValue(); } @Override protected Object onGetDefaultValue(TypedArray a, int index) { try { return a.getInteger(index, 0); } catch (UnsupportedOperationException e) { try { return a.getFloat(index, 0f); } catch (UnsupportedOperationException ignored) { return a.getString(index); } } } @Override public void onBindViewHolder(@NonNull PreferenceViewHolder holder) { super.onBindViewHolder(holder); final String key = getKey(); final var pref = getSharedPreferences(); final var prefMap = pref != null ? pref.getAll() : Collections.emptyMap(); Object value = prefMap.get(key); { View view = holder.findViewById(R.id.prefAmountPreview); if (view instanceof TextView) { String text = null; if (value instanceof Integer) { Integer amount = ((Integer) value); text = amount > 0 ? ("+" + amount) : amount.toString(); if (key.contains("-scale")) text += "%"; } ((TextView) view).setText(text); return; } } { View view = holder.findViewById(R.id.prefColorPreview); if (view instanceof ImageView) { int color = 0xFFffffff; if (value instanceof Integer) color = (int) value | 0xFF000000; Context ctx = getContext(); float radius = ctx.getResources().getDimension(R.dimen.color_preview_radius); int border = UISizes.dp2px(ctx, 1); Drawable drawable = UIColors.getPreviewDrawable(color, border, radius); ((ImageView) view).setImageDrawable(drawable); return; } } { View view = holder.findViewById(R.id.prefAlphaPreview); if (view instanceof TextView) { String text = null; if (value instanceof Integer) text = ((Integer) value) * 100 / 255 + "%"; ((TextView) view).setText(text); return; } } { View view = holder.findViewById(R.id.prefSizePreview); if (view instanceof TextView) { if (value instanceof Float) { float size = (float) value; ((TextView) view).setText(view.getResources().getString(R.string.size_float, size)); } else { int size = -1; if ("result-search-cap".equals(key)) size = PrefCache.getResultSearcherCap(getContext()); else if (value instanceof Integer) size = (int) value; ((TextView) view).setText(view.getResources().getString(R.string.size, size)); } } } { View view = holder.findViewById(R.id.prefShadowPreview); if (view instanceof TextView) { // used for shadow Object value2 = prefMap.get(key.replace("-dx", "-dy")); Object value3 = prefMap.get(key.replace("-dx", "-radius")); if (value instanceof Float && value2 instanceof Float && value3 instanceof Float) { float v1 = (float) value; float v2 = (float) value2; float v3 = (float) value3; ((TextView) view).setText(view.getResources().getString(R.string.shadow_preview, v1, v2, v3)); } else { ((TextView) view).setText(""); } } } { View view = holder.findViewById(R.id.prefOffsetPreview); if (view instanceof TextView) { // used for shadow Object value2 = prefMap.get(key.replace("-dx", "-dy")); if (value instanceof Float && value2 instanceof Float) { float v1 = (float) value; float v2 = (float) value2; ((TextView) view).setText(view.getResources().getString(R.string.offset_preview, v1, v2)); } else { ((TextView) view).setText(""); } } } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/preference/EditAddResetEditor.java ================================================ package rocks.tbog.tblauncher.preference; import android.app.Application; import android.app.Dialog; import android.content.Context; import android.content.SharedPreferences; import android.view.View; import androidx.annotation.NonNull; import androidx.annotation.WorkerThread; import androidx.fragment.app.FragmentActivity; import androidx.fragment.app.FragmentManager; import androidx.lifecycle.AndroidViewModel; import rocks.tbog.tblauncher.utils.Utilities; public abstract class EditAddResetEditor extends AndroidViewModel { private FragmentManager mFragmentManager = null; protected FragmentManager getFragmentManager() { return mFragmentManager; } public EditAddResetEditor(@NonNull Application application) { super(application); } public void loadDefaults(@NonNull Context context) { Utilities.runAsync(()-> loadDefaultsInternal(context)); } public void loadData(@NonNull Context context, @NonNull SharedPreferences prefs) { Utilities.runAsync(()-> loadDataInternal(context, prefs)); } @WorkerThread public abstract void loadDefaultsInternal(@NonNull Context context); @WorkerThread public abstract void loadDataInternal(@NonNull Context context, @NonNull SharedPreferences prefs); public abstract void applyChanges(@NonNull Context context); public abstract void bindEditView(@NonNull View view); public abstract void bindAddView(@NonNull View view); public void onStartLifecycle(@NonNull Dialog dialog, @NonNull BasePreferenceDialog owner) { FragmentActivity activity = owner.getActivity(); if (activity != null) mFragmentManager = activity.getSupportFragmentManager(); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/preference/EditAddResetPreferenceDialog.java ================================================ package rocks.tbog.tblauncher.preference; import android.app.Dialog; import android.content.Context; import android.os.Bundle; import android.util.Log; import android.view.View; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.preference.PreferenceManager; import java.util.Arrays; public abstract class EditAddResetPreferenceDialog extends BasePreferenceDialog { private static final String TAG = EditAddResetPreferenceDialog.class.getSimpleName(); protected EditAddResetEditor mEditor = null; @Nullable public static T newInstance(String key, Class clazz) { T fragment; try { fragment = clazz.newInstance(); } catch (ReflectiveOperationException e) { Log.e(TAG, "no constructor?", e); return null; } final Bundle b = new Bundle(1); b.putString(ARG_KEY, key); fragment.setArguments(b); return fragment; } @NonNull protected abstract String[] getEditAddResetKeys(); @Nullable protected abstract EditAddResetEditor newEditor(); @NonNull @Override public Dialog onCreateDialog(Bundle savedInstanceState) { mEditor = newEditor(); return super.onCreateDialog(savedInstanceState); } @Override public void onDialogClosed(boolean positiveResult) { if (!positiveResult) return; if (mEditor != null) mEditor.applyChanges(requireContext()); } @Override protected void onBindDialogView(View view) { super.onBindDialogView(view); if (mEditor == null) return; Context context = requireContext(); String key = getPreference().getKey(); int keyIndex = Arrays.asList(getEditAddResetKeys()).indexOf(key); switch (keyIndex) { case 0: // edit mEditor.loadData(context, PreferenceManager.getDefaultSharedPreferences(context)); mEditor.bindEditView(view); break; case 1: // add mEditor.loadData(context, PreferenceManager.getDefaultSharedPreferences(context)); mEditor.bindAddView(view); break; case 2: // reset mEditor.loadDefaults(context); mEditor.bindEditView(view); break; default: Toast.makeText(context, "`" + key + "` not found", Toast.LENGTH_SHORT).show(); break; } } @Override public void onStart() { super.onStart(); if (mEditor != null) mEditor.onStartLifecycle(requireDialog(), this); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/preference/EditSearchEnginesPreferenceDialog.java ================================================ package rocks.tbog.tblauncher.preference; import android.app.Application; import android.app.Dialog; import android.content.Context; import android.content.SharedPreferences; import android.graphics.Paint; import android.graphics.Typeface; import android.text.Spannable; import android.text.SpannableStringBuilder; import android.text.TextUtils; import android.text.style.UnderlineSpan; import android.view.View; import android.widget.Adapter; import android.widget.ArrayAdapter; import android.widget.CheckedTextView; import android.widget.EditText; import android.widget.ListView; import android.widget.TextView; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.collection.ArraySet; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.ViewModelProvider; import androidx.preference.PreferenceManager; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.Comparator; import java.util.Objects; import java.util.Set; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.dataprovider.SearchProvider; import rocks.tbog.tblauncher.ui.ListPopup; import rocks.tbog.tblauncher.ui.dialog.EditTextDialog; import rocks.tbog.tblauncher.utils.SimpleTextWatcher; import rocks.tbog.tblauncher.utils.ViewHolderAdapter; import rocks.tbog.tblauncher.utils.ViewHolderListAdapter; public class EditSearchEnginesPreferenceDialog extends EditAddResetPreferenceDialog { @Nullable public static EditSearchEnginesPreferenceDialog newInstance(String key) { return EditAddResetPreferenceDialog.newInstance(key, EditSearchEnginesPreferenceDialog.class); } @Nullable @Override protected EditAddResetEditor newEditor() { return new ViewModelProvider(this).get(EditSearchEngines.class); } @NonNull protected String[] getEditAddResetKeys() { return new String[]{"edit-search-engines", "add-search-engine", "reset-search-engines"}; } public static class EditSearchEngines extends EditAddResetEditor { private final MutableLiveData> searchEngineInfoList = new MutableLiveData<>(); private final MutableLiveData defaultProviderName = new MutableLiveData<>(); private final MutableLiveData addSearchEngineName = new MutableLiveData<>(); private final MutableLiveData addSearchEngineUrl = new MutableLiveData<>(); public EditSearchEngines(@NonNull Application application) { super(application); } public LiveData> getSearchEngineInfoList() { return searchEngineInfoList; } public LiveData getDefaultProviderName() { return defaultProviderName; } public void setDefaultProviderName(String name) { defaultProviderName.setValue(name); } public void updateSearchEngineInfoList(SearchEngineInfo info) { ArrayList arrayList = searchEngineInfoList.getValue(); if (arrayList == null || arrayList.contains(info)) searchEngineInfoList.setValue(arrayList); } @Override public void loadDefaultsInternal(@NonNull Context context) { final ArrayList list = new ArrayList<>(0); Set defaultSearchProviders = SearchProvider.getDefaultSearchProviders(context); list.ensureCapacity(defaultSearchProviders.size()); for (String searchProvider : defaultSearchProviders) { SearchEngineInfo searchEngineInfo = new SearchEngineInfo(searchProvider); searchEngineInfo.selected = true; list.add(searchEngineInfo); } Collections.sort(list, Comparator.comparing(lhs -> lhs.provider)); searchEngineInfoList.postValue(list); defaultProviderName.postValue("Google"); } @Override public void loadDataInternal(@NonNull Context context, @NonNull SharedPreferences prefs) { final ArrayList list = new ArrayList<>(0); // load search engines Set availableSearchProviders = SearchProvider.getAvailableSearchProviders(context, prefs); Set selectedProviderNames = SearchProvider.getSelectedProviderNames(context, prefs); list.ensureCapacity(availableSearchProviders.size()); for (String searchProvider : availableSearchProviders) { SearchEngineInfo searchEngineInfo = new SearchEngineInfo(searchProvider); searchEngineInfo.selected = selectedProviderNames.contains(searchEngineInfo.name); list.add(searchEngineInfo); } Collections.sort(list, Comparator.comparing(lhs -> lhs.provider)); searchEngineInfoList.postValue(list); // get default search engine name String providerName = prefs.getString("default-search-provider", null); if (providerName == null || providerName.isEmpty()) defaultProviderName.postValue(list.isEmpty() ? "" : list.get(0).name); else defaultProviderName.postValue(providerName); } public void applyChanges(@NonNull Context context) { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); Set availableProviders = new ArraySet<>(); Set selectedProviderNames = new ArraySet<>(); String addName = addSearchEngineName.getValue(); String addUrl = addSearchEngineUrl.getValue(); if (addName != null && addUrl != null) { String name = SearchProvider.sanitizeProviderName(addName).trim(); String url = SearchProvider.sanitizeProviderUrl(addUrl).trim(); if (!name.isEmpty() && !url.isEmpty()) { String searchProvider = SearchProvider.makeProvider(name, url); availableProviders.add(searchProvider); selectedProviderNames.add(name); } } ArrayList searchEngineList = getSearchEngineInfoList().getValue(); if (searchEngineList != null) { for (SearchEngineInfo searchEngineInfo : searchEngineList) { if (searchEngineInfo.action == SearchEngineInfo.Action.DELETE) continue; availableProviders.add(SearchProvider.makeProvider(searchEngineInfo.name, searchEngineInfo.url)); if (searchEngineInfo.selected) selectedProviderNames.add(searchEngineInfo.name); } } prefs.edit() .putStringSet("available-search-providers", availableProviders) .putStringSet("selected-search-provider-names", selectedProviderNames) .putString("default-search-provider", getDefaultProviderName().getValue()) .apply(); TBApplication.dataHandler(context).reloadProviders(); } public void bindEditView(@NonNull View view) { ListView listView = view.findViewById(android.R.id.list); listView.setOnItemClickListener((list, itemView, position, id) -> { Adapter adapter = list.getAdapter(); if (adapter instanceof SearchEngineAdapter) { SearchEngineAdapter searchEngineAdapter = (SearchEngineAdapter) adapter; SearchEngineInfo info = searchEngineAdapter.getItem(position); info.selected = !info.selected; updateSearchEngineInfoList(info); } }); listView.setOnItemLongClickListener((list, itemView, position, id) -> { Adapter adapter = list.getAdapter(); if (!(adapter instanceof SearchEngineAdapter)) return false; SearchEngineAdapter searchEngineAdapter = (SearchEngineAdapter) adapter; SearchEngineInfo info = searchEngineAdapter.getItem(position); if (info.action == SearchEngineInfo.Action.DELETE) { String provider = SearchProvider.makeProvider(info.name, info.url); info.action = info.provider.equals(provider) ? SearchEngineInfo.Action.NONE : SearchEngineInfo.Action.RENAME; updateSearchEngineInfoList(info); } else { Context ctx = list.getContext(); ArrayAdapter arrayAdapter = new ArrayAdapter<>(ctx, android.R.layout.simple_list_item_1); ListPopup popup = ListPopup.create(ctx, arrayAdapter); if (!info.name.equals(getDefaultProviderName().getValue()) && info.selected) arrayAdapter.add(new ListPopup.Item(ctx, R.string.search_engine_set_default)); arrayAdapter.add(new ListPopup.Item(ctx, R.string.menu_action_rename)); arrayAdapter.add(new ListPopup.Item(ctx, R.string.search_engine_edit_url)); arrayAdapter.add(new ListPopup.Item(ctx, R.string.menu_action_delete)); popup.setOnItemClickListener((popupAdapter, popupItemView, popupPosition) -> { Object object = popupAdapter.getItem(popupPosition); if (!(object instanceof ListPopup.Item)) return; ListPopup.Item item = (ListPopup.Item) object; if (item.stringId == R.string.search_engine_set_default) { setDefaultProviderName(info.name); } else if (item.stringId == R.string.menu_action_rename) { launchRenameDialog(listView.getContext(), info); } else if (item.stringId == R.string.search_engine_edit_url) { launchEditUrlDialog(listView.getContext(), info); } else if (item.stringId == R.string.menu_action_delete) { info.action = SearchEngineInfo.Action.DELETE; updateSearchEngineInfoList(info); } }); popup.setModal(true); popup.setDimAmount(0.5f); popup.show(itemView); } return true; }); } public void bindAddView(@NonNull View view) { EditText editText; { String name = addSearchEngineName.getValue(); editText = view.findViewById(android.R.id.text1); if (!TextUtils.isEmpty(name)) editText.setText(name); editText.addTextChangedListener(new SimpleTextWatcher() { @Override public void onTextChanged(String newValue) { addSearchEngineName.setValue(newValue); } }); } { String urlValue = addSearchEngineUrl.getValue(); editText = view.findViewById(android.R.id.text2); if (!TextUtils.isEmpty(urlValue)) editText.setText(urlValue); editText.addTextChangedListener(new SimpleTextWatcher() { @Override public void onTextChanged(String newValue) { addSearchEngineUrl.setValue(newValue); } }); } } private void launchRenameDialog(Context ctx, SearchEngineInfo info) { new EditTextDialog.Builder(ctx) .setTitle(R.string.title_rename_search_engine) .setInitialText(info.name) .setConfirmListener(R.string.menu_action_rename, name -> { String newName = name != null ? SearchProvider.sanitizeProviderName(name.toString()).trim() : null; boolean isValid = !TextUtils.isEmpty(newName); ArrayList searchEngineList = getSearchEngineInfoList().getValue(); if (isValid && searchEngineList != null) { for (SearchEngineInfo searchEngineInfo : searchEngineList) { if (searchEngineInfo == info) continue; if (SearchProvider.getProviderName(searchEngineInfo.provider).equals(newName) || searchEngineInfo.name.equals(newName)) { isValid = false; break; } } } if (!isValid) { Toast.makeText(ctx, ctx.getString(R.string.invalid_rename_search_engine, newName), Toast.LENGTH_LONG).show(); return; } // Set new name if (TextUtils.equals(defaultProviderName.getValue(), info.name)) setDefaultProviderName(newName); info.name = newName; info.action = SearchProvider.getProviderName(info.provider).equals(info.name) ? SearchEngineInfo.Action.NONE : SearchEngineInfo.Action.RENAME; updateSearchEngineInfoList(info); }) .setNegativeButton(android.R.string.cancel, null) .getDialog() .show(getFragmentManager(), "rename"); } private void launchEditUrlDialog(Context ctx, SearchEngineInfo info) { new EditTextDialog.Builder(ctx) .setTitle(R.string.title_edit_url_search_engine) .setHint(info.name) .setInitialText(info.url) .setConfirmListener(R.string.confirm_edit_url_search_engine, (newName) -> { if (newName == null) return; // Set new name info.url = SearchProvider.sanitizeProviderUrl(newName.toString()).trim(); if (info.url.equals(SearchProvider.getProviderUrl(info.provider))) { info.action = SearchProvider.getProviderName(info.provider).equals(info.name) ? SearchEngineInfo.Action.NONE : SearchEngineInfo.Action.RENAME; } else { info.action = SearchEngineInfo.Action.RENAME; } updateSearchEngineInfoList(info); }) .setNegativeButton(android.R.string.cancel, null) .getDialog() .show(getFragmentManager(), "edit_url"); } public void onStartLifecycle(@NonNull Dialog dialog, @NonNull BasePreferenceDialog owner) { super.onStartLifecycle(dialog, owner); ListView listView = dialog.findViewById(android.R.id.list); if (listView != null) { ArrayList list = getSearchEngineInfoList().getValue(); if (list == null) list = new ArrayList<>(); SearchEngineAdapter adapter = new SearchEngineAdapter(list); listView.setAdapter(adapter); getSearchEngineInfoList().observe(owner, adapter::replaceItems); } } } public static class SearchEngineInfo { @NonNull public final String provider; public String name; public String url; public boolean selected; public Action action = Action.NONE; public enum Action {NONE, DELETE, RENAME} public SearchEngineInfo(@NonNull String searchProvider) { provider = searchProvider; name = SearchProvider.getProviderName(searchProvider); url = SearchProvider.getProviderUrl(searchProvider); } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; SearchEngineInfo that = (SearchEngineInfo) o; return selected == that.selected && provider.equals(that.provider) && Objects.equals(name, that.name) && Objects.equals(url, that.url) && action == that.action; } @Override public int hashCode() { return Objects.hash(provider, name, url, selected, action); } } public static class SearchEngineAdapter extends ViewHolderListAdapter { SearchEngineAdapter(@NonNull ArrayList list) { super(TagViewHolder.class, android.R.layout.simple_list_item_checked, list); } @Override protected int getItemViewTypeLayout(int viewType) { if (viewType == 1) return android.R.layout.simple_list_item_1; return super.getItemViewTypeLayout(viewType); } public int getItemViewType(int position) { return getItem(position).action == SearchEngineInfo.Action.DELETE ? 1 : 0; } public int getViewTypeCount() { return 2; } public void replaceItems(Collection list) { if (list != mList) { mList.clear(); mList.addAll(list); } notifyDataSetChanged(); } } public static class TagViewHolder extends ViewHolderAdapter.ViewHolder { private final TextView text1View; public TagViewHolder(View itemView) { super(itemView); text1View = itemView.findViewById(android.R.id.text1); text1View.setLines(2); } @Override protected void setContent(SearchEngineInfo content, int position, @NonNull ViewHolderAdapter> adapter) { SpannableStringBuilder enhancedText = new SpannableStringBuilder(); enhancedText .append(content.name) .setSpan(new UnderlineSpan(), 0, content.name.length(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE); enhancedText .append("\n") .append(content.url); text1View.setText(enhancedText); text1View.setTypeface(null, content.action == SearchEngineInfo.Action.RENAME ? Typeface.BOLD : Typeface.NORMAL); if (text1View instanceof CheckedTextView) ((CheckedTextView) text1View).setChecked(content.selected); if (content.action == SearchEngineInfo.Action.DELETE) { text1View.setPaintFlags(text1View.getPaintFlags() | Paint.STRIKE_THRU_TEXT_FLAG); } } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/preference/EditSearchHintPreferenceDialog.java ================================================ package rocks.tbog.tblauncher.preference; import android.app.Application; import android.app.Dialog; import android.content.Context; import android.content.SharedPreferences; import android.graphics.Paint; import android.graphics.Typeface; import android.text.TextUtils; import android.view.View; import android.widget.Adapter; import android.widget.ArrayAdapter; import android.widget.CheckedTextView; import android.widget.EditText; import android.widget.ListView; import android.widget.TextView; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.collection.ArraySet; import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.ViewModelProvider; import androidx.preference.PreferenceManager; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.Comparator; import java.util.List; import java.util.Objects; import java.util.Set; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.ui.ListPopup; import rocks.tbog.tblauncher.utils.DialogHelper; import rocks.tbog.tblauncher.utils.SimpleTextWatcher; import rocks.tbog.tblauncher.utils.ViewHolderAdapter; import rocks.tbog.tblauncher.utils.ViewHolderListAdapter; public class EditSearchHintPreferenceDialog extends EditAddResetPreferenceDialog { @Nullable public static EditSearchHintPreferenceDialog newInstance(String key) { return EditAddResetPreferenceDialog.newInstance(key, EditSearchHintPreferenceDialog.class); } @Nullable @Override protected EditAddResetEditor newEditor() { return new ViewModelProvider(this).get(EditSearchHint.class); } @NonNull protected String[] getEditAddResetKeys() { return new String[]{"edit-search-hint", "add-search-hint", "reset-search-hint"}; } public static class EditSearchHint extends EditAddResetEditor { private final MutableLiveData> searchHintList = new MutableLiveData<>(); private final MutableLiveData addHintName = new MutableLiveData<>(); public EditSearchHint(@NonNull Application application) { super(application); } public void updateSearchHintList(SearchHintInfo info) { ArrayList arrayList = searchHintList.getValue(); if (arrayList == null || arrayList.contains(info)) searchHintList.setValue(arrayList); } public void applyChanges(@NonNull Context context) { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); Set availableHints = new ArraySet<>(); Set selectedHints = new ArraySet<>(); String mAddHint = addHintName.getValue(); if (mAddHint != null) { String name = mAddHint.trim(); availableHints.add(name); selectedHints.add(name); } ArrayList searchHintInfoList = searchHintList.getValue(); if (searchHintInfoList != null) { for (SearchHintInfo hintInfo : searchHintInfoList) { if (hintInfo.action == SearchHintInfo.Action.DELETE) continue; availableHints.add(hintInfo.text); if (hintInfo.selected) selectedHints.add(hintInfo.text); } } prefs.edit() .putStringSet("available-search-hints", availableHints) .putStringSet("selected-search-hints", selectedHints) .apply(); TBApplication.dataHandler(context).reloadProviders(); } public void bindEditView(@NonNull View view) { ListView listView = view.findViewById(android.R.id.list); listView.setOnItemClickListener((list, itemView, position, id) -> { Adapter adapter = list.getAdapter(); if (adapter instanceof SearchHintAdapter) { SearchHintInfo info = ((SearchHintAdapter) adapter).getItem(position); info.selected = !info.selected; updateSearchHintList(info); } }); listView.setOnItemLongClickListener((list, itemView, position, id) -> { Adapter adapter = list.getAdapter(); if (!(adapter instanceof SearchHintAdapter)) return false; SearchHintInfo info = ((SearchHintAdapter) adapter).getItem(position); if (info.action == SearchHintInfo.Action.DELETE) { info.action = info.text.equals(info.hint) ? SearchHintInfo.Action.NONE : SearchHintInfo.Action.RENAME; updateSearchHintList(info); } else { Context ctx = list.getContext(); ArrayAdapter arrayAdapter = new ArrayAdapter<>(ctx, android.R.layout.simple_list_item_1); arrayAdapter.add(new ListPopup.Item(ctx, R.string.menu_action_rename)); arrayAdapter.add(new ListPopup.Item(ctx, R.string.menu_action_delete)); ListPopup.create(ctx, arrayAdapter) .setOnItemClickListener((popupAdapter, popupItemView, popupPosition) -> { Object object = popupAdapter.getItem(popupPosition); if (!(object instanceof ListPopup.Item)) return; ListPopup.Item item = (ListPopup.Item) object; if (item.stringId == R.string.menu_action_rename) { launchRenameDialog(listView.getContext(), info); } else if (item.stringId == R.string.menu_action_delete) { info.action = SearchHintInfo.Action.DELETE; updateSearchHintList(info); } }) .setModal(true) .setDimAmount(0.5f) .show(itemView); } return true; }); } public void bindAddView(@NonNull View view) { String name = addHintName.getValue(); EditText editText = view.findViewById(android.R.id.text1); if (!TextUtils.isEmpty(name)) editText.setText(name); editText.addTextChangedListener(new SimpleTextWatcher() { @Override public void onTextChanged(String newValue) { addHintName.setValue(newValue); } }); } private void launchRenameDialog(@NonNull Context ctx, @NonNull SearchHintInfo info) { DialogHelper.makeRenameDialog(ctx, info.text, (dialog, newName) -> { boolean isValid = true; ArrayList searchHintInfoList = searchHintList.getValue(); if (searchHintInfoList != null) { for (SearchHintInfo hintInfo : searchHintInfoList) { if (info.equals(hintInfo)) continue; if (hintInfo.hint.equals(newName) || hintInfo.text.equals(newName)) { isValid = false; break; } } } if (!isValid) { Toast.makeText(ctx, ctx.getString(R.string.invalid_rename_search_engine, newName), Toast.LENGTH_LONG).show(); return; } // Set new name info.text = newName; info.action = info.hint.equals(info.text) ? SearchHintInfo.Action.NONE : SearchHintInfo.Action.RENAME; updateSearchHintList(info); }) .setTitle(R.string.title_rename_search_hint) .setNegativeButton(android.R.string.cancel, null) .getDialog() .show(getFragmentManager(), "rename"); } public void onStartLifecycle(@NonNull Dialog dialog, @NonNull BasePreferenceDialog owner) { super.onStartLifecycle(dialog, owner); ListView listView = dialog.findViewById(android.R.id.list); if (listView != null) { ArrayList list = searchHintList.getValue(); if (list == null) list = new ArrayList<>(); SearchHintAdapter adapter = new SearchHintAdapter(list); listView.setAdapter(adapter); searchHintList.observe(owner, adapter::replaceItems); } } public void loadDataInternal(@NonNull Context context, @NonNull SharedPreferences prefs) { ArrayList list = new ArrayList<>(0); Set availableHints = prefs.getStringSet("available-search-hints", null); if (availableHints == null) { availableHints = new ArraySet<>(); Collections.addAll(availableHints, context.getResources().getStringArray(R.array.defaultSearchHints)); } Set selectedHints = prefs.getStringSet("selected-search-hints", null); if (selectedHints == null) { selectedHints = new ArraySet<>(availableHints); } list.ensureCapacity(availableHints.size()); for (String hint : availableHints) { SearchHintInfo hintInfo = new SearchHintInfo(hint); hintInfo.selected = selectedHints.contains(hintInfo.hint); list.add(hintInfo); } Collections.sort(list, Comparator.comparing(lhs -> lhs.hint)); searchHintList.postValue(list); } public void loadDefaultsInternal(@NonNull Context context) { ArrayList list = new ArrayList<>(0); String[] defaultSearchHints = context.getResources().getStringArray(R.array.defaultSearchHints); list.ensureCapacity(defaultSearchHints.length); for (String searchHint : defaultSearchHints) { SearchHintInfo searchEngineInfo = new SearchHintInfo(searchHint); searchEngineInfo.selected = true; list.add(searchEngineInfo); } Collections.sort(list, Comparator.comparing(lhs -> lhs.hint)); searchHintList.postValue(list); } } public static class SearchHintInfo { @NonNull public final String hint; public String text; public boolean selected; public Action action = Action.NONE; public enum Action {NONE, DELETE, RENAME} public SearchHintInfo(@NonNull String hintText) { hint = hintText; text = hintText; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; SearchHintInfo that = (SearchHintInfo) o; return selected == that.selected && action == that.action && hint.equals(that.hint) && text.equals(that.text); } @Override public int hashCode() { return Objects.hash(hint, text, selected, action); } } public static class SearchHintAdapter extends ViewHolderListAdapter { public SearchHintAdapter(@NonNull List list) { super(SearchHintVH.class, android.R.layout.simple_list_item_checked, list); } @Override public int getItemViewTypeLayout(int viewType) { if (viewType == 1) return android.R.layout.simple_list_item_1; return super.getItemViewTypeLayout(viewType); } public int getItemViewType(int position) { return getItem(position).action == SearchHintInfo.Action.DELETE ? 1 : 0; } public int getViewTypeCount() { return 2; } public List getItems() { return mList; } public void replaceItems(Collection list) { if (list != mList) { mList.clear(); mList.addAll(list); } notifyDataSetChanged(); } } public static class SearchHintVH extends ViewHolderAdapter.ViewHolder { private final TextView text1View; public SearchHintVH(View view) { super(view); text1View = view.findViewById(android.R.id.text1); } @Override public void setContent(SearchHintInfo content, int position, @NonNull ViewHolderAdapter> adapter) { text1View.setText(content.text); text1View.setTypeface(null, content.action == SearchHintInfo.Action.RENAME ? Typeface.BOLD : Typeface.NORMAL); if (text1View instanceof CheckedTextView) ((CheckedTextView) text1View).setChecked(content.selected); if (content.action == SearchHintInfo.Action.DELETE) { text1View.setPaintFlags(text1View.getPaintFlags() | Paint.STRIKE_THRU_TEXT_FLAG); } } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/preference/IconListPreferenceDialog.java ================================================ package rocks.tbog.tblauncher.preference; import android.content.Context; import android.content.DialogInterface; import android.content.res.ColorStateList; import android.content.res.Resources; import android.content.res.TypedArray; import android.graphics.Color; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; import android.graphics.drawable.StateListDrawable; import android.os.Build; import android.os.Bundle; import android.util.StateSet; import android.util.TypedValue; import android.view.View; import android.widget.ArrayAdapter; import android.widget.CheckedTextView; import android.widget.ListAdapter; import android.widget.TextView; import androidx.annotation.LayoutRes; import androidx.annotation.NonNull; import androidx.appcompat.app.AlertDialog; import androidx.preference.ListPreference; import androidx.preference.PreferenceDialogFragmentCompat; import com.google.android.material.R; import java.util.ArrayList; import java.util.List; import rocks.tbog.tblauncher.drawable.DrawableUtils; import rocks.tbog.tblauncher.utils.DialogHelper; import rocks.tbog.tblauncher.utils.Utilities; import rocks.tbog.tblauncher.utils.ViewHolderAdapter; import rocks.tbog.tblauncher.utils.ViewHolderListAdapter; public class IconListPreferenceDialog extends PreferenceDialogFragmentCompat { private static final String SAVE_STATE_INDEX = "IconListPreferenceDialog.index"; private static final String SAVE_STATE_ENTRIES = "IconListPreferenceDialog.entries"; private static final String SAVE_STATE_ENTRY_VALUES = "IconListPreferenceDialog.entryValues"; private int mClickedDialogEntryIndex = -1; private CharSequence[] mEntries; private CharSequence[] mEntryValues; public static IconListPreferenceDialog newInstance(String key) { final IconListPreferenceDialog fragment = new IconListPreferenceDialog(); final Bundle b = new Bundle(1); b.putString(ARG_KEY, key); fragment.setArguments(b); return fragment; } @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); if (savedInstanceState == null) { final ListPreference preference = getListPreference(); if (preference.getEntries() == null || preference.getEntryValues() == null) { throw new IllegalStateException("ListPreference requires an entries array and an entryValues array."); } mClickedDialogEntryIndex = preference.findIndexOfValue(preference.getValue()); mEntries = preference.getEntries(); mEntryValues = preference.getEntryValues(); } else { mClickedDialogEntryIndex = savedInstanceState.getInt(SAVE_STATE_INDEX, 0); mEntries = savedInstanceState.getCharSequenceArray(SAVE_STATE_ENTRIES); mEntryValues = savedInstanceState.getCharSequenceArray(SAVE_STATE_ENTRY_VALUES); } } @Override public void onSaveInstanceState(@NonNull Bundle outState) { super.onSaveInstanceState(outState); outState.putInt(SAVE_STATE_INDEX, mClickedDialogEntryIndex); outState.putCharSequenceArray(SAVE_STATE_ENTRIES, mEntries); outState.putCharSequenceArray(SAVE_STATE_ENTRY_VALUES, mEntryValues); } @Override protected void onPrepareDialogBuilder(AlertDialog.Builder builder) { super.onPrepareDialogBuilder(builder); ArrayList list = new ArrayList<>(mEntries.length); for (int i = 0; i < mEntries.length; i++) { list.add(new IconEntry(mEntries[i], mEntryValues[i])); } final ListAdapter listAdapter; { final Bundle args = getArguments(); String key = args != null ? args.getString(ARG_KEY) : null; final int listItemLayout = getItemLayout(builder.getContext()); if (key != null && key.endsWith("-shape")) listAdapter = new IconAdapter(ShapeViewHolder.class, listItemLayout, list); else if ("icons-pack".equals(key)) listAdapter = new IconAdapter(PackViewHolder.class, listItemLayout, list); else listAdapter = new ArrayAdapter<>(getContext(), listItemLayout, list); } builder.setSingleChoiceItems( listAdapter, mClickedDialogEntryIndex, (dialog, which) -> { mClickedDialogEntryIndex = which; // Clicking on an item simulates the positive button click IconListPreferenceDialog.this.onClick(dialog, DialogInterface.BUTTON_POSITIVE); // and dismisses the dialog. dialog.dismiss(); }); builder.setPositiveButton(null, null); DialogHelper.setCustomTitle(builder, getPreference().getDialogTitle()); } @Override public void onStart() { super.onStart(); DialogHelper.setButtonBarBackground(requireDialog()); } private ListPreference getListPreference() { return (ListPreference) getPreference(); } @LayoutRes private static int getItemLayout(@NonNull Context context) { @LayoutRes final int layout; final Resources.Theme theme = context.getTheme(); TypedValue res = new TypedValue(); boolean found = theme.resolveAttribute(R.attr.singleChoiceItemLayout, res, true); if (found && res.resourceId != 0) { layout = res.resourceId; } else { TypedArray a = theme.obtainStyledAttributes(R.style.MaterialAlertDialog_MaterialComponents, new int[]{R.attr.singleChoiceItemLayout}); layout = a.getResourceId(0, android.R.layout.simple_list_item_checked); a.recycle(); } return layout; } @Override public void onDialogClosed(boolean positiveResult) { if (positiveResult && mClickedDialogEntryIndex >= 0) { String value = mEntryValues[mClickedDialogEntryIndex].toString(); final ListPreference preference = getListPreference(); if (preference.callChangeListener(value)) { preference.setValue(value); } } } private static class IconEntry { private final CharSequence name; private final CharSequence value; public IconEntry(CharSequence name, CharSequence value) { this.name = name; this.value = value; } } private static class IconAdapter extends ViewHolderListAdapter> { protected IconAdapter(@NonNull Class> viewHolderClass, int listItemLayout, @NonNull List list) { super(viewHolderClass, listItemLayout, list); } } public static class ShapeViewHolder extends ViewHolderAdapter.ViewHolder { private final static int[] STATE_CHECKED = new int[]{android.R.attr.state_checked}; final TextView textView; int defaultColor = 0; int checkedColor = 0; int size; public ShapeViewHolder(View view) { super(view); textView = view.findViewById(android.R.id.text1); final Context context = view.getContext(); // get color from theme { final Resources.Theme theme = context.getTheme(); TypedValue res = new TypedValue(); if (theme.resolveAttribute(R.attr.colorControlActivated, res, true)) { if (res.type >= TypedValue.TYPE_FIRST_COLOR_INT && res.type <= TypedValue.TYPE_LAST_COLOR_INT) { checkedColor = res.data; } } if (theme.resolveAttribute(R.attr.colorControlNormal, res, true)) { if (res.type >= TypedValue.TYPE_FIRST_COLOR_INT && res.type <= TypedValue.TYPE_LAST_COLOR_INT) { defaultColor = res.data; } } } if (defaultColor == 0 || checkedColor == 0) { ColorStateList textColorList = null; if (textView instanceof CheckedTextView) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { textColorList = ((CheckedTextView) textView).getCheckMarkTintList(); } } if (textColorList == null) textColorList = textView.getTextColors(); defaultColor = textColorList.getDefaultColor(); checkedColor = textColorList.getColorForState(STATE_CHECKED, defaultColor); } size = context.getResources().getDimensionPixelSize(rocks.tbog.tblauncher.R.dimen.icon_preview_size); } @Override protected void setContent(IconEntry content, int position, @NonNull ViewHolderAdapter> adapter) { // set icon async Utilities.setViewAsync(textView, context -> { Drawable drawable = new ColorDrawable(defaultColor); for (int shape : DrawableUtils.SHAPE_LIST) { if (Integer.toString(shape).equals(content.value.toString())) { Drawable shapedDrawable; if (shape == DrawableUtils.SHAPE_NONE) { shapedDrawable = new ColorDrawable(Color.TRANSPARENT); } else { StateListDrawable listDrawable = new StateListDrawable(); Drawable checkedDrawable = new ColorDrawable(checkedColor); listDrawable.addState(STATE_CHECKED, DrawableUtils.applyIconMaskShape(context, checkedDrawable, shape)); listDrawable.addState(StateSet.WILD_CARD, DrawableUtils.applyIconMaskShape(context, drawable, shape)); shapedDrawable = listDrawable; } drawable = shapedDrawable; break; } } return drawable; }, (view, drawable) -> { if (!(view instanceof TextView)) return; TextView textView = (TextView) view; // compound drawables need a size drawable.setBounds(0, 0, size, size); // get relative because that's where the checkmark can be Drawable[] cd = textView.getCompoundDrawablesRelative(); // set compound drawable textView.setCompoundDrawablesRelative(cd[0], cd[1], drawable, cd[3]); }); // set text textView.setText(content.name); } } public static class PackViewHolder extends ViewHolderAdapter.ViewHolder { final TextView textView; int size; public PackViewHolder(View view) { super(view); textView = view.findViewById(android.R.id.text1); final Context context = view.getContext(); size = context.getResources().getDimensionPixelSize(rocks.tbog.tblauncher.R.dimen.icon_preview_size); } @Override protected void setContent(IconEntry content, int position, @NonNull ViewHolderAdapter> adapter) { // set icon async Utilities.setViewAsync(textView, ctx -> { try { return ctx.getPackageManager().getApplicationIcon(content.value.toString()); } catch (Exception ignored) { } return new ColorDrawable(Color.TRANSPARENT); }, (view, drawable) -> { if (!(view instanceof TextView)) return; drawable.setBounds(0, 0, size, size); TextView textView = (TextView) view; // get relative because that's where the checkmark can be Drawable[] cd = textView.getCompoundDrawablesRelative(); // set compound drawable textView.setCompoundDrawablesRelative(cd[0], cd[1], drawable, cd[3]); }); // set text textView.setText(content.name); } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/preference/MarginDialog.java ================================================ package rocks.tbog.tblauncher.preference; import android.content.Context; import android.content.SharedPreferences; import android.os.Bundle; import android.util.Log; import android.view.View; import android.widget.TextView; import androidx.lifecycle.MediatorLiveData; import androidx.lifecycle.MutableLiveData; import androidx.preference.DialogPreference; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.ui.CustomizeMarginView; import rocks.tbog.tblauncher.utils.UIColors; import rocks.tbog.tblauncher.utils.UISizes; public class MarginDialog extends BasePreferenceDialog { private static final String TAG = MarginDialog.class.getSimpleName(); private final MutableLiveData offsetX = new MutableLiveData<>(); private final MutableLiveData offsetY = new MutableLiveData<>(); public static MarginDialog newInstance(String key) { MarginDialog fragment = new MarginDialog(); final Bundle b = new Bundle(1); b.putString(ARG_KEY, key); fragment.setArguments(b); return fragment; } @Override public void onDialogClosed(boolean positiveResult) { if (!positiveResult) return; DialogPreference dialogPreference = getPreference(); if (!(dialogPreference instanceof CustomDialogPreference)) return; CustomDialogPreference preference = (CustomDialogPreference) dialogPreference; // save data when user clicked OK final var offsetXValue = offsetX.getValue(); if (offsetXValue != null) preference.persistValueIfAllowed(offsetXValue); SharedPreferences sharedPreferences = preference.getSharedPreferences(); var editor = sharedPreferences != null ? sharedPreferences.edit() : null; if (editor != null) { final var offsetYValue = offsetY.getValue(); if (offsetYValue != null) { final String key = preference.getKey().replace("-dx", "-dy"); editor.putFloat(key, offsetYValue); } editor.apply(); } } @Override protected void onBindDialogView(View root) { super.onBindDialogView(root); DialogPreference preference = getPreference(); final String keyX = preference.getKey(); if (!keyX.endsWith("-dx")) throw new IllegalStateException("pref key `" + keyX + "` must end with `-dx`"); final String keyY = keyX.replace("-dx", "-dy"); SharedPreferences sharedPreferences = preference.getSharedPreferences(); if (sharedPreferences == null) { Log.e(TAG, "getSharedPreferences == null for preference `" + keyX + "`"); return; } var prefMap = sharedPreferences.getAll(); CustomizeMarginView viewXY = root.findViewById(R.id.viewXY); TextView textValueXY = root.findViewById(R.id.textValueXY); // initialize LiveData { var value = prefMap.get(keyX); offsetX.setValue(value instanceof Float ? (float) value : 0f); } { var value = prefMap.get(keyY); offsetY.setValue(value instanceof Float ? (float) value : 0f); } final Context ctx = requireContext(); { Float dx = offsetX.getValue(); Float dy = offsetY.getValue(); if (dx == null) dx = 0f; if (dy == null) dy = 0f; viewXY.setOffsetValues(UISizes.dp2px_float(ctx, dx), UISizes.dp2px_float(ctx, dy)); } // initialize preview { int color1; int color2; switch (keyX) { case "result-list-margin-offset-dx": color1 = UIColors.getResultListBackground(ctx); color2 = UIColors.getResultListRipple(ctx); final var margin = UISizes.getResultListMargin(ctx); viewXY.setMarginParameters(margin.exactCenterX(), margin.exactCenterY()); break; default: color1 = 0; color2 = 0; break; } if (color1 != 0 || color2 != 0) { color1 = UIColors.setAlpha(color1, 0xFF); color2 = UIColors.setAlpha(color2, 0xFF); viewXY.setPreviewColors(color1, color2); } } viewXY.setOnOffsetChanged((dx, dy) -> { offsetX.postValue(UISizes.px2dp_float(viewXY.getContext(), dx)); offsetY.postValue(UISizes.px2dp_float(viewXY.getContext(), dy)); }); MediatorLiveData dataMerge = new MediatorLiveData<>(); dataMerge.addSource(offsetX, aFloat -> dataMerge.setValue(new LiveMarginParameters(aFloat, offsetY.getValue()))); dataMerge.addSource(offsetY, aFloat -> dataMerge.setValue(new LiveMarginParameters(offsetX.getValue(), aFloat))); dataMerge.observe(getDialogLifecycleOwner(), marginParameters -> { final float dx = marginParameters.dx; final float dy = marginParameters.dy; textValueXY.setText(getResources().getString(R.string.value_float_xy, dx, dy)); }); } private static class LiveMarginParameters { Float dx; Float dy; public LiveMarginParameters(Float dx, Float dy) { this.dx = dx; this.dy = dy; } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/preference/MultiDependencies.java ================================================ package rocks.tbog.tblauncher.preference; import android.util.AttributeSet; import android.util.Log; import androidx.preference.CheckBoxPreference; import androidx.preference.Preference; import java.lang.reflect.Method; import java.util.HashMap; import java.util.Map; public abstract class MultiDependencies { private static final String TAG = "MDep"; private static final String NS = "http://tbog.rocks/res/pref"; private static final Method PREFERENCE_METHOD_REGISTER_DEPENDENT; private static final Method PREFERENCE_METHOD_UNREGISTER_DEPENDENT; private final Preference host; private final Map dependencies = new HashMap<>(); static { final Class prefClass = Preference.class; Method registerMethod = null; Method unregisterMethod = null; try { registerMethod = prefClass.getDeclaredMethod("registerDependent", Preference.class); registerMethod.setAccessible(true); unregisterMethod = prefClass.getDeclaredMethod("unregisterDependent", Preference.class); unregisterMethod.setAccessible(true); } catch (Throwable t) { Log.w(TAG, "make methods from " + prefClass + " accessible", t); } PREFERENCE_METHOD_REGISTER_DEPENDENT = registerMethod; PREFERENCE_METHOD_UNREGISTER_DEPENDENT = unregisterMethod; } //We have to get access to the 'findPreferenceInHierarchy' function //from the extended preference, because this function is protected protected abstract Preference findPreferenceInHierarchy(String key); public MultiDependencies(Preference host, AttributeSet attrs) { this.host = host; final String dependencyString = getAttributeStringValue(attrs, NS, "dependencies", null); if (dependencyString != null) { String[] dependencies = dependencyString.split(","); for (String dependency : dependencies) { this.dependencies.put(dependency.trim(), false); } } } public void register() { if (hasDependencies()) registerDependencies(); } public void unregister() { unregisterDependencies(); } public void onDependencyChanged(Preference dependency, boolean disableDependent) { setDependencyState(dependency.getKey(), !disableDependent); setHostState(); } private void setDependencyState(String key, boolean enabled) { if (dependencies.containsKey(key)) dependencies.put(key, enabled); } private static String getAttributeStringValue(AttributeSet attrs, String namespace, String name, String defaultValue) { String value = attrs.getAttributeValue(namespace, name); if (value == null) value = defaultValue; return value; } private void registerDependencies() { for (final Map.Entry entry : dependencies.entrySet()) { final Preference preference = findPreferenceInHierarchy(entry.getKey()); if (preference != null) { try { PREFERENCE_METHOD_REGISTER_DEPENDENT.invoke(preference, host); } catch (final Exception e) { Log.e(TAG, "registerDependent on (" + host.getClass() + ") " + host); } boolean enabled = preference.isEnabled(); if (preference instanceof CheckBoxPreference) { enabled &= ((CheckBoxPreference) preference).isChecked(); } setDependencyState(preference.getKey(), enabled); } } setHostState(); } private void unregisterDependencies() { for (final Map.Entry entry : dependencies.entrySet()) { final Preference preference = findPreferenceInHierarchy(entry.getKey()); if (preference != null) { try { PREFERENCE_METHOD_UNREGISTER_DEPENDENT.invoke(preference, host); } catch (final Exception e) { Log.e(TAG, "unregisterDependent on (" + host.getClass() + ") " + host); } boolean enabled = preference.isEnabled(); if (preference instanceof CheckBoxPreference) { enabled &= ((CheckBoxPreference) preference).isChecked(); } setDependencyState(preference.getKey(), enabled); } } } private void setHostState() { boolean enabled = true; for (Map.Entry entry : dependencies.entrySet()) { if (!entry.getValue()) { enabled = false; break; } } host.setEnabled(enabled); } public boolean hasDependencies() { return dependencies.size() > 0; } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/preference/MultiDependenciesSwitchPreference.java ================================================ package rocks.tbog.tblauncher.preference; import android.content.Context; import android.util.AttributeSet; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.preference.Preference; import androidx.preference.SwitchPreference; public class MultiDependenciesSwitchPreference extends SwitchPreference { MultiDependencies multiDependencies; public MultiDependenciesSwitchPreference(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); initMultiDep(attrs); } public MultiDependenciesSwitchPreference(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); initMultiDep(attrs); } public MultiDependenciesSwitchPreference(@NonNull Context context, @Nullable AttributeSet attrs) { super(context, attrs); initMultiDep(attrs); } public MultiDependenciesSwitchPreference(@NonNull Context context) { this(context, null); } private void initMultiDep(@Nullable AttributeSet attrs) { multiDependencies = new MultiDependencies(this, attrs) { @Override protected Preference findPreferenceInHierarchy(String key) { return MultiDependenciesSwitchPreference.this.findPreferenceInHierarchy(key); } }; } @Override public void onAttached() { super.onAttached(); multiDependencies.register(); } @Override public void onDetached() { multiDependencies.unregister(); super.onDetached(); } @Override protected void onPrepareForRemoval() { multiDependencies.unregister(); super.onPrepareForRemoval(); } @Override public void onDependencyChanged(@NonNull Preference dependency, boolean disableDependent) { if(multiDependencies.hasDependencies()) multiDependencies.onDependencyChanged(dependency, disableDependent); else super.onDependencyChanged(dependency, disableDependent); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/preference/OrderListPreferenceDialog.java ================================================ package rocks.tbog.tblauncher.preference; import android.os.Bundle; import android.util.Log; import android.view.View; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.appcompat.app.AlertDialog; import androidx.preference.MultiSelectListPreference; import androidx.preference.PreferenceDialogFragmentCompat; import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; import java.util.List; import java.util.Objects; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.utils.DialogHelper; import rocks.tbog.tblauncher.utils.PrefOrderedListHelper; import rocks.tbog.tblauncher.utils.ViewHolderAdapter; import rocks.tbog.tblauncher.utils.ViewHolderListAdapter; public class OrderListPreferenceDialog extends PreferenceDialogFragmentCompat { private static final String SAVE_STATE_VALUES = "OrderListPreferenceDialogFragment.values"; private static final String SAVE_STATE_CHANGED = "OrderListPreferenceDialogFragment.changed"; private static final String SAVE_STATE_ENTRIES = "OrderListPreferenceDialogFragment.entries"; private static final String SAVE_STATE_ENTRY_VALUES = "OrderListPreferenceDialogFragment.entryValues"; protected final HashSet mNewValues = new HashSet<>(); boolean mPreferenceChanged; protected CharSequence[] mEntries; protected CharSequence[] mEntryValues; public static OrderListPreferenceDialog newInstance(String key) { final OrderListPreferenceDialog fragment = new OrderListPreferenceDialog(); final Bundle b = new Bundle(2); b.putString(ARG_KEY, key); fragment.setArguments(b); return fragment; } @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); if (savedInstanceState == null) { final MultiSelectListPreference preference = getListPreference(); if (preference.getEntries() == null || preference.getEntryValues() == null) { throw new IllegalStateException("OrderListPreferenceDialog requires an entries array and an entryValues array."); } mNewValues.clear(); mNewValues.addAll(preference.getValues()); mPreferenceChanged = false; mEntries = preference.getEntries(); mEntryValues = preference.getEntryValues(); } else { mNewValues.clear(); mNewValues.addAll(savedInstanceState.getStringArrayList(SAVE_STATE_VALUES)); mPreferenceChanged = savedInstanceState.getBoolean(SAVE_STATE_CHANGED, false); mEntries = savedInstanceState.getCharSequenceArray(SAVE_STATE_ENTRIES); mEntryValues = savedInstanceState.getCharSequenceArray(SAVE_STATE_ENTRY_VALUES); } Log.d("pref", "OrderListPreferenceDialog " + getPreference().getKey() + "\n entries=" + Arrays.toString(mEntries) + "\n values=" + Arrays.toString(mEntryValues)); } @Override public void onSaveInstanceState(@NonNull Bundle outState) { super.onSaveInstanceState(outState); outState.putStringArrayList(SAVE_STATE_VALUES, new ArrayList<>(mNewValues)); outState.putBoolean(SAVE_STATE_CHANGED, mPreferenceChanged); outState.putCharSequenceArray(SAVE_STATE_ENTRIES, mEntries); outState.putCharSequenceArray(SAVE_STATE_ENTRY_VALUES, mEntryValues); } private MultiSelectListPreference getListPreference() { return (MultiSelectListPreference) getPreference(); } protected ArrayList generateEntryList() { final int entryCount = mEntryValues.length; ArrayList entryArrayList = new ArrayList<>(entryCount); for (int i = 0; i < entryCount; i += 1) { ListEntry listEntry = new ListEntry(mEntries[i], mEntryValues[i].toString()); entryArrayList.add(listEntry); } return entryArrayList; } @Override protected void onPrepareDialogBuilder(AlertDialog.Builder builder) { super.onPrepareDialogBuilder(builder); DialogHelper.setCustomTitle(builder, getPreference().getDialogTitle()); if (mEntryValues.length != mEntries.length) throw new IllegalStateException("mEntryValues.length=" + mEntryValues.length + " mEntries.length=" + mEntries.length); ArrayList entryArrayList = generateEntryList(); EntryAdapter entryAdapter = new EntryAdapter(EntryViewHolder.class, entryArrayList); builder.setAdapter(entryAdapter, null); entryAdapter.mOnMoveUpListener = (adapter, view, position) -> { if (position <= 0) return; List list = adapter.getList(); ListEntry entry = list.remove(position); list.add(position - 1, entry); adapter.notifyDataSetChanged(); generateNewValues(list); }; entryAdapter.mOnMoveDownListener = (adapter, view, position) -> { if ((position + 1) >= adapter.getCount()) return; List list = adapter.getList(); ListEntry entry = list.remove(position); list.add(position + 1, entry); adapter.notifyDataSetChanged(); generateNewValues(list); }; } @Override public void onStart() { super.onStart(); DialogHelper.setButtonBarBackground(requireDialog()); } protected void generateNewValues(List list) { mPreferenceChanged = mEntryValues.length != list.size(); mNewValues.clear(); int ord = 0; for (ListEntry entry : list) { if ((ord >= mEntryValues.length) || !mEntryValues[ord].equals(entry.value)) mPreferenceChanged = true; mNewValues.add(PrefOrderedListHelper.makeOrderedValue(entry.value, ord++)); } } @Override public void onDialogClosed(boolean positiveResult) { if (positiveResult && mPreferenceChanged) { final MultiSelectListPreference preference = getListPreference(); Log.d("pref", "onDialogClosed " + preference.getKey() + "\n mNewValues=" + mNewValues); if (preference.callChangeListener(mNewValues)) { preference.setEntryValues(mNewValues.toArray(new CharSequence[0])); preference.setValues(mNewValues); } } mPreferenceChanged = false; } public static class ListEntry { final CharSequence name; final String value; public ListEntry(@NonNull CharSequence name, @NonNull String value) { this.name = name; this.value = value; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; ListEntry entry = (ListEntry) o; return name.equals(entry.name) && value.equals(entry.value); } @Override public int hashCode() { return Objects.hash(name, value); } } private static class EntryAdapter extends ViewHolderListAdapter { private OnItemClickListener mOnMoveUpListener = null; private OnItemClickListener mOnMoveDownListener = null; public interface OnItemClickListener { void onClick(EntryAdapter adapter, View view, int position); } protected EntryAdapter(@NonNull Class viewHolderClass, @NonNull List list) { super(viewHolderClass, R.layout.order_list_item, list); } public List getList() { return mList; } } public static class EntryViewHolder extends ViewHolderAdapter.ViewHolder { TextView textView; View btnUp; View btnDown; protected EntryViewHolder(View view) { super(view); textView = view.findViewById(android.R.id.text1); btnUp = view.findViewById(android.R.id.button1); btnDown = view.findViewById(android.R.id.button2); } @Override protected void setContent(ListEntry content, int position, @NonNull ViewHolderAdapter> adapter) { EntryAdapter entryAdapter = (EntryAdapter) adapter; textView.setText(content.name); if (position == 0) btnUp.setVisibility(View.INVISIBLE); else { btnUp.setVisibility(View.VISIBLE); btnUp.setOnClickListener(v -> { if (entryAdapter.mOnMoveUpListener == null) return; int pos = entryAdapter.getList().indexOf(content); if (pos != -1) entryAdapter.mOnMoveUpListener.onClick(entryAdapter, v, pos); }); } if (position == (adapter.getCount() - 1)) btnDown.setVisibility(View.INVISIBLE); else { btnDown.setVisibility(View.VISIBLE); btnDown.setOnClickListener(v -> { if (entryAdapter.mOnMoveDownListener == null) return; int pos = entryAdapter.getList().indexOf(content); if (pos != -1) entryAdapter.mOnMoveDownListener.onClick(entryAdapter, v, pos); }); } } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/preference/PreferenceColorDialog.java ================================================ package rocks.tbog.tblauncher.preference; import android.annotation.SuppressLint; import android.content.Context; import android.os.Bundle; import android.transition.Fade; import android.transition.TransitionManager; import android.transition.TransitionSet; import android.util.Log; import android.util.TypedValue; import android.view.ContextThemeWrapper; import android.view.LayoutInflater; import android.view.View; import android.view.animation.AccelerateInterpolator; import androidx.annotation.NonNull; import androidx.asynclayoutinflater.view.AsyncLayoutInflater; import androidx.constraintlayout.widget.ConstraintLayout; import androidx.constraintlayout.widget.ConstraintSet; import androidx.preference.DialogPreference; import net.mm2d.color.chooser.ColorChooserDialog; import net.mm2d.color.chooser.ColorChooserView; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.databinding.Mm2dCcColorChooserBinding; import rocks.tbog.tblauncher.utils.Timer; import rocks.tbog.tblauncher.utils.UIColors; public class PreferenceColorDialog extends BasePreferenceDialog { private static final String TAG = PreferenceColorDialog.class.getSimpleName(); private ColorChooserView mChooseView = null; private int mInitialTab; private int mInitialColor; public static PreferenceColorDialog newInstance(String key) { final PreferenceColorDialog fragment = new PreferenceColorDialog(); final Bundle b = new Bundle(1); b.putString(ARG_KEY, key); fragment.setArguments(b); return fragment; } @Override public void onDialogClosed(boolean positiveResult) { if (!positiveResult) return; DialogPreference dialogPreference = getPreference(); if (!(dialogPreference instanceof CustomDialogPreference)) return; CustomDialogPreference preference = (CustomDialogPreference) dialogPreference; if (mChooseView != null) { preference.setValue(mChooseView.getColor()); } preference.persistValueIfAllowed(); } @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); // the DialogPreference has no value set, get the one from SharedPreferences DialogPreference dialogPreference = getPreference(); int color = dialogPreference.getSharedPreferences().getInt(dialogPreference.getKey(), UIColors.COLOR_DEFAULT); if (dialogPreference instanceof CustomDialogPreference) { CustomDialogPreference preference = (CustomDialogPreference) dialogPreference; preference.setValue(color); } if (savedInstanceState != null) { mInitialTab = savedInstanceState.getInt(ColorChooserDialog.KEY_INITIAL_TAB, ColorChooserDialog.TAB_PALETTE); mInitialColor = savedInstanceState.getInt(ColorChooserDialog.KEY_INITIAL_COLOR, color); } else { mInitialTab = ColorChooserDialog.TAB_PALETTE; mInitialColor = color; } } @Override protected View onCreateDialogView(@NonNull Context context) { Log.d(TAG, "onCreateDialogView start"); var t = Timer.startNano(); TypedValue outValue = new TypedValue(); context.getTheme().resolveAttribute(com.google.android.material.R.attr.alertDialogTheme, outValue, true); var themeWrapper = new ContextThemeWrapper(context, outValue.resourceId); var inflater = LayoutInflater.from(themeWrapper); @SuppressLint("InflateParams") View view = inflater.inflate(R.layout.dialog_preference_color_chooser, null, false); final ConstraintLayout rootLayout = view instanceof ConstraintLayout ? (ConstraintLayout) view : null; if (rootLayout == null) return view; new AsyncLayoutInflater(themeWrapper).inflate(R.layout.mm2d_cc_color_chooser, rootLayout, (v, resid, parent) -> { mChooseView = Mm2dCcColorChooserBinding.bind(v).getRoot(); mChooseView.setId(View.generateViewId()); mChooseView.setVisibility(View.GONE); rootLayout.addView(mChooseView); t.stop(); Log.d(TAG, "onCreateDialogView finished " + t); // initialize color-chooser mChooseView.setCurrentItem(mInitialTab); mChooseView.init(mInitialColor); mChooseView.setWithAlpha(getPreference().getKey().endsWith("-argb")); // set new constraints to hide loading and place the button bar below the color-chooser var constraintSet = new ConstraintSet(); constraintSet.clone(rootLayout); constraintSet.setVisibility(R.id.iconLoadingBar, ConstraintSet.GONE); constraintSet.setVisibility(mChooseView.getId(), ConstraintSet.VISIBLE); constraintSet.connect(mChooseView.getId(), ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP); constraintSet.connect(mChooseView.getId(), ConstraintSet.BOTTOM, R.id.buttonPanel, ConstraintSet.TOP); constraintSet.connect(R.id.buttonPanel, ConstraintSet.TOP, mChooseView.getId(), ConstraintSet.BOTTOM); constraintSet.constrainDefaultHeight(mChooseView.getId(), ConstraintSet.MATCH_CONSTRAINT_WRAP); constraintSet.constrainHeight(mChooseView.getId(), ConstraintSet.MATCH_CONSTRAINT); // set transition without the button bar var transition = new TransitionSet(); transition.setOrdering(TransitionSet.ORDERING_TOGETHER); transition.addTransition(new Fade(Fade.OUT)); transition.addTransition(new Fade(Fade.IN)); transition.setInterpolator(new AccelerateInterpolator()); transition.excludeTarget(R.id.buttonPanel, true); // start the transition TransitionManager.beginDelayedTransition(parent, transition); constraintSet.applyTo(rootLayout); }); return rootLayout; } @Override public void onSaveInstanceState(@NonNull Bundle outState) { super.onSaveInstanceState(outState); outState.putInt(ColorChooserDialog.KEY_INITIAL_TAB, mInitialTab = mChooseView.getCurrentItem()); outState.putInt(ColorChooserDialog.KEY_INITIAL_COLOR, mInitialColor = mChooseView.getColor()); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/preference/PreviewImagePreference.java ================================================ package rocks.tbog.tblauncher.preference; import android.annotation.SuppressLint; import android.app.Activity; import android.content.Context; import android.util.AttributeSet; import android.view.View; import android.widget.GridView; import androidx.preference.PreferenceViewHolder; import java.util.ArrayList; import java.util.Collections; import java.util.List; import rocks.tbog.tblauncher.handler.DataHandler; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.db.DBHelper; import rocks.tbog.tblauncher.entry.EntryItem; import rocks.tbog.tblauncher.result.EntryAdapter; import rocks.tbog.tblauncher.result.LoadDataForAdapter; import rocks.tbog.tblauncher.utils.UIColors; import rocks.tbog.tblauncher.utils.Utilities; public class PreviewImagePreference extends androidx.preference.DialogPreference { public PreviewImagePreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); } public PreviewImagePreference(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); } public PreviewImagePreference(Context context, AttributeSet attrs) { super(context, attrs); } public PreviewImagePreference(Context context) { super(context); } @SuppressLint("ClickableViewAccessibility") @Override public void onBindViewHolder(PreferenceViewHolder holder) { super.onBindViewHolder(holder); View view = holder.findViewById(R.id.grid); if (!(view instanceof GridView)) return; GridView gridView = (GridView) view; // disable touch gridView.setOnTouchListener((v, event) -> true); Utilities.setVerticalScrollbarThumbDrawable(gridView, null); Context ctx = gridView.getContext(); int background = UIColors.getResultListBackground(getSharedPreferences()); gridView.setBackgroundColor(background); ArrayList list = new ArrayList<>(); int drawFlags = EntryItem.FLAG_DRAW_GRID | EntryItem.FLAG_DRAW_ICON | EntryItem.FLAG_DRAW_ICON_BADGE | EntryItem.FLAG_DRAW_NO_CACHE; EntryAdapter adapter = new EntryAdapter(list, drawFlags); gridView.setAdapter(adapter); new LoadDataForAdapter(adapter, () -> { Activity activity = Utilities.getActivity(ctx); if (activity == null) return null; DataHandler dataHandler = TBApplication.dataHandler(activity); List data = dataHandler.getHistory(9, DBHelper.HistoryMode.RECENCY, false, Collections.emptySet()); if (data instanceof ArrayList) return (ArrayList) data; return new ArrayList<>(data); }).execute(); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/preference/QuickListPreferenceDialog.java ================================================ package rocks.tbog.tblauncher.preference; import android.os.Bundle; import android.view.View; import rocks.tbog.tblauncher.quicklist.EditQuickList; public class QuickListPreferenceDialog extends BasePreferenceDialog { private final EditQuickList mEditor = new EditQuickList(); public static QuickListPreferenceDialog newInstance(String key) { QuickListPreferenceDialog fragment = new QuickListPreferenceDialog(); final Bundle b = new Bundle(1); b.putString(ARG_KEY, key); fragment.setArguments(b); return fragment; } @Override public void onDialogClosed(boolean positiveResult) { if (!positiveResult) return; mEditor.applyChanges(requireContext()); } @Override protected void onBindDialogView(View view) { super.onBindDialogView(view); mEditor.bindView(view); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/preference/SeekBarChangeListener.java ================================================ package rocks.tbog.tblauncher.preference; import android.widget.SeekBar; import android.widget.TextView; import rocks.tbog.tblauncher.R; public abstract class SeekBarChangeListener implements SeekBar.OnSeekBarChangeListener { protected final int offset; protected final TextView textView; protected final ValueChanged listener; interface ValueChanged { void valueChanged(T newValue); } public SeekBarChangeListener(int offset, TextView textView, ValueChanged listener) { this.offset = offset; this.textView = textView; this.listener = listener; } @Override public void onStartTrackingTouch(SeekBar seekBar) { // do nothing } public static class ProgressChangedInt extends SeekBarChangeListener { public ProgressChangedInt(int offset, TextView textView, ValueChanged listener) { super(offset, textView, listener); } @Override public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { final int newValue = progress + offset; textView.setText(textView.getResources().getString(R.string.value, newValue)); } @Override public void onStopTrackingTouch(SeekBar seekBar) { int progress = seekBar.getProgress(); progress += offset; listener.valueChanged(progress); } } public static class ProgressChangedFloat extends SeekBarChangeListener { protected float incrementBy; public ProgressChangedFloat(int offset, float incrementBy, TextView textView, ValueChanged listener) { super(offset, textView, listener); this.incrementBy = incrementBy; } @Override public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { final float newValue = (progress + offset) * incrementBy; textView.setText(textView.getResources().getString(R.string.value_float, newValue)); } @Override public void onStopTrackingTouch(SeekBar seekBar) { float progress = seekBar.getProgress(); progress = (progress + offset) * incrementBy; listener.valueChanged(progress); } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/preference/ShadowDialog.java ================================================ package rocks.tbog.tblauncher.preference; import android.content.Context; import android.content.SharedPreferences; import android.os.Bundle; import android.util.Log; import android.view.View; import android.widget.SeekBar; import android.widget.TextView; import androidx.lifecycle.MediatorLiveData; import androidx.lifecycle.MutableLiveData; import androidx.preference.DialogPreference; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.ui.CustomizeShadowView; import rocks.tbog.tblauncher.utils.UIColors; import rocks.tbog.tblauncher.utils.UISizes; public class ShadowDialog extends BasePreferenceDialog { private static final String TAG = ShadowDialog.class.getSimpleName(); private final MutableLiveData offsetX = new MutableLiveData<>(); private final MutableLiveData offsetY = new MutableLiveData<>(); private final MutableLiveData radius = new MutableLiveData<>(); public static ShadowDialog newInstance(String key) { ShadowDialog fragment = new ShadowDialog(); final Bundle b = new Bundle(1); b.putString(ARG_KEY, key); fragment.setArguments(b); return fragment; } @Override public void onDialogClosed(boolean positiveResult) { if (!positiveResult) return; DialogPreference dialogPreference = getPreference(); if (!(dialogPreference instanceof CustomDialogPreference)) return; CustomDialogPreference preference = (CustomDialogPreference) dialogPreference; // save data when user clicked OK final var offsetXValue = offsetX.getValue(); if (offsetXValue != null) preference.persistValueIfAllowed(offsetXValue); SharedPreferences sharedPreferences = preference.getSharedPreferences(); var editor = sharedPreferences != null ? sharedPreferences.edit() : null; if (editor != null) { final var offsetYValue = offsetY.getValue(); if (offsetYValue != null) { final String key = preference.getKey().replace("-dx", "-dy"); editor.putFloat(key, offsetYValue); } final var radiusValue = radius.getValue(); if (radiusValue != null) { final String key = preference.getKey().replace("-dx", "-radius"); editor.putFloat(key, radiusValue); } editor.apply(); } } @Override protected void onBindDialogView(View root) { super.onBindDialogView(root); CustomDialogPreference preference = (CustomDialogPreference) getPreference(); final String keyX = preference.getKey(); if (!keyX.endsWith("-dx")) throw new IllegalStateException("pref key `" + keyX + "` must end with `-dx`"); final String keyY = keyX.replace("-dx", "-dy"); final String keyR = keyX.replace("-dx", "-radius"); final String keyC = keyX.replace("-dx", "-color"); SharedPreferences sharedPreferences = preference.getSharedPreferences(); if (sharedPreferences == null) { Log.e(TAG, "getSharedPreferences == null for preference `" + keyX + "`"); return; } var prefMap = sharedPreferences.getAll(); CustomizeShadowView viewXY = root.findViewById(R.id.viewXY); TextView textValueXY = root.findViewById(R.id.textValueXY); SeekBar seekBar = root.findViewById(R.id.seekBar); TextView textValueSlider = root.findViewById(R.id.textValueSlider); // initialize LiveData { var value = prefMap.get(keyX); offsetX.setValue(value instanceof Float ? (float) value : 0f); } { var value = prefMap.get(keyY); offsetY.setValue(value instanceof Float ? (float) value : 0f); } { var value = prefMap.get(keyR); radius.setValue(value instanceof Float ? (float) value : 0f); } // get additional data final int shadowColor; { var value = prefMap.get(keyC); shadowColor = value instanceof Integer ? (int) value : 0; } // set view parameters //setShadowParameters(viewXY, shadowColor); final Context ctx = requireContext(); // initialize shadow preview int color1; int color2; int textColor; int textSize; switch (keyX) { case "result-shadow-dx": color1 = UIColors.getResultListBackground(ctx); color2 = UIColors.getResultListRipple(ctx); textColor = UIColors.getResultTextColor(ctx); textSize = UISizes.getResultTextSize(ctx); break; case "popup-shadow-dx": color1 = UIColors.getPopupBackgroundColor(ctx); color2 = UIColors.getPopupRipple(ctx); textColor = UIColors.getPopupTextColor(ctx); textSize = getResources().getDimensionPixelSize(R.dimen.result_small_size); break; case "search-bar-shadow-dx": color1 = UIColors.getColor(sharedPreferences, "search-bar-argb"); color2 = UIColors.getColor(sharedPreferences, "search-bar-ripple-color"); textColor = UIColors.getSearchTextColor(ctx); textSize = UISizes.sp2px(ctx, sharedPreferences.getInt("search-bar-text-size", getResources().getInteger(R.integer.default_size_text))); break; default: color1 = 0; color2 = 0; textColor = 0; textSize = UISizes.sp2px(ctx, getResources().getInteger(R.integer.default_size_text)); break; } if (color1 != 0) { color1 = UIColors.setAlpha(color1, 0xFF); color2 = UIColors.setAlpha(color2, 0xFF); viewXY.setBackgroundParameters(color1, color2); } if (textColor != 0) viewXY.setTextParameters(null, textColor, textSize); viewXY.setOnOffsetChanged((dx, dy) -> { offsetX.postValue(dx); offsetY.postValue(dy); }); int minValue = 0; float incrementByFloat = .1f; MediatorLiveData dataMerge = new MediatorLiveData<>(); dataMerge.addSource(offsetX, aFloat -> dataMerge.setValue(new LiveShadowParameters(radius.getValue(), aFloat, offsetY.getValue()))); dataMerge.addSource(offsetY, aFloat -> dataMerge.setValue(new LiveShadowParameters(radius.getValue(), offsetX.getValue(), aFloat))); dataMerge.addSource(radius, aFloat -> dataMerge.setValue(new LiveShadowParameters(aFloat, offsetX.getValue(), offsetY.getValue()))); dataMerge.observe(getDialogLifecycleOwner(), liveShadowParameters -> { final float r = liveShadowParameters.radius; final float dx = liveShadowParameters.dx; final float dy = liveShadowParameters.dy; viewXY.setShadowParameters(r, dx, dy, shadowColor); textValueXY.setText(getResources().getString(R.string.value_float_xy, dx, dy)); textValueSlider.setText(getResources().getString(R.string.value_float, r)); }); // set slider value SliderDialog.setProgressFromPreference(seekBar, radius.getValue(), minValue, incrementByFloat); // set seek bar change listener seekBar.setOnSeekBarChangeListener(new SeekBarChangeListener(radius, minValue, incrementByFloat)); } private static class SeekBarChangeListener implements SeekBar.OnSeekBarChangeListener { final MutableLiveData variable; final int minValue; final float incrementByFloat; public SeekBarChangeListener(MutableLiveData var, int min, float inc) { variable = var; minValue = min; incrementByFloat = inc; } @Override public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { final float newValue = (progress + minValue) * incrementByFloat; variable.postValue(newValue); } @Override public void onStartTrackingTouch(SeekBar seekBar) { // do nothing } @Override public void onStopTrackingTouch(SeekBar seekBar) { // do nothing } } private static class LiveShadowParameters { Float dx; Float dy; Float radius; public LiveShadowParameters(Float radius, Float dx, Float dy) { this.dx = dx; this.dy = dy; this.radius = radius; } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/preference/SliderDialog.java ================================================ package rocks.tbog.tblauncher.preference; import android.content.SharedPreferences; import android.os.Bundle; import android.util.Log; import android.view.View; import android.widget.SeekBar; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.preference.DialogPreference; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.utils.PrefCache; public class SliderDialog extends BasePreferenceDialog { private static final String TAG = SliderDialog.class.getSimpleName(); public static SliderDialog newInstance(String key) { SliderDialog fragment = new SliderDialog(); final Bundle b = new Bundle(1); b.putString(ARG_KEY, key); fragment.setArguments(b); return fragment; } @Override public void onDialogClosed(boolean positiveResult) { if (!positiveResult) return; DialogPreference dialogPreference = getPreference(); if (!(dialogPreference instanceof CustomDialogPreference)) return; CustomDialogPreference preference = (CustomDialogPreference) dialogPreference; // save data when user clicked OK preference.persistValueIfAllowed(); } @Override protected void onBindDialogView(View root) { super.onBindDialogView(root); CustomDialogPreference preference = (CustomDialogPreference) getPreference(); final String key = preference.getKey(); // initialize value SharedPreferences sharedPreferences = preference.getSharedPreferences(); if (sharedPreferences == null) { Log.e(TAG, "getSharedPreferences == null for preference `" + key + "`"); return; } { Object value = sharedPreferences.getAll().get(key); if (value != null) preference.setValue(value); } SeekBar seekBar = root.findViewById(R.id.seekBar); // seekBar default minimum is set to 0 if (key.endsWith("-alpha")) { seekBar.setMax(255); ((TextView) root.findViewById(android.R.id.text1)).setText(R.string.title_select_alpha); } switch (key) { case "search-bar-text-size": ((TextView) root.findViewById(android.R.id.text1)).setText(R.string.search_bar_text_size); break; case "search-bar-height": ((TextView) root.findViewById(android.R.id.text1)).setText(R.string.search_bar_height); break; case "quick-list-height": ((TextView) root.findViewById(android.R.id.text1)).setText(R.string.quick_list_height); break; case "popup-corner-radius": case "quick-list-radius": ((TextView) root.findViewById(android.R.id.text1)).setText(R.string.corner_radius); break; case "result-shadow-dx": case "result-shadow-dy": case "popup-shadow-dx": case "popup-shadow-dy": case "search-bar-shadow-dx": case "search-bar-shadow-dy": ((TextView) root.findViewById(android.R.id.text1)).setText(R.string.shadow_offset); break; case "result-shadow-radius": case "popup-shadow-radius": case "search-bar-shadow-radius": ((TextView) root.findViewById(android.R.id.text1)).setText(R.string.shadow_radius); break; default: break; } // because we can't set minimum below API 26 we make our own int minValue = 0; Float incrementByFloat = null; switch (key) { case "result-icon-size": case "quick-list-icon-size": case "tags-menu-icon-size": minValue = getResources().getInteger(R.integer.min_size_icon); seekBar.setMax(getResources().getInteger(R.integer.max_size_icon) - minValue); break; case "result-text-size": case "result-text2-size": case "search-bar-text-size": case "search-bar-height": minValue = 2; seekBar.setMax(seekBar.getMax() - minValue); break; case "quick-list-height": minValue = 2 * PrefCache.getDockRowCount(sharedPreferences); seekBar.setMax(100 * PrefCache.getDockRowCount(sharedPreferences) - minValue); break; case "result-history-size": case "result-history-adaptive": case "result-search-cap": minValue = 1; seekBar.setMax(1000 - minValue); break; case "icon-hue": minValue = -180; seekBar.setMax(180 - minValue); break; case "icon-scale-red": case "icon-scale-green": case "icon-scale-blue": case "icon-scale-alpha": minValue = -200; seekBar.setMax(200 - minValue); break; case "icon-contrast": case "icon-brightness": case "icon-saturation": minValue = -100; seekBar.setMax(100 - minValue); break; case "quick-list-columns": minValue = 1; seekBar.setMax(32 - minValue); break; case "quick-list-rows": minValue = 1; seekBar.setMax(8 - minValue); break; case "result-shadow-dx": case "result-shadow-dy": case "popup-shadow-dx": case "popup-shadow-dy": case "search-bar-shadow-dx": case "search-bar-shadow-dy": incrementByFloat = .1f; minValue = (int) (-5 / incrementByFloat); seekBar.setMax((int) (5 / incrementByFloat) - minValue); break; case "result-shadow-radius": case "popup-shadow-radius": case "search-bar-shadow-radius": incrementByFloat = .1f; seekBar.setMax((int) (10 / incrementByFloat) - minValue); break; default: break; } // set slider value setProgressFromPreference(seekBar, preference.getValue(), minValue, incrementByFloat); final TextView text2 = root.findViewById(android.R.id.text2); final SeekBarChangeListener listener; // default change listener uses integers if (incrementByFloat == null) { listener = new SeekBarChangeListener.ProgressChangedInt(minValue, text2, (integer) -> { CustomDialogPreference pref = ((CustomDialogPreference) SliderDialog.this.getPreference()); pref.setValue(integer); }); } else { listener = new SeekBarChangeListener.ProgressChangedFloat(minValue, incrementByFloat, text2, (aFloat) -> { CustomDialogPreference pref = ((CustomDialogPreference) SliderDialog.this.getPreference()); pref.setValue(aFloat); }); } // update display value listener.onProgressChanged(seekBar, seekBar.getProgress(), false); // set change listener seekBar.setOnSeekBarChangeListener(listener); } public static void setProgressFromPreference(@NonNull SeekBar seekBar, @Nullable Object prefValue, int minValue, @Nullable Float incrementByFloat) { final int seekBarProgress; if (prefValue instanceof Integer) { seekBarProgress = (Integer) prefValue - minValue; } else if (prefValue instanceof Float) { float incrementBy = incrementByFloat != null ? incrementByFloat : 1f; seekBarProgress = Math.round(((Float) prefValue) / incrementBy) - minValue; } else { seekBarProgress = 0; } seekBar.setProgress(seekBarProgress); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/preference/TagOrderListPreferenceDialog.java ================================================ package rocks.tbog.tblauncher.preference; import android.content.Context; import android.graphics.drawable.Drawable; import android.os.Bundle; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.preference.DialogPreference; import androidx.preference.EditTextPreference; import androidx.preference.Preference; import java.util.ArrayList; import java.util.List; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.dataprovider.TagsProvider; import rocks.tbog.tblauncher.entry.ActionEntry; import rocks.tbog.tblauncher.entry.EntryItem; import rocks.tbog.tblauncher.entry.TagEntry; import rocks.tbog.tblauncher.utils.PrefCache; import rocks.tbog.tblauncher.utils.PrefOrderedListHelper; import rocks.tbog.tblauncher.utils.Utilities; public class TagOrderListPreferenceDialog extends OrderListPreferenceDialog { private static final String SAVE_STATE_UNTAGGED_IDX = "TagOrderListPreferenceDialogFragment.untaggedIdx"; private int mUntaggedIndex = 0; public static TagOrderListPreferenceDialog newInstance(String key) { final TagOrderListPreferenceDialog fragment = new TagOrderListPreferenceDialog(); final Bundle b = new Bundle(2); b.putString(ARG_KEY, key); fragment.setArguments(b); return fragment; } @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); if (savedInstanceState == null) { // find the index of the action entry (show/untagged) for (int i = 0; i < mEntryValues.length; i++) { CharSequence entryValue = mEntryValues[i]; if (entryValue.toString().startsWith(ActionEntry.SCHEME)) { mUntaggedIndex = i; break; } } } else { mUntaggedIndex = savedInstanceState.getInt(SAVE_STATE_UNTAGGED_IDX); } } @Override public void onSaveInstanceState(@NonNull Bundle outState) { super.onSaveInstanceState(outState); outState.putInt(SAVE_STATE_UNTAGGED_IDX, mUntaggedIndex); } @Nullable private EditTextPreference getUntaggedIndexPreference() { final DialogPreference.TargetFragment fragment = (DialogPreference.TargetFragment) getTargetFragment(); if (fragment == null) return null; Preference pref = fragment.findPreference("tags-menu-untagged-index"); return pref instanceof EditTextPreference ? (EditTextPreference) pref : null; } @Override protected ArrayList generateEntryList() { final int entryCount = mEntryValues.length; Context context = getContext(); boolean bAddUntagged = context != null && PrefCache.showTagsMenuUntagged(context); EntryItem untaggedEntry = TBApplication.dataHandler(context).getPojo(ActionEntry.SCHEME + "show/untagged"); if (!(untaggedEntry instanceof ActionEntry)) bAddUntagged = false; ArrayList entryArrayList = new ArrayList<>(entryCount + (bAddUntagged ? 1 : 0)); for (int i = 0; i < entryCount; i += 1) { String value = mEntryValues[i].toString(); String tagId = TagsProvider.getTagId(value); ListEntry listEntry = new ListEntry(mEntries[i], tagId); entryArrayList.add(listEntry); } if (bAddUntagged) { EditTextPreference pref = getUntaggedIndexPreference(); if (pref != null) { int idx; try { idx = Integer.parseInt(pref.getText()); } catch (Exception ignored) { idx = 0; } if (idx > entryArrayList.size()) idx = entryArrayList.size(); int size = context.getResources().getDimensionPixelSize(R.dimen.icon_preview_size); Drawable icon = ((ActionEntry) untaggedEntry).getIconDrawable(context); icon.setBounds(0, 0, size, size); int layoutDirection = context.getResources().getConfiguration().getLayoutDirection(); CharSequence label = Utilities.addDrawableBeforeString(untaggedEntry.getName(), icon, layoutDirection); entryArrayList.add(idx, new ListEntry(label, untaggedEntry.id)); } } return entryArrayList; } @Override protected void generateNewValues(List list) { mPreferenceChanged = true; mNewValues.clear(); int ord = 0; for (int idx = 0, listSize = list.size(); idx < listSize; idx++) { String id = list.get(idx).value; if (id.startsWith(TagEntry.SCHEME)) { String tagName = id.substring(TagEntry.SCHEME.length()); mNewValues.add(PrefOrderedListHelper.makeOrderedValue(tagName, ord++)); } else if (id.startsWith(ActionEntry.SCHEME)) { // assume it's "show/untagged" mUntaggedIndex = idx; } } } @Override public void onDialogClosed(boolean positiveResult) { if (positiveResult && mPreferenceChanged) { EditTextPreference pref = getUntaggedIndexPreference(); if (pref != null) pref.setText(Integer.toString(mUntaggedIndex)); } super.onDialogClosed(positiveResult); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/quicklist/DockRecycleLayoutManager.java ================================================ package rocks.tbog.tblauncher.quicklist; import android.graphics.PointF; import android.util.Log; import android.view.View; import android.view.ViewGroup; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.recyclerview.widget.LinearSmoothScroller; import androidx.recyclerview.widget.RecyclerView; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.utils.SparseArrayWrapper; public class DockRecycleLayoutManager extends RecyclerView.LayoutManager implements RecyclerView.SmoothScroller.ScrollVectorProvider { private static final String TAG = "DRLM"; private static final Boolean LOG_DEBUG = true; // Reusable array. This should only be used used transiently and should not be used to retain any state over time. private final SparseArrayWrapper mViewCache = new SparseArrayWrapper<>(); private RecyclerView mRecyclerView = null; private boolean mRefreshViews = false; /* Consistent size applied to all child views */ private int mDecoratedChildWidth = 0; private int mDecoratedChildHeight = 0; private int mColumnCount = 6; private int mRowCount = 1; private int mScrollToAdapterPosition = -1; private int mScrollOffsetHorizontal = 0; private boolean mRightToLeft = false; public DockRecycleLayoutManager(int columnCount, int rowCount) { super(); setColumnCount(columnCount); setRowCount(rowCount); } public void setColumnCount(int count) { mColumnCount = count; } public void setRowCount(int count) { mRowCount = count; } public void setRightToLeft(boolean rightToLeft) { mRightToLeft = rightToLeft; } private static int adapterPosition(@NonNull View child) { return ((RecyclerView.LayoutParams) child.getLayoutParams()).getViewLayoutPosition(); } private static boolean viewNeedsUpdate(View v) { RecyclerView.LayoutParams p = (RecyclerView.LayoutParams) v.getLayoutParams(); return p.viewNeedsUpdate(); } private static String getDebugName(View v) { View name; name = v.findViewById(R.id.item_app_name); if (name instanceof TextView) { CharSequence text = ((TextView) name).getText(); if (text != null && text.length() > 0) return text.toString(); } name = v.findViewById(R.id.item_contact_name); if (name instanceof TextView) { CharSequence text = ((TextView) name).getText(); if (text != null && text.length() > 0) return text.toString(); } name = v.findViewById(android.R.id.text1); if (name instanceof TextView) { CharSequence text = ((TextView) name).getText(); if (text != null && text.length() > 0) return text.toString(); } return ""; } private static String getDebugInfo(View v) { String info = ""; if (v.getLayoutParams() instanceof RecyclerView.LayoutParams) { RecyclerView.LayoutParams p = (RecyclerView.LayoutParams) v.getLayoutParams(); info += "lp" + p.getViewLayoutPosition(); info += "ap" + p.getViewAdapterPosition(); if (p.viewNeedsUpdate()) info += "u"; if (p.isItemChanged()) info += "c"; if (p.isItemRemoved()) info += "r"; if (p.isViewInvalid()) info += "i"; } return info; } private static void logDebug(String message) { if (LOG_DEBUG) Log.d(TAG, message); } @Override public void onAttachedToWindow(RecyclerView view) { super.onAttachedToWindow(view); mRecyclerView = view; } @Override public void onDetachedFromWindow(RecyclerView view, RecyclerView.Recycler recycler) { mRecyclerView = null; super.onDetachedFromWindow(view, recycler); } /* * You must return true from this method if you want your * LayoutManager to support anything beyond "simple" item * animations. Enabling this causes onLayoutChildren() to * be called twice on each animated change; once for a * pre-layout, and again for the real layout. */ @Override public boolean supportsPredictiveItemAnimations() { return false; } /* * Called by RecyclerView when a view removal is triggered. This is called * before onLayoutChildren() in pre-layout if the views removed are not visible. We * use it in this case to inform pre-layout that a removal took place. * * This method is still called if the views removed were visible, but it will * happen AFTER pre-layout. */ @Override public void onItemsRemoved(@NonNull RecyclerView recyclerView, int positionStart, int itemCount) { logDebug("onItemsRemoved start=" + positionStart + " count=" + itemCount); mRefreshViews = true; } @Override public void onItemsMoved(@NonNull RecyclerView recyclerView, int from, int to, int itemCount) { logDebug("onItemsMoved from=" + from + " to=" + to + " count=" + itemCount); mRefreshViews = true; } @Override public RecyclerView.LayoutParams generateDefaultLayoutParams() { return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); } private int getVerticalSpace() { return getHeight() - getPaddingBottom() - getPaddingTop(); } private int getHorizontalSpace() { return getWidth() - getPaddingRight() - getPaddingLeft(); } /* * This method is your initial call from the framework. You will receive it when you * need to start laying out the initial set of views. This method will not be called * repeatedly, so don't rely on it to continually process changes during user * interaction. * * This method will be called when the data set in the adapter changes, so it can be * used to update a layout based on a new item count. * * If predictive animations are enabled, you will see this called twice. First, with * state.isPreLayout() returning true to lay out children in their initial conditions. * Then again to lay out children in their final locations. * * When scrolling, if a view has been added, this will be called after scroll*By */ @Override public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) { //We have nothing to show for an empty data set but clear any existing views if (getItemCount() == 0) { detachAndScrapAttachedViews(recycler); return; } if (getChildCount() == 0 && state.isPreLayout()) { //Nothing to do during prelayout when empty return; } // Always update the visible row/column counts updateSizing(); logDebug("onLayoutChildren" + " childCount=" + getChildCount() + " itemCount=" + getItemCount() + (state.isPreLayout() ? " preLayout" : "") + (state.didStructureChange() ? " structureChanged" : "") + " stateItemCount=" + state.getItemCount()); layoutChildren(recycler); } @Override public void onLayoutCompleted(RecyclerView.State state) { super.onLayoutCompleted(state); //TODO: auto-scroll after resize } @Override public void smoothScrollToPosition(RecyclerView recyclerView, RecyclerView.State state, int position) { LinearSmoothScroller scroller = new LinearSmoothScroller(recyclerView.getContext()); scroller.setTargetPosition(position); startSmoothScroll(scroller); } @Override public void scrollToPosition(int position) { mScrollToAdapterPosition = position; } private void updateSizing() { final int width = getColumnWidth(); final int height = getRowHeight(); if (mDecoratedChildWidth != width || mDecoratedChildHeight != height) { if (mDecoratedChildWidth != width) logDebug("mDecoratedChildWidth changed from " + mDecoratedChildWidth + " to " + width); if (mDecoratedChildHeight != width) logDebug("mDecoratedChildHeight changed from " + mDecoratedChildHeight + " to " + height); mRefreshViews = true; mDecoratedChildWidth = width; mDecoratedChildHeight = height; } } /* Example for 15 items placed on 3 columns and 2 rows (3 pages) * * mRightToLeft == false * page 0 page 1 page 2 * + - - - - - - - - - - - - - - - - + * row 0 | 0 1 2 | 6 7 8 | 12 13 14 | * row 1 | 3 4 5 | 9 10 11 | 15 | * + - - - - - - - - - - - - - - - - + * column 0 1 2 3 4 5 6 7 8 * * * mRightToLeft == true * page 2 page 1 page 0 * + - - - - - - - - - - - - - - - - + * row 0 | 14 13 12 | 8 7 6 | 2 1 0 | * row 1 | 15 | 11 10 9 | 5 4 3 | * + - - - - - - - - - - - - - - - - + * column 8 7 6 5 4 3 2 1 0 */ private int getAdapterIdx(int colIdx, int rowIdx) { int columnInPage = colIdx % mColumnCount; int page = colIdx / mColumnCount; return page * mColumnCount * mRowCount + rowIdx * mColumnCount + columnInPage; } private int getColumnIdx(int adapterPos) { int columnInPage = adapterPos % (mColumnCount * mRowCount) % mColumnCount; int page = getPageIdx(adapterPos); return page * mColumnCount + columnInPage; } private int getRowIdx(int adapterPos) { int group = adapterPos / mColumnCount; return group % mRowCount; } private int getPageIdx(int adapterPos) { return adapterPos / (mColumnCount * mRowCount); } private int getColumnWidth() { return getHorizontalSpace() / mColumnCount; } private int getRowHeight() { return getVerticalSpace() / mRowCount; } private int getColumnPosition(int columnIdx) { int lastPageIdx = getPageIdx(getItemCount() - 1); int maxColumns = (lastPageIdx + 1) * mColumnCount; if (columnIdx < 0 || columnIdx >= maxColumns) { Log.e(TAG, "getColumnPosition(" + columnIdx + ")" + " mColumnCount=" + mColumnCount + " mRowCount=" + mRowCount + " itemCount=" + getItemCount() + " lastPageIdx=" + lastPageIdx + " maxColumns=" + maxColumns); } final int columnOffset = columnIdx * mDecoratedChildWidth; if (mRightToLeft) return getWidth() - getPaddingRight() - columnOffset - mDecoratedChildWidth; return getPaddingLeft() + columnOffset; } private int getRowPosition(int rowIdx) { if (rowIdx < 0 || rowIdx >= mRowCount) { Log.e(TAG, "getRowPosition(" + rowIdx + "); mRowCount=" + mRowCount); } return getPaddingTop() + rowIdx * mDecoratedChildHeight; } private int getPagePosition(int pageIdx) { final int pageWidth = mColumnCount * mDecoratedChildWidth; final int pageOffset = pageIdx * pageWidth; if (mRightToLeft) return getWidth() - getPaddingRight() - pageOffset - pageWidth; return getPaddingLeft() + pageOffset; } private void layoutChildren(RecyclerView.Recycler recycler) { /* * Detach all existing views from the layout. * detachView() is a lightweight operation that we can use to * quickly reorder views without a full add/remove. */ cacheChildren(); // if scrollToPosition is valid, force-scroll to that page if (mScrollToAdapterPosition >= 0 && mScrollToAdapterPosition < getItemCount()) { int pageIdx = getPageIdx(mScrollToAdapterPosition); int pagePosition = getPagePosition(pageIdx); // force-scroll the child views int dx = -pagePosition - mScrollOffsetHorizontal; logDebug("scrollToPosition " + mScrollToAdapterPosition + " pageIdx=" + pageIdx + " pagePos=" + pagePosition + " dx=" + dx); offsetChildrenHorizontal(dx); mScrollOffsetHorizontal += dx; } // turn off scrollToPosition mScrollToAdapterPosition = -1; // compute scroll position after we populate `mViewCache` final int scrollOffset = mScrollOffsetHorizontal; final int firstVisiblePos = findFirstVisibleAdapterPosition(); logDebug("layoutChildren" + " scrollOffset=" + scrollOffset + " firstVisiblePos=" + firstVisiblePos + " padding=" + getPaddingLeft() + " " + getPaddingTop() + " " + getPaddingRight() + " " + getPaddingBottom() + " verticalSpace=" + getVerticalSpace() + " horizontalSpace=" + getHorizontalSpace()); if (mRefreshViews) { logDebug("detachAndScrapAttachedViews" + " viewCache.size=" + mViewCache.size()); // If we want to refresh all views, just scrap them and let the recycler rebind them mRefreshViews = false; mViewCache.clear(); detachAndScrapAttachedViews(recycler); } else { logDebug("detachViews" + " viewCache.size=" + mViewCache.size()); // Temporarily detach all views. We do this to easily move them. detachCachedChildren(recycler); } final int nextLeftPosDelta = mRightToLeft ? -mDecoratedChildWidth : mDecoratedChildWidth; int colIdx = getColumnIdx(firstVisiblePos); int rowIdx = getRowIdx(firstVisiblePos); int topPos = getRowPosition(rowIdx); int scrolledPosition = scrollOffset + getColumnPosition(colIdx); while (scrolledPosition < getWidth() && (scrolledPosition + mDecoratedChildWidth) > 0) { int adapterIdx = getAdapterIdx(colIdx, rowIdx); logDebug("col=" + colIdx + " row=" + rowIdx + " adapterIdx=" + adapterIdx + " scrolledPosition=" + scrolledPosition); View child = layoutAdapterPos(recycler, adapterIdx, scrolledPosition, topPos); if (child == null) { logDebug("null view in" + " col=" + colIdx + " row=" + rowIdx); if (rowIdx == 0) break; } rowIdx += 1; if (rowIdx >= mRowCount) { rowIdx = 0; colIdx += 1; scrolledPosition += nextLeftPosDelta; } topPos = getRowPosition(rowIdx); } clearViewCache(recycler); } /** * Find adapter index of the first visible item on row 0. * It will be the left-most if (mRightToLeft == false) and the right-most otherwise. * * @return adapter index of the first visible view */ private int findFirstVisibleAdapterPosition() { final int colIdx; if (mRightToLeft) colIdx = mScrollOffsetHorizontal / mDecoratedChildWidth; else colIdx = -mScrollOffsetHorizontal / mDecoratedChildWidth; return getAdapterIdx(colIdx, 0); } /** * Layout view from cache or recycler * * @param recycler the recycler * @param adapterPos adapter index * @param leftPos layout position X * @param topPos layout position Y * @return child view */ private View layoutAdapterPos(RecyclerView.Recycler recycler, int adapterPos, int leftPos, int topPos) { if (adapterPos < 0 || adapterPos >= getItemCount()) return null; View child = mViewCache.get(adapterPos); if (child == null) { /* * The Recycler will give us either a newly constructed view or a recycled view it has on-hand. * In either case, the view will already be fully bound to the data by the adapter for us. */ child = recycler.getViewForPosition(adapterPos); addView(child); /* * It is prudent to measure/layout each new view we receive from the Recycler. * We don't have to do this for views we are just re-arranging. */ measureChildWithMargins(child, getHorizontalSpace() - mDecoratedChildWidth, getVerticalSpace() - mDecoratedChildHeight); final RecyclerView.LayoutParams lp = (RecyclerView.LayoutParams) child.getLayoutParams(); final int left = leftPos + lp.leftMargin; final int top = topPos + lp.topMargin; final int measuredWidth = child.getMeasuredWidth(); final int measuredHeight = child.getMeasuredHeight(); //TODO: if measured size is 0 (probably MeasureSpec.UNSPECIFIED) then fix it and measure again. layoutDecorated(child, left, top, left + measuredWidth, top + measuredHeight); logDebug("child #" + indexOfChild(child) + " pos=" + adapterPos + " (" + child.getLeft() + " " + child.getTop() + " " + child.getRight() + " " + child.getBottom() + ")" + " left=" + leftPos + " top=" + topPos + " measured=" + measuredWidth + "x" + measuredHeight + " " + getDebugInfo(child) + " " + getDebugName(child)); } else { attachView(child); logDebug("cache #" + indexOfChild(child) + " pos=" + adapterPos + " (" + child.getLeft() + " " + child.getTop() + " " + child.getRight() + " " + child.getBottom() + ")" + " left=" + leftPos + " top=" + topPos + " " + getDebugInfo(child) + " " + getDebugName(child)); mViewCache.remove(adapterPos); } return child; } /** * Cache all views by their existing position */ private void cacheChildren() { mViewCache.clear(); int childCount = getChildCount(); mViewCache.ensureCapacity(childCount); for (int i = 0; i < childCount; i++) { View child = getChildAt(i); if (child == null) throw new IllegalStateException("null child when count=" + getChildCount() + " and idx=" + i); int position = adapterPosition(child); mViewCache.put(position, child); logDebug("info #" + i + " pos=" + position + " " + getDebugInfo(child) + " " + getDebugName(child)); } } private void detachCachedChildren(RecyclerView.Recycler recycler) { for (int i = 0; i < mViewCache.size(); i++) { View child = mViewCache.valueAt(i); // When an update is in order, scrap the view and let the recycler rebind it if (viewNeedsUpdate(child)) { detachAndScrapView(child, recycler); mViewCache.removeAt(i--); } else { detachView(child); } } } /** * We ask the Recycler to scrap and store any views that we did not re-attach. * These are views that are not currently necessary because they are no longer visible. */ private void clearViewCache(RecyclerView.Recycler recycler) { for (int i = 0; i < mViewCache.size(); i++) { final View removingView = mViewCache.valueAt(i); logDebug("recycleView pos=" + mViewCache.keyAt(i) + " " + getDebugName(removingView)); recycler.recycleView(removingView); } mViewCache.clear(); } private int indexOfChild(View child) { for (int idx = getChildCount() - 1; idx >= 0; idx -= 1) { if (getChildAt(idx) == child) return idx; } return -1; } @Override public boolean canScrollHorizontally() { if (getItemCount() > (mColumnCount * mRowCount)) return true; if (mScrollOffsetHorizontal != 0) return true; if (getChildCount() > 0) { // Allow scrolling if child views are outside visible range if (getDecoratedLeft(getLeftChildView()) < getPaddingLeft()) return true; return getDecoratedRight(getRightChildView()) > (getPaddingLeft() + getHorizontalSpace()); } return false; } @Override public int scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler, RecyclerView.State state) { if (getChildCount() == 0) { return 0; } final int amount; // compute amount of scroll without going beyond the bound if (dx < 0) { // finger is moving from left to right if (mRightToLeft) { int lastPageIdx = getPageIdx(getItemCount() - 1); int pageWidth = getHorizontalSpace(); int maxScroll = lastPageIdx * pageWidth; logDebug("dx=" + dx + " lastPageIdx=" + lastPageIdx + " pageWidth=" + pageWidth + " maxScroll=" + maxScroll + " mScroll=" + mScrollOffsetHorizontal); amount = Math.max(dx, mScrollOffsetHorizontal - maxScroll); } else { logDebug("dx=" + dx + " mScroll=" + mScrollOffsetHorizontal); amount = Math.max(dx, mScrollOffsetHorizontal); } } else if (dx > 0) { // finger is moving from right to left if (mRightToLeft) { logDebug("dx=" + dx + " mScroll=" + mScrollOffsetHorizontal); amount = Math.min(dx, mScrollOffsetHorizontal); } else { int lastPageIdx = getPageIdx(getItemCount() - 1); int pageWidth = getHorizontalSpace(); int maxScroll = lastPageIdx * -pageWidth; logDebug("dx=" + dx + " lastPageIdx=" + lastPageIdx + " pageWidth=" + pageWidth + " maxScroll=" + maxScroll + " mScroll=" + mScrollOffsetHorizontal); amount = Math.min(dx, mScrollOffsetHorizontal - maxScroll); } } else { amount = dx; } if (dx != amount) logDebug("dx=" + dx + " amount=" + amount); if (amount == 0 || (dx < 0 && amount > 0) || (dx > 0 && amount < 0)) return 0; // scroll children offsetChildrenHorizontal(-amount); mScrollOffsetHorizontal -= amount; // check if we need to layout after the scroll if (checkVisibilityAfterScroll()) layoutChildren(recycler); /* * Return value determines if a boundary has been reached * (for edge effects and flings). If returned value does not * match original delta (passed in), RecyclerView will draw * an edge effect. */ return amount; } /** * Check if child views became invisible after scroll or if we need to layout more views * * @return true if we need la re-layout */ private boolean checkVisibilityAfterScroll() { View leftChild = getLeftChildView(); View rightChild = getRightChildView(); int left = getDecoratedLeft(leftChild); int right = getDecoratedRight(rightChild); // check if we should remove views if ((left + mDecoratedChildWidth) < 0 || (right - mDecoratedChildWidth) > getWidth()) return true; // check if we should add views return left > 0 || right < getWidth(); } /** * Return left-most child view from row 0 (may not be visible on screen) * * @return child view */ @NonNull private View getLeftChildView() { int leftChildIdx = mRightToLeft ? (getChildCount() - 1) : 0; View child = getChildAt(leftChildIdx); if (child == null) throw new IllegalStateException("null child when count=" + getChildCount() + " and leftChildIdx=" + leftChildIdx); while (true) { int idx = adapterPosition(child); int row = getRowIdx(idx); if (row == 0) break; leftChildIdx += mRightToLeft ? -1 : 1; if (leftChildIdx < 0 || leftChildIdx >= getChildCount()) break; child = getChildAt(leftChildIdx); if (child == null) throw new IllegalStateException("null child when count=" + getChildCount() + " and leftChildIdx=" + leftChildIdx); } return child; } /** * Return right-most child view from row 0 (may not be visible on screen) * * @return child view */ @NonNull private View getRightChildView() { int rightChildIdx = mRightToLeft ? 0 : (getChildCount() - 1); View child = getChildAt(rightChildIdx); if (child == null) throw new IllegalStateException("null child when count=" + getChildCount() + " and rightChildIdx=" + rightChildIdx); while (true) { int idx = adapterPosition(child); int row = getRowIdx(idx); if (row == 0) break; rightChildIdx -= mRightToLeft ? -1 : 1; if (rightChildIdx < 0 || rightChildIdx >= getChildCount()) break; child = getChildAt(rightChildIdx); if (child == null) throw new IllegalStateException("null child when count=" + getChildCount() + " and rightChildIdx=" + rightChildIdx); } return child; } /** * 0 for leftmost scroll and page count for rightmost * * @return page and scroll */ public float getPageScroll() { if (getChildCount() == 0) return 0f; View view = getChildAt(0); if (view == null) return 0f; int col = getColumnIdx(adapterPosition(view)); int pageWidth = getHorizontalSpace(); float scroll = (getDecoratedLeft(view) - getColumnPosition(col)) / (float) pageWidth; return mRightToLeft ? scroll : -scroll; } public int getPageAdapterPosition(int page) { int col = page * mColumnCount; return getAdapterIdx(col, 0); } @Nullable @Override public PointF computeScrollVectorForPosition(int targetPosition) { if (getChildCount() == 0) return null; View view = getChildAt(0); if (view == null) return null; int pos = adapterPosition(view); return new PointF(targetPosition - pos, 0f); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/quicklist/DragAndDropInfo.java ================================================ package rocks.tbog.tblauncher.quicklist; import android.view.View; class DragAndDropInfo { // the initial view that started the drag public View draggedView; // child index from ViewGroup public int overChildIdx; } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/quicklist/EditQuickList.java ================================================ package rocks.tbog.tblauncher.quicklist; import android.content.ClipData; import android.content.Context; import android.content.SharedPreferences; import android.os.Build; import android.util.Log; import android.view.DragEvent; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.AdapterView; import android.widget.GridView; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.preference.PreferenceManager; import androidx.recyclerview.widget.RecyclerView; import androidx.viewpager.widget.ViewPager; import com.google.android.material.tabs.TabLayout; import java.util.ArrayList; import java.util.Collections; import java.util.List; import rocks.tbog.tblauncher.CustomizeUI; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.dataprovider.ActionProvider; import rocks.tbog.tblauncher.dataprovider.FilterProvider; import rocks.tbog.tblauncher.dataprovider.TagsProvider; import rocks.tbog.tblauncher.db.ModRecord; import rocks.tbog.tblauncher.entry.EntryItem; import rocks.tbog.tblauncher.entry.TagEntry; import rocks.tbog.tblauncher.handler.DataHandler; import rocks.tbog.tblauncher.result.EntryAdapter; import rocks.tbog.tblauncher.result.LoadDataForAdapter; import rocks.tbog.tblauncher.ui.RecyclerList; import rocks.tbog.tblauncher.utils.DebugInfo; public class EditQuickList { private static final String TAG = "EQL"; private RecycleAdapter mAdapter; ViewPager mViewPager; private final AdapterView.OnItemClickListener mAddToQuickList = (parent, view, pos, id) -> { Object item = parent.getAdapter().getItem(pos); if (item instanceof EntryItem) { mAdapter.addItem((EntryItem) item); } }; public void applyChanges(@NonNull Context context) { final int itemCount = mAdapter.getItemCount(); ArrayList idList = new ArrayList<>(itemCount); for (int i = 0; i < itemCount; i++) { EntryItem entry = mAdapter.getItem(i); if (entry == null) { Log.e(TAG, "Adapter item #" + i + " of " + itemCount + " is null"); continue; } idList.add(entry.id); } TBApplication.dataHandler(context).setQuickList(idList); } private void addFilters(@NonNull LayoutInflater inflater, @NonNull ArrayList pages) { GridView gridView = (GridView) inflater.inflate(R.layout.quick_list_editor_page, mViewPager, false); pages.add(new ViewPagerAdapter.PageInfo(inflater.getContext().getString(R.string.edit_quick_list_tab_filters), gridView)); ArrayList list = new ArrayList<>(); EntryAdapter adapter = new EntryAdapter(list); gridView.setAdapter(adapter); new LoadDataForAdapter(adapter, () -> { Context ctx = gridView.getContext(); ArrayList data = new ArrayList<>(); { FilterProvider provider = TBApplication.dataHandler(ctx).getFilterProvider(); if (provider != null) { List entryItems = provider.getPojos(); if (entryItems != null) data.addAll(entryItems); } } return data; }).execute(); gridView.setOnItemClickListener(mAddToQuickList); } private void addActions(@NonNull LayoutInflater inflater, @NonNull ArrayList pages) { GridView gridView = (GridView) inflater.inflate(R.layout.quick_list_editor_page, mViewPager, false); pages.add(new ViewPagerAdapter.PageInfo(inflater.getContext().getString(R.string.edit_quick_list_tab_actions), gridView)); ArrayList list = new ArrayList<>(); EntryAdapter adapter = new EntryAdapter(list); gridView.setAdapter(adapter); new LoadDataForAdapter(adapter, () -> { Context ctx = gridView.getContext(); ArrayList data = new ArrayList<>(); { ActionProvider provider = TBApplication.dataHandler(ctx).getActionProvider(); if (provider != null) { List entryItems = provider.getPojos(); if (entryItems != null) data.addAll(entryItems); } } return data; }).execute(); gridView.setOnItemClickListener(mAddToQuickList); } private void addTags(@NonNull LayoutInflater inflater, @NonNull ArrayList pages) { GridView gridView = (GridView) inflater.inflate(R.layout.quick_list_editor_page, mViewPager, false); pages.add(new ViewPagerAdapter.PageInfo(inflater.getContext().getString(R.string.edit_quick_list_tab_tags), gridView)); ArrayList list = new ArrayList<>(); EntryAdapter adapter = new EntryAdapter(list); gridView.setAdapter(adapter); new LoadDataForAdapter(adapter, () -> { Context ctx = gridView.getContext(); ArrayList data = new ArrayList<>(); { TagsProvider tagsProvider = TBApplication.dataHandler(ctx).getTagsProvider(); if (tagsProvider != null) { List tagNameList = new ArrayList<>(TBApplication.tagsHandler(ctx).getValidTags()); Collections.sort(tagNameList); for (String tagName : tagNameList) { TagEntry tagEntry = tagsProvider.getTagEntry(tagName); data.add(tagEntry); } } } return data; }).execute(); gridView.setOnItemClickListener(mAddToQuickList); } private void addFavorites(@NonNull LayoutInflater inflater, @NonNull ArrayList pages) { GridView gridView = (GridView) inflater.inflate(R.layout.quick_list_editor_page, mViewPager, false); pages.add(new ViewPagerAdapter.PageInfo(inflater.getContext().getString(R.string.edit_quick_list_tab_favorites), gridView)); ArrayList list = new ArrayList<>(); EntryAdapter adapter = new EntryAdapter(list); gridView.setAdapter(adapter); new LoadDataForAdapter(adapter, () -> { Context ctx = gridView.getContext(); DataHandler dataHandler = TBApplication.dataHandler(ctx); List modRecords = dataHandler.getMods(); ArrayList data = new ArrayList<>(modRecords.size()); for (ModRecord fav : modRecords) { EntryItem entry = dataHandler.getPojo(fav.record); if (entry != null) data.add(entry); } return data; }).execute(); gridView.setOnItemClickListener(mAddToQuickList); } public void bindView(@NonNull View view) { final Context context = view.getContext(); mAdapter = new RecycleAdapter(context, new ArrayList<>()); // the correct grid size will be set later DockRecycleLayoutManager layoutManager = new DockRecycleLayoutManager(4, 1); // keep the preview the same as the actual thing RecyclerList quickListPreview = view.findViewById(R.id.dockPreview); quickListPreview.setAdapter(mAdapter); quickListPreview.setHasFixedSize(true); // the default item animator will mess up when drag and dropping quickListPreview.setItemAnimator(null); quickListPreview.setLayoutManager(layoutManager); // don't snap to pages or else we can't move items between them //quickListPreview.addOnScrollListener(new PagedScrollListener()); quickListPreview.setOnDragListener(EditQuickList::previewDragListener); quickListPreview.requestLayout(); // when user clicks, remove the view and the list item mAdapter.setOnClickListener((entry, v) -> mAdapter.removeItem(entry)); mAdapter.setOnLongClickListener(EditQuickList::previewStartDrag); SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context); QuickList.applyUiPref(pref, quickListPreview); if (!QuickList.populateList(context, mAdapter)) { TBApplication.behaviour(context).closeFragmentDialog(); Toast.makeText(context, "Failed!", Toast.LENGTH_SHORT).show(); } //TODO: implement drag and drop for multiple rows layoutManager.setRowCount(1); //TODO: implement drag and drop for right to left layout layoutManager.setRightToLeft(false); mViewPager = view.findViewById(R.id.viewPager); { TabLayout tabLayout = mViewPager.findViewById(R.id.tabLayout); tabLayout.setupWithViewPager(mViewPager); } { ArrayList pages = new ArrayList<>(3); LayoutInflater inflater = LayoutInflater.from(context); // actions addActions(inflater, pages); // filters addFilters(inflater, pages); // tags addTags(inflater, pages); if (DebugInfo.enableFavorites(context)) { // favorites addFavorites(inflater, pages); } pages.trimToSize(); mViewPager.setAdapter(new ViewPagerAdapter(pages)); for (ViewPagerAdapter.PageInfo page : pages) { CustomizeUI.setResultListPref(page.getView()); } } } /** * Start drag and drop action. * * @param entry the EntryItem we are moving * @param v The view we are dragging * @return if the startDrag method completes successfully */ private static boolean previewStartDrag(@NonNull EntryItem entry, @NonNull View v) { final DragAndDropInfo dragDropInfo = new DragAndDropInfo(); int idx = ((ViewGroup) v.getParent()).indexOfChild(v); dragDropInfo.overChildIdx = idx; dragDropInfo.draggedView = v; ClipData clipData = ClipData.newPlainText(Integer.toString(idx), entry.id); View.DragShadowBuilder shadow = new View.DragShadowBuilder(v); v.setVisibility(View.INVISIBLE); return startDragAndDrop(v, clipData, shadow, dragDropInfo); } @SuppressWarnings("deprecation") private static boolean startDragAndDrop(@NonNull View v, ClipData clipData, View.DragShadowBuilder shadow, DragAndDropInfo dragDropInfo) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { return v.startDragAndDrop(clipData, shadow, dragDropInfo, 0); } else { return v.startDrag(clipData, shadow, dragDropInfo, 0); } } protected static void repositionViews(@NonNull ViewGroup quickList, int resetUntilIdx, int moveRightUntilIdx, int moveLeftUntilIdx) { int idx = 0; for (; idx < resetUntilIdx; idx += 1) { View child = quickList.getChildAt(idx); child.animate().translationX(0f); //Log.d(TAG, "child #" + idx + " reset pos"); } for (; idx < moveRightUntilIdx; idx += 1) { View child = quickList.getChildAt(idx); child.animate().translationX(child.getWidth()); //Log.d(TAG, "child #" + idx + " move right"); } for (; idx < moveLeftUntilIdx; idx += 1) { View child = quickList.getChildAt(idx); child.animate().translationX(-child.getWidth()); //Log.d(TAG, "child #" + idx + " move left"); } final int childCount = quickList.getChildCount(); for (; idx < childCount; idx += 1) { View child = quickList.getChildAt(idx); child.animate().translationX(0f); //Log.d(TAG, "child #" + idx + " reset pos"); } } private static boolean previewDragListener(@Nullable View v, @NonNull DragEvent event) { final DragAndDropInfo dragDropInfo; final ViewGroup quickList; Object local = event.getLocalState(); if (!(local instanceof DragAndDropInfo)) { Log.d(TAG, "drag outside activity?"); return true; } if (!(v instanceof ViewGroup)) { Log.d(TAG, "only QuickList should listen"); return true; } dragDropInfo = (DragAndDropInfo) local; quickList = (ViewGroup) v; switch (event.getAction()) { case DragEvent.ACTION_DRAG_STARTED: case DragEvent.ACTION_DRAG_ENTERED: case DragEvent.ACTION_DROP: return true; case DragEvent.ACTION_DRAG_LOCATION: { final float x = event.getX(); // find new location index int location = 0; final int childCount = quickList.getChildCount(); for (int idx = 0; idx < childCount; idx += 1) { View child = quickList.getChildAt(idx); int left = child.getLeft(); //Log.d(TAG, "child #" + idx + " left = " + left); if (left > x) break; location = idx; } // check if we already processed this location if (dragDropInfo.overChildIdx == location) return true; final int emptyLocation = quickList.indexOfChild(dragDropInfo.draggedView); if (location < emptyLocation) { repositionViews(quickList, location, emptyLocation, 0); } else { repositionViews(quickList, emptyLocation + 1, 0, location + 1); } dragDropInfo.overChildIdx = location; // Log.d(TAG, "location = " + location); return true; } case DragEvent.ACTION_DRAG_EXITED: // if dragging outside, reset locations dragDropInfo.overChildIdx = quickList.indexOfChild(dragDropInfo.draggedView); repositionViews(quickList, dragDropInfo.overChildIdx, 0, 0); return true; case DragEvent.ACTION_DRAG_ENDED: default: { //Log.d(TAG, "drag ended"); final int childCount = quickList.getChildCount(); for (int idx = 0; idx < childCount; idx += 1) { View child = quickList.getChildAt(idx); child.animate().cancel(); child.setTranslationX(0f); } RecyclerView.Adapter adapter = quickList instanceof RecyclerList ? ((RecyclerList) quickList).getAdapter() : null; if (adapter instanceof RecycleAdapter) { int initialPosition = ((RecyclerView.LayoutParams) dragDropInfo.draggedView.getLayoutParams()).getViewAdapterPosition(); View overChild = quickList.getChildAt(dragDropInfo.overChildIdx); int newPosition = ((RecyclerView.LayoutParams) overChild.getLayoutParams()).getViewAdapterPosition(); if (initialPosition != newPosition) { ((RecycleAdapter) adapter).moveResult(initialPosition, newPosition); ((RecyclerList) quickList).scrollToPosition(newPosition); } } // check event.getResult() if dropping outside should matter dragDropInfo.draggedView.setVisibility(View.VISIBLE); return false; } } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/quicklist/EditQuickListDialog.java ================================================ package rocks.tbog.tblauncher.quicklist; import android.content.Context; 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 rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.ui.DialogFragment; public class EditQuickListDialog extends DialogFragment { private final EditQuickList mEditor = new EditQuickList(); @Override protected int layoutRes() { return R.layout.quick_list_editor; } @Nullable @Override public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { Context context = requireDialog().getContext(); setupDefaultButtonOkCancel(context); // make sure we use the dialog context LayoutInflater contextInflater = inflater.cloneInContext(context); return super.onCreateView(contextInflater, container, savedInstanceState); } @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); mEditor.bindView(view); setOnPositiveClickListener((dialog, button) -> { mEditor.applyChanges(requireContext()); onConfirm(null); }); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/quicklist/PagedScrollListener.java ================================================ package rocks.tbog.tblauncher.quicklist; import android.util.Log; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; public class PagedScrollListener extends RecyclerView.OnScrollListener { private static final String TAG = "PagedSL"; public PagedScrollListener() { } @Override public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) { if (newState == RecyclerView.SCROLL_STATE_IDLE) { snapToPage(recyclerView); } } public static void snapToPage(@NonNull RecyclerView recyclerView) { if (recyclerView.getLayoutManager() instanceof DockRecycleLayoutManager) { DockRecycleLayoutManager dockRecycleLayoutManager = (DockRecycleLayoutManager) recyclerView.getLayoutManager(); final float scroll = dockRecycleLayoutManager.getPageScroll(); final int page = (int) (scroll + .5f); final float delta = scroll - page; Log.d(TAG, "snapToPage: pageScroll=" + scroll + " delta=" + delta); final int pos; if (delta > .01f) { pos = dockRecycleLayoutManager.getPageAdapterPosition(page); } else if (delta < -0.01f) { pos = dockRecycleLayoutManager.getPageAdapterPosition(page + 1) - 1; } else { return; } Log.d(TAG, "smoothScrollToPosition " + pos + " page=" + page); recyclerView.smoothScrollToPosition(pos); } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/quicklist/QuickList.java ================================================ package rocks.tbog.tblauncher.quicklist; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.content.Context; import android.content.SharedPreferences; import android.content.res.Resources; import android.graphics.drawable.Drawable; import android.graphics.drawable.PaintDrawable; import android.os.Build; import android.util.Log; import android.view.View; import android.view.ViewGroup; import android.view.animation.AccelerateDecelerateInterpolator; import android.view.animation.DecelerateInterpolator; import androidx.annotation.IdRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.preference.PreferenceManager; import androidx.recyclerview.widget.RecyclerView; import java.util.ArrayList; import java.util.Collections; import java.util.List; import rocks.tbog.tblauncher.Behaviour; import rocks.tbog.tblauncher.LauncherState; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.TBLauncherActivity; import rocks.tbog.tblauncher.dataprovider.IProvider; import rocks.tbog.tblauncher.dataprovider.Provider; import rocks.tbog.tblauncher.dataprovider.QuickListProvider; import rocks.tbog.tblauncher.entry.ActionEntry; import rocks.tbog.tblauncher.entry.EntryItem; import rocks.tbog.tblauncher.entry.PlaceholderEntry; import rocks.tbog.tblauncher.entry.StaticEntry; import rocks.tbog.tblauncher.entry.TagEntry; import rocks.tbog.tblauncher.handler.DataHandler; import rocks.tbog.tblauncher.result.ResultHelper; import rocks.tbog.tblauncher.searcher.Searcher; import rocks.tbog.tblauncher.ui.ListPopup; import rocks.tbog.tblauncher.ui.RecyclerList; import rocks.tbog.tblauncher.ui.ViewStubPreview; import rocks.tbog.tblauncher.utils.PrefCache; import rocks.tbog.tblauncher.utils.UIColors; import rocks.tbog.tblauncher.utils.UISizes; /** * Dock */ public class QuickList { private static final String TAG = "Dock"; private static final int RETRY_COUNT = 3; private TBLauncherActivity mTBLauncherActivity; private boolean mQuickListEnabled = false; private boolean mOnlyForResults = false; private boolean mListDirty = true; private int mRetryCountdown; private RecyclerList mQuickList; private RecycleAdapter mAdapter; // private final ArrayList mQuickListItems = new ArrayList<>(0); private SharedPreferences mSharedPreferences = null; private final Runnable runCleanList = new Runnable() { @Override public void run() { if (mListDirty && TBApplication.state().isQuickListVisible()) { QuickList.this.populateList(); DataHandler dataHandler = TBApplication.dataHandler(mQuickList.getContext()); if (mListDirty && dataHandler.fullLoadOverSent()) { if (--mRetryCountdown <= 0) { Log.w(TAG, "Can't load all entries"); return; } } dataHandler.runAfterLoadOver(() -> { if (mListDirty) mQuickList.postDelayed(this, 500); }); } } }; // bAdapterEmpty is true when no search results are displayed private boolean bAdapterEmpty = true; // is any filter activated? private boolean bFilterOn = false; // is any action activated? private boolean bActionOn = false; // last filter scheme, used for better toggle behaviour private String mLastSelection = null; private String mLastAction = null; private static int cornerRadius(@NonNull Context ctx, @NonNull SharedPreferences pref) { Resources resources = ctx.getResources(); final int defaultCorner = resources.getInteger(R.integer.default_corner_radius); final int cornerRadius = pref.getInt("quick-list-radius", defaultCorner); return UISizes.dp2px(ctx, cornerRadius); } public static void applyUiPref(@NonNull SharedPreferences pref, View quickList) { Context ctx = quickList.getContext(); Resources resources = quickList.getResources(); // size int barHeight = pref.getInt("quick-list-height", 0); if (barHeight <= 1) barHeight = resources.getInteger(R.integer.default_dock_height); barHeight = UISizes.dp2px(ctx, barHeight); setGridSize(quickList); setLayoutDirection(quickList, pref.getBoolean("quick-list-rtl", false)); if (quickList.getLayoutParams() instanceof ViewGroup.MarginLayoutParams) { ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) quickList.getLayoutParams(); // set layout height params.height = barHeight; int hMargin = UISizes.getQuickListMarginHorizontal(ctx); int vMargin = UISizes.getQuickListMarginVertical(ctx); params.setMargins(hMargin, vMargin, hMargin, vMargin); quickList.setLayoutParams(params); } else { throw new IllegalStateException("quickList has the wrong layout params"); } final int cornerRadius = cornerRadius(ctx, pref); final int color = getBackgroundColor(pref); // rounded drawable if (cornerRadius > 0) { final PaintDrawable drawable; { Drawable background = quickList.getBackground(); if (background instanceof PaintDrawable) drawable = (PaintDrawable) background; else drawable = new PaintDrawable(); } drawable.getPaint().setColor(color); drawable.setCornerRadius(cornerRadius); quickList.setBackground(drawable); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { // clip list content to rounded corners quickList.setClipToOutline(true); } } else { quickList.setBackgroundColor(color); } } private static void setGridSize(View quickList) { DockRecycleLayoutManager layoutManager = null; if (quickList instanceof RecyclerList) { RecyclerView.LayoutManager mgr = ((RecyclerList) quickList).getLayoutManager(); if (mgr instanceof DockRecycleLayoutManager) layoutManager = (DockRecycleLayoutManager) mgr; } if (layoutManager == null) return; Context context = quickList.getContext(); SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context); int colCount = PrefCache.getDockColumnCount(pref); int rowCount = PrefCache.getDockRowCount(pref); layoutManager.setColumnCount(colCount); layoutManager.setRowCount(rowCount); quickList.requestLayout(); } private static void setLayoutDirection(View quickList, boolean rightToLeft) { DockRecycleLayoutManager layoutManager = null; if (quickList instanceof RecyclerList) { RecyclerView.LayoutManager mgr = ((RecyclerList) quickList).getLayoutManager(); if (mgr instanceof DockRecycleLayoutManager) layoutManager = (DockRecycleLayoutManager) mgr; } if (layoutManager == null) return; layoutManager.setRightToLeft(rightToLeft); } public static int getBackgroundColor(SharedPreferences pref) { return UIColors.getColor(pref, "quick-list-argb"); } public static void onClick(final EntryItem entry, View v) { if (entry instanceof StaticEntry) { entry.doLaunch(v, EntryItem.LAUNCHED_FROM_QUICK_LIST); } else { ResultHelper.launch(v, entry); } } public static boolean onLongClick(final EntryItem entry, View v) { ListPopup menu = entry.getPopupMenu(v, EntryItem.LAUNCHED_FROM_QUICK_LIST); // show menu only if it contains elements if (!menu.getAdapter().isEmpty()) { TBApplication.getApplication(v.getContext()).registerPopup(menu); menu.show(v); return true; } return false; } public Context getContext() { return mTBLauncherActivity; } public void onCreateActivity(TBLauncherActivity tbLauncherActivity) { mTBLauncherActivity = tbLauncherActivity; mSharedPreferences = PreferenceManager.getDefaultSharedPreferences(mTBLauncherActivity); inflateQuickListView(); mAdapter = new RecycleAdapter(getContext(), new ArrayList<>()); mAdapter.setOnClickListener(QuickList::onClick); mAdapter.setOnLongClickListener(QuickList::onLongClick); mQuickList.setHasFixedSize(true); mQuickList.setAdapter(mAdapter); mQuickList.setLayoutManager(new DockRecycleLayoutManager(8, 1)); mQuickList.addOnScrollListener(new PagedScrollListener()); reload(); } public void reload() { mRetryCountdown = RETRY_COUNT; mListDirty = true; if (mQuickList == null) return; mQuickList.removeCallbacks(runCleanList); mQuickList.postDelayed(runCleanList, 100); } @SuppressWarnings("unchecked") private T inflateViewStub(@IdRes int id) { View stub = mTBLauncherActivity.findViewById(id); return (T) ViewStubPreview.inflateStub(stub); } private void inflateQuickListView() { QuickListPosition position = PrefCache.getDockPosition(mSharedPreferences); if (position == QuickListPosition.POSITION_UNDER_SEARCH_BAR && !PrefCache.searchBarAtBottom(mSharedPreferences)) position = QuickListPosition.POSITION_ABOVE_RESULTS; if (position == QuickListPosition.POSITION_ABOVE_RESULTS) mQuickList = inflateViewStub(R.id.dockAboveResults); else if (position == QuickListPosition.POSITION_UNDER_RESULTS) mQuickList = inflateViewStub(R.id.dockUnderResults); else if (position == QuickListPosition.POSITION_UNDER_SEARCH_BAR) mQuickList = inflateViewStub(R.id.dockAtBottom); else mQuickList = inflateViewStub(R.id.quickList); } private void populateList() { if (isQuickListEnabled()) { mListDirty = !populateList(getContext(), mAdapter); } else { mListDirty = false; mAdapter.updateItems(Collections.emptyList()); mQuickList.setVisibility(View.GONE); } } /** * Set dock adapter content. May return false if the QuickListProvider is not loaded or it * contains at least one PlaceholderEntry * * @param context for the DataHandler * @param adapter to store the dock items * @return true if the adapter was populated with valid entries */ public static boolean populateList(Context context, RecycleAdapter adapter) { if (adapter == null) return false; boolean validEntries = true; final List list; QuickListProvider provider = TBApplication.dataHandler(context).getQuickListProvider(); if (provider != null && provider.isLoaded()) { List pojos = provider.getPojos(); list = pojos != null ? pojos : Collections.emptyList(); } else { list = Collections.emptyList(); validEntries = false; } for (EntryItem entry : list) { if (entry instanceof PlaceholderEntry) { validEntries = false; break; } } adapter.updateItems(list); return validEntries; } public void toggleSearch(@NonNull View v, @NonNull String query, @NonNull Class searcherClass) { Context ctx = v.getContext(); TBApplication app = TBApplication.getApplication(ctx); final String actionId; { Object tag_actionId = v.getTag(R.id.tag_actionId); actionId = tag_actionId instanceof String ? (String) tag_actionId : ""; } // toggle off any filter if (bFilterOn) { animToggleOff(); bFilterOn = false; app.behaviour().filterResults(null); } // show search content { // if the last action is not the current action, toggle on this action if (!bActionOn || !isLastSelection(actionId)) { app.behaviour().runSearcher(query, searcherClass); // update toggle information mLastSelection = actionId; bActionOn = true; } else { // to toggle off the action, set bActionOn to false app.behaviour().clearSearch(); } } } public void toggleProvider(View v, IProvider provider, @Nullable java.util.Comparator comparator) { Context ctx = v.getContext(); Behaviour behaviour = TBApplication.behaviour(ctx); final String actionId; { Object tag_actionId = v.getTag(R.id.tag_actionId); actionId = tag_actionId instanceof String ? (String) tag_actionId : ""; } // toggle off any filter if (bFilterOn) { animToggleOff(); bFilterOn = false; behaviour.filterResults(null); } // if the last action is not the current action, toggle on this action if (!bActionOn || !isLastSelection(actionId)) { behaviour.clearSearchText(); // show provider content or toggle off if nothing to show if (behaviour.showProviderEntries(provider, comparator)) { // update toggle information mLastSelection = actionId; bActionOn = true; } else { // to toggle off the action, set bActionOn to false behaviour.clearSearch(); } } else { // to toggle off the action, set bActionOn to false behaviour.clearSearch(); } } public void toggleFilter(View v, IProvider provider, @NonNull String filterName) { Context ctx = v.getContext(); TBApplication app = TBApplication.getApplication(ctx); final String actionId; { Object tag_actionId = v.getTag(R.id.tag_actionId); actionId = tag_actionId instanceof String ? (String) tag_actionId : ""; } // if there is no search we need to filter, just show all matching entries if (bAdapterEmpty) { if (bFilterOn && provider != null && isLastSelection(actionId)) { app.behaviour().clearAdapter(); bFilterOn = false; } else { if (app.behaviour().showProviderEntries(provider)) { mLastSelection = actionId; bFilterOn = true; } else { bFilterOn = false; } List list; list = provider != null ? provider.getPojos() : null; if (list != null) { app.behaviour().updateAdapter(list, false); mLastSelection = actionId; bFilterOn = true; } else { bFilterOn = false; } } // updateAdapter will change `bAdapterEmpty` and we change it back because we want // bAdapterEmpty to represent a search we need to filter bAdapterEmpty = true; } else if (bFilterOn && (provider == null || isLastSelection(actionId))) { animToggleOff(); if (mLastAction != null) { bActionOn = true; mLastSelection = mLastAction; mLastAction = null; } bFilterOn = false; app.behaviour().filterResults(null); } else if (provider != null) { animToggleOff(); if (bActionOn) mLastAction = mLastSelection; bFilterOn = true; mLastSelection = actionId; app.behaviour().filterResults(filterName); } // show what is currently toggled if (bFilterOn) { animToggleOn(v); } } public void toggleFilter(View v, @Nullable Provider provider) { Object tag_filterText = v.getTag(R.id.tag_filterText); String filterText = (tag_filterText instanceof String) ? (String) tag_filterText : ""; toggleFilter(v, provider, filterText); } private void animToggleOn(View v) { v.setSelected(true); v.setHovered(true); } private void animToggleOff() { if (!bFilterOn) return; int n = mQuickList.getChildCount(); for (int i = 0; i < n; i += 1) { View view = mQuickList.getChildAt(i); if (mLastSelection == null || mLastSelection == view.getTag(R.id.tag_actionId)) { view.setSelected(false); view.setHovered(false); } } } private boolean isQuickListEnabled() { if (mQuickListEnabled) { if (TBApplication.state().getDesktop() == LauncherState.Desktop.SEARCH) return PrefCache.modeSearchQuickListVisible(mSharedPreferences); if (TBApplication.state().getDesktop() == LauncherState.Desktop.EMPTY) return PrefCache.modeEmptyQuickListVisible(mSharedPreferences); if (TBApplication.state().getDesktop() == LauncherState.Desktop.WIDGET) return PrefCache.modeWidgetQuickListVisible(mSharedPreferences); } return mQuickListEnabled; } private boolean isOnlyForResults() { if (TBApplication.state().getDesktop() == LauncherState.Desktop.SEARCH) return mOnlyForResults; return false; } public void updateVisibility() { if (isQuickListEnabled()) { if (isOnlyForResults()) { if (TBApplication.state().isResultListVisible()) { show(); return; } } else { show(); return; } } hideQuickList(false); } private void show() { mQuickList.removeCallbacks(runCleanList); if (isQuickListEnabled()) { final SharedPreferences pref = mSharedPreferences; if (pref.getBoolean("quick-list-animation", true)) { mQuickList.animate() .scaleY(1f) .setInterpolator(new AccelerateDecelerateInterpolator()) .setListener(new AnimatorListenerAdapter() { @Override public void onAnimationStart(Animator animation) { TBApplication.state().setQuickList(LauncherState.AnimatedVisibility.ANIM_TO_VISIBLE); } @Override public void onAnimationEnd(Animator animation) { TBApplication.state().setQuickList(LauncherState.AnimatedVisibility.VISIBLE); } }) .start(); } else { mQuickList.animate().cancel(); mQuickList.setScaleY(1f); TBApplication.state().setQuickList(LauncherState.AnimatedVisibility.VISIBLE); } mQuickList.setVisibility(View.VISIBLE); } // after state set, make sure the list is not dirty runCleanList.run(); } public void hideQuickList(boolean animate) { if (isQuickListEnabled()) { animToggleOff(); if (animate) { mQuickList.animate() .scaleY(0f) .setInterpolator(new DecelerateInterpolator()) .setListener(new AnimatorListenerAdapter() { @Override public void onAnimationStart(Animator animation) { TBApplication.state().setQuickList(LauncherState.AnimatedVisibility.ANIM_TO_HIDDEN); } @Override public void onAnimationEnd(Animator animation) { TBApplication.state().setQuickList(LauncherState.AnimatedVisibility.HIDDEN); mQuickList.setVisibility(View.GONE); } }) .start(); mQuickList.setVisibility(View.VISIBLE); } else { TBApplication.state().setQuickList(LauncherState.AnimatedVisibility.HIDDEN); mQuickList.setScaleY(0f); mQuickList.setVisibility(View.GONE); } } else { TBApplication.state().setQuickList(LauncherState.AnimatedVisibility.HIDDEN); mQuickList.setVisibility(View.GONE); } } public void onStart() { final SharedPreferences pref = mSharedPreferences; mQuickListEnabled = pref.getBoolean("quick-list-enabled", true); mOnlyForResults = pref.getBoolean("quick-list-only-for-results", false); applyUiPref(pref, mQuickList); } public void adapterCleared() { animToggleOff(); bFilterOn = false; bActionOn = false; bAdapterEmpty = true; if (isOnlyForResults()) hideQuickList(true); mLastSelection = null; } public void adapterUpdated() { if (isOnlyForResults()) show(); animToggleOff(); bFilterOn = false; bActionOn = mLastSelection != null && (mLastSelection.startsWith(ActionEntry.SCHEME) || mLastSelection.startsWith(TagEntry.SCHEME)); bAdapterEmpty = false; } // ugly: check from where the entry was launched public boolean isViewInList(View view) { return mQuickList.indexOfChild(view) != -1; } public boolean isLastSelection(@NonNull String entryId) { return entryId.equals(mLastSelection); } public enum QuickListPosition { POSITION_ABOVE_RESULTS, POSITION_UNDER_RESULTS, POSITION_UNDER_SEARCH_BAR, } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/quicklist/RecycleAdapter.java ================================================ package rocks.tbog.tblauncher.quicklist; import android.content.Context; import android.content.SharedPreferences; import android.graphics.drawable.Drawable; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.preference.PreferenceManager; import java.util.ArrayList; import rocks.tbog.tblauncher.CustomizeUI; import rocks.tbog.tblauncher.entry.EntryItem; import rocks.tbog.tblauncher.entry.FilterEntry; import rocks.tbog.tblauncher.result.RecycleAdapterBase; import rocks.tbog.tblauncher.result.ResultHelper; import rocks.tbog.tblauncher.utils.UIColors; public class RecycleAdapter extends RecycleAdapterBase { public RecycleAdapter(@NonNull Context context, @NonNull ArrayList results) { super(results); setHasStableIds(true); setGridLayout(context, false); } @NonNull @Override public Holder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { final Context context = parent.getContext(); final int layoutRes = ResultHelper.getItemViewLayout(viewType); LayoutInflater inflater = LayoutInflater.from(context); View itemView = inflater.inflate(layoutRes, parent, false); return new Holder(itemView); } public void setGridLayout(@NonNull Context context, boolean bGridLayout) { final int oldFlags = mDrawFlags; // get new flags mDrawFlags = getDrawFlags(context); mDrawFlags |= bGridLayout ? EntryItem.FLAG_DRAW_GRID : EntryItem.FLAG_DRAW_QUICK_LIST; // refresh items if flags changed if (oldFlags != mDrawFlags) refresh(); } public static int getDrawFlags(@NonNull Context context) { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); int drawFlags = EntryItem.FLAG_DRAW_NO_CACHE | EntryItem.FLAG_RELOAD; if (prefs.getBoolean("quick-list-text-visible", true)) drawFlags |= EntryItem.FLAG_DRAW_NAME; if (prefs.getBoolean("quick-list-icons-visible", true)) drawFlags |= EntryItem.FLAG_DRAW_ICON; if (prefs.getBoolean("quick-list-show-badge", true)) drawFlags |= EntryItem.FLAG_DRAW_ICON_BADGE; if (UIColors.isColorLight(UIColors.getColor(prefs, "quick-list-argb"))) { drawFlags |= EntryItem.FLAG_DRAW_WHITE_BG; } return drawFlags; } @Override public void onBindViewHolder(@NonNull Holder holder, int position) { super.onBindViewHolder(holder, position); Context context = holder.itemView.getContext(); final int color; final EntryItem entry = getItem(position); if (entry instanceof FilterEntry) color = UIColors.getQuickListToggleColor(context); else color = UIColors.getQuickListRipple(context); Drawable selector = CustomizeUI.getSelectorDrawable(holder.itemView, color, true); holder.itemView.setBackground(selector); } public void moveResult(int sourceIdx, int destIdx) { notifyItemMoved(sourceIdx, destIdx); EntryItem entryItem = entryList.remove(sourceIdx); entryList.add(destIdx, entryItem); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/quicklist/ViewPagerAdapter.java ================================================ package rocks.tbog.tblauncher.quicklist; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.viewpager.widget.PagerAdapter; import java.util.ArrayList; class ViewPagerAdapter extends PagerAdapter { private final ArrayList mPages; ViewPagerAdapter(@NonNull ArrayList pages) { mPages = pages; } @Override public int getCount() { return mPages.size(); } @Override public boolean isViewFromObject(@NonNull View view, @NonNull Object object) { if (object instanceof PageInfo) return ((PageInfo) object).view == view; return false; } @Nullable @Override public CharSequence getPageTitle(int position) { return mPages.get(position).title; } @NonNull @Override public Object instantiateItem(@NonNull ViewGroup container, int position) { final PageInfo pageInfo = mPages.get(position); container.addView(pageInfo.view); return pageInfo; } @Override public void destroyItem(@NonNull ViewGroup container, int position, @NonNull Object object) { if (object instanceof PageInfo) container.removeView(((PageInfo) object).view); else container.removeView(mPages.get(position).view); } public static class PageInfo { private final String title; private final View view; public PageInfo(String title, View view) { this.title = title; this.view = view; } public View getView() { return view; } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/result/AsyncSetEntryDrawable.java ================================================ package rocks.tbog.tblauncher.result; import android.app.Activity; import android.content.Context; import android.graphics.drawable.Drawable; import android.util.Log; import android.widget.ImageView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.UiThread; import androidx.annotation.WorkerThread; import java.lang.ref.WeakReference; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.WorkAsync.AsyncTask; import rocks.tbog.tblauncher.WorkAsync.TaskRunner; import rocks.tbog.tblauncher.entry.EntryItem; import rocks.tbog.tblauncher.utils.Utilities; public abstract class AsyncSetEntryDrawable extends AsyncTask { private static final String TAG = "AyncSED"; protected final String cacheId; private final WeakReference weakImage; protected int drawFlags; protected Entry entryItem; public AsyncSetEntryDrawable(@NonNull ImageView image, int drawFlags, @NonNull Entry entryItem) { super(); cacheId = entryItem.getIconCacheId(); Object tag_cacheId = image.getTag(R.id.tag_cacheId); Object tag_iconTask = image.getTag(R.id.tag_iconTask); image.setTag(R.id.tag_cacheId, cacheId); image.setTag(R.id.tag_iconTask, this); boolean keepIcon = false; if (tag_iconTask instanceof AsyncSetEntryDrawable) { AsyncSetEntryDrawable task = (AsyncSetEntryDrawable) tag_iconTask; task.cancel(false); // if the old task was loading the same entry we can keep the icon while we refresh it keepIcon = entryItem.equals(task.entryItem); } else if (tag_cacheId instanceof String) { // if the tag equals cacheId then we can keep the icon while we refresh it keepIcon = tag_cacheId.equals(cacheId); } Log.i(TAG, "start task=" + Integer.toHexString(hashCode()) + " view=" + Integer.toHexString(image.hashCode()) + " tag_iconTask=" + (tag_iconTask != null ? Integer.toHexString(tag_iconTask.hashCode()) : "null") + " entry=`" + entryItem.getName() + "`" + " keepIcon=" + keepIcon + " tag_cacheId=" + tag_cacheId + " cacheId=" + cacheId); if (!keepIcon) { ResultViewHelper.setLoadingIcon(image); } this.weakImage = new WeakReference<>(image); this.drawFlags = drawFlags; this.entryItem = entryItem; } @Nullable public ImageView getImageView() { ImageView imageView = weakImage.get(); // make sure we have a valid activity Activity act = Utilities.getActivity(imageView); if (act == null) return null; return imageView; } @Override protected Drawable doInBackground(Void param) { ImageView image = getImageView(); if (isCancelled() || image == null) { weakImage.clear(); return null; } Context ctx = image.getContext(); return getDrawable(ctx); } @WorkerThread protected abstract Drawable getDrawable(Context context); @UiThread protected void setDrawable(ImageView image, Drawable drawable) { image.setImageDrawable(drawable); // async task finished, set icon task to null image.setTag(R.id.tag_iconTask, null); // start animation if it's possible Utilities.startAnimatable(image); } @Override protected void onPostExecute(Drawable drawable) { ImageView image = getImageView(); if (image == null || drawable == null) { Log.i(TAG, "end task=" + Integer.toHexString(hashCode()) + " view=" + (image == null ? "null" : Integer.toHexString(image.hashCode())) + " drawable=" + drawable + " cacheId=`" + cacheId + "`"); weakImage.clear(); return; } Object tag_cacheId = image.getTag(R.id.tag_cacheId); Object tag_iconTask = image.getTag(R.id.tag_iconTask); if (cacheId != null && !Utilities.checkFlag(drawFlags, EntryItem.FLAG_DRAW_NO_CACHE)) TBApplication.drawableCache(image.getContext()).cacheDrawable(cacheId, drawable); Log.i(TAG, "end task=" + Integer.toHexString(hashCode()) + " view=" + Integer.toHexString(image.hashCode()) + " tag_iconTask=" + (tag_iconTask != null ? Integer.toHexString(tag_iconTask.hashCode()) : "null") + " cacheId=`" + cacheId + "`"); if (tag_iconTask instanceof AsyncSetEntryDrawable) { AsyncSetEntryDrawable task = (AsyncSetEntryDrawable) tag_iconTask; if (!entryItem.equals(task.entryItem)) { Log.d(TAG, "[task] skip reason: `" + entryItem.getName() + "` \u2260 `" + task.entryItem.getName() + "`"); weakImage.clear(); return; } } else { Log.d(TAG, "[task] skip reason: tag_iconTask=null entry=`" + entryItem.getName() + "`"); weakImage.clear(); return; } // if the cacheId changed, skip if (!tag_cacheId.equals(cacheId)) { Log.d(TAG, "[cacheId] skip reason: `" + tag_cacheId + "` \u2260 `" + cacheId + "`"); weakImage.clear(); return; } setDrawable(image, drawable); } @Override protected void onCancelled() { ImageView image = getImageView(); Log.i(TAG, "cancelled task=" + Integer.toHexString(hashCode()) + " view=" + (image != null ? Integer.toHexString(image.hashCode()) : "null")); } public void execute() { TaskRunner.executeOnExecutor(ResultViewHelper.EXECUTOR_LOAD_ICON, this); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/result/CustomRecycleLayoutManager.java ================================================ package rocks.tbog.tblauncher.result; import android.util.Log; import android.view.View; import android.view.ViewGroup; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.recyclerview.widget.LinearSmoothScroller; import androidx.recyclerview.widget.RecyclerView; import java.util.ArrayList; import java.util.Arrays; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.utils.SparseArrayWrapper; public class CustomRecycleLayoutManager extends RecyclerView.LayoutManager implements ReversibleAdapterRecyclerLayoutManager { private static final String TAG = "CRLM"; private static final Boolean LOG_DEBUG = false; private RecyclerView mRecyclerView = null; /* First position visible at any point (adapter index) */ private int mFirstVisiblePosition; /* Number of items to show on a row */ private int mColCount = 1; /* Number of visible rows. It is computed when the size changes but may also increase when layout occurs */ private int mVisibleRows; /* Consistent size applied to all child views */ private int mDecoratedChildWidth = 0; private final ArrayList mRowInfo = new ArrayList<>(0); /* Used for starting the layout from the bottom. If true the first item is places at the bottom */ private boolean mBottomToTop; /* Used for starting the layout from the right. If true the first item of each row is places at the right */ private boolean mRightToLeft; /* Used for reversing adapter order */ private boolean mReverseAdapter; /* Compute column count based on available space */ private boolean mAutoColumn = false; /* When list height grows, keep bottom view at the bottom */ private boolean mAutoScrollBottom = true; /* Keep track of the amount of over-scroll */ private int mOverScrollVertical = 0; private OverScrollListener mOverScrollListener = null; private boolean mRefreshViews = false; // Reusable array. This should only be used used transiently and should not be used to retain any state over time. private final SparseArrayWrapper mViewCache = new SparseArrayWrapper<>(); public interface OverScrollListener { void onOverScroll(RecyclerView recyclerView, int amount); } private static class RowInfo { /* row alignment offset. When layout is bottom to top the first row has pos==-height */ int pos = 0; /* height of this row */ int height = 0; } public CustomRecycleLayoutManager() { this(false, false, false); } public CustomRecycleLayoutManager(boolean bottomToTop, boolean rightToLeft, boolean reverseAdapter) { super(); mBottomToTop = bottomToTop; mRightToLeft = rightToLeft; mReverseAdapter = reverseAdapter; } public void setBottomToTop(boolean bottomToTop) { assertNotInLayoutOrScroll(null); if (mBottomToTop == bottomToTop) { return; } mBottomToTop = bottomToTop; requestLayout(); } public boolean isBottomToTop() { return mBottomToTop; } public void setRightToLeft(boolean rightToLeft) { assertNotInLayoutOrScroll(null); if (mRightToLeft == rightToLeft) { return; } mRightToLeft = rightToLeft; requestLayout(); } public boolean isRightToLeft() { return mRightToLeft; } @Override public void setReverseAdapter(boolean reverseAdapter) { assertNotInLayoutOrScroll(null); if (mReverseAdapter == reverseAdapter) { return; } mReverseAdapter = reverseAdapter; requestLayout(); } @Override public boolean isReverseAdapter() { return mReverseAdapter; } /** * Set column parameters * * @param columnCount number of columns * @param autoFill if true the column count will be recomputed to fit the width */ public void setColumns(int columnCount, boolean autoFill) { assertNotInLayoutOrScroll(null); mColCount = columnCount <= 0 ? 1 : columnCount; mAutoColumn = autoFill; // make sure we are using recycled views mRefreshViews = true; // reset row info mRowInfo.clear(); requestLayout(); } public int getColumnCount() { return mColCount; } public void setAutoScrollBottom(boolean autoScrollBottom) { mAutoScrollBottom = autoScrollBottom; } public void setOverScrollListener(@Nullable OverScrollListener listener) { mOverScrollListener = listener; } @Override public int computeVerticalScrollExtent(@NonNull RecyclerView.State state) { return getVerticalSpace(); } @Override public int computeVerticalScrollOffset(@NonNull RecyclerView.State state) { if (getChildCount() <= 1 || mRowInfo.isEmpty()) return 0; int offset; View child = getChildAt(0); if (child != null) { int rowPosition = getRowPosition(getRowIdx(adapterPosition(child))); rowPosition -= getPaddingTop(); int childTop = getDecoratedTop(child); childTop -= getPaddingTop(); offset = rowPosition - childTop; if (mBottomToTop) { // align to bottom offset = -mRowInfo.get(mRowInfo.size() - 1).pos + offset - getVerticalSpace(); } } else { final View view = findBottomVisibleItemView(); final int topRow = getRowIdx(topAdapterItemIdx()); final int bottomRow = getRowIdx(adapterPosition(view)); final int topRowPos = mRowInfo.get(topRow).pos; final int bottomRowPos = mRowInfo.get(bottomRow).pos; int topRowsHeight = bottomRowPos - topRowPos; topRowsHeight += getRowHeight(bottomRow); offset = getPaddingTop() - getDecoratedBottom(view) + topRowsHeight; } return offset; } @Override public int computeVerticalScrollRange(@NonNull RecyclerView.State state) { if (!mRowInfo.isEmpty()) { RowInfo rowInfo = mRowInfo.get(mRowInfo.size() - 1); return mBottomToTop ? -rowInfo.pos : (rowInfo.pos + rowInfo.height); } return 0; } @Override public void onAttachedToWindow(RecyclerView view) { super.onAttachedToWindow(view); mRecyclerView = view; } @Override public void onDetachedFromWindow(RecyclerView view, RecyclerView.Recycler recycler) { mRecyclerView = null; super.onDetachedFromWindow(view, recycler); } /* * You must return true from this method if you want your * LayoutManager to support anything beyond "simple" item * animations. Enabling this causes onLayoutChildren() to * be called twice on each animated change; once for a * pre-layout, and again for the real layout. */ @Override public boolean supportsPredictiveItemAnimations() { return false; } /* * Called by RecyclerView when a view removal is triggered. This is called * before onLayoutChildren() in pre-layout if the views removed are not visible. We * use it in this case to inform pre-layout that a removal took place. * * This method is still called if the views removed were visible, but it will * happen AFTER pre-layout. */ @Override public void onItemsRemoved(@NonNull RecyclerView recyclerView, int positionStart, int itemCount) { logDebug("onItemsRemoved start=" + positionStart + " count=" + itemCount); mRefreshViews = true; mRowInfo.clear(); } /** * Called in response to a call to {@link RecyclerView.Adapter#notifyDataSetChanged()} or * {@link RecyclerView#swapAdapter(RecyclerView.Adapter, boolean)} ()} and signals that the the entire * data set has changed. * * @param recyclerView not used */ @Override public void onItemsChanged(@NonNull RecyclerView recyclerView) { mRefreshViews = true; if (mFirstVisiblePosition > getItemCount()) { int oldValue = mFirstVisiblePosition; int newValue = mBottomToTop ? bottomAdapterItemIdx() : topAdapterItemIdx(); logDebug("onItemsChanged mFirstVisiblePosition changed from " + oldValue + " to " + newValue); mFirstVisiblePosition = newValue; } else { logDebug("onItemsChanged"); } if (mRowInfo.size() != computeRowCount(getItemCount())) mRowInfo.clear(); } @Override public void onItemsUpdated(@NonNull RecyclerView recyclerView, int positionStart, int itemCount) { logDebug("onItemsUpdated start=" + positionStart + " count=" + itemCount); mRefreshViews = true; } @Override public void onAdapterChanged(@Nullable RecyclerView.Adapter oldAdapter, @Nullable RecyclerView.Adapter newAdapter) { logDebug("onAdapterChanged"); mRefreshViews = true; mRowInfo.clear(); } @Override public void scrollToPosition(int adapterPos) { if (adapterPos < 0 || adapterPos >= getItemCount()) { Log.e(TAG, "Cannot scroll to " + adapterPos + ", item count " + getItemCount()); return; } int oldValue = mFirstVisiblePosition; int newValue = adapterPos / mColCount * mColCount; if (oldValue != newValue) { logDebug("scrollToPosition mFirstVisiblePosition changed from " + oldValue + " to " + newValue); mFirstVisiblePosition = newValue; } } @Override public RecyclerView.LayoutParams generateDefaultLayoutParams() { return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); } private int getVerticalSpace() { return getHeight() - getPaddingBottom() - getPaddingTop(); } private int getHorizontalSpace() { return getWidth() - getPaddingRight() - getPaddingLeft(); } /* * This method is your initial call from the framework. You will receive it when you * need to start laying out the initial set of views. This method will not be called * repeatedly, so don't rely on it to continually process changes during user * interaction. * * This method will be called when the data set in the adapter changes, so it can be * used to update a layout based on a new item count. * * If predictive animations are enabled, you will see this called twice. First, with * state.isPreLayout() returning true to lay out children in their initial conditions. * Then again to lay out children in their final locations. * * When scrolling, if a view has been added, this will be called after scroll*By */ @Override public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) { //We have nothing to show for an empty data set but clear any existing views if (getItemCount() == 0) { detachAndScrapAttachedViews(recycler); return; } if (getChildCount() == 0 && state.isPreLayout()) { //Nothing to do during prelayout when empty return; } if (getChildCount() == 0) { // First or empty layout mFirstVisiblePosition = mBottomToTop ? bottomAdapterItemIdx() : topAdapterItemIdx(); } if (getChildCount() == 0 || mRowInfo.isEmpty()) { final int decoratedChildHeight; if (getChildCount() == 0) { // Scrap measure one child View scrap = recycler.getViewForPosition(mFirstVisiblePosition); addView(scrap); measureChildWithMargins(scrap, 0, 0); mDecoratedChildWidth = getDecoratedMeasuredWidth(scrap); decoratedChildHeight = getDecoratedMeasuredHeight(scrap); detachAndScrapView(scrap, recycler); } else { View child = getChildAt(0); if (child == null) throw new IllegalStateException("null child at idx 0 when childCount=" + getChildCount()); mDecoratedChildWidth = getDecoratedMeasuredWidth(child); decoratedChildHeight = getDecoratedMeasuredHeight(child); } updateRowInfo(decoratedChildHeight); } // Always update the visible row/column counts updateSizing(); logDebug("onLayoutChildren" + " childCount=" + getChildCount() + " itemCount=" + getItemCount() + " columns=" + mColCount + " mDecoratedChildWidth=" + mDecoratedChildWidth + (state.isPreLayout() ? " preLayout" : "") + (state.didStructureChange() ? " structureChanged" : "") + " stateItemCount=" + state.getItemCount()); layoutChildren(recycler); } @Override public void onLayoutCompleted(RecyclerView.State state) { super.onLayoutCompleted(state); if (getChildCount() > 0) { // check if this was a resize that requires a forced scroll if (mBottomToTop && mAutoScrollBottom) { View bottomView = getBottomView(); int childHeight = getDecoratedMeasuredHeight(bottomView); int bottomDelta = getPaddingTop() + getVerticalSpace() - getDecoratedBottom(bottomView); if (bottomDelta > 0) { // the last view is too high offsetChildrenVertical(bottomDelta); logDebug("(1) auto-scroll (" + getDebugName(bottomView) + ") bottom amount=" + bottomDelta); } else if (bottomDelta > (-childHeight * 3 / 5) && bottomAdapterItemIdx() == mFirstVisiblePosition) { // the first visible item (at the bottom) is hidden by a small amount, scroll it into view smoothly //TODO: detect if this occurred because of a user scroll so we can skip this if so logDebug("(2) smooth auto-scroll (" + getDebugName(bottomView) + ") bottom amount=" + bottomDelta); LinearSmoothScroller smoothScroller = new LinearSmoothScroller(bottomView.getContext()); smoothScroller.setTargetPosition(mFirstVisiblePosition); startSmoothScroll(smoothScroller); } else if (bottomAdapterItemIdx() == mFirstVisiblePosition) { // list got resized and the first visible item (at the bottom) is now hidden offsetChildrenVertical(bottomDelta); logDebug("(3) auto-scroll (" + getDebugName(bottomView) + ") bottom amount=" + bottomDelta); } else { logDebug("(4) no auto-scroll (" + getDebugName(bottomView) + ") bottom amount=" + bottomDelta); } } } } /** * Compute scroll offset based on mViewCache * * @return difference between child top position and initial layout position */ private int computeScrollOffset() { if (mViewCache.size() > 0) { int adapterPos = mViewCache.keyAt(0); int rowIdx = getRowIdx(adapterPos); int rowPosition = getRowPosition(rowIdx); View child = mViewCache.valueAt(0); return getDecoratedTop(child) - rowPosition; } return 0; } private int computeRowCount(int itemCount) { return itemCount / mColCount + (itemCount % mColCount == 0 ? 0 : 1); } private class LayoutRowHelper { public final RecyclerView.Recycler recycler; @NonNull public final View[] viewRow; public int mDecoratedHeight; public int mDecoratedTop; public int mDecoratedBottom; public int nextLeftPosDelta; private LayoutRowHelper(RecyclerView.Recycler recycler, int columnCount) { this.recycler = recycler; viewRow = new View[columnCount]; } public void setPositionIncrement(int nextLeft) { nextLeftPosDelta = nextLeft; } public void layoutRow(int rowIdx, int posX, int posY) { int columnCount = viewRow.length; mDecoratedHeight = 0; mDecoratedTop = Integer.MAX_VALUE; mDecoratedBottom = Integer.MIN_VALUE; for (int colIdx = 0; colIdx < columnCount; colIdx += 1) { final int leftPos = posX + nextLeftPosDelta * colIdx; final int adapterPos = adapterPosition(colIdx, rowIdx); View child = layoutView(adapterPos, leftPos, posY); // child may be null if row is incomplete viewRow[colIdx] = child; if (child == null) continue; mDecoratedTop = Math.min(mDecoratedTop, CustomRecycleLayoutManager.this.getDecoratedTop(child)); mDecoratedBottom = Math.max(mDecoratedBottom, CustomRecycleLayoutManager.this.getDecoratedBottom(child)); // update row height int measuredHeight = getDecoratedMeasuredHeight(child); mDecoratedHeight = Math.max(mDecoratedHeight, measuredHeight); } int heightDelta = posY - getDecoratedTop(); if (heightDelta != 0) { offsetVertical(heightDelta); } // make all views in this row the same height for (View child : viewRow) { if (child != null) { // set bottom; we expect the top to not change child.setBottom(mDecoratedBottom - getBottomDecorationHeight(child)); } } } @Nullable private View layoutView(int adapterPos, int leftPos, int topPos) { if (adapterPos < 0 || adapterPos >= getItemCount()) return null; return layoutAdapterPos(recycler, adapterPos, leftPos, topPos); } public void offsetVertical(int offsetY) { for (View child : viewRow) { if (child == null) continue; child.offsetTopAndBottom(offsetY); int childIdx = indexOfChild(child); int position = adapterPosition(child); logDebug("move #" + childIdx + " pos=" + position + " (" + child.getLeft() + " " + child.getTop() + " " + child.getRight() + " " + child.getBottom() + ")" + " " + getDebugInfo(child) + " " + getDebugName(child)); } mDecoratedTop += offsetY; mDecoratedBottom += offsetY; } public void initRow() { Arrays.fill(viewRow, null); } public int getDecoratedHeight() { return mDecoratedHeight; } public int getDecoratedTop() { return mDecoratedTop; } public int getDecoratedBottom() { return mDecoratedBottom; } } private int getRowPosition(int rowIdx) { if (rowIdx < 0 || rowIdx >= mRowInfo.size()) { Log.e(TAG, "getRowPosition(" + rowIdx + "); rowInfo.size=" + mRowInfo.size()); return 0; } int pos = getPaddingTop(); if (mBottomToTop) pos += getVerticalSpace(); pos += mRowInfo.get(rowIdx).pos; return pos; } private int getRowHeight(int rowIdx) { if (rowIdx < 0 || rowIdx >= mRowInfo.size()) { Log.e(TAG, "getRowHeight(" + rowIdx + "); rowInfo.size=" + mRowInfo.size()); return 0; } return mRowInfo.get(rowIdx).height; } private void layoutChildren(RecyclerView.Recycler recycler) { /* * Detach all existing views from the layout. * detachView() is a lightweight operation that we can use to * quickly reorder views without a full add/remove. */ cacheChildren(); // compute scroll position after we populate `mViewCache` final int scrollOffset = computeScrollOffset(); logDebug("layoutChildren" + " mFirstVisiblePosition=" + mFirstVisiblePosition + " scrollOffset=" + scrollOffset + " paddingTop=" + getPaddingTop() + " paddingBottom=" + getPaddingBottom() + " verticalSpace=" + getVerticalSpace()); if (mRefreshViews) { logDebug("detachAndScrapAttachedViews" + " viewCache.size=" + mViewCache.size()); // If we want to refresh all views, just scrap them and let the recycler rebind them mRefreshViews = false; mViewCache.clear(); detachAndScrapAttachedViews(recycler); } else { logDebug("detachViews" + " viewCache.size=" + mViewCache.size()); // Temporarily detach all views. We do this to easily reorder them. detachCachedChildren(recycler); } final int posX = getPaddingLeft() + (mRightToLeft ? (getHorizontalSpace() - mDecoratedChildWidth) : 0); final int nextLeftPosDelta = mRightToLeft ? -mDecoratedChildWidth : mDecoratedChildWidth; LayoutRowHelper rowHelper = new LayoutRowHelper(recycler, mColCount); rowHelper.setPositionIncrement(nextLeftPosDelta); //TODO: start laying out children from the ones we have in cache; // this way there is less chance of moving already cached children //TODO: if (mBottomToTop==true) and we scroll up (thumb goes up) the rows should // update in reverse (starting from current row to 0) // layout rows int visibleRowIdx = 0; while (visibleRowIdx < mVisibleRows) { final int adapterPos = adapterPosition(visibleRowIdx * mColCount); if (adapterPos < 0 || adapterPos >= getItemCount()) { Log.w(TAG, "! visibleRowIdx=" + visibleRowIdx + " missing adapter pos=" + adapterPos + " itemCount=" + getItemCount() + " mVisibleRows=" + mVisibleRows); visibleRowIdx += 1; continue; } rowHelper.initRow(); final int rowIdx = getRowIdx(adapterPos); final int rowHeight = getRowHeight(rowIdx); final int rowPosition = getRowPosition(rowIdx); final int scrolledRowPosition = scrollOffset + rowPosition; logDebug("row #" + rowIdx + " pos=" + adapterPos + " before layout" + " rowHeight=" + rowHeight + " rowPosition=" + rowPosition + " scrolledPos=" + scrolledRowPosition); rowHelper.layoutRow(rowIdx, posX, scrolledRowPosition); if (rowHeight != rowHelper.getDecoratedHeight()) { updateRowHeight(rowIdx, rowHelper.getDecoratedHeight()); if (mBottomToTop) // same as (rowPosition != getRowPosition(rowIdx)) rowHelper.offsetVertical(rowHeight - rowHelper.getDecoratedHeight()); } visibleRowIdx += 1; // if this is the last visible row, check if we could show more if (visibleRowIdx == mVisibleRows) { boolean needsMoreRows = mBottomToTop ? (rowHelper.getDecoratedTop() > 0) : (rowHelper.getDecoratedBottom() < getHeight()); needsMoreRows = needsMoreRows && (visibleRowIdx * mColCount < getItemCount()); if (needsMoreRows) { // force-show more views mVisibleRows += 1; } } } clearViewCache(recycler); } /** * Cache all views by their existing position */ private void cacheChildren() { mViewCache.clear(); int childCount = getChildCount(); mViewCache.ensureCapacity(childCount); for (int i = 0; i < childCount; i++) { View child = getChildAt(i); if (child == null) throw new IllegalStateException("null child when count=" + getChildCount() + " and idx=" + i); int position = adapterPosition(child); mViewCache.put(position, child); logDebug("info #" + i + " pos=" + position + " " + getDebugInfo(child) + " " + getDebugName(child)); } } private void detachCachedChildren(RecyclerView.Recycler recycler) { for (int i = 0; i < mViewCache.size(); i++) { View child = mViewCache.valueAt(i); // When an update is in order, scrap the view and let the recycler rebind it if (viewNeedsUpdate(child)) { detachAndScrapView(child, recycler); mViewCache.removeAt(i--); } else { detachView(child); } } } /** * Layout view from cache or recycler * * @param recycler the recycler * @param adapterPos adapter index * @param leftPos layout position X * @param topPos layout position Y * @return child view */ private View layoutAdapterPos(RecyclerView.Recycler recycler, int adapterPos, int leftPos, int topPos) { View child = mViewCache.get(adapterPos); if (child == null) { /* * The Recycler will give us either a newly constructed view or a recycled view it has on-hand. * In either case, the view will already be fully bound to the data by the adapter for us. */ child = recycler.getViewForPosition(adapterPos); addView(child); /* * It is prudent to measure/layout each new view we receive from the Recycler. * We don't have to do this for views we are just re-arranging. */ measureChildWithMargins(child, (mColCount - 1) * mDecoratedChildWidth, 0); layoutChildView(child, leftPos, topPos); logDebug("child #" + indexOfChild(child) + " pos=" + adapterPos + " (" + child.getLeft() + " " + child.getTop() + " " + child.getRight() + " " + child.getBottom() + ")" + " " + getDebugInfo(child) + " " + getDebugName(child)); } else { attachView(child); logDebug("cache #" + indexOfChild(child) + " pos=" + adapterPos + " (" + child.getLeft() + " " + child.getTop() + " " + child.getRight() + " " + child.getBottom() + ")" + " top=" + topPos + " " + getDebugInfo(child) + " " + getDebugName(child)); mViewCache.remove(adapterPos); } return child; } private void layoutChildView(View view, int left, int top) { layoutChildView(view, left, top, mDecoratedChildWidth, getDecoratedMeasuredHeight(view)); } private void layoutChildView(View view, int left, int top, int width, int height) { layoutDecorated(view, left, top, left + width, top + height); } /** * We ask the Recycler to scrap and store any views that we did not re-attach. * These are views that are not currently necessary because they are no longer visible. */ private void clearViewCache(RecyclerView.Recycler recycler) { for (int i = 0; i < mViewCache.size(); i++) { final View removingView = mViewCache.valueAt(i); logDebug("recycleView pos=" + mViewCache.keyAt(i) + " " + getDebugName(removingView)); recycler.recycleView(removingView); } mViewCache.clear(); } /* * Rather than continuously checking how many views we can fit * based on scroll offsets, we simplify the math by computing the * visible grid as what will initially fit on screen, plus one. */ private void updateSizing() { int visibleWidth = getHorizontalSpace(); int visibleCols; if (mAutoColumn) visibleCols = visibleWidth / mDecoratedChildWidth; else visibleCols = mColCount; if (visibleCols <= 0) visibleCols = 1; int averageChildHeight; if (mBottomToTop) { averageChildHeight = -mRowInfo.get(mRowInfo.size() - 1).pos; } else { RowInfo rowInfo = mRowInfo.get(mRowInfo.size() - 1); averageChildHeight = rowInfo.pos + rowInfo.height; } averageChildHeight /= mRowInfo.size(); // we use getHeight instead of `getVerticalSpace` because we can scroll vertically int visibleHeight = getHeight(); int visibleRows = (visibleHeight / averageChildHeight) + 1; if (visibleHeight % averageChildHeight > 0) { visibleRows++; } if (mColCount != visibleCols) { mColCount = visibleCols; int rowHeight = getRowHeight(0); updateRowInfo(rowHeight); } mVisibleRows = computeRowCount(Math.min(visibleRows * visibleCols, getItemCount())); mDecoratedChildWidth = visibleWidth / visibleCols; } /** * Initialize all rows with valid information. This should be called every time the number of * rows or columns changes. * * @param expectedRowHeight initial height. Row information will be updated during the layout * phase. See `updateRowHeight` */ private void updateRowInfo(int expectedRowHeight) { final int rowCount = computeRowCount(getItemCount()); mRowInfo.clear(); mRowInfo.ensureCapacity(rowCount); logDebug("updateRowInfo" + " height=" + expectedRowHeight + " rowCount=" + rowCount); for (int rowIdx = 0; rowIdx < rowCount; rowIdx++) { RowInfo rowInfo = new RowInfo(); rowInfo.height = expectedRowHeight; if (mBottomToTop) rowInfo.pos = -expectedRowHeight * (rowIdx + 1); else rowInfo.pos = expectedRowHeight * rowIdx; mRowInfo.add(rowInfo); } } /** * This is called during layout when a row height needs adjusting. All rows after the current * one will have their position updated. In bottom to top layout the current row position will * also be updated. * * @param rowIdx row that needs to be adjusted * @param newHeight new height of the row */ private void updateRowHeight(int rowIdx, int newHeight) { if (rowIdx < 0 || rowIdx >= mRowInfo.size()) { Log.e(TAG, "updateRowHeight(" + rowIdx + ")" + " rowInfo.size=" + mRowInfo.size()); return; } RowInfo rowInfo = mRowInfo.get(rowIdx); final int oldPos = rowInfo.pos; final int oldHeight = rowInfo.height; if (mBottomToTop) rowInfo.pos -= newHeight - rowInfo.height; rowInfo.height = newHeight; logDebug("updateRowHeight" + " row #" + rowIdx + " posY changed from " + oldPos + " to " + rowInfo.pos + " height changed from " + oldHeight + " to " + rowInfo.height); for (int row = rowIdx + 1, rowCount = mRowInfo.size(); row < rowCount; row += 1) { RowInfo rowIt = mRowInfo.get(row); if (mBottomToTop) rowIt.pos = rowInfo.pos - rowIt.height; else rowIt.pos = rowInfo.pos + rowInfo.height; rowInfo = rowIt; } } /* * Use this method to tell the RecyclerView if scrolling is even possible * in the vertical direction. */ @Override public boolean canScrollVertically() { if (getItemCount() != getChildCount()) return true; if (getChildCount() > 0) { //We do allow scrolling if (getDecoratedTop(getTopView()) < getPaddingTop()) return true; return getDecoratedBottom(getBottomView()) > (getPaddingTop() + getVerticalSpace()); } return false; } private int indexOfChild(View child) { for (int idx = getChildCount() - 1; idx >= 0; idx -= 1) { if (getChildAt(idx) == child) return idx; } return -1; } private int adapterPosition(@NonNull View child) { return ((RecyclerView.LayoutParams) child.getLayoutParams()).getViewLayoutPosition(); } private int adapterPosition(int childIdx) { int idx = childIdx; if (mReverseAdapter) idx = -idx; return mFirstVisiblePosition + idx; } private int adapterPosition(int colIdx, int rowIdx) { int idx = rowIdx * mColCount + colIdx; if (mReverseAdapter) idx = getItemCount() - 1 - idx; return idx; } private int getRowIdx(int adapterPos) { if (mReverseAdapter) return (getItemCount() - 1 - adapterPos) / mColCount; return adapterPos / mColCount; } /** * Return top child view on screen (may not be visible) * * @return child view */ @NonNull private View getTopView() { final int topChildIdx = mBottomToTop ? (getChildCount() - 1) : 0; View child = getChildAt(topChildIdx); if (child == null) throw new IllegalStateException("null child when count=" + getChildCount() + " and topChildIdx=" + topChildIdx); return child; } /** * Return bottom child view on screen (may not be visible) * * @return child view */ @NonNull private View getBottomView() { final int bottomChildIdx = mBottomToTop ? 0 : (getChildCount() - 1); View child = getChildAt(bottomChildIdx); if (child == null) throw new IllegalStateException("null child when count=" + getChildCount() + " and bottomChildIdx=" + bottomChildIdx); return child; } @NonNull private View findBottomVisibleItemView() { final int childCount = getChildCount(); int botChildIdx = mBottomToTop ? 0 : (childCount - 1); View child = getChildAt(botChildIdx); if (child == null) throw new IllegalStateException("null child when count=" + childCount + " and bottomChildIdx=" + botChildIdx); while (getDecoratedTop(child) > getHeight()) { botChildIdx += mBottomToTop ? 1 : -1; if (botChildIdx < 0 || botChildIdx >= childCount) return child; child = getChildAt(botChildIdx); if (child == null) throw new IllegalStateException("null child when count=" + childCount + " and bottomChildIdx=" + botChildIdx); } return child; } public int findLastVisibleItemPosition() { final int childCount = getChildCount(); int botChildIdx = mBottomToTop ? 0 : (childCount - 1); if (botChildIdx < 0 || botChildIdx >= childCount) return -1; View child = getChildAt(botChildIdx); if (child == null) throw new IllegalStateException("null child when count=" + childCount + " and bottomChildIdx=" + botChildIdx); while (getDecoratedTop(child) > getHeight()) { botChildIdx += mBottomToTop ? 1 : -1; if (botChildIdx < 0 || botChildIdx >= childCount) return adapterPosition(child); child = getChildAt(botChildIdx); if (child == null) throw new IllegalStateException("null child when count=" + childCount + " and bottomChildIdx=" + botChildIdx); } return adapterPosition(child); } /** * First (or last) item from the adapter that can be displayed at the top of the list * * @return index from adapter */ private int topAdapterItemIdx() { return (mBottomToTop ^ mReverseAdapter) ? (getItemCount() - 1) : 0; } /** * First (or last) item from the adapter that can be displayed at the bottom of the list * * @return index from adapter */ private int bottomAdapterItemIdx() { return (mBottomToTop ^ mReverseAdapter) ? 0 : (getItemCount() - 1); } private int aboveAdapterItemIdx(int idx) { return idx - belowAdapterItemIdx(0); } private int belowAdapterItemIdx(int idx) { return idx + ((mBottomToTop ^ mReverseAdapter) ? -mColCount : mColCount); } /* * This method describes how far RecyclerView thinks the contents should scroll vertically. * You are responsible for verifying edge boundaries, and determining if this scroll * event somehow requires that new views be added or old views get recycled. */ @Override public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) { if (getChildCount() == 0) { return 0; } final int amount; // compute amount of scroll without going beyond the bound if (dy < 0) { // finger is moving downward View topView = getTopView(); boolean topBoundReached = adapterPosition(topView) == topAdapterItemIdx(); final int topBound = getPaddingTop(); final int childTop = getDecoratedTop(topView); if (topBoundReached && (childTop - dy) > topBound) { //If top bound reached, enforce limit int topOffset = topBound - childTop; amount = -Math.min(-dy, topOffset); } else { amount = dy; } } else if (dy > 0) { // finger is moving upward View bottomView = getBottomView(); boolean bottomBoundReached = adapterPosition(bottomView) == bottomAdapterItemIdx(); final int bottomBound = getVerticalSpace() + getPaddingTop(); final int childBottom = getDecoratedBottom(bottomView); if (bottomBoundReached && (childBottom - dy) < bottomBound) { //If we've reached the last row, enforce limits int bottomOffset = childBottom - bottomBound; amount = Math.min(dy, bottomOffset); } else { amount = dy; } } else { amount = dy; } if (dy != amount) { logDebug("dy=" + dy + " amount=" + amount + " overScroll=" + mOverScrollVertical); overScrollVertical(dy - amount); } else { resetOverScrollVertical(); } if (amount == 0 || (dy < 0 && amount > 0) || (dy > 0 && amount < 0)) return 0; // scroll children offsetChildrenVertical(-amount); // check if we need to layout after the scroll checkVisibilityAfterScroll(recycler, true); checkVisibilityAfterScroll(recycler, false); /* * Return value determines if a boundary has been reached * (for edge effects and flings). If returned value does not * match original delta (passed in), RecyclerView will draw * an edge effect. */ return amount; } private void overScrollVertical(int amount) { // sum up amount until we can trigger the over scroll event mOverScrollVertical += amount; if (mOverScrollListener != null && mRecyclerView != null) { if (mOverScrollVertical > getDecoratedMeasuredHeight(getBottomView())) { mOverScrollListener.onOverScroll(mRecyclerView, mOverScrollVertical); } } } private void resetOverScrollVertical() { // reset amount mOverScrollVertical = 0; } private void checkVisibilityAfterScroll(RecyclerView.Recycler recycler, boolean checkTop) { View child = checkTop ? getTopView() : getBottomView(); int adapterPosition = adapterPosition(child); int top = getDecoratedTop(child); int newFirstVisible = mFirstVisiblePosition; while (needsVisibilityChange(adapterPosition, top, checkTop)) { if (checkTop) { adapterPosition = aboveAdapterItemIdx(adapterPosition); newFirstVisible = aboveAdapterItemIdx(newFirstVisible); top -= getRowHeight(getRowIdx(adapterPosition)); } else { top += getRowHeight(getRowIdx(adapterPosition)); adapterPosition = belowAdapterItemIdx(adapterPosition); newFirstVisible = belowAdapterItemIdx(newFirstVisible); } } changeFirstVisible(recycler, newFirstVisible); } /** * @param adapterPosition needed to stop checking if adapter start or end reached * @param top child decorated top * @param checkTop `bound` is the top position or the bottom * @return if we need to change `mFirstVisiblePosition` */ private boolean needsVisibilityChange(int adapterPosition, int top, boolean checkTop) { if (adapterPosition <= 0 || adapterPosition >= (getItemCount() - 1)) return false; if (checkTop) return top > 0; int rowHeight = getRowHeight(getRowIdx(adapterPosition)); return (top + rowHeight) < getHeight(); } private void changeFirstVisible(RecyclerView.Recycler recycler, int value) { int oldValue = mFirstVisiblePosition; int newValue = Math.max(Math.min(value, getItemCount() - 1), 0); if (oldValue != newValue) { logDebug("mFirstVisiblePosition changed from " + oldValue + " to " + newValue); mFirstVisiblePosition = newValue; layoutChildren(recycler); } } private static boolean viewNeedsUpdate(View v) { RecyclerView.LayoutParams p = (RecyclerView.LayoutParams) v.getLayoutParams(); return p.viewNeedsUpdate(); } private static String getDebugName(View v) { View name; name = v.findViewById(R.id.item_app_name); if (name instanceof TextView) { CharSequence text = ((TextView) name).getText(); if (text != null && text.length() > 0) return text.toString(); } name = v.findViewById(R.id.item_contact_name); if (name instanceof TextView) { CharSequence text = ((TextView) name).getText(); if (text != null && text.length() > 0) return text.toString(); } name = v.findViewById(android.R.id.text1); if (name instanceof TextView) { CharSequence text = ((TextView) name).getText(); if (text != null && text.length() > 0) return text.toString(); } return ""; } private static String getDebugInfo(View v) { String info = ""; if (v.getLayoutParams() instanceof RecyclerView.LayoutParams) { RecyclerView.LayoutParams p = (RecyclerView.LayoutParams) v.getLayoutParams(); info += "lp" + p.getViewLayoutPosition(); info += "ap" + p.getViewAdapterPosition(); if (p.viewNeedsUpdate()) info += "u"; if (p.isItemChanged()) info += "c"; if (p.isItemRemoved()) info += "r"; if (p.isViewInvalid()) info += "i"; } return info; } private static void logDebug(String message) { if (LOG_DEBUG) Log.d(TAG, message); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/result/EntryAdapter.java ================================================ package rocks.tbog.tblauncher.result; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.BaseAdapter; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import java.util.Collection; import java.util.List; import rocks.tbog.tblauncher.entry.EntryItem; public class EntryAdapter extends BaseAdapter { private final List mItems; private final int mDrawFlags; public EntryAdapter(@NonNull List objects) { mItems = objects; mDrawFlags = EntryItem.FLAG_DRAW_GRID | EntryItem.FLAG_DRAW_NAME | EntryItem.FLAG_DRAW_ICON | EntryItem.FLAG_DRAW_ICON_BADGE; } public EntryAdapter(@NonNull List objects, int drawFlags) { mItems = objects; mDrawFlags = drawFlags; } public void addAll(Collection newElements) { mItems.addAll(newElements); notifyDataSetChanged(); } @Override public int getViewTypeCount() { return ResultHelper.getItemViewTypeCount(); } @Override public int getItemViewType(int position) { return ResultHelper.getItemViewType(mItems.get(position), mDrawFlags); } @Override public EntryItem getItem(int position) { return mItems.get(position); } @Override public int getCount() { return mItems.size(); } @Override public long getItemId(int position) { return getItem(position).hashCode(); } @NonNull @Override public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) { final View view; EntryItem content = getItem(position); if (convertView == null) { //final int viewType = ResultHelper.getItemViewLayout(getItemViewType(position)); final int viewType = content.getResultLayout(mDrawFlags); view = LayoutInflater.from(parent.getContext()).inflate(viewType, parent, false); } else { view = convertView; } content.displayResult(view, mDrawFlags); return view; } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/result/LoadDataForAdapter.java ================================================ package rocks.tbog.tblauncher.result; import java.util.ArrayList; import rocks.tbog.tblauncher.WorkAsync.AsyncTask; import rocks.tbog.tblauncher.entry.EntryItem; import rocks.tbog.tblauncher.utils.Utilities; public class LoadDataForAdapter extends AsyncTask> { private final EntryAdapter adapter; private final LoadInBackground task; public interface LoadInBackground { ArrayList loadInBackground(); } public LoadDataForAdapter(EntryAdapter adapter, LoadInBackground loadInBackground) { super(); this.adapter = adapter; task = loadInBackground; } @Override protected ArrayList doInBackground(Void param) { ArrayList data = task.loadInBackground(); data.trimToSize(); return data; } @Override protected void onPostExecute(ArrayList data) { if (data == null) return; adapter.addAll(data); } public void execute() { Utilities.executeAsync(this); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/result/RecycleAdapter.java ================================================ package rocks.tbog.tblauncher.result; import android.annotation.SuppressLint; import android.content.Context; import android.content.SharedPreferences; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.Filter; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.preference.PreferenceManager; import java.util.ArrayList; import java.util.Collection; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.entry.EntryItem; import rocks.tbog.tblauncher.ui.ListPopup; public class RecycleAdapter extends RecycleAdapterBase { @Nullable private ArrayList resultsOriginal = null; private final Filter mFilter = new RecycleAdapter.FilterById(); public RecycleAdapter(@NonNull Context context, @NonNull ArrayList results) { super(results); setHasStableIds(true); setGridLayout(context, false); setOnClickListener(RecycleAdapter::onClick); setOnLongClickListener(RecycleAdapter::onLongClick); } @NonNull @Override public RecycleAdapterBase.Holder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { final Context context = parent.getContext(); final int layoutRes = ResultHelper.getItemViewLayout(viewType); LayoutInflater inflater = LayoutInflater.from(context); View itemView = inflater.inflate(layoutRes, parent, false); return new RecycleAdapterBase.Holder(itemView); } public void setGridLayout(@NonNull Context context, boolean bGridLayout) { final int oldFlags = mDrawFlags; // get new flags mDrawFlags = getDrawFlags(context); mDrawFlags |= bGridLayout ? EntryItem.FLAG_DRAW_GRID : EntryItem.FLAG_DRAW_LIST; // refresh items if flags changed if (oldFlags != mDrawFlags) refresh(); } private int getDrawFlags(@NonNull Context context) { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); int drawFlags = EntryItem.FLAG_DRAW_NAME; if (prefs.getBoolean("tags-enabled", true)) drawFlags |= EntryItem.FLAG_DRAW_TAGS; if (prefs.getBoolean("icons-visible", true)) drawFlags |= EntryItem.FLAG_DRAW_ICON; if (prefs.getBoolean("shortcut-show-badge", true)) drawFlags |= EntryItem.FLAG_DRAW_ICON_BADGE; return drawFlags; } public void onClick(int index, View anyView) { final EntryItem result = getItem(index); if (result == null) { return; } onClick(result, anyView); } public static void onClick(final EntryItem result, @NonNull View v) { ResultHelper.launch(v, result); } public static boolean onLongClick(final EntryItem result, @NonNull View v) { ListPopup menu = result.getPopupMenu(v); // check if menu contains elements and if yes show it if (!menu.getAdapter().isEmpty()) { TBApplication.getApplication(v.getContext()).registerPopup(menu); menu.show(v); return true; } return false; } @Override public void clear() { resultsOriginal = null; super.clear(); } @SuppressLint("NotifyDataSetChanged") public void updateItems(Collection results) { resultsOriginal = null; super.updateItems(results); } public void removeItem(EntryItem result) { if (resultsOriginal != null) resultsOriginal.remove(result); super.removeItem(result); } public Filter getFilter() { if (resultsOriginal == null) resultsOriginal = new ArrayList<>(entryList); return mFilter; } private class FilterById extends Filter { //Invoked in a worker thread to filter the data according to the constraint. @Override protected FilterResults performFiltering(CharSequence constraint) { if (constraint == null || constraint.length() == 0 || resultsOriginal == null) return null; String schema = constraint.toString(); ArrayList filterList = new ArrayList<>(); for (EntryItem entryItem : resultsOriginal) { if (entryItem.id.startsWith(schema)) filterList.add(entryItem); } FilterResults filterResults = new FilterResults(); filterResults.values = filterList; filterResults.count = filterList.size(); return filterResults; } //Invoked in the UI thread to publish the filtering results in the user interface. @SuppressWarnings("unchecked") @SuppressLint("NotifyDataSetChanged") @Override protected void publishResults(CharSequence constraint, FilterResults filterResults) { if (filterResults != null) { entryList.clear(); entryList.addAll((ArrayList) filterResults.values); notifyDataSetChanged(); } else if (resultsOriginal != null) { entryList.clear(); entryList.addAll(resultsOriginal); resultsOriginal = null; notifyDataSetChanged(); } } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/result/RecycleAdapterBase.java ================================================ package rocks.tbog.tblauncher.result; import android.annotation.SuppressLint; import android.graphics.drawable.Drawable; import android.util.Log; import android.view.View; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.recyclerview.widget.RecyclerView; import java.util.Collection; import java.util.List; import rocks.tbog.tblauncher.CustomizeUI; import rocks.tbog.tblauncher.entry.EntryItem; import rocks.tbog.tblauncher.utils.UIColors; public abstract class RecycleAdapterBase extends RecyclerView.Adapter { private static final String TAG = RecycleAdapterBase.class.getSimpleName(); @NonNull protected final List entryList; protected int mDrawFlags; @Nullable private OnClickListener mOnClickListener = null; @Nullable private OnLongClickListener mOnLongClickListener = null; public RecycleAdapterBase(@NonNull List list) { entryList = list; } public void setOnClickListener(@Nullable OnClickListener listener) { mOnClickListener = listener; } public void setOnLongClickListener(@Nullable OnLongClickListener listener) { mOnLongClickListener = listener; } @Override public int getItemViewType(int position) { final EntryItem entry = getItem(position); if (entry == null) return -1; // this is invalid and will throw later on return ResultHelper.getItemViewType(entry, mDrawFlags); } @Override public long getItemId(int position) { final EntryItem entry = getItem(position); if (entry == null) return -1; return entry.id.hashCode(); } @Override public void onBindViewHolder(@NonNull VH holder, int position) { final EntryItem entry = getItem(position); if (entry == null) return; if (mOnClickListener != null) holder.setOnClickListener(v -> mOnClickListener.onClick(entry, v)); else holder.setOnClickListener(null); if (mOnLongClickListener != null) holder.setOnLongClickListener(v -> mOnLongClickListener.onLongClick(entry, v)); else holder.setOnLongClickListener(null); entry.displayResult(holder.itemView, mDrawFlags); } @Override public int getItemCount() { return entryList.size(); } @Nullable public EntryItem getItem(int index) { final EntryItem entry; try { entry = entryList.get(index); } catch (ArrayIndexOutOfBoundsException e) { Log.e(TAG, "pos=" + index + " size=" + entryList.size(), e); return null; } return entry; } public void removeItem(EntryItem result) { int position = entryList.indexOf(result); entryList.remove(result); notifyItemRemoved(position); } public void addItem(EntryItem item) { notifyItemInserted(entryList.size()); entryList.add(item); } public void clear() { final int itemCount = entryList.size(); entryList.clear(); notifyItemRangeRemoved(0, itemCount); } public void refresh() { final int itemCount = entryList.size(); notifyItemRangeChanged(0, itemCount); } @SuppressLint("NotifyDataSetChanged") public void updateItems(Collection results) { this.entryList.clear(); this.entryList.addAll(results); notifyDataSetChanged(); } public void notifyItemChanged(EntryItem result) { int position = entryList.indexOf(result); Log.d(TAG, "notifyItemChanged #" + position + " id=" + result.id); if (position >= 0) notifyItemChanged(position); } public static class Holder extends RecyclerView.ViewHolder { public Holder(@NonNull View itemView) { super(itemView); itemView.setTag(this); // we set background selector here to do it only once int touchColor = UIColors.getResultListRipple(itemView.getContext()); Drawable selectorBackground = CustomizeUI.getSelectorDrawable(itemView, touchColor, false); itemView.setBackground(selectorBackground); } public void setOnClickListener(@Nullable View.OnClickListener listener) { itemView.setOnClickListener(listener); if (listener == null) itemView.setClickable(false); } public void setOnLongClickListener(@Nullable View.OnLongClickListener listener) { itemView.setOnLongClickListener(listener); if (listener == null) itemView.setLongClickable(false); } } public interface OnClickListener { void onClick(EntryItem entryItem, View view); } public interface OnLongClickListener { boolean onLongClick(EntryItem entryItem, View view); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/result/RecycleScrollListener.java ================================================ package rocks.tbog.tblauncher.result; import android.animation.Animator; import android.animation.ValueAnimator; import android.util.Log; import android.view.View; import android.view.ViewGroup; import android.view.ViewTreeObserver; import android.view.animation.DecelerateInterpolator; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.ui.KeyboardHandler; import rocks.tbog.tblauncher.ui.RecyclerList; import rocks.tbog.tblauncher.ui.WindowInsetsHelper; import rocks.tbog.tblauncher.utils.DebugInfo; import rocks.tbog.tblauncher.utils.DebugString; public class RecycleScrollListener extends RecyclerView.OnScrollListener implements CustomRecycleLayoutManager.OverScrollListener { private static final String TAG = "RScrL"; private final KeyboardHandler handler; private int mScrollAmountY = 0; private int mHideKeyboardThreshold = -1; private int mListHeight = -1; private final State mState = new State(); private static class State { // list resize after keyboard hidden has finished private boolean resizeFinished = false; // keyboard hide requested private boolean resizeInProgress = false; // waiting for the keyboard to set window insets private boolean waitForInsets = false; private boolean resizeWithScroll = false; public boolean resizeFinished() { return resizeFinished; } public boolean resizeInProgress() { return resizeInProgress && !resizeFinished; } public boolean resizeWithScroll() { return resizeWithScroll && !resizeFinished; } @NonNull @Override public String toString() { return "[resizeWithScroll=" + resizeWithScroll + " waitForInsets=" + waitForInsets + " resizeInProgress=" + resizeInProgress + " resizeFinished=" + resizeFinished + "]"; } public void reset() { resizeFinished = false; resizeInProgress = false; waitForInsets = false; resizeWithScroll = false; } public void setResizeAnimation() { resizeInProgress = false; } public void setResizeWithScroll() { resizeWithScroll = true; } public void setResizeFinished() { resizeFinished = true; } } public RecycleScrollListener(KeyboardHandler handler) { this.handler = handler; } @Override public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) { if (mHideKeyboardThreshold == -1) { if (recyclerView.getLayoutManager() instanceof CustomRecycleLayoutManager) { final int lastItem = ((CustomRecycleLayoutManager) recyclerView.getLayoutManager()).findLastVisibleItemPosition(); RecyclerView.ViewHolder vh = recyclerView.findViewHolderForAdapterPosition(lastItem); int lastItemHeight = vh != null ? vh.itemView.getHeight() : 0; if (lastItemHeight == 0) lastItemHeight = recyclerView.getResources().getDimensionPixelSize(R.dimen.icon_size); mHideKeyboardThreshold = lastItemHeight * 3 / 5; } else { int itemHeight = recyclerView.getResources().getDimensionPixelSize(R.dimen.icon_size); mHideKeyboardThreshold = itemHeight * 3 / 5; } } Log.d(TAG, "scroll state=" + scrollStateString(newState)); if (newState == RecyclerView.SCROLL_STATE_DRAGGING) { mState.reset(); mListHeight = recyclerView.getHeight(); int keyboardHeight = WindowInsetsHelper.getKeyboardHeight(recyclerView); boolean keyboardVisible = WindowInsetsHelper.isKeyboardVisible(recyclerView); Log.i(TAG, "start drag; keyboardHeight=" + keyboardHeight + " keyboardVisible=" + keyboardVisible + " mListHeight=" + mListHeight); if (DebugInfo.keyboardScrollHiderTouch(recyclerView.getContext())) recyclerView.setBackgroundColor(0x80ffd700); // if keyboard is already hidden, we are done if (!WindowInsetsHelper.isKeyboardVisible(recyclerView)) handleResizeDone(recyclerView); } else if (newState == RecyclerView.SCROLL_STATE_IDLE) { if (recyclerView.getLayoutManager() instanceof CustomRecycleLayoutManager) { int lastVisible = ((CustomRecycleLayoutManager) recyclerView.getLayoutManager()).findLastVisibleItemPosition(); int itemCount = recyclerView.getAdapter() != null ? recyclerView.getAdapter().getItemCount() : 0; if (lastVisible < (itemCount - 1)) { final int range = recyclerView.computeVerticalScrollRange(); final int extent = recyclerView.computeVerticalScrollExtent(); final int offset = recyclerView.computeVerticalScrollOffset(); Log.d(TAG, "lastVisible=" + (lastVisible + 1) + "/" + itemCount + " range=" + range + " extent=" + extent + " offset=" + offset); mScrollAmountY = range - extent - offset; } else { mScrollAmountY = 0; } } Log.i(TAG, "scrollY=" + mScrollAmountY); if (mState.resizeInProgress()) { mState.setResizeAnimation(); int layoutParamsHeight = recyclerView.getLayoutParams().height; final int fromValue = layoutParamsHeight < 0 ? recyclerView.getHeight() : layoutParamsHeight; final int toValue = ((View) recyclerView.getParent()).getHeight(); if (fromValue == toValue) { handleResizeDone(recyclerView); } else { ValueAnimator anim = ValueAnimator.ofInt(fromValue, toValue); anim.setInterpolator(new DecelerateInterpolator()); anim.setDuration(recyclerView.getResources().getInteger(android.R.integer.config_longAnimTime)); anim.addUpdateListener(animation -> { int h = (int) animation.getAnimatedValue(); setListLayoutHeight(recyclerView, h); }); anim.addListener(new Animator.AnimatorListener() { @Override public void onAnimationStart(Animator animation) { if (DebugInfo.keyboardScrollHiderTouch(recyclerView.getContext())) recyclerView.setBackgroundColor(0x800000ff); setListAutoScroll(recyclerView, true); } @Override public void onAnimationEnd(Animator animation) { handleResizeDone(recyclerView); } @Override public void onAnimationCancel(Animator animation) { handleResizeDone(recyclerView); } @Override public void onAnimationRepeat(Animator animation) { // not happening } }); anim.start(); } } } } @NonNull private static String scrollStateString(int state) { switch (state) { case RecyclerView.SCROLL_STATE_IDLE: return "idle"; case RecyclerView.SCROLL_STATE_DRAGGING: return "drag"; case RecyclerView.SCROLL_STATE_SETTLING: return "sett"; default: return "undef"; } } @Override public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { if (!(recyclerView instanceof RecyclerList)) return; final RecyclerList list = (RecyclerList) recyclerView; int state = list.getScrollState(); if (mState.resizeFinished() || state == RecyclerView.SCROLL_STATE_IDLE) return; mScrollAmountY -= dy; final boolean keyboardVisible = WindowInsetsHelper.isKeyboardVisible(list); Log.d(TAG, "state=" + scrollStateString(state) + " scrollY=" + mScrollAmountY + " KV=" + keyboardVisible + " state=" + mState); // if resize while scrolling is active ... resize if (mState.resizeWithScroll()) { int containerHeight = ((View) list.getParent()).getHeight(); int newHeight = Math.min(mListHeight + mScrollAmountY, containerHeight); Log.d(TAG, "onScrolled: resizeWithScroll newHeight=" + newHeight + " containerHeight=" + containerHeight); if (newHeight == containerHeight) { handleResizeDone(list); } else if (newHeight > list.getHeight()) { setListLayoutHeight(list, newHeight); } return; } // if scrolled enough to trigger the keyboard hiding if (mScrollAmountY > mHideKeyboardThreshold) { int containerHeight = ((View) list.getParent()).getHeight(); Log.d(TAG, "containerHeight=" + containerHeight); if (!mState.waitForInsets && mState.resizeInProgress()) { // resize list to keep items under the dragging finger int newHeight = Math.min(mListHeight + mScrollAmountY, containerHeight); if (newHeight == containerHeight) { handleResizeDone(list); } else { setListLayoutHeight(list, newHeight); // activate resize while scrolling mState.setResizeWithScroll(); if (DebugInfo.keyboardScrollHiderTouch(list.getContext())) list.setBackgroundColor(0x80ff0000); } } else if ((mListHeight + mScrollAmountY) >= containerHeight) { setListLayoutHeight(list, mListHeight); hideKeyboardWhileDragging(list); // let the Scroll Listener resize the list without any additional scrolling setListAutoScroll(list, false); } } } @Override public void onOverScroll(RecyclerView list, int amount) { if (mState.resizeFinished() || !WindowInsetsHelper.isKeyboardVisible(list)) { Log.i(TAG, "overscroll show keyboard with state=" + mState); handler.showKeyboard(); } } private void hideKeyboardWhileDragging(RecyclerView list) { if (mState.waitForInsets) { int keyboardHeight = WindowInsetsHelper.getKeyboardHeight(list); Log.d(TAG, "onScrolled called while mWaitForInsets=true and keyboard height=" + keyboardHeight); } else { // start the resize process (hide keyboard) mState.waitForInsets = true; mState.resizeInProgress = true; handler.hideKeyboard(); ViewTreeObserver vto = ((View) list.getParent()).getViewTreeObserver(); vto.addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() { @Override public boolean onPreDraw() { int keyboardHeight = WindowInsetsHelper.getKeyboardHeight(list); boolean keyboardVisible = WindowInsetsHelper.isKeyboardVisible(list); if (keyboardVisible) { Log.d(TAG, "onPreDraw called with keyboard visible and height=" + keyboardHeight); // proceed with the current drawing pass, waiting for the keyboard to close return true; } ViewTreeObserver vto = ((View) list.getParent()).getViewTreeObserver(); vto.removeOnPreDrawListener(this); if (!mState.waitForInsets || mState.resizeFinished()) { Log.d(TAG, "onPreDraw called when mWaitForInsets=" + mState.waitForInsets + " mResizeFinished=" + mState.resizeFinished); return true; } if (list.getScrollState() == RecyclerView.SCROLL_STATE_IDLE) { handleResizeDone(list); } else { mState.waitForInsets = false; int containerHeight = ((View) list.getParent()).getHeight(); int newHeight = Math.min(mListHeight + mScrollAmountY, containerHeight); Log.d(TAG, "onPreDraw height=min(" + (mListHeight + mScrollAmountY) + ", " + containerHeight + ")"); setListLayoutHeight(list, newHeight); if (DebugInfo.keyboardScrollHiderTouch(list.getContext())) list.setBackgroundColor(0x8000ff00); // request new pass return false; } // proceed with the current drawing pass return true; } }); } } private void setListAutoScroll(@NonNull RecyclerView list, boolean value) { if (list.getLayoutManager() instanceof CustomRecycleLayoutManager) ((CustomRecycleLayoutManager) list.getLayoutManager()).setAutoScrollBottom(value); } private void handleResizeDone(@NonNull RecyclerView list) { if (mState.resizeFinished()) { return; } mScrollAmountY = 0; Log.i(TAG, "resize finished."); if (DebugInfo.keyboardScrollHiderTouch(list.getContext())) list.setBackgroundColor(0x00000000); setListAutoScroll(list, true); setListLayoutHeight(list, ViewGroup.LayoutParams.MATCH_PARENT); mState.setResizeFinished(); } public static void setListLayoutHeight(ViewGroup list, int height) { final ViewGroup.LayoutParams params = list.getLayoutParams(); if (params.height != height) { Log.d(TAG, "change layout height from " + DebugString.layoutParamSize(params.height) + " to " + DebugString.layoutParamSize(height)); params.height = height; list.setLayoutParams(params); list.forceLayout(); } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/result/ResultHelper.java ================================================ package rocks.tbog.tblauncher.result; import static rocks.tbog.tblauncher.entry.EntryItem.LAUNCHED_FROM_QUICK_LIST; import static rocks.tbog.tblauncher.entry.EntryItem.LAUNCHED_FROM_RESULT_LIST; import android.annotation.SuppressLint; import android.content.Context; import android.content.Intent; import android.net.Uri; import android.provider.ContactsContract; import android.util.Log; import android.view.View; import android.widget.Toast; import androidx.annotation.LayoutRes; import androidx.annotation.NonNull; import androidx.collection.ArraySet; import java.util.ArrayList; import java.util.Collections; import java.util.List; import rocks.tbog.tblauncher.Behaviour; import rocks.tbog.tblauncher.Permission; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.dataprovider.QuickListProvider; import rocks.tbog.tblauncher.db.DBHelper; import rocks.tbog.tblauncher.entry.AppEntry; import rocks.tbog.tblauncher.entry.ContactEntry; import rocks.tbog.tblauncher.entry.EntryItem; import rocks.tbog.tblauncher.entry.SearchEntry; import rocks.tbog.tblauncher.entry.ShortcutEntry; import rocks.tbog.tblauncher.entry.StaticEntry; import rocks.tbog.tblauncher.handler.DataHandler; import rocks.tbog.tblauncher.utils.MimeTypeUtils; import rocks.tbog.tblauncher.utils.Utilities; public class ResultHelper { /** * Use index as view type * Value is the layout id */ @NonNull static final private ArraySet sEntryViewType = new ArraySet<>(8); static { addViewTypes(AppEntry.getResultLayout()); addViewTypes(ContactEntry.getResultLayout()); addViewTypes(StaticEntry.getResultLayout()); addViewTypes(ShortcutEntry.getResultLayout()); addViewTypes(SearchEntry.getResultLayout()); Log.i("log", "result view type count=" + sEntryViewType.size()); } private ResultHelper() { // this is a static class } private static void addViewTypes(int[] viewTypes) { for (int viewType : viewTypes) sEntryViewType.add(viewType); } /** * How to launch a result. Most probably, will fire an intent. * This function will record history and then call EntryItem.doLaunch * * @param view {@link View} that was touched * @param pojo the {@link EntryItem} that the user is launching */ public static void launch(@NonNull View view, @NonNull EntryItem pojo) { final int launchedFrom; if (TBApplication.quickList(view.getContext()).isViewInList(view)) launchedFrom = LAUNCHED_FROM_QUICK_LIST; else launchedFrom = LAUNCHED_FROM_RESULT_LIST; launch(view, pojo, launchedFrom); } /** * How to launch a result. Most probably, will fire an intent. * This function will record history and then call EntryItem.doLaunch * * @param view {@link View} that was touched * @param pojo the {@link EntryItem} that the user is launching * @param launchedFrom is the view from QuickList, ResultList, Gesture? */ public static void launch(@NonNull View view, @NonNull EntryItem pojo, int launchedFrom) { if (pojo instanceof StaticEntry) { Log.i("log", "Launching StaticEntry " + pojo.id); recordLaunch(pojo, view.getContext()); pojo.doLaunch(view, launchedFrom); return; } TBApplication.behaviour(view.getContext()).beforeLaunchOccurred(); Log.i("log", "Launching " + pojo.id); recordLaunch(pojo, view.getContext()); // Launch view.postDelayed(() -> { pojo.doLaunch(view, launchedFrom); TBApplication.behaviour(view.getContext()).afterLaunchOccurred(); }, Behaviour.LAUNCH_DELAY); } /** * Put this item in application history * * @param pojo the {@link EntryItem} that the user is launching * @param context android context */ public static void recordLaunch(@NonNull EntryItem pojo, @NonNull Context context) { if (pojo.isExcludedFromHistory()) return; // Save in history TBApplication.getApplication(context).getDataHandler().addToHistory(pojo.getHistoryId()); } /** * Remove the current result from the list * * @param context android context */ public static void removeFromResultsAndHistory(@NonNull EntryItem pojo, @NonNull Context context) { removeFromHistory(pojo, context); //TODO: remove from results only if we are showing history TBApplication.behaviour(context).removeResult(pojo); //TODO: make an UndoBar Toast.makeText(context, context.getString(R.string.removed_item, pojo.getName()), Toast.LENGTH_SHORT).show(); } private static void removeFromHistory(@NonNull EntryItem pojo, @NonNull Context context) { DBHelper.removeFromHistory(context, pojo.id); } public static void launchAddToQuickList(@NonNull Context context, EntryItem pojo) { final DataHandler dataHandler = TBApplication.dataHandler(context); QuickListProvider provider = dataHandler.getQuickListProvider(); // get current Quick List content List list = provider != null ? provider.getPojos() : Collections.emptyList(); final ArrayList idList = new ArrayList<>(list.size()); for (EntryItem entryItem : list) { idList.add(entryItem.id); } // add the new entry idList.add(pojo.id); // save Quick List dataHandler.setQuickList(idList); } public static void launchRemoveFromQuickList(@NonNull Context context, EntryItem pojo) { final DataHandler dataHandler = TBApplication.dataHandler(context); QuickListProvider provider = dataHandler.getQuickListProvider(); // get current Quick List content List list = provider != null ? provider.getPojos() : Collections.emptyList(); final ArrayList idList = new ArrayList<>(list.size()); for (EntryItem entryItem : list) { idList.add(entryItem.id); } // remove the entry idList.remove(pojo.id); // save Quick List dataHandler.setQuickList(idList); } public static void launchMessaging(ContactEntry contactPojo, View v) { Context context = v.getContext(); TBApplication.behaviour(context).beforeLaunchOccurred(); String url = "sms:" + Uri.encode(contactPojo.getPhone()); Intent i = new Intent(Intent.ACTION_SENDTO, Uri.parse(url)); Utilities.setIntentSourceBounds(i, v); i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); context.startActivity(i); TBApplication.behaviour(context).afterLaunchOccurred(); } public static void launchIm(ContactEntry.ImData imData, View v) { Context context = v.getContext(); Intent intent = MimeTypeUtils.getRegisteredIntentByMimeType(context, imData.getMimeType(), imData.getId(), imData.getIdentifier()); if (intent != null) { TBApplication.behaviour(context).beforeLaunchOccurred(); Utilities.setIntentSourceBounds(intent, v); context.startActivity(intent); TBApplication.behaviour(context).afterLaunchOccurred(); } } public static void launchContactView(ContactEntry contactPojo, Context context, View v) { TBApplication.behaviour(context).beforeLaunchOccurred(); Intent action = new Intent(Intent.ACTION_VIEW); action.setData(Uri.withAppendedPath(ContactsContract.Contacts.CONTENT_LOOKUP_URI, String.valueOf(contactPojo.lookupKey))); Utilities.setIntentSourceBounds(action, v); action.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); action.addFlags(Intent.FLAG_ACTIVITY_MULTIPLE_TASK); context.startActivity(action); TBApplication.behaviour(context).afterLaunchOccurred(); } public static void launchCall(Context context, View v, String phone) { TBApplication.behaviour(context).beforeLaunchOccurred(); Intent phoneIntent = new Intent(Intent.ACTION_CALL); phoneIntent.setData(Uri.parse("tel:" + Uri.encode(phone))); Utilities.setIntentSourceBounds(phoneIntent, v); phoneIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); // Make sure we have permission to call someone as this is considered a dangerous permission if (!Permission.checkPermission(context, Permission.PERMISSION_CALL_PHONE)) { Permission.askPermission(Permission.PERMISSION_CALL_PHONE, new Permission.PermissionResultListener() { @SuppressLint("MissingPermission") @Override public void onGranted() { // Great! Start the intent we stored for later use. context.startActivity(phoneIntent); } @Override public void onDenied() { Toast.makeText(context, R.string.permission_denied, Toast.LENGTH_SHORT).show(); } }); return; } // Pre-android 23, or we already have permission context.startActivity(phoneIntent); TBApplication.behaviour(context).afterLaunchOccurred(); } public static int getItemViewTypeCount() { return sEntryViewType.size(); } @LayoutRes public static int getItemViewLayout(int viewType) { if (viewType < 0 || viewType >= sEntryViewType.size()) throw new IllegalStateException("view type " + viewType + " out of range"); Integer layout = sEntryViewType.valueAt(viewType); if (layout == null || layout == 0) throw new IllegalStateException("view type " + viewType + " has invalid layout"); return layout; } public static int getItemViewType(@NonNull EntryItem item, int drawFlags) { int layout = item.getResultLayout(drawFlags); int viewType = sEntryViewType.indexOf(layout); if (viewType < 0) throw new IllegalStateException("no view type for " + item.getClass().getName() + " drawFlags=" + drawFlags); return viewType; } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/result/ResultItemDecoration.java ================================================ package rocks.tbog.tblauncher.result; import android.graphics.Rect; import android.view.View; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; public class ResultItemDecoration extends RecyclerView.ItemDecoration { private final int mHorizontal; private final int mVertical; private final boolean mOnlyForGrid; public ResultItemDecoration(int horizontal, int vertical, boolean onlyForGrid) { mHorizontal = horizontal; mVertical = vertical; mOnlyForGrid = onlyForGrid; } @Override public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) { super.getItemOffsets(outRect, view, parent, state); int columns = 1; boolean bottom2top = false; boolean right2left = false; boolean reverseAdapter = false; RecyclerView.LayoutManager layoutManager = parent.getLayoutManager(); if (layoutManager instanceof CustomRecycleLayoutManager) { columns = ((CustomRecycleLayoutManager) layoutManager).getColumnCount(); bottom2top = ((CustomRecycleLayoutManager) layoutManager).isBottomToTop(); reverseAdapter = ((CustomRecycleLayoutManager) layoutManager).isReverseAdapter(); right2left = ((CustomRecycleLayoutManager) layoutManager).isRightToLeft(); } if (mOnlyForGrid && columns <= 1) return; int adapterPos = parent.getChildAdapterPosition(view); if (reverseAdapter) { int itemCount = parent.getAdapter() != null ? parent.getAdapter().getItemCount() : 0; adapterPos = itemCount - 1 - adapterPos; } int leftColumn = right2left ? (columns - 1) : 0; // add left margin only to the leftmost column if (adapterPos % columns == leftColumn) outRect.left += mHorizontal; // add top margin only to the topmost row if (bottom2top) { int itemCount = parent.getAdapter() != null ? parent.getAdapter().getItemCount() : 0; int rowCount = itemCount / columns + (itemCount % columns == 0 ? 0 : 1); int row = adapterPos / columns; if (row == (rowCount - 1)) outRect.top += mVertical; } else { if (adapterPos < columns) outRect.top += mVertical; } outRect.right += mHorizontal; outRect.bottom += mVertical; } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/result/ResultViewHelper.java ================================================ package rocks.tbog.tblauncher.result; import android.content.Context; import android.graphics.ColorFilter; import android.graphics.drawable.Drawable; import android.text.Spannable; import android.text.SpannableString; import android.text.SpannableStringBuilder; import android.text.style.ForegroundColorSpan; import android.util.Log; import android.util.Pair; import android.util.TypedValue; import android.view.ViewGroup; import android.widget.ImageView; import android.widget.TextView; import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.constraintlayout.widget.ConstraintLayout; import java.lang.reflect.Constructor; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.TreeSet; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicBoolean; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.entry.EntryItem; import rocks.tbog.tblauncher.entry.EntryWithTags; import rocks.tbog.tblauncher.entry.ResultRelevance; import rocks.tbog.tblauncher.normalizer.StringNormalizer; import rocks.tbog.tblauncher.utils.FuzzyScore; import rocks.tbog.tblauncher.utils.PrefCache; import rocks.tbog.tblauncher.utils.UIColors; import rocks.tbog.tblauncher.utils.UISizes; import rocks.tbog.tblauncher.utils.Utilities; public final class ResultViewHelper { public final static ExecutorService EXECUTOR_LOAD_ICON = Executors.newSingleThreadExecutor(); private static final String TAG = "RVH"; private ResultViewHelper() { // this is a namespace } private static CharSequence highlightText(@NonNull StringNormalizer.Result normText, @NonNull String text, @NonNull ResultRelevance relevance, int color) { // merge all results that have the same text source final TreeSet matchedPositions = new TreeSet<>(); relevance.forEach(result -> { if (result.relevance.match && result.relevanceSource.equals(normText)) matchedPositions.addAll(result.relevance.matchedIndices); }); if (matchedPositions.isEmpty()) { return text; } List> matchedSequences = FuzzyScore.MatchInfo.getMatchedSequences(new ArrayList<>(matchedPositions)); return highlightText(normText, text, matchedSequences, color); } private static SpannableString highlightText(StringNormalizer.Result normalized, String text, Iterable> matchedSequences, int color) { SpannableString enriched = new SpannableString(text); for (Pair position : matchedSequences) { enriched.setSpan( new ForegroundColorSpan(color), normalized.mapPosition(position.first), normalized.mapPosition(position.second), Spannable.SPAN_INCLUSIVE_EXCLUSIVE ); } return enriched; } /** * Highlight text * * @param relevance result match information * @param normText we'll use this to match the result with the text we try to highlight * @param text provided visible text that may need highlighting * @param view TextView that gets the text */ public static void displayHighlighted(@NonNull ResultRelevance relevance, @NonNull StringNormalizer.Result normText, @NonNull String text, @NonNull TextView view) { int color = UIColors.getResultHighlightColor(view.getContext()); view.setText(highlightText(normText, text, relevance, color)); } public static boolean displayHighlighted(@NonNull ResultRelevance relevance, Iterable tags, TextView view, Context context) { AtomicBoolean matchFound = new AtomicBoolean(false); TreeSet matchedPositions = new TreeSet<>(); int color = UIColors.getResultHighlightColor(context); SpannableStringBuilder builder = new SpannableStringBuilder(); boolean first = true; for (EntryWithTags.TagDetails tag : tags) { if (!first) builder.append(" \u2223 "); first = false; matchedPositions.clear(); // find all matched positions relevance.forEach(result-> { if (result.relevance.match && result.relevanceSource.equals(tag.normalized)) { matchedPositions.addAll(result.relevance.matchedIndices); matchFound.set(true); } }); if (matchedPositions.isEmpty()) { // no matches found builder.append(tag.name); } else { // highlight found matches List> matchedSequences = FuzzyScore.MatchInfo.getMatchedSequences(new ArrayList<>(matchedPositions)); builder.append(highlightText(tag.normalized, tag.name, matchedSequences, color)); } } view.setText(builder); return matchFound.get(); } public static void setButtonIconAsync(@NonNull ImageView iconView, @NonNull String buttonId, @NonNull Utilities.GetDrawable getDefaultIcon) { Context context = iconView.getContext(); Drawable cache = TBApplication.drawableCache(context).getCachedDrawable(buttonId); if (cache != null) { Log.d(TAG, "cache found, view=" + Integer.toHexString(iconView.hashCode()) + " button=" + buttonId); // found the icon in cache iconView.setImageDrawable(cache); return; } Utilities.setIconAsync(iconView, ctx -> { Drawable buttonIcon = TBApplication.iconsHandler(ctx).getButtonIcon(buttonId); if (buttonIcon == null) { buttonIcon = getDefaultIcon.getDrawable(ctx); } TBApplication.drawableCache(ctx).cacheDrawable(buttonId, buttonIcon); return buttonIcon; }); } public static > void setIconAsync(int drawFlags, @NonNull E entry, @NonNull ImageView iconView, @NonNull Class asyncSetEntryIconClass, @NonNull Class entryItemClass) { String cacheId = entry.getIconCacheId(); if (cacheId.equals(iconView.getTag(R.id.tag_cacheId)) && !Utilities.checkFlag(drawFlags, EntryItem.FLAG_RELOAD)) return; if (!Utilities.checkFlag(drawFlags, EntryItem.FLAG_DRAW_NO_CACHE)) { Drawable cache = TBApplication.drawableCache(iconView.getContext()).getCachedDrawable(cacheId); if (cache != null) { Log.d(TAG, "cache found, view=" + Integer.toHexString(iconView.hashCode()) + " entry=" + entry.getName() + " cacheId=" + cacheId); // found the icon in cache iconView.setImageDrawable(cache); iconView.setTag(R.id.tag_cacheId, cacheId); iconView.setTag(R.id.tag_iconTask, null); // continue to run the async task only if FLAG_RELOAD set if (!Utilities.checkFlag(drawFlags, EntryItem.FLAG_RELOAD)) return; } } // Below we have 2 methods for getting rid of `entryItemClass` parameter /* METHOD 1: Get the actual type of EntryItem from template; this may be faster after the first run, but it needs further profiling T task; var superClass = asyncSetEntryIconClass.getGenericSuperclass(); Class entryClass = EntryItem.class; if (superClass instanceof ParameterizedType) { var actualTypeArguments = ((ParameterizedType) superClass).getActualTypeArguments(); if (actualTypeArguments.length == 1) entryClass = (Class) actualTypeArguments[0]; } // make new task instance from class asyncSetEntryIconClass try { var constructor = asyncSetEntryIconClass.getConstructor(ImageView.class, int.class, entryClass); task = constructor.newInstance(iconView, drawFlags, entry); } catch (IllegalAccessException | InstantiationException | NoSuchMethodException | InvocationTargetException e) { Log.e(TAG, "new , ?=" + asyncSetEntryIconClass.getName(), e); return; } //*/ /* METHOD 2: Find a constructor testing arguments by hand because we don't know the actual type of the EntryItem from asyncSetEntryIconClass T task = null; @SuppressWarnings("unchecked") Constructor[] declaredConstructors = (Constructor[]) asyncSetEntryIconClass.getDeclaredConstructors(); // find and call constructor for template class for (Constructor constructor : declaredConstructors) { var paramTypes = constructor.getParameterTypes(); if (paramTypes.length == 3 && paramTypes[0] == ImageView.class && paramTypes[1] == int.class && paramTypes[2].isAssignableFrom(entry.getClass())) { try { task = constructor.newInstance(iconView, drawFlags, entry); } catch (ReflectiveOperationException e) { Log.e(TAG, "new " + constructor, e); return; } break; } } if (task == null) { Log.e(TAG, "constructor not found for " + asyncSetEntryIconClass.getName() + "\n declaredConstructors=" + Arrays.toString(declaredConstructors)); return; } //*/ // make new task instance from class `asyncSetEntryIconClass` using `entryItemClass` T task; Constructor constructor = null; try { constructor = asyncSetEntryIconClass.getConstructor(ImageView.class, int.class, entryItemClass); task = constructor.newInstance(iconView, drawFlags, entry); } catch (ReflectiveOperationException e) { if (constructor != null) Log.e(TAG, "new " + constructor, e); else Log.e(TAG, "constructor not found for `" + asyncSetEntryIconClass.getName() + "` and entry `" + entry.getClass() + "`\n declaredConstructors=" + Arrays.toString(asyncSetEntryIconClass.getDeclaredConstructors())); return; } // run the async task task.execute(); } public static void applyResultItemShadow(@NonNull TextView textView) { Context ctx = textView.getContext(); float radius = UISizes.getResultListShadowRadius(ctx); float dx = UISizes.getResultListShadowOffsetHorizontal(ctx); float dy = UISizes.getResultListShadowOffsetVertical(ctx); int color = UIColors.getResultListShadowColor(ctx); if (radius != textView.getShadowRadius() || dx != textView.getShadowDx() || dy != textView.getShadowDy() || color != textView.getShadowColor()) { textView.setShadowLayer(radius, dx, dy, color); } } public static void applyPreferences(int drawFlags, TextView nameView, ImageView iconView) { Context ctx = nameView.getContext(); nameView.setTextColor(UIColors.getResultTextColor(ctx)); nameView.setTextSize(TypedValue.COMPLEX_UNIT_PX, UISizes.getResultTextSize(ctx)); applyResultItemShadow(nameView); if (Utilities.checkAnyFlag(drawFlags, EntryItem.FLAG_DRAW_LIST | EntryItem.FLAG_DRAW_GRID)) { ViewGroup.LayoutParams params = iconView.getLayoutParams(); int size = UISizes.getResultIconSize(ctx); if (params.width != size || params.height != size) { params.width = size; params.height = size; iconView.setLayoutParams(params); } } else if (Utilities.checkFlag(drawFlags, EntryItem.FLAG_DRAW_QUICK_LIST)) { ViewGroup.LayoutParams params = iconView.getLayoutParams(); if (params instanceof ConstraintLayout.LayoutParams) { ConstraintLayout.LayoutParams cParams = (ConstraintLayout.LayoutParams) params; int size = UISizes.getDockMaxIconSize(ctx); if (cParams.matchConstraintMaxWidth != size || cParams.matchConstraintMaxHeight != size) { cParams.matchConstraintMaxWidth = size; cParams.matchConstraintMaxHeight = size; iconView.setLayoutParams(params); } } } // if (Utilities.checkFlag(drawFlags, EntryItem.FLAG_DRAW_LIST)) // { // int[] colors = new int[] {0xFF000000, 0xFF00ff00, 0xFF000000}; // GradientDrawable bkg = new GradientDrawable(GradientDrawable.Orientation.TOP_BOTTOM, colors); // iconView.setBackground(bkg); // } } public static void applyPreferences(int drawFlags, TextView nameView, TextView tagsView, ImageView iconView) { applyPreferences(drawFlags, nameView, iconView); Context ctx = tagsView.getContext(); tagsView.setTextColor(UIColors.getResultText2Color(ctx)); tagsView.setTextSize(TypedValue.COMPLEX_UNIT_PX, UISizes.getResultText2Size(ctx)); } public static void applyListRowPreferences(ViewGroup rowView) { // set result list item height Context ctx = rowView.getContext(); int rowHeight = UISizes.getResultListRowHeight(ctx); ViewGroup.LayoutParams params = rowView.getLayoutParams(); if (params.height != rowHeight) { params.height = rowHeight; rowView.setLayoutParams(params); } } @Nullable private static ColorFilter getColorFilter(@NonNull Context context, int drawFlags) { final ColorFilter colorFilter; if (Utilities.checkFlag(drawFlags, EntryItem.FLAG_DRAW_QUICK_LIST)) colorFilter = UIColors.colorFilterQuickIcon(context); else colorFilter = UIColors.colorFilter(context); return colorFilter; } @Nullable public static ColorFilter setIconColorFilter(@NonNull ImageView icon, int drawFlags) { ColorFilter colorFilter = getColorFilter(icon.getContext(), drawFlags); icon.setColorFilter(colorFilter); return colorFilter; } public static void removeIconColorFilter(@NonNull ImageView icon) { icon.clearColorFilter(); } public static void setLoadingIcon(@NonNull ImageView image) { @DrawableRes int drawableId = PrefCache.getLoadingIconRes(image.getContext()); image.setImageResource(drawableId); Utilities.startAnimatable(image); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/result/ReversibleAdapterRecyclerLayoutManager.java ================================================ package rocks.tbog.tblauncher.result; public interface ReversibleAdapterRecyclerLayoutManager { void setReverseAdapter(boolean reverseAdapter); boolean isReverseAdapter(); } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/searcher/HistorySearcher.java ================================================ package rocks.tbog.tblauncher.searcher; import android.app.Activity; import android.content.Context; import androidx.annotation.NonNull; import java.util.HashSet; import java.util.List; import java.util.Set; import rocks.tbog.tblauncher.handler.DataHandler; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.db.DBHelper; import rocks.tbog.tblauncher.db.ModRecord; import rocks.tbog.tblauncher.entry.EntryItem; import rocks.tbog.tblauncher.utils.PrefCache; import rocks.tbog.tblauncher.utils.Utilities; public class HistorySearcher extends Searcher { DBHelper.HistoryMode mHistoryMode; public HistorySearcher(ISearchActivity activity, @NonNull String query) { super(activity, query); mHistoryMode = DataHandler.getHistoryMode(query); } @Override protected Void doInBackground(Void param) { ISearchActivity searchActivity = activityWeakReference.get(); Activity activity = searchActivity != null ? Utilities.getActivity(searchActivity.getContext()) : null; if (activity == null) return null; // int maxResults = getMaxResultCount(activity); processedPojos.clear(); List history = getHistory(activity, mHistoryMode); int order = history.size(); for (EntryItem item : history) { item.setRelevance(item.normalizedName, null); item.boostRelevance(order--); //addResult(item); processedPojos.add(item); if (processedPojos.size() > maxResults) processedPojos.poll(); } return null; } static List getHistory(@NonNull Context context, DBHelper.HistoryMode mode) { DataHandler dataHandler = TBApplication.dataHandler(context); Set exclude; // exclude favorites { exclude = new HashSet<>(); for (ModRecord rec : dataHandler.getMods()) { if (rec.isInQuickList()) exclude.add(rec.record); } } int itemCount = PrefCache.getResultHistorySize(context); return dataHandler.getHistory(itemCount, mode, false, exclude); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/searcher/ISearchActivity.java ================================================ package rocks.tbog.tblauncher.searcher; import android.content.Context; import androidx.annotation.NonNull; import java.util.List; import rocks.tbog.tblauncher.entry.EntryItem; public interface ISearchActivity { void displayLoader(boolean b); @NonNull Context getContext(); /** * Called after the search task finished */ void resetTask(); /** * Called when the searcher found no results */ void clearAdapter(); /** * Called when searcher found results */ void updateAdapter(@NonNull List results, boolean isRefresh); /** * Called when user removed/hidden app */ void removeResult(@NonNull EntryItem result); /** * Show only results matching filter text * @param text to filter for */ void filterResults(String text); } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/searcher/ISearcher.java ================================================ package rocks.tbog.tblauncher.searcher; import androidx.annotation.WorkerThread; import rocks.tbog.tblauncher.entry.EntryItem; public interface ISearcher { @WorkerThread boolean addResult(EntryItem... items); boolean tagsEnabled(); } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/searcher/QuerySearcher.java ================================================ package rocks.tbog.tblauncher.searcher; import android.content.Context; import androidx.annotation.NonNull; import androidx.annotation.WorkerThread; import java.util.HashMap; import java.util.List; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.db.DBHelper; import rocks.tbog.tblauncher.db.ValuedHistoryRecord; import rocks.tbog.tblauncher.entry.EntryItem; import rocks.tbog.tblauncher.utils.MapCompat; /** * AsyncTask retrieving data from the providers and updating the view * * @author dorvaryn */ public class QuerySearcher extends Searcher { private final String trimmedQuery; private final HashMap knownIds = new HashMap<>(); public QuerySearcher(ISearchActivity activity, @NonNull String query) { super(activity, query); trimmedQuery = query.trim(); } @Override public boolean addResult(EntryItem... pojos) { // Give a boost if item was previously selected for this query for (EntryItem pojo : pojos) { int historyRecord = MapCompat.getOrDefault(knownIds, pojo.id, 0); if (historyRecord != 0) { pojo.boostRelevance(25 * historyRecord); } } // call super implementation to update the adapter return super.addResult(pojos); } /** * Called on the background thread */ @WorkerThread @Override protected Void doInBackground(Void param) { ISearchActivity searchActivity = activityWeakReference.get(); Context context = searchActivity != null ? searchActivity.getContext() : null; if (context == null) return null; // Have we ever made the same query and selected something ? List lastIdsForQuery = DBHelper.getPreviousResultsForQuery(context, trimmedQuery); knownIds.clear(); for (ValuedHistoryRecord id : lastIdsForQuery) { knownIds.put(id.record, (int) id.value); } // Request results via "addResult" TBApplication.getApplication(context).getDataHandler().requestResults(trimmedQuery, this); return null; } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/searcher/ResultBuffer.java ================================================ package rocks.tbog.tblauncher.searcher; import java.util.ArrayList; import java.util.Collection; import rocks.tbog.tblauncher.entry.EntryItem; public class ResultBuffer implements ISearcher { private final boolean tagsEnabled; private final ArrayList entryItems = new ArrayList<>(); Class typeClass; public ResultBuffer(boolean tagsEnabled, Class typeClass) { this.tagsEnabled = tagsEnabled; this.typeClass = typeClass; } public Collection getEntryItems() { return entryItems; } @Override public boolean addResult(EntryItem... items) { boolean result = false; for (EntryItem item : items) result |= entryItems.add(typeClass.cast(item)); return result; } @Override public boolean tagsEnabled() { return tagsEnabled; } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/searcher/Searcher.java ================================================ package rocks.tbog.tblauncher.searcher; import android.app.Activity; import android.content.Context; import android.util.Log; import androidx.annotation.CallSuper; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.Collections; import java.util.PriorityQueue; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.WorkAsync.AsyncTask; import rocks.tbog.tblauncher.WorkAsync.TaskRunner; import rocks.tbog.tblauncher.entry.EntryItem; import rocks.tbog.tblauncher.utils.PrefCache; import rocks.tbog.tblauncher.utils.Utilities; public abstract class Searcher extends AsyncTask implements ISearcher { // define a different thread than the default AsyncTask thread or else we will block everything else that uses AsyncTask while we search public static final ExecutorService SEARCH_THREAD = Executors.newSingleThreadExecutor(); protected static final int INITIAL_CAPACITY = 50; protected final WeakReference activityWeakReference; protected final PriorityQueue processedPojos; protected final int maxResults; private final boolean tagsEnabled; private long start; /** * Set to true when we are simply refreshing current results (scroll will not be reset) * When false, we reset the scroll back to the last item in the list */ private boolean isRefresh = false; @NonNull protected final String query; public Searcher(ISearchActivity activity, @NonNull String query) { super(); this.query = query; activityWeakReference = new WeakReference<>(activity); processedPojos = getPojoProcessor(activity); tagsEnabled = PrefCache.getFuzzySearchTags(activity.getContext()); maxResults = getMaxResultCount(activity.getContext()); } @Nullable public Context getContext() { ISearchActivity activity = activityWeakReference.get(); return activity != null ? activity.getContext() : null; } @NonNull public String getQuery() { return query; } protected PriorityQueue getPojoProcessor(ISearchActivity activity) { return new PriorityQueue<>(INITIAL_CAPACITY, EntryItem.RELEVANCE_COMPARATOR); } protected int getMaxResultCount(Context context) { return PrefCache.getResultSearcherCap(context); } /** * This is called from the background thread by the providers */ @WorkerThread @Override public boolean addResult(EntryItem... pojos) { if (isCancelled()) return false; ISearchActivity searchActivity = activityWeakReference.get(); Activity activity = searchActivity != null ? Utilities.getActivity(searchActivity.getContext()) : null; if (activity == null) return false; Collections.addAll(processedPojos, pojos); while (processedPojos.size() > maxResults) processedPojos.poll(); return true; } @CallSuper @Override protected void onPreExecute() { super.onPreExecute(); start = System.currentTimeMillis(); displayActivityLoader(); } private void displayActivityLoader() { ISearchActivity searchActivity = activityWeakReference.get(); Activity activity = searchActivity != null ? Utilities.getActivity(searchActivity.getContext()) : null; if (activity == null) return; searchActivity.displayLoader(true); } @Override protected void onPostExecute(Void param) { ISearchActivity searchActivity = activityWeakReference.get(); Activity activity = searchActivity != null ? Utilities.getActivity(searchActivity.getContext()) : null; if (activity == null) return; // Loader should still be displayed until all the providers have finished loading searchActivity.displayLoader(!TBApplication.getApplication(activity).getDataHandler().fullLoadOverSent()); if (this.processedPojos.isEmpty()) { searchActivity.clearAdapter(); } else { PriorityQueue queue = this.processedPojos; ArrayList results = new ArrayList<>(queue.size()); while (queue.peek() != null) { results.add(queue.poll()); } searchActivity.updateAdapter(results, isRefresh); } searchActivity.resetTask(); long time = System.currentTimeMillis() - start; Log.v("Timing", "Time to run query `" + query + "` on " + getClass().getSimpleName() + " to completion: " + time + "ms"); } public void setRefresh(boolean refresh) { isRefresh = refresh; } @Override public boolean tagsEnabled() { return tagsEnabled; } public void execute() { TaskRunner.executeOnExecutor(Searcher.SEARCH_THREAD, this); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/searcher/TagList.java ================================================ package rocks.tbog.tblauncher.searcher; import android.app.Activity; import android.content.Context; import android.content.SharedPreferences; import androidx.annotation.NonNull; import androidx.annotation.WorkerThread; import androidx.preference.PreferenceManager; import java.util.HashSet; import java.util.List; import java.util.PriorityQueue; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.dataprovider.TagsProvider; import rocks.tbog.tblauncher.entry.ActionEntry; import rocks.tbog.tblauncher.entry.EntryItem; import rocks.tbog.tblauncher.entry.EntryWithTags; import rocks.tbog.tblauncher.entry.TagEntry; import rocks.tbog.tblauncher.handler.DataHandler; import rocks.tbog.tblauncher.utils.PrefCache; import rocks.tbog.tblauncher.utils.PrefOrderedListHelper; import rocks.tbog.tblauncher.utils.Utilities; public class TagList extends Searcher { final HashSet foundIdSet = new HashSet<>(); public TagList(ISearchActivity activity, @NonNull String query) { super(activity, query); } @Override protected PriorityQueue getPojoProcessor(ISearchActivity activity) { if ("untagged".equals(query)) return new PriorityQueue<>(INITIAL_CAPACITY, EntryItem.NAME_COMPARATOR); return super.getPojoProcessor(activity); } @WorkerThread @Override public boolean addResult(EntryItem... pojos) { if (isCancelled()) return false; ISearchActivity searchActivity = activityWeakReference.get(); Activity activity = searchActivity != null ? Utilities.getActivity(searchActivity.getContext()) : null; if (activity == null) return false; // only allow untagged entries for (EntryItem entryItem : pojos) { if (entryItem instanceof EntryWithTags) { if (((EntryWithTags) entryItem).getTags().isEmpty()) { addProcessedPojo(entryItem); } } } return true; } private void addProcessedPojo(EntryItem entryItem) { // if id already processed, skip it if (!foundIdSet.add(entryItem.id)) return; processedPojos.add(entryItem); if (processedPojos.size() > maxResults) processedPojos.poll(); } @WorkerThread @Override protected Void doInBackground(Void param) { ISearchActivity searchActivity = activityWeakReference.get(); Context context = searchActivity != null ? searchActivity.getContext() : null; if (context == null) return null; DataHandler dh = TBApplication.dataHandler(context); // Request results via "addResult" if ("untagged".equals(query)) dh.requestAllRecords(this); else if ("list".equals(query) || "listReversed".equals(query)) { TagsProvider tagsProvider = dh.getTagsProvider(); boolean reversed = query.endsWith("Reversed"); // add tags from the tags menu list if (tagsProvider != null) { SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context); List tagOrder = PrefOrderedListHelper.getOrderedList(pref, "tags-menu-list", "tags-menu-order"); for (String orderValue : tagOrder) { String tagName = PrefOrderedListHelper.getOrderedValueName(orderValue); int order = 10 * PrefOrderedListHelper.getOrderedValueIndex(orderValue); TagEntry tagEntry = tagsProvider.getTagEntry(tagName); tagEntry.setRelevance(tagEntry.normalizedName, null); tagEntry.boostRelevance(reversed ? order : -order); addProcessedPojo(tagEntry); } } // add the show untagged action to the result list EntryItem untaggedEntry; boolean bAddUntagged = PrefCache.showTagsMenuUntagged(context); if (bAddUntagged) { untaggedEntry = TBApplication.dataHandler(context).getPojo(ActionEntry.SCHEME + "show/untagged"); if (untaggedEntry instanceof ActionEntry) { int idx = -1 + 10 * PrefCache.getTagsMenuUntaggedIndex(context); untaggedEntry.setRelevance(untaggedEntry.normalizedName, null); untaggedEntry.boostRelevance(reversed ? idx : -idx); addProcessedPojo(untaggedEntry); } } } return null; } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/searcher/TagSearcher.java ================================================ package rocks.tbog.tblauncher.searcher; import android.app.Activity; import android.content.Context; import androidx.annotation.NonNull; import androidx.annotation.WorkerThread; import java.util.HashSet; import java.util.PriorityQueue; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.entry.EntryItem; import rocks.tbog.tblauncher.entry.EntryWithTags; import rocks.tbog.tblauncher.utils.Utilities; public class TagSearcher extends Searcher { final EntryWithTags.TagDetails tagDetails; final HashSet foundIdSet = new HashSet<>(); public TagSearcher(ISearchActivity activity, @NonNull String query) { super(activity, query); tagDetails = new EntryWithTags.TagDetails(query); } @Override protected PriorityQueue getPojoProcessor(ISearchActivity activity) { return new PriorityQueue<>(INITIAL_CAPACITY, EntryItem.NAME_COMPARATOR); } @WorkerThread @Override public boolean addResult(EntryItem... pojos) { if (isCancelled()) return false; ISearchActivity searchActivity = activityWeakReference.get(); Activity activity = searchActivity != null ? Utilities.getActivity(searchActivity.getContext()) : null; if (activity == null) return false; for (EntryItem entryItem : pojos) { if (entryItem instanceof EntryWithTags) { if (((EntryWithTags) entryItem).getTags().contains(tagDetails)) { addProcessedPojo((EntryWithTags) entryItem); } } } return true; } private void addProcessedPojo(EntryWithTags entryItem) { // if id already processed, skip it if (!foundIdSet.add(entryItem.id)) return; processedPojos.add(entryItem); if (processedPojos.size() > maxResults) processedPojos.poll(); } @WorkerThread @Override protected Void doInBackground(Void param) { ISearchActivity searchActivity = activityWeakReference.get(); Context context = searchActivity != null ? searchActivity.getContext() : null; if (context == null) return null; // Request results via "addResult" TBApplication.getApplication(context).getDataHandler().requestAllRecords(this); return null; } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/shortcut/SaveSingleOreoShortcutAsync.java ================================================ package rocks.tbog.tblauncher.shortcut; import android.annotation.TargetApi; import android.content.Context; import android.content.Intent; import android.content.pm.LauncherApps; import android.content.pm.ShortcutInfo; import android.os.Build; import android.util.Log; import android.widget.Toast; import androidx.annotation.NonNull; import java.lang.ref.WeakReference; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.WorkAsync.AsyncTask; import rocks.tbog.tblauncher.dataprovider.ShortcutsProvider; import rocks.tbog.tblauncher.db.ShortcutRecord; import rocks.tbog.tblauncher.handler.DataHandler; import rocks.tbog.tblauncher.utils.Utilities; @TargetApi(Build.VERSION_CODES.O) public class SaveSingleOreoShortcutAsync extends AsyncTask {// AsyncTask { private static final String TAG = "OreoShortcutAsync"; private final WeakReference context; private final WeakReference dataHandler; private final Intent intent; public SaveSingleOreoShortcutAsync(@NonNull Context context, @NonNull Intent intent) { this.context = new WeakReference<>(context); this.dataHandler = new WeakReference<>(TBApplication.getApplication(context).getDataHandler()); this.intent = intent; } @Override protected Boolean doInBackground(Void v) { final LauncherApps.PinItemRequest pinItemRequest = intent.getParcelableExtra(LauncherApps.EXTRA_PIN_ITEM_REQUEST); final ShortcutInfo shortcutInfo = pinItemRequest != null ? pinItemRequest.getShortcutInfo() : null; if (shortcutInfo == null) { cancel(true); return null; } Context context = this.context.get(); if (context == null) { cancel(true); return null; } // Create Pojo ShortcutRecord record = ShortcutUtil.createShortcutRecord(context, shortcutInfo, false); if (record == null) { return false; } final DataHandler dataHandler = this.dataHandler.get(); if (dataHandler == null) { cancel(true); return null; } try { if (!pinItemRequest.accept()) return false; } catch (IllegalStateException e) { return false; } // Add shortcut to the DataHandler return dataHandler.addShortcut(record); } @Override protected void onPostExecute(Boolean success) { if (success == null || isCancelled()) { Context ctx = context.get(); if (ctx != null) Toast.makeText(ctx, R.string.cant_pin_shortcut, Toast.LENGTH_LONG).show(); return; } if (success) { Log.i(TAG, "Shortcut added to KISS"); final DataHandler dataHandler = this.dataHandler.get(); if (dataHandler == null) return; ShortcutsProvider shortcutsProvider = dataHandler.getShortcutsProvider(); if (shortcutsProvider != null) shortcutsProvider.reload(true); } } public void execute() { Utilities.executeAsync(this); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/shortcut/ShortcutUtil.java ================================================ package rocks.tbog.tblauncher.shortcut; import android.annotation.TargetApi; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.ApplicationInfo; import android.content.pm.LauncherApps; import android.content.pm.PackageManager; import android.content.pm.ShortcutInfo; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.drawable.Drawable; import android.os.Build; import android.os.Process; import android.os.UserHandle; import android.os.UserManager; import android.text.TextUtils; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; import androidx.preference.PreferenceManager; import java.io.ByteArrayOutputStream; import java.util.ArrayList; import java.util.Collections; import java.util.List; import rocks.tbog.tblauncher.db.DBHelper; import rocks.tbog.tblauncher.db.ShortcutRecord; import rocks.tbog.tblauncher.utils.Utilities; import static android.content.pm.LauncherApps.ShortcutQuery.FLAG_MATCH_MANIFEST; import static android.content.pm.LauncherApps.ShortcutQuery.FLAG_MATCH_PINNED; public class ShortcutUtil { final static private String TAG = "ShortcutUtil"; /** * @return true if shortcuts are enabled in settings and android version is higher or equals android 8 */ public static boolean areShortcutsEnabled(Context context) { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); return android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && prefs.getBoolean("enable-shortcuts", true); } /** * Save single shortcut to DB via pin request */ @TargetApi(Build.VERSION_CODES.O) public static void addShortcut(Context context, Intent intent) { new SaveSingleOreoShortcutAsync(context, intent).execute(); } /** * Remove all shortcuts saved in the database */ public static void removeAllShortcuts(Context context) { DBHelper.removeAllShortcuts(context); } /** * @return all shortcuts from all applications available on the device */ @TargetApi(Build.VERSION_CODES.O) public static List getAllShortcuts(Context context) { return getShortcut(context, null); } @TargetApi(Build.VERSION_CODES.O) public static List getShortcut(Context context, String packageName) { return getShortcut(context, packageName, FLAG_MATCH_MANIFEST | FLAG_MATCH_PINNED); } /** * @return all shortcuts for given package name */ @TargetApi(Build.VERSION_CODES.O) public static List getShortcut(Context context, String packageName, int queryFlags) { List shortcutInfoList = new ArrayList<>(); UserManager manager = (UserManager) context.getSystemService(Context.USER_SERVICE); assert manager != null; LauncherApps launcherApps = (LauncherApps) context.getSystemService(Context.LAUNCHER_APPS_SERVICE); assert launcherApps != null; if (launcherApps.hasShortcutHostPermission()) { LauncherApps.ShortcutQuery shortcutQuery = new LauncherApps.ShortcutQuery(); shortcutQuery.setQueryFlags(queryFlags); if (!TextUtils.isEmpty(packageName)) { shortcutQuery.setPackage(packageName); } for (android.os.UserHandle profile : manager.getUserProfiles()) { List list; try { list = launcherApps.getShortcuts(shortcutQuery, profile); } catch (IllegalStateException e) { // profile is locked or user not running list = null; } if (list != null) shortcutInfoList.addAll(list); } } return shortcutInfoList; } /** * Create ShortcutPojo from ShortcutInfo */ @TargetApi(Build.VERSION_CODES.O) public static ShortcutRecord createShortcutRecord(Context context, ShortcutInfo shortcutInfo, boolean includePackageName) { ShortcutRecord record = new ShortcutRecord(); record.packageName = shortcutInfo.getPackage(); record.infoData = shortcutInfo.getId(); record.addFlags(ShortcutRecord.FLAG_OREO); LauncherApps launcherApps = (LauncherApps) context.getSystemService(Context.LAUNCHER_APPS_SERVICE); assert launcherApps != null; final Drawable iconDrawable = launcherApps.getShortcutIconDrawable(shortcutInfo, 0); record.iconPng = iconDrawable != null ? getIconBlob(iconDrawable) : null; String appName = includePackageName ? getAppNameFromPackageName(context, shortcutInfo.getPackage()) : null; if (shortcutInfo.getShortLabel() != null) { if (!TextUtils.isEmpty(appName)) { record.displayName = appName + ": " + shortcutInfo.getShortLabel().toString(); } else { record.displayName = shortcutInfo.getShortLabel().toString(); } } else if (shortcutInfo.getLongLabel() != null) { if (!TextUtils.isEmpty(appName)) { record.displayName = appName + ": " + shortcutInfo.getLongLabel().toString(); } else { record.displayName = shortcutInfo.getLongLabel().toString(); } } else { Log.d(TAG, "Invalid shortcut for " + record.packageName + ", ignoring"); return null; } return record; } /** * @return App name from package name */ @NonNull public static String getAppNameFromPackageName(Context context, String packageName) { try { PackageManager packageManager = context.getPackageManager(); ApplicationInfo info = packageManager.getApplicationInfo(packageName, PackageManager.GET_META_DATA); return packageManager.getApplicationLabel(info).toString(); } catch (PackageManager.NameNotFoundException ignored) { return ""; } } @NonNull public static byte[] getIconBlob(@NonNull Drawable iconDrawable) { Bitmap icon = Utilities.drawableToBitmap(iconDrawable); ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); // Can't user WEBP compression because from API v18 to v21 there is no alpha encoding // see: https://stackoverflow.com/questions/38753798/android-webp-encoding-in-api-v18-and-above-bitmap-compressbitmap-compressforma icon.compress(Bitmap.CompressFormat.PNG, 100, outputStream); return outputStream.toByteArray(); } @Nullable public static Bitmap getInitialIcon(@NonNull Context context, long dbId) { byte[] iconBlob = DBHelper.getShortcutIcon(context, dbId); if (iconBlob != null) return BitmapFactory.decodeByteArray(iconBlob, 0, iconBlob.length); return null; } /** * Removes the given shortcut from the current list of pinned shortcuts. * (Should run on background thread) */ @WorkerThread public static void removeShortcut(@NonNull Context context, @NonNull ShortcutInfo shortcutInfo) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { LauncherApps launcherApps = (LauncherApps) context.getSystemService(Context.LAUNCHER_APPS_SERVICE); assert launcherApps != null; String packageName = shortcutInfo.getPackage(); String id = shortcutInfo.getId(); UserHandle user = shortcutInfo.getUserHandle(); // query for pinned shortcuts List shortcutIds; { LauncherApps.ShortcutQuery q = new LauncherApps.ShortcutQuery(); q.setQueryFlags(LauncherApps.ShortcutQuery.FLAG_MATCH_PINNED); List shortcutInfos = launcherApps.getShortcuts(q, Process.myUserHandle()); if (shortcutInfos == null) shortcutInfos = Collections.emptyList(); shortcutIds = new ArrayList<>(shortcutInfos.size()); for (ShortcutInfo info : shortcutInfos) shortcutIds.add(info.getId()); } // unpin the shortcut shortcutIds.remove(id); try { launcherApps.pinShortcuts(packageName, shortcutIds, user); } catch (SecurityException | IllegalStateException e) { Log.w(TAG, "Failed to unpin shortcut", e); } } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/ui/BlockableListView.java ================================================ package rocks.tbog.tblauncher.ui; import android.annotation.SuppressLint; import android.content.Context; import android.util.AttributeSet; import android.view.MotionEvent; import android.widget.ListView; /** * ListView subclass that provides an interface for (temporarily) blocking all of it's input events */ public class BlockableListView extends ListView { private boolean touchEventsBlocked = false; public BlockableListView(Context context) { super(context); } public BlockableListView(Context context, AttributeSet attrs) { super(context, attrs); } public BlockableListView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); } /** * Prevent this ListView from receiving any new touch events *

* Use {@link #unblockTouchEvents()} to end the blockage. */ public void blockTouchEvents() { this.touchEventsBlocked = true; } /** * Stop preventing this ListView from receiving touch events */ public void unblockTouchEvents() { this.touchEventsBlocked = false; } @SuppressLint("ClickableViewAccessibility") @Override public boolean onTouchEvent(MotionEvent ev) { return this.touchEventsBlocked || super.onTouchEvent(ev); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/ui/BottomPullEffectView.java ================================================ package rocks.tbog.tblauncher.ui; import android.content.Context; import android.graphics.Canvas; import android.os.Build; import android.util.AttributeSet; import android.view.View; import android.widget.EdgeEffect; /** * View that renders that over-scroll/"pulled too far" effect *

* Parts (or even all) of the given effect parameters may be discarded with the underlying Android * platform does not support them. */ public class BottomPullEffectView extends View { private EdgeEffect effect; private float lastPullDistance; private float lastPullDisplacement; private boolean lastPullAnimated; public BottomPullEffectView(Context context) { super(context); } public BottomPullEffectView(Context context, AttributeSet attrs) { super(context, attrs); } public BottomPullEffectView(Context context, AttributeSet attrs, int flags) { super(context, attrs, flags); } /** * Force the pull effect to display the given pull distance and left/right displacement * * @param distance Pull distance (0.0f – 1.0f) * @param displacement Left/right displacement (0.0f → right, 0.5f → center, 1.0f → left) * @param animated Should this pull eventually fade away? */ public void setPull(float distance, float displacement, boolean animated) { // Reset internal effect state by creating a new instance //XXX: This may cause unnecessary GC runs! this.effect = new EdgeEffect(this.getContext()); // Provide new pull effect data this.effect.setSize(this.getWidth(), this.getHeight()); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { this.effect.onPull(distance, displacement); } else { this.effect.onPull(distance); } if (!animated) { // Prevent more than one frame being drawn this.effect.finish(); } this.lastPullDistance = distance; this.lastPullDisplacement = displacement; this.lastPullAnimated = animated; // Request scene to be redrawn this.invalidate(); } /** * Draw a release animation for the previous pull effect */ public void releasePull() { if (this.effect == null) { return; } // Recreate effect without `finish()`-ing it, so that the release effect will be // properly drawn if (!this.lastPullAnimated) { this.setPull(this.lastPullDistance, this.lastPullDisplacement, true); } this.effect.onRelease(); this.invalidate(); } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); if (this.effect == null) { return; } final int canvas_save_count = canvas.save(); canvas.translate(-this.getWidth(), this.getHeight()); canvas.rotate(180, this.getWidth(), 0); this.effect.setSize(this.getWidth(), this.getHeight()); boolean invalidate = this.effect.draw(canvas); canvas.restoreToCount(canvas_save_count); if (invalidate) { this.invalidate(); } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/ui/CenteredImageSpan.java ================================================ package rocks.tbog.tblauncher.ui; import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.Rect; import android.graphics.drawable.Drawable; import android.text.style.ImageSpan; import androidx.annotation.NonNull; public class CenteredImageSpan extends ImageSpan { // Extra variables used to redefine the Font Metrics when an ImageSpan is added private int initialDescent = 0; private int extraSpace = 0; public CenteredImageSpan(@NonNull Drawable drawable) { super(drawable, ImageSpan.ALIGN_BOTTOM); } @Override public int getSize(Paint paint, CharSequence text, int start, int end, Paint.FontMetricsInt fm) { Drawable d = getDrawable(); Rect rect = d.getBounds(); if (fm != null) { // Centers the text with the ImageSpan if (rect.bottom - (fm.descent - fm.ascent) >= 0) { // Stores the initial descent and computes the margin available initialDescent = fm.descent; extraSpace = rect.bottom - (fm.descent - fm.ascent); } fm.descent = extraSpace / 2 + initialDescent; fm.bottom = fm.descent; fm.ascent = -rect.bottom + fm.descent; fm.top = fm.ascent; } return rect.right; } @Override public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, @NonNull Paint paint) { Drawable b = getDrawable(); canvas.save(); // center int transY = top + (bottom - top) / 2 - b.getBounds().height() / 2; canvas.translate(x, transY); b.draw(canvas); canvas.restore(); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/ui/CustomizeMarginView.java ================================================ package rocks.tbog.tblauncher.ui; import android.annotation.SuppressLint; import android.content.Context; import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.Rect; import android.util.AttributeSet; import android.view.MotionEvent; import android.view.View; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import rocks.tbog.tblauncher.R; public class CustomizeMarginView extends View { private final Rect targetRect = new Rect(); private final Rect windowRect = new Rect(); private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); private float offsetX = 0f; private float offsetY = 0f; private int bgColor1; private int bgColor2; private float horizontalMargin; private float verticalMargin; private float offsetScale; private final int _padding; private final int _width; private final int _height; private final float _sampleRadius; private final float _sampleFrameRadius; private OnOffsetChanged onOffsetChanged = null; private final int colorSampleFrame; public interface OnOffsetChanged { void onOffsetChanged(float dx, float dy); } public CustomizeMarginView(Context context, @Nullable AttributeSet attrs) { this(context, attrs, 0); } public CustomizeMarginView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); _padding = getResources().getDimensionPixelSize(R.dimen.mm2d_cc_panel_margin); _width = getResources().getDimensionPixelSize(R.dimen.mm2d_cc_hsv_size) + _padding * 2; _height = getResources().getDimensionPixelSize(R.dimen.mm2d_cc_hsv_size) + _padding * 2; _sampleRadius = getResources().getDimension(R.dimen.mm2d_cc_sample_radius); _sampleFrameRadius = _sampleRadius + getResources().getDimension(R.dimen.mm2d_cc_sample_frame); colorSampleFrame = getResources().getColor(R.color.mm2d_cc_sample_frame); paint.setStrokeWidth(0f); setLayerType(LAYER_TYPE_SOFTWARE, paint); bgColor1 = 0xFF000000; bgColor2 = 0xFFffffff; horizontalMargin = _padding * 2; verticalMargin = _padding * 2; offsetScale = Math.max(horizontalMargin, verticalMargin); } public void setOnOffsetChanged(OnOffsetChanged listener) { onOffsetChanged = listener; } public void setPreviewColors(int bgColor1, int bgColor2) { this.bgColor1 = bgColor1; this.bgColor2 = bgColor2; } public void setMarginParameters(float horizontalMargin, float verticalMargin) { this.horizontalMargin = horizontalMargin; this.verticalMargin = verticalMargin; this.offsetScale = Math.max(horizontalMargin, verticalMargin); invalidate(); } public void setOffsetValues(float offsetX, float offsetY) { this.offsetX = offsetX; this.offsetY = offsetY; invalidate(); } @Override protected void onDraw(@NonNull Canvas canvas) { if (targetRect.isEmpty()) { canvas.getClipBounds(targetRect); } windowRect.set(targetRect); windowRect.inset(Math.round(horizontalMargin), Math.round(verticalMargin)); windowRect.offset(Math.round(offsetX), Math.round(offsetY)); // background paint.setColor(bgColor1); paint.setStyle(Paint.Style.FILL_AND_STROKE); canvas.drawRect(targetRect, paint); // window paint.setColor(bgColor2); paint.setStyle(Paint.Style.FILL_AND_STROKE); canvas.drawRect(windowRect, paint); // draw offset circle var x = (offsetX / offsetScale * .5f + .5f) * targetRect.width() + targetRect.left; var y = (offsetY / offsetScale * .5f + .5f) * targetRect.height() + targetRect.top; paint.setColor(colorSampleFrame); canvas.drawCircle(x, y, _sampleFrameRadius, paint); paint.setColor(bgColor1); canvas.drawCircle(x, y, _sampleRadius, paint); } private static float clampOffset(float value) { return Math.min(1f, Math.max(0f, value)); } @SuppressLint("ClickableViewAccessibility") @Override public boolean onTouchEvent(MotionEvent event) { if (event.getAction() == MotionEvent.ACTION_DOWN) { getParent().requestDisallowInterceptTouchEvent(true); } offsetX = clampOffset((event.getX() - targetRect.left) / targetRect.width()); offsetY = clampOffset((event.getY() - targetRect.top) / targetRect.height()); // center and scale offset offsetX = (offsetX * 2f - 1f) * offsetScale; offsetY = (offsetY * 2f - 1f) * offsetScale; // round values offsetX = Math.round(offsetX); offsetY = Math.round(offsetY); if (onOffsetChanged != null) onOffsetChanged.onOffsetChanged(offsetX, offsetY); invalidate(); return true; } @Override protected void onLayout(boolean changed, int left, int top, int right, int bottom) { targetRect.set( getPaddingLeft() + _padding, getPaddingTop() + _padding, getWidth() - getPaddingRight() - _padding, getHeight() - getPaddingBottom() - _padding ); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { final var paddingHorizontal = getPaddingLeft() + getPaddingRight(); final var paddingVertical = getPaddingTop() + getPaddingBottom(); final var resizeWidth = MeasureSpec.getMode(widthMeasureSpec) != MeasureSpec.EXACTLY; final var resizeHeight = MeasureSpec.getMode(heightMeasureSpec) != MeasureSpec.EXACTLY; if (!resizeWidth && !resizeHeight) { setMeasuredDimension( resolveSizeAndState( Math.max(_width + paddingHorizontal, getSuggestedMinimumWidth()), widthMeasureSpec, MeasureSpec.UNSPECIFIED ), resolveSizeAndState( Math.max(_height + paddingVertical, getSuggestedMinimumHeight()), heightMeasureSpec, MeasureSpec.UNSPECIFIED ) ); return; } var widthSize = resolveAdjustedSize(_width + paddingHorizontal, widthMeasureSpec); var heightSize = resolveAdjustedSize(_height + paddingVertical, heightMeasureSpec); var actualAspect = (widthSize - paddingHorizontal) / (float) (heightSize - paddingVertical); if (Math.abs(actualAspect - 1f) < 0.0000001f) { setMeasuredDimension(widthSize, heightSize); return; } if (resizeWidth) { final var newWidth = heightSize - paddingVertical + paddingHorizontal; if (!resizeHeight) { widthSize = resolveAdjustedSize(newWidth, widthMeasureSpec); } if (newWidth <= widthSize) { widthSize = newWidth; setMeasuredDimension(widthSize, heightSize); return; } } if (resizeHeight) { final var newHeight = widthSize - paddingHorizontal + paddingVertical; if (!resizeWidth) { heightSize = resolveAdjustedSize(newHeight, heightMeasureSpec); } if (newHeight <= heightSize) { heightSize = newHeight; } } setMeasuredDimension(widthSize, heightSize); } private int resolveAdjustedSize(int desiredSize, int measureSpec) { int specMode = MeasureSpec.getMode(measureSpec); int specSize = MeasureSpec.getSize(measureSpec); switch (specMode) { case MeasureSpec.AT_MOST: return Math.min(desiredSize, specSize); case MeasureSpec.EXACTLY: return specSize; case MeasureSpec.UNSPECIFIED: default: return desiredSize; } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/ui/CustomizeShadowView.java ================================================ package rocks.tbog.tblauncher.ui; import android.annotation.SuppressLint; import android.content.Context; import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.Rect; import android.util.AttributeSet; import android.view.MotionEvent; import android.view.View; import androidx.annotation.Nullable; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.utils.UISizes; public class CustomizeShadowView extends View { private final Rect targetRect = new Rect(); private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); private float offsetX = 0f; private float offsetY = 0f; private float shadowRadius; private int shadowColor; private int textColor; private float textSize; private int bgColor1; private int bgColor2; private final int _gridSize; private final float _offsetScale = 5f; private final int _padding; private final int _width; private final int _height; private final float _sampleRadius; private final float _sampleFrameRadius; private final float _sampleShadowRadius; private String text; private String[] lines; private OnOffsetChanged onOffsetChanged = null; private final int colorSampleFrame; private final int colorSampleShadow; public interface OnOffsetChanged { void onOffsetChanged(float dx, float dy); } public CustomizeShadowView(Context context, @Nullable AttributeSet attrs) { this(context, attrs, 0); } public CustomizeShadowView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); _gridSize = getResources().getInteger(R.integer.shadow_preview_grid); _padding = getResources().getDimensionPixelSize(R.dimen.mm2d_cc_panel_margin); _width = getResources().getDimensionPixelSize(R.dimen.mm2d_cc_hsv_size) + _padding * 2; _height = getResources().getDimensionPixelSize(R.dimen.mm2d_cc_hsv_size) + _padding * 2; _sampleRadius = getResources().getDimension(R.dimen.mm2d_cc_sample_radius); _sampleFrameRadius = _sampleRadius + getResources().getDimension(R.dimen.mm2d_cc_sample_frame); _sampleShadowRadius = _sampleFrameRadius + getResources().getDimension(R.dimen.mm2d_cc_sample_shadow); colorSampleFrame = getResources().getColor(R.color.mm2d_cc_sample_frame); colorSampleShadow = getResources().getColor(R.color.mm2d_cc_sample_shadow); shadowRadius = 5f; shadowColor = 0xFF000000; setTextParameters(getResources().getText(R.string.shadow_offset_preview), 0xFFffffff, UISizes.sp2px(context, getResources().getInteger(R.integer.default_size_text))); paint.setTextAlign(Paint.Align.CENTER); setLayerType(LAYER_TYPE_SOFTWARE, paint); bgColor1 = shadowColor; bgColor2 = textColor; } public void setShadowParameters(float radius, float dx, float dy, int color) { shadowRadius = radius; offsetX = dx; offsetY = dy; shadowColor = color | 0xFF000000; invalidate(); } public void setTextParameters(@Nullable CharSequence text, int color, int size) { if (text != null) { this.text = text.toString(); lines = this.text.split("\\s"); } textColor = color; textSize = size; invalidate(); } public void setOnOffsetChanged(OnOffsetChanged listener) { onOffsetChanged = listener; } public void setBackgroundParameters(int bgColor1, int bgColor2) { this.bgColor1 = bgColor1; this.bgColor2 = bgColor2; } @Override protected void onDraw(Canvas canvas) { if (targetRect.isEmpty()) canvas.getClipBounds(targetRect); paint.setColor(bgColor1); canvas.drawRect(targetRect, paint); //draw checker board { float size = targetRect.width() / (float) _gridSize; paint.setColor(bgColor2); for (int y = 0; y < _gridSize; y += 1) for (int x = 0; x < _gridSize; x += 1) if ((x + y) % 2 == 0) { float cX = targetRect.left + x * size; float cY = targetRect.top + y * size; canvas.drawRect(cX, cY, cX + size, cY + size, paint); } } paint.setShadowLayer(shadowRadius, offsetX, offsetY, shadowColor); final float centerX = targetRect.centerX(); // draw small text paint.setColor(textColor); paint.setTextSize(textSize); canvas.drawText(text, centerX, targetRect.top - paint.ascent() + _offsetScale, paint); canvas.drawText(text, centerX, targetRect.bottom - paint.descent() - _offsetScale, paint); // prepare big text paint.setTextSize(targetRect.height() / (lines.length + 2f)); final float lineHeight = paint.descent() - paint.ascent(); float lineY = targetRect.centerY() - (lineHeight * lines.length) / 2f - paint.descent(); // write big text split by whitespace for (String line : lines) { lineY += lineHeight; canvas.drawText(line, centerX, lineY, paint); } paint.clearShadowLayer(); // draw offset circle var x = (offsetX / _offsetScale * .5f + .5f) * targetRect.width() + targetRect.left; var y = (offsetY / _offsetScale * .5f + .5f) * targetRect.height() + targetRect.top; paint.setColor(colorSampleShadow); canvas.drawCircle(x, y, _sampleShadowRadius, paint); paint.setColor(colorSampleFrame); canvas.drawCircle(x, y, _sampleFrameRadius, paint); paint.setColor(shadowColor); canvas.drawCircle(x, y, _sampleRadius, paint); } private static float clampOffset(float value) { return Math.min(1f, Math.max(0f, value)); } @SuppressLint("ClickableViewAccessibility") @Override public boolean onTouchEvent(MotionEvent event) { if (event.getAction() == MotionEvent.ACTION_DOWN) { getParent().requestDisallowInterceptTouchEvent(true); } offsetX = clampOffset((event.getX() - targetRect.left) / targetRect.width()); offsetY = clampOffset((event.getY() - targetRect.top) / targetRect.height()); // center and scale offset offsetX = (offsetX * 2f - 1f) * _offsetScale; offsetY = (offsetY * 2f - 1f) * _offsetScale; // round values to 1/10 offsetX = Math.round(offsetX * 10f) * .1f; offsetY = Math.round(offsetY * 10f) * .1f; if (onOffsetChanged != null) onOffsetChanged.onOffsetChanged(offsetX, offsetY); invalidate(); return true; } @Override protected void onLayout(boolean changed, int left, int top, int right, int bottom) { targetRect.set( getPaddingLeft() + _padding, getPaddingTop() + _padding, getWidth() - getPaddingRight() - _padding, getHeight() - getPaddingBottom() - _padding ); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { final var paddingHorizontal = getPaddingLeft() + getPaddingRight(); final var paddingVertical = getPaddingTop() + getPaddingBottom(); final var resizeWidth = MeasureSpec.getMode(widthMeasureSpec) != MeasureSpec.EXACTLY; final var resizeHeight = MeasureSpec.getMode(heightMeasureSpec) != MeasureSpec.EXACTLY; if (!resizeWidth && !resizeHeight) { setMeasuredDimension( resolveSizeAndState( Math.max(_width + paddingHorizontal, getSuggestedMinimumWidth()), widthMeasureSpec, MeasureSpec.UNSPECIFIED ), resolveSizeAndState( Math.max(_height + paddingVertical, getSuggestedMinimumHeight()), heightMeasureSpec, MeasureSpec.UNSPECIFIED ) ); return; } var widthSize = resolveAdjustedSize(_width + paddingHorizontal, widthMeasureSpec); var heightSize = resolveAdjustedSize(_height + paddingVertical, heightMeasureSpec); var actualAspect = (widthSize - paddingHorizontal) / (float) (heightSize - paddingVertical); if (Math.abs(actualAspect - 1f) < 0.0000001f) { setMeasuredDimension(widthSize, heightSize); return; } if (resizeWidth) { final var newWidth = heightSize - paddingVertical + paddingHorizontal; if (!resizeHeight) { widthSize = resolveAdjustedSize(newWidth, widthMeasureSpec); } if (newWidth <= widthSize) { widthSize = newWidth; setMeasuredDimension(widthSize, heightSize); return; } } if (resizeHeight) { final var newHeight = widthSize - paddingHorizontal + paddingVertical; if (!resizeWidth) { heightSize = resolveAdjustedSize(newHeight, heightMeasureSpec); } if (newHeight <= heightSize) { heightSize = newHeight; } } setMeasuredDimension(widthSize, heightSize); } private int resolveAdjustedSize(int desiredSize, int measureSpec) { int specMode = MeasureSpec.getMode(measureSpec); int specSize = MeasureSpec.getSize(measureSpec); switch (specMode) { case MeasureSpec.AT_MOST: return Math.min(desiredSize, specSize); case MeasureSpec.EXACTLY: return specSize; case MeasureSpec.UNSPECIFIED: default: return desiredSize; } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/ui/CutoutFactory.java ================================================ package rocks.tbog.tblauncher.ui; import android.annotation.SuppressLint; import android.annotation.TargetApi; import android.app.Activity; import android.content.Context; import android.content.res.Resources; import android.graphics.Rect; import android.os.Build; import android.view.DisplayCutout; import android.view.View; import android.view.WindowInsets; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import java.lang.reflect.Method; import rocks.tbog.tblauncher.utils.DeviceUtils; public class CutoutFactory { @Nullable public static ICutout getByManufacturer(Context context, String manufacturer) { if ("Huawei".equalsIgnoreCase(manufacturer)) return new HuaweiCutout(context); if ("Oppo".equalsIgnoreCase(manufacturer)) return new OppoCutout(context); if ("Vivo".equalsIgnoreCase(manufacturer)) return new VivoCutout(context); if ("Xiaomi".equalsIgnoreCase(manufacturer)) return new XiaomiCutout(context); return null; } @TargetApi(Build.VERSION_CODES.P) @Nullable public static ICutout getForAndroidPie(Activity activity) { WindowInsets windowInsets = activity.getWindow().getDecorView().getRootWindowInsets(); if ( windowInsets == null ) { // getRootWindowInsets() must be called after onAttachedToWindow() return null; } final DisplayCutout displayCutout = windowInsets.getDisplayCutout(); if (displayCutout == null) return null; return new AndroidPCutout(displayCutout); } @NonNull public static ICutout getStatusBar(Context context) { return new StatusBarCutout(context); } @NonNull public static ICutout getNoCutout() { return new NoCutout(); } private static abstract class ComputeSafeZoneFromCutout implements ICutout { @NonNull protected final Context context; ComputeSafeZoneFromCutout(@NonNull Context context) { this.context = context; } @Override public Rect getSafeZone() { Rect safe = new Rect(0, 0, 0, 0); Rect[] cutout = getCutout(); if (cutout.length == 1) { // assume one notch in screen top and phone in portrait safe.top = cutout[0].top + cutout[0].bottom; } // else if (cutout.length > 1) { // throw new RuntimeException(); // not implemented yet. // } return safe; } } // Huawei https://developer.huawei.com/consumer/cn/devservice/doc/50114 private static class HuaweiCutout extends ComputeSafeZoneFromCutout { HuaweiCutout(@NonNull Context context) { super(context); } @Override public boolean hasCutout() { try { ClassLoader classLoader = context.getClassLoader(); Class class_HwNotchSizeUtil = classLoader.loadClass("com.huawei.android.util.HwNotchSizeUtil"); @SuppressWarnings("unchecked") Method method_hasNotchInScreen = class_HwNotchSizeUtil.getMethod("hasNotchInScreen"); return (boolean) method_hasNotchInScreen.invoke(class_HwNotchSizeUtil); } catch (Exception e) { //ignored } return false; } @Override public Rect[] getCutout() { try { ClassLoader classLoader = context.getClassLoader(); Class class_HwNotchSizeUtil = classLoader.loadClass("com.huawei.android.util.HwNotchSizeUtil"); @SuppressWarnings("unchecked") Method method_getNotchSize = class_HwNotchSizeUtil.getMethod("getNotchSize"); int[] size = (int[]) method_getNotchSize.invoke(class_HwNotchSizeUtil); @SuppressWarnings("ConstantConditions") int notchWidth = size[0]; int notchHeight = size[1]; int screenWidth = DeviceUtils.getScreenWidth(context); int x = (screenWidth - notchWidth) >> 1; int y = 0; Rect rect = new Rect(x, y, x + notchWidth, y + notchHeight); return new Rect[]{rect}; } catch (Exception e) { //ignored } return new Rect[0]; } } // Oppo https://open.oppomobile.com/wiki/doc#id=10159 private static class OppoCutout extends ComputeSafeZoneFromCutout { OppoCutout(@NonNull Context context) { super(context); } @Override public boolean hasCutout() { String CutoutFeature = "com.oppo.feature.screen.heteromorphism"; return context.getPackageManager().hasSystemFeature(CutoutFeature); } @Override public Rect[] getCutout() { String value = System.getProperty("ro.oppo.screen.heteromorphism"); @SuppressWarnings("ConstantConditions") String[] texts = value.split("[,:]"); int[] values = new int[texts.length]; try { for (int i = 0; i < texts.length; ++i) values[i] = Integer.parseInt(texts[i]); } catch (NumberFormatException e) { values = null; } if (values != null && values.length == 4) { Rect rect = new Rect(); rect.left = values[0]; rect.top = values[1]; rect.right = values[2]; rect.bottom = values[3]; return new Rect[]{rect}; } return new Rect[0]; } } // Vivo https://dev.vivo.com.cn/documentCenter/doc/145 private static class VivoCutout extends ComputeSafeZoneFromCutout { VivoCutout(@NonNull Context context) { super(context); } @Override public boolean hasCutout() { try { ClassLoader clazz = context.getClassLoader(); @SuppressLint("PrivateApi") Class ftFeature = clazz.loadClass("android.util.FtFeature"); Method[] methods = ftFeature.getDeclaredMethods(); for (Method method : methods) { if (method.getName().equalsIgnoreCase("isFeatureSupport")) { int NOTCH_IN_SCREEN = 0x00000020; // 表示是否有凹槽 int ROUNDED_IN_SCREEN = 0x00000008; // 表示是否有圆角 return (boolean) method.invoke(ftFeature, NOTCH_IN_SCREEN); } } } catch (Exception e) { //ignored } return false; } @Override public Rect[] getCutout() { // throw new RuntimeException(); // not implemented yet. return new Rect[0]; } } // Xiaomi // Oreo: https://dev.mi.com/console/doc/detail?pId=1293 // Pie: https://dev.mi.com/console/doc/detail?pId=1341 private static class XiaomiCutout extends ComputeSafeZoneFromCutout { XiaomiCutout(@NonNull Context context) { super(context); } @Override public boolean hasCutout() { // `getprop ro.miui.notch` output 1 if it's a notch screen. String text = System.getProperty("ro.miui.notch"); return "1".equals(text); } @SuppressWarnings("UnnecessaryLocalVariable") @Override public Rect[] getCutout() { Resources res = context.getResources(); int widthResId = res.getIdentifier("notch_width", "dimen", "android"); int heightResId = res.getIdentifier("notch_height", "dimen", "android"); if (widthResId > 0 && heightResId > 0) { int notchWidth = res.getDimensionPixelSize(widthResId); int notchHeight = res.getDimensionPixelSize(heightResId); // one notch in screen top int screenWidth = DeviceUtils.getScreenWidth(context); int left = (screenWidth - notchWidth) >> 1; int right = left + notchWidth; int top = 0; int bottom = notchHeight; Rect rect = new Rect(left, top, right, bottom); return new Rect[]{rect}; } return new Rect[0]; } } // In case some manufactures' not coming up with a getNotchHeight() method, you can just use the status bar's height. Android has guarantee that notch height is at most the status bar height. public static class StatusBarCutout extends ComputeSafeZoneFromCutout { StatusBarCutout(@NonNull Context context) { super(context); } public static int getStatusBarHeight(Context context) { int statusBarHeight = 0; Resources res = context.getResources(); int resourceId = res.getIdentifier("status_bar_height", "dimen", "android"); if (resourceId > 0) { statusBarHeight = res.getDimensionPixelSize(resourceId); } return statusBarHeight; } @Override public boolean hasCutout() { return true; } @Override public Rect[] getCutout() { return new Rect[]{new Rect(0, 0, 0, getStatusBarHeight(context))}; } } // Android P cutout @TargetApi(Build.VERSION_CODES.P) private static class AndroidPCutout implements ICutout { @NonNull final Rect safeInset; AndroidPCutout(@NonNull DisplayCutout displayCutout) { int left = displayCutout.getSafeInsetLeft(); int top = displayCutout.getSafeInsetTop(); int right = displayCutout.getSafeInsetRight(); int bottom = displayCutout.getSafeInsetBottom(); safeInset = new Rect(left, top, right, bottom); } @Override public boolean hasCutout() { return safeInset.top != 0 || safeInset.bottom != 0 || safeInset.left != 0 || safeInset.right != 0; } @Override public Rect[] getCutout() { // throw new RuntimeException(); // not implemented yet. Should return displayCutout.getBoundingRectsAll() return new Rect[0]; } @Override public Rect getSafeZone() { return safeInset; } } private static class NoCutout implements ICutout { @Override public boolean hasCutout() { return false; } @Override public Rect[] getCutout() { return new Rect[0]; } @Override public Rect getSafeZone() { return new Rect(0, 0, 0, 0); } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/ui/DialogFragment.java ================================================ package rocks.tbog.tblauncher.ui; import android.app.Dialog; import android.content.Context; import android.content.DialogInterface; import android.os.Build; import android.os.Bundle; import android.util.Log; import android.util.TypedValue; import android.view.ContextThemeWrapper; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.view.Window; import android.view.WindowManager; import android.widget.TextView; import androidx.annotation.IdRes; import androidx.annotation.LayoutRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.utils.UITheme; public abstract class DialogFragment extends androidx.fragment.app.DialogFragment { private static final String TAG = "DialogFrag"; private OnDismissListener mOnDismissListener = null; private OnConfirmListener mOnConfirmListener = null; private OnButtonClickListener mOnPositiveClickListener = null; private OnButtonClickListener mOnNeutralClickListener = null; private OnButtonClickListener mOnNegativeClickListener = null; public enum Button { POSITIVE, NEGATIVE, NEUTRAL } @LayoutRes protected abstract int layoutRes(); public interface OnDismissListener { void onDismiss(@NonNull DialogFragment dialog); } public interface OnConfirmListener { void onConfirm(@Nullable T output); } public interface OnButtonClickListener { void onButtonClick(@NonNull DialogFragment dialog, @NonNull Button button); } public void setOnDismissListener(OnDismissListener listener) { mOnDismissListener = listener; } public void setOnConfirmListener(OnConfirmListener listener) { mOnConfirmListener = listener; } public void setOnPositiveClickListener(OnButtonClickListener listener) { mOnPositiveClickListener = listener; } public void setOnNegativeClickListener(OnButtonClickListener listener) { mOnNegativeClickListener = listener; } public void setOnNeutralClickListener(OnButtonClickListener listener) { mOnNeutralClickListener = listener; } public DialogFragment putArgString(@Nullable String key, @Nullable String value) { Bundle args = getArguments(); if (args == null) args = new Bundle(); args.putString(key, value); setArguments(args); return this; } public DialogFragment putArgLong(@Nullable String key, long value) { Bundle args = getArguments(); if (args == null) args = new Bundle(); args.putLong(key, value); setArguments(args); return this; } public DialogFragment putArgInt(@Nullable String key, int value) { Bundle args = getArguments(); if (args == null) args = new Bundle(); args.putInt(key, value); setArguments(args); return this; } @Override public void onDismiss(@NonNull DialogInterface dialog) { if (mOnDismissListener != null) mOnDismissListener.onDismiss(this); super.onDismiss(dialog); } public void onConfirm(@Nullable Output output) { if (mOnConfirmListener != null) mOnConfirmListener.onConfirm(output); } public void onButtonClick(@NonNull Button button) { switch (button) { case POSITIVE: if (mOnPositiveClickListener != null) mOnPositiveClickListener.onButtonClick(this, button); break; case NEGATIVE: if (mOnNegativeClickListener != null) mOnNegativeClickListener.onButtonClick(this, button); break; case NEUTRAL: default: if (mOnNeutralClickListener != null) mOnNeutralClickListener.onButtonClick(this, button); break; } dismiss(); } @Override public void onCreate(@Nullable Bundle savedInstanceState) { //Log.i(TAG, "---> onCreate <---"); super.onCreate(savedInstanceState); int theme = UITheme.getDialogTheme(requireContext()); if (theme == UITheme.ID_NULL) theme = R.style.NoTitleDialogTheme; setStyle(DialogFragment.STYLE_NO_FRAME, theme); //Log.i(TAG, "theme=" + getTheme()); //Log.i(TAG, "context=" + getContext()); } @NonNull @Override public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { //Log.i(TAG, "---> onCreateDialog <---"); Context themeWrapper = new ContextThemeWrapper(requireContext(), getTheme()); TypedValue outValue = new TypedValue(); themeWrapper.getTheme().resolveAttribute(com.google.android.material.R.attr.alertDialogTheme, outValue, true); int dialogStyle = outValue.resourceId; DialogWrapper dialog = new DialogWrapper(themeWrapper, dialogStyle); //Log.i(TAG, "dialog=" + dialog); Log.i(TAG, "dialog.context=" + dialog.getContext()); return dialog; } @NonNull public View inflateLayoutRes(@NonNull LayoutInflater inflater, @Nullable ViewGroup container) { //Log.i(TAG, "context=" + getContext()); //Log.i(TAG, "dialog.context=" + dialog.getContext()); Log.i(TAG, "inflater.context=" + inflater.getContext()); return inflater.inflate(layoutRes(), container, false); } @Nullable @Override public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { //Log.i(TAG, "---> onCreateView <---"); Dialog dialog = requireDialog(); //Log.i(TAG, "dialog=" + dialog); Window window = dialog.getWindow(); if (window != null) { window.requestFeature(Window.FEATURE_NO_TITLE); window.addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND); window.setDimAmount(0.7f); } dialog.setCanceledOnTouchOutside(true); View view = inflateLayoutRes(inflater, container); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { view.setClipToOutline(true); } createButtonBar(view, inflater); return view; } protected void setupDefaultButtonOkCancel(Context context) { Bundle args = getArguments() != null ? getArguments() : new Bundle(); if (!isStateSaved()) { args.putCharSequence("btnPositiveText", context.getText(android.R.string.ok)); args.putCharSequence("btnNegativeText", context.getText(android.R.string.cancel)); setArguments(args); } } protected void setupDefaultButtonOk(Context context) { Bundle args = getArguments() != null ? getArguments() : new Bundle(); if (!isStateSaved()) { args.putCharSequence("btnPositiveText", context.getText(android.R.string.ok)); setArguments(args); } } private void createButtonBar(View view, LayoutInflater inflater) { Bundle args = getArguments(); if (args == null) return; CharSequence btnPositiveText = args.getCharSequence("btnPositiveText", ""); CharSequence btnNegativeText = args.getCharSequence("btnNegativeText", ""); CharSequence btnNeutralText = args.getCharSequence("btnNeutralText", ""); if (btnPositiveText.length() == 0 && btnNegativeText.length() == 0 && btnNeutralText.length() == 0) return; View buttonPanel = resolvePanel(view, inflater); if (buttonPanel == null) { Log.e(TAG, "failed to inflate button bar"); return; } TextView button1 = buttonPanel.findViewById(android.R.id.button1); TextView button2 = buttonPanel.findViewById(android.R.id.button2); TextView button3 = buttonPanel.findViewById(android.R.id.button3); if (btnPositiveText.length() == 0) { button1.setVisibility(View.GONE); } else { button1.setVisibility(View.VISIBLE); button1.setText(btnPositiveText); button1.setOnClickListener(v -> onButtonClick(Button.POSITIVE)); } if (btnNegativeText.length() == 0) { button2.setVisibility(View.GONE); } else { button2.setVisibility(View.VISIBLE); button2.setText(btnNegativeText); button2.setOnClickListener(v -> onButtonClick(Button.NEGATIVE)); } if (btnNeutralText.length() == 0) { button3.setVisibility(View.GONE); View spacer = buttonPanel.findViewById(R.id.spacer); if (spacer != null) spacer.setVisibility(View.GONE); } else { button3.setVisibility(View.VISIBLE); button3.setText(btnNeutralText); button3.setOnClickListener(v -> onButtonClick(Button.NEUTRAL)); } } @Nullable private ViewGroup resolvePanel(@NonNull View view, @NonNull LayoutInflater inflater) { View buttonPanel = view.findViewById(R.id.buttonPanel); if (buttonPanel instanceof ViewGroup) return (ViewGroup) buttonPanel; if (view instanceof ViewGroup) { buttonPanel = inflater.inflate(R.layout.ok_cancel_button_bar, (ViewGroup) view, false); ((ViewGroup) view).addView(buttonPanel); return (ViewGroup) buttonPanel; } return null; } @Nullable public T findViewById(@IdRes int id) { return requireDialog().findViewById(id); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/ui/DialogWrapper.java ================================================ package rocks.tbog.tblauncher.ui; import android.content.Context; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatDialog; public class DialogWrapper extends AppCompatDialog { private OnWindowFocusChanged mOnWindowFocusChanged = null; public interface OnWindowFocusChanged { void onWindowFocusChanged(@NonNull DialogWrapper dialog, boolean hasFocus); } public DialogWrapper(Context context) { super(context); } public DialogWrapper(Context context, int theme) { super(context, theme); } protected DialogWrapper(Context context, boolean cancelable, OnCancelListener cancelListener) { super(context, cancelable, cancelListener); } public void setOnWindowFocusChanged(@Nullable OnWindowFocusChanged callback) { mOnWindowFocusChanged = callback; } @Override public void onWindowFocusChanged(boolean hasFocus) { super.onWindowFocusChanged(hasFocus); if (mOnWindowFocusChanged != null) mOnWindowFocusChanged.onWindowFocusChanged(this, hasFocus); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/ui/ICutout.java ================================================ package rocks.tbog.tblauncher.ui; import android.graphics.Rect; public interface ICutout { boolean hasCutout(); Rect[] getCutout(); Rect getSafeZone(); } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/ui/KeyboardHandler.java ================================================ package rocks.tbog.tblauncher.ui; public interface KeyboardHandler { void showKeyboard(); void hideKeyboard(); } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/ui/LinearAdapter.java ================================================ package rocks.tbog.tblauncher.ui; import android.annotation.SuppressLint; import android.content.Context; import android.graphics.PorterDuff; import android.graphics.PorterDuffColorFilter; import android.graphics.drawable.Drawable; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.BaseAdapter; import android.widget.TextView; import androidx.annotation.LayoutRes; import androidx.annotation.NonNull; import androidx.annotation.StringRes; import androidx.appcompat.content.res.AppCompatResources; import java.util.ArrayList; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.utils.UIColors; /** * Adapter used to inflate views in a LinearLayout * WARNING: don't use this adapter for long lists, it does not recycle views */ public class LinearAdapter extends BaseAdapter { final ArrayList list = new ArrayList<>(0); public interface MenuItem { @LayoutRes int getLayoutResource(); boolean isEnabled(); } public static class ItemDivider implements MenuItem { @Override public int getLayoutResource() { return R.layout.popup_divider; } @Override public boolean isEnabled() { return false; } } public static class ItemTitle implements MenuItem { @NonNull final String name; public ItemTitle(Context context, @StringRes int nameRes) { this.name = context.getString(nameRes); } public ItemTitle(@NonNull String string) { this.name = string; } @NonNull @Override public String toString() { return name; } @Override public int getLayoutResource() { return R.layout.popup_title; } @Override public boolean isEnabled() { return false; } } public static class ItemString implements MenuItem { @NonNull final String string; public ItemString(@NonNull String string) { this.string = string; } @NonNull @Override public String toString() { return string; } @Override public int getLayoutResource() { return R.layout.popup_list_item; } @Override public boolean isEnabled() { return true; } } public static class ItemText extends ItemString { public ItemText(@NonNull String string) { super(string); } @Override public int getLayoutResource() { return R.layout.popup_list_text; } @Override public boolean isEnabled() { return false; } } public static class Item extends ItemString { @StringRes public final int stringId; public Item(Context context, @StringRes int stringId) { super(context.getResources().getString(stringId)); this.stringId = stringId; } // public Item(@NonNull String string) { // super(string); // this.stringId = 0; // } } @Override public int getCount() { return list.size(); } @Override public MenuItem getItem(int position) { return list.get(position); } @Override public long getItemId(int position) { return position; } @SuppressLint("ViewHolder") @Override public View getView(int position, View convertView, ViewGroup parent) { MenuItem item = getItem(position); convertView = LayoutInflater.from(parent.getContext()).inflate(item.getLayoutResource(), parent, false); // set color of the divider View divider = convertView.findViewById(R.id.title_divider); if (divider != null) { Context ctx = divider.getContext(); int color = UIColors.getPopupBorderColor(ctx); int background = UIColors.getPopupBackgroundColor(ctx); int separator = UIColors.isColorLight(background) ? R.drawable.list_separator_dark : R.drawable.list_separator_light; Drawable drawable = AppCompatResources.getDrawable(ctx, separator); if (drawable == null) drawable = divider.getBackground(); if (drawable != null) { drawable.setColorFilter(new PorterDuffColorFilter(color, PorterDuff.Mode.MULTIPLY)); divider.setBackground(drawable); } } if (item instanceof ItemDivider) { return convertView; } bindView(convertView, item); return convertView; } protected void bindView(View convertView, MenuItem item) { String text = item.toString(); TextView textView = convertView.findViewById(android.R.id.text1); textView.setText(text); } public void add(MenuItem item) { list.add(item); notifyDataSetChanged(); } public void add(int index, MenuItem item) { list.add(index, item); notifyDataSetChanged(); } @Override public boolean areAllItemsEnabled() { return false; } @Override public boolean isEnabled(int position) { MenuItem item = list.get(position); return item.isEnabled(); } public void remove(MenuItem item) { list.remove(item); notifyDataSetChanged(); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/ui/LinearAdapterPlus.java ================================================ package rocks.tbog.tblauncher.ui; import android.graphics.drawable.Drawable; import android.view.View; import android.widget.ImageView; import androidx.annotation.NonNull; /** * Add callbacks to the LinearAdapter */ public class LinearAdapterPlus extends LinearAdapter { public interface BindCallback { /** * @param view the view to bind with * @return true if super.bindView should not be called */ boolean bindView(View view); } public static class ItemStringIcon extends ItemString implements BindCallback { @NonNull final Drawable icon; public ItemStringIcon(@NonNull String string, @NonNull Drawable icon) { super(string); this.icon = icon; } @Override public int getLayoutResource() { return android.R.layout.activity_list_item; } @Override public boolean bindView(View view) { ImageView image = view.findViewById(android.R.id.icon); image.setImageDrawable(icon); return false; } } @Override protected void bindView(View view, MenuItem item) { if (item instanceof BindCallback && ((BindCallback) item).bindView(view)) return; super.bindView(view, item); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/ui/ListPopup.java ================================================ package rocks.tbog.tblauncher.ui; import android.content.Context; import android.database.DataSetObserver; import android.graphics.Rect; import android.graphics.drawable.Drawable; import android.os.Build; import android.util.Log; import android.view.ContextThemeWrapper; import android.view.Gravity; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import android.view.WindowManager; import android.widget.LinearLayout; import android.widget.ListAdapter; import android.widget.PopupWindow; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.StringRes; import rocks.tbog.tblauncher.CustomizeUI; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.utils.UIColors; import rocks.tbog.tblauncher.utils.UISizes; import rocks.tbog.tblauncher.utils.UITheme; public class ListPopup extends PopupWindow { private static final String TAG = "Popup"; private final Rect mTempRect = new Rect(); private final int[] mTempLocation = new int[2]; private OnItemLongClickListener mItemLongClickListener = null; private OnItemClickListener mItemClickListener = null; private DataSetObserver mObserver = null; private ListAdapter mAdapter = null; private boolean dismissOnClick = true; private float dimAmount = .7f; private boolean mIsModal = false; // send all touch events to this window public static ListPopup create(@NonNull Context context, ListAdapter adapter) { Log.d(TAG, "initial context=" + context); ContextThemeWrapper ctx = new ContextThemeWrapper(context, R.style.ListPopupTheme); ListPopup popup = new ListPopup(ctx); View root = popup.getContentView().getRootView(); Drawable background = CustomizeUI.getPopupBackgroundDrawable(context); root.setBackground(background); int padding = UISizes.dp2px(context, 1); root.setPadding(padding, padding, padding, padding); ((ViewGroup) root).setClipToPadding(true); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { root.setClipToOutline(true); } CustomizeUI.setListViewScrollbarPref(popup.getContentView(), UIColors.getPopupRipple(ctx)); popup.setAdapter(adapter); return popup; } private ListPopup(@NonNull Context context) { super(context, null, android.R.attr.popupMenuStyle); ScrollView scrollView = new ScrollView(context); LinearLayout layout = new LinearLayout(context); layout.setId(R.id.root_layout); layout.setOrientation(LinearLayout.VERTICAL); scrollView.addView(layout); setContentView(scrollView); setWidth(ViewGroup.LayoutParams.WRAP_CONTENT); setHeight(ViewGroup.LayoutParams.WRAP_CONTENT); // set transparent window background setBackgroundDrawable(null); } public ListPopup setOnItemClickListener(OnItemClickListener onItemClickListener) { mItemClickListener = onItemClickListener; return this; } public ListPopup setOnItemLongClickListener(OnItemLongClickListener onItemLongClickListener) { mItemLongClickListener = onItemLongClickListener; return this; } public ListAdapter getAdapter() { return mAdapter; } public ListPopup setDismissOnItemClick(boolean dismissOnClick) { this.dismissOnClick = dismissOnClick; return this; } public boolean isInsideViewBounds(int x, int y) { final int[] pos = mTempLocation; final Rect rect = mTempRect; View rootView = getContentView().getRootView(); rootView.getDrawingRect(rect); rootView.getLocationOnScreen(pos); rect.offset(pos[0], pos[1]); return rect.contains(x, y); } /** * Set background dim amount (0=no dim, 1=full dim) * * @param dimAmount a value of 0 or less to disable dimming */ public ListPopup setDimAmount(float dimAmount) { this.dimAmount = dimAmount; return this; } /** * Sets the adapter that provides the data and the views to represent the data * in this popup window. * * @param adapter The adapter to use to create this window's content. */ public void setAdapter(ListAdapter adapter) { if (mObserver == null) { mObserver = new PopupDataSetObserver(); } else if (mAdapter != null) { mAdapter.unregisterDataSetObserver(mObserver); } mAdapter = adapter; if (mAdapter != null) { adapter.registerDataSetObserver(mObserver); } } private LinearLayout getLinearLayout() { return getContentView().findViewById(R.id.root_layout); //return (LinearLayout) ((ScrollView) getContentView()).getChildAt(0); } private void updateItems() { LinearLayout layout = getLinearLayout(); Context ctx = layout.getContext(); int selectorColor = UIColors.getPopupRipple(ctx); int textColor = UIColors.getPopupTextColor(ctx); int titleColor = UIColors.getPopupTitleColor(ctx); layout.removeAllViews(); int adapterCount = mAdapter.getCount(); for (int i = 0; i < adapterCount; i += 1) { View view = mAdapter.getView(i, null, layout); // apply selector background view.setBackground(CustomizeUI.getSelectorDrawable(view, selectorColor, false)); setTextColorRecursive(view, mAdapter.isEnabled(i) ? textColor : titleColor); layout.addView(view); if (mAdapter.isEnabled(i)) { view.setOnClickListener(this::onItemClicked); if (mItemLongClickListener == null) { view.setLongClickable(false); } else { view.setOnLongClickListener(this::onItemLongClicked); } } } layout.forceLayout(); } /** * Set the text color of the first TextView * * @param view TextView to set color of or GroupView to search * @param color text color * @return if color applied */ private static boolean setTextColorRecursive(View view, int color) { if (view instanceof TextView) { TextView textView = (TextView) view; textView.setTextColor(color); UITheme.applyPopupTextShadow(textView); return true; } else if (view instanceof ViewGroup) { int childCount = ((ViewGroup) view).getChildCount(); for (int childIdx = 0; childIdx < childCount; childIdx += 1) { View child = ((ViewGroup) view).getChildAt(childIdx); if (setTextColorRecursive(child, color)) return true; } } return false; } private void onItemClicked(View view) { if (mItemClickListener != null) { LinearLayout layout1 = getLinearLayout(); int position = layout1.indexOfChild(view); mItemClickListener.onItemClick(mAdapter, view, position); } if (dismissOnClick) dismiss(); } private boolean onItemLongClicked(View view) { if (mItemLongClickListener == null) return false; LinearLayout layout12 = getLinearLayout(); int position = layout12.indexOfChild(view); return mItemLongClickListener.onItemLongClick(mAdapter, view, position); } private void beforeShow() { updateItems(); if (mIsModal) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { setTouchModal(true); } setOutsideTouchable(false); setFocusable(true); } else { // don't steal the focus, this will prevent the keyboard from changing setFocusable(false); } // draw over stuff if needed setClippingEnabled(false); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { // we already take this into account setOverlapAnchor(false); } } public void showCenter(@NonNull View viewForWindowToken) { showAtLocation(viewForWindowToken, Gravity.CENTER, 0, 0); } public void show(@NonNull View anchor) { show(anchor, .5f); } public void show(@NonNull View anchor, float anchorOverlap) { beforeShow(); final Rect displayFrame = mTempRect; anchor.getWindowVisibleDisplayFrame(displayFrame); final int[] anchorPos = mTempLocation; anchor.getLocationInWindow(anchorPos); //anchor.getLocationOnScreen(anchorPos); final int distanceToBottom = displayFrame.bottom - (anchorPos[1] + anchor.getHeight()); final int distanceToTop = anchorPos[1] - displayFrame.top; View rootView = getContentView().getRootView(); rootView.forceLayout(); rootView.measure(View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED), View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)); int xOffset = anchorPos[0] + anchor.getPaddingLeft(); if (xOffset + rootView.getMeasuredWidth() > displayFrame.right) xOffset = displayFrame.right - rootView.getMeasuredWidth(); int overlapAmount = (int) (anchor.getHeight() * anchorOverlap); int yOffset; if (distanceToBottom > rootView.getMeasuredHeight()) { // show below anchor yOffset = anchorPos[1] + overlapAmount; setAnimationStyle(R.style.PopupAnimationTop); } else if (distanceToTop > distanceToBottom) { // show above anchor yOffset = anchorPos[1] + overlapAmount - rootView.getMeasuredHeight(); setAnimationStyle(R.style.PopupAnimationBottom); if (distanceToTop < rootView.getMeasuredHeight()) { yOffset += rootView.getMeasuredHeight() - distanceToTop - overlapAmount; } } else { // show below anchor with scroll yOffset = anchorPos[1] + overlapAmount; setAnimationStyle(R.style.PopupAnimationTop); } final int width = rootView.getMeasuredWidth(); final int height = rootView.getMeasuredHeight(); int[] offset = setSizeAndPosition(displayFrame, xOffset, yOffset, width, height); super.showAtLocation(anchor, Gravity.START | Gravity.TOP, offset[0], offset[1]); applyDim(); } @Override public void showAsDropDown(View anchor, int xoff, int yoff, int gravity) { //TODO: this is just a placeholder, implement if used show(anchor); } @Override public void showAtLocation(View parent, int gravity, int x, int y) { beforeShow(); final Rect displayFrame = mTempRect; parent.getWindowVisibleDisplayFrame(displayFrame); int[] offset = setSizeAndPosition(displayFrame, x, y); if (y - offset[1] > getHeight() / 2) setAnimationStyle(R.style.PopupAnimationBottom); else setAnimationStyle(R.style.PopupAnimationTop); super.showAtLocation(parent, gravity, offset[0], offset[1]); applyDim(); } private int[] setSizeAndPosition(Rect displayFrame, int xOffset, int yOffset) { View rootView = getContentView().getRootView(); rootView.forceLayout(); rootView.measure(View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED), View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)); int width = rootView.getMeasuredWidth(); int height = rootView.getMeasuredHeight(); return setSizeAndPosition(displayFrame, xOffset, yOffset, width, height); } /** * set size and recompute offset * * @param displayFrame display bounds * @param xOffset x offset * @param yOffset y offset * @param width measured width of root view * @param height measured height of root view * @return new offset returned using mTempLocation */ private int[] setSizeAndPosition(Rect displayFrame, int xOffset, int yOffset, int width, int height) { if (xOffset + width > displayFrame.right) xOffset = displayFrame.right - width; if (yOffset + height > displayFrame.bottom) yOffset = displayFrame.bottom - height; if (xOffset < displayFrame.left) { xOffset = displayFrame.left; // may enable scroll width = Math.min(width, displayFrame.width()); } if (yOffset < displayFrame.top) { yOffset = displayFrame.top; // may enable scroll height = Math.min(height, displayFrame.height()); } setWidth(width); setHeight(height); mTempLocation[0] = xOffset; mTempLocation[1] = yOffset; return mTempLocation; } /** * Must be called after calling show* */ private void applyDim() { if (dimAmount > 0.f) { View container = getContentView().getRootView(); WindowManager.LayoutParams p = (WindowManager.LayoutParams) container.getLayoutParams(); // add flag p.flags |= WindowManager.LayoutParams.FLAG_DIM_BEHIND; p.dimAmount = 0.7f; WindowManager wm = (WindowManager) container.getContext().getSystemService(Context.WINDOW_SERVICE); assert wm != null; wm.updateViewLayout(container, p); } } public ListPopup setModal(boolean modal) { mIsModal = modal; return this; } public interface OnItemClickListener { void onItemClick(ListAdapter adapter, View view, int position); } public interface OnItemLongClickListener { boolean onItemLongClick(ListAdapter adapter, View view, int position); } /** * Use `Item` for fast prototyping in an `ArrayAdapter` */ public static class Item { @StringRes public final int stringId; final String string; public Item(Context context, @StringRes int stringId) { super(); this.stringId = stringId; this.string = context.getResources() .getString(stringId); } public Item(String string) { super(); this.stringId = 0; this.string = string; } @NonNull @Override public String toString() { return this.string; } } protected class ScrollView extends android.widget.ScrollView { public ScrollView(Context context) { super(context); } @Override public boolean dispatchTouchEvent(MotionEvent event) { // act as a modal, if we click outside dismiss the popup final int x = (int) event.getX(); final int y = (int) event.getY(); if ((event.getAction() == MotionEvent.ACTION_DOWN) && ((x < 0) || (x >= getWidth()) || (y < 0) || (y >= getHeight()))) { dismiss(); return true; } else if (event.getAction() == MotionEvent.ACTION_OUTSIDE) { dismiss(); return true; } return super.dispatchTouchEvent(event); } } private class PopupDataSetObserver extends DataSetObserver { @Override public void onChanged() { if (isShowing()) { // Resize the popup to fit new content updateItems(); update(); } } @Override public void onInvalidated() { dismiss(); } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/ui/RecyclerList.java ================================================ package rocks.tbog.tblauncher.ui; import android.content.Context; import android.util.AttributeSet; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.recyclerview.widget.RecyclerView; import rocks.tbog.tblauncher.result.ReversibleAdapterRecyclerLayoutManager; public class RecyclerList extends RecyclerView { private static final String TAG = "list"; public RecyclerList(@NonNull Context context) { this(context, null); } public RecyclerList(@NonNull Context context, @Nullable AttributeSet attrs) { super(context, attrs); } public RecyclerList(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); } public int getAdapterFirstItemIdx() { if (getLayoutManager() instanceof ReversibleAdapterRecyclerLayoutManager) { ReversibleAdapterRecyclerLayoutManager lm = (ReversibleAdapterRecyclerLayoutManager) getLayoutManager(); return lm.isReverseAdapter() ? (getLayoutManager().getItemCount() - 1) : 0; } return 0; } public void scrollToFirstItem() { final int adapterPos = getAdapterFirstItemIdx(); if (adapterPos >= 0) { Log.d(TAG, "scrollToPosition( " + adapterPos + " )"); scrollToPosition(adapterPos); } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/ui/SearchEditText.java ================================================ package rocks.tbog.tblauncher.ui; import android.content.Context; import android.util.AttributeSet; import android.view.DragEvent; import android.view.KeyEvent; import androidx.appcompat.widget.AppCompatEditText; public class SearchEditText extends AppCompatEditText { private OnEditorActionListener mEditorListener; public SearchEditText(Context context) { super(context); } public SearchEditText(Context context, AttributeSet attrs) { super(context, attrs); } public SearchEditText(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); } @Override public void setOnEditorActionListener(OnEditorActionListener listener) { mEditorListener = listener; super.setOnEditorActionListener(listener); } @Override public boolean onKeyPreIme(int keyCode, KeyEvent event) { if (event.getKeyCode() == KeyEvent.KEYCODE_BACK && event.getAction() == KeyEvent.ACTION_UP) if (mEditorListener != null && mEditorListener.onEditorAction(this, android.R.id.closeButton, event)) return true; return super.onKeyPreIme(keyCode, event); } @Override public boolean onDragEvent(DragEvent event) { // Fixes bug when dropping onto a textEdit widget which can cause a NPE // This fix should be on ALL TextEdit Widgets !!! // See : https://stackoverflow.com/a/23483957 return true; } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/ui/SquareImageView.java ================================================ package rocks.tbog.tblauncher.ui; import android.content.Context; import android.util.AttributeSet; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; public class SquareImageView extends androidx.appcompat.widget.AppCompatImageView { private static final String TAG = "SqImgView"; protected int mComputedSize = -1; //protected boolean mRequestLayout = false; public SquareImageView(@NonNull Context context) { super(context); } public SquareImageView(@NonNull Context context, @Nullable AttributeSet attrs) { super(context, attrs); } public SquareImageView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); } interface ToString { @NonNull String fromMode(T input); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int size; int widthMode = MeasureSpec.getMode(widthMeasureSpec); int heightMode = MeasureSpec.getMode(heightMeasureSpec); int widthSize = MeasureSpec.getSize(widthMeasureSpec); int heightSize = MeasureSpec.getSize(heightMeasureSpec); ToString toString = input -> { switch (MeasureSpec.getMode(input)) { case MeasureSpec.UNSPECIFIED: return "UNSPECIFIED"; case MeasureSpec.EXACTLY: return "EXACTLY"; case MeasureSpec.AT_MOST: return "AT_MOST"; default: return Integer.toString(input); } }; // Log.v(TAG, Integer.toHexString(System.identityHashCode(this)) // + " measured=" + getMeasuredWidth() + "x" + getMeasuredHeight() // + "\n\t" // + " widthMode=" + toString.fromMode(widthMode) // + " widthSize=" + widthSize // + "\n\t" // + " heightMode=" + toString.fromMode(heightMode) // + " heightSize=" + heightSize // ); if (widthMode == MeasureSpec.EXACTLY && widthSize > 0) { size = widthSize; mComputedSize = size; } else if (heightMode == MeasureSpec.EXACTLY && heightSize > 0) { size = heightSize; mComputedSize = size; } else if (heightMode == MeasureSpec.AT_MOST && widthMode == MeasureSpec.UNSPECIFIED) { if (mComputedSize > 0) { mComputedSize = Math.min(mComputedSize, heightSize); size = mComputedSize; } else { size = heightSize; } } else if (widthMode == MeasureSpec.AT_MOST && heightMode == MeasureSpec.UNSPECIFIED) { if (mComputedSize > 0) { mComputedSize = Math.min(widthSize, mComputedSize); size = mComputedSize; } else { size = widthSize; } } else { int minSize = Math.min(widthSize, heightSize); mComputedSize = Math.min(mComputedSize, minSize); size = mComputedSize > 0 ? mComputedSize : minSize; } final int widthSizeAndState = resolveSizeAndState(size, widthMeasureSpec, 0); final int heightSizeAndState = resolveSizeAndState(size, heightMeasureSpec, 0); widthSize = widthSizeAndState & MEASURED_SIZE_MASK; heightSize = heightSizeAndState & MEASURED_SIZE_MASK; if ((widthSizeAndState & MEASURED_STATE_TOO_SMALL) != 0 || (heightSizeAndState & MEASURED_STATE_TOO_SMALL) != 0) { Log.d(TAG, Integer.toHexString(System.identityHashCode(this)) + " mark for re-layout" + " | too small " + widthSize + "×" + heightSize + " | size=" + size); setMeasuredDimension(widthSizeAndState, heightSizeAndState); post(this::requestLayout); return; } int finalWidthSpec; int finalHeightSpec; if (mComputedSize > 0) { finalWidthSpec = MeasureSpec.makeMeasureSpec(widthSize, MeasureSpec.EXACTLY); finalHeightSpec = MeasureSpec.makeMeasureSpec(heightSize, MeasureSpec.EXACTLY); } else { Log.d(TAG, Integer.toHexString(System.identityHashCode(this)) + " AT_MOST " + widthSize + "×" + heightSize + " | size=" + size); finalWidthSpec = MeasureSpec.makeMeasureSpec(widthSize, MeasureSpec.AT_MOST); finalHeightSpec = MeasureSpec.makeMeasureSpec(heightSize, MeasureSpec.AT_MOST); } // let super method call `setMeasuredDimension` super.onMeasure(finalWidthSpec, finalHeightSpec); if (mComputedSize > 0 && (getMeasuredWidth() != size || getMeasuredHeight() != size)) { Log.d(TAG, Integer.toHexString(System.identityHashCode(this)) + " mark for re-layout" + " | measured at " + getMeasuredWidth() + "×" + getMeasuredHeight() + " | " + toString.fromMode(finalWidthSpec) + " " + widthSize + "×" + heightSize + " | size=" + size); post(this::requestLayout); } } @Override protected void onSizeChanged(int w, int h, int oldWidth, int oldHeight) { if (oldWidth == oldHeight && oldWidth == mComputedSize) { Log.d(TAG, Integer.toHexString(System.identityHashCode(this)) + " onSizeChanged to " + w + "×" + h + " from " + oldWidth + "×" + oldHeight + " | reset computed size"); // reset computed size mComputedSize = -1; } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/ui/TagsMenuUtils.java ================================================ package rocks.tbog.tblauncher.ui; import static rocks.tbog.tblauncher.entry.EntryItem.LAUNCHED_FROM_GESTURE; import android.annotation.SuppressLint; import android.content.Context; import android.graphics.drawable.Drawable; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.BaseAdapter; import android.widget.TextView; import androidx.annotation.NonNull; import java.util.ArrayList; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.dataprovider.TagsProvider; import rocks.tbog.tblauncher.entry.ActionEntry; import rocks.tbog.tblauncher.entry.EntryItem; import rocks.tbog.tblauncher.entry.StaticEntry; import rocks.tbog.tblauncher.entry.TagEntry; import rocks.tbog.tblauncher.searcher.TagSearcher; import rocks.tbog.tblauncher.utils.PrefCache; import rocks.tbog.tblauncher.utils.UISizes; import rocks.tbog.tblauncher.utils.Utilities; public class TagsMenuUtils { public static ListPopup createTagsMenu(Context ctx, Iterable tagNames) { TagsProvider tagsProvider = TBApplication.dataHandler(ctx).getTagsProvider(); MenuTagAdapter adapter = new MenuTagAdapter(); for (String tagName : tagNames) { TagEntry tagEntry = tagsProvider != null ? tagsProvider.getTagEntry(tagName) : null; MenuTagAdapter.MenuItem menuItem = tagEntry != null ? new MenuTagAdapter.MenuItem(tagEntry) : new MenuTagAdapter.MenuItem(tagName); adapter.addItem(menuItem); } EntryItem untaggedEntry; boolean bAddUntagged = PrefCache.showTagsMenuUntagged(ctx); if (bAddUntagged) { untaggedEntry = TBApplication.dataHandler(ctx).getPojo(ActionEntry.SCHEME + "show/untagged"); if (untaggedEntry instanceof ActionEntry) { int idx = PrefCache.getTagsMenuUntaggedIndex(ctx); if (idx > adapter.getCount()) idx = adapter.getCount(); adapter.addItem(idx, new MenuTagAdapter.MenuItem((ActionEntry) untaggedEntry)); } } return ListPopup.create(ctx, adapter) .setOnItemClickListener((a, v, pos) -> { MenuTagAdapter.MenuItem item = (MenuTagAdapter.MenuItem) a.getItem(pos); if (item == null) return; if (item.staticEntry != null) { item.staticEntry.doLaunch(v, LAUNCHED_FROM_GESTURE); return; } TBApplication.quickList(ctx).toggleSearch(v, item.toString(), TagSearcher.class); }); } private static class MenuTagAdapter extends BaseAdapter { private final ArrayList mList = new ArrayList<>(); private static class MenuItem { final String text; final private StaticEntry staticEntry; public MenuItem(@NonNull String tagName) { text = tagName; staticEntry = null; } public MenuItem(@NonNull StaticEntry entry) { text = entry.getName(); staticEntry = entry; } @NonNull @Override public String toString() { return text; } public void setIcon(TextView textView) { if (staticEntry == null) { // this is not likely to happen return; } Context ctx = textView.getContext(); if (!PrefCache.showTagsMenuIcons(ctx)) return; // make sure we have enough space to inline the drawable final int size = UISizes.getTagsMenuIconSize(ctx); Drawable loadingIcon = PrefCache.getLoadingIconDrawable(ctx); loadingIcon.setBounds(0, 0, size, size); textView.setCompoundDrawables(loadingIcon, null, null, null); textView.setCompoundDrawablePadding(UISizes.sp2px(ctx, 2)); Utilities.startAnimatable(textView); // async load and show the icon Utilities.setViewAsync(textView, staticEntry::getIconDrawable, (view, drawable) -> { if (view instanceof TextView) { drawable.setBounds(0, 0, size, size); TextView v = (TextView) view; v.setCompoundDrawables(drawable, null, null, null); } }); } } public MenuTagAdapter() { super(); } public void addItem(MenuItem item) { mList.add(item); notifyDataSetChanged(); } public void addItem(int index, MenuItem item) { mList.add(index, item); notifyDataSetChanged(); } @Override public int getCount() { return mList.size(); } @Override public MenuItem getItem(int position) { return mList.get(position); } @Override public long getItemId(int position) { return getItem(position).hashCode(); } @Override public boolean hasStableIds() { return true; } @SuppressLint("ViewHolder") @Override public View getView(int position, View convertView, ViewGroup parent) { final View view; view = LayoutInflater.from(parent.getContext()).inflate(getItemViewType(position), parent, false); final MenuItem item = getItem(position); if (view instanceof TextView) { TextView textView = (TextView) view; textView.setText(item.toString()); item.setIcon(textView); } return view; } @Override public int getItemViewType(int position) { return R.layout.popup_list_item; } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/ui/ViewStubPreview.java ================================================ package rocks.tbog.tblauncher.ui; import android.content.Context; import android.content.res.TypedArray; import android.graphics.Canvas; import android.util.AttributeSet; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.view.ViewParent; import android.view.ViewStub; import androidx.annotation.LayoutRes; import androidx.annotation.Nullable; import androidx.constraintlayout.widget.ConstraintHelper; import androidx.constraintlayout.widget.ConstraintLayout; import java.lang.ref.WeakReference; import rocks.tbog.tblauncher.R; /** * Copy of {@link android.view.ViewStub} so that we can see something in the preview */ public final class ViewStubPreview extends View { private int mLayoutResource; private int mInflatedId; private WeakReference mInflatedViewRef = null; private LayoutInflater mInflater = null; private OnInflateListener mInflateListener = null; public ViewStubPreview(Context context, AttributeSet attrs) { this(context, attrs, 0); } public ViewStubPreview(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.ViewStubPreview, defStyle, 0); mInflatedId = a.getResourceId(R.styleable.ViewStubPreview_inflatedId, NO_ID); mLayoutResource = a.getResourceId(R.styleable.ViewStubPreview_layout, 0); a.recycle(); if (!isInEditMode()) { setVisibility(GONE); setWillNotDraw(true); } } /** * Returns the id taken by the inflated view. If the inflated id is * {@link View#NO_ID}, the inflated view keeps its original id. * * @return A positive integer used to identify the inflated view or * {@link #NO_ID} if the inflated view should keep its id. * @attr name android:inflatedId * @see #setInflatedId(int) */ public int getInflatedId() { return mInflatedId; } /** * Defines the id taken by the inflated view. If the inflated id is * {@link View#NO_ID}, the inflated view keeps its original id. * * @param inflatedId A positive integer used to identify the inflated view or * {@link #NO_ID} if the inflated view should keep its id. * @attr name android:inflatedId * @see #getInflatedId() */ public void setInflatedId(int inflatedId) { mInflatedId = inflatedId; } /** * Returns the layout resource that will be used by {@link #setVisibility(int)} or * {@link #inflate()} to replace this StubbedView * in its parent by another view. * * @return The layout resource identifier used to inflate the new View. * @attr name android:layout * @see #setLayoutResource(int) * @see #setVisibility(int) * @see #inflate() */ public int getLayoutResource() { return mLayoutResource; } /** * Specifies the layout resource to inflate when this StubbedView becomes visible or invisible * or when {@link #inflate()} is invoked. The View created by inflating the layout resource is * used to replace this StubbedView in its parent. * * @param layoutResource A valid layout resource identifier (different from 0.) * @attr name android:layout * @see #getLayoutResource() * @see #setVisibility(int) * @see #inflate() */ public void setLayoutResource(int layoutResource) { mLayoutResource = layoutResource; } /** * Set {@link LayoutInflater} to use in {@link #inflate()}, or {@code null} * to use the default. */ public void setLayoutInflater(LayoutInflater inflater) { mInflater = inflater; } /** * Get current {@link LayoutInflater} used in {@link #inflate()}. */ public LayoutInflater getLayoutInflater() { return mInflater; } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { if (isInEditMode()) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); return; } setMeasuredDimension(0, 0); } @Override public void draw(Canvas canvas) { if (isInEditMode()) super.draw(canvas); } @Override protected void dispatchDraw(Canvas canvas) { // don't draw the stub } /** * When visibility is set to {@link #VISIBLE} or {@link #INVISIBLE}, * {@link #inflate()} is invoked and this StubbedView is replaced in its parent * by the inflated layout resource. After that calls to this function are passed * through to the inflated view. * * @param visibility One of {@link #VISIBLE}, {@link #INVISIBLE}, or {@link #GONE}. * @see #inflate() */ @Override public void setVisibility(int visibility) { if (mInflatedViewRef != null) { View view = mInflatedViewRef.get(); if (view != null) { view.setVisibility(visibility); } else { throw new IllegalStateException("setVisibility called on un-referenced view"); } } else { super.setVisibility(visibility); if (visibility == VISIBLE || visibility == INVISIBLE) { inflate(); } } } /** * Inflates the layout resource identified by {@link #getLayoutResource()} * and replaces this StubbedView in its parent by the inflated layout resource. * * @return The inflated layout resource. */ public View inflate() { final ViewParent viewParent = getParent(); if (viewParent instanceof ViewGroup) { if (mLayoutResource != 0) { final ViewGroup parent = (ViewGroup) viewParent; final LayoutInflater factory; if (mInflater != null) { factory = mInflater; } else { factory = LayoutInflater.from(getContext()); } final View view = factory.inflate(mLayoutResource, parent, false); if (mInflatedId != NO_ID) { view.setId(mInflatedId); } final int index = parent.indexOfChild(this); parent.removeViewInLayout(this); final ViewGroup.LayoutParams layoutParams = getLayoutParams(); if (layoutParams != null) { parent.addView(view, index, layoutParams); } else { parent.addView(view, index); } // update parent ConstraintLayout constraints if (parent instanceof ConstraintLayout) updateConstraintsAfterStubInflate((ConstraintLayout) parent, getId(), view.getId()); mInflatedViewRef = new WeakReference<>(view); if (mInflateListener != null) { mInflateListener.onInflate(this, view); } return view; } else { throw new IllegalArgumentException("ViewStub must have a valid layoutResource"); } } else { throw new IllegalStateException("ViewStub must have a non-null ViewGroup viewParent"); } } /** * Specifies the inflate listener to be notified after this ViewStub successfully * inflated its layout resource. * * @param inflateListener The OnInflateListener to notify of successful inflation. * @see android.view.ViewStub.OnInflateListener */ public void setOnInflateListener(OnInflateListener inflateListener) { mInflateListener = inflateListener; } @Nullable public static View inflateStub(@Nullable View view) { return inflateStub(view, 0); } @Nullable public static View inflateStub(@Nullable View view, @LayoutRes int layoutRes) { if (view instanceof ViewStubPreview) { if (layoutRes != 0) ((ViewStubPreview) view).setLayoutResource(layoutRes); // ViewStubPreview already calls updateConstraintsAfterStubInflate return ((ViewStubPreview) view).inflate(); } if (!(view instanceof ViewStub)) return view; ViewStub stub = (ViewStub) view; int stubId = stub.getId(); // get parent before the call to inflate ConstraintLayout constraintLayout = stub.getParent() instanceof ConstraintLayout ? (ConstraintLayout) stub.getParent() : null; if (layoutRes != 0) stub.setLayoutResource(layoutRes); View inflatedView = stub.inflate(); int inflatedId = inflatedView.getId(); updateConstraintsAfterStubInflate(constraintLayout, stubId, inflatedId); return inflatedView; } private static void updateConstraintsAfterStubInflate(@Nullable ConstraintLayout constraintLayout, int stubId, int inflatedId) { if (inflatedId == View.NO_ID) return; // change parent ConstraintLayout constraints if (constraintLayout != null && stubId != inflatedId) { int childCount = constraintLayout.getChildCount(); for (int childIdx = 0; childIdx < childCount; childIdx += 1) { View child = constraintLayout.getChildAt(childIdx); if (child instanceof ConstraintHelper) { // get a copy of the id list int[] refIds = ((ConstraintHelper) child).getReferencedIds(); boolean changed = false; // change constraint reference IDs for (int idx = 0; idx < refIds.length; idx += 1) { if (refIds[idx] == stubId) { refIds[idx] = inflatedId; changed = true; } } if (changed) ((ConstraintHelper) child).setReferencedIds(refIds); } ConstraintLayout.LayoutParams params = (ConstraintLayout.LayoutParams) child.getLayoutParams(); if (changeConstraintLayoutParamsTarget(params, stubId, inflatedId)) child.setLayoutParams(params); } } } private static boolean changeConstraintLayoutParamsTarget(ConstraintLayout.LayoutParams params, int fromId, int toId) { boolean changed = false; if (params.leftToLeft == fromId) { params.leftToLeft = toId; changed = true; } if (params.leftToRight == fromId) { params.leftToRight = toId; changed = true; } if (params.rightToLeft == fromId) { params.rightToLeft = toId; changed = true; } if (params.rightToRight == fromId) { params.rightToRight = toId; changed = true; } if (params.topToTop == fromId) { params.topToTop = toId; changed = true; } if (params.topToBottom == fromId) { params.topToBottom = toId; changed = true; } if (params.bottomToTop == fromId) { params.bottomToTop = toId; changed = true; } if (params.bottomToBottom == fromId) { params.bottomToBottom = toId; changed = true; } if (params.baselineToBaseline == fromId) { params.baselineToBaseline = toId; changed = true; } if (params.baselineToTop == fromId) { params.baselineToTop = toId; changed = true; } if (params.circleConstraint == fromId) { params.circleConstraint = toId; changed = true; } if (params.startToEnd == fromId) { params.startToEnd = toId; changed = true; } if (params.startToStart == fromId) { params.startToStart = toId; changed = true; } if (params.endToStart == fromId) { params.endToStart = toId; changed = true; } if (params.endToEnd == fromId) { params.endToEnd = toId; changed = true; } return changed; } /** * Listener used to receive a notification after a ViewStub has successfully * inflated its layout resource. * * @see android.view.ViewStub#setOnInflateListener(android.view.ViewStub.OnInflateListener) */ public interface OnInflateListener { /** * Invoked after a ViewStub successfully inflated its layout resource. * This method is invoked after the inflated view was added to the * hierarchy but before the layout pass. * * @param stub The ViewStub that initiated the inflation. * @param inflated The inflated View. */ void onInflate(ViewStubPreview stub, View inflated); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/ui/WindowInsetsHelper.java ================================================ package rocks.tbog.tblauncher.ui; import android.app.Activity; import android.content.Context; import android.content.ContextWrapper; import android.os.Build; import android.view.View; import android.view.ViewGroup; import android.view.Window; import android.view.inputmethod.InputMethodManager; import android.widget.EditText; import androidx.annotation.NonNull; import androidx.core.view.ViewCompat; import androidx.core.view.WindowCompat; import androidx.core.view.WindowInsetsCompat; import androidx.core.view.WindowInsetsControllerCompat; import rocks.tbog.tblauncher.R; public class WindowInsetsHelper implements KeyboardHandler { private final WindowInsetsControllerCompat controller; private final View mRoot; /** * Initialize WindowInsetsControllerCompat. It's best to have the root as an EditText to * simplify the `showKeyboard` code * * @param root any view in the window. Used to get the context and window token. */ public WindowInsetsHelper(View root) { if (root == null) throw new IllegalStateException("WindowInsetsHelper root == null"); mRoot = root; Window window = findWindow(root.getContext()); if (window == null) throw new IllegalStateException("WindowInsetsHelper window == null for " + root); controller = WindowCompat.getInsetsController(window, root); controller.setSystemBarsBehavior(WindowInsetsControllerCompat.BEHAVIOR_DEFAULT); } @Override public void showKeyboard() { // on KitKat `controller.show` is no-op if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.KITKAT) { Context ctx = mRoot.getContext(); InputMethodManager imm = (InputMethodManager) ctx.getSystemService(Context.INPUT_METHOD_SERVICE); View view = mRoot; if (view.isInEditMode() || view.onCheckIsTextEditor()) { view.requestFocus(); } else { Window window = findWindow(ctx); if (window != null) { // we should display the keyboard for the currently focused view view = window.getCurrentFocus(); } else { view = null; } } // Fallback on finding the first EditText if (view == null) { view = findTextEditor(mRoot); } imm.showSoftInput(view, InputMethodManager.SHOW_IMPLICIT); } else { controller.show(WindowInsetsCompat.Type.ime()); } } private static Window findWindow(Context ctx) { Context context = ctx; while (context instanceof ContextWrapper) { if (context instanceof Activity) { Window window = ((Activity) context).getWindow(); if (window != null) return window; } context = ((ContextWrapper) context).getBaseContext(); } return null; } private static View findTextEditor(View root) { if (root instanceof ViewGroup) { int childCount = ((ViewGroup) root).getChildCount(); // search this level for (int childIdx = 0; childIdx < childCount; childIdx += 1) { View child = ((ViewGroup) root).getChildAt(childIdx); if (child.onCheckIsTextEditor() || child instanceof EditText) return child; } // look deeper for (int childIdx = 0; childIdx < childCount; childIdx += 1) { View child = ((ViewGroup) root).getChildAt(childIdx); child = findTextEditor(child); if (child.onCheckIsTextEditor() || child instanceof EditText) return child; } } return root; } @Override public void hideKeyboard() { // on KitKat `controller.hide` is no-op if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.KITKAT) { Context ctx = mRoot.getContext(); InputMethodManager imm = (InputMethodManager) ctx.getSystemService(Context.INPUT_METHOD_SERVICE); imm.hideSoftInputFromWindow(mRoot.getWindowToken(), 0); } else { controller.hide(WindowInsetsCompat.Type.ime()); } // we need to keep focus on some window view or else the keyboard may eat the next back press // example: "Multiling O Keyboard + emoji" by Honso (kl.ime.oh) will not send the key event KEYCODE_BACK // after we hide the keyboard by scrolling Window window = findWindow(mRoot.getContext()); if (window != null) window.getDecorView().requestFocus(); } public void showSystemBars() { controller.show(WindowInsetsCompat.Type.systemBars()); } public void hideSystemBars() { controller.hide(WindowInsetsCompat.Type.systemBars()); } @NonNull private static View getRootView(View view) { // we need a root view that has `android:fitsSystemWindows="true"` to find the height of the keyboard ViewGroup rootView = (ViewGroup) view.getRootView(); ViewGroup rootLayout = rootView.findViewById(R.id.root_layout); // child 0 is `R.id.notificationBackground` // child 1 is a full-screen ViewGroup that has `android:fitsSystemWindows="true"` return rootLayout.getChildAt(1); } public static int getKeyboardHeight(View view) { // we need a root view that has `android:fitsSystemWindows="true"` to find the height of the keyboard return getRootView(view).getPaddingBottom(); } public static boolean isKeyboardVisible(View view) { // On devices running API 20 and below, getRootWindowInsets always returns null. WindowInsetsCompat insets = ViewCompat.getRootWindowInsets(view); if (insets != null) return insets.isVisible(WindowInsetsCompat.Type.ime()); // in the unlikely case we can't get the insets, assume we have a keyboard if the bottom padding is greater than 150px return getKeyboardHeight(view) > 150; } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/ui/dialog/ConfirmDialog.java ================================================ package rocks.tbog.tblauncher.ui.dialog; import android.content.Context; import android.os.Build; import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.ui.DialogFragment; public class ConfirmDialog extends DialogFragment { @Override protected int layoutRes() { return R.layout.pref_confirm; } @Nullable @Override public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { Context context = requireDialog().getContext(); setupDefaultButtonOkCancel(context); // make sure we use the dialog context LayoutInflater contextInflater = inflater.cloneInContext(context); return super.onCreateView(contextInflater, container, savedInstanceState); } @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { view.setClipToOutline(true); } Bundle args = getArguments() != null ? getArguments() : new Bundle(); CharSequence descriptionText = args.getCharSequence("descriptionText", ""); CharSequence titleText = args.getCharSequence("titleText", ""); ((TextView) view.findViewById(android.R.id.text1)).setText(titleText); ((TextView) view.findViewById(android.R.id.text2)).setText(descriptionText); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/ui/dialog/EditTextDialog.java ================================================ package rocks.tbog.tblauncher.ui.dialog; import android.app.Dialog; import android.content.Context; import android.os.Build; import android.os.Bundle; import android.util.Log; import android.view.KeyEvent; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.view.inputmethod.EditorInfo; import android.widget.EditText; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.StringRes; import androidx.core.view.ViewCompat; import androidx.core.view.WindowInsetsCompat; import androidx.core.view.WindowInsetsControllerCompat; import com.google.android.material.textfield.TextInputLayout; import rocks.tbog.tblauncher.Behaviour; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.ui.DialogFragment; import rocks.tbog.tblauncher.ui.DialogWrapper; public class EditTextDialog extends DialogFragment { private static final String TAG = EditTextDialog.class.getSimpleName(); private EditTextDialog() { super(); } @Override protected int layoutRes() { return R.layout.dialog_rename; } @Nullable @Override public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { // make sure we use the dialog context LayoutInflater dialogInflater = inflater.cloneInContext(requireDialog().getContext()); return super.onCreateView(dialogInflater, container, savedInstanceState); } @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { view.setClipToOutline(true); } // read and apply the arguments Bundle args = getArguments() != null ? getArguments() : new Bundle(); CharSequence initialText = args.getCharSequence("initialText", ""); CharSequence titleText = args.getCharSequence("titleText", ""); CharSequence hintText = args.getCharSequence("hintText", ""); //hint if (hintText.length() != 0) { TextInputLayout textInputLayout = view.findViewById(android.R.id.hint); textInputLayout.setHintEnabled(true); textInputLayout.setHint(hintText); } // initial text { EditText textView = view.findViewById(R.id.rename); textView.setText(initialText); textView.setOnEditorActionListener((v, actionId, event) -> { if (event == null) { if (actionId != EditorInfo.IME_ACTION_NONE) { final CharSequence name = v.getText(); if (name.length() == 0) { dismiss(); return true; } onConfirm(name); return true; } } else if (event.getKeyCode() == KeyEvent.KEYCODE_ENTER) { if (event.getAction() == KeyEvent.ACTION_UP) { final CharSequence name = v.getText(); onConfirm(name); } return true; } return false; }); textView.requestFocus(); } // title { TextView textView = view.findViewById(android.R.id.title); textView.setText(titleText); } } @Override public void onStart() { super.onStart(); Dialog dialog = getDialog(); if (dialog instanceof DialogWrapper) { ((DialogWrapper) dialog).setOnWindowFocusChanged((dlg, hasFocus) -> { if (hasFocus) { dlg.setOnWindowFocusChanged(null); View view = dlg.getCurrentFocus(); if (view == null) view = dlg.findViewById(android.R.id.content); WindowInsetsControllerCompat ctrl = view != null ? ViewCompat.getWindowInsetsController(view) : null; if (ctrl != null) ctrl.show(WindowInsetsCompat.Type.ime()); else Log.e(TAG, "failed to show keyboard"); } }); } } public static class Builder { private final Context mContext; private final Bundle mArgs = new Bundle(); private EditTextDialog mDialog = null; private OnButtonClickListener mClickPositive = null; private OnButtonClickListener mClickNegative = null; private OnButtonClickListener mClickNeutral = null; private OnConfirmListener mOnConfirm = null; public Builder(@NonNull Context context) { super(); mContext = context; } public Builder setTitle(@Nullable CharSequence title) { mArgs.putCharSequence("titleText", title); return this; } public Builder setTitle(@StringRes int titleId) { mArgs.putCharSequence("titleText", mContext.getText(titleId)); return this; } public Builder setHint(@Nullable CharSequence hint) { mArgs.putCharSequence("hintText", hint); return this; } public Builder setHint(@StringRes int hintId) { return setHint(mContext.getText(hintId)); } public Builder setInitialText(@Nullable CharSequence text) { mArgs.putCharSequence("initialText", text); return this; } public Builder setPositiveButton(@StringRes int btnTextId, OnButtonClickListener onClickListener) { mArgs.putCharSequence("btnPositiveText", mContext.getText(btnTextId)); mClickPositive = onClickListener; if (mDialog != null) mDialog.setOnPositiveClickListener(mClickPositive); return this; } public Builder setNegativeButton(@StringRes int btnTextId, OnButtonClickListener onClickListener) { mArgs.putCharSequence("btnNegativeText", mContext.getText(btnTextId)); mClickNegative = onClickListener; if (mDialog != null) mDialog.setOnNegativeClickListener(mClickNegative); return this; } public Builder setNeutralButton(@StringRes int btnTextId, OnButtonClickListener onClickListener) { mArgs.putCharSequence("btnNeutralText", mContext.getText(btnTextId)); mClickNeutral = onClickListener; if (mDialog != null) mDialog.setOnNeutralClickListener(mClickNeutral); return this; } public Builder setConfirmListener(@StringRes int positiveTextId, OnConfirmListener onConfirm) { mArgs.putCharSequence("btnPositiveText", mContext.getText(positiveTextId)); mClickPositive = (dialog, button) -> { EditText input = dialog.findViewById(R.id.rename); dialog.onConfirm(input != null ? input.getText() : null); }; mOnConfirm = onConfirm; if (mDialog != null) { mDialog.setOnPositiveClickListener(mClickPositive); mDialog.setOnConfirmListener(mOnConfirm); } return this; } @NonNull public EditTextDialog getDialog() { if (mDialog == null) { mDialog = new EditTextDialog(); mDialog.setArguments(mArgs); mDialog.setOnPositiveClickListener(mClickPositive); mDialog.setOnNegativeClickListener(mClickNegative); mDialog.setOnNeutralClickListener(mClickNeutral); mDialog.setOnConfirmListener(mOnConfirm); } return mDialog; } public void show() { EditTextDialog dialog = getDialog(); Behaviour.showDialog(mContext, dialog, "dialog_rename"); } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/ui/dialog/PleaseWaitDialog.java ================================================ package rocks.tbog.tblauncher.ui.dialog; import android.app.Dialog; import android.content.Context; import android.graphics.drawable.Animatable; import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.ImageView; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.ui.DialogFragment; import rocks.tbog.tblauncher.utils.UISizes; public class PleaseWaitDialog extends DialogFragment { public static final String ARG_TITLE = "title"; public static final String ARG_DESCRIPTION = "desc"; View mView = null; Runnable mWork = null; public void setWork(@Nullable Runnable work) { mWork = work; } public void onWorkFinished() { if (mView == null) { onConfirm(null); dismiss(); return; } // OK button { View button = mView.findViewById(android.R.id.button1); button.setEnabled(true); } // progress indicator { View view = mView.findViewById(android.R.id.progress); if (view != null) view.setVisibility(View.INVISIBLE); } } @Override protected int layoutRes() { return R.layout.pref_confirm; } @Nullable @Override public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { @NonNull Dialog dialog = requireDialog(); Context context = dialog.getContext(); setupDefaultButtonOk(context); // make sure we use the dialog context inflater = inflater.cloneInContext(context); mView = super.onCreateView(inflater, container, savedInstanceState); // Window window = dialog.getWindow(); // if (window != null) // window.setBackgroundDrawableResource(R.drawable.dialog_background_dark); // add progress indicator if (mView instanceof ViewGroup) { ImageView loading = new ImageView(context); loading.setImageResource(R.drawable.ic_loading_arrows); if (loading.getDrawable() instanceof Animatable) ((Animatable) loading.getDrawable()).start(); //loading.setImageDrawable(DrawableUtils.getProgressBarIndeterminate(context)); loading.setId(android.R.id.progress); // add progress bar before the button panel { View buttonPanel = mView.findViewById(R.id.buttonPanel); int index = ((ViewGroup) mView).indexOfChild(buttonPanel); ((ViewGroup) mView).addView(loading, index); } ViewGroup.LayoutParams params = loading.getLayoutParams(); params.width = ViewGroup.LayoutParams.MATCH_PARENT; params.height = UISizes.getResultIconSize(context) * 2; loading.setLayoutParams(params); } // while we wait, we wait, not cancel dialog.setCanceledOnTouchOutside(false); dialog.setCancelable(false); return mView; } @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); Bundle args = getArguments() != null ? getArguments() : new Bundle(); // title { TextView text = view.findViewById(android.R.id.text1); String title = args.getString(ARG_TITLE); if (title != null) text.setText(title); else text.setText(R.string.please_wait); } // description { TextView text = view.findViewById(android.R.id.text2); String description = args.getString(ARG_DESCRIPTION); if (description != null) text.setText(description); else text.setText(""); } // OK button { setOnPositiveClickListener((dialog, button) -> onConfirm(null)); View button = view.findViewById(android.R.id.button1); button.setEnabled(false); } } @Override public void onStart() { super.onStart(); if (mWork != null) { // start the loading after the dialog is visible mView.postDelayed(mWork, 500); } else { onWorkFinished(); } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/ui/dialog/TagsManagerDialog.java ================================================ package rocks.tbog.tblauncher.ui.dialog; import android.content.Context; 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 rocks.tbog.tblauncher.Behaviour; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.TagsManager; import rocks.tbog.tblauncher.entry.EntryItem; import rocks.tbog.tblauncher.searcher.TagSearcher; import rocks.tbog.tblauncher.ui.DialogFragment; import rocks.tbog.tblauncher.utils.DialogHelper; public class TagsManagerDialog extends DialogFragment { private final TagsManager mManager = new TagsManager(); @Override protected int layoutRes() { return R.layout.tags_manager; } @Nullable @Override public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { Context context = requireDialog().getContext(); setupDefaultButtonOkCancel(context); // make sure we use the dialog context inflater = inflater.cloneInContext(context); return super.onCreateView(inflater, container, savedInstanceState); } @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); mManager.bindView(view, (v, info) -> { if (info.staticEntry != null) { info.staticEntry.doLaunch(v, EntryItem.LAUNCHED_FROM_GESTURE); } else { Context ctx = v.getContext(); TBApplication.quickList(ctx).toggleSearch(v, info.tagName, TagSearcher.class); } if (mManager.hasChangesMade()) askBeforeDismiss(); }); } @Override public void onButtonClick(@NonNull Button button) { if (button == Button.POSITIVE) { mManager.applyChanges(requireContext()); onConfirm(null); } else if (button == Button.NEGATIVE && mManager.hasChangesMade()) { askBeforeDismiss(); return; } super.onButtonClick(button); } @Override public void onStart() { super.onStart(); mManager.onStart(); } private void askBeforeDismiss() { Context ctx = requireContext(); DialogFragment dlg = DialogHelper.makeConfirmDialog(ctx, R.string.exit_tags_manager_confirm, R.string.exit_tags_manager_description, (dialog, btn) -> TagsManagerDialog.this.dismiss()); Behaviour.showDialog(ctx, dlg, "dialog_confirm"); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/utils/ArrayHelper.java ================================================ package rocks.tbog.tblauncher.utils; public class ArrayHelper { public static boolean contains(int[] arr, int find) { for (int value : arr) if (value == find) return true; return false; } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/utils/ClipboardUtils.java ================================================ package rocks.tbog.tblauncher.utils; import android.content.ClipData; import android.content.ClipboardManager; import android.content.Context; public class ClipboardUtils { public static void setClipboard(Context context, String text) { ClipboardManager clipboard; if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { clipboard = context.getSystemService(ClipboardManager.class); } else { clipboard = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); } ClipData clip = ClipData.newPlainText("Copied Text", text); clipboard.setPrimaryClip(clip); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/utils/ColorFilterHelper.java ================================================ package rocks.tbog.tblauncher.utils; import android.graphics.ColorFilter; import android.graphics.ColorMatrix; import android.graphics.ColorMatrixColorFilter; /** * http://groups.google.com/group/android-developers/browse_thread/thread/9e215c83c3819953 * http://gskinner.com/blog/archives/2007/12/colormatrix_cla.html * https://medium.com/mobile-app-development-publication/android-image-color-change-with-colormatrix-e927d7fb6eb4 */ public class ColorFilterHelper { private static final float[] DELTA_INDEX = { 0f, 0.01f, 0.02f, 0.04f, 0.05f, 0.06f, 0.07f, 0.08f, 0.1f, 0.11f, 0.12f, 0.14f, 0.15f, 0.16f, 0.17f, 0.18f, 0.20f, 0.21f, 0.22f, 0.24f, 0.25f, 0.27f, 0.28f, 0.30f, 0.32f, 0.34f, 0.36f, 0.38f, 0.40f, 0.42f, 0.44f, 0.46f, 0.48f, 0.5f, 0.53f, 0.56f, 0.59f, 0.62f, 0.65f, 0.68f, 0.71f, 0.74f, 0.77f, 0.80f, 0.83f, 0.86f, 0.89f, 0.92f, 0.95f, 0.98f, 1.0f, 1.06f, 1.12f, 1.18f, 1.24f, 1.30f, 1.36f, 1.42f, 1.48f, 1.54f, 1.60f, 1.66f, 1.72f, 1.78f, 1.84f, 1.90f, 1.96f, 2.0f, 2.12f, 2.25f, 2.37f, 2.50f, 2.62f, 2.75f, 2.87f, 3.0f, 3.2f, 3.4f, 3.6f, 3.8f, 4.0f, 4.3f, 4.7f, 4.9f, 5.0f, 5.5f, 6.0f, 6.5f, 6.8f, 7.0f, 7.3f, 7.5f, 7.8f, 8.0f, 8.4f, 8.7f, 9.0f, 9.4f, 9.6f, 9.8f, 10.f }; public static boolean adjustHue(ColorMatrix cm, int amount) { if (amount == 0) return false; int value = clampValue(amount, 180); double rad = Math.toRadians(value); float cosVal = (float) Math.cos(rad); float sinVal = (float) Math.sin(rad); float R = 0.2125f; float G = 0.7154f; float B = 0.0721f; float[] mat = new float[]{ R + cosVal * (1 - R) + sinVal * (-R), G + cosVal * (-G) + sinVal * (-G), B + cosVal * (-B) + sinVal * (1 - B), 0, 0, R + cosVal * (-R) + sinVal * (0.143f), G + cosVal * (1 - G) + sinVal * (0.140f), B + cosVal * (-B) + sinVal * (-0.283f), 0, 0, R + cosVal * (-R) + sinVal * (-(1 - R)), G + cosVal * (-G) + sinVal * (G), B + cosVal * (1 - B) + sinVal * (B), 0, 0, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 0f, 1f}; cm.postConcat(new ColorMatrix(mat)); return true; } public static boolean adjustBrightness(ColorMatrix cm, int amount) { if (amount == 0) return false; int value = clampValue(amount, 100); // convert from -100..100 to -255..255 value = value * 255 / 100; float[] mat = new float[]{ 1, 0, 0, 0, value, 0, 1, 0, 0, value, 0, 0, 1, 0, value, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1 }; cm.postConcat(new ColorMatrix(mat)); return true; } public static boolean adjustContrast(ColorMatrix cm, int amount) { if (amount == 0) return false; int value = clampValue(amount, 100); float x; if (value < 0) { x = 127.5f + value / 100f * 127.5f; } else { x = 127.5f + DELTA_INDEX[value] * 127.5f; } float c = x / 127.5f; float b = .5f * (127.5f - x); float[] mat = new float[]{ c, 0, 0, 0, b, 0, c, 0, 0, b, 0, 0, c, 0, b, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1 }; cm.postConcat(new ColorMatrix(mat)); return true; } public static boolean adjustSaturation(ColorMatrix cm, int amount) { if (amount == 0) return false; int value = clampValue(amount, 100); final float x = 1f + ((value > 0) ? 3f * value / 100f : value / 100f); final float inv = 1f - x; final float R = 0.3086f * inv; final float G = 0.6094f * inv; final float B = 0.0820f * inv; float[] mat = new float[]{ R + x, G, B, 0, 0, R, G + x, B, 0, 0, R, G, B + x, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1 }; cm.postConcat(new ColorMatrix(mat)); return true; } public static boolean adjustScale(ColorMatrix cm, int r, int g, int b, int a) { if (r == 0 && g == 0 && b == 0 && a == 0) return false; final float R = getChannelScale(r); final float G = getChannelScale(g); final float B = getChannelScale(b); final float A = getChannelScale(a); float[] mat = new float[]{ R, 0, 0, 0, -255f * (R - 1f) * .5f, 0, G, 0, 0, -255f * (G - 1f) * .5f, 0, 0, B, 0, -255f * (B - 1f) * .5f, 0, 0, 0, A, -255f * (A - 1f) * .5f, 0, 0, 0, 0, 1 }; cm.postConcat(new ColorMatrix(mat)); return true; } private static float getChannelScale(int scale) { float s = clampValue(scale, 200); return s / 100f + 1f; } // make sure values are within the specified range, hue has a limit of 180, others are 100: private static int clampValue(int val, int limit) { return Math.min(limit, Math.max(-limit, val)); } public static ColorFilter adjustColor(int brightness, int contrast, int saturation, int hue) { ColorMatrix cm = new ColorMatrix(); adjustHue(cm, hue); adjustContrast(cm, contrast); adjustBrightness(cm, brightness); adjustSaturation(cm, saturation); return new ColorMatrixColorFilter(cm); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/utils/DebugInfo.java ================================================ package rocks.tbog.tblauncher.utils; import android.content.Context; import android.content.SharedPreferences; import androidx.preference.PreferenceManager; public class DebugInfo { public static boolean widgetAdd(Context context) { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); return prefs.getBoolean("debug-widget-add-info", false); } public static boolean widgetInfo(Context context) { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); return prefs.getBoolean("debug-widget-info", false); } public static boolean itemRelevance(Context context) { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); return prefs.getBoolean("debug-item-relevance", false); } public static boolean keyboardScrollHiderTouch(Context context) { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); return prefs.getBoolean("debug-ksh-touch", false); } public static boolean enableFavorites(Context context) { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); return prefs.getBoolean("debug-favorites", false); } public static boolean providerStatus(Context context) { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); return prefs.getBoolean("debug-provider-status", false); } public static boolean itemIconInfo(Context context) { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); return prefs.getBoolean("debug-item-icon-info", false); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/utils/DebugString.java ================================================ package rocks.tbog.tblauncher.utils; import android.view.ViewGroup; public class DebugString { public static String layoutParamSize(int size) { switch (size) { case ViewGroup.LayoutParams.MATCH_PARENT: return "MATCH_PARENT"; case ViewGroup.LayoutParams.WRAP_CONTENT: return "WRAP_CONTENT"; default: return String.valueOf(size); } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/utils/DeviceUtils.java ================================================ package rocks.tbog.tblauncher.utils; import android.annotation.SuppressLint; import android.bluetooth.BluetoothAdapter; import android.content.Context; import android.content.res.Configuration; import android.net.wifi.WifiManager; import android.os.Build; import android.provider.Settings; import android.telephony.TelephonyManager; import android.text.TextUtils; import android.util.DisplayMetrics; import android.util.Log; import android.view.WindowManager; //import android.webkit.WebView; import androidx.annotation.Nullable; import java.io.UnsupportedEncodingException; import java.lang.reflect.Method; import java.net.URLEncoder; import java.security.MessageDigest; import java.util.Locale; import java.util.UUID; /** * Created by KhanhDuong on 8/14/2015. * Author Khanh Duong * Email khanhduong@innoria.com * Company www.innoria.com */ public class DeviceUtils { /** * get device id base on current system config * @param context * @return */ public static String getDeviceId(Context context) { //get IMEI first String imei = getIMEI(context); if (!TextUtils.isEmpty(imei)) { return encodeMD5(imei); } Log.e("DeviceUtils", "getDeviceId, IMEI is NULL. start get device serial"); //get Android device serial for device without telephony String serialNum = getDeviceSerial(context); if (!TextUtils.isEmpty(serialNum)) { return encodeMD5(serialNum); } Log.e("DeviceUtils", "getDeviceId, SERIAL is NULL. start get device Android_ID"); //get Android_ID. not trust, it's reseted after factory reset String androidId = getAndroidId(context); if (!TextUtils.isEmpty(androidId)) { return encodeMD5(androidId); } Log.e("DeviceUtils", "getDeviceId, Can not generate device ID"); return null; } /** * get device serial id. * @param context * @return */ @SuppressLint("HardwareIds") public static String getDeviceSerial(Context context) { String serial = null; try { @SuppressLint("PrivateApi") Class c = Class.forName("android.os.SystemProperties"); Method get = c.getMethod("get", String.class); serial = (String)get.invoke(c, "ro.serialno"); } catch (Exception e) { e.printStackTrace(); } return serial; } @Nullable private static String getIMEI(Context context) { // try { // TelephonyManager telephonyManager = (TelephonyManager) context // .getSystemService(Context.TELEPHONY_SERVICE); // @SuppressLint("HardwareIds") // String imei = telephonyManager.getDeviceId(); // // return imei; // } catch (SecurityException e) { // Log.e("", "TELEPHONY_SERVICE", e); // } catch (Exception e) { // e.printStackTrace(); // } return null; } // /** // * this method will generate the special id with WIFI MAC Address // * // * @param context // * @return // */ // public static String getDeviceIDWithWifiMACAddress(Context context, String saltCode) { // if (saltCode == null) { // saltCode = "_JonnyKenAndRuby_"; // } // String deviceId = getIMEI(context); // try { // // DEVICE_ID // Requires READ_PHONE_STATE // if (deviceId == null) { // deviceId = ""; // } // // // DEVICE SERIAL // String deviceSerial = getDeviceSerial(context); // if (deviceSerial == null) { // deviceSerial = ""; // } // // String androidId = getAndroidId(context); // if (androidId == null) { // androidId = ""; // } // // // WIFI MAC ADDRESS: android.permission.ACCESS_WIFI_STATE // WifiManager wm = (WifiManager)context.getApplicationContext().getSystemService(Context.WIFI_SERVICE); // String wifiMAC = wm.getConnectionInfo().getMacAddress(); // // // sum // deviceId = deviceId + saltCode + androidId + saltCode + wifiMAC + saltCode + deviceSerial; // deviceId = encodeMD5(deviceId); // } catch (Exception e) { // e.printStackTrace(); // } // Log.d("KST", "DeviceID With WifiMAC: " + deviceId); // return deviceId; // } /** * get Android ID of device * @param context * @return */ public static String getAndroidId(Context context) { @SuppressLint("HardwareIds") String androidId = Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID); return androidId; } // /** // * this method will generate the special id with // * Bluetooth MAC Address. // * // * @param context // * @return // */ // public static String getDeviceIDWithBluetoothAddress(Context context, String saltCode) { // if (saltCode == null) { // saltCode = "_JonnyKenAndRuby_"; // } // String deviceId = getIMEI(context); // try { // // DEVICE_ID // Requires READ_PHONE_STATE // if (deviceId == null) { // deviceId = ""; // } // // // DEVICE SERIAL // String serial = getDeviceSerial(context); // if (serial == null) { // serial = ""; // } // // // Bluetooth MAC address android.permission.BLUETOOTH required // BluetoothAdapter bluetoothAdapter = BluetoothAdapter.getDefaultAdapter(); // String bluetoothMAC = bluetoothAdapter.getAddress(); // // // sum // deviceId = deviceId + saltCode + bluetoothMAC + saltCode + serial; // deviceId = encodeMD5(deviceId); // } catch (Exception e) { // e.printStackTrace(); // } // return deviceId; // } /** * help us convert to MD5 hash value. * @param text * @return */ public static String encodeMD5(String text) { MessageDigest m = null; try { m = MessageDigest.getInstance("MD5"); m.update(text.getBytes(), 0, text.length()); byte p_md5Data[] = m.digest(); String m_szUniqueID = new String(); for (int i=0;i= 11) tablet // * @return // */ // public static boolean isSupportedHoneycombTablet(Context context) { // return hasHoneycomb() && isTablet(context); // } // // // /** // * check support froyo // * @return // */ // public static boolean hasFroyo() { // // Can use static final constants like FROYO, declared in later versions // // of the OS since they are inlined at compile time. This is guaranteed // // behavior. // return Build.VERSION.SDK_INT >= Build.VERSION_CODES.FROYO; // } // // public static boolean hasGingerbread() { // return Build.VERSION.SDK_INT >= Build.VERSION_CODES.GINGERBREAD; // } // // /** // * check support honey com (sdk_int >= 11) // * @return // */ // public static boolean hasHoneycomb() { // // return Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB; // return Build.VERSION.SDK_INT >= 11; // } // // /** // * check support honey com (sdk_int >= 12) // * @return // */ // public static boolean hasHoneycombMR1() { // // return Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB_MR1; // return Build.VERSION.SDK_INT >= 12; // } // // /** // * check support Jelly bean (sdk_int >= 16) // * @return // */ // public static boolean hasJellyBean() { // // return Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN; // return Build.VERSION.SDK_INT >= 16; // } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/utils/DialogHelper.java ================================================ package rocks.tbog.tblauncher.utils; import android.annotation.SuppressLint; import android.app.Dialog; import android.content.Context; import android.content.res.Resources; import android.graphics.drawable.Drawable; import android.os.Bundle; import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewParent; import android.widget.EditText; import android.widget.TextView; import androidx.annotation.IdRes; import androidx.annotation.NonNull; import androidx.annotation.StringRes; import androidx.appcompat.app.AlertDialog; import rocks.tbog.tblauncher.CustomizeUI; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.ui.DialogFragment; import rocks.tbog.tblauncher.ui.dialog.ConfirmDialog; import rocks.tbog.tblauncher.ui.dialog.EditTextDialog; public class DialogHelper { private static final String TAG = DialogHelper.class.getSimpleName(); public static void setCustomTitle(AlertDialog.Builder builder, CharSequence title) { // Dialog doesn't provide a view we can send as root @SuppressLint("InflateParams") View customTitle = LayoutInflater.from(builder.getContext()).inflate(R.layout.dialog_title, null, false); TextView titleView = customTitle.findViewById(android.R.id.title); titleView.setText(title); builder.setCustomTitle(customTitle); } @SuppressLint("RestrictedApi") public static void setButtonBarBackground(Dialog dialog) { Context ctx = dialog.getContext(); View buttonLayout; @IdRes int buttonPanel; // try to find the `buttonPanel` defined in the android layout buttonPanel = ctx.getResources().getIdentifier("buttonPanel", "id", "android"); buttonLayout = dialog.findViewById(buttonPanel); // try to find the `buttonPanel` defined in the androidx layout if (buttonLayout == null) { buttonPanel = ctx.getResources().getIdentifier("buttonPanel", "id", ctx.getPackageName()); buttonLayout = dialog.findViewById(buttonPanel); } // hack: can't find the button container by id, get the parent of one button if (buttonLayout == null) { View button = dialog.findViewById(android.R.id.button1); ViewParent parent = button == null ? null : button.getParent(); if (parent instanceof View) { buttonLayout = (View) parent; // assuming the buttonPanel is inflated from `abc_alert_dialog_button_bar_material.xml` if (buttonLayout instanceof androidx.appcompat.widget.ButtonBarLayout) { parent = buttonLayout.getParent(); if (parent instanceof android.widget.ScrollView) buttonLayout = (View) parent; } } } // apply the background if (buttonLayout != null) { Drawable background = CustomizeUI.getDialogButtonBarBackgroundDrawable(ctx.getTheme()); if (background != null) buttonLayout.setBackground(background); } } public static ConfirmDialog makeConfirmDialog(@NonNull Context context, @StringRes int titleId, @StringRes int descId, DialogFragment.OnButtonClickListener onOk) { Resources r = context.getResources(); Bundle args = new Bundle(); args.putCharSequence("titleText", r.getText(titleId)); args.putCharSequence("descriptionText", r.getText(descId)); ConfirmDialog confirmDialog = new ConfirmDialog(); confirmDialog.setArguments(args); confirmDialog.setOnPositiveClickListener(onOk); return confirmDialog; } public interface OnRename { void rename(Dialog dialog, String name); } public static EditTextDialog.Builder makeRenameDialog(@NonNull Context ctx, CharSequence currentName, @NonNull OnRename callback) { // // get activity theme for this dialog // Context themeWrapper = UITheme.getDialogThemedContext(ctx); EditTextDialog.Builder builder = new EditTextDialog.Builder(ctx) .setInitialText(currentName) .setPositiveButton(R.string.menu_action_rename, (dialog, button) -> { EditText input = dialog.findViewById(R.id.rename); dialog.onConfirm(input != null ? input.getText() : null); }) .setNegativeButton(android.R.string.cancel, null); EditTextDialog dialog = builder.getDialog(); dialog.setOnConfirmListener(newName -> { Log.d(TAG, "rename confirm: `" + newName + "`"); if (newName == null) return; callback.rename(dialog.getDialog(), newName.toString().trim()); }); return builder; // .afterInflate(dialog -> { // @SuppressLint("CutPasteId") // EditText nameView = ((Dialog) dialog).findViewById(R.id.rename); // nameView.setText(currentName); // //// showKeyboard((Dialog) dialog, nameView); //// nameView.postDelayed(() -> showKeyboard((Dialog) dialog, nameView), 500); // int color = 0xFFffd700;//UIColors.getThemeColor(((Dialog) dialog).getContext(), android.R.attr.textColor); // Utilities.setTextSelectHandleColor(nameView, color); // }); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/utils/EdgeGlowHelper.java ================================================ package rocks.tbog.tblauncher.utils; import android.graphics.PorterDuff; import android.graphics.PorterDuffColorFilter; import android.graphics.drawable.Drawable; import android.os.Build; import android.util.Log; import android.widget.AbsListView; import android.widget.EdgeEffect; import androidx.annotation.ColorInt; import androidx.annotation.NonNull; import androidx.core.widget.EdgeEffectCompat; import androidx.recyclerview.widget.RecyclerView; import java.lang.reflect.Field; import java.util.Arrays; /** * Found this in a comment from https://stackoverflow.com/questions/27342957/how-to-change-the-color-of-overscroll-edge-and-overscroll-glow * Taken from https://pastebin.com/TAujMUu9 */ public final class EdgeGlowHelper { private static final String TAG = EdgeGlowHelper.class.getSimpleName(); private static final Class CLASS_RECYCLER_VIEW = RecyclerView.class; private static final Field RECYCLER_VIEW_FIELD_EDGE_GLOW_TOP; private static final Field RECYCLER_VIEW_FIELD_EDGE_GLOW_LEFT; private static final Field RECYCLER_VIEW_FIELD_EDGE_GLOW_RIGHT; private static final Field RECYCLER_VIEW_FIELD_EDGE_GLOW_BOTTOM; private static final Class CLASS_LIST_VIEW = AbsListView.class; private static final Field LIST_VIEW_FIELD_EDGE_GLOW_TOP; private static final Field LIST_VIEW_FIELD_EDGE_GLOW_BOTTOM; private static final Field EDGE_GLOW_FIELD_EDGE; private static final Field EDGE_GLOW_FIELD_GLOW; private static final Field EDGE_EFFECT_COMPAT_FIELD_EDGE_EFFECT; static { Field edgeGlowTop = null; Field edgeGlowBottom = null; Field edgeGlowLeft = null; Field edgeGlowRight = null; for (Field f : CLASS_RECYCLER_VIEW.getDeclaredFields()) { switch (f.getName()) { case "mTopGlow": f.setAccessible(true); edgeGlowTop = f; break; case "mBottomGlow": f.setAccessible(true); edgeGlowBottom = f; break; case "mLeftGlow": f.setAccessible(true); edgeGlowLeft = f; break; case "mRightGlow": f.setAccessible(true); edgeGlowRight = f; break; default: // do nothing break; } } RECYCLER_VIEW_FIELD_EDGE_GLOW_TOP = edgeGlowTop; RECYCLER_VIEW_FIELD_EDGE_GLOW_BOTTOM = edgeGlowBottom; RECYCLER_VIEW_FIELD_EDGE_GLOW_LEFT = edgeGlowLeft; RECYCLER_VIEW_FIELD_EDGE_GLOW_RIGHT = edgeGlowRight; } static { Field edgeGlowTop = null; Field edgeGlowBottom = null; for (Field f : CLASS_LIST_VIEW.getDeclaredFields()) { switch (f.getName()) { case "mEdgeGlowTop": f.setAccessible(true); edgeGlowTop = f; break; case "mEdgeGlowBottom": f.setAccessible(true); edgeGlowBottom = f; break; default: // do nothing break; } } LIST_VIEW_FIELD_EDGE_GLOW_TOP = edgeGlowTop; LIST_VIEW_FIELD_EDGE_GLOW_BOTTOM = edgeGlowBottom; } static { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { Field edge = null; Field glow = null; for (Field f : EdgeEffect.class.getDeclaredFields()) { switch (f.getName()) { case "mEdge": f.setAccessible(true); edge = f; break; case "mGlow": f.setAccessible(true); glow = f; break; default: // do nothing break; } } EDGE_GLOW_FIELD_EDGE = edge; EDGE_GLOW_FIELD_GLOW = glow; } else { EDGE_GLOW_FIELD_EDGE = null; EDGE_GLOW_FIELD_GLOW = null; } } static { Field efc = null; try { efc = EdgeEffectCompat.class.getDeclaredField("mEdgeEffect"); } catch (NoSuchFieldException e) { Log.e(TAG, "field `mEdgeEffect` not found in `EdgeEffectCompat`", e); } EDGE_EFFECT_COMPAT_FIELD_EDGE_EFFECT = efc; } public static void setEdgeGlowColor(RecyclerView recyclerView, @ColorInt int color) { recyclerView.setEdgeEffectFactory(new EdgeGlowFactory(color)); try { Object ee; ee = RECYCLER_VIEW_FIELD_EDGE_GLOW_TOP.get(recyclerView); setEffectGlowColor(ee, color); ee = RECYCLER_VIEW_FIELD_EDGE_GLOW_BOTTOM.get(recyclerView); setEffectGlowColor(ee, color); ee = RECYCLER_VIEW_FIELD_EDGE_GLOW_LEFT.get(recyclerView); setEffectGlowColor(ee, color); ee = RECYCLER_VIEW_FIELD_EDGE_GLOW_RIGHT.get(recyclerView); setEffectGlowColor(ee, color); } catch (Exception e) { Log.e(TAG, "set RecyclerView(" + recyclerView.getClass().getSimpleName() + ") edge color to 0x" + Integer.toHexString(color).toUpperCase(), e); } } public static void setEdgeGlowColor(AbsListView listView, @ColorInt int color) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { listView.setEdgeEffectColor(color); } else { try { Object ee; ee = LIST_VIEW_FIELD_EDGE_GLOW_TOP.get(listView); setEffectGlowColor(ee, color); ee = LIST_VIEW_FIELD_EDGE_GLOW_BOTTOM.get(listView); setEffectGlowColor(ee, color); } catch (Exception e) { Log.e(TAG, "set AbsListView(" + listView.getClass().getSimpleName() + ") edge color to 0x" + Integer.toHexString(color).toUpperCase(), e); } } } private static void setEffectGlowColor(Object effect, @ColorInt int color) { Object edgeEffect = effect; if (edgeEffect instanceof EdgeEffectCompat) { // EdgeEffectCompat try { edgeEffect = EDGE_EFFECT_COMPAT_FIELD_EDGE_EFFECT.get(edgeEffect); } catch (IllegalAccessException e) { Log.e(TAG, "can't set glow color for overscroll", e); return; } } if (edgeEffect == null) return; if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { // EdgeGlow try { final Drawable mEdge = (Drawable) EDGE_GLOW_FIELD_EDGE.get(edgeEffect); final Drawable mGlow = (Drawable) EDGE_GLOW_FIELD_GLOW.get(edgeEffect); for (Drawable drawable : Arrays.asList(mEdge, mGlow)) { drawable.setColorFilter(new PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)); drawable.setCallback(null); // free up any references } } catch (Exception e) { Log.e(TAG, "can't set glow color for overscroll", e); } } else { // EdgeEffect ((EdgeEffect) edgeEffect).setColor(color); } } public static class EdgeGlowFactory extends RecyclerView.EdgeEffectFactory { @ColorInt private final int mColor; public EdgeGlowFactory(@ColorInt int color) { mColor = color; } @NonNull @Override protected EdgeEffect createEdgeEffect(@NonNull RecyclerView view, int direction) { EdgeEffect effect = super.createEdgeEffect(view, direction); setEffectGlowColor(effect, mColor); return effect; } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/utils/FileUtils.java ================================================ package rocks.tbog.tblauncher.utils; import android.app.Activity; import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.net.Uri; import android.os.ParcelFileDescriptor; import android.util.Log; import android.util.Xml; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.app.ShareCompat; import androidx.core.content.FileProvider; import org.xmlpull.v1.XmlPullParser; import org.xmlpull.v1.XmlPullParserException; import java.io.BufferedWriter; import java.io.Closeable; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.FileWriter; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.OutputStreamWriter; import java.io.Writer; import java.nio.channels.FileChannel; import java.nio.charset.StandardCharsets; import java.util.List; import rocks.tbog.tblauncher.R; public class FileUtils { private static final String TAG = "FileUtils"; private static final String SETTINGS_FOLDER = "settings"; private static final String SETTINGS_EXT = ".xml"; @Nullable public static FileInputStream getFileInputStream(@NonNull Context context, @Nullable Uri uri) { if (uri == null) return null; ParcelFileDescriptor descriptor; try { descriptor = context.getContentResolver().openFileDescriptor(uri, "r"); } catch (FileNotFoundException e) { Log.e(TAG, "openFileDescriptor " + uri, e); return null; } if (descriptor != null) return new ParcelFileDescriptor.AutoCloseInputStream(descriptor); return null; } private static void sendFile(@NonNull Activity activity, @NonNull String directory, @NonNull String filename, @NonNull String extension, @Nullable ContentGenerator content) { Context context = activity.getApplicationContext(); File cacheDir = new File(context.getCacheDir(), directory); File cacheFile = new File(cacheDir, filename + extension); if (content != null) { try { cacheDir.mkdirs(); } catch (Exception ignored) { } try { FileWriter fw = new FileWriter(cacheFile); BufferedWriter bw = new BufferedWriter(fw); //bw.write(content); content.generate(bw); bw.close(); } catch (IOException e) { Log.e(TAG, "Failed to write " + filename, e); } } Uri uri = FileProvider.getUriForFile(context, context.getPackageName() + ".provider", cacheFile); try { String title; String type; if (extension.endsWith(SETTINGS_EXT)) { title = activity.getString(R.string.export_chooser_xml, filename); type = ("text/xml"); } else { title = activity.getString(R.string.export_chooser, filename); type = "text/plain"; } Intent intent = new ShareCompat.IntentBuilder(activity) .setType(type) .setSubject(title) .setStream(uri) .setChooserTitle(title) .createChooserIntent() .addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); // grant permission for all apps that can handle given intent List resInfoList = context.getPackageManager().queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY); for (ResolveInfo resolveInfo : resInfoList) { String packageName = resolveInfo.activityInfo.packageName; context.grantUriPermission(packageName, uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION); } activity.startActivity(intent); } catch (Exception e) { Log.d(TAG, "startChooserIntent", e); Toast.makeText(activity, context.getString(R.string.error, e.getLocalizedMessage()), Toast.LENGTH_LONG).show(); } } public static void sendSettingsFile(@NonNull Activity activity, @NonNull String filename) { sendFile(activity, SETTINGS_FOLDER, filename, SETTINGS_EXT, null); } public static void writeSettingsFile(@NonNull Context context, @NonNull String filename, @NonNull ContentGenerator generator) { writeFile(context.getCacheDir(), SETTINGS_FOLDER, filename, SETTINGS_EXT, generator); } private static void writeFile(@NonNull File path, @NonNull String directory, @NonNull String filename, @Nullable String extension, @NonNull ContentGenerator content) { File cacheDir = new File(path, directory); String cacheName = extension != null ? (filename + extension) : filename; File cacheFile = new File(cacheDir, cacheName); try { cacheDir.mkdirs(); } catch (Exception ignored) { } try { OutputStream os = new FileOutputStream(cacheFile); OutputStreamWriter osw = new OutputStreamWriter(os, StandardCharsets.UTF_8); BufferedWriter bw = new BufferedWriter(osw); content.generate(bw); bw.close(); } catch (IOException e) { Log.e(TAG, "Failed to write " + filename, e); } } // @Nullable // private static InputStream getInputStream(Context context, String directory, String filename, String extension) { // File cacheDir = new File(context.getCacheDir(), directory); // File cacheFile = new File(cacheDir, filename + extension); // try { // return new FileInputStream(cacheFile); // } catch (FileNotFoundException e) { // Log.e(TAG, "new FileInputStream " + filename, e); // } // return null; // } // @Nullable // public static XmlPullParser getSettingsFromFile(@NonNull Context context, @NonNull String filename) { // XmlPullParser parser; // try { // XmlPullParserFactory xppf = XmlPullParserFactory.newInstance(); // //xppf.setNamespaceAware(true); // parser = xppf.newPullParser(); // } catch (XmlPullParserException e) { // //TODO: implement custom parser if this ever happens // Log.e(TAG, "XmlPullParserFactory::newPullParser", e); // return null; // } // InputStream inputStream = getInputStream(context, SETTINGS_FOLDER, filename, SETTINGS_EXT); // if (inputStream == null) // return null; // try { // parser.setInput(inputStream, StandardCharsets.UTF_8.name()); // } catch (XmlPullParserException e) { // Log.e(TAG, "XmlPullParser.setInput", e); // parser = null; // } // return parser; // } // @Nullable // public static XmlPullParser getXmlParser(@NonNull Context context, @Nullable Uri uri) { // return getXmlParser(context, getInputStream(context, uri)); // } @Nullable public static XmlPullParser getXmlParser(@NonNull Context context, @Nullable InputStream inputStream) { if (inputStream == null) return null; XmlPullParser parser = Xml.newPullParser(); try { parser.setInput(inputStream, StandardCharsets.UTF_8.name()); } catch (XmlPullParserException e) { Log.e(TAG, "XmlPullParser.setInput", e); parser = null; } return parser; } public static void chooseSettingsFile(@NonNull Activity activity, int requestCode) { chooseFile(activity, "text/xml", requestCode); } private static void chooseFile(@NonNull Activity activity, String type, int requestCode) { Intent intent = new Intent(Intent.ACTION_GET_CONTENT); intent.setTypeAndNormalize(type); intent.addCategory(Intent.CATEGORY_OPENABLE); try { activity.startActivityForResult( Intent.createChooser(intent, activity.getString(R.string.import_chooser)), requestCode); } catch (android.content.ActivityNotFoundException ex) { // Potentially direct the user to the Market with a Dialog Toast.makeText(activity, R.string.choose_file_activity_not_found, Toast.LENGTH_SHORT).show(); } } public static void closeQuietly(@Nullable Closeable c) { if (c == null) return; try { c.close(); } catch (Exception ignored) { } } /** * Copy file from Uri to Cache and return the cache file * * @param context application context * @param uri source file * @param filename cache file name * @return new cache file or null if we failed to copy */ @Nullable public static File copyFile(@NonNull Context context, @Nullable Uri uri, @NonNull String filename) { FileInputStream inputStream = getFileInputStream(context, uri); if (inputStream == null) return null; FileOutputStream outputStream = null; FileChannel inChannel = null; FileChannel outChannel = null; File cacheFile = new File(context.getCacheDir(), filename); try { outputStream = new FileOutputStream(cacheFile); // prepare channels inChannel = inputStream.getChannel(); outChannel = outputStream.getChannel(); // copy from `inChannel` to `outChannel` 8 KB at a time long amount = 8 * 1024; final long size = inChannel.size(); long position = 0; while (position < size) position += inChannel.transferTo(position, amount, outChannel); } catch (IOException e) { Log.e(TAG, "Failed to copy " + uri, e); // we must return null if we failed to copy cacheFile = null; } closeQuietly(inChannel); closeQuietly(outChannel); closeQuietly(inputStream); closeQuietly(outputStream); return cacheFile; } // private static BufferedReader loadFile(Context context, String directory, String filename, String extension) { // File cacheDir = new File(context.getCacheDir(), directory); // File cacheFile = new File(cacheDir, filename + extension); // // try { // InputStream is = new FileInputStream(cacheFile); // InputStreamReader isr = new InputStreamReader(is, StandardCharsets.UTF_8); // BufferedReader br = new BufferedReader(isr); // return br; //// String line; //// while ((line = br.readLine()) != null) { //// text.append(line); //// text.append('\n'); //// } //// br.close(); // } // catch (IOException ignored) { // } // return null; // } public interface ContentGenerator { void generate(Writer writer) throws IOException; } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/utils/FuzzyScore.java ================================================ package rocks.tbog.tblauncher.utils; import android.util.Pair; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import java.util.ArrayList; import java.util.Collections; import java.util.List; /** * A Sublime Text inspired fuzzy match algorithm * https://github.com/forrestthewoods/lib_fts/blob/master/docs/fuzzy_match.md *

* match("otw", "Power of the Wild", info) = true, info.score = 14 * match("otw", "Druid of the Claw", info) = true, info.score = -3 * match("otw", "Frostwolf Grunt", info) = true, info.score = -13 */ public class FuzzyScore { private final int patternLength; private final int[] patternChar; private final int[] patternLower; /** * bonus for adjacent matches */ private int adjacency_bonus; /** * bonus if match occurs after a separator */ private int separator_bonus; /** * bonus if match is uppercase and prev is lower */ private int camel_bonus; /** * penalty applied for every letter in str before the first match */ private int leading_letter_penalty; /** * maximum penalty for leading letters */ private int max_leading_letter_penalty; /** * penalty for every letter that doesn't matter */ private int unmatched_letter_penalty; private final MatchInfo matchInfo; public FuzzyScore(@NonNull int[] pattern, boolean detailedMatchIndices) { super(); patternLength = pattern.length; patternChar = new int[patternLength]; patternLower = new int[patternLength]; for (int i = 0; i < patternLower.length; i += 1) { patternChar[i] = pattern[i]; patternLower[i] = Character.toLowerCase(pattern[i]); } adjacency_bonus = 10; separator_bonus = 5; camel_bonus = 10; leading_letter_penalty = -3; max_leading_letter_penalty = -9; unmatched_letter_penalty = -1; if (detailedMatchIndices) { matchInfo = new MatchInfo(patternLength); } else { matchInfo = new MatchInfo(); } } public FuzzyScore(@NonNull int[] pattern) { this(pattern, true); } public int getPatternLength() { return patternLength; } public void setAdjacencyBonus(int adjacency_bonus) { this.adjacency_bonus = adjacency_bonus; } public void setSeparatorBonus(int separator_bonus) { this.separator_bonus = separator_bonus; } public void setCamelBonus(int camel_bonus) { this.camel_bonus = camel_bonus; } public void setLeadingLetterPenalty(int leading_letter_penalty) { this.leading_letter_penalty = leading_letter_penalty; } public void setMaxLeadingLetterPenalty(int max_leading_letter_penalty) { this.max_leading_letter_penalty = max_leading_letter_penalty; } public void setUnmatchedLetterPenalty(int unmatched_letter_penalty) { this.unmatched_letter_penalty = unmatched_letter_penalty; } public static String patternToString(@Nullable int[] pattern) { if (pattern == null) return "null"; int iMax = pattern.length - 1; if (iMax == -1) return "[]"; StringBuilder b = new StringBuilder(); b.append('['); for (int i = 0; ; i++) { b.appendCodePoint(pattern[i]); if (i == iMax) return b.append(']').toString(); } } @NonNull @Override public String toString() { return "FuzzyScore{" + "patternLength=" + patternLength + ", patternChar=" + patternToString(patternChar) + ", patternLower=" + patternToString(patternLower) + ", adjacency_bonus=" + adjacency_bonus + ", separator_bonus=" + separator_bonus + ", camel_bonus=" + camel_bonus + ", leading_letter_penalty=" + leading_letter_penalty + ", max_leading_letter_penalty=" + max_leading_letter_penalty + ", unmatched_letter_penalty=" + unmatched_letter_penalty + ", matchInfo=" + matchInfo + '}'; } /** * @param text string where to search * @return true if each character in pattern is found sequentially within text */ @NonNull public MatchInfo match(@NonNull CharSequence text) { int idx = 0; int idxCodepoint = 0; int textLength = text.length(); int[] codepoints = new int[Character.codePointCount(text, 0, textLength)]; while (idx < textLength) { int codepoint = Character.codePointAt(text, idx); codepoints[idxCodepoint] = codepoint; idx += Character.charCount(codepoint); idxCodepoint += 1; } return match(codepoints); } /** * @param text string converted to codepoints * @return true if each character in pattern is found sequentially within text */ @NonNull public MatchInfo match(@NonNull int[] text) { // Loop variables int score = 0; int patternIdx = 0; int strIdx = 0; int strLength = text.length; boolean prevMatched = false; boolean prevLower = false; boolean prevSeparator = true; // true so if first letter match gets separator bonus // Use "best" matched letter if multiple string letters match the pattern Integer bestLetter = null; Integer bestLower = null; Integer bestLetterIdx = null; int bestLetterScore = 0; if (matchInfo.matchedIndices != null) { matchInfo.matchedIndices.clear(); } // Loop over strings while (strIdx != strLength) { Integer patternChar = null; Integer patternLower = null; if (patternIdx != patternLength) { patternChar = this.patternChar[patternIdx]; patternLower = this.patternLower[patternIdx]; } int strChar = text[strIdx]; int strLower = Character.toLowerCase(strChar); int strUpper = Character.toUpperCase(strChar); boolean nextMatch = patternChar != null && patternLower == strLower; boolean rematch = bestLetter != null && bestLower == strLower; boolean advanced = nextMatch && bestLetter != null; boolean patternRepeat = bestLetter != null && patternChar != null && patternLower.equals(bestLower); if (advanced || patternRepeat) { score += bestLetterScore; if (matchInfo.matchedIndices != null) { matchInfo.matchedIndices.add(bestLetterIdx); } bestLetter = null; bestLower = null; bestLetterIdx = null; bestLetterScore = 0; } if (nextMatch || rematch) { int newScore = 0; // Apply penalty for each letter before the first pattern match // Note: std::max because penalties are negative values. So max is smallest penalty. if (patternIdx == 0) { int penalty = Math.max(strIdx * leading_letter_penalty, max_leading_letter_penalty); score += penalty; } // Apply bonus for consecutive bonuses if (prevMatched && !rematch) newScore += adjacency_bonus; // Apply bonus for matches after a separator if (prevSeparator) newScore += separator_bonus; // Apply bonus across camel case boundaries. Includes "clever" isLetter check. if (prevLower && strChar == strUpper && strLower != strUpper) newScore += camel_bonus; // Update pattern index IF the next pattern letter was matched if (nextMatch) ++patternIdx; // Update best letter in text which may be for a "next" letter or a "rematch" if (newScore >= bestLetterScore) { // Apply penalty for now skipped letter if (bestLetter != null) score += unmatched_letter_penalty; bestLetter = strChar; bestLower = strLower; bestLetterIdx = strIdx; bestLetterScore = newScore; } prevMatched = true; } else { score += unmatched_letter_penalty; prevMatched = false; } // Includes "clever" isLetter check. prevLower = strChar == strLower && strLower != strUpper; prevSeparator = Character.isWhitespace(strChar); ++strIdx; } // Apply score for last match if (bestLetter != null) { score += bestLetterScore; if (matchInfo.matchedIndices != null) { matchInfo.matchedIndices.add(bestLetterIdx); } } matchInfo.match = patternIdx == patternLength; if (matchInfo.match) { matchInfo.score = score; } return matchInfo; } public static final class MatchInfo { /** * higher is better match. Value has no intrinsic meaning. Range varies with pattern. * Can only compare scores with same search pattern. */ public int score = 0; public boolean match = false; public final ArrayList matchedIndices; public MatchInfo() { matchedIndices = null; } MatchInfo(int patternLength) { matchedIndices = new ArrayList<>(patternLength); } public MatchInfo(@NonNull MatchInfo o) { score = o.score; match = o.match; matchedIndices = o.matchedIndices != null ? new ArrayList<>(o.matchedIndices) : null; } @NonNull public List> getMatchedSequences() { return getMatchedSequences(matchedIndices); } @NonNull public static List> getMatchedSequences(@Nullable List matchedIndices) { if (matchedIndices == null) return Collections.emptyList(); // compute pair match indices List> positions = new ArrayList<>(matchedIndices.size()); int start = matchedIndices.get(0); int end = start + 1; for (int i = 1; i < matchedIndices.size(); i += 1) { if (end == matchedIndices.get(i)) { end += 1; } else { positions.add(new Pair<>(start, end)); start = matchedIndices.get(i); end = start + 1; } } positions.add(new Pair<>(start, end)); return positions; } public static MatchInfo copyOrNewInstance(@NonNull MatchInfo source, @Nullable MatchInfo destination) { if (destination == null || (destination.matchedIndices == null && source.matchedIndices != null)) return new MatchInfo(source); destination.score = source.score; destination.match = source.match; if (destination.matchedIndices != null) { destination.matchedIndices.clear(); if (source.matchedIndices != null) destination.matchedIndices.addAll(source.matchedIndices); } return destination; } @NonNull @Override public String toString() { return "MatchInfo{" + "score=" + score + ", match=" + match + ", matchedIndices=" + matchedIndices + '}'; } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/utils/GestureDetectorHelper.java ================================================ package rocks.tbog.tblauncher.utils; import android.util.Log; import android.view.GestureDetector; import androidx.core.view.GestureDetectorCompat; import java.lang.reflect.Field; public final class GestureDetectorHelper { private static final String TAG = "TBUtil"; public static void setGestureDetectorTouchSlop(GestureDetector gestureDetector, int value) { try { Field f_mTouchSlopSquare = GestureDetector.class.getDeclaredField("mTouchSlopSquare"); f_mTouchSlopSquare.setAccessible(true); f_mTouchSlopSquare.setInt(gestureDetector, value * value); } catch (NoSuchFieldException | IllegalAccessException | NullPointerException e) { Log.w(TAG, gestureDetector.toString(), e); } } public static void setGestureDetectorTouchSlop(GestureDetectorCompat gestureDetector, int value) { try { Field f_mImpl = GestureDetectorCompat.class.getDeclaredField("mImpl"); f_mImpl.setAccessible(true); Object mImpl = f_mImpl.get(gestureDetector); if (mImpl == null) { Log.w(TAG, f_mImpl + " is null"); return; } Class c_GDCIJellybeanMr2 = null; Class c_GDCIBase = null; try { c_GDCIJellybeanMr2 = Class.forName(GestureDetectorCompat.class.getName() + "$GestureDetectorCompatImplJellybeanMr2"); c_GDCIBase = Class.forName(GestureDetectorCompat.class.getName() + "$GestureDetectorCompatImplBase"); } catch (ClassNotFoundException ignored) { } if (c_GDCIJellybeanMr2 != null && c_GDCIJellybeanMr2.isInstance(mImpl)) { Field f_mDetector = c_GDCIJellybeanMr2.getDeclaredField("mDetector"); f_mDetector.setAccessible(true); Object mDetector = f_mDetector.get(mImpl); if (mDetector instanceof GestureDetector) setGestureDetectorTouchSlop((GestureDetector) mDetector, value); } else if (c_GDCIBase != null) { Field f_mTouchSlopSquare = c_GDCIBase.getDeclaredField("mTouchSlopSquare"); f_mTouchSlopSquare.setAccessible(true); f_mTouchSlopSquare.setInt(mImpl, value * value); } else { Log.w(TAG, "not handled: " + mImpl.getClass().toString()); } } catch (NoSuchFieldException | IllegalAccessException | NullPointerException e) { Log.w(TAG, gestureDetector.getClass().toString(), e); } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/utils/GoogleCalendarIcon.java ================================================ package rocks.tbog.tblauncher.utils; import android.content.ComponentName; import android.content.Context; import android.content.pm.PackageManager; import android.content.res.Resources; import android.content.res.TypedArray; import android.graphics.drawable.Drawable; import android.os.Bundle; import androidx.annotation.Nullable; import androidx.core.content.res.ResourcesCompat; import java.util.Calendar; /** * This class is used to display a custom icon for Google Calendar * Every day, the icon is different and display the current day * Credits: https://github.com/LawnchairLauncher/Lawnchair/blob/d12b30d5333c03969ad340eb9b4e1846c12a6a73/src/com/google/android/apps/nexuslauncher/DynamicIconProvider.java */ public class GoogleCalendarIcon { public static final String GOOGLE_CALENDAR = "com.google.android.calendar"; @Nullable public static Drawable getDrawable(Context context, String activityName) { // retrieve today's icon PackageManager pm = context.getPackageManager(); ComponentName cn = new ComponentName(GOOGLE_CALENDAR, activityName); try { Bundle metaData = pm.getActivityInfo(cn, PackageManager.GET_META_DATA | PackageManager.GET_UNINSTALLED_PACKAGES).metaData; Resources resourcesForApplication = pm.getResourcesForApplication(GOOGLE_CALENDAR); int dayResId = getDayResId(metaData, resourcesForApplication); if (dayResId != 0) { return ResourcesCompat.getDrawable(resourcesForApplication, dayResId, context.getTheme()); } } catch (PackageManager.NameNotFoundException ignored) { } return null; } private static int getDayResId(Bundle bundle, Resources resources) { if (bundle != null) { int dateArrayId = bundle.getInt(GOOGLE_CALENDAR + ".dynamic_icons_nexus_round", 0); if (dateArrayId != 0) { try { TypedArray dateIds = resources.obtainTypedArray(dateArrayId); int dateId = dateIds.getResourceId(getDayOfMonth(), 0); dateIds.recycle(); return dateId; } catch (Resources.NotFoundException ignored) { } } } return 0; } private static int getDayOfMonth() { return Calendar.getInstance().get(Calendar.DAY_OF_MONTH) - 1; } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/utils/ISparseArray.java ================================================ package rocks.tbog.tblauncher.utils; public interface ISparseArray { /** * Returns true if the key exists in the array. This is equivalent to * {@link #indexOfKey(int)} >= 0. * * @param key Potential key in the mapping * @return true if the key is defined in the mapping */ boolean contains(int key); /** * Gets the Object mapped from the specified key, or null * if no such mapping has been made. */ E get(int key); /** * Gets the Object mapped from the specified key, or the specified Object * if no such mapping has been made. */ E get(int key, E valueIfKeyNotFound); /** * Removes the mapping from the specified key, if there was any. */ void delete(int key); /** * Alias for {@link #delete(int)}. */ default void remove(int key) { delete(key); } /** * Removes the mapping at the specified index. */ void removeAt(int index); /** * Remove a range of mappings as a batch. * * @param index Index to begin at * @param size Number of mappings to remove * *

For indices outside of the range 0...size()-1, * the behavior is undefined.

*/ void removeAtRange(int index, int size); /** * Alias for {@link #put(int, Object)} to support Kotlin [index]= operator. * * @see #put(int, Object) */ default void set(int key, E value) { put(key, value); } /** * Adds a mapping from the specified key to the specified value, * replacing the previous mapping from the specified key if there * was one. */ void put(int key, E value); /** * Returns the number of key-value mappings that this SparseArray * currently stores. */ int size(); /** * Given an index in the range 0...size()-1, returns * the key from the indexth key-value mapping that this * SparseArray stores. * *

The keys corresponding to indices in ascending order are guaranteed to * be in ascending order, e.g., keyAt(0) will return the * smallest key and keyAt(size()-1) will return the largest * key.

*/ int keyAt(int index); /** * Given an index in the range 0...size()-1, returns * the value from the indexth key-value mapping that this * SparseArray stores. * *

The values corresponding to indices in ascending order are guaranteed * to be associated with keys in ascending order, e.g., * valueAt(0) will return the value associated with the * smallest key and valueAt(size()-1) will return the value * associated with the largest key.

*/ E valueAt(int index); /** * Given an index in the range 0...size()-1, sets a new * value for the indexth key-value mapping that this * SparseArray stores. */ void setValueAt(int index, E value); /** * Returns the index for which {@link #keyAt} would return the * specified key, or a negative number if the specified * key is not mapped. */ int indexOfKey(int key); /** * Returns an index for which {@link #valueAt} would return the * specified value, or a negative number if no keys map to the * specified value. *

Beware that this is a linear search, unlike lookups by key, * and that multiple keys can map to the same value and this will * find only one of them. *

Note also that unlike most collections' {@code indexOf} methods, * this method compares values using {@code ==} rather than {@code equals}. */ int indexOfValue(E value); /** * Removes all key-value mappings from this SparseArray. */ void clear(); /** * Puts a key/value pair into the array, optimizing for the case where * the key is greater than all existing keys in the array. */ void append(int key, E value); } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/utils/KeyboardToggleHelper.java ================================================ package rocks.tbog.tblauncher.utils; import android.view.View; import rocks.tbog.tblauncher.ui.WindowInsetsHelper; public class KeyboardToggleHelper extends WindowInsetsHelper { public boolean mHiddenByScrolling = false; public boolean mRequestOpen = false; public boolean mLaunchedApp = false; public KeyboardToggleHelper(View root) { super(root); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/utils/KeyboardTriggerBehaviour.java ================================================ package rocks.tbog.tblauncher.utils; import android.app.Activity; import android.os.Build; import android.util.Log; import android.view.View; import android.view.ViewTreeObserver; import androidx.core.view.ViewCompat; import androidx.lifecycle.LiveData; import rocks.tbog.tblauncher.TBApplication; public class KeyboardTriggerBehaviour extends LiveData { private static final String TAG = "KeyTB"; private final View contentView; private final ViewTreeObserver.OnGlobalLayoutListener globalLayoutListener; public KeyboardTriggerBehaviour(Activity activity) { super(Status.CLOSED); contentView = activity.findViewById(android.R.id.content); globalLayoutListener = () -> { TBApplication.state().syncKeyboardVisibility(contentView); boolean closed = TBApplication.state().isKeyboardHidden(); Status status = getValue(); Log.d(TAG, "[listener] state().isKeyboardHidden=" + closed + " status=" + status); if (closed && status != Status.CLOSED) postValue(Status.CLOSED); else if (!closed && status != Status.OPEN) postValue(Status.OPEN); }; } @Override protected void onActive() { super.onActive(); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { Log.d(TAG, "onActive - WindowInsetsListener"); ViewCompat.setOnApplyWindowInsetsListener(contentView, (v, insets) -> { globalLayoutListener.onGlobalLayout(); return insets; }); } else { Log.d(TAG, "onActive - GlobalLayoutListener"); contentView.getViewTreeObserver().addOnGlobalLayoutListener(globalLayoutListener); } // run the listener to update the current status globalLayoutListener.onGlobalLayout(); } @Override protected void onInactive() { super.onInactive(); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { Log.d(TAG, "onInactive - WindowInsetsListener"); ViewCompat.setOnApplyWindowInsetsListener(contentView, null); } else { Log.d(TAG, "onInactive - GlobalLayoutListener"); contentView.getViewTreeObserver().removeOnGlobalLayoutListener(globalLayoutListener); } } public enum Status {OPEN, CLOSED} } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/utils/MapCompat.java ================================================ package rocks.tbog.tblauncher.utils; import androidx.annotation.NonNull; import java.util.Map; public class MapCompat { public static V getOrDefault(@NonNull Map map, K key, V defaultValue) { V v = map.get(key); return ((v != null) || map.containsKey(key)) ? v : defaultValue; } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/utils/MimeTypeUtils.java ================================================ package rocks.tbog.tblauncher.utils; import static java.util.Collections.emptySet; import android.content.ContentUris; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.database.Cursor; import android.net.Uri; import android.preference.PreferenceManager; import android.provider.ContactsContract; import android.util.Log; import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.TreeSet; import rocks.tbog.tblauncher.Permission; public class MimeTypeUtils { // Known android mime types that are not supported by KISS private static final Set UNSUPPORTED_MIME_TYPES = new HashSet<>(Arrays.asList( ContactsContract.CommonDataKinds.Event.CONTENT_ITEM_TYPE, ContactsContract.CommonDataKinds.GroupMembership.CONTENT_ITEM_TYPE, ContactsContract.CommonDataKinds.Identity.CONTENT_ITEM_TYPE, ContactsContract.CommonDataKinds.Im.CONTENT_ITEM_TYPE, ContactsContract.CommonDataKinds.Nickname.CONTENT_ITEM_TYPE, ContactsContract.CommonDataKinds.Note.CONTENT_ITEM_TYPE, ContactsContract.CommonDataKinds.Organization.CONTENT_ITEM_TYPE, ContactsContract.CommonDataKinds.Photo.CONTENT_ITEM_TYPE, ContactsContract.CommonDataKinds.Relation.CONTENT_ITEM_TYPE, ContactsContract.CommonDataKinds.SipAddress.CONTENT_ITEM_TYPE, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE, ContactsContract.CommonDataKinds.StructuredPostal.CONTENT_ITEM_TYPE, ContactsContract.CommonDataKinds.Website.CONTENT_ITEM_TYPE )); private MimeTypeUtils() { } /** * @param context * @return a list of all supported mime types from existing contacts */ public static Set getSupportedMimeTypes(Context context) { if (!Permission.checkPermission(context, Permission.PERMISSION_READ_CONTACTS)) { return emptySet(); } Timer timer = Timer.startNano(); Set mimeTypes = new HashSet<>(); Cursor cursor = context.getContentResolver().query( ContactsContract.Data.CONTENT_URI, new String[]{ContactsContract.Data.MIMETYPE}, null, null, null); if (cursor != null) { if (cursor.getCount() > 0) { int mimeTypeIndex = cursor.getColumnIndex(ContactsContract.Data.MIMETYPE); while (cursor.moveToNext()) { String mimeType = cursor.getString(mimeTypeIndex); if (isSupportedMimeType(context, mimeType)) { mimeTypes.add(mimeType); } } } cursor.close(); } // always add classic phone contacts mimeTypes.add(ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE); Log.i("time", timer + " to load " + mimeTypes.size() + " supported mime types"); return mimeTypes; } private static boolean isSupportedMimeType(Context context, String mimeType) { if (mimeType == null) { return false; } if (UNSUPPORTED_MIME_TYPES.contains(mimeType)) { return false; } // check if intent for custom mime type is registered Intent intent = getRegisteredIntentByMimeType(context, mimeType, -1, ""); return intent != null; } /** * @param context * @return a list of all mime types that should be shown */ public static Set getActiveMimeTypes(Context context) { Timer timer = Timer.startNano(); SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); Set selectedMimeTypes = prefs.getStringSet("selected-contact-mime-types", getDefaultMimeTypes()); Set supportedMimeTypes = getSupportedMimeTypes(context); supportedMimeTypes.retainAll(selectedMimeTypes); Log.i("time", timer + " to load " + supportedMimeTypes.size() + " active mime types"); return supportedMimeTypes; } /** * Create a new intent to view given row of contact data. * * @param mimeType mimetype of contact data row * @param id id of contact data row * @param schemeSpecificPart * @return intent to view contact by mime type and id, null if no activity is registered for intent */ public static Intent getRegisteredIntentByMimeType(Context context, String mimeType, long id, String schemeSpecificPart) { final Intent intent = getIntentByMimeType(mimeType, id, schemeSpecificPart); if (isIntentRegistered(context, intent)) { return intent; } else { return null; } } /** * create a new intent to view given row of contact data * * @param mimeType mime type of contact data row * @param id id of contact data row * @param schemeSpecificPart * @return intent to view contact by mime type and id */ public static Intent getIntentByMimeType(String mimeType, long id, String schemeSpecificPart) { Intent intent; if (ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE.equals(mimeType)) { final Uri phoneUri = Uri.fromParts("tel", Uri.encode(schemeSpecificPart), null); intent = new Intent(Intent.ACTION_CALL, phoneUri); } else if (ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE.equals(mimeType)) { final Uri mailUri = Uri.fromParts("mailto", schemeSpecificPart, null); intent = new Intent(Intent.ACTION_SENDTO, mailUri); } else { intent = new Intent(Intent.ACTION_VIEW); final Uri uri = ContentUris.withAppendedId(ContactsContract.Data.CONTENT_URI, id); intent.setDataAndType(uri, mimeType); } intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); return intent; } /** * @param context * @param intent * @return true if any activity is registered for given intent */ private static boolean isIntentRegistered(Context context, Intent intent) { final PackageManager packageManager = context.getPackageManager(); final List receiverList = packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY); return receiverList.size() > 0; } /** * strip common vnd.android.cursor.item/ from mimeType * * @param mimeType * @return shortened version of mime type */ public static String getShortMimeType(String mimeType) { return mimeType.replaceFirst("vnd\\.android\\.cursor\\.item/", ""); } /** * @return mimeTypes that are shown by default */ public static Set getDefaultMimeTypes() { return new TreeSet<>(Collections.singletonList(ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE)); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/utils/PackageManagerUtils.java ================================================ package rocks.tbog.tblauncher.utils; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.net.Uri; import java.util.List; public class PackageManagerUtils { /** * Method to enable/disable a specific component */ public static void enableComponent(Context ctx, Class component, boolean enabled) { PackageManager pm = ctx.getPackageManager(); ComponentName cn = new ComponentName(ctx, component); pm.setComponentEnabledSetting(cn, enabled ? PackageManager.COMPONENT_ENABLED_STATE_ENABLED : PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP); } /** * Search best matching app for given intent. * * @param context context * @param intent intent * @return ResolveInfo for best matching app by intent */ public static ResolveInfo getBestResolve(Context context, Intent intent) { if (intent == null) { return null; } final PackageManager packageManager = context.getPackageManager(); final List matches = packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY); final int size = matches.size(); if (size == 0) { return null; } else if (size == 1) { return matches.get(0); } // Try finding preferred activity, otherwise detect disambiguation final ResolveInfo foundResolve = packageManager.resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY); final boolean foundDisambiguation = (foundResolve.match & IntentFilter.MATCH_CATEGORY_MASK) == 0; if (!foundDisambiguation) { // Found concrete match, so return directly return foundResolve; } // Accept first system app ResolveInfo firstSystem = null; for (ResolveInfo info : matches) { final boolean isSystem = (info.activityInfo.applicationInfo.flags & ApplicationInfo.FLAG_SYSTEM) != 0; if (isSystem && firstSystem == null) firstSystem = info; } // Return first system found, otherwise first from list return firstSystem != null ? firstSystem : matches.get(0); } /** * @param context context * @param intent intent * @return component name of best matching app for given intent */ public static ComponentName getComponentName(Context context, Intent intent) { ResolveInfo resolveInfo = getBestResolve(context, intent); if (resolveInfo != null) { return new ComponentName(resolveInfo.activityInfo.packageName, resolveInfo.activityInfo.name); } return null; } /** * @param context context * @param intent intent * @return label of best matching app for given intent */ public static String getLabel(Context context, Intent intent) { ResolveInfo resolveInfo = PackageManagerUtils.getBestResolve(context, intent); if (resolveInfo != null) { return String.valueOf(resolveInfo.loadLabel(context.getPackageManager())); } return null; } /** * @param context context * @param componentName componentName * @return launching component name for given component */ public static ComponentName getLaunchingComponent(Context context, ComponentName componentName) { if (componentName == null) { return null; } ComponentName launchingComponent = getLaunchingComponent(context, componentName.getPackageName()); if (launchingComponent != null) { return launchingComponent; } return componentName; } /** * @param context context * @param packageName package name * @return launching component name for given package */ public static ComponentName getLaunchingComponent(Context context, String packageName) { if (packageName == null) { return null; } Intent launchIntent = context.getPackageManager().getLaunchIntentForPackage(packageName); if (launchIntent != null) { return launchIntent.getComponent(); } return null; } /** * Creates intent to start activity with given uri. * Uri must have some given criteria to work: *

    *
  • it must contain an explicit schema (absolute)
  • *
  • the schema specific part must be longer than 2 (//...) so is some result that can be handled
  • *
* * @param uri * @return intent */ public static Intent createUriIntent(Uri uri) { if (uri.isAbsolute() && uri.getSchemeSpecificPart().length() > 2) { Intent intent = new Intent(Intent.ACTION_VIEW, uri); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); return intent; } return null; } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/utils/PrefCache.java ================================================ package rocks.tbog.tblauncher.utils; import android.content.Context; import android.content.SharedPreferences; import android.content.res.Resources; import android.graphics.Color; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; import android.util.Log; import androidx.annotation.DrawableRes; import androidx.annotation.LayoutRes; import androidx.annotation.NonNull; import androidx.appcompat.content.res.AppCompatResources; import androidx.collection.ArraySet; import androidx.preference.PreferenceManager; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.preference.ContentLoadHelper; import rocks.tbog.tblauncher.quicklist.QuickList; public class PrefCache { private final static ArraySet PREF_THAT_REQUIRE_MIGRATION = new ArraySet<>(Arrays.asList( "result-list-color", "result-list-alpha", "notification-bar-color", "notification-bar-alpha", "search-bar-color", "search-bar-alpha", "quick-list-color", "quick-list-alpha", "result-list-rounded", "search-bar-rounded" )); private static int RESULT_HISTORY_SIZE = 0; private static int RESULT_HISTORY_ADAPTIVE = 0; private static int RESULT_SEARCHER_CAP = -1; private static int LOADING_ICON_RES = 0; // Resources.ID_NULL private static Boolean FUZZY_SEARCH_TAGS = null; private static Boolean TAGS_MENU_ICONS = null; private static Boolean TAGS_MENU_UNTAGGED = null; private static int TAGS_MENU_UNTAGGED_IDX = -1; private static List RESULT_POPUP_ORDER = null; private PrefCache() { } public static void resetCache() { RESULT_HISTORY_SIZE = 0; RESULT_HISTORY_ADAPTIVE = 0; RESULT_SEARCHER_CAP = -1; LOADING_ICON_RES = 0; FUZZY_SEARCH_TAGS = null; TAGS_MENU_ICONS = null; TAGS_MENU_UNTAGGED = null; TAGS_MENU_UNTAGGED_IDX = -1; RESULT_POPUP_ORDER = null; } public static int getResultHistorySize(Context context) { if (RESULT_HISTORY_SIZE == 0) { SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context); final int defaultSize = context.getResources().getInteger(R.integer.default_result_history_size); RESULT_HISTORY_SIZE = pref.getInt("result-history-size", defaultSize); } return RESULT_HISTORY_SIZE; } public static int getHistoryAdaptive(Context context) { if (RESULT_HISTORY_ADAPTIVE == 0) { SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context); final int defaultSize = context.getResources().getInteger(R.integer.default_result_history_adaptive); RESULT_HISTORY_ADAPTIVE = pref.getInt("result-history-adaptive", defaultSize); } return RESULT_HISTORY_ADAPTIVE; } public static boolean showWidgetScreenAfterLaunch(SharedPreferences pref) { return pref.getBoolean("behaviour-widget-after-launch", true); } public static boolean clearSearchAfterLaunch(SharedPreferences pref) { return pref.getBoolean("behaviour-clear-search-after-launch", true); } public static boolean linkKeyboardAndSearchBar(SharedPreferences pref) { return pref.getBoolean("behaviour-link-keyboard-search-bar", true); } public static boolean getFuzzySearchTags(Context context) { if (FUZZY_SEARCH_TAGS == null) { SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context); FUZZY_SEARCH_TAGS = pref.getBoolean("fuzzy-search-tags", true); } return FUZZY_SEARCH_TAGS; } public static boolean showTagsMenuIcons(Context context) { if (TAGS_MENU_ICONS == null) { SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context); TAGS_MENU_ICONS = pref.getBoolean("tags-menu-icons", false); } return TAGS_MENU_ICONS; } public static boolean showTagsMenuUntagged(Context context) { if (TAGS_MENU_UNTAGGED == null) { SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context); TAGS_MENU_UNTAGGED = pref.getBoolean("tags-menu-untagged", false); try { TAGS_MENU_UNTAGGED_IDX = Integer.parseInt(pref.getString("tags-menu-untagged-index", "0")); } catch (Exception ignored) { } } return TAGS_MENU_UNTAGGED; } public static int getTagsMenuUntaggedIndex(Context context) { if (TAGS_MENU_UNTAGGED_IDX == -1) { SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context); try { TAGS_MENU_UNTAGGED_IDX = Integer.parseInt(pref.getString("tags-menu-untagged-index", "0")); } catch (Exception ignored) { } } return TAGS_MENU_UNTAGGED_IDX; } public static int getResultSearcherCap(Context context) { if (RESULT_SEARCHER_CAP == -1) { SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context); final int defaultCap = context.getResources().getInteger(R.integer.default_result_searcher_cap); RESULT_SEARCHER_CAP = pref.getInt("result-search-cap", defaultCap); if (RESULT_SEARCHER_CAP == 0) RESULT_SEARCHER_CAP = Integer.MAX_VALUE; } return RESULT_SEARCHER_CAP; } public static boolean modeEmptyQuickListVisible(SharedPreferences preferences) { return preferences.getBoolean("dm-empty-quick-list", false); } public static boolean modeEmptyFullscreen(SharedPreferences preferences) { return preferences.getBoolean("dm-empty-fullscreen", true); } public static boolean modeSearchQuickListVisible(SharedPreferences preferences) { return preferences.getBoolean("dm-search-quick-list", true); } public static boolean modeSearchFullscreen(SharedPreferences preferences) { return preferences.getBoolean("dm-search-fullscreen", false); } @NonNull public static String modeSearchOpenResult(SharedPreferences preferences) { String result = preferences.getString("dm-search-open-result", null); return result == null ? "none" : result; } public static boolean modeWidgetQuickListVisible(SharedPreferences preferences) { return preferences.getBoolean("dm-widget-quick-list", false); } public static boolean modeWidgetFullscreen(SharedPreferences preferences) { return preferences.getBoolean("dm-widget-fullscreen", false); } public static boolean searchBarAtBottom(SharedPreferences preferences) { return preferences.getBoolean("search-bar-at-bottom", true); } @LayoutRes public static int getSearchBarLayout(SharedPreferences pref) { String layout = pref.getString("search-bar-layout", null); if ("btn-text-menu".equals(layout)) return R.layout.search_bar; if ("pill-search".equals(layout)) return R.layout.search_pill; return 0; } public static boolean searchBarHasTimer(SharedPreferences pref) { String layout = pref.getString("search-bar-layout", null); return "pill-search".equals(layout); } public static QuickList.QuickListPosition getDockPosition(SharedPreferences pref) { String position = pref.getString("quick-list-position", null); if (position != null) { switch (position) { case "above-result-list": return QuickList.QuickListPosition.POSITION_ABOVE_RESULTS; case "under-result-list": return QuickList.QuickListPosition.POSITION_UNDER_RESULTS; case "under-search-bar": return QuickList.QuickListPosition.POSITION_UNDER_SEARCH_BAR; default: break; } } return QuickList.QuickListPosition.POSITION_UNDER_RESULTS; } public static boolean linkCloseKeyboardToBackButton(SharedPreferences preferences) { return preferences.getBoolean("behaviour-link-close-keyboard-back-button", true); } @DrawableRes public static int getLoadingIconRes(@NonNull Context context) { if (LOADING_ICON_RES == 0) { SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context); String iconName = pref.getString("loading-icon", null); if (iconName == null) iconName = "none"; switch (iconName) { case "arrows": LOADING_ICON_RES = R.drawable.ic_loading_arrows; break; case "pulse": LOADING_ICON_RES = R.drawable.ic_loading_pulse; break; case "none": default: LOADING_ICON_RES = android.R.color.transparent; break; } } return LOADING_ICON_RES; } @NonNull public static Drawable getLoadingIconDrawable(@NonNull Context context) { @DrawableRes int loadingIconRes = getLoadingIconRes(context); Drawable loadingIcon = AppCompatResources.getDrawable(context, loadingIconRes); if (loadingIcon == null) loadingIcon = new ColorDrawable(Color.TRANSPARENT); return loadingIcon; } public static boolean modulateContactIcons(Context context) { SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context); return pref.getBoolean("matrix-contacts", false); } public static List getResultPopupOrder(@NonNull Context context) { if (RESULT_POPUP_ORDER == null) { SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context); ContentLoadHelper.OrderedMultiSelectListData data = ContentLoadHelper.generateResultPopupContent(context, pref); List orderedValues = data.getOrderedListValues(); RESULT_POPUP_ORDER = new ArrayList<>(orderedValues.size()); for (String orderValue : orderedValues) { String value = PrefOrderedListHelper.getOrderedValueName(orderValue); for (ContentLoadHelper.CategoryItem categoryItem : ContentLoadHelper.RESULT_POPUP_CATEGORIES) { if (categoryItem.value.equals(value)) { RESULT_POPUP_ORDER.add(categoryItem); break; } } } } return RESULT_POPUP_ORDER; } public static boolean firstAtBottom(SharedPreferences preferences) { return preferences.getBoolean("result-first-at-bottom", true); } public static boolean rightToLeft(SharedPreferences preferences) { return preferences.getBoolean("result-right-to-left", true); } public static boolean getResultFadeOut(SharedPreferences pref) { return pref.getBoolean("result-fading-edge", false); } public static int getDockColumnCount(SharedPreferences pref) { return pref.getInt("quick-list-columns", 1); } public static int getDockRowCount(SharedPreferences pref) { return pref.getInt("quick-list-rows", 1); } public static boolean isMigrateRequired(@NonNull SharedPreferences pref) { Map allPref = pref.getAll(); for (String key : PREF_THAT_REQUIRE_MIGRATION) if (allPref.containsKey(key)) return true; return false; } public static boolean migratePreferences(@NonNull Context context, @NonNull SharedPreferences pref) { HashMap prefMapCopy = new HashMap<>(pref.getAll()); SharedPreferences.Editor editor = pref.edit(); boolean changesMade = migratePreferences(context, prefMapCopy, editor); editor.apply(); return changesMade; } public static boolean migratePreferences(@NonNull Context context, @NonNull HashMap entries, @NonNull SharedPreferences.Editor editor) { Resources res = context.getResources(); boolean changesMade; changesMade = migrateColor(entries, editor, "result-list"); changesMade = migrateColor(entries, editor, "notification-bar") || changesMade; changesMade = migrateColor(entries, editor, "search-bar") || changesMade; changesMade = migrateColor(entries, editor, "quick-list") || changesMade; int defaultCornerRadius = UISizes.px2dp(context, res.getDimensionPixelSize(R.dimen.result_corner_radius)); changesMade = migrateToggleToValue(entries, editor, "result-list-rounded", "result-list-radius", 0, defaultCornerRadius) || changesMade; changesMade = migrateToggleToValue(entries, editor, "search-bar-rounded", "search-bar-radius", 0, defaultCornerRadius) || changesMade; return changesMade; } private static boolean migrateColor(@NonNull HashMap entries, @NonNull SharedPreferences.Editor editor, String key) { String keyColor = key + "-color"; String keyAlpha = key + "-alpha"; Object color = entries.get(keyColor); Object alpha = entries.get(keyAlpha); if (color instanceof Integer && alpha instanceof Integer) { int argb = UIColors.setAlpha((Integer) color, (Integer) alpha); String keyARGB = key + "-argb"; editor .remove(keyColor) .remove(keyAlpha) .putInt(keyARGB, argb); Log.d("pref", "migrate `" + key + "` from " + "(alpha=0x" + Integer.toHexString((Integer) alpha) + " color=0x" + Integer.toHexString((Integer) color) + ")" + " to argb=0x" + Integer.toHexString(argb)); return true; } return false; } private static boolean migrateToggleToValue(HashMap entries, SharedPreferences.Editor editor, String keyToggle, String keyValue, int valueOff, int valueOn) { Object toggle = entries.get(keyToggle); if (toggle instanceof Boolean) { int value = ((Boolean) toggle) ? valueOn : valueOff; editor .remove(keyToggle) .putInt(keyValue, value); Log.d("pref", "migrate `" + keyToggle + "` from " + "value=" + toggle + " to `" + keyValue + "` value=" + value); return true; } return false; } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/utils/PrefOrderedListHelper.java ================================================ package rocks.tbog.tblauncher.utils; import android.content.SharedPreferences; import android.util.Log; import androidx.annotation.NonNull; import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Set; public class PrefOrderedListHelper { private static final String TAG = "Order"; /** * Synchronize the toggle list with the order list. Remove toggled off entries and add at the end new ones. * * @param sharedPreferences we get the list from here and apply the changes to * @param listKey preference key of the list * @param orderKey preference key of the order list */ public static void syncOrderedList(@NonNull SharedPreferences sharedPreferences, @NonNull String listKey, @NonNull String orderKey) { // get list values in a set I can modify Set listSet = new HashSet<>(sharedPreferences.getStringSet(listKey, Collections.emptySet())); final int listSize = listSet.size(); // get order final List orderValues; Set orderSet = sharedPreferences.getStringSet(orderKey, null); if (orderSet == null) { // we don't have any order yet orderValues = Collections.emptyList(); } else { orderValues = new ArrayList<>(orderSet); Collections.sort(orderValues); } // this will be the new order ArrayList newValues = new ArrayList<>(listSize); // keep previous order int idx = 0; for (String value : orderValues) { String name = getOrderedValueName(value); if (listSet.remove(name)) { newValues.add(makeOrderedValue(name, idx++)); } } // add at the end all the new values for (String name : listSet) newValues.add(makeOrderedValue(name, idx++)); Set newOrderSet = new HashSet<>(newValues); if (!newOrderSet.equals(orderSet)) sharedPreferences.edit().putStringSet(orderKey, newOrderSet).apply(); } public static List getOrderedList(@NonNull SharedPreferences sharedPreferences, @NonNull String listKey, @NonNull String orderKey) { syncOrderedList(sharedPreferences, listKey, orderKey); Set orderSet = sharedPreferences.getStringSet(orderKey, Collections.emptySet()); List orderValues = new ArrayList<>(orderSet); Collections.sort(orderValues); return orderValues; } public static String getOrderedValueName(String value) { int pos = value.indexOf(". "); pos = pos >= 0 ? pos + 2 : 0; return value.substring(pos); } public static int getOrderedValueIndex(String value) { int pos = value.indexOf(". "); int order = 0; if (pos > 0) { String hexOrder = value.substring(0, pos); try { order = Integer.parseInt(hexOrder, 16); } catch (Exception e) { Log.e(TAG, "parse `" + hexOrder + "` in base 16", e); } } else { Log.e(TAG, "invalid ordered value `" + value + "`"); } return order; } public static String makeOrderedValue(String name, int position) { return String.format(Locale.US, "%08x. %s", position, name); } public static ArrayList getOrderedArrayList(CharSequence[] entryValues) { ArrayList orderedValues = new ArrayList<>(entryValues.length); int ord = 0; for (CharSequence value : entryValues) { String orderedValue = makeOrderedValue(value.toString(), ord); orderedValues.add(orderedValue); ord += 1; } return orderedValues; } public static ArrayList getOrderedArrayList(List entryValues) { ArrayList orderedValues = new ArrayList<>(entryValues.size()); int ord = 0; for (String value : entryValues) { String orderedValue = makeOrderedValue(value, ord); orderedValues.add(orderedValue); ord += 1; } return orderedValues; } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/utils/RootHandler.java ================================================ package rocks.tbog.tblauncher.utils; import android.content.SharedPreferences; import android.util.Log; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; public class RootHandler { private static final Charset UTF_8 = StandardCharsets.UTF_8; private Boolean isRootAvailable = null; private Boolean isRootActivated = null; public RootHandler(SharedPreferences prefs) { resetRootHandler(prefs); } public boolean isRootActivated() { return this.isRootActivated; } public void resetRootHandler(SharedPreferences prefs) { isRootActivated = prefs.getBoolean("root-mode", false); isRootAvailable = null; } public boolean isRootAvailable() { if (isRootAvailable == null) { try { isRootAvailable = executeRootShell(null); } catch (Exception e) { isRootAvailable = false; } } return isRootAvailable; } public boolean hibernateApp(String packageName) { try { return executeRootShell("am force-stop " + packageName); } catch (Exception e) { return false; } } private boolean executeRootShell(String command) { Process p = null; try { p = Runtime.getRuntime().exec("su"); //put command if (command != null && !command.trim().equals("")) { p.getOutputStream().write((command + "\n").getBytes(UTF_8)); } //exit from su command p.getOutputStream().write("exit\n".getBytes(UTF_8)); p.getOutputStream().flush(); p.getOutputStream().close(); int result = p.waitFor(); if (result != 0) throw new Exception("Command execution failed " + result); return true; } catch (Exception e) { Log.e("RootHandler", command, e); } finally { if (p != null) { p.destroy(); } } return false; } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/utils/SimpleTextWatcher.java ================================================ package rocks.tbog.tblauncher.utils; import android.text.Editable; import android.text.TextWatcher; public abstract class SimpleTextWatcher implements 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) { onTextChanged(s.toString()); } public abstract void onTextChanged(String newValue); } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/utils/SimpleXmlWriter.java ================================================ package rocks.tbog.tblauncher.utils; import android.util.Xml; import androidx.annotation.Nullable; import org.xmlpull.v1.XmlSerializer; import java.io.IOException; import java.io.OutputStream; import java.io.Writer; import java.nio.charset.StandardCharsets; /** * Wrapper class over most probably com.android.org.kxml2.io.KXmlSerializer * Because it's a platform dependent class we can't extend. */ public class SimpleXmlWriter implements XmlSerializer { private final XmlSerializer xmlSerializer; private String namespace = null; private SimpleXmlWriter(XmlSerializer serializer) { xmlSerializer = serializer; } public static SimpleXmlWriter getNewInstance() { /* * Returns a new instance of the platform default {@link XmlSerializer} more efficiently than * using {@code XmlPullParserFactory.newInstance().newSerializer()}. */ XmlSerializer serializer = Xml.newSerializer(); return new SimpleXmlWriter(serializer); } public boolean setIndentation(boolean turnOn, @Nullable String indentString) { try { xmlSerializer.setFeature("http://xmlpull.org/v1/doc/features.html#indent-output", turnOn); if (indentString != null) xmlSerializer.setProperty("http://xmlpull.org/v1/doc/properties.html#serializer-indentation", indentString); } catch (Exception e) { return false; } return true; } public boolean setIndentation(boolean turnOn) { return setIndentation(turnOn, null); } /** * Most probably not supported * * @param separator line separator * @return true if serializer property exists */ public boolean setLineSeparator(String separator) { try { xmlSerializer.setProperty("http://xmlpull.org/v1/doc/properties.html#serializer-line-separator", separator); } catch (Exception e) { return false; } return true; } public void setCurrentNamespace(String namespace) { this.namespace = namespace; } @Override public void setFeature(String name, boolean state) throws IllegalArgumentException, IllegalStateException { xmlSerializer.setFeature(name, state); } @Override public boolean getFeature(String name) { return xmlSerializer.getFeature(name); } @Override public void setProperty(String name, Object value) throws IllegalArgumentException, IllegalStateException { xmlSerializer.setProperty(name, value); } @Override public Object getProperty(String name) { return xmlSerializer.getProperty(name); } @Override public void setOutput(OutputStream os, String encoding) throws IOException, IllegalArgumentException, IllegalStateException { xmlSerializer.setOutput(os, encoding); } @Override public void setOutput(Writer writer) throws IOException, IllegalArgumentException, IllegalStateException { xmlSerializer.setOutput(writer); } @Override public void startDocument(String encoding, Boolean standalone) throws IOException, IllegalArgumentException, IllegalStateException { xmlSerializer.startDocument(encoding, standalone); } public void startDocument() throws IOException, IllegalArgumentException, IllegalStateException { xmlSerializer.startDocument(StandardCharsets.UTF_8.name(), true); } @Override public void endDocument() throws IOException, IllegalArgumentException, IllegalStateException { xmlSerializer.endDocument(); } @Override public void setPrefix(String prefix, String namespace) throws IOException, IllegalArgumentException, IllegalStateException { xmlSerializer.setPrefix(prefix, namespace); } @Override public String getPrefix(String namespace, boolean generatePrefix) throws IllegalArgumentException { return xmlSerializer.getPrefix(namespace, generatePrefix); } @Override public int getDepth() { return xmlSerializer.getDepth(); } @Override public String getNamespace() { return xmlSerializer.getNamespace(); } @Override public String getName() { return xmlSerializer.getName(); } @Override public XmlSerializer startTag(String namespace, String name) throws IOException, IllegalArgumentException, IllegalStateException { return xmlSerializer.startTag(namespace, name); } public SimpleXmlWriter startTag(String name) throws IOException, IllegalArgumentException, IllegalStateException { xmlSerializer.startTag(namespace, name); return this; } @Override public XmlSerializer attribute(String namespace, String name, String value) throws IOException, IllegalArgumentException, IllegalStateException { return xmlSerializer.attribute(namespace, name, value); } public SimpleXmlWriter attribute(String name, String value) throws IOException, IllegalArgumentException, IllegalStateException { xmlSerializer.attribute(namespace, name, value); return this; } public SimpleXmlWriter attribute(String name, int value) throws IOException, IllegalArgumentException, IllegalStateException { xmlSerializer.attribute(namespace, name, Integer.toString(value)); return this; } public SimpleXmlWriter attribute(String name, long value) throws IOException, IllegalArgumentException, IllegalStateException { xmlSerializer.attribute(namespace, name, Long.toString(value)); return this; } @Override public XmlSerializer endTag(String namespace, String name) throws IOException, IllegalArgumentException, IllegalStateException { return xmlSerializer.endTag(namespace, name); } public SimpleXmlWriter endTag(String name) throws IOException, IllegalArgumentException, IllegalStateException { xmlSerializer.endTag(namespace, name); return this; } @Override public XmlSerializer text(String text) throws IOException, IllegalArgumentException, IllegalStateException { return xmlSerializer.text(text); } public SimpleXmlWriter content(String text) throws IOException, IllegalArgumentException, IllegalStateException { xmlSerializer.text(text); return this; } public SimpleXmlWriter content(int amount) throws IOException, IllegalArgumentException, IllegalStateException { xmlSerializer.text(String.valueOf(amount)); return this; } @Override public XmlSerializer text(char[] buf, int start, int len) throws IOException, IllegalArgumentException, IllegalStateException { return xmlSerializer.text(buf, start, len); } public SimpleXmlWriter content(byte[] buf) throws IOException, IllegalArgumentException, IllegalStateException { final int size = buf.length; char[] text = new char[size]; for (int i = 0; i < size; i += 1) text[i] = (char) buf[i]; xmlSerializer.text(text, 0, size); return this; } /** * Wrap text in tags and make sure we have valid chars * boolean valid = (ch >= 0x20 && ch <= 0xd7ff) || * (ch == '\t' || ch == '\n' || ch == '\r') || * (ch >= 0xe000 && ch <= 0xfffd); * * @param text to be converted to char[] by calling toCharArray() * @throws IOException from writer * @throws IllegalArgumentException when an invalid char is found or from writer * @throws IllegalStateException from writer */ @Override public void cdsect(String text) throws IOException, IllegalArgumentException, IllegalStateException { xmlSerializer.cdsect(text); } @Override public void entityRef(String text) throws IOException, IllegalArgumentException, IllegalStateException { xmlSerializer.entityRef(text); } @Override public void processingInstruction(String text) throws IOException, IllegalArgumentException, IllegalStateException { xmlSerializer.processingInstruction(text); } @Override public void comment(String text) throws IOException, IllegalArgumentException, IllegalStateException { xmlSerializer.comment(text); } /** * Write * * @param text the text inside DOCTYPE * @throws IOException from writer * @throws IllegalArgumentException from writer * @throws IllegalStateException from writer */ @Override public void docdecl(String text) throws IOException, IllegalArgumentException, IllegalStateException { xmlSerializer.docdecl(text); } @Override public void ignorableWhitespace(String text) throws IOException, IllegalArgumentException, IllegalStateException { xmlSerializer.ignorableWhitespace(text); } @Override public void flush() throws IOException { xmlSerializer.flush(); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/utils/SparseArrayWrapper.java ================================================ /* * Copyright (C) 2006 The Android Open Source Project * * 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 rocks.tbog.tblauncher.utils; import android.os.Build; import android.util.SparseArray; public class SparseArrayWrapper implements ISparseArray { protected SparseArray mArray; protected int mCapacity; /** * Creates a new SparseArray containing no mappings. */ public SparseArrayWrapper() { this(0); } /** * Creates a new SparseArray containing no mappings that will not * require any additional memory allocation to store the specified * number of mappings. */ public SparseArrayWrapper(int initialCapacity) { mCapacity = initialCapacity; mArray = new SparseArray<>(mCapacity); } /** * Increases the capacity of this ArrayList instance, if necessary, * to ensure that it can hold at least the number of elements * specified by the minimum capacity argument. * Should only be used with an empty array because the copy * operation is not optimized * * @param capacity the desired minimum capacity */ public void ensureCapacity(int capacity) { if (mCapacity < capacity) { SparseArray oldArray = mArray; int size = oldArray.size(); mCapacity = capacity; mArray = new SparseArray<>(mCapacity); for (int index = 0; index < size; index += 1) { int key = oldArray.keyAt(index); E value = oldArray.valueAt(index); mArray.append(key, value); } } } @Override public boolean contains(int key) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { return mArray.contains(key); } return mArray.indexOfKey(key) >= 0; } @Override public E get(int key) { return mArray.get(key); } @Override public E get(int key, E valueIfKeyNotFound) { return mArray.get(key, valueIfKeyNotFound); } @Override public void delete(int key) { mArray.delete(key); } @Override public void removeAt(int index) { mArray.removeAt(index); } @Override public void removeAtRange(int index, int size) { mArray.removeAtRange(index, size); } @Override public void put(int key, E value) { mArray.put(key, value); updateCapacity(); } @Override public int size() { return mArray.size(); } public int capacity() { return mCapacity; } @Override public int keyAt(int index) { return mArray.keyAt(index); } @Override public E valueAt(int index) { return mArray.valueAt(index); } @Override public void setValueAt(int index, E value) { mArray.setValueAt(index, value); } @Override public int indexOfKey(int key) { return mArray.indexOfKey(key); } @Override public int indexOfValue(E value) { return mArray.indexOfValue(value); } @Override public void clear() { mArray.clear(); } @Override public void append(int key, E value) { mArray.append(key, value); updateCapacity(); } protected void updateCapacity() { int size = mArray.size(); if (size > mCapacity) mCapacity = size; } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/utils/SystemUiVisibility.java ================================================ package rocks.tbog.tblauncher.utils; import android.os.Build; import android.view.View; import androidx.annotation.RequiresApi; public class SystemUiVisibility { // Note that some of these constants are new as of API 16 (Jelly Bean) // and API 19 (KitKat). It is safe to use them, as they are inlined // at compile-time and do nothing on earlier devices. private static final int REMOVE_STATUS_AND_NAVIGATION = View.SYSTEM_UI_FLAG_LOW_PROFILE | View.SYSTEM_UI_FLAG_FULLSCREEN | View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION; private static final int SHOW_SYSTEM_BARS = View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION; private static int setFlags(int flags, int visibility) { return flags | visibility; } private static int clearFlags(int flags, int visibility) { return flags & ~visibility; } @RequiresApi(api = Build.VERSION_CODES.M) public static void setLightStatusBar(View view) { int flags = view.getSystemUiVisibility(); flags = setFlags(flags, View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR); view.setSystemUiVisibility(flags); } @RequiresApi(api = Build.VERSION_CODES.M) public static void clearLightStatusBar(View view) { int flags = view.getSystemUiVisibility(); flags = clearFlags(flags, View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR); view.setSystemUiVisibility(flags); } @RequiresApi(api = Build.VERSION_CODES.O) public static void setLightNavigationBar(View view) { int flags = view.getSystemUiVisibility(); flags = setFlags(flags, View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR); view.setSystemUiVisibility(flags); } @RequiresApi(api = Build.VERSION_CODES.O) public static void clearLightNavigationBar(View view) { int flags = view.getSystemUiVisibility(); flags = clearFlags(flags, View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR); view.setSystemUiVisibility(flags); } public static void setFullscreen(View view) { int flags = view.getSystemUiVisibility(); flags = clearFlags(flags, SHOW_SYSTEM_BARS); // removal of status and navigation bar flags = setFlags(flags, REMOVE_STATUS_AND_NAVIGATION); view.setSystemUiVisibility(flags); } public static void clearFullscreen(View view) { int flags = view.getSystemUiVisibility(); flags = clearFlags(flags, REMOVE_STATUS_AND_NAVIGATION); // Show the system bar flags = setFlags(flags, SHOW_SYSTEM_BARS); view.setSystemUiVisibility(flags); } public static boolean isFullscreenSet(View view) { int currentFlags = view.getSystemUiVisibility(); int flags = clearFlags(currentFlags, SHOW_SYSTEM_BARS); flags = setFlags(flags, REMOVE_STATUS_AND_NAVIGATION); return currentFlags == flags; } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/utils/Timer.java ================================================ package rocks.tbog.tblauncher.utils; import androidx.annotation.NonNull; import java.util.concurrent.TimeUnit; public class Timer { protected long mStart; protected long mStop; protected TimeUnit mUnit; public static final StopTimeComparator STOP_TIME_COMPARATOR = new StopTimeComparator(); public Timer() { this(0, TimeUnit.MILLISECONDS); } protected Timer(long now, @NonNull TimeUnit unit) { mStart = now; mStop = now; mUnit = unit; } public static Timer startNano() { return new Timer(System.nanoTime(), TimeUnit.NANOSECONDS); } public static Timer startMilli() { return new Timer(System.currentTimeMillis(), TimeUnit.MILLISECONDS); } public void start() { if (mUnit == TimeUnit.NANOSECONDS) { mStart = System.nanoTime(); } else { mStart = System.currentTimeMillis(); } mStop = mStart; } public void stop() { if (mUnit == TimeUnit.NANOSECONDS) { mStop = System.nanoTime(); } else { mStop = System.currentTimeMillis(); } //return mStop - mStart; } @NonNull @Override public String toString() { if (mUnit == TimeUnit.NANOSECONDS && mUnit.toSeconds(mStop - mStart) == 0) { long deltaTime = mUnit.toNanos(mStop - mStart); long ms = TimeUnit.NANOSECONDS.toMillis(deltaTime); if (ms == 0) return deltaTime + "ns"; long ns = deltaTime - TimeUnit.MILLISECONDS.toNanos(ms); if (ns > 0) return ms + "ms " + ns + "ns"; return ms + "ms"; } return toStringSeconds(); } @NonNull public String toStringSeconds() { long deltaTime = mUnit.toMillis(mStop - mStart); long s = TimeUnit.MILLISECONDS.toSeconds(deltaTime); long ms = deltaTime - TimeUnit.SECONDS.toMillis(s); if (s == 0) return ms + "ms"; if (ms > 0) return s + "sec " + ms + "ms"; return s + "sec"; } public static class StopTimeComparator implements java.util.Comparator { @Override public int compare(Timer o1, Timer o2) { if (o1 == o2) return 0; if (o1 == null) return -1; if (o2 == null) return 1; return (int) (o1.mStop - o2.mStop); } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/utils/UIColors.java ================================================ package rocks.tbog.tblauncher.utils; import android.content.Context; import android.content.SharedPreferences; import android.content.res.Resources; import android.graphics.Color; import android.graphics.ColorFilter; import android.graphics.ColorMatrix; import android.graphics.ColorMatrixColorFilter; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; import android.graphics.drawable.GradientDrawable; import android.os.Build; import android.util.TypedValue; import android.view.Window; import android.view.WindowManager; import androidx.annotation.AttrRes; import androidx.annotation.ColorInt; import androidx.annotation.FloatRange; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.view.ContextThemeWrapper; import androidx.core.graphics.ColorUtils; import androidx.preference.PreferenceManager; import rocks.tbog.tblauncher.R; public final class UIColors { public static final int COLOR_DEFAULT = 0xFF3cb371; private static int CACHED_SYSTEM_ACCENT = 0; private static int CACHED_COLOR_HIGHLIGHT = 0; private static int CACHED_COLOR_RESULT_TEXT = 0; private static int CACHED_COLOR_RESULT_TEXT2 = 0; private static int CACHED_COLOR_RESULT_SHADOW = 0; private static int CACHED_COLOR_QL_TOGGLE = 0; private static int CACHED_RIPPLE_QL = 0; private static int CACHED_COLOR_CONTACT_ACTION = 0; private static int CACHED_COLOR_SEARCH_TEXT = 0; private static int CACHED_COLOR_SEARCH_SHADOW = 0; private static Integer CACHED_BACKGROUND_RESULT_LIST = null; private static int CACHED_RIPPLE_RESULT_LIST = 0; private static Integer CACHED_BACKGROUND_ICON = null; private static Integer CACHED_COLOR_POPUP_BORDER = null; private static Integer CACHED_COLOR_POPUP_BACKGROUND = null; private static int CACHED_RIPPLE_POPUP = 0; private static int CACHED_COLOR_POPUP_TEXT = 0; private static int CACHED_COLOR_POPUP_TITLE = 0; private static int CACHED_COLOR_POPUP_SHADOW = 0; private static boolean CACHED_MAT_ICON = false; private static ColorMatrix COLOR_MATRIX_ICON = null; private UIColors() { } public static void resetCache() { CACHED_SYSTEM_ACCENT = 0; CACHED_COLOR_HIGHLIGHT = 0; CACHED_COLOR_RESULT_TEXT = 0; CACHED_COLOR_RESULT_TEXT2 = 0; CACHED_COLOR_RESULT_SHADOW = 0; CACHED_COLOR_QL_TOGGLE = 0; CACHED_RIPPLE_QL = 0; CACHED_COLOR_CONTACT_ACTION = 0; CACHED_COLOR_SEARCH_TEXT = 0; CACHED_COLOR_SEARCH_SHADOW = 0; CACHED_BACKGROUND_RESULT_LIST = null; CACHED_RIPPLE_RESULT_LIST = 0; CACHED_BACKGROUND_ICON = null; CACHED_COLOR_POPUP_BORDER = null; CACHED_COLOR_POPUP_BACKGROUND = null; CACHED_RIPPLE_POPUP = 0; CACHED_COLOR_POPUP_TEXT = 0; CACHED_COLOR_POPUP_TITLE = 0; CACHED_COLOR_POPUP_SHADOW = 0; CACHED_MAT_ICON = false; } public static int getDefaultColor(Context context) { return COLOR_DEFAULT; } @ColorInt public static int getThemeColor(Context context, @AttrRes int idRes) { TypedValue typedValue = new TypedValue(); Resources.Theme theme = context.getTheme(); theme.resolveAttribute(idRes, typedValue, true); return typedValue.data; } private static int getSystemAccent(Context context) { int color = COLOR_DEFAULT; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { ContextThemeWrapper contextThemeWrapper = new ContextThemeWrapper(context, android.R.style.Theme_DeviceDefault); color = getThemeColor(contextThemeWrapper, android.R.attr.colorAccent); } // Oxygen OS accent color, also used by some custom ROMs now String propertyValue = Utilities.getSystemProperty("persist.sys.theme.accentcolor", ""); if (!propertyValue.isEmpty()) { if (!propertyValue.startsWith("#")) propertyValue = "#" + propertyValue; try { color = Color.parseColor(propertyValue); } catch (IllegalArgumentException ignored) { } } return color; } @ColorInt public static int getColor(SharedPreferences pref, String key, @ColorInt int defaultColor) { return pref.getInt(key, defaultColor); } @ColorInt public static int getColor(SharedPreferences pref, String key) { return getColor(pref, key, COLOR_DEFAULT); } public static int getAlpha(SharedPreferences pref, String key) { return pref.getInt(key, 0xFF) & 0xFF; } public static int setAlpha(int color, int alpha) { return color & 0x00ffffff | ((alpha & 0xFF) << 24); } /** * Returns the relative luminance of a color. * Code adapted from ColorUtils::calculateLuminance(@ColorInt int color) * https://android.googlesource.com/platform/frameworks/base/+/master/core/java/com/android/internal/graphics/ColorUtils.java * * @return a value between 0 (darkest black) and 1 (lightest white) */ @FloatRange(from = 0.f, to = 1.f) public static float luminance(@ColorInt int color) { int r = Color.red(color); int g = Color.green(color); int b = Color.blue(color); // Convert RGB components to its CIE XYZ representative components. float sr = r / 255f; sr = sr < 0.04045f ? sr / 12.92f : (float) Math.pow((sr + 0.055) / 1.055, 2.4); float sg = g / 255f; sg = sg < 0.04045f ? sg / 12.92f : (float) Math.pow((sg + 0.055) / 1.055, 2.4); float sb = b / 255f; sb = sb < 0.04045f ? sb / 12.92f : (float) Math.pow((sb + 0.055) / 1.055, 2.4); return (sr * 0.2126f + sg * 0.7152f + sb * 0.0722f); } public static boolean isColorLight(@ColorInt int color) { return luminance(color) > .5f; } /** * Darken or lighten the color. For amount 2 the result is white, for 1 color is unchanged, for 0 result is black * * @param color color to be changed * @param amount [0..2] - less than 1 to darken and grater to lighten */ public static int modulateColorLightness(@ColorInt int color, @FloatRange(from = 0.f, to = 2.f) float amount) { float[] hsl = new float[3]; ColorUtils.colorToHSL(color, hsl); if (amount <= 1f) hsl[2] = Math.max(0f, hsl[2] * amount); else { final float ratio = amount - 1f; final float inverseRatio = 1f - ratio; hsl[2] = Math.min(1f, hsl[2] * inverseRatio + ratio); } return ColorUtils.HSLToColor(hsl); } /** * The Web Content Accessibility Guidelines (WCAG 2.0) level AA requires a 4.5:1 color contrast between text and background for normal text, and 3:1 to large text. * * @param background background color * @return text color for large text */ public static int getTextContrastColor(@ColorInt int background) { int result = -1; float lumBack = UIColors.luminance(background); float min = 0f; float max = 1f; int count = 0; float ratio; // use binary search to find a text color to satisfy the color contrast while (min < max) { float mid = (min + max) * .5f; float modulateAmount = lumBack < .5f ? (1f + mid) : (1f - mid); int text = UIColors.modulateColorLightness(background, modulateAmount); if (++count > 10) { if (result == -1) result = text; break; } float lumText = UIColors.luminance(text); if (lumText >= lumBack) { ratio = (lumText + .05f) / (lumBack + .05f); } else { ratio = (lumBack + .05f) / (lumText + .05f); } if (ratio < 4.5f) // 4.5:1 ratio min = mid; else { max = mid; result = text; } } // return opaque color return result | 0xFF000000; } public static void setStatusBarColor(AppCompatActivity compatActivity, @ColorInt int notificationBarColor) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { Window window = compatActivity.getWindow(); window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS); window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS); // Update status bar color window.setStatusBarColor(notificationBarColor); } ActionBar actionBar = compatActivity.getSupportActionBar(); if (actionBar != null) { actionBar.setBackgroundDrawable(new ColorDrawable(notificationBarColor)); } } public static void setNavigationBarColor(AppCompatActivity activity, int color, int divColor) { Window window = activity.getWindow(); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS); window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS); window.setNavigationBarColor(color); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { window.setNavigationBarDividerColor(divColor); } } } public static int getSystemAccentColor(Context context) { if (CACHED_SYSTEM_ACCENT == 0) { int accent = getSystemAccent(context); CACHED_SYSTEM_ACCENT = setAlpha(accent, 0xFF); } return CACHED_SYSTEM_ACCENT; } public static int getResultHighlightColor(Context context) { if (CACHED_COLOR_HIGHLIGHT == 0) { SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context); int highlightColor = getColor(pref, "result-highlight-color"); CACHED_COLOR_HIGHLIGHT = setAlpha(highlightColor, 0xFF); } return CACHED_COLOR_HIGHLIGHT; } public static int getResultTextColor(Context context) { if (CACHED_COLOR_RESULT_TEXT == 0) { SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context); int highlightColor = pref.getInt("result-text-color", 0xffffff); CACHED_COLOR_RESULT_TEXT = setAlpha(highlightColor, 0xFF); } return CACHED_COLOR_RESULT_TEXT; } public static int getResultText2Color(Context context) { if (CACHED_COLOR_RESULT_TEXT2 == 0) { SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context); int highlightColor = pref.getInt("result-text2-color", 0xbbffbb); CACHED_COLOR_RESULT_TEXT2 = setAlpha(highlightColor, 0xFF); } return CACHED_COLOR_RESULT_TEXT2; } public static int getResultListShadowColor(Context context) { if (CACHED_COLOR_RESULT_SHADOW == 0) { SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context); int color = UIColors.getColor(pref, "result-shadow-color"); CACHED_COLOR_RESULT_SHADOW = setAlpha(color, 0xFF); } return CACHED_COLOR_RESULT_SHADOW; } public static int getQuickListToggleColor(Context context) { if (CACHED_COLOR_QL_TOGGLE == 0) { SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context); int highlightColor = getColor(pref, "quick-list-toggle-color"); CACHED_COLOR_QL_TOGGLE = setAlpha(highlightColor, 0xFF); } return CACHED_COLOR_QL_TOGGLE; } public static int getQuickListRipple(Context context) { if (CACHED_RIPPLE_QL == 0) { SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context); int color = UIColors.getColor(pref, "quick-list-ripple-color"); int alpha = 0xFF; CACHED_RIPPLE_QL = setAlpha(color, alpha); } return CACHED_RIPPLE_QL; } public static int getContactActionColor(Context context) { if (CACHED_COLOR_CONTACT_ACTION == 0) { SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context); int highlightColor = getColor(pref, "contact-action-color"); CACHED_COLOR_CONTACT_ACTION = setAlpha(highlightColor, 0xFF); } return CACHED_COLOR_CONTACT_ACTION; } public static int getSearchTextColor(Context context) { if (CACHED_COLOR_SEARCH_TEXT == 0) { SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context); int highlightColor = getColor(pref, "search-bar-text-color"); CACHED_COLOR_SEARCH_TEXT = setAlpha(highlightColor, 0xFF); } return CACHED_COLOR_SEARCH_TEXT; } public static int getSearchShadowColor(Context context) { if (CACHED_COLOR_SEARCH_SHADOW == 0) { SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context); int color = UIColors.getColor(pref, "search-bar-shadow-color"); CACHED_COLOR_SEARCH_SHADOW = setAlpha(color, 0xFF); } return CACHED_COLOR_SEARCH_SHADOW; } public static int getResultListBackground(Context context) { if (CACHED_BACKGROUND_RESULT_LIST == null) { SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context); return getResultListBackground(pref); } return CACHED_BACKGROUND_RESULT_LIST; } public static int getResultListBackground(SharedPreferences pref) { if (CACHED_BACKGROUND_RESULT_LIST == null) { CACHED_BACKGROUND_RESULT_LIST = UIColors.getColor(pref, "result-list-argb"); } return CACHED_BACKGROUND_RESULT_LIST; } public static int getResultListRipple(Context context) { if (CACHED_RIPPLE_RESULT_LIST == 0) { SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context); int color = UIColors.getColor(pref, "result-ripple-color"); int alpha = 0xFF; CACHED_RIPPLE_RESULT_LIST = setAlpha(color, alpha); } return CACHED_RIPPLE_RESULT_LIST; } public static int getIconBackground(Context context) { if (CACHED_BACKGROUND_ICON == null) { SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context); CACHED_BACKGROUND_ICON = UIColors.getColor(pref, "icon-background-argb"); } return CACHED_BACKGROUND_ICON; } public static int getPopupBorderColor(Context context) { if (CACHED_COLOR_POPUP_BORDER == null) { SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context); int color = pref.getInt("popup-border-argb", 0); if (color == 0) { color = getSystemAccentColor(context); pref.edit().putInt("popup-border-argb", color).apply(); } CACHED_COLOR_POPUP_BORDER = color; } return CACHED_COLOR_POPUP_BORDER; } public static int getPopupBackgroundColor(Context context) { if (CACHED_COLOR_POPUP_BACKGROUND == null) { SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context); CACHED_COLOR_POPUP_BACKGROUND = UIColors.getColor(pref, "popup-background-argb"); } return CACHED_COLOR_POPUP_BACKGROUND; } public static int getPopupRipple(Context context) { if (CACHED_RIPPLE_POPUP == 0) { SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context); int color = UIColors.getColor(pref, "popup-ripple-color"); CACHED_RIPPLE_POPUP = setAlpha(color, 0xFF); } return CACHED_RIPPLE_POPUP; } public static int getPopupTextColor(Context context) { if (CACHED_COLOR_POPUP_TEXT == 0) { SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context); int color = UIColors.getColor(pref, "popup-text-color"); CACHED_COLOR_POPUP_TEXT = setAlpha(color, 0xFF); } return CACHED_COLOR_POPUP_TEXT; } public static int getPopupTitleColor(Context context) { if (CACHED_COLOR_POPUP_TITLE == 0) { SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context); int color = UIColors.getColor(pref, "popup-title-color"); CACHED_COLOR_POPUP_TITLE = setAlpha(color, 0xFF); } return CACHED_COLOR_POPUP_TITLE; } public static int getPopupShadowColor(Context context) { if (CACHED_COLOR_POPUP_SHADOW == 0) { SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context); int color = UIColors.getColor(pref, "popup-shadow-color"); CACHED_COLOR_POPUP_SHADOW = setAlpha(color, 0xFF); } return CACHED_COLOR_POPUP_SHADOW; } public static Drawable getPreviewDrawable(int color, int border, float radius) { float luminance = UIColors.luminance(color); int borderColor = UIColors.modulateColorLightness(color, 2.f * (1.f - luminance)); GradientDrawable drawable = new GradientDrawable(); drawable.setCornerRadius(radius); drawable.setStroke(border, borderColor); drawable.setColor(color); return drawable; } public static ColorFilter colorFilterQuickIcon(@NonNull Context context) { return colorFilter(context); } @Nullable public static ColorFilter colorFilter(@NonNull Context context) { if (!CACHED_MAT_ICON) { SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context); Resources resources = context.getResources(); int hue = pref.getInt("icon-hue", resources.getInteger(R.integer.default_icon_hue)); int contrast = pref.getInt("icon-contrast", resources.getInteger(R.integer.default_icon_contrast)); int brightness = pref.getInt("icon-brightness", resources.getInteger(R.integer.default_icon_brightness)); int saturation = pref.getInt("icon-saturation", resources.getInteger(R.integer.default_icon_saturation)); int scaleR = pref.getInt("icon-scale-red", resources.getInteger(R.integer.default_icon_scale)); int scaleG = pref.getInt("icon-scale-green", resources.getInteger(R.integer.default_icon_scale)); int scaleB = pref.getInt("icon-scale-blue", resources.getInteger(R.integer.default_icon_scale)); int scaleA = pref.getInt("icon-scale-alpha", resources.getInteger(R.integer.default_icon_scale)); final ColorMatrix cm = new ColorMatrix(); boolean modified; modified = ColorFilterHelper.adjustScale(cm, scaleR, scaleG, scaleB, scaleA); modified = ColorFilterHelper.adjustHue(cm, hue) || modified; modified = ColorFilterHelper.adjustContrast(cm, contrast) || modified; modified = ColorFilterHelper.adjustBrightness(cm, brightness) || modified; modified = ColorFilterHelper.adjustSaturation(cm, saturation) || modified; CACHED_MAT_ICON = true; COLOR_MATRIX_ICON = modified ? cm : null; } return COLOR_MATRIX_ICON == null ? null : new ColorMatrixColorFilter(COLOR_MATRIX_ICON); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/utils/UISizes.java ================================================ package rocks.tbog.tblauncher.utils; import android.content.Context; import android.content.SharedPreferences; import android.content.res.Resources; import android.graphics.Rect; import android.os.Build; import android.util.DisplayMetrics; import android.util.TypedValue; import android.view.ViewGroup; import androidx.annotation.DimenRes; import androidx.annotation.NonNull; import androidx.preference.PreferenceManager; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.ui.CutoutFactory; public final class UISizes { // cached sizes are in pixels so we don't need to convert after each get* private static int CACHED_SIZE_RESULT_TEXT = 0; private static int CACHED_SIZE_RESULT_TEXT2 = 0; private static int CACHED_SIZE_RESULT_ICON = 0; private static int CACHED_SIZE_TAGS_MENU_ICON = 0; private static int CACHED_SIZE_DOCK_ICON = 0; private static int CACHED_SIZE_STATUS_BAR = 0; private static int CACHED_RADIUS_POPUP_CORNER = -1; private static Float CACHED_RADIUS_POPUP_SHADOW = null; private static Float CACHED_DX_POPUP_SHADOW = null; private static Float CACHED_DY_POPUP_SHADOW = null; private static Integer CACHED_HEIGHT_RESULT_LIST_ROW = null; private static Float CACHED_RADIUS_RESULT_LIST_SHADOW = null; private static Float CACHED_DX_RESULT_LIST_SHADOW = null; private static Float CACHED_DY_RESULT_LIST_SHADOW = null; private static final float EPSILON_PX_SIZE = 0.001f; private UISizes() { } public static void resetCache() { CACHED_SIZE_RESULT_TEXT = 0; CACHED_SIZE_RESULT_TEXT2 = 0; CACHED_SIZE_RESULT_ICON = 0; CACHED_SIZE_TAGS_MENU_ICON = 0; CACHED_SIZE_DOCK_ICON = 0; CACHED_SIZE_STATUS_BAR = 0; CACHED_RADIUS_POPUP_CORNER = -1; CACHED_RADIUS_POPUP_SHADOW = null; CACHED_DX_POPUP_SHADOW = null; CACHED_DY_POPUP_SHADOW = null; CACHED_HEIGHT_RESULT_LIST_ROW = null; CACHED_RADIUS_RESULT_LIST_SHADOW = null; CACHED_DX_RESULT_LIST_SHADOW = null; CACHED_DY_RESULT_LIST_SHADOW = null; } public static int sp2px(Context context, int size) { float px = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, size, context.getResources().getDisplayMetrics()); return Math.max(1, (int) (px + .5f)); } public static int dp2px(Context context, int size) { if (size == 0) return 0; float px = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, size, context.getResources().getDisplayMetrics()); return Math.max(1, (int) (px + .5f)); } public static int dp2px_float(Context context, float size) { float px = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, size, context.getResources().getDisplayMetrics()); return Math.round(px); } public static int px2dp(Context context, int pixelSize) { if (pixelSize == 0) return 0; float dp; DisplayMetrics metrics = context.getResources().getDisplayMetrics(); if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { // Avoid divide-by-zero, and return 0 since that's what the inverse function will do if (metrics.density == 0) { return 0; } dp = pixelSize / metrics.density; } else { dp = TypedValue.deriveDimension(TypedValue.COMPLEX_UNIT_DIP, pixelSize, metrics); } return Math.max(1, (int) (dp + .5f)); } public static float px2dp_float(Context context, float size) { DisplayMetrics metrics = context.getResources().getDisplayMetrics(); float px; if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { // Avoid divide-by-zero, and return 0 since that's what the inverse function will do if (metrics.density == 0) { return 0f; } px = size / metrics.density; } else { px = TypedValue.deriveDimension(TypedValue.COMPLEX_UNIT_DIP, size, metrics); } return Math.round(px * 1000.f) * 0.001f; } public static int getResultTextSize(Context context) { if (CACHED_SIZE_RESULT_TEXT == 0) { SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context); final int defaultSize = context.getResources().getInteger(R.integer.default_size_text); final int size = pref.getInt("result-text-size", defaultSize); CACHED_SIZE_RESULT_TEXT = sp2px(context, size); } return CACHED_SIZE_RESULT_TEXT; } public static int getResultText2Size(Context context) { if (CACHED_SIZE_RESULT_TEXT2 == 0) { SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context); final int defaultSize = context.getResources().getInteger(R.integer.default_size_text2); final int size = pref.getInt("result-text2-size", defaultSize); CACHED_SIZE_RESULT_TEXT2 = sp2px(context, size); } return CACHED_SIZE_RESULT_TEXT2; } public static int getResultIconSize(Context context) { if (CACHED_SIZE_RESULT_ICON == 0) { SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context); final int defaultSize = context.getResources().getInteger(R.integer.default_size_icon); final int size = pref.getInt("result-icon-size", defaultSize); CACHED_SIZE_RESULT_ICON = dp2px(context, Math.max(1, size)); } return CACHED_SIZE_RESULT_ICON; } public static int getResultListRadius(Context context) { SharedPreferences pref = TBApplication.getApplication(context).preferences(); int radius = pref.getInt("result-list-radius", -1); if (radius < 0) return context.getResources().getDimensionPixelSize(R.dimen.result_corner_radius); return dp2px(context, radius); } public static Rect getResultListMargin(Context context) { SharedPreferences pref = TBApplication.getApplication(context).preferences(); final int marginHorizontal = pref.getInt("result-list-margin-horizontal", 0); final int marginVertical = pref.getInt("result-list-margin-vertical", 0); float marginOffsetX = pref.getFloat("result-list-margin-offset-dx", 0); float marginOffsetY = pref.getFloat("result-list-margin-offset-dy", 0); if (marginOffsetX > marginHorizontal) marginOffsetX = marginHorizontal; if (marginOffsetY > marginVertical) marginOffsetY = marginVertical; Rect margin = new Rect(); margin.left = dp2px_float(context, marginHorizontal + marginOffsetX); margin.right = dp2px_float(context, marginHorizontal - marginOffsetX); margin.top = dp2px_float(context, marginVertical + marginOffsetY); margin.bottom = dp2px_float(context, marginVertical - marginOffsetY); return margin; } public static int getResultListRowHeight(Context context) { if (CACHED_HEIGHT_RESULT_LIST_ROW == null) { SharedPreferences pref = TBApplication.getApplication(context).preferences(); boolean manual = pref.getBoolean("result-list-row-height-manual", false); if (manual) { int height = pref.getInt("result-list-row-height", 0); if (height <= 0) CACHED_HEIGHT_RESULT_LIST_ROW = ViewGroup.LayoutParams.WRAP_CONTENT; else CACHED_HEIGHT_RESULT_LIST_ROW = dp2px(context, height); } else { int iconSize = getResultIconSize(context); int resultMargin = context.getResources().getDimensionPixelSize(R.dimen.result_margin_vertical); int iconMargin = context.getResources().getDimensionPixelSize(R.dimen.icon_margin_vertical); CACHED_HEIGHT_RESULT_LIST_ROW = iconSize + 2 * resultMargin + 2 * iconMargin; } } return CACHED_HEIGHT_RESULT_LIST_ROW; } public static float getResultListShadowRadius(Context context) { if (CACHED_RADIUS_RESULT_LIST_SHADOW == null) { SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context); final float defaultSize = getFloatResource(context.getResources(), R.dimen.default_result_shadow_radius); final float size = pref.getFloat("result-shadow-radius", defaultSize); CACHED_RADIUS_RESULT_LIST_SHADOW = size < EPSILON_PX_SIZE ? 0f : size; } return CACHED_RADIUS_RESULT_LIST_SHADOW; } public static float getResultListShadowOffsetHorizontal(Context context) { if (CACHED_DX_RESULT_LIST_SHADOW == null) { SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context); final float defaultSize = getFloatResource(context.getResources(), R.dimen.default_result_shadow_dx); final float size = pref.getFloat("result-shadow-dx", defaultSize); CACHED_DX_RESULT_LIST_SHADOW = Math.abs(size) < EPSILON_PX_SIZE ? 0f : size; } return CACHED_DX_RESULT_LIST_SHADOW; } public static float getResultListShadowOffsetVertical(Context context) { if (CACHED_DY_RESULT_LIST_SHADOW == null) { SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context); final float defaultSize = getFloatResource(context.getResources(), R.dimen.default_result_shadow_dy); final float size = pref.getFloat("result-shadow-dy", defaultSize); CACHED_DY_RESULT_LIST_SHADOW = Math.abs(size) < EPSILON_PX_SIZE ? 0f : size; } return CACHED_DY_RESULT_LIST_SHADOW; } public static int getTagsMenuIconSize(Context context) { if (CACHED_SIZE_TAGS_MENU_ICON == 0) { SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context); final int defaultSize = context.getResources().getInteger(R.integer.default_size_icon); final int size = pref.getInt("tags-menu-icon-size", defaultSize); CACHED_SIZE_TAGS_MENU_ICON = dp2px(context, Math.max(1, size)); } return CACHED_SIZE_TAGS_MENU_ICON; } public static int getDockMaxIconSize(Context context) { if (CACHED_SIZE_DOCK_ICON == 0) { SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context); final int defaultSize = context.getResources().getInteger(R.integer.default_size_icon); final int size = pref.getInt("quick-list-icon-size", defaultSize); CACHED_SIZE_DOCK_ICON = dp2px(context, Math.max(1, size)); } return CACHED_SIZE_DOCK_ICON; } public static int getStatusBarSize(Context context) { if (CACHED_SIZE_STATUS_BAR == 0) { CACHED_SIZE_STATUS_BAR = CutoutFactory.StatusBarCutout.getStatusBarHeight(context); } return CACHED_SIZE_STATUS_BAR; } public static int getPopupCornerRadius(Context context) { if (CACHED_RADIUS_POPUP_CORNER == -1) { SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context); final int defaultSize = context.getResources().getInteger(R.integer.default_corner_radius); final int size = pref.getInt("popup-corner-radius", defaultSize); CACHED_RADIUS_POPUP_CORNER = dp2px(context, size); } return CACHED_RADIUS_POPUP_CORNER; } public static float getPopupShadowRadius(Context context) { if (CACHED_RADIUS_POPUP_SHADOW == null) { SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context); final float defaultSize = getFloatResource(context.getResources(), R.dimen.default_result_shadow_radius); final float size = pref.getFloat("popup-shadow-radius", defaultSize); CACHED_RADIUS_POPUP_SHADOW = size < EPSILON_PX_SIZE ? 0f : size; } return CACHED_RADIUS_POPUP_SHADOW; } public static float getPopupShadowOffsetHorizontal(Context context) { if (CACHED_DX_POPUP_SHADOW == null) { SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context); final float defaultSize = getFloatResource(context.getResources(), R.dimen.default_result_shadow_dx); final float size = pref.getFloat("popup-shadow-dx", defaultSize); CACHED_DX_POPUP_SHADOW = Math.abs(size) < EPSILON_PX_SIZE ? 0f : size; } return CACHED_DX_POPUP_SHADOW; } public static float getPopupShadowOffsetVertical(Context context) { if (CACHED_DY_POPUP_SHADOW == null) { SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context); final float defaultSize = getFloatResource(context.getResources(), R.dimen.default_result_shadow_dy); final float size = pref.getFloat("popup-shadow-dy", defaultSize); CACHED_DY_POPUP_SHADOW = Math.abs(size) < EPSILON_PX_SIZE ? 0f : size; } return CACHED_DY_POPUP_SHADOW; } // /** // * Example usage: `int size = UISizes.getTextAppearanceTextSize(context, android.R.attr.textAppearanceMedium);` // * // * @param context we need the context to get the theme // * @param textAppearance text size of what attribute // * @return text size // */ // public static int getTextAppearanceTextSize(Context context, @AttrRes int textAppearance) { // int size = 0; // TypedValue appearance = new TypedValue(); // if (context.getTheme().resolveAttribute(textAppearance, appearance, true)) { // TypedArray ta = context.obtainStyledAttributes(appearance.resourceId, new int[]{android.R.attr.textSize}); // size = ta.getDimensionPixelSize(0, size); // ta.recycle(); // } // return (size == 0) ? sp2px(context, 12) : size; // } public static int getSearchBarRadius(Context context) { SharedPreferences pref = TBApplication.getApplication(context).preferences(); int radius = pref.getInt("search-bar-radius", 0); return dp2px(context, radius); } public static int getSearchBarMarginVertical(Context context) { SharedPreferences pref = TBApplication.getApplication(context).preferences(); int margin = pref.getInt("search-bar-margin-vertical", 0); return dp2px(context, margin); } public static int getSearchBarMarginHorizontal(Context context) { SharedPreferences pref = TBApplication.getApplication(context).preferences(); int margin = pref.getInt("search-bar-margin-horizontal", 0); return dp2px(context, margin); } public static float getSearchBarShadowRadius(Context context) { SharedPreferences pref = TBApplication.getApplication(context).preferences(); final float defaultSize = getFloatResource(context.getResources(), R.dimen.default_result_shadow_radius); final float size = pref.getFloat("search-bar-shadow-radius", defaultSize); return size < EPSILON_PX_SIZE ? 0f : size; } public static float getSearchBarShadowOffsetHorizontal(Context context) { SharedPreferences pref = TBApplication.getApplication(context).preferences(); final float defaultSize = getFloatResource(context.getResources(), R.dimen.default_result_shadow_dx); final float size = pref.getFloat("search-bar-shadow-dx", defaultSize); return Math.abs(size) < EPSILON_PX_SIZE ? 0f : size; } public static float getSearchBarShadowOffsetVertical(Context context) { SharedPreferences pref = TBApplication.getApplication(context).preferences(); final float defaultSize = getFloatResource(context.getResources(), R.dimen.default_result_shadow_dy); final float size = pref.getFloat("search-bar-shadow-dy", defaultSize); return Math.abs(size) < EPSILON_PX_SIZE ? 0f : size; } public static int getQuickListMarginVertical(Context context) { SharedPreferences pref = TBApplication.getApplication(context).preferences(); int margin = pref.getInt("quick-list-margin-vertical", 0); return dp2px(context, margin); } public static int getQuickListMarginHorizontal(Context context) { SharedPreferences pref = TBApplication.getApplication(context).preferences(); int margin = pref.getInt("quick-list-margin-horizontal", 0); return dp2px(context, margin); } private static float getFloatResource(@NonNull Resources resources, @DimenRes int resId) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { return resources.getFloat(resId); } else { final TypedValue value = new TypedValue(); try { resources.getValue(resId, value, true); if (value.type == TypedValue.TYPE_FLOAT) { return value.getFloat(); } throw new Resources.NotFoundException("Resource ID #0x" + Integer.toHexString(resId) + " type #0x" + Integer.toHexString(value.type) + " is not valid"); } catch (Resources.NotFoundException e) { return 0f; } } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/utils/UITheme.java ================================================ package rocks.tbog.tblauncher.utils; import android.content.Context; import android.content.SharedPreferences; import android.widget.TextView; import androidx.annotation.AnyRes; import androidx.annotation.NonNull; import androidx.annotation.StyleRes; import androidx.appcompat.view.ContextThemeWrapper; import androidx.preference.PreferenceManager; import rocks.tbog.tblauncher.R; public class UITheme { @AnyRes public static final int ID_NULL = 0; private static final String[] PREF_BACKGROUND = { "icon-background-argb", "notification-bar-argb", "search-bar-argb", "result-list-argb", "result-shadow-color", "popup-shadow-color", "search-bar-shadow-color", "quick-list-argb", "popup-background-argb", }; private static final String[] PREF_HIGHLIGHT = { "search-bar-ripple-color", "search-bar-cursor-argb", "result-ripple-color", "result-highlight-color", "quick-list-toggle-color", "quick-list-ripple-color", "popup-border-argb", "popup-ripple-color", }; private static final String[] PREF_FOREGROUND = { "search-bar-text-color", "search-bar-icon-color", "contact-action-color", "result-text-color", "popup-text-color", "popup-title-color", }; private static final String[] PREF_FOREGROUND2 = { "result-text2-color", }; private UITheme() { } @StyleRes public static int getSettingsTheme(Context context) { SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context); String theme = sharedPreferences.getString("settings-theme", null); if (theme != null) { switch (theme) { case "default": return R.style.SettingsTheme_Default; case "white": return R.style.SettingsTheme_White; case "black": return R.style.SettingsTheme_Black; case "dark": return R.style.SettingsTheme_DarkBg; case "DeepBlues": return R.style.SettingsTheme_DeepBlues; default: return R.style.SettingsTheme; } } return ID_NULL; } @StyleRes public static int getDialogTheme(Context context) { return getSettingsTheme(context); } @NonNull public static Context getDialogThemedContext(@NonNull Context context) { int theme = getDialogTheme(context); if (theme == ID_NULL) return context; return new ContextThemeWrapper(context, theme); } public static void applyColorsThemeSimple(Context context) { SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context); final int colorBg = UIColors.getColor(pref, "primary-color"); final int colorFg = UIColors.getColor(pref, "secondary-color"); final int colorHl = UIColors.getTextContrastColor(colorBg); final float lumBg = UIColors.luminance(colorBg); final float lumFg = UIColors.luminance(colorFg); final int colorFg2; if (lumBg > .5f && lumFg > .5f) colorFg2 = UIColors.modulateColorLightness(colorFg, .2f); else if (lumBg > .5f) colorFg2 = UIColors.modulateColorLightness(colorFg, 2.f * (1.f - lumFg)); else if (lumFg > .5f) colorFg2 = UIColors.modulateColorLightness(colorFg, 1.9f); else colorFg2 = UIColors.getTextContrastColor(colorBg); SharedPreferences.Editor editor = pref.edit(); setColor(editor, PREF_BACKGROUND, colorBg, 0xCD); setColor(editor, PREF_HIGHLIGHT, colorHl, 0xFF); setColor(editor, PREF_FOREGROUND, colorFg, 0xFF); setColor(editor, PREF_FOREGROUND2, colorFg2, 0xFF); editor.apply(); } public static void applyColorsThemeHighlight(Context context) { SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context); final int colorBg = UIColors.getColor(pref, "primary-color"); final int colorHl = UIColors.getColor(pref, "secondary-color"); final int colorFg = UIColors.getTextContrastColor(colorBg); final float lumFg = UIColors.luminance(colorFg); final int colorFg2 = UIColors.modulateColorLightness(colorFg, 2.f * (1.f - lumFg)); SharedPreferences.Editor editor = pref.edit(); setColor(editor, PREF_BACKGROUND, colorBg, 0xCD); setColor(editor, PREF_HIGHLIGHT, colorHl, 0xFF); setColor(editor, PREF_FOREGROUND, colorFg, 0xFF); setColor(editor, PREF_FOREGROUND2, colorFg2, 0xFF); editor.apply(); } private static void setColor(@NonNull SharedPreferences.Editor editor, String[] colorList, int color, int alpha) { for (String prefName : colorList) { final int prefColor; if (prefName.endsWith("-argb")) { prefColor = UIColors.setAlpha(color, alpha); } else { prefColor = UIColors.setAlpha(color, 0); } editor.putInt(prefName, prefColor); } } public static void applySearchBarTextShadow(@NonNull TextView textView) { Context ctx = textView.getContext(); float radius = UISizes.getSearchBarShadowRadius(ctx); float dx = UISizes.getSearchBarShadowOffsetHorizontal(ctx); float dy = UISizes.getSearchBarShadowOffsetVertical(ctx); int color = UIColors.getSearchShadowColor(ctx); if (radius != textView.getShadowRadius() || dx != textView.getShadowDx() || dy != textView.getShadowDy() || color != textView.getShadowColor()) { textView.setShadowLayer(radius, dx, dy, color); } } public static void applyPopupTextShadow(@NonNull TextView textView) { Context ctx = textView.getContext(); float radius = UISizes.getPopupShadowRadius(ctx); float dx = UISizes.getPopupShadowOffsetHorizontal(ctx); float dy = UISizes.getPopupShadowOffsetVertical(ctx); int color = UIColors.getPopupShadowColor(ctx); if (radius != textView.getShadowRadius() || dx != textView.getShadowDx() || dy != textView.getShadowDy() || color != textView.getShadowColor()) { textView.setShadowLayer(radius, dx, dy, color); } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/utils/UserHandleCompat.java ================================================ package rocks.tbog.tblauncher.utils; import android.annotation.TargetApi; import android.content.ComponentName; import android.content.Context; import android.os.Build; import android.os.Process; import android.os.UserManager; import androidx.annotation.NonNull; /** * Wrapper class for `android.os.UserHandle` that works with all Android versions */ public class UserHandleCompat { public static final UserHandleCompat CURRENT_USER = new UserHandleCompat(); private final long serial; private final Object handle; // android.os.UserHandle on Android 4.2 and newer public UserHandleCompat() { this(0, null); } @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1) public UserHandleCompat(long serial, android.os.UserHandle user) { if (android.os.Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1) { // OS does not provide any APIs for multi-user support this.serial = 0; this.handle = null; } else if (user != null && Process.myUserHandle().equals(user)) { // For easier processing the current user is also stored as `null`, even // if there is multi-user support this.serial = 0; this.handle = null; } else { // Store the given user handle this.serial = serial; this.handle = user; } } public UserHandleCompat(Context context, android.os.UserHandle userHandle) { final UserManager manager = (UserManager) context.getSystemService(Context.USER_SERVICE); assert manager != null; serial = manager.getSerialNumberForUser(userHandle); handle = userHandle; } public static UserHandleCompat fromComponentName(Context ctx, String componentName) { if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { UserManager manager = (UserManager) ctx.getSystemService(Context.USER_SERVICE); assert manager != null; long serial = getUserSerial(componentName); android.os.UserHandle handle = manager.getUserForSerialNumber(serial); return new UserHandleCompat(serial, handle); } return UserHandleCompat.CURRENT_USER; } @NonNull public static ComponentName unflattenComponentName(@NonNull String name) { return new ComponentName(getPackageName(name), getActivityName(name)); } @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1) public android.os.UserHandle getRealHandle() { if (this.handle != null) { return (android.os.UserHandle) this.handle; } else { return Process.myUserHandle(); } } public boolean isCurrentUser() { return (this.handle == null); } private String addUserSuffixToString(String base, char separator) { if (this.handle == null) { return base; } else { return base + separator + this.serial; } } @SuppressWarnings("CatchAndPrintStackTrace") public boolean hasStringUserSuffix(String string, char separator) { long serial = 0; int index = string.lastIndexOf((int) separator); if (index > -1) { String serialText = string.substring(index); try { serial = Long.parseLong(serialText); } catch (NumberFormatException e) { e.printStackTrace(); } } return (serial == this.serial); } public String getUserComponentName(ComponentName component) { return getUserComponentName(component.getPackageName(), component.getClassName()); } public String getUserComponentName(String packageName, String activityName) { return addUserSuffixToString(packageName + "/" + activityName, '#'); } public static String getPackageName(@NonNull String componentName) { int index = componentName.indexOf('/'); if (index > 0) return componentName.substring(0, index); return ""; } public static String getActivityName(@NonNull String componentName) { int start = componentName.indexOf('/') + 1; int end = componentName.lastIndexOf('#'); if (end == -1) end = componentName.length(); if (start > 0 && start < end) { return componentName.substring(start, end); } return ""; } public static long getUserSerial(@NonNull String componentName) { int index = componentName.indexOf('#') + 1; if (index > 0 && index < componentName.length()) { try { return Long.parseLong(componentName.substring(index)); } catch (NumberFormatException ignored) { } } return 0; } public String getBadgedLabelForUser(Context context, String label) { if (handle == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) return label; return context.getPackageManager().getUserBadgedLabel(label, (android.os.UserHandle) handle).toString(); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/utils/Utilities.java ================================================ package rocks.tbog.tblauncher.utils; import android.annotation.SuppressLint; import android.app.Activity; import android.app.ActivityOptions; import android.content.Context; import android.content.ContextWrapper; import android.content.Intent; import android.content.pm.PackageManager; import android.content.res.Resources; import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.ColorFilter; import android.graphics.PorterDuff; import android.graphics.PorterDuffColorFilter; import android.graphics.Rect; import android.graphics.drawable.Animatable; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; import android.graphics.drawable.GradientDrawable; import android.graphics.drawable.ShapeDrawable; import android.graphics.drawable.shapes.RectShape; import android.os.Build; import android.os.Bundle; import android.text.SpannableString; import android.text.Spanned; import android.util.Base64; import android.util.Log; import android.view.View; import android.view.ViewTreeObserver; import android.widget.ImageView; import android.widget.TextView; import androidx.annotation.ColorInt; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.UiThread; import androidx.annotation.WorkerThread; import androidx.appcompat.content.res.AppCompatResources; import androidx.core.content.res.ResourcesCompat; import androidx.lifecycle.Lifecycle; import java.io.ByteArrayOutputStream; import java.lang.ref.WeakReference; import java.lang.reflect.Field; import java.lang.reflect.Method; import java.util.concurrent.ExecutorService; import java.util.concurrent.SynchronousQueue; import java.util.concurrent.ThreadFactory; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import rocks.tbog.tblauncher.WorkAsync.AsyncTask; import rocks.tbog.tblauncher.WorkAsync.RunnableTask; import rocks.tbog.tblauncher.WorkAsync.TaskRunner; import rocks.tbog.tblauncher.result.ResultViewHelper; import rocks.tbog.tblauncher.ui.CenteredImageSpan; import rocks.tbog.tblauncher.ui.CutoutFactory; import rocks.tbog.tblauncher.ui.ICutout; public class Utilities { public final static ExecutorService EXECUTOR_RUN_ASYNC; private final static int[] ON_SCREEN_POS = new int[2]; private final static Rect ON_SCREEN_RECT = new Rect(); private static final String TAG = "TBUtil"; private static final int CORE_POOL_SIZE = 1; private static final int MAXIMUM_POOL_SIZE = 10; private static final int KEEP_ALIVE_SECONDS = 3; private static final ThreadFactory sThreadFactory = new ThreadFactory() { private final AtomicInteger mCount = new AtomicInteger(1); public Thread newThread(Runnable r) { return new Thread(r, "UtilAsync #" + mCount.getAndIncrement()); } }; private static final Class CLASS_GRADIENT_DRAWABLE_GRADIENT_STATE; private static final Field GRADIENT_DRAWABLE_FIELD_GRADIENT_STATE; private static final Field GRADIENT_DRAWABLE_GRADIENT_STATE_FIELD_POSITIONS; static { ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor( CORE_POOL_SIZE, MAXIMUM_POOL_SIZE, KEEP_ALIVE_SECONDS, TimeUnit.SECONDS, new SynchronousQueue(), sThreadFactory); threadPoolExecutor.setRejectedExecutionHandler((runnable, executor) -> { Log.w(TAG, "task rejected"); if (!executor.isShutdown()) { runnable.run(); } }); EXECUTOR_RUN_ASYNC = threadPoolExecutor; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { CLASS_GRADIENT_DRAWABLE_GRADIENT_STATE = null; GRADIENT_DRAWABLE_FIELD_GRADIENT_STATE = null; GRADIENT_DRAWABLE_GRADIENT_STATE_FIELD_POSITIONS = null; } else { // make mGradientState accessible Field f_mGradientState = null; try { f_mGradientState = GradientDrawable.class.getDeclaredField("mGradientState"); f_mGradientState.setAccessible(true); } catch (Throwable t) { Log.w(TAG, "make mGradientState from " + GradientDrawable.class.getSimpleName() + " accessible", t); } GRADIENT_DRAWABLE_FIELD_GRADIENT_STATE = f_mGradientState; // make mGradientState.mPositions accessible Class c_GradientState = null; try { c_GradientState = Class.forName(GradientDrawable.class.getName() + "$GradientState"); } catch (ClassNotFoundException ignored) { } CLASS_GRADIENT_DRAWABLE_GRADIENT_STATE = c_GradientState; Field f_mPositions = null; if (c_GradientState != null) { try { f_mPositions = c_GradientState.getDeclaredField("mPositions"); f_mPositions.setAccessible(true); } catch (Throwable t) { Log.w(TAG, "make GradientState.mPositions from " + c_GradientState + " accessible", t); } } GRADIENT_DRAWABLE_GRADIENT_STATE_FIELD_POSITIONS = f_mPositions; } } // https://stackoverflow.com/questions/3035692/how-to-convert-a-drawable-to-a-bitmap @NonNull public static Bitmap drawableToBitmap(@Nullable Drawable drawable) { Bitmap bitmap; if (drawable instanceof BitmapDrawable) { BitmapDrawable bitmapDrawable = (BitmapDrawable) drawable; if (bitmapDrawable.getBitmap() != null) { return bitmapDrawable.getBitmap(); } } if (drawable == null || drawable.getIntrinsicWidth() <= 0 || drawable.getIntrinsicHeight() <= 0) { bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888); // Single color bitmap will be created of 1x1 pixel } else { bitmap = Bitmap.createBitmap(drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight(), Bitmap.Config.ARGB_8888); } Canvas canvas = new Canvas(bitmap); if (drawable != null) { drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); drawable.draw(canvas); } else { canvas.drawRGB(255, 255, 255); } return bitmap; } @Nullable public static byte[] bitmapToByteArray(@NonNull Bitmap bitmap) { ByteArrayOutputStream stream = null; try { stream = new ByteArrayOutputStream(1024); if (!bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)) stream = null; else { stream.flush(); stream.close(); } } catch (Exception e) { Log.e(TAG, "Unable to convert bitmap", e); stream = null; } return stream != null ? stream.toByteArray() : null; } /** * Returns a drawable suitable for the all apps view. If the package or the resource do not * exist, it returns null. */ public static Drawable createIconDrawable(Intent.ShortcutIconResource iconRes, Context context) { PackageManager packageManager = context.getPackageManager(); // the resource try { Resources resources = packageManager.getResourcesForApplication(iconRes.packageName); final int id = resources.getIdentifier(iconRes.resourceName, null, null); return ResourcesCompat.getDrawableForDensity(resources, id, 0, null); } catch (Exception e) { // Icon not found. } return null; } /** * Returns a drawable which is of the appropriate size to be displayed as an icon */ public static Drawable createIconDrawable(Bitmap icon, Context context) { return new BitmapDrawable(context.getResources(), icon); } @NonNull public static ICutout getNotchCutout(Activity activity) { ICutout cutout; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) cutout = CutoutFactory.getForAndroidPie(activity); else cutout = CutoutFactory.getByManufacturer(activity, Build.MANUFACTURER); return cutout == null ? CutoutFactory.getNoCutout() : cutout; } public static void setIconAsync(@NonNull ImageView image, @NonNull GetDrawable callback) { TaskRunner.executeOnExecutor(ResultViewHelper.EXECUTOR_LOAD_ICON, new Utilities.AsyncSetDrawable(image) { @Override protected Drawable getDrawable(Context context) { return callback.getDrawable(context); } } ); } public static void setViewAsync(@NonNull View image, @NonNull GetDrawable cbGet, @NonNull SetDrawable cbSet) { TaskRunner.executeOnExecutor(ResultViewHelper.EXECUTOR_LOAD_ICON, new Utilities.AsyncViewSet(image) { @Override protected Drawable getDrawable(Context context) { return cbGet.getDrawable(context); } @Override protected void setDrawable(@NonNull View view, @NonNull Drawable drawable) { cbSet.setDrawable(view, drawable); } } ); } public static void setIntentSourceBounds(@NonNull Intent intent, @Nullable View v) { if (v == null) return; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { v.getLocationOnScreen(ON_SCREEN_POS); ON_SCREEN_RECT.set(ON_SCREEN_POS[0], ON_SCREEN_POS[1], ON_SCREEN_POS[0] + v.getWidth(), ON_SCREEN_POS[1] + v.getHeight()); intent.setSourceBounds(ON_SCREEN_RECT); } } @Nullable public static Bundle makeStartActivityOptions(@Nullable View source) { if (source == null) return null; Bundle opts = null; // If we got an icon, we create options to get a nice animation if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { opts = ActivityOptions.makeClipRevealAnimation(source, 0, 0, source.getMeasuredWidth(), source.getMeasuredHeight()).toBundle(); } if (opts == null) { opts = ActivityOptions.makeScaleUpAnimation(source, 0, 0, source.getMeasuredWidth(), source.getMeasuredHeight()).toBundle(); } return opts; } @Nullable public static Rect getOnScreenRect(@Nullable View v) { if (v == null) return null; v.getLocationOnScreen(ON_SCREEN_POS); ON_SCREEN_RECT.set(ON_SCREEN_POS[0], ON_SCREEN_POS[1], ON_SCREEN_POS[0] + v.getWidth(), ON_SCREEN_POS[1] + v.getHeight()); return ON_SCREEN_RECT; } public static boolean checkFlag(int flags, int flagToCheck) { return (flags & flagToCheck) == flagToCheck; } public static boolean checkAnyFlag(int flags, int anyFlag) { return (flags & anyFlag) != 0; } /** * Return a valid activity or null given a view * * @param view any view of an activity * @return an activity or null */ @Nullable public static Activity getActivity(@Nullable View view) { return view != null ? getActivity(view.getContext()) : null; } /** * Return a valid activity or null given a context * * @param ctx context * @return an activity or null */ @Nullable public static Activity getActivity(@Nullable Context ctx) { while (ctx instanceof ContextWrapper) { if (ctx instanceof Activity) { Activity act = (Activity) ctx; if (act.isFinishing() || act.isDestroyed()) return null; return act; } ctx = ((ContextWrapper) ctx).getBaseContext(); } return null; } // public static void positionToast(@NonNull Toast toast, @NonNull View anchor, int offsetX, int offsetY) { // // toasts are positioned relatively to decor view, views relatively to their parents, we have to gather additional data to have a common coordinate system // Rect rect = new Rect(); // anchor.getWindowVisibleDisplayFrame(rect); // // // covert anchor view absolute position to a position which is relative to decor view // int[] viewLocation = new int[2]; // anchor.getLocationOnScreen(viewLocation); // int viewLeft = viewLocation[0] - rect.left; // int viewTop = viewLocation[1] - rect.top; // // // measure toast to center it relatively to the anchor view // DisplayMetrics metrics = new DisplayMetrics(); // anchor.getDisplay().getMetrics(metrics); // int widthMeasureSpec = View.MeasureSpec.makeMeasureSpec(metrics.widthPixels, View.MeasureSpec.UNSPECIFIED); // int heightMeasureSpec = View.MeasureSpec.makeMeasureSpec(metrics.heightPixels, View.MeasureSpec.UNSPECIFIED); // toast.getView().measure(widthMeasureSpec, heightMeasureSpec); // int toastWidth = toast.getView().getMeasuredWidth(); // // // compute toast offsets // int toastX = viewLeft + (anchor.getWidth() - toastWidth) / 2 + offsetX; // int toastY = viewTop + anchor.getHeight() + offsetY; // // toast.setGravity(Gravity.START | Gravity.TOP, toastX, toastY); // } public static RunnableTask runAsync(@NonNull Lifecycle lifecycle, @NonNull TaskRunner.AsyncRunnable background, @NonNull TaskRunner.AsyncRunnable after) { RunnableTask task = TaskRunner.newTask(lifecycle, background, after); TaskRunner.runOnUiThread(() -> EXECUTOR_RUN_ASYNC.execute(task)); return task; } public static RunnableTask runAsync(@NonNull TaskRunner.AsyncRunnable background, @Nullable TaskRunner.AsyncRunnable after) { RunnableTask task = TaskRunner.newTask(background, after); TaskRunner.runOnUiThread(() -> EXECUTOR_RUN_ASYNC.execute(task)); return task; } public static void runAsync(@NonNull Runnable background) { EXECUTOR_RUN_ASYNC.execute(background); } public static void executeAsync(@NonNull AsyncTask task) { TaskRunner.executeOnExecutor(EXECUTOR_RUN_ASYNC, task); } public static void setColorFilterMultiply(@NonNull ImageView imageView, int color) { setColorFilterMultiply(imageView.getDrawable(), color); } public static void setColorFilterMultiply(@Nullable Drawable drawable, int color) { if (drawable == null) return; ColorFilter cf = new PorterDuffColorFilter(color, PorterDuff.Mode.MULTIPLY); drawable.setColorFilter(cf); } @SuppressLint("ObsoleteSdkInt") public static void expandNotificationsPanel(Activity activity) { @SuppressLint("WrongConstant") Object statusBarService = activity.getSystemService("statusbar"); if (statusBarService != null) { try { Class statusbarManager = Class.forName("android.app.StatusBarManager"); Method expand; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { expand = statusbarManager.getMethod("expandNotificationsPanel"); } else { expand = statusbarManager.getMethod("expand"); } expand.setAccessible(true); expand.invoke(statusBarService); } catch (Exception ignored) { } } } @SuppressLint("ObsoleteSdkInt") public static void expandSettingsPanel(Activity activity) { boolean expandCalled = false; @SuppressLint("WrongConstant") Object statusBarService = activity.getSystemService("statusbar"); if (statusBarService != null) { try { Class statusbarManager = Class.forName("android.app.StatusBarManager"); Method expand; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { expand = statusbarManager.getMethod("expandSettingsPanel"); expand.setAccessible(true); expand.invoke(statusBarService); expandCalled = true; } } catch (Exception ignored) { } } if (!expandCalled) { Intent settings = new Intent(android.provider.Settings.ACTION_SETTINGS); activity.startActivity(settings); } } public static void setVerticalScrollbarThumbDrawable(View scrollView, Drawable drawable) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { scrollView.setVerticalScrollbarThumbDrawable(drawable); } else { try { //noinspection JavaReflectionMemberAccess Field mScrollCacheField = View.class.getDeclaredField("mScrollCache"); mScrollCacheField.setAccessible(true); Object mScrollCache = mScrollCacheField.get(scrollView); Field scrollBarField = mScrollCache.getClass().getDeclaredField("scrollBar"); scrollBarField.setAccessible(true); Object scrollBar = scrollBarField.get(mScrollCache); Method method = scrollBar.getClass().getDeclaredMethod("setVerticalThumbDrawable", Drawable.class); method.setAccessible(true); method.invoke(scrollBar, drawable); } catch (Exception ignored) { } } } public static boolean classContainsDeclaredField(@NonNull Class objectClass, @NonNull String fieldName) { for (Field field : objectClass.getDeclaredFields()) { if (field.getName().equals(fieldName)) { return true; } } return false; } public static void setTextCursorDrawable(@NonNull TextView editText, Drawable drawable) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { editText.setTextCursorDrawable(drawable); } else { boolean setResToNull = false; if (classContainsDeclaredField(TextView.class, "mCursorDrawable")) { try { @SuppressLint("BlockedPrivateApi") Field fmCursorDrawable = TextView.class.getDeclaredField("mCursorDrawable"); fmCursorDrawable.setAccessible(true); fmCursorDrawable.set(editText, drawable); setResToNull = true; } catch (Throwable t) { Log.w(TAG, "set TextView mCursorDrawable", t); } } if (classContainsDeclaredField(TextView.class, "mCursorDrawableRes")) { try { Field fmCursorDrawableRes = TextView.class.getDeclaredField("mCursorDrawableRes"); fmCursorDrawableRes.setAccessible(true); if (setResToNull) fmCursorDrawableRes.setInt(editText, 0); else if (fmCursorDrawableRes.getInt(editText) == 0) { // this resource will not get used, we just need something != 0 int res = android.R.drawable.divider_horizontal_dark; fmCursorDrawableRes.setInt(editText, res); } } catch (Throwable t) { Log.w(TAG, "set TextView mCursorDrawableRes", t); } } //https://github.com/aosp-mirror/platform_frameworks_base/blob/c46c4a6765196bcabf3ea89771a1f9067b22baad/core/java/android/widget/TextView.java#L4587 if (classContainsDeclaredField(TextView.class, "mEditor")) { Object mEditor = null; try { Field fmEditor = TextView.class.getDeclaredField("mEditor"); fmEditor.setAccessible(true); mEditor = fmEditor.get(editText); } catch (Throwable t) { Log.w(TAG, "get TextView mEditor", t); } if (mEditor == null) return; if (classContainsDeclaredField(mEditor.getClass(), "mCursorDrawable")) { try { Field fmCursorDrawable = mEditor.getClass().getDeclaredField("mCursorDrawable"); fmCursorDrawable.setAccessible(true); fmCursorDrawable.set(mEditor, new Drawable[]{drawable, drawable}); } catch (Throwable t) { Log.w(TAG, "set Editor mCursorDrawable[2]", t); } } } } } private static Drawable getDrawableFromTextViewEditor(@NonNull TextView view, @NonNull String editorField) { Drawable drawable = null; Object editor = null; try { Field f_editor = TextView.class.getDeclaredField("mEditor"); f_editor.setAccessible(true); editor = f_editor.get(view); } catch (Throwable t) { Log.w(TAG, "get Editor from " + view.getClass(), t); } if (editor != null && classContainsDeclaredField(editor.getClass(), editorField)) { try { Field f_handle = editor.getClass().getDeclaredField(editorField); f_handle.setAccessible(true); if (f_handle.getType().isArray()) { Object drawables = f_handle.get(editor); drawable = ((Drawable[]) drawables)[0]; } else { drawable = (Drawable) f_handle.get(editor); } } catch (Throwable t) { Log.w(TAG, "get `" + editorField + "` from " + editor.getClass(), t); } } return drawable; } @Nullable private static Drawable getDrawableFromTextView(@NonNull TextView view, @NonNull String fieldName, @NonNull String editorField) { Context ctx = view.getContext(); String resFieldName = fieldName + "Res"; if (classContainsDeclaredField(TextView.class, resFieldName)) { try { Field f_res = TextView.class.getDeclaredField(resFieldName); f_res.setAccessible(true); int res = f_res.getInt(view); if (res != Resources.ID_NULL) { Drawable drawable = AppCompatResources.getDrawable(ctx, res); if (drawable != null) return drawable; } } catch (Throwable t) { Log.w(TAG, "get `" + resFieldName + "` from " + TextView.class, t); } } if (classContainsDeclaredField(TextView.class, fieldName)) { try { Field f_drawable = TextView.class.getDeclaredField(fieldName); f_drawable.setAccessible(true); Drawable drawable = (Drawable) f_drawable.get(view); if (drawable != null) return drawable; } catch (Throwable t) { Log.w(TAG, "get `" + fieldName + "` from " + TextView.class, t); } } return getDrawableFromTextViewEditor(view, editorField); } public static void setTextCursorColor(@NonNull TextView editText, @ColorInt int color) { Context ctx = editText.getContext(); Drawable drawable = getDrawableFromTextView(editText, "mCursorDrawable", "mCursorDrawable"); if (drawable == null) { drawable = new ShapeDrawable(new RectShape()); ((ShapeDrawable) drawable).setIntrinsicWidth(UISizes.dp2px(ctx, 2)); ((ShapeDrawable) drawable).getPaint().setColor(color); } drawable.setColorFilter(new PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)); setTextCursorDrawable(editText, drawable); } private static void setTextSelectHandle(@NonNull TextView editText, @NonNull String fieldName, @NonNull String editorField, Drawable drawable) { // String fieldNameRes = fieldName + "Res"; // if (classContainsDeclaredField(TextView.class, fieldNameRes)) { // try { // Field f_handleRes = TextView.class.getDeclaredField(fieldNameRes); // f_handleRes.setAccessible(true); // f_handleRes.setInt(editText, 0); // } catch (Throwable t) { // Log.w(TAG, "set `" + fieldNameRes + "` from " + editText.getClass(), t); // } // } if (classContainsDeclaredField(TextView.class, fieldName)) { try { Field f_handle = TextView.class.getDeclaredField(fieldName); f_handle.setAccessible(true); f_handle.set(editText, drawable); } catch (Throwable t) { Log.w(TAG, "set `" + fieldName + "` from " + editText.getClass(), t); } } if (!classContainsDeclaredField(TextView.class, "mEditor")) return; Object editor = null; try { Field f_editor = TextView.class.getDeclaredField("mEditor"); f_editor.setAccessible(true); editor = f_editor.get(editText); } catch (Throwable t) { Log.w(TAG, "get Editor from " + editText.getClass(), t); } if (editor == null) return; try { Field f_handle = editor.getClass().getDeclaredField(editorField); f_handle.setAccessible(true); f_handle.set(editor, drawable); } catch (Throwable t) { Log.w(TAG, "set `" + editorField + "` from " + editor.getClass(), t); } } public static void setTextSelectHandle(@NonNull TextView editText, Drawable left, Drawable right, Drawable center) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { editText.setTextSelectHandle(center); editText.setTextSelectHandleLeft(left); editText.setTextSelectHandleRight(right); } else { setTextSelectHandle(editText, "mTextSelectHandleLeft", "mSelectHandleLeft", left); setTextSelectHandle(editText, "mTextSelectHandleRight", "mSelectHandleRight", right); setTextSelectHandle(editText, "mTextSelectHandle", "mSelectHandleCenter", center); } } public static void setTextSelectHandleColor(@NonNull TextView editText, @ColorInt int color) { Drawable drawableLeft; Drawable drawableRight; Drawable drawableCenter; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { drawableLeft = editText.getTextSelectHandleLeft(); drawableRight = editText.getTextSelectHandleRight(); drawableCenter = editText.getTextSelectHandle(); } else { drawableLeft = getDrawableFromTextView(editText, "mTextSelectHandleLeft", "mSelectHandleLeft"); drawableRight = getDrawableFromTextView(editText, "mTextSelectHandleRight", "mSelectHandleRight"); drawableCenter = getDrawableFromTextView(editText, "mTextSelectHandle", "mSelectHandleCenter"); } if (drawableLeft == null) drawableLeft = new ColorDrawable(color); if (drawableRight == null) drawableRight = new ColorDrawable(color); if (drawableCenter == null) drawableCenter = new ColorDrawable(color); PorterDuffColorFilter porterDuffColorFilter = new PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN); drawableLeft.setColorFilter(porterDuffColorFilter); drawableRight.setColorFilter(porterDuffColorFilter); drawableCenter.setColorFilter(porterDuffColorFilter); setTextSelectHandle(editText, drawableLeft, drawableRight, drawableCenter); } public static boolean setGradientDrawableColors(@NonNull GradientDrawable drawable, @Nullable @ColorInt int[] colors, @Nullable float[] offsets) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { drawable.setColors(colors, offsets); return true; } else { drawable.setColors(colors); Object mGradientState = null; try { mGradientState = GRADIENT_DRAWABLE_FIELD_GRADIENT_STATE.get(drawable); } catch (IllegalAccessException ignored) { } final Class c_GradientState = CLASS_GRADIENT_DRAWABLE_GRADIENT_STATE; if (c_GradientState != null && c_GradientState.isInstance(mGradientState)) { try { GRADIENT_DRAWABLE_GRADIENT_STATE_FIELD_POSITIONS.set(mGradientState, offsets); return true; } catch (IllegalAccessException ignored) { } } } return false; } public static int getNextCodePointIndex(CharSequence s, int startPosition) { int codePoint = Character.codePointAt(s, startPosition); int next = startPosition + Character.charCount(codePoint); if (next < s.length()) { // skip next character if it's not helpful codePoint = Character.codePointAt(s, next); boolean skip = codePoint == 0x200D; skip = skip || Character.UnicodeBlock.VARIATION_SELECTORS.equals(Character.UnicodeBlock.of(codePoint)); if (skip) return getNextCodePointIndex(s, next); } return next; } public static int codePointsLength(@Nullable CharSequence s) { final int length = s != null ? s.length() : 0; int n = 0; for (int i = 0; i < length; ) { int codePoint = Character.codePointAt(s, i); i += Character.charCount(codePoint); // skip this if it's ZERO WIDTH JOINER if (codePoint == 0x200D) continue; if (Character.UnicodeBlock.VARIATION_SELECTORS.equals(Character.UnicodeBlock.of(codePoint))) continue; ++n; } return n; } @Nullable public static byte[] decodeIcon(@Nullable String text, @Nullable String encoding) { if (text != null) { text = text.trim(); int size = text.length(); if (encoding == null || "base64".equals(encoding)) { byte[] base64enc = new byte[size]; for (int i = 0; i < size; i += 1) { char c = text.charAt(i); base64enc[i] = (byte) (c & 0xff); } return Base64.decode(base64enc, Base64.NO_WRAP); } } return null; } public static String getSystemProperty(String property, String defaultValue) { try { @SuppressWarnings("rawtypes") @SuppressLint("PrivateApi") Class clazz = Class.forName("android.os.SystemProperties"); @SuppressWarnings("unchecked") Method getter = clazz.getDeclaredMethod("get", String.class); String value = (String) getter.invoke(null, property); if (value != null && !value.isEmpty()) { return value; } } catch (Exception ignored) { } return defaultValue; } /** * @param resName * @param c * @return */ public static int getResId(String resName, Class c) { try { Field idField = c.getDeclaredField(resName); return idField.getInt(idField); } catch (Exception e) { Log.w(TAG, "getResId( " + resName + " )", e); return -1; } } public static void startAnimatable(ImageView image) { Drawable drawable = image.getDrawable(); if (drawable instanceof Animatable) ((Animatable) drawable).start(); } public static void startAnimatable(TextView textView) { final Runnable startAnimation = () -> { Drawable[] drawables = textView.getCompoundDrawables(); for (Drawable drawable : drawables) if (drawable instanceof Animatable) ((Animatable) drawable).start(); }; if (textView.isLaidOut()) { startAnimation.run(); } else { textView.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { @Override public void onGlobalLayout() { textView.getViewTreeObserver().removeOnGlobalLayoutListener(this); startAnimation.run(); } }); } } /** * @param text text we add the icon to * @param icon drawable to use as an icon * @param layoutDirection will be either View.LAYOUT_DIRECTION_LTR or View.LAYOUT_DIRECTION_RTL. * @return SpannableString with an ImageSpan at the beginning */ public static SpannableString addDrawableBeforeString(@NonNull String text, @NonNull Drawable icon, int layoutDirection) { final SpannableString name; final int pos; if (layoutDirection == View.LAYOUT_DIRECTION_RTL) { name = new SpannableString(text + " #"); pos = name.length() - 1; } else { name = new SpannableString("# " + text); pos = 0; } name.setSpan(new CenteredImageSpan(icon), pos, pos + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); return name; } public static SpannableString addDrawableAfterString(@NonNull String text, @NonNull Drawable icon, int layoutDirection) { int dir = layoutDirection != View.LAYOUT_DIRECTION_RTL ? View.LAYOUT_DIRECTION_RTL : View.LAYOUT_DIRECTION_LTR; return addDrawableBeforeString(text, icon, dir); } public static String appendString(@NonNull String textA, @Nullable String glue, @NonNull String textB, int layoutDirection) { int expectedLength = textA.length() + textB.length(); if (glue != null) expectedLength += glue.length(); StringBuilder builder = new StringBuilder(expectedLength); builder.append(layoutDirection == View.LAYOUT_DIRECTION_RTL ? textB : textA); if (glue != null) builder.append(glue); builder.append(layoutDirection == View.LAYOUT_DIRECTION_RTL ? textA : textB); return builder.toString(); } public interface GetDrawable { @Nullable Drawable getDrawable(@NonNull Context context); } public interface SetDrawable { void setDrawable(@NonNull View view, @NonNull Drawable drawable); } public static abstract class AsyncViewSet extends AsyncTask { protected final WeakReference weakView; protected AsyncViewSet(View view) { super(); this.weakView = new WeakReference<>(view); if (view.getTag() instanceof AsyncViewSet) ((AsyncViewSet) view.getTag()).cancel(true); view.setTag(this); } @Override protected Drawable doInBackground(Void param) { View image = weakView.get(); Activity act = Utilities.getActivity(image); if (isCancelled() || act == null || image.getTag() != this) { weakView.clear(); return null; } Context ctx = image.getContext(); return getDrawable(ctx); } @WorkerThread protected abstract Drawable getDrawable(Context context); @UiThread protected abstract void setDrawable(@NonNull View view, @NonNull Drawable drawable); @Override protected void onPostExecute(Drawable drawable) { View view = weakView.get(); if (view == null || view.getTag() != this) return; Activity act = Utilities.getActivity(view); if (act == null || drawable == null) { weakView.clear(); return; } setDrawable(view, drawable); view.setTag(null); } public void execute() { TaskRunner.executeOnExecutor(ResultViewHelper.EXECUTOR_LOAD_ICON, this); } } public static abstract class AsyncSetDrawable extends AsyncViewSet { protected AsyncSetDrawable(@NonNull ImageView image) { super(image); image.setImageResource(android.R.color.transparent); } @Override protected void setDrawable(@NonNull View image, @NonNull Drawable drawable) { ((ImageView) image).setImageDrawable(drawable); } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/utils/ViewHolderAdapter.java ================================================ package rocks.tbog.tblauncher.utils; import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.BaseAdapter; import androidx.annotation.LayoutRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import java.util.Collection; import rocks.tbog.tblauncher.BuildConfig; import rocks.tbog.tblauncher.WorkAsync.AsyncTask; /** * Adapter class that implements the View holder pattern. * The ViewHolder is held as a tag in the list item view. * * @param Type of data to send to the ViewHolder * @param ViewHolder class */ public abstract class ViewHolderAdapter> extends BaseAdapter { @NonNull final Class mViewHolderClass; @LayoutRes final int mListItemLayout; protected ViewHolderAdapter(@NonNull Class viewHolderClass, @LayoutRes int listItemLayout) { mViewHolderClass = viewHolderClass; mListItemLayout = listItemLayout; } @LayoutRes protected int getItemViewTypeLayout(int viewType) { return mListItemLayout; } @Override public abstract T getItem(int position); @Override public long getItemId(int position) { return getItem(position).hashCode(); } @Override public boolean hasStableIds() { return true; } @Nullable protected VH getNewViewHolder(View view) { VH holder = null; try { holder = mViewHolderClass.getDeclaredConstructor(View.class).newInstance(view); } catch (Exception e) { Log.e("VHA", "ViewHolder can't be instantiated (make sure class and constructor are public)", e); } return holder; } @Override public View getView(int position, View convertView, ViewGroup parent) { final View view; if (convertView == null) { int viewType = getItemViewType(position); if (BuildConfig.DEBUG) { int viewTypeCount = getViewTypeCount(); if (viewType >= viewTypeCount) throw new IllegalStateException("ViewType " + viewType + " >= ViewTypeCount " + viewTypeCount); } @LayoutRes int itemLayout = getItemViewTypeLayout(viewType); view = LayoutInflater.from(parent.getContext()).inflate(itemLayout, parent, false); } else { view = convertView; } Object tag = view.getTag(); VH holder = mViewHolderClass.isInstance(tag) ? mViewHolderClass.cast(tag) : getNewViewHolder(view); if (holder != null) { T content = getItem(position); holder.setContent(content, position, this); } return view; } public static abstract class ViewHolder { protected ViewHolder(View view) { view.setTag(this); } protected abstract void setContent(T content, int position, @NonNull ViewHolderAdapter> adapter); } public static abstract class LoadAsyncData>> extends AsyncTask> { protected final A adapter; private final LoadInBackground task; public interface LoadInBackground { @Nullable Collection loadInBackground(); } public LoadAsyncData(@NonNull A adapter, @NonNull LoadInBackground loadInBackground) { super(); this.adapter = adapter; task = loadInBackground; } @Override protected Collection doInBackground(Void param) { return task.loadInBackground(); } @Override protected void onPostExecute(Collection data) { if (data == null) return; //adapter.addAll(data); onDataLoadFinished(adapter, data); } protected abstract void onDataLoadFinished(@NonNull A adapter, @NonNull Collection data); public void execute() { Utilities.executeAsync(this); } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/utils/ViewHolderListAdapter.java ================================================ package rocks.tbog.tblauncher.utils; import android.util.Log; import androidx.annotation.LayoutRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import java.util.Collection; import java.util.List; public abstract class ViewHolderListAdapter> extends ViewHolderAdapter { @NonNull protected final List mList; protected ViewHolderListAdapter(@NonNull Class viewHolderClass, int listItemLayout, @NonNull List list) { super(viewHolderClass, listItemLayout); mList = list; } @LayoutRes protected int getItemViewTypeLayout(int viewType) { return mListItemLayout; } @Override public T getItem(int position) { return mList.get(position); } @Override public int getCount() { return mList.size(); } public void addItems(Collection items) { mList.addAll(items); notifyDataSetChanged(); } public void addItem(T item) { mList.add(item); notifyDataSetChanged(); } @Nullable public > L newLoadAsyncList(@NonNull Class loadAsyncClass, @NonNull LoadAsyncData.LoadInBackground loadInBackground) { L loadAsync = null; try { loadAsync = loadAsyncClass.getDeclaredConstructor(this.getClass(), LoadAsyncData.LoadInBackground.class).newInstance(this, loadInBackground); } catch (ReflectiveOperationException e) { Log.e("VHLA", "LoadAsync can't be instantiated (make sure class and constructor are public)", e); } return loadAsync; } @NonNull public LoadAsyncList newLoadAsyncList(@NonNull LoadAsyncData.LoadInBackground loadInBackground) { return new LoadAsyncList<>(this, loadInBackground); } public static class LoadAsyncList, A extends ViewHolderListAdapter> extends LoadAsyncData { public LoadAsyncList(@NonNull A adapter, @NonNull LoadInBackground loadInBackground) { super(adapter, loadInBackground); } @Override protected void onDataLoadFinished(@NonNull A adapter, @NonNull Collection data) { if (!isCancelled()) adapter.addItems(data); } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/widgets/ItemTitle.java ================================================ package rocks.tbog.tblauncher.widgets; import androidx.annotation.NonNull; class ItemTitle implements MenuItem { @NonNull private final String name; ItemTitle(@NonNull String string) { this.name = string; } @NonNull @Override public String getName() { return name; } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/widgets/ItemWidget.java ================================================ package rocks.tbog.tblauncher.widgets; import android.content.Context; import android.content.pm.ApplicationInfo; import android.util.Log; import android.view.View; import android.widget.ImageView; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.core.text.HtmlCompat; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.utils.UISizes; import rocks.tbog.tblauncher.utils.Utilities; import rocks.tbog.tblauncher.utils.ViewHolderAdapter; class ItemWidget implements MenuItem { private static final String TAG = ItemWidget.class.getSimpleName(); protected final WidgetInfo info; public ItemWidget(@NonNull WidgetInfo info) { this.info = info; } @NonNull @Override public String getName() { return info.widgetName; } public static class InfoViewHolder extends ViewHolderAdapter.ViewHolder { TextView text1; ImageView icon; public InfoViewHolder(View view) { super(view); text1 = view.findViewById(android.R.id.text1); icon = view.findViewById(android.R.id.icon); } @Override protected void setContent(MenuItem content, int position, @NonNull ViewHolderAdapter> adapter) { final CharSequence text; if (content instanceof ItemWidget) { WidgetInfo info = ((ItemWidget) content).info; Context ctx = text1.getContext(); ApplicationInfo widgetAppInfo = null; try { final String widgetPackage = info.appWidgetInfo.provider.getPackageName(); widgetAppInfo = ctx.getPackageManager().getApplicationInfo(widgetPackage, 0); } catch (Exception e) { Log.w(TAG, "widget " + info.appWidgetInfo.provider, e); } int widgetSdkVer = 0; if (widgetAppInfo != null) { widgetSdkVer = widgetAppInfo.targetSdkVersion; } int cellX = 0; int cellY = 0; if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { cellX = info.appWidgetInfo.targetCellWidth; cellY = info.appWidgetInfo.targetCellHeight; } if (cellX == 0 || cellY == 0) { if (widgetSdkVer >= android.os.Build.VERSION_CODES.S) { // (73×n-16) × (118×m-16) cellX = (int) ((UISizes.px2dp_float(ctx, info.appWidgetInfo.minWidth) + 16f) / 73f); cellY = (int) ((UISizes.px2dp_float(ctx, info.appWidgetInfo.minHeight) + 16f) / 118f); } else { // Android 11 and lower // 70×n−30 cellX = (int) ((UISizes.px2dp(ctx, info.appWidgetInfo.minWidth) + 30f) / 70f); cellY = (int) ((UISizes.px2dp(ctx, info.appWidgetInfo.minHeight) + 30f) / 70f); } } cellX = Math.max(1, cellX); cellY = Math.max(1, cellY); final String html; if (info.widgetDesc != null) { html = text1.getResources().getString(R.string.widget_name_and_desc, info.widgetName, info.widgetDesc, cellX, cellY); } else { html = text1.getResources().getString(R.string.widget_name, info.widgetName, cellX, cellY); } text = HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_LEGACY); Utilities.setIconAsync(icon, context -> WidgetManager.getWidgetPreview(context, info.appWidgetInfo)); } else { text = content.getName(); if (icon != null) icon.setImageDrawable(null); } text1.setText(text); } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/widgets/LoadWidgetsAsync.java ================================================ package rocks.tbog.tblauncher.widgets; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import java.util.Collection; import rocks.tbog.tblauncher.utils.ViewHolderListAdapter; public class LoadWidgetsAsync extends ViewHolderListAdapter.LoadAsyncList { @Nullable public Runnable whenDone = null; public LoadWidgetsAsync(@NonNull WidgetListAdapter adapter, @NonNull LoadInBackground loadInBackground) { super(adapter, loadInBackground); } @Override protected void onDataLoadFinished(@NonNull WidgetListAdapter adapter, @NonNull Collection data) { adapter.clearList(); super.onDataLoadFinished(adapter, data); } @Override protected void onPostExecute(Collection data) { super.onPostExecute(data); if (whenDone != null) whenDone.run(); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/widgets/MenuItem.java ================================================ package rocks.tbog.tblauncher.widgets; import androidx.annotation.NonNull; interface MenuItem { @NonNull String getName(); } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/widgets/PickAppWidgetActivity.java ================================================ package rocks.tbog.tblauncher.widgets; import android.appwidget.AppWidgetManager; import android.appwidget.AppWidgetProviderInfo; import android.content.Context; import android.content.Intent; import android.os.Build; import android.os.Bundle; import android.text.Editable; import android.text.TextUtils; import android.text.TextWatcher; import android.util.Log; import android.view.View; import android.widget.ListView; import android.widget.TextView; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; import androidx.appcompat.app.AppCompatActivity; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.Iterator; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.normalizer.StringNormalizer; import rocks.tbog.tblauncher.utils.FuzzyScore; public class PickAppWidgetActivity extends AppCompatActivity { private static final String TAG = "PickAppWidget"; private TextView mSearch; View widgetLoadingGroup; WidgetListAdapter adapter; LoadWidgetsAsync loadWidgetsAsync = null; @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.widget_picker); final Context context = getApplicationContext(); final ListView list = findViewById(android.R.id.list); adapter = new WidgetListAdapter(); widgetLoadingGroup = findViewById(R.id.widgetLoadingGroup); list.setAdapter(adapter); list.setOnItemClickListener((parent, view, position, id) -> { Object item = parent.getAdapter().getItem(position); WidgetInfo info = null; if (item instanceof ItemWidget) info = ((ItemWidget) item).info; if (info == null) return; Intent intent = getIntent(); var appWidgetManager = AppWidgetManager.getInstance(context); int appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, WidgetManager.INVALID_WIDGET_ID); if (appWidgetId != WidgetManager.INVALID_WIDGET_ID) { boolean bindAllowed; if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP) { bindAllowed = appWidgetManager.bindAppWidgetIdIfAllowed(appWidgetId, info.appWidgetInfo.getProfile(), info.appWidgetInfo.provider, null); } else { bindAllowed = appWidgetManager.bindAppWidgetIdIfAllowed(appWidgetId, info.appWidgetInfo.provider); } intent.putExtra(WidgetManager.EXTRA_WIDGET_BIND_ALLOWED, bindAllowed); intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_PROVIDER, info.appWidgetInfo.provider); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_PROVIDER_PROFILE, info.appWidgetInfo.getProfile()); } setResult(RESULT_OK, intent); } else { setResult(RESULT_CANCELED, intent); } finish(); }); // set page search bar mSearch = findViewById(R.id.search); mSearch.addTextChangedListener(new TextWatcher() { public void afterTextChanged(Editable s) { // Auto left-trim text. if (s.length() > 0 && s.charAt(0) == ' ') s.delete(0, 1); } public void beforeTextChanged(CharSequence s, int start, int count, int after) { } public void onTextChanged(CharSequence s, int start, int before, int count) { mSearch.post(() -> refreshList()); } }); mSearch.requestFocus(); refreshList(); } private synchronized void refreshList() { final Context context = getApplicationContext(); widgetLoadingGroup.setVisibility(View.VISIBLE); if (loadWidgetsAsync != null) loadWidgetsAsync.cancel(false); loadWidgetsAsync = adapter.newLoadAsyncList(LoadWidgetsAsync.class, () -> { // get widget list var widgetList = getWidgetList(context); var text = mSearch.getText(); if (text.length() > 0) { StringNormalizer.Result normalized = StringNormalizer.normalizeWithResult(text, true); FuzzyScore fuzzyScore = new FuzzyScore(normalized.codePoints); for (Iterator iterator = widgetList.iterator(); iterator.hasNext(); ) { WidgetInfo widgetInfo = iterator.next(); var matchAppName = !TextUtils.isEmpty(widgetInfo.appName) && fuzzyScore.match(widgetInfo.appName).match; var matchWidgetName = !TextUtils.isEmpty(widgetInfo.widgetName) && fuzzyScore.match(widgetInfo.widgetName).match; var matchDescription = !TextUtils.isEmpty(widgetInfo.widgetDesc) && fuzzyScore.match(widgetInfo.widgetDesc).match; if (!matchAppName && !matchWidgetName && !matchDescription) iterator.remove(); } } // sort list Collections.sort(widgetList, Comparator.comparing(o -> o.appName)); //StringBuilder dbgList = new StringBuilder(); // assuming the list is sorted by apps, add titles with app name ArrayList adapterList = new ArrayList<>(widgetList.size()); String lastApp = null; for (var item : widgetList) { if (!item.appName.equals(lastApp)) { //dbgList // .append("\napp=`") // .append(item.appName) // .append("`"); lastApp = item.appName; adapterList.add(new ItemTitle(item.appName)); } //dbgList // .append("\n\twidget=`") // .append(item.widgetName) // .append("`\n\t\tdesc=`") // .append(item.widgetDesc) // .append("`"); adapterList.add(new ItemWidget(item)); } //Log.d(TAG, dbgList.toString()); Log.d(TAG, "list size=" + adapterList.size()); return adapterList; }); if (loadWidgetsAsync != null) { loadWidgetsAsync.whenDone = () -> { widgetLoadingGroup.setVisibility(View.GONE); synchronized (PickAppWidgetActivity.this) { loadWidgetsAsync = null; } }; loadWidgetsAsync.execute(); } else { finish(); Toast.makeText(context, R.string.add_widget_failed, Toast.LENGTH_LONG).show(); } } @WorkerThread private static ArrayList getWidgetList(@NonNull Context context) { var appWidgetManager = AppWidgetManager.getInstance(context); var installedProviders = appWidgetManager.getInstalledProviders(); var infoArrayList = new ArrayList(installedProviders.size()); var packageManager = context.getPackageManager(); for (AppWidgetProviderInfo providerInfo : installedProviders) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { if (providerInfo.widgetFeatures == AppWidgetProviderInfo.WIDGET_FEATURE_HIDE_FROM_PICKER) { // widget is hidden continue; } } if ((providerInfo.widgetCategory & (AppWidgetProviderInfo.WIDGET_CATEGORY_HOME_SCREEN | AppWidgetProviderInfo.WIDGET_CATEGORY_SEARCHBOX)) == 0) { // widget is not for home screen usage continue; } // get widget name String label = null; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { label = providerInfo.loadLabel(packageManager); } if (label == null) { label = providerInfo.label; } if (label == null) label = providerInfo.provider.flattenToShortString(); // get widget description String description = null; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { var desc = providerInfo.loadDescription(context); if (desc != null) description = desc.toString(); } // it's useless to have the description the same as the label if (label.equals(description)) description = null; String appName = providerInfo.provider.getPackageName(); try { var appInfo = packageManager.getApplicationInfo(providerInfo.provider.getPackageName(), 0); appName = appInfo.loadLabel(packageManager).toString(); } catch (Exception e) { Log.e(TAG, "get `" + providerInfo.provider.getPackageName() + "` label"); } infoArrayList.add(new WidgetInfo(appName, label, description, providerInfo)); } return infoArrayList; } @Override public void onBackPressed() { setResult(RESULT_CANCELED, getIntent()); super.onBackPressed(); } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/widgets/WidgetInfo.java ================================================ package rocks.tbog.tblauncher.widgets; import android.appwidget.AppWidgetProviderInfo; class WidgetInfo { final String appName; final String widgetName; final String widgetDesc; final AppWidgetProviderInfo appWidgetInfo; WidgetInfo(String app, String name, String description, AppWidgetProviderInfo appWidgetInfo) { this.appName = app; this.widgetName = name; this.widgetDesc = description; this.appWidgetInfo = appWidgetInfo; } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/widgets/WidgetLayout.java ================================================ package rocks.tbog.tblauncher.widgets; import android.annotation.SuppressLint; import android.appwidget.AppWidgetHostView; import android.content.ComponentName; import android.content.Context; import android.graphics.Point; import android.graphics.PointF; import android.graphics.Rect; import android.os.Build; import android.util.AttributeSet; import android.util.Log; import android.view.Gravity; import android.view.LayoutInflater; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import android.widget.FrameLayout; import android.widget.ImageView; import android.widget.LinearLayout; import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import java.util.ArrayList; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.TBLauncherActivity; import rocks.tbog.tblauncher.utils.ArrayHelper; public class WidgetLayout extends ViewGroup { private static final String TAG = "WdgLayout"; /** * These are used for computing child frames based on their gravity. */ private final Rect mTmpContainerRect = new Rect(); private final Rect mTmpChildRect = new Rect(); private final Point mPageCount = new Point(1, 1); private final ArrayList mAfterLayoutTaskList = new ArrayList<>(1); public interface OnAfterLayoutTask { void onAfterLayout(); } public enum Handle { MOVE_FREE, MOVE_AXIAL, RESIZE_DIAGONAL, RESIZE_AXIAL, MOVE_FREE_RESIZE_AXIAL, RESIZE_DIAGONAL_MOVE_AXIAL, DISABLED; public boolean isMove() { return this == MOVE_FREE || this == MOVE_AXIAL; } public boolean isResize() { return this == RESIZE_DIAGONAL || this == RESIZE_AXIAL; } public boolean isMoveResize() { return this == MOVE_FREE_RESIZE_AXIAL || this == RESIZE_DIAGONAL_MOVE_AXIAL; } } public WidgetLayout(Context context) { this(context, null); } public WidgetLayout(Context context, AttributeSet attrs) { this(context, attrs, 0); } public WidgetLayout(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); init(); } @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) public WidgetLayout(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); init(); } private void init() { setClipChildren(true); setClipToPadding(true); } public void setPageCount(int horizontal, int vertical) { if (horizontal < 1 || vertical < 1) throw new IllegalStateException("setPageCount(" + horizontal + "," + vertical + ") Page count must be >= 1"); mPageCount.set(horizontal, vertical); } public int getHorizontalPageCount() { return mPageCount.x; } public int getVerticalPageCount() { return mPageCount.y; } /** * Add a one time run task * * @param task what to run */ public void addOnAfterLayoutTask(OnAfterLayoutTask task) { mAfterLayoutTaskList.add(task); } /** * Set current page * * @param pageX horizontal page to show * @param pageY vertical page to show */ public void scrollToPage(float pageX, float pageY) { final int pageWidth = getWidth() / mPageCount.x; final int pageHeight = getHeight() / mPageCount.y; final float x = pageWidth * (mPageCount.x - 1) * pageX; final float y = pageHeight * (mPageCount.y - 1) * pageY; scrollTo((int) x, (int) y); } // public boolean isHandleEnabled(View widgetView) { // return indexOfChild(widgetView) == -1; // } @NonNull public Handle getHandleType(View widgetView) { int viewIndex = indexOfChild(widgetView); if (viewIndex == -1) { for (int idx = 0; idx < getChildCount(); idx += 1) { View child = getChildAt(idx); if (child instanceof ViewGroup) { viewIndex = ((ViewGroup) child).indexOfChild(widgetView); if (viewIndex != -1) { // we keep the handle type in the tag Object tag = child.getTag(); if (tag instanceof Handle) return (Handle) child.getTag(); throw new IllegalStateException("widget view tag should hold the Handle"); } } } } return Handle.DISABLED; } public void disableHandle(View widgetView) { enableHandle(widgetView, Handle.DISABLED); } public void enableHandle(View widgetView, Handle handle) { convertAutoPositionTo(PageLayoutParams.Placement.MARGIN_TL_AS_POSITION); ViewGroup widgetHandle = null; // remove widget view from this layout int viewIndex = indexOfChild(widgetView); // if the widget is already wrapped by the handle if (viewIndex == -1) { for (int idx = 0; idx < getChildCount(); idx += 1) { View child = getChildAt(idx); if (child instanceof ViewGroup) { viewIndex = ((ViewGroup) child).indexOfChild(widgetView); if (viewIndex != -1) { widgetHandle = (ViewGroup) child; break; } } } // can't find the widget handle if (widgetHandle == null) return; } else { removeViewAt(viewIndex); // inflate the widget handle layout widgetHandle = (ViewGroup) LayoutInflater.from(getContext()).inflate(R.layout.widget_handle, this, false); { PageLayoutParams lp = new PageLayoutParams((PageLayoutParams) widgetView.getLayoutParams()); lp.width = ViewGroup.LayoutParams.WRAP_CONTENT; lp.height = ViewGroup.LayoutParams.WRAP_CONTENT; widgetHandle.setLayoutParams(lp); } { PageLayoutParams lp = (PageLayoutParams) widgetView.getLayoutParams(); lp.setMargins(0, 0, 0, 0); widgetView.setLayoutParams(lp); } // add the widget view to the handle layout as the first child widgetHandle.addView(widgetView, 0); // add the handle layout to this layout addView(widgetHandle, viewIndex); } // use the tag to keep the handle type widgetHandle.setTag(handle); switch (handle) { case DISABLED: { final PageLayoutParams lp = (PageLayoutParams) widgetHandle.getLayoutParams(); lp.width = widgetView.getWidth(); lp.height = widgetView.getHeight(); int idx = indexOfChild(widgetHandle); widgetHandle.removeViewAt(0); removeViewAt(idx); addView(widgetView, idx); widgetView.setLayoutParams(lp); break; } case MOVE_FREE: setupCornerHandles(widgetHandle, R.drawable.ic_handle_move, sMoveListener, true); break; case MOVE_AXIAL: setupLineHandles(widgetHandle, R.drawable.ic_handle_move, sMoveListener, true); break; case RESIZE_DIAGONAL: setupCornerHandles(widgetHandle, R.drawable.ic_handle_resize_bl, sResizeListener, true); break; case RESIZE_AXIAL: setupLineHandles(widgetHandle, R.drawable.ic_handle_resize_l, sResizeListener, true); break; case MOVE_FREE_RESIZE_AXIAL: setupCornerHandles(widgetHandle, R.drawable.ic_handle_move, sMoveListener, false); setupLineHandles(widgetHandle, R.drawable.ic_handle_resize_l, sResizeListener, false); break; case RESIZE_DIAGONAL_MOVE_AXIAL: setupCornerHandles(widgetHandle, R.drawable.ic_handle_resize_bl, sResizeListener, false); setupLineHandles(widgetHandle, R.drawable.ic_handle_move, sMoveListener, false); break; } } @SuppressLint("ClickableViewAccessibility") private static void setupCornerHandles(ViewGroup widgetHandle, @DrawableRes int ic_handle_corner, OnTouchListener touchListener, boolean hideOthers) { if (hideOthers) { widgetHandle.findViewById(R.id.handle_left).setVisibility(GONE); widgetHandle.findViewById(R.id.handle_top).setVisibility(GONE); widgetHandle.findViewById(R.id.handle_right).setVisibility(GONE); widgetHandle.findViewById(R.id.handle_bottom).setVisibility(GONE); } { ImageView image = widgetHandle.findViewById(R.id.handle_top_left); image.setVisibility(VISIBLE); image.setImageResource(ic_handle_corner); image.setOnTouchListener(touchListener); } { ImageView image = widgetHandle.findViewById(R.id.handle_top_right); image.setVisibility(VISIBLE); image.setImageResource(ic_handle_corner); image.setOnTouchListener(touchListener); } { ImageView image = widgetHandle.findViewById(R.id.handle_bottom_right); image.setVisibility(VISIBLE); image.setImageResource(ic_handle_corner); image.setOnTouchListener(touchListener); } { ImageView image = widgetHandle.findViewById(R.id.handle_bottom_left); image.setVisibility(VISIBLE); image.setImageResource(ic_handle_corner); image.setOnTouchListener(touchListener); } } @SuppressLint("ClickableViewAccessibility") private static void setupLineHandles(ViewGroup widgetHandle, @DrawableRes int ic_handle, OnTouchListener touchListener, boolean hideOthers) { if (hideOthers) { widgetHandle.findViewById(R.id.handle_top_left).setVisibility(GONE); widgetHandle.findViewById(R.id.handle_top_right).setVisibility(GONE); widgetHandle.findViewById(R.id.handle_bottom_right).setVisibility(GONE); widgetHandle.findViewById(R.id.handle_bottom_left).setVisibility(GONE); } { ImageView image = widgetHandle.findViewById(R.id.handle_left); image.setVisibility(VISIBLE); image.setImageResource(ic_handle); image.setOnTouchListener(touchListener); } { ImageView image = widgetHandle.findViewById(R.id.handle_top); image.setVisibility(VISIBLE); image.setImageResource(ic_handle); image.setOnTouchListener(touchListener); } { ImageView image = widgetHandle.findViewById(R.id.handle_right); image.setVisibility(VISIBLE); image.setImageResource(ic_handle); image.setOnTouchListener(touchListener); } { ImageView image = widgetHandle.findViewById(R.id.handle_bottom); image.setVisibility(VISIBLE); image.setImageResource(ic_handle); image.setOnTouchListener(touchListener); } } private void convertAutoPositionTo(PageLayoutParams.Placement placement) { final int childCount = getChildCount(); for (int childIdx = 0; childIdx < childCount; childIdx++) { final View child = getChildAt(childIdx); final PageLayoutParams lp = (PageLayoutParams) child.getLayoutParams(); if (lp.placement != PageLayoutParams.Placement.AUTO) continue; lp.placement = placement; lp.leftMargin = child.getLeft(); lp.topMargin = child.getTop(); //lp.rightMargin = child.getRight(); //lp.bottomMargin = child.getBottom(); child.setLayoutParams(lp); } } /** * This prevents the pressed state from appearing when the user is actually trying to scroll the content. * * @return true */ @Override public boolean shouldDelayChildPressedState() { return TBApplication.liveWallpaper(getContext()).isPreferenceWPDragAnimate(); } @Override public PageLayoutParams generateLayoutParams(AttributeSet attrs) { return new PageLayoutParams(getContext(), attrs); } @Override protected ViewGroup.LayoutParams generateLayoutParams(ViewGroup.LayoutParams p) { if (p instanceof LinearLayout.LayoutParams) return new PageLayoutParams((LinearLayout.LayoutParams) p); return new PageLayoutParams(new LinearLayout.LayoutParams(p)); } @Override protected ViewGroup.LayoutParams generateDefaultLayoutParams() { return new PageLayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); } @Override protected boolean checkLayoutParams(ViewGroup.LayoutParams p) { return p instanceof PageLayoutParams; } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int count = getChildCount(); int width = Math.max(getSuggestedMinimumWidth(), MeasureSpec.getSize(widthMeasureSpec)); int height = Math.max(getSuggestedMinimumHeight(), MeasureSpec.getSize(heightMeasureSpec)); // Iterate through all children and measure them. for (int i = 0; i < count; i++) { final View child = getChildAt(i); if (child.getVisibility() == GONE) continue; ViewGroup.LayoutParams lp = child.getLayoutParams(); // Measure the child. final int childWidthMeasureSpec; if (lp.width == ViewGroup.LayoutParams.WRAP_CONTENT) childWidthMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); else childWidthMeasureSpec = ViewGroup.getChildMeasureSpec(widthMeasureSpec, 0, lp.width); final int childHeightMeasureSpec; if (lp.height == ViewGroup.LayoutParams.WRAP_CONTENT) childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); else childHeightMeasureSpec = ViewGroup.getChildMeasureSpec(heightMeasureSpec, 0, lp.height); child.measure(childWidthMeasureSpec, childHeightMeasureSpec); //TODO: check that child is not out of bounds } for (int pagePosition : PageLayoutParams.PAGE_POSITIONS) { layoutPagePosition(pagePosition, width, height, false); width = Math.max(width, mTmpContainerRect.width()); height = Math.max(height, mTmpContainerRect.height()); } int resolvedWidth = resolveSize(width, widthMeasureSpec); int resolvedHeight = resolveSize(height, heightMeasureSpec); setMeasuredDimension(resolvedWidth, resolvedHeight); } @Override protected void onLayout(boolean changed, int left, int top, int right, int bottom) { final int pageWidth = right - left; final int pageHeight = bottom - top; Log.d(TAG, "onLayout left=" + left + " top=" + top + " width=" + pageWidth + " height=" + pageHeight + " pageCount=" + mPageCount.x + "x" + mPageCount.y); if (mPageCount.x == 1 && mPageCount.y == 1) { layoutPagePosition(PageLayoutParams.PAGE_MIDDLE, pageWidth, pageHeight, true); } else if (mPageCount.x > 1 && mPageCount.y == 1) { for (int pagePos : PageLayoutParams.PAGE_POSITIONS_HORIZONTAL) { layoutPagePosition(pagePos, pageWidth, pageHeight, true); } } else if (mPageCount.x == 1 && mPageCount.y > 1) { for (int pagePos : PageLayoutParams.PAGE_POSITIONS_VERTICAL) { layoutPagePosition(pagePos, pageWidth, pageHeight, true); } } else { for (int pagePos : PageLayoutParams.PAGE_POSITIONS) layoutPagePosition(pagePos, pageWidth, pageHeight, true); } callAfterLayout(); } protected void callAfterLayout() { for (OnAfterLayoutTask afterLayout : mAfterLayoutTaskList) afterLayout.onAfterLayout(); mAfterLayoutTaskList.clear(); } @Nullable public AppWidgetHostView getWidget(int appWidgetId) { for (int idx = 0; idx < getChildCount(); idx += 1) { View child = getChildAt(idx); if (child instanceof AppWidgetHostView && ((AppWidgetHostView) child).getAppWidgetId() == appWidgetId) { return (AppWidgetHostView) child; } if (child instanceof ViewGroup) { View view = ((ViewGroup) child).getChildAt(0); if (view instanceof AppWidgetHostView && ((AppWidgetHostView) view).getAppWidgetId() == appWidgetId) { return (AppWidgetHostView) view; } } } return null; } @Nullable public View getPlaceholder(@NonNull ComponentName provider) { for (int idx = 0; idx < getChildCount(); idx += 1) { View child = getChildAt(idx); if (provider.equals(child.getTag())) return child; } return null; } public void addPlaceholder(View placeholder, ComponentName provider) { addView(placeholder); placeholder.setTag(provider); } public void removeWidget(AppWidgetHostView view) { disableHandle(view); removeView(view); } public boolean removeWidget(int appWidgetId) { for (int idx = 0; idx < getChildCount(); idx += 1) { View child = getChildAt(idx); if (child instanceof AppWidgetHostView && ((AppWidgetHostView) child).getAppWidgetId() == appWidgetId) { removeView(child); return true; } if (child instanceof ViewGroup) { View view = ((ViewGroup) child).getChildAt(0); if (view instanceof AppWidgetHostView && ((AppWidgetHostView) view).getAppWidgetId() == appWidgetId) { removeWidget((AppWidgetHostView) view); return true; } } } return false; } private int getLeftMarginForPage(int page, int width) { if (mPageCount.x == 1) return 0; final int pagePos = PageLayoutParams.getPagePosition(page); final int pageIdx = PageLayoutParams.getPageIndex(page); if (pagePos == PageLayoutParams.PAGE_LEFT) return (mPageCount.x / 2 - pageIdx) * width; final int center = mPageCount.x / 2 * width; if (pagePos == PageLayoutParams.PAGE_RIGHT) return center + pageIdx * width; return center; } private int getTopMarginForPage(int page, int height) { if (mPageCount.y == 1) return 0; final int pagePos = PageLayoutParams.getPagePosition(page); final int pageIdx = PageLayoutParams.getPageIndex(page); if (pagePos == PageLayoutParams.PAGE_UP) return (mPageCount.y / 2 - pageIdx) * height; final int center = mPageCount.y / 2 * height; if (pagePos == PageLayoutParams.PAGE_DOWN) return center + pageIdx * height; return center; } private void layoutPagePosition(int pagePosition, int pageWidth, int pageHeight, boolean childLayout) { final int pageStart; final int pageCount; if (pagePosition == PageLayoutParams.PAGE_MIDDLE) { pageStart = 0; pageCount = 1; } else { pageStart = 1; boolean horizontal = ArrayHelper.contains(PageLayoutParams.PAGE_POSITIONS_HORIZONTAL, pagePosition); pageCount = 1 + (horizontal ? mPageCount.x : mPageCount.y) / 2; } int width = pageWidth; int height = pageHeight; for (int pageIdx = pageStart; pageIdx < pageCount; pageIdx += 1) { int page = PageLayoutParams.makePage(pagePosition, pageIdx); pageLayout(page, width, height, childLayout); width = Math.max(width, mTmpContainerRect.width()); height = Math.max(height, mTmpContainerRect.height()); } } private void pageLayout(int page, int width, int height, boolean childLayout) { mTmpContainerRect.setEmpty(); final int pageTop = getTopMarginForPage(page, height); final int pageLeft = getLeftMarginForPage(page, width); // apply padding final int pageWidth = width - getPaddingLeft() - getPaddingRight(); final int pageHeight = height - getPaddingTop() - getPaddingBottom(); Log.d(TAG, (childLayout ? "childLayout " : "pageLayout ") + PageLayoutParams.debugPage(page) + " left=" + pageLeft + " top=" + pageTop + " width=" + pageWidth + " height=" + pageHeight); int autoX = 0; int autoY = 0; int maxChildY = 0; StringBuilder debugChild = new StringBuilder(); final int childCount = getChildCount(); for (int childIdx = 0; childIdx < childCount; childIdx++) { final View child = getChildAt(childIdx); if (child.getVisibility() == GONE) continue; final PageLayoutParams lp = (PageLayoutParams) child.getLayoutParams(); if (PageLayoutParams.validatedPage(lp.screenPage) != page) continue; debugChild.setLength(0); debugChild.append(Integer.toHexString(System.identityHashCode(child))) .append(" child #") .append(childIdx) .append("\n\t"); mTmpChildRect.set(0, 0, child.getMeasuredWidth(), child.getMeasuredHeight()); debugChild.append("measured ") .append(child.getMeasuredWidth()) .append("×") .append(child.getMeasuredHeight()) .append("\n\t"); switch (lp.placement) { case AUTO: mTmpChildRect.offset(autoX, autoY); if (mTmpChildRect.right > pageWidth) { mTmpChildRect.offset(-autoX, -autoY); autoX = 0; autoY = maxChildY; mTmpChildRect.offset(autoX, autoY); } if (mTmpChildRect.bottom > pageHeight) { mTmpChildRect.offset(-autoX, -autoY); autoY = 0; maxChildY = mTmpContainerRect.bottom; mTmpChildRect.offset(autoX, autoY); } autoX = mTmpChildRect.right; maxChildY = Math.max(maxChildY, mTmpChildRect.bottom); break; case MARGIN_TL_AS_POSITION: mTmpChildRect.offset(lp.leftMargin, lp.topMargin); } debugChild.append("offset in page ") .append(mTmpChildRect.left) .append("×") .append(mTmpChildRect.top) .append(" size ") .append(mTmpChildRect.width()) .append("×") .append(mTmpChildRect.height()) .append("\n\t"); // apply page offset mTmpChildRect.offset(pageLeft, pageTop); int initialLeft = mTmpChildRect.left; int initialTop = mTmpChildRect.top; // don't let the child start to the right of this page while (mTmpChildRect.left > (pageLeft + width)) mTmpChildRect.offset(-Math.min(width, mTmpChildRect.width()) / 4, 0); // don't let the child end to the left of this page while (mTmpChildRect.right < pageLeft) mTmpChildRect.offset(Math.min(width, mTmpChildRect.width()) / 4, 0); // don't let the child start below this page while (mTmpChildRect.top > (pageTop + height)) mTmpChildRect.offset(0, -Math.min(height, mTmpChildRect.height()) / 4); // don't let the child end above this page while (mTmpChildRect.bottom < pageTop) mTmpChildRect.offset(0, Math.min(height, mTmpChildRect.height()) / 4); debugChild.append("page correction ") .append(initialLeft - mTmpChildRect.left) .append("×") .append(initialTop - mTmpChildRect.top) .append(" padding ") .append(getPaddingLeft()) .append("×") .append(getPaddingTop()) .append("\n\t"); // apply page padding mTmpChildRect.offset(getPaddingLeft(), getPaddingTop()); // Place the child. if (childLayout) { child.layout(mTmpChildRect.left, mTmpChildRect.top, mTmpChildRect.right, mTmpChildRect.bottom); debugChild.append("layout"); } Log.d(TAG, debugChild.toString()); mTmpContainerRect.union(mTmpChildRect); } } private static final OnTouchListener sMoveListener = new OnTouchListener() { final PointF mDownPos = new PointF(); @SuppressLint({"RtlHardcoded", "ClickableViewAccessibility"}) @Override public boolean onTouch(View v, MotionEvent event) { final int action = event.getActionMasked(); final View parent = (View) v.getParent(); switch (action) { case MotionEvent.ACTION_DOWN: mDownPos.set(event.getRawX(), event.getRawY()); return true; case MotionEvent.ACTION_MOVE: { final float xMove = event.getRawX() - mDownPos.x; final float yMove = event.getRawY() - mDownPos.y; int gravity = ((FrameLayout.LayoutParams) v.getLayoutParams()).gravity; boolean horizontal = ((gravity & Gravity.LEFT) == Gravity.LEFT) || ((gravity & Gravity.RIGHT) == Gravity.RIGHT); boolean vertical = ((gravity & Gravity.TOP) == Gravity.TOP) || ((gravity & Gravity.BOTTOM) == Gravity.BOTTOM); if (horizontal) parent.setTranslationX(xMove); if (vertical) parent.setTranslationY(yMove); return true; } case MotionEvent.ACTION_UP: { final PageLayoutParams lp = (PageLayoutParams) parent.getLayoutParams(); int left = lp.leftMargin; int top = lp.topMargin; lp.leftMargin += (int) parent.getTranslationX(); lp.topMargin += (int) parent.getTranslationY(); parent.setTranslationX(0f); parent.setTranslationY(0f); parent.setLayoutParams(lp); Log.d(TAG, Integer.toHexString(System.identityHashCode(parent)) + "\n\tbefore pos " + left + "×" + top + "\n\t after pos " + lp.leftMargin + "×" + lp.topMargin); return true; } case MotionEvent.ACTION_CANCEL: { parent.setTranslationX(0f); parent.setTranslationY(0f); return true; } } return false; } }; private static final OnTouchListener sResizeListener = new OnTouchListener() { final Point mDownSize = new Point(); final Point mDownMargin = new Point(); final PointF mDownPos = new PointF(); @SuppressLint({"RtlHardcoded", "ClickableViewAccessibility"}) @Override public boolean onTouch(View v, MotionEvent event) { final ViewGroup parent = (ViewGroup) v.getParent(); final View widgetView = parent.getChildAt(0); final int action = event.getActionMasked(); switch (action) { case MotionEvent.ACTION_DOWN: { { final ViewGroup.LayoutParams lp = widgetView.getLayoutParams(); mDownSize.set(lp.width, lp.height); } { final PageLayoutParams lp = (PageLayoutParams) parent.getLayoutParams(); mDownMargin.set(lp.leftMargin, lp.topMargin); } mDownPos.set(event.getRawX(), event.getRawY()); return true; } case MotionEvent.ACTION_MOVE: { int xMove = (int) (event.getRawX() - mDownPos.x + .5f); int yMove = (int) (event.getRawY() - mDownPos.y + .5f); int gravity = ((FrameLayout.LayoutParams) v.getLayoutParams()).gravity; // move widget handler { final PageLayoutParams lp = (PageLayoutParams) parent.getLayoutParams(); boolean changed = false; if ((gravity & Gravity.LEFT) == Gravity.LEFT) { lp.leftMargin = mDownMargin.x + xMove; xMove = -xMove; changed = true; } if ((gravity & Gravity.TOP) == Gravity.TOP) { lp.topMargin = mDownMargin.y + yMove; yMove = -yMove; changed = true; } if (changed) parent.setLayoutParams(lp); } // resize widget { final ViewGroup.LayoutParams lp = widgetView.getLayoutParams(); boolean horizontal = ((gravity & Gravity.LEFT) == Gravity.LEFT) || ((gravity & Gravity.RIGHT) == Gravity.RIGHT); boolean vertical = ((gravity & Gravity.TOP) == Gravity.TOP) || ((gravity & Gravity.BOTTOM) == Gravity.BOTTOM); if (horizontal) lp.width = mDownSize.x + xMove; if (vertical) lp.height = mDownSize.y + yMove; widgetView.setLayoutParams(lp); if (widgetView instanceof AppWidgetHostView hostView) { TBLauncherActivity activity = TBApplication.launcherActivity(v.getContext()); if (activity != null) activity.widgetManager.updateWidgetSize(hostView, lp.width, lp.height); } } //requestLayout(); return true; } case MotionEvent.ACTION_UP: { return true; } case MotionEvent.ACTION_CANCEL: { { final ViewGroup.LayoutParams lp = widgetView.getLayoutParams(); lp.width = mDownSize.x; lp.height = mDownSize.y; widgetView.setLayoutParams(lp); } { final PageLayoutParams lp = (PageLayoutParams) parent.getLayoutParams(); lp.leftMargin = mDownMargin.x; lp.topMargin = mDownMargin.y; parent.setLayoutParams(lp); } return true; } } return false; } }; public static class PageLayoutParams extends ViewGroup.MarginLayoutParams { /** * The screen/page to put this view into */ public static final int PAGE_MIDDLE = 0; public static final int PAGE_LEFT = 1; public static final int PAGE_RIGHT = 2; public static final int PAGE_UP = 4; public static final int PAGE_DOWN = 8; public static final int PAGE_POSITION_SHIFT = 0; public static final int PAGE_POSITION_MASK = (PAGE_LEFT | PAGE_RIGHT | PAGE_UP | PAGE_DOWN) << PAGE_POSITION_SHIFT; // = 0xf public static final int PAGE_DISTANCE_SHIFT = 4; public static final int PAGE_DISTANCE_MASK = 0xf << PAGE_DISTANCE_SHIFT; public static final int[] PAGE_POSITIONS = new int[]{PAGE_LEFT, PAGE_UP, PAGE_MIDDLE, PAGE_RIGHT, PAGE_DOWN}; public static final int[] PAGE_POSITIONS_HORIZONTAL = new int[]{PAGE_LEFT, PAGE_MIDDLE, PAGE_RIGHT}; public static final int[] PAGE_POSITIONS_VERTICAL = new int[]{PAGE_UP, PAGE_MIDDLE, PAGE_DOWN}; public int screenPage = PAGE_MIDDLE; public Placement placement = Placement.AUTO; public static int makePage(int pagePosition, int pageIdx) { int pos = (pagePosition << PAGE_POSITION_SHIFT) & PAGE_POSITION_MASK; int idx = (pageIdx << PAGE_DISTANCE_SHIFT) & PAGE_DISTANCE_MASK; return pos | idx; } public static int validatedPage(int page) { int pos = getPagePosition(page); int idx = getPageIndex(page); return makePage(pos, idx); } public static String debugPage(int page) { int pos = (page & PAGE_POSITION_MASK) >> PAGE_POSITION_SHIFT; int idx = (page & PAGE_DISTANCE_MASK) >> PAGE_DISTANCE_SHIFT; switch (pos) { case PAGE_LEFT: return idx + "L"; case PAGE_UP: return idx + "U"; case PAGE_RIGHT: return idx + "R"; case PAGE_DOWN: return idx + "D"; case PAGE_MIDDLE: return idx + "M"; default: return String.valueOf(idx); } } public static int getPagePosition(int page) { return (page & PAGE_POSITION_MASK) >> PAGE_POSITION_SHIFT; } public static int getPageIndex(int page) { int pos = (page & PAGE_POSITION_MASK) >> PAGE_POSITION_SHIFT; int idx = (page & PAGE_DISTANCE_MASK) >> PAGE_DISTANCE_SHIFT; // middle page is special if (pos == PAGE_MIDDLE) return 0; // only middle page is allowed to have idx 0 if (idx == 0) return 1; return idx; } public enum Placement { AUTO, MARGIN_TL_AS_POSITION, } public PageLayoutParams(Context ctx, AttributeSet attrs) { super(ctx, attrs); } public PageLayoutParams(int width, int height) { super(width, height); } public PageLayoutParams(ViewGroup.MarginLayoutParams source) { super(source); } public PageLayoutParams(PageLayoutParams source) { this((ViewGroup.MarginLayoutParams) source); screenPage = source.screenPage; placement = source.placement; } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/widgets/WidgetListAdapter.java ================================================ package rocks.tbog.tblauncher.widgets; import java.util.ArrayList; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.utils.ViewHolderListAdapter; public class WidgetListAdapter extends ViewHolderListAdapter { public WidgetListAdapter() { super(ItemWidget.InfoViewHolder.class, R.layout.popup_list_item_icon, new ArrayList<>()); } public void clearList() { mList.clear(); notifyDataSetChanged(); } @Override public boolean isEnabled(int position) { return !(getItem(position) instanceof ItemTitle); } @Override protected int getItemViewTypeLayout(int viewType) { if (viewType == 1) return R.layout.popup_title; return super.getItemViewTypeLayout(viewType); } public int getItemViewType(int position) { return getItem(position) instanceof ItemTitle ? 1 : 0; } public int getViewTypeCount() { return 2; } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/widgets/WidgetManager.java ================================================ package rocks.tbog.tblauncher.widgets; import android.app.Activity; import android.app.ActivityOptions; import android.appwidget.AppWidgetHost; import android.appwidget.AppWidgetHostView; import android.appwidget.AppWidgetManager; import android.appwidget.AppWidgetProviderInfo; import android.content.ActivityNotFoundException; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; import android.content.res.Resources; import android.graphics.PointF; import android.graphics.Rect; import android.graphics.drawable.Drawable; import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.os.UserHandle; import android.util.ArrayMap; import android.util.Log; import android.util.SizeF; import android.view.ContextThemeWrapper; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.ImageView; import android.widget.ListAdapter; import android.widget.TextView; import android.widget.Toast; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import androidx.annotation.StringRes; import androidx.annotation.WorkerThread; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; import androidx.core.content.res.ResourcesCompat; import java.util.ArrayList; import java.util.Iterator; import rocks.tbog.tblauncher.Behaviour; import rocks.tbog.tblauncher.LiveWallpaper; import rocks.tbog.tblauncher.R; import rocks.tbog.tblauncher.SettingsActivity; import rocks.tbog.tblauncher.TBApplication; import rocks.tbog.tblauncher.db.DBHelper; import rocks.tbog.tblauncher.db.PlaceholderWidgetRecord; import rocks.tbog.tblauncher.db.WidgetRecord; import rocks.tbog.tblauncher.drawable.DrawableUtils; import rocks.tbog.tblauncher.ui.LinearAdapter; import rocks.tbog.tblauncher.ui.LinearAdapterPlus; import rocks.tbog.tblauncher.ui.ListPopup; import rocks.tbog.tblauncher.utils.DebugInfo; import rocks.tbog.tblauncher.utils.DeviceUtils; import rocks.tbog.tblauncher.utils.UserHandleCompat; import rocks.tbog.tblauncher.utils.Utilities; public class WidgetManager { private static final String TAG = "Wdg"; public static final int INVALID_WIDGET_ID = AppWidgetManager.INVALID_APPWIDGET_ID; private AppWidgetManager mAppWidgetManager; private WidgetHost mAppWidgetHost; private WidgetLayout mLayout; private WidgetLayout.Handle mLastMoveType = WidgetLayout.Handle.MOVE_FREE; private WidgetLayout.Handle mLastResizeType = WidgetLayout.Handle.RESIZE_DIAGONAL; private WidgetLayout.Handle mLastMoveResizeType = WidgetLayout.Handle.MOVE_FREE_RESIZE_AXIAL; private final ArrayMap mWidgets = new ArrayMap<>(0); private final ArrayList mPlaceholders = new ArrayList<>(0); private static final int APPWIDGET_HOST_ID = 1337; private static final int REQUEST_CONFIGURE_APPWIDGET = 102; public static final String EXTRA_WIDGET_BIND_ALLOWED = "widgetBindAllowed"; // called after widget was picked by the user ActivityResultLauncher widgetPickerResult; // called after user responded to permission request ActivityResultLauncher widgetBindResult; /** * Registers the AppWidgetHost to listen for updates to any widgets this app has. */ public boolean start(Context context) { Context ctx = context.getApplicationContext(); mAppWidgetManager = AppWidgetManager.getInstance(ctx); try { mAppWidgetHost = new WidgetHost(ctx); mAppWidgetHost.startListening(); } catch (android.content.res.Resources.NotFoundException e) { Log.e(TAG, "startListening failed", e); // Widget app was just updated? See https://github.com/Neamar/KISS/issues/959 mAppWidgetHost = null; return false; } return true; } public void stop() { mAppWidgetHost.stopListening(); mAppWidgetHost = null; } /** * Called on the creation of the activity. */ public void onCreateActivity(AppCompatActivity activity) { mLayout = activity.findViewById(R.id.widgetContainer); mLayout.setPageCount(LiveWallpaper.SCREEN_COUNT_HORIZONTAL, LiveWallpaper.SCREEN_COUNT_VERTICAL); restoreWidgets(); // post the scroll event to happen after the measure and layout phase final LiveWallpaper lw = TBApplication.liveWallpaper(activity); mLayout.addOnAfterLayoutTask(() -> { PointF offset = lw.getWallpaperOffset(); WidgetManager.this.scroll(offset.x, offset.y); }); widgetPickerResult = activity.registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> { Log.d(TAG, "widgetPickerResult " + result); var data = result.getData(); if (result.getResultCode() == Activity.RESULT_OK) { if (data != null && !data.getBooleanExtra(EXTRA_WIDGET_BIND_ALLOWED, false)) requestBindWidget(data); else { createWidget(activity, data); configureWidget(activity, data); } } else { if (data != null) { int appWidgetId = data.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, INVALID_WIDGET_ID); if (appWidgetId != INVALID_WIDGET_ID) { removeWidget(appWidgetId); } } else { Toast.makeText(activity, R.string.add_widget_failed, Toast.LENGTH_LONG).show(); } } }); widgetBindResult = activity.registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> { Log.d(TAG, "widgetBindResult " + result); var data = result.getData(); if (result.getResultCode() == Activity.RESULT_OK) { createWidget(activity, data); configureWidget(activity, data); } else { if (data != null) { int appWidgetId = data.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, INVALID_WIDGET_ID); if (appWidgetId != INVALID_WIDGET_ID) { removeWidget(appWidgetId); } } Toast.makeText(activity, R.string.bind_widget_failed, Toast.LENGTH_LONG).show(); } }); } private void loadFromDB(Context context) { ArrayList widgets = DBHelper.getWidgets(context); mWidgets.clear(); mPlaceholders.clear(); // we expect no placeholders mWidgets.ensureCapacity(widgets.size()); for (WidgetRecord record : widgets) { if (record instanceof PlaceholderWidgetRecord) { mPlaceholders.add((PlaceholderWidgetRecord) record); } else { mWidgets.put(record.appWidgetId, record); } } } private void restoreWidgets() { mLayout.removeAllViews(); if (mAppWidgetHost == null) { Log.w(TAG, "`restoreWidgets` called prior to `startListening`"); if (!start(mLayout.getContext())) { Log.w(TAG, "`start` failed, try `restoreWidgets` after 500ms"); mLayout.postDelayed(this::restoreWidgets, 500); return; } } int[] appWidgetIds; if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { appWidgetIds = mAppWidgetHost.getAppWidgetIds(); } else { appWidgetIds = new int[0]; } loadFromDB(mLayout.getContext()); // sync DB with AppWidgetHost for (int appWidgetId : appWidgetIds) { if (!mWidgets.containsKey(appWidgetId)) { // remove widget that has no info in DB removeWidget(appWidgetId); } } if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { ArrayList toDelete = new ArrayList<>(0); for (WidgetRecord rec : mWidgets.values()) { boolean found = false; for (int appWidgetId : appWidgetIds) if (appWidgetId == rec.appWidgetId) { found = true; break; } if (!found) { // remove widget from DB toDelete.add(rec.appWidgetId); } } for (int appWidgetId : toDelete) removeWidget(appWidgetId); } // restore widgets for (WidgetRecord rec : mWidgets.values()) { restoreWidget(rec); } // restore placeholders for (PlaceholderWidgetRecord placeholderWidget : mPlaceholders) { addPlaceholderToLayout(placeholderWidget); } } // public List getWidgetsForPackage(String packageName) { // List providers; // if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { // providers = mAppWidgetManager.getInstalledProvidersForPackage(packageName, null); // } else { // providers = new ArrayList<>(); // for (AppWidgetProviderInfo prov : mAppWidgetManager.getInstalledProviders()) { // if (prov.provider.getPackageName().equals(packageName)) { // providers.add(prov); // } // } // } // return providers; // } private void restoreWidget(WidgetRecord rec) { final int appWidgetId = rec.appWidgetId; Context ctx = mLayout.getContext().getApplicationContext(); AppWidgetProviderInfo appWidgetInfo = mAppWidgetManager.getAppWidgetInfo(appWidgetId); if (appWidgetInfo == null) return; AppWidgetHostView hostView = mAppWidgetHost.createView(ctx, appWidgetId, appWidgetInfo); addWidgetToLayout(hostView, appWidgetInfo, rec); } public void onBeforeRestoreFromBackup(boolean clearAll) { if (mAppWidgetHost == null) { Log.e(TAG, "`onBeforeRestoreFromBackup` called prior to `startListening`"); return; } loadFromDB(mLayout.getContext()); if (clearAll) { mLayout.removeAllViews(); mPlaceholders.clear(); } } public void onAfterRestoreFromBackup(boolean clearExtra) { if (clearExtra) { // remove all widgets not found in mLayout int[] appWidgetIds = new int[mWidgets.size()]; //Arrays.fill(appWidgetIds, INVALID_WIDGET_ID); // copy widget ids we may need to remove { int idx = 0; for (WidgetRecord rec : mWidgets.values()) appWidgetIds[idx++] = rec.appWidgetId; } for (int appWidgetId : appWidgetIds) { AppWidgetHostView widgetHostView = mLayout.getWidget(appWidgetId); if (widgetHostView != null) removeWidget(widgetHostView); } } else { // restore widgets from mWidgets that are not in mLayout yet for (WidgetRecord rec : mWidgets.values()) { if (mLayout.getWidget(rec.appWidgetId) == null) restoreWidget(rec); } } Context ctx = mLayout.getContext(); for (WidgetRecord rec : mWidgets.values()) { DBHelper.setWidgetProperties(ctx, rec); } // remove all placeholders DBHelper.removeWidget(ctx, INVALID_WIDGET_ID); // add back all placeholders for (PlaceholderWidgetRecord placeholder : mPlaceholders) { DBHelper.addWidget(ctx, placeholder); } } /** * called when importing from backup / XML * * @param append if true widget information will not be changed / imported / restored * @param record placeholder information */ public void restoreFromBackup(boolean append, PlaceholderWidgetRecord record/*, String name, ComponentName provider, byte[] preview, WidgetRecord record*/) { int appWidgetId = record.appWidgetId; boolean bFound = false; { WidgetRecord widgetRecord = mWidgets.get(appWidgetId); AppWidgetProviderInfo info = widgetRecord != null ? getWidgetProviderInfo(widgetRecord.appWidgetId) : null; // check if appWidgetId can be restored if (info != null) { bFound = record.provider.equals(info.provider); } } // check if we can find a provider match if (!bFound) { for (WidgetRecord rec : mWidgets.values()) { // if we already restored this widget, skip if (mLayout.getWidget(rec.appWidgetId) != null) continue; AppWidgetProviderInfo info = getWidgetProviderInfo(rec.appWidgetId); if (info == null) continue; if (record.provider.equals(info.provider)) { appWidgetId = rec.appWidgetId; bFound = true; break; } } } //TODO: check if we can recycle a widget based on provider if (bFound) { record.appWidgetId = appWidgetId; if (!append) { // widget found, apply the properties mWidgets.put(appWidgetId, record); } mLayout.removeWidget(appWidgetId); restoreWidget(record); } else { // widget not found, add a placeholder record.appWidgetId = INVALID_WIDGET_ID; mPlaceholders.add(record); addPlaceholderToLayout(record); } } private void updateAppWidgetOptions(AppWidgetHostView hostView, AppWidgetProviderInfo appWidgetInfo, WidgetRecord rec) { Context ctx = hostView.getContext(); Rect padding = new Rect(0, 0, 0, 0); AppWidgetHostView.getDefaultPaddingForWidget(ctx, appWidgetInfo.provider, padding); float density = ctx.getResources().getDisplayMetrics().density; float widgetWidthDips = rec.width / density; float widgetHeightDips = rec.height / density; float xPaddingDips = (padding.left + padding.right) / density; float yPaddingDips = (padding.top + padding.bottom) / density; int minWidth = (int) (widgetWidthDips - xPaddingDips); int maxWidth = (int) (widgetWidthDips - xPaddingDips + .5f); int minHeight = (int) (widgetHeightDips - yPaddingDips); int maxHeight = (int) (widgetHeightDips - yPaddingDips + .5f); Bundle oldOpt = null; try { oldOpt = mAppWidgetManager.getAppWidgetOptions(rec.appWidgetId); } catch (Exception e) { Log.e(TAG, "getAppWidgetOptions(" + rec.appWidgetId + ") " + appWidgetInfo.provider, e); } if (oldOpt == null || minWidth != oldOpt.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH) || minHeight != oldOpt.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT) || maxWidth != oldOpt.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH) || maxHeight != oldOpt.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT)) { final Bundle opt; if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { opt = oldOpt != null ? oldOpt.deepCopy() : new Bundle(); } else { opt = oldOpt != null ? new Bundle(oldOpt) : new Bundle(); } opt.putInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH, minWidth); opt.putInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, minHeight); opt.putInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH, maxWidth); opt.putInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, maxHeight); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { ArrayList sizes = new ArrayList<>(); sizes.add(new SizeF(widgetWidthDips, widgetHeightDips)); opt.putParcelableArrayList(AppWidgetManager.OPTION_APPWIDGET_SIZES, sizes); } // send update hostView.updateAppWidgetOptions(opt); } } private void addWidgetToLayout(AppWidgetHostView hostView, AppWidgetProviderInfo appWidgetInfo, WidgetRecord rec) { View placeholder = mLayout.getPlaceholder(appWidgetInfo.provider); WidgetLayout.PageLayoutParams params = null; if (placeholder != null) params = (WidgetLayout.PageLayoutParams) placeholder.getLayoutParams(); if (params == null) { params = new WidgetLayout.PageLayoutParams(rec.width, rec.height); params.leftMargin = rec.left; params.topMargin = rec.top; params.screenPage = rec.screen; params.placement = WidgetLayout.PageLayoutParams.Placement.MARGIN_TL_AS_POSITION; } hostView.setMinimumWidth(appWidgetInfo.minWidth); hostView.setMinimumHeight(appWidgetInfo.minHeight); hostView.setAppWidget(rec.appWidgetId, appWidgetInfo); updateAppWidgetOptions(hostView, appWidgetInfo, rec); hostView.setOnLongClickListener(v -> { if (v instanceof WidgetView) { ListPopup menu = getConfigPopup((WidgetView) v); TBApplication.getApplication(v.getContext()).registerPopup(menu); menu.show(v, 0.f); return true; } return false; }); // replace placeholder (if it exists) with the widget { int insertPosition = mLayout.indexOfChild(placeholder); if (insertPosition != -1) mLayout.removeViewAt(insertPosition); mLayout.addView(hostView, insertPosition, params); } Context context = mLayout.getContext(); // remove from `mPlaceholders` { for (Iterator iterator = mPlaceholders.iterator(); iterator.hasNext(); ) { PlaceholderWidgetRecord placeholderWidget = iterator.next(); if (placeholderWidget.provider.equals(appWidgetInfo.provider)) { DBHelper.removeWidgetPlaceholder(context, INVALID_WIDGET_ID, placeholderWidget.provider.flattenToString()); iterator.remove(); } } } } private void addPlaceholderToLayout(@NonNull PlaceholderWidgetRecord rec) { final Context context = mLayout.getContext(); Drawable preview = DrawableUtils.getBitmapDrawable(context, rec.preview); View placeholder = LayoutInflater.from(context).inflate(R.layout.widget_placeholder, mLayout, false); { WidgetLayout.PageLayoutParams params = new WidgetLayout.PageLayoutParams(rec.width, rec.height); params.leftMargin = rec.left; params.topMargin = rec.top; params.screenPage = rec.screen; params.placement = WidgetLayout.PageLayoutParams.Placement.MARGIN_TL_AS_POSITION; placeholder.setLayoutParams(params); } { TextView text = placeholder.findViewById(android.R.id.text1); text.setText(context.getString(R.string.widget_placeholder, rec.name)); } { ImageView icon = placeholder.findViewById(android.R.id.icon); icon.setImageDrawable(preview); } final ComponentName provider = rec.provider != null ? rec.provider : new ComponentName("null", "null"); placeholder.setOnClickListener(v -> { Activity activity = Utilities.getActivity(v); if (activity == null) return; int appWidgetId = mAppWidgetHost.allocateAppWidgetId(); boolean bindAllowed; if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP) { UserHandleCompat userHandle = UserHandleCompat.CURRENT_USER; bindAllowed = mAppWidgetManager.bindAppWidgetIdIfAllowed(appWidgetId, userHandle.getRealHandle(), provider, null); } else { bindAllowed = mAppWidgetManager.bindAppWidgetIdIfAllowed(appWidgetId, provider); } Intent intent = new Intent(AppWidgetManager.ACTION_APPWIDGET_BIND); intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId); if (bindAllowed) { createWidget(activity, intent); configureWidget(activity, intent); } else { intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_PROVIDER, provider); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { UserHandleCompat userHandle = UserHandleCompat.CURRENT_USER; intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_PROVIDER_PROFILE, userHandle.getRealHandle()); } requestBindWidget(intent); } //Toast.makeText(activity, provider.flattenToString(), Toast.LENGTH_SHORT).show(); }); placeholder.setOnLongClickListener(placeholderView -> { Activity activity = Utilities.getActivity(placeholderView); if (activity == null) return false; TextView text = placeholderView.findViewById(android.R.id.text1); final CharSequence placeholderName = text != null ? text.getText() : "null"; ContextThemeWrapper ctxDialog = new ContextThemeWrapper(activity, R.style.TitleDialogTheme); new AlertDialog.Builder(ctxDialog) .setTitle(R.string.widget_placeholder_remove) .setMessage(placeholderName + "\n" + provider.flattenToShortString()) .setPositiveButton(android.R.string.ok, (dialog, which) -> { mLayout.removeView(placeholderView); for (Iterator iterator = mPlaceholders.iterator(); iterator.hasNext(); ) { PlaceholderWidgetRecord placeholderWidgetRecord = iterator.next(); if (provider.equals(placeholderWidgetRecord.provider)) { DBHelper.removeWidgetPlaceholder(activity, INVALID_WIDGET_ID, placeholderWidgetRecord.provider.flattenToString()); iterator.remove(); } } dialog.dismiss(); }) .setNegativeButton(android.R.string.cancel, (dialog, which) -> dialog.dismiss()) .show(); return true; }); mLayout.addPlaceholder(placeholder, provider); } /** * Launches the menu to select the widget. The selected widget will be on * the result of the activity. */ public void showSelectWidget(AppCompatActivity activity) { Intent pickIntent = new Intent(activity, PickAppWidgetActivity.class); int appWidgetId = mAppWidgetHost.allocateAppWidgetId(); pickIntent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId); widgetPickerResult.launch(pickIntent); } /** * This avoids a bug in the com.android.settings.AppWidgetPickActivity, * which is used to select widgets. This just adds empty extras to the * intent, avoiding the bug. *

* See more: http://code.google.com/p/android/issues/detail?id=4272 */ public static void addEmptyData(Intent pickIntent) { ArrayList customInfo = new ArrayList<>(1); pickIntent.putParcelableArrayListExtra(AppWidgetManager.EXTRA_CUSTOM_INFO, customInfo); ArrayList customExtras = new ArrayList<>(1); pickIntent.putParcelableArrayListExtra(AppWidgetManager.EXTRA_CUSTOM_EXTRAS, customExtras); } private void requestBindWidget(@NonNull Intent data) { Bundle extras = data.getExtras(); if (extras == null) return; final int appWidgetId = extras.getInt(AppWidgetManager.EXTRA_APPWIDGET_ID, INVALID_WIDGET_ID); final ComponentName provider = extras.getParcelable(AppWidgetManager.EXTRA_APPWIDGET_PROVIDER); final UserHandle profile; if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP) { profile = extras.getParcelable(AppWidgetManager.EXTRA_APPWIDGET_PROVIDER_PROFILE); } else { profile = null; } new Handler().postDelayed(() -> { Log.d(TAG, "asking for permission"); Intent intent = new Intent(AppWidgetManager.ACTION_APPWIDGET_BIND); intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId); intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_PROVIDER, provider); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_PROVIDER_PROFILE, profile); } addEmptyData(intent); widgetBindResult.launch(intent); }, 500); } /** * Checks if the widget needs any configuration. If it needs, launches the * configuration activity. */ private void configureWidget(Activity activity, Intent data) { Bundle extras = data != null ? data.getExtras() : null; if (extras == null) return; int appWidgetId = extras.getInt(AppWidgetManager.EXTRA_APPWIDGET_ID, INVALID_WIDGET_ID); AppWidgetProviderInfo appWidgetInfo = mAppWidgetManager.getAppWidgetInfo(appWidgetId); boolean canConfigure = appWidgetInfo.configure != null; boolean shouldConfigure = true; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { if ((appWidgetInfo.widgetFeatures & AppWidgetProviderInfo.WIDGET_FEATURE_CONFIGURATION_OPTIONAL) != 0) { shouldConfigure = false; } } /* See https://stackoverflow.com/a/40269593 * If you use the AppWidgetManager.ACTION_APPWIDGET_PICK intent to pick the intent from the chooser displayed by the Android OS, there is no need to bind as the framework automatically binds the widget. * If you implement a custom chooser (for example, something which shows the preview images of widgets which is implemented in lots of custom launchers), then binding is necessary. */ // boolean hasPermission = mAppWidgetManager.bindAppWidgetIdIfAllowed(appWidgetId, appWidgetInfo.provider); // if (!hasPermission) { // Intent intent = new Intent(AppWidgetManager.ACTION_APPWIDGET_BIND); // intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId); // intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_PROVIDER, appWidgetInfo.provider); // activity.startActivityForResult(intent, REQUEST_PICK_APPWIDGET/*REQUEST_BIND*/); // } if (canConfigure && shouldConfigure) { Log.d(TAG, "configureWidget " + appWidgetInfo.configure); launchConfigureWidgetActivity(activity, appWidgetId, appWidgetInfo); } } private void launchConfigureWidgetActivity(Activity activity, int appWidgetId, AppWidgetProviderInfo appWidgetInfo) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { startConfigActivity(appWidgetId, activity, REQUEST_CONFIGURE_APPWIDGET); } else { Intent intent = new Intent(AppWidgetManager.ACTION_APPWIDGET_CONFIGURE); intent.setComponent(appWidgetInfo.configure); intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId); try { activity.startActivityForResult(intent, REQUEST_CONFIGURE_APPWIDGET); } catch (SecurityException e) { Log.e(TAG, "ACTION_APPWIDGET_CONFIGURE", e); Toast.makeText(activity, e.getMessage(), Toast.LENGTH_LONG).show(); } } } @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) public void startConfigActivity(int widgetId, Activity activity, int requestCode) { final int flags = 0; final Bundle options; if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { options = ActivityOptions .makeBasic() .setPendingIntentBackgroundActivityStartMode(ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED) .toBundle(); } else { options = null; } try { mAppWidgetHost.startAppWidgetConfigureActivityForResult(activity, widgetId, flags, requestCode, options); } catch (ActivityNotFoundException | SecurityException e) { Toast.makeText(activity, e.getMessage(), Toast.LENGTH_LONG).show(); } } /** * Creates the widget and adds it to our view layout. */ public void createWidget(Activity activity, Intent data) { Bundle extras = data != null ? data.getExtras() : null; if (extras == null) return; int appWidgetId = extras.getInt(AppWidgetManager.EXTRA_APPWIDGET_ID, INVALID_WIDGET_ID); AppWidgetProviderInfo appWidgetInfo = mAppWidgetManager.getAppWidgetInfo(appWidgetId); if (appWidgetInfo == null || mWidgets.containsKey(appWidgetId)) return; AppWidgetHostView hostView = mAppWidgetHost.createView(activity.getApplicationContext(), appWidgetId, appWidgetInfo); WidgetRecord rec = new WidgetRecord(); rec.appWidgetId = appWidgetId; rec.width = Math.max(appWidgetInfo.minWidth, appWidgetInfo.minResizeWidth); rec.height = Math.max(appWidgetInfo.minHeight, appWidgetInfo.minResizeHeight); final int screenWidth = DeviceUtils.getScreenWidth(activity); final int screenHeight = DeviceUtils.getScreenHeight(activity); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { // emulate a cell grid with n×(n+1) cells final int targetCellWidth = Math.max(1, appWidgetInfo.targetCellWidth); final int targetCellHeight = Math.max(1, appWidgetInfo.targetCellHeight); int gridWidth = Math.min(4, targetCellWidth); int gridHeight = Math.min(5, targetCellHeight); if (gridWidth > (gridHeight - 1)) { gridHeight = gridWidth + 1; } else if (gridWidth < (gridHeight - 1)) { gridWidth = gridHeight - 1; } final int cellWidth = screenWidth / gridWidth; final int cellHeight = screenHeight / gridHeight; rec.width = Math.max(rec.width, targetCellWidth * cellWidth); rec.height = Math.max(rec.height, targetCellHeight * cellHeight); } else { // emulate a cell grid with 4×5 cells rec.width = Math.max(rec.width, screenWidth / 4); rec.height = Math.max(rec.height, screenHeight / 5); } DBHelper.addWidget(activity, rec); mWidgets.put(rec.appWidgetId, rec); addWidgetToLayout(hostView, appWidgetInfo, rec); } /** * Removes the widget displayed by this AppWidgetHostView. */ public void removeWidget(AppWidgetHostView hostView) { final int appWidgetId = hostView.getAppWidgetId(); mLayout.removeWidget(hostView); mAppWidgetHost.deleteAppWidgetId(appWidgetId); DBHelper.removeWidget(mLayout.getContext(), appWidgetId); mWidgets.remove(appWidgetId); } public void removeWidget(int appWidgetId) { mLayout.removeWidget(appWidgetId); mAppWidgetHost.deleteAppWidgetId(appWidgetId); DBHelper.removeWidget(mLayout.getContext(), appWidgetId); mWidgets.remove(appWidgetId); } public void updateWidgetSize(AppWidgetHostView hostView, int width, int height) { int appWidgetId = hostView.getAppWidgetId(); AppWidgetProviderInfo appWidgetInfo = mAppWidgetManager.getAppWidgetInfo(appWidgetId); WidgetRecord rec = new WidgetRecord(mWidgets.get(appWidgetId)); rec.width = width; rec.height = height; updateAppWidgetOptions(hostView, appWidgetInfo, rec); } /** * Called from the main activity, should return true to stop further processing of this result * If the user has selected an widget, the result will be in the 'data' when this function is called. * * @param activity The activity that received the result * @param requestCode The integer request code originally supplied to * startActivityForResult(), allowing you to identify who this * result came from. * @param resultCode The integer result code returned by the child activity * through its setResult(). * @param data An Intent, which can return result data to the caller * (various data can be attached to Intent "extras"). * @return if this result was processed here */ public boolean onActivityResult(@NonNull Activity activity, int requestCode, int resultCode, @Nullable Intent data) { switch (resultCode) { case Activity.RESULT_OK: if (requestCode == REQUEST_CONFIGURE_APPWIDGET) { createWidget(activity, data); return true; } break; case Activity.RESULT_CANCELED: if (data != null) { int appWidgetId = data.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, INVALID_WIDGET_ID); if (appWidgetId != INVALID_WIDGET_ID) { removeWidget(appWidgetId); return true; } } break; } return false; } public int widgetCount() { return mWidgets.size() + mPlaceholders.size(); } /** * A popup with all active widgets to choose one * * @return the menu */ public ListPopup getWidgetListPopup(@StringRes int title) { Context ctx = mLayout.getContext(); LinearAdapter adapter = new LinearAdapterPlus(); adapter.add(new LinearAdapter.ItemTitle(ctx, title)); for (WidgetRecord rec : mWidgets.values()) { adapter.add(WidgetPopupItem.create(ctx, mAppWidgetManager, rec.appWidgetId)); } for (PlaceholderWidgetRecord placeholder : mPlaceholders) { adapter.add(PlaceholderPopupItem.create(ctx, placeholder)); } return ListPopup.create(ctx, adapter); } /** * Popup with options for all widgets * * @param activity used to start the widget select popup * @return the popup menu */ public ListPopup getConfigPopup(AppCompatActivity activity) { LinearAdapter adapter = new LinearAdapter(); adapter.add(new LinearAdapter.ItemTitle(activity, R.string.menu_widget_title)); adapter.add(new LinearAdapter.Item(activity, R.string.menu_widget_add)); if (widgetCount() > 0) { adapter.add(new LinearAdapter.Item(activity, R.string.menu_widget_configure)); adapter.add(new LinearAdapter.Item(activity, R.string.menu_widget_setup)); adapter.add(new LinearAdapter.Item(activity, R.string.menu_widget_remove)); } adapter.add(new LinearAdapter.Item(activity, R.string.menu_popup_launcher_settings)); ListPopup menu = ListPopup.create(activity, adapter); menu.setOnItemClickListener((a, v, pos) -> { LinearAdapter.MenuItem item = ((LinearAdapter) a).getItem(pos); @StringRes int stringId = 0; if (item instanceof LinearAdapter.Item) { stringId = ((LinearAdapter.Item) a.getItem(pos)).stringId; } if (stringId == R.string.menu_widget_add) { TBApplication.widgetManager(activity).showSelectWidget(activity); } else if (stringId == R.string.menu_widget_configure) { ListPopup configWidgetPopup = TBApplication.widgetManager(activity).getWidgetListPopup(R.string.menu_widget_configure); configWidgetPopup.setOnItemClickListener((a1, v1, pos1) -> { Object item1 = a1.getItem(pos1); if (item1 instanceof WidgetPopupItem) { AppWidgetHostView widgetView = mLayout.getWidget(((WidgetPopupItem) item1).appWidgetId); if (widgetView == null) return; ListPopup popup = getConfigPopup((WidgetView) widgetView); TBApplication.getApplication(mLayout.getContext()).registerPopup(popup); popup.show(widgetView, 0.f); } else if (item1 instanceof PlaceholderPopupItem) { PlaceholderWidgetRecord placeholder = ((PlaceholderPopupItem) item1).placeholder; View placeholderView = mLayout.getPlaceholder(placeholder.provider); if (placeholderView != null) placeholderView.performClick(); } }); TBApplication.getApplication(activity).registerPopup(configWidgetPopup); configWidgetPopup.showCenter(activity.getWindow().getDecorView()); } else if (stringId == R.string.menu_widget_setup) { ListPopup configWidgetPopup = TBApplication.widgetManager(activity).getWidgetListPopup(R.string.menu_widget_setup); configWidgetPopup.setOnItemClickListener((a1, v1, pos1) -> { Object item1 = a1.getItem(pos1); if (item1 instanceof WidgetPopupItem) { AppWidgetHostView widgetView = mLayout.getWidget(((WidgetPopupItem) item1).appWidgetId); if (widgetView == null) return; launchConfigureWidgetActivity(activity, widgetView.getAppWidgetId(), widgetView.getAppWidgetInfo()); } else if (item1 instanceof PlaceholderPopupItem) { PlaceholderWidgetRecord placeholder = ((PlaceholderPopupItem) item1).placeholder; View placeholderView = mLayout.getPlaceholder(placeholder.provider); if (placeholderView != null) placeholderView.performClick(); } }); TBApplication.getApplication(activity).registerPopup(configWidgetPopup); configWidgetPopup.showCenter(activity.getWindow().getDecorView()); } else if (stringId == R.string.menu_widget_remove) { showRemoveWidgetPopup(); } else if (stringId == R.string.menu_popup_launcher_settings) { var intent = new Intent(Utilities.getActivity(mLayout), SettingsActivity.class); Utilities.setIntentSourceBounds(intent, v); Bundle startActivityOptions = Utilities.makeStartActivityOptions(v); mLayout.postDelayed(() -> { var act = Utilities.getActivity(mLayout); if (act == null) return; try { act.startActivity(intent, startActivityOptions); } catch (ActivityNotFoundException ignored) { // ignored } }, Behaviour.LAUNCH_DELAY); } }); return menu; } public void showRemoveWidgetPopup() { Context context = mLayout.getContext(); ListPopup removeWidgetPopup = TBApplication.widgetManager(context).getWidgetListPopup(R.string.menu_widget_remove); removeWidgetPopup.setOnItemClickListener((a1, v1, pos1) -> { Object item1 = a1.getItem(pos1); if (item1 instanceof WidgetPopupItem) { removeWidget(((WidgetPopupItem) item1).appWidgetId); } else if (item1 instanceof PlaceholderPopupItem) { PlaceholderWidgetRecord placeholder = ((PlaceholderPopupItem) item1).placeholder; View placeholderView = mLayout.getPlaceholder(placeholder.provider); if (placeholderView != null) placeholderView.performLongClick(); } }); TBApplication.getApplication(context).registerPopup(removeWidgetPopup); removeWidgetPopup.showCenter(mLayout); } private static boolean canMoveToPage(WidgetLayout layout, int from, int to) { if (from != WidgetLayout.PageLayoutParams.PAGE_MIDDLE) return to == WidgetLayout.PageLayoutParams.PAGE_MIDDLE; if (layout == null) return false; boolean ok = false; if (layout.getVerticalPageCount() > 1) ok = ok || to == WidgetLayout.PageLayoutParams.PAGE_UP || to == WidgetLayout.PageLayoutParams.PAGE_DOWN; if (layout.getHorizontalPageCount() > 1) ok = ok || to == WidgetLayout.PageLayoutParams.PAGE_LEFT || to == WidgetLayout.PageLayoutParams.PAGE_RIGHT; return ok; } /** * Popup with options for the widget in the view * * @param view of the widget * @return the popup menu */ protected ListPopup getConfigPopup(WidgetView view) { final int appWidgetId = view.getAppWidgetId(); Context ctx = mLayout.getContext(); LinearAdapter adapter = new LinearAdapter(); WidgetRecord widget = mWidgets.get(appWidgetId); if (widget != null) { addConfigPopupItems(mLayout, adapter, view, widget); } else { adapter.add(new LinearAdapter.ItemString("ERROR: Not found")); } ListPopup menu = ListPopup.create(ctx, adapter); menu.setOnItemClickListener((a, v, position) -> handleConfigPopupItemClick(a, view, position)); return menu; } private static void addConfigPopupItems(WidgetLayout widgetLayout, LinearAdapter adapter, WidgetView view, WidgetRecord widget) { Context ctx = widgetLayout.getContext(); adapter.add(new LinearAdapter.ItemTitle(getWidgetName(ctx, view.getAppWidgetInfo()))); final WidgetLayout.Handle handleType = widgetLayout.getHandleType(view); if (handleType.isMove()) { adapter.add(new WidgetOptionItem(ctx, R.string.cfg_widget_move_switch, WidgetOptionItem.Action.MOVE_SWITCH)); adapter.add(new WidgetOptionItem(ctx, R.string.cfg_widget_move_exit, WidgetOptionItem.Action.RESET)); } else { adapter.add(new WidgetOptionItem(ctx, R.string.cfg_widget_move, WidgetOptionItem.Action.MOVE)); } if (handleType.isResize()) { adapter.add(new WidgetOptionItem(ctx, R.string.cfg_widget_resize_switch, WidgetOptionItem.Action.RESIZE_SWITCH)); adapter.add(new WidgetOptionItem(ctx, R.string.cfg_widget_resize_exit, WidgetOptionItem.Action.RESET)); } else { adapter.add(new WidgetOptionItem(ctx, R.string.cfg_widget_resize, WidgetOptionItem.Action.RESIZE)); } if (handleType.isMoveResize()) { adapter.add(new WidgetOptionItem(ctx, R.string.cfg_widget_move_resize, WidgetOptionItem.Action.MOVE_RESIZE_SWITCH)); adapter.add(new WidgetOptionItem(ctx, R.string.cfg_widget_move_resize_exit, WidgetOptionItem.Action.RESET)); } else { adapter.add(new WidgetOptionItem(ctx, R.string.cfg_widget_move_resize, WidgetOptionItem.Action.MOVE_RESIZE)); } adapter.add(new LinearAdapter.ItemDivider()); final ViewGroup.LayoutParams lp = view.getLayoutParams(); if (lp instanceof WidgetLayout.PageLayoutParams) { final int screenPage = ((WidgetLayout.PageLayoutParams) lp).screenPage; if (canMoveToPage(widgetLayout, screenPage, WidgetLayout.PageLayoutParams.PAGE_LEFT)) adapter.add(new WidgetOptionItem(ctx, R.string.cfg_widget_screen_left, WidgetOptionItem.Action.MOVE2SCREEN_LEFT)); if (canMoveToPage(widgetLayout, screenPage, WidgetLayout.PageLayoutParams.PAGE_UP)) adapter.add(new WidgetOptionItem(ctx, R.string.cfg_widget_screen_up, WidgetOptionItem.Action.MOVE2SCREEN_UP)); if (canMoveToPage(widgetLayout, screenPage, WidgetLayout.PageLayoutParams.PAGE_MIDDLE)) adapter.add(new WidgetOptionItem(ctx, R.string.cfg_widget_screen_middle, WidgetOptionItem.Action.MOVE2SCREEN_MIDDLE)); if (canMoveToPage(widgetLayout, screenPage, WidgetLayout.PageLayoutParams.PAGE_RIGHT)) adapter.add(new WidgetOptionItem(ctx, R.string.cfg_widget_screen_right, WidgetOptionItem.Action.MOVE2SCREEN_RIGHT)); if (canMoveToPage(widgetLayout, screenPage, WidgetLayout.PageLayoutParams.PAGE_DOWN)) adapter.add(new WidgetOptionItem(ctx, R.string.cfg_widget_screen_down, WidgetOptionItem.Action.MOVE2SCREEN_DOWN)); adapter.add(new WidgetOptionItem(ctx, R.string.cfg_widget_back, WidgetOptionItem.Action.MOVE_BELOW)); adapter.add(new WidgetOptionItem(ctx, R.string.cfg_widget_front, WidgetOptionItem.Action.MOVE_ABOVE)); } adapter.add(new WidgetOptionItem(ctx, R.string.cfg_widget_remove, WidgetOptionItem.Action.REMOVE)); if (DebugInfo.widgetInfo(ctx)) { adapter.add(new LinearAdapter.ItemTitle("Debug info")); adapter.add(new LinearAdapter.ItemString("Name: " + getWidgetName(ctx, view.getAppWidgetInfo()))); adapter.add(new LinearAdapter.ItemText(widget.packedProperties())); adapter.add(new LinearAdapter.ItemString("ID: " + widget.appWidgetId)); } } private void handleConfigPopupItemClick(ListAdapter adapter, WidgetView view, int position) { Object item = adapter.getItem(position); if (item instanceof WidgetOptionItem) { switch (((WidgetOptionItem) item).mAction) { case MOVE: view.setOnClickListener(v1 -> { view.setOnClickListener(null); view.setOnDoubleClickListener(null); mLayout.disableHandle(view); saveWidgetProperties(view); }); view.setOnDoubleClickListener(v1 -> { if (mLayout.getHandleType(view) == WidgetLayout.Handle.MOVE_FREE) { mLastMoveType = WidgetLayout.Handle.MOVE_AXIAL; } else { mLastMoveType = WidgetLayout.Handle.MOVE_FREE; } mLayout.enableHandle(view, mLastMoveType); }); mLayout.enableHandle(view, mLastMoveType); break; case MOVE_SWITCH: if (mLayout.getHandleType(view) == WidgetLayout.Handle.MOVE_FREE) { mLastMoveType = WidgetLayout.Handle.MOVE_AXIAL; } else { mLastMoveType = WidgetLayout.Handle.MOVE_FREE; } mLayout.enableHandle(view, mLastMoveType); break; case RESIZE: view.setOnClickListener(v1 -> { view.setOnClickListener(null); view.setOnDoubleClickListener(null); mLayout.disableHandle(view); saveWidgetProperties(view); }); view.setOnDoubleClickListener(v1 -> { if (mLayout.getHandleType(view) == WidgetLayout.Handle.RESIZE_DIAGONAL) { mLastResizeType = WidgetLayout.Handle.RESIZE_AXIAL; } else { mLastResizeType = WidgetLayout.Handle.RESIZE_DIAGONAL; } mLayout.enableHandle(view, mLastResizeType); }); mLayout.enableHandle(view, mLastResizeType); break; case RESIZE_SWITCH: if (mLayout.getHandleType(view) == WidgetLayout.Handle.RESIZE_DIAGONAL) { mLastResizeType = WidgetLayout.Handle.RESIZE_AXIAL; } else { mLastResizeType = WidgetLayout.Handle.RESIZE_DIAGONAL; } mLayout.enableHandle(view, mLastResizeType); break; case MOVE_RESIZE: view.setOnClickListener(v1 -> { view.setOnClickListener(null); view.setOnDoubleClickListener(null); mLayout.disableHandle(view); saveWidgetProperties(view); }); view.setOnDoubleClickListener(v1 -> { if (mLayout.getHandleType(view) == WidgetLayout.Handle.MOVE_FREE_RESIZE_AXIAL) { mLastMoveResizeType = WidgetLayout.Handle.RESIZE_DIAGONAL_MOVE_AXIAL; } else { mLastMoveResizeType = WidgetLayout.Handle.MOVE_FREE_RESIZE_AXIAL; } mLayout.enableHandle(view, mLastMoveResizeType); }); mLayout.enableHandle(view, mLastMoveResizeType); case MOVE_RESIZE_SWITCH: if (mLayout.getHandleType(view) == WidgetLayout.Handle.MOVE_FREE_RESIZE_AXIAL) { mLastMoveResizeType = WidgetLayout.Handle.RESIZE_DIAGONAL_MOVE_AXIAL; } else { mLastMoveResizeType = WidgetLayout.Handle.MOVE_FREE_RESIZE_AXIAL; } mLayout.enableHandle(view, mLastMoveResizeType); break; case RESET: view.setOnClickListener(null); view.setOnDoubleClickListener(null); mLayout.disableHandle(view); saveWidgetProperties(view); break; case REMOVE: removeWidget(view); break; case MOVE2SCREEN_LEFT: { final WidgetLayout.PageLayoutParams lp = (WidgetLayout.PageLayoutParams) view.getLayoutParams(); lp.screenPage = WidgetLayout.PageLayoutParams.PAGE_LEFT; view.setLayoutParams(lp); saveWidgetProperties(view); break; } case MOVE2SCREEN_UP: { final WidgetLayout.PageLayoutParams lp = (WidgetLayout.PageLayoutParams) view.getLayoutParams(); lp.screenPage = WidgetLayout.PageLayoutParams.PAGE_UP; view.setLayoutParams(lp); saveWidgetProperties(view); break; } case MOVE2SCREEN_RIGHT: { final WidgetLayout.PageLayoutParams lp = (WidgetLayout.PageLayoutParams) view.getLayoutParams(); lp.screenPage = WidgetLayout.PageLayoutParams.PAGE_RIGHT; view.setLayoutParams(lp); saveWidgetProperties(view); break; } case MOVE2SCREEN_DOWN: { final WidgetLayout.PageLayoutParams lp = (WidgetLayout.PageLayoutParams) view.getLayoutParams(); lp.screenPage = WidgetLayout.PageLayoutParams.PAGE_DOWN; view.setLayoutParams(lp); saveWidgetProperties(view); break; } case MOVE2SCREEN_MIDDLE: { final WidgetLayout.PageLayoutParams lp = (WidgetLayout.PageLayoutParams) view.getLayoutParams(); lp.screenPage = WidgetLayout.PageLayoutParams.PAGE_MIDDLE; view.setLayoutParams(lp); saveWidgetProperties(view); break; } case MOVE_ABOVE: { int idx = mLayout.indexOfChild(view); mLayout.removeViewAt(idx); mLayout.addView(view); saveWidgetProperties(view); break; } case MOVE_BELOW: { int idx = mLayout.indexOfChild(view); mLayout.removeViewAt(idx); mLayout.addView(view, 0); saveWidgetProperties(view); break; } } } } private void saveWidgetProperties(WidgetView view) { final int appWidgetId = view.getAppWidgetId(); mLayout.addOnAfterLayoutTask(() -> { WidgetRecord rec = mWidgets.get(appWidgetId); AppWidgetHostView widgetHostView = mLayout.getWidget(appWidgetId); if (rec != null && widgetHostView != null) { rec.saveProperties(widgetHostView); Utilities.runAsync(() -> DBHelper.setWidgetProperties(mLayout.getContext(), rec)); AppWidgetProviderInfo appWidgetInfo = mAppWidgetManager.getAppWidgetInfo(appWidgetId); if (appWidgetInfo != null) updateAppWidgetOptions(widgetHostView, appWidgetInfo, rec); } }); mLayout.requestLayout(); } public void setPageCount(int horizontal, int vertical) { if (mLayout == null) return; mLayout.setPageCount(horizontal, vertical); } /** * Scroll to page, just like the wallpaper * * @param scrollX horizontal scroll position 0.f .. 1.f * @param scrollY vertical scroll position 0.f .. 1.f */ public void scroll(float scrollX, float scrollY) { if (mLayout == null) return; final int pageCountX = mLayout.getHorizontalPageCount(); final float pageX = pageCountX * scrollX; final int pageCountY = mLayout.getVerticalPageCount(); final float pageY = pageCountY * scrollY; mLayout.scrollToPage(pageX, pageY); } @Nullable public static AppWidgetProviderInfo getWidgetProviderInfo(@NonNull Context ctx, int appWidgetId) { AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(ctx); AppWidgetProviderInfo info; try { info = appWidgetManager.getAppWidgetInfo(appWidgetId); } catch (Exception ignored) { return null; } return info; } @Nullable public AppWidgetProviderInfo getWidgetProviderInfo(int appWidgetId) { AppWidgetProviderInfo info; try { info = mAppWidgetManager.getAppWidgetInfo(appWidgetId); } catch (Exception ignored) { return null; } return info; } @NonNull public static String getWidgetName(@NonNull Context ctx, @Nullable AppWidgetProviderInfo info) { String name = null; if (info != null) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { name = info.loadLabel(ctx.getPackageManager()); } else { name = info.label; } } return name == null ? "[null]" : name; } @WorkerThread @NonNull public static Drawable getWidgetPreview(@NonNull Context context, @NonNull AppWidgetProviderInfo info) { Drawable preview = null; final int density = context.getResources().getDisplayMetrics().densityDpi; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { preview = info.loadPreviewImage(context, density); } if (preview != null) return preview; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { preview = info.loadIcon(context, density); } if (preview != null) return preview; Resources resources = null; try { resources = context.getPackageManager().getResourcesForApplication(info.provider.getPackageName()); } catch (PackageManager.NameNotFoundException e) { Log.w(TAG, "getResourcesForApplication " + info.provider.getPackageName(), e); } if (resources != null) { try { preview = ResourcesCompat.getDrawableForDensity(resources, info.previewImage, density, null); } catch (Resources.NotFoundException ignored) { //ignored } if (preview != null) return preview; try { preview = ResourcesCompat.getDrawableForDensity(resources, info.icon, density, null); } catch (Resources.NotFoundException ignored) { //ignored } if (preview != null) return preview; } UserHandleCompat userHandle = UserHandleCompat.CURRENT_USER; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { userHandle = new UserHandleCompat(context, info.getProfile()); } var icon = TBApplication.iconsHandler(context).getIconForPackage(info.provider, userHandle); return icon.getDrawable(); } static class WidgetOptionItem extends LinearAdapter.Item { enum Action { MOVE, MOVE_SWITCH, RESIZE, RESIZE_SWITCH, MOVE_RESIZE, MOVE_RESIZE_SWITCH, RESET, REMOVE, MOVE2SCREEN_LEFT, MOVE2SCREEN_UP, MOVE2SCREEN_MIDDLE, MOVE2SCREEN_RIGHT, MOVE2SCREEN_DOWN, MOVE_BELOW, MOVE_ABOVE, } final Action mAction; public WidgetOptionItem(Context ctx, @StringRes int stringId, Action action) { super(ctx, stringId); mAction = action; } } static class PlaceholderPopupItem extends LinearAdapterPlus.ItemStringIcon { final PlaceholderWidgetRecord placeholder; @NonNull static PlaceholderPopupItem create(Context ctx, PlaceholderWidgetRecord placeholder) { Drawable icon = DrawableUtils.getBitmapDrawable(ctx, placeholder.preview); String name = ctx.getString(R.string.widget_placeholder, placeholder.name); return new PlaceholderPopupItem(placeholder, name, icon); } private PlaceholderPopupItem(@NonNull PlaceholderWidgetRecord placeholder, @NonNull String name, Drawable icon) { super(name, icon); this.placeholder = placeholder; } @Override public int getLayoutResource() { return R.layout.popup_list_item_icon; } } static class WidgetPopupItem extends LinearAdapterPlus.ItemStringIcon { int appWidgetId; @NonNull static WidgetPopupItem create(Context ctx, AppWidgetManager appWidgetManager, int appWidgetId) { AppWidgetProviderInfo info = appWidgetManager.getAppWidgetInfo(appWidgetId); String name = getWidgetName(ctx, info); //TODO: make preview icon loading async Drawable icon = getWidgetPreview(ctx, info); return new WidgetPopupItem(name, appWidgetId, icon); } private WidgetPopupItem(@NonNull String string, int appWidgetId, @NonNull Drawable icon) { super(string, icon); this.appWidgetId = appWidgetId; } @Override public int getLayoutResource() { return R.layout.popup_list_item_icon; } } static class WidgetHost extends AppWidgetHost { public WidgetHost(Context context) { super(context, APPWIDGET_HOST_ID); } @Override protected AppWidgetHostView onCreateView(Context context, int appWidgetId, AppWidgetProviderInfo appWidget) { return new WidgetView(context); } } } ================================================ FILE: app/src/main/java/rocks/tbog/tblauncher/widgets/WidgetView.java ================================================ package rocks.tbog.tblauncher.widgets; import android.annotation.SuppressLint; import android.appwidget.AppWidgetHostView; import android.content.Context; import android.util.Log; import android.view.GestureDetector; import android.view.MotionEvent; import androidx.annotation.Nullable; import androidx.core.view.GestureDetectorCompat; public class WidgetView extends AppWidgetHostView { private static final String TAG = "WdgView"; private final GestureDetectorCompat gestureDetector; private boolean mIntercepted = false; private boolean mJustIntercepted = false; private boolean mSendCancel = false; private boolean mLongClickCalled = false; private OnClickListener mOnClickListener = null; private OnClickListener mOnDoubleClickListener = null; private OnLongClickListener mOnLongClickListener = null; public WidgetView(Context context) { super(context); //TODO: make WidgetView implement OnGestureListener and get rid of onGestureListener GestureDetector.SimpleOnGestureListener onGestureListener = new GestureDetector.SimpleOnGestureListener() { @Override public boolean onSingleTapUp(MotionEvent e) { // if we have a double tap listener, wait for onSingleTapConfirmed if (mOnDoubleClickListener != null) return true; if (mOnClickListener != null) { mOnClickListener.onClick(WidgetView.this); return true; } return false; } @Override public boolean onSingleTapConfirmed(MotionEvent e) { // if we have both a double tap and click, handle click here if (mOnClickListener != null && mOnDoubleClickListener != null) { mOnClickListener.onClick(WidgetView.this); return true; } return false; } @Override public void onLongPress(MotionEvent e) { if (mOnLongClickListener != null) { mLongClickCalled = true; mOnLongClickListener.onLongClick(WidgetView.this); } } @Override public boolean onDoubleTapEvent(MotionEvent e) { //Log.d(TAG, "onDoubleTapEvent " + e); if (mOnDoubleClickListener != null) { final int act = e.getActionMasked(); if (act == MotionEvent.ACTION_UP) mOnDoubleClickListener.onClick(WidgetView.this); return true; } return false; } @Override public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { //Log.d(TAG, "onScroll mSendCancel = true"); //mSendCancel = true; //TBApplication.liveWallpaper(context).scroll(e1, e2); //return true; return false; } @Override public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { // Log.d(TAG, "onFling mSendCancel = true" + // String.format("\r\nvelocity( %.2f, %.2f )", velocityX, velocityY) + // String.format("\r\ndown pos ( %.2f, %.2f )", e1.getX(), e1.getY()) + // String.format("\r\n up pos ( %.2f, %.2f )", e2.getX(), e2.getY()) // ); // mSendCancel = true; // return true; return false; } }; gestureDetector = new GestureDetectorCompat(context, onGestureListener); } @Override public void setOnLongClickListener(@Nullable OnLongClickListener listener) { gestureDetector.setIsLongpressEnabled(listener != null); mOnLongClickListener = listener; } @Override public void setOnClickListener(@Nullable OnClickListener listener) { mOnClickListener = listener; } public void setOnDoubleClickListener(@Nullable OnClickListener listener) { mOnDoubleClickListener = listener; } @Override public boolean onInterceptTouchEvent(MotionEvent event) { Log.d(TAG, "onInterceptTouchEvent\r\n" + event + "\r\nmIntercepted = " + mIntercepted); if (event.getPointerCount() != 1) return false; final int act = event.getActionMasked(); switch (act) { case MotionEvent.ACTION_DOWN: // Throw away all previous state when starting a new touch gesture. // The framework may have dropped the up or cancel event for the previous gesture // due to an app switch, ANR, or some other state change. mIntercepted = false; mJustIntercepted = false; mSendCancel = false; mLongClickCalled = false; break; case MotionEvent.ACTION_UP: if (mLongClickCalled) { mLongClickCalled = false; return mJustIntercepted = true; } break; } if (mIntercepted) return true; if (gestureDetector.onTouchEvent(event)) { Log.d(TAG, "mJustIntercepted = " + true); return mJustIntercepted = true; } Log.d(TAG, "super.onInterceptTouchEvent"); return super.onInterceptTouchEvent(event); } @SuppressLint("ClickableViewAccessibility") @Override public boolean onTouchEvent(MotionEvent event) { Log.d(TAG, "onTouchEvent\t\n" + event + "\r\nmJustIntercepted " + mJustIntercepted + " mIntercepted " + mIntercepted); // first call after the intercept can be ignored if (mJustIntercepted) { mJustIntercepted = false; mIntercepted = true; Log.d(TAG, "mJustIntercepted = " + false); return true; } if (event.getPointerCount() != 1) return false; if (gestureDetector.onTouchEvent(event)) return true; // if we intercepted this gesture, handle all touch events boolean handled = mIntercepted; if (mSendCancel) { handled = true; event.setAction(MotionEvent.ACTION_CANCEL); } Log.d(TAG, "super.onTouchEvent"); if (super.onTouchEvent(event)) { Log.d(TAG, "mIntercepted = " + false); mIntercepted = false; handled = true; } else { // if no child view handled this event, send cancel to gestureDetector MotionEvent cancel = MotionEvent.obtainNoHistory(event); cancel.setAction(MotionEvent.ACTION_CANCEL); Log.d(TAG, "gestureDetector CANCEL"); gestureDetector.onTouchEvent(cancel); } return handled; } @Override public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) { // deny this request //super.requestDisallowInterceptTouchEvent(disallowIntercept); } } ================================================ FILE: app/src/main/res/anim/popup_in_bottom.xml ================================================ ================================================ FILE: app/src/main/res/anim/popup_in_top.xml ================================================ ================================================ FILE: app/src/main/res/anim/popup_out.xml ================================================ ================================================ FILE: app/src/main/res/color/accent_text_selector.xml ================================================ ================================================ FILE: app/src/main/res/color/accent_text_selector_black.xml ================================================ ================================================ FILE: app/src/main/res/color/accent_text_selector_deep_blues.xml ================================================ ================================================ FILE: app/src/main/res/color/accent_text_selector_white.xml ================================================ ================================================ FILE: app/src/main/res/color/primary_text_selector_darkbg.xml ================================================ ================================================ FILE: app/src/main/res/color/primary_text_selector_lightbg.xml ================================================ ================================================ FILE: app/src/main/res/color/secondary_text_selector_darkbg.xml ================================================ ================================================ FILE: app/src/main/res/color/settings_primary_selector_black.xml ================================================ ================================================ FILE: app/src/main/res/color/settings_primary_selector_darkbg.xml ================================================ ================================================ FILE: app/src/main/res/color/settings_primary_selector_deep_blues.xml ================================================ ================================================ FILE: app/src/main/res/color/settings_primary_selector_default.xml ================================================ ================================================ FILE: app/src/main/res/color/settings_primary_selector_lightbg.xml ================================================ ================================================ FILE: app/src/main/res/color/settings_secondary_selector_black.xml ================================================ ================================================ FILE: app/src/main/res/color/settings_secondary_selector_deep_blues.xml ================================================ ================================================ FILE: app/src/main/res/color/settings_secondary_selector_default.xml ================================================ ================================================ FILE: app/src/main/res/color/settings_secondary_selector_lightbg.xml ================================================ ================================================ FILE: app/src/main/res/drawable/button_bar_background.xml ================================================ ================================================ FILE: app/src/main/res/drawable/button_bar_background_deep_blues.xml ================================================ ================================================ FILE: app/src/main/res/drawable/button_bar_background_default.xml ================================================ ================================================ FILE: app/src/main/res/drawable/button_bar_background_light.xml ================================================ ================================================ FILE: app/src/main/res/drawable/dialog_background.xml ================================================ ================================================ FILE: app/src/main/res/drawable/dialog_background_black.xml ================================================ ================================================ FILE: app/src/main/res/drawable/dialog_background_dark.xml ================================================ ================================================ FILE: app/src/main/res/drawable/dialog_background_deep_blues.xml ================================================ ================================================ FILE: app/src/main/res/drawable/dialog_background_default.xml ================================================ ================================================ FILE: app/src/main/res/drawable/dialog_background_light.xml ================================================ ================================================ FILE: app/src/main/res/drawable/handle_background.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_add_tag.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_android.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_apps.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_apps_grid_az.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_apps_grid_za.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_apps_list_az.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_apps_list_za.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_arrow_back.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_backup.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_behaviour.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_browse_add_icon.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_bug.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_clear.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_contact_placeholder.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_contacts.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_contacts_az.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_contacts_za.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_dots.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_edit.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_eye_crossed.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_favorites.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_features.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_functions.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_gesture.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_grid.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_handle_move.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_handle_resize_bl.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_handle_resize_l.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_history.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_icon.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_keyboard.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_list.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_loading_arrows.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_loading_pulse.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_memory.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_message.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_phone.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_phone_ui.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_popup.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_quick.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_refresh.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_remove_tag.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_search.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_search_bar.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_send.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_settings.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_shortcuts.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_shortcuts_az.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_shortcuts_za.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_tags.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_undo.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_untagged.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_wallpaper.xml ================================================ ================================================ FILE: app/src/main/res/drawable/launcher_pill.xml ================================================ ================================================ FILE: app/src/main/res/drawable/launcher_pill_background.xml ================================================ ================================================ FILE: app/src/main/res/drawable/launcher_white.xml ================================================ ================================================ FILE: app/src/main/res/drawable/list_separator_dark.xml ================================================ ================================================ FILE: app/src/main/res/drawable/list_separator_deep_blues.xml ================================================ ================================================ FILE: app/src/main/res/drawable/list_separator_default.xml ================================================ ================================================ FILE: app/src/main/res/drawable/list_separator_light.xml ================================================ ================================================ FILE: app/src/main/res/drawable/mm2d_cc_ic_check.xml ================================================ ================================================ FILE: app/src/main/res/drawable/notification_bar_background.xml ================================================ ================================================ FILE: app/src/main/res/drawable/notification_dot.xml ================================================ ================================================ FILE: app/src/main/res/drawable/popup_background.xml ================================================ ================================================ FILE: app/src/main/res/drawable/tab_background_black.xml ================================================ ================================================ FILE: app/src/main/res/drawable/tab_background_deep_blues.xml ================================================ ================================================ FILE: app/src/main/res/drawable/tab_background_default.xml ================================================ ================================================ FILE: app/src/main/res/drawable/tab_background_light.xml ================================================ ================================================ FILE: app/src/main/res/drawable/window_title_background.xml ================================================ ================================================ FILE: app/src/main/res/drawable/window_title_background_deep_blues.xml ================================================ ================================================ FILE: app/src/main/res/drawable/window_title_background_default.xml ================================================ ================================================ FILE: app/src/main/res/drawable/window_title_background_light.xml ================================================ ================================================ FILE: app/src/main/res/drawable-v23/button_bar_background.xml ================================================ ================================================ FILE: app/src/main/res/drawable-v23/window_title_background.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_fullscreen.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_settings.xml ================================================ ================================================ FILE: app/src/main/res/layout/add_search_engine.xml ================================================ ================================================ FILE: app/src/main/res/layout/add_search_hint.xml ================================================ ================================================ FILE: app/src/main/res/layout/custom_icon_item.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_custom_shape_icon_select_page.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_edit_tags.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_icon_select.xml ================================================