Repository: owncloud/News-Android-App Branch: master Commit: 3e68d436222c Files: 480 Total size: 2.3 MB Directory structure: gitextract_ye9_lomr/ ├── .editorconfig ├── .github/ │ ├── FUNDING.yml │ ├── dependabot.yml │ └── workflows/ │ ├── analysis.yml │ ├── ci.yml │ └── codeql.yml ├── .gitignore ├── .tx/ │ └── config ├── CHANGELOG.md ├── COPYING-AGPL.md ├── COPYING-README.md ├── Gemfile ├── NEWS-POLICY.md ├── News-Android-App/ │ ├── .gitignore │ ├── build.gradle │ ├── config/ │ │ └── detekt/ │ │ └── detekt.yml │ ├── proguard-rules.pro │ ├── proguard-test.pro │ ├── remove_invalid_languages.sh │ └── src/ │ ├── androidTest/ │ │ ├── AndroidManifest.xml │ │ └── java/ │ │ ├── de/ │ │ │ └── luhmer/ │ │ │ └── owncloudnewsreader/ │ │ │ ├── CustomTestRunner.java │ │ │ ├── TestApplication.java │ │ │ ├── di/ │ │ │ │ ├── TestApiModule.java │ │ │ │ ├── TestApiProvider.java │ │ │ │ └── TestComponent.java │ │ │ ├── helper/ │ │ │ │ └── Utils.java │ │ │ └── tests/ │ │ │ ├── DownloadWebPageServiceTest.java │ │ │ ├── NewFeedTests.java │ │ │ ├── NewsReaderListActivityUiTests.java │ │ │ └── NightModeTest.java │ │ ├── helper/ │ │ │ ├── CustomMatchers.java │ │ │ ├── OrientationChangeAction.java │ │ │ └── RecyclerViewAssertions.java │ │ └── screengrab/ │ │ └── ScreenshotTest.java │ ├── dev/ │ │ └── res/ │ │ ├── drawable/ │ │ │ └── ic_launcher_foreground.xml │ │ └── values/ │ │ └── strings.xml │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── assets/ │ │ │ └── web.css │ │ ├── icon.xcf │ │ ├── java/ │ │ │ ├── com/ │ │ │ │ └── bumptech/ │ │ │ │ └── glide/ │ │ │ │ └── samples/ │ │ │ │ └── svg/ │ │ │ │ ├── SvgDecoder.kt │ │ │ │ └── SvgDrawableTranscoder.kt │ │ │ └── de/ │ │ │ └── luhmer/ │ │ │ └── owncloudnewsreader/ │ │ │ ├── AddFolderDialogFragment.java │ │ │ ├── Constants.java │ │ │ ├── DirectoryChooserActivity.java │ │ │ ├── FolderOptionsDialogFragment.java │ │ │ ├── LazyLoadingLinearLayoutManager.kt │ │ │ ├── ListView/ │ │ │ │ ├── BlockingExpandableListView.java │ │ │ │ ├── PodcastArrayAdapter.java │ │ │ │ ├── PodcastFeedArrayAdapter.java │ │ │ │ └── SubscriptionExpandableListAdapter.java │ │ │ ├── LoginDialogActivity.java │ │ │ ├── NewFeedActivity.java │ │ │ ├── NewsDetailActivity.java │ │ │ ├── NewsDetailFragment.java │ │ │ ├── NewsDetailImageDialogFragment.java │ │ │ ├── NewsReaderApplication.java │ │ │ ├── NewsReaderDetailFragment.java │ │ │ ├── NewsReaderListActivity.java │ │ │ ├── NewsReaderListDialogFragment.java │ │ │ ├── NewsReaderListFragment.java │ │ │ ├── NewsReaderOPMLImportDialogFragment.java │ │ │ ├── PiPVideoPlaybackActivity.java │ │ │ ├── PodcastFragment.java │ │ │ ├── PodcastFragmentActivity.java │ │ │ ├── SettingsActivity.java │ │ │ ├── SettingsFragment.java │ │ │ ├── VersionInfoDialogFragment.java │ │ │ ├── adapter/ │ │ │ │ ├── HasId.kt │ │ │ │ ├── NewsListRecyclerAdapter.java │ │ │ │ ├── ProgressBarWebChromeClient.kt │ │ │ │ ├── ProgressViewHolder.kt │ │ │ │ ├── RecyclerItemClickListener.kt │ │ │ │ ├── RssItemCardViewHolder.kt │ │ │ │ ├── RssItemFullTextViewHolder.kt │ │ │ │ ├── RssItemHeadlineThumbnailViewHolder.kt │ │ │ │ ├── RssItemHeadlineViewHolder.kt │ │ │ │ ├── RssItemTextViewHolder.kt │ │ │ │ ├── RssItemThumbnailViewHolder.kt │ │ │ │ ├── RssItemViewHolder.java │ │ │ │ └── RssItemWebViewHolder.kt │ │ │ ├── async_tasks/ │ │ │ │ ├── DownloadChangelogTask.java │ │ │ │ ├── DownloadImageHandler.java │ │ │ │ └── RssItemToHtmlTask.java │ │ │ ├── authentication/ │ │ │ │ ├── AccountGeneral.java │ │ │ │ ├── OwnCloudAccountAuthenticator.java │ │ │ │ └── OwnCloudSyncAdapter.java │ │ │ ├── chrometabs/ │ │ │ │ └── KeepAliveService.kt │ │ │ ├── database/ │ │ │ │ ├── DatabaseConnectionOrm.java │ │ │ │ ├── DatabaseHelperOrm.java │ │ │ │ ├── generator/ │ │ │ │ │ ├── DatabaseOrmGenerator.java │ │ │ │ │ ├── LastestVersion.java │ │ │ │ │ └── SchemaVersion.java │ │ │ │ └── model/ │ │ │ │ ├── CurrentRssItemView.java │ │ │ │ ├── CurrentRssItemViewDao.java │ │ │ │ ├── DaoMaster.java │ │ │ │ ├── DaoSession.java │ │ │ │ ├── Feed.java │ │ │ │ ├── FeedDao.java │ │ │ │ ├── Folder.java │ │ │ │ ├── FolderDao.java │ │ │ │ ├── RssItem.java │ │ │ │ └── RssItemDao.java │ │ │ ├── di/ │ │ │ │ ├── ApiModule.java │ │ │ │ ├── ApiProvider.java │ │ │ │ └── AppComponent.java │ │ │ ├── events/ │ │ │ │ └── podcast/ │ │ │ │ ├── CollapsePodcastView.java │ │ │ │ ├── ExitPlayback.java │ │ │ │ ├── ExpandPodcastView.java │ │ │ │ ├── NewPodcastPlaybackListener.java │ │ │ │ ├── PodcastCompletedEvent.java │ │ │ │ ├── PodcastFeedClicked.kt │ │ │ │ ├── RegisterVideoOutput.java │ │ │ │ ├── SeekPodcast.java │ │ │ │ ├── SpeedPodcast.java │ │ │ │ ├── StartDownloadPodcast.kt │ │ │ │ ├── TogglePlayerStateEvent.java │ │ │ │ └── WindPodcast.java │ │ │ ├── helper/ │ │ │ │ ├── AppCompatPreferenceActivity.java │ │ │ │ ├── AsyncTaskHelper.java │ │ │ │ ├── AutoResizeTextView.java │ │ │ │ ├── ColorHelper.java │ │ │ │ ├── DatabaseUtils.kt │ │ │ │ ├── DateTimeFormatter.java │ │ │ │ ├── FavIconHandler.java │ │ │ │ ├── FavIconUtils.java │ │ │ │ ├── ForegroundListener.kt │ │ │ │ ├── GsonConfig.java │ │ │ │ ├── ImageDownloadFinished.java │ │ │ │ ├── ImageHandler.java │ │ │ │ ├── NetworkConnection.java │ │ │ │ ├── NewsFileUtils.java │ │ │ │ ├── NextcloudGlideModule.kt │ │ │ │ ├── NotificationActionReceiver.java │ │ │ │ ├── NotificationActionReceiverDownloadWebPage.java │ │ │ │ ├── OpmlXmlParser.java │ │ │ │ ├── PostDelayHandler.java │ │ │ │ ├── Search.java │ │ │ │ ├── StopWatch.java │ │ │ │ ├── ThemeChooser.java │ │ │ │ ├── ThemeUtils.java │ │ │ │ └── URLConnectionReader.kt │ │ │ ├── interfaces/ │ │ │ │ ├── ExpListTextClicked.java │ │ │ │ └── IPlayPausePodcastClicked.java │ │ │ ├── model/ │ │ │ │ ├── AbstractItem.java │ │ │ │ ├── ConcreteFeedItem.java │ │ │ │ ├── CurrentRssViewDataHolder.java │ │ │ │ ├── FolderSubscribtionItem.java │ │ │ │ ├── MediaItem.java │ │ │ │ ├── NextcloudNewsVersion.java │ │ │ │ ├── NextcloudStatus.java │ │ │ │ ├── OcsUser.java │ │ │ │ ├── PodcastFeedItem.java │ │ │ │ ├── PodcastItem.java │ │ │ │ ├── TTSItem.java │ │ │ │ └── Tuple.java │ │ │ ├── notification/ │ │ │ │ └── NextcloudNotificationManager.java │ │ │ ├── providers/ │ │ │ │ └── OwnCloudSyncProvider.kt │ │ │ ├── reader/ │ │ │ │ ├── FeedItemTags.java │ │ │ │ ├── InsertIntoDatabase.java │ │ │ │ ├── OnAsyncTaskCompletedListener.java │ │ │ │ └── nextcloud/ │ │ │ │ ├── IHandleJsonObject.java │ │ │ │ ├── InsertRssItemIntoDatabase.java │ │ │ │ ├── ItemIds.java │ │ │ │ ├── ItemMap.java │ │ │ │ ├── ItemStateSync.java │ │ │ │ ├── NewsAPI.java │ │ │ │ ├── NextcloudNewsDeserializer.java │ │ │ │ ├── NextcloudServerDeserializer.java │ │ │ │ ├── OcsAPI.java │ │ │ │ ├── RssItemObservable.java │ │ │ │ └── Types.java │ │ │ ├── services/ │ │ │ │ ├── DownloadImagesService.java │ │ │ │ ├── DownloadWebPageService.java │ │ │ │ ├── OwnCloudAuthenticatorService.kt │ │ │ │ ├── OwnCloudSyncService.java │ │ │ │ ├── PodcastDownloadService.java │ │ │ │ ├── PodcastPlaybackService.java │ │ │ │ ├── SyncItemStateService.java │ │ │ │ ├── events/ │ │ │ │ │ ├── StopWebArchiveDownloadEvent.kt │ │ │ │ │ ├── SyncFailedEvent.kt │ │ │ │ │ ├── SyncFinishedEvent.kt │ │ │ │ │ └── SyncStartedEvent.kt │ │ │ │ └── podcast/ │ │ │ │ ├── MediaPlayerPlaybackService.java │ │ │ │ ├── PlaybackService.java │ │ │ │ └── TTSPlaybackService.java │ │ │ ├── ssl/ │ │ │ │ ├── MTMDecision.java │ │ │ │ ├── MemorizingDialogFragment.java │ │ │ │ ├── MemorizingTrustManager.java │ │ │ │ ├── OkHttpSSLClient.java │ │ │ │ └── TLSSocketFactory.kt │ │ │ ├── view/ │ │ │ │ ├── AnimatingProgressBar.java │ │ │ │ ├── ChangeLogFileListView.kt │ │ │ │ ├── PodcastNotification.java │ │ │ │ └── PodcastSlidingUpPanelLayout.java │ │ │ └── widget/ │ │ │ ├── WidgetNewsViewsFactory.java │ │ │ ├── WidgetProvider.java │ │ │ └── WidgetService.kt │ │ └── res/ │ │ ├── anim/ │ │ │ ├── all_read_success.xml │ │ │ ├── slide_in_left.xml │ │ │ ├── slide_in_right.xml │ │ │ ├── slide_out_left.xml │ │ │ └── slide_out_right.xml │ │ ├── color/ │ │ │ ├── options_menu_item.xml │ │ │ └── options_menu_item_night.xml │ │ ├── drawable/ │ │ │ ├── background_with_shadow.xml │ │ │ ├── checkbox_background_holo_dark.xml │ │ │ ├── cursor.xml │ │ │ ├── custom_progress.xml │ │ │ ├── fa_all_read_target.xml │ │ │ ├── fa_all_read_target_success.xml │ │ │ ├── fa_bg.xml │ │ │ ├── feed_icon.xml │ │ │ ├── ic_action_delete_24.xml │ │ │ ├── ic_action_delete_24_theme_aware.xml │ │ │ ├── ic_action_download_24.xml │ │ │ ├── ic_action_expand_less_24.xml │ │ │ ├── ic_action_open_in_browser_24.xml │ │ │ ├── ic_action_open_in_browser_24_theme_aware.xml │ │ │ ├── ic_action_pause_24.xml │ │ │ ├── ic_add_black_24dp.xml │ │ │ ├── ic_baseline_account_circle_24.xml │ │ │ ├── ic_baseline_create_new_folder_24_black.xml │ │ │ ├── ic_baseline_folder_24.xml │ │ │ ├── ic_baseline_play_arrow_24.xml │ │ │ ├── ic_baseline_play_arrow_24_theme_aware.xml │ │ │ ├── ic_checkbox_black.xml │ │ │ ├── ic_checkbox_outline_black.xml │ │ │ ├── ic_checkbox_outline_theme_aware.xml │ │ │ ├── ic_checkbox_outline_white.xml │ │ │ ├── ic_checkbox_theme_aware.xml │ │ │ ├── ic_checkbox_white.xml │ │ │ ├── ic_done_all.xml │ │ │ ├── ic_forward_30_24.xml │ │ │ ├── ic_launcher_background.xml │ │ │ ├── ic_launcher_foreground.xml │ │ │ ├── ic_replay_10_24.xml │ │ │ ├── ic_search_24dp_theme_aware.xml │ │ │ ├── ic_settings_black_24dp.xml │ │ │ ├── ic_share_theme_aware.xml │ │ │ ├── ic_share_white.xml │ │ │ ├── ic_slow_motion_video_24.xml │ │ │ ├── ic_star_24_theme_aware.xml │ │ │ ├── ic_star_black_24dp.xml │ │ │ ├── ic_star_border_24dp_theme_aware.xml │ │ │ ├── ic_star_white_24.xml │ │ │ ├── ic_visibility_24.xml │ │ │ ├── incognito.xml │ │ │ ├── rounded_rectangle.xml │ │ │ ├── shadow.xml │ │ │ ├── swipe_markasread.xml │ │ │ ├── swipe_openinbrowser.xml │ │ │ ├── swipe_setstarred.xml │ │ │ ├── swipe_share.xml │ │ │ └── widget_background.xml │ │ ├── layout/ │ │ │ ├── activity_login_dialog.xml │ │ │ ├── activity_new_feed.xml │ │ │ ├── activity_news_detail.xml │ │ │ ├── activity_newsreader.xml │ │ │ ├── activity_pip_video_playback.xml │ │ │ ├── activity_settings.xml │ │ │ ├── dialog_list_folder.xml │ │ │ ├── dialog_version_info.xml │ │ │ ├── empty_content_view.xml │ │ │ ├── fragment_dialog_add_folder.xml │ │ │ ├── fragment_dialog_feedoptions.xml │ │ │ ├── fragment_dialog_folderoptions.xml │ │ │ ├── fragment_dialog_image.xml │ │ │ ├── fragment_dialog_listviewitem.xml │ │ │ ├── fragment_dialog_opml_import.xml │ │ │ ├── fragment_news_detail.xml │ │ │ ├── fragment_newsreader_detail.xml │ │ │ ├── fragment_newsreader_list.xml │ │ │ ├── fragment_newsreader_list_footer.xml │ │ │ ├── fragment_podcast.xml │ │ │ ├── podcast_feed_row.xml │ │ │ ├── podcast_row.xml │ │ │ ├── progressbar_item.xml │ │ │ ├── subscription_detail_list_item_card_view.xml │ │ │ ├── subscription_detail_list_item_headline.xml │ │ │ ├── subscription_detail_list_item_headline_thumbnail.xml │ │ │ ├── subscription_detail_list_item_podcast_wrapper.xml │ │ │ ├── subscription_detail_list_item_text.xml │ │ │ ├── subscription_detail_list_item_thumbnail.xml │ │ │ ├── subscription_detail_list_item_web_layout.xml │ │ │ ├── subscription_list_item.xml │ │ │ ├── subscription_list_sub_item.xml │ │ │ ├── toolbar_layout.xml │ │ │ ├── widget_fastactions_detailview.xml │ │ │ ├── widget_item.xml │ │ │ └── widget_layout.xml │ │ ├── layout-sw600dp-land/ │ │ │ └── activity_newsreader.xml │ │ ├── menu/ │ │ │ ├── list_footer_menu.xml │ │ │ ├── news_detail.xml │ │ │ └── news_reader.xml │ │ ├── mipmap-anydpi-v26/ │ │ │ ├── ic_launcher.xml │ │ │ └── ic_launcher_round.xml │ │ ├── values/ │ │ │ ├── attrs.xml │ │ │ ├── booleans.xml │ │ │ ├── colors.xml │ │ │ ├── config.xml │ │ │ ├── dimens.xml │ │ │ ├── integers.xml │ │ │ ├── isrighttoleft.xml │ │ │ ├── strings.xml │ │ │ ├── styles.xml │ │ │ └── themes.xml │ │ ├── values-ar/ │ │ │ └── strings.xml │ │ ├── values-ast/ │ │ │ └── strings.xml │ │ ├── values-bg-rBG/ │ │ │ └── strings.xml │ │ ├── values-ca/ │ │ │ └── strings.xml │ │ ├── values-cs-rCZ/ │ │ │ └── strings.xml │ │ ├── values-da/ │ │ │ └── strings.xml │ │ ├── values-de/ │ │ │ └── strings.xml │ │ ├── values-el/ │ │ │ └── strings.xml │ │ ├── values-en-rGB/ │ │ │ └── strings.xml │ │ ├── values-es/ │ │ │ └── strings.xml │ │ ├── values-es-rCL/ │ │ │ └── strings.xml │ │ ├── values-es-rCO/ │ │ │ └── strings.xml │ │ ├── values-es-rCR/ │ │ │ └── strings.xml │ │ ├── values-es-rDO/ │ │ │ └── strings.xml │ │ ├── values-es-rEC/ │ │ │ └── strings.xml │ │ ├── values-es-rGT/ │ │ │ └── strings.xml │ │ ├── values-es-rHN/ │ │ │ └── strings.xml │ │ ├── values-es-rMX/ │ │ │ └── strings.xml │ │ ├── values-es-rNI/ │ │ │ └── strings.xml │ │ ├── values-es-rPA/ │ │ │ └── strings.xml │ │ ├── values-es-rPE/ │ │ │ └── strings.xml │ │ ├── values-es-rPR/ │ │ │ └── strings.xml │ │ ├── values-es-rPY/ │ │ │ └── strings.xml │ │ ├── values-es-rSV/ │ │ │ └── strings.xml │ │ ├── values-es-rUS/ │ │ │ └── strings.xml │ │ ├── values-es-rUY/ │ │ │ └── strings.xml │ │ ├── values-et-rEE/ │ │ │ └── strings.xml │ │ ├── values-eu/ │ │ │ └── strings.xml │ │ ├── values-fa/ │ │ │ └── strings.xml │ │ ├── values-fi-rFI/ │ │ │ └── strings.xml │ │ ├── values-fr/ │ │ │ └── strings.xml │ │ ├── values-ga/ │ │ │ └── strings.xml │ │ ├── values-gl/ │ │ │ └── strings.xml │ │ ├── values-he/ │ │ │ └── strings.xml │ │ ├── values-hr/ │ │ │ └── strings.xml │ │ ├── values-hu-rHU/ │ │ │ └── strings.xml │ │ ├── values-id/ │ │ │ └── strings.xml │ │ ├── values-is/ │ │ │ └── strings.xml │ │ ├── values-it/ │ │ │ └── strings.xml │ │ ├── values-ja-rJP/ │ │ │ └── strings.xml │ │ ├── values-ka-rGE/ │ │ │ └── strings.xml │ │ ├── values-ko/ │ │ │ └── strings.xml │ │ ├── values-large/ │ │ │ ├── refs.xml │ │ │ └── styles.xml │ │ ├── values-ldrtl/ │ │ │ └── isrighttoleft.xml │ │ ├── values-lo/ │ │ │ └── strings.xml │ │ ├── values-lt-rLT/ │ │ │ └── strings.xml │ │ ├── values-nb-rNO/ │ │ │ └── strings.xml │ │ ├── values-night/ │ │ │ ├── booleans.xml │ │ │ └── colors.xml │ │ ├── values-nl/ │ │ │ └── strings.xml │ │ ├── values-oc/ │ │ │ └── strings.xml │ │ ├── values-pl/ │ │ │ └── strings.xml │ │ ├── values-pt-rBR/ │ │ │ └── strings.xml │ │ ├── values-pt-rPT/ │ │ │ └── strings.xml │ │ ├── values-ro/ │ │ │ └── strings.xml │ │ ├── values-ru/ │ │ │ └── strings.xml │ │ ├── values-sc/ │ │ │ └── strings.xml │ │ ├── values-sk-rSK/ │ │ │ └── strings.xml │ │ ├── values-sl/ │ │ │ └── strings.xml │ │ ├── values-sq/ │ │ │ └── strings.xml │ │ ├── values-sr/ │ │ │ └── strings.xml │ │ ├── values-sr-rSP/ │ │ │ └── strings.xml │ │ ├── values-sv/ │ │ │ └── strings.xml │ │ ├── values-sw/ │ │ │ └── strings.xml │ │ ├── values-sw600dp/ │ │ │ └── config.xml │ │ ├── values-sw720dp-land/ │ │ │ └── dimens.xml │ │ ├── values-th-rTH/ │ │ │ └── strings.xml │ │ ├── values-tr/ │ │ │ └── strings.xml │ │ ├── values-ug/ │ │ │ └── strings.xml │ │ ├── values-uk/ │ │ │ └── strings.xml │ │ ├── values-v23/ │ │ │ └── themes.xml │ │ ├── values-v27/ │ │ │ └── themes.xml │ │ ├── values-vi/ │ │ │ └── strings.xml │ │ ├── values-w820dp/ │ │ │ └── dimens.xml │ │ ├── values-zh-rCN/ │ │ │ └── strings.xml │ │ ├── values-zh-rHK/ │ │ │ └── strings.xml │ │ ├── values-zh-rTW/ │ │ │ └── strings.xml │ │ └── xml/ │ │ ├── account_preferences.xml │ │ ├── authenticator.xml │ │ ├── automotive_app_desc.xml │ │ ├── file_provider_paths.xml │ │ ├── pref_about.xml │ │ ├── pref_data_sync.xml │ │ ├── pref_display.xml │ │ ├── pref_general.xml │ │ ├── syncadapter.xml │ │ └── widget_info.xml │ └── test/ │ ├── java/ │ │ └── de/ │ │ └── luhmer/ │ │ └── owncloudnewsreader/ │ │ ├── asynctasks/ │ │ │ └── RssItemToHtmlTaskTest.kt │ │ └── junit_tests/ │ │ ├── ImageHandlerTest.java │ │ └── TestDbTest.java │ └── resources/ │ └── org.robolectric.Config.properties ├── PRIVACY.md ├── README.md ├── Screengrabfile ├── build.gradle ├── config/ │ └── detekt/ │ └── detekt.yml ├── docker-nextcloud-test-instances/ │ ├── .gitignore │ ├── README.md │ └── docker-compose.yaml ├── executeScreengrab.sh ├── fastlane/ │ ├── Fastfile │ ├── README.md │ └── metadata/ │ └── android/ │ ├── de-DE/ │ │ ├── changelogs/ │ │ │ ├── 166.txt │ │ │ ├── 167.txt │ │ │ ├── 168.txt │ │ │ ├── 170.txt │ │ │ ├── 171.txt │ │ │ ├── 172.txt │ │ │ ├── 173.txt │ │ │ ├── 174.txt │ │ │ ├── 175.txt │ │ │ ├── 176.txt │ │ │ ├── 177.txt │ │ │ ├── 178.txt │ │ │ ├── 179.txt │ │ │ ├── 180.txt │ │ │ ├── 181.txt │ │ │ ├── 182.txt │ │ │ ├── 183.txt │ │ │ ├── 184.txt │ │ │ ├── 185.txt │ │ │ ├── 186.txt │ │ │ ├── 189.txt │ │ │ ├── 190.txt │ │ │ ├── 191.txt │ │ │ ├── 192.txt │ │ │ ├── 193.txt │ │ │ ├── 194.txt │ │ │ ├── 195.txt │ │ │ └── 196.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ ├── title.txt │ │ └── video.txt │ └── en-US/ │ ├── changelogs/ │ │ ├── 166.txt │ │ ├── 167.txt │ │ ├── 168.txt │ │ ├── 170.txt │ │ ├── 171.txt │ │ ├── 172.txt │ │ ├── 173.txt │ │ ├── 174.txt │ │ ├── 175.txt │ │ ├── 176.txt │ │ ├── 177.txt │ │ ├── 178.txt │ │ ├── 179.txt │ │ ├── 180.txt │ │ ├── 181.txt │ │ ├── 182.txt │ │ ├── 183.txt │ │ ├── 184.txt │ │ ├── 185.txt │ │ ├── 186.txt │ │ ├── 189.txt │ │ ├── 190.txt │ │ ├── 191.txt │ │ ├── 192.txt │ │ ├── 193.txt │ │ ├── 194.txt │ │ ├── 195.txt │ │ └── 196.txt │ ├── full_description.txt │ ├── short_description.txt │ ├── title.txt │ └── video.txt ├── gradle/ │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat ├── security/ │ └── GHSL-2021-1033_Nextcloud_News_for_Android.md └── settings.gradle ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ [*.{kt,kts}] max_line_length = 120 ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms custom: https://www.paypal.com/donate?hosted_button_id=5TJ6LTEVTDF5J ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: gradle directory: "/" schedule: interval: daily open-pull-requests-limit: 5 - package-ecosystem: github-actions directory: "/" schedule: interval: daily open-pull-requests-limit: 5 ================================================ FILE: .github/workflows/analysis.yml ================================================ name: Analysis # Declare default permissions as read only. permissions: read-all on: [push, pull_request] jobs: detekt: name: detekt runs-on: ubuntu-latest if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name steps: - uses: actions/checkout@v6 - name: set up JDK 21 uses: actions/setup-java@v5.2.0 with: distribution: 'temurin' java-version: 21 - name: Run detekt run: bash ./gradlew detekt spotless: name: Spotless (ktlint) runs-on: ubuntu-latest if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name steps: - uses: actions/checkout@v6 - name: set up JDK 21 uses: actions/setup-java@v5.2.0 with: distribution: 'temurin' java-version: 21 - name: Run spotless run: bash ./gradlew spotlessCheck ================================================ FILE: .github/workflows/ci.yml ================================================ name: Android CI on: [push, pull_request] jobs: validation: name: Validate Gradle Wrapper runs-on: ubuntu-latest if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name steps: - uses: actions/checkout@v6 - uses: gradle/actions/wrapper-validation@v6 lint: name: Run Lint Checks runs-on: ubuntu-latest if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name steps: - uses: actions/checkout@v6 - name: set up JDK 21 uses: actions/setup-java@v5.2.0 with: distribution: 'temurin' java-version: 21 - name: Lint run: bash ./gradlew lint test: name: Run Unit Tests runs-on: ubuntu-latest if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name steps: - uses: actions/checkout@v6 - name: set up JDK 21 uses: actions/setup-java@v5.2.0 with: distribution: 'temurin' java-version: 21 - name: Unit tests run: bash ./gradlew test --stacktrace apk: name: Generate APK runs-on: ubuntu-latest if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name steps: - uses: actions/checkout@v6 - name: set up JDK 21 uses: actions/setup-java@v5.2.0 with: distribution: 'temurin' java-version: 21 - name: Build debug APK run: bash ./gradlew assembleDev --stacktrace - name: Upload APK uses: actions/upload-artifact@v7 with: name: app-dev-debug path: News-Android-App/build/outputs/apk/dev/debug/News-Android-App-dev-debug.apk ================================================ FILE: .github/workflows/codeql.yml ================================================ name: CodeQL on: push: branches-ignore: - 'dependabot/**' pull_request: jobs: codeql: name: CodeQL security scan runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v6 - name: set up JDK 21 uses: actions/setup-java@v5.2.0 with: distribution: 'temurin' java-version: 21 - name: Initialize CodeQL uses: github/codeql-action/init@v4 with: languages: java - name: Build debug APK run: bash ./gradlew assembleDev - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v4 ================================================ FILE: .gitignore ================================================ /local.properties /.idea/workspace.xml .DS_Store # built application files *.apk *.ap_ # files for the dex VM *.dex # Java class files *.class # generated files bin/ gen/ # Local configuration file (sdk path, etc) local.properties # Eclipse project files .classpath .project .settings # Android Studio .idea/ .gradle /*/local.properties /*/out /*/*/build build/* /*/*/production *.iml *.iws *.ipr *~ *.swp News-Android-App/out.map News-Android-App/extra/release/* News-Android-App/oss/release/* fastlane/Appfile fastlane/report.xml gradle/verification-keyring.gpg ================================================ FILE: .tx/config ================================================ [main] host = https://www.transifex.com [o:nextcloud:p:nextcloud:r:android-news] file_filter = News-Android-App/src/main/res/values-/strings.xml source_file = News-Android-App/src/main/res/values/strings.xml source_lang = en type = ANDROID lang_map = ar_YE: ar-rYE, es_CL: es-rCL, ii_CN: ii-rCN, lt_LT: lt-rLT, pt_BR: pt-rBR, nso_ZA: nso-rZA, rw_RW: rw-rRW, sr_RS: sr-rRS, bo_CN: bo-rCN, en_MY: en-rMY, tg_TJ: tg-rTJ, de_LI: de-rLI, en_JM: en-rJM, en_NZ: en-rNZ, pa_IN: pa-rIN, tr_TR: tr-rTR, en_DE: en-rDE, en_IN: en-rIN, es_DO: es-rDO, hu_HU: hu-rHU, ml_IN: ml-rIN, fo_FO: fo-rFO, gl_ES: gl-rES, smj_SE: smj-rSE, ar_LB: ar-rLB, es_CO: es-rCO, et_EE: et-rEE, ha_NG: ha-rNG, as_IN: as-rIN, es_MX: es-rMX, iu_CA: iu-rCA, fr_LU: fr-rLU, mt_MT: mt-rMT, quz_PE: quz-rPE, se_NO: se-rNO, ca@valencia: ca-rXV, id_ID: id-rID, mi_NZ: mi-rNZ, uk_UA: uk-rUA, kl_GL: kl-rGL, smn_FI: smn-rFI, ar_AE: ar-rAE, arn_CL: arn-rCL, ka_GE: ka-rGE, ca_ES: ca-rES, es_HN: es-rHN, ar_QA: ar-rQA, gu_IN: gu-rIN, ar_SY: ar-rSY, bg_BG: bg-rBG, da_DK: da-rDK, se_SE: se-rSE, sma_SE: sma-rSE, sr_BA: sr-rBA, ar_EG: ar-rEG, fi_FI: fi-rFI, gd_GB: gd-rGB, sr_ME: sr-rME, en@pirate: en-rXP, lv_LV: lv-rLV, sv_SE: sv-rSE, de_CH: de-rCH, fa_IR: fa-rIR, mn_MN: mn-rMN, sms_FI: sms-rFI, ur_PK: ur-rPK, zh_HK: zh-rHK, tn_ZA: tn-rZA, ar_LY: ar-rLY, fil_PH: fil-rPH, ku_IQ: ku-rIQ, nn_NO: nn-rNO, sah_RU: sah-rRU, ta_LK: ta-rLK, en_ZW: en-rZW, ro_RO: ro-rRO, en_TT: en-rTT, br_FR: br-rFR, es_BO: es-rBO, tk_TM: tk-rTM, es_419: es-rUS, qut_GT: qut-rGT, bn_BD: bn-rBD, en_AU: en-rAU, hsb_DE: hsb-rDE, en_IE: en-rIE, kk_KZ: kk-rKZ, mr_IN: mr-rIN, ar_BH: ar-rBH, ru_RU: ru-rRU, be_BY: be-rBY, es_CR: es-rCR, es_PY: es-rPY, mk_MK: mk-rMK, prs_AF: prs-rAF, it_CH: it-rCH, ps_AF: ps-rAF, ar_DZ: ar-rDZ, hr_HR: hr-rHR, zh_CN.GB2312: zh-rBG, pl_PL: pl-rPL, sr_CS: sr-rCS, vi_VN: vi-rVN, mn_CN: mn-rCN, es_PA: es-rPA, hr_BA: hr-rBA, ja_JP: ja-rJP, en_CA: en-rCA, en_US: en-rUS, he_IL: he-rIL, wo_SN: wo-rSN, sq_AL: sq-rAL, ar_KW: ar-rKW, ar_TN: ar-rTN, de_DE: de-rDE, or_IN: or-rIN, rm_CH: rm-rCH, ba_RU: ba-rRU, en_ZA: en-rZA, es_ES: es-rES, bs_BA: bs-rBA, cy_GB: cy-rGB, en_GB: en-rGB, moh_CA: moh-rCA, ms_MY: ms-rMY, syr_SY: syr-rSY, zh_MO: zh-rMO, dv_MV: dv-rMV, se_FI: se-rFI, it_IT: it-rIT, quz_BO: quz-rBO, de_AT: de-rAT, kok_IN: kok-rIN, fy_NL: fy-rNL, ig_NG: ig-rNG, ko_KR: ko-rKR, fr_MC: fr-rMC, zh_TW: zh-rTW, am_ET: am-rET, ar_OM: ar-rOM, es_VE: es-rVE, oc_FR: oc-rFR, co_FR: co-rFR, nl_NL: nl-rNL, pt_PT: pt-rPT, lb_LU: lb-rLU, ar_JO: ar-rJO, cs_CZ: cs-rCZ, es_PE: es-rPE, es_PR: es-rPR, ga_IE: ga-rIE, tzm_DZ: tzm-rDZ, yo_NG: yo-rNG, hy_AM: hy-rAM, az_AZ: az-rAZ, de_LU: de-rLU, es_GT: es-rGT, nl_BE: nl-rBE, fr_CA: fr-rCA, smj_NO: smj-rNO, en_PH: en-rPH, es_UY: es-rUY, km_KH: km-rKH, sl_SI: sl-rSI, sa_IN: sa-rIN, si_LK: si-rLK, tt_RU: tt-rRU, zh_CN: zh-rCN, af_ZA: af-rZA, en_BZ: en-rBZ, fr_CH: fr-rCH, nb_NO: nb-rNO, sma_NO: sma-rNO, is_IS: is-rIS, kn_IN: kn-rIN, ky_KG: ky-rKG, my_MM: my, sr@latin: sr-rSP, gsw_FR: gsw-rFR, uz_UZ: uz-rUZ, zh_SG: zh-rSG, zu_ZA: zu-rZA, fr_BE: fr-rBE, lo_LA: lo-rLA, ms_BN: ms-rBN, ar_SA: ar-rSA, sw_KE: sw-rKE, ar_MA: ar-rMA, xh_ZA: xh-rZA, en_SG: en-rSG, es_EC: es-rEC, fr_FR: fr-rFR, ug_CN: ug-rCN, el_GR: el-rGR, quz_EC: quz-rEC, sv_FI: sv-rFI, th_TH: th-rTH, ar_IQ: ar-rIQ, es_NI: es-rNI, es_SV: es-rSV, sk_SK: sk-rSK, tl_PH: tl-rPH, bn_IN: bn-rIN, es_AR: es-rAR, hi_IN: hi-rIN, ta_IN: ta-rIN, te_IN: te-rIN, eu_ES: eu-rES, dsb_DE: dsb-rDE, ne_NP: ne-rNP ================================================ FILE: CHANGELOG.md ================================================ 0.9.9.95 --------------------- - Dependency updates - Fixed - !1637 - Fix bug in "Mark all unread items as read" feature (thanks to @Unpublished) 0.9.9.94 --------------------- - Dependency updates - Fixed - !1628 - Make syncing when reaching the bottom optional (thanks to @DoHe) 0.9.9.93 --------------------- - Dependency updates - Fixed - !1612 - Streamline FAB-aware Snackbar usage (thanks to @Unpublished) - Fixed - !1609 - Only mark unread items as read - Fixed - #1620 - Fix broken changelog - Fixed - #1618 - Fixed broken sync when tapping nextcloud logo 0.9.9.92 --------------------- - Dependency updates - Added - !1603 - Automatically trigger sync when bottom is reached - Added - #1367 - Add Pinch to zoom for images (thanks to @DoHe) - Added - !1531 - Show snackbar after batch marking items as read (thanks to @Unpublished) - Fixed - #1590 - Fixed broken widget - Fixed - #1591 - Handle URL encoding when reading mediaThumbnail from body (thanks to @DoHe) - Fixed - #1576 - Import opml do not work at all - Fixed - #1510 - Client sends form data when POSTing instead of JSON - Many smaller changes under the hood 0.9.9.91 --------------------- - Dependency updates - Added - #1490 - Option to Increase Text Size in Detail View for Accessibility - Fixed - #1456 - Bullet points in unsorted item list look all of the same level - Fixed - #1428 - Font size a lot smaller when language set to other than English (superseded by #1490) (@thanks to @cemrich) - Fixed - !1498 - Fix crash when clearing cache 0.9.9.90 --------------------- - Dependency updates - Added - !1273 - Add "remove podcast" in toolbar and "Downloaded podcasts" special folder (thanks to @mkanilsson) - Fixed - !1400 - Code section shows the same listing in some code posts (thanks to @Unpublished) - Fixed - !1410 - Changing theme (light/dark) caused favicons to display in wrong color (thanks to @Unpublished / @DoHe) - Fixed - #1035 - Widget opens wrong article / #1355 Clicking on the widget jumps to a previously opened article (thanks to @Unpublished) - Fixed - #1381 - Crash when opening downloaded podcasts (thanks to @Unpublished) - Fixed - #1368 - Images not shown in Details view of specific feeds - Changed - #1382 - Reduced apk size by 8% (thanks to @connyduck) 0.9.9.85 --------------------- - Dependency updates - Fix crash when playing video podcasts on Android Auto - Add Google News Policy / Contact Us page 0.9.9.84 --------------------- - Dependency updates - Fixed - !1332 - Typo in selected browser preference usage (thanks to @thebaztet) 0.9.9.83 --------------------- - Dependency updates - Fixed - !1315 - Fixed crash when reporting errors (API 33 and below) (thanks to @Unpublished) - Fixed - !1311 - Fixed flickering of toolbar (thanks to @cemrich) - Fixed - !1306 - Fix avatar placeholder renders white on white in light theme (thanks to @cemrich) - Fixed - !1303 - Fix app crashing when streaming or downloading podcasts (thanks to @cemrich) - Fixed - !1304 - Back button preference and functionality to open sidebar (thanks to @mentalinc) - Changed - !1307 - Enable androids autofill service in login mask (thanks to @cemrich) 0.9.9.82 --------------------- - Note: Due to changes to the database schema the data will be cleared when upgrading - Note: What a release! Thank you for the endless contributions from the community! - Added - !1262 - Add shortcut to "Show only unread articles" in the toolbar (thanks to @mkanilsson) - Added - !1264 - Add share to swipe option (thanks to @mkanilsson) - Added - !1266 - per-feed open-in settings (thanks to @mkanilsson) - Added - !1265 - Allow searching both title and body at the same time (thanks to @mkanilsson) - Added - !1271 - Enable support for predictive back gestures (thanks to @KingOfDog) - Added - !1286 - Show changelog after update of app - Changed - !1256 - Fix starred items not obeying sort order (thanks to @mkanilsson) - Changed - !1186 - Material 3 Theme (thanks to @stefan-niedermann) - Changed - Dependency updates - Fixed - !1255 - add line-height for h1 tags (thanks to @mkanilsson) - Fixed - !1257 - Seeking in podcast player doesn't work (thanks to @annasoin) - Fixed - !1272 - Fix weird formatting in articles (thanks to @mkanilsson) - Fixed - #1276 - active toggles lack color 0.9.9.81 --------------------- - Changed - Updated SSO lib 0.9.9.80 --------------------- - Changed - Internal dependency updates - Changed - !1212 - Nextcloud Single-Sign-On updates - Changed - !1200 - Bail out early on generating unread rss items notifications (thanks to @Unpublished) - Changed - !1199 - Housekeeping / Remove unused classes (thanks to @Unpublished) - Changed - !1195 - Migrate some classes to Kotlin (thanks to @Unpublished) - Fixed - !1214 - Text formatting is a bit weird 0.9.9.79 --------------------- - Changed - Internal dependency updates - Changed - !1171 - Allow selecting feed URL in options dialog (thanks to @Unpublished) - Fixed - !1187 - Fix crash related when trying to move a feed (thanks to @Unpublished) - Fixed - !1184 - Prevent podcast view from showing up on every app start (thanks to @Unpublished) 0.9.9.78 --------------------- - Fixed - !1134 - Fix broken Notifications on Android 13 (thanks to @Unpublished) 0.9.9.77 --------------------- - Fixed - #1111 - Fix incorrect height of listview rows - Changed - !1115 - Switched from Universal-Image-Loader to Glide as image loading library - Added - Added support for SVG favicons - Added - !1130 - Added support for external media players (thanks to @JFronny) 0.9.9.76 --------------------- - Security related fixes (only F-Droid users affected): [#1109](https://github.com/nextcloud/news-android/issues/1109) / [fdroid/fdroiddata#2753](https://gitlab.com/fdroid/fdroiddata/-/issues/2753) 0.9.9.75 --------------------- - Fixed crash when relative links in articles are clicked - Support Material You Theming with App Icon (thanks to @salixor) 0.9.9.74 --------------------- - Fixed incompatibility issues with Nextcloud News 18.1.0 0.9.9.73 (Beta) --------------------- - Fixed - #1061 App sometimes crashes on long tap on detail view - Fixed database crashed by reducing the number of loaded items per page - Fixed crash when long tapping folders in navigation drawer - Fixed app crash when ui updates - Fixed crashes caused by swiping on articles in list view 0.9.9.72 (Beta) --------------------- - Added - !1066 Support for Folder Management (Rename, Remove, Create) (thanks @proninyaroslav) - Fixed - #1075 Feed name update not updating in RSS items (thanks @proninyaroslav) - Fixed - #1064 Add button to exit audio/podcast player once it's open - Fixed - #1048 Fix broken podcast time scrolling - Fixed - #1053 Podcast player disappears after rotating device 0.9.9.71 (Beta) --------------------- - Added - #1060 Always show incognito mode icon if incognito mode is enabled 0.9.9.70 (Beta) --------------------- - Fixed - Try to fix more app crashes during sync (reduce number of items per sync) - Fixed - Speedup detail view by not storing instance state of webview 0.9.9.69 --------------------- - Fixed - #1055 App crashes during sync (OutOfMemory Error) 0.9.9.68 (Beta) --------------------- - Fixed - #1012 Loadingbar is visible even though page is done loading - Fixed - #1029 Unread list does not actualize after manual update (Only when using legacy login) - Fixed - #1046 "No notification" setting still generates notifications in separate notification channel - Fixed - Fix missing images if webview has been restored (e.g. after app has been in background) - Fixed - News App is broken after restoring it from a backup (when using SSO) 0.9.9.67 (Beta) --------------------- - Fixed - #1044 Colors/Theme sometimes not applied - Fixed - #1042 Relative image links/URLs don't open correctly - Fixed - #1039 SSO not working with Beta Version of Files App 0.9.9.66 (Beta) --------------------- - Fixed - #1036 Fixed crashes on Android 12 devices (#1032 / #1037) 0.9.9.65 (Beta) --------------------- - Fixed - Fix broken sync due to incompatibility between latest nextcloud files app and Single Sign On Library 0.9.9.64 (Beta) --------------------- - Fixed - #1006 Refactor and fix sync issues - Changed - Improve OPML import dialog - Changed - Increase the soft limit of articles in the app from 1500 to 5000 articles 0.9.9.63 (Beta) --------------------- - Added - #1002 support for more granular notification settings - Changed - added file extension to downloaded/exported images - Changed - allow clicks on notification after an image has been saved/downloaded from detail view - Fixed - #1018 Item state sync is not working correctly when many items have been changed - Fix security issue GHSL-2021-1033 (Thanks to GitHub Security Lab - with special thanks to Tony Torralba and Kevin Backhouse) 0.9.9.62 --------------------- - Changed - #824 Enhance empty content view (thanks Stefan) - Changed - #976 Sync Interval - settings menu is now a popup (thanks @fabienli) - Changed - #974 only show notification if it is different to the previous unread articles list 0.9.9.61 --------------------- - Changed - #969 Remove unnecessary Notifications setting - Changed - #968 Rename "Light/Dark (based on Daytime)" to "System Default" - Changed - #960 Make articles respect default system font - Fixed - #964 Crash when using card layout 0.9.9.60 --------------------- - Changed - Major Design Update thanks to @stefan-niedermann! - Changed - #944 Drop dark mode based on location - Fixed - #958 OPML Export Dialog is now translated - Fixed - #945 New Thubmnails list layout does not show favorite status - Fixed - #938 Everincreasing Fontsize with Fontsize setting "Big" 0.9.9.54 --------------------- - Fixed - #918 Poor scroll performance for some feeds - Fixed - #903 Bottom part of article not visible because of action icons - Changed - #929 Widget should respect dark / light theme - Changed - Auto-Sync is enabled by default now (every 15min) - Changed - Minor adjustments to UI (including new default list layout) - Changed - New list layout in the app - Changed - Widget redesign 0.9.9.53 --------------------- - Version bump for another Google review 0.9.9.52 --------------------- - Version bump for another Google review 0.9.9.51 --------------------- - Version bump for another Google review 0.9.9.50 --------------------- - Bug fix - #880 Starred items were not synchronized in certain situations - Bug fix - #889 Fast Access Functions activated on startup but settings deactivated - Bug fix - #892 Refresh unread items view after update - Bug fix - #403 Problem with syncing "old" favorites - Changed - #896 Change User-Info API 0.9.9.41 --------------------- - Bug fix - #887 Fix crashes due to huge rss items - Bug fix - #878 Floating menu setting had no effect / Show either the floating or the kebab menu - Feature - #754 Add more meaningful notifications - Feature - !885 Enable auto sync by default (every 24h) 0.9.9.40 --------------------- - Bug fix - #344 Starred items are not synced correctly - Feature - !881 When enabled, also use custom tabs when skipping detailed view 0.9.9.39 --------------------- - Google refused update 0.9.9.38 --------------------- - Feature - #868 - Add thumbnail support (media) articles - Feature Removal: Double-Tap-To-Star in detail view - Fix app crashes 0.9.9.37 --------------------- - New feature: Fast actions (Huge thanks to @emasty) 0.9.9.36 --------------------- - Reduce min-newsApi level to 17 0.9.9.35 --------------------- - Fix Single-Sign On related Issues - Bug fix - #769 - Nextcloud API not responding - Bug fix - #830 - Only ask for Location permission if auto theme enabled - Bug fix - #786 - Error loading xml resource in Android 4.4 - Bug fix - #833 - fix tables are too wide - Bug fix - #821 - "Add feed"-icon is misaligned - Bug fix - #821 - Text in drawer not bold, except of "add new feed" and "settings" (thanks @tobiasKaminsky) 0.9.9.34 / 0.9.9.33 --------------------- - Fix F-Droid build issues 0.9.9.32 --------------------- - Fix bug that items containing "image/jpeg" as enclosure, are interpreted as podcasts - Fix app crash when changing server settings - Improve opml import / export 0.9.9.31 --------------------- - Feature - #787 - Display profile avatar in the sidenav - Feature - #788 - Move settings menu to sidenav as last entry (thanks @emasty) - Feature - #789 - Add a new feed should be in sidenav (thanks @emasty) - Feature - #804 - Support Android 10 System DayNight Modes (thanks @wbrawner) - Feature - #811 - Android Auto Support (including Voice Control) - Feature - #810 - Automatically add debug information when reporting github issue through the app - Bug fix - #807 - Fixed open article in browser call (thanks @emasty) - Bug fix - #806 - Update app icon background layer (thanks @stefan-niedermann) 0.9.9.28 / 0.9.9.29 / 0.9.9.30 --------------------- - Retry rejected review by google due to new android auto support - Fix - #795 Adjust app icon to match new regulations - Feature - #799 - Added podcast browser for Android Auto App - Feature - #798 better display of code blocks - Feature - #791 Implement incognito mode 0.9.9.27 --------------------- - Fix - #795 Adjust app icon to match new regulations - Fix - Fix validation of urls during manual account setup 0.9.9.26 --------------------- - Fix - #726 Add new feed fails - Fix - #744 Fix issues when adding feeds (thanks @Unpublished) - Feature - #747 Add option to share article when using chrome-custom-tabs - Fix - Reset database when account is stored - Fix - Workaround for app-crashes due to widget problems - Feature - Support for Android Auto (Podcast playback) - Feature - Use picture-in-picture mode for video podcasts - Fix - Fix restarts of app due to a bug in android compat library (when using dark mode) 0.9.9.25 --------------------- - Fix - app crashes 0.9.9.24 --------------------- - Fix - app crashes 0.9.9.23 --------------------- - Fix - app crashes - Feature - #717 Launch a synchronization when switching from this app to another 0.9.9.22 --------------------- - Fix - app crash during startup - Fix - app crash during sync 0.9.9.21 --------------------- - Fix - #713 App hangs during sync - Fix - Sync on startup not working in some cases - UI Improvement - Improve first app start experience 0.9.9.20 --------------------- - Fix - #702 Widget not updating - Fix - #698 Black font color on all layouts - Fix - #696 Background sync (automatic sync) broken - Fix - #693 Fix background color in Settings (Thank you @AnotherDaniel) - Fix - #683 OLED bg is lost on orientation change in auto NightMode (Thank you @AnotherDaniel) - Fix - #681 Make audio podcasts controllable by Bluetooth media controls - Fix - #678 Settings section/page header text is black, also in dark theme - UI Improvement - #704 Move About/Changelog to Settings (Thank you @AnotherDaniel) - UI Improvement - #690 Settings - Disable oled setting if Light theme is selected (Thank you @AnotherDaniel) - UI Improvement - #688 Settings - Add missing section header of first/general settings category (Thank you @AnotherDaniel) - UI Improvement - #680 Add option to only show headlines - UI Improvement - #677 New feed items look read 0.9.9.19 --------------------- - Feature - #661 NightMode (Thank you @AnotherDaniel) - Feature - #658 Several UI refactorings - Feature - #585 Thumbnail support - Feature - #467 Adjustable Font Size (Thank you @AnotherDaniel) - Feature - #596 Download articles to view them offline - Fix - #664 Order of folders is confusing (Thank you @AnotherDaniel) - Fix - #633 swipe upwards to mark as read - Fix - #667 Beeping while podcast playing/downloading 0.9.9.18 (Google Play) --------------------- - Improve - #651 Automatic reload of rss item list when empty - Improve - #657 Rename app from "Nextcloud News" to "News" - Feature - #478 Add Search Functionality (Thank you @NilsGriebner) - Feature - #644 Move feed (Thank you @NilsGriebner) - Improve first sign-on experience - Several UI improvements - Single Sign On (first official beta!) 0.9.9.17 (Google Play) --------------------- - Fix - #630 Improve unread rss item count notification (#645) - Fix - #646 support for animated gifs on android 8.1 - Fix - Login issues when using passwords with special characters - Add Single Sign On (requires a non published version of the Nextcloud files app) 0.9.9.16 (Google Play) --------------------- - Massive improvements to the rss-item-list scrolling performance - Fix - #632 App crash when downloading single image - Fix - #629 Error when trying to fetch more items - Add support for Android 8+ Notifications - Added widget preview - thank you @stefan-niedermann - Added roundIcon for API level 25 - thank you @stefan-niedermann - Added adaptive icon and adjusted splash screen - thank you @stefan-niedermann 0.9.9.15 (Google Play) --------------------- - Fix app crashes due to missing translations - Fix - #622 Sync fails (showrss.info feeds) - Fix - #618 No white theme in 0.9.9.13 fdroid - Fix - #564 Feed icons don't appear to be caching - Fix - #557 Reloading Icon barely visible - Add option to change podcast playback speed (thanks @jwaghetti) 0.9.9.14 (Google Play) --------------------- - Add more translations to reduce number of app crashes - Fix - #617 Strange behaviour when marking read with scroll - Fix - #616 Improve usability - Blue links with dark OLED theme 0.9.9.13 (Google Play) --------------------- - Optimization - #614 Black background color for OLED screens (New Theme) - Fix - #612 Impossible to delete or edit RSS feeds - Fix - #607 Workaround for SSL Handshake failed on Android 7.0 (thank you @svenschn) - Fix - #606 App crash when opening "Settings" on Android 4.2 - Fix - #591 Add option to load links in feeds in external browser - Fix app crash on android 8 0.9.9.12 (Google Play) --------------------- - Several bug fixes - Fix - #602 crash when trying to open settings 0.9.9.11 (Google Play) --------------------- - Fix app crashes on Android 8+ - Fix - #571 Move updates/changes to a separate CHANGES/CHANGELOG file 0.9.9.10 (Google Play) --------------------- - Several bug fixes - Add support for cardview - Optimization - #590 Improve "mark as read while scrolling" feature - Optimization - #591 Load links of feeds in external browser 0.9.9.9 (Google Play) --------------------- - Fix several app crashes - Fix several widget issues - Bug fix - #583 App crashes on Android 8 - Bug fix - #579 App crash due to invalid drawable tag vector - Optimization - #575 Widget unusable on dark background - Bug fix - #587 App crashes 0.9.9.8 (Google Play) --------------------- - Fix several app crashes - Use flavors (for proprietary newsApi calls) 0.9.9.7 (Google Play) --------------------- - Fix several app crashes 0.9.9.6 (Google Play) --------------------- - Rewrite of sync backend (use Retrofit, Dagger, OkHttp) - Fix app crash (when using self signed ssl certificates) - Several other fixes and improvements 0.9.9.5 (Google Play) --------------------- - Bug fix - #559 Sync is slow 0.9.9.4 (Google Play) --------------------- - Feature - #549 Native YouTube video support - Bug fix - #497 Starts playing podcast when headphones are removed - Bug fix - #546 Share button has wrong color - Bug fix - #540 Dialog disappears on device rotation - Bug fix - #532 Graphical bug in landscape mode 10.1" - Optimization - #538 Display total number of new items instead of last fetched in the notification - Optimization - #537 display news title instead of "unread articles" - UI-Update - #542 Nextcloud Theme - Thank you @stefan-niedermann 0.9.9.3 (Google Play) --------------------- - Critical bug fix - #539 Can not sync with Nextcloud 11 beta 1 - Bug fix - #511 Prevent preload of videos 0.9.9.2 (Google Play) --------------------- - Partial bug fix - #532 Graphical bug in landscape mode 10.1" - Bug fix - #530 App crashes when trying to launch "Settings" 0.9.9.1 (Google Play) --------------------- - Bug fix - #531 Design issues with longclick-dialogs 0.9.9.0 (Google Play) --------------------- - Improvement - Better error handling if API returns wrong version code - Feature - Add Splash Screen - Several Bug fixes and improvements 0.9.8.7 (Google Play) --------------------- - Fix app crash - #519 New versions force quit on CM11 0.9.8.6 (Google Play) --------------------- - Fix app crash - #519 New versions force quit on CM11 0.9.8.5 (Google Play) --------------------- - Critical bug fix - #518 Bug in 0.9.8.3: Using the app caused marking all articles as read and starred articles are lost 0.9.8.4 --------------------- - Critical bug fix - #518 Bug in 0.9.8.3: Using the app caused marking all articles as read and starred articles are lost 0.9.8.3 --------------------- - Bug fix - #502 App crash when scrolling on empty list - Bug fix - #509 Sharing links duplicates titles - Bug fix - #493 Server and Username disappears - Bug fix - #498 Server and Username disappears - Improvement - #515 Increase the padding for article content - Feature - #513 Deduplicate articles - Feature - add support for video/mp4 podcasts - Several Bug fixes and improvements 0.9.8.2 (Google Play) --------------------- - Critical bug fix - #492 App crashes on start 0.9.8.1 (Google Play) --------------------- - Bug fix - #487 App crashes when entering "Settings" on Android 4.4.4 - Security fix - #489 rfc: disable password check 0.9.8 (Google Play) --------------------- - Bug fix - #435 Audio podcasts: icon disappears in detailed view - Bug fix - #396 podcasts stop playing, maybe high memory usage - Bug fix - #463 "Download images" stops after a few images - Bug fix - #480 Show ALT text (and TITLE) in image long-click menu - Bug fix - #481 Error with special characters in the title of feed - Improvement - #479 Add a button to share article - Improvement - #445 Audio Podcast: Download progress - Improvement - #434 Audio Podcast: use androids media control elements - Improvement - #436 Audio podcast: highlight them in detailed view - Improvement - #485 Rename to "ownCloud News" - Performance improvement - Several Bug fixes and improvements 0.9.7.6 (Google Play) --------------------- - Bug fix - #349 Widget always empty - Improvement - #472 Starring is broken (add swipe to star again) - Improvement - #146 add context menu on pressing long on an item - Improvement - #239 Add support for OPML files import/export - Improvement - #343 Mark as read only when scrolling past article - Improvement - #374 unread badge - teslaunread-newsApi - Security improvement (Prevent XSS) 0.9.7.5 (Google Play) --------------------- - Bug fix 0.9.7.4 (Google Play) --------------------- - Improvement - #474 New Feature: Rename and remove feeds - Improvement - #465 Support for right-to-left languages - Improvement - #456 Download-Directory-Chooser for images in webview - Bug fix - #466 Articles are displayed in desktop view 0.9.7.3 (Google Play) --------------------- - Improvement - #431 Avoid volume change at beginning and end of feed - Improvement - #430 Switched collapse folder icons - Improvement - #438 context menu "save image" in detail view - Bug fix - #410 Latest release on google play does not sync 0.9.7.2 (Google Play) --------------------- - Add profile picture support - Bug fixes 0.9.7.1 (Google Play) --------------------- - Improvement - #359 Feed view with full article content - UI-Tweaks - #377 read and star slide - Add ShowcaseView 0.9.7 (Google Play) --------------------- - Bug fix - #393 Login button might get cropped / completely hidden - Bug fix - #407 Auto reload news after sync - Bug fixes 0.9.6.3 (Google Play) --------------------- - Bug fix - #399 Missing scroll indicator in article list - Bug fix - #401 "Open in browser" not using default browser 0.9.6.2 (Google Play) --------------------- - Bug fix - #394 back button doesn't work correct 0.9.6.1 (Google Play) --------------------- - Bug fix - #381 Back button doesn't work correct in articles - Bug fix - #392 Wrong article is shown 0.9.6 (Google Play) --------------------- - Performance improvements - Bug fixes 0.9.5.4 (Google Play) --------------------- - Bug fix - #388 App crash when opening an article - Bug fix - #386 News always open in external browser 0.9.5.3 (Google Play) --------------------- - Bug fix - #383 Back button doesn't close the app on tablets 0.9.5.2 (Google Play) --------------------- - Improvement - #376 Design improvements 0.9.5.1 (Google Play) --------------------- - Bug fix - #371 App crash since 0.9.4 at startup 0.9.5 (Google Play) --------------------- - UI-Redesign (special thanks to Daniel Schaal) - Bug fix - #367 Widget non-functional, crashes frequently - Bug fix - #366 "Sync on startup" option does not sync on startup only - Bug fixes 0.9.4 (Google Play - Beta) --------------------- - Improvement - #363 Add support for Chrome Custom Tabs - Improvement - #361 Pause podcast when receiving call - Improvement - #362 Redesign of the login dialog (special thanks to Daniel Schaal) - Bug fix - #360 Fix app crash - Bug fix - #364 App crash on Android < 4.1 - Bug fix - #258 login fails with long passwords - Bug fixes 0.9.3 (Google Play) --------------------- - Several UI-Improvements (special thanks to Daniel Schaal) 0.9.2 (Google Play) --------------------- - Improvement - #350 option to set lines-count of title - Improvement - #318 Image in advanced News item - Bug fix - #342 Low contrast checkboxes in settings (pre Lollipop) 0.9.1 (Google Play) --------------------- - Improvement - #343 Mark as read only when scrolling past article - Improvement - #345 Pause support for podcast streams - Bug fix - #348 Notification icon in Android 5.0 is just a white square 0.9.0 (Google Play) --------------------- - Bug fix - #339 Does not remember position in article listing - Improvement - #338 Allow App to be installed on SD-Card 0.8.9.5 (Google Play - Beta) --------------------- - Bug fix - #334 Display error since 0.8.6 - white screen after swiping to next article 0.8.8 (Google Play - Beta) --------------------- - Improvement - #329 "Mark all as read" freezes UI for long lists. - Bug fix - #333 Crash when opening Settings on Android 2.3 - Bug fix - #332 [0.8.7] Crash when opening article after mark newer item as read 0.8.7 (Google Play - Beta) --------------------- - Bug fix - #331 Bug in 0.8.6: App crashs by opening an article in external browser 0.8.6 (Google Play - Beta) --------------------- - Bug fix - #303 List Item opens wrong Article (off by one) - Bug fix - #308 Allow feeds being checked as "keep unread" instead of "read" when "mark as read while scrolling" feature is used - Bug fix - #324 when I read the last unread news the news are not marked as read - Bug fix - #327 Scrolling with volume keys produces sound... (Thanks @cemrich) - Bug fix - #321 Disabled options menu item not greyed out in actionbar (Thanks @cemrich) - Bug fixes 0.8.5 (Google Play - Beta) --------------------- - Bug fix - #311 Android 5.0.2 performance - Bug fixes 0.8.4 (Google Play) --------------------- - PLEASE NOTE: This update deletes all your un-synchronized changes. After updating you'll need to perform a manual sync. - Improvement - #288 Text to Speech (TTS) - Bug fix - #307 App crash when favicon has a height or width of 0px - Improvement - Show dialog to share a link/open in browser on long clicking a link in the detail-view - Improve performance 0.8.3 (Google Play) --------------------- - Bug fix - #301 App crashes while adding feed - Bug fix - #296 App crashes when cache is full - Bug fix - #295 Images included from relative URLs are not loaded 0.8.2 (Google Play) --------------------- - Bug fix - #292 0.8.1: Can't save sync interval - Improvement - #282 Launch by default: *rss.xml - subscribe 0.8.1 (Google Play) --------------------- - Bug fix - #291 0.8.0: App crashes when adding new feed - Bug fix - #290 0.8.0: Three dot menu in light theme wrong colors (black font on dark gray background) - Bug fix - #289 0.8.0: Widget isn't working anymore - Bug fixes/Improvements 0.8.0 (Google Play) --------------------- - Material Design - Bug fix - #236 Read items in Android News Client are not synced to server - Bug fixes/Improvements 0.7.7 (Google Play) --------------------- - Bug fix - #278 App broken on latest News release (4.0.1) - Improvement - #277 Blockquote not correctly rendered - Improvement - #272 Too much loading since v0.7.x - Bug fixes/Improvements 0.7.6 (Google Play) --------------------- - Bug fixes 0.7.5 (Google Play) --------------------- - Bug fix - #269 App won't start 0.7.4 (Google Play) --------------------- - Update podcast feature - Fix podcast video view position - Bug fix - #256 3.001: Expected BEGIN_ARRAY but was Number at line 1 column 19 - Bug fix - #262 Deleting a feed on the server does not delete it on the client - Bug fix - #257 Share "Title - url" via twitter - Lot of bug fixes 0.7.3 (Google Play - Beta) --------------------- - Update podcast feature (Add option to download podcast) 0.7.2 (Google Play - Beta) --------------------- - Improvement - #252 "Open in Browser" should open current page. - New feature - #182 »Read« checkbox in widget - Move "Sync Settings" option from Actionbar to Settings - Bug fix - #212 sort order of starred items - Improvement - #124 Add download image options: Over WiFi only, Over WiFi and Mobil or ask when not connected to WiFi - Bug fixes - Improve podcast layout 0.7.1 (Google Play) --------------------- - Layout improvements - Performance improvements - Bug fix - #244 app crashs if screen rotate 0.7.0 (Google Play - Beta) --------------------- - Layout improvements - Bug fixes 0.6.9.9 (Google Play - Beta) --------------------- - Bug fix - #245 clicking on feeds under starred items gives weird result - Lot of bug fixes 0.6.9.8 (Google Play - Beta) --------------------- - Bug fix - #243 Readed items are not synced to owncloud - Bug fix - #242 Starred items aren't counted - Bug fix - #241 Feeds without unread items are shown 0.6.9.7 (Google Play - Beta) --------------------- - Rewrite backend - **IMPORTANT** All your data will be deleted. You'll have to make a full-sync after the update. - Lot of bug fixes/improvements - Performance improvements - Add sorting podcasts by pub-date (descending) - Add showcase view (API 11+) 0.6.9.6 (Google Play) --------------------- - Fix app crash on devices with Android 2.2 - 2.3.* - Small layout improvements (podcast view) - Automatically restart app after podcast view has been enabled/disabled (or app theme changed) - Start podcasts from the item detail view 0.6.9.5 (Google Play - Beta) --------------------- - Add option to delete downloaded podcasts - Bug fixes 0.6.9.4 (Google Play - Beta) --------------------- - Add Podcast download support - Add Video Podcast Support - Youtube playlists are supported (Subscribe to a YouTube playlist using RSS) - Fix app crash - Other fixes and improvements 0.6.9.3 (Google Play - Beta) --------------------- - Accept ogg podcasts - Improve layout of podcast player 0.6.9.2 (Google Play - Beta) --------------------- - Bug fix - #234 Favorite items don't count in total item count - Accept mpeg podcasts (only mp3) 0.6.9.1 (Google Play - Beta) --------------------- - Add notifications for Podcasts - Fix app crash (tablets) - Add option to disable podcast support - Add podcast view to item detail view - Bug fix - #231 App 0.6.7 crashs on start or after closing 0.6.9 (Google Play - Beta) --------------------- - Add Podcast support (early preview) - Bug fixes 0.6.8 (Google Play - Beta) --------------------- - Bug fix - #232 Sync of already read items creates duplicate items - Bug fix - #230 Leaving space after ownCloud address in the Login dialog produces an error 0.6.7 (Google Play - Beta) --------------------- - Improvement - #226 Poor sync performance under high count of unread articles - Bug fix - #227 Images appears in android gallery apps 0.6.6 (Google Play) --------------------- - Bug fix - #223 All unread article counts are 0 - Bug fix - #220 Wrong display of unread items 0.6.5 (Google Play - Beta) --------------------- - Bug fix - #223 All unread article counts are 0 0.6.4 (Google Play - Beta) --------------------- - Improvement - Improved feed list scroll performance - Improvement - Fixed that the list was blocked while updating the unread count 0.6.3 (Google Play - Beta) --------------------- - Feature - Import Accounts from other ownCloud Apps 0.6.2 (Google Play) --------------------- - Bug fix - #219 No feed icons in list overview (0.6.1) 0.6.1 (Google Play) --------------------- - Bug fix - #216 Bug in list overview in 0.6.0 0.6.0 (Google Play - Beta) --------------------- - Performance improvements - Layout improvement - Fix critical app crash when leaving the add new activity - Improvement - #199 Change App-Logo/Icon - Improvement (better performance now) - #154 Scrolling feed list is slow - Improvement - #215 Adjust Colors of light and dark view to the web-interface 0.5.9 (Google Play - Beta) --------------------- - Extreme performance improvements - Several bug fixes - Layout improvement - New feature - #35 Subscribe to feed with app - Improvement - #208 Summary: gray font on black background - Improvement - #154 Scrolling feed list is slow 0.5.8 (Google Play) --------------------- - Bug fix - #214 Scrolling within article causes unwanted tap on links 0.5.7 (Google Play - Beta) --------------------- - Bug fix - #213 When using the dark theme websites with no background color are unreadable - Improvement - #211 Links within articles - Improvement - #198 enable auto sync configuration 0.5.6 --------------------- - Fixed flickering of the screen when changing Feeds (in dark Theme) - New Pull-To-Refresh Style - Bug fix - #200 Clicking article in widget makes app crash - Bug fix - #196 Stutter with "mark as read while scrolling" turned on - Improvement - #189 Read mouse-over 0.5.5 --------------------- - Improve Changelog View - Bug fix - #186 Missing "clear cache" in the settings (on Tablets) - Improvement - #189 Read mouse-over - Improvement - Fix Layout problems in DetailView - Improvement - #195 Mark as read when opened in browser - Bug fix - #194 favIcons and imgCache show up in Gallery 0.5.4 --------------------- - Improvement - #184 Option to disable notification 0.5.3 --------------------- - Update star/checkbox icons for devices with lower screen size - Update language support - Improvement - #176 Open directly in Browser - Improvement - #179 Widget items not clickable - Improvement - #183 Widget items color stripe position 0.5.2 --------------------- - Improvement - Notification when background sync is enabled and new items are received - Improvement - Fix high CPU-Load in Detail-View - Improvement - Speed up image caching 0.5.0 --------------------- - Improvement - #162 New items available notification pops up when there really aren't - Improvement - #160 Widget font size - Improvement - #161 'Send via' should be removed from sharing 0.4.11 --------------------- - Critical Bug fix - #158 0.4.10 instantly crashes when opening 0.4.10 (unpublished) --------------------- - Improvement - New Changelog Design - Improvement - AppRater Plugin added - Improvement - #155 Feed view isn't refreshed on sync - Improvement - #153 Sidebar should be darker - Bug fixes 0.4.9 --------------------- - Update German Language support - Readded full support of Android 2.2+ (was broken since 0.4.4) - Improvement - In Landscape Mode on Tablets (7inch+) the Feed/Folder pane is always visible. - Improvement - #77 There should be icons for folders and special categories - Improvement - #139 Article list jumps after having article opened - Improvement - #137 Back button shouldn't close app when app displays a specific feed or folder - Improvement - #151 Reload slide pane when open event is triggered - Improvement - #136 Fix that the translated app name is used as the folder name - Improvement - #134 Sidebar - "Loading ..." font color should be brighter - Improvement - #133 Refreshing after adding server data results in unauthorized - Improvement - #57 Background synchronization - Bug fix - #152 Changing sorting direction 0.4.8 --------------------- - Improvement - #140 Open in browser and Share with controls always act on original article - Update Language support 0.4.7 --------------------- - fixed app crash when sync on startup is enabled - faster favIcon pre-caching 0.4.6 --------------------- - Fixed app freeze when sync is finished - Small improvements 0.4.5 (unpublished) --------------------- - Fixed critical app crash after sync finished - Improved security for self signed certificates. Special thanks to Dominik Schürmann (@dschuermann) #130 (Implement MemorizingTrustManager to prevent MitM attacks) - Small bug fixes - Improvement - #128 (»Mark all as read« is sometimes disabled) - Improvement - #125 (Feed list entries flash when unread count changes) - Improvement - #129 (Line height needs to be increased for better readability) 0.4.4 (unpublished) --------------------- - Fixed Security issue - thank you for the hint @davivel #47 (can't connect to my ownCloud) - Fixed issue - #105 (Androids back button does not hide empty feeds) - Fixed issue - #119 ("mark all read" button has some bugs) - Fixed issue - #103 (Favicons not shown) - Fixed issue - #112 (Click on wrong item) - Fixed issue - #115 (Database lock issue) - Improvement - #117 (Rearange the icons in the detail view) - Improvement - #118 (Add author to the new detail view header) - Improvement - #120 (Add coloured line to the feeds view in the average coulour of the favicon) - Improvement - #107 (Keep unread) - Improvement - #60 (Sync from unread items (or any feed view)) - Improvement - #113 (Long press on image to show title text) - Improvement - #108 (Mark as read once post is beyond the screen) - Improvement - #34 (Widget) - Layout improvement - #106 (Option to skip list view) - Layout improvement - #55 (collapsible feeds list to maximize item space on phablets) - Layout improvement - #15 (make column bar between folders and newslist movable) - Layout improvement - #116 (Remove About/Changelog Menu Item from the List Detail View (Second view)) - Improved german translation - #88 (Bad german translation) 0.4.3 --------------------- - Fixed issue #104 (0.4.2 does not sync) - Fix issue that sometimes Exeptions are not shown - Update F-Droid (merge dev with master) - Update Language Support from master branch 0.4.2 --------------------- - critical bug fix that sync was broken #102 (0.4.1 doesn't sync anymore) 0.4.1 --------------------- - Font settings are also applied to the item detail view now - Fix issue that the Button "Download more items" was not working 0.4.0 --------------------- - Fixed app crash when image cache is enabled and the dialog appear which asks the user if download via roaming is allowed. - Fixed app crash reports. - Fixed Issue #96 (Can't sync feeds - using a bad URL) - Improved #95 Make font/font size user selectable - Improved #86 clearing the cache while having read items prevents them from being synced - Implemented Feature #99 Option to change item order new-old/old-new 0.3.9 --------------------- - Support for APIv1 and APIv2. (That means the app on Google Play will be updated, too!) - Small fixes - Improved memory usage while synchronizing - Auto sync of item state every 5 minutes - Changed font style to Roboto Light 0.3.8 --------------------- - Fixed Issue when trying to download more items in "all unread" and "starred items" view. - Added option to set up the maximum cache size. - Fixed app crash on tablets (could crash somtimes since v.0.3.7 when trying to download more items). - Fixed Issue #78 (The cache should be cleared in the background) - Improved feature #84 (Buttons to toggle the folders are hard to hit and not descriptive) - Improved feature #76 (There should me more spacing between feeds and folders) - Speed optimizations in the Folder/Feed Overview - About/Changelog added 0.3.7 --------------------- - Option to mark articles as read while scrolling #14 ("mark as read" on scroll) - Rich list theme layout (WebView) #6 - Fixed issue #46 (Android 3.2.1 crash) - Fixed issue #68 (Special folder "all unread articles" shows all articles) - Fixed issue #69 (Crash when image cache enabled) 0.3.6 --------------------- - Option to scroll through articles with Volume rockers #61 (Use volume rocker to browse through articles) - Option to download old items for feed/folder #63 (Allow dowloading old items) - Light Theme for item view #59 (White Theme doesn't apply to articles) - Image offline caching function asks now if you want to download if you're not connected with wifi - Item detail optimizations 0.3.5 --------------------- - Fixed issue #52 (Folders visible multiple times) - Fixed issue #53 (New items get added at the bottom) - Added default feed favIcon - Theme is now also applied in the settings screen - Implemented #56 (Click on header to open article in browser) 0.3.4 --------------------- - Offline reading (Only when you sync items the marked/starred/unread/unstarred items get synchronized. This save a lot of network traffic - Offline image caching - Login is getting verified when you click sign-in - Strict-Hostname-Verification (Important Security Fix) - Simple or extended list view - Light or dark app Theme - Implemented #29 Mark all Article in one Column as readed - A lot of other new stuff and fixes 0.3.3 --------------------- - Dark/Light App Theme - Feed List Design Simple/Extended - many new languages have been added 0.3.2 --------------------- - Fixed app crash when leaving item detail view. 0.3.1 --------------------- - Polish language support added (thank you for translating Cyryl) - App crash fixed when no item header text is available - Go back in the item view if you press the home button - Added Up Button in detail view as fix for GitHub Issue #13 - Other small fixes 0.3.0 --------------------- - Android 2.2+ Support added - small bugfixes ================================================ FILE: COPYING-AGPL.md ================================================ GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 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 Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are 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. 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. Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. 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 Affero 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. Remote Network Interaction; Use with the GNU General Public License. Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. 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 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 work with which it is combined will remain governed by version 3 of the GNU General Public License. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU Affero 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 Affero 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 Affero 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 Affero 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 Affero 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 Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. 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 AGPL, see . ================================================ FILE: COPYING-README.md ================================================ Files in the ownCloud News Reader Android App Repo are licensed under the Affero General Public License version 3, the text of which can be found in COPYING-AGPL, or any later version of the AGPL, unless otherwise noted. ================================================ FILE: Gemfile ================================================ source "https://rubygems.org" gem "fastlane" ================================================ FILE: NEWS-POLICY.md ================================================ # Nextcloud News Android [News Policy](https://support.google.com/googleplay/android-developer/answer/9935326) The "Nextcloud News" Android-App (in the following referred to as "App") is RSS feed aggregator that allows users to subscribe to and read content from various external news sources and blogs. Please read the following disclaimer carefully as it contains important information about the use of our app. **Nature of Content:** "Nextcloud News" is a platform for aggregating content from a variety of RSS feeds chosen by our users. We do not create, endorse, or control any of the content displayed in the app. The views and opinions expressed in the articles are those of the original publishers and authors and do not necessarily reflect the official policy or position of our App. **Source Transparency:** For each article, we strive to provide clear information about the publisher and author. We believe in transparency and aim to guide users in recognizing the source of the information. **Age of Content:** The content displayed in our app is in real-time from the chosen RSS feeds. Consequently, we cannot guarantee that all content is current or less than three months old. Users are encouraged to check the publication dates and validity of the information in the feeds they subscribe to. **Contact Information:** For any queries or concerns regarding the app, please refer to the ' Contact' section below. **User Responsibility:** As users have the freedom to choose their own RSS feeds, we encourage responsible usage. We advise our users to select reputable and reliable news sources. Our app is not responsible for the accuracy or reliability of any information provided by external sources. **Updates to Disclaimer:** This disclaimer is subject to changes and updates. We will notify users of any significant changes through our app or website. Continued use of the app after such changes will constitute acceptance of the new terms. Thank you for choosing Nextcloud News for your news and information needs. We are dedicated to providing a platform that respects the diversity of content and promotes informed reading. ## Contact - **David Luhmer** - **E-Mail:** david-dev(at)live.de - **Phone:** +49 2222 9770191 ================================================ FILE: News-Android-App/.gitignore ================================================ /build ================================================ FILE: News-Android-App/build.gradle ================================================ plugins { id 'com.android.application' id 'com.google.devtools.ksp' id 'io.gitlab.arturbosch.detekt' version "1.23.8" id "com.diffplug.spotless" version "8.2.1" } android { testOptions.unitTests.includeAndroidResources = true defaultConfig { compileSdk = Integer.parseInt(project.ANDROID_BUILD_SDK_VERSION) minSdkVersion Integer.parseInt(project.ANDROID_BUILD_MIN_SDK_VERSION) targetSdkVersion Integer.parseInt(project.ANDROID_BUILD_TARGET_SDK_VERSION) //testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "de.luhmer.owncloudnewsreader.CustomTestRunner" // The following argument makes the Android Test Orchestrator run its // "pm clear" command after each test invocation. This command ensures // that the app's state is completely cleared between tests. testInstrumentationRunnerArguments clearPackageData: 'true' vectorDrawables.useSupportLibrary = true versionCode 196 versionName "0.9.9.95" } buildFeatures { buildConfig true viewBinding true } testOptions { execution 'ANDROIDX_TEST_ORCHESTRATOR' unitTests.returnDefaultValues = true } compileOptions { coreLibraryDesugaringEnabled true sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } buildTypes { debug { shrinkResources false minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' testProguardFiles 'proguard-test.pro' pseudoLocalesEnabled true } release { shrinkResources true minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' testProguardFiles 'proguard-test.pro' //signingConfig signingConfigs.debug } } flavorDimensions = ["default"] productFlavors { // 100% Open-Source Edition oss { dimension "default" } // Used for continous integration, e.g. to test built .apk-files from pull requests dev { dimension "default" applicationIdSuffix ".dev" } } spotless { kotlin { target 'src/*/java/**/*.kt' ktlint() } } packagingOptions { resources { excludes += ['META-INF/LICENSE.txt', 'META-INF/NOTICE.txt', 'LICENSE.txt', 'META-INF/services/javax.annotation.processing.Processor'] } } // Gradle automatically adds 'android.test.runner' as a dependency. useLibrary 'android.test.runner' useLibrary 'android.test.base' useLibrary 'android.test.mock' lint { abortOnError true checkReleaseBuilds false disable 'MissingTranslation', 'ExtraTranslation', 'MissingQuantity', 'InconsistentArrays', 'TypographyEllipsis' ignoreWarnings true } namespace 'de.luhmer.owncloudnewsreader' } repositories { google() mavenCentral() maven { url "https://jitpack.io" content { includeGroup "com.github.nextcloud" } } maven { url 'https://guardian.github.io/maven/repo-releases' } //needed for com.gu:option:1.3 in Android-DirectoryChooser } final DAGGER_VERSION = '2.59.2' final GLIDE_VERSION = '5.0.5' final ESPRESSO_VERSION = '3.7.0' final OKHTTP_VERSION = '5.3.2' final MOCKITO_VERSION = '5.21.0' final RETROFIT_VERSION = '3.0.0' dependencies { // core android studio module // compile project(':core') // You must install or update the Google Repository through the SDK manager to use this dependency. // The Google Repository (separate from the corresponding library) can be found in the Extras category. // implementation 'com.google.android.gms:play-services:4.2.42' // implementation project(path: ':MaterialShowcaseView:library', configuration: 'default') // implementation project(':Android-SingleSignOn:lib') implementation 'com.github.nextcloud:Android-SingleSignOn:f401ec4' // latest version which uses OKHTTP 5 implementation "androidx.core:core:1.17.0" implementation 'androidx.annotation:annotation:1.9.1' implementation "androidx.appcompat:appcompat:1.7.1" implementation "androidx.preference:preference:1.2.1" implementation 'androidx.core:core-splashscreen:1.2.0' implementation "androidx.media:media:1.7.1" // https://mvnrepository.com/artifact/com.google.android.material/material implementation "com.google.android.material:material:1.13.0" implementation "androidx.palette:palette:1.0.0" implementation "androidx.recyclerview:recyclerview:1.4.0" implementation "androidx.browser:browser:1.9.0" implementation "androidx.cardview:cardview:1.0.0" implementation 'androidx.constraintlayout:constraintlayout:2.2.1' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.2.0' implementation 'com.google.code.gson:gson:2.13.2' implementation "com.github.bumptech.glide:glide:${GLIDE_VERSION}" ksp "com.github.bumptech.glide:ksp:${GLIDE_VERSION}" debugImplementation "com.github.technoir42:glide-debug-indicator:0.9.1" implementation 'com.caverock:androidsvg-aar:1.4' implementation 'com.sothree.slidinguppanel:library:3.4.0' implementation 'org.greenrobot:eventbus:3.3.1' implementation 'de.greenrobot:greendao:2.1.0' implementation ('de.greenrobot:greendao-generator:2.1.0') { exclude group: 'org.freemarker' } // implementation 'org.freemarker:freemarker:2.3.23' // Required for DAO generation // implementation 'org.apache.commons:commons-lang3:3.4' // Required for DAO generation implementation 'com.github.gabrielemariotti.changeloglib:changelog:2.1.0' implementation 'org.jsoup:jsoup:1.22.1' implementation ('net.rdrei.android.dirchooser:library:3.2@aar') { exclude group: 'com.google.auto.value', module: 'auto-value' transitive = true } implementation "com.google.dagger:dagger:${DAGGER_VERSION}" ksp "com.google.dagger:dagger-compiler:${DAGGER_VERSION}" implementation 'io.reactivex.rxjava3:rxandroid:3.0.2' // Because RxAndroid releases are few and far between, it is recommended you also // explicitly depend on RxJava's latest version for bug fixes and new features. implementation 'io.reactivex.rxjava3:rxjava:3.1.12' implementation "com.squareup.retrofit2:adapter-rxjava3:$RETROFIT_VERSION" implementation "com.squareup.retrofit2:retrofit:$RETROFIT_VERSION" implementation "com.squareup.retrofit2:converter-gson:$RETROFIT_VERSION" implementation "com.squareup.okhttp3:okhttp:${OKHTTP_VERSION}" implementation "com.squareup.okhttp3:logging-interceptor:${OKHTTP_VERSION}" coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5' testImplementation 'junit:junit:4.13.2' testImplementation("org.mockito:mockito-core:$MOCKITO_VERSION") { exclude group: 'org.hamcrest' } testImplementation 'com.google.dexmaker:dexmaker:1.2' testImplementation 'com.google.dexmaker:dexmaker-mockito:1.2' testImplementation "com.squareup.okhttp3:mockwebserver:${OKHTTP_VERSION}" // https://mvnrepository.com/artifact/com.squareup.okhttp3/mockwebserver //androidTestImplementation "com.squareup.okhttp3:mockwebserver:${OKHTTP_VERSION}" androidTestImplementation 'tools.fastlane:screengrab:2.1.1' // Add falcon:2.2.0 explicitly, because screengrab:2.1.0 relies on falcon:2.1.1 which is not available on mavenCentral // See https://github.com/fastlane/fastlane/issues/12651#issuecomment-849653404 androidTestImplementation 'com.jraska:falcon:2.2.0' //androidTestImplementation "org.mockito:mockito-core:MOCKITO_VERSION" androidTestImplementation "org.mockito:mockito-android:$MOCKITO_VERSION" //androidTestImplementation 'com.google.dexmaker:dexmaker:1.2' //androidTestImplementation 'com.google.dexmaker:dexmaker-mockito:1.2' testImplementation 'org.robolectric:robolectric:4.16.1' // Core library androidTestImplementation 'androidx.test:core:1.7.0' // AndroidJUnitRunner and JUnit Rules androidTestImplementation 'androidx.test:runner:1.7.0' androidTestImplementation 'androidx.test:rules:1.7.0' // Assertions androidTestImplementation 'androidx.test.ext:junit:1.3.0' androidTestImplementation 'androidx.test.ext:truth:1.7.0' androidTestImplementation 'com.google.truth:truth:1.4.5' // Espresso dependencies androidTestImplementation "androidx.test.espresso:espresso-core:$ESPRESSO_VERSION" androidTestImplementation "androidx.test.espresso:espresso-contrib:$ESPRESSO_VERSION" androidTestImplementation "androidx.test.espresso:espresso-intents:$ESPRESSO_VERSION" androidTestImplementation "androidx.test.espresso:espresso-accessibility:$ESPRESSO_VERSION" androidTestImplementation "androidx.test.espresso:espresso-web:$ESPRESSO_VERSION" androidTestImplementation "androidx.test.espresso.idling:idling-concurrent:$ESPRESSO_VERSION" // https://developer.android.com/training/testing/junit-runner.html#using-android-test-orchestrator androidTestUtil 'androidx.test:orchestrator:1.6.1' // The following Espresso dependency can be either "implementation" // or "androidTestImplementation", depending on whether you want the // dependency to appear on your APK's compile classpath or the test APK // classpath. //androidTestImplementation 'androidx.test.espresso:espresso-idling-resource:3.1.1' } ================================================ FILE: News-Android-App/config/detekt/detekt.yml ================================================ build: maxIssues: 0 weights: # complexity: 2 # LongParameterList: 1 # style: 1 # comments: 1 processors: active: true exclude: # - 'FunctionCountProcessor' # - 'PropertyCountProcessor' # - 'ClassCountProcessor' # - 'PackageCountProcessor' # - 'KtFileCountProcessor' console-reports: active: true exclude: # - 'ProjectStatisticsReport' # - 'ComplexityReport' # - 'NotificationReport' # - 'FindingsReport' # - 'BuildFailureReport' comments: active: true CommentOverPrivateFunction: active: false CommentOverPrivateProperty: active: false EndOfSentenceFormat: active: false endOfSentenceFormat: ([.?!][ \t\n\r\f<])|([.?!]$) UndocumentedPublicClass: active: false searchInNestedClass: true searchInInnerClass: true searchInInnerObject: true searchInInnerInterface: true UndocumentedPublicFunction: active: false complexity: active: true ComplexCondition: active: true threshold: 4 ComplexInterface: active: false threshold: 10 includeStaticDeclarations: false ComplexMethod: active: true threshold: 10 ignoreSingleWhenExpression: false ignoreSimpleWhenEntries: false excludes: ['**/androidTest/**'] LabeledExpression: active: false ignoredLabels: "" LargeClass: active: true threshold: 600 LongMethod: active: true threshold: 60 excludes: ['**/androidTest/**'] LongParameterList: active: true functionThreshold: 6 constructorThreshold: 6 ignoreDefaultParameters: false MethodOverloading: active: false threshold: 6 NestedBlockDepth: active: true threshold: 4 StringLiteralDuplication: active: false threshold: 3 ignoreAnnotation: true excludeStringsWithLessThan5Characters: true ignoreStringsRegex: '$^' TooManyFunctions: active: true thresholdInFiles: 15 thresholdInClasses: 15 thresholdInInterfaces: 15 thresholdInObjects: 15 thresholdInEnums: 11 ignoreDeprecated: true ignorePrivate: false ignoreOverridden: true empty-blocks: active: true EmptyCatchBlock: active: true allowedExceptionNameRegex: "^(_|(ignore|expected).*)" EmptyClassBlock: active: true EmptyDefaultConstructor: active: true EmptyDoWhileBlock: active: true EmptyElseBlock: active: true EmptyFinallyBlock: active: true EmptyForBlock: active: true EmptyFunctionBlock: active: true ignoreOverridden: false EmptyIfBlock: active: true EmptyInitBlock: active: true EmptyKtFile: active: true EmptySecondaryConstructor: active: true EmptyWhenBlock: active: true EmptyWhileBlock: active: true exceptions: active: true ExceptionRaisedInUnexpectedLocation: active: false methodNames: 'toString,hashCode,equals,finalize' InstanceOfCheckForException: active: false NotImplementedDeclaration: active: false PrintStackTrace: active: false RethrowCaughtException: active: false ReturnFromFinally: active: false SwallowedException: active: false ignoredExceptionTypes: 'InterruptedException,NumberFormatException,ParseException,MalformedURLException' ThrowingExceptionFromFinally: active: false ThrowingExceptionInMain: active: false ThrowingExceptionsWithoutMessageOrCause: active: false exceptions: 'IllegalArgumentException,IllegalStateException,IOException' ThrowingNewInstanceOfSameException: active: false TooGenericExceptionCaught: active: true exceptionNames: - ArrayIndexOutOfBoundsException - Error - Exception - IllegalMonitorStateException - NullPointerException - IndexOutOfBoundsException - RuntimeException - Throwable allowedExceptionNameRegex: "^(_|(ignore|expected).*)" TooGenericExceptionThrown: active: true exceptionNames: - Error - Exception - Throwable - RuntimeException naming: active: true ClassNaming: active: true classPattern: '[A-Z$][a-zA-Z0-9$]*' ConstructorParameterNaming: active: true parameterPattern: '[a-z][A-Za-z0-9]*' privateParameterPattern: '[a-z][A-Za-z0-9]*' excludeClassPattern: '$^' EnumNaming: active: true enumEntryPattern: '^[A-Z][_a-zA-Z0-9]*' ForbiddenClassName: active: false forbiddenName: '' FunctionMaxLength: active: false maximumFunctionNameLength: 30 FunctionMinLength: active: false minimumFunctionNameLength: 3 FunctionNaming: active: true functionPattern: '^([a-z$][a-zA-Z$0-9]*)|(`.*`)$' excludeClassPattern: '$^' ignoreOverridden: true excludes: - "**/*Test.kt" - "**/*IT.kt" FunctionParameterNaming: active: true parameterPattern: '[a-z][A-Za-z0-9]*' excludeClassPattern: '$^' ignoreOverridden: true MatchingDeclarationName: active: true MemberNameEqualsClassName: active: false ignoreOverridden: true ObjectPropertyNaming: active: true constantPattern: '[A-Za-z][_A-Za-z0-9]*' propertyPattern: '[A-Za-z][_A-Za-z0-9]*' privatePropertyPattern: '(_)?[A-Za-z][_A-Za-z0-9]*' PackageNaming: active: true packagePattern: '^[a-z]+(\.[a-z][A-Za-z0-9]*)*$' TopLevelPropertyNaming: active: true constantPattern: '[A-Z][_A-Z0-9]*' propertyPattern: '[A-Za-z][_A-Za-z0-9]*' privatePropertyPattern: '(_)?[A-Za-z][A-Za-z0-9]*' VariableMaxLength: active: false maximumVariableNameLength: 64 VariableMinLength: active: false minimumVariableNameLength: 1 VariableNaming: active: true variablePattern: '[a-z][A-Za-z0-9]*' privateVariablePattern: '(_)?[a-z][A-Za-z0-9]*' excludeClassPattern: '$^' ignoreOverridden: true performance: active: true ArrayPrimitive: active: false ForEachOnRange: active: true SpreadOperator: active: true UnnecessaryTemporaryInstantiation: active: true potential-bugs: active: true DuplicateCaseInWhenExpression: active: true EqualsAlwaysReturnsTrueOrFalse: active: false EqualsWithHashCodeExist: active: true ExplicitGarbageCollectionCall: active: true InvalidRange: active: false IteratorHasNextCallsNextMethod: active: false IteratorNotThrowingNoSuchElementException: active: false LateinitUsage: active: false ignoreAnnotated: [] ignoreOnClassesPattern: "" UnconditionalJumpStatementInLoop: active: false UnreachableCode: active: true UnsafeCallOnNullableType: active: false UnsafeCast: active: false UselessPostfixExpression: active: false WrongEqualsTypeParameter: active: false style: active: true CollapsibleIfStatements: active: false DataClassContainsFunctions: active: false conversionFunctionPrefix: 'to' EqualsNullCall: active: false EqualsOnSignatureLine: active: false ExplicitItLambdaParameter: active: false ExpressionBodySyntax: active: false includeLineWrapping: false ForbiddenComment: active: true values: 'TODO:,FIXME:,STOPSHIP:' ForbiddenImport: active: false imports: '' ForbiddenVoid: active: false FunctionOnlyReturningConstant: active: false ignoreOverridableFunction: true excludedFunctions: 'describeContents' LoopWithTooManyJumpStatements: active: false maxJumpCount: 1 MagicNumber: active: true ignoreNumbers: '-1,0,1,2' ignoreHashCodeFunction: true ignorePropertyDeclaration: false ignoreConstantDeclaration: true ignoreCompanionObjectPropertyDeclaration: true ignoreAnnotation: false ignoreNamedArgument: true ignoreEnums: false excludes: - "**/*Test.kt" - "**/*IT.kt" MandatoryBracesIfStatements: active: false MaxLineLength: active: true maxLineLength: 120 excludePackageStatements: true excludeImportStatements: true excludeCommentStatements: false MayBeConst: active: false ModifierOrder: active: true NestedClassesVisibility: active: false NewLineAtEndOfFile: active: true NoTabs: active: false OptionalAbstractKeyword: active: true OptionalUnit: active: false OptionalWhenBraces: active: false PreferToOverPairSyntax: active: false ProtectedMemberInFinalClass: active: false RedundantVisibilityModifierRule: active: false ReturnCount: active: true max: 2 excludedFunctions: "equals" excludeLabeled: false excludeReturnFromLambda: true SafeCast: active: true SerialVersionUIDInSerializableClass: active: false SpacingBetweenPackageAndImports: active: false ThrowsCount: active: true max: 2 TrailingWhitespace: active: false UnderscoresInNumericLiterals: active: false acceptableLength: 5 UnnecessaryAbstractClass: active: false ignoreAnnotated: - "dagger.Module" UnnecessaryApply: active: false UnnecessaryInheritance: active: false UnnecessaryLet: active: false UnnecessaryParentheses: active: false UntilInsteadOfRangeTo: active: false UnusedImports: active: false UnusedPrivateClass: active: false UnusedPrivateMember: active: false allowedNames: "(_|ignored|expected|serialVersionUID)" UseDataClass: active: false ignoreAnnotated: [] UtilityClassWithPublicConstructor: active: false VarCouldBeVal: active: false WildcardImport: active: true excludeImports: 'java.util.*,kotlinx.android.synthetic.*' ================================================ FILE: News-Android-App/proguard-rules.pro ================================================ # Add project specific ProGuard rules here. # By default, the flags in this file are appended to flags specified # in /opt/android-studio/sdk/tools/proguard/proguard-android.txt # You can edit the include path and order by changing the ProGuard # include property in project.properties. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html # Add any project specific keep options here: # 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 *; #} # Required for Test execution -dontwarn org.xmlpull.v1.** -dontwarn org.apache.tools.ant.** -dontwarn java.beans.** -dontwarn javax.naming.** -dontwarn sun.misc.Unsafe # Mockito -dontwarn org.mockito.** -keepnames class * implements java.io.Serializable -keepclassmembers class * implements java.io.Serializable { static final long serialVersionUID; private static final java.io.ObjectStreamField[] serialPersistentFields; !static !transient ; private void writeObject(java.io.ObjectOutputStream); private void readObject(java.io.ObjectInputStream); java.lang.Object writeReplace(); java.lang.Object readResolve(); } # AndroidSlidingUpPanel # https://github.com/umano/AndroidSlidingUpPanel/issues/921 -dontwarn com.sothree.slidinguppanel.SlidingUpPanelLayout # jsoup -dontwarn com.google.re2j.* # Other Libraries -dontwarn org.apache.velocity.** -dontwarn freemarker.** -dontwarn com.google.auto.value.** -dontwarn autovalue.shaded.** #-keep class com.gu.option.Option #-keep class com.gu.option.UnitFunction # keep application classes used as database and network models -keep class de.luhmer.owncloudnewsreader.database.model.** { *; } -keep class de.luhmer.owncloudnewsreader.reader.nextcloud.ItemIds { *; } -keep class de.luhmer.owncloudnewsreader.reader.nextcloud.ItemMap { *; } -keep class de.luhmer.owncloudnewsreader.model.** { *; } # keep the name of SyncItemStateService so SyncItemStateService.isMyServiceRunning works -keepnames class de.luhmer.owncloudnewsreader.services.SyncItemStateService # keep fields necessary for NewsReaderListActivity.adjustEdgeSizeOfDrawer and NewsReaderListActivity.getEdgeSizeOfDrawer to work -keepclassmembers class androidx.drawerlayout.widget.DrawerLayout { private androidx.customview.widget.ViewDragHelper mLeftDragger; } -keepclassmembers class androidx.customview.widget.ViewDragHelper { private int mEdgeSize; } -printmapping out.map -keepattributes SourceFile,LineNumberTable -renamesourcefileattribute SourceFile ############### # GreenDAO -keep class de.greenrobot.** { *; } -dontwarn de.greenrobot.daogenerator.DaoGenerator -keepclassmembers class * extends de.greenrobot.dao.AbstractDao { *; } ############### # Guava (official) ## Not yet defined: follow https://github.com/google/guava/issues/2117 # Guava (unofficial) ## https://github.com/google/guava/issues/2926#issuecomment-325455128 ## https://stackoverflow.com/questions/9120338/proguard-configuration-for-guava-with-obfuscation-and-optimization -dontwarn com.google.common.base.** -dontwarn com.google.errorprone.annotations.** -dontwarn com.google.j2objc.annotations.** -dontwarn java.lang.ClassValue -dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement -dontwarn javax.annotation.** -dontwarn javax.inject.** -dontwarn sun.misc.Unsafe # Added for guava 23.5-android -dontwarn afu.org.checkerframework.** -dontwarn org.checkerframework.** # Required for unit tests # https://stackoverflow.com/a/39777485 # Also, note that this rule should be added to the regular proguard file(the one of listed in proguardFiles) and not the test one(declared as testProguardFile) # java.lang.NoSuchMethodError: No virtual method getParameter -keepclasseswithmembers public class com.nextcloud.android.sso.aidl.NextcloudRequest { *; } -keepclasseswithmembers public class com.nextcloud.android.sso.AccountImporter { *; } # NewsReaderListActivityTests -keepclasseswithmembers public class androidx.recyclerview.widget.RecyclerView { *; } ================================================ FILE: News-Android-App/proguard-test.pro ================================================ # proguard-test.pro: -include proguard-rules.pro -keepattributes SourceFile,LineNumberTable -dontwarn androidx.test.espresso.** ############### # Required for Mockito -keep class retrofit2.NextcloudRetrofitApiBuilder { *; } -keep class net.bytebuddy.* { *; } -dontwarn net.bytebuddy.** -keep class module-info -keepattributes Module* -dontwarn org.mockito.** # Proguard rules that are applied to your test apk/code. -ignorewarnings -keepattributes *Annotation* -dontnote junit.framework.** -dontnote junit.runner.** -dontwarn android.test.** -dontwarn android.support.test.** -dontwarn org.junit.** -dontwarn org.hamcrest.** -dontwarn com.squareup.javawriter.JavaWriter -dontwarn androidx.concurrent.futures.AbstractResolvableFuture #-dontwarn org.conscrypt.Conscrypt #com.google.common.util.concurrent.ListenableFuture #-keep interface okhttp3.internal.platform.ConscryptPlatform #-keep class okhttp3.internal.platform.ConscryptPlatform #-keep class org.conscrypt.Conscrypt { *; } #-keep interface org.conscrypt.Conscrypt { *; } ================================================ FILE: News-Android-App/remove_invalid_languages.sh ================================================ git rm -r src/main/res/values-ach/ git rm -r src/main/res/values-ady/ git rm -r src/main/res/values-nds/ git rm -r src/main/res/values-nqo/ git rm -r src/main/res/values-tzm/ ================================================ FILE: News-Android-App/src/androidTest/AndroidManifest.xml ================================================ ================================================ FILE: News-Android-App/src/androidTest/java/de/luhmer/owncloudnewsreader/CustomTestRunner.java ================================================ package de.luhmer.owncloudnewsreader; import android.app.Application; import android.content.Context; import androidx.test.runner.AndroidJUnitRunner; public class CustomTestRunner extends AndroidJUnitRunner { /* @Override public void onCreate(Bundle arguments) { // The workaround for Mockito issue #922 // https://github.com/mockito/mockito/issues/922 arguments.putString("notPackage", "net.bytebuddy"); super.onCreate(arguments); } */ public Application newApplication(ClassLoader cl, String className, Context context) throws IllegalAccessException, InstantiationException, ClassNotFoundException { return super.newApplication(cl, TestApplication.class.getName(), context); } } ================================================ FILE: News-Android-App/src/androidTest/java/de/luhmer/owncloudnewsreader/TestApplication.java ================================================ package de.luhmer.owncloudnewsreader; import de.luhmer.owncloudnewsreader.di.DaggerTestComponent; import de.luhmer.owncloudnewsreader.di.TestApiModule; public class TestApplication extends NewsReaderApplication { @Override public void initDaggerAppComponent() { // Dagger%COMPONENT_NAME% mAppComponent = DaggerTestComponent.builder() .apiModule(new TestApiModule(this)) .build(); // If a Dagger 2 component does not have any constructor arguments for any of its modules, // then we can use .create() as a shortcut instead: //mAppComponent = DaggerAppComponent.create(); } } ================================================ FILE: News-Android-App/src/androidTest/java/de/luhmer/owncloudnewsreader/di/TestApiModule.java ================================================ package de.luhmer.owncloudnewsreader.di; import android.app.Application; import android.content.Context; import android.content.SharedPreferences; import android.util.Log; import androidx.test.InstrumentationRegistry; import com.nextcloud.android.sso.AccountImporter; import com.nextcloud.android.sso.model.SingleSignOnAccount; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import de.luhmer.owncloudnewsreader.NewsReaderListFragment; import de.luhmer.owncloudnewsreader.SettingsActivity; import de.luhmer.owncloudnewsreader.helper.ThemeChooser; import de.luhmer.owncloudnewsreader.model.OcsUser; import de.luhmer.owncloudnewsreader.ssl.MemorizingTrustManager; public class TestApiModule extends ApiModule { private static final String TAG = TestApiModule.class.getCanonicalName(); private final Application application; public static String DUMMY_ACCOUNT_AccountName = "test-account"; public static String DUMMY_ACCOUNT_username = "david"; public static String DUMMY_ACCOUNT_token = "abc"; public static String DUMMY_ACCOUNT_server_url = "http://nextcloud.com/"; public TestApiModule(Application application) { super(application); this.application = application; } @Override public SharedPreferences providesSharedPreferences() { // Create dummy account String prefKey = "PREF_ACCOUNT_STRING" + DUMMY_ACCOUNT_AccountName; SingleSignOnAccount ssoAccount = new SingleSignOnAccount( DUMMY_ACCOUNT_AccountName, DUMMY_ACCOUNT_username, DUMMY_ACCOUNT_token, DUMMY_ACCOUNT_server_url, "prod" ); OcsUser userInfo = new OcsUser("1", DUMMY_ACCOUNT_username); //SharedPreferences sharedPrefs = new MockSharedPreference(); SharedPreferences sharedPrefs = application.getSharedPreferences(providesSharedPreferencesFileName(), Context.MODE_PRIVATE); // Reset SharedPreferences to make tests reproducible sharedPrefs.edit().clear().commit(); // Turn on Single-Sign-On sharedPrefs.edit().putBoolean(SettingsActivity.SW_USE_SINGLE_SIGN_ON, true).commit(); // Set mock preferences for AccountImporter AccountImporter.setSharedPreferences(sharedPrefs); // Return mock login data when requesting the account try { sharedPrefs.edit().putString(prefKey, SingleSignOnAccount.toString(ssoAccount)).commit(); } catch (IOException e) { throw new Error(e); } // For userinfo in main activity sharedPrefs.edit().putString(SettingsActivity.EDT_OWNCLOUDROOTPATH_STRING, DUMMY_ACCOUNT_server_url).commit(); sharedPrefs.edit().putString(SettingsActivity.EDT_USERNAME_STRING, DUMMY_ACCOUNT_username).commit(); sharedPrefs.edit().putString("PREF_CURRENT_ACCOUNT_STRING", DUMMY_ACCOUNT_AccountName).commit(); try { sharedPrefs.edit().putString("USER_INFO", NewsReaderListFragment.toString(userInfo)).commit(); } catch (IOException e) { throw new Error(e); } ThemeChooser.init(sharedPrefs); return sharedPrefs; } @Override public String providesSharedPreferencesFileName() { return application.getPackageName() + "_preferences_test"; } @Override public String providesDatabaseFileName() { String filename = "OwncloudNewsReaderOrmTest.db"; try { String dst = "/data/data/" + application.getApplicationContext().getPackageName() + "/databases/" + filename; File dstFile = new File(dst); dstFile.getParentFile().mkdirs(); // https://stackoverflow.com/a/35690692 copy(InstrumentationRegistry.getContext().getAssets().open("OwncloudNewsReaderOrm.db"), dstFile); } catch (IOException e) { Log.e(TAG, "Failed copying Test Database", e); } //return PreferenceManager.getDefaultSharedPreferencesName(mApplication); return filename; } @Override protected ApiProvider provideAPI(MemorizingTrustManager mtm, SharedPreferences sp) { ApiProvider apiProvider = new TestApiProvider(mtm, sp, application); return apiProvider; } public static void copy(InputStream in, File dst) throws IOException { try (OutputStream out = new FileOutputStream(dst)) { // Transfer bytes from in to out byte[] buf = new byte[1024]; int len; while ((len = in.read(buf)) > 0) { out.write(buf, 0, len); } } in.close(); } } ================================================ FILE: News-Android-App/src/androidTest/java/de/luhmer/owncloudnewsreader/di/TestApiProvider.java ================================================ package de.luhmer.owncloudnewsreader.di; import static org.mockito.ArgumentMatchers.any; import static de.luhmer.owncloudnewsreader.di.TestApiModule.DUMMY_ACCOUNT_AccountName; import static de.luhmer.owncloudnewsreader.di.TestApiModule.DUMMY_ACCOUNT_username; import android.content.Context; import android.content.SharedPreferences; import android.os.Looper; import android.os.NetworkOnMainThreadException; import android.util.Log; import com.nextcloud.android.sso.aidl.NextcloudRequest; import com.nextcloud.android.sso.api.NetworkRequest; import com.nextcloud.android.sso.api.NextcloudAPI; import com.nextcloud.android.sso.api.Response; import com.nextcloud.android.sso.exceptions.NextcloudHttpRequestFailedException; import org.mockito.Mockito; import java.io.ByteArrayInputStream; import java.io.InputStream; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import de.luhmer.owncloudnewsreader.helper.GsonConfig; import de.luhmer.owncloudnewsreader.reader.nextcloud.NewsAPI; import de.luhmer.owncloudnewsreader.ssl.MemorizingTrustManager; import retrofit2.NextcloudRetrofitApiBuilder; public class TestApiProvider extends ApiProvider { private static final String TAG = TestApiProvider.class.getCanonicalName(); public static final String NEW_FEED_SUCCESS = "http://test.de/new"; public static final String NEW_FEED_EXISTING = "http://test.de/existing"; public static final String NEW_FEED_FAIL = "http://test.de/fail"; private static final String NEW_FEED_EXISTING_ERROR_MESSAGE = "{\"message\":\"Feed konnte nicht hinzugef\\u00fcgt werden: Existiert bereits\"}"; private static final String NEW_FEED_FAIL_ERROR_MESSAGE = "{\"message\":\"FeedIo\\\\Adapter\\\\NotFoundException: Client error: `GET http:\\/\\/feeds2.feedburner.com\\/stadt-bremerhaven\\/dqXM222` resulted in a `404 Feed not found error: FeedBurner cannot locate this feed URI.` response:\\n\\u003Chtml\\u003E\\n\\u003Chead\\u003E\\n\\u003Cstyle type=\\\"text\\/css\\\"\\u003E\\na:link, a:visited {\\n color: #000099;\\n text-decoration: underline;\\n}\\n\\na:hover {\\n (truncated...)\\n in \\/apps2\\/news\\/lib\\/Fetcher\\/Client\\/FeedIoClient.php:57\\nStack trace:\\n#0 \\/apps2\\/news\\/vendor\\/debril\\/feed-io\\/src\\/FeedIo\\/Reader.php(116): OCA\\\\News\\\\Fetcher\\\\Client\\\\FeedIoClient-\\u003EgetResponse('http:\\/\\/feeds2.f...', Object(DateTime))\\n#1 \\/apps2\\/news\\/vendor\\/debril\\/feed-io\\/src\\/FeedIo\\/FeedIo.php(286): FeedIo\\\\Reader-\\u003Eread('http:\\/\\/feeds2.f...', Object(FeedIo\\\\Feed), Object(DateTime))\\n#2 \\/apps2\\/news\\/lib\\/Fetcher\\/FeedFetcher.php(77): FeedIo\\\\FeedIo-\\u003Eread('http:\\/\\/feeds2.f...')\\n#3 \\/apps2\\/news\\/lib\\/Fetcher\\/Fetcher.php(68): OCA\\\\News\\\\Fetcher\\\\FeedFetcher-\\u003Efetch('http:\\/\\/feeds2.f...', true, NULL, NULL, NULL)\\n#4 \\/apps2\\/news\\/lib\\/Service\\/FeedService.php(116): OCA\\\\News\\\\Fetcher\\\\Fetcher-\\u003Efetch('http:\\/\\/feeds2.f...', true, NULL, NULL, NULL)\\n#5 \\/apps2\\/news\\/lib\\/Controller\\/FeedApiController.php(96): OCA\\\\News\\\\Service\\\\FeedService-\\u003Ecreate('http:\\/\\/feeds2.f...', 0, 'david')\\n#6 \\/nextcloud\\/lib\\/private\\/AppFramework\\/Http\\/Dispatcher.php(166): OCA\\\\News\\\\Controller\\\\FeedApiController-\\u003Ecreate('http:\\/\\/feeds2.f...', 0)\\n#7 \\/nextcloud\\/lib\\/private\\/AppFramework\\/Http\\/Dispatcher.php(99): OC\\\\AppFramework\\\\Http\\\\Dispatcher-\\u003EexecuteController(Object(OCA\\\\News\\\\Controller\\\\FeedApiController), 'create')\\n#8 \\/nextcloud\\/lib\\/private\\/AppFramework\\/App.php(118): OC\\\\AppFramework\\\\Http\\\\Dispatcher-\\u003Edispatch(Object(OCA\\\\News\\\\Controller\\\\FeedApiController), 'create')\\n#9 \\/nextcloud\\/lib\\/private\\/AppFramework\\/Routing\\/RouteActionHandler.php(47): OC\\\\AppFramework\\\\App::main('OCA\\\\\\\\News\\\\\\\\Contro...', 'create', Object(OC\\\\AppFramework\\\\DependencyInjection\\\\DIContainer), Array)\\n#10 [internal function]: OC\\\\AppFramework\\\\Routing\\\\RouteActionHandler-\\u003E__invoke(Array)\\n#11 \\/nextcloud\\/lib\\/private\\/Route\\/Router.php(297): call_user_func(Object(OC\\\\AppFramework\\\\Routing\\\\RouteActionHandler), Array)\\n#12 \\/nextcloud\\/lib\\/base.php(987): OC\\\\Route\\\\Router-\\u003Ematch('\\/apps\\/news\\/api\\/...')\\n#13 \\/nextcloud\\/index.php(42): OC::handleRequest()\\n#14 {main}\"}"; public NewsTestNetworkRequest networkRequestSpy; TestApiProvider(MemorizingTrustManager mtm, SharedPreferences sp, Context context) { super(mtm, sp, context); } @Override protected void initSsoApi(final NextcloudAPI.ApiConnectedListener callback) { NewsTestNetworkRequest networkRequest = new NewsTestNetworkRequest(context, callback); networkRequestSpy = Mockito.spy(networkRequest); // By spying on the method "performNetworkRequest" we can later check if requests were build correctly try { Mockito.doCallRealMethod().when(networkRequestSpy).performNetworkRequest(any(), any()); } catch (Exception e) { e.printStackTrace(); } NextcloudAPI nextcloudAPI = new NextcloudAPI(GsonConfig.GetGson(), networkRequestSpy); mNewsApi = new NextcloudRetrofitApiBuilder(nextcloudAPI, NewsAPI.mApiEndpoint).create(NewsAPI.class); } public class NewsTestNetworkRequest extends NetworkRequest { NewsTestNetworkRequest(Context context, NextcloudAPI.ApiConnectedListener callback) { super(null, null, callback); } @Override protected void connect(String type) { super.connect(type); mCallback.onConnected(); } public InputStream performNetworkRequest(NextcloudRequest request, InputStream requestBodyInputStream) throws Exception { if(Looper.myLooper() == Looper.getMainLooper()) { throw new NetworkOnMainThreadException(); } Log.w(TAG, "Requested URL: " + request.getUrl()); InputStream inputStream; switch (request.getUrl()) { case "/index.php/apps/news/api/v1-2/feeds": if("POST".equals(request.getMethod())) { inputStream = handleCreateFeed(request); } else { inputStream = stringToInputStream("{\"feeds\": []}"); } break; case "/index.php/apps/news/api/v1-2/user": inputStream = handleUser(); break; case "/index.php/apps/news/api/v1-2/folders": inputStream = handleFolders(); break; case "/index.php/apps/news/api/v1-2/items": inputStream = stringToInputStream("{\"items\": []}"); break; //case "index.php/apps/news/api/v1-2/feeds": case "/index.php/apps/news/api/v1-2/items/unread/multiple": inputStream = stringToInputStream(""); break; default: Log.e(TAG, request.getUrl()); throw new Error("Not implemented yet!"); } return inputStream; } @Override protected Response performNetworkRequestV2(NextcloudRequest request, InputStream requestBodyInputStream) throws Exception { return new Response( performNetworkRequest(request, requestBodyInputStream), new ArrayList<>(0) ); } private InputStream handleFolders() { String folders = "{\"folders\":[{\"id\":2,\"name\":\"Comic\"},{\"id\":3,\"name\":\"Android\"}]}"; return stringToInputStream(folders); } // https://github.com/nextcloud/news/blob/master/docs/externalapi/Legacy.md#create-a-feed private InputStream handleCreateFeed(NextcloudRequest request) throws NextcloudHttpRequestFailedException { var url = request.getParameterV2().stream().filter((s) -> s.key.equals("url")).findFirst().get().value; switch (url) { case NEW_FEED_SUCCESS: return stringToInputStream(""); case NEW_FEED_EXISTING: throw new NextcloudHttpRequestFailedException(context, 409, new Throwable(NEW_FEED_EXISTING_ERROR_MESSAGE)); case NEW_FEED_FAIL: throw new NextcloudHttpRequestFailedException(context, 422, new Throwable(NEW_FEED_FAIL_ERROR_MESSAGE)); default: throw new Error("Not implemented yet!"); } } private InputStream handleUser() { String user = "{\n" + " \"userId\": \"" + DUMMY_ACCOUNT_AccountName + "\",\n" + " \"displayName\": \"" + DUMMY_ACCOUNT_username + "\",\n" + " \"lastLoginTimestamp\": 1241231233, \n" + " \"avatar\": null" + "}"; return stringToInputStream(user); } private InputStream stringToInputStream(String data) { return new ByteArrayInputStream(data.getBytes(StandardCharsets.UTF_8)); } } } ================================================ FILE: News-Android-App/src/androidTest/java/de/luhmer/owncloudnewsreader/di/TestComponent.java ================================================ package de.luhmer.owncloudnewsreader.di; import javax.inject.Singleton; import dagger.Component; import de.luhmer.owncloudnewsreader.tests.NewFeedTests; import de.luhmer.owncloudnewsreader.tests.NewsReaderListActivityUiTests; import de.luhmer.owncloudnewsreader.tests.NightModeTest; @Singleton @Component(modules = { ApiModule.class }) public interface TestComponent extends AppComponent { void inject(NewFeedTests newFeedTest); void inject(NightModeTest nightModeTest); void inject(NewsReaderListActivityUiTests newsReaderListActivityUiTests); } ================================================ FILE: News-Android-App/src/androidTest/java/de/luhmer/owncloudnewsreader/helper/Utils.java ================================================ package de.luhmer.owncloudnewsreader.helper; import android.content.Context; import android.content.SharedPreferences; import de.luhmer.owncloudnewsreader.R; import static androidx.test.espresso.Espresso.onView; import static androidx.test.espresso.action.ViewActions.click; import static androidx.test.espresso.matcher.ViewMatchers.withId; public class Utils { public static void initMaterialShowCaseView(Context context) { String PREFS_NAME = "material_showcaseview_prefs"; SharedPreferences sp = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); sp.edit() .putInt("status_SWIPE_LEFT_RIGHT_AND_PTR", -1) .putInt("status_LOGO_SYNC", -1) .commit(); } public static void clearFocus() { sleep(200); onView(withId(R.id.toolbar)).perform(click()); sleep(200); } public static void sleep(int millis) { try { Thread.sleep(millis); } catch (InterruptedException e) { e.printStackTrace(); } } public static void sleep(float seconds) { try { Thread.sleep((long) seconds * 1000); } catch (InterruptedException e) { e.printStackTrace(); } } } ================================================ FILE: News-Android-App/src/androidTest/java/de/luhmer/owncloudnewsreader/tests/DownloadWebPageServiceTest.java ================================================ package de.luhmer.owncloudnewsreader.tests; import androidx.test.filters.LargeTest; import androidx.test.rule.ActivityTestRule; import androidx.test.runner.AndroidJUnit4; import org.junit.Rule; import org.junit.runner.RunWith; import de.luhmer.owncloudnewsreader.NewsReaderListActivity; @RunWith(AndroidJUnit4.class) @LargeTest public class DownloadWebPageServiceTest { //private String expectedAppName; @Rule public ActivityTestRule mActivityRule = new ActivityTestRule<>(NewsReaderListActivity.class); /* private UiDevice uiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()); private Activity getActivity() { return mActivityRule.getActivity(); } @Before private void setUp() { expectedAppName = getActivity().getString(R.string.app_name); } @Test public void testStartDownload() { openActionBarOverflowOrOptionsMenu(InstrumentationRegistry.getTargetContext()); onView(withText(getActivity().getString(R.string.action_download_articles_offline))).perform(click()); } private void clearAllNotifications() { UiDevice uiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()); uiDevice.openNotification(); long timeoutInMillis = 1000; uiDevice.wait(Until.hasObject(By.textStartsWith(expectedAppName)), timeoutInMillis); //UiObject2 clearAll = uiDevice.findObject(By.res(clearAllNotificationRes)); //clearAll.click(); } @Test void shouldSendNotificationWhichContainsTitleTextAndAllCities() { String expectedAppName = "Test"; String expectedAllCities = "Test"; String expectedTitle = "Test"; String expectedText = "Test"; // TODO do something here..! uiDevice.openNotification(); //uiDevice.wait(Until.hasObject(By.textStartsWith(expectedAppName)), timeout); UiObject2 title = uiDevice.findObject(By.text(expectedTitle)); UiObject2 text= uiDevice.findObject(By.textStartsWith(expectedText)); //UiObject2 allCities= uiDevice.findObject(By.res(expectedAllCitiesActionRes)); assertEquals(expectedTitle, title.getText()); assertTrue(text.getText().startsWith(expectedText)); //assertEquals(expectedAllCities.toLowerCase(), allCities.getText().toLowerCase()); clearAllNotifications(); } private class ClickOnSendNotification implements ViewAction { private final String TAG = ClickOnSendNotification.class.getCanonicalName(); public String getDescription() { return "Click on the send notification button"; } public Matcher getConstraints() { return Matchers.allOf(isDisplayed(), isAssignableFrom(Button.class)); } public void perform(@Nullable UiController uiController, @Nullable View view) { //view.findViewById(R.id.stop).performClick(); Log.d(TAG, "perform() called with: uiController = [" + uiController + "], view = [" + view + "]"); } } */ } ================================================ FILE: News-Android-App/src/androidTest/java/de/luhmer/owncloudnewsreader/tests/NewFeedTests.java ================================================ package de.luhmer.owncloudnewsreader.tests; import static androidx.test.espresso.Espresso.onView; import static androidx.test.espresso.action.ViewActions.click; import static androidx.test.espresso.action.ViewActions.closeSoftKeyboard; import static androidx.test.espresso.action.ViewActions.typeText; import static androidx.test.espresso.assertion.ViewAssertions.matches; import static androidx.test.espresso.matcher.ViewMatchers.hasErrorText; import static androidx.test.espresso.matcher.ViewMatchers.withId; import static junit.framework.Assert.assertFalse; import static junit.framework.Assert.assertTrue; import static junit.framework.Assert.fail; import static org.hamcrest.CoreMatchers.is; import static org.junit.Assert.assertEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.timeout; import static org.mockito.Mockito.verify; import androidx.test.filters.LargeTest; import androidx.test.rule.ActivityTestRule; import com.nextcloud.android.sso.aidl.NextcloudRequest; import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; import org.mockito.junit.MockitoJUnitRunner; import javax.inject.Inject; import de.luhmer.owncloudnewsreader.NewFeedActivity; import de.luhmer.owncloudnewsreader.R; import de.luhmer.owncloudnewsreader.TestApplication; import de.luhmer.owncloudnewsreader.di.ApiProvider; import de.luhmer.owncloudnewsreader.di.TestApiProvider; import de.luhmer.owncloudnewsreader.di.TestComponent; //@RunWith(AndroidJUnit4.class) @RunWith(MockitoJUnitRunner.class) @LargeTest public class NewFeedTests { @Rule public ActivityTestRule activityRule = new ActivityTestRule<>(NewFeedActivity.class); protected @Inject ApiProvider mApi; @Before public void setUp() { TestComponent ac = (TestComponent) ((TestApplication)(activityRule.getActivity().getApplication())).getAppComponent(); ac.inject(this); // Reset Spy object mApi.initApi(null); //reset(((TestApiProvider)mApi).networkRequestSpy); } @Test public void addNewFeed() { String feed = TestApiProvider.NEW_FEED_SUCCESS; // Type text and then press the button. onView(withId(R.id.et_feed_url)).perform(typeText(feed), closeSoftKeyboard()); onView(withId(R.id.btn_addFeed)).perform(click()); try { verifyRequest(feed); //onView(withId(R.id.et_feed_url)).check(matches(hasErrorText(nullValue(String.class)))); // Check Activity existed Thread.sleep(1000); assertFalse(activityRule.getActivity().getWindow().getDecorView().isShown()); } catch (Exception e) { fail(e.getMessage()); } } @Test public void addExistingFeed() { String feed = TestApiProvider.NEW_FEED_EXISTING; // Type text and then press the button. onView(withId(R.id.et_feed_url)).perform(typeText(feed), closeSoftKeyboard()); onView(withId(R.id.btn_addFeed)).perform(click()); try { verifyRequest(feed); // Check Activity still open Thread.sleep(1000); assertTrue(activityRule.getActivity().getWindow().getDecorView().isShown()); onView(withId(R.id.et_feed_url)).check(matches(hasErrorText(is("Feed konnte nicht hinzugefügt werden: Existiert bereits")))); } catch (Exception e) { fail(e.getMessage()); } } @Test public void addInvalidFeed() { String feed = TestApiProvider.NEW_FEED_FAIL; // Type text and then press the button. onView(withId(R.id.et_feed_url)).perform(typeText(feed), closeSoftKeyboard()); onView(withId(R.id.btn_addFeed)).perform(click()); try { verifyRequest(feed); // Check Activity still open Thread.sleep(1000); assertTrue(activityRule.getActivity().getWindow().getDecorView().isShown()); onView(withId(R.id.et_feed_url)).check(matches(hasErrorText(is("FeedIo\\Adapter\\NotFoundException: Client error: `GET http://feeds2.feedburner.com/stadt-bremerhaven/dqXM222` resulted in a `404 Feed not found error: ...")))); } catch (Exception e) { fail(e.getMessage()); } } // Verify that the API was actually called private void verifyRequest(String feed) throws Exception { TestApiProvider.NewsTestNetworkRequest nr = ((TestApiProvider) mApi).networkRequestSpy; ArgumentCaptor argument = ArgumentCaptor.forClass(NextcloudRequest.class); verify(nr, timeout(2000)).performNetworkRequest(argument.capture(), any()); assertEquals("/index.php/apps/news/api/v1-2/feeds", argument.getValue().getUrl()); var url = argument.getValue().getParameterV2().stream().filter((s) -> s.key.equals(("url"))).findFirst().get().value; var folderId = argument.getValue().getParameterV2().stream().filter((s) -> s.key.equals(("folderId"))).findFirst().get().value; assertEquals(feed, url); assertEquals("0", folderId); } } ================================================ FILE: News-Android-App/src/androidTest/java/de/luhmer/owncloudnewsreader/tests/NewsReaderListActivityUiTests.java ================================================ package de.luhmer.owncloudnewsreader.tests; import android.content.SharedPreferences; import android.os.Bundle; import android.os.SystemClock; import android.view.View; import androidx.annotation.IdRes; import androidx.annotation.NonNull; import androidx.fragment.app.Fragment; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.test.espresso.Espresso; import androidx.test.espresso.contrib.RecyclerViewActions; import androidx.test.espresso.matcher.BoundedMatcher; import androidx.test.espresso.matcher.ViewMatchers; import androidx.test.filters.LargeTest; import androidx.test.rule.ActivityTestRule; import androidx.test.rule.GrantPermissionRule; import androidx.test.runner.AndroidJUnit4; import com.nextcloud.android.sso.aidl.NextcloudRequest; import org.hamcrest.Description; import org.hamcrest.Matcher; import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.List; import java.util.stream.Collectors; import javax.inject.Inject; import de.luhmer.owncloudnewsreader.Constants; import de.luhmer.owncloudnewsreader.NewsReaderDetailFragment; import de.luhmer.owncloudnewsreader.NewsReaderListActivity; import de.luhmer.owncloudnewsreader.R; import de.luhmer.owncloudnewsreader.TestApplication; import de.luhmer.owncloudnewsreader.adapter.NewsListRecyclerAdapter; import de.luhmer.owncloudnewsreader.adapter.RssItemViewHolder; import de.luhmer.owncloudnewsreader.di.ApiProvider; import de.luhmer.owncloudnewsreader.di.TestApiProvider; import de.luhmer.owncloudnewsreader.di.TestComponent; import helper.OrientationChangeAction; import helper.RecyclerViewAssertions; import static androidx.core.util.Preconditions.checkNotNull; import static androidx.test.InstrumentationRegistry.getInstrumentation; import static androidx.test.InstrumentationRegistry.registerInstance; import static androidx.test.espresso.Espresso.onView; import static androidx.test.espresso.action.ViewActions.click; import static androidx.test.espresso.action.ViewActions.typeText; import static androidx.test.espresso.assertion.ViewAssertions.doesNotExist; import static androidx.test.espresso.assertion.ViewAssertions.matches; import static androidx.test.espresso.matcher.ViewMatchers.hasDescendant; import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; import static androidx.test.espresso.matcher.ViewMatchers.isRoot; import static androidx.test.espresso.matcher.ViewMatchers.withClassName; import static androidx.test.espresso.matcher.ViewMatchers.withContentDescription; import static androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility; import static androidx.test.espresso.matcher.ViewMatchers.withId; import static androidx.test.espresso.matcher.ViewMatchers.withText; import static de.luhmer.owncloudnewsreader.helper.Utils.clearFocus; import static de.luhmer.owncloudnewsreader.helper.Utils.initMaterialShowCaseView; import static de.luhmer.owncloudnewsreader.helper.Utils.sleep; import static junit.framework.TestCase.assertNotNull; import static junit.framework.TestCase.assertTrue; import static junit.framework.TestCase.fail; import static org.hamcrest.Matchers.allOf; import static org.hamcrest.Matchers.is; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @RunWith(AndroidJUnit4.class) @LargeTest public class NewsReaderListActivityUiTests { private int scrollPosition = 10; @Rule public ActivityTestRule mActivityRule = new ActivityTestRule<>(NewsReaderListActivity.class); @Rule public GrantPermissionRule mRuntimePermissionRule = GrantPermissionRule.grant(android.Manifest.permission.ACCESS_FINE_LOCATION); protected @Inject SharedPreferences mPrefs; protected @Inject ApiProvider mApi; private NewsReaderListActivity getActivity() { return mActivityRule.getActivity(); } @Before public void setUp() { registerInstance(getInstrumentation(), new Bundle()); sleep(0.3f); TestComponent ac = (TestComponent) ((TestApplication)(getActivity().getApplication())).getAppComponent(); ac.inject(this); clearFocus(); initMaterialShowCaseView(getActivity()); } @Test public void testPositionAfterOrientationChange_sameActivity() { NewsReaderDetailFragment ndf = (NewsReaderDetailFragment) waitForFragment(R.id.content_frame, 5000); onView(withId(R.id.list)).perform( RecyclerViewActions.scrollToPosition(scrollPosition)); onView(isRoot()).perform(OrientationChangeAction.orientationLandscape(getActivity())); //onView(isRoot()).perform(OrientationChangeAction.orientationPortrait(getActivity())); sleep(2000); LinearLayoutManager llm = (LinearLayoutManager) ndf.getRecyclerView().getLayoutManager(); int expectedPosition = scrollPosition-(scrollPosition-llm.findFirstVisibleItemPosition()); // As there is a little offset when rotating.. we need to add one here.. onView(withId(R.id.list)).check(new RecyclerViewAssertions(expectedPosition+1)); onView(withId(R.id.tv_no_items_available)).check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE))); sleep(2000); onView(isRoot()).perform(OrientationChangeAction.orientationPortrait(getActivity())); onView(withId(R.id.list)).check(new RecyclerViewAssertions(expectedPosition)); onView(withId(R.id.tv_no_items_available)).check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE))); } @Test public void testPositionAfterActivityRestart_sameActivity() { onView(withId(R.id.list)).perform(RecyclerViewActions.scrollToPosition(scrollPosition)); onView(withId(R.id.list)).perform(RecyclerViewActions.actionOnItemAtPosition(scrollPosition, click())); sleep(2000); Espresso.pressBack(); NewsReaderDetailFragment ndf = (NewsReaderDetailFragment) waitForFragment(R.id.content_frame, 5000); assertNotNull(ndf); final NewsListRecyclerAdapter na = (NewsListRecyclerAdapter) ndf.getRecyclerView().getAdapter(); assertNotNull(na); final RssItemViewHolder vh = (RssItemViewHolder) ndf.getRecyclerView().getChildViewHolder(ndf.getRecyclerView().getLayoutManager().findViewByPosition(scrollPosition)); assertNotNull(vh); LinearLayoutManager llm = (LinearLayoutManager) ndf.getRecyclerView().getLayoutManager(); getActivity().runOnUiThread(() -> na.changeReadStateOfItem(vh, false)); sleep(1.0f); int expectedPosition = scrollPosition-(scrollPosition-llm.findFirstVisibleItemPosition()); onView(withId(R.id.list)).check(new RecyclerViewAssertions(expectedPosition)); onView(withId(R.id.tv_no_items_available)).check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE))); } @Test public void testSyncFinishedRefreshRecycler_sameActivity() { assertTrue(syncResultTest(true)); } @Test public void testSyncFinishedSnackbar_sameActivity() { assertTrue(syncResultTest(false)); } @Test public void searchTest() { String firstItem = "Immer wieder sonntags KW 19"; // String firstItem = "These are the best screen protectors for the Huawei P30 Pro"; // Check first item checkRecyclerViewFirstItemText(firstItem); // Open search menu onView(allOf(withId(R.id.menu_search), withContentDescription(getString(R.string.action_search)), isDisplayed())).perform(click()); // Type in "test" into searchbar onView(allOf(withClassName(is("android.widget.SearchView$SearchAutoComplete")), isDisplayed())).perform(typeText("test")); sleep(1000); checkRecyclerViewFirstItemText("VR ohne Kabel: Die Oculus Quest im Test, definitiv der richtige Ansatz"); // checkRecyclerViewFirstItemText("Testfahrt im Mercedes E 300 de mit 90-kW-Elektromotor und Vierzylinder-Diesel"); // Close search bar onView(withContentDescription("Collapse")).perform(click()); sleep(1000); // Test if search reset was successful checkRecyclerViewFirstItemText(firstItem); } @Test public void syncTest() { // Open navigation drawer onView(allOf(withContentDescription(getString(R.string.news_list_drawer_text)), isDisplayed())).perform(click()); sleep(1500); /* // Click on Got it onView(allOf(withText("GOT IT"), isDisplayed())).perform(click()); sleep(1000); */ // Trigger refresh onView(allOf(withContentDescription(getString(R.string.content_desc_tap_to_refresh)), isDisplayed())).perform(click()); sleep(1000); try { verifySyncRequested(); } catch (Exception e) { fail(e.getMessage()); } } // Verify that the API was actually called private void verifySyncRequested() throws Exception { TestApiProvider.NewsTestNetworkRequest nr = ((TestApiProvider)mApi).networkRequestSpy; ArgumentCaptor argument = ArgumentCaptor.forClass(NextcloudRequest.class); verify(nr, times(6)).performNetworkRequest(argument.capture(), any()); List requestedUrls = argument.getAllValues().stream().map(nextcloudRequest -> nextcloudRequest.getUrl()).collect(Collectors.toList()); assertTrue(requestedUrls.contains("/index.php/apps/news/api/v1-2/folders")); assertTrue(requestedUrls.contains("/index.php/apps/news/api/v1-2/feeds")); assertTrue(requestedUrls.contains("/index.php/apps/news/api/v1-2/items/unread/multiple")); assertTrue(requestedUrls.contains("/index.php/apps/news/api/v1-2/items")); // TODO Double check why /items is called twice... ? assertTrue(requestedUrls.contains("/index.php/apps/news/api/v1-2/user")); } private void checkRecyclerViewFirstItemText(String text) { onView(withId(R.id.list)).check(matches(atPosition(0, hasDescendant(withText(text))))); } private String getString(@IdRes int resId) { return mActivityRule.getActivity().getString(resId); } public static Matcher atPosition(final int position, @NonNull final Matcher itemMatcher) { checkNotNull(itemMatcher); return new BoundedMatcher(RecyclerView.class) { @Override public void describeTo(Description description) { description.appendText("has item at position " + position + ": "); itemMatcher.describeTo(description); } @Override protected boolean matchesSafely(final RecyclerView view) { RecyclerView.ViewHolder viewHolder = view.findViewHolderForAdapterPosition(position); if (viewHolder == null) { // has no item on such position return false; } return itemMatcher.matches(viewHolder.itemView); } }; } private boolean syncResultTest(boolean testFirstPosition) { if(!testFirstPosition) { onView(withId(R.id.list)).perform(RecyclerViewActions.scrollToPosition(scrollPosition)); } mPrefs.edit().putInt(Constants.LAST_UPDATE_NEW_ITEMS_COUNT_STRING, 5).commit(); try { final Method method = NewsReaderListActivity.class.getDeclaredMethod("syncFinishedHandler"); method.setAccessible(true); getActivity().runOnUiThread(new Runnable() { @Override public void run() { try { if (!(boolean) method.invoke(getActivity())) { fail("Method invocation failed!"); } } catch (IllegalAccessException e) { e.printStackTrace(); fail(e.getMessage()); } catch (InvocationTargetException e) { e.printStackTrace(); fail(e.getMessage()); } } }); getInstrumentation().waitForIdleSync(); sleep(1.0f); if(!testFirstPosition) { onView(withId(com.google.android.material.R.id.snackbar_text)).check(matches(isDisplayed())); } else { onView(withId(com.google.android.material.R.id.snackbar_text)).check(doesNotExist()); } } catch (NoSuchMethodException e) { e.printStackTrace(); fail(e.getMessage()); } return true; } private Fragment waitForFragment(int id, int timeout) { long endTime = SystemClock.uptimeMillis() + timeout; while (SystemClock.uptimeMillis() <= endTime) { Fragment fragment = getActivity().getSupportFragmentManager().findFragmentById(id); if (fragment != null) { return fragment; } } return null; } } ================================================ FILE: News-Android-App/src/androidTest/java/de/luhmer/owncloudnewsreader/tests/NightModeTest.java ================================================ package de.luhmer.owncloudnewsreader.tests; import android.app.Activity; import android.content.Context; import android.content.SharedPreferences; import androidx.recyclerview.widget.RecyclerView; import androidx.test.InstrumentationRegistry; import androidx.test.espresso.contrib.RecyclerViewActions; import androidx.test.filters.LargeTest; import androidx.test.rule.ActivityTestRule; import androidx.test.rule.GrantPermissionRule; import androidx.test.runner.AndroidJUnit4; import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import javax.inject.Inject; import de.luhmer.owncloudnewsreader.NewsReaderListActivity; import de.luhmer.owncloudnewsreader.R; import de.luhmer.owncloudnewsreader.TestApplication; import de.luhmer.owncloudnewsreader.di.TestComponent; import de.luhmer.owncloudnewsreader.helper.ThemeChooser; import static androidx.test.espresso.Espresso.onView; import static androidx.test.espresso.Espresso.openActionBarOverflowOrOptionsMenu; import static androidx.test.espresso.action.ViewActions.click; import static androidx.test.espresso.matcher.ViewMatchers.hasDescendant; import static androidx.test.espresso.matcher.ViewMatchers.withContentDescription; import static androidx.test.espresso.matcher.ViewMatchers.withText; import static junit.framework.TestCase.assertTrue; import static org.hamcrest.CoreMatchers.instanceOf; import static org.hamcrest.CoreMatchers.is; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.fail; @RunWith(AndroidJUnit4.class) @LargeTest public class NightModeTest { protected @Inject SharedPreferences mPrefs; /** * NOTE: These tests only work during "daylight".. (this is because there is no way to check * the current state of the android day/night mode) */ @Rule public ActivityTestRule mActivityRule = new ActivityTestRule<>(NewsReaderListActivity.class); //public ActivityTestRule mActivityRule = new ActivityTestRule<>(NewsReaderListActivity.class, true, false); @Rule public GrantPermissionRule mRuntimePermissionRule = GrantPermissionRule.grant(android.Manifest.permission.ACCESS_FINE_LOCATION); private Activity getActivity() { return mActivityRule.getActivity(); } @Before public void resetSharedPrefs() { TestComponent ac = (TestComponent) ((TestApplication)(getActivity().getApplication())).getAppComponent(); ac.inject(this); /* // Set Fixed time Instant.now( Clock.fixed( Instant.parse( "2019-04-05T18:00:00Z"), ZoneOffset.UTC ) ); */ } @Test public void testBackgroundDaylightTheme() { assertFalse(isDarkTheme()); //onView(withId(R.id.sliding_layout)).check(matches(withBackgroundColor(android.R.color.white, getActivity()))); } @Test public void testOledAutoMode() { openActionBarOverflowOrOptionsMenu(InstrumentationRegistry.getTargetContext()); openSettings(); changeAppTheme(R.string.pref_display_apptheme_auto); switchOled(); navigateUp(); assertFalse(isDarkTheme()); sleep(); //onView(withId(R.id.sliding_layout)).check(ViewAssertions.matches(CustomMatchers.withBackgroundColor(android.R.color.white, getActivity()))); assertEquals(ThemeChooser.THEME.LIGHT, getPrivateField("mSelectedTheme")); } @Test public void testLightTheme() { openActionBarOverflowOrOptionsMenu(InstrumentationRegistry.getTargetContext()); openSettings(); changeAppTheme(R.string.pref_display_apptheme_light); navigateUp(); sleep(); boolean isDarkTheme = isDarkTheme(); assertFalse(ThemeChooser.isOledMode(false)); assertFalse(isDarkTheme); assertEquals(ThemeChooser.THEME.LIGHT, getPrivateField("mSelectedTheme")); //sleep(); } @Test public void testDarkTheme() { openActionBarOverflowOrOptionsMenu(InstrumentationRegistry.getTargetContext()); openSettings(); changeAppTheme(R.string.pref_display_apptheme_dark); navigateUp(); sleep(); boolean isDarkTheme = isDarkTheme(); assertFalse(ThemeChooser.isOledMode(false)); assertTrue(isDarkTheme); assertEquals(ThemeChooser.THEME.DARK, getPrivateField("mSelectedTheme")); //sleep(); } @Test public void testDarkOledTheme() { openActionBarOverflowOrOptionsMenu(InstrumentationRegistry.getTargetContext()); openSettings(); changeAppTheme(R.string.pref_display_apptheme_dark); switchOled(); navigateUp(); sleep(); boolean isDarkTheme = isDarkTheme(); assertTrue(ThemeChooser.isOledMode(false)); assertTrue(isDarkTheme); assertEquals(ThemeChooser.THEME.OLED, getPrivateField("mSelectedTheme")); //sleep(); } private void sleep() { try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } } private void navigateUp() { onView(withContentDescription(androidx.appcompat.R.string.abc_action_bar_up_description)).perform(click()); } private void openSettings() { onView(withText(getActivity().getString(R.string.action_settings))).perform(click()); } private void changeAppTheme(int appThemeText) { String title = getActivity().getString(R.string.pref_title_app_theme); onView(is(instanceOf(RecyclerView.class))) .perform(RecyclerViewActions.scrollTo(hasDescendant(withText(title)))) .perform(RecyclerViewActions.actionOnItem(hasDescendant(withText(title)), click())); onView(withText(getActivity().getString(appThemeText))) .perform(click()); } private void switchOled() { String title = getActivity().getString(R.string.pref_oled_mode); onView(is(instanceOf(RecyclerView.class))) .perform(RecyclerViewActions.scrollTo(hasDescendant(withText(title)))) .perform(RecyclerViewActions.actionOnItem(hasDescendant(withText(title)), click())); } private boolean isDarkTheme() { try { Method method = ThemeChooser.class.getDeclaredMethod("isDarkTheme", Context.class); method.setAccessible(true); boolean isDarkTheme = (boolean) method.invoke(null, getActivity()); return isDarkTheme; } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { fail(e.toString() + " - " + e.getMessage()); } return false; } private Object getPrivateField(String fieldName) { try { Field[] fields = ThemeChooser.class.getDeclaredFields(); for (Field field : fields) { if(fieldName.equals(field.getName())) { field.setAccessible(true); return field.get(null); } } } catch (IllegalAccessException e) { fail(e.getMessage()); } return null; } } ================================================ FILE: News-Android-App/src/androidTest/java/helper/CustomMatchers.java ================================================ package helper; import android.app.Activity; import android.content.Context; import android.graphics.Bitmap; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; import android.graphics.drawable.StateListDrawable; import android.os.Build; import android.util.Log; import android.view.View; import android.widget.ImageView; import android.widget.TextView; import androidx.core.content.ContextCompat; import androidx.test.espresso.matcher.BoundedMatcher; import org.hamcrest.Description; import org.hamcrest.Matcher; import org.hamcrest.TypeSafeDiagnosingMatcher; import org.hamcrest.TypeSafeMatcher; public class CustomMatchers { private static final String TAG = CustomMatchers.class.getCanonicalName(); public static Matcher withBackgroundColor(final int resourceColorId, final Activity activity) { return new TypeSafeDiagnosingMatcher() { String error; @Override public void describeTo(Description description) { description.appendText(error); } @Override protected boolean matchesSafely(View view, Description mismatchDescription) { Drawable drawable = view.getBackground(); Drawable otherDrawable = ContextCompat.getDrawable(view.getContext(), resourceColorId); if (drawable instanceof ColorDrawable) { int colorId = ((ColorDrawable) drawable).getColor(); if(colorId == resourceColorId) { return true; } else { error = "FAILED Got: " + colorId; } } else { Log.e(TAG, drawable.toString()); Log.e(TAG, otherDrawable.toString()); error = "Not ColorDrawable's!!"; } return false; } }; } private static int getColor(Context context, int color) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { return context.getColor(color); } else { return ContextCompat.getColor(context, color); } } public static int getBackgroundColor(Context context, View v, int defaultColor) { Drawable drawable = v.getBackground(); if (drawable instanceof ColorDrawable) { ColorDrawable colorDrawable = (ColorDrawable) drawable; return colorDrawable.getColor(); } else { return getColor(context, defaultColor); } } public static Matcher withBackground(final int resourceId) { return new TypeSafeMatcher() { @Override public boolean matchesSafely(View view) { return sameBitmap(view.getContext(), view.getBackground(), resourceId); } @Override protected void describeMismatchSafely(View item, Description mismatchDescription) { mismatchDescription.appendText("view.getBackground() returned: " + item.getBackground()); } @Override public void describeTo(Description description) { description.appendText("" + resourceId); } }; } public static Matcher withCompoundDrawable(final int resourceId) { return new BoundedMatcher(TextView.class) { @Override public void describeTo(Description description) { description.appendText("has compound drawable resource " + resourceId); } @Override public boolean matchesSafely(TextView textView) { for (Drawable drawable : textView.getCompoundDrawables()) { if (sameBitmap(textView.getContext(), drawable, resourceId)) { return true; } } return false; } }; } public static Matcher withImageDrawable(final int resourceId) { return new BoundedMatcher(ImageView.class) { @Override public void describeTo(Description description) { description.appendText("has image drawable resource " + resourceId); } @Override public boolean matchesSafely(ImageView imageView) { return sameBitmap(imageView.getContext(), imageView.getDrawable(), resourceId); } }; } private static boolean sameBitmap(Context context, Drawable drawable, int resourceId) { Drawable otherDrawable = context.getResources().getDrawable(resourceId); if (drawable == null || otherDrawable == null) { Log.e(TAG, "drawable null!!"); return false; } if (drawable instanceof StateListDrawable && otherDrawable instanceof StateListDrawable) { Log.e(TAG, "other drawable!!"); return drawable.getCurrent().equals(otherDrawable.getCurrent()); } if (drawable instanceof BitmapDrawable) { Log.e(TAG, "bitmap drawable!!"); Bitmap bitmap = ((BitmapDrawable) drawable).getBitmap(); Bitmap otherBitmap = ((BitmapDrawable) otherDrawable).getBitmap(); return bitmap.sameAs(otherBitmap); } return false; } } ================================================ FILE: News-Android-App/src/androidTest/java/helper/OrientationChangeAction.java ================================================ /* * The MIT License (MIT) * * Copyright (c) 2015 - Nathan Barraille * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. * */ package helper; import android.app.Activity; import android.content.pm.ActivityInfo; import android.view.View; import androidx.test.espresso.UiController; import androidx.test.espresso.ViewAction; import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry; import androidx.test.runner.lifecycle.Stage; import org.hamcrest.Matcher; import java.util.Collection; import static androidx.test.espresso.matcher.ViewMatchers.isRoot; /** * An Espresso ViewAction that changes the orientation of the screen */ public class OrientationChangeAction implements ViewAction { private final int orientation; private Activity activity; private OrientationChangeAction(int orientation, Activity activity) { this.orientation = orientation; this.activity = activity; } @Override public Matcher getConstraints() { return isRoot(); } @Override public String getDescription() { return "change orientation to " + orientation; } @Override public void perform(UiController uiController, View view) { uiController.loopMainThreadUntilIdle(); activity.setRequestedOrientation(orientation); Collection resumedActivities = ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(Stage.RESUMED); if (resumedActivities.isEmpty()) { throw new RuntimeException("Could not change orientation"); } } public static ViewAction orientationLandscape(Activity activity) { return new OrientationChangeAction(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE, activity); } public static ViewAction orientationPortrait(Activity activity) { return new OrientationChangeAction(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT, activity); } } ================================================ FILE: News-Android-App/src/androidTest/java/helper/RecyclerViewAssertions.java ================================================ package helper; import android.view.View; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.test.espresso.NoMatchingViewException; import androidx.test.espresso.ViewAssertion; public class RecyclerViewAssertions implements ViewAssertion { private int mExpectedPos; public RecyclerViewAssertions(int expectedPos) { this.mExpectedPos = expectedPos; } @Override public void check(View view, NoMatchingViewException e) { RecyclerView recyclerView = (RecyclerView) view; LinearLayoutManager layoutManager = ((LinearLayoutManager)recyclerView.getLayoutManager()); int firstVisiblePosition = layoutManager.findFirstVisibleItemPosition(); if(firstVisiblePosition != mExpectedPos) { throw new RuntimeException("Wrong position! Expected: " + mExpectedPos + " but was: " + firstVisiblePosition); } } } ================================================ FILE: News-Android-App/src/androidTest/java/screengrab/ScreenshotTest.java ================================================ package screengrab; import androidx.core.view.GravityCompat; import androidx.test.filters.LargeTest; import androidx.test.rule.ActivityTestRule; import androidx.test.rule.GrantPermissionRule; import org.junit.Before; import org.junit.ClassRule; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; import de.luhmer.owncloudnewsreader.NewsReaderDetailFragment; import de.luhmer.owncloudnewsreader.NewsReaderListActivity; import de.luhmer.owncloudnewsreader.NewsReaderListFragment; import de.luhmer.owncloudnewsreader.adapter.NewsListRecyclerAdapter; import de.luhmer.owncloudnewsreader.adapter.RssItemViewHolder; import de.luhmer.owncloudnewsreader.database.DatabaseConnectionOrm; import de.luhmer.owncloudnewsreader.model.PodcastItem; import tools.fastlane.screengrab.Screengrab; import tools.fastlane.screengrab.UiAutomatorScreenshotStrategy; import tools.fastlane.screengrab.locale.LocaleTestRule; import static de.luhmer.owncloudnewsreader.helper.Utils.clearFocus; import static de.luhmer.owncloudnewsreader.helper.Utils.initMaterialShowCaseView; /** * Created by David on 06.03.2016. */ @RunWith(JUnit4.class) @LargeTest public class ScreenshotTest { @ClassRule public static final LocaleTestRule localTestRule = new LocaleTestRule(); @Rule public ActivityTestRule mActivityRule = new ActivityTestRule<>(NewsReaderListActivity.class); @Rule public GrantPermissionRule mRuntimePermissionRule = GrantPermissionRule.grant(android.Manifest.permission.ACCESS_FINE_LOCATION, android.Manifest.permission.WRITE_EXTERNAL_STORAGE); private NewsReaderListActivity mActivity; private NewsReaderListFragment nrlf; private NewsReaderDetailFragment nrdf; private int itemPos = 0; //private int podcastGroupPosition = 3; @Before public void setUp() { Screengrab.setDefaultScreenshotStrategy(new UiAutomatorScreenshotStrategy()); mActivity = mActivityRule.getActivity(); nrlf = mActivity.getSlidingListFragment(); nrdf = mActivity.getNewsReaderDetailFragment(); clearFocus(); initMaterialShowCaseView(mActivity); } @Test public void testTakeScreenshots() { Screengrab.screenshot("startup"); mActivity.runOnUiThread(() -> { openDrawer(); //nrlf.getListView().expandGroup(podcastGroupPosition); }); try { Thread.sleep(500); } catch (InterruptedException e) { e.printStackTrace(); } Screengrab.screenshot("slider_open"); mActivity.runOnUiThread(() -> { closeDrawer(); try { Thread.sleep(200); } catch (InterruptedException e) { e.printStackTrace(); } mActivity.onClick(null, itemPos); //Select item }); try { Thread.sleep(8000); } catch (InterruptedException e) { e.printStackTrace(); } Screengrab.screenshot("detail_activity"); mActivity.runOnUiThread(() -> { NewsListRecyclerAdapter na = (NewsListRecyclerAdapter) nrdf.getRecyclerView().getAdapter(); RssItemViewHolder vh = (RssItemViewHolder) nrdf.getRecyclerView().getChildViewHolder(nrdf.getRecyclerView().getLayoutManager().findViewByPosition(itemPos)); na.changeReadStateOfItem(vh, false); }); } @Test public void testAudioPodcast() { mActivity.runOnUiThread(() -> { openDrawer(); //nrlf.getListView().expandGroup(podcastGroupPosition); //openFeed(podcastGroupPosition, 0); openFeed(2, 1); // Open Android Podcast }); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } //Screengrab.screenshot("podcast_list"); mActivity.runOnUiThread(() -> { RssItemViewHolder vh = (RssItemViewHolder) nrdf.getRecyclerView().getChildViewHolder(nrdf.getRecyclerView().getLayoutManager().findViewByPosition(0)); PodcastItem podcastItem = DatabaseConnectionOrm.ParsePodcastItemFromRssItem(mActivity, vh.getRssItem()); mActivity.openMediaItem(podcastItem); }); try { Thread.sleep(10000); } catch (InterruptedException e) { e.printStackTrace(); } Screengrab.screenshot("podcast_running"); mActivity.runOnUiThread(() -> mActivity.pausePodcast()); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } } @Test public void testVideoPodcast() { mActivity.runOnUiThread(() -> { //Set url to mock nrlf.bindUserInfoToUI(); openDrawer(); //openFeed(0, 13); //Click on ARD Podcast openFeed(7, -1); }); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } mActivity.runOnUiThread(() -> { RssItemViewHolder vh = (RssItemViewHolder) nrdf.getRecyclerView().getChildViewHolder(nrdf.getRecyclerView().getLayoutManager().findViewByPosition(1)); PodcastItem podcastItem = DatabaseConnectionOrm.ParsePodcastItemFromRssItem(mActivity, vh.getRssItem()); mActivity.openMediaItem(podcastItem); }); try { Thread.sleep(15000); } catch (InterruptedException e) { e.printStackTrace(); } Screengrab.screenshot("video_podcast_running"); mActivity.runOnUiThread(() -> mActivity.pausePodcast()); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } } private void openFeed(int groupPosition, int childPosition) { nrlf.onChildClickListener.onChildClick(null, null, groupPosition, childPosition, 0); //Click on ARD Podcast } private void openDrawer() { if(mActivity.binding.drawerLayout != null) { mActivity.binding.drawerLayout.openDrawer(GravityCompat.START, true); } } private void closeDrawer() { if(mActivity.binding.drawerLayout != null) { mActivity.binding.drawerLayout.closeDrawer(GravityCompat.START, true); } } } ================================================ FILE: News-Android-App/src/dev/res/drawable/ic_launcher_foreground.xml ================================================ ================================================ FILE: News-Android-App/src/dev/res/values/strings.xml ================================================ News Dev de.luhmer.owncloudnewsreader.dev de.luhmer.owncloudnewsreader.dev ================================================ FILE: News-Android-App/src/main/AndroidManifest.xml ================================================ ================================================ FILE: News-Android-App/src/main/assets/web.css ================================================ :root { --fontsize-body: 1.1em; --fontsize-header: 1.1em; --fontsize-subscript: 0.7em; } /* @font-face { font-family: "ROBOTO_REGULAR"; src: url('fonts/Roboto-Regular.ttf'); } */ body.lightTheme { background-color: #ffffff; color: black; } body.darkThemeOLED { background-color: #000; color: #eee; } body.darkThemeOLED a:link { color: #0289ff; } body.darkThemeOLED a:visited { color: #cd4fff; } body.darkTheme { background-color: #121212; color: #ffffff; } body.lightTheme a:link, body.lightTheme a:active, body.lightTheme a:hover { color: #333 !important; } body.lightTheme a:visited { color: #000 !important; } body.darkTheme a:link, body.darkTheme a:active, body.darkTheme a:hover { color: #dadada !important; } body#darkTheme a:visited { color: #aeaeae !important; } /* For debugging */ /* * { border: 1px solid #F00; } */ body, blockquote, img, iframe, video, div, table, tbody, tr, td, pre, code, blockquote, p, em, b, span { width: auto !important; height: auto !important; max-width: 100% !important; } body { font-size: var(--fontsize-body); font-weight: normal; margin: 0px; word-wrap: break-word !important; /* font-family: 'ROBOTO_REGULAR'; */ margin-left: 1rem; margin-right: 1rem; } body iframe:not([src]) { display: none; } img { /* make images fill the whole screen */ max-width: calc(100% + 2rem) !important; margin-left: -1rem !important; } div#content > p { margin-top: 0px; } pre { background-color: #f7f7f7; padding: 5px; border-radius: 3px; } body.darkTheme pre { background-color: #313131; } body.darkThemeOLED pre { background-color: #000; } pre span { /* fix for https://github.com/nextcloud/news-android/issues/798 */ display: initial; } pre, code, blockquote, p, em, b { white-space: -moz-pre-wrap !important; white-space: -pre-wrap !important; white-space: -o-pre-wrap !important; white-space: pre-wrap !important; word-wrap: break-word !important; } blockquote { white-space:pre-line !important; padding-left: 10px; border-left: 3px solid #ccc; } a { margin-left: auto !important; margin-right: auto !important; } #content table { width: 100% !important; /* https://www.w3schools.com/cssref/pr_tab_table-layout.asp */ table-layout: fixed !important; } #header { font-size: var(--fontsize-header); margin-bottom: 10px; font-weight: bold; line-height: 1.6; } #header a:link, #header a:active, #header a:hover, #header a:visited { color: #000000 !important; text-decoration: none !important; } #header.darkTheme a:link, #header.darkTheme a:active, #header.darkTheme a:hover, #header.darkTheme a:visited { color: #ffffff !important; } #header.darkThemeOLED a:link, #header.darkThemeOLED a:active, #header.darkThemeOLED a:hover, #header.darkThemeOLED a:visited { color: #ffffff !important; } ul { /* We use custom list style for Unordered Lists */ list-style: none; /* For the first level we should not have padding */ padding: 0px; } ol { /* ol items already have an intrinsic padding (the number in front has a negativ padding) */ padding-left: 22px; } /* Nested lists should be indented a little */ ul li ul, ul li ol, ol li ul, ol li ol { padding-left: 14px; } ul li:before { vertical-align: 18%; margin-right: 10px; border-style: solid; border-width: 0.10em 0.10em 0 0; content: ''; display: inline-block; height: 0.3em; left: 0.15em; position: relative; top: 0.15em; transform: rotate(45deg); width: 0.3em; /* vertical-align: -10%; font-family: "Material Icons"; content: "\e5cc"; */ } #subscription { display: flex; } #subscription span { line-height: 16px; display: inline-flex; align-items: center; } #subscription, #datetime { margin-bottom:3px; font-size: var(--fontsize-subscript); } #header_small_text { display: flex; justify-content: space-between; overflow: auto; flex-direction: row; } #header_small_text { color: #9E9E9E; } #top_section { margin-top: 1rem; } #content { margin-top: 0.6rem; margin-bottom: 100px; line-height: 1.5em !important; } h1 { line-height: 1em !important; } #imgFavicon { margin-right: 4px; vertical-align:middle; margin-bottom:2px; margin-left: 0px !important; width:16px !important; height:16px !important; } .rtl #imgFavicon { margin-right: 0 !important; margin-left: 4px !important; } ================================================ FILE: News-Android-App/src/main/java/com/bumptech/glide/samples/svg/SvgDecoder.kt ================================================ package com.bumptech.glide.samples.svg import com.bumptech.glide.load.Options import com.bumptech.glide.load.ResourceDecoder import com.bumptech.glide.load.engine.Resource import com.bumptech.glide.load.resource.SimpleResource import com.bumptech.glide.request.target.Target import com.caverock.androidsvg.SVG import com.caverock.androidsvg.SVGParseException import java.io.IOException import java.io.InputStream /** * Decodes an SVG internal representation from an [InputStream]. */ class SvgDecoder : ResourceDecoder { override fun handles( source: InputStream, options: Options, ): Boolean = true @Throws(IOException::class) override fun decode( source: InputStream, width: Int, height: Int, options: Options, ): Resource? = try { val svg = SVG.getFromInputStream(source) if (width != Target.SIZE_ORIGINAL) { svg.documentWidth = width.toFloat() } if (height != Target.SIZE_ORIGINAL) { svg.documentHeight = height.toFloat() } SimpleResource(svg) } catch (ex: SVGParseException) { throw IOException("Cannot load SVG from stream", ex) } } ================================================ FILE: News-Android-App/src/main/java/com/bumptech/glide/samples/svg/SvgDrawableTranscoder.kt ================================================ package com.bumptech.glide.samples.svg import android.graphics.Picture import android.graphics.drawable.PictureDrawable import com.bumptech.glide.load.Options import com.bumptech.glide.load.engine.Resource import com.bumptech.glide.load.resource.SimpleResource import com.bumptech.glide.load.resource.transcode.ResourceTranscoder import com.caverock.androidsvg.SVG /** * Convert the [SVG]'s internal representation to an Android-compatible one ([Picture]). */ class SvgDrawableTranscoder : ResourceTranscoder { override fun transcode( toTranscode: Resource, options: Options, ): Resource { val svg = toTranscode.get() val picture = svg.renderToPicture() val drawable = PictureDrawable(picture) return SimpleResource(drawable) } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/AddFolderDialogFragment.java ================================================ package de.luhmer.owncloudnewsreader; import android.animation.AnimatorListenerAdapter; import android.app.Activity; import android.content.Context; import android.os.Bundle; import android.text.Editable; import android.text.TextWatcher; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.fragment.app.DialogFragment; import java.util.HashMap; import java.util.Map; import javax.inject.Inject; import de.luhmer.owncloudnewsreader.database.DatabaseConnectionOrm; import de.luhmer.owncloudnewsreader.databinding.FragmentDialogAddFolderBinding; import de.luhmer.owncloudnewsreader.di.ApiProvider; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.schedulers.Schedulers; public class AddFolderDialogFragment extends DialogFragment { protected @Inject ApiProvider mApi; private NewsReaderListActivity parentActivity; protected FragmentDialogAddFolderBinding binding; static AddFolderDialogFragment newInstance() { AddFolderDialogFragment f = new AddFolderDialogFragment(); Bundle args = new Bundle(); f.setArguments(args); return f; } @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); ((NewsReaderApplication) requireActivity().getApplication()).getAppComponent().injectFragment(this); setStyle(DialogFragment.STYLE_NO_TITLE, R.style.FloatingDialog); } @Override public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { binding = FragmentDialogAddFolderBinding.inflate(inflater, container, false); binding.buttonAddConfirm.setEnabled(false); binding.buttonAddCancel.setOnClickListener(v -> dismiss()); binding.folderNameInput.addTextChangedListener(new TextWatcher() { @Override public void afterTextChanged(Editable s) {} @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {} @Override public void onTextChanged(CharSequence s, int start, int before, int count) { binding.buttonAddConfirm.setEnabled(s.length() != 0); binding.folderNameInput.setError(null); } }); binding.buttonAddConfirm.setOnClickListener(v -> { String name = binding.folderNameInput.getText().toString(); DatabaseConnectionOrm dbConn = new DatabaseConnectionOrm(getContext()); boolean alreadyExists = dbConn.getFolderByLabel(name) != null; if (alreadyExists) { binding.folderNameInput.setError(getString(R.string.folder_already_exists)); return; } showProgress(true); setCancelable(false); getDialog().setCanceledOnTouchOutside(false); Map paramMap = new HashMap<>(0); paramMap.put("name", name); mApi.getNewsAPI().createFolderObservable(paramMap) .subscribeOn(Schedulers.newThread()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(folders -> { dbConn.insertNewFolders(folders); parentActivity.getSlidingListFragment().reloadAdapter(); parentActivity.startSync(); dismiss(); }, throwable -> { Context context = getContext(); if (context == null) { return; } Toast.makeText(context.getApplicationContext(), getString(R.string.login_dialog_text_something_went_wrong) + " - " + throwable.getMessage(), Toast.LENGTH_LONG).show(); dismiss(); }); }); return binding.getRoot(); } public void setActivity(Activity parentActivity) { this.parentActivity = (NewsReaderListActivity)parentActivity; } public void showProgress(final boolean show) { int shortAnimTime = getResources().getInteger(android.R.integer.config_shortAnimTime); binding.folderNameInput.setVisibility(show ? View.GONE : View.VISIBLE); binding.buttonAddConfirm.setEnabled(!show); binding.progressView.setVisibility(show ? View.VISIBLE : View.GONE); binding.progressView.animate().setDuration(shortAnimTime) .alpha(show ? 1 : 0).setListener(new AnimatorListenerAdapter() {}); } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/Constants.java ================================================ package de.luhmer.owncloudnewsreader; import android.content.SharedPreferences; import java.util.regex.Matcher; import java.util.regex.Pattern; public class Constants { public static final Boolean debugModeWidget = false; public static final int maxItemsCount = 5000; public static final String LAST_UPDATE_NEW_ITEMS_COUNT_STRING = "LAST_UPDATE_NEW_ITEMS_COUNT_STRING"; public static final String NOTIFICATION_ACTION_STOP_STRING = "NOTIFICATION_STOP"; public static final String NOTIFICATION_ACTION_MARK_ALL_AS_READ_STRING = "NOTIFICATION_MARK_ALL_AS_READ"; protected static final String NEWS_WEB_VERSION_NUMBER_STRING = "NewsWebVersionNumber"; protected static final int MIN_NEXTCLOUD_FILES_APP_VERSION_CODE = 30030052; public static final String USER_INFO_STRING = "USER_INFO"; public static final String PREVIOUS_VERSION_CODE = "PREVIOUS_VERSION_CODE"; protected static boolean isNextCloud(SharedPreferences prefs) { int[] version = extractVersionNumberFromString(prefs.getString(Constants.NEWS_WEB_VERSION_NUMBER_STRING, "")); if (version[0] == 0) { // not initialized yet.. return true; // let's assume that it is nextcloud.. } return version[0] >= 9; } private static int[] extractVersionNumberFromString(String appVersion) { Pattern p = Pattern.compile("(\\d+).(\\d+).(\\d+)"); Matcher m = p.matcher(appVersion); int[] version = new int[] { 0, 0, 0 }; if (m.matches()) { version[0] = Integer.parseInt(m.group(1)); version[1] = Integer.parseInt(m.group(2)); version[2] = Integer.parseInt(m.group(3)); } return version; } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/DirectoryChooserActivity.java ================================================ package de.luhmer.owncloudnewsreader; import android.os.Bundle; import androidx.annotation.Nullable; /** * Created by benson on 11/20/15. */ public class DirectoryChooserActivity extends net.rdrei.android.dirchooser.DirectoryChooserActivity { @Override public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); int theme = R.style.AppTheme; setTheme(theme); } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/FolderOptionsDialogFragment.java ================================================ package de.luhmer.owncloudnewsreader; import android.animation.AnimatorListenerAdapter; import android.app.Activity; import android.content.Context; import android.os.Bundle; import android.text.Editable; import android.text.TextWatcher; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.ArrayAdapter; import android.widget.Toast; import androidx.fragment.app.DialogFragment; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import javax.inject.Inject; import de.luhmer.owncloudnewsreader.database.DatabaseConnectionOrm; import de.luhmer.owncloudnewsreader.database.model.Feed; import de.luhmer.owncloudnewsreader.databinding.FragmentDialogFolderoptionsBinding; import de.luhmer.owncloudnewsreader.di.ApiProvider; import de.luhmer.owncloudnewsreader.reader.nextcloud.NewsAPI; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.annotations.NonNull; import io.reactivex.rxjava3.core.Completable; import io.reactivex.rxjava3.core.Observable; import io.reactivex.rxjava3.schedulers.Schedulers; public class FolderOptionsDialogFragment extends DialogFragment { protected @Inject ApiProvider mApi; private long mFolderId; private String mDialogTitle; private LinkedHashMap mMenuItems; private NewsReaderListActivity parentActivity; protected FragmentDialogFolderoptionsBinding binding; static FolderOptionsDialogFragment newInstance(long folderId, String dialogTitle) { FolderOptionsDialogFragment f = new FolderOptionsDialogFragment(); Bundle args = new Bundle(); args.putLong("folderid", folderId); args.putString("title", dialogTitle); f.setArguments(args); return f; } @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); ((NewsReaderApplication) requireActivity().getApplication()).getAppComponent().injectFragment(this); final Bundle args = requireArguments(); mFolderId = args.getLong("folderid"); mDialogTitle = args.getString("title"); mMenuItems = new LinkedHashMap<>(); mMenuItems.put(getString(R.string.action_folder_rename), () -> showRenameFolderView(mFolderId, mDialogTitle)); mMenuItems.put(getString(R.string.action_folder_remove), () -> showRemoveFolderView(mFolderId)); setStyle(DialogFragment.STYLE_NO_TITLE, R.style.FloatingDialog); } @Override public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { binding = FragmentDialogFolderoptionsBinding.inflate(inflater, container, false); binding.tvMenuTitle.setText(mDialogTitle); List menuItemsList = new ArrayList<>(mMenuItems.keySet()); final ArrayAdapter arrayAdapter = new ArrayAdapter<>( getActivity(), R.layout.fragment_dialog_listviewitem, menuItemsList); binding.lvMenuList.setAdapter(arrayAdapter); binding.lvMenuList.setOnItemClickListener((adapterView, view, i, l) -> { String key = arrayAdapter.getItem(i); MenuAction mAction = mMenuItems.get(key); mAction.execute(); }); return binding.getRoot(); } public void setActivity(Activity parentActivity) { this.parentActivity = (NewsReaderListActivity)parentActivity; } public void showProgress(final boolean show) { int shortAnimTime = getResources().getInteger(android.R.integer.config_shortAnimTime); binding.renameFolderDialog.setVisibility(show ? View.GONE : View.VISIBLE); binding.removeFolderDialog.setVisibility(show ? View.GONE : View.VISIBLE); binding.progressView.setVisibility(show ? View.VISIBLE : View.GONE); binding.progressView.animate().setDuration(shortAnimTime).alpha( show ? 1 : 0).setListener(new AnimatorListenerAdapter() { }); } private void showRenameFolderView(final long folderId, final String folderName) { binding.renamefolderFoldername.setText(folderName); binding.buttonRenameConfirm.setEnabled(false); binding.lvMenuList.setVisibility(View.GONE); binding.renameFolderDialog.setVisibility(View.VISIBLE); binding.renamefolderFoldername.addTextChangedListener(new TextWatcher() { @Override public void afterTextChanged(Editable s) {} @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {} @Override public void onTextChanged(CharSequence s, int start, int before, int count) { binding.buttonRenameConfirm.setEnabled( !s.toString().equals(folderName) && s.length() != 0); } }); binding.buttonRenameCancel.setOnClickListener(v -> dismiss()); binding.buttonRenameConfirm.setOnClickListener(v -> { showProgress(true); setCancelable(false); getDialog().setCanceledOnTouchOutside(false); Map paramMap = new LinkedHashMap<>(); paramMap.put("name", binding.renamefolderFoldername.getText().toString()); mApi.getNewsAPI().renameFolder(folderId, paramMap) .subscribeOn(Schedulers.newThread()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(() -> { DatabaseConnectionOrm dbConn = new DatabaseConnectionOrm(getContext()); dbConn.renameFolderById(folderId, binding.renamefolderFoldername.getText().toString()); parentActivity.getSlidingListFragment().reloadAdapter(); parentActivity.startSync(); dismiss(); }, throwable -> { Context context = getContext(); if (context == null) { return; } Toast.makeText(context.getApplicationContext(), getString(R.string.login_dialog_text_something_went_wrong) + " - " + throwable.getMessage(), Toast.LENGTH_LONG).show(); dismiss(); }); }); } private void showRemoveFolderView(final long folderId) { binding.lvMenuList.setVisibility(View.GONE); binding.removeFolderDialog.setVisibility(View.VISIBLE); binding.buttonRemoveCancel.setOnClickListener(v -> dismiss()); binding.buttonRemoveConfirm.setOnClickListener(v -> { showProgress(true); setCancelable(false); getDialog().setCanceledOnTouchOutside(false); NewsAPI newsApi = mApi.getNewsAPI(); Observable deleteFeedsTask = newsApi.feeds() .subscribeOn(Schedulers.newThread()) .flatMap(feedList -> Observable.fromIterable(feedList) .filter(feed -> folderId == feed.getFolderId()) ) .flatMap(feed -> newsApi.deleteFeed(feed.getId()) .andThen(Observable.just(feed)) ) .observeOn(AndroidSchedulers.mainThread()) .doOnNext(feed -> { DatabaseConnectionOrm dbConn = new DatabaseConnectionOrm(getContext()); dbConn.removeFeedById(feed.getId()); Long currentFeedId = parentActivity.getNewsReaderDetailFragment().getIdFeed(); if(currentFeedId != null && currentFeedId == feed.getId()) { parentActivity.switchToAllUnreadItemsFolder(); } }); Completable.fromObservable(deleteFeedsTask) .observeOn(Schedulers.newThread()) .andThen(newsApi.deleteFolder(folderId)) .observeOn(AndroidSchedulers.mainThread()) .subscribe(() -> { DatabaseConnectionOrm dbConn = new DatabaseConnectionOrm(getContext()); dbConn.removeFolderById(folderId); Long currentFolderId = parentActivity.getNewsReaderDetailFragment().getIdFolder(); if(currentFolderId != null && currentFolderId == folderId) { parentActivity.switchToAllUnreadItemsFolder(); } parentActivity.getSlidingListFragment().reloadAdapter(); parentActivity.startSync(); dismiss(); }, throwable -> { Context context = getContext(); if (context == null) { return; } Toast.makeText(context.getApplicationContext(), getString(R.string.login_dialog_text_something_went_wrong) + " - " + throwable.getMessage(), Toast.LENGTH_LONG).show(); dismiss(); }); }); } interface MenuAction { void execute(); } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/LazyLoadingLinearLayoutManager.kt ================================================ package de.luhmer.owncloudnewsreader import android.content.Context import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import kotlin.math.roundToInt class LazyLoadingLinearLayoutManager( context: Context?, @RecyclerView.Orientation orientation: Int, reverseLayout: Boolean, ) : LinearLayoutManager(context, orientation, reverseLayout) { var totalItemCount: Int = 0 override fun computeVerticalScrollRange(state: RecyclerView.State): Int { if (state.itemCount == 0) { return 0 } return ( super.computeVerticalScrollRange( state, ) / state.itemCount.toFloat() * totalItemCount ).roundToInt() } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/ListView/BlockingExpandableListView.java ================================================ package de.luhmer.owncloudnewsreader.ListView; import android.content.Context; import android.util.AttributeSet; import android.widget.ExpandableListView; public class BlockingExpandableListView extends ExpandableListView { private boolean mBlockLayoutChildren; public BlockingExpandableListView(Context context, AttributeSet attrs) { super(context, attrs); } public void setBlockLayoutChildren(boolean block) { mBlockLayoutChildren = block; } @Override protected void layoutChildren() { if (!mBlockLayoutChildren) { super.layoutChildren(); } } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/ListView/PodcastArrayAdapter.java ================================================ package de.luhmer.owncloudnewsreader.ListView; import android.annotation.SuppressLint; import android.content.Context; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.ArrayAdapter; import android.widget.Toast; import androidx.annotation.NonNull; import org.greenrobot.eventbus.EventBus; import de.luhmer.owncloudnewsreader.R; import de.luhmer.owncloudnewsreader.databinding.PodcastRowBinding; import de.luhmer.owncloudnewsreader.events.podcast.StartDownloadPodcast; import de.luhmer.owncloudnewsreader.helper.NewsFileUtils; import de.luhmer.owncloudnewsreader.model.PodcastItem; public class PodcastArrayAdapter extends ArrayAdapter { private final LayoutInflater inflater; private final EventBus eventBus; public PodcastArrayAdapter(Context context, PodcastItem[] values) { super(context, R.layout.podcast_row, values); inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); eventBus = EventBus.getDefault(); } @SuppressLint("SetTextI18n") @Override public View getView(final int position, View view, ViewGroup parent) { final ViewHolder holder; if (view != null) { holder = (ViewHolder) view.getTag(); } else { PodcastRowBinding binding = PodcastRowBinding.inflate(inflater, parent, false); view = binding.getRoot(); holder = new ViewHolder(binding); view.setTag(holder); } final PodcastItem podcastItem = getItem(position); holder.binding.tvTitle.setText(podcastItem.title); holder.binding.tvBody.setText(podcastItem.mimeType); holder.binding.flDownloadPodcastWrapper.setOnClickListener(view1 -> { holder.binding.flDownloadPodcastWrapper.setVisibility(View.GONE); Toast.makeText(getContext(), "Starting download.. Please wait", Toast.LENGTH_SHORT).show(); eventBus.post(new StartDownloadPodcast(podcastItem)); }); holder.binding.flDeletePodcastWrapper.setOnClickListener(view13 -> { if(NewsFileUtils.deletePodcastFile(getContext(), podcastItem.fingerprint, podcastItem.link)) { podcastItem.offlineCached = false; podcastItem.downloadProgress = PodcastItem.DOWNLOAD_NOT_STARTED; notifyDataSetChanged(); } }); holder.binding.pbDownloadPodcast.setProgress(podcastItem.downloadProgress); if(podcastItem.downloadProgress >= 0) { holder.binding.tvDownloadPodcastProgress.setVisibility(View.VISIBLE); holder.binding.pbDownloadPodcast.setVisibility(View.VISIBLE); holder.binding.tvDownloadPodcastProgress.setText(podcastItem.downloadProgress + "%"); } else { holder.binding.tvDownloadPodcastProgress.setVisibility(View.GONE); holder.binding.pbDownloadPodcast.setVisibility(View.GONE); } if(podcastItem.downloadProgress.equals(PodcastItem.DOWNLOAD_NOT_STARTED)) { holder.binding.flDownloadPodcastWrapper.setVisibility(View.VISIBLE); } else { holder.binding.flDownloadPodcastWrapper.setVisibility(View.GONE); } holder.binding.flDeletePodcastWrapper.setVisibility((podcastItem.downloadProgress.equals(PodcastItem.DOWNLOAD_COMPLETED)) ? View.VISIBLE : View.GONE ); /* File podcastFile = new File(PodcastDownloadService.getUrlToPodcastFile(getContext(), podcastItem.link, true)); File podcastFileCache = new File(PodcastDownloadService.getUrlToPodcastFile(getContext(), podcastItem.link, true) + ".download"); if(podcastFile.exists()) { holder.flDownloadPodcast.setVisibility(View.GONE); } else if(podcastFileCache.exists()) { holder.flDownloadPodcast.setVisibility(View.GONE); } else holder.flDownloadPodcast.setVisibility(View.VISIBLE); */ return view; } private void playPodcast() { } static class ViewHolder { @NonNull final PodcastRowBinding binding; public ViewHolder(@NonNull PodcastRowBinding binding) { this.binding = binding; } } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/ListView/PodcastFeedArrayAdapter.java ================================================ package de.luhmer.owncloudnewsreader.ListView; import android.content.Context; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.ArrayAdapter; import android.widget.TextView; import androidx.annotation.NonNull; import org.greenrobot.eventbus.EventBus; import de.luhmer.owncloudnewsreader.R; import de.luhmer.owncloudnewsreader.databinding.PodcastFeedRowBinding; import de.luhmer.owncloudnewsreader.events.podcast.PodcastFeedClicked; import de.luhmer.owncloudnewsreader.model.PodcastFeedItem; public class PodcastFeedArrayAdapter extends ArrayAdapter { private final LayoutInflater inflater; private final EventBus eventBus; public PodcastFeedArrayAdapter(Context context, PodcastFeedItem[] values) { super(context, R.layout.podcast_feed_row, values); inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); eventBus = EventBus.getDefault(); } @Override public View getView(final int position, View view, ViewGroup parent) { ViewHolder holder; if (view != null) { holder = (ViewHolder) view.getTag(); } else { PodcastFeedRowBinding binding = PodcastFeedRowBinding.inflate(inflater, parent, false); view = binding.getRoot(); holder = new ViewHolder(binding); binding.getRoot().setTag(holder); } final PodcastFeedItem feedItem = getItem(position); holder.binding.tvTitle.setText(feedItem.mFeed.getFeedTitle()); holder.binding.tvBody.setText(feedItem.mPodcastCount + " Podcasts available"); view.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { PodcastFeedClicked podcastFeedClicked = new PodcastFeedClicked(position); eventBus.post(podcastFeedClicked); } }); return view; } static class ViewHolder { @NonNull final PodcastFeedRowBinding binding; public ViewHolder(@NonNull PodcastFeedRowBinding binding) { this.binding = binding; } } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/ListView/SubscriptionExpandableListAdapter.java ================================================ /* * Android ownCloud News * * @author David Luhmer * @copyright 2013 David Luhmer david-dev@live.de * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE * License as published by the Free Software Foundation; either * version 3 of the License, or any later version. * * This library 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 AFFERO GENERAL PUBLIC LICENSE for more details. * * You should have received a copy of the GNU Affero General Public * License along with this library. If not, see . * */ package de.luhmer.owncloudnewsreader.ListView; import static de.luhmer.owncloudnewsreader.ListView.SubscriptionExpandableListAdapter.SPECIAL_FOLDERS.ALL_DOWNLOADED_PODCASTS; import static de.luhmer.owncloudnewsreader.ListView.SubscriptionExpandableListAdapter.SPECIAL_FOLDERS.ALL_STARRED_ITEMS; import static de.luhmer.owncloudnewsreader.ListView.SubscriptionExpandableListAdapter.SPECIAL_FOLDERS.ALL_UNREAD_ITEMS; import static de.luhmer.owncloudnewsreader.ListView.SubscriptionExpandableListAdapter.SPECIAL_FOLDERS.ITEMS_WITHOUT_FOLDER; import android.annotation.SuppressLint; import android.content.Context; import android.content.SharedPreferences; import android.os.AsyncTask; import android.util.Log; import android.util.SparseArray; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.BaseExpandableListAdapter; import android.widget.ExpandableListView; import android.widget.LinearLayout; import android.widget.ListView; import androidx.annotation.NonNull; import androidx.core.view.ViewCompat; import java.util.ArrayList; import java.util.List; import de.luhmer.owncloudnewsreader.R; import de.luhmer.owncloudnewsreader.SettingsActivity; import de.luhmer.owncloudnewsreader.database.DatabaseConnectionOrm; import de.luhmer.owncloudnewsreader.database.model.Feed; import de.luhmer.owncloudnewsreader.database.model.Folder; import de.luhmer.owncloudnewsreader.databinding.SubscriptionListItemBinding; import de.luhmer.owncloudnewsreader.databinding.SubscriptionListSubItemBinding; import de.luhmer.owncloudnewsreader.helper.FavIconHandler; import de.luhmer.owncloudnewsreader.helper.StopWatch; import de.luhmer.owncloudnewsreader.interfaces.ExpListTextClicked; import de.luhmer.owncloudnewsreader.model.AbstractItem; import de.luhmer.owncloudnewsreader.model.ConcreteFeedItem; import de.luhmer.owncloudnewsreader.model.FolderSubscribtionItem; import de.luhmer.owncloudnewsreader.model.Tuple; public class SubscriptionExpandableListAdapter extends BaseExpandableListAdapter { private final String TAG = getClass().getCanonicalName(); private final Context mContext; private final DatabaseConnectionOrm dbConn; private final ListView listView; private ExpListTextClicked eListTextClickHandler; private final FavIconHandler favIconHandler; private ArrayList mCategoriesArrayList; private SparseArray> mItemsArrayList; private boolean showOnlyUnread = false; private SparseArray starredCountFeeds; private int downloadedPodcastsCount; private SparseArray unreadCountFolders; private SparseArray unreadCountFeeds; private final SharedPreferences mPrefs; public enum SPECIAL_FOLDERS { ALL_UNREAD_ITEMS(-10), ALL_STARRED_ITEMS(-11), ALL_ITEMS(-12), ALL_DOWNLOADED_PODCASTS(-13), ITEMS_WITHOUT_FOLDER(-22); private final int id; SPECIAL_FOLDERS(int id) { this.id = id; } public int getValue() { return id; } public String getValueString() { return String.valueOf(id); } @Override public String toString() { return getValueString(); } } public SubscriptionExpandableListAdapter(Context mContext, DatabaseConnectionOrm dbConn, ListView listView, SharedPreferences prefs) { this.favIconHandler = new FavIconHandler(mContext); this.mPrefs = prefs; this.mContext = mContext; this.dbConn = dbConn; unreadCountFeeds = new SparseArray<>(); unreadCountFolders = new SparseArray<>(); starredCountFeeds = new SparseArray<>(); downloadedPodcastsCount = 0; mCategoriesArrayList = new ArrayList<>(); mItemsArrayList = new SparseArray<>(); this.listView = listView; } @Override public Object getChild(int groupPosition, int childPosition) { int parent_id = (int)getGroupId(groupPosition); return mItemsArrayList.get(parent_id).get(childPosition); } @Override public long getChildId(int groupPosition, int childPosition) { return ((ConcreteFeedItem)(getChild(groupPosition, childPosition))).id_database; } @Override public View getChildView(int groupPosition, int childPosition, boolean isLastChild, View convertView, ViewGroup parent) { final ConcreteFeedItem item = (ConcreteFeedItem)getChild(groupPosition, childPosition); final ChildHolder viewHolder; if (convertView != null) { viewHolder = (ChildHolder) convertView.getTag(); } else { LinearLayout view = new LinearLayout(mContext); SubscriptionListSubItemBinding binding = SubscriptionListSubItemBinding.inflate(LayoutInflater.from(mContext), view, true); convertView = binding.getRoot(); viewHolder = new ChildHolder(binding); convertView.setTag(viewHolder); } if (item != null) { String headerText = (item.header != null) ? item.header : ""; viewHolder.binding.summary.setText(headerText); String unreadCount; if (item.idFolder == ALL_STARRED_ITEMS.getValue()) { unreadCount = starredCountFeeds.get((int) item.id_database); } else { unreadCount = unreadCountFeeds.get((int) item.id_database); } viewHolder.binding.tvUnreadCount.setText(unreadCount != null ? unreadCount : ""); favIconHandler.loadFavIconForFeed(item.favIcon, viewHolder.binding.iVFavicon); } else { viewHolder.binding.summary.setText(mContext.getString(R.string.login_dialog_text_something_went_wrong)); viewHolder.binding.tvUnreadCount.setText(""); viewHolder.binding.iVFavicon.setImageDrawable(null); } return convertView; } static class ChildHolder { @NonNull final SubscriptionListSubItemBinding binding; public ChildHolder(@NonNull SubscriptionListSubItemBinding binding) { this.binding = binding; } } @Override public int getChildrenCount(int groupPosition) { int parent_id = (int)getGroupId(groupPosition); return (mItemsArrayList.get(parent_id) != null) ? mItemsArrayList.get(parent_id).size() : 0; } @Override public Object getGroup(int groupPosition) { return mCategoriesArrayList.get(groupPosition); } @Override public int getGroupCount() { return mCategoriesArrayList.size(); } @Override public long getGroupId(int groupPosition) { return ((AbstractItem)getGroup(groupPosition)).id_database; } private enum GroupViewType { FOLDER, FEED } @Override public int getGroupType(int groupPosition) { AbstractItem ai = mCategoriesArrayList.get(groupPosition); if(ai instanceof FolderSubscribtionItem) return GroupViewType.FOLDER.ordinal(); else return GroupViewType.FEED.ordinal(); } @Override public int getGroupTypeCount() { return GroupViewType.values().length; } @Override public View getGroupView(final int groupPosition, final boolean isExpanded, View convertView, ViewGroup parent) { GroupHolder viewHolder; final AbstractItem group = (AbstractItem) getGroup(groupPosition); if (convertView == null) { SubscriptionListItemBinding binding = SubscriptionListItemBinding.inflate(LayoutInflater.from(mContext), new LinearLayout(mContext), true); viewHolder = new GroupHolder(binding); convertView = binding.getRoot(); binding.getRoot().setTag(viewHolder); } else { viewHolder = (GroupHolder) convertView.getTag(); } viewHolder.binding.summary.setText(group.header); viewHolder.binding.listItemLayout.setOnClickListener(v -> { long idFeed = group.id_database; boolean skipFireEvent = false; if (group instanceof ConcreteFeedItem) { fireListTextClicked(idFeed, false, (long) ITEMS_WITHOUT_FOLDER.getValue()); skipFireEvent = true; } if (!skipFireEvent) fireListTextClicked(idFeed, true, group.idFolder); }); viewHolder.binding.listItemLayout.setOnLongClickListener(v -> { long idFeed = group.id_database; if (group instanceof ConcreteFeedItem) { fireListTextLongClicked(idFeed, false, (long) ITEMS_WITHOUT_FOLDER.getValue()); } else { fireListTextLongClicked(idFeed, true, group.idFolder); } return true; //consume event }); viewHolder.binding.tVFeedsCount.setText(""); boolean skipGetUnread = false; if(group.idFolder != null && group.idFolder == ITEMS_WITHOUT_FOLDER.getValue()) { String unreadCount = unreadCountFeeds.get((int) group.id_database); if(unreadCount != null) { viewHolder.binding.tVFeedsCount.setText(unreadCount); } skipGetUnread = true; } if (!skipGetUnread) { String unreadCount = unreadCountFolders.get((int) group.id_database); if (unreadCount != null) { viewHolder.binding.tVFeedsCount.setText(unreadCount); } } if (group.id_database == ALL_DOWNLOADED_PODCASTS.getValue()) { viewHolder.binding.tVFeedsCount.setText(String.valueOf(downloadedPodcastsCount)); } int rotation = 0; int contentDescriptionId = R.string.content_desc_none; if (group.idFolder != null) { viewHolder.binding.imgViewExpandableIndicator.setVisibility(View.GONE); if (group.idFolder == ITEMS_WITHOUT_FOLDER.getValue()) { ConcreteFeedItem concreteFeedItem = ((ConcreteFeedItem) group); favIconHandler.loadFavIconForFeed(concreteFeedItem.favIcon, viewHolder.binding.imgViewFavicon); } } else { if(group.id_database == ALL_STARRED_ITEMS.getValue()) { viewHolder.binding.imgViewExpandableIndicator.setVisibility(View.GONE); viewHolder.binding.imgViewFavicon.setVisibility(View.VISIBLE); rotation = 0; viewHolder.binding.imgViewFavicon.setImageResource(R.drawable.ic_star_border_24dp_theme_aware); } else if(group.id_database == ALL_DOWNLOADED_PODCASTS.getValue()) { viewHolder.binding.imgViewExpandableIndicator.setVisibility(View.GONE); viewHolder.binding.imgViewFavicon.setVisibility(View.VISIBLE); viewHolder.binding.imgViewFavicon.setImageResource(R.drawable.ic_baseline_play_arrow_24_theme_aware); } else if (getChildrenCount( groupPosition ) == 0 ) { viewHolder.binding.imgViewExpandableIndicator.setVisibility(View.GONE); viewHolder.binding.imgViewFavicon.setVisibility(View.INVISIBLE); } else { viewHolder.binding.imgViewExpandableIndicator.setVisibility(View.VISIBLE); viewHolder.binding.imgViewFavicon.setVisibility(View.INVISIBLE); viewHolder.binding.imgViewExpandableIndicator.setImageResource(R.drawable.ic_action_expand_less_24); if(isExpanded) { rotation = 180; contentDescriptionId = R.string.content_desc_collapse; } else { if (ViewCompat.getLayoutDirection(listView) == ViewCompat.LAYOUT_DIRECTION_RTL) { rotation = -90; // mirror for rtl layout } else { rotation = 90; } contentDescriptionId = R.string.content_desc_expand; } viewHolder.binding.imgViewExpandableIndicator.setOnClickListener(v -> { if(isExpanded) ((ExpandableListView)listView).collapseGroup(groupPosition); else ((ExpandableListView)listView).expandGroup(groupPosition); }); } } viewHolder.binding.imgViewExpandableIndicator.setRotation(rotation); viewHolder.binding.imgViewExpandableIndicator.setContentDescription(viewHolder.binding.imgViewExpandableIndicator.getContext().getString(contentDescriptionId)); return convertView; } static class GroupHolder { @NonNull final SubscriptionListItemBinding binding; public GroupHolder(@NonNull SubscriptionListItemBinding binding) { this.binding = binding; } } @Override public boolean hasStableIds() { return false; } @Override public boolean isChildSelectable(int groupPosition, int childPosition) { return true; } public void notifyDataSetChangedAsync() { new NotifyDataSetChangedAsyncTask().execute((Void) null); } /** * Reload categories and items from the database */ public Tuple, SparseArray>> loadCategoriesAndItemsFromDatabase() { showOnlyUnread = mPrefs.getBoolean(SettingsActivity.CB_SHOWONLYUNREAD_STRING, false); ArrayList mCategories = new ArrayList<>(); mCategories.add(new FolderSubscribtionItem(mContext.getString(R.string.allUnreadFeeds), null, ALL_UNREAD_ITEMS.getValue())); mCategories.add(new FolderSubscribtionItem(mContext.getString(R.string.starredFeeds), null, ALL_STARRED_ITEMS.getValue())); mCategories.add(new FolderSubscribtionItem(mContext.getString(R.string.downloadedPodcasts), null, ALL_DOWNLOADED_PODCASTS.getValue())); StopWatch sw = new StopWatch(); sw.start(); List folderList = dbConn.getListOfFolders(); sw.stop(); Log.v(TAG, "Time needed (fetch folder list): " + sw); for (Folder folder : folderList) { mCategories.add(new FolderSubscribtionItem(folder.getLabel(), null, folder.getId())); } for (Feed feed : dbConn.getListOfFeedsWithoutFolders(showOnlyUnread)) { mCategories.add(new ConcreteFeedItem(feed.getFeedTitle(), (long) ITEMS_WITHOUT_FOLDER.getValue(), feed.getId(), feed.getFaviconUrl(), feed.getId())); } SparseArray> mItems = new SparseArray<>(); for (int groupPosition = 0; groupPosition < mCategories.size(); groupPosition++) { //int parent_id = (int)getGroupId(groupPosition); int parent_id = (int) mCategories.get(groupPosition).id_database; mItems.append(parent_id, new ArrayList<>()); List feedItemList = null; if (parent_id == ALL_UNREAD_ITEMS.getValue()) { feedItemList = dbConn.getAllFeedsWithUnreadRssItems(); } else if (parent_id == ALL_STARRED_ITEMS.getValue()) { feedItemList = dbConn.getAllFeedsWithStarredRssItems(); } else if (parent_id == ALL_DOWNLOADED_PODCASTS.getValue()) { feedItemList = dbConn.getAllFeedsWithDownloadedPodcasts(mContext); } else { for (Folder folder : folderList) {//Find the current selected folder if (folder.getId() == parent_id) {//Current item feedItemList = dbConn.getAllFeedsWithUnreadRssItemsForFolder(folder.getId()); break; } } } if(feedItemList != null) { for (Feed feed : feedItemList) { ConcreteFeedItem newItem = new ConcreteFeedItem(feed.getFeedTitle(), (long) parent_id, feed.getId(), feed.getFaviconUrl(), feed.getId()); mItems.get(parent_id).add(newItem); } } } return new Tuple<>(mCategories, mItems); } public void ReloadAdapterAsync() { new ReloadAdapterAsyncTask().execute((Void) null); } @SuppressLint("NewApi") // wrongly reports setSelectionFromTop is only available in lollipop public void notifyCountDataSetChanged(SparseArray unreadCountFolders, SparseArray unreadCountFeeds, SparseArray starredCountFeeds, int downloadedPodcastsCount) { this.unreadCountFolders = unreadCountFolders; this.unreadCountFeeds = unreadCountFeeds; this.starredCountFeeds = starredCountFeeds; this.downloadedPodcastsCount = downloadedPodcastsCount; BlockingExpandableListView bView = (BlockingExpandableListView) listView; int firstVisPos = bView.getFirstVisiblePosition(); View firstVisView = bView.getChildAt(0); int top = firstVisView != null ? firstVisView.getTop() : 0; // Number of items added before the first visible item int itemsAddedBeforeFirstVisible = 0; bView.setBlockLayoutChildren(true); notifyDataSetChanged(); bView.setBlockLayoutChildren(false); // Call setSelectionFromTop to change the ListView position if(bView.getCount() >= firstVisPos + itemsAddedBeforeFirstVisible) bView.setSelectionFromTop(firstVisPos + itemsAddedBeforeFirstVisible, top); } private class ReloadAdapterAsyncTask extends AsyncTask, SparseArray>>> { @Override protected Tuple, SparseArray>> doInBackground(Void... voids) { StopWatch stopWatch = new StopWatch(); stopWatch.start(); Tuple, SparseArray>> ad = loadCategoriesAndItemsFromDatabase(); stopWatch.stop(); Log.v(TAG, "Reload Adapter - time taken: " + stopWatch); return ad; } @Override protected void onPostExecute(Tuple, SparseArray>> arrayListSparseArrayTuple) { mCategoriesArrayList = arrayListSparseArrayTuple.key; mItemsArrayList = arrayListSparseArrayTuple.value; notifyDataSetChanged(); // inform list view that the data changed notifyDataSetChangedAsync(); super.onPostExecute(arrayListSparseArrayTuple); } } private class NotifyDataSetChangedAsyncTask extends AsyncTask { SparseArray starredCountFeedsTemp; SparseArray unreadCountFoldersTemp; SparseArray unreadCountFeedsTemp; SparseArray urlsToFavIconsTemp; int downloadedPodcastsCountTemp; @Override protected Void doInBackground(Void... voids) { StopWatch stopwatch = new StopWatch(); stopwatch.start(); SparseArray[] temp = dbConn.getUnreadItemCountFeedFolder(); unreadCountFoldersTemp = temp[0];// dbConn.getUnreadItemCountForFolder(); unreadCountFeedsTemp = temp[1]; // dbConn.getUnreadItemCountForFeed(); starredCountFeedsTemp = dbConn.getStarredItemCount(); downloadedPodcastsCountTemp = dbConn.getDownloadedPodcastsCount(mContext); urlsToFavIconsTemp = dbConn.getUrlsToFavIcons(); stopwatch.stop(); Log.v(TAG, "Fetched folder/feed counts in " + stopwatch); return null; } @Override protected void onPostExecute(Void aVoid) { if(showOnlyUnread) { for (int i = 0; i < mCategoriesArrayList.size(); i++) { AbstractItem item = mCategoriesArrayList.get(i); if(item instanceof FolderSubscribtionItem && unreadCountFoldersTemp.get(((Long) item.id_database).intValue()) == null) { Log.v(TAG, "Remove folder item: " + item.header); // we need to keep the ALL_DOWNLOADED_PODCASTS in case at least one article is in there if (!(item.id_database == ALL_DOWNLOADED_PODCASTS.getValue() && downloadedPodcastsCount > 0)) { mCategoriesArrayList.remove(i); i--; } } else if(item instanceof ConcreteFeedItem && unreadCountFeedsTemp.get(((Long) item.id_database).intValue()) == null) { Log.v(TAG, "Remove feed item: " + item.header); mCategoriesArrayList.remove(i); i--; } /* else { Log.v(TAG, "Keep.. " + unreadCountFoldersTemp.get(((Long) item.id_database).intValue())); } */ } for (int i = 0; i < mItemsArrayList.size(); i++) { ArrayList item = mItemsArrayList.valueAt(i); for (int x = 0; x < item.size(); x++) { if (unreadCountFeedsTemp.get((int) item.get(x).id_database) == null) { item.remove(x); x--; Log.v(TAG, "Remove sub feed!!"); } } } } notifyCountDataSetChanged(unreadCountFoldersTemp, unreadCountFeedsTemp, starredCountFeedsTemp, downloadedPodcastsCountTemp); super.onPostExecute(aVoid); } } public void setHandlerListener(ExpListTextClicked listener) { eListTextClickHandler = listener; } protected void fireListTextClicked(long idFeed, boolean isFolder, Long optional_folder_id) { if(eListTextClickHandler != null) eListTextClickHandler.onTextClicked(idFeed, isFolder, optional_folder_id); } protected void fireListTextLongClicked(long idFeed, boolean isFolder, Long optional_folder_id) { if(eListTextClickHandler != null) eListTextClickHandler.onTextLongClicked(idFeed, isFolder, optional_folder_id); } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/LoginDialogActivity.java ================================================ /* * Android ownCloud News * * @author David Luhmer * @copyright 2013 David Luhmer david-dev@live.de * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE * License as published by the Free Software Foundation; either * version 3 of the License, or any later version. * * This library 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 AFFERO GENERAL PUBLIC LICENSE for more details. * * You should have received a copy of the GNU Affero General Public * License along with this library. If not, see . * */ package de.luhmer.owncloudnewsreader; import static java.util.Objects.requireNonNull; import static de.luhmer.owncloudnewsreader.Constants.MIN_NEXTCLOUD_FILES_APP_VERSION_CODE; import android.annotation.SuppressLint; import android.app.Activity; import android.app.ProgressDialog; import android.content.Intent; import android.content.SharedPreferences; import android.content.SharedPreferences.Editor; import android.os.Bundle; import android.text.Editable; import android.text.InputType; import android.text.SpannableString; import android.text.TextUtils; import android.text.TextWatcher; import android.text.method.LinkMovementMethod; import android.text.util.Linkify; import android.util.Log; import android.util.Patterns; import android.view.View; import android.widget.TextView; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; import com.nextcloud.android.sso.AccountImporter; import com.nextcloud.android.sso.FilesAppTypeRegistry; import com.nextcloud.android.sso.api.NextcloudAPI; import com.nextcloud.android.sso.exceptions.AccountImportCancelledException; import com.nextcloud.android.sso.exceptions.AndroidGetAccountsPermissionNotGranted; import com.nextcloud.android.sso.exceptions.NextcloudFilesAppNotInstalledException; import com.nextcloud.android.sso.exceptions.NextcloudHttpRequestFailedException; import com.nextcloud.android.sso.helper.SingleAccountHelper; import com.nextcloud.android.sso.helper.VersionCheckHelper; import com.nextcloud.android.sso.model.SingleSignOnAccount; import com.nextcloud.android.sso.ui.UiExceptionManager; import java.net.MalformedURLException; import java.net.URL; import javax.inject.Inject; import de.luhmer.owncloudnewsreader.database.DatabaseConnectionOrm; import de.luhmer.owncloudnewsreader.databinding.ActivityLoginDialogBinding; import de.luhmer.owncloudnewsreader.di.ApiProvider; import de.luhmer.owncloudnewsreader.model.NextcloudNewsVersion; import de.luhmer.owncloudnewsreader.ssl.MemorizingTrustManager; import de.luhmer.owncloudnewsreader.ssl.OkHttpSSLClient; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.annotations.NonNull; import io.reactivex.rxjava3.core.Observer; import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.schedulers.Schedulers; /** * Activity which displays a login screen to the user, offering registration as * well. */ public class LoginDialogActivity extends AppCompatActivity { private final String TAG = LoginDialogActivity.class.getCanonicalName(); public static final int RESULT_LOGIN = 16000; private final TextWatcher PasswordTextChangedListener = new TextWatcher() { @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) { } @Override public void onTextChanged(CharSequence s, int start, int before, int count) { } @Override public void afterTextChanged(Editable s) { } }; protected @Inject SharedPreferences mPrefs; protected @Inject MemorizingTrustManager mMemorizingTrustManager; //private UserLoginTask mAuthTask = null; // Values for email and password at the time of the login attempt. private String mUsername; private String mPassword; private String mOc_root_path; // UI references. protected ActivityLoginDialogBinding binding; private SingleSignOnAccount importedAccount = null; private boolean mPasswordVisible = false; private final View.OnClickListener TogglePasswordVisibilityListener = new View.OnClickListener() { @Override public void onClick(View v) { int lastSelection = binding.password.getSelectionEnd(); mPasswordVisible = !mPasswordVisible; if (mPasswordVisible) { binding.password.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD); } else { binding.password.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD); } binding.password.setSelection(lastSelection); } }; /** * Keep track of the login task to ensure we can cancel it if requested. */ protected @Inject ApiProvider mApi; @Override protected void onStart() { super.onStart(); mMemorizingTrustManager.bindDisplayActivity(this); } @Override protected void onStop() { mMemorizingTrustManager.unbindDisplayActivity(this); super.onStop(); } public void startSingleSignOn() { var type = FilesAppTypeRegistry.getInstance().findByAccountType("nextcloud"); // prod if (!VersionCheckHelper.verifyMinVersion(LoginDialogActivity.this, MIN_NEXTCLOUD_FILES_APP_VERSION_CODE, type)) { // Dialog will be shown automatically return; } binding.oldLoginWrapper.setVisibility(View.GONE); try { AccountImporter.pickNewAccount(LoginDialogActivity.this); } catch (NextcloudFilesAppNotInstalledException e) { UiExceptionManager.showDialogForException(LoginDialogActivity.this, e); } catch (AndroidGetAccountsPermissionNotGranted e) { AccountImporter.requestAndroidAccountPermissionsAndPickAccount(this); } } public void startManualLogin() { attemptLogin(); } public void manualLogin() { binding.oldLoginWrapper.setVisibility(View.VISIBLE); } @Override public void onCreate(Bundle savedInstance) { super.onCreate(savedInstance); ((NewsReaderApplication) getApplication()).getAppComponent().injectActivity(this); binding = ActivityLoginDialogBinding.inflate(getLayoutInflater()); setContentView(binding.getRoot()); binding.btnSingleSignOn.setOnClickListener((v) -> startSingleSignOn()); binding.btnLogin.setOnClickListener((v) -> startManualLogin()); binding.tvManualLogin.setOnClickListener((v) -> manualLogin()); // Manual Login binding.passwordContainer.setEndIconOnClickListener(TogglePasswordVisibilityListener); binding.password.addTextChangedListener(PasswordTextChangedListener); mUsername = mPrefs.getString(SettingsActivity.EDT_USERNAME_STRING, ""); mPassword = mPrefs.getString(SettingsActivity.EDT_PASSWORD_STRING, ""); mOc_root_path = mPrefs.getString(SettingsActivity.EDT_OWNCLOUDROOTPATH_STRING, ""); boolean mCbDisableHostnameVerification = mPrefs.getBoolean(SettingsActivity.CB_DISABLE_HOSTNAME_VERIFICATION_STRING, false); // Set up the login form. binding.username.setText(mUsername); binding.password.setText(mPassword); binding.edtOwncloudRootPath.setText(mOc_root_path); binding.cbAllowAllSSLCertificates.setChecked(mCbDisableHostnameVerification); binding.cbAllowAllSSLCertificates.setOnCheckedChangeListener((buttonView, isChecked) -> mPrefs.edit() .putBoolean(SettingsActivity.CB_DISABLE_HOSTNAME_VERIFICATION_STRING, isChecked) .commit()); } @Override public void onBackPressed() { if (mPrefs.getString(SettingsActivity.EDT_OWNCLOUDROOTPATH_STRING, null) == null) { // exit application if no account is set uo finishAffinity(); } else { // go back to previous activity super.onBackPressed(); } } private ProgressDialog buildPendingDialogWhileLoggingIn() { ProgressDialog pDialog = new ProgressDialog(this); pDialog.setTitle(getString(R.string.login_progress_signing_in)); return pDialog; } private void loginSingleSignOn() { final ProgressDialog dialogLogin = buildPendingDialogWhileLoggingIn(); dialogLogin.show(); Editor editor = mPrefs.edit(); editor.putString(SettingsActivity.EDT_OWNCLOUDROOTPATH_STRING, importedAccount.url); editor.putString(SettingsActivity.EDT_PASSWORD_STRING, importedAccount.token); editor.putString(SettingsActivity.EDT_USERNAME_STRING, importedAccount.name); editor.putBoolean(SettingsActivity.SW_USE_SINGLE_SIGN_ON, true); editor.commit(); resetDatabase(); SingleAccountHelper.commitCurrentAccount(this, importedAccount.name); mApi.initApi(new NextcloudAPI.ApiConnectedListener() { @Override public void onConnected() { Log.d(TAG, "onConnected() called"); finishLogin(dialogLogin); } @Override public void onError(Exception ex) { dialogLogin.dismiss(); Log.d(TAG, "onError() called with: ex = [" + ex + "]"); ShowAlertDialog(getString(R.string.login_dialog_title_error), ex.getMessage(), LoginDialogActivity.this); } }); } /** * Attempts to sign in or register the account specified by the login form. * If there are form errors (invalid email, missing fields, etc.), the * errors are presented and no actual login attempt is made. */ @SuppressLint({"SetTextI18n"}) public void attemptLogin() { // Reset errors. binding.username.setError(null); binding.password.setError(null); binding.edtOwncloudRootPath.setError(null); // Append "https://" is url doesn't contain it already mOc_root_path = requireNonNull(binding.edtOwncloudRootPath.getText()).toString().trim(); if (!mOc_root_path.startsWith("http")) { binding.edtOwncloudRootPath.setText("https://" + mOc_root_path); } // Store values at the time of the login attempt. mUsername = requireNonNull(binding.username.getText()).toString().trim(); mPassword = requireNonNull(binding.password.getText()).toString(); mOc_root_path = binding.edtOwncloudRootPath.getText().toString().trim(); boolean cancel = false; View focusView = null; // Check for a valid password. if (TextUtils.isEmpty(mPassword)) { binding.password.setError(getString(R.string.error_field_required)); focusView = binding.password; cancel = true; } // Check for a valid email address. if (TextUtils.isEmpty(mUsername)) { binding.username.setError(getString(R.string.error_field_required)); focusView = binding.username; cancel = true; } if (TextUtils.isEmpty(mOc_root_path)) { binding.edtOwncloudRootPath.setError(getString(R.string.error_field_required)); focusView = binding.edtOwncloudRootPath; cancel = true; } else { try { URL url = new URL(mOc_root_path); if (!Patterns.WEB_URL.matcher(mOc_root_path).matches()) { throw new MalformedURLException(); } if (!url.getProtocol().equals("https")) { ShowAlertDialog(getString(R.string.login_dialog_title_security_warning), getString(R.string.login_dialog_text_security_warning), this); } } catch (MalformedURLException e) { binding.edtOwncloudRootPath.setError(getString(R.string.error_invalid_url)); focusView = binding.edtOwncloudRootPath; cancel = true; } } if (cancel) { // There was an error; don't attempt login and focus the first // form field with an error. focusView.requestFocus(); } else { Editor editor = mPrefs.edit(); editor.putString(SettingsActivity.EDT_OWNCLOUDROOTPATH_STRING, mOc_root_path); editor.putString(SettingsActivity.EDT_PASSWORD_STRING, mPassword); editor.putString(SettingsActivity.EDT_USERNAME_STRING, mUsername); editor.putBoolean(SettingsActivity.SW_USE_SINGLE_SIGN_ON, false); editor.commit(); resetDatabase(); final ProgressDialog dialogLogin = buildPendingDialogWhileLoggingIn(); dialogLogin.show(); mApi.initApi(new NextcloudAPI.ApiConnectedListener() { @Override public void onConnected() { Log.d(TAG, "onConnected() called"); finishLogin(dialogLogin); } @Override public void onError(Exception ex) { dialogLogin.dismiss(); Log.d(TAG, "onError() called with: ex = [" + ex + "]"); ShowAlertDialog(getString(R.string.login_dialog_title_error), ex.getMessage(), LoginDialogActivity.this); } }); } } private void resetDatabase() { //Reset Database DatabaseConnectionOrm dbConn = new DatabaseConnectionOrm(LoginDialogActivity.this); dbConn.resetDatabase(); } private void finishLogin(final ProgressDialog dialogLogin) { mApi.getNewsAPI().version() .subscribeOn(Schedulers.newThread()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(new Observer<>() { boolean loginSuccessful = false; @Override public void onSubscribe(@NonNull Disposable d) { Log.v(TAG, "onSubscribe() called with: d = [" + d + "]"); } @Override public void onNext(@NonNull NextcloudNewsVersion version) { Log.v(TAG, "onNext() called with: status = [" + version.version + "]"); loginSuccessful = true; mPrefs.edit().putString(Constants.NEWS_WEB_VERSION_NUMBER_STRING, version.version).apply(); if (version.version.equals("0")) { ShowAlertDialog(getString(R.string.login_dialog_title_error), getString(R.string.login_dialog_text_zero_version_code), LoginDialogActivity.this); loginSuccessful = false; } importedAccount = null; } @Override public void onError(@NonNull Throwable e) { dialogLogin.dismiss(); Log.v(TAG, "onError() called with: e = [" + e + "]"); Throwable t = OkHttpSSLClient.HandleExceptions(e); if (t instanceof NextcloudHttpRequestFailedException && ((NextcloudHttpRequestFailedException) t).getStatusCode() == 302) { ShowAlertDialog( getString(R.string.login_dialog_title_error), getString(R.string.login_dialog_text_news_app_not_installed_on_server, "https://github.com/nextcloud/news/blob/master/docs/install.md#installing-from-the-app-store"), LoginDialogActivity.this); } else { ShowAlertDialog(getString(R.string.login_dialog_title_error), t.getMessage(), LoginDialogActivity.this); } } @Override public void onComplete() { dialogLogin.dismiss(); Log.v(TAG, "onComplete() called - Login successful: " + loginSuccessful); if (loginSuccessful) { Intent returnIntent = new Intent(); setResult(RESULT_OK, returnIntent); finish(); } } }); } public static void ShowAlertDialog(String title, String text, Activity activity) { // Linkify the message final SpannableString s = new SpannableString(text != null ? text : activity.getString(R.string.login_dialog_select_account_unknown_error_toast)); Linkify.addLinks(s, Linkify.ALL); AlertDialog aDialog = new AlertDialog.Builder(activity) .setTitle(title) .setMessage(s) .setPositiveButton(activity.getString(android.R.string.ok), null) .create(); aDialog.show(); // Make the textview clickable. Must be called after show() ((TextView) aDialog.findViewById(android.R.id.message)).setMovementMethod(LinkMovementMethod.getInstance()); } @Override public void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); try { AccountImporter.onActivityResult(requestCode, resultCode, data, LoginDialogActivity.this, account -> { LoginDialogActivity.this.importedAccount = account; loginSingleSignOn(); }); } catch (AccountImportCancelledException ignored) { } } @Override public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { super.onRequestPermissionsResult(requestCode, permissions, grantResults); AccountImporter.onRequestPermissionsResult(requestCode, permissions, grantResults, this); } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/NewFeedActivity.java ================================================ package de.luhmer.owncloudnewsreader; import static android.content.pm.PackageManager.PERMISSION_GRANTED; import static java.util.Objects.requireNonNull; import android.Manifest; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.content.ContentResolver; import android.content.ContentValues; import android.content.Context; import android.content.Intent; import android.net.Uri; import android.os.AsyncTask; import android.os.Build; import android.os.Bundle; import android.os.Environment; import android.provider.MediaStore; import android.text.TextUtils; import android.util.Log; import android.util.Xml; import android.view.MenuItem; import android.view.View; import android.view.inputmethod.InputMethodManager; import android.widget.ArrayAdapter; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; import androidx.core.app.ActivityCompat; import androidx.core.content.ContextCompat; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentTransaction; import org.json.JSONException; import org.json.JSONObject; import org.xmlpull.v1.XmlPullParser; import java.io.BufferedReader; import java.io.ByteArrayInputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.OutputStream; import java.io.OutputStreamWriter; import java.net.URL; import java.nio.charset.StandardCharsets; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; import javax.inject.Inject; import de.luhmer.owncloudnewsreader.database.DatabaseConnectionOrm; import de.luhmer.owncloudnewsreader.database.model.Feed; import de.luhmer.owncloudnewsreader.database.model.Folder; import de.luhmer.owncloudnewsreader.databinding.ActivityNewFeedBinding; import de.luhmer.owncloudnewsreader.di.ApiProvider; import de.luhmer.owncloudnewsreader.helper.AsyncTaskHelper; import de.luhmer.owncloudnewsreader.helper.OpmlXmlParser; import de.luhmer.owncloudnewsreader.helper.ThemeChooser; import de.luhmer.owncloudnewsreader.helper.URLConnectionReader; import de.luhmer.owncloudnewsreader.ssl.OkHttpSSLClient; import retrofit2.Call; import retrofit2.Callback; import retrofit2.Response; public class NewFeedActivity extends AppCompatActivity { private static final String TAG = NewFeedActivity.class.getCanonicalName(); public final static String ADD_NEW_SUCCESS = "success"; private static final int PERMISSIONS_REQUEST_WRITE_CODE = 1; private final static int REQUEST_CODE_OPML_IMPORT = 2; // UI references. protected ActivityNewFeedBinding binding; private List folders; protected @Inject ApiProvider mApi; protected boolean useMediaStore = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q; @NonNull public static String convertStreamToString(InputStream is) throws Exception { BufferedReader reader = new BufferedReader(new InputStreamReader(is)); StringBuilder sb = new StringBuilder(); String line; while ((line = reader.readLine()) != null) { sb.append(line).append("\n"); } reader.close(); return sb.toString(); } public static String getStringFromFile(String filePath) throws Exception { File fl = new File(filePath); FileInputStream fin = new FileInputStream(fl); String ret = convertStreamToString(fin); //Make sure you close all streams. fin.close(); return ret; } @Override protected void onCreate(Bundle savedInstanceState) { ((NewsReaderApplication) getApplication()).getAppComponent().injectActivity(this); ThemeChooser.chooseTheme(this); super.onCreate(savedInstanceState); ThemeChooser.afterOnCreate(this); binding = ActivityNewFeedBinding.inflate(getLayoutInflater()); setContentView(binding.getRoot()); binding.btnAddFeed.setOnClickListener((v) -> btnAddFeedClick()); binding.btnImportOpml.setOnClickListener((v) -> importOpml()); binding.btnExportOpml.setOnClickListener((v) -> exportOpml()); setSupportActionBar(binding.toolbarLayout.toolbar); requireNonNull(getSupportActionBar()).setDisplayHomeAsUpEnabled(true); DatabaseConnectionOrm dbConn = new DatabaseConnectionOrm(this); folders = dbConn.getListOfFolders(); Folder rootFolder = new Folder(0, getString(R.string.move_feed_root_folder)); if (folders.isEmpty()) { // list is of type EmptyList and is not modifiable - therefore create a new modifiable list folders = new ArrayList<>(); } folders.add(0, rootFolder); String[] folderNames = folders.stream().map(Folder::getLabel).toArray(String[]::new); ArrayAdapter spinnerArrayAdapter = new ArrayAdapter<>(this, android.R.layout.simple_spinner_dropdown_item, folderNames); binding.spFolder.setAdapter(spinnerArrayAdapter); Intent intent = getIntent(); String action = intent.getAction(); if (action != null) { String url = ""; if (action.equals(Intent.ACTION_VIEW)) { url = intent.getDataString(); } else if (action.equals(Intent.ACTION_SEND)) { url = intent.getStringExtra(Intent.EXTRA_TEXT); } try { validatePathOrThrowException(url); if (url.endsWith(".opml")) { AsyncTaskHelper.StartAsyncTask(new ImportOpmlSubscriptionsTask(url, NewFeedActivity.this)); } // String scheme = intent.getScheme(); // ContentResolver resolver = getContentResolver(); // Uri uri = intent.getData(); Log.v("tag", "Content intent detected: " + action + " : " + url); binding.etFeedUrl.setText(url); } catch (IllegalStateException e) { Log.e(TAG, e.getMessage()); showAlertDialog(e.getMessage()); } } } private void showAlertDialog(String text) { new AlertDialog.Builder(this) .setMessage(text) .setTitle(getString(R.string.opml_export)) .setNeutralButton(getString(android.R.string.ok), null) .create() .show(); } private void validatePathOrThrowException(String path) { // Prevent java/path-injection // https://github.com/nextcloud/news-android/security/code-scanning/5 if (path == null) { throw new IllegalStateException("Path is empty"); } else if (path.contains("..")) { throw new IllegalStateException("Path contains forbidden character"); } } private void openFilePicker() { startActivityForResult(new Intent(Intent.ACTION_GET_CONTENT).addCategory(Intent.CATEGORY_OPENABLE).setType("*/*"), REQUEST_CODE_OPML_IMPORT); } public void btnAddFeedClick() { // Hide keyboard InputMethodManager imm = (InputMethodManager) getSystemService( Context.INPUT_METHOD_SERVICE); imm.hideSoftInputFromWindow(binding.etFeedUrl.getWindowToken(), 0); attemptAddNewFeed(); } public void importOpml() { openFilePicker(); } @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); if (requestCode == REQUEST_CODE_OPML_IMPORT && resultCode == RESULT_OK) { final Uri importUri = data.getData(); switch (importUri.getScheme()) { case ContentResolver.SCHEME_CONTENT: case ContentResolver.SCHEME_FILE: new Thread(() -> { final File cacheFile = new File(getCacheDir().getAbsolutePath() + "/import.opml"); byte[] buffer = new byte[4096]; try ( final InputStream inputStream = getContentResolver().openInputStream(importUri); final FileOutputStream outputStream = new FileOutputStream(cacheFile) ) { int count; while ((count = inputStream.read(buffer)) > 0) { outputStream.write(buffer, 0, count); } runOnUiThread(() -> AsyncTaskHelper.StartAsyncTask(new ImportOpmlSubscriptionsTask(cacheFile.getAbsolutePath(), NewFeedActivity.this))); } catch (IOException e) { e.printStackTrace(); Toast.makeText(this, e.getLocalizedMessage(), Toast.LENGTH_LONG).show(); } }).start(); break; default: Toast.makeText(this, "Unknown URI scheme: " + importUri.getScheme(), Toast.LENGTH_LONG).show(); } } } public void exportOpml() { String permission = Manifest.permission.WRITE_EXTERNAL_STORAGE; if (useMediaStore) { exportOpmlFile(); } else { if (ContextCompat.checkSelfPermission(this, permission) != PERMISSION_GRANTED) { if (ActivityCompat.shouldShowRequestPermissionRationale(this, permission)) { Toast.makeText(this, "Please enable \"Write\" permission for Files and Media for the Nextcloud News App", Toast.LENGTH_SHORT).show(); } else { ActivityCompat.requestPermissions(this, new String[]{permission}, PERMISSIONS_REQUEST_WRITE_CODE); } } else { exportOpmlFile(); } } } private void exportOpmlFile() { String xml = OpmlXmlParser.GenerateOPML(this); SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd"); String filename = "subscriptions-" + format.format(new Date()) + ".opml"; try { String path = ""; if (useMediaStore) { ContentResolver contentResolver = getContentResolver(); ContentValues contentValues = new ContentValues(); contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, filename); contentValues.put(MediaStore.MediaColumns.MIME_TYPE, "application/xml"); contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS); Uri uri = contentResolver.insert(MediaStore.Files.getContentUri("external"), contentValues); path = "/storage/Downloads/" + filename; // in case we use MediaStore we can't get the cleartext path OutputStream out = contentResolver.openOutputStream(uri); out.write(xml.getBytes(StandardCharsets.UTF_8)); out.close(); } else { File fPath = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), filename); path = fPath.getPath(); FileOutputStream fos = new FileOutputStream(fPath); OutputStreamWriter outputStreamWriter = new OutputStreamWriter(fos); outputStreamWriter.write(xml); outputStreamWriter.close(); fos.close(); } showAlertDialog(getString(R.string.successfully_exported) + " " + path); } catch (IOException e) { Log.e("Exception", "File write failed: " + e); showAlertDialog("Failed to export OPML - please report this issue - " + e.getMessage()); } } @Override public void onRequestPermissionsResult( int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults ) { super.onRequestPermissionsResult(requestCode, permissions, grantResults); // check if user granted the requested permission if (grantResults.length > 0 && grantResults[0] == PERMISSION_GRANTED) { if (requestCode == PERMISSIONS_REQUEST_WRITE_CODE) { // user tried to export OPML -> retry after the permission has been granted exportOpml(); } } } public static String truncate(String str, int len) { if (str.length() > len) { return str.substring(0, len) + "..."; } else { return str; } } private boolean isUrlValid(String url) { try { new URL(url); return true; } catch (Exception ex) { ex.printStackTrace(); } return false; } /** * Attempts to sign in or register the account specified by the login form. * If there are form errors (invalid email, missing fields, etc.), the * errors are presented and no actual login attempt is made. */ public void attemptAddNewFeed() { Folder folder = folders.get(binding.spFolder.getSelectedItemPosition()); // Reset errors. binding.etFeedUrl.setError(null); // Store values at the time of the login attempt. String urlToFeed = binding.etFeedUrl.getText().toString(); boolean cancel = false; View focusView = null; // Check for a valid email address. if (TextUtils.isEmpty(urlToFeed)) { binding.etFeedUrl.setError(getString(R.string.error_field_required)); focusView = binding.etFeedUrl; cancel = true; } else if (!isUrlValid(urlToFeed)) { binding.etFeedUrl.setError(getString(R.string.error_invalid_url)); focusView = binding.etFeedUrl; cancel = true; } if (cancel) { // There was an error; don't attempt login and focus the first // form field with an error. focusView.requestFocus(); } else { // Show a progress spinner, and kick off a background task to // perform the user login attempt. showProgress(true); mApi.getNewsAPI().createFeed(urlToFeed, folder.getId()).enqueue(new Callback>() { @Override public void onResponse(@NonNull Call> call, @NonNull final Response> response) { runOnUiThread(() -> { showProgress(false); if (response.isSuccessful()) { Intent returnIntent = new Intent(); returnIntent.putExtra(ADD_NEW_SUCCESS, true); setResult(RESULT_OK, returnIntent); finish(); } else { try { String errorMessage = response.errorBody().string(); try { //Log.e(TAG, errorMessage); JSONObject jObjError = new JSONObject(errorMessage); errorMessage = jObjError.getString("message"); errorMessage = truncate(errorMessage, 150); } catch (JSONException e) { Log.e(TAG, "Extracting error message failed: " + errorMessage, e); } binding.etFeedUrl.setError(errorMessage); Log.e(TAG, errorMessage); } catch (IOException e) { Log.e(TAG, "IOException", e); binding.etFeedUrl.setError(getString(R.string.login_dialog_text_something_went_wrong)); } binding.etFeedUrl.requestFocus(); } }); } @Override public void onFailure(@NonNull Call> call, @NonNull final Throwable t) { runOnUiThread(() -> { showProgress(false); binding.etFeedUrl.setError(getString(R.string.login_dialog_text_something_went_wrong) + " - " + OkHttpSSLClient.HandleExceptions((Exception) t).getMessage()); binding.etFeedUrl.requestFocus(); }); } }); } } @Override public boolean onOptionsItemSelected(MenuItem item) { // Respond to the action bar's Up/Home button if (item.getItemId() == android.R.id.home) {//NavUtils.navigateUpFromSameTask(this); finish(); return true; } else { Log.v(TAG, "Unknown option selected.."); } return super.onOptionsItemSelected(item); } /** * Shows the progress UI and hides the login form. */ public void showProgress(final boolean show) { int shortAnimTime = getResources().getInteger(android.R.integer.config_shortAnimTime); binding.newFeedForm.setVisibility(show ? View.GONE : View.VISIBLE); binding.newFeedForm.animate().setDuration(shortAnimTime).alpha( show ? 0 : 1).setListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { binding.newFeedForm.setVisibility(show ? View.GONE : View.VISIBLE); } }); binding.newFeedProgress.setVisibility(show ? View.VISIBLE : View.GONE); binding.newFeedProgress.animate().setDuration(shortAnimTime).alpha( show ? 1 : 0).setListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { binding.newFeedProgress.setVisibility(show ? View.VISIBLE : View.GONE); } }); } public class ImportOpmlSubscriptionsTask extends AsyncTask, Boolean> { private final String mUrlToFile; private HashMap extractedUrls; private NewsReaderOPMLImportDialogFragment pd; private final Context mContext; ImportOpmlSubscriptionsTask(String urlToFile, Context context) { this.mUrlToFile = urlToFile; this.mContext = context; } @Override protected void onPreExecute() { FragmentTransaction ft = getSupportFragmentManager().beginTransaction(); Fragment prev = getSupportFragmentManager().findFragmentByTag("news_reader_opml_import_dialog"); if (prev != null) { ft.remove(prev); } ft.addToBackStack(null); pd = NewsReaderOPMLImportDialogFragment.newInstance(false); pd.show(ft, "news_reader_opml_import_dialog"); super.onPreExecute(); } @Override protected Boolean doInBackground(Void... params) { try { // wait for NewsReaderOPMLImportDialogFragment to be visible Thread.sleep(500); } catch (InterruptedException e) { e.printStackTrace(); } String opmlContent; try { if (mUrlToFile.startsWith("http")) {//http[s] opmlContent = URLConnectionReader.getText(mUrlToFile); } else { opmlContent = getStringFromFile(mUrlToFile); } InputStream is = new ByteArrayInputStream(opmlContent.getBytes()); XmlPullParser parser = Xml.newPullParser(); parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false); parser.setInput(is, null); parser.nextTag(); extractedUrls = OpmlXmlParser.ReadFeed(parser); List result = new ArrayList<>(); publishProgress(new ArrayList<>(result)); final HashMap existingFolders = new HashMap<>(); mApi.getNewsAPI().folders().blockingSubscribe(folders -> { for (Folder folder : folders) { existingFolders.put(folder.getLabel(), folder.getId()); } }); for (String feedUrl : extractedUrls.keySet()) { long folderId = 0; //id of the parent folder, 0 for root String folderName = extractedUrls.get(feedUrl); if (folderName != null) { //Get Folder ID (create folder if not exists) if (!existingFolders.containsKey(folderName)) { // If folder does not exist, create a new one on the server final Map folderMap = new HashMap<>(1); folderMap.put("name", folderName); Folder folder = mApi.getNewsAPI().createFolder(folderMap).execute().body().get(0); folderId = folder.getId(); // Add folder to list of existing folder in order to prevent that the method tries to create it multiple times existingFolders.put(folder.getLabel(), folderId); } folderId = existingFolders.get(folderName); } Response> response = mApi.getNewsAPI().createFeed(feedUrl, folderId).execute(); if (response.isSuccessful()) { Feed feed = response.body().get(0); result.add("✓ " + feed.getLink()); Log.d(TAG, "Successfully imported feed: " + feedUrl + " - Feed-ID: " + feed.getId()); } else if (response.code() == 409) { // already exists result.add("⤏ " + feedUrl); } else { result.add("✗ " + response.code() + " - " + feedUrl); Log.e(TAG, "Failed to import feed: " + feedUrl + " - Status-Code: " + response.code()); Log.e(TAG, response.errorBody().string()); } // make list immutable and report it as progress publishProgress(new ArrayList<>(result)); } } catch (Exception e) { e.printStackTrace(); return false; } return true; } @Override protected void onProgressUpdate(List... values) { // StringBuilder text = new StringBuilder("This might take a few minutes.. please wait..\n"); StringBuilder text = new StringBuilder(); List log = values[0]; for (String line : log) { text.append("\n").append(line); } pd.updateProgress(log.size(), extractedUrls.size()); pd.setMessage(text.toString().trim()); super.onProgressUpdate(values); } @Override protected void onPostExecute(Boolean result) { pd.setVisibilityOkButton(true); if (!result) { Toast.makeText(mContext, "Failed to parse OPML file", Toast.LENGTH_SHORT).show(); } else { Toast.makeText(mContext, "Import done!", Toast.LENGTH_LONG).show(); } super.onPostExecute(result); } } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/NewsDetailActivity.java ================================================ /* * Android ownCloud News * * @author David Luhmer * @copyright 2013 David Luhmer david-dev@live.de *

* This library is free software; you can redistribute it and/or * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE * License as published by the Free Software Foundation; either * version 3 of the License, or any later version. *

* This library 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 AFFERO GENERAL PUBLIC LICENSE for more details. *

* You should have received a copy of the GNU Affero General Public * License along with this library. If not, see . */ package de.luhmer.owncloudnewsreader; import static java.util.Objects.requireNonNull; import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.text.Html; import android.util.Log; import android.util.SparseArray; import android.view.KeyEvent; import android.view.Menu; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.webkit.WebView; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentPagerAdapter; import androidx.fragment.app.FragmentStatePagerAdapter; import androidx.viewpager.widget.PagerAdapter; import androidx.viewpager.widget.ViewPager; import java.io.File; import java.lang.ref.WeakReference; import java.util.HashSet; import java.util.Set; import javax.inject.Inject; import de.greenrobot.dao.query.LazyList; import de.luhmer.owncloudnewsreader.database.DatabaseConnectionOrm; import de.luhmer.owncloudnewsreader.database.model.RssItem; import de.luhmer.owncloudnewsreader.databinding.ActivityNewsDetailBinding; import de.luhmer.owncloudnewsreader.helper.ThemeChooser; import de.luhmer.owncloudnewsreader.helper.ThemeUtils; import de.luhmer.owncloudnewsreader.model.PodcastItem; import de.luhmer.owncloudnewsreader.model.TTSItem; import de.luhmer.owncloudnewsreader.services.PodcastDownloadService; import de.luhmer.owncloudnewsreader.view.PodcastSlidingUpPanelLayout; import de.luhmer.owncloudnewsreader.widget.WidgetProvider; public class NewsDetailActivity extends PodcastFragmentActivity { private static final String TAG = NewsDetailActivity.class.getCanonicalName(); public static final String INCOGNITO_MODE_ENABLED = "INCOGNITO_MODE_ENABLED"; /** * The {@link PagerAdapter} that will provide * fragments for each of the sections. We use a * {@link FragmentPagerAdapter} derivative, which * will keep every loaded fragment in memory. If this becomes too memory * intensive, it may be best to switch to a * {@link FragmentStatePagerAdapter}. */ private SectionsPagerAdapter mSectionsPagerAdapter; public LazyList rssItems; /** * The {@link ViewPager} that will host the section contents. */ private ViewPager mViewPager; private int currentPosition; private MenuItem menuItem_PlayPodcast; private MenuItem menuItem_RemovePodcast; private MenuItem menuItem_Starred; private MenuItem menuItem_Read; private MenuItem menuItem_Incognito; private DatabaseConnectionOrm dbConn; protected ActivityNewsDetailBinding binding; protected @Inject SharedPreferences mPrefs; private boolean mShowFastActions; @Override protected void onCreate(Bundle savedInstanceState) { ((NewsReaderApplication) getApplication()).getAppComponent().injectActivity(this); super.onCreate(savedInstanceState); binding = ActivityNewsDetailBinding.inflate(getLayoutInflater()); setContentView(binding.getRoot()); /* //make full transparent statusBar getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN); setWindowFlag(this, WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS, false); getWindow().setStatusBarColor(Color.TRANSPARENT); getWindow().clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS); getWindow().addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS); getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN|View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR); */ /* if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR); } getWindow().setStatusBarColor(Color.WHITE); */ // For Debugging the WebView using Chrome Remote Debugging if (0 != (getApplicationInfo().flags & ApplicationInfo.FLAG_DEBUGGABLE)) { WebView.setWebContentsDebuggingEnabled(true); } setSupportActionBar(binding.toolbarLayout.toolbar); /* if (bottomAppBar != null) { setSupportActionBar(bottomAppBar); } */ //getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR); dbConn = new DatabaseConnectionOrm(this); Intent intent = getIntent(); int item_id = 0; if (intent.hasExtra(NewsReaderListActivity.ITEM_ID)) { item_id = intent.getExtras().getInt(NewsReaderListActivity.ITEM_ID); } if (intent.hasExtra(NewsReaderListActivity.TITLE)) { requireNonNull(getSupportActionBar()).setTitle(intent.getExtras().getString(NewsReaderListActivity.TITLE)); } requireNonNull(getSupportActionBar()).setDisplayHomeAsUpEnabled(true); rssItems = dbConn.getAllRssItems(); // If the Activity gets started from the Widget, read the item id and get the selected index in the cursor. if (intent.hasExtra(WidgetProvider.RSS_ITEM_ID)) { boolean foundArticle = false; long rssItemId = intent.getExtras().getLong(WidgetProvider.RSS_ITEM_ID); if (Constants.debugModeWidget) { Log.d(TAG, "Activity launched with RSS Item ID: " + rssItemId); } for (RssItem rssItem : rssItems) { if (rssItemId == rssItem.getId()) { getSupportActionBar().setTitle(rssItem.getTitle()); foundArticle = true; break; } else { item_id++; } } // if article can't be found for whatever reason just use index 0 and prevent app from crashing if (!foundArticle) { item_id = 0; Log.e(TAG, "RSS Item with ID " + rssItemId + " cannot be found"); } } // Create the adapter that will return a fragment for each of the three // primary sections of the app. mSectionsPagerAdapter = new SectionsPagerAdapter(getSupportFragmentManager()); binding.progressIndicator.setMax(mSectionsPagerAdapter.getCount()); // Set up the ViewPager with the sections adapter. mViewPager = findViewById(R.id.pager); mViewPager.setAdapter(mSectionsPagerAdapter); try { mViewPager.setCurrentItem(item_id, true); if (savedInstanceState == null) { // Only do that when activity is started for the first time. Not on orientation changes etc.. pageChanged(item_id); } } catch (Exception ex) { ex.printStackTrace(); } mViewPager.addOnPageChangeListener(onPageChangeListener); // mBtnDisableIncognito.setOnClickListener(v -> { // toggleIncognitoMode(); // }); this.initFastActionBar(); } @Override protected void onResume() { super.onResume(); updateActionBarIcons(); } @Override protected PodcastSlidingUpPanelLayout getPodcastSlidingUpPanelLayout() { return binding.slidingLayout; } private void toggleIncognitoMode() { // toggle incognito mode setIncognitoEnabled(!isIncognitoEnabled()); for (int i = currentPosition - 1; i <= currentPosition + 1; i++) { Log.d(TAG, "change incognito for idx: " + i); WeakReference ndf = mSectionsPagerAdapter.items.get(i); if (ndf != null) { ndf.get().syncIncognitoState(); ndf.get().startLoadRssItemToWebViewTask(this); } } } /** * Init fast action bar based on user settings. * Only show if user selected setting CB_SHOW_FAST_ACTIONS. Otherwise hide. *

* author: emasty https://github.com/emasty */ private void initFastActionBar() { mShowFastActions = mPrefs.getBoolean(SettingsActivity.CB_SHOW_FAST_ACTIONS, true); if (mShowFastActions) { // Set click listener for buttons on action bar binding.faDetailBar.faOpenInBrowser.setOnClickListener(v -> this.openInBrowser(currentPosition)); //binding.faDetailBar.faToggle.setOnClickListener(v -> this.toggleFastActionBar()); // toggle expand / collapse binding.faDetailBar.faStar.setOnClickListener(v -> NewsDetailActivity.this.toggleRssItemStarredState()); binding.faDetailBar.faMarkAsRead.setOnClickListener(v -> NewsDetailActivity.this.markRead(currentPosition)); // binding.faDetailBar.faShare.setOnClickListener(v -> this.share(currentPosition)); binding.faDetailBar.getRoot().setVisibility(View.VISIBLE); // initially the bar should be opened in the expanded state // this.toggleFastActionBar(); } else { binding.faDetailBar.getRoot().setVisibility(View.INVISIBLE); } } /** * Expands or shrinks the fast action bar to show/hide secondary functions */ /* private void toggleFastActionBar() { int currentState = binding.faDetailBar.faCollapseLayout.getVisibility(); switch (currentState) { case View.GONE: binding.faDetailBar.faToggle.setImageResource(R.drawable.ic_fa_expand); binding.faDetailBar.faCollapseLayout.setVisibility(View.VISIBLE); break; case View.VISIBLE: binding.faDetailBar.faToggle.setImageResource(R.drawable.ic_fa_shrink); binding.faDetailBar.faCollapseLayout.setVisibility(View.GONE); break; default: break; } //((Animatable)fastActionToggle.getDrawable()).start(); binding.faDetailBar.faToggle.setScaleX(-1); } */ @Override protected void onDestroy() { super.onDestroy(); rssItems.close(); } private final ViewPager.OnPageChangeListener onPageChangeListener = new ViewPager.OnPageChangeListener() { @Override public void onPageSelected(int pos) { pageChanged(pos); } @Override public void onPageScrolled(int arg0, float arg1, int arg2) { } @Override public void onPageScrollStateChanged(int arg0) { } }; @Override public boolean onKeyDown(int keyCode, KeyEvent event) { if (mPrefs.getBoolean(SettingsActivity.CB_NAVIGATE_WITH_VOLUME_BUTTONS_STRING, false)) { if ((keyCode == KeyEvent.KEYCODE_VOLUME_DOWN)) { if (currentPosition < rssItems.size() - 1) { mViewPager.setCurrentItem(currentPosition + 1, true); } // capture event to avoid volume change at end of feed return true; } else if ((keyCode == KeyEvent.KEYCODE_VOLUME_UP)) { if (currentPosition > 0) { mViewPager.setCurrentItem(currentPosition - 1, true); } // capture event to avoid volume change at beginning of feed return true; } } if (keyCode == KeyEvent.KEYCODE_BACK) { NewsDetailFragment ndf = getNewsDetailFragmentAtPosition(currentPosition);//(NewsDetailFragment) getSupportFragmentManager().findFragmentByTag("android:switcher:" + R.id.pager + ":" + currentPosition); if (ndf != null && ndf.canNavigateBack()) { ndf.navigateBack(); return true; } } return super.onKeyDown(keyCode, event); } @Override public boolean onKeyUp(int keyCode, KeyEvent event) { if ((keyCode == KeyEvent.KEYCODE_VOLUME_UP) || (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN)) { // capture event to suppress android system sound return true; } return super.onKeyUp(keyCode, event); } private void pageChanged(int position) { stopVideoOnCurrentPage(); currentPosition = position; resumeVideoPlayersOnCurrentPage(); binding.progressIndicator.setProgress(position + 1); if (rssItems.get(position).getFeed() != null) { // Try getting the feed title and use it for the action bar title requireNonNull(getSupportActionBar()).setTitle(rssItems.get(position).getFeed().getFeedTitle()); } else { requireNonNull(getSupportActionBar()).setTitle(rssItems.get(position).getTitle()); } RssItem rssItem = rssItems.get(position); if (!rssItem.getRead_temp()) { if (!NewsReaderListActivity.stayUnreadItems.contains(rssItem.getId())) { markItemAsReadOrUnread(rssItems.get(position), true); } mPostDelayHandler.delayTimer(); Log.v("PAGE CHANGED", "PAGE: " + position + " - IDFEED: " + rssItems.get(position).getId()); } updateActionBarIcons(); } private NewsDetailFragment getNewsDetailFragmentAtPosition(int position) { if (mSectionsPagerAdapter.items.get(position) != null) return mSectionsPagerAdapter.items.get(position).get(); return null; } private void resumeVideoPlayersOnCurrentPage() { NewsDetailFragment fragment = getNewsDetailFragmentAtPosition(currentPosition); if (fragment != null) { // could be null if not instantiated yet fragment.resumeCurrentPage(); } } private void stopVideoOnCurrentPage() { NewsDetailFragment fragment = getNewsDetailFragmentAtPosition(currentPosition); if (fragment != null) { // could be null if not instantiated yet fragment.pauseCurrentPage(); } } public void updateActionBarIcons() { RssItem rssItem = rssItems.get(currentPosition); boolean isStarred = rssItem.getStarred_temp(); boolean isRead = rssItem.getRead_temp(); PodcastItem podcastItem = DatabaseConnectionOrm.ParsePodcastItemFromRssItem(this, rssItem); boolean podcastAvailable = !"".equals(podcastItem.link); if (menuItem_PlayPodcast != null) { menuItem_PlayPodcast.setVisible(podcastAvailable); } if(menuItem_RemovePodcast != null) { File file = new File(PodcastDownloadService.getUrlToPodcastFile(this, podcastItem.fingerprint, podcastItem.link, false)); menuItem_RemovePodcast.setVisible(file.exists()); } if (menuItem_Starred != null) { int res = isStarred ? R.drawable.ic_star_24_theme_aware : R.drawable.ic_star_border_24dp_theme_aware; menuItem_Starred.setIcon(res); binding.faDetailBar.faStar.setImageResource(res); } if (menuItem_Read != null) { int res = isRead ? R.drawable.ic_checkbox_theme_aware : R.drawable.ic_checkbox_outline_theme_aware; menuItem_Read.setIcon(res); menuItem_Read.setChecked(isRead); binding.faDetailBar.faMarkAsRead.setImageResource(res); } if (menuItem_Incognito != null) { if (isIncognitoEnabled()) { // always show incognito icon if incognito mode is enabled menuItem_Incognito.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); } else { menuItem_Incognito.setShowAsAction(MenuItem.SHOW_AS_ACTION_NEVER); } } } @Override public void onBackPressed() { if (!handlePodcastBackPressed()) super.onBackPressed(); } @Override public boolean onCreateOptionsMenu(Menu menu) { // Inflate the menu; this adds items to the action bar if it is present. getMenuInflater().inflate(R.menu.news_detail, menu); MenuItem menuItem_OpenInBrowser = menu.findItem(R.id.action_openInBrowser); MenuItem menuItem_ShareItem = menu.findItem(R.id.action_ShareItem); menuItem_Starred = menu.findItem(R.id.action_starred); menuItem_Read = menu.findItem(R.id.action_read); menuItem_PlayPodcast = menu.findItem(R.id.action_playPodcast); menuItem_RemovePodcast = menu.findItem(R.id.action_removePodcast); menuItem_Incognito = menu.findItem(R.id.action_incognito_mode); if (mShowFastActions) { menuItem_Starred.setVisible(false); menuItem_Read.setVisible(false); menuItem_OpenInBrowser.setVisible(false); // menuItem_ShareItem.setVisible(false); } Set selections = mPrefs.getStringSet("sp_news_detail_actionbar_icons", new HashSet<>()); String[] selected = selections.toArray(new String[]{}); for (String selection : selected) { switch (selection) { case "open_in_browser": menuItem_OpenInBrowser.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); // TODO!! this is not working.. break; case "share": menuItem_ShareItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); // TODO!! this is not working.. break; case "podcast": menuItem_PlayPodcast.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); // TODO!! this is not working.. break; } } initIncognitoMode(); updateActionBarIcons(); return true; } @Override public boolean onOptionsItemSelected(MenuItem item) { RssItem rssItem = rssItems.get(currentPosition); final int itemId = item.getItemId(); if (itemId == android.R.id.home) { onBackPressed(); return true; } else if (itemId == R.id.action_read) { this.markRead(currentPosition); } else if (itemId == R.id.action_starred) { toggleRssItemStarredState(); } else if (itemId == R.id.action_openInBrowser) { this.openInBrowser(currentPosition); } else if (itemId == R.id.action_playPodcast) { openPodcast(rssItem); } else if (itemId == R.id.action_removePodcast) { removePodcastMedia(rssItem, (result) -> { if (menuItem_RemovePodcast != null) { menuItem_RemovePodcast.setVisible(!result); } }); } else if (itemId == R.id.action_tts) { this.startTTS(currentPosition); } else if (itemId == R.id.action_ShareItem) { this.share(currentPosition); } else if (itemId == R.id.action_incognito_mode) { toggleIncognitoMode(); updateActionBarIcons(); } return super.onOptionsItemSelected(item); } /** * Opens current article in selected browser * * @param currentPosition currently viewed article */ private void openInBrowser(int currentPosition) { RssItem rssItem = rssItems.get(currentPosition); NewsDetailFragment newsDetailFragment = getNewsDetailFragmentAtPosition(currentPosition); String link; if (newsDetailFragment != null) { link = newsDetailFragment.binding.webview.getUrl(); if ("about:blank".equals(link)) { link = rssItem.getLink(); } if (!link.isEmpty()) { newsDetailFragment.loadURL(link); } } else { Toast.makeText(NewsDetailActivity.this, "NewsDetailFragment is not initialized - please try again and report this error", Toast.LENGTH_LONG).show(); } } /** * Initiates share event for current item * * @param currentPosition currently viewed article */ private void share(int currentPosition) { RssItem rssItem = rssItems.get(currentPosition); String title = rssItem.getTitle(); String content = rssItem.getLink(); NewsDetailFragment fragment = getNewsDetailFragmentAtPosition(currentPosition); if (fragment != null) { // could be null if not instantiated yet if (!fragment.binding.webview.getUrl().equals("about:blank") && !fragment.binding.webview.getUrl().trim().equals("")) { content = fragment.binding.webview.getUrl(); title = fragment.binding.webview.getTitle(); } } Intent share = new Intent(Intent.ACTION_SEND); share.setType("text/plain"); //share.putExtra(Intent.EXTRA_SUBJECT, rssFiles.get(currentPosition).getTitle()); //share.putExtra(Intent.EXTRA_TEXT, rssFiles.get(currentPosition).getLink()); share.putExtra(Intent.EXTRA_SUBJECT, title); share.putExtra(Intent.EXTRA_TEXT, content); startActivity(Intent.createChooser(share, "Share Item")); } /** * Starts TTS for current position * * @param currentPosition currently viewed article */ private void startTTS(int currentPosition) { RssItem rssItem = rssItems.get(currentPosition); String text = rssItem.getTitle() + ". " + Html.fromHtml(rssItem.getBody()).toString(); // Log.d(TAG, text); TTSItem ttsItem = new TTSItem(rssItem.getId(), rssItem.getAuthor(), rssItem.getTitle(), text, rssItem.getFeed().getFaviconUrl()); openMediaItem(ttsItem); } /** * Toggles marked as read for current element * * @param currentPosition currently viewed article */ private void markRead(int currentPosition) { RssItem rssItem = rssItems.get(currentPosition); markItemAsReadOrUnread(rssItem, !menuItem_Read.isChecked()); updateActionBarIcons(); mPostDelayHandler.delayTimer(); } public void toggleRssItemStarredState() { RssItem rssItem = rssItems.get(currentPosition); Boolean curState = rssItem.getStarred_temp(); rssItem.setStarred_temp(!curState); dbConn.updateRssItem(rssItem); updateActionBarIcons(); mPostDelayHandler.delayTimer(); } private boolean isChromeDefaultBrowser() { Intent browserIntent = new Intent("android.intent.action.VIEW", Uri.parse("http://")); ResolveInfo resolveInfo = getPackageManager().resolveActivity(browserIntent, PackageManager.MATCH_DEFAULT_ONLY); Log.v(TAG, "Default Browser is: " + requireNonNull(resolveInfo).loadLabel(getPackageManager()).toString()); return (resolveInfo.loadLabel(getPackageManager()).toString().contains("Chrome")); } private void markItemAsReadOrUnread(RssItem item, boolean read) { NewsReaderListActivity.stayUnreadItems.add(item.getId()); item.setRead_temp(read); dbConn.updateRssItem(item); updateActionBarIcons(); } @Override public void finish() { Intent intent = new Intent(); intent.putExtra("POS", mViewPager.getCurrentItem()); setResult(RESULT_OK, intent); super.finish(); } public boolean isIncognitoEnabled() { return mPrefs.getBoolean(INCOGNITO_MODE_ENABLED, false); } public void setIncognitoEnabled(boolean enabled) { mPrefs.edit().putBoolean(INCOGNITO_MODE_ENABLED, enabled).commit(); initIncognitoMode(); } public void initIncognitoMode() { if (isIncognitoEnabled()) { boolean isLightTheme = !ThemeChooser.isDarkTheme(this); if (isLightTheme) { int color = getResources().getColor(isIncognitoEnabled() ? R.color.material_grey_900 : R.color.colorPrimary); ThemeUtils.colorizeToolbar(binding.toolbarLayout.toolbar, color); // the first three menu items are from the fast actions (if enabled) int skipItems = mShowFastActions ? 3 : 0; int white = getResources().getColor(android.R.color.white); ThemeUtils.colorizeToolbarForeground(binding.toolbarLayout.toolbar, white, skipItems); clearLightStatusBar(getWindow().getDecorView()); getWindow().setStatusBarColor(color); } } //ThemeUtils.colorizeToolbar(bottomAppBar, color); //ThemeUtils.changeStatusBarColor(this, color); //getWindow().setNavigationBarColor(color); /* switch (ThemeChooser.getSelectedTheme()) { case LIGHT: Log.d(TAG, "initIncognitoMode: LIGHT"); getWindow().setStatusBarColor(Color.WHITE); break; case DARK: clearLightStatusBar(getWindow().getDecorView()); Log.d(TAG, "initIncognitoMode: DARK"); getWindow().setStatusBarColor(getResources().getColor(R.color.material_grey_900)); break; case OLED: clearLightStatusBar(getWindow().getDecorView()); Log.d(TAG, "initIncognitoMode: OLED"); getWindow().setStatusBarColor(Color.BLACK); break; } */ } private void setLightStatusBar(@NonNull View view) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { int flags = view.getSystemUiVisibility(); // get current flag flags |= View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR; // add LIGHT_STATUS_BAR to flag view.setSystemUiVisibility(flags); } } public static void clearLightStatusBar(@NonNull View view) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { int flags = view.getSystemUiVisibility(); flags &= ~View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR; view.setSystemUiVisibility(flags); } } /** * A {@link FragmentPagerAdapter} that returns a fragment corresponding to * one of the sections/tabs/pages. */ //public class SectionsPagerAdapter extends FragmentPagerAdapter { public class SectionsPagerAdapter extends FragmentStatePagerAdapter { SparseArray> items = new SparseArray<>(); public SectionsPagerAdapter(FragmentManager fm) { super(fm); for (Fragment fragment : fm.getFragments()) { if (fragment instanceof NewsDetailFragment) { int id = ((NewsDetailFragment) fragment).getSectionNumber(); Log.v(TAG, "Retaining NewsDetailFragment with ID: " + id); items.put(id, new WeakReference<>((NewsDetailFragment) fragment)); } } } @NonNull @Override public Fragment getItem(int position) { NewsDetailFragment fragment = null; if(items.get(position) != null) { fragment = items.get(position).get(); } if(fragment == null) { fragment = new NewsDetailFragment(); Bundle args = new Bundle(); args.putInt(NewsDetailFragment.ARG_SECTION_NUMBER, position); fragment.setArguments(args); items.put(position, new WeakReference<>(fragment)); } return fragment; } @Override public void destroyItem(@NonNull ViewGroup container, int position, @NonNull Object object) { items.remove(position); super.destroyItem(container, position, object); } @Override public int getCount() { //return cursor.getCount(); return rssItems.size(); } @Override public CharSequence getPageTitle(int position) { return null; } } protected void setBackgroundColorOfViewPager(int backgroundColor) { this.mViewPager.setBackgroundColor(backgroundColor); } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/NewsDetailFragment.java ================================================ /* * Android ownCloud News * * @author David Luhmer * @copyright 2013 David Luhmer david-dev@live.de * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE * License as published by the Free Software Foundation; either * version 3 of the License, or any later version. * * This library 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 AFFERO GENERAL PUBLIC LICENSE for more details. * * You should have received a copy of the GNU Affero General Public * License along with this library. If not, see . * */ package de.luhmer.owncloudnewsreader; import android.annotation.SuppressLint; import android.content.Intent; import android.content.SharedPreferences; import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.util.Log; import android.view.ContextMenu; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.webkit.WebBackForwardList; import android.webkit.WebHistoryItem; import android.webkit.WebSettings; import android.webkit.WebView; import android.webkit.WebViewClient; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.browser.customtabs.CustomTabsIntent; import androidx.core.content.ContextCompat; import androidx.fragment.app.DialogFragment; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentActivity; import androidx.fragment.app.FragmentTransaction; import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.select.Elements; import java.io.File; import java.net.MalformedURLException; import java.net.URL; import javax.inject.Inject; import de.luhmer.owncloudnewsreader.adapter.ProgressBarWebChromeClient; import de.luhmer.owncloudnewsreader.async_tasks.RssItemToHtmlTask; import de.luhmer.owncloudnewsreader.database.model.RssItem; import de.luhmer.owncloudnewsreader.databinding.FragmentNewsDetailBinding; import de.luhmer.owncloudnewsreader.helper.AsyncTaskHelper; import de.luhmer.owncloudnewsreader.helper.ColorHelper; import de.luhmer.owncloudnewsreader.services.DownloadWebPageService; public class NewsDetailFragment extends Fragment implements RssItemToHtmlTask.Listener { public static final String ARG_SECTION_NUMBER = "ARG_SECTION_NUMBER"; private static final String RSS_ITEM_PAGE_URL = "about:blank"; public final String TAG = getClass().getCanonicalName(); protected FragmentNewsDetailBinding binding; protected @Inject SharedPreferences mPrefs; private int section_number; protected String html; // private String title = ""; // private String baseUrl = null; private float scalingFactor = 1.0f; // private GestureDetector mGestureDetector; public NewsDetailFragment() { } public int getSectionNumber() { return section_number; } @Override public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); ((NewsReaderApplication) requireActivity().getApplication()).getAppComponent().injectFragment(this); scalingFactor = Float.parseFloat(mPrefs.getString(SettingsActivity.SP_FONT_SIZE, "1.0")); // Retain this fragment across configuration changes. setRetainInstance(true); } @Override public void onResume() { super.onResume(); resumeCurrentPage(); registerForContextMenu(binding.webview); } @Override public void onPause() { super.onPause(); pauseCurrentPage(); } @Override public void onDestroy() { super.onDestroy(); // Log.d(TAG, "onDestroy: " + title); binding.webview.destroy(); } public void pauseCurrentPage() { binding.webview.onPause(); binding.webview.pauseTimers(); } public void resumeCurrentPage() { applyWebSettings(); binding.webview.onResume(); binding.webview.resumeTimers(); } /** * @return true when calls to NewsDetailFragment#navigateBack() * can be processed right now * @see NewsDetailFragment#navigateBack() */ public boolean canNavigateBack() { return !isCurrentPageRssItem(); } /** * Navigates back to the last displayed page. Call NewsDetailFragment#canNavigateBack() * to check if back navigation is possible right now. Use e.g. for back button handling. * @see NewsDetailFragment#navigateBack() */ public void navigateBack() { if (isLastPageRssItem()) { binding.webview.clearHistory(); startLoadRssItemToWebViewTask((NewsDetailActivity) getActivity()); } else if (!isCurrentPageRssItem()){ binding.webview.goBack(); } } @Override public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { binding = FragmentNewsDetailBinding.inflate(inflater, container, false); section_number = (Integer) requireArguments().get(ARG_SECTION_NUMBER); NewsDetailActivity ndActivity = ((NewsDetailActivity)getActivity()); assert ndActivity != null; /* // Do not reload webView if retained if (savedInstanceState != null) { Log.d(TAG, "onCreateView restore webview"); binding.webview.restoreState(savedInstanceState); setWebViewBackgroundColor(ndActivity); binding.progressBarLoading.setVisibility(View.GONE); binding.progressbarWebview.setVisibility(View.GONE); // Make sure to sync the incognitio on retained views syncIncognitoState(); this.addBottomPaddingForFastActions(binding.webview); } else { Log.d(TAG, "onCreateView new webview"); startLoadRssItemToWebViewTask(ndActivity); } // setUpGestureDetector(); */ // the whole process of saving and restoring instances is way too expensive - especially // for huge pages (such as android central has them) - it'll just freeze the webview startLoadRssItemToWebViewTask(ndActivity); return binding.getRoot(); } private void setWebViewBackgroundColor(NewsDetailActivity ndActivity) { int backgroundColor = ContextCompat.getColor(ndActivity, R.color.news_detail_background_color); binding.webview.setBackgroundColor(backgroundColor); ndActivity.setBackgroundColorOfViewPager(backgroundColor); } protected void syncIncognitoState() { NewsDetailActivity ndActivity = ((NewsDetailActivity) requireActivity()); boolean isIncognito = ndActivity.isIncognitoEnabled(); binding.webview.getSettings().setBlockNetworkLoads(isIncognito); // binding.webview.getSettings().setBlockNetworkImage(isIncognito); } /* @Override public void onSaveInstanceState(@NonNull Bundle outState) { Log.d(TAG, "onSaveInstanceState: " + title); //binding.webview.saveState(outState); } */ /** * Double tap to star listener (double tap the webview to mark the current item as read) */ /* private void setUpGestureDetector() { mGestureDetector = new GestureDetector(getContext(), new GestureDetector.SimpleOnGestureListener()); mGestureDetector.setOnDoubleTapListener(new GestureDetector.OnDoubleTapListener() { @Override public boolean onSingleTapConfirmed(MotionEvent e) { return false; } @Override public boolean onDoubleTap(MotionEvent e) { Log.v(TAG, "onDoubleTap() called with: e = [" + e + "]"); NewsDetailActivity ndActivity = ((NewsDetailActivity)getActivity()); if(ndActivity != null) { ((NewsDetailActivity) getActivity()).toggleRssItemStarredState(); // Star has 5 corners. So we can rotate it by 2/5 View view = getActivity().findViewById(R.id.action_starred); ObjectAnimator animator = ObjectAnimator.ofFloat(view, "rotation", view.getRotation() + (2*(360f/5f))); animator.start(); } return false; } @Override public boolean onDoubleTapEvent(MotionEvent e) { return false; } }); } */ protected void startLoadRssItemToWebViewTask(NewsDetailActivity ndActivity) { binding.webview.setVisibility(View.GONE); binding.progressBarLoading.setVisibility(View.VISIBLE); setWebViewBackgroundColor(ndActivity); init_webView(); RssItem rssItem = ndActivity.rssItems.get(section_number); Log.d(TAG, "startLoadRssItemToWebViewTask: " + rssItem.getTitle()); RssItemToHtmlTask task = new RssItemToHtmlTask(ndActivity, rssItem, this, mPrefs); AsyncTaskHelper.StartAsyncTask(task); } @Override public void onRssItemParsed(String htmlPage) { binding.webview.setVisibility(View.VISIBLE); binding.progressBarLoading.setVisibility(View.GONE); Log.d(TAG, "progressBarLoading gone"); setSoftwareRenderModeForWebView(htmlPage, binding.webview); html = htmlPage; binding.webview.loadDataWithBaseURL("file:///android_asset/", htmlPage, "text/html", "UTF-8", RSS_ITEM_PAGE_URL); } /** * This function has no effect on devices with api level < HONEYCOMB */ private void setSoftwareRenderModeForWebView(String htmlPage, WebView webView) { if (htmlPage.contains(".gif")) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { // Workaround some playback issues with gifs on devices below android oreo webView.setLayerType(WebView.LAYER_TYPE_SOFTWARE, null); } Log.v("NewsDetailFragment", "Using LAYER_TYPE_SOFTWARE"); } else { if (webView.getLayerType() == WebView.LAYER_TYPE_HARDWARE) { Log.v("NewsDetailFragment", "Using LAYER_TYPE_HARDWARE"); } else if (webView.getLayerType() == WebView.LAYER_TYPE_SOFTWARE) { Log.v("NewsDetailFragment", "Using LAYER_TYPE_SOFTWARE"); } else { Log.v("NewsDetailFragment", "Using LAYER_TYPE_DEFAULT"); } } } private void applyWebSettings() { WebSettings webSettings = binding.webview.getSettings(); //webSettings.setPluginState(WebSettings.PluginState.ON); webSettings.setJavaScriptEnabled(true); webSettings.setAllowContentAccess(true); webSettings.setAllowFileAccess(true); webSettings.setDomStorageEnabled(true); webSettings.setJavaScriptCanOpenWindowsAutomatically(false); webSettings.setSupportMultipleWindows(false); boolean zoomEnabled = mPrefs.getBoolean(SettingsActivity.CB_DETAILED_VIEW_ZOOM, true); webSettings.setSupportZoom(zoomEnabled); webSettings.setBuiltInZoomControls(zoomEnabled); webSettings.setDisplayZoomControls(false); webSettings.setUseWideViewPort(true); webSettings.setMediaPlaybackRequiresUserGesture(true); webSettings.setTextZoom(Math.round(scalingFactor * 100)); syncIncognitoState(); } @SuppressLint("SetJavaScriptEnabled") private void init_webView() { int backgroundColor = ColorHelper.getColorFromAttribute(getContext(), R.attr.news_detail_background_color); binding.webview.setBackgroundColor(backgroundColor); applyWebSettings(); syncIncognitoState(); binding.webview.setWebChromeClient(new ProgressBarWebChromeClient(binding.progressbarWebview)); binding.webview.setWebViewClient(new WebViewClient() { /* private final Map loadedUrls = new HashMap<>(); @Override public WebResourceResponse shouldInterceptRequest(WebView view, String url) { //Log.d(TAG, "shouldInterceptRequest: " + url); boolean isAd; if (!loadedUrls.containsKey(url)) { isAd = AdBlocker.isAd(url); loadedUrls.put(url, isAd); } else { isAd = loadedUrls.get(url); } return isAd ? AdBlocker.createEmptyResource() : super.shouldInterceptRequest(view, url); } */ @Override public boolean shouldOverrideUrlLoading(WebView view, String url) { NewsDetailFragment.this.loadURL(url); return true; } @Override public void onPageFinished(WebView view, String url) { super.onPageFinished(view, url); addBottomPaddingForFastActions(view); } }); /* binding.webview.setOnTouchListener((v, event) -> { mGestureDetector.onTouchEvent(event); return false; }); */ } /** * Add free space to bottom of web-site if Fast-Actions are switched on. * Otherwise the fast action bar might hide the article content. * Method to modify the body margins with JavaScript seems to be dirty, but no other * solution seems to be available. * * This method does (for unknown reasons) not work if WebView gets restored. The Javascript is * called but not executed. * * This is (only) a problem, if user swipes back in viewpager to already loaded articles. * Solution might be to switch to a different design. * - Bottom App Bar -- overall cleanest solution but interferes with current implementation * of Podcast Player * - Auto-hiding ActionBar. Hard to implement as scroll behaviour of WebView has to be used * for hiding/showing ActionBar. * * @param view WebView with article */ private void addBottomPaddingForFastActions(WebView view) { if (mPrefs.getBoolean(SettingsActivity.CB_SHOW_FAST_ACTIONS,true)) { view.loadUrl("javascript:document.body.style.marginBottom=\"100px\"; void 0"); } } /** * Loads the given url in the selected view based on user settings (Custom Chrome Tabs, webview or external) * * @param url address to load */ public void loadURL(String url) { int selectedBrowser = Integer.parseInt(mPrefs.getString(SettingsActivity.SP_DISPLAY_BROWSER, "0")); File webArchiveFile = DownloadWebPageService.getWebPageArchiveFileForUrl(getActivity(), url); if(webArchiveFile.exists()) { // Test if WebArchive exists for url binding.tvOfflineVersion.setVisibility(View.VISIBLE); binding.webview.loadUrl("file://" + webArchiveFile.getAbsolutePath()); } else { binding.tvOfflineVersion.setVisibility(View.GONE); switch (selectedBrowser) { case 0: // Custom Tabs final FragmentActivity activity = requireActivity(); CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder() .setShowTitle(true) .setStartAnimations(activity, R.anim.slide_in_right, R.anim.slide_out_left) .setExitAnimations(activity, R.anim.slide_in_left, R.anim.slide_out_right) .addDefaultShareMenuItem(); try { builder.build().launchUrl(activity, Uri.parse(url)); } catch(Exception ex) { Toast.makeText(NewsDetailFragment.this.getContext(), "Invalid URL: " + url, Toast.LENGTH_LONG).show(); } break; case 1: // External Browser Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); startActivity(browserIntent); break; case 2: // Built in binding.webview.loadUrl(url); break; default: throw new IllegalStateException("Unknown selection!"); } } } public void onCreateContextMenu(@NonNull ContextMenu menu, @NonNull View view, ContextMenu.ContextMenuInfo menuInfo) { if (!(view instanceof WebView)) { Log.w(TAG, "onCreateContextMenu - no webview reference found"); return; } if (view != binding.webview) { Log.d(TAG, "onCreateContextMenu - wrong webview - skip creation of context menu"); } WebView.HitTestResult result = ((WebView) view).getHitTestResult(); if (result == null) { Log.d(TAG, "onCreateContextMenu - no webview hit result"); return; } if (html == null) { Log.e(TAG, "onCreateContextMenu - html is not set - failed to load RSS item"); return; } int type = result.getType(); DialogFragment newFragment = null; switch (type) { case WebView.HitTestResult.IMAGE_TYPE: case WebView.HitTestResult.SRC_IMAGE_ANCHOR_TYPE: String imageUrl = result.getExtra(); if (imageUrl.startsWith("http") || imageUrl.startsWith("file")) { URL mImageUrl; String imgtitle; String imgaltval; String imgsrcval; imgsrcval = imageUrl.substring(imageUrl.lastIndexOf('/') + 1); Document htmlDoc = Jsoup.parse(html); Elements imgtag = htmlDoc.getElementsByAttributeValueContaining("src", imageUrl); try { imgtitle = imgtag.first().attr("title"); } catch (NullPointerException e) { imgtitle = ""; } try { imgaltval = imgtag.first().attr("alt"); } catch (NullPointerException e) { imgaltval = ""; } try { mImageUrl = new URL(imageUrl); } catch (MalformedURLException e) { return; } String title = imgsrcval; int titleIcon = android.R.drawable.ic_menu_gallery; String text = (imgtitle.isEmpty()) ? imgaltval : imgtitle; // Create and show the dialog. newFragment = NewsDetailImageDialogFragment.newInstanceImage(title, titleIcon, text, mImageUrl); } break; case WebView.HitTestResult.SRC_ANCHOR_TYPE: String url = result.getExtra(); URL mUrl; String text; try { Document htmlDoc = Jsoup.parse(html); Elements urltag = htmlDoc.getElementsByAttributeValueContaining("href", url); text = urltag.text(); mUrl = new URL(url); } catch (MalformedURLException e) { return; } // Create and show the dialog. newFragment = NewsDetailImageDialogFragment.newInstanceUrl(text, mUrl.toString()); break; case WebView.HitTestResult.EMAIL_TYPE: case WebView.HitTestResult.GEO_TYPE: case WebView.HitTestResult.PHONE_TYPE: case WebView.HitTestResult.EDIT_TEXT_TYPE: break; default: Log.v(TAG, "Unknown type: " + type + ". Skipping.."); } if (newFragment != null) { FragmentTransaction ft = getParentFragmentManager().beginTransaction(); newFragment.show(ft, "menu_fragment_dialog"); } } /** * @return true when the last page on the webview's history stack is * the original rss item page */ private boolean isLastPageRssItem() { WebBackForwardList list = binding.webview.copyBackForwardList(); WebHistoryItem lastItem = list.getItemAtIndex(list.getCurrentIndex() - 1); return lastItem != null && lastItem.getUrl().equals(RSS_ITEM_PAGE_URL); } /** * @return true when the current page on the webview's history stack is * the original rss item page */ @SuppressWarnings("BooleanMethodIsAlwaysInverted") private boolean isCurrentPageRssItem() { if(binding.webview.copyBackForwardList().getCurrentItem() != null) { String currentPageUrl = binding.webview.copyBackForwardList().getCurrentItem().getOriginalUrl(); return currentPageUrl.equals("data:text/html;charset=utf-8;base64,"); } return true; } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/NewsDetailImageDialogFragment.java ================================================ package de.luhmer.owncloudnewsreader; import static androidx.core.content.PermissionChecker.checkSelfPermission; import android.Manifest; import android.app.Activity; import android.app.DownloadManager; import android.content.BroadcastReceiver; import android.content.ClipData; import android.content.ClipboardManager; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.content.SharedPreferences; import android.database.Cursor; import android.graphics.BitmapFactory; import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.os.Environment; import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.webkit.MimeTypeMap; import android.widget.AdapterView; import android.widget.ArrayAdapter; import android.widget.ImageView; import android.widget.ListView; import android.widget.TextView; import android.widget.Toast; import androidx.core.app.ActivityCompat; import androidx.core.content.ContextCompat; import androidx.core.content.PermissionChecker; import androidx.fragment.app.DialogFragment; import net.rdrei.android.dirchooser.DirectoryChooserConfig; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.net.MalformedURLException; import java.net.URL; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import de.luhmer.owncloudnewsreader.helper.NewsFileUtils; import de.luhmer.owncloudnewsreader.notification.NextcloudNotificationManager; public class NewsDetailImageDialogFragment extends DialogFragment { private static final int REQUEST_DIRECTORY = 0; public enum TYPE { IMAGE, URL } private static final String TAG = NewsDetailImageDialogFragment.class.getCanonicalName(); private int mDialogIcon; private String mDialogTitle; private String mDialogText; private URL mImageUrl; private TYPE mDialogType; private long downloadID; private DownloadManager downloadManager; private BroadcastReceiver downloadCompleteReceiver; private LinkedHashMap mMenuItems; static NewsDetailImageDialogFragment newInstanceImage(String dialogTitle, Integer titleIcon, String dialogText, URL imageUrl) { NewsDetailImageDialogFragment f = new NewsDetailImageDialogFragment(); if(titleIcon == null) { titleIcon = android.R.drawable.ic_menu_info_details; } Bundle args = new Bundle(); args.putSerializable("dialogType", TYPE.IMAGE); args.putInt("titleIcon", titleIcon); args.putString("title", dialogTitle); args.putString("text", dialogText); args.putSerializable("imageUrl", imageUrl); f.setArguments(args); return f; } protected static NewsDetailImageDialogFragment newInstanceUrl(String dialogTitle, String dialogText) { NewsDetailImageDialogFragment f = new NewsDetailImageDialogFragment(); Bundle args = new Bundle(); args.putSerializable("dialogType", TYPE.URL); args.putInt("titleIcon", android.R.drawable.ic_menu_info_details); args.putString("title", dialogTitle); args.putString("text", dialogText); f.setArguments(args); return f; } @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); final Bundle args = requireArguments(); mDialogIcon = args.getInt("titleIcon"); mDialogTitle = args.getString("title"); mDialogText = args.getString("text"); mImageUrl = (URL) args.getSerializable("imageUrl"); mDialogType = (TYPE) args.getSerializable("dialogType"); mMenuItems = new LinkedHashMap<>(); //Build the menu switch(mDialogType) { case IMAGE: if(mImageUrl.toString().startsWith("http")) { //Only allow download for http[s] images (prevent download of cached images) mMenuItems.put(getString(R.string.action_img_download), new MenuActionLongClick() { @Override public void execute() { if (haveStoragePermission()) { downloadImage(mImageUrl); } } public void executeLongClick() { changeDownloadDir(); } }); mMenuItems.put(getString(R.string.action_img_open), () -> openLinkInBrowser(mImageUrl)); mMenuItems.put(getString(R.string.action_img_sharelink), this::shareImage); mMenuItems.put(getString(R.string.action_img_copylink), () -> copyToClipboard(mDialogTitle, mImageUrl.toString())); } else if (mImageUrl.toString().startsWith("file:///")) { mMenuItems.put(getString(R.string.action_img_download), new MenuActionLongClick() { @Override public void execute() { if (haveStoragePermission()) { storeCachedImage(mImageUrl.getPath()); } } public void executeLongClick() { changeDownloadDir(); } }); } else { mDialogTitle = "Unknown Type"; mDialogText = "The URL type of image url: \"" + mImageUrl.toString() + "\" is unknown, please report this issue."; } break; case URL: mMenuItems.put(getString(R.string.action_link_open), () -> { try { openLinkInBrowser(new URL(mDialogText)); } catch (MalformedURLException e) { Toast.makeText(getActivity(), getString(R.string.error_invalid_url), Toast.LENGTH_SHORT).show(); e.printStackTrace(); } }); mMenuItems.put(getString(R.string.action_link_share), this::shareLink); mMenuItems.put(getString(R.string.action_link_copy), () -> copyToClipboard(mDialogTitle, mDialogText)); break; } setStyle(DialogFragment.STYLE_NO_TITLE, R.style.FloatingDialog); } @Override public void onStart() { showDownloadShowcase(); super.onStart(); } private void showDownloadShowcase() { final Context context = requireContext(); if(mMenuItems.containsKey(context.getString(R.string.action_img_download))) { List menuItemsList = new ArrayList<>(mMenuItems.keySet()); int position = menuItemsList.indexOf(context.getString(R.string.action_img_download)); Log.v(TAG, "Position of Download Menu: " + position); /* // Bug in the Library.. ShowcaseView is rendered behind the DialogFragment //TODO check https://github.com/deano2390/MaterialShowcaseView/issues/51 for updates new MaterialShowcaseView.Builder(getActivity()) .setTarget(mListView /*.getChildAt(position) *//*) .setDismissText("GOT IT") .setContentText("Long press to change the target download directory") .setDelay(300) // optional but starting animations immediately in onCreate can make them choppy .singleUse("LONG_PRESS_DOWNLOAD_TARGET_DIR") // provide a unique ID used to ensure it is only shown once .show(); */ } } @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { View v = inflater.inflate(R.layout.fragment_dialog_image, container, false); TextView tvTitle = v.findViewById(R.id.ic_menu_title); TextView tvText = v.findViewById(R.id.ic_menu_item_text); ImageView imgTitle = v.findViewById(R.id.ic_menu_gallery); tvTitle.setText(mDialogTitle); tvText.setText(mDialogText); imgTitle.setImageResource(mDialogIcon); if(mDialogType == TYPE.IMAGE) { registerImageDownloadReceiver(); if(mDialogText.equals(mDialogTitle) || mDialogText.equals("")) { tvText.setVisibility(View.GONE); } } ListView mListView = v.findViewById(R.id.ic_menu_item_list); List menuItemsList = new ArrayList<>(mMenuItems.keySet()); final ArrayAdapter arrayAdapter = new ArrayAdapter<>( getActivity(), R.layout.fragment_dialog_listviewitem, menuItemsList); mListView.setAdapter(arrayAdapter); mListView.setLongClickable(true); mListView.setOnItemClickListener(new AdapterView.OnItemClickListener() { @Override public void onItemClick(AdapterView adapterView, View view, int i, long l) { String key = arrayAdapter.getItem(i); MenuAction mAction = mMenuItems.get(key); mAction.execute(); } }); mListView.setOnItemLongClickListener(new AdapterView.OnItemLongClickListener() { @Override public boolean onItemLongClick(AdapterView adapterView, View view, int position, long id) { String key = arrayAdapter.getItem(position); try { MenuActionLongClick mAction = (MenuActionLongClick) mMenuItems.get(key); mAction.executeLongClick(); } catch (ClassCastException e) { return false; } return true; } }); return v; } @Override public void onDestroyView() { unregisterImageDownloadReceiver(); super.onDestroyView(); } private void copyToClipboard(String label, String text) { ClipboardManager clipboard = (ClipboardManager) requireContext().getSystemService(Activity.CLIPBOARD_SERVICE); ClipData clip = ClipData.newPlainText(label, text); clipboard.setPrimaryClip(clip); Toast.makeText(getActivity(), getString(R.string.toast_copied_to_clipboard), Toast.LENGTH_SHORT).show(); dismiss(); } private void shareImage() { Intent sharingIntent = new Intent(Intent.ACTION_SEND); sharingIntent.setType("text/plain"); sharingIntent.putExtra(Intent.EXTRA_SUBJECT, mDialogText); sharingIntent.putExtra(Intent.EXTRA_TEXT, mImageUrl.toString()); startActivity(Intent.createChooser(sharingIntent, getString(R.string.intent_title_share))); dismiss(); } private void shareLink() { Intent sharingIntent = new Intent(Intent.ACTION_SEND); sharingIntent.setType("text/plain"); sharingIntent.putExtra(Intent.EXTRA_SUBJECT, mDialogTitle); sharingIntent.putExtra(Intent.EXTRA_TEXT, mDialogText); startActivity(Intent.createChooser(sharingIntent, getString(R.string.intent_title_share))); dismiss(); } private void openLinkInBrowser(URL url) { Intent i = new Intent(Intent.ACTION_VIEW); i.setData(Uri.parse(url.toString())); startActivity(i); dismiss(); } public static String getMimeTypeOfUri(String path) throws IOException { BitmapFactory.Options opt = new BitmapFactory.Options(); /* The doc says that if inJustDecodeBounds set to true, the decoder * will return null (no bitmap), but the out... fields will still be * set, allowing the caller to query the bitmap without having to * allocate the memory for its pixels. */ opt.inJustDecodeBounds = true; InputStream inStream = new FileInputStream(path); BitmapFactory.decodeStream(inStream, null, opt); inStream.close(); return opt.outMimeType; } private void downloadImage(URL url) { Toast.makeText(requireContext().getApplicationContext(), getString(R.string.toast_img_download_wait), Toast.LENGTH_SHORT).show(); if (isExternalStorageWritable()) { String filename = getFileNameFromPath(url.getFile(), true); downloadManager = (DownloadManager) requireContext().getSystemService(Context.DOWNLOAD_SERVICE); DownloadManager.Request request = new DownloadManager.Request(Uri.parse(url.toString())); request.setDestinationUri(getDownloadDir(filename)); request.setTitle(getString(R.string.app_name) + " - " + getString(R.string.action_img_download)); request.setDescription(filename); request.setVisibleInDownloadsUi(true); //request.setAllowedNetworkTypes(DownloadManager.Request.NETWORK_WIFI | DownloadManager.Request.NETWORK_MOBILE); //request.setAllowedOverRoaming(false); //request.setVisibleInDownloadsUi(false); //request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN); downloadID = downloadManager.enqueue(request); requireDialog().hide(); } else { Toast.makeText(requireContext().getApplicationContext(), getString(R.string.toast_img_notwriteable), Toast.LENGTH_LONG).show(); dismiss(); } } private void storeCachedImage(String path) { if(isExternalStorageWritable()) { String filename = getFileNameFromPath(path, false); File dstPath = new File(getDownloadDir(filename).getPath()); try { NewsFileUtils.copyFile(new FileInputStream(path), new FileOutputStream(dstPath)); } catch (IOException e) { Toast.makeText(requireContext().getApplicationContext(), e.getMessage(), Toast.LENGTH_LONG).show(); } NextcloudNotificationManager.showNotificationDownloadSingleImageComplete(requireContext().getApplicationContext(), dstPath); requireDialog().hide(); } else { Toast.makeText(requireContext().getApplicationContext(), getString(R.string.toast_img_notwriteable), Toast.LENGTH_LONG).show(); dismiss(); } } private String getFileNameFromPath(String path, boolean web) { String filename = path.substring(path.lastIndexOf('/') + 1); if (!(filename.endsWith(".jpg") || filename.endsWith(".png") || filename.endsWith(".webp"))) { try { String fileExtension = ""; if (web) { fileExtension = MimeTypeMap.getFileExtensionFromUrl(path); } else { fileExtension = getMimeTypeOfUri(path).replace("image/", ""); } if (!fileExtension.isEmpty()) { filename += "." + fileExtension; } } catch (Exception ex) { Log.e(TAG, "Failed to extract file extension from url: " + ex.getMessage()); } } return filename; } public boolean haveStoragePermission() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (checkSelfPermission(requireContext(), Manifest.permission.WRITE_EXTERNAL_STORAGE) == PermissionChecker.PERMISSION_GRANTED) { Log.v("Permission error", "You have permission"); return true; } else { Log.e("Permission error", "Asking for permission"); ActivityCompat.requestPermissions(requireActivity(), new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, 1); return false; } } else { //you dont need to worry about these stuff below api level 23 Log.e("Permission error","You already have the permission"); return true; } } private void changeDownloadDir() { final Intent chooserIntent = new Intent(getActivity(), DirectoryChooserActivity.class); final DirectoryChooserConfig config = DirectoryChooserConfig.builder() .initialDirectory(requireActivity().getPreferences(Context.MODE_PRIVATE).getString("manualImageDownloadLocation", "")) .newDirectoryName("new folder") .allowNewDirectoryNameModification(true) .allowReadOnlyDirectory(false) .build(); chooserIntent.putExtra(DirectoryChooserActivity.EXTRA_CONFIG, config); startActivityForResult(chooserIntent, REQUEST_DIRECTORY); } private void setNewDownloadDir(String path) { if(path.equals("")) { path = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).toString(); } SharedPreferences sharedPref = requireActivity().getPreferences(Context.MODE_PRIVATE); SharedPreferences.Editor editor = sharedPref.edit(); editor.putString("manualImageDownloadLocation", path); editor.commit(); } private Uri getDownloadDir(String filename) { SharedPreferences sharedPref = requireActivity().getPreferences(Context.MODE_PRIVATE); String dir = sharedPref.getString("manualImageDownloadLocation", ""); if(dir.equals("")) { //sharedPref has never been set setNewDownloadDir(""); //set to default public download dir return getDownloadDir(filename); } String tmp = "file://" +dir +"/" +filename; return Uri.parse(tmp); } public void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); if (requestCode == REQUEST_DIRECTORY) { if (resultCode == DirectoryChooserActivity.RESULT_CODE_DIR_SELECTED) { String dir = data.getStringExtra(DirectoryChooserActivity.RESULT_SELECTED_DIR); setNewDownloadDir(dir); } } } private void unregisterImageDownloadReceiver() { if (downloadCompleteReceiver != null) { requireActivity().unregisterReceiver(downloadCompleteReceiver); downloadCompleteReceiver = null; } } private void registerImageDownloadReceiver() { if(downloadCompleteReceiver != null) return; downloadCompleteReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { long refID = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1); if (downloadID == refID) { DownloadManager.Query query = new DownloadManager.Query(); query.setFilterById(refID); Cursor cursor = downloadManager.query(query); cursor.moveToFirst(); int columnIndex = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS); int status = cursor.getInt(columnIndex); int columnReason = cursor.getColumnIndex(DownloadManager.COLUMN_REASON); int reason = cursor.getInt(columnReason); switch (status) { case DownloadManager.STATUS_SUCCESSFUL: Toast.makeText(requireContext().getApplicationContext(), getString(R.string.toast_img_saved), Toast.LENGTH_LONG).show(); //String imagePath = downloadManager.getUriForDownloadedFile(refID).toString(); String downloadFileLocalUri = cursor.getString(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_LOCAL_URI)); File image = new File(Uri.parse(downloadFileLocalUri).getPath()); NextcloudNotificationManager.showNotificationDownloadSingleImageComplete(context, image); if(isVisible()) { dismiss(); } break; case DownloadManager.STATUS_FAILED: Toast.makeText(requireContext().getApplicationContext(), getString(R.string.error_download_failed) + ": " + reason, Toast.LENGTH_LONG).show(); if(isVisible()) { dismiss(); } break; default: Log.e(TAG, "this should never happen! - unknown download status"); } } } }; IntentFilter intentFilter = new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE); ContextCompat.registerReceiver(requireActivity(), downloadCompleteReceiver, intentFilter, ContextCompat.RECEIVER_NOT_EXPORTED); } public boolean isExternalStorageWritable() { String state = Environment.getExternalStorageState(); return Environment.MEDIA_MOUNTED.equals(state); } interface MenuAction { void execute(); } interface MenuActionLongClick extends MenuAction { void executeLongClick(); } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/NewsReaderApplication.java ================================================ package de.luhmer.owncloudnewsreader; import android.app.Application; import de.luhmer.owncloudnewsreader.di.ApiModule; import de.luhmer.owncloudnewsreader.di.AppComponent; import de.luhmer.owncloudnewsreader.di.DaggerAppComponent; import de.luhmer.owncloudnewsreader.helper.ForegroundListener; public class NewsReaderApplication extends Application { protected AppComponent mAppComponent; @Override public void onCreate() { super.onCreate(); registerActivityLifecycleCallbacks(new ForegroundListener()); initDaggerAppComponent(); // AdBlocker.init(this); } public void initDaggerAppComponent() { // Dagger%COMPONENT_NAME% mAppComponent = DaggerAppComponent.builder() .apiModule(new ApiModule(this)) .build(); // If a Dagger 2 component does not have any constructor arguments for any of its modules, // then we can use .create() as a shortcut instead: //mAppComponent = DaggerAppComponent.create(); } public AppComponent getAppComponent() { return mAppComponent; } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/NewsReaderDetailFragment.java ================================================ /* * Android ownCloud News * * @author David Luhmer * @copyright 2013 David Luhmer david-dev@live.de * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE * License as published by the Free Software Foundation; either * version 3 of the License, or any later version. * * This library 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 AFFERO GENERAL PUBLIC LICENSE for more details. * * You should have received a copy of the GNU Affero General Public * License along with this library. If not, see . * */ package de.luhmer.owncloudnewsreader; import static java.util.Objects.requireNonNull; import static de.luhmer.owncloudnewsreader.ListView.SubscriptionExpandableListAdapter.SPECIAL_FOLDERS.ALL_DOWNLOADED_PODCASTS; import static de.luhmer.owncloudnewsreader.ListView.SubscriptionExpandableListAdapter.SPECIAL_FOLDERS.ALL_STARRED_ITEMS; import static de.luhmer.owncloudnewsreader.ListView.SubscriptionExpandableListAdapter.SPECIAL_FOLDERS.ALL_UNREAD_ITEMS; import static de.luhmer.owncloudnewsreader.SettingsActivity.SP_SWIPE_LEFT_ACTION; import static de.luhmer.owncloudnewsreader.SettingsActivity.SP_SWIPE_LEFT_ACTION_DEFAULT; import static de.luhmer.owncloudnewsreader.SettingsActivity.SP_SWIPE_RIGHT_ACTION; import static de.luhmer.owncloudnewsreader.SettingsActivity.SP_SWIPE_RIGHT_ACTION_DEFAULT; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.content.res.TypedArray; import android.graphics.Canvas; import android.graphics.Rect; import android.graphics.drawable.Animatable; import android.graphics.drawable.Drawable; import android.net.Uri; import android.os.AsyncTask; import android.os.Bundle; import android.os.Handler; import android.os.Parcelable; import android.util.AttributeSet; import android.util.Log; import android.view.GestureDetector; import android.view.LayoutInflater; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import android.view.animation.Animation; import android.view.animation.AnimationUtils; import android.widget.ImageView; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.fragment.app.Fragment; import androidx.recyclerview.widget.DefaultItemAnimator; import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; import com.google.android.material.floatingactionbutton.FloatingActionButton; import com.google.android.material.snackbar.BaseTransientBottomBar; import com.google.android.material.snackbar.Snackbar; import java.util.List; import java.util.stream.Collectors; import javax.inject.Inject; import de.luhmer.owncloudnewsreader.adapter.NewsListRecyclerAdapter; import de.luhmer.owncloudnewsreader.adapter.RssItemViewHolder; import de.luhmer.owncloudnewsreader.database.DatabaseConnectionOrm; import de.luhmer.owncloudnewsreader.database.DatabaseConnectionOrm.SORT_DIRECTION; import de.luhmer.owncloudnewsreader.database.model.RssItem; import de.luhmer.owncloudnewsreader.database.model.RssItemDao; import de.luhmer.owncloudnewsreader.databinding.FragmentNewsreaderDetailBinding; import de.luhmer.owncloudnewsreader.helper.AsyncTaskHelper; import de.luhmer.owncloudnewsreader.helper.DatabaseUtilsKt; import de.luhmer.owncloudnewsreader.helper.PostDelayHandler; import de.luhmer.owncloudnewsreader.helper.Search; import de.luhmer.owncloudnewsreader.helper.StopWatch; import io.reactivex.rxjava3.observers.DisposableObserver; import io.reactivex.rxjava3.subjects.PublishSubject; /** * A fragment representing a single NewsReader detail screen. This fragment is * either contained in a {@link NewsReaderListActivity} in two-pane mode (on * tablets) or a {@link NewsReaderListActivity} on handsets. */ public class NewsReaderDetailFragment extends Fragment { private static final String LAYOUT_MANAGER_STATE = "LAYOUT_MANAGER_STATE"; protected final String TAG = getClass().getCanonicalName(); FragmentNewsreaderDetailBinding binding; private Long idFeed; private Drawable leftSwipeDrawable; private Drawable rightSwipeDrawable; private String prevLeftAction = ""; private String prevRightAction = ""; private Parcelable layoutManagerSavedState; // Variables related to mark as read when scrolling private boolean mMarkAsReadWhileScrollingEnabled; private boolean mSyncWhenScrolledToBottomEnabled; private int previousFirstVisibleItem = -1; private Long idFolder; private String title; private int onResumeCount = 0; private RecyclerView.OnItemTouchListener itemTouchListener; protected @Inject SharedPreferences mPrefs; protected @Inject PostDelayHandler mPostDelayHandler; public PublishSubject syncTrigger = PublishSubject.create(); private PodcastFragmentActivity mActivity; /** * Mandatory empty constructor for the fragment manager to instantiate the * fragment (e.g. upon screen orientation changes). */ public NewsReaderDetailFragment() { } @Override public void onAttach(@NonNull Context context) { super.onAttach(context); this.mActivity = (PodcastFragmentActivity) context; } @Override public void onDetach() { this.mActivity = null; super.onDetach(); } protected DisposableObserver> searchResultObserver = new DisposableObserver>() { @Override public void onNext(@NonNull List rssItems) { loadRssItemsIntoView(rssItems); } @Override public void onError(Throwable e) { binding.pbLoading.setVisibility(View.GONE); Toast.makeText(mActivity, e.getLocalizedMessage(), Toast.LENGTH_LONG).show(); } @Override public void onComplete() { Log.v(TAG, "Search Completed!"); } }; public static SORT_DIRECTION getSortDirection(SharedPreferences prefs) { return DatabaseUtilsKt.getSortDirectionFromSettings(prefs); } /** * @return the idFeed */ public Long getIdFeed() { return idFeed; } /** * @return the idFolder */ public Long getIdFolder() { return idFolder; } /** * @return the titel */ public String getTitle() { return title; } protected void setTitle(String title) { this.title = title; requireNonNull(mActivity.getSupportActionBar()).setTitle(title); } protected void setData(Long idFeed, Long idFolder, String title, boolean updateListView) { Log.v(TAG, "Creating new instance"); this.idFeed = idFeed; this.idFolder = idFolder; setTitle(title); if (updateListView) { updateCurrentRssView(); } else { refreshCurrentRssView(); } } @Override public void onResume() { Log.v(TAG, "onResume called!"); mMarkAsReadWhileScrollingEnabled = mPrefs.getBoolean(SettingsActivity.CB_MARK_AS_READ_WHILE_SCROLLING_STRING, false); mSyncWhenScrolledToBottomEnabled = mPrefs.getBoolean(SettingsActivity.CB_SYNC_WHEN_SCROLLED_TO_BOTTOM_STRING, false); this.initFastDoneAll(this.requireView()); //When the fragment is instantiated by the xml file, onResume will be called twice if (onResumeCount >= 2) { refreshCurrentRssView(); } onResumeCount++; updateSwipeDrawables(false); super.onResume(); } protected void updateMenuItemsState() { NewsReaderListActivity nla = (NewsReaderListActivity) mActivity; if(nla != null && nla.getMenuItemDownloadMoreItems() != null) { nla.getMenuItemDownloadMoreItems().setEnabled(idFolder == null || idFolder != ALL_UNREAD_ITEMS.getValue()); } } protected void notifyDataSetChangedOnAdapter() { NewsListRecyclerAdapter nca = (NewsListRecyclerAdapter) binding.list.getAdapter(); if (nca != null) { nca.notifyDataSetChanged(); } } /** * Refreshes the current RSS-View */ protected void refreshCurrentRssView() { Log.v(TAG, "refreshCurrentRssView"); NewsListRecyclerAdapter nra = ((NewsListRecyclerAdapter) binding.list.getAdapter()); if (nra != null) { nra.refreshAdapterDataAsync(() -> { binding.pbLoading.setVisibility(View.GONE); if (layoutManagerSavedState != null) { requireNonNull(binding.list.getLayoutManager()).onRestoreInstanceState(layoutManagerSavedState); layoutManagerSavedState = null; } }); } } /** * Init fast action for mark all as read shown as floating action bar button (fab) * * @param rootView root view of fragment */ protected void initFastDoneAll(View rootView) { FloatingActionButton fab_done_all = binding.fabDoneAll; if (mPrefs.getBoolean(SettingsActivity.CB_SHOW_FAST_ACTIONS, true)) { fab_done_all.setVisibility(View.VISIBLE); fab_done_all.setOnTouchListener(new FastMarkReadMotionListener(rootView)); } else { fab_done_all.setVisibility(View.GONE); } } /** * Updates the current RSS-View */ public void updateCurrentRssView() { Log.v(TAG, "updateCurrentRssView"); AsyncTaskHelper.StartAsyncTask(new UpdateCurrentRssViewTask()); } public RecyclerView getRecyclerView() { return binding.list; } public LinearLayoutManager getLayoutManager() { return (LinearLayoutManager) binding.list.getLayoutManager(); } protected List performSearch(String searchString) { Handler mainHandler = new Handler(mActivity.getMainLooper()); Runnable myRunnable = () -> { binding.pbLoading.setVisibility(View.VISIBLE); binding.tvNoItemsAvailable.getRoot().setVisibility(View.GONE); }; mainHandler.post(myRunnable); return Search.PerformSearch(mActivity, idFolder, idFeed, searchString, mPrefs); } void loadRssItemsIntoView(List rssItems) { previousFirstVisibleItem = -1; try { NewsListRecyclerAdapter nra = ((NewsListRecyclerAdapter) binding.list.getAdapter()); if (nra == null) { nra = new NewsListRecyclerAdapter(mActivity, binding.list, mActivity, mPostDelayHandler, mPrefs); binding.list.setAdapter(nra); } nra.updateAdapterData(rssItems); binding.pbLoading.setVisibility(View.GONE); if (nra.getItemCount() <= 0) { binding.tvNoItemsAvailable.getRoot().setVisibility(View.VISIBLE); } else { binding.tvNoItemsAvailable.getRoot().setVisibility(View.GONE); } binding.list.scrollToPosition(0); } catch (Exception ex) { ex.printStackTrace(); } } @Override public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { binding = FragmentNewsreaderDetailBinding.inflate(inflater, container, false); binding.list.setHasFixedSize(true); binding.list.setLayoutManager(new LazyLoadingLinearLayoutManager(mActivity, RecyclerView.VERTICAL, false)); binding.list.setItemAnimator(new DefaultItemAnimator()); ItemTouchHelper itemTouchHelper = new ItemTouchHelper(new NewsReaderItemTouchHelperCallback()); itemTouchHelper.attachToRecyclerView(binding.list); //recyclerView.addItemDecoration(new DividerItemDecoration(mActivity)); // Enable divider line /* recyclerView.setOnTouchListener(new View.OnTouchListener() { @Override public boolean onTouch(View v, MotionEvent event) { ((NewsReaderListActivity) mActivity).clearSearchViewFocus(); return false; } }); */ binding.swipeRefresh.setOnRefreshListener((SwipeRefreshLayout.OnRefreshListener) mActivity); binding.list.addOnScrollListener(new RecyclerView.OnScrollListener() { @Override public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { if (dy > 0) { // check for scroll down Log.v(TAG, "Scroll Delta y: " + dy); LinearLayoutManager linearLayoutManager = (LinearLayoutManager) binding.list.getLayoutManager(); NewsListRecyclerAdapter adapter = (NewsListRecyclerAdapter) binding.list.getAdapter(); if (linearLayoutManager != null && adapter != null) { int firstVisibleItem = linearLayoutManager.findFirstVisibleItemPosition(); int lastVisibleItem = linearLayoutManager.findLastVisibleItemPosition(); int visibleItemCount = lastVisibleItem - firstVisibleItem; int totalItemCount = adapter.getItemCount(); boolean reachedBottom = (lastVisibleItem == (totalItemCount - 1)); if (mMarkAsReadWhileScrollingEnabled) { handleMarkAsReadScrollEvent(firstVisibleItem, lastVisibleItem, visibleItemCount, reachedBottom, adapter); } // trigger sync (to automatically reload) once we reach the end/bottom int lastCompletelyVisibleItem = linearLayoutManager.findLastCompletelyVisibleItemPosition(); boolean reachedBottomFully = (lastCompletelyVisibleItem == (totalItemCount - 1)); if (mSyncWhenScrolledToBottomEnabled && reachedBottomFully) { Log.d(TAG, "Reached end of list - trigger sync"); syncTrigger.onNext(true); } } } } }); itemTouchListener = new RecyclerView.OnItemTouchListener() { final GestureDetector detector = new GestureDetector(mActivity, new RecyclerViewOnGestureListener()); @Override public boolean onInterceptTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e) { detector.onTouchEvent(e); return false; } @Override public void onTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e) { } @Override public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) { } }; return binding.getRoot(); } private void handleMarkAsReadScrollEvent(int firstVisibleItem, int lastVisibleItem, int visibleItemCount, boolean reachedBottom, NewsListRecyclerAdapter adapter) { // Exit if the position didn't change. if (firstVisibleItem == previousFirstVisibleItem && !reachedBottom) { return; } previousFirstVisibleItem = firstVisibleItem; //Log.v(TAG, "First visible: " + firstVisibleItem + " - Last visible: " + lastVisibleItem + " - visible count: " + visibleItemCount + " - total count: " + totalItemCount); //Set the item at top to read //ViewHolder vh = (ViewHolder) recyclerView.findViewHolderForLayoutPosition(firstVisibleItem); // Mark the first two items as read final int numberItemsAhead = 1; for (int i = firstVisibleItem; i < firstVisibleItem + numberItemsAhead; i++) { //Log.v(TAG, "Mark item as read: " + i); RssItemViewHolder vh = (RssItemViewHolder) binding.list.findViewHolderForLayoutPosition(i); if (vh != null && !vh.shouldStayUnread()) { adapter.changeReadStateOfItem(vh, true); } } //Check if Listview is scrolled to bottom if (reachedBottom && visibleItemCount != 0 && //Check if list is empty binding.list.getChildAt(visibleItemCount).getBottom() <= binding.list.getHeight()) { for (int i = firstVisibleItem; i <= lastVisibleItem; i++) { RecyclerView.ViewHolder vhTemp = binding.list.findViewHolderForLayoutPosition(i); if (vhTemp instanceof RssItemViewHolder vh) { //Check for ViewHolder instance because of ProgressViewHolder if (!vh.shouldStayUnread()) { adapter.changeReadStateOfItem(vh, true); } else { Log.v(TAG, "shouldStayUnread"); } } } } } @Override public void onInflate(@NonNull Context context, @NonNull AttributeSet attrs, Bundle savedInstanceState) { super.onInflate(context, attrs, savedInstanceState); ((NewsReaderApplication) requireActivity().getApplication()).getAppComponent().injectFragment(this); updateSwipeDrawables(true); } /** * * @param forceUpdate force swipe drawables to be reloaded */ private void updateSwipeDrawables(boolean forceUpdate) { String leftAction = mPrefs.getString(SP_SWIPE_LEFT_ACTION, SP_SWIPE_LEFT_ACTION_DEFAULT); String rightAction = mPrefs.getString(SP_SWIPE_RIGHT_ACTION, SP_SWIPE_RIGHT_ACTION_DEFAULT); if (!forceUpdate && leftAction.equals(prevLeftAction) && rightAction.equals(prevRightAction)) { return; } prevLeftAction = leftAction; prevRightAction = rightAction; int leftId = getLayoutId(leftAction); int rightId = getLayoutId(rightAction); TypedArray styledAttributes = requireContext().obtainStyledAttributes(new int[]{leftId, rightId}); leftSwipeDrawable = styledAttributes.getDrawable(0); rightSwipeDrawable = styledAttributes.getDrawable(1); styledAttributes.recycle(); } private int getLayoutId(String action) { switch (action) { case "0": return R.attr.openinbrowserDrawable; case "1": return R.attr.starredDrawable; case "2": return R.attr.markasreadDrawable; case "3": return R.attr.shareDrawable; default: Log.e(TAG, "Invalid option saved to prefs. This should not happen"); return Integer.MAX_VALUE; } } @Override public void onViewStateRestored(Bundle savedInstanceState) { if (savedInstanceState != null) layoutManagerSavedState = savedInstanceState.getParcelable(LAYOUT_MANAGER_STATE); super.onViewStateRestored(savedInstanceState); } @Override public void onSaveInstanceState(@NonNull Bundle outState) { super.onSaveInstanceState(outState); outState.putParcelable(LAYOUT_MANAGER_STATE, getLayoutManager().onSaveInstanceState()); } public int getFirstVisibleScrollPosition() { LinearLayoutManager layoutManager = ((LinearLayoutManager) binding.list.getLayoutManager()); return layoutManager.findFirstVisibleItemPosition(); } private class UpdateCurrentRssViewTask extends AsyncTask> { @Override protected void onPreExecute() { binding.pbLoading.setVisibility(View.VISIBLE); binding.tvNoItemsAvailable.getRoot().setVisibility(View.GONE); super.onPreExecute(); } @Override protected List doInBackground(Void... voids) { DatabaseConnectionOrm dbConn = new DatabaseConnectionOrm(NewsReaderDetailFragment.this.getContext()); SORT_DIRECTION sortDirection = getSortDirection(mPrefs); boolean onlyUnreadItems = mPrefs.getBoolean(SettingsActivity.CB_SHOWONLYUNREAD_STRING, false); boolean onlyStarredItems = idFolder != null && idFolder == ALL_STARRED_ITEMS.getValue(); String sqlSelectStatement = null; if (idFeed != null) { if (idFolder != null && idFolder == ALL_UNREAD_ITEMS.getValue()) { onlyUnreadItems = true; } sqlSelectStatement = dbConn.getAllItemsIdsForFeedSQL(idFeed, onlyUnreadItems, onlyStarredItems, sortDirection); } else if (idFolder != null) { if (idFolder == ALL_STARRED_ITEMS.getValue() || idFolder == ALL_DOWNLOADED_PODCASTS.getValue()) onlyUnreadItems = false; sqlSelectStatement = dbConn.getAllItemsIdsForFolderSQL(idFolder, onlyUnreadItems, sortDirection, mActivity); } if (sqlSelectStatement != null) { int index = sqlSelectStatement.indexOf("ORDER BY"); if (index == -1) { index = sqlSelectStatement.length(); } sqlSelectStatement = new StringBuilder(sqlSelectStatement).insert(index, " GROUP BY " + RssItemDao.Properties.Fingerprint.columnName + " ").toString(); dbConn.insertIntoRssCurrentViewTable(sqlSelectStatement); } StopWatch sw = new StopWatch(); sw.start(); List items = dbConn.getCurrentRssItemView(0); if (idFolder == ALL_DOWNLOADED_PODCASTS.getValue()) { items = items.stream().filter((rss) -> { var podcast = DatabaseConnectionOrm.ParsePodcastItemFromRssItem(mActivity, rss); return podcast.offlineCached; }).collect(Collectors.toList()); } sw.stop(); Log.v(TAG, "Time needed (init loading): " + sw); return items; } @Override protected void onPostExecute(List rssItem) { loadRssItemsIntoView(rssItem); if (rssItem.size() < 10) { // Less than 10 items in the list (usually 3-5 items fit on one screen) // There is no API to check, if this listener has already been added. We don't want to // add it multiple times, so we take the safe route here by removing it before adding it. binding.list.removeOnItemTouchListener(itemTouchListener); binding.list.addOnItemTouchListener(itemTouchListener); } else { binding.list.removeOnItemTouchListener(itemTouchListener); } } } // This Gesture listener is only attached when there are few articles on the screen (e.g. less than 10) // because the list onScroll callback won't be triggered when all items fit on the screen. Therefore // we use this gesture listener to detect swipes on the screen private class RecyclerViewOnGestureListener extends GestureDetector.SimpleOnGestureListener { private int minLeftEdgeDistance = -1; private void initEdgeDistance() { if (getResources().getBoolean(R.bool.isTablet)) { // if tablet mode enabled, the navigation drawer will always be visible. // Therefore we don't need no offset here minLeftEdgeDistance = 0; } else { // otherwise, have left-edge offset to avoid mark-read gesture when user is pulling to open drawer minLeftEdgeDistance = ((NewsReaderListActivity) mActivity).getEdgeSizeOfDrawer(); } } @Override public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { if (minLeftEdgeDistance == -1) { // if not initialized initEdgeDistance(); } if (e1 == null) { Log.e(TAG, "motion event 1 is null"); return false; } if (e2 == null) { Log.e(TAG, "motion event 2 is null"); return false; } LinearLayoutManager linearLayoutManager = (LinearLayoutManager) binding.list.getLayoutManager(); NewsListRecyclerAdapter adapter = (NewsListRecyclerAdapter) binding.list.getAdapter(); if (linearLayoutManager == null || adapter == null) { return false; } int firstVisibleItem = linearLayoutManager.findFirstVisibleItemPosition(); int lastVisibleItem = linearLayoutManager.findLastVisibleItemPosition(); int visibleItemCount = lastVisibleItem - firstVisibleItem; int totalItemCount = adapter.getItemCount(); boolean reachedBottom = (lastVisibleItem == (totalItemCount - 1)); if (mMarkAsReadWhileScrollingEnabled && e1.getX() > minLeftEdgeDistance && // only if gesture starts a bit away from left window edge (e2.getY() - e1.getY()) < 0) { // and if swipe direction is upwards handleMarkAsReadScrollEvent(firstVisibleItem, lastVisibleItem, visibleItemCount, reachedBottom, adapter); return true; } return false; } } // TODO: somehow always cancel item out animation private class NewsReaderItemTouchHelperCallback extends ItemTouchHelper.SimpleCallback { public NewsReaderItemTouchHelperCallback() { super(0, ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT); } @Override public float getSwipeThreshold(@NonNull RecyclerView.ViewHolder viewHolder) { return 0.25f; } @Override public boolean onMove(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder, @NonNull RecyclerView.ViewHolder target) { return false; } @Override public void onSwiped(@NonNull final RecyclerView.ViewHolder viewHolder, final int direction) { final NewsListRecyclerAdapter adapter = (NewsListRecyclerAdapter) binding.list.getAdapter(); String swipeAction; if (direction == ItemTouchHelper.LEFT) swipeAction = mPrefs.getString(SP_SWIPE_LEFT_ACTION, SP_SWIPE_LEFT_ACTION_DEFAULT); else swipeAction = mPrefs.getString(SP_SWIPE_RIGHT_ACTION, SP_SWIPE_RIGHT_ACTION_DEFAULT); switch (swipeAction) { case "0": // Open link in browser and mark as read String currentUrl = ((RssItemViewHolder) viewHolder).getRssItem().getLink(); Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(currentUrl)); startActivity(browserIntent); adapter.changeReadStateOfItem((RssItemViewHolder) viewHolder, true); break; case "1": // Star adapter.toggleStarredStateOfItem((RssItemViewHolder) viewHolder); break; case "2": // Read adapter.toggleReadStateOfItem((RssItemViewHolder) viewHolder); break; case "3": // Share RssItem rssItem = ((RssItemViewHolder) viewHolder).getRssItem(); String title = rssItem.getTitle(); String content = rssItem.getLink(); Intent share = new Intent(Intent.ACTION_SEND); share.setType("text/plain"); share.putExtra(Intent.EXTRA_SUBJECT, title); share.putExtra(Intent.EXTRA_TEXT, content); startActivity(Intent.createChooser(share, "Share Item")); break; default: Log.e(TAG, "Swipe preferences has an invalid value"); break; } // Hack to reset view, see https://code.google.com/p/android/issues/detail?id=175798 binding.list.removeView(viewHolder.itemView); } @Override public void onChildDraw(@NonNull Canvas c, @NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder, float dX, float dY, int actionState, boolean isCurrentlyActive) { super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive); // binding.swipeRefresh cancels swiping left/right when accidentally moving in the y direction; binding.swipeRefresh.setEnabled(!isCurrentlyActive); if (isCurrentlyActive) { Rect viewRect = new Rect(); viewHolder.itemView.getDrawingRect(viewRect); float fractionMoved = Math.abs(dX / viewHolder.itemView.getMeasuredWidth()); Drawable drawable; if (dX < 0) { drawable = leftSwipeDrawable; viewRect.left = (int) dX + viewRect.right; } else { drawable = rightSwipeDrawable; viewRect.right = (int) dX - viewRect.left; } if (fractionMoved > getSwipeThreshold(viewHolder)) drawable.setState(new int[]{android.R.attr.state_above_anchor}); else drawable.setState(new int[]{-android.R.attr.state_above_anchor}); viewRect.offset(0, viewHolder.itemView.getTop()); drawable.setBounds(viewRect); drawable.draw(c); } } } /** * MotionListener for Floating Action Bar Button to mark all articles in current * news feed as marked without using the menu. * * A movement up is required to prevent accidentally marking articles as read. */ private class FastMarkReadMotionListener implements View.OnTouchListener { private final View fabMarkAllAsRead; private final ImageView targetView; private boolean markAsRead = false; private float originX, originY; private float dx, dy; public FastMarkReadMotionListener(View fabMarkAllAsRead) { this.fabMarkAllAsRead = fabMarkAllAsRead; this.targetView = fabMarkAllAsRead.findViewById(R.id.target_done_all); } @Override public boolean onTouch(View v, MotionEvent event) { switch (event.getAction()) { case MotionEvent.ACTION_DOWN: this.startUserInteractionProcess(v, event); break; case MotionEvent.ACTION_MOVE: this.moveFAB(v, event); break; case MotionEvent.ACTION_UP: this.stopUserInteractionProcess(v); break; default: // Do nothing break; } return true; } /** * Start Animation for user to drag all read button to target. * Once the button is moved to the target, a success animation is loaded and shown. * * @param v FAB moved by the user * @param event motion event for v */ private void startUserInteractionProcess(View v, MotionEvent event) { // Save start location of movement and button this.originX = v.getX(); this.originY = v.getY(); this.dx = v.getX() - event.getRawX(); this.dy = v.getY() - event.getRawY(); this.markAsRead = false; // Start animation of target this.targetView.setImageResource(R.drawable.fa_all_read_target); this.targetView.setVisibility(View.VISIBLE); ((Animatable)this.targetView.getDrawable()).start(); } /** * Handle move event of FAB to mark all articles as read * Two things are done here: * - button location is changed * - it is checked iv button is moved into target area * * @param v FAB moved by the user * @param event motion event for v */ private void moveFAB(View v, MotionEvent event) { v.setX(event.getRawX() + this.dx); v.setY(event.getRawY() + this.dy); this.checkLocation(event); } /** * Checks if FAB to mark all as read was moved within the shown target area. * For location calculation, the actual location of the target view is read * and calculated if current move position is within the view area of the target view. * * @param evt MotionEvent of all read FAB */ private void checkLocation(MotionEvent evt) { // Location on screen for target is required as motion event returns location on screen int[] location = new int[2]; this.targetView.getLocationOnScreen(location); Rect r = new Rect(location[0], location[1], (location[0] + targetView.getWidth()), (location[1] + targetView.getHeight())); if (r.contains((int)evt.getRawX(), (int)evt.getRawY())) { if (!this.markAsRead) { this.markAsRead = true; this.targetView.setImageResource(R.drawable.fa_all_read_target_success); ((Animatable) this.targetView.getDrawable()).start(); } } else { if (this.markAsRead) { this.markAsRead = false; this.targetView.setImageResource(R.drawable.fa_all_read_target); ((Animatable) this.targetView.getDrawable()).start(); } } } /** * Stops the user interaction * - FAB is animated back to original position * - A success animation is shown of all articles will be marked as read * - Target view is hidden again * * @param v view of fab */ private void stopUserInteractionProcess(View v) { if (this.markAsRead) { Animation anim_success = AnimationUtils.loadAnimation(NewsReaderDetailFragment.this.getContext(), R.anim.all_read_success); anim_success.setAnimationListener(new Animation.AnimationListener() { @Override public void onAnimationStart(Animation animation) { v.animate().x(originX).y(originY).setDuration(100).setStartDelay(0).start(); } @Override public void onAnimationEnd(Animation animation) { ((Animatable)targetView.getDrawable()).stop(); targetView.setVisibility(View.INVISIBLE); } @Override public void onAnimationRepeat(Animation animation) { //Nothing to do here for now } }); this.targetView.startAnimation(anim_success); this.markAllAsReadForCurrentView(); } else { this.targetView.setVisibility(View.INVISIBLE); v.animate().x(this.originX).y(this.originY).setDuration(100).setStartDelay(0).start(); ((Animatable)this.targetView.getDrawable()).stop(); } } /** * Mark all articles in current view as read. */ private void markAllAsReadForCurrentView() { DatabaseConnectionOrm dbConn2 = new DatabaseConnectionOrm(this.fabMarkAllAsRead.getContext()); var deletedCount = dbConn2.markAllItemsAsReadForCurrentView(); NewsReaderDetailFragment.this.refreshCurrentRssView(); Snackbar.make( fabMarkAllAsRead, getResources().getQuantityString( R.plurals.marked_as_read_message, deletedCount, deletedCount ), BaseTransientBottomBar.LENGTH_SHORT ).setAnchorView(fabMarkAllAsRead).show(); } } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/NewsReaderListActivity.java ================================================ /* * Android ownCloud News * * @author David Luhmer * @copyright 2013 David Luhmer david-dev@live.de * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE * License as published by the Free Software Foundation; either * version 3 of the License, or any later version. * * This library 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 AFFERO GENERAL PUBLIC LICENSE for more details. * * You should have received a copy of the GNU Affero General Public * License along with this library. If not, see . * */ package de.luhmer.owncloudnewsreader; import static androidx.annotation.VisibleForTesting.PROTECTED; import static de.luhmer.owncloudnewsreader.LoginDialogActivity.RESULT_LOGIN; import static de.luhmer.owncloudnewsreader.LoginDialogActivity.ShowAlertDialog; import static de.luhmer.owncloudnewsreader.SettingsActivity.PREF_SERVER_SETTINGS; import android.Manifest; import android.accounts.Account; import android.accounts.AccountManager; import android.annotation.SuppressLint; import android.app.Activity; import android.content.ContentResolver; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.content.res.Configuration; import android.graphics.drawable.Drawable; import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.util.Log; import android.view.Menu; import android.view.MenuItem; import android.view.View; import android.widget.SearchView; import android.widget.Toast; import androidx.activity.OnBackPressedCallback; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import androidx.appcompat.app.ActionBarDrawerToggle; import androidx.appcompat.app.AlertDialog; import androidx.browser.customtabs.CustomTabsIntent; import androidx.core.view.GravityCompat; import androidx.customview.widget.ViewDragHelper; import androidx.fragment.app.DialogFragment; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentTransaction; import androidx.preference.PreferenceManager; import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; import com.bumptech.glide.Glide; import com.bumptech.glide.load.engine.DiskCacheStrategy; import com.bumptech.glide.request.target.CustomTarget; import com.bumptech.glide.request.target.Target; import com.bumptech.glide.request.transition.Transition; import com.google.android.material.snackbar.Snackbar; import com.nextcloud.android.sso.AccountImporter; import com.nextcloud.android.sso.api.NextcloudAPI; import com.nextcloud.android.sso.exceptions.AccountImportCancelledException; import com.nextcloud.android.sso.exceptions.NextcloudFilesAppAccountNotFoundException; import com.nextcloud.android.sso.exceptions.NextcloudFilesAppAccountPermissionNotGrantedException; import com.nextcloud.android.sso.exceptions.NextcloudFilesAppNotSupportedException; import com.nextcloud.android.sso.exceptions.NextcloudHttpRequestFailedException; import com.nextcloud.android.sso.exceptions.NoCurrentAccountSelectedException; import com.nextcloud.android.sso.exceptions.SSOException; import com.nextcloud.android.sso.exceptions.TokenMismatchException; import com.nextcloud.android.sso.helper.SingleAccountHelper; import com.nextcloud.android.sso.ui.UiExceptionManager; import com.sothree.slidinguppanel.SlidingUpPanelLayout; import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; import java.lang.reflect.Field; import java.util.Arrays; import java.util.HashSet; import java.util.List; import java.util.Objects; import java.util.concurrent.TimeUnit; import javax.inject.Inject; import javax.inject.Named; import de.luhmer.owncloudnewsreader.ListView.SubscriptionExpandableListAdapter; import de.luhmer.owncloudnewsreader.adapter.NewsListRecyclerAdapter; import de.luhmer.owncloudnewsreader.adapter.RecyclerItemClickListener; import de.luhmer.owncloudnewsreader.adapter.RssItemViewHolder; import de.luhmer.owncloudnewsreader.authentication.AccountGeneral; import de.luhmer.owncloudnewsreader.database.DatabaseConnectionOrm; import de.luhmer.owncloudnewsreader.database.model.Feed; import de.luhmer.owncloudnewsreader.database.model.Folder; import de.luhmer.owncloudnewsreader.database.model.RssItem; import de.luhmer.owncloudnewsreader.databinding.ActivityNewsreaderBinding; import de.luhmer.owncloudnewsreader.helper.DatabaseUtilsKt; import de.luhmer.owncloudnewsreader.helper.ThemeChooser; import de.luhmer.owncloudnewsreader.model.OcsUser; import de.luhmer.owncloudnewsreader.reader.nextcloud.RssItemObservable; import de.luhmer.owncloudnewsreader.services.DownloadImagesService; import de.luhmer.owncloudnewsreader.services.DownloadWebPageService; import de.luhmer.owncloudnewsreader.services.OwnCloudSyncService; import de.luhmer.owncloudnewsreader.services.events.SyncFailedEvent; import de.luhmer.owncloudnewsreader.services.events.SyncFinishedEvent; import de.luhmer.owncloudnewsreader.services.events.SyncStartedEvent; import de.luhmer.owncloudnewsreader.ssl.OkHttpSSLClient; import de.luhmer.owncloudnewsreader.view.PodcastSlidingUpPanelLayout; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.core.Completable; import io.reactivex.rxjava3.functions.Action; import io.reactivex.rxjava3.observers.DisposableObserver; import io.reactivex.rxjava3.schedulers.Schedulers; import io.reactivex.rxjava3.subjects.PublishSubject; /** * An activity representing a list of NewsReader. This activity has different * presentations for handset and tablet-size devices. * The activity makes heavy use of fragments. The list of items is a * {@link NewsReaderListFragment} and the item details (if present) is a * {@link NewsReaderDetailFragment}. *

* This activity also implements the required * {@link NewsReaderListFragment.Callbacks} interface to listen for item * selections. */ public class NewsReaderListActivity extends PodcastFragmentActivity implements NewsReaderListFragment.Callbacks, RecyclerItemClickListener, SwipeRefreshLayout.OnRefreshListener, SearchView.OnQueryTextListener { private static final String TAG = NewsReaderListActivity.class.getCanonicalName(); public static final String ITEM_ID = "ITEM_ID"; public static final String TITLE = "TITLE"; public static HashSet stayUnreadItems = new HashSet<>(); private MenuItem menuItemOnlyUnread; private MenuItem menuItemDownloadMoreItems; private Long currentFolderId; @VisibleForTesting(otherwise = PROTECTED) public ActivityNewsreaderBinding binding; private boolean mBackOpensDrawer = false; //private ServiceConnection mConnection = null; private OcsUser currentUser = null; private ActionBarDrawerToggle drawerToggle; private SearchView mSearchView; private String mSearchString; private static final String SEARCH_KEY = "SEARCH_KEY"; private PublishSubject searchPublishSubject; private static final int REQUEST_CODE_PERMISSION_DOWNLOAD_WEB_ARCHIVE = 1; private static final int REQUEST_CODE_PERMISSION_NOTIFICATIONS = 2; private static final String ID_FEED_STRING = "ID_FEED_STRING"; private static final String IS_FOLDER_BOOLEAN = "IS_FOLDER_BOOLEAN"; private static final String OPTIONAL_FOLDER_ID = "OPTIONAL_FOLDER_ID"; private static final String LIST_ADAPTER_TOTAL_COUNT = "LIST_ADAPTER_TOTAL_COUNT"; private static final String LIST_ADAPTER_PAGE_COUNT = "LIST_ADAPTER_PAGE_COUNT"; @Inject @Named("sharedPreferencesFileName") String sharedPreferencesFileName; private final View.OnClickListener mSnackbarListener = view -> { //Toast.makeText(getActivity(), "button 1 pressed", 3000).show(); updateCurrentRssView(); }; @Override public void onPostCreate(Bundle savedInstanceState) { super.onPostCreate(savedInstanceState); if (drawerToggle != null) { drawerToggle.syncState(); } // Fragments are not ready when calling the method below in onCreate() updateButtonLayout(); // Start auto sync if enabled (and user is logged in) if (isUserLoggedIn() && mPrefs.getBoolean(SettingsActivity.CB_SYNCONSTARTUP_STRING, true)) { startSync(); } } private boolean isUserLoggedIn() { return (mPrefs.getString(SettingsActivity.EDT_OWNCLOUDROOTPATH_STRING, null) != null); } SlidingUpPanelLayout.PanelSlideListener panelSlideListener = new SlidingUpPanelLayout.PanelSlideListener() { @Override public void onPanelSlide(View panel, float slideOffset) { } @Override public void onPanelStateChanged(View panel, SlidingUpPanelLayout.PanelState previousState, SlidingUpPanelLayout.PanelState newState) { boolean panelIsOpen = newState.equals(SlidingUpPanelLayout.PanelState.EXPANDED); // in case the podcast panel is open, we need to close it first (intercept back presses) onBackPressedCallback.setEnabled(panelIsOpen || mBackOpensDrawer); } }; OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(true) { // we need to handle two cases: // - The user has the "Open Sidebar on Backpress" option enabled // - the callback need to be set because we want to close the podcast pane on back navigation (in case it's open) // - set callback will be enabled/disabled based on whether the podcast pane is open/closed // - The user has the "Open Sidebar on Backpress" option disabled // - the callback needs to check first if the podcast is open - if so - close it and on // the next back navigation open the sidebar - and then close the app // - once the podcast pane is open - the callback will be disabled // - the event listener (onDrawerClosed) will enable the back pressed callback again @Override public void handleOnBackPressed() { Log.d(TAG, "handleOnBackPressed() 1"); if (!handlePodcastBackPressed()) { Log.d(TAG, "handleOnBackPressed() 2"); binding.drawerLayout.openDrawer(GravityCompat.START); setEnabled(false); } } }; protected DisposableObserver startSyncObserver = new DisposableObserver<>() { @Override public void onNext(@NonNull Boolean nothing) { startSync(); } @Override public void onError(Throwable e) { Log.e(TAG, e.getMessage()); } @Override public void onComplete() { } }; @Override protected void onRestoreInstanceState(@NonNull Bundle savedInstanceState) { restoreInstanceState(savedInstanceState); super.onRestoreInstanceState(savedInstanceState); } @Override protected void onSaveInstanceState(@NonNull Bundle outState) { saveInstanceState(outState); super.onSaveInstanceState(outState); } private void saveInstanceState(Bundle outState) { NewsReaderDetailFragment ndf = getNewsReaderDetailFragment(); if (ndf != null) { outState.putLong(OPTIONAL_FOLDER_ID, ndf.getIdFolder()); outState.putBoolean(IS_FOLDER_BOOLEAN, ndf.getIdFeed() == null); outState.putLong(ID_FEED_STRING, ndf.getIdFeed() != null ? ndf.getIdFeed() : ndf.getIdFolder()); NewsListRecyclerAdapter adapter = (NewsListRecyclerAdapter) ndf.getRecyclerView().getAdapter(); if (adapter != null) { outState.putInt(LIST_ADAPTER_TOTAL_COUNT, adapter.getTotalItemCount()); outState.putInt(LIST_ADAPTER_PAGE_COUNT, adapter.getCachedPages()); } } if (mSearchView != null) { mSearchString = mSearchView.getQuery().toString(); outState.putString(SEARCH_KEY, mSearchString); } } private void restoreInstanceState(Bundle savedInstanceState) { if (savedInstanceState.containsKey(ID_FEED_STRING) && savedInstanceState.containsKey(IS_FOLDER_BOOLEAN) && savedInstanceState.containsKey(OPTIONAL_FOLDER_ID)) { NewsListRecyclerAdapter adapter = new NewsListRecyclerAdapter(this, getNewsReaderDetailFragment().binding.list, this, mPostDelayHandler, mPrefs); adapter.setTotalItemCount(savedInstanceState.getInt(LIST_ADAPTER_TOTAL_COUNT)); adapter.setCachedPages(savedInstanceState.getInt(LIST_ADAPTER_PAGE_COUNT)); getNewsReaderDetailFragment() .getRecyclerView() .setAdapter(adapter); updateDetailFragment(savedInstanceState.getLong(ID_FEED_STRING), savedInstanceState.getBoolean(IS_FOLDER_BOOLEAN), savedInstanceState.getLong(OPTIONAL_FOLDER_ID), false); } mSearchString = savedInstanceState.getString(SEARCH_KEY, null); } @Override public void onConfigurationChanged(@NonNull Configuration newConfig) { super.onConfigurationChanged(newConfig); if (drawerToggle != null) { drawerToggle.onConfigurationChanged(newConfig); } } void showChangelogIfNecessary() { // on first app start with new version - always show the changelog int currentVersionCode = BuildConfig.VERSION_CODE; int previousVersionCode = mPrefs.getInt(Constants.PREVIOUS_VERSION_CODE, 0); if (currentVersionCode > previousVersionCode) { DialogFragment dialog = new VersionInfoDialogFragment(); dialog.show(getSupportFragmentManager(), "VersionChangelogDialogFragment"); mPrefs.edit().putInt(Constants.PREVIOUS_VERSION_CODE, currentVersionCode).apply(); } } /** * This method increases the "pull to open drawer" area by three. * This method should be called only once! */ private void adjustEdgeSizeOfDrawer() { try { // increase the size of the drag margin to prevent starting a star swipe when // trying to open the drawer. Field mDragger = Objects.requireNonNull(binding.drawerLayout).getClass().getDeclaredField("mLeftDragger"); mDragger.setAccessible(true); ViewDragHelper draggerObj = (ViewDragHelper) mDragger.get(binding.drawerLayout); Field mEdgeSize = Objects.requireNonNull(draggerObj).getClass().getDeclaredField("mEdgeSize"); mEdgeSize.setAccessible(true); int edge = mEdgeSize.getInt(draggerObj); mEdgeSize.setInt(draggerObj, edge * 3); } catch (Exception e) { Log.e(TAG, "Setting edge width of drawer failed..", e); } } public int getEdgeSizeOfDrawer() { try { Field mDragger = Objects.requireNonNull(binding.drawerLayout).getClass().getDeclaredField("mLeftDragger"); mDragger.setAccessible(true); ViewDragHelper draggerObj = (ViewDragHelper) mDragger.get(binding.drawerLayout); Field mEdgeSize = Objects.requireNonNull(draggerObj).getClass().getDeclaredField("mEdgeSize"); mEdgeSize.setAccessible(true); return mEdgeSize.getInt(draggerObj); } catch (Exception e) { Log.e(TAG, "Failed to get edge size of drawer", e); } return 0; } /** * Check if the account is in the Android Account Manager. If not it will be added automatically */ private void initAccountManager() { AccountManager mAccountManager = AccountManager.get(this); boolean isAccountThere = false; Account[] accounts = mAccountManager.getAccounts(); String accountType = AccountGeneral.getAccountType(this); for (Account account : accounts) { if (account.type.intern().equals(accountType)) { isAccountThere = true; } } //If the account is not in the Android Account Manager if (!isAccountThere) { //Then add the new account Account account = new Account(getString(R.string.app_name), accountType); try { mAccountManager.addAccountExplicitly(account, "", new Bundle()); SettingsFragment.setAccountSyncInterval(this, getResources().getInteger(R.integer.default_sync_minutes)); } catch (SecurityException exception) { // not sure if this error can still occur.. it showed up a few versions ago.. so we'll // keep it here just to be safe new AlertDialog.Builder(this) .setTitle("Failed to add account") .setMessage("If you installed this app previously from anywhere else than the Google Play Store (e.g. F-Droid), please make sure to uninstall it first.") .setPositiveButton(android.R.string.ok, (dialog, which) -> { dialog.dismiss(); }) .setIcon(android.R.drawable.ic_dialog_alert) .show(); } } } public void checkNotificationPermissions() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { requestPermissions(new String[]{Manifest.permission.POST_NOTIFICATIONS}, REQUEST_CODE_PERMISSION_NOTIFICATIONS); } } /** * Updates the unread counts of the data in the sidebar (e.g. when the user marked a few articles as read we just need to reload the unread counts) */ public void reloadCountNumbersOfSlidingPaneAdapter() { NewsReaderListFragment nlf = getSlidingListFragment(); if (nlf != null) { nlf.listViewNotifyDataSetChanged(); } } /** * Reload the whole Sidebar (all the categories / items in the sidebar) */ public void reloadSidebar() { NewsReaderListFragment nlf = getSlidingListFragment(); if (nlf != null) { nlf.reloadAdapter(); nlf.bindUserInfoToUI(); } } protected void updateCurrentRssView() { NewsReaderDetailFragment ndf = getNewsReaderDetailFragment(); if (ndf != null) { ndf.updateCurrentRssView(); } } public void switchToAllUnreadItemsFolder() { updateDetailFragment(SubscriptionExpandableListAdapter.SPECIAL_FOLDERS.ALL_UNREAD_ITEMS.getValue(), true, null, true); } @Subscribe(threadMode = ThreadMode.MAIN) public void onEventMainThread(SyncFailedEvent event) { Throwable exception = event.getCause(); // If SSOException is wrapped inside another exception, we extract that SSOException if(exception.getCause() != null && exception.getCause() instanceof SSOException) { exception = exception.getCause(); } if(exception instanceof SSOException){ if(exception instanceof NextcloudHttpRequestFailedException && ((NextcloudHttpRequestFailedException) exception).getStatusCode() == 302) { ShowAlertDialog( getString(R.string.login_dialog_title_error), getString(R.string.login_dialog_text_news_app_not_installed_on_server, "https://github.com/nextcloud/news/blob/master/docs/install.md#installing-from-the-app-store"), this); } else if (exception instanceof TokenMismatchException) { Toast.makeText(NewsReaderListActivity.this, "Token out of sync. Please reauthenticate", Toast.LENGTH_LONG).show(); try { SingleAccountHelper.reauthenticateCurrentAccount(this); } catch (NextcloudFilesAppAccountNotFoundException | NoCurrentAccountSelectedException | NextcloudFilesAppNotSupportedException e) { UiExceptionManager.showDialogForException(this, e); } catch (NextcloudFilesAppAccountPermissionNotGrantedException e) { // Unable to reauthenticate account just like that.. startLoginActivity(); } //StartLoginFragment(this); } else { UiExceptionManager.showDialogForException(this, (SSOException) exception); //UiExceptionManager.showNotificationForException(this, (SSOException) exception); } } else { Toast.makeText(NewsReaderListActivity.this, exception.getLocalizedMessage(), Toast.LENGTH_LONG).show(); } updateButtonLayout(); syncFinishedHandler(); } @Subscribe(threadMode = ThreadMode.MAIN) public void onEventMainThread(SyncStartedEvent event) { Log.d(TAG, "onEventMainThread - SyncStartedEvent"); updateButtonLayout(); } @Subscribe(threadMode = ThreadMode.MAIN) public void onEventMainThread(SyncFinishedEvent event) { Log.d(TAG, "onEventMainThread - SyncFinishedEvent"); updateButtonLayout(); syncFinishedHandler(); } @Override protected void onCreate(Bundle savedInstanceState) { ((NewsReaderApplication) getApplication()).getAppComponent().injectActivity(this); SharedPreferences defaultValueSp = getSharedPreferences(PreferenceManager.KEY_HAS_SET_DEFAULT_VALUES, Context.MODE_PRIVATE); if (!defaultValueSp.getBoolean(PreferenceManager.KEY_HAS_SET_DEFAULT_VALUES, false)) { PreferenceManager.setDefaultValues(this, sharedPreferencesFileName, Context.MODE_PRIVATE, R.xml.pref_data_sync, true); PreferenceManager.setDefaultValues(this, sharedPreferencesFileName, Context.MODE_PRIVATE, R.xml.pref_display, true); PreferenceManager.setDefaultValues(this, sharedPreferencesFileName, Context.MODE_PRIVATE, R.xml.pref_general, true); } super.onCreate(savedInstanceState); binding = ActivityNewsreaderBinding.inflate(getLayoutInflater()); setContentView(binding.getRoot()); setSupportActionBar(binding.toolbarLayout.toolbar); initAccountManager(); checkNotificationPermissions(); // Init config --> if nothing is configured start the login dialog. if (!isUserLoggedIn()) { startLoginActivity(); } Bundle args = new Bundle(); String userName = mPrefs.getString(SettingsActivity.EDT_USERNAME_STRING, null); String url = mPrefs.getString(SettingsActivity.EDT_OWNCLOUDROOTPATH_STRING, null); args.putString("accountName", String.format("%s\n%s", userName, url)); NewsReaderListFragment newsReaderListFragment = new NewsReaderListFragment(); newsReaderListFragment.setArguments(args); // Insert the fragment by replacing any existing fragment FragmentManager fragmentManager = getSupportFragmentManager(); fragmentManager.beginTransaction() .replace(R.id.left_drawer, newsReaderListFragment) .commit(); if (binding.drawerLayout != null) { drawerToggle = new ActionBarDrawerToggle(this, binding.drawerLayout, binding.toolbarLayout.toolbar, R.string.news_list_drawer_text, R.string.news_list_drawer_text) { @Override public void onDrawerClosed(View drawerView) { super.onDrawerClosed(drawerView); onBackPressedCallback.setEnabled(mBackOpensDrawer); syncState(); } @Override public void onDrawerOpened(View drawerView) { super.onDrawerOpened(drawerView); reloadCountNumbersOfSlidingPaneAdapter(); // -> handleOnBackPressed() will disable it // onBackPressedCallback.setEnabled(false); syncState(); } }; binding.drawerLayout.addDrawerListener(drawerToggle); adjustEdgeSizeOfDrawer(); } setSupportActionBar(binding.toolbarLayout.toolbar); Objects.requireNonNull(getSupportActionBar()).setDisplayShowHomeEnabled(true); if (drawerToggle != null) { drawerToggle.syncState(); } getPodcastSlidingUpPanelLayout().addPanelSlideListener(panelSlideListener); getOnBackPressedDispatcher().addCallback(this, onBackPressedCallback); //AppRater.app_launched(this); //AppRater.rateNow(this); getNewsReaderDetailFragment().syncTrigger .debounce(500, TimeUnit.MILLISECONDS) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribeWith(startSyncObserver); if (savedInstanceState == null) { // When the app starts (no orientation change) updateDetailFragment(SubscriptionExpandableListAdapter.SPECIAL_FOLDERS.ALL_UNREAD_ITEMS.getValue(), true, null, true); } showChangelogIfNecessary(); } @Override protected void onResume() { mBackOpensDrawer = mPrefs.getBoolean(SettingsActivity.CB_PREF_BACK_OPENS_DRAWER, false); onBackPressedCallback.setEnabled(mBackOpensDrawer); reloadSidebar(); invalidateOptionsMenu(); super.onResume(); } @Override protected PodcastSlidingUpPanelLayout getPodcastSlidingUpPanelLayout() { return binding.slidingLayout; } @Override public void onRefresh() { startSync(); } /** * @return true if new items count was greater than 0 */ private boolean syncFinishedHandler() { UpdateItemList(); updatePodcastView(); updateDetailFragmentTitle(); reloadSidebar(); if(mApi.getNewsAPI() != null) { getSlidingListFragment().startAsyncTaskGetUserInfo(); } int newItemsCount = mPrefs.getInt(Constants.LAST_UPDATE_NEW_ITEMS_COUNT_STRING, 0); if (newItemsCount > 0) { int firstVisiblePosition = getNewsReaderDetailFragment().getFirstVisibleScrollPosition(); // Only show the update snackbar if scrollposition is not top. // 0 if scrolled all the way up // 1 if no items are visible right now (e.g. first sync) if (firstVisiblePosition == 0 || firstVisiblePosition == -1) { updateCurrentRssView(); } else { showSnackbarNewItems(newItemsCount); } return true; } else { int firstVisiblePosition = getNewsReaderDetailFragment().getFirstVisibleScrollPosition(); // update rss view even if no new items are available // If the user just finished reading some articles (e.g. all unread items) - he most // likely wants the read articles to be removed when the sync is finished if (firstVisiblePosition == 0 || firstVisiblePosition == -1) { // if the app was just started (initial sync - just reload the list) updateCurrentRssView(); } else { // otherwise ask user if he want's to reload e.g. when we are scrolled all the way down showSnackbarNoNewItems(); } } return false; } private void showSnackbarNewItems(int newItemsCount) { Snackbar snackbar = makeFABAwareSnackbar( getResources().getQuantityString(R.plurals.message_bar_new_articles_available, newItemsCount, newItemsCount), Snackbar.LENGTH_LONG ); snackbar.setAction(getString(R.string.message_bar_reload), mSnackbarListener); //snackbar.setActionTextColor(ContextCompat.getColor(this, R.color.accent_material_dark)); // Setting android:TextColor to #000 in the light theme results in black on black // text on the Snackbar, set the text back to white, //TextView textView = snackbar.getView().findViewById(com.google.android.material.R.id.snackbar_text); //textView.setTextColor(Color.WHITE); snackbar.show(); } /** * Callback method from {@link NewsReaderListFragment.Callbacks} indicating * that the item with the given ID was selected. */ @Override public void onTopItemClicked(long idFeed, boolean isFolder, Long optional_folder_id) { if (binding.drawerLayout != null) binding.drawerLayout.closeDrawer(GravityCompat.START); updateDetailFragment(idFeed, isFolder, optional_folder_id, true); } @Override public void onChildItemClicked(long idFeed, Long optional_folder_id) { if (binding.drawerLayout != null) binding.drawerLayout.closeDrawer(GravityCompat.START); updateDetailFragment(idFeed, false, optional_folder_id, true); } @Override public void onTopItemLongClicked(long idFeed, boolean isFolder) { startDialogFragment(idFeed, isFolder); } @Override public void onUserInfoUpdated(OcsUser userInfo) { currentUser = userInfo; invalidateOptionsMenu(); } @Override public void onCreateFolderClicked() { FragmentTransaction ft = getSupportFragmentManager().beginTransaction(); Fragment prev = getSupportFragmentManager().findFragmentByTag("add_folder_dialog"); if (prev != null) { ft.remove(prev); } ft.addToBackStack(null); AddFolderDialogFragment fragment = AddFolderDialogFragment.newInstance(); fragment.setActivity(this); fragment.show(ft, "add_folder_dialog"); } @Override public void onChildItemLongClicked(long idFeed) { startDialogFragment(idFeed, false); } private void startDialogFragment(long id, Boolean isFolder) { DatabaseConnectionOrm dbConn = new DatabaseConnectionOrm(getApplicationContext()); if (!isFolder) { String titel = dbConn.getFeedById(id).getFeedTitle(); String iconurl = dbConn.getFeedById(id).getFaviconUrl(); String feedurl = dbConn.getFeedById(id).getLink(); FragmentTransaction ft = getSupportFragmentManager().beginTransaction(); Fragment prev = getSupportFragmentManager().findFragmentByTag("news_reader_list_dialog"); if (prev != null) { ft.remove(prev); } ft.addToBackStack(null); NewsReaderListDialogFragment fragment = NewsReaderListDialogFragment.newInstance(id, titel, iconurl, feedurl); fragment.setActivity(this); fragment.show(ft, "news_reader_list_dialog"); } else { Folder folder = dbConn.getFolderById(id); if (folder == null) { Log.e(TAG, "cannot find folder with id: " + id); return; } String label = folder.getLabel(); FragmentTransaction ft = getSupportFragmentManager().beginTransaction(); Fragment prev = getSupportFragmentManager().findFragmentByTag("folder_options_dialog"); if (prev != null) { ft.remove(prev); } ft.addToBackStack(null); FolderOptionsDialogFragment fragment = FolderOptionsDialogFragment.newInstance(id, label); fragment.setActivity(this); fragment.show(ft, "folder_options_dialog"); } } public static final int RESULT_ADD_NEW_FEED = 15643; private void updateDetailFragmentTitle() { NewsReaderDetailFragment fragment = getNewsReaderDetailFragment(); Long id = fragment.getIdFeed() == null ? fragment.getIdFolder() : fragment.getIdFeed(); if (id == null) { return; } DatabaseConnectionOrm dbConn = new DatabaseConnectionOrm(getApplicationContext()); String title = null; boolean isFolder = fragment.getIdFolder() == null; if (isFolder) { int idFolder = id.intValue(); if (idFolder >= 0) { Folder folder = dbConn.getFolderById(id); if (folder == null) { return; } title = folder.getLabel(); } else if (idFolder == -10) { title = getString(R.string.allUnreadFeeds); } else if (idFolder == -11) { title = getString(R.string.starredFeeds); } else if (idFolder == -13) { title = getString(R.string.downloadedPodcasts); } } else { Feed feed = dbConn.getFeedById(id); if (feed == null) { return; } title = feed.getFeedTitle(); } fragment.setTitle(title); } public void UpdateItemList() { try { NewsReaderDetailFragment nrD = getNewsReaderDetailFragment(); if (nrD != null && nrD.getRecyclerView() != null) { nrD.getRecyclerView().getAdapter().notifyDataSetChanged(); } } catch (Exception ex) { ex.printStackTrace(); } } private void showSnackbarNoNewItems() { Snackbar snackbar = makeFABAwareSnackbar( getResources().getString(R.string.message_bar_scroll_top), Snackbar.LENGTH_LONG ); snackbar.setAction(getString(R.string.message_bar_reload), mSnackbarListener); snackbar.show(); } public void startSync() { if (!isUserLoggedIn()) { startLoginActivity(); } else { if (!OwnCloudSyncService.isSyncRunning()) { Log.d(TAG, "Starting Sync"); mPostDelayHandler.stopRunningPostDelayHandler(); // Stop pending sync handler Bundle accBundle = new Bundle(); accBundle.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true); AccountManager mAccountManager = AccountManager.get(this); Account[] accounts = mAccountManager.getAccounts(); for (Account acc : accounts) { String accountType = AccountGeneral.getAccountType(this); if (acc.type.equals(accountType)) { ContentResolver.requestSync(acc, accountType, accBundle); } } //http://stackoverflow.com/questions/5253858/why-does-contentresolver-requestsync-not-trigger-a-sync } else { Log.d(TAG, "Sync is already running - Just update Button Layout"); updateButtonLayout(); } } } public void updateButtonLayout() { NewsReaderListFragment newsReaderListFragment = getSlidingListFragment(); NewsReaderDetailFragment newsReaderDetailFragment = getNewsReaderDetailFragment(); if(newsReaderListFragment != null && newsReaderDetailFragment != null) { boolean isSyncRunning = OwnCloudSyncService.isSyncRunning(); newsReaderListFragment.setRefreshing(isSyncRunning); newsReaderDetailFragment.binding.swipeRefresh.setRefreshing(isSyncRunning); } } private void updateDetailFragment(long id, Boolean folder, Long optional_folder_id, boolean updateListView) { if(menuItemDownloadMoreItems != null) { menuItemDownloadMoreItems.setEnabled(true); } DatabaseConnectionOrm dbConn = new DatabaseConnectionOrm(getApplicationContext()); Long feedId = null; Long folderId; String title = null; if(!folder) { currentFolderId = null; feedId = id; folderId = optional_folder_id; title = dbConn.getFeedById(id).getFeedTitle(); } else { currentFolderId = id; folderId = id; int idFolder = (int) id; if (idFolder >= 0) { title = dbConn.getFolderById(id).getLabel(); } else if (idFolder == -10) { title = getString(R.string.allUnreadFeeds); } else if (idFolder == -11) { title = getString(R.string.starredFeeds); } else if (idFolder == -13) { title = getString(R.string.downloadedPodcasts); } } syncMenuItemUnreadOnly(); NewsReaderDetailFragment fragment = getNewsReaderDetailFragment(); fragment.setData(feedId, folderId, title, updateListView); } public MenuItem getMenuItemDownloadMoreItems() { return menuItemDownloadMoreItems; } @Override public boolean onPrepareOptionsMenu(Menu menu) { MenuItem accountItem = menu.findItem(R.id.menu_account); prepareAccountMenuItem(accountItem); return super.onPrepareOptionsMenu(menu); } @Override public boolean onCreateOptionsMenu(Menu menu) { // Inflate the menu; this adds items to the action bar if it is present. getMenuInflater().inflate(R.menu.news_reader, menu); menuItemDownloadMoreItems = menu.findItem(R.id.menu_downloadMoreItems); menuItemDownloadMoreItems.setEnabled(false); MenuItem searchItem = menu.findItem(R.id.menu_search); menuItemOnlyUnread = menu.findItem(R.id.menu_toggleShowOnlyUnread); menuItemOnlyUnread.setChecked(mPrefs.getBoolean(SettingsActivity.CB_SHOWONLYUNREAD_STRING, false)); syncMenuItemUnreadOnly(); //Set expand listener to close keyboard searchItem.setOnActionExpandListener(new MenuItem.OnActionExpandListener() { @Override public boolean onMenuItemActionExpand(MenuItem item) { return true; } @Override public boolean onMenuItemActionCollapse(MenuItem item) { //onQueryTextChange(""); // Reset search mSearchView.setQuery("", true); clearSearchViewFocus(); return true; } }); mSearchView = (SearchView) menu.findItem(R.id.menu_search).getActionView(); mSearchView.setIconifiedByDefault(false); mSearchView.setOnQueryTextListener(this); mSearchView.setOnQueryTextFocusChangeListener((v, hasFocus) -> { if(!hasFocus) { clearSearchViewFocus(); } }); NewsReaderDetailFragment ndf = getNewsReaderDetailFragment(); if(ndf != null) { ndf.updateMenuItemsState(); } updateButtonLayout(); // focus the SearchView (if search view was active before orientation change) if (mSearchString != null && !mSearchString.isEmpty()) { searchItem.expandActionView(); mSearchView.setQuery(mSearchString, true); mSearchView.clearFocus(); } return true; } public static final int RESULT_SETTINGS = 15642; private void syncMenuItemUnreadOnly() { if (menuItemOnlyUnread != null && currentFolderId != null) { menuItemOnlyUnread.setVisible(!(currentFolderId == -11 || currentFolderId == -10)); } } @Override public boolean onOptionsItemSelected(@NonNull MenuItem item) { if (drawerToggle != null && drawerToggle.onOptionsItemSelected(item)) return true; int itemId = item.getItemId(); if (itemId == android.R.id.home) { if (handlePodcastBackPressed()) return true; } else if (itemId == R.id.menu_update) { startSync(); } else if (itemId == R.id.menu_account) { startLoginActivity(); } else if (itemId == R.id.menu_toggleShowOnlyUnread) { boolean newValue = !mPrefs.getBoolean(SettingsActivity.CB_SHOWONLYUNREAD_STRING, false); mPrefs.edit().putBoolean(SettingsActivity.CB_SHOWONLYUNREAD_STRING, newValue).commit(); item.setChecked(newValue); reloadSidebar(); updateCurrentRssView(); } else if (itemId == R.id.menu_StartImageCaching) { final DatabaseConnectionOrm dbConn = new DatabaseConnectionOrm(this); long highestItemId = dbConn.getLowestRssItemIdUnread(); Intent data = new Intent(); data.putExtra(DownloadImagesService.LAST_ITEM_ID, highestItemId); data.putExtra(DownloadImagesService.DOWNLOAD_MODE_STRING, DownloadImagesService.DownloadMode.PICTURES_ONLY); DownloadImagesService.enqueueWork(this, data); } else if (itemId == R.id.menu_CreateDatabaseDump) { DatabaseUtilsKt.copyDatabaseToSdCard(this); new AlertDialog.Builder(this) .setMessage("Created dump at: " + DatabaseUtilsKt.getPath(this)) .setNeutralButton(getString(android.R.string.ok), null) .show(); } else if (itemId == R.id.menu_markAllAsRead) { NewsReaderDetailFragment ndf = getNewsReaderDetailFragment(); if (ndf != null) { DatabaseConnectionOrm dbConn2 = new DatabaseConnectionOrm(this); var deletedCount =dbConn2.markAllItemsAsReadForCurrentView(); reloadCountNumbersOfSlidingPaneAdapter(); ndf.refreshCurrentRssView(); var snackbar = makeFABAwareSnackbar(getResources().getQuantityString( R.plurals.marked_as_read_message, deletedCount, deletedCount ), Snackbar.LENGTH_SHORT); snackbar.show(); } return true; } else if (itemId == R.id.menu_downloadMoreItems) { DownloadMoreItems(); return true; } else if (itemId == R.id.menu_search) { mSearchView.setIconified(false); mSearchView.setFocusable(true); mSearchView.requestFocusFromTouch(); return true; } else if (itemId == R.id.menu_download_web_archive) { checkAndStartDownloadWebPagesForOfflineReadingPermission(); return true; } return super.onOptionsItemSelected(item); } private Snackbar makeFABAwareSnackbar(String text, int duration) { NewsReaderDetailFragment ndf = getNewsReaderDetailFragment(); var fab = ndf.binding.fabDoneAll; var snackbar = Snackbar.make( binding.coordinatorLayout, text, duration ); if (fab.getVisibility() == View.VISIBLE) { snackbar.setAnchorView(fab); } return snackbar; } private void checkAndStartDownloadWebPagesForOfflineReadingPermission() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED && checkSelfPermission(Manifest.permission.FOREGROUND_SERVICE) == PackageManager.PERMISSION_GRANTED) { Log.v("Permission error","You have permission"); startDownloadWebPagesForOfflineReading(); } else { Log.e("Permission error","Asking for permission"); requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.FOREGROUND_SERVICE}, REQUEST_CODE_PERMISSION_DOWNLOAD_WEB_ARCHIVE); } } else { //you dont need to worry about these stuff below api level 23 Log.v("Permission error","You already have the permission"); startDownloadWebPagesForOfflineReading(); } } private void startDownloadWebPagesForOfflineReading() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { startForegroundService(new Intent(this, DownloadWebPageService.class)); } else { startService(new Intent(this, DownloadWebPageService.class)); } } private void DownloadMoreItems() { final NewsReaderDetailFragment ndf = getNewsReaderDetailFragment(); // Folder is selected.. download more items for all feeds in this folder if(ndf.getIdFeed() == null) { Long idFolder = ndf.getIdFolder(); List specialFolders = Arrays.asList( SubscriptionExpandableListAdapter.SPECIAL_FOLDERS.ALL_UNREAD_ITEMS.getValue(), SubscriptionExpandableListAdapter.SPECIAL_FOLDERS.ALL_STARRED_ITEMS.getValue(), SubscriptionExpandableListAdapter.SPECIAL_FOLDERS.ALL_ITEMS.getValue() ); // if a special folder is selected, we can start the sync if (specialFolders.contains(idFolder.intValue())) { startSync(); } else { // Otherwise load more items for that particular folder and all its feeds DatabaseConnectionOrm dbConn = new DatabaseConnectionOrm(this); for (Feed feed : dbConn.getFolderById(idFolder).getFeedList()) { downloadMoreItemsForFeed(feed.getId()); } } } else { // Single feed is selected.. download more items downloadMoreItemsForFeed(ndf.getIdFeed()); } Toast.makeText(this, getString(R.string.toast_GettingMoreItems), Toast.LENGTH_SHORT).show(); } @SuppressLint("CheckResult") private void downloadMoreItemsForFeed(final Long feedId) { Completable.fromAction(new Action() { @Override public void run() throws Exception { DatabaseConnectionOrm dbConn = new DatabaseConnectionOrm(NewsReaderListActivity.this); RssItem rssItem = dbConn.getLowestRssItemIdByFeed(feedId); long offset = Long.MAX_VALUE; if(rssItem != null) { offset = rssItem.getId(); } int type = 0; // the type of the query (Feed: 0, Folder: 1, Starred: 2, All: 3) List buffer = mApi.getNewsAPI().items(100, offset, type, feedId, true, false).execute().body(); RssItemObservable.performDatabaseBatchInsert(dbConn, buffer); } }) .subscribeOn(Schedulers.newThread()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(() -> { updateCurrentRssView(); Log.v(TAG, "Finished Download extra items.."); }, throwable -> { throwable.printStackTrace(); Throwable e = OkHttpSSLClient.HandleExceptions(throwable); Toast.makeText(NewsReaderListActivity.this, getString(R.string.login_dialog_text_something_went_wrong) + " - " + e.getMessage(), Toast.LENGTH_SHORT).show(); }); } @Override protected void onActivityResult(int requestCode, int resultCode, final Intent data) { super.onActivityResult(requestCode, resultCode, data); if(resultCode == RESULT_OK) { updateListView(); reloadCountNumbersOfSlidingPaneAdapter(); } if (requestCode == RESULT_LOGIN) { Intent intent = new Intent(); intent.putExtra(PREF_SERVER_SETTINGS, true); setResult(RESULT_OK, intent); } if(requestCode == RESULT_SETTINGS) { // Extra is set if user entered/modified server settings if (data == null || data.getBooleanExtra(PREF_SERVER_SETTINGS,false)) { resetUiAndStartSync(); } else { //Update settings of image Loader mApi.initApi(new NextcloudAPI.ApiConnectedListener() { @Override public void onConnected() { ensureCorrectTheme(data); } @Override public void onError(Exception ex) { ensureCorrectTheme(data); ex.printStackTrace(); } }); } } else if(requestCode == RESULT_ADD_NEW_FEED) { if(data != null) { boolean val = data.getBooleanExtra(NewFeedActivity.ADD_NEW_SUCCESS, false); if (val) { startSync(); } } } else if(requestCode == RESULT_LOGIN) { resetUiAndStartSync(); } try { AccountImporter.onActivityResult(requestCode, resultCode, data, this, account -> { Log.d(TAG, "accountAccessGranted() called with: account = [" + account + "]"); mApi.initApi(new NextcloudAPI.ApiConnectedListener() { @Override public void onConnected() { Log.d(TAG, "onConnected() called"); } @Override public void onError(Exception ex) { Log.e(TAG, "onError() called with:", ex); } }); }); } catch (AccountImportCancelledException ignored) { } } @Override public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { super.onRequestPermissionsResult(requestCode, permissions, grantResults); if(grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { if(requestCode == REQUEST_CODE_PERMISSION_DOWNLOAD_WEB_ARCHIVE) { startDownloadWebPagesForOfflineReading(); } else { Log.d(TAG, "No action defined here yet.."); } } } private void ensureCorrectTheme(Intent data) { String oldListLayout = data.getStringExtra(SettingsActivity.RI_FEED_LIST_LAYOUT); String newListLayout = mPrefs.getString(SettingsActivity.SP_FEED_LIST_LAYOUT, "0"); boolean themeChanged = !newListLayout.equals(oldListLayout); boolean cacheWasCleared = data.hasExtra(SettingsActivity.RI_CACHE_CLEARED); Log.d(TAG, "themeChanged: " + themeChanged + " cacheWasCleared: " + cacheWasCleared); if (ThemeChooser.themeRequiresRestartOfUI() || themeChanged) { NewsReaderListActivity.this.recreate(); } else if (cacheWasCleared) { resetUiAndStartSync(); } } @VisibleForTesting public NewsReaderListFragment getSlidingListFragment() { return ((NewsReaderListFragment) getSupportFragmentManager().findFragmentById(R.id.left_drawer)); } @VisibleForTesting public NewsReaderDetailFragment getNewsReaderDetailFragment() { return (NewsReaderDetailFragment) getSupportFragmentManager().findFragmentById(R.id.content_frame); } public void startLoginActivity() { Intent loginIntent = new Intent(this, LoginDialogActivity.class); startActivityForResult(loginIntent, RESULT_LOGIN); } private void resetUiAndStartSync() { reloadSidebar(); updateCurrentRssView(); startSync(); } private void updateListView() { getNewsReaderDetailFragment().notifyDataSetChangedOnAdapter(); } @Override public void onClick(RssItemViewHolder vh, int position) { Feed feed = vh.getRssItem().getFeed(); // check @NewsReadListDialogFragment // open feed in means: // 1: openInDetailedView // 2: openInBrowserCct // 3: openInBrowserExternal Long openIn = feed.getOpenIn(); Uri currentUrl = Uri.parse(vh.getRssItem().getLink()); if (openIn == null) { if (mPrefs.getBoolean(SettingsActivity.CB_SKIP_DETAILVIEW_AND_OPEN_BROWSER_DIRECTLY_STRING, false)) { //Choose Browser based on user settings //modified copy from NewsDetailFragment.java:loadUrl(String url) int selectedBrowser = Integer.parseInt(mPrefs.getString(SettingsActivity.SP_DISPLAY_BROWSER, "0")); switch (selectedBrowser) { case 0, 2 -> openRssItemInCustomTab(currentUrl); case 1 -> openRssItemInExternalBrowser(currentUrl); } ((NewsListRecyclerAdapter) getNewsReaderDetailFragment().getRecyclerView().getAdapter()).changeReadStateOfItem(vh, true); } else { openRssItemInDetailedView(position); } } else { switch (openIn.intValue()) { case 1 -> openRssItemInDetailedView(position); case 2 -> openRssItemInCustomTab(currentUrl); case 3 -> openRssItemInExternalBrowser(currentUrl); default -> throw new RuntimeException("Unreachable: openIn has illegal value " + openIn); } } } private void openRssItemInDetailedView(int position) { Intent intentNewsDetailAct = new Intent(this, NewsDetailActivity.class); intentNewsDetailAct.putExtra(NewsReaderListActivity.ITEM_ID, position); intentNewsDetailAct.putExtra(NewsReaderListActivity.TITLE, getNewsReaderDetailFragment().getTitle()); startActivityForResult(intentNewsDetailAct, Activity.RESULT_CANCELED); } private void openRssItemInCustomTab(Uri currentUrl) { CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder() .setShowTitle(true) .setStartAnimations(this, R.anim.slide_in_right, R.anim.slide_out_left) .setExitAnimations(this, R.anim.slide_in_left, R.anim.slide_out_right) .setShareState(CustomTabsIntent.SHARE_STATE_ON); builder.build().launchUrl(this, currentUrl); } private void openRssItemInExternalBrowser(Uri currentUrl) { Intent browserIntent = new Intent(Intent.ACTION_VIEW, currentUrl); startActivity(browserIntent); } private void prepareAccountMenuItem(MenuItem accountMenuItem) { if (currentUser == null || currentUser.getId() == null) { // the default menu item is fine if no user info is present return; } accountMenuItem.setTitle(currentUser.getDisplayName()); String ownCloudRootPath = mPrefs.getString(SettingsActivity.EDT_OWNCLOUDROOTPATH_STRING, null); String avatarUrl = currentUser.getAvatarUrl(ownCloudRootPath); Glide.with(this) .asDrawable() .load(avatarUrl) .diskCacheStrategy(DiskCacheStrategy.DATA) .placeholder(R.drawable.ic_baseline_account_circle_24) .error(R.drawable.ic_baseline_account_circle_24) .circleCrop() .into(new CustomTarget(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL) { @Override public void onResourceReady(@NonNull Drawable resource, @Nullable Transition transition) { accountMenuItem.setIcon(resource); } @Override public void onLoadCleared(@Nullable Drawable placeholder) { accountMenuItem.setIcon(R.drawable.ic_baseline_account_circle_24); } }); } // private void openRssItemInInternalBrowser(Uri currentUrl) { // getNewsReaderDetailFragment().binding.webview.loadUrl(currentUrl.toString()); // } @Override public boolean onLongClick(RssItemViewHolder vh, int position) { RssItem rssItem = vh.getRssItem(); DialogFragment newFragment = NewsDetailImageDialogFragment.newInstanceUrl(rssItem.getTitle(), rssItem.getLink()); FragmentTransaction ft = getSupportFragmentManager().beginTransaction(); Fragment prev = getSupportFragmentManager().findFragmentByTag("menu_fragment_dialog"); if (prev != null) { ft.remove(prev); } ft.addToBackStack(null); newFragment.show(ft, "menu_fragment_dialog"); return true; } @Override public boolean onQueryTextSubmit(String query) { clearSearchViewFocus(); return true; } @Override public boolean onQueryTextChange(String newText) { if (searchPublishSubject == null) { searchPublishSubject = PublishSubject.create(); searchPublishSubject .debounce(400, TimeUnit.MILLISECONDS) .distinctUntilChanged() .map(s -> getNewsReaderDetailFragment().performSearch(s)) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribeWith(getNewsReaderDetailFragment().searchResultObserver); } searchPublishSubject.onNext(newText); return true; } public void clearSearchViewFocus() { mSearchView.clearFocus(); } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/NewsReaderListDialogFragment.java ================================================ package de.luhmer.owncloudnewsreader; import android.animation.AnimatorListenerAdapter; import android.app.Activity; import android.content.Intent; import android.net.Uri; import android.os.Bundle; import android.text.Editable; import android.text.TextWatcher; import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.ArrayAdapter; import android.widget.Toast; import androidx.fragment.app.DialogFragment; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import javax.inject.Inject; import de.luhmer.owncloudnewsreader.database.DatabaseConnectionOrm; import de.luhmer.owncloudnewsreader.database.model.Feed; import de.luhmer.owncloudnewsreader.database.model.Folder; import de.luhmer.owncloudnewsreader.databinding.FragmentDialogFeedoptionsBinding; import de.luhmer.owncloudnewsreader.di.ApiProvider; import de.luhmer.owncloudnewsreader.helper.FavIconHandler; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.annotations.NonNull; import io.reactivex.rxjava3.schedulers.Schedulers; public class NewsReaderListDialogFragment extends DialogFragment { protected @Inject ApiProvider mApi; private long mFeedId; private String mDialogTitle; private String mDialogText; private String mDialogIconUrl; private LinkedHashMap mMenuItems; private NewsReaderListActivity parentActivity; protected FragmentDialogFeedoptionsBinding binding; static NewsReaderListDialogFragment newInstance(long feedId, String dialogTitle, String iconurl, String feedurl) { NewsReaderListDialogFragment f = new NewsReaderListDialogFragment(); Bundle args = new Bundle(); args.putLong("feedid", feedId); args.putString("title", dialogTitle); args.putString("iconurl", iconurl); args.putString("feedurl", feedurl); f.setArguments(args); return f; } @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); ((NewsReaderApplication) requireActivity().getApplication()).getAppComponent().injectFragment(this); final Bundle args = requireArguments(); mFeedId = args.getLong("feedid"); mDialogTitle = args.getString("title"); mDialogIconUrl = args.getString("iconurl"); mDialogText = args.getString("feedurl"); mMenuItems = new LinkedHashMap<>(); mMenuItems.put(getString(R.string.action_feed_rename), () -> showRenameFeedView(mFeedId, mDialogTitle)); mMenuItems.put(getString(R.string.action_feed_remove), () -> showRemoveFeedView(mFeedId)); mMenuItems.put(getString(R.string.action_feed_move), () -> showMoveFeedView(mFeedId)); mMenuItems.put(getString(R.string.action_feed_notification_settings), () -> showNotificationSettingsView(mFeedId)); mMenuItems.put(getString(R.string.action_feed_open_in), () -> showOpenSettingsView(mFeedId)); setStyle(DialogFragment.STYLE_NO_TITLE, R.style.FloatingDialog); } @Override public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { binding = FragmentDialogFeedoptionsBinding.inflate(inflater, container, false); FavIconHandler favIconHandler = new FavIconHandler(requireContext()); favIconHandler.loadFavIconForFeed(mDialogIconUrl, binding.icMenuFeedicon); binding.tvMenuTitle.setText(mDialogTitle); binding.tvMenuText.setText(mDialogText); binding.tvMenuText.setOnClickListener(v -> { if (mDialogText != null) { Intent i = new Intent(Intent.ACTION_VIEW); i.setData(Uri.parse(mDialogText)); startActivity(i); } }); List menuItemsList = new ArrayList<>(mMenuItems.keySet()); final ArrayAdapter arrayAdapter = new ArrayAdapter<>( getActivity(), R.layout.fragment_dialog_listviewitem, menuItemsList); binding.lvMenuList.setAdapter(arrayAdapter); binding.lvMenuList.setOnItemClickListener((adapterView, view, i, l) -> { String key = arrayAdapter.getItem(i); MenuAction mAction = mMenuItems.get(key); mAction.execute(); }); return binding.getRoot(); } public void setActivity(Activity parentActivity) { this.parentActivity = (NewsReaderListActivity)parentActivity; } public void showProgress(final boolean show) { int shortAnimTime = getResources().getInteger(android.R.integer.config_shortAnimTime); binding.renameFeedDialog.setVisibility(show ? View.GONE : View.VISIBLE); binding.removeFeedDialog.setVisibility(show ? View.GONE : View.VISIBLE); binding.progressView.setVisibility(show ? View.VISIBLE : View.GONE); binding.progressView.animate().setDuration(shortAnimTime).alpha( show ? 1 : 0).setListener(new AnimatorListenerAdapter() { }); } private void showRenameFeedView(final long feedId, final String feedName) { binding.renamefeedFeedname.setText(feedName); binding.buttonRenameConfirm.setEnabled(false); binding.lvMenuList.setVisibility(View.GONE); binding.renameFeedDialog.setVisibility(View.VISIBLE); binding.renamefeedFeedname.addTextChangedListener(new TextWatcher() { @Override public void afterTextChanged(Editable s) {} @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {} @Override public void onTextChanged(CharSequence s, int start, int before, int count) { binding.buttonRenameConfirm.setEnabled(!s.toString().equals(feedName) && s.length() != 0); } }); binding.buttonRenameCancel.setOnClickListener(v -> dismiss()); binding.buttonRenameConfirm.setOnClickListener(new View.OnClickListener() { public void onClick(View v) { showProgress(true); setCancelable(false); getDialog().setCanceledOnTouchOutside(false); Map paramMap = new LinkedHashMap<>(); paramMap.put("feedTitle", binding.renamefeedFeedname.getText().toString()); mApi.getNewsAPI().renameFeed(feedId, paramMap) .subscribeOn(Schedulers.newThread()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(() -> { DatabaseConnectionOrm dbConn = new DatabaseConnectionOrm(getContext()); dbConn.renameFeedById(mFeedId, binding.renamefeedFeedname.getText().toString()); parentActivity.getSlidingListFragment().reloadAdapter(); parentActivity.startSync(); dismiss(); }, throwable -> { Toast.makeText(getContext().getApplicationContext(), getString(R.string.login_dialog_text_something_went_wrong) + " - " + throwable.getMessage(), Toast.LENGTH_LONG).show(); dismiss(); }); } }); } private void showRemoveFeedView(final long feedId) { binding.lvMenuList.setVisibility(View.GONE); binding.removeFeedDialog.setVisibility(View.VISIBLE); binding.buttonRemoveCancel.setOnClickListener(v -> dismiss()); binding.buttonRemoveConfirm.setOnClickListener(v -> { showProgress(true); setCancelable(false); getDialog().setCanceledOnTouchOutside(false); mApi.getNewsAPI().deleteFeed(feedId) .subscribeOn(Schedulers.newThread()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(() -> { DatabaseConnectionOrm dbConn = new DatabaseConnectionOrm(getContext()); dbConn.removeFeedById(mFeedId); Long currentFeedId = parentActivity.getNewsReaderDetailFragment().getIdFeed(); if(currentFeedId != null && currentFeedId == mFeedId) { parentActivity.switchToAllUnreadItemsFolder(); } parentActivity.getSlidingListFragment().reloadAdapter(); parentActivity.updateCurrentRssView(); dismiss(); }, throwable -> { Toast.makeText(getContext().getApplicationContext(), getString(R.string.login_dialog_text_something_went_wrong) + " - " + throwable.getMessage(), Toast.LENGTH_LONG).show(); dismiss(); }); }); } /** * https://github.com/nextcloud/news/blob/master/docs/externalapi/Legacy.md#move-a-feed-to-a-different-folder * @param mFeedId Feed to move */ private void showMoveFeedView(final long mFeedId) { binding.lvMenuList.setVisibility(View.GONE); binding.moveFeedDialog.setVisibility(View.VISIBLE); binding.tvMenuText.setText(getString(R.string.feed_move_list_description)); DatabaseConnectionOrm dbConn = new DatabaseConnectionOrm(getContext()); final List folders = new ArrayList<>(dbConn.getListOfFolders()); folders.add(new Folder(0, getString(R.string.move_feed_root_folder))); // root folder (fake insert it here since this folder is not synced) List folderNames = new ArrayList<>(); for(Folder folder: folders) { folderNames.add(folder.getLabel()); } ArrayAdapter folderAdapter = new ArrayAdapter<> (getActivity(), R.layout.dialog_list_folder, android.R.id.text1, folderNames); binding.folderList.setAdapter(folderAdapter); binding.folderList.setOnItemClickListener((parent, view, position, id) -> { final Folder folder = folders.get(position); showProgress(true); setCancelable(false); getDialog().setCanceledOnTouchOutside(false); Map paramMap = new LinkedHashMap<>(); paramMap.put("folderId", folder.getId()); mApi.getNewsAPI().moveFeed(mFeedId, paramMap) .subscribeOn(Schedulers.newThread()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(() -> { DatabaseConnectionOrm dbConn1 = new DatabaseConnectionOrm(getContext()); Feed feed = dbConn1.getFeedById(mFeedId); feed.setFolder(folder); parentActivity.getSlidingListFragment().reloadAdapter(); parentActivity.startSync(); dismiss(); }, throwable -> { Toast.makeText(getContext().getApplicationContext(), getString(R.string.login_dialog_text_something_went_wrong) + " - " + throwable.getMessage(), Toast.LENGTH_LONG).show(); dismiss(); }); }); } private void showOpenSettingsView(final long feedId) { binding.lvMenuList.setVisibility(View.GONE); binding.openFeedDialog.setVisibility(View.VISIBLE); DatabaseConnectionOrm dbConn = new DatabaseConnectionOrm(getContext()); Feed feed = dbConn.getFeedById(feedId); Long openIn = feed.getOpenIn(); binding.openInUseGeneralSetting.setChecked(false); binding.openInDetailedView.setChecked(false); binding.openInBrowserCct.setChecked(false); binding.openInBrowserExternal.setChecked(false); if (openIn == null) { binding.openInUseGeneralSetting.setChecked(true); } else { switch (openIn.intValue()) { case 1: binding.openInDetailedView.setChecked(true); break; case 2: binding.openInBrowserCct.setChecked(true); break; case 3: binding.openInBrowserExternal.setChecked(true); break; default: throw new RuntimeException("Unreachable: openIn has illegal value " + openIn); } } binding.openInUseGeneralSetting.setOnCheckedChangeListener((button, checked) -> setOpenInForFeed(feed, null, checked)); binding.openInDetailedView.setOnCheckedChangeListener((button, checked) -> setOpenInForFeed(feed, 1L, checked)); binding.openInBrowserCct.setOnCheckedChangeListener((button, checked) -> setOpenInForFeed(feed, 2L, checked)); binding.openInBrowserExternal.setOnCheckedChangeListener((button, checked) -> setOpenInForFeed(feed, 3L, checked)); } private void showNotificationSettingsView(final long feedId) { binding.lvMenuList.setVisibility(View.GONE); binding.notificationFeedDialog.setVisibility(View.VISIBLE); DatabaseConnectionOrm dbConn = new DatabaseConnectionOrm(getContext()); Feed feed = dbConn.getFeedById(feedId); String notificationChannel = feed.getNotificationChannel(); binding.notificationSettingNone.setChecked(false); binding.notificationSettingDefault.setChecked(false); binding.notificationSettingUnique.setChecked(false); switch (notificationChannel) { case "none": binding.notificationSettingNone.setChecked(true); break; case "default": binding.notificationSettingDefault.setChecked(true); break; default: binding.notificationSettingUnique.setChecked(true); break; } binding.notificationSettingNone.setOnCheckedChangeListener((button, checked) -> setNotificationChannelForFeed(feed, "none", checked)); binding.notificationSettingDefault.setOnCheckedChangeListener((button, checked) -> setNotificationChannelForFeed(feed, "default", checked)); binding.notificationSettingUnique.setOnCheckedChangeListener((button, checked) -> // Use the feed name as notification channel name setNotificationChannelForFeed(feed, feed.getFeedTitle(), checked)); } private void setOpenInForFeed(Feed feed, Long openIn, Boolean checked) { if (checked) { feed.setOpenIn(openIn); feed.update(); this.showOpenSettingsView(feed.getId()); // reload dialog } } private void setNotificationChannelForFeed(Feed feed, String channel, Boolean checked) { if (checked) { feed.setNotificationChannel(channel); feed.update(); this.showNotificationSettingsView(feed.getId()); // reload dialog } } interface MenuAction { void execute(); } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/NewsReaderListFragment.java ================================================ /* * Android ownCloud News * * @author David Luhmer * @copyright 2013 David Luhmer david-dev@live.de * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE * License as published by the Free Software Foundation; either * version 3 of the License, or any later version. * * This library 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 AFFERO GENERAL PUBLIC LICENSE for more details. * * You should have received a copy of the GNU Affero General Public * License along with this library. If not, see . * */ package de.luhmer.owncloudnewsreader; import static de.luhmer.owncloudnewsreader.Constants.USER_INFO_STRING; import static de.luhmer.owncloudnewsreader.LoginDialogActivity.RESULT_LOGIN; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.os.Bundle; import android.util.Base64; import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.View.OnCreateContextMenuListener; import android.view.ViewGroup; import android.widget.AdapterView; import android.widget.ExpandableListView; import android.widget.ExpandableListView.OnChildClickListener; import androidx.annotation.NonNull; import androidx.annotation.VisibleForTesting; import androidx.core.view.ViewCompat; import androidx.core.view.WindowInsetsCompat; import androidx.fragment.app.Fragment; import com.google.android.material.navigation.NavigationView; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.io.Serializable; import javax.inject.Inject; import de.luhmer.owncloudnewsreader.ListView.SubscriptionExpandableListAdapter; import de.luhmer.owncloudnewsreader.database.DatabaseConnectionOrm; import de.luhmer.owncloudnewsreader.databinding.FragmentNewsreaderListBinding; import de.luhmer.owncloudnewsreader.di.ApiProvider; import de.luhmer.owncloudnewsreader.interfaces.ExpListTextClicked; import de.luhmer.owncloudnewsreader.model.AbstractItem; import de.luhmer.owncloudnewsreader.model.ConcreteFeedItem; import de.luhmer.owncloudnewsreader.model.OcsUser; import de.luhmer.owncloudnewsreader.reader.nextcloud.OcsAPI; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.core.Observer; import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.schedulers.Schedulers; /** * A list fragment representing a list of NewsReader. This fragment also * supports tablet devices by allowing list items to be given an 'activated' * state upon selection. This helps indicate which item is currently being * viewed in a {@link NewsReaderDetailFragment}. *

* Activities containing this fragment MUST implement the {@link Callbacks} * interface. */ public class NewsReaderListFragment extends Fragment implements OnCreateContextMenuListener { protected @Inject ApiProvider mApi; protected @Inject SharedPreferences mPrefs; private SubscriptionExpandableListAdapter lvAdapter; protected FragmentNewsreaderListBinding binding; /** * The fragment's current callback object, which is notified of list item * clicks. */ private Callbacks mCallbacks = null; protected static final String TAG = "NewsReaderListFragment"; public void listViewNotifyDataSetChanged() { lvAdapter.notifyDataSetChangedAsync(); } public void reloadAdapter() { lvAdapter.ReloadAdapterAsync(); } public void setRefreshing(boolean isRefreshing) { if (isRefreshing) { //headerLogo.setImageResource(R.drawable.ic_launcher_background); binding.headerLogo.setVisibility(View.INVISIBLE); binding.headerLogoProgress.setVisibility(View.VISIBLE); } else { //headerLogo.setImageResource(R.drawable.ic_launcher); binding.headerLogo.setVisibility(View.VISIBLE); binding.headerLogoProgress.setVisibility(View.INVISIBLE); } } /** * A callback interface that all activities containing this fragment must * implement. This mechanism allows activities to be notified of item * selections. */ public interface Callbacks { /** * Callback for when an item has been selected. */ void onChildItemClicked(long idFeed, Long optional_folder_id); void onTopItemClicked(long idFeed, boolean isFolder, Long onTopItemClicked); void onChildItemLongClicked(long idFeed); void onTopItemLongClicked(long idFeed, boolean isFolder); void onUserInfoUpdated(OcsUser userInfo); void onCreateFolderClicked(); } /** * Mandatory empty constructor for the fragment manager to instantiate the * fragment (e.g. upon screen orientation changes). */ public NewsReaderListFragment() { } @Override public void onCreate(Bundle savedInstance) { super.onCreate(savedInstance); ((NewsReaderApplication) requireActivity().getApplication()).getAppComponent().injectFragment(this); } @Override public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { binding = FragmentNewsreaderListBinding.inflate(requireActivity().getLayoutInflater(), container, false); lvAdapter = new SubscriptionExpandableListAdapter(getActivity(), new DatabaseConnectionOrm(getActivity()), binding.expandableListView, mPrefs); lvAdapter.setHandlerListener(expListTextClickedListener); binding.expandableListView.setGroupIndicator(null); binding.expandableListView.setOnChildClickListener(onChildClickListener); binding.expandableListView.setOnItemLongClickListener(onItemLongClickListener); binding.expandableListView.setClickable(true); binding.expandableListView.setLongClickable(true); binding.expandableListView.setAdapter(lvAdapter); binding.headerLogo.setOnClickListener(v -> ((NewsReaderListActivity) requireActivity()).startSync()); lvAdapter.notifyDataSetChanged(); reloadAdapter(); bindNavigationMenu(binding.getRoot(), inflater); // move header of sidebar down according to insets ViewCompat.setOnApplyWindowInsetsListener(binding.headerView, (View v, WindowInsetsCompat insets) -> { var systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()); v.setPadding(0, systemBars.top, 0, 0); return insets; }); // make sure that the end of the sidebar doesn't go behind the navigation bar ViewCompat.setOnApplyWindowInsetsListener(binding.expandableListView, (View v, WindowInsetsCompat insets) -> { var systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()); v.setPadding(0, 0, 0, systemBars.bottom); return insets; }); return binding.getRoot(); } @Override public void onAttach(@NonNull Context context) { super.onAttach(context); // Activities containing this fragment must implement its callbacks. if (!(context instanceof Callbacks)) { throw new IllegalStateException( "Activity must implement fragment's callbacks."); } mCallbacks = (Callbacks) context; } @Override public void onDetach() { super.onDetach(); mCallbacks = null; } /** * Cares about settings items in news list drawer. * - Binds settings, shown at bottom of drawer * - Inflates NavigationView which is set as footerview of ListView * Currently used to show item "add newsfeed" at bottom of list. * * @param parent content view of drawer * @param inflater inflater provided to fragment */ private void bindNavigationMenu(View parent, LayoutInflater inflater) { // Create NavigationView to show as footer of ListView View footerView = inflater.inflate(R.layout.fragment_newsreader_list_footer, null, false); ExpandableListView list = parent.findViewById(R.id.expandableListView); NavigationView footerNavigation = footerView.findViewById(R.id.listfooterMenu); footerNavigation.setNavigationItemSelectedListener(item -> { int itemId = item.getItemId(); if (itemId == R.id.action_add_new_feed) { if (mApi.getNewsAPI() != null) { Intent newFeedIntent = new Intent(getContext(), NewFeedActivity.class); requireActivity().startActivityForResult(newFeedIntent, NewsReaderListActivity.RESULT_ADD_NEW_FEED); } else { Intent loginIntent = new Intent(getContext(), LoginDialogActivity.class); requireActivity().startActivityForResult(loginIntent, RESULT_LOGIN); } return true; } else if (itemId == R.id.drawer_settings) { Intent intent = new Intent(getContext(), SettingsActivity.class); requireActivity().startActivityForResult(intent, NewsReaderListActivity.RESULT_SETTINGS); return true; } else if (itemId == R.id.action_add_new_folder) { mCallbacks.onCreateFolderClicked(); return true; } return false; }); list.addFooterView(footerView); } private final ExpListTextClicked expListTextClickedListener = new ExpListTextClicked() { @Override public void onTextClicked(long idFeed, boolean isFolder, Long optional_folder_id) { mCallbacks.onTopItemClicked(idFeed, isFolder, optional_folder_id); } @Override public void onTextLongClicked(long idFeed, boolean isFolder, Long optional_folder_id) { mCallbacks.onTopItemLongClicked(idFeed, isFolder); } }; // Code below is only used for unit tests @VisibleForTesting public OnChildClickListener onChildClickListener = new OnChildClickListener() { @Override public boolean onChildClick(ExpandableListView parent, View v, int groupPosition, int childPosition, long id) { long idItem; if(childPosition != -1) { idItem = lvAdapter.getChildId(groupPosition, childPosition); } else { idItem = groupPosition; } Long optional_id_folder = null; AbstractItem groupItem = (AbstractItem) lvAdapter.getGroup(groupPosition); if(groupItem != null) optional_id_folder = groupItem.id_database; if(groupItem instanceof ConcreteFeedItem) { idItem = ((ConcreteFeedItem)groupItem).feedId; } mCallbacks.onChildItemClicked(idItem, optional_id_folder); return false; } }; AdapterView.OnItemLongClickListener onItemLongClickListener = (parent, view, position, id) -> { if (ExpandableListView.getPackedPositionType(id) == ExpandableListView.PACKED_POSITION_TYPE_CHILD) { int childPosition = ExpandableListView.getPackedPositionChild(id); mCallbacks.onChildItemLongClicked(childPosition); } return true; }; public void startAsyncTaskGetUserInfo() { OcsAPI serverAPI = mApi.getServerAPI(); if(serverAPI == null) { return; } mApi.getServerAPI().user() .subscribeOn(Schedulers.newThread()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(new Observer<>() { @Override public void onSubscribe(@NonNull Disposable d) { } @Override public void onNext(@NonNull OcsUser userInfo) { Log.d(TAG, "onNext() called with: userInfo = [" + userInfo + "]"); try { String userInfoAsString = NewsReaderListFragment.toString(userInfo); //Log.v(TAG, userInfoAsString); mPrefs.edit().putString(USER_INFO_STRING, userInfoAsString).apply(); } catch (IOException e) { e.printStackTrace(); } } @Override public void onError(@NonNull Throwable e) { Log.e(TAG, "onError() called with:", e); if ("Method Not Allowed".equals(e.getMessage())) { //Remove if old version is used mPrefs.edit().remove(USER_INFO_STRING).apply(); } bindUserInfoToUI(); } @Override public void onComplete() { bindUserInfoToUI(); } }); } public void bindUserInfoToUI() { if(getActivity() == null) { // e.g. Activity is closed return; } SharedPreferences mPrefs = ((PodcastFragmentActivity) getActivity()).mPrefs; if(!mPrefs.contains(SettingsActivity.EDT_OWNCLOUDROOTPATH_STRING)) { // return if app is not setup yet.. return; } String uInfo = mPrefs.getString(USER_INFO_STRING, null); if(uInfo == null) { return; } try { OcsUser userInfo = (OcsUser) fromString(uInfo); mCallbacks.onUserInfoUpdated(userInfo); } catch (Exception ex) { ex.printStackTrace(); } } /** Read the object from Base64 string. */ public static Object fromString(String s) throws IOException, ClassNotFoundException { byte [] data = Base64.decode(s, Base64.DEFAULT); ObjectInputStream ois = new ObjectInputStream( new ByteArrayInputStream( data ) ); Object o = ois.readObject(); ois.close(); return o; } /** Write the object to a Base64 string. */ public static String toString(Serializable o) throws IOException { ByteArrayOutputStream baos = new ByteArrayOutputStream(); ObjectOutputStream oos = new ObjectOutputStream( baos ); oos.writeObject(o); oos.close(); return Base64.encodeToString(baos.toByteArray(), Base64.DEFAULT); } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/NewsReaderOPMLImportDialogFragment.java ================================================ package de.luhmer.owncloudnewsreader; import android.os.Bundle; import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import androidx.fragment.app.DialogFragment; import androidx.fragment.app.FragmentTransaction; import javax.inject.Inject; import de.luhmer.owncloudnewsreader.databinding.FragmentDialogOpmlImportBinding; import de.luhmer.owncloudnewsreader.di.ApiProvider; import io.reactivex.rxjava3.annotations.NonNull; public class NewsReaderOPMLImportDialogFragment extends DialogFragment { private static final String TAG = NewsReaderOPMLImportDialogFragment.class.getCanonicalName(); protected @Inject ApiProvider mApi; protected FragmentDialogOpmlImportBinding binding; static NewsReaderOPMLImportDialogFragment newInstance(boolean showOkButton) { var f = new NewsReaderOPMLImportDialogFragment(); Bundle args = new Bundle(); args.putBoolean("show_ok_button", showOkButton); f.setArguments(args); return f; } @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setStyle(DialogFragment.STYLE_NO_TITLE, R.style.FloatingDialog); } @Override public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { binding = FragmentDialogOpmlImportBinding.inflate(inflater, container, false); final Bundle args = requireArguments(); boolean showOkButton = args.getBoolean("show_ok_button", true); setVisibilityOkButton(showOkButton); binding.okButton.setOnClickListener(v -> { FragmentTransaction ft = getActivity().getSupportFragmentManager().beginTransaction(); ft.remove(this); ft.commit(); }); return binding.getRoot(); } public void updateProgress(final int current, final int max) { if (binding != null) { binding.opmlImportProgress.setMax(max); binding.opmlImportProgress.setProgress(current); int percentage = Math.round((float) current / (float) max * 100f); Log.d(TAG, current + "-" + max + "- " + percentage); binding.tvPercentage.setText(String.format("%d%%", percentage)); binding.tvAbsoluteProgress.setText(String.format("%d / %d", current, max)); } else { Log.e(TAG, "Binding is not ready yet"); } } public void setMessage(final String message) { if (binding != null) { binding.tvMessage.setText(message); binding.messageScrollview.post(() -> binding.messageScrollview.fullScroll(View.FOCUS_DOWN)); } else { Log.e(TAG, "Binding is not ready yet"); } } public void setVisibilityOkButton(final boolean show) { if (binding != null) { binding.okButton.setVisibility(show ? View.VISIBLE : View.GONE); } else { Log.e(TAG, "Binding is not ready yet"); } } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/PiPVideoPlaybackActivity.java ================================================ package de.luhmer.owncloudnewsreader; import static de.luhmer.owncloudnewsreader.services.PodcastPlaybackService.CURRENT_PODCAST_MEDIA_TYPE; import android.app.PictureInPictureParams; import android.content.ComponentName; import android.content.pm.PackageManager; import android.content.res.Configuration; import android.graphics.Point; import android.os.Build; import android.os.Bundle; import android.support.v4.media.MediaBrowserCompat; import android.support.v4.media.MediaMetadataCompat; import android.support.v4.media.session.MediaControllerCompat; import android.support.v4.media.session.MediaSessionCompat; import android.support.v4.media.session.PlaybackStateCompat; import android.util.Log; import android.view.Display; import android.view.SurfaceView; import android.widget.LinearLayout; import android.widget.RelativeLayout; import android.widget.Toast; import androidx.appcompat.app.AppCompatActivity; import org.greenrobot.eventbus.EventBus; import de.luhmer.owncloudnewsreader.events.podcast.RegisterVideoOutput; import de.luhmer.owncloudnewsreader.helper.ThemeChooser; import de.luhmer.owncloudnewsreader.services.PodcastPlaybackService; import de.luhmer.owncloudnewsreader.services.podcast.PlaybackService; public class PiPVideoPlaybackActivity extends AppCompatActivity { private static final String TAG = PiPVideoPlaybackActivity.class.getCanonicalName(); private EventBus mEventBus; private MediaBrowserCompat mMediaBrowser; protected static boolean activityIsRunning = false; @Override protected void onCreate(Bundle savedInstanceState) { Log.d(TAG, "onCreate() called with: savedInstanceState = [" + savedInstanceState + "]"); ThemeChooser.chooseTheme(this); super.onCreate(savedInstanceState); ThemeChooser.afterOnCreate(this); setContentView(R.layout.activity_pip_video_playback); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && getPackageManager().hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)) { //moveTaskToBack(false); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { PictureInPictureParams.Builder pictureInPictureParamsBuilder = new PictureInPictureParams.Builder(); //Rational aspectRatio = new Rational(vv.getWidth(), vv.getHeight()); //pictureInPictureParamsBuilder.setAspectRatio(aspectRatio).build(); enterPictureInPictureMode(pictureInPictureParamsBuilder.build()); } else { enterPictureInPictureMode(); } } else { Toast.makeText(this, "This device does not support video playback.", Toast.LENGTH_LONG).show(); finish(); } } @Override public void onPictureInPictureModeChanged (boolean isInPictureInPictureMode, Configuration newConfig) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig); } Log.d(TAG, "onPictureInPictureModeChanged() called with: isInPictureInPictureMode = [" + isInPictureInPictureMode + "], newConfig = [" + newConfig + "]"); RelativeLayout surfaceViewWrapper = findViewById(R.id.layout_activity_pip); SurfaceView surfaceView = (SurfaceView) surfaceViewWrapper.getChildAt(0); if (surfaceView != null) { if (isInPictureInPictureMode) { surfaceView.setLayoutParams(new RelativeLayout.LayoutParams( RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT)); } else { Display display = getWindowManager().getDefaultDisplay(); Point size = new Point(); display.getSize(size); float width = size.x; //int height = size.y; //int newWidth = (int) (width * (9f/16f)); int newWidth = (int) (width * (3f/4f)); surfaceView.setLayoutParams(new RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, newWidth)); } } /* if (isInPictureInPictureMode) { // Hide the full-screen UI (controls, etc.) while in picture-in-picture mode. } else { // Restore the full-screen UI. //Intent intent = new Intent(this, NewsReaderListActivity.class); //intent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT); //startActivity(intent); // Finish PiP Activity //finish(); } */ /* // When dismissing if(!isInPictureInPictureMode) { finish(); } */ } @Override protected void onStart() { Log.d(TAG, "onStart() called"); super.onStart(); mEventBus = EventBus.getDefault(); //mEventBus.register(this); mMediaBrowser = new MediaBrowserCompat(this, new ComponentName(this, PodcastPlaybackService.class), mConnectionCallbacks, null); // optional Bundle mMediaBrowser.connect(); activityIsRunning = true; } @Override public void onStop() { Log.d(TAG, "onStop() called"); unregisterVideoViews(); //mEventBus.unregister(this); // (see "stay in sync with the MediaSession") if (MediaControllerCompat.getMediaController(this) != null) { MediaControllerCompat.getMediaController(this).unregisterCallback(controllerCallback); } mMediaBrowser.disconnect(); activityIsRunning = false; super.onStop(); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && getPackageManager().hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)) { finishAndRemoveTask(); } } public void unregisterVideoViews() { mEventBus.post(new RegisterVideoOutput(null, null)); } /* @Subscribe public void onEvent(CollapsePodcastView event) { Log.d(TAG, "onEvent() called with: event = [" + event + "]"); finishAndRemoveTask(); } */ @Override public void onBackPressed() { super.onBackPressed(); Log.d(TAG, "onBackPressed() called"); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { enterPictureInPictureMode(); } } private final MediaBrowserCompat.ConnectionCallback mConnectionCallbacks = new MediaBrowserCompat.ConnectionCallback() { @Override public void onConnected() { Log.d(TAG, "onConnected() called"); // Get the token for the MediaSession MediaSessionCompat.Token token = mMediaBrowser.getSessionToken(); // Create a MediaControllerCompat MediaControllerCompat mediaController = new MediaControllerCompat(PiPVideoPlaybackActivity.this, token); // Save the controller MediaControllerCompat.setMediaController(PiPVideoPlaybackActivity.this, mediaController); // Register a Callback to stay in sync mediaController.registerCallback(controllerCallback); // Display the initial state MediaMetadataCompat metadata = mediaController.getMetadata(); handleMetadataChange(metadata); } }; MediaControllerCompat.Callback controllerCallback = new MediaControllerCompat.Callback() { @Override public void onMetadataChanged(MediaMetadataCompat metadata) { Log.v(TAG, "onMetadataChanged() called with: metadata = [" + metadata + "]"); handleMetadataChange(metadata); } @Override public void onPlaybackStateChanged(PlaybackStateCompat stateCompat) { Log.v(TAG, "onPlaybackStateChanged() called with: state = [" + stateCompat + "]"); } }; private void handleMetadataChange(MediaMetadataCompat metadata) { Log.d(TAG, "handleMetadataChange() called with: metadata = [" + metadata + "]"); unregisterVideoViews(); RelativeLayout surfaceViewWrapper = findViewById(R.id.layout_activity_pip); surfaceViewWrapper.removeAllViews(); PlaybackService.VideoType mediaType = PlaybackService.VideoType.valueOf(metadata.getString(CURRENT_PODCAST_MEDIA_TYPE)); Log.d(TAG, "handleMetadataChange() called with: mediaType = [" + mediaType + "]"); switch (mediaType) { case None: finish(); break; case Video: // default SurfaceView surfaceView = createSurfaceView(); surfaceViewWrapper.addView(surfaceView); mEventBus.post(new RegisterVideoOutput(surfaceView, surfaceViewWrapper)); break; /* case YouTube: final int YOUTUBE_CONTENT_VIEW_ID = 10101010; FrameLayout frame = new FrameLayout(this); frame.setId(YOUTUBE_CONTENT_VIEW_ID); surfaceViewWrapper.addView(frame); YoutubePlayerManager.StartYoutubePlayer(this, YOUTUBE_CONTENT_VIEW_ID, mEventBus, () -> Log.d(TAG, "onInit Success()")); break; */ default: break; } } private SurfaceView createSurfaceView() { SurfaceView surfaceView = new SurfaceView(this); surfaceView.setLayoutParams(new LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.MATCH_PARENT)); return surfaceView; } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/PodcastFragment.java ================================================ package de.luhmer.owncloudnewsreader; import static android.media.MediaMetadata.METADATA_KEY_MEDIA_ID; import static de.luhmer.owncloudnewsreader.services.PodcastPlaybackService.CURRENT_PODCAST_MEDIA_TYPE; import static de.luhmer.owncloudnewsreader.services.PodcastPlaybackService.PLAYBACK_SPEED_FLOAT; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.os.ResultReceiver; import android.support.v4.media.MediaBrowserCompat; import android.support.v4.media.MediaMetadataCompat; import android.support.v4.media.session.MediaControllerCompat; import android.support.v4.media.session.MediaSessionCompat; import android.support.v4.media.session.PlaybackStateCompat; import android.text.InputFilter; import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.EditText; import android.widget.NumberPicker; import android.widget.SeekBar; import androidx.annotation.NonNull; import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentActivity; import com.bumptech.glide.Glide; import com.bumptech.glide.load.MultiTransformation; import com.bumptech.glide.load.engine.DiskCacheStrategy; import com.bumptech.glide.load.resource.bitmap.CenterCrop; import com.bumptech.glide.load.resource.bitmap.RoundedCorners; import com.sothree.slidinguppanel.SlidingUpPanelLayout; import org.greenrobot.eventbus.EventBus; import org.greenrobot.eventbus.Subscribe; import java.io.File; import java.lang.reflect.Field; import java.util.Arrays; import java.util.Locale; import de.luhmer.owncloudnewsreader.ListView.PodcastArrayAdapter; import de.luhmer.owncloudnewsreader.ListView.PodcastFeedArrayAdapter; import de.luhmer.owncloudnewsreader.databinding.FragmentPodcastBinding; import de.luhmer.owncloudnewsreader.events.podcast.CollapsePodcastView; import de.luhmer.owncloudnewsreader.events.podcast.ExitPlayback; import de.luhmer.owncloudnewsreader.events.podcast.ExpandPodcastView; import de.luhmer.owncloudnewsreader.events.podcast.SpeedPodcast; import de.luhmer.owncloudnewsreader.events.podcast.StartDownloadPodcast; import de.luhmer.owncloudnewsreader.events.podcast.TogglePlayerStateEvent; import de.luhmer.owncloudnewsreader.events.podcast.WindPodcast; import de.luhmer.owncloudnewsreader.events.podcast.SeekPodcast; import de.luhmer.owncloudnewsreader.model.PodcastFeedItem; import de.luhmer.owncloudnewsreader.model.PodcastItem; import de.luhmer.owncloudnewsreader.services.PodcastDownloadService; import de.luhmer.owncloudnewsreader.services.PodcastPlaybackService; import de.luhmer.owncloudnewsreader.services.podcast.PlaybackService; import de.luhmer.owncloudnewsreader.view.PodcastSlidingUpPanelLayout; /** * Use the {@link PodcastFragment#newInstance} factory method to * create an instance of this fragment. * */ public class PodcastFragment extends Fragment { private static final String TAG = PodcastFragment.class.getCanonicalName(); //private static UpdatePodcastStatusEvent podcast; // Retain over different instances private PodcastSlidingUpPanelLayout sliding_layout; private EventBus eventBus; private MediaBrowserCompat mMediaBrowser; private FragmentActivity mActivity; private long currentPositionInMillis = 0; private long maxPositionInMillis = 100000; protected FragmentPodcastBinding binding; /** * Use this factory method to create a new instance of * this fragment using the provided parameters. * * @return A new instance of fragment PodcastFragment. */ public static PodcastFragment newInstance() { return new PodcastFragment(); } @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); //setRetainInstance(true); eventBus = EventBus.getDefault(); } @Override public void onResume() { eventBus.register(this); super.onResume(); //mActivity.setVolumeControlStream(AudioManager.STREAM_MUSIC); } @Override public void onPause() { super.onPause(); eventBus.unregister(this); } @Override public void onStart() { super.onStart(); mMediaBrowser = new MediaBrowserCompat(mActivity, new ComponentName(mActivity, PodcastPlaybackService.class), mConnectionCallbacks, null); // optional Bundle mMediaBrowser.connect(); } @Override public void onStop() { super.onStop(); // (see "stay in sync with the MediaSession") if (MediaControllerCompat.getMediaController(mActivity) != null) { MediaControllerCompat.getMediaController(mActivity).unregisterCallback(mediaControllerCallback); MediaControllerCompat.getMediaController(mActivity).unregisterCallback(controllerCallback); } mMediaBrowser.disconnect(); } @Override public void onAttach(@NonNull Context context) { super.onAttach(context); mActivity = getActivity(); } @Override public void onDetach() { super.onDetach(); mActivity = null; } protected void tryOpeningPictureinPictureMode() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { //moveTaskToBack(false /* nonRoot */); if(!PiPVideoPlaybackActivity.activityIsRunning) { Intent intent = new Intent(getActivity(), PiPVideoPlaybackActivity.class); intent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION); //intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); //intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK); //intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); startActivity(intent); } } } @Subscribe public void onEvent(StartDownloadPodcast podcast) { PodcastDownloadService.startPodcastDownload(getActivity(), podcast.getPodcast()); } @Subscribe public void onEvent(PodcastDownloadService.DownloadProgressUpdate downloadProgress) { PodcastArrayAdapter podcastArrayAdapter = (PodcastArrayAdapter) binding.podcastTitleGrid.getAdapter(); for(int i = 0; i < binding.podcastTitleGrid.getCount(); i++) { if(podcastArrayAdapter.getItem(i).link.equals(downloadProgress.podcast.link)) { if(!podcastArrayAdapter.getItem(i).downloadProgress.equals(downloadProgress.podcast.downloadProgress)) { //If Progress changed PodcastItem pItem = podcastArrayAdapter.getItem(i); if (downloadProgress.podcast.downloadProgress == 100) { pItem.downloadProgress = PodcastItem.DOWNLOAD_COMPLETED; File file = new File(PodcastDownloadService.getUrlToPodcastFile(getActivity(), pItem.fingerprint, pItem.link, false)); pItem.offlineCached = file.exists(); } else pItem.downloadProgress = downloadProgress.podcast.downloadProgress; binding.podcastTitleGrid.invalidateViews(); } return; } } } protected void playPause() { eventBus.post(new TogglePlayerStateEvent()); } protected void playPauseSlider() { playPause(); } protected void windForward() { eventBus.post(new WindPodcast(30000)); //Toast.makeText(getActivity(), "This feature is not supported yet :(", Toast.LENGTH_SHORT).show(); } protected void windBack() { eventBus.post(new WindPodcast(-10000)); } protected void openSpeedMenu() { showPlaybackSpeedPicker(); } private final SlidingUpPanelLayout.PanelSlideListener onPanelSlideListener = new SlidingUpPanelLayout.PanelSlideListener() { @Override public void onPanelSlide(View view, float v) { } @Override public void onPanelStateChanged(View panel, SlidingUpPanelLayout.PanelState previousState, SlidingUpPanelLayout.PanelState newState) { if (newState == SlidingUpPanelLayout.PanelState.COLLAPSED) { sliding_layout.setDragView(binding.llPodcastHeader); binding.viewSwitcherProgress.setDisplayedChild(0); } else if (newState == SlidingUpPanelLayout.PanelState.EXPANDED) { sliding_layout.setDragView(binding.viewSwitcherProgress); binding.viewSwitcherProgress.setDisplayedChild(1); } } }; @Override public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { // create ContextThemeWrapper from the original Activity Context with the custom theme //Context context = new ContextThemeWrapper(getActivity(), R.style.Theme_MaterialComponents_Light_DarkActionBar); // clone the inflater using the ContextThemeWrapper //LayoutInflater localInflater = inflater.cloneInContext(context); // inflate using the cloned inflater, not the passed in default //View view = localInflater.inflate(R.layout.fragment_podcast, container, false); binding = FragmentPodcastBinding.inflate(inflater, container, false); binding.flPlayPausePodcastWrapper.setOnClickListener((v) -> playPause()); binding.btnPlayPausePodcastSlider.setOnClickListener((v) -> playPauseSlider()); binding.btnNextPodcastSlider.setOnClickListener((v) -> windForward()); binding.btnPreviousPodcastSlider.setOnClickListener((v) -> windBack()); binding.btnPodcastSpeed.setOnClickListener((v) -> openSpeedMenu()); binding.btnExitPodcast.setOnClickListener((v) -> eventBus.post(new ExitPlayback())); //View view = inflater.inflate(R.layout.fragment_podcast, container, false); if(getActivity() instanceof PodcastFragmentActivity) { sliding_layout = ((PodcastFragmentActivity) getActivity()).getSlidingLayout(); } if(sliding_layout != null) { sliding_layout.setSlideableView(binding.rlPodcast); sliding_layout.setDragView(binding.llPodcastHeader); //sliding_layout.setEnableDragViewTouchEvents(true); sliding_layout.addPanelSlideListener(onPanelSlideListener); } PodcastFeedArrayAdapter mArrayAdapter = new PodcastFeedArrayAdapter(getActivity(), new PodcastFeedItem[0]); if(mArrayAdapter.getCount() > 0) { binding.tvNoPodcastsAvailable.setVisibility(View.GONE); } binding.podcastTitleGrid.setVisibility(View.GONE); binding.podcastFeedList.setVisibility(View.VISIBLE); binding.sbProgress.setOnSeekBarChangeListener(onSeekBarChangeListener); return binding.getRoot(); } boolean blockSeekbarUpdate = false; private final SeekBar.OnSeekBarChangeListener onSeekBarChangeListener = new SeekBar.OnSeekBarChangeListener() { @Override public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { /* if(fromUser) { Log.v(TAG, "onProgressChanged: " + progress + "%"); before = progress; } */ } @Override public void onStartTrackingTouch(SeekBar seekBar) { Log.v(TAG, "onStartTrackingTouch"); blockSeekbarUpdate = true; } @Override public void onStopTrackingTouch(final SeekBar seekBar) { int after = seekBar.getProgress(); long ms = Math.round((after / 100d) * maxPositionInMillis); Log.v(TAG, "onStopTrackingTouch - after (%): " + after + " - ms: " + ms); eventBus.post(new SeekPodcast(ms)); blockSeekbarUpdate = false; } }; private void showPlaybackSpeedPicker() { final NumberPicker numberPicker = new NumberPicker(getContext()); numberPicker.setDescendantFocusability(NumberPicker.FOCUS_BLOCK_DESCENDANTS); numberPicker.setMinValue(0); numberPicker.setMaxValue(PodcastPlaybackService.PLAYBACK_SPEEDS.length-1); numberPicker.setFormatter(i -> String.valueOf(PodcastPlaybackService.PLAYBACK_SPEEDS[i])); if(getActivity() instanceof PodcastFragmentActivity) { getCurrentPlaybackSpeed(playbackSpeed -> { int position = Arrays.binarySearch(PodcastPlaybackService.PLAYBACK_SPEEDS, playbackSpeed); numberPicker.setValue(position); }); } else { numberPicker.setValue(3); } numberPicker.setWrapSelectorWheel(false); AlertDialog.Builder alertDialogBuilder = new AlertDialog.Builder(requireContext()); // set title alertDialogBuilder.setTitle(getString(R.string.podcast_playback_speed_dialog_title)); // set dialog message alertDialogBuilder .setCancelable(false) .setPositiveButton(getString(android.R.string.ok), (dialog, id) -> { float speed = PodcastPlaybackService.PLAYBACK_SPEEDS[numberPicker.getValue()]; eventBus.post(new SpeedPodcast(speed)); dialog.cancel(); }) .setNegativeButton(getString(android.R.string.cancel), (dialog, id) -> dialog.cancel()) .setView(numberPicker); // create alert dialog AlertDialog alertDialog = alertDialogBuilder.create(); // show it alertDialog.show(); // Code below is required to fix bug in Android (default value is not shown) (https://stackoverflow.com/a/30859583) try { Field f = NumberPicker.class.getDeclaredField("mInputText"); f.setAccessible(true); EditText inputText = (EditText) f.get(numberPicker); inputText.setFilters(new InputFilter[0]); } catch (NoSuchFieldException e) { e.printStackTrace(); } catch (IllegalAccessException e) { e.printStackTrace(); } } private final MediaControllerCompat.Callback controllerCallback = new MediaControllerCompat.Callback() { @Override public void onMetadataChanged(MediaMetadataCompat metadata) { Log.v(TAG, "onMetadataChanged() called with: metadata = [" + metadata + "]"); displayMetadata(metadata); } @Override public void onPlaybackStateChanged(PlaybackStateCompat stateCompat) { Log.v(TAG, "onPlaybackStateChanged() called with: state = [" + stateCompat + "]"); displayPlaybackState(stateCompat); } }; private void displayMetadata(MediaMetadataCompat metadata) { String title = metadata.getString(MediaMetadataCompat.METADATA_KEY_TITLE); String author = metadata.getString(MediaMetadataCompat.METADATA_KEY_ARTIST); if(author != null) { title += " - " + author; } binding.tvTitle.setText(title); binding.tvTitleSlider.setText(title); String favIconUrl = metadata.getString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI); if(favIconUrl != null) { Log.d(TAG, "currentPlayingPodcastReceived: " + favIconUrl); int placeholder = R.drawable.default_feed_icon_light; Glide.with(this.mActivity) .load(favIconUrl) .diskCacheStrategy(DiskCacheStrategy.DATA) .placeholder(placeholder) .error(placeholder) .transform(new MultiTransformation<>(new CenterCrop(), new RoundedCorners(10))) .into(binding.imgFeedFavicon); } PlaybackService.VideoType mediaType = PlaybackService.VideoType.valueOf(metadata.getString(CURRENT_PODCAST_MEDIA_TYPE)); if("-1".equals(metadata.getString(METADATA_KEY_MEDIA_ID))) { // Collapse if no podcast is loaded eventBus.post(new CollapsePodcastView()); } else { // Expand if podcast is loaded eventBus.post(new ExpandPodcastView()); if (mediaType == PlaybackService.VideoType.Video) { Log.v(TAG, "init regular video"); tryOpeningPictureinPictureMode(); } } maxPositionInMillis = metadata.getLong(MediaMetadataCompat.METADATA_KEY_DURATION); } private void displayPlaybackState(PlaybackStateCompat stateCompat) { boolean showPlayingButton = false; int state = stateCompat.getState(); if(PlaybackStateCompat.STATE_PLAYING == state || PlaybackStateCompat.STATE_BUFFERING == state || PlaybackStateCompat.STATE_CONNECTING == state || PlaybackStateCompat.STATE_PAUSED == state) { //Log.v(TAG, "State is: " + state); if (PlaybackStateCompat.STATE_PAUSED != state) { showPlayingButton = true; } } int drawableId = showPlayingButton ? R.drawable.ic_action_pause_24 : R.drawable.ic_baseline_play_arrow_24; int contentDescriptionId = showPlayingButton ? R.string.content_desc_pause : R.string.content_desc_play; // If attached to context.. if(mActivity != null) { binding.btnPlayPausePodcast.setImageResource(drawableId); binding.btnPlayPausePodcast.setContentDescription(getString(contentDescriptionId)); binding.btnPlayPausePodcastSlider.setImageResource(drawableId); } currentPositionInMillis = stateCompat.getPosition(); updateProgressBar(state); } private void updateProgressBar(@PlaybackStateCompat.State int state) { int hours = (int)(currentPositionInMillis / (1000*60*60)); int minutes = (int)(currentPositionInMillis % (1000*60*60)) / (1000*60); int seconds = (int) ((currentPositionInMillis % (1000*60*60)) % (1000*60) / 1000); minutes += hours * 60; binding.tvFrom.setText(String.format(Locale.getDefault(), "%02d:%02d", minutes, seconds)); binding.tvFromSlider.setText(String.format(Locale.getDefault(), "%02d:%02d", minutes, seconds)); hours = (int)(maxPositionInMillis / (1000*60*60)); minutes = (int)(maxPositionInMillis % (1000*60*60)) / (1000*60); seconds = (int) ((maxPositionInMillis % (1000*60*60)) % (1000*60) / 1000); minutes += hours * 60; binding.tvTo.setText(String.format(Locale.getDefault(), "%02d:%02d", minutes, seconds)); binding.tvToSlider.setText(String.format(Locale.getDefault(), "%02d:%02d", minutes, seconds)); if(state == PlaybackStateCompat.STATE_CONNECTING) { binding.sbProgress.setVisibility(View.INVISIBLE); binding.pbProgress2.setVisibility(View.VISIBLE); binding.pbProgress.setIndeterminate(true); } else { double progress = ((double) currentPositionInMillis / (double) maxPositionInMillis) * 100d; if(!blockSeekbarUpdate) { binding.sbProgress.setVisibility(View.VISIBLE); binding.pbProgress2.setVisibility(View.INVISIBLE); binding.sbProgress.setProgress((int) progress); } binding.pbProgress.setIndeterminate(false); binding.pbProgress.setProgress((int) progress); } } // https://developer.android.com/guide/topics/media-apps/audio-app/building-a-mediabrowser-client#customize-mediabrowser-connectioncallback private final MediaBrowserCompat.ConnectionCallback mConnectionCallbacks = new MediaBrowserCompat.ConnectionCallback() { @Override public void onConnected() { Log.d(TAG, "onConnected() called"); // Get the token for the MediaSession MediaSessionCompat.Token token = mMediaBrowser.getSessionToken(); // Create a MediaControllerCompat MediaControllerCompat mediaController = new MediaControllerCompat(mActivity, token); // Save the controller MediaControllerCompat.setMediaController(mActivity, mediaController); // Register a Callback to stay in sync mediaController.registerCallback(controllerCallback); // Display the initial state MediaMetadataCompat metadata = mediaController.getMetadata(); PlaybackStateCompat pbState = mediaController.getPlaybackState(); displayMetadata(metadata); displayPlaybackState(pbState); // Finish building the UI //buildTransportControls(); } @Override public void onConnectionSuspended() { Log.d(TAG, "onConnectionSuspended() called"); // The Service has crashed. Disable transport controls until it automatically reconnects } @Override public void onConnectionFailed() { Log.e(TAG, "onConnectionFailed() called"); // The Service has refused our connection } }; public void getCurrentPlaybackSpeed(final OnPlaybackSpeedCallback callback) { MediaControllerCompat.getMediaController(mActivity) .sendCommand(PLAYBACK_SPEED_FLOAT, null, new ResultReceiver(new Handler()) { @Override protected void onReceiveResult(int resultCode, Bundle resultData) { callback.currentPlaybackReceived(resultData.getFloat(PLAYBACK_SPEED_FLOAT)); } }); } /* public boolean getCurrentPlayingPodcast(final OnCurrentPlayingPodcastCallback callback) { if(mMediaBrowser != null && mMediaBrowser.isConnected()) { MediaControllerCompat.getMediaController(mActivity) .sendCommand(CURRENT_PODCAST_ITEM_MEDIA_ITEM, null, new ResultReceiver(new Handler()) { @Override protected void onReceiveResult(int resultCode, Bundle resultData) { callback.currentPlayingPodcastReceived((MediaItem) resultData.getSerializable(CURRENT_PODCAST_ITEM_MEDIA_ITEM)); } }); return true; } else { return false; } } */ private final MediaControllerCompat.Callback mediaControllerCallback = new MediaControllerCompat.Callback() { @Override public void onSessionReady() { Log.d(TAG, "onSessionReady() called"); super.onSessionReady(); } @Override public void onSessionDestroyed() { Log.d(TAG, "onSessionDestroyed() called"); super.onSessionDestroyed(); } @Override public void onSessionEvent(String event, Bundle extras) { Log.d(TAG, "onSessionEvent() called with: event = [" + event + "], extras = [" + extras + "]"); super.onSessionEvent(event, extras); } }; public interface OnPlaybackSpeedCallback { void currentPlaybackReceived(float playbackSpeed); } /* public interface OnCurrentPlayingPodcastCallback { void currentPlayingPodcastReceived(MediaItem mediaItem); }*/ } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/PodcastFragmentActivity.java ================================================ package de.luhmer.owncloudnewsreader; import static de.luhmer.owncloudnewsreader.Constants.MIN_NEXTCLOUD_FILES_APP_VERSION_CODE; import android.content.ActivityNotFoundException; import android.content.Intent; import android.content.SharedPreferences; import android.content.res.Resources; import android.net.Uri; import android.os.Bundle; import android.support.v4.media.session.MediaControllerCompat; import android.support.v4.media.session.PlaybackStateCompat; import android.util.Log; import android.util.TypedValue; import android.widget.Toast; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; import androidx.core.content.FileProvider; import com.nextcloud.android.sso.FilesAppTypeRegistry; import com.nextcloud.android.sso.helper.VersionCheckHelper; import com.sothree.slidinguppanel.SlidingUpPanelLayout; import org.greenrobot.eventbus.EventBus; import org.greenrobot.eventbus.Subscribe; import java.io.File; import java.util.function.Consumer; import java.util.regex.Matcher; import java.util.regex.Pattern; import javax.inject.Inject; import de.luhmer.owncloudnewsreader.database.DatabaseConnectionOrm; import de.luhmer.owncloudnewsreader.database.model.RssItem; import de.luhmer.owncloudnewsreader.di.ApiProvider; import de.luhmer.owncloudnewsreader.events.podcast.CollapsePodcastView; import de.luhmer.owncloudnewsreader.events.podcast.ExitPlayback; import de.luhmer.owncloudnewsreader.events.podcast.ExpandPodcastView; import de.luhmer.owncloudnewsreader.events.podcast.PodcastCompletedEvent; import de.luhmer.owncloudnewsreader.helper.PostDelayHandler; import de.luhmer.owncloudnewsreader.helper.ThemeChooser; import de.luhmer.owncloudnewsreader.interfaces.IPlayPausePodcastClicked; import de.luhmer.owncloudnewsreader.model.MediaItem; import de.luhmer.owncloudnewsreader.model.PodcastItem; import de.luhmer.owncloudnewsreader.notification.NextcloudNotificationManager; import de.luhmer.owncloudnewsreader.services.PodcastDownloadService; import de.luhmer.owncloudnewsreader.services.PodcastPlaybackService; import de.luhmer.owncloudnewsreader.ssl.MemorizingTrustManager; import de.luhmer.owncloudnewsreader.view.PodcastSlidingUpPanelLayout; import de.luhmer.owncloudnewsreader.widget.WidgetProvider; public abstract class PodcastFragmentActivity extends AppCompatActivity implements IPlayPausePodcastClicked { private static final String TAG = PodcastFragmentActivity.class.getCanonicalName(); @Inject protected SharedPreferences mPrefs; @Inject protected ApiProvider mApi; @Inject protected MemorizingTrustManager mMTM; @Inject protected PostDelayHandler mPostDelayHandler; private EventBus eventBus; private PodcastFragment mPodcastFragment; @Override protected void onCreate(@Nullable Bundle savedInstanceState) { //Log.v(TAG, "onCreate() called with: savedInstanceState = [" + savedInstanceState + "]"); ((NewsReaderApplication) getApplication()).getAppComponent().injectActivity(this); ThemeChooser.chooseTheme(this); super.onCreate(savedInstanceState); ThemeChooser.afterOnCreate(this); //if (mApi.getAPI() instanceof Proxy) { // doesn't work.. retrofit is also a "proxy" boolean useSSO = mPrefs.getBoolean(SettingsActivity.SW_USE_SINGLE_SIGN_ON, false); if(useSSO) { var type = FilesAppTypeRegistry.getInstance().findByAccountType("nextcloud"); // prod VersionCheckHelper.verifyMinVersion(this, MIN_NEXTCLOUD_FILES_APP_VERSION_CODE, type); } mPostDelayHandler.stopRunningPostDelayHandler(); } @Override protected void onPostCreate(Bundle savedInstanceState) { //Log.v(TAG, "onPostCreate() called with: savedInstanceState = [" + savedInstanceState + "]"); super.onPostCreate(savedInstanceState); eventBus = EventBus.getDefault(); updatePodcastView(); } @Override protected void onStart() { super.onStart(); mMTM.bindDisplayActivity(this); } @Override protected void onStop() { mMTM.unbindDisplayActivity(this); super.onStop(); } @Override public void onUserLeaveHint() { super.onUserLeaveHint(); mPostDelayHandler.delayOnExitTimer(); } @Override public void onWindowFocusChanged(boolean hasWindowFocus) { if (hasWindowFocus) { int currentOrientation = getResources().getConfiguration().orientation; if (currentOrientation != lastOrientation) { getPodcastSlidingUpPanelLayout().setPanelState(SlidingUpPanelLayout.PanelState.COLLAPSED); lastOrientation = currentOrientation; } } super.onWindowFocusChanged(hasWindowFocus); } int lastOrientation = -1; @Override protected void onResume() { eventBus.register(this); super.onResume(); } @Override protected void onPause() { eventBus.unregister(this); /* isVideoViewVisible = false; videoViewInitialized = false; eventBus.post(new RegisterVideoOutput(null, null)); rlVideoPodcastSurfaceWrapper.setVisibility(View.GONE); rlVideoPodcastSurfaceWrapper.removeAllViews(); */ WidgetProvider.UpdateWidget(this); NextcloudNotificationManager.showUnreadRssItemsNotification(this, mPrefs, true); super.onPause(); } /* public static boolean isMyServiceRunning(Class serviceClass, Context context) { ActivityManager manager = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); for (ActivityManager.RunningServiceInfo service : manager.getRunningServices(Integer.MAX_VALUE)) { if (serviceClass.getName().equals(service.service.getClassName())) { return true; } } return false; } */ /* private void buildTransportControls() { // Grab the view for the play/pause button int pbState = MediaControllerCompat.getMediaController(PodcastFragmentActivity.this).getPlaybackState().getState(); if (pbState == PlaybackStateCompat.STATE_PLAYING) { MediaControllerCompat.getMediaController(PodcastFragmentActivity.this).getTransportControls().pause(); } else { MediaControllerCompat.getMediaController(PodcastFragmentActivity.this).getTransportControls().play(); } MediaControllerCompat mediaController = MediaControllerCompat.getMediaController(PodcastFragmentActivity.this); // Display the initial state MediaMetadataCompat metadata = mediaController.getMetadata(); PlaybackStateCompat pbState = mediaController.getPlaybackState(); // Register a Callback to stay in sync mediaController.registerCallback(controllerCallback); } */ public PodcastSlidingUpPanelLayout getSlidingLayout() { return getPodcastSlidingUpPanelLayout(); } public boolean handlePodcastBackPressed() { if(mPodcastFragment != null && getPodcastSlidingUpPanelLayout().getPanelState().equals(SlidingUpPanelLayout.PanelState.EXPANDED)) { getPodcastSlidingUpPanelLayout().setPanelState(SlidingUpPanelLayout.PanelState.COLLAPSED); return true; } return false; } protected void updatePodcastView() { if(mPodcastFragment == null) { mPodcastFragment = PodcastFragment.newInstance(); } getSupportFragmentManager().beginTransaction() .replace(R.id.podcast_frame, mPodcastFragment) .commitAllowingStateLoss(); MediaControllerCompat mediaController = MediaControllerCompat.getMediaController(this); boolean isNotInit = mediaController == null || mediaController.getPlaybackState() == null || mediaController.getPlaybackState().getState() == PlaybackStateCompat.STATE_NONE; if (isNotInit) { collapsePodcastView(); } } @Subscribe public void onEvent(CollapsePodcastView event) { Log.v(TAG, "onEvent(CollapsePodcastView) called with: event = [" + event + "]"); collapsePodcastView(); } @Subscribe public void onEvent(ExpandPodcastView event) { Log.v(TAG, "onEvent(ExpandPodcastView) called with: event = [" + event + "]"); expandPodcastView(); } @Subscribe public void onEvent(ExitPlayback event) { Log.v(TAG, "onEvent(ExitPlayback) called with: event = [" + event + "]"); collapsePodcastView(); getPodcastSlidingUpPanelLayout().setPanelState(SlidingUpPanelLayout.PanelState.COLLAPSED); } private void collapsePodcastView() { getPodcastSlidingUpPanelLayout().setPanelHeight(0); } private void expandPodcastView() { getPodcastSlidingUpPanelLayout().setPanelHeight((int) dipToPx(68)); } @Subscribe public void onEvent(PodcastCompletedEvent podcastCompletedEvent) { collapsePodcastView(); getPodcastSlidingUpPanelLayout().setPanelState(SlidingUpPanelLayout.PanelState.COLLAPSED); //currentlyPlaying = false; } public static int pxToDp(int px) { return (int) (px / Resources.getSystem().getDisplayMetrics().density); } private float dipToPx(@SuppressWarnings("SameParameterValue") float dip) { return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dip, getResources().getDisplayMetrics()); } @VisibleForTesting public void openMediaItem(final MediaItem mediaItem) { if (mPrefs.getBoolean(SettingsActivity.CB_EXTERNAL_PLAYER, false) && mediaItem instanceof PodcastItem) { // PodcastItems can be audio or video Uri uri = ((PodcastItem) mediaItem).offlineCached // in case it's locally cached (offline) ? FileProvider.getUriForFile(this, BuildConfig.APPLICATION_ID + ".provider", new File(mediaItem.link)) : Uri.parse(mediaItem.link); Intent intent = new Intent(Intent.ACTION_VIEW); intent.setDataAndType(uri, ((PodcastItem) mediaItem).mimeType); intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); startActivity(intent); } else { // in case the user wants to use the internal player or we have a TTS item (text to speech) Intent intent = new Intent(this, PodcastPlaybackService.class); intent.putExtra(PodcastPlaybackService.MEDIA_ITEM, mediaItem); startService(intent); // if(!mMediaBrowser.isConnected()) { // mMediaBrowser.connect(); // } // bindService(intent, mConnection, Context.BIND_AUTO_CREATE); } } @Override public void openPodcast(final RssItem rssItem) { final PodcastItem podcastItem = DatabaseConnectionOrm.ParsePodcastItemFromRssItem(this, rssItem); File file = new File(PodcastDownloadService.getUrlToPodcastFile(this, podcastItem.fingerprint, podcastItem.link, false)); if(file.exists()) { podcastItem.link = file.getAbsolutePath(); openMediaItem(podcastItem); } else if(!podcastItem.offlineCached) { AlertDialog.Builder alertDialog = new AlertDialog.Builder(this) .setNegativeButton("Abort", null) .setTitle("Podcast"); if("youtube".equals(podcastItem.mimeType)) { alertDialog.setPositiveButton("Open Youtube", (dialogInterface, i) -> openYoutube(podcastItem)); } else { alertDialog.setNeutralButton("Download", (dialogInterface, i) -> { PodcastDownloadService.startPodcastDownload(PodcastFragmentActivity.this, podcastItem); Toast.makeText(PodcastFragmentActivity.this, "Starting download of podcast. Please wait..", Toast.LENGTH_SHORT).show(); }); alertDialog.setPositiveButton("Stream", (dialogInterface, i) -> openMediaItem(podcastItem)); alertDialog.setMessage("Choose if you want to download or stream the selected podcast"); } alertDialog.show(); } } public void removePodcastMedia(final RssItem rssItem, final Consumer callback) { final PodcastItem podcastItem = DatabaseConnectionOrm.ParsePodcastItemFromRssItem(this, rssItem); File file = new File(PodcastDownloadService.getUrlToPodcastFile(this, podcastItem.fingerprint, podcastItem.link, false)); if (!file.exists()) { callback.accept(true); } AlertDialog.Builder alertDialog = new AlertDialog.Builder(this) .setNegativeButton(getString(R.string.dialog_podcast_remove_confirm), (dialogInterface, i) -> { boolean success = file.delete() && file.getParentFile().delete(); // remove audio file and parent folder if (!success) { Toast.makeText(PodcastFragmentActivity.this, getString(R.string.dialog_podcast_status_failed, podcastItem.title), Toast.LENGTH_SHORT).show(); } else { Toast.makeText(PodcastFragmentActivity.this, getString(R.string.dialog_podcast_status_success, podcastItem.title), Toast.LENGTH_SHORT).show(); } callback.accept(success); }) .setNeutralButton(getString(android.R.string.cancel), (dialogInterface, i) -> { callback.accept(false); }) .setTitle(getString(R.string.dialog_podcast_remove_title)) .setMessage(getString(R.string.dialog_podcast_remove_body, podcastItem.title)); alertDialog.show(); } @Override public void pausePodcast() { MediaControllerCompat.getMediaController(PodcastFragmentActivity.this).getTransportControls().pause(); } private void openYoutube(PodcastItem podcastItem) { Log.e(TAG, podcastItem.link); String youtubeVideoID = getVideoIdFromYoutubeUrl(podcastItem.link); if(youtubeVideoID == null) { Toast.makeText(this, "Failed to extract youtube video id for url: " + podcastItem.link + ". Please report this issue.", Toast.LENGTH_LONG).show(); return; } Intent appIntent = new Intent(Intent.ACTION_VIEW, Uri.parse("vnd.youtube:" + youtubeVideoID)); Intent webIntent = new Intent(Intent.ACTION_VIEW, Uri.parse("http://www.youtube.com/watch?v=" + podcastItem.link)); try { startActivity(appIntent); } catch (ActivityNotFoundException ex) { startActivity(webIntent); } } public String getVideoIdFromYoutubeUrl(String url){ String videoId = null; String regex = "http(?:s)?:\\/\\/(?:m.)?(?:www\\.)?youtu(?:\\.be\\/|be\\.com\\/(?:watch\\?(?:feature=youtu.be\\&)?v=|v\\/|embed\\/|user\\/(?:[\\w#]+\\/)+))([^&#?\\n]+)"; Pattern pattern = Pattern.compile(regex, Pattern.CASE_INSENSITIVE); Matcher matcher = pattern.matcher(url); if(matcher.find()){ videoId = matcher.group(1); } return videoId; } protected abstract PodcastSlidingUpPanelLayout getPodcastSlidingUpPanelLayout(); } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/SettingsActivity.java ================================================ /* * Android ownCloud News * * @author David Luhmer * @copyright 2013 David Luhmer david-dev@live.de * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE * License as published by the Free Software Foundation; either * version 3 of the License, or any later version. * * This library 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 AFFERO GENERAL PUBLIC LICENSE for more details. * * You should have received a copy of the GNU Affero General Public * License along with this library. If not, see . * */ package de.luhmer.owncloudnewsreader; import android.content.Intent; import android.content.SharedPreferences; import android.os.Bundle; import android.preference.PreferenceActivity; import android.view.MenuItem; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.widget.Toolbar; import javax.inject.Inject; import de.luhmer.owncloudnewsreader.helper.ThemeChooser; /** * A {@link PreferenceActivity} that presents a set of application settings. On * handset devices, settings are presented as a single list. On tablets, * settings are split by category, with category headers shown to the left of * the list of settings. *

* See * Android Design: Settings for design guidelines and the Settings * API Guide for more information on developing a Settings UI. */ public class SettingsActivity extends AppCompatActivity { private static final String TAG = SettingsActivity.class.getCanonicalName(); /** * Determines whether to always show the simplified settings UI, where * settings are presented in a single list. When false, settings are shown * as a master/detail two-pane view on tablets. When true, a single pane is * shown on tablets. */ public static final String EDT_USERNAME_STRING = "edt_username"; public static final String EDT_PASSWORD_STRING = "edt_password"; public static final String EDT_OWNCLOUDROOTPATH_STRING = "edt_owncloudRootPath"; public static final String SW_USE_SINGLE_SIGN_ON = "sw_use_single_sign_on"; public static final String EDT_CLEAR_CACHE = "edt_clearCache"; //public static final String CB_ALLOWALLSSLCERTIFICATES_STRING = "cb_AllowAllSSLCertificates"; public static final String CB_SYNCONSTARTUP_STRING = "cb_AutoSyncOnStart"; public static final String CB_SHOWONLYUNREAD_STRING = "cb_ShowOnlyUnread"; public static final String CB_NAVIGATE_WITH_VOLUME_BUTTONS_STRING = "cb_NavigateWithVolumeButtons"; public static final String LV_CACHE_IMAGES_OFFLINE_STRING = "lv_cacheImagesOffline"; public static final String CB_MARK_AS_READ_WHILE_SCROLLING_STRING = "cb_MarkAsReadWhileScrolling"; public static final String CB_SYNC_WHEN_SCROLLED_TO_BOTTOM_STRING = "cb_SyncWhenScrolledToBottom"; public static final String CB_SHOW_FAST_ACTIONS = "cb_ShowFastActions"; public static final String CB_PREF_BACK_OPENS_DRAWER = "cb_prefBackButtonOpensDrawer"; public static final String CB_DISABLE_HOSTNAME_VERIFICATION_STRING = "cb_DisableHostnameVerification"; public static final String CB_SKIP_DETAILVIEW_AND_OPEN_BROWSER_DIRECTLY_STRING = "cb_openInBrowserDirectly"; //public static final String CB_ENABLE_PODCASTS_STRING = "cb_enablePodcasts"; public static final String PREF_SERVER_SETTINGS = "pref_server_settings"; public static final String PREF_SYNC_SETTINGS = "pref_sync_settings"; public static final String SYNC_INTERVAL_IN_MINUTES_STRING_DEPRECATED = "SYNC_INTERVAL_IN_MINUTES_STRING"; public static final String SP_APP_THEME = "sp_app_theme"; public static final String CB_OLED_MODE = "cb_oled_mode"; public static final String CB_DETAILED_VIEW_ZOOM = "cb_detailed_view_zoom"; public static final String CB_EXTERNAL_PLAYER = "cb_external_player"; public static final String SP_FEED_LIST_LAYOUT = "sp_feed_list_layout"; // used for shared prefs public static final String RI_FEED_LIST_LAYOUT = "ai_feed_list_layout"; // used for result intents public static final String SP_FONT_SIZE = "sp_font_size"; public static final String RI_CACHE_CLEARED = "CACHE_CLEARED"; // used for result intents public static final String SP_MAX_CACHE_SIZE = "sp_max_cache_size"; public static final String SP_SORT_ORDER = "sp_sort_order"; public static final String SP_DISPLAY_BROWSER = "sp_display_browser"; public static final String SP_SEARCH_IN = "sp_search_in"; public static final String SP_SWIPE_RIGHT_ACTION = "sp_swipe_right_action"; public static final String SP_SWIPE_LEFT_ACTION = "sp_swipe_left_action"; public static final String SP_SWIPE_RIGHT_ACTION_DEFAULT = "1"; public static final String SP_SWIPE_LEFT_ACTION_DEFAULT = "2"; public static final String CB_VERSION = "cb_version"; public static final String CB_REPORT_ISSUE = "cb_reportIssue"; protected @Inject SharedPreferences mPrefs; public Intent resultIntent = new Intent(); @Override protected void onCreate(Bundle savedInstanceState) { ((NewsReaderApplication) getApplication()).getAppComponent().injectActivity(this); ThemeChooser.chooseTheme(this); super.onCreate(savedInstanceState); ThemeChooser.afterOnCreate(this); setContentView(R.layout.activity_settings); setupActionBar(); // some settings might add a few flags to the result Intent at runtime // (e.g. clearing cache / switching list layout / theme / ...) setResult(RESULT_OK, resultIntent); } @Override protected void onPostCreate(Bundle savedInstanceState) { super.onPostCreate(savedInstanceState); getSupportFragmentManager() .beginTransaction() .replace(R.id.container, new SettingsFragment()) .commit(); } private void setupActionBar() { Toolbar toolbar = findViewById(R.id.toolbar); setSupportActionBar(toolbar); getSupportActionBar().setDisplayShowHomeEnabled(true); getSupportActionBar().setDisplayHomeAsUpEnabled(true); getSupportActionBar().setTitle(R.string.title_activity_settings); } @Override public boolean onOptionsItemSelected(MenuItem item) { if (item.getItemId() == android.R.id.home) { finish(); return true; } return super.onOptionsItemSelected(item); } @Override protected void onStart() { super.onStart(); // Fix GHSL-2021-1033 String feedListLayout = mPrefs.getString(SettingsActivity.SP_FEED_LIST_LAYOUT, "0"); resultIntent.putExtra(SettingsActivity.RI_FEED_LIST_LAYOUT, feedListLayout); } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/SettingsFragment.java ================================================ package de.luhmer.owncloudnewsreader; import static de.luhmer.owncloudnewsreader.Constants.USER_INFO_STRING; import static de.luhmer.owncloudnewsreader.SettingsActivity.CB_MARK_AS_READ_WHILE_SCROLLING_STRING; import static de.luhmer.owncloudnewsreader.SettingsActivity.CB_SYNC_WHEN_SCROLLED_TO_BOTTOM_STRING; import static de.luhmer.owncloudnewsreader.SettingsActivity.CB_NAVIGATE_WITH_VOLUME_BUTTONS_STRING; import static de.luhmer.owncloudnewsreader.SettingsActivity.CB_OLED_MODE; import static de.luhmer.owncloudnewsreader.SettingsActivity.CB_PREF_BACK_OPENS_DRAWER; import static de.luhmer.owncloudnewsreader.SettingsActivity.CB_REPORT_ISSUE; import static de.luhmer.owncloudnewsreader.SettingsActivity.CB_SHOWONLYUNREAD_STRING; import static de.luhmer.owncloudnewsreader.SettingsActivity.CB_SHOW_FAST_ACTIONS; import static de.luhmer.owncloudnewsreader.SettingsActivity.CB_SKIP_DETAILVIEW_AND_OPEN_BROWSER_DIRECTLY_STRING; import static de.luhmer.owncloudnewsreader.SettingsActivity.CB_SYNCONSTARTUP_STRING; import static de.luhmer.owncloudnewsreader.SettingsActivity.CB_VERSION; import static de.luhmer.owncloudnewsreader.SettingsActivity.CB_DETAILED_VIEW_ZOOM; import static de.luhmer.owncloudnewsreader.SettingsActivity.EDT_CLEAR_CACHE; import static de.luhmer.owncloudnewsreader.SettingsActivity.EDT_OWNCLOUDROOTPATH_STRING; import static de.luhmer.owncloudnewsreader.SettingsActivity.EDT_PASSWORD_STRING; import static de.luhmer.owncloudnewsreader.SettingsActivity.EDT_USERNAME_STRING; import static de.luhmer.owncloudnewsreader.SettingsActivity.LV_CACHE_IMAGES_OFFLINE_STRING; import static de.luhmer.owncloudnewsreader.SettingsActivity.PREF_SYNC_SETTINGS; import static de.luhmer.owncloudnewsreader.SettingsActivity.SP_APP_THEME; import static de.luhmer.owncloudnewsreader.SettingsActivity.SP_DISPLAY_BROWSER; import static de.luhmer.owncloudnewsreader.SettingsActivity.SP_FEED_LIST_LAYOUT; import static de.luhmer.owncloudnewsreader.SettingsActivity.SP_FONT_SIZE; import static de.luhmer.owncloudnewsreader.SettingsActivity.SP_MAX_CACHE_SIZE; import static de.luhmer.owncloudnewsreader.SettingsActivity.SP_SEARCH_IN; import static de.luhmer.owncloudnewsreader.SettingsActivity.SP_SORT_ORDER; import static de.luhmer.owncloudnewsreader.SettingsActivity.SP_SWIPE_LEFT_ACTION; import static de.luhmer.owncloudnewsreader.SettingsActivity.SP_SWIPE_RIGHT_ACTION; import static de.luhmer.owncloudnewsreader.SettingsActivity.SYNC_INTERVAL_IN_MINUTES_STRING_DEPRECATED; import android.accounts.Account; import android.accounts.AccountManager; import android.app.ProgressDialog; import android.content.ContentResolver; import android.content.Context; import android.content.DialogInterface; import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.PackageInfo; import android.content.pm.PackageManager; import android.net.Uri; import android.os.AsyncTask; import android.os.Build; import android.os.Bundle; import android.widget.Toast; import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.DialogFragment; import androidx.preference.CheckBoxPreference; import androidx.preference.DialogPreference; import androidx.preference.ListPreference; import androidx.preference.Preference; import androidx.preference.PreferenceFragmentCompat; import androidx.preference.TwoStatePreference; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.List; import java.util.Map; import javax.inject.Inject; import javax.inject.Named; import de.luhmer.owncloudnewsreader.authentication.AccountGeneral; import de.luhmer.owncloudnewsreader.database.DatabaseConnectionOrm; import de.luhmer.owncloudnewsreader.helper.ImageHandler; import de.luhmer.owncloudnewsreader.helper.NewsFileUtils; import de.luhmer.owncloudnewsreader.helper.PostDelayHandler; public class SettingsFragment extends PreferenceFragmentCompat { protected @Inject SharedPreferences mPrefs; protected @Inject @Named("sharedPreferencesFileName") String sharedPreferencesFileName; private static String version = ""; @Override public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { ((NewsReaderApplication) requireActivity().getApplication()).getAppComponent().injectFragment(this); // Define the settings file to use by this settings fragment getPreferenceManager().setSharedPreferencesName(sharedPreferencesFileName); version = VersionInfoDialogFragment.getVersionString(getActivity()); migrateSyncIntervalValue(); // migrates pref SYNC_INTERVAL_IN_MINUTES_STRING to pref_sync_settings addPreferencesFromResource(R.xml.pref_general); bindGeneralPreferences(this); addPreferencesFromResource(R.xml.pref_display); bindDisplayPreferences(this); addPreferencesFromResource(R.xml.pref_data_sync); bindDataSyncPreferences(this); addPreferencesFromResource(R.xml.pref_about); bindAboutPreferences(this); //addPreferencesFromResource(R.xml.pref_podcast); //bindPodcastPreferences(this); /* // Fix settings page header ("breadcrumb") text color for dark mode // Thank you Stackoverflow: https://stackoverflow.com/a/27078485 final View breadcrumb = findViewById(android.R.id.title); if (breadcrumb == null) { // Single pane layout return; } try { final Field titleColor = breadcrumb.getClass().getDeclaredField("mTextColor"); titleColor.setAccessible(true); titleColor.setInt(breadcrumb, ContextCompat.getColor(this, R.color.primaryTextColor)); } catch (final Exception e) { Log.e(TAG, "onBuildHeaders failed", e); } */ } /** * A preference value change listener that updates the preference's summary * to reflect its new value. */ private static final Preference.OnPreferenceChangeListener sBindPreferenceSummaryToValueListener = (preference, value) -> { String stringValue = value.toString(); if (preference instanceof ListPreference listPreference) { // For list preferences, look up the correct display value in // the preference's 'entries' list. int index = listPreference.findIndexOfValue(stringValue); // Set the summary to reflect the new value. preference.setSummary(index >= 0 ? listPreference.getEntries()[index] : null); // only enable black-bg setting if light or auto theme is selected if(SP_APP_THEME.equals(preference.getKey())) { // value "1" means Light theme preference.getPreferenceManager().findPreference(CB_OLED_MODE).setEnabled(!value.equals("1")); } else if(PREF_SYNC_SETTINGS.equals(preference.getKey())) { // set the sync value in account setAccountSyncInterval(preference.getContext(), Integer.parseInt(stringValue)); } } else { String key = preference.getKey(); // For all other preferences, set the summary to the value's // simple string representation. if(key.equals(EDT_PASSWORD_STRING)) preference.setSummary(null); else preference.setSummary(stringValue); } return true; }; private static final Preference.OnPreferenceChangeListener sBindPreferenceBooleanToValueListener = (preference, newValue) -> { if(preference instanceof CheckBoxPreference cbPreference) { //For legacy Android support cbPreference.setChecked((Boolean) newValue); } else { TwoStatePreference twoStatePreference = ((TwoStatePreference) preference); twoStatePreference.setChecked((Boolean) newValue); } return true; }; /** * Binds a preference's summary to its value. More specifically, when the * preference's value is changed, its summary (line of text below the * preference title) is updated to reflect the value. The summary is also * immediately updated upon calling this method. The exact display format is * dependent on the type of preference. * * @see #sBindPreferenceSummaryToValueListener */ private void bindPreferenceSummaryToValue(Preference preference) { // Set the listener to watch for value changes. preference.setOnPreferenceChangeListener(sBindPreferenceSummaryToValueListener); // Trigger the listener immediately with the preference's // current value. sBindPreferenceSummaryToValueListener.onPreferenceChange( preference, mPrefs.getString(preference.getKey(), "")); } private void bindPreferenceBooleanToValue(Preference preference) { // Set the listener to watch for value changes. preference.setOnPreferenceChangeListener(sBindPreferenceBooleanToValueListener); // Trigger the listener immediately with the preference's // current value. sBindPreferenceBooleanToValueListener.onPreferenceChange( preference, mPrefs.getBoolean(preference.getKey(), false)); } // TODO DO WE NEED THE CODE BELOW?!! /* @Nullable @Override public View onCreateView(String name, Context context, AttributeSet attrs) { // Allow super to try and create a view first final View result = super.onCreateView(name, context, attrs); if (result != null) { return result; } return null; } */ private void bindDisplayPreferences(PreferenceFragmentCompat prefFrag) { bindPreferenceSummaryToValue(prefFrag.findPreference(SP_APP_THEME)); bindPreferenceBooleanToValue(prefFrag.findPreference(CB_OLED_MODE)); bindPreferenceSummaryToValue(prefFrag.findPreference(SP_FEED_LIST_LAYOUT)); bindPreferenceSummaryToValue(prefFrag.findPreference(SP_FONT_SIZE)); bindPreferenceBooleanToValue(prefFrag.findPreference(CB_DETAILED_VIEW_ZOOM)); bindPreferenceSummaryToValue(prefFrag.findPreference(SP_DISPLAY_BROWSER)); } private void bindGeneralPreferences(DialogPreference.TargetFragment prefFrag) { /* bindPreferenceSummaryToValue(prefFrag.findPreference(EDT_USERNAME_STRING)); bindPreferenceSummaryToValue(prefFrag.findPreference(EDT_PASSWORD_STRING)); bindPreferenceSummaryToValue(prefFrag.findPreference(EDT_OWNCLOUDROOTPATH_STRING)); */ //bindPreferenceBooleanToValue(prefFrag.findPreference(CB_ALLOWALLSSLCERTIFICATES_STRING)); bindPreferenceBooleanToValue(prefFrag.findPreference(CB_SYNCONSTARTUP_STRING)); bindPreferenceBooleanToValue(prefFrag.findPreference(CB_SHOWONLYUNREAD_STRING)); bindPreferenceBooleanToValue(prefFrag.findPreference(CB_NAVIGATE_WITH_VOLUME_BUTTONS_STRING)); bindPreferenceBooleanToValue(prefFrag.findPreference(CB_MARK_AS_READ_WHILE_SCROLLING_STRING)); bindPreferenceBooleanToValue(prefFrag.findPreference(CB_SYNC_WHEN_SCROLLED_TO_BOTTOM_STRING)); bindPreferenceBooleanToValue(prefFrag.findPreference(CB_SHOW_FAST_ACTIONS)); bindPreferenceBooleanToValue(prefFrag.findPreference(CB_SKIP_DETAILVIEW_AND_OPEN_BROWSER_DIRECTLY_STRING)); bindPreferenceBooleanToValue(prefFrag.findPreference(CB_PREF_BACK_OPENS_DRAWER)); bindPreferenceSummaryToValue(prefFrag.findPreference(SP_SORT_ORDER)); bindPreferenceSummaryToValue(prefFrag.findPreference(SP_SEARCH_IN)); bindPreferenceSummaryToValue(prefFrag.findPreference(SP_SWIPE_RIGHT_ACTION)); bindPreferenceSummaryToValue(prefFrag.findPreference(SP_SWIPE_LEFT_ACTION)); } /** * migrates pref SYNC_INTERVAL_IN_MINUTES_STRING to pref_sync_settings * temporary function, could be removed whenever is wished */ private void migrateSyncIntervalValue() { // For migration compatibility, in case preference SYNC_INTERVAL_IN_MINUTES_STRING is there // we migrate its value in PREF_SYNC_SETTINGS int minutes = mPrefs.getInt(SYNC_INTERVAL_IN_MINUTES_STRING_DEPRECATED, -1); if (minutes != -1) { // we need to migrate mPrefs.edit().putString(PREF_SYNC_SETTINGS, String.valueOf(minutes)).commit(); mPrefs.edit().remove(SYNC_INTERVAL_IN_MINUTES_STRING_DEPRECATED).commit(); } // impact if the above code is removed: // the list will show the default sync interval value of 15min // whereas the user may have configured some other value // once the user selects a value, this new value is actually used; and no more impact is expected } private void bindDataSyncPreferences(final PreferenceFragmentCompat prefFrag) { // handle the sync interval list: bindPreferenceSummaryToValue(prefFrag.findPreference(PREF_SYNC_SETTINGS)); // String[] authorities = { "de.luhmer.owncloudnewsreader" }; // Intent intentSyncSettings = new Intent(Settings.ACTION_SYNC_SETTINGS); // intentSyncSettings.putExtra(Settings.EXTRA_AUTHORITIES, authorities); // String[] authorities = { "de.luhmer.owncloudnewsreader" }; // Intent intentSyncSettings = new Intent(Settings.ACTION_SYNC_SETTINGS); // intentSyncSettings.putExtra(Settings.EXTRA_AUTHORITIES, authorities); //bindPreferenceSummaryToValue(prefFrag.findPreference(SP_MAX_ITEMS_SYNC)); Preference clearCachePref = prefFrag.findPreference(EDT_CLEAR_CACHE); bindPreferenceSummaryToValue(prefFrag.findPreference(LV_CACHE_IMAGES_OFFLINE_STRING)); bindPreferenceSummaryToValue(prefFrag.findPreference(SP_MAX_CACHE_SIZE)); clearCachePref.setOnPreferenceClickListener(preference -> { mPrefs.edit().remove(USER_INFO_STRING).apply(); checkForUnsycedChangesInDatabaseAndResetDatabase(prefFrag.getActivity()); return true; }); } private void bindAboutPreferences(final PreferenceFragmentCompat prefFrag) { prefFrag.findPreference(CB_VERSION).setSummary(version); Preference changelogPreference = prefFrag.findPreference(CB_VERSION); changelogPreference.setOnPreferenceClickListener(preference -> { DialogFragment dialog = new VersionInfoDialogFragment(); dialog.show(prefFrag.requireActivity().getSupportFragmentManager(), "VersionChangelogDialogFragment"); return true; }); findPreference(CB_REPORT_ISSUE).setOnPreferenceClickListener(preference -> { openBugReport(); return true; }); } private void bindPodcastPreferences(PreferenceFragmentCompat prefFrag) { //bindPreferenceBooleanToValue(prefFrag.findPreference(CB_ENABLE_PODCASTS_STRING)); } public void checkForUnsycedChangesInDatabaseAndResetDatabase(final Context context) { DatabaseConnectionOrm dbConn = new DatabaseConnectionOrm(context); boolean resetDatabase = !dbConn.areThereAnyUnsavedChangesInDatabase(); if(resetDatabase) { new ResetDatabaseAsyncTask(context).execute(); } else { new AlertDialog.Builder(context) .setTitle(context.getString(R.string.warning)) .setMessage(context.getString(R.string.reset_cache_unsaved_changes)) .setPositiveButton(context.getString(android.R.string.ok), new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { PostDelayHandler pDelayHandler = new PostDelayHandler(context); pDelayHandler.stopRunningPostDelayHandler(); new ResetDatabaseAsyncTask(context).execute(); } }) .setNegativeButton(context.getString(android.R.string.no), null) .create() .show(); } } private void openBugReport() { String title = ""; String body = ""; StringBuilder debugInfo = new StringBuilder("Please describe your bug here...\n\n---\n"); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { try { PackageInfo pInfo = requireContext().getPackageManager().getPackageInfo(requireContext().getPackageName(), 0); debugInfo.append("\nApp Version: ").append(pInfo.versionName); debugInfo.append("\nApp Version Code: ").append(pInfo.versionCode); } catch (PackageManager.NameNotFoundException e) { e.printStackTrace(); } debugInfo.append("\n\n---\n"); debugInfo.append("\nSSO enabled: ").append(mPrefs.getBoolean(SettingsActivity.SW_USE_SINGLE_SIGN_ON, false)); debugInfo.append("\n\n---\n"); debugInfo.append("\nOS Version: ").append(System.getProperty("os.version")).append("(").append(Build.VERSION.INCREMENTAL).append(")"); debugInfo.append("\nOS API Level: ").append(Build.VERSION.SDK_INT); debugInfo.append("\nDevice: ").append(Build.DEVICE); debugInfo.append("\nModel (and Product): ").append(Build.MODEL).append(" (").append(Build.PRODUCT).append(")"); debugInfo.append("\n\n---\n\n"); List excludedSettings = Arrays.asList(EDT_USERNAME_STRING, EDT_PASSWORD_STRING, EDT_OWNCLOUDROOTPATH_STRING, Constants.LAST_UPDATE_NEW_ITEMS_COUNT_STRING, USER_INFO_STRING); Map allEntries = mPrefs.getAll(); for (Map.Entry entry : allEntries.entrySet()) { String key = entry.getKey(); if (!excludedSettings.contains(key)) { debugInfo.append(entry).append("\n"); } } body = URLEncoder.encode(debugInfo.toString(), StandardCharsets.UTF_8); } Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/nextcloud/news-android/issues/new?title=" + title + "&body=" + body)); startActivity(browserIntent); } public static final long SECONDS_PER_MINUTE = 60L; public static void setAccountSyncInterval(Context context, int minutes) { AccountManager mAccountManager = AccountManager.get(context); String accountType = AccountGeneral.getAccountType(context); Account[] accounts = mAccountManager.getAccountsByType(accountType); for (Account account : accounts) { if (minutes != 0) { long SYNC_INTERVAL = minutes * SECONDS_PER_MINUTE; ContentResolver.setSyncAutomatically(account, accountType, true); Bundle bundle = new Bundle(); ContentResolver.addPeriodicSync( account, accountType, bundle, SYNC_INTERVAL); } else { ContentResolver.setSyncAutomatically(account, accountType, false); } } } public static class ResetDatabaseAsyncTask extends AsyncTask { private ProgressDialog pd; private final Context context; public ResetDatabaseAsyncTask(Context context) { this.context = context; } @Override protected void onPreExecute() { pd = new ProgressDialog(context); pd.setIndeterminate(true); pd.setCancelable(false); pd.setTitle(context.getString(R.string.dialog_clearing_cache)); pd.setMessage(context.getString(R.string.dialog_clearing_cache_please_wait)); pd.show(); super.onPreExecute(); } @Override protected Void doInBackground(Void... params) { //Thread.sleep(1000); DatabaseConnectionOrm dbConn = new DatabaseConnectionOrm(context); dbConn.resetDatabase(); NewsFileUtils.clearWebArchiveCache(context); NewsFileUtils.clearPodcastCache(context); return null; } @Override protected void onPostExecute(Void result) { super.onPostExecute(result); // needs to be executed on main thread ImageHandler.clearCache(context); pd.dismiss(); Toast.makeText(context, context.getString(R.string.cache_is_cleared), Toast.LENGTH_SHORT).show(); if(context instanceof SettingsActivity sa) { sa.resultIntent.putExtra(SettingsActivity.RI_CACHE_CLEARED, true); } } } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/VersionInfoDialogFragment.java ================================================ /* * Android ownCloud News * * @author David Luhmer * @copyright 2013 David Luhmer david-dev@live.de * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE * License as published by the Free Software Foundation; either * version 3 of the License, or any later version. * * This library 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 AFFERO GENERAL PUBLIC LICENSE for more details. * * You should have received a copy of the GNU Affero General Public * License along with this library. If not, see . * */ package de.luhmer.owncloudnewsreader; import android.app.Activity; import android.app.Dialog; import android.content.pm.PackageInfo; import android.content.pm.PackageManager; import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; import android.view.WindowManager.LayoutParams; import android.widget.ProgressBar; import android.widget.TextView; import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.DialogFragment; import java.io.IOException; import java.util.Formatter; import de.luhmer.owncloudnewsreader.async_tasks.DownloadChangelogTask; import de.luhmer.owncloudnewsreader.view.ChangeLogFileListView; /** * Displays current app version and changelog. */ public class VersionInfoDialogFragment extends DialogFragment { @Override public Dialog onCreateDialog(Bundle savedInstanceState) { // load views LayoutInflater inflater = getActivity().getLayoutInflater(); View view = inflater.inflate(R.layout.dialog_version_info, null); ChangeLogFileListView clListView = view.findViewById(R.id.changelog_listview); final ProgressBar progressBar = view.findViewById(R.id.changeLogLoadingProgressBar); TextView versionTextView = view.findViewById(R.id.tv_androidAppVersion); // build dialog AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()) .setView(view) .setPositiveButton(getString(android.R.string.ok), (dialog, which) -> dismiss()) .setCancelable(true) // React to click outside of version info .setTitle("Changelog"); // changelog content is in english only anyways.. // set current version versionTextView.setText(getVersionString(getActivity())); // load changelog into view loadChangeLog(clListView, progressBar); return builder.create(); } /* (non-Javadoc) * @see android.support.v4.app.DialogFragment#onStart() */ @Override public void onStart() { //Use the full screen for this dialog even in Landscape Mode. LayoutParams params = getDialog().getWindow().getAttributes(); params.width = LayoutParams.MATCH_PARENT; getDialog().getWindow().setAttributes(params); super.onStart(); } public static String getVersionString(Activity activity) { String version = "?"; try { PackageInfo pInfo = activity.getPackageManager().getPackageInfo(activity.getPackageName(), 0); version = pInfo.versionName; } catch (PackageManager.NameNotFoundException e){ e.printStackTrace(); } Formatter formatter = new Formatter(); String versionString = activity.getString(R.string.current_version); return formatter.format(versionString, version).toString(); } /** * Loads changelog into the given view and hides progress bar when done. */ private void loadChangeLog(ChangeLogFileListView clListView, final ProgressBar progressBar) { new DownloadChangelogTask(getActivity(), clListView, new DownloadChangelogTask.Listener() { @Override public void onSuccess() { progressBar.setVisibility(View.GONE); } @Override public void onError(IOException e) { progressBar.setVisibility(View.GONE); e.printStackTrace(); } }).execute(); } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/adapter/HasId.kt ================================================ package de.luhmer.owncloudnewsreader.adapter interface HasId { val id: T } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/adapter/NewsListRecyclerAdapter.java ================================================ package de.luhmer.owncloudnewsreader.adapter; import android.content.Context; import android.content.SharedPreferences; import android.os.AsyncTask; import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.fragment.app.FragmentActivity; import androidx.recyclerview.widget.RecyclerView; import com.bumptech.glide.Glide; import com.bumptech.glide.RequestManager; import org.greenrobot.eventbus.EventBus; import org.greenrobot.eventbus.Subscribe; import java.util.ArrayList; import java.util.List; import de.luhmer.owncloudnewsreader.LazyLoadingLinearLayoutManager; import de.luhmer.owncloudnewsreader.NewsReaderListActivity; import de.luhmer.owncloudnewsreader.SettingsActivity; import de.luhmer.owncloudnewsreader.database.DatabaseConnectionOrm; import de.luhmer.owncloudnewsreader.database.model.RssItem; import de.luhmer.owncloudnewsreader.databinding.ProgressbarItemBinding; import de.luhmer.owncloudnewsreader.databinding.SubscriptionDetailListItemCardViewBinding; import de.luhmer.owncloudnewsreader.databinding.SubscriptionDetailListItemHeadlineBinding; import de.luhmer.owncloudnewsreader.databinding.SubscriptionDetailListItemHeadlineThumbnailBinding; import de.luhmer.owncloudnewsreader.databinding.SubscriptionDetailListItemTextBinding; import de.luhmer.owncloudnewsreader.databinding.SubscriptionDetailListItemThumbnailBinding; import de.luhmer.owncloudnewsreader.databinding.SubscriptionDetailListItemWebLayoutBinding; import de.luhmer.owncloudnewsreader.events.podcast.PodcastCompletedEvent; import de.luhmer.owncloudnewsreader.helper.AsyncTaskHelper; import de.luhmer.owncloudnewsreader.helper.FavIconHandler; import de.luhmer.owncloudnewsreader.helper.PostDelayHandler; import de.luhmer.owncloudnewsreader.helper.StopWatch; import de.luhmer.owncloudnewsreader.interfaces.IPlayPausePodcastClicked; import de.luhmer.owncloudnewsreader.model.CurrentRssViewDataHolder; public class NewsListRecyclerAdapter extends RecyclerView.Adapter { private static final String TAG = "NewsListRecyclerAdapter"; @SuppressWarnings("FieldCanBeLocal") private final int VIEW_ITEM = 1; // Item private final int VIEW_PROG = 0; // Progress private final FavIconHandler faviconHandler; private final RequestManager glide; private long idOfCurrentlyPlayedPodcast = -1; private List lazyList; private final DatabaseConnectionOrm dbConn; private final PostDelayHandler pDelayHandler; private final FragmentActivity activity; private int cachedPages = 1; private final IPlayPausePodcastClicked playPausePodcastClicked; private boolean loading = false; // The minimum amount of items to have below your current scroll position // before loading more. private final int visibleThreshold = 5; private final SharedPreferences mPrefs; private LazyLoadingLinearLayoutManager layoutManager = null; public NewsListRecyclerAdapter(FragmentActivity activity, RecyclerView recyclerView, IPlayPausePodcastClicked playPausePodcastClicked, PostDelayHandler postDelayHandler, SharedPreferences prefs) { this.activity = activity; this.playPausePodcastClicked = playPausePodcastClicked; this.mPrefs = prefs; pDelayHandler = postDelayHandler; dbConn = new DatabaseConnectionOrm(activity); faviconHandler = new FavIconHandler(activity); glide = Glide.with(activity); setHasStableIds(true); EventBus.getDefault().register(this); if (recyclerView.getLayoutManager() instanceof LazyLoadingLinearLayoutManager lm) { layoutManager = lm; recyclerView .addOnScrollListener(new RecyclerView.OnScrollListener() { @Override public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { super.onScrolled(recyclerView, dx, dy); int adapterItemCount = layoutManager.getItemCount(); int adapterTotalItemCount = layoutManager.getTotalItemCount(); int lastVisibleItem = layoutManager .findLastVisibleItemPosition(); if (!loading && adapterItemCount <= (lastVisibleItem + visibleThreshold) && adapterItemCount < adapterTotalItemCount && adapterItemCount > 0) { loading = true; Log.v(TAG, "start load more task..."); recyclerView.post(() -> { // End has been reached // Do something try { lazyList.add(null); notifyItemInserted(lazyList.size() - 1); AsyncTaskHelper.StartAsyncTask(new LoadMoreItemsAsyncTask()); } catch (UnsupportedOperationException ex) { Log.e(TAG, "error while lazy loading more items"); // this can happen in case a podcast download is running and // the user tries to open the Downloaded Podcast View } }); } } }); } } public int getTotalItemCount() { if (this.layoutManager != null) { return this.layoutManager.getTotalItemCount(); } return 0; } public int getCachedPages() { return cachedPages; } public void setTotalItemCount(int totalItemCount) { if (this.layoutManager != null) { this.layoutManager.setTotalItemCount(totalItemCount); } } public void setCachedPages(int cachedPages) { this.cachedPages = cachedPages; } /* // TODO right now this is not working anymore.. We need to use the MediaSession here.. // Not sure if this is the cleanest solution though.. @Subscribe public void onEvent(UpdatePodcastStatusEvent podcast) { if (podcast.isPlaying()) { if (podcast.getRssItemId() != idOfCurrentlyPlayedPodcast) { idOfCurrentlyPlayedPodcast = podcast.getRssItemId(); notifyDataSetChanged(); Log.v(TAG, "Updating Listview - Podcast started"); } } else if (idOfCurrentlyPlayedPodcast != -1) { idOfCurrentlyPlayedPodcast = -1; notifyDataSetChanged(); Log.v(TAG, "Updating Listview - Podcast paused"); } } */ @Subscribe public void onEvent(PodcastCompletedEvent podcastCompletedEvent) { idOfCurrentlyPlayedPodcast = -1; notifyDataSetChanged(); Log.v(TAG, "Updating Listview - Podcast completed"); } @NonNull @Override public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { if (viewType == VIEW_PROG) { LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); ProgressbarItemBinding binding = ProgressbarItemBinding.inflate(layoutInflater, parent, false); return new ProgressViewHolder(binding); } else { Context context = parent.getContext(); RssItemViewHolder viewHolder = null; switch (Integer.parseInt(mPrefs.getString(SettingsActivity.SP_FEED_LIST_LAYOUT, "0"))) { case 0: viewHolder = new RssItemThumbnailViewHolder( SubscriptionDetailListItemThumbnailBinding.inflate(LayoutInflater.from(context), parent, false), faviconHandler, glide, mPrefs ); break; case 1: viewHolder = new RssItemTextViewHolder( SubscriptionDetailListItemTextBinding.inflate(LayoutInflater.from(context), parent, false), faviconHandler, glide, mPrefs ); break; case 3: viewHolder = new RssItemFullTextViewHolder( SubscriptionDetailListItemTextBinding.inflate(LayoutInflater.from(context), parent, false), faviconHandler, glide, mPrefs ); break; case 2: viewHolder = new RssItemWebViewHolder( SubscriptionDetailListItemWebLayoutBinding.inflate(LayoutInflater.from(context), parent, false), faviconHandler, glide, mPrefs ); break; case 4: viewHolder = new RssItemCardViewHolder( SubscriptionDetailListItemCardViewBinding.inflate(LayoutInflater.from(context), parent, false), faviconHandler, glide, mPrefs ); break; case 5: viewHolder = new RssItemHeadlineViewHolder( SubscriptionDetailListItemHeadlineBinding.inflate(LayoutInflater.from(context), parent, false), faviconHandler, glide, mPrefs ); break; case 6: viewHolder = new RssItemHeadlineThumbnailViewHolder( SubscriptionDetailListItemHeadlineThumbnailBinding.inflate(LayoutInflater.from(context), parent, false), faviconHandler, glide, mPrefs ); break; default: Log.e(TAG, "Unknown layout.."); } RssItemViewHolder finalViewHolder = viewHolder; if(viewHolder.getStar() != null) { viewHolder.getStar().setOnClickListener(view1 -> toggleStarredStateOfItem(finalViewHolder)); } viewHolder.getPlayPausePodcastWrapper().setOnClickListener(v -> { if (finalViewHolder.isPlaying()) { playPausePodcastClicked.pausePodcast(); } else { playPausePodcastClicked.openPodcast(finalViewHolder.getRssItem()); } }); viewHolder.setClickListener((RecyclerItemClickListener) activity); /* // TODO implement option to delete cached podcasts (https://github.com/nextcloud/news-android/issues/742) holder.flPlayPausePodcastWrapper.setOnLongClickListener(v -> { // TODO check if cached.. new AlertDialog.Builder(activity) .setTitle("") .setMessage("") .setPositiveButton("", (dialog, which) -> {}) .setNegativeButton("", (dialog, which) -> {}) .create() .show(); return false; }); */ return viewHolder; } } @Override public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder viewHolder, int position) { if (viewHolder instanceof ProgressViewHolder) { ((ProgressViewHolder) viewHolder).getBinding().progressBar.setIndeterminate(true); } else { final RssItemViewHolder holder = (RssItemViewHolder) viewHolder; RssItem item = lazyList.get(position); holder.bind(item); holder.setStayUnread(NewsReaderListActivity.stayUnreadItems.contains(item.getId())); //Podcast stuff if (DatabaseConnectionOrm.ALLOWED_PODCASTS_TYPES.contains(item.getEnclosureMime())) { final boolean isPlaying = idOfCurrentlyPlayedPodcast == item.getId(); //Enable podcast buttons in view holder.getPlayPausePodcastWrapper().setVisibility(View.VISIBLE); holder.setPlaying(isPlaying); holder.setDownloadPodcastProgressbar(); } else { holder.getPlayPausePodcastWrapper().setVisibility(View.GONE); } } } @Override public void onViewDetachedFromWindow(@NonNull RecyclerView.ViewHolder holder) { if (holder instanceof RssItemViewHolder) { EventBus.getDefault().unregister(holder); } } @Override public void onViewAttachedToWindow(@NonNull RecyclerView.ViewHolder holder) { if (holder instanceof RssItemViewHolder) { EventBus.getDefault().register(holder); } } public void changeReadStateOfItem(RssItemViewHolder viewHolder, boolean isChecked) { RssItem rssItem = viewHolder.getRssItem(); if (rssItem.getRead_temp() != isChecked) { // Only perform database operations if really needed rssItem.setRead_temp(isChecked); dbConn.updateRssItem(rssItem); pDelayHandler.delayTimer(); viewHolder.setReadState(isChecked); //notifyItemChanged(viewHolder.getAdapterPosition()); NewsReaderListActivity.stayUnreadItems.add(rssItem.getId()); } } public void toggleReadStateOfItem(RssItemViewHolder viewHolder) { RssItem rssItem = viewHolder.getRssItem(); boolean isRead = !rssItem.getRead_temp(); changeReadStateOfItem(viewHolder, isRead); } public void toggleStarredStateOfItem(RssItemViewHolder viewHolder) { RssItem rssItem = viewHolder.getRssItem(); boolean isStarred = !rssItem.getStarred_temp(); rssItem.setStarred_temp(isStarred); if (isStarred) { changeReadStateOfItem(viewHolder, true); } dbConn.updateRssItem(rssItem); pDelayHandler.delayTimer(); viewHolder.setStarred(isStarred); } @Override public int getItemViewType(int position) { return lazyList.get(position) != null ? VIEW_ITEM : VIEW_PROG; } @Override public int getItemCount() { return lazyList != null ? lazyList.size() : 0; } @Override public long getItemId(int position) { if (lazyList != null) { RssItem item = lazyList.get(position); return item != null ? item.getId() : 0; } return 0; } private List refreshAdapterData() { List rssItems = new ArrayList<>(); DatabaseConnectionOrm dbConn = new DatabaseConnectionOrm(activity); for(int i = 0; i < cachedPages; i++) { rssItems.addAll(dbConn.getCurrentRssItemView(i)); } return rssItems; } public void updateAdapterData(List rssItems) { NewsReaderListActivity.stayUnreadItems.clear(); cachedPages = 1; //if (this.lazyList != null) { //this.lazyList.close(); //} //new ReloadAdapterAsyncTask().execute(); setTotalItemCount(((Long) dbConn.getCurrentRssItemViewCount()).intValue()); lazyList = rssItems; notifyDataSetChanged(); loading = false; } public interface IOnRefreshFinished { void OnRefreshFinished(); } public void refreshAdapterDataAsync(IOnRefreshFinished listener) { AsyncTaskHelper.StartAsyncTask(new RefreshDataAsyncTask(listener)); } private class RefreshDataAsyncTask extends AsyncTask> { private final IOnRefreshFinished listener; public RefreshDataAsyncTask(IOnRefreshFinished listener) { this.listener = listener; } @Override protected void onPreExecute() { loading = true; super.onPreExecute(); } @Override protected List doInBackground(Void... params) { StopWatch sw = new StopWatch(); sw.start(); List rssItems = refreshAdapterData(); sw.stop(); Log.v(TAG, "Time needed (refreshing adapter): " + sw); return rssItems; } @Override protected void onPostExecute(List rssItems) { lazyList = rssItems; notifyDataSetChanged(); loading = false; listener.OnRefreshFinished(); super.onPostExecute(rssItems); } } private class LoadMoreItemsAsyncTask extends AsyncTask> { @Override protected List doInBackground(Void... params) { StopWatch sw = new StopWatch(); sw.start(); DatabaseConnectionOrm dbConn = new DatabaseConnectionOrm(activity); List items = dbConn.getCurrentRssItemView(cachedPages++); sw.stop(); Log.v(TAG, "Time needed (loading more): " + sw); return items; } @Override protected void onPostExecute(List rssItems) { int prevSize = lazyList.size(); Log.d(TAG, "prevSize=" + prevSize); lazyList.remove(prevSize - 1); lazyList.addAll(rssItems); notifyItemRangeInserted(prevSize, rssItems.size()); loading = false; super.onPostExecute(rssItems); } } private class ReloadAdapterAsyncTask extends AsyncTask { @Override protected CurrentRssViewDataHolder doInBackground(Void... params) { StopWatch sw = new StopWatch(); sw.start(); List list = dbConn.getCurrentRssItemView(0); CurrentRssViewDataHolder holder = new CurrentRssViewDataHolder(); holder.maxCount = dbConn.getCurrentRssItemViewCount(); holder.rssItems = list; sw.stop(); Log.v(TAG, "Reloaded CurrentRssView - time taken: " + sw); return holder; } @Override protected void onPostExecute(CurrentRssViewDataHolder holder) { lazyList = holder.rssItems; setTotalItemCount(holder.maxCount.intValue()); cachedPages = 1; notifyDataSetChanged(); } } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/adapter/ProgressBarWebChromeClient.kt ================================================ package de.luhmer.owncloudnewsreader.adapter import android.util.Log import android.webkit.ConsoleMessage import android.webkit.WebChromeClient import android.webkit.WebView import android.widget.ProgressBar private const val COMPLETE = 100 /** * A very simple WebChromeClient which sets the status of a given * ProgressBar instance while loading. The ProgressBar instance will * only be visible during loading. */ class ProgressBarWebChromeClient( private val progressBar: ProgressBar, ) : WebChromeClient() { val tag = javaClass.canonicalName override fun onConsoleMessage(cm: ConsoleMessage): Boolean { Log.v(tag, cm.message() + " at " + cm.sourceId() + ":" + cm.lineNumber()) return true } override fun onProgressChanged( view: WebView, progress: Int, ) { progressBar.progress = progress if (progress < COMPLETE && progressBar.visibility == ProgressBar.GONE) { progressBar.visibility = ProgressBar.VISIBLE } else if (progress == COMPLETE) { progressBar.visibility = ProgressBar.GONE } } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/adapter/ProgressViewHolder.kt ================================================ package de.luhmer.owncloudnewsreader.adapter import androidx.recyclerview.widget.RecyclerView import de.luhmer.owncloudnewsreader.databinding.ProgressbarItemBinding class ProgressViewHolder( val binding: ProgressbarItemBinding, ) : RecyclerView.ViewHolder(binding.root) ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/adapter/RecyclerItemClickListener.kt ================================================ package de.luhmer.owncloudnewsreader.adapter interface RecyclerItemClickListener { fun onClick( vh: RssItemViewHolder<*>, position: Int, ) fun onLongClick( vh: RssItemViewHolder<*>, position: Int, ): Boolean } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/adapter/RssItemCardViewHolder.kt ================================================ package de.luhmer.owncloudnewsreader.adapter import android.content.SharedPreferences import android.view.View import android.widget.FrameLayout import android.widget.ImageView import android.widget.ProgressBar import android.widget.TextView import androidx.annotation.CallSuper import com.bumptech.glide.RequestManager import de.luhmer.owncloudnewsreader.database.model.RssItem import de.luhmer.owncloudnewsreader.databinding.SubscriptionDetailListItemCardViewBinding import de.luhmer.owncloudnewsreader.helper.FavIconHandler class RssItemCardViewHolder internal constructor( binding: SubscriptionDetailListItemCardViewBinding, faviconHandler: FavIconHandler, glide: RequestManager, sharedPreferences: SharedPreferences, ) : RssItemViewHolder( binding, faviconHandler, glide, sharedPreferences, ) { override fun getImageViewFavIcon(): ImageView = binding.imgViewFavIcon override fun getStar(): ImageView = binding.starImageview override fun getPlayPausePodcastButton(): ImageView = binding.podcastWrapper.btnPlayPausePodcast override fun getColorFeed(): View = binding.colorLineFeed override fun getTextViewTitle(): TextView = binding.tvSubscription override fun getTextViewSummary(): TextView = binding.summary override fun getTextViewBody(): TextView = binding.body override fun getTextViewItemDate(): TextView = binding.tvItemDate override fun getPlayPausePodcastWrapper(): FrameLayout = binding.podcastWrapper.flPlayPausePodcastWrapper override fun getPodcastDownloadProgress(): ProgressBar = binding.podcastWrapper.podcastDownloadProgress @CallSuper override fun bind(rssItem: RssItem) { super.bind(rssItem) } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/adapter/RssItemFullTextViewHolder.kt ================================================ package de.luhmer.owncloudnewsreader.adapter import android.content.SharedPreferences import com.bumptech.glide.RequestManager import de.luhmer.owncloudnewsreader.databinding.SubscriptionDetailListItemTextBinding import de.luhmer.owncloudnewsreader.helper.FavIconHandler class RssItemFullTextViewHolder internal constructor( binding: SubscriptionDetailListItemTextBinding, faviconHandler: FavIconHandler, glide: RequestManager, sharedPreferences: SharedPreferences, ) : RssItemTextViewHolder(binding, faviconHandler, glide, sharedPreferences) ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/adapter/RssItemHeadlineThumbnailViewHolder.kt ================================================ package de.luhmer.owncloudnewsreader.adapter import android.content.SharedPreferences import android.view.View import android.widget.FrameLayout import android.widget.ImageView import android.widget.ProgressBar import android.widget.TextView import androidx.annotation.CallSuper import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat import com.bumptech.glide.RequestManager import com.bumptech.glide.load.MultiTransformation import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.load.resource.bitmap.CenterCrop import com.bumptech.glide.load.resource.bitmap.RoundedCorners import de.luhmer.owncloudnewsreader.R import de.luhmer.owncloudnewsreader.database.DatabaseConnectionOrm import de.luhmer.owncloudnewsreader.database.model.RssItem import de.luhmer.owncloudnewsreader.databinding.SubscriptionDetailListItemHeadlineThumbnailBinding import de.luhmer.owncloudnewsreader.helper.FavIconHandler private const val RADIUS_IN_DP = 60 class RssItemHeadlineThumbnailViewHolder internal constructor( binding: SubscriptionDetailListItemHeadlineThumbnailBinding, faviconHandler: FavIconHandler, glide: RequestManager, sharedPreferences: SharedPreferences, ) : RssItemViewHolder( binding, faviconHandler, glide, sharedPreferences, ) { var feedIcon = VectorDrawableCompat.create(itemView.resources, R.drawable.feed_icon, null) override fun getImageViewFavIcon(): ImageView = binding.imgViewFavIcon override fun getStar(): ImageView = binding.starImageview override fun getPlayPausePodcastButton(): ImageView = binding.podcastWrapper.btnPlayPausePodcast override fun getColorFeed(): View? = null override fun getTextViewTitle(): TextView = binding.tvSubscription override fun getTextViewSummary(): TextView = binding.summary override fun getTextViewBody(): TextView? = null override fun getTextViewItemDate(): TextView? = null override fun getPlayPausePodcastWrapper(): FrameLayout = binding.podcastWrapper.flPlayPausePodcastWrapper override fun getPodcastDownloadProgress(): ProgressBar = binding.podcastWrapper.podcastDownloadProgress @CallSuper override fun bind(rssItem: RssItem) { super.bind(rssItem) binding.starImageview.visibility = if (rssItem.starred_temp) View.VISIBLE else View.GONE binding.imgViewThumbnail.colorFilter = null val mediaThumbnail = rssItem.mediaThumbnail if (!mediaThumbnail.isNullOrEmpty()) { binding.imgViewThumbnail.visibility = View.VISIBLE mGlide .load(mediaThumbnail) .diskCacheStrategy(DiskCacheStrategy.DATA) .placeholder(feedIcon) .error(feedIcon) .transform(MultiTransformation(CenterCrop(), RoundedCorners(RADIUS_IN_DP))) .into(binding.imgViewThumbnail) } else { // Show Podcast Icon if no thumbnail is available but it is a podcast // (otherwise the podcast button will go missing) if (DatabaseConnectionOrm.ALLOWED_PODCASTS_TYPES.contains(rssItem.enclosureMime)) { binding.imgViewThumbnail.visibility = View.VISIBLE // imgViewThumbnail.setColorFilter(Color.parseColor("#d8d8d8")); binding.imgViewThumbnail.setImageDrawable(feedIcon) } else { binding.imgViewThumbnail.setImageDrawable(null) binding.imgViewThumbnail.visibility = View.GONE } } } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/adapter/RssItemHeadlineViewHolder.kt ================================================ package de.luhmer.owncloudnewsreader.adapter import android.content.SharedPreferences import android.view.View import android.widget.FrameLayout import android.widget.ImageView import android.widget.ProgressBar import android.widget.TextView import androidx.annotation.CallSuper import androidx.viewbinding.ViewBinding import com.bumptech.glide.RequestManager import de.luhmer.owncloudnewsreader.database.model.RssItem import de.luhmer.owncloudnewsreader.databinding.SubscriptionDetailListItemHeadlineBinding import de.luhmer.owncloudnewsreader.helper.FavIconHandler class RssItemHeadlineViewHolder internal constructor( binding: ViewBinding, faviconHandler: FavIconHandler, glide: RequestManager, sharedPreferences: SharedPreferences, ) : RssItemViewHolder( binding, faviconHandler, glide, sharedPreferences, ) { override fun getImageViewFavIcon(): ImageView = binding.imgViewFavIcon override fun getStar(): ImageView = binding.starImageview override fun getPlayPausePodcastButton(): ImageView = binding.podcastWrapper.btnPlayPausePodcast override fun getColorFeed(): View = binding.colorLineFeed override fun getTextViewTitle(): TextView = binding.tvSubscription override fun getTextViewSummary(): TextView = binding.summary override fun getTextViewBody(): TextView? = null override fun getTextViewItemDate(): TextView = binding.tvItemDate override fun getPlayPausePodcastWrapper(): FrameLayout = binding.podcastWrapper.flPlayPausePodcastWrapper override fun getPodcastDownloadProgress(): ProgressBar = binding.podcastWrapper.podcastDownloadProgress @CallSuper override fun bind(rssItem: RssItem) { super.bind(rssItem) } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/adapter/RssItemTextViewHolder.kt ================================================ package de.luhmer.owncloudnewsreader.adapter import android.content.SharedPreferences import android.view.View import android.widget.FrameLayout import android.widget.ImageView import android.widget.ProgressBar import android.widget.TextView import androidx.annotation.CallSuper import androidx.viewbinding.ViewBinding import com.bumptech.glide.RequestManager import de.luhmer.owncloudnewsreader.database.model.RssItem import de.luhmer.owncloudnewsreader.databinding.SubscriptionDetailListItemTextBinding import de.luhmer.owncloudnewsreader.helper.FavIconHandler open class RssItemTextViewHolder internal constructor( binding: ViewBinding, faviconHandler: FavIconHandler, glide: RequestManager, sharedPreferences: SharedPreferences, ) : RssItemViewHolder( binding, faviconHandler, glide, sharedPreferences, ) { override fun getImageViewFavIcon(): ImageView = binding.imgViewFavIcon override fun getStar(): ImageView = binding.starImageview override fun getPlayPausePodcastButton(): ImageView = binding.podcastWrapper.btnPlayPausePodcast override fun getColorFeed(): View = binding.colorLineFeed override fun getTextViewTitle(): TextView = binding.tvSubscription override fun getTextViewSummary(): TextView = binding.summary override fun getTextViewBody(): TextView = binding.body override fun getTextViewItemDate(): TextView = binding.tvItemDate override fun getPlayPausePodcastWrapper(): FrameLayout = binding.podcastWrapper.flPlayPausePodcastWrapper override fun getPodcastDownloadProgress(): ProgressBar = binding.podcastWrapper.podcastDownloadProgress @CallSuper override fun bind(rssItem: RssItem) { super.bind(rssItem) } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/adapter/RssItemThumbnailViewHolder.kt ================================================ package de.luhmer.owncloudnewsreader.adapter import android.content.SharedPreferences import android.view.View import android.widget.FrameLayout import android.widget.ImageView import android.widget.ProgressBar import android.widget.TextView import androidx.annotation.CallSuper import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat import com.bumptech.glide.RequestManager import com.bumptech.glide.load.MultiTransformation import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.load.resource.bitmap.CenterCrop import com.bumptech.glide.load.resource.bitmap.RoundedCorners import de.luhmer.owncloudnewsreader.R import de.luhmer.owncloudnewsreader.database.DatabaseConnectionOrm import de.luhmer.owncloudnewsreader.database.model.RssItem import de.luhmer.owncloudnewsreader.databinding.SubscriptionDetailListItemThumbnailBinding import de.luhmer.owncloudnewsreader.helper.FavIconHandler private const val RADIUS_IN_DP = 60 class RssItemThumbnailViewHolder internal constructor( binding: SubscriptionDetailListItemThumbnailBinding, faviconHandler: FavIconHandler, glide: RequestManager, sharedPreferences: SharedPreferences, ) : RssItemViewHolder( binding, faviconHandler, glide, sharedPreferences, ) { var feedIcon = VectorDrawableCompat.create(itemView.resources, R.drawable.feed_icon, null) override fun getImageViewFavIcon(): ImageView = binding.imgViewFavIcon override fun getStar(): ImageView = binding.starImageview override fun getPlayPausePodcastButton(): ImageView = binding.podcastWrapper.btnPlayPausePodcast override fun getColorFeed(): View? = null override fun getTextViewTitle(): TextView = binding.tvSubscription override fun getTextViewSummary(): TextView = binding.summary override fun getTextViewBody(): TextView = binding.body override fun getTextViewItemDate(): TextView = binding.tvItemDate override fun getPlayPausePodcastWrapper(): FrameLayout = binding.podcastWrapper.flPlayPausePodcastWrapper override fun getPodcastDownloadProgress(): ProgressBar = binding.podcastWrapper.podcastDownloadProgress @CallSuper override fun bind(rssItem: RssItem) { super.bind(rssItem) binding.imgViewThumbnail.colorFilter = null val mediaThumbnail = rssItem.mediaThumbnail if (!mediaThumbnail.isNullOrEmpty()) { binding.imgViewThumbnail.visibility = View.VISIBLE mGlide .load(mediaThumbnail) .diskCacheStrategy(DiskCacheStrategy.DATA) .placeholder(feedIcon) .error(feedIcon) .transform(MultiTransformation(CenterCrop(), RoundedCorners(RADIUS_IN_DP))) .into(binding.imgViewThumbnail) } else { // Show Podcast Icon if no thumbnail is available but it is a podcast // (otherwise the podcast button will go missing) if (DatabaseConnectionOrm.ALLOWED_PODCASTS_TYPES.contains(rssItem.enclosureMime)) { binding.imgViewThumbnail.visibility = View.VISIBLE // imgViewThumbnail.setColorFilter(Color.parseColor("#d8d8d8")); binding.imgViewThumbnail.setImageDrawable(feedIcon) } else { binding.imgViewThumbnail.visibility = View.GONE } } } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/adapter/RssItemViewHolder.java ================================================ package de.luhmer.owncloudnewsreader.adapter; import android.content.Context; import android.content.SharedPreferences; import android.content.res.TypedArray; import android.graphics.Color; import android.graphics.Typeface; import android.text.Html; import android.text.Spannable; import android.text.SpannableString; import android.text.style.ForegroundColorSpan; import android.util.Log; import android.util.SparseIntArray; import android.util.TypedValue; import android.view.View; import android.widget.FrameLayout; import android.widget.ImageView; import android.widget.ProgressBar; import android.widget.TextView; import androidx.annotation.CallSuper; import androidx.annotation.NonNull; import androidx.core.content.ContextCompat; import androidx.recyclerview.widget.RecyclerView; import androidx.viewbinding.ViewBinding; import com.bumptech.glide.Glide; import com.bumptech.glide.RequestManager; import org.greenrobot.eventbus.Subscribe; import java.util.regex.Pattern; import de.luhmer.owncloudnewsreader.R; import de.luhmer.owncloudnewsreader.SettingsActivity; import de.luhmer.owncloudnewsreader.database.model.RssItem; import de.luhmer.owncloudnewsreader.helper.ColorHelper; import de.luhmer.owncloudnewsreader.helper.DateTimeFormatter; import de.luhmer.owncloudnewsreader.helper.FavIconHandler; import de.luhmer.owncloudnewsreader.services.PodcastDownloadService; public abstract class RssItemViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener, View.OnLongClickListener { private final static String TAG = RssItemViewHolder.class.getCanonicalName(); protected T binding; private static final SparseIntArray downloadProgressList = new SparseIntArray(); private final FavIconHandler favIconHandler; protected final SharedPreferences mPrefs; @SuppressWarnings("FieldCanBeLocal") private final int LengthBody = 400; private final ForegroundColorSpan bodyForegroundColor; private RecyclerItemClickListener clickListener; private RssItem rssItem; private boolean stayUnread = false; private boolean playing; private int starColor; private int inactiveStarColor; protected RequestManager mGlide; private final SparseIntArray initalFontSizes = new SparseIntArray(); RssItemViewHolder( @NonNull ViewBinding binding, FavIconHandler favIconHandler, RequestManager glide, SharedPreferences sharedPreferences ) { super(binding.getRoot()); this.binding = (T) binding; this.mPrefs = sharedPreferences; Context context = itemView.getContext(); bodyForegroundColor = new ForegroundColorSpan(ContextCompat.getColor(context, android.R.color.secondary_text_dark)); mGlide = glide; this.favIconHandler = favIconHandler; itemView.setOnClickListener(this); itemView.setOnLongClickListener(this); extractInitialFontSize(getTextViewBody()); extractInitialFontSize(getTextViewTitle()); extractInitialFontSize(getTextViewSummary()); extractInitialFontSize(getTextViewBody()); extractInitialFontSize(getTextViewItemDate()); } private void extractInitialFontSize(TextView tv) { if (tv != null) { initalFontSizes.append(tv.getId(), Math.round(tv.getTextSize())); } } /** * Apply scaling factor to TextView font size, based on app font-size preference. * * @param tv TextView object to be scaled * @param initialTvSize app layout definition default size of TextView element * @param halfScale if set to true, will only apply half of the scaling factor */ private void scaleTextSize(TextView tv, int initialTvSize, boolean halfScale, SharedPreferences mPrefs) { float scalingFactor = Float.parseFloat(mPrefs.getString(SettingsActivity.SP_FONT_SIZE, "1.0")); if (halfScale) { scalingFactor = scalingFactor + (1 - scalingFactor) / 2; } if (initialTvSize < 0) { initialTvSize = Math.round(tv.getTextSize()); } // float sp = initialSize / tv.getContext().getResources().getDisplayMetrics().scaledDensity; // transform scaled pixels, device pixels int newSize = Math.round(initialTvSize * scalingFactor); // String name = tv.getResources().getResourceEntryName(tv.getId()); // Log.d(TAG, name + " scale textsize from " + initialTvSize + " to " + newSize); tv.setTextSize(TypedValue.COMPLEX_UNIT_PX, newSize); } /** * Return the number of rss item body text lines, depending on the currently selected font size/scale; * only meant to be used with thumbnail feed view. * * @return number of lines of rss item body text lines to be used in thumbnail feed view */ private static int scaleTextLines(SharedPreferences prefs) { float scalingFactor = Float.parseFloat(prefs.getString(SettingsActivity.SP_FONT_SIZE, "1.0")); /* The following formula computes the number of text lines for Simple item view; it simply boils * down to a linear conversion from the font scaling factor from 0.8 -> 6 lines to 1.6 -> 3 lines */ return Math.round((scalingFactor * -5) + 10); } abstract protected ImageView getImageViewFavIcon(); abstract protected ImageView getStar(); abstract protected ImageView getPlayPausePodcastButton(); abstract protected View getColorFeed(); abstract protected TextView getTextViewTitle(); abstract protected TextView getTextViewSummary(); abstract protected TextView getTextViewBody(); abstract protected TextView getTextViewItemDate(); abstract protected FrameLayout getPlayPausePodcastWrapper(); abstract protected ProgressBar getPodcastDownloadProgress(); @CallSuper public void bind(@NonNull RssItem rssItem) { this.rssItem = rssItem; if(getStar() != null) { int[] attribute = new int[]{R.attr.starredColor, R.attr.unstarredColor}; TypedArray array = getStar().getContext().getTheme().obtainStyledAttributes(attribute); starColor = array.getColor(0, Color.TRANSPARENT); inactiveStarColor = array.getColor(1, Color.LTGRAY); array.recycle(); } TextView textViewBody = getTextViewBody(); String title = null; String favIconUrl = null; if (rssItem.getFeed() != null) { title = rssItem.getFeed().getFeedTitle(); favIconUrl = rssItem.getFeed().getFaviconUrl(); } else { Log.v(TAG, "Feed not found!!!"); } setReadState(rssItem.getRead_temp()); setStarred(rssItem.getStarred_temp()); setFeedColor(ColorHelper.getFeedColor(itemView.getContext(), rssItem.getFeed())); TextView textViewSummary = getTextViewSummary(); if (textViewSummary != null) { try { int textSizeSummary = initalFontSizes.get(getTextViewSummary().getId()); textViewSummary.setText(Html.fromHtml(rssItem.getTitle())); scaleTextSize(textViewSummary, textSizeSummary, false, mPrefs); } catch (Exception e) { e.printStackTrace(); } } TextView textViewTitle = getTextViewTitle(); TextView textViewItemDate = getTextViewItemDate(); int sizeOfFavIcon = 32; int marginFavIcon = 0; if (textViewTitle != null && title != null) { if(textViewItemDate != null) { // we have seperate views for title and date textViewTitle.setText(Html.fromHtml(title)); } else { // append date to title textViewTitle.setText(String.format("%s · %s", Html.fromHtml(title), DateTimeFormatter.getTimeAgo(rssItem.getPubDate()))); } int textSizeTitle = initalFontSizes.get(textViewTitle.getId()); scaleTextSize(textViewTitle, textSizeTitle, true, mPrefs); sizeOfFavIcon = textSizeTitle; marginFavIcon = Math.round(textViewTitle.getTextSize()); } if (textViewItemDate != null) { int textSizeItemDate = initalFontSizes.get(getTextViewItemDate().getId()); //textViewItemDate.setText(DateUtils.getRelativeTimeSpanString(rssItem.getPubDate().getTime())); textViewItemDate.setText(DateTimeFormatter.getTimeAgo(rssItem.getPubDate())); scaleTextSize(textViewItemDate, textSizeItemDate, true, mPrefs); sizeOfFavIcon = textSizeItemDate; marginFavIcon = Math.round(textViewItemDate.getTextSize()); } ImageView imgViewFavIcon = getImageViewFavIcon(); if (imgViewFavIcon != null) { favIconHandler.loadFavIconForFeed(favIconUrl, imgViewFavIcon, Math.round((marginFavIcon - sizeOfFavIcon) / 2f)); } if (textViewBody != null) { int textSizeBody = initalFontSizes.get(textViewBody.getId()); String body = rssItem.getMediaDescription(); if (body == null || body.isEmpty()) { body = rssItem.getBody(); } boolean limitLength = true; // Strip html from String if (this instanceof RssItemFullTextViewHolder) { textViewBody.setMaxLines(200); limitLength = false; } else if (this instanceof RssItemTextViewHolder) { textViewBody.setMaxLines(scaleTextLines(mPrefs)); limitLength = false; } // long startTime = System.nanoTime(); body = getBodyText(body, limitLength); // This is a bottleneck // long difference = System.nanoTime() - startTime; // Log.d(TAG, "Duration: " + difference / 1000 / 1000 + "ms"); textViewBody.setText(Html.fromHtml(body)); scaleTextSize(textViewBody, textSizeBody, false, mPrefs); } } @Override public void onClick(View v) { clickListener.onClick(this, getLayoutPosition()); } public void setClickListener(RecyclerItemClickListener clickListener) { this.clickListener = clickListener; } @Override public boolean onLongClick(View v) { return clickListener.onLongClick(this, getLayoutPosition()); } public void setStarred(boolean isStarred) { int color = isStarred ? starColor : inactiveStarColor; int contentDescriptionId = isStarred ? R.string.content_desc_remove_from_favorites : R.string.content_desc_add_to_favorites; ImageView star = getStar(); if(star != null) { star.setColorFilter(color); star.setContentDescription(star.getContext().getString(contentDescriptionId)); } } public RssItem getRssItem() { return rssItem; } @SuppressWarnings("BooleanMethodIsAlwaysInverted") public boolean shouldStayUnread() { return stayUnread; } public void setStayUnread(boolean shouldStayUnread) { this.stayUnread = shouldStayUnread; } private String getBodyText(String body, boolean limitLength) { if (body.startsWith("", ""); } body = body.replaceAll("]*>", ""); body = body.replaceAll("]*>", ""); SpannableString bodyStringSpannable = new SpannableString(Html.fromHtml(body)); bodyStringSpannable.setSpan(bodyForegroundColor, 0, bodyStringSpannable.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE); String bodyString = bodyStringSpannable.toString().trim(); if (limitLength && bodyString.length() > LengthBody) { bodyString = bodyString.substring(0, LengthBody) + "..."; } return bodyString; } private void setFeedColor(int color) { if (getColorFeed() != null) { getColorFeed().setBackgroundColor(color); } } public void setReadState(boolean isRead) { TextView textViewSummary = getTextViewSummary(); if (textViewSummary != null) { float alpha = 1f; if (isRead) { textViewSummary.setTypeface(Typeface.DEFAULT); alpha = 0.7f; } else { textViewSummary.setTypeface(Typeface.DEFAULT_BOLD); } ((View) textViewSummary.getParent()).setAlpha(alpha); } } public boolean isPlaying() { return playing; } public void setPlaying(boolean playing) { this.playing = playing; int imageId = playing ? R.drawable.ic_action_pause_24 : R.drawable.ic_baseline_play_arrow_24; int contentDescriptionId = playing ? R.string.content_desc_pause : R.string.content_desc_play; ImageView playPause = getPlayPausePodcastButton(); String contentDescription = playPause.getContext().getString(contentDescriptionId); playPause.setContentDescription(contentDescription); playPause.setImageResource(imageId); } public void setDownloadPodcastProgressbar() { float progress; if (PodcastDownloadService.PodcastAlreadyCached(itemView.getContext(), rssItem.getFingerprint(), rssItem.getEnclosureLink())) { progress = 100; } else { progress = downloadProgressList.get(rssItem.getId().intValue(), 0); } getPodcastDownloadProgress().setProgress((int) progress); Log.v(TAG, "Progress of download2: " + progress); } @Subscribe public void onEvent(PodcastDownloadService.DownloadProgressUpdate downloadProgress) { downloadProgressList.put((int) downloadProgress.podcast.itemId, downloadProgress.podcast.downloadProgress); if (rssItem.getId().equals(downloadProgress.podcast.itemId)) { getPodcastDownloadProgress().setProgress(downloadProgress.podcast.downloadProgress); Log.v(TAG, "Progress of download1: " + downloadProgress.podcast.downloadProgress); } } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/adapter/RssItemWebViewHolder.kt ================================================ package de.luhmer.owncloudnewsreader.adapter import android.content.SharedPreferences import android.widget.FrameLayout import android.widget.ImageView import android.widget.ProgressBar import android.widget.TextView import androidx.annotation.CallSuper import androidx.viewbinding.ViewBinding import com.bumptech.glide.RequestManager import de.luhmer.owncloudnewsreader.async_tasks.RssItemToHtmlTask import de.luhmer.owncloudnewsreader.database.model.RssItem import de.luhmer.owncloudnewsreader.databinding.SubscriptionDetailListItemWebLayoutBinding import de.luhmer.owncloudnewsreader.helper.FavIconHandler class RssItemWebViewHolder( binding: ViewBinding, faviconHandler: FavIconHandler, glide: RequestManager, sharedPreferences: SharedPreferences, ) : RssItemViewHolder( binding, faviconHandler, glide, sharedPreferences, ) { override fun getImageViewFavIcon(): ImageView = binding.layoutThumbnail.imgViewFavIcon override fun getStar(): ImageView = binding.layoutThumbnail.starImageview override fun getPlayPausePodcastButton(): ImageView = binding.layoutThumbnail.podcastWrapper.btnPlayPausePodcast override fun getColorFeed(): ImageView? = null override fun getTextViewTitle(): TextView = binding.layoutThumbnail.tvSubscription override fun getTextViewSummary(): TextView = binding.layoutThumbnail.summary override fun getTextViewBody(): TextView = binding.layoutThumbnail.body override fun getTextViewItemDate(): TextView = binding.layoutThumbnail.tvItemDate override fun getPlayPausePodcastWrapper(): FrameLayout = binding.layoutThumbnail.podcastWrapper.flPlayPausePodcastWrapper override fun getPodcastDownloadProgress(): ProgressBar = binding.layoutThumbnail.podcastWrapper.podcastDownloadProgress @CallSuper override fun bind(rssItem: RssItem) { super.bind(rssItem) val htmlPage: String = RssItemToHtmlTask.getHtmlPage( mGlide, rssItem, false, mPrefs, itemView.context, ) binding.webViewBody.loadDataWithBaseURL( "file:///android_asset/", htmlPage, "text/html", "UTF-8", "", ) } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/async_tasks/DownloadChangelogTask.java ================================================ package de.luhmer.owncloudnewsreader.async_tasks; import android.content.Context; import android.database.DataSetObserver; import android.os.AsyncTask; import android.util.Log; import java.io.BufferedInputStream; import java.io.BufferedReader; import java.io.BufferedWriter; import java.io.File; import java.io.FileWriter; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.net.HttpURLConnection; import java.net.URL; import java.util.ArrayList; import de.luhmer.owncloudnewsreader.view.ChangeLogFileListView; /** * Downloads the owncloud news reader changelog from github, transforms it into xml * and saves it as tempfile. This xml tempfile can be used for changeloglib library. */ public class DownloadChangelogTask extends AsyncTask { private static final String TAG = "DownloadChangelogTask"; private static final String CHANGELOG_URL = "https://raw.githubusercontent.com/nextcloud/news-android/master/CHANGELOG.md"; private static final String FILE_NAME = "changelog.xml"; private final Context mContext; private final ChangeLogFileListView mChangelogView; private final Listener mListener; private IOException exception; /** * @param context * @param changelogView this list view will be automatically filled when * downloading and saving has finished * @param listener called when task has finished or errors have been raised */ public DownloadChangelogTask(Context context, ChangeLogFileListView changelogView, Listener listener) { mContext = context; mChangelogView = changelogView; mListener = listener; } @Override protected String doInBackground(Void... params) { String path = null; try { ArrayList changelogArr = downloadChangelog(); String xml = convertToXML(changelogArr); path = saveToTempFile(xml, FILE_NAME); } catch (IOException e) { exception = e; } return path; } @Override protected void onPostExecute(String filePath) { if (exception != null) { mListener.onError(exception); return; } mChangelogView.loadFile(filePath); mChangelogView.getAdapter().registerDataSetObserver(new DataSetObserver() { @Override public void onChanged() { mListener.onSuccess(); } }); } private ArrayList downloadChangelog() throws IOException { ArrayList changelogArr = new ArrayList<>(); URL url = new URL(CHANGELOG_URL); HttpURLConnection urlConnection = (HttpURLConnection) url.openConnection(); try { InputStream isTemp = new BufferedInputStream(urlConnection.getInputStream()); BufferedReader in = new BufferedReader(new InputStreamReader(isTemp)); String inputLine; String prevLine = ""; int lineNumber = 0; while ((inputLine = in.readLine()) != null) { lineNumber++; if(inputLine.trim().isEmpty() && prevLine.startsWith("---")) { Log.e(TAG, "skip empty line after version code in changelog (please fix changelog - remove all empty lines after the version code line - Line: " + lineNumber + ")"); } else { changelogArr.add(inputLine.replace("<", "[").replace(">", "]")); } prevLine = inputLine; } in.close(); } finally { urlConnection.disconnect(); } return changelogArr; } private String convertToXML(ArrayList changelogArr) { changelogArr.add(""); // create xml nodes StringBuilder builder = new StringBuilder(); builder.append("\n"); builder.append(""); boolean isFirst = true; String previousLine = ""; for (String line : changelogArr) { if (line.contains("---------------------")) { if (!isFirst) { builder.append(""); } builder.append(""); isFirst = false; } else if (line.startsWith("- ")) { // change entry builder.append(""); builder.append(line.substring(2).trim()); builder.append(""); } previousLine = line; } builder.append(""); builder.append(""); return builder.toString(); } private String saveToTempFile(String content, @SuppressWarnings("SameParameterValue") String fileName) throws IOException { File file = File.createTempFile(fileName, null, mContext.getCacheDir()); try (BufferedWriter out = new BufferedWriter(new FileWriter(file))) { out.write(content); } return "file://" + file.getAbsolutePath(); } public interface Listener { /** * Called when ChangeLogFileListView instance has successfully been updated. */ void onSuccess(); /** * Called when some error has been thrown during download, parsing or saving. */ void onError(IOException e); } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/async_tasks/DownloadImageHandler.java ================================================ /* * Android ownCloud News * * @author David Luhmer * @copyright 2013 David Luhmer david-dev@live.de * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE * License as published by the Free Software Foundation; either * version 3 of the License, or any later version. * * This library 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 AFFERO GENERAL PUBLIC LICENSE for more details. * * You should have received a copy of the GNU Affero General Public * License along with this library. If not, see . * */ package de.luhmer.owncloudnewsreader.async_tasks; import android.graphics.Bitmap; import android.util.Log; import com.bumptech.glide.RequestManager; import com.bumptech.glide.load.engine.DiskCacheStrategy; import java.net.URL; import java.util.concurrent.ExecutionException; import de.luhmer.owncloudnewsreader.helper.ImageDownloadFinished; public class DownloadImageHandler { private static final String TAG = DownloadImageHandler.class.getCanonicalName(); private URL mImageUrl; private ImageDownloadFinished imageDownloadFinished; public DownloadImageHandler(String imageUrl) { try { this.mImageUrl = new URL(imageUrl); } catch(Exception ex) { Log.d(TAG, "Invalid URL: " + imageUrl, ex); } } public void preloadSync(RequestManager glide) { try { Bitmap bm = glide .asBitmap() .load(mImageUrl.toString()) .diskCacheStrategy(DiskCacheStrategy.DATA) .submit() .get(); NotifyDownloadFinished(bm); } catch (ExecutionException | InterruptedException e) { e.printStackTrace(); } NotifyDownloadFinished(null); } private void NotifyDownloadFinished(Bitmap bitmap) { if(imageDownloadFinished != null) { imageDownloadFinished.DownloadFinished(bitmap); } } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/async_tasks/RssItemToHtmlTask.java ================================================ package de.luhmer.owncloudnewsreader.async_tasks; import static de.luhmer.owncloudnewsreader.NewsDetailActivity.INCOGNITO_MODE_ENABLED; import static de.luhmer.owncloudnewsreader.helper.ThemeChooser.THEME; import android.content.Context; import android.content.SharedPreferences; import android.os.AsyncTask; import android.text.Html; import android.text.format.DateUtils; import android.util.Log; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import com.bumptech.glide.Glide; import com.bumptech.glide.RequestManager; import com.bumptech.glide.load.DataSource; import com.bumptech.glide.load.engine.DiskCacheStrategy; import com.bumptech.glide.load.engine.GlideException; import com.bumptech.glide.request.RequestListener; import com.bumptech.glide.request.target.Target; import java.io.File; import java.util.ArrayList; import java.util.Date; import java.util.List; import java.util.UUID; import java.util.regex.Matcher; import java.util.regex.Pattern; import de.luhmer.owncloudnewsreader.R; import de.luhmer.owncloudnewsreader.SettingsActivity; import de.luhmer.owncloudnewsreader.database.model.Feed; import de.luhmer.owncloudnewsreader.database.model.RssItem; import de.luhmer.owncloudnewsreader.helper.ImageHandler; import de.luhmer.owncloudnewsreader.helper.ThemeChooser; public class RssItemToHtmlTask extends AsyncTask { private static final double BODY_FONT_SIZE = 1.1; private static final double HEADING_FONT_SIZE = 1.1; private static final double SUBSCRIPT_FONT_SIZE = 0.7; private static final String TAG = RssItemToHtmlTask.class.getCanonicalName(); private static final Pattern PATTERN_PRELOAD_VIDEOS_REMOVE = Pattern.compile("(]*)(preload=\".*?\")(.*?>)"); private static final Pattern PATTERN_PRELOAD_VIDEOS_INSERT = Pattern.compile("(]*)(.*?)(.*?>)"); private static final Pattern PATTERN_AUTOPLAY_VIDEOS_1 = Pattern.compile("(]*)(autoplay=\".*?\")(.*?>)"); private static final Pattern PATTERN_AUTOPLAY_VIDEOS_2 = Pattern.compile("(]*)(\\sautoplay)(.*?>)"); // private static final Pattern PATTERN_AUTOPLAY_REGEX_CB = Pattern.compile("(.*?)^(Unser Feedsponsor:\\s*<\\/p>

\\s*.*?\\s*<\\/p>)(.*)", Pattern.MULTILINE); private static final Pattern PATTERN_PRE_BLOCK = Pattern.compile("

(.*?)
", Pattern.MULTILINE | Pattern.DOTALL); private final RssItem mRssItem; private final Listener mListener; private final SharedPreferences mPrefs; private final boolean isRightToLeft; private final RequestManager mGlide; public interface Listener { /** * The RSS item has successfully been parsed. * @param htmlPage RSS item as HTML string */ void onRssItemParsed(String htmlPage); } public RssItemToHtmlTask(Context context, RssItem rssItem, Listener listener, SharedPreferences prefs) { this.mRssItem = rssItem; this.mListener = listener; this.mPrefs = prefs; this.mGlide = Glide.with(context); this.isRightToLeft = context.getResources().getBoolean(R.bool.is_right_to_left); } @Override protected String doInBackground(Void... params) { return getHtmlPage(this.mGlide, mRssItem, true, mPrefs, isRightToLeft); } @Override protected void onPostExecute(String htmlPage) { mListener.onRssItemParsed(htmlPage); super.onPostExecute(htmlPage); } public static String getHtmlPage(RequestManager glide, RssItem rssItem, boolean showHeader, SharedPreferences mPrefs, Context context) { return getHtmlPage(glide, rssItem, showHeader, mPrefs, context.getResources().getBoolean(R.bool.is_right_to_left)); } /** * @param rssItem item to parse * @param showHeader true if a header with item title, feed title, etc. should be included * @return given RSS item as full HTML page */ public static String getHtmlPage(RequestManager glide, RssItem rssItem, boolean showHeader, SharedPreferences mPrefs, boolean isRightToLeft) { boolean incognitoMode = mPrefs.getBoolean(INCOGNITO_MODE_ENABLED, false); String favIconUrl = null; Feed feed = rssItem.getFeed(); //int feedColor = colors[0]; if (feed != null) { favIconUrl = feed.getFaviconUrl(); } if (favIconUrl != null) { favIconUrl = getCachedFavIcon(glide, favIconUrl); } else { favIconUrl = "file:///android_res/drawable/default_feed_icon_light.png"; } String body_id = getSelectedTheme(); Log.v(TAG, "Selected Theme: " + body_id); String rtlClass = isRightToLeft ? "rtl" : ""; String rtlDir = isRightToLeft ? "rtl" : "ltr"; StringBuilder builder = new StringBuilder(); boolean zoomEnabled = mPrefs.getBoolean(SettingsActivity.CB_DETAILED_VIEW_ZOOM, true); String zoomRestrictions = zoomEnabled ? "" : ", maximum-scale=1, minimum-scale=1, user-scalable=0"; builder.append(String.format("", rtlDir, zoomRestrictions)); builder.append(""); // font size scaling // builder.append(""); builder.append(String.format("", body_id, rtlClass)); if (showHeader) { builder.append( buildHeader(rssItem, body_id, favIconUrl) ); } String description = rssItem.getBody(); if (!description.isEmpty()) { description = removeLineBreaksFromHtml(description); } else if(rssItem.getMediaDescription() != null) { // in case the rss body is empty, fallback to the media description (e.g. youtube / ted talks) description = rssItem.getMediaDescription(); } if(!incognitoMode) { // If incognito mode is disabled, try getting images from cache description = getDescriptionWithCachedImages(glide, rssItem.getLink(), description).trim(); } else { // When incognito is on, we need to provide some error handling //description = description.replaceAll(""); builder.append(description); builder.append(""); builder.append(""); return builder.toString().replaceAll("\"//", "\"https://"); } @VisibleForTesting() public static String removeLineBreaksFromHtml(String description) { // UUID is used so there is only a very small chance that the placeholder text actually exists in the article var uuid = UUID.randomUUID().toString(); // pre-blocks shouldn't have their formatting changed var matcher = PATTERN_PRE_BLOCK.matcher(description); var preBlocks = new ArrayList(); while (matcher.find()) { var group = matcher.group(); description = description.replaceFirst(Pattern.quote(group), "PRE_BLOCK_THAT_WILL_BE_REPLACED_" + uuid + "_" + preBlocks.size()); preBlocks.add(group); } description = description .replaceAll("\n\n", "THIS_WILL_BE_BECOME_ONE_NEWLINE_LATER_" + uuid) // This is required because otherwise `\n\n` would become 2 spaces .replaceAll(">\n", ">") // The first character after a tag shouldn't have a space .replaceAll("\n", " ") .replaceAll("THIS_WILL_BE_BECOME_ONE_NEWLINE_LATER_" + uuid, "\n"); for (int i = 0; i < preBlocks.size(); i++) { description = description.replaceFirst( "PRE_BLOCK_THAT_WILL_BE_REPLACED_" + uuid + "_" + i, Matcher.quoteReplacement(preBlocks.get(i)) ); } return description; } private static String getSelectedTheme() { THEME selectedTheme = ThemeChooser.getSelectedTheme(); switch (selectedTheme) { case LIGHT: return "lightTheme"; case DARK: return "darkTheme"; case OLED: return "darkThemeOLED"; default: return null; } } private static String buildHeader(RssItem rssItem, String body_id, String favIconUrl) { StringBuilder builder = new StringBuilder(); builder.append("
"); builder.append(String.format("
", body_id)); String itemTitle = Html.escapeHtml(rssItem.getTitle()); String linkToFeed = Html.escapeHtml(rssItem.getLink()); builder.append(String.format("%s", linkToFeed, itemTitle)); builder.append("
"); String authorLine = Html.escapeHtml(rssItem.getAuthor()); if ("".equals(authorLine)) { // If author is empty, use name of feed instead Feed feed = rssItem.getFeed(); if (feed != null) { authorLine = feed.getFeedTitle(); } } builder.append("
"); builder.append("
"); builder.append(String.format("", favIconUrl)); builder.append(String.format("%s", authorLine.trim())); builder.append("
"); Date date = rssItem.getPubDate(); if (date != null) { String dateString = (String) DateUtils.getRelativeTimeSpanString(date.getTime()); builder.append("
"); builder.append(dateString); builder.append("
"); } builder.append("
"); builder.append("
"); return builder.toString(); } private static String getCachedFavIcon(RequestManager glide, String favIconUrl) { File file = null; try { file = glide .asFile() .diskCacheStrategy(DiskCacheStrategy.DATA) .onlyRetrieveFromCache(true) .load(favIconUrl) .submit() .get(); } catch (Exception e) { Log.w(TAG, "favicon is not cached"); } if (file != null) { Log.d(TAG, "favicon is cached!"); return "file://" + file.getAbsolutePath(); } else { return favIconUrl; // Return favicon url if not cached } } /* private static String getFontSizeScalingCss(SharedPreferences mPrefs) { // font size scaling double scalingFactor = Float.parseFloat(mPrefs.getString(SettingsActivity.SP_FONT_SIZE, "1.0")); DecimalFormat fontFormat = new DecimalFormat("#.##", new DecimalFormatSymbols(Locale.US)); return String.format( Locale.US, """ :root {\s --fontsize-body: %sem;\s --fontsize-header: %sem;\s --fontsize-subscript: %sem;\s } """, fontFormat.format(scalingFactor * BODY_FONT_SIZE), fontFormat.format(scalingFactor * HEADING_FONT_SIZE), fontFormat.format(scalingFactor * SUBSCRIPT_FONT_SIZE) ); } */ private static String getDescriptionWithCachedImages(RequestManager glide, String articleUrl, String text) { List links = ImageHandler.getImageLinksFromText(articleUrl, text); for(String link : links) { link = link.trim(); try { File file = null; try { file = glide .asFile() .diskCacheStrategy(DiskCacheStrategy.DATA) .onlyRetrieveFromCache(true) // .listener(rl) .load(link) .submit() .get(); Log.d(TAG, "image is cached"); } catch (Exception e) { Log.w(TAG, "image is not cached"); } if(file != null) { text = text.replace(link, "file://" + file.getAbsolutePath()); } } catch(Exception ex) { ex.printStackTrace(); } } return text; } private static final RequestListener rl = new RequestListener<>() { @Override public boolean onLoadFailed(@Nullable GlideException e, Object model, Target target, boolean isFirstResource) { // Log the GlideException here (locally or with a remote logging framework): Log.e(TAG, "Load failed", e); // You can also log the individual causes: for (Throwable t : e.getRootCauses()) { Log.e(TAG, "Caused by", t); } // Or, to log all root causes locally, you can use the built in helper method: e.logRootCauses(TAG); return false; // Allow calling onLoadFailed on the Target. } @Override public boolean onResourceReady(File resource, Object model, Target target, DataSource dataSource, boolean isFirstResource) { // Log successes here or use DataSource to keep track of cache hits and misses. return false; // Allow calling onResourceReady on the Target. } }; private static String replacePatternInText(Pattern pattern, String text, String replacement) { Matcher m = pattern.matcher(text); return m.replaceAll(replacement); } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/authentication/AccountGeneral.java ================================================ package de.luhmer.owncloudnewsreader.authentication; import android.content.Context; import de.luhmer.owncloudnewsreader.R; public class AccountGeneral { /** * Account name */ public static final String ACCOUNT_NAME = "ownCloud News"; /** * Auth token types */ public static final String AUTHTOKEN_TYPE_READ_ONLY = "Read only"; public static final String AUTHTOKEN_TYPE_READ_ONLY_LABEL = "Read only access to an Nextcloud News account"; public static final String AUTHTOKEN_TYPE_FULL_ACCESS = "Full access"; public static final String AUTHTOKEN_TYPE_FULL_ACCESS_LABEL = "Full access to an Nextcloud News account"; /** * Account type id */ public static String getAccountType(Context context) { return context.getString(R.string.account_type); } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/authentication/OwnCloudAccountAuthenticator.java ================================================ package de.luhmer.owncloudnewsreader.authentication; import android.accounts.AbstractAccountAuthenticator; import android.accounts.Account; import android.accounts.AccountAuthenticatorResponse; import android.accounts.AccountManager; import android.accounts.NetworkErrorException; import android.content.Context; import android.content.Intent; import android.os.Bundle; import android.text.TextUtils; import android.util.Log; import de.luhmer.owncloudnewsreader.LoginDialogActivity; import static android.accounts.AccountManager.KEY_BOOLEAN_RESULT; public class OwnCloudAccountAuthenticator extends AbstractAccountAuthenticator { private static final String TAG = "UdinicAuthenticator"; private final Context mContext; public OwnCloudAccountAuthenticator(Context context) { super(context); // I hate you! Google - set mContext as protected! this.mContext = context; } @Override public Bundle addAccount(AccountAuthenticatorResponse response, String accountType, String authTokenType, String[] requiredFeatures, Bundle options) { Log.d("udinic", TAG + "> addAccount"); final Intent intent = new Intent(mContext, LoginDialogActivity.class); //intent.putExtra(AuthenticatorActivity.ARG_ACCOUNT_TYPE, accountType); //intent.putExtra(AuthenticatorActivity.ARG_AUTH_TYPE, authTokenType); //intent.putExtra(AuthenticatorActivity.ARG_IS_ADDING_NEW_ACCOUNT, true); intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response); final Bundle bundle = new Bundle(); bundle.putParcelable(AccountManager.KEY_INTENT, intent); return bundle; } @Override public Bundle getAuthToken(AccountAuthenticatorResponse response, Account account, String authTokenType, Bundle options) { Log.d("udinic", TAG + "> getAuthToken"); // If the caller requested an authToken type we don't support, then // return an error if (!authTokenType.equals(AccountGeneral.AUTHTOKEN_TYPE_READ_ONLY) && !authTokenType.equals(AccountGeneral.AUTHTOKEN_TYPE_FULL_ACCESS)) { final Bundle result = new Bundle(); result.putString(AccountManager.KEY_ERROR_MESSAGE, "invalid authTokenType"); return result; } // Extract the username and password from the Account Manager, and ask // the server for an appropriate AuthToken. final AccountManager am = AccountManager.get(mContext); String authToken = am.peekAuthToken(account, authTokenType); //String userId = null; //User identifier, needed for creating ACL on our server-side Log.d("udinic", TAG + "> peekAuthToken returned - " + authToken); // Lets give another try to authenticate the user if (TextUtils.isEmpty(authToken)) { final String password = am.getPassword(account); if (password != null) { try { Log.d("udinic", TAG + "> re-authenticating with the existing password"); /* User user = sServerAuthenticate.userSignIn(account.name, password, authTokenType); if (user != null) { authToken = user.getSessionToken(); userId = user.getObjectId(); } */ } catch (Exception e) { e.printStackTrace(); } } } // If we get an authToken - we return it if (!TextUtils.isEmpty(authToken)) { final Bundle result = new Bundle(); result.putString(AccountManager.KEY_ACCOUNT_NAME, account.name); result.putString(AccountManager.KEY_ACCOUNT_TYPE, account.type); result.putString(AccountManager.KEY_AUTHTOKEN, authToken); return result; } // If we get here, then we couldn't access the user's password - so we // need to re-prompt them for their credentials. We do that by creating // an intent to display our AuthenticatorActivity. final Intent intent = new Intent(mContext, LoginDialogActivity.class); intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response); //intent.putExtra(AuthenticatorActivity.ARG_ACCOUNT_TYPE, account.type); //intent.putExtra(AuthenticatorActivity.ARG_AUTH_TYPE, authTokenType); final Bundle bundle = new Bundle(); bundle.putParcelable(AccountManager.KEY_INTENT, intent); return bundle; } @Override public String getAuthTokenLabel(String authTokenType) { if (AccountGeneral.AUTHTOKEN_TYPE_FULL_ACCESS.equals(authTokenType)) return AccountGeneral.AUTHTOKEN_TYPE_FULL_ACCESS_LABEL; else if (AccountGeneral.AUTHTOKEN_TYPE_READ_ONLY.equals(authTokenType)) return AccountGeneral.AUTHTOKEN_TYPE_READ_ONLY_LABEL; else return authTokenType + " (Label)"; } @Override public Bundle hasFeatures(AccountAuthenticatorResponse response, Account account, String[] features) throws NetworkErrorException { final Bundle result = new Bundle(); result.putBoolean(KEY_BOOLEAN_RESULT, false); return result; } @Override public Bundle editProperties(AccountAuthenticatorResponse response, String accountType) { return null; } @Override public Bundle confirmCredentials(AccountAuthenticatorResponse response, Account account, Bundle options) throws NetworkErrorException { return null; } @Override public Bundle updateCredentials(AccountAuthenticatorResponse response, Account account, String authTokenType, Bundle options) throws NetworkErrorException { return null; } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/authentication/OwnCloudSyncAdapter.java ================================================ package de.luhmer.owncloudnewsreader.authentication; import android.accounts.Account; import android.content.AbstractThreadedSyncAdapter; import android.content.ContentProviderClient; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.content.SyncResult; import android.os.Bundle; import android.os.Handler; import android.os.Looper; import android.util.Log; import android.widget.Toast; import org.greenrobot.eventbus.EventBus; import org.reactivestreams.Publisher; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import javax.inject.Inject; import de.luhmer.owncloudnewsreader.Constants; import de.luhmer.owncloudnewsreader.NewsReaderApplication; import de.luhmer.owncloudnewsreader.R; import de.luhmer.owncloudnewsreader.database.DatabaseConnectionOrm; import de.luhmer.owncloudnewsreader.database.model.Feed; import de.luhmer.owncloudnewsreader.database.model.Folder; import de.luhmer.owncloudnewsreader.di.ApiProvider; import de.luhmer.owncloudnewsreader.helper.ForegroundListener; import de.luhmer.owncloudnewsreader.helper.StopWatch; import de.luhmer.owncloudnewsreader.notification.NextcloudNotificationManager; import de.luhmer.owncloudnewsreader.reader.InsertIntoDatabase; import de.luhmer.owncloudnewsreader.reader.nextcloud.ItemStateSync; import de.luhmer.owncloudnewsreader.reader.nextcloud.RssItemObservable; import de.luhmer.owncloudnewsreader.services.DownloadImagesService; import de.luhmer.owncloudnewsreader.services.events.SyncFailedEvent; import de.luhmer.owncloudnewsreader.services.events.SyncFinishedEvent; import de.luhmer.owncloudnewsreader.services.events.SyncStartedEvent; import de.luhmer.owncloudnewsreader.ssl.OkHttpSSLClient; import de.luhmer.owncloudnewsreader.widget.WidgetProvider; import io.reactivex.rxjava3.annotations.NonNull; import io.reactivex.rxjava3.core.Observable; import io.reactivex.rxjava3.core.Observer; import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.schedulers.Schedulers; public class OwnCloudSyncAdapter extends AbstractThreadedSyncAdapter { private static final String TAG = OwnCloudSyncAdapter.class.getCanonicalName(); public boolean syncRunning = false; protected @Inject SharedPreferences mPrefs; protected @Inject ApiProvider mApi; public OwnCloudSyncAdapter(Context context, boolean autoInitialize) { super(context, autoInitialize); ((NewsReaderApplication) context).getAppComponent().injectService(this); } @Override public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient provider, SyncResult syncResult) { Log.d("udinic", "onPerformSync for account[" + account.name + "] [" + Thread.currentThread().getName() + "]\""); StopWatch syncStopWatch = new StopWatch(); syncStopWatch.start(); // Send sync started event syncRunning = true; EventBus.getDefault().post(new SyncStartedEvent()); // run actual sync sync(); // Update Widget / Notification WidgetProvider.UpdateWidget(getContext()); updateNotification(); // Download Favicons for feeds startFaviconDownload(); // Send sync finished event syncRunning = false; EventBus.getDefault().post(new SyncFinishedEvent()); syncStopWatch.stop(); Log.v(TAG, "Finished sync - time needed (synchronization): " + syncStopWatch); } private static class NextcloudSyncResult { private final List folders; private final List feeds; private final boolean stateSyncSuccessful; NextcloudSyncResult(List folders, List feeds, Boolean stateSyncSuccessful) { this.folders = folders; this.feeds = feeds; this.stateSyncSuccessful = stateSyncSuccessful; } } // Start sync private void sync() { if(mApi.getNewsAPI() == null) { throwException(new IllegalStateException("API is NOT initialized")); Log.e(TAG, "API is NOT initialized.."); return; } else { Log.v(TAG, "API is initialized.."); } final DatabaseConnectionOrm dbConn = new DatabaseConnectionOrm(getContext()); Observable rssStateSync = Observable.fromPublisher( (Publisher) s -> { Log.v(TAG, "(rssStateSync) subscribe() called with: s = [" + s + "] [" + Thread.currentThread().getName() + "]"); try { ItemStateSync.PerformItemStateSync(mApi.getNewsAPI(), dbConn); s.onNext(true); s.onComplete(); } catch(Exception ex) { s.onError(ex); } }).subscribeOn(Schedulers.newThread()); // First sync Feeds and Folders and rss item states (in parallel) Observable> folderObservable = mApi .getNewsAPI() .folders() .map(folders -> { // If the folders is more than one, returns the most new. HashMap uniqueLabelFolders = new HashMap<>(); for (Folder folder : folders) { String label = folder.getLabel(); Folder uniqueFolder = uniqueLabelFolders.get(label); if (uniqueFolder == null || uniqueFolder.getId() < folder.getId()) { uniqueLabelFolders.put(label, folder); } } return new ArrayList<>(uniqueLabelFolders.values()); }) .subscribeOn(Schedulers.newThread()); Observable> feedsObservable = mApi .getNewsAPI() .feeds() .subscribeOn(Schedulers.newThread()); // Wait for results Observable combined = Observable.zip(folderObservable, feedsObservable, rssStateSync, (folders, feeds, mRes) -> { Log.v(TAG, "apply() called with: folders = [" + folders + "], feeds = [" + feeds + "], mRes = [" + mRes + "] [" + Thread.currentThread().getName() + "]"); return new NextcloudSyncResult(folders, feeds, mRes); }); Log.v(TAG, "subscribing now.. [" + Thread.currentThread().getName() + "]"); try { NextcloudSyncResult syncResult = combined.blockingFirst(); // Delete cached entities to keep entity relationships up to date for observers and readers, // for example, relationship of RSS items with feeds that have changed (name changed, etc). // The presence of old data in the cache can affect the obtaining of up-to-date information. dbConn.clearSessionCache(); InsertIntoDatabase.InsertFoldersIntoDatabase(syncResult.folders, dbConn); InsertIntoDatabase.InsertFeedsIntoDatabase(syncResult.feeds, dbConn); Log.v(TAG, "State sync successful: " + syncResult.stateSyncSuccessful); // Start the sync (Rss Items) syncRssItems(dbConn); } catch(Exception ex) { //Log.e(TAG, "throwException: ", ex); throwException(ex); } } private void syncRssItems(final DatabaseConnectionOrm dbConn) { Log.v(TAG, "syncRssItems() called with: dbConn = [" + dbConn + "] [" + Thread.currentThread().getName() + "]"); // .observeOn(AndroidSchedulers.mainThread()) Observable.fromPublisher(new RssItemObservable(dbConn, mApi.getNewsAPI(), mPrefs)) .subscribeOn(Schedulers.newThread()) .blockingSubscribe(new Observer<>() { @Override public void onSubscribe(@NonNull Disposable d) { Log.d(TAG, "[syncRssItems] - onSubscribe() called"); } @Override public void onNext(@NonNull final Integer totalCount) { Log.v(TAG, "[syncRssItems] - onNext() called with: totalCount = [" + totalCount + "]"); Handler handler = new Handler(Looper.getMainLooper()); handler.post(() -> Toast.makeText( getContext(), getContext().getResources().getQuantityString(R.plurals.fetched_items_so_far, totalCount, totalCount), Toast.LENGTH_SHORT).show()); } @Override public void onError(@NonNull Throwable e) { Log.v(TAG, "[syncRssItems] - onError() called with: throwable = [" + e + "]"); throwException(e); } @Override public void onComplete() { Log.v(TAG, "[syncRssItems] - onComplete() called"); } }); } private void throwException(Throwable ex) { Log.e(TAG, "throwException() called [" + Thread.currentThread().getName() + "]", ex); syncRunning = false; if(ex instanceof Exception) { EventBus.getDefault().post(new SyncFailedEvent(OkHttpSSLClient.HandleExceptions((Exception) ex))); } else { EventBus.getDefault().post(new SyncFailedEvent(ex)); } } private void updateNotification() { // DatabaseConnectionOrm dbConn = new DatabaseConnectionOrm(getContext()); int newItemsCountLastSync = mPrefs.getInt(Constants.LAST_UPDATE_NEW_ITEMS_COUNT_STRING, 0); if (newItemsCountLastSync > 0) { // int newItemsCount = Integer.parseInt(dbConn.getUnreadItemsCountForSpecificFolder(SubscriptionExpandableListAdapter.SPECIAL_FOLDERS.ALL_UNREAD_ITEMS)); // If another app is not in foreground if (!ForegroundListener.Companion.isInForeground()) { NextcloudNotificationManager.showUnreadRssItemsNotification(getContext(), mPrefs, false); } } } private void startFaviconDownload() { Intent data = new Intent(); data.putExtra(DownloadImagesService.DOWNLOAD_MODE_STRING, DownloadImagesService.DownloadMode.FAVICONS_ONLY); DownloadImagesService.enqueueWork(getContext(), data); } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/chrometabs/KeepAliveService.kt ================================================ // Copyright 2015 Google Inc. All Rights Reserved. // // 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 de.luhmer.owncloudnewsreader.chrometabs import android.app.Service import android.content.Intent import android.os.Binder import android.os.IBinder /** * Empty service used by the custom tab to bind to, raising the application's importance. */ class KeepAliveService : Service() { override fun onBind(intent: Intent): IBinder? = sBinder companion object { private val sBinder = Binder() } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/database/DatabaseConnectionOrm.java ================================================ package de.luhmer.owncloudnewsreader.database; import static de.luhmer.owncloudnewsreader.ListView.SubscriptionExpandableListAdapter.SPECIAL_FOLDERS; import static de.luhmer.owncloudnewsreader.ListView.SubscriptionExpandableListAdapter.SPECIAL_FOLDERS.ALL_DOWNLOADED_PODCASTS; import static de.luhmer.owncloudnewsreader.ListView.SubscriptionExpandableListAdapter.SPECIAL_FOLDERS.ALL_ITEMS; import static de.luhmer.owncloudnewsreader.ListView.SubscriptionExpandableListAdapter.SPECIAL_FOLDERS.ALL_STARRED_ITEMS; import static de.luhmer.owncloudnewsreader.ListView.SubscriptionExpandableListAdapter.SPECIAL_FOLDERS.ALL_UNREAD_ITEMS; import android.content.Context; import android.database.Cursor; import android.os.AsyncTask; import android.util.Log; import android.util.SparseArray; import java.io.File; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Set; import java.util.stream.Collectors; import javax.inject.Inject; import javax.inject.Named; import de.greenrobot.dao.query.LazyList; import de.greenrobot.dao.query.Query; import de.greenrobot.dao.query.QueryBuilder; import de.greenrobot.dao.query.WhereCondition; import de.luhmer.owncloudnewsreader.Constants; import de.luhmer.owncloudnewsreader.NewsReaderApplication; import de.luhmer.owncloudnewsreader.database.model.CurrentRssItemViewDao; import de.luhmer.owncloudnewsreader.database.model.DaoSession; import de.luhmer.owncloudnewsreader.database.model.Feed; import de.luhmer.owncloudnewsreader.database.model.FeedDao; import de.luhmer.owncloudnewsreader.database.model.Folder; import de.luhmer.owncloudnewsreader.database.model.FolderDao; import de.luhmer.owncloudnewsreader.database.model.RssItem; import de.luhmer.owncloudnewsreader.database.model.RssItemDao; import de.luhmer.owncloudnewsreader.helper.AsyncTaskHelper; import de.luhmer.owncloudnewsreader.helper.NewsFileUtils; import de.luhmer.owncloudnewsreader.helper.StopWatch; import de.luhmer.owncloudnewsreader.model.PodcastFeedItem; import de.luhmer.owncloudnewsreader.model.PodcastItem; import de.luhmer.owncloudnewsreader.services.PodcastDownloadService; public class DatabaseConnectionOrm { public static final List ALLOWED_PODCASTS_TYPES = new ArrayList() { { this.add("audio/mp3"); this.add("audio/mp4"); this.add("audio/mpeg"); this.add("audio/ogg"); this.add("audio/opus"); this.add("audio/ogg;codecs=opus"); this.add("audio/x-m4a"); this.add("youtube"); this.add("video/mp4"); } }; private final String TAG = getClass().getCanonicalName(); //private static final String[] VIDEO_FORMATS = { "youtube", "video/mp4" }; private static final String[] VIDEO_FORMATS = { "video/mp4" }; public enum SORT_DIRECTION { asc, desc } private final DaoSession daoSession; private final static int PageSize = 25; private final Context context; protected @Inject @Named("databaseFileName") String databasePath; public void resetDatabase() { daoSession.getRssItemDao().deleteAll(); daoSession.getFeedDao().deleteAll(); daoSession.getFolderDao().deleteAll(); daoSession.getCurrentRssItemViewDao().deleteAll(); } public DatabaseConnectionOrm(Context context) { this.context = context; if(databasePath == null) { ((NewsReaderApplication) context.getApplicationContext()).getAppComponent().injectDatabaseConnection(this); } daoSession = DatabaseHelperOrm.getDaoSession(context, databasePath); } public void deleteOldAndInsertNewFolders (final Folder... folder) { daoSession.runInTx(() -> { daoSession.getFolderDao().deleteAll(); daoSession.getFolderDao().insertInTx(folder); }); } public void deleteOldAndInsertNewFolders (final Iterable folder) { daoSession.runInTx(() -> { daoSession.getFolderDao().deleteAll(); daoSession.getFolderDao().insertInTx(folder); }); } public void insertNewFolders(final Iterable folder) { daoSession.getFolderDao().insertInTx(folder); } public void insertNewFeed (Iterable feeds) { daoSession.getFeedDao().insertOrReplaceInTx(feeds); } public void insertNewItems(Iterable items) { daoSession.getRssItemDao().insertOrReplaceInTx(items); } public List getListOfFolders() { // return daoSession.getFolderDao().loadAll(); return daoSession.getFolderDao().queryBuilder().orderAsc(FolderDao.Properties.Label).list(); } /* public List getListOfFoldersWithUnreadItems() { return daoSession.getFolderDao().queryBuilder().where( new WhereCondition.PropertyCondition(FolderDao.Properties.Id, " IN " + "(SELECT " + FeedDao.Properties.FolderId.columnName + " FROM " + FeedDao.TABLENAME + " feed " + " JOIN " + RssItemDao.TABLENAME + " rss ON feed." + FeedDao.Properties.Id.columnName + " = rss." + RssItemDao.Properties.FeedId.columnName + " WHERE rss." + RssItemDao.Properties.Read_temp.columnName + " != 1)") ).list(); } */ public List getListOfFeeds() { return daoSession.getFeedDao().queryBuilder().orderAsc(FeedDao.Properties.FeedTitle).list(); } public List getListOfFeedsWithUnreadItems() { List feedsWithUnreadItems = new ArrayList<>(); for(Feed feed : getListOfFeeds()) { for(RssItem rssItem : feed.getRssItemList()) { if (!rssItem.getRead_temp()) { feedsWithUnreadItems.add(feed); break; } } } return feedsWithUnreadItems; } public Folder getFolderById(long folderId) { return daoSession.getFolderDao().queryBuilder().where(FolderDao.Properties.Id.eq(folderId)).unique(); } public Folder getFolderByLabel(String label) { return daoSession.getFolderDao().queryBuilder().where(FolderDao.Properties.Label.eq(label)).unique(); } public Feed getFeedById(long feedId) { return daoSession.getFeedDao().queryBuilder().where(FeedDao.Properties.Id.eq(feedId)).unique(); } public List getListOfFeedsWithFolders() { return daoSession.getFeedDao().queryBuilder().orderAsc(FeedDao.Properties.FeedTitle).where(FeedDao.Properties.FolderId.isNotNull()).list(); } public List getListOfFeedsWithoutFolders(boolean onlyWithUnreadRssItems) { if(onlyWithUnreadRssItems) { return daoSession.getFeedDao().queryBuilder().orderAsc(FeedDao.Properties.FeedTitle).where(FeedDao.Properties.FolderId.eq(0L), new WhereCondition.StringCondition(FeedDao.Properties.Id.columnName + " IN " + "(SELECT " + RssItemDao.Properties.FeedId.columnName + " FROM " + RssItemDao.TABLENAME + " WHERE " + RssItemDao.Properties.Read_temp.columnName + " != 1)")).list(); } else { return daoSession.getFeedDao().queryBuilder().orderAsc(FeedDao.Properties.FeedTitle).where(FeedDao.Properties.FolderId.eq(0L)).list(); } } public List getAllFeedsWithUnreadRssItems() { return daoSession.getFeedDao().queryRaw(", " + RssItemDao.TABLENAME + " R " + " WHERE R." + RssItemDao.Properties.FeedId.columnName + " = T._id " + " AND " + RssItemDao.Properties.Read_temp.columnName + " != 1 GROUP BY T._id"); } public List getAllFeedsWithUnreadRssItemsForFolder(long folderId) { return daoSession.getFeedDao().queryBuilder().orderAsc(FeedDao.Properties.FeedTitle).where(FeedDao.Properties.FolderId.eq(folderId)).list(); } public List getAllFeedsWithStarredRssItems() { return daoSession.getFeedDao().queryBuilder().orderAsc(FeedDao.Properties.FeedTitle).where( new WhereCondition.StringCondition(FeedDao.Properties.Id.columnName + " IN " + "(SELECT " + RssItemDao.Properties.FeedId.columnName + " FROM " + RssItemDao.TABLENAME + " WHERE " + RssItemDao.Properties.Starred_temp.columnName + " = 1)")).list(); } public List getAllFeedsWithDownloadedPodcasts(Context context) { var ids = NewsFileUtils.getDownloadedPodcastsFingerprints(context); var files = Arrays.stream(ids).map((f) -> "\"" + f + "\"").collect(Collectors.toList()); return daoSession.getFeedDao().queryBuilder().orderAsc(FeedDao.Properties.FeedTitle).where( new WhereCondition.StringCondition(FeedDao.Properties.Id.columnName + " IN " + "(SELECT " + RssItemDao.Properties.FeedId.columnName + " FROM " + RssItemDao.TABLENAME + " WHERE " + RssItemDao.Properties.Fingerprint.columnName + " in (" + String.join(",", files) + "))")).list(); } public List getListOfFeedsWithAudioPodcasts() { WhereCondition whereCondition = new WhereCondition.StringCondition(FeedDao.Properties.Id.columnName + " IN " + "(SELECT " + RssItemDao.Properties.FeedId.columnName + " FROM " + RssItemDao.TABLENAME + " WHERE " + RssItemDao.Properties.EnclosureMime.columnName + " IN(\"" + join(ALLOWED_PODCASTS_TYPES, "\",\"") + "\"))"); List feedsWithPodcast = daoSession.getFeedDao().queryBuilder().orderAsc(FeedDao.Properties.FeedTitle).where(whereCondition).list(); List podcastFeedItemsList = new ArrayList<>(feedsWithPodcast.size()); for(Feed feed : feedsWithPodcast) { int podcastCount = 0; for(RssItem rssItem : feed.getRssItemList()) { if(ALLOWED_PODCASTS_TYPES.contains(rssItem.getEnclosureMime())) podcastCount++; } podcastFeedItemsList.add(new PodcastFeedItem(feed, podcastCount)); } return podcastFeedItemsList; } public List getListOfAudioPodcastsForFeed(Context context, long feedId) { List result = new ArrayList<>(); for(RssItem rssItem : daoSession.getRssItemDao().queryBuilder() .where(RssItemDao.Properties.EnclosureMime.in(ALLOWED_PODCASTS_TYPES), RssItemDao.Properties.FeedId.eq(feedId)) .orderDesc(RssItemDao.Properties.PubDate).list()) { PodcastItem podcastItem = ParsePodcastItemFromRssItem(context, rssItem); result.add(podcastItem); } return result; } public boolean areThereAnyUnsavedChangesInDatabase() { long countUnreadRead = daoSession.getRssItemDao().queryBuilder().where(RssItemDao.Properties.Read_temp.notEq(RssItemDao.Properties.Read)).count(); long countStarredUnstarred = daoSession.getRssItemDao().queryBuilder().where(RssItemDao.Properties.Starred_temp.notEq(RssItemDao.Properties.Starred)).count(); return (countUnreadRead + countStarredUnstarred) > 0; } public void updateFeed(Feed feed) { daoSession.getFeedDao().update(feed); } public long getLowestRssItemIdUnread() { RssItem rssItem = daoSession.getRssItemDao().queryBuilder().where(RssItemDao.Properties.Read_temp.eq(false)).orderAsc(RssItemDao.Properties.Id).limit(1).unique(); if(rssItem != null) return rssItem.getId(); else return 0; } public RssItem getLowestRssItemIdByFeed(long idFeed) { return daoSession.getRssItemDao().queryBuilder().where(RssItemDao.Properties.FeedId.eq(idFeed)).orderAsc(RssItemDao.Properties.Id).limit(1).unique(); } public RssItem getRssItemById(long rssItemId) { return daoSession.getRssItemDao().queryBuilder().where(RssItemDao.Properties.Id.eq(rssItemId)).unique(); } /** * Changes the read unread state of the item. This is NOT the temp value!!! * @param itemIds * @param markAsRead */ public void change_readUnreadStateOfItem(List itemIds, boolean markAsRead) { if(itemIds != null) for(String idItem : itemIds) updateIsReadOfRssItem(idItem, markAsRead); } /** * Changes the starred unstarred state of the item. This is NOT the temp value!!! * @param itemIds * @param markAsStarred */ public void changeStarrUnstarrStateOfItem(List itemIds, boolean markAsStarred) { if(itemIds != null) for(String idItem : itemIds) updateIsStarredOfRssItem(idItem, markAsStarred); } public void updateIsReadOfRssItem(String ITEM_ID, Boolean isRead) { RssItem rssItem = daoSession.getRssItemDao().queryBuilder().where(RssItemDao.Properties.Id.eq(ITEM_ID)).unique(); rssItem.setRead(isRead); rssItem.setRead_temp(isRead); daoSession.getRssItemDao().update(rssItem); } public void updateIsStarredOfRssItem(String ITEM_ID, Boolean isStarred) { RssItem rssItem = daoSession.getRssItemDao().queryBuilder().where(RssItemDao.Properties.Id.eq(ITEM_ID)).unique(); rssItem.setStarred(isStarred); rssItem.setStarred_temp(isStarred); daoSession.getRssItemDao().update(rssItem); } public int markAllItemsAsReadForCurrentView() { /* String sql = "UPDATE " + RssItemDao.TABLENAME + " SET " + RssItemDao.Properties.Read_temp.columnName + " = 1 " + "WHERE " + RssItemDao.Properties.Id.columnName + " IN (SELECT " + CurrentRssItemViewDao.Properties.RssItemId.columnName + " FROM " + CurrentRssItemViewDao.TABLENAME + ")"; daoSession.getDatabase().execSQL(sql); */ // 100 causes android.database.sqlite.SQLiteBlobTooBigException on some devices final int itemsPerIteration = 25; WhereCondition whereCondition = new WhereCondition.StringCondition(RssItemDao.Properties.Id.columnName + " IN " + "(SELECT " + CurrentRssItemViewDao.Properties.RssItemId.columnName + " FROM " + CurrentRssItemViewDao.TABLENAME + ") AND " + RssItemDao.Properties.Read_temp.columnName + "= 0"); Query query = daoSession .getRssItemDao() .queryBuilder() .where(whereCondition) .limit(itemsPerIteration) .build(); int iterationCount = 0; List rssItemList; do { rssItemList = query.listLazy(); for (RssItem rssItem : rssItemList) { rssItem.setRead_temp(true); } daoSession.getRssItemDao().updateInTx(rssItemList); iterationCount++; } while (rssItemList.size() == itemsPerIteration); return (iterationCount - 1) * itemsPerIteration + rssItemList.size(); } public List getRssItemsIdsFromList(List rssItemList) { List itemIds = new ArrayList<>(); for(RssItem rssItem : rssItemList) { itemIds.add(String.valueOf(rssItem.getId())); } return itemIds; } public List getAllNewReadRssItems() { return daoSession.getRssItemDao().queryBuilder().where(RssItemDao.Properties.Read.eq(false), RssItemDao.Properties.Read_temp.eq(true)).list(); } public List getAllNewUnreadRssItems() { return daoSession.getRssItemDao().queryBuilder().where(RssItemDao.Properties.Read.eq(true), RssItemDao.Properties.Read_temp.eq(false)).list(); } public List getAllNewStarredRssItems() { return daoSession.getRssItemDao().queryBuilder().where(RssItemDao.Properties.Starred.eq(false), RssItemDao.Properties.Starred_temp.eq(true)).list(); } public List getAllNewUnstarredRssItems() { return daoSession.getRssItemDao().queryBuilder().where(RssItemDao.Properties.Starred.eq(true), RssItemDao.Properties.Starred_temp.eq(false)).list(); } public LazyList getAllUnreadRssItemsForWidget() { return daoSession.getRssItemDao().queryBuilder().where(RssItemDao.Properties.Read_temp.eq(false)).limit(100).orderDesc(RssItemDao.Properties.PubDate).listLazy(); } public Set getNotificationGroups() { List feeds = daoSession.getFeedDao().loadAll(); String[] notificationChannelsGroups = feeds .stream() .map(Feed::getNotificationChannel) .filter(nc -> !nc.equals("none")) .toArray(String[]::new); return new HashSet<>(Arrays.asList(notificationChannelsGroups)); } public QueryBuilder getAllUnreadRssItemsForNotificationGroup(SORT_DIRECTION sortDirection, String notificationGroup) { QueryBuilder qb = daoSession.getRssItemDao().queryBuilder() .where(RssItemDao.Properties.Read_temp.eq(false)); // filter for notification group qb.join(RssItemDao.Properties.FeedId, Feed.class, FeedDao.Properties.Id) .where(FeedDao.Properties.NotificationChannel.eq(notificationGroup)); if (sortDirection == SORT_DIRECTION.asc) { qb = qb.orderAsc(RssItemDao.Properties.PubDate); } else { qb = qb.orderDesc(RssItemDao.Properties.PubDate); } return qb; } public void markAllItemsAsRead() { StopWatch sw = new StopWatch(); sw.start(); String sql = "UPDATE " + RssItemDao.TABLENAME + " SET " + RssItemDao.Properties.Read_temp.columnName + " = 1 WHERE " + RssItemDao.Properties.Read_temp.columnName + " = 0"; daoSession.getDatabase().execSQL(sql); sw.stop(); Log.v(TAG, "Time needed for marking all unread items as read: " + sw); } public LazyList getAllUnreadRssItemsForDownloadWebPageService() { return daoSession.getRssItemDao().queryBuilder().where(RssItemDao.Properties.Read_temp.eq(false)).orderDesc(RssItemDao.Properties.PubDate).listLazy(); } public LazyList getAllItemsWithIdHigher(long id) { return daoSession.getRssItemDao().queryBuilder().where(RssItemDao.Properties.Id.ge(id)).listLazy(); } /*** * Warning: This methods performs database operations asynchronously. Therefore this method * will return immediately - even though the operation might not be completed */ public void updateRssItem(RssItem rssItem) { AsyncTaskHelper.StartAsyncTask(new UpdateRssItemAsyncTask(rssItem)); } class UpdateRssItemAsyncTask extends AsyncTask { private final RssItem rssItem; UpdateRssItemAsyncTask(RssItem rssItem) { this.rssItem = rssItem; } @Override protected Void doInBackground(Void... voids) { daoSession.getRssItemDao().update(rssItem); // Code below is used to deduplicate rss items (see https://github.com/nextcloud/news-android/issues/513) if(rssItem.getRead_temp()) { // Get all rss items with the same fingerprint (This operation is very slow) List rssItemList = daoSession.getRssItemDao().queryBuilder().where( RssItemDao.Properties.Fingerprint.eq(rssItem.getFingerprint()), RssItemDao.Properties.Id.notEq(rssItem.getId())) .list(); // Sync the read-state of the items for (RssItem rssItem1 : rssItemList) { rssItem1.setRead_temp(rssItem.getRead_temp()); } // Update in database daoSession.getRssItemDao().updateInTx(rssItemList); } return null; } } public void removeFeedById(final long feedId) { daoSession.runInTx(() -> { daoSession.getFeedDao().deleteByKey(feedId); List list = daoSession.getRssItemDao().queryBuilder().where(RssItemDao.Properties.FeedId.eq(feedId)).list(); for (RssItem rssItem : list) { daoSession.getRssItemDao().delete(rssItem); } }); } public void renameFeedById(long feedId, String newTitle) { Feed feed = daoSession.getFeedDao().queryBuilder().where(FeedDao.Properties.Id.eq(feedId)).unique(); feed.setFeedTitle(newTitle); daoSession.getFeedDao().update(feed); } public SparseArray getUrlsToFavIcons() { SparseArray favIconUrls = new SparseArray<>(); for(Feed feed : getListOfFeeds()) favIconUrls.put((int) feed.getId(), feed.getFaviconUrl()); return favIconUrls; } public long getCurrentRssItemViewCount() { return daoSession.getCurrentRssItemViewDao().count(); } public List getCurrentRssItemView(int page) { String where_clause = ", " + CurrentRssItemViewDao.TABLENAME + " C " + " WHERE C." + CurrentRssItemViewDao.Properties.RssItemId.columnName + " = T." + RssItemDao.Properties.Id.columnName + " AND C._id > " + page * PageSize + " AND c._id <= " + ((page+1) * PageSize) + " ORDER BY C." + CurrentRssItemViewDao.Properties.Id.columnName; return daoSession.getRssItemDao().queryRaw(where_clause); } public LazyList getAllRssItems() { String where_clause = ", " + CurrentRssItemViewDao.TABLENAME + " C " + " WHERE C." + CurrentRssItemViewDao.Properties.RssItemId.columnName + " = T." + RssItemDao.Properties.Id.columnName + " ORDER BY C." + CurrentRssItemViewDao.Properties.Id.columnName; return daoSession.getRssItemDao().queryRawCreate(where_clause).listLazy(); } /** * Removes only the folder, without removing feeds inside the folder */ public void removeFolderById(final long folderId) { daoSession.getFolderDao().deleteByKey(folderId); } public void renameFolderById(long folderId, String newLabel) { Folder folder = daoSession.getFolderDao().queryBuilder().where(FolderDao.Properties.Id.eq(folderId)).unique(); folder.setLabel(newLabel); daoSession.getFolderDao().update(folder); } /* public void markAllItemsAsReadForCurrentView() { String sql = "UPDATE " + RssItemDao.TABLENAME + " SET " + RssItemDao.Properties.Read_temp.columnName + " = 1 WHERE " + RssItemDao.Properties.Id.columnName + " IN (SELECT " + CurrentRssItemViewDao.Properties.RssItemId.columnName + " FROM " + CurrentRssItemViewDao.TABLENAME + ")"; daoSession.getDatabase().execSQL(sql); } */ public static PodcastItem ParsePodcastItemFromRssItem(Context context, RssItem rssItem) { PodcastItem podcastItem = new PodcastItem(); Feed feed = rssItem.getFeed(); podcastItem.author = feed.getFeedTitle();// rssItem.getAuthor(); podcastItem.itemId = rssItem.getId(); podcastItem.title = rssItem.getTitle(); podcastItem.link = rssItem.getEnclosureLink(); podcastItem.mimeType = rssItem.getEnclosureMime(); podcastItem.favIcon = feed.getFaviconUrl(); podcastItem.fingerprint = rssItem.getFingerprint(); if("image/jpeg".equals(podcastItem.mimeType)) { // We don't want to accidentally think that enclosed images are podcasts podcastItem.link = ""; podcastItem.mimeType = ""; } podcastItem.isVideoPodcast = Arrays.asList(DatabaseConnectionOrm.VIDEO_FORMATS).contains(podcastItem.mimeType); File file = new File(PodcastDownloadService.getUrlToPodcastFile(context, podcastItem.fingerprint, podcastItem.link, false)); podcastItem.offlineCached = file.exists(); return podcastItem; } public String getAllItemsIdsForFeedSQL(long idFeed, boolean onlyUnread, boolean onlyStarredItems, SORT_DIRECTION sortDirection) { String buildSQL = "SELECT " + RssItemDao.Properties.Id.columnName + " FROM " + RssItemDao.TABLENAME + " WHERE " + RssItemDao.Properties.FeedId.columnName + " = " + idFeed; if(onlyUnread && !onlyStarredItems) buildSQL += " AND " + RssItemDao.Properties.Read_temp.columnName + " != 1"; else if(onlyStarredItems) buildSQL += " AND " + RssItemDao.Properties.Starred_temp.columnName + " = 1"; buildSQL += " ORDER BY " + RssItemDao.Properties.PubDate.columnName + " " + sortDirection.toString(); return buildSQL; } public String getAllItemsIdsForFeedSQLFilteredByTitle(final long feedId, boolean onlyUnread, boolean onlyStarredItems, SORT_DIRECTION sortDirection, final String searchString) { String buildSQL = getAllItemsIdsForFeedSQL(feedId, onlyUnread, onlyStarredItems, sortDirection); return new StringBuilder( buildSQL).insert(buildSQL.indexOf("ORDER"), " AND " + getSearchSQLForColumn(RssItemDao.Properties.Title.columnName, searchString)).toString(); } public String getAllItemsIdsForFeedSQLFilteredByBodySQL(final long feedId, boolean onlyUnread, boolean onlyStarredItems, SORT_DIRECTION sortDirection, final String searchString) { String buildSQL = getAllItemsIdsForFeedSQL(feedId, onlyUnread, onlyStarredItems, sortDirection); return new StringBuilder( buildSQL).insert(buildSQL.indexOf("ORDER"), " AND " + getSearchSQLForColumn(RssItemDao.Properties.Body.columnName, searchString)).toString(); } public String getAllItemsIdsForFeedSQLFilteredByTitleAndBodySQL(final long feedId, boolean onlyUnread, boolean onlyStarredItems, SORT_DIRECTION sortDirection, final String searchString) { String buildSQL = getAllItemsIdsForFeedSQL(feedId, onlyUnread, onlyStarredItems, sortDirection); String titleQuery = getSearchSQLForColumn(RssItemDao.Properties.Title.columnName, searchString); String bodyQuery = getSearchSQLForColumn(RssItemDao.Properties.Body.columnName, searchString); return new StringBuilder( buildSQL).insert(buildSQL.indexOf("ORDER"), " AND (" + titleQuery + " OR " + bodyQuery + ")" ).toString(); } private String getSearchSQLForColumn(String column, String searchString) { return column + " LIKE \"%" + searchString + "%\""; } public Long getLowestItemIdByFolder(Long id_folder) { WhereCondition whereCondition = new WhereCondition.StringCondition(RssItemDao.Properties.FeedId.columnName + " IN " + "(SELECT " + FeedDao.Properties.Id.columnName + " FROM " + FeedDao.TABLENAME + " WHERE " + FeedDao.Properties.FolderId.columnName + " = " + id_folder + ")"); RssItem rssItem = daoSession.getRssItemDao().queryBuilder().orderAsc(RssItemDao.Properties.Id).where(whereCondition).limit(1).unique(); return (rssItem != null) ? rssItem.getId() : 0; } public String getAllItemsIdsForFolderSQL(long ID_FOLDER, boolean onlyUnread, SORT_DIRECTION sortDirection, Context context) { String buildSQL = "SELECT " + RssItemDao.Properties.Id.columnName + " FROM " + RssItemDao.TABLENAME; if(!(ID_FOLDER == ALL_UNREAD_ITEMS.getValue() || ID_FOLDER == ALL_STARRED_ITEMS.getValue() || ID_FOLDER == ALL_DOWNLOADED_PODCASTS.getValue()) || ID_FOLDER == ALL_ITEMS.getValue())//Wenn nicht Alle Artikel ausgewaehlt wurde (-10) oder (-11) fuer Starred Feeds { buildSQL += " WHERE " + RssItemDao.Properties.FeedId.columnName + " IN " + "(SELECT sc." + FeedDao.Properties.Id.columnName + " FROM " + FeedDao.TABLENAME + " sc " + " JOIN " + FolderDao.TABLENAME + " f ON sc." + FeedDao.Properties.FolderId.columnName + " = f." + FolderDao.Properties.Id.columnName + " WHERE f." + FolderDao.Properties.Id.columnName + " = " + ID_FOLDER + ")"; if(onlyUnread) buildSQL += " AND " + RssItemDao.Properties.Read_temp.columnName + " != 1"; } else if(ID_FOLDER == ALL_UNREAD_ITEMS.getValue()) buildSQL += " WHERE " + RssItemDao.Properties.Read_temp.columnName + " != 1"; else if(ID_FOLDER == ALL_STARRED_ITEMS.getValue()) buildSQL += " WHERE " + RssItemDao.Properties.Starred_temp.columnName + " = 1"; else if (ID_FOLDER == ALL_DOWNLOADED_PODCASTS.getValue()) { var ids = NewsFileUtils.getDownloadedPodcastsFingerprints(context); var files = Arrays.stream(ids).map((f) -> "\"" + f + "\"").collect(Collectors.toList()); buildSQL += " WHERE " + RssItemDao.Properties.Fingerprint.columnName + " in (" + String.join(",", files) + ")"; } buildSQL += " ORDER BY " + RssItemDao.Properties.PubDate.columnName + " " + sortDirection.toString(); return buildSQL; } public String getAllItemsIdsForFolderSQLSearch(long ID_FOLDER, SORT_DIRECTION sortDirection, List columns, String searchString) { String buildSQL = "SELECT " + RssItemDao.Properties.Id.columnName + " FROM " + RssItemDao.TABLENAME; if (!(ID_FOLDER == ALL_UNREAD_ITEMS.getValue() || ID_FOLDER == ALL_STARRED_ITEMS.getValue()) || ID_FOLDER == ALL_ITEMS.getValue())//Wenn nicht Alle Artikel ausgewaehlt wurde (-10) oder (-11) fuer Starred Feeds { buildSQL += " WHERE " + RssItemDao.Properties.FeedId.columnName + " IN " + "(SELECT sc." + FeedDao.Properties.Id.columnName + " FROM " + FeedDao.TABLENAME + " sc " + " JOIN " + FolderDao.TABLENAME + " f ON sc." + FeedDao.Properties.FolderId.columnName + " = f." + FolderDao.Properties.Id.columnName + " WHERE f." + FolderDao.Properties.Id.columnName + " = " + ID_FOLDER + ") AND "; } else { buildSQL += " WHERE "; } columns = columns.stream().map(c -> c + " LIKE \"%" + searchString + "%\"").collect(Collectors.toList()); buildSQL += String.join(" OR ", columns); buildSQL += " ORDER BY " + RssItemDao.Properties.PubDate.columnName + " " + sortDirection.toString(); return buildSQL; } public void insertIntoRssCurrentViewTable(String SQL_SELECT) { StopWatch sw = new StopWatch(); sw.start(); SQL_SELECT = "INSERT INTO " + CurrentRssItemViewDao.TABLENAME + " (" + CurrentRssItemViewDao.Properties.RssItemId.columnName + ") " + SQL_SELECT; final String SQL_INSERT_STATEMENT = SQL_SELECT; daoSession.runInTx(new Runnable() { @Override public void run() { daoSession.getCurrentRssItemViewDao().deleteAll(); daoSession.getDatabase().execSQL(SQL_INSERT_STATEMENT); } }); sw.stop(); Log.v(TAG, "Time needed for insert: " + sw); } public String getUnreadItemsCountForSpecificFolder(SPECIAL_FOLDERS specialFolder) { String buildSQL = "SELECT COUNT(1)" + " FROM " + RssItemDao.TABLENAME + " rss "; if(specialFolder != null && specialFolder.equals(SPECIAL_FOLDERS.ALL_STARRED_ITEMS)) { buildSQL += " WHERE " + RssItemDao.Properties.Starred_temp.columnName + " = 1 "; } else { buildSQL += " WHERE " + RssItemDao.Properties.Read_temp.columnName + " != 1 "; } SparseArray values = getStringSparseArrayFromSQL(buildSQL, 0, 0); return values.valueAt(0); } /** * * @return [0] = unread items count for folders, [1] = unread items count for feeds */ public SparseArray[] getUnreadItemCountFeedFolder() { SparseArray[] values = new SparseArray[2]; String buildSQL = "SELECT f." + FolderDao.Properties.Id.columnName + ", feed." + FeedDao.Properties.Id.columnName + ", COUNT(1)" + " FROM " + RssItemDao.TABLENAME + " rss " + " JOIN " + FeedDao.TABLENAME + " feed ON rss." + RssItemDao.Properties.FeedId.columnName + " = feed." + FeedDao.Properties.Id.columnName + " LEFT OUTER JOIN " + FolderDao.TABLENAME + " f ON feed." + FeedDao.Properties.FolderId.columnName + " = f." + FolderDao.Properties.Id.columnName + " WHERE " + RssItemDao.Properties.Read_temp.columnName + " != 1 " + " GROUP BY f." + FolderDao.Properties.Id.columnName + ", feed." + FeedDao.Properties.Id.columnName; //" GROUP BY (case when f." + FolderDao.Properties.Id.columnName + " IS NULL then feed." + FeedDao.Properties.Id.columnName + " ELSE f." + FolderDao.Properties.Id.columnName + " end)"; values[0] = new SparseArray<>(); values[1] = new SparseArray<>(); int totalUnreadItemsCount = 0; try (Cursor cursor = daoSession.getDatabase().rawQuery(buildSQL, null)) { if (cursor != null) { if (cursor.getCount() > 0) { cursor.moveToFirst(); do { int folderId = cursor.getInt(0); int feedId = cursor.getInt(1); int unreadCount = cursor.getInt(2); totalUnreadItemsCount += unreadCount; values[1].put(feedId, String.valueOf(unreadCount)); if (folderId != 0) { if (values[0].get(folderId) != null) { unreadCount += Integer.parseInt(values[0].get(folderId)); } values[0].put(folderId, String.valueOf(unreadCount)); } } while (cursor.moveToNext()); } } } values[0].put(SPECIAL_FOLDERS.ALL_UNREAD_ITEMS.getValue(), String.valueOf(totalUnreadItemsCount)); values[0].put(SPECIAL_FOLDERS.ALL_STARRED_ITEMS.getValue(), getUnreadItemsCountForSpecificFolder(SPECIAL_FOLDERS.ALL_STARRED_ITEMS)); return values; } public SparseArray getStarredItemCount() { String buildSQL = "SELECT " + RssItemDao.Properties.FeedId.columnName + ", COUNT(1)" + // rowid as _id, " FROM " + RssItemDao.TABLENAME + " WHERE " + RssItemDao.Properties.Starred_temp.columnName + " = 1 " + " GROUP BY " + RssItemDao.Properties.FeedId.columnName; return getStringSparseArrayFromSQL(buildSQL, 0, 1); } public int getDownloadedPodcastsCount(Context context) { var ids = NewsFileUtils.getDownloadedPodcastsFingerprints(context); var files = Arrays.stream(ids).map((f) -> "\"" + f + "\"").collect(Collectors.toList()); String buildSQL = "SELECT COUNT(1)" + " FROM " + RssItemDao.TABLENAME + " WHERE " + RssItemDao.Properties.Fingerprint.columnName + " in (" + String.join(",", files) + ")"; return (int) getLongValueBySQL(buildSQL); } public void clearDatabaseOverSize() { //If i have 9023 rows in the database, when i run that query it should delete 8023 rows and leave me with 1000 //database.execSQL("DELETE FROM " + RSS_ITEM_TABLE + " WHERE " + + "ORDER BY rowid DESC LIMIT 1000 * //Let's say it said 1005 - you need to delete 5 rows. //DELETE FROM table ORDER BY dateRegistered ASC LIMIT 5 int max = Constants.maxItemsCount; int total = (int) getLongValueBySQL("SELECT COUNT(*) FROM " + RssItemDao.TABLENAME); int unread = (int) getLongValueBySQL("SELECT COUNT(*) FROM " + RssItemDao.TABLENAME + " WHERE " + RssItemDao.Properties.Read_temp.columnName + " != 1"); int read = total - unread; if(total > max) { Log.v(TAG, "Clearing Database oversize"); int overSize = total - max; //Soll verhindern, dass ungelesene Artikel gelöscht werden if(overSize > read) overSize = read; var downloadedPodcastsFingerprints = NewsFileUtils.getDownloadedPodcastsFingerprints(context); var files = Arrays.stream(downloadedPodcastsFingerprints).map((f) -> "\"" + f + "\"").collect(Collectors.toList()); String sqlStatement = "DELETE FROM " + RssItemDao.TABLENAME + " WHERE " + RssItemDao.Properties.Id.columnName + " IN (SELECT " + RssItemDao.Properties.Id.columnName + " FROM " + RssItemDao.TABLENAME + " WHERE " + RssItemDao.Properties.Read_temp.columnName + " = 1 AND " + RssItemDao.Properties.Starred_temp.columnName + " != 1 " + " AND " + RssItemDao.Properties.Fingerprint.columnName + " NOT IN (" + String.join(",", files) + ")" + // This means that the article has downloaded podcast media " AND " + RssItemDao.Properties.Id.columnName + " NOT IN (SELECT " + CurrentRssItemViewDao.Properties.RssItemId.columnName + " FROM " + CurrentRssItemViewDao.TABLENAME + ")" + " ORDER BY " + RssItemDao.Properties.Id.columnName + " asc LIMIT " + overSize + ")"; daoSession.getDatabase().execSQL(sqlStatement); /* SELECT * FROM rss_item WHERE read_temp = 1 ORDER BY rowid asc LIMIT 3; */ } else { Log.v(TAG, "Clearing Database oversize not necessary"); } } public long getLastModified() { List rssItemList = daoSession.getRssItemDao().queryBuilder().orderDesc(RssItemDao.Properties.LastModified).limit(1).list(); if(rssItemList.size() > 0) return rssItemList.get(0).getLastModified().getTime(); return 0; } public long getLowestItemId(boolean onlyStarred) { List rssItemList; if(onlyStarred) rssItemList = daoSession.getRssItemDao().queryBuilder().orderDesc(RssItemDao.Properties.Starred_temp).orderAsc(RssItemDao.Properties.Id).limit(1).list(); else rssItemList = daoSession.getRssItemDao().queryBuilder().orderAsc(RssItemDao.Properties.Id).limit(1).list(); if(rssItemList.size() > 0) return rssItemList.get(0).getId(); return 0; } public long getHighestItemId() { List rssItemList = daoSession.getRssItemDao().queryBuilder().orderDesc(RssItemDao.Properties.Id).limit(1).list(); if(rssItemList.size() > 0) return rssItemList.get(0).getId(); return 0; } public long getLongValueBySQL(String buildSQL) { long result = -1; try (Cursor cursor = daoSession.getDatabase().rawQuery(buildSQL, null)) { if (cursor != null && cursor.moveToFirst()) { result = cursor.getLong(0); } } return result; } public SparseArray getIntegerSparseArrayFromSQL(String buildSQL, int indexKey, int indexValue) { SparseArray result = new SparseArray<>(); try (Cursor cursor = daoSession.getDatabase().rawQuery(buildSQL, null)) { if (cursor != null) { if (cursor.getCount() > 0) { cursor.moveToFirst(); do { int key = cursor.getInt(indexKey); Integer value = cursor.getInt(indexValue); result.put(key, value); } while (cursor.moveToNext()); } } } return result; } public SparseArray getStringSparseArrayFromSQL(String buildSQL, int indexKey, int indexValue) { SparseArray result = new SparseArray<>(); try (Cursor cursor = daoSession.getDatabase().rawQuery(buildSQL, null)) { if (cursor != null) { if (cursor.getCount() > 0) { cursor.moveToFirst(); do { int key = cursor.getInt(indexKey); String value = cursor.getString(indexValue); result.put(key, value); } while (cursor.moveToNext()); } } } return result; } public static String join(Collection col, String delim) { StringBuilder sb = new StringBuilder(); Iterator iter = col.iterator(); if (iter.hasNext()) sb.append(iter.next().toString()); while (iter.hasNext()) { sb.append(delim); sb.append(iter.next().toString()); } return sb.toString(); } public void clearSessionCache() { daoSession.clear(); } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/database/DatabaseHelperOrm.java ================================================ /* * Android ownCloud News * * @author David Luhmer * @copyright 2013 David Luhmer david-dev@live.de * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE * License as published by the Free Software Foundation; either * version 3 of the License, or any later version. * * This library 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 AFFERO GENERAL PUBLIC LICENSE for more details. * * You should have received a copy of the GNU Affero General Public * License along with this library. If not, see . * */ package de.luhmer.owncloudnewsreader.database; import android.content.Context; import android.database.sqlite.SQLiteDatabase; import de.luhmer.owncloudnewsreader.database.model.DaoMaster; import de.luhmer.owncloudnewsreader.database.model.DaoSession; public class DatabaseHelperOrm { private volatile static DaoSession daoSession; public static DaoSession getDaoSession(Context context, String DATABASE_NAME_ORM) { if(daoSession == null) { synchronized (DatabaseHelperOrm.class) { if(daoSession == null) { // As we are in development we will use the DevOpenHelper which drops the database on a schema update DaoMaster.DevOpenHelper helper = new DaoMaster.DevOpenHelper(context, DATABASE_NAME_ORM, null); // Access the database using the helper SQLiteDatabase db = helper.getWritableDatabase(); // Construct the DaoMaster which brokers DAOs for the Domain Objects DaoMaster daoMaster = new DaoMaster(db); // Create the session which is a container for the DAO layer and has a cache which will return handles to the same object across multiple queries daoSession = daoMaster.newSession(); } } } return daoSession; } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/database/generator/DatabaseOrmGenerator.java ================================================ package de.luhmer.owncloudnewsreader.database.generator; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; import de.greenrobot.daogenerator.DaoGenerator; public class DatabaseOrmGenerator { private static final String SCHEMA_OUTPUT_DIR = "./News-Android-App/src/main/java/"; /** * Generator main application which builds all of the schema versions * (including older versions used for migration test purposes) and ensures * business rules are met; these include ensuring we only have a single * current schema instance and the version numbering is correct. */ public static void main(String[] args) throws Exception { List versions = new ArrayList<>(); versions.add(new LastestVersion(true)); validateSchemas(versions); for (SchemaVersion version : versions) { // NB: Test output creates stubs, we have an established testing // standard which should be followed in preference to generating // these stubs. new DaoGenerator().generateAll(version.getSchema(), SCHEMA_OUTPUT_DIR); } } /** * Validate the schema, throws * * @throws IllegalArgumentException * if data is invalid */ public static void validateSchemas(List versions) throws IllegalArgumentException { int numCurrent = 0; Set versionNumbers = new HashSet<>(); for (SchemaVersion version : versions) { if (version.isCurrent()) { numCurrent++; } int versionNumber = version.getVersionNumber(); if (versionNumbers.contains(versionNumber)) { throw new IllegalArgumentException( "Unable to process schema versions, multiple instances with version number : " + version.getVersionNumber()); } versionNumbers.add(versionNumber); } if (numCurrent != 1) { throw new IllegalArgumentException( "Unable to generate schema, exactly one schema marked as current is required."); } } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/database/generator/LastestVersion.java ================================================ package de.luhmer.owncloudnewsreader.database.generator; import de.greenrobot.daogenerator.Entity; import de.greenrobot.daogenerator.Property; import de.greenrobot.daogenerator.Schema; public class LastestVersion extends SchemaVersion { /** * Constructor * * @param current */ public LastestVersion(boolean current) { super(current); Schema schema = getSchema(); addEntitysToSchema(schema); } @SuppressWarnings("unused") // id properties (folderId, etc.) need to be in database private static void addEntitysToSchema(Schema schema) { /* Folder */ Entity folder = schema.addEntity("Folder"); Property folderId = folder.addIdProperty().notNull().getProperty(); folder.addStringProperty("label").notNull(); /* Feed */ Entity feed = schema.addEntity("Feed"); Property feedId = feed.addIdProperty().notNull().getProperty(); Property folderIdProperty = feed.addLongProperty("folderId").index().getProperty(); feed.addStringProperty("feedTitle").notNull(); feed.addStringProperty("faviconUrl"); feed.addStringProperty("link"); feed.addStringProperty("avgColour"); feed.addStringProperty("notificationChannel"); // none, default, feed.addLongProperty("openIn"); /* RSS Item */ Entity rssItem = schema.addEntity("RssItem"); Property rssItemId = rssItem.addIdProperty().notNull().getProperty(); Property rssItemFeedId = rssItem.addLongProperty("feedId").notNull().index().getProperty(); rssItem.addStringProperty("link"); rssItem.addStringProperty("title"); rssItem.addStringProperty("body"); rssItem.addBooleanProperty("read"); rssItem.addBooleanProperty("starred"); rssItem.addStringProperty("author").notNull(); rssItem.addStringProperty("guid").notNull(); rssItem.addStringProperty("guidHash").notNull(); rssItem.addStringProperty("fingerprint").notNull(); rssItem.addBooleanProperty("read_temp"); rssItem.addBooleanProperty("starred_temp"); rssItem.addDateProperty("lastModified"); rssItem.addDateProperty("pubDate"); rssItem.addStringProperty("enclosureLink"); rssItem.addStringProperty("enclosureMime"); rssItem.addStringProperty("mediaThumbnail"); rssItem.addStringProperty("mediaDescription"); rssItem.addBooleanProperty("rtl"); feed.addToOne(folder, folderIdProperty); folder.addToMany(feed, folderIdProperty); feed.addToMany(rssItem, rssItemFeedId); rssItem.addToOne(feed, rssItemFeedId); Entity rssItemView = schema.addEntity("CurrentRssItemView"); rssItemView.addIdProperty().notNull(); rssItemView.addLongProperty("rssItemId").notNull(); rssItem.implementsInterface("HasId"); } /** * {@inheritDoc} */ @Override public int getVersionNumber() { return 10; } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/database/generator/SchemaVersion.java ================================================ package de.luhmer.owncloudnewsreader.database.generator; import de.greenrobot.daogenerator.Schema; public abstract class SchemaVersion { public static final String CURRENT_SCHEMA_PACKAGE = "de.luhmer.owncloudnewsreader.database.model"; private final Schema schema; private final boolean current; /** * Constructor * * @param current indicating if this is the current schema. */ public SchemaVersion(boolean current) { int version = getVersionNumber(); String packageName = CURRENT_SCHEMA_PACKAGE; if (!current) { packageName += ".v" + version; } this.schema = new Schema(version, packageName); this.schema.enableKeepSectionsByDefault(); this.current = current; } /** * @return the GreenDAO schema. */ protected Schema getSchema() { return schema; } /** * @return boolean indicating if this is the highest or current schema version. */ public boolean isCurrent() { return current; } /** * @return unique integer schema version identifier. */ public abstract int getVersionNumber(); } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/database/model/CurrentRssItemView.java ================================================ package de.luhmer.owncloudnewsreader.database.model; // THIS CODE IS GENERATED BY greenDAO, EDIT ONLY INSIDE THE "KEEP"-SECTIONS // KEEP INCLUDES - put your custom includes here // KEEP INCLUDES END /** * Entity mapped to table "CURRENT_RSS_ITEM_VIEW". */ public class CurrentRssItemView { private long id; private long rssItemId; // KEEP FIELDS - put your custom fields here // KEEP FIELDS END public CurrentRssItemView() { } public CurrentRssItemView(long id) { this.id = id; } public CurrentRssItemView(long id, long rssItemId) { this.id = id; this.rssItemId = rssItemId; } public long getId() { return id; } public void setId(long id) { this.id = id; } public long getRssItemId() { return rssItemId; } public void setRssItemId(long rssItemId) { this.rssItemId = rssItemId; } // KEEP METHODS - put your custom methods here // KEEP METHODS END } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/database/model/CurrentRssItemViewDao.java ================================================ package de.luhmer.owncloudnewsreader.database.model; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteStatement; import de.greenrobot.dao.AbstractDao; import de.greenrobot.dao.Property; import de.greenrobot.dao.internal.DaoConfig; // THIS CODE IS GENERATED BY greenDAO, DO NOT EDIT. /** * DAO for table "CURRENT_RSS_ITEM_VIEW". */ public class CurrentRssItemViewDao extends AbstractDao { public static final String TABLENAME = "CURRENT_RSS_ITEM_VIEW"; /** * Drops the underlying database table. */ public static void dropTable(SQLiteDatabase db, boolean ifExists) { String sql = "DROP TABLE " + (ifExists ? "IF EXISTS " : "") + "\"CURRENT_RSS_ITEM_VIEW\""; db.execSQL(sql); } public CurrentRssItemViewDao(DaoConfig config) { super(config); } public CurrentRssItemViewDao(DaoConfig config, DaoSession daoSession) { super(config, daoSession); } /** Creates the underlying database table. */ public static void createTable(SQLiteDatabase db, boolean ifNotExists) { String constraint = ifNotExists? "IF NOT EXISTS ": ""; db.execSQL("CREATE TABLE " + constraint + "\"CURRENT_RSS_ITEM_VIEW\" (" + // "\"_id\" INTEGER PRIMARY KEY NOT NULL ," + // 0: id "\"RSS_ITEM_ID\" INTEGER NOT NULL );"); // 1: rssItemId } /** * @inheritdoc */ @Override protected void bindValues(SQLiteStatement stmt, CurrentRssItemView entity) { stmt.clearBindings(); stmt.bindLong(1, entity.getId()); stmt.bindLong(2, entity.getRssItemId()); } /** * @inheritdoc */ @Override public CurrentRssItemView readEntity(Cursor cursor, int offset) { CurrentRssItemView entity = new CurrentRssItemView( // cursor.getLong(offset), // id cursor.getLong(offset + 1) // rssItemId ); return entity; } /** * @inheritdoc */ @Override public Long readKey(Cursor cursor, int offset) { return cursor.getLong(offset); } /** * Properties of entity CurrentRssItemView.
* Can be used for QueryBuilder and for referencing column names. */ public static class Properties { public final static Property Id = new Property(0, long.class, "id", true, "_id"); public final static Property RssItemId = new Property(1, long.class, "rssItemId", false, "RSS_ITEM_ID"); } /** * @inheritdoc */ @Override public void readEntity(Cursor cursor, CurrentRssItemView entity, int offset) { entity.setId(cursor.getLong(offset)); entity.setRssItemId(cursor.getLong(offset + 1)); } /** * @inheritdoc */ @Override protected Long updateKeyAfterInsert(CurrentRssItemView entity, long rowId) { entity.setId(rowId); return rowId; } /** * @inheritdoc */ @Override public Long getKey(CurrentRssItemView entity) { if (entity != null) { return entity.getId(); } else { return null; } } /** * @inheritdoc */ @Override protected boolean isEntityUpdateable() { return true; } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/database/model/DaoMaster.java ================================================ package de.luhmer.owncloudnewsreader.database.model; import android.content.Context; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteDatabase.CursorFactory; import android.database.sqlite.SQLiteOpenHelper; import android.util.Log; import de.greenrobot.dao.AbstractDaoMaster; import de.greenrobot.dao.identityscope.IdentityScopeType; // THIS CODE IS GENERATED BY greenDAO, DO NOT EDIT. /** * Master of DAO (schema version 10): knows all DAOs. */ public class DaoMaster extends AbstractDaoMaster { public static final int SCHEMA_VERSION = 10; /** Creates underlying database table using DAOs. */ public static void createAllTables(SQLiteDatabase db, boolean ifNotExists) { FolderDao.createTable(db, ifNotExists); FeedDao.createTable(db, ifNotExists); RssItemDao.createTable(db, ifNotExists); CurrentRssItemViewDao.createTable(db, ifNotExists); } /** Drops underlying database table using DAOs. */ public static void dropAllTables(SQLiteDatabase db, boolean ifExists) { FolderDao.dropTable(db, ifExists); FeedDao.dropTable(db, ifExists); RssItemDao.dropTable(db, ifExists); CurrentRssItemViewDao.dropTable(db, ifExists); } public static abstract class OpenHelper extends SQLiteOpenHelper { public OpenHelper(Context context, String name, CursorFactory factory) { super(context, name, factory, SCHEMA_VERSION); } @Override public void onCreate(SQLiteDatabase db) { Log.i("greenDAO", "Creating tables for schema version " + SCHEMA_VERSION); createAllTables(db, false); } } /** WARNING: Drops all table on Upgrade! Use only during development. */ public static class DevOpenHelper extends OpenHelper { public DevOpenHelper(Context context, String name, CursorFactory factory) { super(context, name, factory); } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { Log.i("greenDAO", "Upgrading schema from version " + oldVersion + " to " + newVersion + " by dropping all tables"); dropAllTables(db, true); onCreate(db); } } public DaoMaster(SQLiteDatabase db) { super(db, SCHEMA_VERSION); registerDaoClass(FolderDao.class); registerDaoClass(FeedDao.class); registerDaoClass(RssItemDao.class); registerDaoClass(CurrentRssItemViewDao.class); } public DaoSession newSession() { return new DaoSession(db, IdentityScopeType.Session, daoConfigMap); } public DaoSession newSession(IdentityScopeType type) { return new DaoSession(db, type, daoConfigMap); } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/database/model/DaoSession.java ================================================ package de.luhmer.owncloudnewsreader.database.model; import android.database.sqlite.SQLiteDatabase; import java.util.Map; import de.greenrobot.dao.AbstractDao; import de.greenrobot.dao.AbstractDaoSession; import de.greenrobot.dao.identityscope.IdentityScopeType; import de.greenrobot.dao.internal.DaoConfig; // THIS CODE IS GENERATED BY greenDAO, DO NOT EDIT. /** * {@inheritDoc} * * @see de.greenrobot.dao.AbstractDaoSession */ public class DaoSession extends AbstractDaoSession { private final DaoConfig folderDaoConfig; private final DaoConfig feedDaoConfig; private final DaoConfig rssItemDaoConfig; private final DaoConfig currentRssItemViewDaoConfig; private final FolderDao folderDao; private final FeedDao feedDao; private final RssItemDao rssItemDao; private final CurrentRssItemViewDao currentRssItemViewDao; public DaoSession(SQLiteDatabase db, IdentityScopeType type, Map>, DaoConfig> daoConfigMap) { super(db); folderDaoConfig = daoConfigMap.get(FolderDao.class).clone(); folderDaoConfig.initIdentityScope(type); feedDaoConfig = daoConfigMap.get(FeedDao.class).clone(); feedDaoConfig.initIdentityScope(type); rssItemDaoConfig = daoConfigMap.get(RssItemDao.class).clone(); rssItemDaoConfig.initIdentityScope(type); currentRssItemViewDaoConfig = daoConfigMap.get(CurrentRssItemViewDao.class).clone(); currentRssItemViewDaoConfig.initIdentityScope(type); folderDao = new FolderDao(folderDaoConfig, this); feedDao = new FeedDao(feedDaoConfig, this); rssItemDao = new RssItemDao(rssItemDaoConfig, this); currentRssItemViewDao = new CurrentRssItemViewDao(currentRssItemViewDaoConfig, this); registerDao(Folder.class, folderDao); registerDao(Feed.class, feedDao); registerDao(RssItem.class, rssItemDao); registerDao(CurrentRssItemView.class, currentRssItemViewDao); } public void clear() { folderDaoConfig.getIdentityScope().clear(); feedDaoConfig.getIdentityScope().clear(); rssItemDaoConfig.getIdentityScope().clear(); currentRssItemViewDaoConfig.getIdentityScope().clear(); } public FolderDao getFolderDao() { return folderDao; } public FeedDao getFeedDao() { return feedDao; } public RssItemDao getRssItemDao() { return rssItemDao; } public CurrentRssItemViewDao getCurrentRssItemViewDao() { return currentRssItemViewDao; } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/database/model/Feed.java ================================================ package de.luhmer.owncloudnewsreader.database.model; import java.util.List; import de.greenrobot.dao.DaoException; // THIS CODE IS GENERATED BY greenDAO, EDIT ONLY INSIDE THE "KEEP"-SECTIONS // KEEP INCLUDES - put your custom includes here // KEEP INCLUDES END /** * Entity mapped to table "FEED". */ public class Feed { private long id; private Long folderId; /** Not-null value. */ private String feedTitle; private String faviconUrl; private String link; private String avgColour; private String notificationChannel; private Long openIn; /** Used to resolve relations */ private transient DaoSession daoSession; /** Used for active entity operations. */ private transient FeedDao myDao; private Folder folder; private Long folder__resolvedKey; private List rssItemList; // KEEP FIELDS - put your custom fields here // KEEP FIELDS END public Feed() { } public Feed(long id) { this.id = id; } public Feed(long id, Long folderId, String feedTitle, String faviconUrl, String link, String avgColour, String notificationChannel, Long openIn) { this.id = id; this.folderId = folderId; this.feedTitle = feedTitle; this.faviconUrl = faviconUrl; this.link = link; this.avgColour = avgColour; this.notificationChannel = notificationChannel; this.openIn = openIn; } /** called by internal mechanisms, do not call yourself. */ public void __setDaoSession(DaoSession daoSession) { this.daoSession = daoSession; myDao = daoSession != null ? daoSession.getFeedDao() : null; } public long getId() { return id; } public void setId(long id) { this.id = id; } public Long getFolderId() { return folderId; } public void setFolderId(Long folderId) { this.folderId = folderId; } /** Not-null value. */ public String getFeedTitle() { return feedTitle; } /** Not-null value; ensure this value is available before it is saved to the database. */ public void setFeedTitle(String feedTitle) { this.feedTitle = feedTitle; } public String getFaviconUrl() { return faviconUrl; } public void setFaviconUrl(String faviconUrl) { this.faviconUrl = faviconUrl; } public String getLink() { return link; } public void setLink(String link) { this.link = link; } public String getAvgColour() { return avgColour; } public void setAvgColour(String avgColour) { this.avgColour = avgColour; } public String getNotificationChannel() { return notificationChannel; } public void setNotificationChannel(String notificationChannel) { this.notificationChannel = notificationChannel; } public Long getOpenIn() { return openIn; } public void setOpenIn(Long openIn) { this.openIn = openIn; } /** * To-one relationship, resolved on first access. */ public Folder getFolder() { Long __key = this.folderId; if (folder__resolvedKey == null || !folder__resolvedKey.equals(__key)) { if (daoSession == null) { throw new DaoException("Entity is detached from DAO context"); } FolderDao targetDao = daoSession.getFolderDao(); Folder folderNew = targetDao.load(__key); synchronized (this) { folder = folderNew; folder__resolvedKey = __key; } } return folder; } public void setFolder(Folder folder) { synchronized (this) { this.folder = folder; folderId = folder == null ? null : folder.getId(); folder__resolvedKey = folderId; } } /** To-many relationship, resolved on first access (and after reset). Changes to to-many relations are not persisted, make changes to the target entity. */ public List getRssItemList() { if (rssItemList == null) { if (daoSession == null) { throw new DaoException("Entity is detached from DAO context"); } RssItemDao targetDao = daoSession.getRssItemDao(); List rssItemListNew = targetDao._queryFeed_RssItemList(id); synchronized (this) { if(rssItemList == null) { rssItemList = rssItemListNew; } } } return rssItemList; } /** Resets a to-many relationship, making the next get call to query for a fresh result. */ public synchronized void resetRssItemList() { rssItemList = null; } /** Convenient call for {@link AbstractDao#delete(Object)}. Entity must attached to an entity context. */ public void delete() { if (myDao == null) { throw new DaoException("Entity is detached from DAO context"); } myDao.delete(this); } /** Convenient call for {@link AbstractDao#update(Object)}. Entity must attached to an entity context. */ public void update() { if (myDao == null) { throw new DaoException("Entity is detached from DAO context"); } myDao.update(this); } /** Convenient call for {@link AbstractDao#refresh(Object)}. Entity must attached to an entity context. */ public void refresh() { if (myDao == null) { throw new DaoException("Entity is detached from DAO context"); } myDao.refresh(this); } // KEEP METHODS - put your custom methods here // KEEP METHODS END } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/database/model/FeedDao.java ================================================ package de.luhmer.owncloudnewsreader.database.model; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteStatement; import java.util.ArrayList; import java.util.List; import de.greenrobot.dao.AbstractDao; import de.greenrobot.dao.Property; import de.greenrobot.dao.internal.DaoConfig; import de.greenrobot.dao.internal.SqlUtils; import de.greenrobot.dao.query.Query; import de.greenrobot.dao.query.QueryBuilder; // THIS CODE IS GENERATED BY greenDAO, DO NOT EDIT. /** * DAO for table "FEED". */ public class FeedDao extends AbstractDao { public static final String TABLENAME = "FEED"; /** Creates the underlying database table. */ public static void createTable(SQLiteDatabase db, boolean ifNotExists) { String constraint = ifNotExists ? "IF NOT EXISTS " : ""; db.execSQL("CREATE TABLE " + constraint + "\"FEED\" (" + // "\"_id\" INTEGER PRIMARY KEY NOT NULL ," + // 0: id "\"FOLDER_ID\" INTEGER," + // 1: folderId "\"FEED_TITLE\" TEXT NOT NULL ," + // 2: feedTitle "\"FAVICON_URL\" TEXT," + // 3: faviconUrl "\"LINK\" TEXT," + // 4: link "\"AVG_COLOUR\" TEXT," + // 5: avgColour "\"NOTIFICATION_CHANNEL\" TEXT," + // 6: notificationChannel "\"OPEN_IN\" INTEGER);"); // 7: openIn // Add Indexes db.execSQL("CREATE INDEX " + constraint + "IDX_FEED_FOLDER_ID ON FEED" + " (\"FOLDER_ID\");"); } private DaoSession daoSession; private Query folder_FeedListQuery; public FeedDao(DaoConfig config) { super(config); } public FeedDao(DaoConfig config, DaoSession daoSession) { super(config, daoSession); this.daoSession = daoSession; } /** * Drops the underlying database table. */ public static void dropTable(SQLiteDatabase db, boolean ifExists) { String sql = "DROP TABLE " + (ifExists ? "IF EXISTS " : "") + "\"FEED\""; db.execSQL(sql); } /** * @inheritdoc */ @Override protected void bindValues(SQLiteStatement stmt, Feed entity) { stmt.clearBindings(); stmt.bindLong(1, entity.getId()); Long folderId = entity.getFolderId(); if (folderId != null) { stmt.bindLong(2, folderId); } stmt.bindString(3, entity.getFeedTitle()); String faviconUrl = entity.getFaviconUrl(); if (faviconUrl != null) { stmt.bindString(4, faviconUrl); } String link = entity.getLink(); if (link != null) { stmt.bindString(5, link); } String avgColour = entity.getAvgColour(); if (avgColour != null) { stmt.bindString(6, avgColour); } String notificationChannel = entity.getNotificationChannel(); if (notificationChannel != null) { stmt.bindString(7, notificationChannel); } Long openIn = entity.getOpenIn(); if (openIn != null) { stmt.bindLong(8, openIn); } } @Override protected void attachEntity(Feed entity) { super.attachEntity(entity); entity.__setDaoSession(daoSession); } /** * @inheritdoc */ @Override public Long readKey(Cursor cursor, int offset) { return cursor.getLong(offset); } /** * @inheritdoc */ @Override public Feed readEntity(Cursor cursor, int offset) { Feed entity = new Feed( // cursor.getLong(offset), // id cursor.isNull(offset + 1) ? null : cursor.getLong(offset + 1), // folderId cursor.getString(offset + 2), // feedTitle cursor.isNull(offset + 3) ? null : cursor.getString(offset + 3), // faviconUrl cursor.isNull(offset + 4) ? null : cursor.getString(offset + 4), // link cursor.isNull(offset + 5) ? null : cursor.getString(offset + 5), // avgColour cursor.isNull(offset + 6) ? null : cursor.getString(offset + 6), // notificationChannel cursor.isNull(offset + 7) ? null : cursor.getLong(offset + 7) // openIn ); return entity; } /** * @inheritdoc */ @Override public void readEntity(Cursor cursor, Feed entity, int offset) { entity.setId(cursor.getLong(offset)); entity.setFolderId(cursor.isNull(offset + 1) ? null : cursor.getLong(offset + 1)); entity.setFeedTitle(cursor.getString(offset + 2)); entity.setFaviconUrl(cursor.isNull(offset + 3) ? null : cursor.getString(offset + 3)); entity.setLink(cursor.isNull(offset + 4) ? null : cursor.getString(offset + 4)); entity.setAvgColour(cursor.isNull(offset + 5) ? null : cursor.getString(offset + 5)); entity.setNotificationChannel(cursor.isNull(offset + 6) ? null : cursor.getString(offset + 6)); entity.setOpenIn(cursor.isNull(offset + 7) ? null : cursor.getLong(offset + 7)); } protected Feed loadCurrentDeep(Cursor cursor, boolean lock) { Feed entity = loadCurrent(cursor, 0, lock); int offset = getAllColumns().length; Folder folder = loadCurrentOther(daoSession.getFolderDao(), cursor, offset); entity.setFolder(folder); return entity; } /** * @inheritdoc */ @Override protected Long updateKeyAfterInsert(Feed entity, long rowId) { entity.setId(rowId); return rowId; } /** * @inheritdoc */ @Override public Long getKey(Feed entity) { if (entity != null) { return entity.getId(); } else { return null; } } /** @inheritdoc */ @Override protected boolean isEntityUpdateable() { return true; } /** Internal query to resolve the "feedList" to-many relationship of Folder. */ public List _queryFolder_FeedList(Long folderId) { synchronized (this) { if (folder_FeedListQuery == null) { QueryBuilder queryBuilder = queryBuilder(); queryBuilder.where(Properties.FolderId.eq(null)); folder_FeedListQuery = queryBuilder.build(); } } Query query = folder_FeedListQuery.forCurrentThread(); query.setParameter(0, folderId); return query.list(); } private String selectDeep; protected String getSelectDeep() { if (selectDeep == null) { StringBuilder builder = new StringBuilder("SELECT "); SqlUtils.appendColumns(builder, "T", getAllColumns()); builder.append(','); SqlUtils.appendColumns(builder, "T0", daoSession.getFolderDao().getAllColumns()); builder.append(" FROM FEED T"); builder.append(" LEFT JOIN FOLDER T0 ON T.\"FOLDER_ID\"=T0.\"_id\""); builder.append(' '); selectDeep = builder.toString(); } return selectDeep; } /** Reads all available rows from the given cursor and returns a list of new ImageTO objects. */ public List loadAllDeepFromCursor(Cursor cursor) { int count = cursor.getCount(); List list = new ArrayList(count); if (cursor.moveToFirst()) { if (identityScope != null) { identityScope.lock(); identityScope.reserveRoom(count); } try { do { list.add(loadCurrentDeep(cursor, false)); } while (cursor.moveToNext()); } finally { if (identityScope != null) { identityScope.unlock(); } } } return list; } public Feed loadDeep(Long key) { assertSinglePk(); if (key == null) { return null; } StringBuilder builder = new StringBuilder(getSelectDeep()); builder.append("WHERE "); SqlUtils.appendColumnsEqValue(builder, "T", getPkColumns()); String sql = builder.toString(); String[] keyArray = new String[]{key.toString()}; Cursor cursor = db.rawQuery(sql, keyArray); try { boolean available = cursor.moveToFirst(); if (!available) { return null; } else if (!cursor.isLast()) { throw new IllegalStateException("Expected unique result, but count was " + cursor.getCount()); } return loadCurrentDeep(cursor, true); } finally { cursor.close(); } } /** * Properties of entity Feed.
* Can be used for QueryBuilder and for referencing column names. */ public static class Properties { public final static Property Id = new Property(0, long.class, "id", true, "_id"); public final static Property FolderId = new Property(1, Long.class, "folderId", false, "FOLDER_ID"); public final static Property FeedTitle = new Property(2, String.class, "feedTitle", false, "FEED_TITLE"); public final static Property FaviconUrl = new Property(3, String.class, "faviconUrl", false, "FAVICON_URL"); public final static Property Link = new Property(4, String.class, "link", false, "LINK"); public final static Property AvgColour = new Property(5, String.class, "avgColour", false, "AVG_COLOUR"); public final static Property NotificationChannel = new Property(6, String.class, "notificationChannel", false, "NOTIFICATION_CHANNEL"); public final static Property OpenIn = new Property(7, Long.class, "openIn", false, "OPEN_IN"); } protected List loadDeepAllAndCloseCursor(Cursor cursor) { try { return loadAllDeepFromCursor(cursor); } finally { cursor.close(); } } /** A raw-style query where you can pass any WHERE clause and arguments. */ public List queryDeep(String where, String... selectionArg) { Cursor cursor = db.rawQuery(getSelectDeep() + where, selectionArg); return loadDeepAllAndCloseCursor(cursor); } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/database/model/Folder.java ================================================ package de.luhmer.owncloudnewsreader.database.model; import java.util.List; import de.greenrobot.dao.DaoException; // THIS CODE IS GENERATED BY greenDAO, EDIT ONLY INSIDE THE "KEEP"-SECTIONS // KEEP INCLUDES - put your custom includes here // KEEP INCLUDES END /** * Entity mapped to table "FOLDER". */ public class Folder { private long id; /** Not-null value. */ private String label; /** Used to resolve relations */ private transient DaoSession daoSession; /** Used for active entity operations. */ private transient FolderDao myDao; private List feedList; // KEEP FIELDS - put your custom fields here // KEEP FIELDS END public Folder() { } public Folder(long id) { this.id = id; } public Folder(long id, String label) { this.id = id; this.label = label; } /** called by internal mechanisms, do not call yourself. */ public void __setDaoSession(DaoSession daoSession) { this.daoSession = daoSession; myDao = daoSession != null ? daoSession.getFolderDao() : null; } public long getId() { return id; } public void setId(long id) { this.id = id; } /** Not-null value. */ public String getLabel() { return label; } /** Not-null value; ensure this value is available before it is saved to the database. */ public void setLabel(String label) { this.label = label; } /** To-many relationship, resolved on first access (and after reset). Changes to to-many relations are not persisted, make changes to the target entity. */ public List getFeedList() { if (feedList == null) { if (daoSession == null) { throw new DaoException("Entity is detached from DAO context"); } FeedDao targetDao = daoSession.getFeedDao(); List feedListNew = targetDao._queryFolder_FeedList(id); synchronized (this) { if(feedList == null) { feedList = feedListNew; } } } return feedList; } /** Resets a to-many relationship, making the next get call to query for a fresh result. */ public synchronized void resetFeedList() { feedList = null; } /** Convenient call for {@link AbstractDao#delete(Object)}. Entity must attached to an entity context. */ public void delete() { if (myDao == null) { throw new DaoException("Entity is detached from DAO context"); } myDao.delete(this); } /** Convenient call for {@link AbstractDao#update(Object)}. Entity must attached to an entity context. */ public void update() { if (myDao == null) { throw new DaoException("Entity is detached from DAO context"); } myDao.update(this); } /** Convenient call for {@link AbstractDao#refresh(Object)}. Entity must attached to an entity context. */ public void refresh() { if (myDao == null) { throw new DaoException("Entity is detached from DAO context"); } myDao.refresh(this); } // KEEP METHODS - put your custom methods here // KEEP METHODS END } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/database/model/FolderDao.java ================================================ package de.luhmer.owncloudnewsreader.database.model; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteStatement; import de.greenrobot.dao.AbstractDao; import de.greenrobot.dao.Property; import de.greenrobot.dao.internal.DaoConfig; // THIS CODE IS GENERATED BY greenDAO, DO NOT EDIT. /** * DAO for table "FOLDER". */ public class FolderDao extends AbstractDao { public static final String TABLENAME = "FOLDER"; /** * @inheritdoc */ @Override public Long readKey(Cursor cursor, int offset) { return cursor.getLong(offset); } private DaoSession daoSession; public FolderDao(DaoConfig config) { super(config); } public FolderDao(DaoConfig config, DaoSession daoSession) { super(config, daoSession); this.daoSession = daoSession; } /** Creates the underlying database table. */ public static void createTable(SQLiteDatabase db, boolean ifNotExists) { String constraint = ifNotExists? "IF NOT EXISTS ": ""; db.execSQL("CREATE TABLE " + constraint + "\"FOLDER\" (" + // "\"_id\" INTEGER PRIMARY KEY NOT NULL ," + // 0: id "\"LABEL\" TEXT NOT NULL );"); // 1: label } /** Drops the underlying database table. */ public static void dropTable(SQLiteDatabase db, boolean ifExists) { String sql = "DROP TABLE " + (ifExists ? "IF EXISTS " : "") + "\"FOLDER\""; db.execSQL(sql); } /** @inheritdoc */ @Override protected void bindValues(SQLiteStatement stmt, Folder entity) { stmt.clearBindings(); stmt.bindLong(1, entity.getId()); stmt.bindString(2, entity.getLabel()); } @Override protected void attachEntity(Folder entity) { super.attachEntity(entity); entity.__setDaoSession(daoSession); } /** * @inheritdoc */ @Override public Folder readEntity(Cursor cursor, int offset) { Folder entity = new Folder( // cursor.getLong(offset), // id cursor.getString(offset + 1) // label ); return entity; } /** * Properties of entity Folder.
* Can be used for QueryBuilder and for referencing column names. */ public static class Properties { public final static Property Id = new Property(0, long.class, "id", true, "_id"); public final static Property Label = new Property(1, String.class, "label", false, "LABEL"); } /** * @inheritdoc */ @Override public void readEntity(Cursor cursor, Folder entity, int offset) { entity.setId(cursor.getLong(offset)); entity.setLabel(cursor.getString(offset + 1)); } /** * @inheritdoc */ @Override protected Long updateKeyAfterInsert(Folder entity, long rowId) { entity.setId(rowId); return rowId; } /** * @inheritdoc */ @Override public Long getKey(Folder entity) { if (entity != null) { return entity.getId(); } else { return null; } } /** * @inheritdoc */ @Override protected boolean isEntityUpdateable() { return true; } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/database/model/RssItem.java ================================================ package de.luhmer.owncloudnewsreader.database.model; import de.greenrobot.dao.DaoException; import de.luhmer.owncloudnewsreader.adapter.HasId; // THIS CODE IS GENERATED BY greenDAO, EDIT ONLY INSIDE THE "KEEP"-SECTIONS // KEEP INCLUDES - put your custom includes here // KEEP INCLUDES END /** * Entity mapped to table "RSS_ITEM". */ public class RssItem implements HasId { private long id; private long feedId; private String link; private String title; private String body; private Boolean read; private Boolean starred; /** Not-null value. */ private String author; /** Not-null value. */ private String guid; /** Not-null value. */ private String guidHash; /** Not-null value. */ private String fingerprint; private Boolean read_temp; private Boolean starred_temp; private java.util.Date lastModified; private java.util.Date pubDate; private String enclosureLink; private String enclosureMime; private String mediaThumbnail; private String mediaDescription; private Boolean rtl; /** Used to resolve relations */ private transient DaoSession daoSession; /** Used for active entity operations. */ private transient RssItemDao myDao; private Feed feed; private Long feed__resolvedKey; // KEEP FIELDS - put your custom fields here // KEEP FIELDS END public RssItem() { } public RssItem(long id) { this.id = id; } public RssItem(long id, long feedId, String link, String title, String body, Boolean read, Boolean starred, String author, String guid, String guidHash, String fingerprint, Boolean read_temp, Boolean starred_temp, java.util.Date lastModified, java.util.Date pubDate, String enclosureLink, String enclosureMime, String mediaThumbnail, String mediaDescription, Boolean rtl) { this.id = id; this.feedId = feedId; this.link = link; this.title = title; this.body = body; this.read = read; this.starred = starred; this.author = author; this.guid = guid; this.guidHash = guidHash; this.fingerprint = fingerprint; this.read_temp = read_temp; this.starred_temp = starred_temp; this.lastModified = lastModified; this.pubDate = pubDate; this.enclosureLink = enclosureLink; this.enclosureMime = enclosureMime; this.mediaThumbnail = mediaThumbnail; this.mediaDescription = mediaDescription; this.rtl = rtl; } /** called by internal mechanisms, do not call yourself. */ public void __setDaoSession(DaoSession daoSession) { this.daoSession = daoSession; myDao = daoSession != null ? daoSession.getRssItemDao() : null; } public Long getId() { return id; } public void setId(long id) { this.id = id; } public long getFeedId() { return feedId; } public void setFeedId(long feedId) { this.feedId = feedId; } public String getLink() { return link; } public void setLink(String link) { this.link = link; } public String getTitle() { return title; } public void setTitle(String title) { this.title = title; } public String getBody() { return body; } public void setBody(String body) { this.body = body; } public Boolean getRead() { return read; } public void setRead(Boolean read) { this.read = read; } public Boolean getStarred() { return starred; } public void setStarred(Boolean starred) { this.starred = starred; } /** Not-null value. */ public String getAuthor() { return author; } /** Not-null value; ensure this value is available before it is saved to the database. */ public void setAuthor(String author) { this.author = author; } /** Not-null value. */ public String getGuid() { return guid; } /** Not-null value; ensure this value is available before it is saved to the database. */ public void setGuid(String guid) { this.guid = guid; } /** Not-null value. */ public String getGuidHash() { return guidHash; } /** Not-null value; ensure this value is available before it is saved to the database. */ public void setGuidHash(String guidHash) { this.guidHash = guidHash; } /** Not-null value. */ public String getFingerprint() { return fingerprint; } /** Not-null value; ensure this value is available before it is saved to the database. */ public void setFingerprint(String fingerprint) { this.fingerprint = fingerprint; } public Boolean getRead_temp() { return read_temp; } public void setRead_temp(Boolean read_temp) { this.read_temp = read_temp; } public Boolean getStarred_temp() { return starred_temp; } public void setStarred_temp(Boolean starred_temp) { this.starred_temp = starred_temp; } public java.util.Date getLastModified() { return lastModified; } public void setLastModified(java.util.Date lastModified) { this.lastModified = lastModified; } public java.util.Date getPubDate() { return pubDate; } public void setPubDate(java.util.Date pubDate) { this.pubDate = pubDate; } public String getEnclosureLink() { return enclosureLink; } public void setEnclosureLink(String enclosureLink) { this.enclosureLink = enclosureLink; } public String getEnclosureMime() { return enclosureMime; } public void setEnclosureMime(String enclosureMime) { this.enclosureMime = enclosureMime; } public String getMediaThumbnail() { return mediaThumbnail; } public void setMediaThumbnail(String mediaThumbnail) { this.mediaThumbnail = mediaThumbnail; } public String getMediaDescription() { return mediaDescription; } public void setMediaDescription(String mediaDescription) { this.mediaDescription = mediaDescription; } public Boolean getRtl() { return rtl; } public void setRtl(Boolean rtl) { this.rtl = rtl; } /** To-one relationship, resolved on first access. */ public Feed getFeed() { long __key = this.feedId; if (feed__resolvedKey == null || !feed__resolvedKey.equals(__key)) { if (daoSession == null) { throw new DaoException("Entity is detached from DAO context"); } FeedDao targetDao = daoSession.getFeedDao(); Feed feedNew = targetDao.load(__key); synchronized (this) { feed = feedNew; feed__resolvedKey = __key; } } return feed; } public void setFeed(Feed feed) { if (feed == null) { throw new DaoException("To-one property 'feedId' has not-null constraint; cannot set to-one to null"); } synchronized (this) { this.feed = feed; feedId = feed.getId(); feed__resolvedKey = feedId; } } /** Convenient call for {@link AbstractDao#delete(Object)}. Entity must attached to an entity context. */ public void delete() { if (myDao == null) { throw new DaoException("Entity is detached from DAO context"); } myDao.delete(this); } /** Convenient call for {@link AbstractDao#update(Object)}. Entity must attached to an entity context. */ public void update() { if (myDao == null) { throw new DaoException("Entity is detached from DAO context"); } myDao.update(this); } /** Convenient call for {@link AbstractDao#refresh(Object)}. Entity must attached to an entity context. */ public void refresh() { if (myDao == null) { throw new DaoException("Entity is detached from DAO context"); } myDao.refresh(this); } // KEEP METHODS - put your custom methods here // KEEP METHODS END } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/database/model/RssItemDao.java ================================================ package de.luhmer.owncloudnewsreader.database.model; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteStatement; import java.util.ArrayList; import java.util.List; import de.greenrobot.dao.AbstractDao; import de.greenrobot.dao.Property; import de.greenrobot.dao.internal.DaoConfig; import de.greenrobot.dao.internal.SqlUtils; import de.greenrobot.dao.query.Query; import de.greenrobot.dao.query.QueryBuilder; // THIS CODE IS GENERATED BY greenDAO, DO NOT EDIT. /** * DAO for table "RSS_ITEM". */ public class RssItemDao extends AbstractDao { public static final String TABLENAME = "RSS_ITEM"; /** @inheritdoc */ @Override protected void bindValues(SQLiteStatement stmt, RssItem entity) { stmt.clearBindings(); stmt.bindLong(1, entity.getId()); stmt.bindLong(2, entity.getFeedId()); String link = entity.getLink(); if (link != null) { stmt.bindString(3, link); } String title = entity.getTitle(); if (title != null) { stmt.bindString(4, title); } String body = entity.getBody(); if (body != null) { stmt.bindString(5, body); } Boolean read = entity.getRead(); if (read != null) { stmt.bindLong(6, read ? 1L: 0L); } Boolean starred = entity.getStarred(); if (starred != null) { stmt.bindLong(7, starred ? 1L: 0L); } stmt.bindString(8, entity.getAuthor()); stmt.bindString(9, entity.getGuid()); stmt.bindString(10, entity.getGuidHash()); stmt.bindString(11, entity.getFingerprint()); Boolean read_temp = entity.getRead_temp(); if (read_temp != null) { stmt.bindLong(12, read_temp ? 1L: 0L); } Boolean starred_temp = entity.getStarred_temp(); if (starred_temp != null) { stmt.bindLong(13, starred_temp ? 1L: 0L); } java.util.Date lastModified = entity.getLastModified(); if (lastModified != null) { stmt.bindLong(14, lastModified.getTime()); } java.util.Date pubDate = entity.getPubDate(); if (pubDate != null) { stmt.bindLong(15, pubDate.getTime()); } String enclosureLink = entity.getEnclosureLink(); if (enclosureLink != null) { stmt.bindString(16, enclosureLink); } String enclosureMime = entity.getEnclosureMime(); if (enclosureMime != null) { stmt.bindString(17, enclosureMime); } String mediaThumbnail = entity.getMediaThumbnail(); if (mediaThumbnail != null) { stmt.bindString(18, mediaThumbnail); } String mediaDescription = entity.getMediaDescription(); if (mediaDescription != null) { stmt.bindString(19, mediaDescription); } Boolean rtl = entity.getRtl(); if (rtl != null) { stmt.bindLong(20, rtl ? 1L : 0L); } } private DaoSession daoSession; private Query feed_RssItemListQuery; public RssItemDao(DaoConfig config) { super(config); } public RssItemDao(DaoConfig config, DaoSession daoSession) { super(config, daoSession); this.daoSession = daoSession; } /** Creates the underlying database table. */ public static void createTable(SQLiteDatabase db, boolean ifNotExists) { String constraint = ifNotExists? "IF NOT EXISTS ": ""; db.execSQL("CREATE TABLE " + constraint + "\"RSS_ITEM\" (" + // "\"_id\" INTEGER PRIMARY KEY NOT NULL ," + // 0: id "\"FEED_ID\" INTEGER NOT NULL ," + // 1: feedId "\"LINK\" TEXT," + // 2: link "\"TITLE\" TEXT," + // 3: title "\"BODY\" TEXT," + // 4: body "\"READ\" INTEGER," + // 5: read "\"STARRED\" INTEGER," + // 6: starred "\"AUTHOR\" TEXT NOT NULL ," + // 7: author "\"GUID\" TEXT NOT NULL ," + // 8: guid "\"GUID_HASH\" TEXT NOT NULL ," + // 9: guidHash "\"FINGERPRINT\" TEXT NOT NULL ," + // 10: fingerprint "\"READ_TEMP\" INTEGER," + // 11: read_temp "\"STARRED_TEMP\" INTEGER," + // 12: starred_temp "\"LAST_MODIFIED\" INTEGER," + // 13: lastModified "\"PUB_DATE\" INTEGER," + // 14: pubDate "\"ENCLOSURE_LINK\" TEXT," + // 15: enclosureLink "\"ENCLOSURE_MIME\" TEXT," + // 16: enclosureMime "\"MEDIA_THUMBNAIL\" TEXT," + // 17: mediaThumbnail "\"MEDIA_DESCRIPTION\" TEXT," + // 18: mediaDescription "\"RTL\" INTEGER);"); // 19: rtl // Add Indexes db.execSQL("CREATE INDEX " + constraint + "IDX_RSS_ITEM_FEED_ID ON RSS_ITEM" + " (\"FEED_ID\");"); } /** Drops the underlying database table. */ public static void dropTable(SQLiteDatabase db, boolean ifExists) { String sql = "DROP TABLE " + (ifExists ? "IF EXISTS " : "") + "\"RSS_ITEM\""; db.execSQL(sql); } /** * @inheritdoc */ @Override public Long readKey(Cursor cursor, int offset) { return cursor.getLong(offset); } @Override protected void attachEntity(RssItem entity) { super.attachEntity(entity); entity.__setDaoSession(daoSession); } /** * @inheritdoc */ @Override public RssItem readEntity(Cursor cursor, int offset) { RssItem entity = new RssItem( // cursor.getLong(offset), // id cursor.getLong(offset + 1), // feedId cursor.isNull(offset + 2) ? null : cursor.getString(offset + 2), // link cursor.isNull(offset + 3) ? null : cursor.getString(offset + 3), // title cursor.isNull(offset + 4) ? null : cursor.getString(offset + 4), // body cursor.isNull(offset + 5) ? null : cursor.getShort(offset + 5) != 0, // read cursor.isNull(offset + 6) ? null : cursor.getShort(offset + 6) != 0, // starred cursor.getString(offset + 7), // author cursor.getString(offset + 8), // guid cursor.getString(offset + 9), // guidHash cursor.getString(offset + 10), // fingerprint cursor.isNull(offset + 11) ? null : cursor.getShort(offset + 11) != 0, // read_temp cursor.isNull(offset + 12) ? null : cursor.getShort(offset + 12) != 0, // starred_temp cursor.isNull(offset + 13) ? null : new java.util.Date(cursor.getLong(offset + 13)), // lastModified cursor.isNull(offset + 14) ? null : new java.util.Date(cursor.getLong(offset + 14)), // pubDate cursor.isNull(offset + 15) ? null : cursor.getString(offset + 15), // enclosureLink cursor.isNull(offset + 16) ? null : cursor.getString(offset + 16), // enclosureMime cursor.isNull(offset + 17) ? null : cursor.getString(offset + 17), // mediaThumbnail cursor.isNull(offset + 18) ? null : cursor.getString(offset + 18), // mediaDescription cursor.isNull(offset + 19) ? null : cursor.getShort(offset + 19) != 0 // rtl ); return entity; } protected RssItem loadCurrentDeep(Cursor cursor, boolean lock) { RssItem entity = loadCurrent(cursor, 0, lock); int offset = getAllColumns().length; Feed feed = loadCurrentOther(daoSession.getFeedDao(), cursor, offset); if (feed != null) { entity.setFeed(feed); } return entity; } /** @inheritdoc */ @Override public void readEntity(Cursor cursor, RssItem entity, int offset) { entity.setId(cursor.getLong(offset)); entity.setFeedId(cursor.getLong(offset + 1)); entity.setLink(cursor.isNull(offset + 2) ? null : cursor.getString(offset + 2)); entity.setTitle(cursor.isNull(offset + 3) ? null : cursor.getString(offset + 3)); entity.setBody(cursor.isNull(offset + 4) ? null : cursor.getString(offset + 4)); entity.setRead(cursor.isNull(offset + 5) ? null : cursor.getShort(offset + 5) != 0); entity.setStarred(cursor.isNull(offset + 6) ? null : cursor.getShort(offset + 6) != 0); entity.setAuthor(cursor.getString(offset + 7)); entity.setGuid(cursor.getString(offset + 8)); entity.setGuidHash(cursor.getString(offset + 9)); entity.setFingerprint(cursor.getString(offset + 10)); entity.setRead_temp(cursor.isNull(offset + 11) ? null : cursor.getShort(offset + 11) != 0); entity.setStarred_temp(cursor.isNull(offset + 12) ? null : cursor.getShort(offset + 12) != 0); entity.setLastModified(cursor.isNull(offset + 13) ? null : new java.util.Date(cursor.getLong(offset + 13))); entity.setPubDate(cursor.isNull(offset + 14) ? null : new java.util.Date(cursor.getLong(offset + 14))); entity.setEnclosureLink(cursor.isNull(offset + 15) ? null : cursor.getString(offset + 15)); entity.setEnclosureMime(cursor.isNull(offset + 16) ? null : cursor.getString(offset + 16)); entity.setMediaThumbnail(cursor.isNull(offset + 17) ? null : cursor.getString(offset + 17)); entity.setMediaDescription(cursor.isNull(offset + 18) ? null : cursor.getString(offset + 18)); entity.setRtl(cursor.isNull(offset + 19) ? null : cursor.getShort(offset + 19) != 0); } /** * @inheritdoc */ @Override protected Long updateKeyAfterInsert(RssItem entity, long rowId) { entity.setId(rowId); return rowId; } /** * @inheritdoc */ @Override public Long getKey(RssItem entity) { if (entity != null) { return entity.getId(); } else { return null; } } /** * @inheritdoc */ @Override protected boolean isEntityUpdateable() { return true; } /** * Internal query to resolve the "rssItemList" to-many relationship of Feed. */ public List _queryFeed_RssItemList(long feedId) { synchronized (this) { if (feed_RssItemListQuery == null) { QueryBuilder queryBuilder = queryBuilder(); queryBuilder.where(Properties.FeedId.eq(null)); feed_RssItemListQuery = queryBuilder.build(); } } Query query = feed_RssItemListQuery.forCurrentThread(); query.setParameter(0, feedId); return query.list(); } private String selectDeep; protected String getSelectDeep() { if (selectDeep == null) { StringBuilder builder = new StringBuilder("SELECT "); SqlUtils.appendColumns(builder, "T", getAllColumns()); builder.append(','); SqlUtils.appendColumns(builder, "T0", daoSession.getFeedDao().getAllColumns()); builder.append(" FROM RSS_ITEM T"); builder.append(" LEFT JOIN FEED T0 ON T.\"FEED_ID\"=T0.\"_id\""); builder.append(' '); selectDeep = builder.toString(); } return selectDeep; } /** * Properties of entity RssItem.
* Can be used for QueryBuilder and for referencing column names. */ public static class Properties { public final static Property Id = new Property(0, long.class, "id", true, "_id"); public final static Property FeedId = new Property(1, long.class, "feedId", false, "FEED_ID"); public final static Property Link = new Property(2, String.class, "link", false, "LINK"); public final static Property Title = new Property(3, String.class, "title", false, "TITLE"); public final static Property Body = new Property(4, String.class, "body", false, "BODY"); public final static Property Read = new Property(5, Boolean.class, "read", false, "READ"); public final static Property Starred = new Property(6, Boolean.class, "starred", false, "STARRED"); public final static Property Author = new Property(7, String.class, "author", false, "AUTHOR"); public final static Property Guid = new Property(8, String.class, "guid", false, "GUID"); public final static Property GuidHash = new Property(9, String.class, "guidHash", false, "GUID_HASH"); public final static Property Fingerprint = new Property(10, String.class, "fingerprint", false, "FINGERPRINT"); public final static Property Read_temp = new Property(11, Boolean.class, "read_temp", false, "READ_TEMP"); public final static Property Starred_temp = new Property(12, Boolean.class, "starred_temp", false, "STARRED_TEMP"); public final static Property LastModified = new Property(13, java.util.Date.class, "lastModified", false, "LAST_MODIFIED"); public final static Property PubDate = new Property(14, java.util.Date.class, "pubDate", false, "PUB_DATE"); public final static Property EnclosureLink = new Property(15, String.class, "enclosureLink", false, "ENCLOSURE_LINK"); public final static Property EnclosureMime = new Property(16, String.class, "enclosureMime", false, "ENCLOSURE_MIME"); public final static Property MediaThumbnail = new Property(17, String.class, "mediaThumbnail", false, "MEDIA_THUMBNAIL"); public final static Property MediaDescription = new Property(18, String.class, "mediaDescription", false, "MEDIA_DESCRIPTION"); public final static Property Rtl = new Property(19, Boolean.class, "rtl", false, "RTL"); } public RssItem loadDeep(Long key) { assertSinglePk(); if (key == null) { return null; } StringBuilder builder = new StringBuilder(getSelectDeep()); builder.append("WHERE "); SqlUtils.appendColumnsEqValue(builder, "T", getPkColumns()); String sql = builder.toString(); String[] keyArray = new String[]{key.toString()}; Cursor cursor = db.rawQuery(sql, keyArray); try { boolean available = cursor.moveToFirst(); if (!available) { return null; } else if (!cursor.isLast()) { throw new IllegalStateException("Expected unique result, but count was " + cursor.getCount()); } return loadCurrentDeep(cursor, true); } finally { cursor.close(); } } /** * Reads all available rows from the given cursor and returns a list of new ImageTO objects. */ public List loadAllDeepFromCursor(Cursor cursor) { int count = cursor.getCount(); List list = new ArrayList(count); if (cursor.moveToFirst()) { if (identityScope != null) { identityScope.lock(); identityScope.reserveRoom(count); } try { do { list.add(loadCurrentDeep(cursor, false)); } while (cursor.moveToNext()); } finally { if (identityScope != null) { identityScope.unlock(); } } } return list; } protected List loadDeepAllAndCloseCursor(Cursor cursor) { try { return loadAllDeepFromCursor(cursor); } finally { cursor.close(); } } /** A raw-style query where you can pass any WHERE clause and arguments. */ public List queryDeep(String where, String... selectionArg) { Cursor cursor = db.rawQuery(getSelectDeep() + where, selectionArg); return loadDeepAllAndCloseCursor(cursor); } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/di/ApiModule.java ================================================ package de.luhmer.owncloudnewsreader.di; import android.app.Application; import android.content.Context; import android.content.SharedPreferences; import com.google.gson.FieldNamingPolicy; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import javax.inject.Named; import javax.inject.Singleton; import dagger.Module; import dagger.Provides; import de.luhmer.owncloudnewsreader.helper.PostDelayHandler; import de.luhmer.owncloudnewsreader.helper.ThemeChooser; import de.luhmer.owncloudnewsreader.ssl.MemorizingTrustManager; import okhttp3.Cache; import okhttp3.OkHttpClient; /** * Created by david on 22.05.17. */ @Module public class ApiModule { private final Application mApplication; public ApiModule(Application application) { this.mApplication = application; } // Dagger will only look for methods annotated with @Provides @Provides @Singleton // Application reference must come from AppModule.class SharedPreferences providesSharedPreferences() { //return PreferenceManager.getDefaultSharedPreferences(mApplication); SharedPreferences mPrefs = mApplication.getSharedPreferences(providesSharedPreferencesFileName(), Context.MODE_PRIVATE); ThemeChooser.init(mPrefs); return mPrefs; } // Dagger will only look for methods annotated with @Provides @Provides @Named("sharedPreferencesFileName") public String providesSharedPreferencesFileName() { //return PreferenceManager.getDefaultSharedPreferencesName(mApplication); return mApplication.getPackageName() + "_preferences"; } // Dagger will only look for methods annotated with @Provides @Provides @Named("databaseFileName") public String providesDatabaseFileName() { //return PreferenceManager.getDefaultSharedPreferencesName(mApplication); return "OwncloudNewsReaderOrm.db"; } /* @Provides @Singleton NextcloudAPI providexNextcloudAPI() { return new NextcloudAPI(""); }*/ /* @Provides @Singleton Cache provideOkHttpCache(Application application) { int cacheSize = 10 * 1024 * 1024; // 10 MiB Cache cache = new Cache(application.getCacheDir(), cacheSize); return cache; }*/ @Provides @Singleton Gson provideGson() { GsonBuilder gsonBuilder = new GsonBuilder(); gsonBuilder.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES); return gsonBuilder.create(); } @Provides @Singleton OkHttpClient provideOkHttpClient(Cache cache) { // setCache(cache); return new OkHttpClient(); } @Provides @Singleton PostDelayHandler providePostDelayHandler() { return new PostDelayHandler(mApplication); } /* @Provides @Singleton Retrofit provideRetrofit(String baseUrl, Gson gson, OkHttpClient okHttpClient) { Retrofit retrofit = new Retrofit.Builder() .addConverterFactory(GsonConverterFactory.create(gson)) .baseUrl(baseUrl) .client(okHttpClient) .build(); return retrofit; } */ @Provides @Singleton MemorizingTrustManager provideMTM() { return new MemorizingTrustManager(mApplication); } @Provides @Singleton ApiProvider provideAPI(MemorizingTrustManager mtm, SharedPreferences sp) { return new ApiProvider(mtm, sp, mApplication); } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/di/ApiProvider.java ================================================ package de.luhmer.owncloudnewsreader.di; import android.content.Context; import android.content.SharedPreferences; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.VisibleForTesting; import com.nextcloud.android.sso.api.NextcloudAPI; import com.nextcloud.android.sso.exceptions.SSOException; import com.nextcloud.android.sso.helper.SingleAccountHelper; import com.nextcloud.android.sso.model.SingleSignOnAccount; import de.luhmer.owncloudnewsreader.SettingsActivity; import de.luhmer.owncloudnewsreader.helper.GsonConfig; import de.luhmer.owncloudnewsreader.reader.nextcloud.NewsAPI; import de.luhmer.owncloudnewsreader.reader.nextcloud.OcsAPI; import de.luhmer.owncloudnewsreader.ssl.MemorizingTrustManager; import de.luhmer.owncloudnewsreader.ssl.OkHttpSSLClient; import okhttp3.HttpUrl; import okhttp3.OkHttpClient; import retrofit2.NextcloudRetrofitApiBuilder; import retrofit2.Retrofit; import retrofit2.adapter.rxjava3.RxJava3CallAdapterFactory; import retrofit2.converter.gson.GsonConverterFactory; /** * Created by david on 26.05.17. */ public class ApiProvider { private static final String TAG = ApiProvider.class.getCanonicalName(); private final MemorizingTrustManager mMemorizingTrustManager; protected final SharedPreferences mPrefs; protected Context context; private NextcloudAPI mNextcloudSsoApi; protected NewsAPI mNewsApi; private OcsAPI mServerApi; public ApiProvider(MemorizingTrustManager mtm, SharedPreferences sp, Context context) { this.mMemorizingTrustManager = mtm; this.mPrefs = sp; this.context = context; initApi(new NextcloudAPI.ApiConnectedListener() { @Override public void onConnected() { } @Override public void onError(Exception ex) { } }); } public void initApi(@NonNull NextcloudAPI.ApiConnectedListener apiConnectedListener) { if(mNextcloudSsoApi != null) { // Destroy previous Service Connection if we need to reconnect (e.g. login again) mNextcloudSsoApi.close(); mNextcloudSsoApi = null; } boolean useSSO = mPrefs.getBoolean(SettingsActivity.SW_USE_SINGLE_SIGN_ON, false); if(useSSO) { initSsoApi(apiConnectedListener); } else { if(mPrefs.contains(SettingsActivity.EDT_OWNCLOUDROOTPATH_STRING)) { String username = mPrefs.getString(SettingsActivity.EDT_USERNAME_STRING, ""); String password = mPrefs.getString(SettingsActivity.EDT_PASSWORD_STRING, ""); String baseUrlStr = mPrefs.getString(SettingsActivity.EDT_OWNCLOUDROOTPATH_STRING, null); HttpUrl baseUrl = HttpUrl.parse(baseUrlStr).newBuilder() .addPathSegments("index.php/apps/news/api/v1-2/") .build(); Log.d("ApiModule", "HttpUrl: " + baseUrl); OkHttpClient client = OkHttpSSLClient.GetSslClient(baseUrl, username, password, mPrefs, mMemorizingTrustManager); initRetrofitApi(baseUrl, client); apiConnectedListener.onConnected(); } else { apiConnectedListener.onError(new Exception("no login data")); } } } private void initRetrofitApi(HttpUrl baseUrl, OkHttpClient client) { Retrofit retrofit = new Retrofit.Builder() .addConverterFactory(GsonConverterFactory.create(GsonConfig.GetGson())) .addCallAdapterFactory(RxJava3CallAdapterFactory.create()) .baseUrl(baseUrl) .client(client) .build(); mNewsApi = retrofit.create(NewsAPI.class); mServerApi = null; } protected void initSsoApi(final NextcloudAPI.ApiConnectedListener callback) { try { SingleSignOnAccount ssoAccount = SingleAccountHelper.getCurrentSingleSignOnAccount(context); mNextcloudSsoApi = new NextcloudAPI(context, ssoAccount, GsonConfig.GetGson(), callback); mNewsApi = new NextcloudRetrofitApiBuilder(mNextcloudSsoApi, NewsAPI.mApiEndpoint).create(NewsAPI.class); mServerApi = new NextcloudRetrofitApiBuilder(mNextcloudSsoApi, OcsAPI.mApiEndpoint).create(OcsAPI.class); } catch (SSOException e) { callback.onError(e); } } public NewsAPI getNewsAPI() { return mNewsApi; } public OcsAPI getServerAPI() { return mServerApi; } @VisibleForTesting public void setAPI(NewsAPI newsApi) { this.mNewsApi = newsApi; } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/di/AppComponent.java ================================================ package de.luhmer.owncloudnewsreader.di; import javax.inject.Singleton; import dagger.Component; import de.luhmer.owncloudnewsreader.AddFolderDialogFragment; import de.luhmer.owncloudnewsreader.FolderOptionsDialogFragment; import de.luhmer.owncloudnewsreader.LoginDialogActivity; import de.luhmer.owncloudnewsreader.NewFeedActivity; import de.luhmer.owncloudnewsreader.NewsDetailActivity; import de.luhmer.owncloudnewsreader.NewsDetailFragment; import de.luhmer.owncloudnewsreader.NewsReaderDetailFragment; import de.luhmer.owncloudnewsreader.NewsReaderListActivity; import de.luhmer.owncloudnewsreader.NewsReaderListDialogFragment; import de.luhmer.owncloudnewsreader.NewsReaderListFragment; import de.luhmer.owncloudnewsreader.PodcastFragmentActivity; import de.luhmer.owncloudnewsreader.SettingsActivity; import de.luhmer.owncloudnewsreader.SettingsFragment; import de.luhmer.owncloudnewsreader.authentication.OwnCloudSyncAdapter; import de.luhmer.owncloudnewsreader.database.DatabaseConnectionOrm; import de.luhmer.owncloudnewsreader.helper.NextcloudGlideModule; import de.luhmer.owncloudnewsreader.services.SyncItemStateService; import de.luhmer.owncloudnewsreader.widget.WidgetProvider; /** * Created by david on 22.05.17. */ @Singleton @Component(modules = { ApiModule.class }) public interface AppComponent { void injectActivity(NewsReaderListActivity activity); void injectActivity(NewsDetailActivity activity); void injectActivity(PodcastFragmentActivity activity); void injectActivity(NewFeedActivity activity); void injectActivity(SettingsActivity activity); void injectActivity(LoginDialogActivity activity); void injectFragment(NewsReaderListDialogFragment fragment); void injectFragment(NewsReaderListFragment fragment); void injectFragment(SettingsFragment fragment); void injectFragment(NewsDetailFragment fragment); void injectFragment(NewsReaderDetailFragment fragment); void injectFragment(FolderOptionsDialogFragment fragment); void injectFragment(AddFolderDialogFragment fragment); void injectService(SyncItemStateService service); void injectService(OwnCloudSyncAdapter ownCloudSyncAdapter); void injectWidget(WidgetProvider widgetProvider); void injectGlideModule(NextcloudGlideModule glideModule); void injectDatabaseConnection(DatabaseConnectionOrm databaseConnectionOrm); } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/events/podcast/CollapsePodcastView.java ================================================ package de.luhmer.owncloudnewsreader.events.podcast; public class CollapsePodcastView { } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/events/podcast/ExitPlayback.java ================================================ package de.luhmer.owncloudnewsreader.events.podcast; public class ExitPlayback { } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/events/podcast/ExpandPodcastView.java ================================================ package de.luhmer.owncloudnewsreader.events.podcast; public class ExpandPodcastView { } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/events/podcast/NewPodcastPlaybackListener.java ================================================ package de.luhmer.owncloudnewsreader.events.podcast; public class NewPodcastPlaybackListener { } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/events/podcast/PodcastCompletedEvent.java ================================================ package de.luhmer.owncloudnewsreader.events.podcast; public class PodcastCompletedEvent { } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/events/podcast/PodcastFeedClicked.kt ================================================ package de.luhmer.owncloudnewsreader.events.podcast class PodcastFeedClicked( val position: Int, ) ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/events/podcast/RegisterVideoOutput.java ================================================ package de.luhmer.owncloudnewsreader.events.podcast; import android.view.SurfaceView; import android.view.View; public class RegisterVideoOutput { public RegisterVideoOutput(SurfaceView surfaceView, View parentResizableView) { this.surfaceView = surfaceView; this.parentResizableView = parentResizableView; } public SurfaceView surfaceView; public View parentResizableView; } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/events/podcast/SeekPodcast.java ================================================ package de.luhmer.owncloudnewsreader.events.podcast; public class SeekPodcast { public double milliSeconds; public SeekPodcast(double milliSeconds) { this.milliSeconds = milliSeconds; } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/events/podcast/SpeedPodcast.java ================================================ package de.luhmer.owncloudnewsreader.events.podcast; public class SpeedPodcast { public SpeedPodcast(float playbackSpeed) { this.playbackSpeed = playbackSpeed; } public float playbackSpeed; } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/events/podcast/StartDownloadPodcast.kt ================================================ package de.luhmer.owncloudnewsreader.events.podcast import de.luhmer.owncloudnewsreader.model.PodcastItem class StartDownloadPodcast( val podcast: PodcastItem, ) ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/events/podcast/TogglePlayerStateEvent.java ================================================ package de.luhmer.owncloudnewsreader.events.podcast; public class TogglePlayerStateEvent { public enum State { Toggle, Play, Pause } private State mState = State.Toggle; public TogglePlayerStateEvent() { } public TogglePlayerStateEvent(State state) { this.mState = state; } public State getState() { return mState; } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/events/podcast/WindPodcast.java ================================================ package de.luhmer.owncloudnewsreader.events.podcast; public class WindPodcast { public double milliSeconds; public WindPodcast(double milliSeconds) { this.milliSeconds = milliSeconds; } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/helper/AppCompatPreferenceActivity.java ================================================ /* * Copyright (C) 2014 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 de.luhmer.owncloudnewsreader.helper; import android.content.res.Configuration; import android.os.Bundle; import android.preference.PreferenceActivity; import androidx.annotation.LayoutRes; import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AppCompatDelegate; import androidx.appcompat.widget.Toolbar; import android.view.MenuInflater; import android.view.View; import android.view.ViewGroup; /** * A {@link PreferenceActivity} which implements and proxies the necessary calls * to be used with AppCompat. * * This technique can be used with an {@link android.app.Activity} class, not just * {@link PreferenceActivity}. */ public abstract class AppCompatPreferenceActivity extends PreferenceActivity { private AppCompatDelegate mDelegate; @Override protected void onCreate(Bundle savedInstanceState) { getDelegate().installViewFactory(); getDelegate().onCreate(savedInstanceState); super.onCreate(savedInstanceState); } @Override protected void onPostCreate(Bundle savedInstanceState) { super.onPostCreate(savedInstanceState); getDelegate().onPostCreate(savedInstanceState); } public ActionBar getSupportActionBar() { return getDelegate().getSupportActionBar(); } public void setSupportActionBar(@Nullable Toolbar toolbar) { getDelegate().setSupportActionBar(toolbar); } @Override public MenuInflater getMenuInflater() { return getDelegate().getMenuInflater(); } @Override public void setContentView(@LayoutRes int layoutResID) { getDelegate().setContentView(layoutResID); } @Override public void setContentView(View view) { getDelegate().setContentView(view); } @Override public void setContentView(View view, ViewGroup.LayoutParams params) { getDelegate().setContentView(view, params); } @Override public void addContentView(View view, ViewGroup.LayoutParams params) { getDelegate().addContentView(view, params); } @Override protected void onPostResume() { super.onPostResume(); getDelegate().onPostResume(); } @Override protected void onTitleChanged(CharSequence title, int color) { super.onTitleChanged(title, color); getDelegate().setTitle(title); } @Override public void onConfigurationChanged(Configuration newConfig) { super.onConfigurationChanged(newConfig); getDelegate().onConfigurationChanged(newConfig); } @Override protected void onStop() { super.onStop(); getDelegate().onStop(); } @Override protected void onDestroy() { super.onDestroy(); getDelegate().onDestroy(); } public void invalidateOptionsMenu() { getDelegate().invalidateOptionsMenu(); } private AppCompatDelegate getDelegate() { if (mDelegate == null) { mDelegate = AppCompatDelegate.create(this, null); } return mDelegate; } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/helper/AsyncTaskHelper.java ================================================ package de.luhmer.owncloudnewsreader.helper; import android.os.AsyncTask; public class AsyncTaskHelper { @SafeVarargs public static void StartAsyncTask(AsyncTask asyncTask, Params... params) { asyncTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, params); } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/helper/AutoResizeTextView.java ================================================ package de.luhmer.owncloudnewsreader.helper; /** * DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE * Version 2, December 2004 * * Copyright (C) 2004 Sam Hocevar * * Everyone is permitted to copy and distribute verbatim or modified * copies of this license document, and changing it is allowed as long * as the name is changed. * * DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE * TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION * * 0. You just DO WHAT THE FUCK YOU WANT TO. */ import android.annotation.SuppressLint; import android.content.Context; import android.text.Layout.Alignment; import android.text.StaticLayout; import android.text.TextPaint; import android.util.AttributeSet; import android.util.TypedValue; import android.widget.TextView; import androidx.appcompat.widget.AppCompatTextView; /** * Text view that auto adjusts text size to fit within the view. * If the text size equals the minimum text size and still does not * fit, append with an ellipsis. * * @author Chase Colburn * @since Apr 4, 2011 */ public class AutoResizeTextView extends AppCompatTextView { // Minimum text size for this text view public static final float MIN_TEXT_SIZE = 20; // Interface for resize notifications public interface OnTextResizeListener { void onTextResize(TextView textView, float oldSize, float newSize); } // Our ellipse string private static final String mEllipsis = "..."; // Registered resize listener private OnTextResizeListener mTextResizeListener; // Flag for text and/or size changes to force a resize private boolean mNeedsResize = false; // Text size that is set from code. This acts as a starting point for resizing private float mTextSize; // Temporary upper bounds on the starting text size private float mMaxTextSize = 0; // Lower bounds for text size private float mMinTextSize = MIN_TEXT_SIZE; // Text view line spacing multiplier private float mSpacingMult = 1.0f; // Text view additional line spacing private float mSpacingAdd = 0.0f; // Add ellipsis to text that overflows at the smallest text size private boolean mAddEllipsis = true; // Default constructor override public AutoResizeTextView(Context context) { this(context, null); } // Default constructor when inflating from XML file public AutoResizeTextView(Context context, AttributeSet attrs) { this(context, attrs, 0); } // Default constructor override public AutoResizeTextView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); mTextSize = getTextSize(); } /** * When text changes, set the force resize flag to true and reset the text size. */ @Override protected void onTextChanged(final CharSequence text, final int start, final int before, final int after) { mNeedsResize = true; // Since this view may be reused, it is good to reset the text size resetTextSize(); } /** * If the text view size changed, set the force resize flag to true */ @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { if (w != oldw || h != oldh) { mNeedsResize = true; } } /** * Register listener to receive resize notifications * @param listener */ public void setOnResizeListener(OnTextResizeListener listener) { mTextResizeListener = listener; } /** * Override the set text size to update our internal reference values */ @Override public void setTextSize(float size) { super.setTextSize(size); mTextSize = getTextSize(); } /** * Override the set text size to update our internal reference values */ @Override public void setTextSize(int unit, float size) { super.setTextSize(unit, size); mTextSize = getTextSize(); } /** * Override the set line spacing to update our internal reference values */ @Override public void setLineSpacing(float add, float mult) { super.setLineSpacing(add, mult); mSpacingMult = mult; mSpacingAdd = add; } /** * Set the upper text size limit and invalidate the view * @param maxTextSize */ public void setMaxTextSize(float maxTextSize) { mMaxTextSize = maxTextSize; requestLayout(); invalidate(); } /** * Return upper text size limit * @return */ public float getMaxTextSize() { return mMaxTextSize; } /** * Set the lower text size limit and invalidate the view * @param minTextSize */ public void setMinTextSize(float minTextSize) { mMinTextSize = minTextSize; requestLayout(); invalidate(); } /** * Return lower text size limit * @return */ public float getMinTextSize() { return mMinTextSize; } /** * Set flag to add ellipsis to text that overflows at the smallest text size * @param addEllipsis */ public void setAddEllipsis(boolean addEllipsis) { mAddEllipsis = addEllipsis; } /** * Return flag to add ellipsis to text that overflows at the smallest text size * @return */ public boolean getAddEllipsis() { return mAddEllipsis; } /** * Reset the text to the original size */ public void resetTextSize() { if(mTextSize > 0) { super.setTextSize(TypedValue.COMPLEX_UNIT_PX, mTextSize); mMaxTextSize = mTextSize; } } /** * Resize text after measuring */ @Override protected void onLayout(boolean changed, int left, int top, int right, int bottom) { if(changed || mNeedsResize) { int widthLimit = (right - left) - getCompoundPaddingLeft() - getCompoundPaddingRight(); int heightLimit = (bottom - top) - getCompoundPaddingBottom() - getCompoundPaddingTop(); resizeText(widthLimit, heightLimit); } super.onLayout(changed, left, top, right, bottom); } /** * Resize the text size with default width and height */ public void resizeText() { int heightLimit = getHeight() - getPaddingBottom() - getPaddingTop(); int widthLimit = getWidth() - getPaddingLeft() - getPaddingRight(); resizeText(widthLimit, heightLimit); } /** * Resize the text size with specified width and height * @param width * @param height */ @SuppressLint("SetTextI18n") public void resizeText(int width, int height) { CharSequence text = getText(); // Do not resize if the view does not have dimensions or there is no text if(text == null || text.length() == 0 || height <= 0 || width <= 0 || mTextSize == 0) { return; } // Get the text view's paint object TextPaint textPaint = getPaint(); // Store the current text size float oldTextSize = textPaint.getTextSize(); // If there is a max text size set, use the lesser of that and the default text size float targetTextSize = mMaxTextSize > 0 ? Math.min(mTextSize, mMaxTextSize) : mTextSize; // Get the required text height int textHeight = getTextHeight(text, textPaint, width, targetTextSize); // Until we either fit within our text view or we had reached our min text size, incrementally try smaller sizes while(textHeight > height && targetTextSize > mMinTextSize) { targetTextSize = Math.max(targetTextSize - 2, mMinTextSize); textHeight = getTextHeight(text, textPaint, width, targetTextSize); } // If we had reached our minimum text size and still don't fit, append an ellipsis if(mAddEllipsis && targetTextSize == mMinTextSize && textHeight > height) { // Draw using a static layout StaticLayout layout = new StaticLayout(text, textPaint, width, Alignment.ALIGN_NORMAL, mSpacingMult, mSpacingAdd, false); // Check that we have a least one line of rendered text if(layout.getLineCount() > 0) { // Since the line at the specific vertical position would be cut off, // we must trim up to the previous line int lastLine = layout.getLineForVertical(height) - 1; // If the text would not even fit on a single line, clear it if(lastLine < 0) { setText(""); } // Otherwise, trim to the previous line and add an ellipsis else { int start = layout.getLineStart(lastLine); int end = layout.getLineEnd(lastLine); float lineWidth = layout.getLineWidth(lastLine); float ellipseWidth = textPaint.measureText(mEllipsis); // Trim characters off until we have enough room to draw the ellipsis while(width < lineWidth + ellipseWidth) { lineWidth = textPaint.measureText(text.subSequence(start, --end + 1).toString()); } setText(text.subSequence(0, end) + mEllipsis); } } } // Some devices try to auto adjust line spacing, so force default line spacing // and invalidate the layout as a side effect textPaint.setTextSize(targetTextSize); setLineSpacing(mSpacingAdd, mSpacingMult); // Notify the listener if registered if(mTextResizeListener != null) { mTextResizeListener.onTextResize(this, oldTextSize, targetTextSize); } // Reset force resize flag mNeedsResize = false; } // Set the text size of the text paint object and use a static layout to render text off screen before measuring private int getTextHeight(CharSequence source, TextPaint paint, int width, float textSize) { // Update the text paint object paint.setTextSize(textSize); // Measure using a static layout StaticLayout layout = new StaticLayout(source, paint, width, Alignment.ALIGN_NORMAL, mSpacingMult, mSpacingAdd, true); return layout.getHeight(); } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/helper/ColorHelper.java ================================================ package de.luhmer.owncloudnewsreader.helper; import android.annotation.SuppressLint; import android.content.Context; import android.content.res.TypedArray; import android.graphics.Color; import de.luhmer.owncloudnewsreader.R; import de.luhmer.owncloudnewsreader.database.model.Feed; public class ColorHelper { @SuppressLint("DefaultLocale") public static String getCssColor(int color) { // using %f for the double value would result in a localized string, e.g. 0,12 which // would be an invalid css color string return String.format("rgba(%d,%d,%d,%s)", Color.red(color), Color.green(color), Color.blue(color), Double.toString(Color.alpha(color)/255.0)); } public static int[] getColorsFromAttributes(Context context, int... attr) { final TypedArray a = context .obtainStyledAttributes(attr); int[] colors = new int[a.getIndexCount()]; for(int i=0; i= 1) return colors[0]; else return 0; } public static int getFeedColor(Context context, Feed item) { int color; if(item != null && item.getAvgColour() != null) color = Integer.parseInt(item.getAvgColour()); else color = getColorFromAttribute(context, R.attr.dividerLineColor); return color; } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/helper/DatabaseUtils.kt ================================================ /* * Android ownCloud News * * @author David Luhmer * @copyright 2013 David Luhmer david-dev@live.de * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE * License as published by the Free Software Foundation; either * version 3 of the License, or any later version. * * This library 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 AFFERO GENERAL PUBLIC LICENSE for more details. * * You should have received a copy of the GNU Affero General Public * License along with this library. If not, see . * */ package de.luhmer.owncloudnewsreader.helper import android.content.Context import android.content.SharedPreferences import android.util.Log import de.luhmer.owncloudnewsreader.SettingsActivity import de.luhmer.owncloudnewsreader.database.DatabaseConnectionOrm.SORT_DIRECTION import java.io.File const val DATABASE_NAME = "OwncloudNewsReader.db" fun copyDatabaseToSdCard(context: Context): Boolean { val path = context.getDatabasePath(DATABASE_NAME).path val db = File(path) val backupDb = getPath(context) if (db.exists()) { try { val parentFolder = backupDb.parentFile parentFolder?.mkdirs() db.copyTo(backupDb, true) return true } catch (ignore: Exception) { Log.e("DatabaseUtils", "copyDatabaseToSdCard: ", ignore) } } return false } fun getPath(context: Context): File = File( NewsFileUtils.getCacheDirPath(context) + "/dbBackup/" + DATABASE_NAME, ) fun getSortDirectionFromSettings(prefs: SharedPreferences): SORT_DIRECTION { val default = SORT_DIRECTION.desc val sortDirection = prefs.getString(SettingsActivity.SP_SORT_ORDER, default.toString()) return sortDirection?.toInt()?.let { SORT_DIRECTION.values().getOrNull(it) } ?: default } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/helper/DateTimeFormatter.java ================================================ package de.luhmer.owncloudnewsreader.helper; import android.content.Context; import java.util.Calendar; import java.util.Date; import de.luhmer.owncloudnewsreader.R; public class DateTimeFormatter { private static final int SECOND_MILLIS = 1000; private static final int MINUTE_MILLIS = 60 * SECOND_MILLIS; private static final int HOUR_MILLIS = 60 * MINUTE_MILLIS; private static final int DAY_MILLIS = 24 * HOUR_MILLIS; private static final int WEEK_MILLIS = 7 * DAY_MILLIS; public static String getTimeAgo(Date date) { Date now = Calendar.getInstance().getTime(); final long diff = now.getTime() - date.getTime(); if (diff < SECOND_MILLIS) { return "0"; } else if (diff < MINUTE_MILLIS) { return diff / SECOND_MILLIS + "now"; } else if (diff < 2 * MINUTE_MILLIS) { return "1m"; } else if (diff < 59 * MINUTE_MILLIS) { return diff / MINUTE_MILLIS + "m"; } else if (diff < 90 * MINUTE_MILLIS) { return "1h"; } else if (diff < 24 * HOUR_MILLIS) { return diff / HOUR_MILLIS + "h"; } else if (diff < 48 * HOUR_MILLIS) { return "1d"; } else if (diff < 6 * DAY_MILLIS) { return diff / DAY_MILLIS + "d"; } else if (diff < 11 * DAY_MILLIS) { return "1w"; } else { return diff / WEEK_MILLIS + "w"; } } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/helper/FavIconHandler.java ================================================ /* * Android ownCloud News * * @author David Luhmer * @copyright 2013 David Luhmer david-dev@live.de * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE * License as published by the Free Software Foundation; either * version 3 of the License, or any later version. * * This library 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 AFFERO GENERAL PUBLIC LICENSE for more details. * * You should have received a copy of the GNU Affero General Public * License along with this library. If not, see . * */ package de.luhmer.owncloudnewsreader.helper; import android.content.Context; import android.graphics.Bitmap; import android.graphics.drawable.Drawable; import android.util.Log; import android.widget.ImageView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.content.ContextCompat; import androidx.palette.graphics.Palette; import com.bumptech.glide.Glide; import com.bumptech.glide.RequestManager; import com.bumptech.glide.load.engine.DiskCacheStrategy; import com.bumptech.glide.load.resource.bitmap.RoundedCorners; import com.bumptech.glide.request.RequestOptions; import com.bumptech.glide.request.target.SimpleTarget; import com.bumptech.glide.request.target.Target; import com.bumptech.glide.request.transition.Transition; import de.luhmer.owncloudnewsreader.R; import de.luhmer.owncloudnewsreader.database.DatabaseConnectionOrm; import de.luhmer.owncloudnewsreader.database.model.Feed; public class FavIconHandler { private static final String TAG = FavIconHandler.class.getCanonicalName(); private final RequestManager mGlide; private final Context mContext; private final int mPlaceHolder; public FavIconHandler(Context context) { mPlaceHolder = FavIconHandler.getResourceIdForRightDefaultFeedIcon(); mContext = context; mGlide = Glide.with(context); } public void loadFavIconForFeed(@Nullable String favIconUrl, ImageView imgView) { RequestOptions requestOptions = new RequestOptions(); requestOptions = requestOptions.transforms(new RoundedCorners(6)); if (favIconUrl == null) { mGlide .load(mPlaceHolder) .apply(requestOptions) .into(imgView); } else { mGlide .load(favIconUrl) .diskCacheStrategy(DiskCacheStrategy.DATA) .placeholder(mPlaceHolder) .error(mPlaceHolder) .apply(requestOptions) .onlyRetrieveFromCache(true) // disable loading of favicons from network (usually those favicons are broken) .into(imgView); } } boolean isSVG(String url) { return url.contains("svg"); } /** * Version of loadFacIconForFeed that applies a vertical offset to the icon ImageView, * to compensate for font size scaling alignment issue * * @param favIconUrl URL of icon to load/display * @param imgView ImageView object to use for icon display * @param offset Y translation to apply to ImageView */ public void loadFavIconForFeed(String favIconUrl, ImageView imgView, int offset) { loadFavIconForFeed(favIconUrl, imgView); imgView.setTranslationY(offset); } public static int getResourceIdForRightDefaultFeedIcon() { if (ThemeChooser.getSelectedTheme().equals(ThemeChooser.THEME.LIGHT)) { return R.drawable.default_feed_icon_dark; } else { return R.drawable.default_feed_icon_light; } } public void preCacheFavIcon(final Feed feed) throws IllegalStateException { if (feed.getFaviconUrl() == null) { Log.v(TAG, "No favicon for " + feed.getFeedTitle()); return; } String favIconUrl = feed.getFaviconUrl(); // pre caching doesn't work for SVG icons if (isSVG(favIconUrl)) { return; } // Log.v(TAG, "Pre caching favicon: " + favIconUrl); mGlide .asBitmap() .load(favIconUrl) .diskCacheStrategy(DiskCacheStrategy.DATA) .apply(RequestOptions.overrideOf(Target.SIZE_ORIGINAL)) .into(new SimpleTarget() { @Override public void onResourceReady(@NonNull Bitmap resource, @Nullable Transition transition) { UpdateAvgColorOfFeed(feed.getId(), resource, mContext); Log.d(TAG, "Successfully downloaded image for url: " + favIconUrl); } @Override public void onLoadFailed(@Nullable Drawable errorDrawable) { super.onLoadFailed(errorDrawable); Log.d(TAG, "Failed to download image for url: " + favIconUrl); } }); } private void UpdateAvgColorOfFeed(long feedId, Bitmap bitmap, Context context) { if (bitmap != null) { DatabaseConnectionOrm dbConn = new DatabaseConnectionOrm(context); Feed feed = dbConn.getFeedById(feedId); Palette palette = Palette.from(bitmap).generate(); String avg = String.valueOf( palette.getVibrantColor(ContextCompat.getColor(context, androidx.appcompat.R.color.material_blue_grey_800)) ); feed.setAvgColour(avg); dbConn.updateFeed(feed); // Log.v(TAG, "Updating AVG color of feed: " + feed.getFeedTitle() + " - Color: " + avg); } else { Log.v(TAG, "Failed to update AVG color of feed: " + feedId); } } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/helper/FavIconUtils.java ================================================ package de.luhmer.owncloudnewsreader.helper; import java.io.UnsupportedEncodingException; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; public class FavIconUtils { private static final String TAG = FavIconUtils.class.getCanonicalName(); public static String fixFavIconUrl(String favIconUrl) { if (favIconUrl == null) { return null; } if (favIconUrl.startsWith("https://i2.wp.com/stadt-bremerhaven.de/wp-content/uploads/2014/12/logo")) { // Fix favicon for cachys blog... return "https://stadt-bremerhaven.de/wp-content/uploads/2018/08/sblogo-150x150.jpg"; } return favIconUrl; /* try { favIconUrl = decodeSpecialChars(favIconUrl); }catch(Exception ex) { Log.e(TAG, ex.toString()); } return fixSvgIcons(favIconUrl); */ } protected static String decodeSpecialChars(String favIconUrl) throws UnsupportedEncodingException { String before = favIconUrl; int idx = favIconUrl.indexOf("?"); String path = favIconUrl; if(idx > 0) { path = favIconUrl.substring(0, idx); } favIconUrl = favIconUrl.replace(path, URLDecoder.decode(path, StandardCharsets.UTF_8.name())); /* URL url = Objects.requireNonNull(HttpUrl.parse(favIconUrl)).url(); String pathDecoded = URLDecoder.decode(url.getPath(), StandardCharsets.UTF_8.name()); String portPostfix = (url.getPort() != -1) ? String.valueOf(url.getPort()) : ""; // some urls use specials chars which for some reason cause issues in Glide // e.g. // https://i2.wp.com/stadt-bremerhaven.de/wp-content/uploads/2014/12/logo-gro%C3%9F-549c81bbv1_site_icon.png?fit=32%2C32 // https://i2.wp.com//stadt-bremerhaven.de/wp-content/uploads/2014/12/logo-groß-549c81bbv1_site_icon.png?fit=32%2C32 favIconUrl = String.format("%s://%s%s/%s", url.getProtocol(), url.getHost(), portPostfix, pathDecoded); if(url.getQuery() != null) { favIconUrl = favIconUrl + "?" + url.getQuery(); } */ //Log.d(TAG, "before: " + before); //Log.d(TAG, "after: " + favIconUrl); return favIconUrl; } protected static String fixSvgIcons(String favIconUrl) { if(favIconUrl.endsWith(".svg")) { favIconUrl = String.format("https://images.weserv.nl?url=%s&output=webp", favIconUrl); } return favIconUrl; } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/helper/ForegroundListener.kt ================================================ package de.luhmer.owncloudnewsreader.helper import android.app.Activity import android.app.Application.ActivityLifecycleCallbacks import android.os.Bundle class ForegroundListener : ActivityLifecycleCallbacks { override fun onActivityCreated( activity: Activity, savedInstanceState: Bundle?, ) { // do nothing } override fun onActivityStarted(activity: Activity) { numStarted++ } override fun onActivityResumed(activity: Activity) { // do nothing } override fun onActivityPaused(activity: Activity) { // do nothing } override fun onActivityStopped(activity: Activity) { numStarted-- } override fun onActivitySaveInstanceState( activity: Activity, outState: Bundle, ) { // do nothing } override fun onActivityDestroyed(activity: Activity) { // do nothing } companion object { private var numStarted = 0 val isInForeground: Boolean get() = numStarted > 0 } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/helper/GsonConfig.java ================================================ package de.luhmer.owncloudnewsreader.helper; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.reflect.TypeToken; import java.lang.reflect.Type; import java.util.List; import de.luhmer.owncloudnewsreader.database.model.Feed; import de.luhmer.owncloudnewsreader.database.model.Folder; import de.luhmer.owncloudnewsreader.database.model.RssItem; import de.luhmer.owncloudnewsreader.model.OcsUser; import de.luhmer.owncloudnewsreader.reader.nextcloud.NextcloudNewsDeserializer; import de.luhmer.owncloudnewsreader.reader.nextcloud.NextcloudServerDeserializer; import de.luhmer.owncloudnewsreader.reader.nextcloud.Types; /** * Created by david on 27.06.17. */ public class GsonConfig { public static Gson GetGson() { Type feedList = new TypeToken>() {}.getType(); Type folderList = new TypeToken>() {}.getType(); Type rssItemsList = new TypeToken>() {}.getType(); Type ocsUser = new TypeToken() {}.getType(); // Info: RssItems are handled as a stream (to be more memory efficient - see @OwnCloudSyncService and @RssItemObservable) return new GsonBuilder() .setLenient() .registerTypeAdapter(folderList, new NextcloudNewsDeserializer<>(Types.FOLDERS.toString(), Folder.class)) .registerTypeAdapter(feedList, new NextcloudNewsDeserializer<>(Types.FEEDS.toString(), Feed.class)) .registerTypeAdapter(rssItemsList, new NextcloudNewsDeserializer<>(Types.ITEMS.toString(), RssItem.class)) .registerTypeAdapter(ocsUser, new NextcloudServerDeserializer<>("ocsUser", OcsUser.class)) .create(); } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/helper/ImageDownloadFinished.java ================================================ /* * Android ownCloud News * * @author David Luhmer * @copyright 2013 David Luhmer david-dev@live.de * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE * License as published by the Free Software Foundation; either * version 3 of the License, or any later version. * * This library 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 AFFERO GENERAL PUBLIC LICENSE for more details. * * You should have received a copy of the GNU Affero General Public * License along with this library. If not, see . * */ package de.luhmer.owncloudnewsreader.helper; import android.graphics.Bitmap; public interface ImageDownloadFinished { void DownloadFinished(Bitmap bitmap); } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/helper/ImageHandler.java ================================================ /* * Android ownCloud News * * @author David Luhmer * @copyright 2013 David Luhmer david-dev@live.de * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE * License as published by the Free Software Foundation; either * version 3 of the License, or any later version. * * This library 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 AFFERO GENERAL PUBLIC LICENSE for more details. * * You should have received a copy of the GNU Affero General Public * License along with this library. If not, see . * */ package de.luhmer.owncloudnewsreader.helper; import android.content.Context; import android.util.Log; import com.bumptech.glide.Glide; import java.net.MalformedURLException; import java.net.URL; import java.util.ArrayList; import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; public class ImageHandler { private static final String TAG = "[ImageHandler]"; private static final Pattern patternImg = Pattern.compile("]*>"); private static final Pattern patternImgSrcLink = Pattern.compile("src=\"(.*?)\""); private static final Pattern patternHref = Pattern.compile("]*>"); private static final Pattern patternHrefLink = Pattern.compile("href=\"(.*?)\""); public static List getImageLinksFromText(String articleUrl, String text) { List links = new ArrayList<>(); Matcher matcher = patternImg.matcher(text); // Check all occurrences while (matcher.find()) { Matcher matcherSrcLink = patternImgSrcLink.matcher(matcher.group()); if(matcherSrcLink.find()) { String link = matcherSrcLink.group(1); if (link != null) { if (link.startsWith("//")) { //Maybe the text contains image urls without http or https prefix. link = "https:" + link; } // the android universal image loader doesn't support svg images. Therefore we don't want to load them through UIL if (link.endsWith(".svg")) { Log.d(TAG, "detected unsupported svg image in article: " + articleUrl + " -> " + link); } else { links.add(link); } } } } return links; } public static String fixBrokenImageLinksInArticle(String articleUrl, String text) { return fixBrokenLinkInArticle(articleUrl, text, patternImg, patternImgSrcLink, "src"); } public static String fixBrokenHrefInArticle(String articleUrl, String text) { return fixBrokenLinkInArticle(articleUrl, text, patternHref, patternHrefLink, "href"); } public static String fixBrokenLinkInArticle(String articleUrl, String text, Pattern matcherElement, Pattern matcherLink, String htmlAttribut) { Matcher matcher = matcherElement.matcher(text); // Check all occurrences while (matcher.find()) { Matcher matcherSrcLink = matcherLink.matcher(matcher.group()); if(matcherSrcLink.find()) { String link = matcherSrcLink.group(1); String originalLink = link; String originalArticleUrl = articleUrl; if(link != null) { if(link.startsWith("//")) { //Maybe the text contains image urls without http or https prefix. // System.out.println("CASE_MISSING_PROTOCOL"); link = "https:" + link; } else if (link.startsWith("/")) { // detected absolute url // System.out.println("CASE_ABSOLUTE_URL"); try { URL uri = new URL(articleUrl); String protocol = uri.getProtocol(); String authority = uri.getAuthority(); link = String.format("%s://%s", protocol, authority) + link; } catch (MalformedURLException e) { e.printStackTrace(); Log.e(TAG, e.toString()); } } else { // check if we have relative urls such as // ./abc.jpeg or ./../abc.jpeg, ../abc.jpeg or ../../abc.jpeg boolean linkNeedsHost = false; if(link.startsWith("./")) { //Log.d(TAG, "fix relative url (remove ./ in front)"); link = link.substring(2); // remove ./ from link linkNeedsHost = true; } // if link is relative without anything else in front (e.g. pix/wow.svg) if(!link.startsWith("http") && !link.startsWith(".") && !"about:blank".equals(articleUrl)) { if(!link.contains("/")) { // could be just a domain name or a reference to a file in the same directory (either way we should leave it as it is) //System.out.println("CASE_RELATIVE_DOMAIN_OR_FILE"); } else { String lastPartOfUrl = getFileName(link); // the link ends with a filname (e.g. "test.jpg") - therefore we can assume that it is a relative url if(lastPartOfUrl.contains(".")) { if(!articleUrl.endsWith("/")) { // the article contains a file in the end (doesn't end with "/") - therefore we need to remove the last part of the article URL // System.out.println("CASE_RELATIVE_FILE_END"); // remove last part of article url to get a relative url articleUrl = sliceLastPathOfUrl(articleUrl); linkNeedsHost = true; } else { // article URL ends with a "/" so we can just append it // System.out.println("CASE_RELATIVE_ADD_HOST"); linkNeedsHost = true; } } else { // in case we have an url such as astralcodexten.substack.com/subscribe we assume that it is a path and we should not modify it // System.out.println("CASE_RELATIVE_DOMAIN_SUBPATH"); } } } // in case the article url is of type articles/matrix-vs-xmpp.html we need to remove the file plus the first part of the url if(link.startsWith("../") && !articleUrl.endsWith("/")) { // System.out.println("CASE_RELATIVE_PARENT"); linkNeedsHost = true; articleUrl = sliceLastPathOfUrl(articleUrl); articleUrl = sliceLastPathOfUrl(articleUrl); link = link.substring(3); // remove ../ from link } // if the article urls ends with an / we can just remove it piece by piece while(link.startsWith("../")) { // System.out.println("CASE_RELATIVE_PARENT"); linkNeedsHost = true; articleUrl = sliceLastPathOfUrl(articleUrl); link = link.substring(3); // remove ../ from link } if(linkNeedsHost) { // concat article url + link (and make sure that we have only one /) if(articleUrl.endsWith("/")) { link = articleUrl + link; } else { link = articleUrl + "/" + link; } } } } if(!originalLink.equals(link)) { // String l = "Fixed link in article: " + originalArticleUrl + ": " + originalLink + " -> " + link; // Log.d(TAG, l); // text = text.replaceAll(originalLink, link); // this causes OutOfMemoryExceptions (https://github.com/nextcloud/news-android/issues/1055) Pattern URL_PATTERN = Pattern.compile(String.format("%s=\"%s\"", htmlAttribut, originalLink)); Matcher urlMatcher = URL_PATTERN.matcher(text); text = urlMatcher.replaceAll(String.format("%s=\"%s\"", htmlAttribut, link)); } } } return text; } private static String sliceLastPathOfUrl(String url) { int idx = url.lastIndexOf("/"); int countOfSlashes = url.split("/").length - 1; // Log.d(TAG, url + " " + countOfSlashes); // make sure we don't count into the domain name (at least two slashes for ://) if(idx > 0 && countOfSlashes > 2) { return url.substring(0, idx); } else { return url; } } private static String getFileName(String url) { int idx = url.lastIndexOf("/"); int countOfSlashes = url.split("/").length - 1; if(idx > 0) { return url.substring(idx); } else { return url; } } public static void clearCache(Context context) { Glide.get(context).clearMemory(); Glide.get(context).clearDiskCache(); } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/helper/NetworkConnection.java ================================================ /* * Android ownCloud News * * @author David Luhmer * @copyright 2013 David Luhmer david-dev@live.de * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE * License as published by the Free Software Foundation; either * version 3 of the License, or any later version. * * This library 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 AFFERO GENERAL PUBLIC LICENSE for more details. * * You should have received a copy of the GNU Affero General Public * License along with this library. If not, see . * */ package de.luhmer.owncloudnewsreader.helper; import android.content.Context; import android.net.ConnectivityManager; import android.net.NetworkInfo; public class NetworkConnection { public static boolean isNetworkAvailable(Context context) { ConnectivityManager connectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); NetworkInfo activeNetworkInfo = connectivityManager.getActiveNetworkInfo(); return activeNetworkInfo != null && activeNetworkInfo.isConnected(); } public static boolean isWLANConnected(Context context) { ConnectivityManager connectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); NetworkInfo activeNetworkInfo = connectivityManager.getActiveNetworkInfo(); return activeNetworkInfo != null && activeNetworkInfo.getType() == ConnectivityManager.TYPE_WIFI && activeNetworkInfo.isConnected(); } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/helper/NewsFileUtils.java ================================================ /* * Android ownCloud News * * @author David Luhmer * @copyright 2013 David Luhmer david-dev@live.de * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE * License as published by the Free Software Foundation; either * version 3 of the License, or any later version. * * This library 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 AFFERO GENERAL PUBLIC LICENSE for more details. * * You should have received a copy of the GNU Affero General Public * License along with this library. If not, see . * */ package de.luhmer.owncloudnewsreader.helper; import android.content.Context; import android.os.Environment; import android.util.Log; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.nio.channels.FileChannel; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.stream.Collectors; import de.luhmer.owncloudnewsreader.services.DownloadWebPageService; import de.luhmer.owncloudnewsreader.services.PodcastDownloadService; public class NewsFileUtils { private static final String TAG = NewsFileUtils.class.getCanonicalName(); /** * Creates the specified toFile as a byte for byte copy of the * fromFile. If toFile already exists, then it * will be replaced with a copy of fromFile. The name and path * of toFile will be that of toFile.
*
* Note: fromFile and toFile will be closed by * this function. * * @param fromFile * - FileInputStream for the file to copy from. * @param toFile * - FileInputStream for the file to copy to. */ public static void copyFile(FileInputStream fromFile, FileOutputStream toFile) throws IOException { FileChannel fromChannel = null; FileChannel toChannel = null; try { fromChannel = fromFile.getChannel(); toChannel = toFile.getChannel(); fromChannel.transferTo(0, fromChannel.size(), toChannel); } finally { try { if (fromChannel != null) { fromChannel.close(); } } finally { if (toChannel != null) { toChannel.close(); } } } } public static boolean deletePodcastFile(Context context, String fingerprint, String url) { try { File file = new File(PodcastDownloadService.getUrlToPodcastFile(context, fingerprint, url, false)); if(file.exists()) return file.delete(); } catch (Exception ex) { ex.printStackTrace(); } return false; } public static boolean clearPodcastCache(Context context) { try { File dir = new File(getPathPodcasts(context)); deleteDirectory(dir); } catch (IOException ex) { Log.e(TAG, "Error while deleting podcasts", ex); } return false; } public static void clearWebArchiveCache(Context context) { getWebPageArchiveStorage(context).mkdirs(); String path = getWebPageArchiveStorage(context).getAbsolutePath(); Log.d("Files", "Path: " + path); File directory = new File(path); File[] files = directory.listFiles(); Log.d("Files", "Size: " + files.length); for (File file : files) { String name = file.getName(); //og.d("Files", "FileName: " + file.getName()); if (name.startsWith(DownloadWebPageService.WebArchiveFinalPrefix)) { Log.v(TAG, "Deleting file: " + name); //file.delete(); } } } public static String getCacheDirPath(Context context) { //return context.getCacheDir().getAbsolutePath(); return context.getExternalCacheDir().getAbsolutePath(); } public static String getPathPodcasts(Context context) { return context.getExternalFilesDir(Environment.DIRECTORY_MUSIC).getAbsolutePath()+ "/podcasts"; } public static File getWebPageArchiveStorage(Context context) { return new File(NewsFileUtils.getCacheDirPath(context), "web-archive/"); } public static boolean isExternalStorageWritable() { String state = Environment.getExternalStorageState(); return Environment.MEDIA_MOUNTED.equals(state); } /* Method below are copied from https://github.com/apache/commons-io/blob/master/src/main/java/org/apache/commons/io/FileUtils.java */ /** * Deletes a directory recursively. * * @param directory directory to delete * @throws IOException in case deletion is unsuccessful * @throws IllegalArgumentException if {@code directory} does not exist or is not a directory */ public static void deleteDirectory(final File directory) throws IOException { if (!directory.exists()) { return; } cleanDirectory(directory); if (!directory.delete()) { final String message = "Unable to delete directory " + directory + "."; throw new IOException(message); } } /** * Lists files in a directory, asserting that the supplied directory satisfies exists and is a directory * @param directory The directory to list * @return The files in the directory, never null. * @throws IOException if an I/O error occurs */ private static File[] verifiedListFiles(final File directory) throws IOException { if (!directory.exists()) { final String message = directory + " does not exist"; throw new IllegalArgumentException(message); } if (!directory.isDirectory()) { final String message = directory + " is not a directory"; throw new IllegalArgumentException(message); } final File[] files = directory.listFiles(); if (files == null) { // null if security restricted throw new IOException("Failed to list contents of " + directory); } return files; } /** * Cleans a directory without deleting it. * * @param directory directory to clean * @throws IOException in case cleaning is unsuccessful * @throws IllegalArgumentException if {@code directory} does not exist or is not a directory */ public static void cleanDirectory(final File directory) throws IOException { final File[] files = verifiedListFiles(directory); IOException exception = null; for (final File file : files) { try { forceDelete(file); } catch (final IOException ioe) { exception = ioe; } } if (null != exception) { throw exception; } } /** * Deletes a file. If file is a directory, delete it and all sub-directories. *

* The difference between File.delete() and this method are: *

    *
  • A directory to be deleted does not have to be empty.
  • *
  • You get exceptions when a file or directory cannot be deleted. * (java.io.File methods returns a boolean)
  • *
* * @param file file or directory to delete, must not be {@code null} * @throws NullPointerException if the directory is {@code null} * @throws FileNotFoundException if the file was not found * @throws IOException in case deletion is unsuccessful */ public static void forceDelete(final File file) throws IOException { if (file.isDirectory()) { deleteDirectory(file); } else { final boolean filePresent = file.exists(); if (!file.delete()) { if (!filePresent) { throw new FileNotFoundException("File does not exist: " + file); } final String message = "Unable to delete file: " + file; throw new IOException(message); } } } public static String[] getDownloadedPodcastsFingerprints(Context context) { File folder = new File(NewsFileUtils.getPathPodcasts(context)); File[] files = folder.listFiles(); if (files == null) { return new String[0]; } List ids = Arrays.stream(files).map(File::getName).collect(Collectors.toList()); return ids.toArray(new String[0]); } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/helper/NextcloudGlideModule.kt ================================================ package de.luhmer.owncloudnewsreader.helper import android.content.Context import android.content.SharedPreferences import android.graphics.drawable.PictureDrawable import com.bumptech.glide.Glide import com.bumptech.glide.GlideBuilder import com.bumptech.glide.Registry import com.bumptech.glide.annotation.GlideModule import com.bumptech.glide.load.engine.cache.InternalCacheDiskCacheFactory import com.bumptech.glide.module.AppGlideModule import com.bumptech.glide.samples.svg.SvgDecoder import com.bumptech.glide.samples.svg.SvgDrawableTranscoder import com.caverock.androidsvg.SVG import de.luhmer.owncloudnewsreader.NewsReaderApplication import de.luhmer.owncloudnewsreader.SettingsActivity import de.luhmer.owncloudnewsreader.di.ApiProvider import java.io.InputStream import javax.inject.Inject private const val CACHE_SIZE = 500 private const val KB = 1024 private const val MB = 1024 * KB @GlideModule class NextcloudGlideModule : AppGlideModule() { @Inject lateinit var mApi: ApiProvider @Inject lateinit var mPrefs: SharedPreferences override fun applyOptions( context: Context, builder: GlideBuilder, ) { super.applyOptions(context, builder) (context.applicationContext as NewsReaderApplication).appComponent.injectGlideModule(this) val cacheSize = mPrefs.getString(SettingsActivity.SP_MAX_CACHE_SIZE, CACHE_SIZE.toString()) val diskCacheSizeBytes = (cacheSize?.toInt() ?: CACHE_SIZE) * MB // Glide uses DiskLruCacheWrapper as the default DiskCache. DiskLruCacheWrapper is a fixed // size disk cache with LRU eviction. The default disk cache size is 250 MB and is placed // in a specific directory in the Application’s cache folder. builder.setDiskCache(InternalCacheDiskCacheFactory(context, diskCacheSizeBytes.toLong())) // builder.setDiskCache(ExternalPreferredCacheDiskCacheFactory(context)) // #00ff00 Memory Cache (Green) // #0066ff Disk Cache (Blue) // #ff0000 Remote (Red) // #ffff00 Local (Yellow) // enable caching indicators for Glide // builder.setDefaultTransitionOptions( // Drawable::class.java, // DrawableTransitionOptions.with(DebugIndicatorTransitionFactory.DEFAULT) // ) } override fun registerComponents( context: Context, glide: Glide, registry: Registry, ) { super.registerComponents(context, glide, registry) registry .register(SVG::class.java, PictureDrawable::class.java, SvgDrawableTranscoder()) .append(InputStream::class.java, SVG::class.java, SvgDecoder()) } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/helper/NotificationActionReceiver.java ================================================ package de.luhmer.owncloudnewsreader.helper; import android.app.NotificationManager; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.os.Build; import android.util.Log; import androidx.annotation.RequiresApi; import org.greenrobot.eventbus.EventBus; import de.luhmer.owncloudnewsreader.database.DatabaseConnectionOrm; import de.luhmer.owncloudnewsreader.services.events.SyncFinishedEvent; import static android.app.Notification.EXTRA_NOTIFICATION_ID; import static de.luhmer.owncloudnewsreader.Constants.NOTIFICATION_ACTION_MARK_ALL_AS_READ_STRING; public class NotificationActionReceiver extends BroadcastReceiver { private static final String TAG = NotificationActionReceiver.class.getCanonicalName(); @RequiresApi(api = Build.VERSION_CODES.O) @Override public void onReceive(Context context, Intent intent) { String action = intent.getAction(); if (NOTIFICATION_ACTION_MARK_ALL_AS_READ_STRING.equals(action)) { DatabaseConnectionOrm dbConn = new DatabaseConnectionOrm(context); Log.d(TAG, "NOTIFICATION_ACTION_MARK_ALL_AS_READ_STRING"); dbConn.markAllItemsAsRead(); EventBus.getDefault().post(new SyncFinishedEvent()); int notificationId = intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1); if (notificationId != -1) { NotificationManager nMgr = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); nMgr.cancel(notificationId); } } else { Log.d(TAG, action); } } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/helper/NotificationActionReceiverDownloadWebPage.java ================================================ package de.luhmer.owncloudnewsreader.helper; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.util.Log; import org.greenrobot.eventbus.EventBus; import de.luhmer.owncloudnewsreader.services.events.StopWebArchiveDownloadEvent; import static de.luhmer.owncloudnewsreader.Constants.NOTIFICATION_ACTION_STOP_STRING; public class NotificationActionReceiverDownloadWebPage extends BroadcastReceiver { private static final String TAG = NotificationActionReceiver.class.getCanonicalName(); @Override public void onReceive(Context context, Intent intent) { String action = intent.getAction(); if (NOTIFICATION_ACTION_STOP_STRING.equals(action)) { Log.d(TAG, "NOTIFICATION_ACTION_STOP_STRING"); EventBus.getDefault().post(new StopWebArchiveDownloadEvent()); } else { Log.d(TAG, action); } } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/helper/OpmlXmlParser.java ================================================ package de.luhmer.owncloudnewsreader.helper; import android.content.Context; import android.util.Log; import android.util.Xml; import org.xmlpull.v1.XmlPullParser; import org.xmlpull.v1.XmlPullParserException; import org.xmlpull.v1.XmlSerializer; import java.io.IOException; import java.io.StringWriter; import java.util.HashMap; import java.util.List; import de.luhmer.owncloudnewsreader.database.DatabaseConnectionOrm; import de.luhmer.owncloudnewsreader.database.model.Feed; import de.luhmer.owncloudnewsreader.database.model.Folder; /** * Created by David on 14.01.2016. */ public class OpmlXmlParser { private static final String TAG = OpmlXmlParser.class.getCanonicalName(); //Create XML public static String GenerateOPML(Context context) { XmlSerializer serializer = Xml.newSerializer(); StringWriter writer = new StringWriter(); try { serializer.setOutput(writer); serializer.startDocument("UTF-8", true); serializer.startTag("", "opml"); serializer.attribute("", "version", "2.0"); serializer.startTag("", "head"); serializer.startTag("", "title"); serializer.text("Subscriptions"); serializer.endTag("", "title"); serializer.endTag("", "head"); serializer.startTag("", "body"); DatabaseConnectionOrm dbConn = new DatabaseConnectionOrm(context); List folderList = dbConn.getListOfFolders(); List feedList = dbConn.getListOfFeeds(); //Process all feeds in folders for(Folder folder : folderList) { serializer.startTag("", "outline"); serializer.attribute("", "title", folder.getLabel()); serializer.attribute("", "text", folder.getLabel()); for(Feed feed : folder.getFeedList()) { feedList.remove(feed);//Remove feed from feedlist (So only feeds without folders will remain) GenerateXMLForFeed(serializer, feed); } serializer.endTag("", "outline"); } //All feeds without folder for(Feed feed : feedList) { GenerateXMLForFeed(serializer, feed); } serializer.endTag("", "body"); serializer.endTag("", "opml"); serializer.endDocument(); Log.d(TAG, writer.toString()); return writer.toString(); } catch (Exception e) { throw new RuntimeException(e); } } private static void GenerateXMLForFeed(XmlSerializer serializer, Feed feed) throws IOException { serializer.startTag("", "outline"); serializer.attribute("", "title", feed.getFeedTitle()); serializer.attribute("", "text", feed.getFeedTitle()); serializer.attribute("", "type", "rss"); serializer.attribute("", "xmlUrl", feed.getLink()); //serializer.attribute("", "htmlUrl", key); serializer.endTag("", "outline"); } //Parse XML // We don't use namespaces private static final String ns = null; public static HashMap ReadFeed(XmlPullParser parser) throws XmlPullParserException, IOException { HashMap extractedUrls = new HashMap<>(); parser.require(XmlPullParser.START_TAG, ns, "opml"); while (parser.next() != XmlPullParser.END_TAG) { if (parser.getEventType() != XmlPullParser.START_TAG) { continue; } String name = parser.getName(); // Starts by looking for the entry tag if (name.equals("body")) { extractedUrls.putAll(readFolder(parser)); } else { Skip(parser); } } return extractedUrls; } private static class Entry { public Entry(String folderName, String feedUrl) { this.feedUrl = feedUrl; this.folderName = folderName; } public String folderName; public String feedUrl; } private static HashMap readFolder(XmlPullParser parser) throws XmlPullParserException, IOException { HashMap extractedUrls = new HashMap<>(); String name; String folderName = null; parser.require(XmlPullParser.START_TAG, ns, "body"); while(parser.next() >= 0) { //Loop over all if(parser.getEventType() == XmlPullParser.END_TAG) { //If read endtag and folder Name is != null if(folderName == null) { //If end tag is read and we aren't exiting a folder --> exit! break; } folderName = null; } if (parser.getEventType() != XmlPullParser.START_TAG) { continue; } name = parser.getName(); if (name.equals("outline")) { Entry entry = ReadOutline(parser); if (entry.folderName != null) { folderName = entry.folderName; } else { entry.folderName = folderName; extractedUrls.put(entry.feedUrl, entry.folderName); parser.next(); //Read closing tag } } } return extractedUrls; } // Parses the contents of an entry. If it encounters a title, summary, or link tag, hands them off // to their respective "read" methods for processing. Otherwise, skips the tag. private static Entry ReadOutline(XmlPullParser parser) { //parser.require(XmlPullParser.START_TAG, ns, "outline"); String link = parser.getAttributeValue(null, "xmlUrl"); String title = null; if(link == null) { //Parse folder title if no feedUrl is available title = parser.getAttributeValue(null, "title"); } return new Entry(title, link); } private static void Skip(XmlPullParser parser) throws XmlPullParserException, IOException { if (parser.getEventType() != XmlPullParser.START_TAG) { throw new IllegalStateException(); } int depth = 1; while (depth != 0) { switch (parser.next()) { case XmlPullParser.END_TAG: depth--; break; case XmlPullParser.START_TAG: depth++; break; } } } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/helper/PostDelayHandler.java ================================================ /* * Android ownCloud News * * @author David Luhmer * @copyright 2013 David Luhmer david-dev@live.de * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE * License as published by the Free Software Foundation; either * version 3 of the License, or any later version. * * This library 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 AFFERO GENERAL PUBLIC LICENSE for more details. * * You should have received a copy of the GNU Affero General Public * License along with this library. If not, see . * */ package de.luhmer.owncloudnewsreader.helper; import android.content.Context; import android.content.Intent; import android.os.Handler; import android.util.Log; import de.luhmer.owncloudnewsreader.services.SyncItemStateService; public class PostDelayHandler { private static final String TAG = "PostDelayHandler"; private static Handler handlerTimer; private final Context context; private static boolean isDelayed = false; public PostDelayHandler(Context context) { this.context = context; if(handlerTimer == null) { handlerTimer = new Handler(); } } public void stopRunningPostDelayHandler() { Log.v(TAG, "stopRunningPostDelayHandler() called"); handlerTimer.removeCallbacksAndMessages(null); isDelayed = false; } public void delayTimer() { // Time to wait until a sync is triggered (after last change in the app) //60 000 = 1min delay(5 * 60000); //delay(10000); // 10 seconds } public void delayOnExitTimer() { stopRunningPostDelayHandler(); // Time to wait until a sync is triggered when the user switches activities / exists the app //delay(10000); // 10 seconds delay(5000); // 5 seconds } private void delay(final int time) { Log.v(TAG, "delay() called with: time = [" + time + "]"); if(!isDelayed) { isDelayed = true; handlerTimer.postDelayed(() -> { isDelayed = false; Log.v(TAG, "Time exceeded.. Sync state of changed items. Delay was: " + time); if((!SyncItemStateService.isMyServiceRunning(context)) && NetworkConnection.isNetworkAvailable(context)) { Log.v(TAG, "Starting SyncItemStateService"); SyncItemStateService.enqueueWork(context, new Intent()); } }, time); } } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/helper/Search.java ================================================ package de.luhmer.owncloudnewsreader.helper; import android.content.Context; import android.content.SharedPreferences; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; import de.luhmer.owncloudnewsreader.SettingsActivity; import de.luhmer.owncloudnewsreader.database.DatabaseConnectionOrm; import de.luhmer.owncloudnewsreader.database.model.RssItem; import de.luhmer.owncloudnewsreader.database.model.RssItemDao; public class Search { private static final String SEARCH_IN_TITLE = "0"; private static final String SEARCH_IN_BODY = "1"; private static final String SEARCH_IN_BOTH = "2"; public static List PerformSearch(Context context, Long idFolder, Long idFeed, String searchString, SharedPreferences mPrefs) { DatabaseConnectionOrm.SORT_DIRECTION sortDirection = DatabaseUtilsKt.getSortDirectionFromSettings(mPrefs); DatabaseConnectionOrm dbConn = new DatabaseConnectionOrm(context); String sqlSelectStatement = null; if (idFeed != null) { sqlSelectStatement = getFeedSQLStatement(idFeed, sortDirection, searchString, dbConn, mPrefs); } else if (idFolder != null) { sqlSelectStatement = getFolderSQLStatement(idFolder, sortDirection, searchString, dbConn, mPrefs); } List items = new ArrayList<>(); if (sqlSelectStatement != null) { dbConn.insertIntoRssCurrentViewTable(sqlSelectStatement); items = dbConn.getCurrentRssItemView(0); } return items; } private static String getFeedSQLStatement(final long idFeed, final DatabaseConnectionOrm.SORT_DIRECTION sortDirection, final String searchString, final DatabaseConnectionOrm dbConn, final SharedPreferences mPrefs) { String sql = ""; String searchIn = mPrefs.getString(SettingsActivity.SP_SEARCH_IN, SEARCH_IN_BOTH); if(searchIn.equals(SEARCH_IN_TITLE)) { sql = dbConn.getAllItemsIdsForFeedSQLFilteredByTitle(idFeed, false, false, sortDirection, searchString); } else if(searchIn.equals(SEARCH_IN_BODY)) { sql = dbConn.getAllItemsIdsForFeedSQLFilteredByBodySQL(idFeed, false, false, sortDirection, searchString); } else if (searchIn.equals(SEARCH_IN_BOTH)) { sql = dbConn.getAllItemsIdsForFeedSQLFilteredByTitleAndBodySQL(idFeed, false, false, sortDirection, searchString); } return sql; } private static String getFolderSQLStatement(final long ID_FOLDER, final DatabaseConnectionOrm.SORT_DIRECTION sortDirection, final String searchString, final DatabaseConnectionOrm dbConn, final SharedPreferences mPrefs) { String sql = ""; String searchIn = mPrefs.getString(SettingsActivity.SP_SEARCH_IN, SEARCH_IN_BOTH); if(searchIn.equals(SEARCH_IN_TITLE)) { sql = dbConn.getAllItemsIdsForFolderSQLSearch(ID_FOLDER, sortDirection, Collections.singletonList(RssItemDao.Properties.Title.columnName), searchString); } else if(searchIn.equals(SEARCH_IN_BODY)) { sql = dbConn.getAllItemsIdsForFolderSQLSearch(ID_FOLDER, sortDirection, Collections.singletonList(RssItemDao.Properties.Body.columnName), searchString); } else if(searchIn.equals(SEARCH_IN_BOTH)) { var columns = Arrays.asList(RssItemDao.Properties.Body.columnName, RssItemDao.Properties.Title.columnName); sql = dbConn.getAllItemsIdsForFolderSQLSearch(ID_FOLDER, sortDirection, columns, searchString); } return sql; } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/helper/StopWatch.java ================================================ /* Copyright (c) 2005, Corey Goldberg StopWatch.java 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 2 of the License, or (at your option) any later version. Modified: Bilal Rabbani bilalrabbani1@live.com (Nov 2013) */ package de.luhmer.owncloudnewsreader.helper; public class StopWatch { private long startTime = 0; private boolean running = false; private long currentTime = 0; public void start() { this.startTime = System.currentTimeMillis(); this.running = true; } public void stop() { this.running = false; } public void pause() { this.running = false; currentTime = System.currentTimeMillis() - startTime; } public void resume() { this.running = true; this.startTime = System.currentTimeMillis() - currentTime; } //elaspsed time in milliseconds public long getElapsedTimeMili() { long elapsed = 0; if (running) { elapsed =((System.currentTimeMillis() - startTime)/100) % 1000 ; } return elapsed; } //elaspsed time in seconds public long getElapsedTimeSecs() { long elapsed = 0; if (running) { elapsed = ((System.currentTimeMillis() - startTime) / 1000) % 60; } return elapsed; } //elaspsed time in minutes public long getElapsedTimeMin() { long elapsed = 0; if (running) { elapsed = (((System.currentTimeMillis() - startTime) / 1000) / 60 ) % 60; } return elapsed; } //elaspsed time in hours public long getElapsedTimeHour() { long elapsed = 0; if (running) { elapsed = ((((System.currentTimeMillis() - startTime) / 1000) / 60 ) / 60); } return elapsed; } public String toString() { return getElapsedTimeHour() + ":" + getElapsedTimeMin() + ":" + getElapsedTimeSecs() + "." + getElapsedTimeMili(); } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/helper/ThemeChooser.java ================================================ /* * Android ownCloud News * * @author David Luhmer * @copyright 2013 David Luhmer david-dev@live.de * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE * License as published by the Free Software Foundation; either * version 3 of the License, or any later version. * * This library 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 AFFERO GENERAL PUBLIC LICENSE for more details. * * You should have received a copy of the GNU Affero General Public * License along with this library. If not, see . * */ package de.luhmer.owncloudnewsreader.helper; import android.app.Activity; import android.content.Context; import android.content.SharedPreferences; import android.content.res.Configuration; import android.os.Build; import android.util.Log; import androidx.appcompat.app.AppCompatDelegate; import de.luhmer.owncloudnewsreader.R; import de.luhmer.owncloudnewsreader.SettingsActivity; public class ThemeChooser { private static final String TAG = ThemeChooser.class.getCanonicalName(); public enum THEME { LIGHT, DARK, OLED } // Contains the selected theme defined in the settings (used for checking whether the app needs // to restart after changing the theme private static Integer mSelectedThemeFromPreferences; private static Boolean mOledMode; private static SharedPreferences mPrefs; // Contains the current selected theme private static THEME mSelectedTheme = THEME.LIGHT; private ThemeChooser() { } public static void chooseTheme(Activity act) { int defaultNightMode; switch(getSelectedThemeFromPreferences(false)) { case 0: // Auto (Light / Dark) Log.v(TAG, "Auto (Light / Dark)"); if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { //noinspection deprecation defaultNightMode = AppCompatDelegate.MODE_NIGHT_AUTO_TIME; } else { // Android 10+ (Q) supports a system-wide dark mode defaultNightMode = AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM; } mSelectedTheme = THEME.LIGHT; break; case 1: // Light Theme Log.v(TAG, "Light"); defaultNightMode = AppCompatDelegate.MODE_NIGHT_NO; mSelectedTheme = THEME.LIGHT; break; case 2: // Dark Theme Log.v(TAG, "Dark"); defaultNightMode = AppCompatDelegate.MODE_NIGHT_YES; mSelectedTheme = THEME.DARK; break; default: // This should never happen - just in case.. use the light theme.. Log.v(TAG, "Default"); defaultNightMode = AppCompatDelegate.MODE_NIGHT_AUTO_TIME; mSelectedTheme = THEME.LIGHT; break; } act.setTheme(R.style.AppTheme); AppCompatDelegate.setDefaultNightMode(defaultNightMode); } public static void afterOnCreate(Activity act) { //int uiNightMode = Configuration.UI_MODE_NIGHT_NO; if(isDarkTheme(act)) { mSelectedTheme = THEME.DARK; // this is required for auto mode at night if (isOledMode(false) && isDarkTheme(act)) { act.setTheme(R.style.AppTheme_OLED); Log.v(TAG, "activate OLED mode"); //uiNightMode = Configuration.UI_MODE_NIGHT_YES; AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES); mSelectedTheme = THEME.OLED; } } /* Configuration newConfig = new Configuration(act.getResources().getConfiguration()); newConfig.uiMode &= ~Configuration.UI_MODE_NIGHT_MASK; newConfig.uiMode |= uiNightMode; act.getResources().updateConfiguration(newConfig, null); */ } /** * Returns true if automatic theme selection is enabled. * Otherwise it'll return false */ public static boolean isAutoThemeSelectionEnabled() { int selectedTheme = getSelectedThemeFromPreferences(false); return selectedTheme == 0; // 0 => Auto (Light / Dark) } // Check if the currently loaded theme is different from the one set in the settings, or if OLED mode changed public static boolean themeRequiresRestartOfUI() { boolean themeChanged = !mSelectedThemeFromPreferences.equals(getSelectedThemeFromPreferences(true)); boolean oledChanged = !mOledMode.equals(isOledMode(true)); Log.v(TAG, "themeChanged: " + themeChanged + "; oledChanged: " + oledChanged); return themeChanged || oledChanged; } public static boolean isDarkTheme(Context context) { switch (AppCompatDelegate.getDefaultNightMode()) { case AppCompatDelegate.MODE_NIGHT_YES: Log.v(TAG, "MODE_NIGHT_YES (Dark Theme)"); return true; case AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM: //noinspection deprecation case AppCompatDelegate.MODE_NIGHT_AUTO: //Log.v(TAG, "MODE_NIGHT_AUTO"); int nightModeFlags = context.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK; if (Configuration.UI_MODE_NIGHT_YES == nightModeFlags) { Log.v(TAG, "MODE_NIGHT_AUTO (Dark Theme)"); return true; } Log.v(TAG, "MODE_NIGHT_AUTO (Light Theme)"); return false; case AppCompatDelegate.MODE_NIGHT_NO: Log.v(TAG, "MODE_NIGHT_NO (Light Theme)"); return false; default: Log.v(TAG, "Undefined Night-Mode"); return false; } } public static boolean isOledMode(boolean forceReloadCache) { if(mOledMode == null || forceReloadCache) { mOledMode = mPrefs.getBoolean(SettingsActivity.CB_OLED_MODE, false); } return mOledMode; } public static THEME getSelectedTheme() { return mSelectedTheme; } private static int getSelectedThemeFromPreferences(boolean forceReloadCache) { if(mSelectedThemeFromPreferences == null || forceReloadCache) { mSelectedThemeFromPreferences = Integer.parseInt(mPrefs.getString(SettingsActivity.SP_APP_THEME, "0")); } return mSelectedThemeFromPreferences; } public static void init(SharedPreferences prefs) { mPrefs = prefs; getSelectedThemeFromPreferences(true); // Init cache isOledMode(true); // Init cache } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/helper/ThemeUtils.java ================================================ /* * Android ownCloud News * * @author David Luhmer * @copyright 2019 David Luhmer david-dev@live.de * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE * License as published by the Free Software Foundation; either * version 3 of the License, or any later version. * * This library 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 AFFERO GENERAL PUBLIC LICENSE for more details. * * You should have received a copy of the GNU Affero General Public * License along with this library. If not, see . * */ package de.luhmer.owncloudnewsreader.helper; import android.app.Activity; import android.graphics.ColorFilter; import android.graphics.PorterDuff; import android.graphics.PorterDuffColorFilter; import android.graphics.drawable.Drawable; import android.os.Build; import android.view.Menu; import android.view.View; import android.view.Window; import android.view.WindowManager; import android.widget.ImageButton; import androidx.annotation.ColorInt; import androidx.annotation.RequiresApi; import androidx.appcompat.widget.ActionMenuView; import androidx.appcompat.widget.Toolbar; public class ThemeUtils { // private static final String TAG = ThemeUtils.class.getCanonicalName(); private ThemeUtils() {} /** * Use this method to colorize the toolbar to the desired target color * @param toolbarView toolbar view being colored * @param toolbarBackgroundColor the target background color */ public static void colorizeToolbar(Toolbar toolbarView, @ColorInt int toolbarBackgroundColor) { toolbarView.setBackgroundColor(toolbarBackgroundColor); for(int i = 0; i < toolbarView.getChildCount(); i++) { final View v = toolbarView.getChildAt(i); v.setBackgroundColor(toolbarBackgroundColor); if (v instanceof ActionMenuView) { for (int j = 0; j < ((ActionMenuView) v).getChildCount(); j++) { v.setBackgroundColor(toolbarBackgroundColor); } } } } /** * Use this method to colorize the toolbar to the desired target color * * @param toolbarView toolbar view being colored * @param toolbarForegroundColor the target background color * @param skipMenuItems how many menu items should not be colored */ public static void colorizeToolbarForeground(Toolbar toolbarView, @ColorInt int toolbarForegroundColor, int skipMenuItems) { toolbarView.setTitleTextColor(toolbarForegroundColor); ColorFilter cf = new PorterDuffColorFilter(toolbarForegroundColor, PorterDuff.Mode.SRC_IN); Drawable drawable = toolbarView.getOverflowIcon(); if (drawable != null) { drawable.setColorFilter(cf); } for (int i = 0; i < toolbarView.getChildCount(); i++) { final View v = toolbarView.getChildAt(i); if (v instanceof ImageButton) { ((ImageButton) v).setColorFilter(cf); } else if (v instanceof ActionMenuView) { Menu menu = ((ActionMenuView) v).getMenu(); for (int x = skipMenuItems; x < menu.size(); x++) { Drawable d = menu.getItem(x).getIcon(); if (d != null) { d.setColorFilter(cf); } } } /* else { Log.d(TAG, v.toString()); } */ } } /** * Use this method to colorize the status bar to the desired target color * * @param activity * @param statusBarColor */ @RequiresApi(api = Build.VERSION_CODES.KITKAT) public static void changeStatusBarColor(Activity activity, @ColorInt int statusBarColor) { Window window = activity.getWindow(); // clear FLAG_TRANSLUCENT_STATUS flag: window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS); // add FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS flag to the window window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS); window.setStatusBarColor(statusBarColor); } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/helper/URLConnectionReader.kt ================================================ @file:JvmName("URLConnectionReader") package de.luhmer.owncloudnewsreader.helper import java.io.BufferedReader import java.io.IOException import java.io.InputStreamReader import java.net.URL /** * Created by David on 13.01.2016. */ @Throws(IOException::class) fun getText(url: String?): String { val website = URL(url) val connection = website.openConnection() val response = StringBuilder() BufferedReader(InputStreamReader(connection.getInputStream())).use { inReader -> { var inputLine: String? while (inReader.readLine().also { inputLine = it } != null) response.append(inputLine) } } return response.toString() } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/interfaces/ExpListTextClicked.java ================================================ /* * Android ownCloud News * * @author David Luhmer * @copyright 2013 David Luhmer david-dev@live.de * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE * License as published by the Free Software Foundation; either * version 3 of the License, or any later version. * * This library 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 AFFERO GENERAL PUBLIC LICENSE for more details. * * You should have received a copy of the GNU Affero General Public * License along with this library. If not, see . * */ package de.luhmer.owncloudnewsreader.interfaces; public interface ExpListTextClicked { void onTextClicked(long idFeed, boolean isFolder, Long optional_folder_id); void onTextLongClicked(long idFeed, boolean isFolder, Long optional_folder_id); } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/interfaces/IPlayPausePodcastClicked.java ================================================ package de.luhmer.owncloudnewsreader.interfaces; import de.luhmer.owncloudnewsreader.database.model.RssItem; public interface IPlayPausePodcastClicked { void openPodcast(RssItem rssItem); void pausePodcast(); } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/model/AbstractItem.java ================================================ /* * Android ownCloud News * * @author David Luhmer * @copyright 2013 David Luhmer david-dev@live.de * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE * License as published by the Free Software Foundation; either * version 3 of the License, or any later version. * * This library 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 AFFERO GENERAL PUBLIC LICENSE for more details. * * You should have received a copy of the GNU Affero General Public * License along with this library. If not, see . * */ package de.luhmer.owncloudnewsreader.model; public abstract class AbstractItem { public long id_database; public String header; public Long idFolder; AbstractItem(long id_database, String header, Long idFolder) { this.id_database = id_database; this.header = header; this.idFolder = idFolder; } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/model/ConcreteFeedItem.java ================================================ /* * Android ownCloud News * * @author David Luhmer * @copyright 2013 David Luhmer david-dev@live.de * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE * License as published by the Free Software Foundation; either * version 3 of the License, or any later version. * * This library 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 AFFERO GENERAL PUBLIC LICENSE for more details. * * You should have received a copy of the GNU Affero General Public * License along with this library. If not, see . * */ package de.luhmer.owncloudnewsreader.model; public class ConcreteFeedItem extends AbstractItem { public long feedId; public String favIcon; public ConcreteFeedItem(String header, Long folder_id, long feedId, String favIcon, long id_database/*, String parent_title*/) { super(id_database, header, folder_id); this.feedId = feedId; this.favIcon = favIcon; this.id_database = id_database; } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/model/CurrentRssViewDataHolder.java ================================================ package de.luhmer.owncloudnewsreader.model; import java.util.List; import de.luhmer.owncloudnewsreader.database.model.RssItem; public class CurrentRssViewDataHolder { public Long maxCount; public List rssItems; } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/model/FolderSubscribtionItem.java ================================================ /* * Android ownCloud News * * @author David Luhmer * @copyright 2013 David Luhmer david-dev@live.de * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE * License as published by the Free Software Foundation; either * version 3 of the License, or any later version. * * This library 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 AFFERO GENERAL PUBLIC LICENSE for more details. * * You should have received a copy of the GNU Affero General Public * License along with this library. If not, see . * */ package de.luhmer.owncloudnewsreader.model; public class FolderSubscribtionItem extends AbstractItem { public FolderSubscribtionItem(String headerFolder, Long idFolder, long idFolder_database) { super(idFolder_database, headerFolder, idFolder_database); this.idFolder = idFolder; } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/model/MediaItem.java ================================================ package de.luhmer.owncloudnewsreader.model; import java.io.Serializable; public abstract class MediaItem implements Serializable { public long itemId; public String author; public String title; public String favIcon; public String link; } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/model/NextcloudNewsVersion.java ================================================ package de.luhmer.owncloudnewsreader.model; /** * Created by david on 26.05.17. */ public class NextcloudNewsVersion { public String version; } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/model/NextcloudStatus.java ================================================ package de.luhmer.owncloudnewsreader.model; /** * Created by david on 26.05.17. */ public class NextcloudStatus { public String version; public Warnings warnings; static class Warnings { public String improperlyConfiguredCron; } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/model/OcsUser.java ================================================ package de.luhmer.owncloudnewsreader.model; import android.net.Uri; import androidx.annotation.Nullable; import java.io.Serializable; /** * thanks to stefan and artur * https://github.com/stefan-niedermann/nextcloud-deck/blob/master/app/src/main/java/it/niedermann/nextcloud/deck/model/ocs/user/OcsUser.java */ public class OcsUser implements Serializable { private static final long serialVersionUID = 1L; String id; String displayName; public OcsUser() { } public OcsUser(String id, String displayName) { this.id = id; this.displayName = displayName; } public String getId() { return id; } public void setId(String id) { this.id = id; } public String getDisplayName() { return displayName; } public void setDisplayName(String displayName) { this.displayName = displayName; } public @Nullable String getAvatarUrl(@Nullable String ownCloudRootPath) { if (id == null || ownCloudRootPath == null) { return null; } return ownCloudRootPath + "/index.php/avatar/" + Uri.encode(id) + "/64"; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; OcsUser ocsUser = (OcsUser) o; if (id != null ? !id.equals(ocsUser.id) : ocsUser.id != null) return false; return displayName != null ? displayName.equals(ocsUser.displayName) : ocsUser.displayName == null; } @Override public int hashCode() { int result = id != null ? id.hashCode() : 0; result = 31 * result + (displayName != null ? displayName.hashCode() : 0); return result; } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/model/PodcastFeedItem.java ================================================ package de.luhmer.owncloudnewsreader.model; import de.luhmer.owncloudnewsreader.database.model.Feed; public class PodcastFeedItem { public PodcastFeedItem(Feed feed, int podcastCount) { this.mFeed = feed; this.mPodcastCount = podcastCount; } public Feed mFeed; public int mPodcastCount; } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/model/PodcastItem.java ================================================ package de.luhmer.owncloudnewsreader.model; public class PodcastItem extends MediaItem { public PodcastItem() { } public PodcastItem(long itemId, String author, String title, String link, String mimeType, boolean offlineCached, String favIcon, boolean isVideoPodcast, String fingerprint) { this.itemId = itemId; this.author = author; this.title = title; this.link = link; this.mimeType = mimeType; this.offlineCached = offlineCached; this.favIcon = favIcon; this.isVideoPodcast = isVideoPodcast; this.fingerprint = fingerprint; } public String mimeType; public String fingerprint; public boolean offlineCached; public boolean isVideoPodcast; public Integer downloadProgress; public static Integer DOWNLOAD_COMPLETED = -1; public static Integer DOWNLOAD_NOT_STARTED = -2; /* public boolean isYoutubeVideo() { return link.matches("^https?://(www.)?youtube.com/.*"); } */ } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/model/TTSItem.java ================================================ package de.luhmer.owncloudnewsreader.model; public class TTSItem extends MediaItem { public TTSItem(long itemId, String author, String title, String text, String favIcon) { this.itemId = itemId; this.author = author; this.title = title; this.text = text; this.favIcon = favIcon; } public String text; } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/model/Tuple.java ================================================ package de.luhmer.owncloudnewsreader.model; // TODO replace with Pair public class Tuple { public final E key; public final T value; public Tuple(E key, T value) { this.key = key; this.value = value; } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/notification/NextcloudNotificationManager.java ================================================ package de.luhmer.owncloudnewsreader.notification; import static android.app.Notification.EXTRA_NOTIFICATION_ID; import static de.luhmer.owncloudnewsreader.Constants.NOTIFICATION_ACTION_MARK_ALL_AS_READ_STRING; import android.app.Notification; import android.app.NotificationChannel; import android.app.NotificationManager; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.content.res.Resources; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.drawable.Drawable; import android.net.Uri; import android.os.Build; import android.service.notification.StatusBarNotification; import android.support.v4.media.MediaDescriptionCompat; import android.support.v4.media.MediaMetadataCompat; import android.support.v4.media.session.MediaControllerCompat; import android.support.v4.media.session.MediaSessionCompat; import android.support.v4.media.session.PlaybackStateCompat; import android.text.TextUtils; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.app.NotificationCompat; import androidx.core.content.FileProvider; import androidx.media.app.NotificationCompat.MediaStyle; import androidx.media.session.MediaButtonReceiver; import com.bumptech.glide.Glide; import com.bumptech.glide.load.engine.DiskCacheStrategy; import com.bumptech.glide.request.target.CustomTarget; import com.bumptech.glide.request.transition.Transition; import java.io.File; import java.util.ArrayList; import java.util.List; import java.util.Set; import de.greenrobot.dao.query.QueryBuilder; import de.luhmer.owncloudnewsreader.BuildConfig; import de.luhmer.owncloudnewsreader.NewsReaderListActivity; import de.luhmer.owncloudnewsreader.R; import de.luhmer.owncloudnewsreader.database.DatabaseConnectionOrm; import de.luhmer.owncloudnewsreader.database.model.RssItem; import de.luhmer.owncloudnewsreader.helper.DatabaseUtilsKt; import de.luhmer.owncloudnewsreader.helper.NotificationActionReceiver; public class NextcloudNotificationManager { private static final int ID_DownloadSingleImageComplete = 10; // private static final int UNREAD_RSS_ITEMS_NOTIFICATION_ID = 246; public static void showNotificationDownloadSingleImageComplete(Context context, File imagePath) { String channelDownloadImage = context.getString(R.string.action_img_download); NotificationManager notificationManager = getNotificationManagerAndCreateChannel(context, channelDownloadImage); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && !notificationManager.areNotificationsEnabled()) { return; } Glide.with(context).asBitmap().load("file://" + imagePath.getAbsolutePath()).diskCacheStrategy(DiskCacheStrategy.NONE).into(new CustomTarget(1024, 512) { @Override public void onResourceReady(@NonNull Bitmap bitmap, @Nullable Transition transition) { // Uri imageUri = Uri.parse(imagePath); Uri imageUri = FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".provider", imagePath); Intent intent = new Intent(); intent.setAction(Intent.ACTION_VIEW); intent.setDataAndType(imageUri, "image/*"); intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE); NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(context, channelDownloadImage) .setSmallIcon(R.drawable.ic_notification) .setContentTitle(context.getString(R.string.toast_img_saved) + " - " + imagePath.getName()) .setContentIntent(pendingIntent) .setAutoCancel(true) .setStyle(new NotificationCompat.BigPictureStyle().bigPicture(bitmap)); notificationManager.notify(ID_DownloadSingleImageComplete, mBuilder.build()); } @Override public void onLoadCleared(@Nullable Drawable placeholder) { } }); } public static NotificationCompat.Builder buildNotificationDownloadImageService(Context context, String channelId) { getNotificationManagerAndCreateChannel(context, channelId); Intent intentNewsReader = new Intent(context, NewsReaderListActivity.class); PendingIntent pIntent = PendingIntent.getActivity(context, 0, intentNewsReader, PendingIntent.FLAG_IMMUTABLE); return new NotificationCompat.Builder(context, channelId) .setContentTitle(context.getResources().getString(R.string.app_name)) .setContentText(context.getString(R.string.notification_download_images_offline)) .setSmallIcon(R.drawable.ic_notification) .setContentIntent(pIntent) .setAutoCancel(true) .setOnlyAlertOnce(true) .setOngoing(true); } public static NotificationCompat.Builder buildNotificationDownloadWebPageService(Context context, String channelId) { getNotificationManagerAndCreateChannel(context, channelId); Intent intentNewsReader = new Intent(context, NewsReaderListActivity.class); PendingIntent pIntent = PendingIntent.getActivity(context, 0, intentNewsReader, PendingIntent.FLAG_IMMUTABLE); return new NotificationCompat.Builder(context, channelId) .setContentTitle(context.getResources().getString(R.string.app_name)) .setContentText(context.getString(R.string.notification_download_articles_offline)) .setSmallIcon(R.drawable.ic_notification) .setContentIntent(pIntent) .setAutoCancel(true) .setOnlyAlertOnce(true) .setOngoing(true); } public static void showNotificationImageDownloadLimitReached(Context context, String channelId, int limit) { NotificationManager notificationManager = getNotificationManagerAndCreateChannel(context, channelId); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && !notificationManager.areNotificationsEnabled()) { return; } Intent intentNewsReader = new Intent(context, NewsReaderListActivity.class); PendingIntent pIntent = PendingIntent.getActivity(context, 0, intentNewsReader, PendingIntent.FLAG_IMMUTABLE); NotificationCompat.Builder notifyBuilder = new NotificationCompat.Builder(context, channelId) .setContentTitle("Nextcloud News") .setContentText("Only " + limit + " images can be cached at once") .setSmallIcon(R.drawable.ic_notification) .setContentIntent(pIntent); Notification notify = notifyBuilder.build(); //Hide the notification after its selected notify.flags |= Notification.FLAG_AUTO_CANCEL; // Use random ID notificationManager.notify(123, notify); } /** * Build a notification using the information from the given media session. Makes heavy use * of {@link MediaMetadataCompat#getDescription()} to extract the appropriate information. * @param context Context used to construct the notification. * @param mediaSession Media session to get information. * @return A pre-built notification with information from the given media session. */ public static NotificationCompat.Builder buildPodcastNotification(Context context, String channelId, MediaSessionCompat mediaSession) { getNotificationManagerAndCreateChannel(context, channelId); /* // Creates an explicit intent for an ResultActivity to receive. Intent resultIntent = new Intent(context, NewsReaderListActivity.class); // Because clicking the notification opens a new ("special") activity, there's // no need to create an artificial back stack. PendingIntent resultPendingIntent = PendingIntent.getActivity( context, 0, resultIntent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT ); return new NotificationCompat.Builder(context, channelId) .setSmallIcon(R.drawable.ic_notification) .setAutoCancel(true) .setOngoing(true) .setOnlyAlertOnce(true) .setContentIntent(resultPendingIntent); */ Bitmap bitmapIcon = BitmapFactory.decodeResource(context.getResources(), R.mipmap.ic_launcher); MediaControllerCompat controller = mediaSession.getController(); MediaMetadataCompat mediaMetadata = controller.getMetadata(); MediaDescriptionCompat description = mediaMetadata.getDescription(); NotificationCompat.Builder builder = new NotificationCompat.Builder(context, channelId) /* .setStyle(new NotificationCompat.MediaStyle() .setShowActionsInCompactView( new int[]{playPauseButtonPosition}) // show only play/pause in compact view .setMediaSession(mSession.getSessionToken())) */ //.setUsesChronometer(true) .setContentTitle(description.getTitle()) .setContentText(description.getSubtitle()) .setSubText(description.getDescription()) .setSmallIcon(R.drawable.ic_notification) //.setContentText(description.getSubtitle()) //.setContentText(mediaMetadata.getText(MediaMetadataCompat.METADATA_KEY_ARTIST)) //.setSubText(description.getDescription()) //.setLargeIcon(description.getIconBitmap()) .setLargeIcon(bitmapIcon) .setContentIntent(controller.getSessionActivity()) .setDeleteIntent(MediaButtonReceiver.buildMediaButtonPendingIntent(context, PlaybackStateCompat.ACTION_STOP)) .setOnlyAlertOnce(true); boolean isPlaying = controller.getPlaybackState().getState() == PlaybackStateCompat.STATE_PLAYING; builder.addAction(getPlayPauseAction(context, isPlaying)); // Make the transport controls visible on the lockscreen builder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC); builder.setStyle(new MediaStyle() //.setShowActionsInCompactView(0) // show only play/pause in compact view .setMediaSession(mediaSession.getSessionToken()) .setShowActionsInCompactView(0) .setShowCancelButton(true) .setCancelButtonIntent( MediaButtonReceiver.buildMediaButtonPendingIntent( context, PlaybackStateCompat.ACTION_STOP))); return builder; } private static NotificationCompat.Action getPlayPauseAction(Context context, boolean isPlaying) { int drawableId = isPlaying ? R.drawable.ic_action_pause_24 : R.drawable.ic_baseline_play_arrow_24; String actionText = isPlaying ? "Pause" : "Play"; // TODO extract as string resource PendingIntent pendingIntent = MediaButtonReceiver.buildMediaButtonPendingIntent(context, isPlaying ? PlaybackStateCompat.ACTION_PAUSE : PlaybackStateCompat.ACTION_PLAY); return new NotificationCompat.Action(drawableId, actionText, pendingIntent); } public static NotificationCompat.Builder buildDownloadPodcastNotification(Context context, String channelId) { getNotificationManagerAndCreateChannel(context, channelId); Intent intentNewsReader = new Intent(context, NewsReaderListActivity.class); PendingIntent pIntent = PendingIntent.getActivity(context, 0, intentNewsReader, PendingIntent.FLAG_IMMUTABLE); NotificationCompat.Builder mNotificationDownloadPodcast = new NotificationCompat.Builder(context, channelId) .setContentTitle(context.getResources().getString(R.string.app_name)) .setContentText(context.getString(R.string.notification_downloading_podcast_title)) .setSmallIcon(R.drawable.ic_notification) .setContentIntent(pIntent) .setAutoCancel(true) .setOnlyAlertOnce(true) .setOngoing(true); return mNotificationDownloadPodcast; } public static void showUnreadRssItemsNotification(Context context, SharedPreferences mPrefs, Boolean updateExistingNotificationsOnly) { Resources res = context.getResources(); String channelId = context.getString(R.string.app_name); NotificationManager notificationManager = getNotificationManagerAndCreateChannel(context, channelId); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && !notificationManager.areNotificationsEnabled()) { return; } DatabaseConnectionOrm dbConn = new DatabaseConnectionOrm(context); DatabaseConnectionOrm.SORT_DIRECTION sortDirection = DatabaseUtilsKt.getSortDirectionFromSettings(mPrefs); Set notificationGroups = dbConn.getNotificationGroups(); for (String notificationGroup : notificationGroups) { // use hashcode for notification group as identifier for the notification Integer notificationId = notificationGroup.hashCode(); // if the user exists the app we need to update the notifications - but only if the notification is already visible if (updateExistingNotificationsOnly && !isUnreadRssCountNotificationVisible(context, notificationId)) { continue; } QueryBuilder qbItemsForNotificationGroup = dbConn.getAllUnreadRssItemsForNotificationGroup(sortDirection, notificationGroup); Integer newItemsCount = Math.toIntExact(qbItemsForNotificationGroup.count()); List items = qbItemsForNotificationGroup.limit(6).list(); // only read 6 items from database String tickerMessage = res.getQuantityString(R.plurals.notification_new_items_ticker, newItemsCount, newItemsCount); String contentText = res.getQuantityString(R.plurals.notification_new_items_text, newItemsCount, newItemsCount); if (items.size() > 0) { contentText = "\u2022 " + items.get(0).getTitle(); } String contentTitle = notificationGroup.equals("default") ? tickerMessage : String.format("[%s] %s", notificationGroup, tickerMessage); List previewLines = new ArrayList<>(); for (RssItem item : items) { // • = \u2022, ● = \u25CF, ○ = \u25CB, ▪ = \u25AA, ■ = \u25A0, □ = \u25A1, ► = \u25BA previewLines.add("\u2022 " + item.getTitle().trim()); } String previewText = TextUtils.join("\n", previewLines); Intent markAllAsReadIntent = new Intent(context, NotificationActionReceiver.class); markAllAsReadIntent.setAction(NOTIFICATION_ACTION_MARK_ALL_AS_READ_STRING); markAllAsReadIntent.putExtra(EXTRA_NOTIFICATION_ID, notificationId); PendingIntent markAllAsReadPendingIntent = PendingIntent.getBroadcast(context, 0, markAllAsReadIntent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_ONE_SHOT); NotificationCompat.Builder builder = new NotificationCompat.Builder(context, channelId) .setSmallIcon(R.drawable.ic_notification) .setContentTitle(contentTitle) .setStyle(new NotificationCompat.BigTextStyle().bigText(previewText)) //.setDefaults(Notification.DEFAULT_ALL) .addAction(R.drawable.ic_checkbox_white, context.getString(R.string.menu_markAllAsRead), markAllAsReadPendingIntent) .setAutoCancel(true) .setNumber(newItemsCount) .setContentText(contentText); Intent notificationIntent = new Intent(context, NewsReaderListActivity.class); PendingIntent contentIntent = PendingIntent.getActivity(context, notificationId, notificationIntent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); builder.setContentIntent(contentIntent); if (newItemsCount > 0) { notificationManager.notify(notificationId, builder.build()); } else { // no new items available - hide/remove notification notificationManager.cancel(notificationId); } } } public static boolean isUnreadRssCountNotificationVisible(Context context, Integer notificationId) { NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { for (StatusBarNotification statusBarNotification : notificationManager.getActiveNotifications()) { if (statusBarNotification.getId() == notificationId) { return true; } } } return false; } private static NotificationManager getNotificationManagerAndCreateChannel(Context context, String channelId) { NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { int importance = NotificationManager.IMPORTANCE_DEFAULT; NotificationChannel mChannel = new NotificationChannel(channelId, channelId, importance); mChannel.setSound(null, null); mChannel.enableVibration(false); //mChannel.setShowBadge(false); //mChannel.enableLights(true); notificationManager.createNotificationChannel(mChannel); } return notificationManager; } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/providers/OwnCloudSyncProvider.kt ================================================ package de.luhmer.owncloudnewsreader.providers import android.content.ContentProvider import android.content.ContentValues import android.database.Cursor import android.net.Uri class OwnCloudSyncProvider : ContentProvider() { /* * Always return true, indicating that the * provider loaded correctly. */ override fun onCreate(): Boolean = true /* * Return an empty String for MIME type */ override fun getType(uri: Uri): String = "" /* * query() always returns no results * */ override fun query( uri: Uri, projection: Array?, selection: String?, selectionArgs: Array?, sortOrder: String?, ): Cursor? = null /* * insert() always returns null (no URI) */ override fun insert( uri: Uri, values: ContentValues?, ): Uri? = null /* * delete() always returns "no rows affected" (0) */ override fun delete( uri: Uri, selection: String?, selectionArgs: Array?, ): Int = 0 /* * update() always returns "no rows affected" (0) */ override fun update( uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?, ): Int = 0 } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/reader/FeedItemTags.java ================================================ /* * Android ownCloud News * * @author David Luhmer * @copyright 2013 David Luhmer david-dev@live.de * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE * License as published by the Free Software Foundation; either * version 3 of the License, or any later version. * * This library 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 AFFERO GENERAL PUBLIC LICENSE for more details. * * You should have received a copy of the GNU Affero General Public * License along with this library. If not, see . * */ package de.luhmer.owncloudnewsreader.reader; public enum FeedItemTags { MARK_ITEM_AS_READ("read"), MARK_ITEM_AS_UNREAD("unread"), MARK_ITEM_AS_STARRED("star"), MARK_ITEM_AS_UNSTARRED("unstar"), ALL_STARRED("2"), ALL("3"); private final String segment; FeedItemTags(String segment) { this.segment = segment; } @Override public String toString() { return this.segment; } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/reader/InsertIntoDatabase.java ================================================ /* * Android ownCloud News * * @author David Luhmer * @copyright 2013 David Luhmer david-dev@live.de * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE * License as published by the Free Software Foundation; either * version 3 of the License, or any later version. * * This library 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 AFFERO GENERAL PUBLIC LICENSE for more details. * * You should have received a copy of the GNU Affero General Public * License along with this library. If not, see . * */ package de.luhmer.owncloudnewsreader.reader; import android.util.Log; import java.util.List; import de.luhmer.owncloudnewsreader.database.DatabaseConnectionOrm; import de.luhmer.owncloudnewsreader.database.model.Feed; import de.luhmer.owncloudnewsreader.database.model.Folder; import de.luhmer.owncloudnewsreader.helper.FavIconUtils; public class InsertIntoDatabase { private static final String TAG = "InsertRssItemIntoDb"; public static void InsertFoldersIntoDatabase(List folderList, DatabaseConnectionOrm dbConn) { dbConn.deleteOldAndInsertNewFolders(folderList); /* List feeds = dbConn.getListOfFeeds(); List tagsAvailable = new ArrayList(feeds.size()); for(int i = 0; i < feeds.size(); i++) tagsAvailable.add(feeds.get(i).getFeedTitle()); if(folderList != null) { int addedCount = 0; int removedCount = 0; for(Folder folder : folderList) { if(!tagsAvailable.contains(folder.getLabel())) { addedCount++; dbConn.insertNewFolder(folder); } } Log.d("ADD", ""+ addedCount); Log.d("REMOVE", ""+ removedCount++); } */ } public static void InsertFeedsIntoDatabase(List feeds, DatabaseConnectionOrm dbConn) { List oldFeeds = dbConn.getListOfFeeds(); if(feeds != null) { dbConn.insertNewFeed(feeds); for(Feed oldFeed : oldFeeds) { boolean found = false; for(Feed newFeed : feeds) { if(oldFeed.getId() == newFeed.getId()) { found = true; // Set the avg color after sync again. newFeed.setAvgColour(oldFeed.getAvgColour()); // Set the notification channel after sync again newFeed.setNotificationChannel(oldFeed.getNotificationChannel()); newFeed.setOpenIn(oldFeed.getOpenIn()); // fix favicon url newFeed.setFaviconUrl(FavIconUtils.fixFavIconUrl(newFeed.getFaviconUrl())); dbConn.updateFeed(newFeed); break; } } if(!found) { dbConn.removeFeedById(oldFeed.getId()); Log.v(TAG, "Remove Subscription: " + oldFeed.getFeedTitle()); } } } } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/reader/OnAsyncTaskCompletedListener.java ================================================ /* * Android ownCloud News * * @author David Luhmer * @copyright 2013 David Luhmer david-dev@live.de * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE * License as published by the Free Software Foundation; either * version 3 of the License, or any later version. * * This library 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 AFFERO GENERAL PUBLIC LICENSE for more details. * * You should have received a copy of the GNU Affero General Public * License along with this library. If not, see . * */ package de.luhmer.owncloudnewsreader.reader; public interface OnAsyncTaskCompletedListener { void onAsyncTaskCompleted(final Exception task_result); } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/reader/nextcloud/IHandleJsonObject.java ================================================ package de.luhmer.owncloudnewsreader.reader.nextcloud; import com.google.gson.JsonObject; /** * Created by david on 24.05.17. */ public interface IHandleJsonObject { boolean performAction(JsonObject jObj); } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/reader/nextcloud/InsertRssItemIntoDatabase.java ================================================ /* * Android ownCloud News * * @author David Luhmer * @copyright 2013 David Luhmer david-dev@live.de * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE * License as published by the Free Software Foundation; either * version 3 of the License, or any later version. * * This library 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 AFFERO GENERAL PUBLIC LICENSE for more details. * * You should have received a copy of the GNU Affero General Public * License along with this library. If not, see . * */ package de.luhmer.owncloudnewsreader.reader.nextcloud; import android.text.Html; import android.util.Log; import com.google.gson.JsonObject; import java.util.Date; import java.util.List; import java.util.UUID; import de.luhmer.owncloudnewsreader.database.model.RssItem; import de.luhmer.owncloudnewsreader.helper.ImageHandler; class InsertRssItemIntoDatabase { private final static String TAG = InsertRssItemIntoDatabase.class.getCanonicalName(); static RssItem parseItem(JsonObject e) { Date pubDate = new Date(e.get("pubDate").getAsLong() * 1000); String content = e.get("body").getAsString(); /* // URL Decoding content (some pages provide url decoded content - such as showrss.info try { // Try URL decoding content = URLDecoder.decode(content, "UTF-8"); } catch (UnsupportedEncodingException e1) { e1.printStackTrace(); } */ //String url = e.get("url").getAsString(); String url = getStringOrDefault("url", "about:blank", e); String guid = e.get("guid").getAsString(); String enclosureLink = getStringOrEmpty("enclosureLink", e); String enclosureMime = getStringOrEmpty("enclosureMime", e); String mediaDescription = getStringOrEmpty("mediaDescription", e); Boolean rtl = getBooleanOrDefault("rtl", false, e); if(enclosureLink.trim().equals("") && url.matches("^https?://(www.)?youtube.com/.*")) { enclosureLink = url; enclosureMime = "youtube"; } RssItem rssItem = new RssItem(); rssItem.setId(e.get("id").getAsLong()); rssItem.setFeedId(e.get("feedId").getAsLong()); rssItem.setGuid(guid); // non-null rssItem.setGuidHash(e.get("guidHash").getAsString()); // non-null rssItem.setFingerprint(getStringOrDefault("fingerprint", "", e)); rssItem.setLastModified(new Date(Long.parseLong(getStringOrDefault("lastModified", "0", e)))); rssItem.setRead(!e.get("unread").getAsBoolean()); rssItem.setRead_temp(rssItem.getRead()); rssItem.setStarred(e.get("starred").getAsBoolean()); rssItem.setStarred_temp(rssItem.getStarred()); rssItem.setPubDate(pubDate); rssItem.setRtl(rtl); //Possible XSS fields rssItem.setTitle(getStringOrDefault("title", "", e)); rssItem.setAuthor(getStringOrDefault("author", "", e)); rssItem.setLink(url); rssItem.setEnclosureLink(enclosureLink); rssItem.setEnclosureMime(enclosureMime); rssItem.setMediaDescription(mediaDescription); if(rssItem.getFingerprint() == null) { rssItem.setFingerprint(UUID.randomUUID().toString()); } // Calculate the size of the rss items - useful if users run into a SQLiteBlobTooBigException // https://github.com/nextcloud/news-android/issues/887 int contentLength = content.length(); double sizeInMb = contentLength/1024d/1024d; if(sizeInMb > 0.4) { Log.w(TAG, "Massive rss item detected - " + content.length() + " chars / " + content.length() / 1024d / 1024d + "mb - url: " + rssItem.getLink()); // Trim string down to 500k characters int maxLengthAllowed = 500000; if(content.length() > maxLengthAllowed) { Log.w(TAG, "Limiting rss item size to 500k characters - url:" + rssItem.getLink()); content = content.substring(0, maxLengthAllowed); } } else if(sizeInMb > 0.1) { Log.w(TAG, "Large rss item detected - " + content.length() + " chars / " + content.length() / 1024d / 1024d + "mb - url: " + rssItem.getLink()); } try { // try fixing relative image links content = ImageHandler.fixBrokenImageLinksInArticle(url, content); // try fixing relative href links content = ImageHandler.fixBrokenHrefInArticle(url, content); } catch (Exception ex) { ex.printStackTrace(); Log.e(TAG, "Error while fixing broken image links in article" + ex); } catch (OutOfMemoryError error) { error.printStackTrace(); Log.e(TAG, "OutOfMemoryError while fixing broken image links in article" + error); Log.e(TAG, "OutOfMemoryError Article length:" + content.length()); } rssItem.setBody(content); String mediaThumbnail = getStringOrEmpty("mediaThumbnail", e); // Possible XSS Fields // in case the server doesn't provide a mediaThumbnail - the app will try to find one if(mediaThumbnail.isEmpty()) { List images = ImageHandler.getImageLinksFromText(url, content); if (!images.isEmpty()) { mediaThumbnail = Html.fromHtml(images.get(0)).toString(); // Log.d(TAG, "extracted mediaThumbnail from body" + mediaThumbnail); } else { Log.d(TAG, "extraction of mediaThumbnail not possible - no images detected"); } } rssItem.setMediaThumbnail(mediaThumbnail); return rssItem; } private static String getStringOrEmpty(String key, JsonObject jObj) { return getStringOrDefault(key, "", jObj); } private static String getStringOrDefault(String key, String defaultValue, JsonObject jObj) { if(jObj.has(key) && !jObj.get(key).isJsonNull()) { return jObj.get(key).getAsString(); } else { return defaultValue; } } @SuppressWarnings("SameParameterValue") private static Boolean getBooleanOrDefault(String key, Boolean defaultValue, JsonObject jObj) { if(jObj.has(key) && !jObj.get(key).isJsonNull()) { return jObj.get(key).getAsBoolean(); } else { return defaultValue; } } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/reader/nextcloud/ItemIds.java ================================================ package de.luhmer.owncloudnewsreader.reader.nextcloud; import java.util.HashSet; import java.util.Set; /** * Created by david on 26.05.17. */ public class ItemIds { private final Set items = new HashSet<>(); public ItemIds(Iterable items) { for (String itemId : items) { this.items.add(Long.parseLong(itemId)); } } public Set getItems() { return items; } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/reader/nextcloud/ItemMap.java ================================================ package de.luhmer.owncloudnewsreader.reader.nextcloud; import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Set; import de.luhmer.owncloudnewsreader.database.DatabaseConnectionOrm; import de.luhmer.owncloudnewsreader.database.model.RssItem; /** * Created by david on 26.05.17. */ public class ItemMap { private final Set> items = new HashSet<>(); public ItemMap(Iterable itemIds, DatabaseConnectionOrm dbConn) { for(String idItem : itemIds) { RssItem rssItem = dbConn.getRssItemById(Long.parseLong(idItem)); HashMap itemMap = new HashMap<>(); itemMap.put("feedId", rssItem.getFeedId()); itemMap.put("guidHash", rssItem.getGuidHash()); this.items.add(itemMap); } } public Set> getItems() { return items; } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/reader/nextcloud/ItemStateSync.java ================================================ package de.luhmer.owncloudnewsreader.reader.nextcloud; import android.util.Log; import java.io.IOException; import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; import de.luhmer.owncloudnewsreader.database.DatabaseConnectionOrm; import de.luhmer.owncloudnewsreader.reader.FeedItemTags; import okhttp3.ResponseBody; import retrofit2.Response; /** * Created by david on 26.05.17. */ public class ItemStateSync { private static final String TAG = ItemStateSync.class.getCanonicalName(); public static void PerformItemStateSync(NewsAPI newsApi, DatabaseConnectionOrm dbConn) throws IOException { int MAX_SYNC_ITEMS_PER_REQUEST = 300; Map> itemsToSync = new HashMap<>(); itemsToSync.put( FeedItemTags.MARK_ITEM_AS_READ, dbConn.getRssItemsIdsFromList(dbConn.getAllNewReadRssItems()) ); itemsToSync.put( FeedItemTags.MARK_ITEM_AS_UNREAD, dbConn.getRssItemsIdsFromList(dbConn.getAllNewUnreadRssItems()) ); itemsToSync.put( FeedItemTags.MARK_ITEM_AS_STARRED, dbConn.getRssItemsIdsFromList(dbConn.getAllNewStarredRssItems()) ); itemsToSync.put( FeedItemTags.MARK_ITEM_AS_UNSTARRED, dbConn.getRssItemsIdsFromList(dbConn.getAllNewUnstarredRssItems()) ); Log.d(TAG, "itemsToSync[MARK_ITEM_AS_READ]:" + itemsToSync.get(FeedItemTags.MARK_ITEM_AS_READ).size()); Log.d(TAG, "itemsToSync[MARK_ITEM_AS_UNREAD]:" + itemsToSync.get(FeedItemTags.MARK_ITEM_AS_UNREAD).size()); Log.d(TAG, "itemsToSync[MARK_ITEM_AS_STARRED]:" + itemsToSync.get(FeedItemTags.MARK_ITEM_AS_STARRED).size()); Log.d(TAG, "itemsToSync[MARK_ITEM_AS_UNSTARRED]:" + itemsToSync.get(FeedItemTags.MARK_ITEM_AS_UNSTARRED).size()); for(Map.Entry> entry : itemsToSync.entrySet()) { FeedItemTags operation = entry.getKey(); Collection> itemIdsPartitioned = partitionBasedOnSize(entry.getValue(), MAX_SYNC_ITEMS_PER_REQUEST); for(List itemIds : itemIdsPartitioned) { Log.d(TAG, "Marking " + itemIds.size() + " items as " + operation.toString()); PerformTagExecution(itemIds, operation, dbConn, newsApi); } } } static Collection> partitionBasedOnSize(List inputList, int size) { final AtomicInteger counter = new AtomicInteger(0); return inputList.stream() .collect(Collectors.groupingBy(s -> counter.getAndIncrement() / size)) .values(); } private static void executeRequest(ExecuteRequestCallable data, OnSuccessCallable onSuccess) throws IOException { Response response = data.call(); if (response.isSuccessful()) { onSuccess.call(); } else { ResponseBody errorBody = response.errorBody(); if (errorBody != null) { String errorBodyStr = errorBody.string(); Log.e(TAG, errorBodyStr); throw new IOException(errorBodyStr); } else { throw new IOException("mark item as read failed - http code: " + response.code()); } } } private static void PerformTagExecution(List itemIds, FeedItemTags tag, DatabaseConnectionOrm dbConn, NewsAPI newsApi) throws IOException { if (itemIds.size() <= 0) { // Nothing to sync --> Skip return; } switch (tag) { case MARK_ITEM_AS_READ: executeRequest( () -> newsApi.markItemsRead(new ItemIds(itemIds)).execute(), () -> dbConn.change_readUnreadStateOfItem(itemIds, true) ); break; case MARK_ITEM_AS_UNREAD: executeRequest( () -> newsApi.markItemsUnread(new ItemIds(itemIds)).execute(), () -> dbConn.change_readUnreadStateOfItem(itemIds, false) ); break; case MARK_ITEM_AS_STARRED: executeRequest( () -> newsApi.markItemsStarred(new ItemMap(itemIds, dbConn)).execute(), () -> dbConn.changeStarrUnstarrStateOfItem(itemIds, true) ); break; case MARK_ITEM_AS_UNSTARRED: executeRequest( () -> newsApi.markItemsUnstarred(new ItemMap(itemIds, dbConn)).execute(), () -> dbConn.changeStarrUnstarrStateOfItem(itemIds, false) ); break; } } interface ExecuteRequestCallable { T call() throws IOException; } interface OnSuccessCallable { void call(); } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/reader/nextcloud/NewsAPI.java ================================================ package de.luhmer.owncloudnewsreader.reader.nextcloud; import com.nextcloud.android.sso.api.EmptyResponse; import java.util.List; import java.util.Map; import de.luhmer.owncloudnewsreader.database.model.Feed; import de.luhmer.owncloudnewsreader.database.model.Folder; import de.luhmer.owncloudnewsreader.database.model.RssItem; import de.luhmer.owncloudnewsreader.model.NextcloudNewsVersion; import de.luhmer.owncloudnewsreader.model.NextcloudStatus; import io.reactivex.rxjava3.core.Completable; import io.reactivex.rxjava3.core.Observable; import okhttp3.ResponseBody; import retrofit2.Call; import retrofit2.http.Body; import retrofit2.http.DELETE; import retrofit2.http.Field; import retrofit2.http.GET; import retrofit2.http.POST; import retrofit2.http.PUT; import retrofit2.http.Path; import retrofit2.http.Query; import retrofit2.http.Streaming; /** * Created by david on 22.05.17. */ public interface NewsAPI { String mApiEndpoint = "/index.php/apps/news/api/v1-2/"; @GET("status") Observable status(); @GET("version") Observable version(); /** FOLDERS **/ @GET("folders") Observable> folders(); /** FEEDS **/ @GET("feeds") Observable> feeds(); @POST("folders") Call> createFolder(@Body Map folderMap); @POST("folders") Observable> createFolderObservable(@Body Map folderMap); @POST("feeds") Call> createFeed(@Field("url") String url, @Field("folderId") Long parentFolderID); @PUT("feeds/{feedId}/rename") Completable renameFeed(@Path("feedId") long feedId, @Body Map paramMap); @PUT("folders/{folderId}") Completable renameFolder(@Path("folderId") long folderId, @Body Map paramMap); @PUT("feeds/{feedId}/move") Completable moveFeed(@Path("feedId") long feedId, @Body Map folderIdMap); @DELETE("feeds/{feedId}") Completable deleteFeed(@Path("feedId") long feedId); @DELETE("folders/{folderId}") Completable deleteFolder(@Path("folderId") long folderId); /** ITEMS **/ @GET("items") Call> items( @Query("batchSize") long batchSize, @Query("offset") long offset, @Query("type") int type, @Query("id") long id, @Query("getRead") boolean getRead, @Query("oldestFirst") boolean oldestFirst ); @GET("items/updated") @Streaming Observable updatedItems( @Query("lastModified") long lastModified, @Query("type") int type, @Query("id") long id ); @PUT("items/read/multiple") Call markItemsRead(@Body ItemIds items); @PUT("items/unread/multiple") Call markItemsUnread(@Body ItemIds items); @PUT("items/star/multiple") Call markItemsStarred(@Body ItemMap itemMap); @PUT("items/unstar/multiple") Call markItemsUnstarred(@Body ItemMap itemMap); } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/reader/nextcloud/NextcloudNewsDeserializer.java ================================================ package de.luhmer.owncloudnewsreader.reader.nextcloud; import com.google.gson.JsonArray; import com.google.gson.JsonDeserializationContext; import com.google.gson.JsonDeserializer; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParseException; import java.lang.reflect.Type; import java.util.ArrayList; import java.util.List; import de.luhmer.owncloudnewsreader.database.model.Feed; import de.luhmer.owncloudnewsreader.database.model.Folder; import de.luhmer.owncloudnewsreader.database.model.RssItem; /** * Created by david on 24.05.17. */ public class NextcloudNewsDeserializer implements JsonDeserializer> { private final String mKey; private final Class mType; public NextcloudNewsDeserializer(String key, Class type) { this.mKey = key; this.mType = type; } public static final String TAG = NextcloudNewsDeserializer.class.getCanonicalName(); @Override public List deserialize(final JsonElement json, final Type typeOfT, final JsonDeserializationContext context) throws JsonParseException { JsonArray jArr = json.getAsJsonObject().getAsJsonArray(mKey); List items = new ArrayList<>(); for(int i = 0; i < jArr.size(); i++) { if(mType == Folder.class) { items.add((T) parseFolder(jArr.get(i).getAsJsonObject())); } else if(mType == Feed.class) { items.add((T) parseFeed(jArr.get(i).getAsJsonObject())); } else if(mType == RssItem.class) { items.add((T) InsertRssItemIntoDatabase.parseItem(jArr.get(i).getAsJsonObject())); } } return items; } private Folder parseFolder(JsonObject e) { return new Folder(e.get("id").getAsLong(), getNullAsEmptyString(e.get("name"))); } private Feed parseFeed(JsonObject e) { String faviconLink = getNullAsEmptyString(e.get("faviconLink")); if(faviconLink != null) if(faviconLink.equals("null") || faviconLink.trim().equals("")) faviconLink = null; Feed feed = new Feed(); feed.setNotificationChannel("default"); feed.setId(e.get("id").getAsLong()); JsonElement folderId = e.get("folderId"); if(folderId.isJsonNull()) { feed.setFolderId(0L); } else { feed.setFolderId(folderId.getAsLong()); } feed.setFaviconUrl(faviconLink); //Possible XSS fields feed.setFeedTitle(getNullAsEmptyString(e.get("title"))); feed.setLink(getNullAsEmptyString(e.get("url"))); //feed.setLink(e.optString("link")); return feed; } private String getNullAsEmptyString(JsonElement jsonElement) { return jsonElement.isJsonNull() ? "" : jsonElement.getAsString(); } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/reader/nextcloud/NextcloudServerDeserializer.java ================================================ package de.luhmer.owncloudnewsreader.reader.nextcloud; import com.google.gson.JsonDeserializationContext; import com.google.gson.JsonDeserializer; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParseException; import java.lang.reflect.Type; import de.luhmer.owncloudnewsreader.model.OcsUser; /** * Created by david on 24.05.17. */ public class NextcloudServerDeserializer implements JsonDeserializer { private final String mKey; private final Class mType; public NextcloudServerDeserializer(String key, Class type) { this.mKey = key; this.mType = type; } public static final String TAG = NextcloudServerDeserializer.class.getCanonicalName(); @Override public T deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { if(typeOfT == OcsUser.class) { return (T) this.parseOcsUser(json.getAsJsonObject()); } return null; } private static OcsUser parseOcsUser(JsonObject obj) { OcsUser ocsUser = new OcsUser(); JsonElement data = obj.get("ocs").getAsJsonObject().get("data"); if (!data.isJsonNull()) { JsonObject user = data.getAsJsonObject(); if (user.has("id")) { ocsUser.setId(user.get("id").getAsString()); } if (user.has("displayname")) { ocsUser.setDisplayName(user.get("displayname").getAsString()); } else if (user.has("display-name")) { ocsUser.setDisplayName(user.get("display-name").getAsString()); } } return ocsUser; } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/reader/nextcloud/OcsAPI.java ================================================ package de.luhmer.owncloudnewsreader.reader.nextcloud; import de.luhmer.owncloudnewsreader.model.OcsUser; import io.reactivex.rxjava3.core.Observable; import retrofit2.http.GET; public interface OcsAPI { String mApiEndpoint = "/ocs/v2.php/"; @GET("cloud/user?format=json") Observable user(); } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/reader/nextcloud/RssItemObservable.java ================================================ package de.luhmer.owncloudnewsreader.reader.nextcloud; import android.content.SharedPreferences; import android.util.Log; import com.google.gson.JsonObject; import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonToken; import org.reactivestreams.Publisher; import org.reactivestreams.Subscriber; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.util.ArrayList; import java.util.List; import java.util.Objects; import de.luhmer.owncloudnewsreader.Constants; import de.luhmer.owncloudnewsreader.database.DatabaseConnectionOrm; import de.luhmer.owncloudnewsreader.database.model.RssItem; import de.luhmer.owncloudnewsreader.reader.FeedItemTags; import io.reactivex.rxjava3.annotations.NonNull; import io.reactivex.rxjava3.core.Observable; import io.reactivex.rxjava3.core.ObservableSource; import io.reactivex.rxjava3.core.Observer; import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.functions.Function; import okhttp3.ResponseBody; import okio.BufferedSource; /** * onNext returns the current amount of synced items */ public class RssItemObservable implements Publisher { private final DatabaseConnectionOrm mDbConn; private final NewsAPI mNewsApi; private final SharedPreferences mPrefs; private static final String TAG = RssItemObservable.class.getCanonicalName(); private static final int maxSizePerSync = 200; public RssItemObservable(DatabaseConnectionOrm dbConn, NewsAPI newsApi, SharedPreferences prefs) { this.mDbConn = dbConn; this.mNewsApi = newsApi; this.mPrefs = prefs; } @Override public void subscribe(Subscriber s) { try { sync(s); s.onComplete(); } catch (Exception ex) { s.onError(ex); } } public static Observable events(final BufferedSource source) { return Observable.create(e -> { try { InputStreamReader isr = new InputStreamReader(source.inputStream()); BufferedReader br = new BufferedReader(isr); try (isr; br; JsonReader reader = new JsonReader(br)) { reader.beginObject(); String currentName; while (reader.hasNext() && (currentName = reader.nextName()) != null) { if (currentName.equals("items")) { break; } else { reader.skipValue(); } } reader.beginArray(); while (reader.hasNext()) { JsonObject jsonObj = getJsonObjectFromReader(reader); RssItem item = InsertRssItemIntoDatabase.parseItem(Objects.requireNonNull(jsonObj)); e.onNext(item); } reader.endArray(); } } catch (IOException | NullPointerException err) { err.printStackTrace(); e.onError(err); } e.onComplete(); }); } private static long getMaxIdFromItems(List buffer) { long max = 0; for (RssItem item : buffer) { if (item.getId() > max) { max = item.getId(); } } return max; } public static boolean performDatabaseBatchInsert(DatabaseConnectionOrm dbConn, List buffer) { Log.v(TAG, "performDatabaseBatchInsert() called with [" + buffer.size() + " rss items]"); dbConn.insertNewItems(buffer); buffer.clear(); return true; } public void sync(Subscriber subscriber) throws IOException { mDbConn.clearDatabaseOverSize(); long lastModified = mDbConn.getLastModified(); int requestCount = 0; int totalCount = 0; int maxSyncSize = maxSizePerSync; if (lastModified == 0) { // Only on first sync long offset = 0; Log.v(TAG, "First sync - download all available unread articles!!"); // int maxItemsInDatabase = Constants.maxItemsCount; do { Log.v(TAG, "[unread] offset=" + offset + ", requestCount=" + requestCount + ", maxSyncSize=" + maxSyncSize + ", total downloaded=" + totalCount); List buffer = (mNewsApi.items(maxSyncSize, offset, Integer.parseInt(FeedItemTags.ALL.toString()), 0, false, true).execute().body()); requestCount = 0; if(buffer != null) { requestCount = buffer.size(); performDatabaseBatchInsert(mDbConn, buffer); } if(requestCount > 0) offset = mDbConn.getHighestItemId(); totalCount += requestCount; subscriber.onNext(totalCount); } while(requestCount == maxSyncSize); Log.v(TAG, "[all] offset=" + offset + ", requestCount=" + requestCount + ", maxSyncSize=" + maxSyncSize); Log.v(TAG, "Sync all items done - Synchronizing all starred articles now"); mPrefs.edit().putInt(Constants.LAST_UPDATE_NEW_ITEMS_COUNT_STRING, totalCount).apply(); offset = 0; do { List buffer = mNewsApi.items(maxSyncSize, offset, Integer.parseInt(FeedItemTags.ALL_STARRED.toString()), 0, true, true).execute().body(); requestCount = 0; if(buffer != null) { requestCount = buffer.size(); offset = getMaxIdFromItems(buffer); // get maximum id of returned items performDatabaseBatchInsert(mDbConn, buffer); } Log.v(TAG, "[starred] offset=" + offset + ", requestCount=" + requestCount + ", maxSyncSize=" + maxSyncSize + ", total downloaded=" + totalCount); totalCount += requestCount; subscriber.onNext(totalCount); } while(requestCount == maxSyncSize); } else { Log.v(TAG, "Incremental sync!!"); //First reset the count of last updated items mPrefs.edit().putInt(Constants.LAST_UPDATE_NEW_ITEMS_COUNT_STRING, 0).apply(); // long highestItemIdBeforeSync = mDbConn.getHighestItemId(); // Get all updated items mNewsApi.updatedItems(lastModified, Integer.parseInt(FeedItemTags.ALL.toString()), 0) .flatMap((Function>) responseBody -> events(responseBody.source())) .subscribe(new Observer<>() { int totalUpdatedUnreadItemCount = 0; final int bufferSize = maxSizePerSync / 2; final List buffer = new ArrayList<>(bufferSize); // Buffer of size X @Override public void onSubscribe(@NonNull Disposable d) { Log.v(TAG, "onSubscribe() called"); } @Override public void onNext(@NonNull RssItem rssItem) { long rssLastModified = rssItem.getLastModified().getTime(); // Log.v(TAG, "onNext() rssItem: " + rssItem.getTitle() + " - " + rssItem.getLastModified()); // If updated item is unread and last modification was different from last sync time if (!rssItem.getRead() && rssLastModified != lastModified) { totalUpdatedUnreadItemCount++; } buffer.add(rssItem); if (buffer.size() >= bufferSize) { Log.v(TAG, "onNext() buffer size exceeded - insert items: " + buffer.size()); performDatabaseBatchInsert(mDbConn, buffer); } } @Override public void onError(@NonNull Throwable e) { Log.e(TAG, "onError() called with: e = [" + e + "]"); } @Override public void onComplete() { Log.v(TAG, "onComplete() called - items: " + buffer.size()); performDatabaseBatchInsert(mDbConn, buffer); //If no exception occurs, set the number of updated items mPrefs.edit().putInt(Constants.LAST_UPDATE_NEW_ITEMS_COUNT_STRING, totalUpdatedUnreadItemCount).apply(); } }); } } private static JsonObject getJsonObjectFromReader(JsonReader jsonReader) { JsonObject jObj = new JsonObject(); JsonToken tokenInstance; try { tokenInstance = jsonReader.peek(); if(tokenInstance == JsonToken.BEGIN_OBJECT) jsonReader.beginObject(); else if (tokenInstance == JsonToken.BEGIN_ARRAY) jsonReader.beginArray(); while(jsonReader.hasNext()) { JsonToken token; String name; try { name = jsonReader.nextName(); token = jsonReader.peek(); //Log.d(TAG, token.toString()); switch(token) { case NUMBER: jObj.addProperty(name, jsonReader.nextLong()); break; case NULL: jsonReader.skipValue(); break; case BOOLEAN: jObj.addProperty(name, jsonReader.nextBoolean()); break; case BEGIN_OBJECT: jObj.add(name, getJsonObjectFromReader(jsonReader)); break; case BEGIN_ARRAY: jsonReader.skipValue(); break; default: jObj.addProperty(name, jsonReader.nextString()); } } catch(Exception ex) { ex.printStackTrace(); jsonReader.skipValue(); } } if(tokenInstance == JsonToken.BEGIN_OBJECT) jsonReader.endObject(); else if (tokenInstance == JsonToken.BEGIN_ARRAY) jsonReader.endArray(); return jObj; } catch (Exception e) { e.printStackTrace(); } return null; } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/reader/nextcloud/Types.java ================================================ package de.luhmer.owncloudnewsreader.reader.nextcloud; /** * Created by david on 24.05.17. */ public enum Types { FOLDERS("folders"), FEEDS("feeds"), ITEMS("items"); private final String text; /** * @param text */ private Types(final String text) { this.text = text; } /* (non-Javadoc) * @see java.lang.Enum#toString() */ @Override public String toString() { return text; } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/services/DownloadImagesService.java ================================================ /* * Android ownCloud News * * @author David Luhmer * @copyright 2013 David Luhmer david-dev@live.de * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE * License as published by the Free Software Foundation; either * version 3 of the License, or any later version. * * This library 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 AFFERO GENERAL PUBLIC LICENSE for more details. * * You should have received a copy of the GNU Affero General Public * License along with this library. If not, see . * */ package de.luhmer.owncloudnewsreader.services; import android.app.NotificationManager; import android.content.Context; import android.content.Intent; import android.util.Log; import androidx.annotation.NonNull; import androidx.core.app.JobIntentService; import androidx.core.app.NotificationCompat; import com.bumptech.glide.Glide; import com.bumptech.glide.RequestManager; import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.Random; import de.greenrobot.dao.query.LazyList; import de.luhmer.owncloudnewsreader.R; import de.luhmer.owncloudnewsreader.async_tasks.DownloadImageHandler; import de.luhmer.owncloudnewsreader.database.DatabaseConnectionOrm; import de.luhmer.owncloudnewsreader.database.model.Feed; import de.luhmer.owncloudnewsreader.database.model.RssItem; import de.luhmer.owncloudnewsreader.helper.FavIconHandler; import de.luhmer.owncloudnewsreader.helper.ImageHandler; import de.luhmer.owncloudnewsreader.notification.NextcloudNotificationManager; public class DownloadImagesService extends JobIntentService { public static final String LAST_ITEM_ID = "LAST_ITEM_ID"; private static final String TAG = DownloadImagesService.class.getCanonicalName(); public enum DownloadMode { FAVICONS_ONLY, PICTURES_ONLY, FAVICONS_AND_PICTURES } public static final String DOWNLOAD_MODE_STRING = "DOWNLOAD_MODE"; private static Random random; private int NOTIFICATION_ID = 1923; private NotificationCompat.Builder mNotificationDownloadImages; private int maxCount; private NotificationManager mNotificationManager; /** * Unique job/channel ID for this service. */ private static final int JOB_ID = 1000; private static final String CHANNEL_ID = "Download Images Service"; /** * Convenience method for enqueuing work in to this service. */ public static void enqueueWork(Context context, Intent work) { enqueueWork(context, DownloadImagesService.class, JOB_ID, work); } @Override public void onCreate() { super.onCreate(); try { maxCount = 0; if (random == null) random = new Random(); NOTIFICATION_ID = random.nextInt(); mNotificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); } catch (Exception ex) { ex.printStackTrace(); } } @Override public void onDestroy() { Log.d(TAG, "onDestroy"); if(mNotificationDownloadImages != null) { if(maxCount == 0) { mNotificationManager.cancel(NOTIFICATION_ID); } } super.onDestroy(); } @Override protected void onHandleWork(@NonNull Intent intent) { DownloadMode downloadMode = (DownloadMode) intent.getSerializableExtra(DOWNLOAD_MODE_STRING); DatabaseConnectionOrm dbConn = new DatabaseConnectionOrm(this); mNotificationDownloadImages = NextcloudNotificationManager.buildNotificationDownloadImageService(this, CHANNEL_ID); if(Objects.equals(downloadMode, DownloadMode.FAVICONS_ONLY)) { List feedList = dbConn.getListOfFeeds(); FavIconHandler favIconHandler = new FavIconHandler(getApplicationContext()); for(Feed feed : feedList) { try { favIconHandler.preCacheFavIcon(feed); } catch(IllegalStateException ex) { Log.e(TAG, ex.getMessage()); } } } else if(Objects.equals(downloadMode, DownloadMode.FAVICONS_AND_PICTURES) || Objects.equals(downloadMode, DownloadMode.PICTURES_ONLY)) { long lastId = intent.getLongExtra(LAST_ITEM_ID, 0); List rssItemList = dbConn.getAllItemsWithIdHigher(lastId); List links = new ArrayList<>(); for(RssItem rssItem : rssItemList) { String body = rssItem.getBody(); links.addAll(ImageHandler.getImageLinksFromText(rssItem.getLink(), body)); if(links.size() > 10000) { NextcloudNotificationManager.showNotificationImageDownloadLimitReached(this, CHANNEL_ID, 10000); break; } } ((LazyList)rssItemList).close(); maxCount = links.size(); if (maxCount > 0) { mNotificationManager.notify(NOTIFICATION_ID, mNotificationDownloadImages.build()); } downloadImages(links); } } private void downloadImages(List linksToImages) { try { RequestManager glide = Glide.with(this.getApplicationContext()); while(linksToImages.size() > 0) { String link = linksToImages.remove(0); new DownloadImageHandler(link).preloadSync(glide); updateNotificationProgress(linksToImages.size()); } } catch (Exception ex) { ex.printStackTrace(); Log.e(TAG, "Error while downloading images."); mNotificationDownloadImages .setContentText("Error while downloading images - " + ex.toString()) .setProgress(0, 0, false); mNotificationManager.notify(NOTIFICATION_ID, mNotificationDownloadImages.build()); } } private void updateNotificationProgress(int remainingImagesCount) { int count = maxCount - remainingImagesCount; if(maxCount == count) { mNotificationManager.cancel(NOTIFICATION_ID); } else { mNotificationDownloadImages .setContentText((count + 1) + "/" + maxCount + " - " + getString(R.string.notification_download_images_offline)) .setProgress(maxCount, count + 1, false); mNotificationManager.notify(NOTIFICATION_ID, mNotificationDownloadImages.build()); } } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/services/DownloadWebPageService.java ================================================ package de.luhmer.owncloudnewsreader.services; import android.app.NotificationManager; import android.app.PendingIntent; import android.app.Service; import android.content.Context; import android.content.Intent; import android.os.Handler; import android.os.IBinder; import android.os.Looper; import androidx.core.app.NotificationCompat; import android.util.Log; import android.webkit.ConsoleMessage; import android.webkit.ValueCallback; import android.webkit.WebChromeClient; import android.webkit.WebResourceError; import android.webkit.WebResourceRequest; import android.webkit.WebView; import android.webkit.WebViewClient; import org.greenrobot.eventbus.EventBus; import org.greenrobot.eventbus.Subscribe; import java.io.File; import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import de.luhmer.owncloudnewsreader.R; import de.luhmer.owncloudnewsreader.database.DatabaseConnectionOrm; import de.luhmer.owncloudnewsreader.database.model.RssItem; import de.luhmer.owncloudnewsreader.helper.NewsFileUtils; import de.luhmer.owncloudnewsreader.helper.NotificationActionReceiver; import de.luhmer.owncloudnewsreader.notification.NextcloudNotificationManager; import de.luhmer.owncloudnewsreader.services.events.StopWebArchiveDownloadEvent; import static de.luhmer.owncloudnewsreader.Constants.NOTIFICATION_ACTION_STOP_STRING; /** * An {@link Service} subclass for handling asynchronous task requests in * a service on a separate handler thread. *

* helper methods. */ public class DownloadWebPageService extends Service { private static final String TAG = DownloadWebPageService.class.getCanonicalName(); private static final int JOB_ID = 1002; private static final int NOTIFICATION_ID = JOB_ID; private static final String CHANNEL_ID = "Download Web Page Service"; public static final String WebArchiveFinalPrefix = "web_archive_"; private static final int NUMBER_OF_CORES = 4; private NotificationCompat.Builder mNotificationWebPages; private NotificationManager mNotificationManager; // Sets the amount of time an idle thread waits before terminating private static final int KEEP_ALIVE_TIME = 1; // Sets the Time Unit to seconds private static final TimeUnit KEEP_ALIVE_TIME_UNIT = TimeUnit.SECONDS; private final AtomicBoolean interrupted = new AtomicBoolean(); private final AtomicInteger doneCount = new AtomicInteger(); private Integer totalCount = 0; private ThreadPoolExecutor mDownloadThreadPool; @Override public void onCreate() { Log.d(TAG, "onCreate() called"); super.onCreate(); initNotification(); downloadWebPages(); EventBus.getDefault().register(this); startForeground(NOTIFICATION_ID, mNotificationWebPages.build()); } @Override public void onDestroy() { Log.d(TAG, "onDestroy() called"); mNotificationManager.cancel(NOTIFICATION_ID); EventBus.getDefault().unregister(this); super.onDestroy(); } @Override public IBinder onBind(Intent intent) { return null; } private void initNotification() { mNotificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); mNotificationWebPages = NextcloudNotificationManager.buildNotificationDownloadWebPageService(this, CHANNEL_ID); Intent stopIntent = new Intent(this, NotificationActionReceiver.class); stopIntent.setAction(NOTIFICATION_ACTION_STOP_STRING); PendingIntent stopPendingIntent = PendingIntent.getBroadcast(this, 0, stopIntent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_ONE_SHOT); mNotificationWebPages.addAction(R.drawable.ic_action_pause_24, "Stop", stopPendingIntent); } @Subscribe public void onEvent(StopWebArchiveDownloadEvent event) { mDownloadThreadPool.shutdownNow(); interrupted.set(true); stopSelf(); } private void runOnMainThreadAndWait(final Runnable runnable) throws InterruptedException { synchronized(runnable) { Handler handler = new Handler(Looper.getMainLooper()); handler.post(() -> { runnable.run(); synchronized (runnable) { runnable.notifyAll(); } }); runnable.wait(); // unlocks runnable while waiting } } private void delayedRunOnMainThread(Runnable runnable, @SuppressWarnings("SameParameterValue") int waitMillis) { try { Thread.sleep(waitMillis); runOnMainThreadAndWait(runnable); } catch (InterruptedException e) { Log.e(TAG, "Error occurred..", e); } } private void downloadWebPages() { mNotificationWebPages.setProgress(0, 100, true); mNotificationManager.notify(NOTIFICATION_ID, mNotificationWebPages.build()); final DatabaseConnectionOrm dbConn = new DatabaseConnectionOrm(DownloadWebPageService.this); final BlockingQueue downloadWorkQueue = new LinkedBlockingQueue<>(); NewsFileUtils.getWebPageArchiveStorage(this).mkdirs(); for (RssItem rssItem : dbConn.getAllUnreadRssItemsForDownloadWebPageService()) { downloadWorkQueue.add(new DownloadWebPage(rssItem.getLink())); } //downloadWorkQueue.clear(); /* List items = dbConn.getAllUnreadRssItemsForDownloadWebPageService(); for (int i = 0; i < 5; i++) { downloadWorkQueue.add(new DownloadWebPage(items.get(i).getLink())); } */ startDownloadingQueue(downloadWorkQueue); } private void startDownloadingQueue(BlockingQueue downloadWorkQueue) { totalCount = downloadWorkQueue.size(); // Creates a thread pool manager mDownloadThreadPool = new ThreadPoolExecutor( NUMBER_OF_CORES, // Initial pool size NUMBER_OF_CORES, // Max pool size KEEP_ALIVE_TIME, KEEP_ALIVE_TIME_UNIT, downloadWorkQueue); // Start all tasks in queue mDownloadThreadPool.prestartAllCoreThreads(); // Tell ThreadPoolExecutor to stop once done mDownloadThreadPool.shutdown(); // If no articles are present, remove notification right away. Otherwise the user has to close it manually if(totalCount == 0) { mNotificationManager.cancel(NOTIFICATION_ID); } } class DownloadWebPage implements Runnable { private final String url; private WebView webView; private final Object lock; DownloadWebPage(String url) { this.url = url; lock = new Object(); } @Override public void run() { //Log.v(TAG, "Running DownloadWebPage for url: " + url); synchronized (lock) { File webArchiveFile = getWebPageArchiveFileForUrl(DownloadWebPageService.this, url); if (!webArchiveFile.exists()) { //Log.v(TAG, "Loading page:"); initWebView(); loadUrlInWebViewAndWait(); } else { Log.v(TAG, "Already cached article: " + url); } } updateNotificationProgress(); } private void initWebView() { try { runOnMainThreadAndWait(() -> { webView = new WebView(DownloadWebPageService.this); webView.setWebViewClient(new DownloadImageWebViewClient(lock)); webView.setWebChromeClient(new DownloadImageWebViewChromeClient()); }); } catch (InterruptedException e) { Log.e(TAG, "Error while setting up WebView", e); } } private void loadUrlInWebViewAndWait() { try { runOnMainThreadAndWait(() -> { Log.d(TAG, "downloading website for url: " + url); webView.loadUrl(url); }); lock.wait(); } catch (InterruptedException e) { Log.e(TAG, "Error while opening url", e); } } } static class DownloadImageWebViewChromeClient extends WebChromeClient { @Override public boolean onConsoleMessage(ConsoleMessage cm) { //Log.d("TAG", cm.message() + " at " + cm.sourceId() + ":" + cm.lineNumber()); return true; } } class DownloadImageWebViewClient extends WebViewClient { private final String TAG = DownloadImageWebViewClient.class.getName(); private final Object lock; private boolean failed = false; DownloadImageWebViewClient(Object lock) { this.lock = lock; } @Override public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) { //Log.e(TAG, "onReceivedError() called with: view = [" + view + "], request = [" + request + "], error = [" + error + "]"); failed = true; super.onReceivedError(view, request, error); } public void onPageFinished(final WebView view, final String url) { //Log.e(TAG, "onPageFinished() called with: view = [" + view + "], url = [" + url + "]"); if(failed) { Log.e(TAG, "Skipping onPageFinished as request failed.. " + url); } else { saveWebArchive(view, url); } // Notify waiting thread that we're done.. synchronized (lock) { lock.notifyAll(); } } private void saveWebArchive(final WebView view, final String url) { new Thread(() -> delayedRunOnMainThread(() -> { // Can't store directly on external dir.. (workaround -> store on internal storage first and move then)) final File webArchive = getWebPageArchiveFileForUrl(DownloadWebPageService.this, url); final File webArchiveExternalStorage = getWebPageArchiveFileForUrl(DownloadWebPageService.this, url); view.saveWebArchive(webArchive.getAbsolutePath(), false, value -> { // Move file to external storage once done writing webArchive.renameTo(webArchiveExternalStorage); //boolean success = webArchive.renameTo(webArchiveExternalStorage); //Log.v(TAG, "Move succeeded: " + success); }); }, 2000)).start(); } } private synchronized void updateNotificationProgress() { if(interrupted.get()) { Log.v(TAG, "interrupted.. stop requested.. do not show progress anymore!"); } else { int current = doneCount.incrementAndGet(); Log.d(TAG, String.format("updateNotificationProgress (%d/%d)", current, totalCount)); if (current == totalCount) { //mNotificationManager.cancel(NOTIFICATION_ID); EventBus.getDefault().post(new StopWebArchiveDownloadEvent()); } else { mNotificationWebPages .setContentText((current) + "/" + totalCount + " - " + getString(R.string.notification_download_articles_offline)) .setProgress(totalCount, current, false); mNotificationManager.notify(NOTIFICATION_ID, mNotificationWebPages.build()); } } } public static File getWebPageArchiveFileForUrl(Context context, String url) { return new File(NewsFileUtils.getWebPageArchiveStorage(context), getWebPageArchiveFilename(url)); } public static String getWebPageArchiveFilename(String url) { return WebArchiveFinalPrefix + url.hashCode() + ".mht"; } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/services/OwnCloudAuthenticatorService.kt ================================================ package de.luhmer.owncloudnewsreader.services import android.app.Service import android.content.Intent import android.os.IBinder import de.luhmer.owncloudnewsreader.authentication.OwnCloudAccountAuthenticator class OwnCloudAuthenticatorService : Service() { // Instance field that stores the authenticator object private var mAuthenticator: OwnCloudAccountAuthenticator? = null override fun onCreate() { // Create a new authenticator object mAuthenticator = OwnCloudAccountAuthenticator(this) } /* * When the system binds to this Service to make the RPC call * return the authenticator's IBinder. */ override fun onBind(intent: Intent): IBinder? = mAuthenticator?.iBinder } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/services/OwnCloudSyncService.java ================================================ package de.luhmer.owncloudnewsreader.services; import android.app.Service; import android.content.Intent; import android.os.IBinder; import android.util.Log; import de.luhmer.owncloudnewsreader.authentication.OwnCloudSyncAdapter; public class OwnCloudSyncService extends Service { // https://developer.android.com/training/sync-adapters/creating-sync-adapter#java private static final String TAG = OwnCloudSyncService.class.getCanonicalName(); private static final Object sSyncAdapterLock = new Object(); private static OwnCloudSyncAdapter sSyncAdapter = null; @Override public void onCreate() { /* * Create the sync adapter as a singleton. * Set the sync adapter as syncable * Disallow parallel syncs */ synchronized (sSyncAdapterLock) { if (sSyncAdapter == null) { sSyncAdapter = new OwnCloudSyncAdapter(getApplicationContext(), true); } } } @Override public IBinder onBind(Intent intent) { /* * Get the object that allows external processes * to call onPerformSync(). The object is created * in the base class code when the SyncAdapter * constructors call super() */ return sSyncAdapter.getSyncAdapterBinder(); } public static boolean isSyncRunning() { Log.d(TAG, "isSyncRunning() called"); //return syncRunning; if(sSyncAdapter != null) { return sSyncAdapter.syncRunning; } return false; } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/services/PodcastDownloadService.java ================================================ package de.luhmer.owncloudnewsreader.services; import android.app.DownloadManager; import android.app.IntentService; import android.app.NotificationManager; import android.content.Context; import android.content.Intent; import android.net.Uri; import androidx.core.app.NotificationCompat; import android.util.Log; import android.widget.Toast; import org.greenrobot.eventbus.EventBus; import java.io.BufferedInputStream; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.math.BigInteger; import java.net.URL; import java.net.URLConnection; import java.security.MessageDigest; import java.util.Locale; import de.luhmer.owncloudnewsreader.helper.NewsFileUtils; import de.luhmer.owncloudnewsreader.model.PodcastItem; import de.luhmer.owncloudnewsreader.notification.NextcloudNotificationManager; /** * An {@link IntentService} subclass for handling asynchronous task requests in * a service on a separate handler thread. *

* helper methods. */ public class PodcastDownloadService extends IntentService { private static final String TAG = PodcastDownloadService.class.getCanonicalName(); // IntentService can perform, e.g. ACTION_FETCH_NEW_ITEMS private static final String ACTION_DOWNLOAD = "de.luhmer.owncloudnewsreader.services.action.DOWNLOAD"; private static final String EXTRA_RECEIVER = "de.luhmer.owncloudnewsreader.services.extra.RECEIVER"; private static final String EXTRA_URL = "de.luhmer.owncloudnewsreader.services.extra.URL"; private final EventBus eventBus; /** * Starts this service to download a podcast. If * the service is already performing a task this action will be queued. * * @see IntentService */ public static void startPodcastDownload(Context context, PodcastItem podcastItem/*, ResultReceiver receiver*/) { Intent intent = new Intent(context, PodcastDownloadService.class); intent.setAction(ACTION_DOWNLOAD); intent.putExtra(EXTRA_URL, podcastItem); //intent.putExtra(EXTRA_RECEIVER, receiver); context.startService(intent); } public PodcastDownloadService() { super("PodcastDownloadService"); eventBus = EventBus.getDefault(); } @Override protected void onHandleIntent(Intent intent) { if (intent != null) { final String action = intent.getAction(); if (ACTION_DOWNLOAD.equals(action)) { //ResultReceiver receiver = intent.getParcelableExtra(EXTRA_RECEIVER); PodcastItem podcast = (PodcastItem) intent.getSerializableExtra(EXTRA_URL); downloadPodcast(podcast, this); } } } /** * Handle action Foo in the provided background thread with the provided * parameters. */ private void handleActionDownload(PodcastItem podcast) { Uri uri = Uri.parse(podcast.link); DownloadManager.Request request = new DownloadManager.Request(uri); request.setDescription(podcast.mimeType); request.setTitle(podcast.title); request.allowScanningByMediaScanner(); request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED); String path = "file://" + getUrlToPodcastFile(this, podcast.fingerprint, podcast.link, true); request.setDestinationUri(Uri.parse(path)); //request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, "bla.txt"); // get download service and enqueue file DownloadManager manager = (DownloadManager) getSystemService(Context.DOWNLOAD_SERVICE); manager.enqueue(request); } public static String getUrlToPodcastFile(Context context, String fingerprint, String WEB_URL_TO_FILE, boolean createDir) { File file = new File(WEB_URL_TO_FILE); String path = NewsFileUtils.getPathPodcasts(context) + "/" + fingerprint + "/"; if(createDir) new File(path).mkdirs(); return path + file.getName(); } private void downloadPodcast(PodcastItem podcast, Context context) { NotificationManager notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); NotificationCompat.Builder mNotificationDownloadPodcast = NextcloudNotificationManager.buildDownloadPodcastNotification(context, "Download Podcast"); int NOTIFICATION_ID = 543226; notificationManager.notify(NOTIFICATION_ID, mNotificationDownloadPodcast.build()); try { String urlTemp = podcast.link; String path = getUrlToPodcastFile(this, podcast.fingerprint, urlTemp, true); Log.v(TAG, "Storing podcast to: " + path); URL url = new URL(urlTemp); URLConnection connection = url.openConnection(); connection.connect(); connection.setConnectTimeout(10000); connection.setReadTimeout(120000);//2min // this will be useful so that you can show a typical 0-100% progress bar int fileLength = connection.getContentLength(); //float fileSizeInMb = (float)fileLength / 1024f / 1024f; float fileSizeInMb = (float)fileLength / 1000f / 1000f; // This matches the actual file size.. // download the file InputStream input = new BufferedInputStream(url.openStream()); String pathCache = path + ".download"; OutputStream output = new FileOutputStream(pathCache); long startTime = System.nanoTime(); byte[] data = new byte[1024]; long total = 0; int count; int lastProgress = -1; int byteCountSinceLastProgress = 0; while ((count = input.read(data)) != -1) { total += count; byteCountSinceLastProgress += count; podcast.downloadProgress = (int) (total * 100 / fileLength); //Only update the ui/notification if the progress changed (e.g. from 1% to 2%) if(lastProgress != podcast.downloadProgress) { lastProgress = podcast.downloadProgress; eventBus.post(new DownloadProgressUpdate(podcast)); float speedInKBps = calculateNetworkSpeed(byteCountSinceLastProgress, startTime); startTime = System.nanoTime(); byteCountSinceLastProgress = 0; mNotificationDownloadPodcast.setProgress(100, podcast.downloadProgress, false); mNotificationDownloadPodcast.setContentText(podcast.downloadProgress + "% - " + formatFloat(speedInKBps) + "KB/s - " + formatFloat(fileSizeInMb) + "MB"); notificationManager.notify(NOTIFICATION_ID, mNotificationDownloadPodcast.build()); } output.write(data, 0, count); } output.flush(); output.close(); input.close(); new File(pathCache).renameTo(new File(path)); } catch (IOException e) { e.printStackTrace(); Toast.makeText(context, e.getLocalizedMessage(), Toast.LENGTH_SHORT).show(); } podcast.downloadProgress = 100; eventBus.post(new DownloadProgressUpdate(podcast)); notificationManager.cancel(NOTIFICATION_ID); /* Bundle resultData = new Bundle(); resultData.putInt("progress" ,100); receiver.send(UPDATE_PROGRESS, resultData); */ } private float calculateNetworkSpeed(int byteCountSinceLastProgress, long startTime) { float speedInKBps = 0.0f; try { // seconds, milliseconds, microseconds, nanoseconds long currentTime = System.nanoTime(); float timeInSecs = (currentTime - startTime) / 1000f / 1000f / 1000f; speedInKBps = ((float)byteCountSinceLastProgress / timeInSecs) / 1024f; } catch (ArithmeticException ae) { // ignore.. } return speedInKBps; } private String formatFloat(float val) { return String.format(Locale.getDefault(), "%.1f", val); } //public static final int UPDATE_PROGRESS = 5555; public static class DownloadProgressUpdate { public DownloadProgressUpdate(PodcastItem podcast) { this.podcast = podcast; } public PodcastItem podcast; } public static boolean PodcastAlreadyCached(Context context, String podcastFingerprint, String podcastUrl) { File file = new File(PodcastDownloadService.getUrlToPodcastFile(context, podcastFingerprint, podcastUrl, false)); return file.exists(); } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/services/PodcastPlaybackService.java ================================================ package de.luhmer.owncloudnewsreader.services; import static android.view.KeyEvent.KEYCODE_MEDIA_STOP; import static de.luhmer.owncloudnewsreader.database.DatabaseConnectionOrm.ParsePodcastItemFromRssItem; import android.Manifest; import android.app.PendingIntent; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; import android.media.AudioManager; import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.os.ResultReceiver; import android.support.v4.media.MediaBrowserCompat; import android.support.v4.media.MediaDescriptionCompat; import android.support.v4.media.MediaMetadataCompat; import android.support.v4.media.session.MediaSessionCompat; import android.support.v4.media.session.PlaybackStateCompat; import android.telephony.PhoneStateListener; import android.telephony.TelephonyCallback; import android.telephony.TelephonyManager; import android.text.TextUtils; import android.util.Log; import android.view.KeyEvent; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import androidx.core.app.ActivityCompat; import androidx.media.MediaBrowserServiceCompat; import androidx.media.session.MediaButtonReceiver; import org.greenrobot.eventbus.EventBus; import org.greenrobot.eventbus.Subscribe; import java.util.ArrayList; import java.util.List; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; import de.luhmer.owncloudnewsreader.NewsReaderListActivity; import de.luhmer.owncloudnewsreader.R; import de.luhmer.owncloudnewsreader.database.DatabaseConnectionOrm; import de.luhmer.owncloudnewsreader.database.model.RssItem; import de.luhmer.owncloudnewsreader.events.podcast.ExitPlayback; import de.luhmer.owncloudnewsreader.events.podcast.NewPodcastPlaybackListener; import de.luhmer.owncloudnewsreader.events.podcast.PodcastCompletedEvent; import de.luhmer.owncloudnewsreader.events.podcast.RegisterVideoOutput; import de.luhmer.owncloudnewsreader.events.podcast.SeekPodcast; import de.luhmer.owncloudnewsreader.events.podcast.SpeedPodcast; import de.luhmer.owncloudnewsreader.events.podcast.TogglePlayerStateEvent; import de.luhmer.owncloudnewsreader.events.podcast.WindPodcast; import de.luhmer.owncloudnewsreader.model.MediaItem; import de.luhmer.owncloudnewsreader.model.PodcastFeedItem; import de.luhmer.owncloudnewsreader.model.PodcastItem; import de.luhmer.owncloudnewsreader.model.TTSItem; import de.luhmer.owncloudnewsreader.services.podcast.MediaPlayerPlaybackService; import de.luhmer.owncloudnewsreader.services.podcast.PlaybackService; import de.luhmer.owncloudnewsreader.services.podcast.TTSPlaybackService; import de.luhmer.owncloudnewsreader.view.PodcastNotification; public class PodcastPlaybackService extends MediaBrowserServiceCompat { /** Declares that ContentStyle is supported */ public static final String CONTENT_STYLE_SUPPORTED = "android.media.browse.CONTENT_STYLE_SUPPORTED"; /** * Bundle extra indicating the presentation hint for playable media items. */ public static final String CONTENT_STYLE_PLAYABLE_HINT = "android.media.browse.CONTENT_STYLE_PLAYABLE_HINT"; /** * Bundle extra indicating the presentation hint for browsable media items. */ public static final String CONTENT_STYLE_BROWSABLE_HINT = "android.media.browse.CONTENT_STYLE_BROWSABLE_HINT"; /** * Specifies the corresponding items should be presented as lists. */ public static final int CONTENT_STYLE_LIST_ITEM_HINT_VALUE = 1; /** * Specifies that the corresponding items should be presented as grids. */ public static final int CONTENT_STYLE_GRID_ITEM_HINT_VALUE = 2; public static final String MEDIA_ITEM = "MediaItem"; private static final String TAG = "PodcastPlaybackService"; public static final String PLAYBACK_SPEED_FLOAT = "PLAYBACK_SPEED"; public static final String CURRENT_PODCAST_ITEM_MEDIA_ITEM = "CURRENT_PODCAST_ITEM"; public static final String CURRENT_PODCAST_MEDIA_TYPE = "CURRENT_PODCAST_MEDIA_TYPE"; private static final long PROGRESS_UPDATE_INTERNAL = 1000; private static final long PROGRESS_UPDATE_INITIAL_INTERVAL = 100; private PodcastNotification podcastNotification; private EventBus eventBus; private Handler mHandler; private PlaybackService mPlaybackService; private MediaSessionCompat mSession; public static final float[] PLAYBACK_SPEEDS = { 0.25f, 0.5f, 0.75f, 1.0f, 1.25f, 1.5f, 1.75f, 2.0f, 2.5f, 3.0f }; private float currentPlaybackSpeed = 1; public static final int delay = 500; //In milliseconds private final ScheduledExecutorService mExecutorService = Executors.newSingleThreadScheduledExecutor(); private ScheduledFuture mScheduleFuture; private CustomTelephonyCallback customTelephonyCallback; public MediaItem getCurrentlyPlayingPodcast() { if(mPlaybackService != null) { return mPlaybackService.getMediaItem(); } return null; } public boolean isActive() { return mPlaybackService != null; } static final AtomicLong NEXT_ID = new AtomicLong(0); final long id = NEXT_ID.getAndIncrement(); @Nullable @Override public BrowserRoot onGetRoot(@NonNull String s, int i, @Nullable Bundle bundle) { Bundle extras = new Bundle(); extras.putBoolean(CONTENT_STYLE_SUPPORTED, true); extras.putInt(CONTENT_STYLE_BROWSABLE_HINT, CONTENT_STYLE_GRID_ITEM_HINT_VALUE); extras.putInt(CONTENT_STYLE_PLAYABLE_HINT, CONTENT_STYLE_LIST_ITEM_HINT_VALUE); return new MediaBrowserServiceCompat.BrowserRoot( getString(R.string.app_name),// Name visible in Android Auto extras); } @Override public void onLoadChildren(@NonNull String s, @NonNull Result> result) { Log.d(TAG, "onLoadChildren() called with: s = [" + s + "], result = [" + result + "]"); List mediaItems = new ArrayList<>(); DatabaseConnectionOrm dbConn = new DatabaseConnectionOrm(this); if(!s.startsWith("FEED_")) { for (PodcastFeedItem feed : dbConn.getListOfFeedsWithAudioPodcasts()) { MediaDescriptionCompat.Builder desc = new MediaDescriptionCompat.Builder() .setMediaId("FEED_" + feed.mFeed.getId()) .setTitle(feed.mFeed.getFeedTitle()) .setSubtitle(feed.mPodcastCount + " podcasts"); if (feed.mFeed.getFaviconUrl() != null) { desc.setIconUri(Uri.parse(feed.mFeed.getFaviconUrl())); } mediaItems.add(new MediaBrowserCompat.MediaItem(desc.build(), MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)); } } else { long feedId = Long.parseLong(s.substring(5)); for (PodcastItem item: dbConn.getListOfAudioPodcastsForFeed(this, feedId)) { MediaDescriptionCompat.Builder desc = new MediaDescriptionCompat.Builder() .setMediaId("PODCAST_" + item.itemId) .setTitle(item.title); if (item.author != null) { desc.setSubtitle(item.author); } if (item.favIcon != null) { desc.setIconUri(Uri.parse(item.favIcon)); } /* //Song duration Long duration = 100L; Bundle songDuration = new Bundle(); songDuration.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, duration); desc.setExtras(songDuration); */ mediaItems.add(new MediaBrowserCompat.MediaItem(desc.build(), MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)); } } result.sendResult(mediaItems); } @Override public boolean onUnbind(Intent intent) { Log.d(TAG, "onUnbind() called with: intent = [" + intent + "] - ID: " + id); return super.onUnbind(intent); } @Override public void onCreate() { super.onCreate(); Log.v(TAG, "onCreate PodcastPlaybackService - ID: " + id); // pause podcast when phone is ringing if(ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_GRANTED) { if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { TelephonyManager telephony = (TelephonyManager) this.getSystemService(Context.TELEPHONY_SERVICE); customTelephonyCallback = new CustomTelephonyCallback(); telephony.registerTelephonyCallback(this.getMainExecutor(), customTelephonyCallback); } else { TelephonyManager mgr = (TelephonyManager) getSystemService(TELEPHONY_SERVICE); if (mgr != null) { mgr.listen(phoneStateListener, PhoneStateListener.LISTEN_CALL_STATE); } } } initMediaSessions(); podcastNotification = new PodcastNotification(this, mSession); mHandler = new Handler(); eventBus = EventBus.getDefault(); eventBus.register(this); //eventBus.post(new PodcastPlaybackServiceStarted()); setSessionToken(mSession.getSessionToken()); Intent intent = new Intent(this, NewsReaderListActivity.class); intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); PendingIntent pi = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); mSession.setSessionActivity(pi); //startForeground(PodcastNotification.NOTIFICATION_ID, podcastNotification.getNotification()); /* //Handles headphones coming unplugged. cannot be done through a manifest receiver IntentFilter filter = new IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY); registerReceiver(mNoisyReceiver, filter); */ } @Override public void onDestroy() { Log.v(TAG, "onDestroy PodcastPlaybackService - ID: " + id); if (!isActive()) { Log.v(TAG, "Stopping PodcastPlaybackService/PlaybackService because of inactivity"); stopSelf(); if (mSession != null) { mSession.release(); } } else { Log.v(TAG, "Stopping PlaybackService is not active - skip exit"); } try { if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { TelephonyManager telephony = (TelephonyManager) this.getSystemService(Context.TELEPHONY_SERVICE); telephony.unregisterTelephonyCallback(customTelephonyCallback); } else { TelephonyManager mgr = (TelephonyManager) getSystemService(TELEPHONY_SERVICE); if (mgr != null) { mgr.listen(phoneStateListener, PhoneStateListener.LISTEN_NONE); } } } catch (Exception ex) { Log.e(TAG, "Probably missing permission.." + ex); } mExecutorService.shutdown(); podcastNotification.cancel(); eventBus.unregister(this); super.onDestroy(); } @Override public int onStartCommand(Intent intent, int flags, int startId) { Log.d(TAG, "onStartCommand() called with: intent = [" + intent + "], flags = [" + flags + "], startId = [" + startId + "]"); MediaButtonReceiver.handleIntent(mSession, intent); if (intent != null) { if (mPlaybackService != null) { mPlaybackService.destroy(); mPlaybackService = null; } stopProgressUpdates(); if(intent.hasExtra(MEDIA_ITEM)) { MediaItem mediaItem = (MediaItem) intent.getSerializableExtra(MEDIA_ITEM); if (mediaItem instanceof PodcastItem) { //if (((PodcastItem) mediaItem).isYoutubeVideo()) { // mPlaybackService = new YoutubePlaybackService(this, podcastStatusListener, mediaItem); //} else { mPlaybackService = new MediaPlayerPlaybackService(this, podcastStatusListener, mediaItem); //} } else if (mediaItem instanceof TTSItem) { mPlaybackService = new TTSPlaybackService(this, podcastStatusListener, mediaItem); } updateMetadata(mediaItem); // Update notification after setting metadata (notification uses metadata information) podcastNotification.createPodcastNotification(); mPlaybackService.playbackSpeedChanged(currentPlaybackSpeed); startProgressUpdates(); requestAudioFocus(); } } return super.onStartCommand(intent, flags, startId); } private void updateMetadata(MediaItem mediaItem) { MediaItem mi = mediaItem; if(mi == null) { mi = new PodcastItem(-1, "", "", "", "", false, null, false, ""); } int totalDuration = 0; if(mPlaybackService != null) { totalDuration = mPlaybackService.getTotalDuration(); } mSession.setMetadata(new MediaMetadataCompat.Builder() .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, mi.author) .putString(MediaMetadataCompat.METADATA_KEY_TITLE, mi.title) //.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, mediaItem.author) // Android Auto .putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, mi.favIcon) .putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, String.valueOf(mi.itemId)) .putString(CURRENT_PODCAST_MEDIA_TYPE, getCurrentlyPlayedMediaType().toString()) .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, totalDuration) //.putLong(EXTRA_IS_EXPLICIT, EXTRA_METADATA_ENABLED_VALUE) // Android Auto //.putLong(EXTRA_IS_DOWNLOADED, EXTRA_METADATA_ENABLED_VALUE) // Android Auto .build()); } /* private Long getVideoWidth() { if(mPlaybackService instanceof MediaPlayerPlaybackService) { return ((MediaPlayerPlaybackService)mPlaybackService).getVideoWidth(); } return null; } */ private final PlaybackService.PodcastStatusListener podcastStatusListener = new PlaybackService.PodcastStatusListener() { @Override public void podcastStatusUpdated() { syncMediaAndPlaybackStatus(); if(mPlaybackService != null) { updateMetadata(mPlaybackService.getMediaItem()); } } @Override public void podcastCompleted() { Log.d(TAG, "Podcast completed, cleaning up"); endCurrentMediaPlayback(); EventBus.getDefault().post(new PodcastCompletedEvent()); } }; private void endCurrentMediaPlayback() { Log.d(TAG, "endCurrentMediaPlayback() called"); stopProgressUpdates(); // Set metadata updateMetadata(null); if(mPlaybackService != null) { mPlaybackService.destroy(); mPlaybackService = null; } syncMediaAndPlaybackStatus(); Log.d(TAG, "cancel notification"); podcastNotification.cancel(); abandonAudioFocus(); } @Subscribe public void onEvent(ExitPlayback event) { this.endCurrentMediaPlayback(); } @Subscribe public void onEvent(TogglePlayerStateEvent event) { Log.d(TAG, "onEvent() called with: event = [" + event + "]"); if(event.getState() == TogglePlayerStateEvent.State.Toggle) { if (isPlaying()) { Log.v(TAG, "calling pause()"); pause(); } else { Log.v(TAG, "calling play()"); play(); } } else if(event.getState() == TogglePlayerStateEvent.State.Play) { Log.v(TAG, "calling play()"); play(); } else if(event.getState() == TogglePlayerStateEvent.State.Pause) { Log.v(TAG, "calling pause()"); pause(); } } private boolean isPlaying() { return (mPlaybackService != null && mPlaybackService.getStatus() == PlaybackStateCompat.STATE_PLAYING); } @Subscribe public void onEvent(WindPodcast event) { if(mPlaybackService != null) { int seekTo = (int) (mPlaybackService.getCurrentPosition() + event.milliSeconds); if(seekTo < 0) { seekTo = 0; } mPlaybackService.seekTo(seekTo); } } @Subscribe public void onEvent(SeekPodcast event) { if(mPlaybackService != null) { int seekTo = (int) (event.milliSeconds); mPlaybackService.seekTo(seekTo); } } @Subscribe public void onEvent(RegisterVideoOutput videoOutput) { if(mPlaybackService != null && mPlaybackService instanceof MediaPlayerPlaybackService) { ((MediaPlayerPlaybackService) mPlaybackService).setVideoView(videoOutput.surfaceView); } } @Subscribe public void onEvent(NewPodcastPlaybackListener newListener) { syncMediaAndPlaybackStatus(); } @Subscribe public void onEvent(SpeedPodcast event) { this.currentPlaybackSpeed = event.playbackSpeed; if(mPlaybackService != null) { mPlaybackService.playbackSpeedChanged(currentPlaybackSpeed); } } public void play() { if(mPlaybackService != null) { // Start playback mPlaybackService.play(); startProgressUpdates(); requestAudioFocus(); } } public void pause() { if(mPlaybackService != null) { mPlaybackService.pause(); } stopProgressUpdates(); abandonAudioFocus(); } private void requestAudioFocus() { AudioManager audioManager = (AudioManager) getSystemService(Context.AUDIO_SERVICE); // Request audio focus for playback int result = audioManager.requestAudioFocus( audioFocusChangeListener, // Use the music stream. AudioManager.STREAM_MUSIC, // Request permanent focus. AudioManager.AUDIOFOCUS_GAIN); if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { Log.d(TAG, "AUDIOFOCUS_REQUEST_GRANTED"); } } private void abandonAudioFocus() { AudioManager audioManager = (AudioManager) getSystemService(Context.AUDIO_SERVICE); // Abandon audio focus when playback complete audioManager.abandonAudioFocus(audioFocusChangeListener); } private final AudioManager.OnAudioFocusChangeListener audioFocusChangeListener = focusChange -> { if (focusChange == AudioManager.AUDIOFOCUS_LOSS) { // Permanent loss of audio focus // Pause playback immediately mSession.getController().getTransportControls().pause(); } else if (focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT) { // Pause playback mSession.getController().getTransportControls().pause(); } else if (focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK) { // Lower the volume, keep playing } else if (focusChange == AudioManager.AUDIOFOCUS_GAIN) { // Your app has been granted audio focus again // Raise volume to normal, restart playback if necessary mSession.getController().getTransportControls().play(); } }; private void startProgressUpdates() { mScheduleFuture = mExecutorService.scheduleAtFixedRate( () -> mHandler.post(PodcastPlaybackService.this::syncMediaAndPlaybackStatus), PROGRESS_UPDATE_INITIAL_INTERVAL, PROGRESS_UPDATE_INTERNAL, TimeUnit.MILLISECONDS); } private void stopProgressUpdates() { if (mScheduleFuture != null) { mScheduleFuture.cancel(false); } syncMediaAndPlaybackStatus(); // Send one last update } public float getPlaybackSpeed() { return currentPlaybackSpeed; } public void syncMediaAndPlaybackStatus() { @PlaybackStateCompat.State int playbackState; int currentPosition = 0; int totalDuration = 0; if(mPlaybackService == null || mPlaybackService.getMediaItem().itemId == -1) { // When podcast is not initialized or playback is finished playbackState = PlaybackStateCompat.STATE_NONE; mSession.setPlaybackState(new PlaybackStateCompat.Builder() .setState(playbackState, currentPosition, 1.0f) .setActions(buildPlaybackActions(playbackState, false)) .build()); stopForeground(false); } else { currentPosition = mPlaybackService.getCurrentPosition(); totalDuration = mPlaybackService.getTotalDuration(); playbackState = mPlaybackService.getStatus(); if (playbackState== PlaybackStateCompat.STATE_PLAYING) { startForeground(PodcastNotification.NOTIFICATION_ID, podcastNotification.getNotification()); } else { stopForeground(false); } mSession.setPlaybackState(new PlaybackStateCompat.Builder() .setState(playbackState, currentPosition, 1.0f) .setActions(buildPlaybackActions(playbackState, true)) .build()); } mSession.setActive(playbackState == PlaybackStateCompat.STATE_PLAYING); podcastNotification.updateStateOfNotification(playbackState, currentPosition, totalDuration); } private long buildPlaybackActions(int playbackState, boolean mediaLoaded) { long actions = playbackState == PlaybackStateCompat.STATE_PLAYING ? PlaybackStateCompat.ACTION_PAUSE : PlaybackStateCompat.ACTION_PLAY; actions |= PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS | PlaybackStateCompat.ACTION_SKIP_TO_NEXT; //PlaybackStateCompat.ACTION_STOP; if(mediaLoaded) { actions |= PlaybackStateCompat.ACTION_SEEK_TO; } return actions; } PhoneStateListener phoneStateListener = new PhoneStateListener() { @Override public void onCallStateChanged(int state, String incomingNumber) { if (state == TelephonyManager.CALL_STATE_RINGING) { //Incoming call: Pause music pause(); } /* else if(state == TelephonyManager.CALL_STATE_IDLE) { //Not in call: Play music } else if(state == TelephonyManager.CALL_STATE_OFFHOOK) { //A call is dialing, active or on hold } */ super.onCallStateChanged(state, incomingNumber); } }; @RequiresApi(Build.VERSION_CODES.S) class CustomTelephonyCallback extends TelephonyCallback implements TelephonyCallback.CallStateListener { @Override public void onCallStateChanged(int state) { if(state == TelephonyManager.CALL_STATE_RINGING) { pause(); } } } private final class MediaSessionCallback extends MediaSessionCompat.Callback { @Override public void onPlayFromMediaId(String mediaId, Bundle extras) { Log.d(TAG, "onPlayFromMediaId() called with: mediaId = [" + mediaId + "], extras = [" + extras + "]"); if(mediaId.startsWith("PODCAST_")) { int podcastId = Integer.parseInt(mediaId.substring(8)); DatabaseConnectionOrm dbConn = new DatabaseConnectionOrm(PodcastPlaybackService.this); RssItem rssItem = dbConn.getRssItemById(podcastId); PodcastItem podcastItem = ParsePodcastItemFromRssItem(PodcastPlaybackService.this, rssItem); startPlayingPodcastItem(podcastItem); } super.onPlayFromMediaId(mediaId, extras); } @Override public void onPlay() { Log.d(TAG, "onPlay() called"); play(); } @Override public void onPause() { Log.d(TAG, "onPause() called"); pause(); } @Override public void onPlayFromSearch(String query, Bundle extras) { Log.d(TAG, "onPlayFromSearch() called with: query = [" + query + "], extras = [" + extras + "]"); // In case the user just says "Play music" if(TextUtils.isEmpty(query)) { List audioPodcasts = getAllPodcastItems(); // If there are any audio podcasts if(audioPodcasts.size() > 0) { PodcastItem podcastItem = audioPodcasts.get(0); startPlayingPodcastItem(podcastItem); } } else { // User is actually searching for something.. List audioPodcasts = getAllPodcastItems(); if(audioPodcasts.size() > 0) { boolean foundMatching = false; for(PodcastItem pi : audioPodcasts) { if(pi.title.contains(query)) { startPlayingPodcastItem(pi); foundMatching = true; break; } } // in case we didn't find a matching podcast.. palay a random one if(!foundMatching) { PodcastItem podcastItem = audioPodcasts.get(0); startPlayingPodcastItem(podcastItem); } } } super.onPlayFromSearch(query, extras); } @Override public void onCommand(String command, Bundle extras, ResultReceiver cb) { Log.d(TAG, "onCommand() called with: command = [" + command + "], extras = [" + extras + "], cb = [" + cb + "]"); if (command.equals(PLAYBACK_SPEED_FLOAT)) { Bundle b = new Bundle(); b.putFloat(PLAYBACK_SPEED_FLOAT, currentPlaybackSpeed); cb.send(0, b); } else if(command.equals(CURRENT_PODCAST_ITEM_MEDIA_ITEM)) { Bundle b = new Bundle(); if(mPlaybackService != null) { b.putSerializable(CURRENT_PODCAST_ITEM_MEDIA_ITEM, mPlaybackService.getMediaItem()); } else { b.putSerializable(CURRENT_PODCAST_ITEM_MEDIA_ITEM, null); } cb.send(0, b); } super.onCommand(command, extras, cb); } @Override public void onSeekTo(long pos) { Log.d(TAG, "onSeekTo() called with: pos = [" + pos + "]"); super.onSeekTo(pos); } @Override public void onSkipToNext() { Log.d(TAG, "onSkipToNext() called"); MediaItem currentlyPlayingPodcast = getCurrentlyPlayingPodcast(); List podcastItems = getAllPodcastItems(); for(int i = 0; i < podcastItems.size(); i++) { PodcastItem podcastItem = podcastItems.get(i); if(podcastItem.itemId == currentlyPlayingPodcast.itemId) { if(i+1 < podcastItems.size()) { startPlayingPodcastItem(podcastItems.get(i+1)); } break; } } super.onSkipToNext(); } @Override public void onSkipToPrevious() { Log.d(TAG, "onSkipToPrevious() called"); MediaItem currentlyPlayingPodcast = getCurrentlyPlayingPodcast(); List podcastItems = getAllPodcastItems(); for(int i = 0; i < podcastItems.size(); i++) { PodcastItem podcastItem = podcastItems.get(i); if(podcastItem.itemId == currentlyPlayingPodcast.itemId) { if(i > 0) { startPlayingPodcastItem(podcastItems.get(i-1)); } break; } } super.onSkipToPrevious(); } @Override public boolean onMediaButtonEvent(Intent mediaButtonEvent) { Log.d(TAG, "onMediaButtonEvent() called with: mediaButtonEvent = [" + mediaButtonEvent + "]"); if(mediaButtonEvent.hasExtra("android.intent.extra.KEY_EVENT")) { KeyEvent keyEvent = mediaButtonEvent.getParcelableExtra("android.intent.extra.KEY_EVENT"); Log.d(TAG, keyEvent.toString()); // Stop requested (e.g. notification was swiped away) if(keyEvent.getKeyCode() == KEYCODE_MEDIA_STOP) { pause(); endCurrentMediaPlayback(); stopSelf(); /* boolean isPlaying = mSession.getController().getPlaybackState().getState() == PlaybackStateCompat.STATE_PLAYING; if(isPlaying) { EventBus.getDefault().post(new TogglePlayerStateEvent()); } */ } } return super.onMediaButtonEvent(mediaButtonEvent); } } private void startPlayingPodcastItem(PodcastItem podcastItem) { Intent intent = new Intent(PodcastPlaybackService.this, PodcastPlaybackService.class); intent.putExtra(PodcastPlaybackService.MEDIA_ITEM, podcastItem); startService(intent); } private List getAllPodcastItems() { DatabaseConnectionOrm dbConn = new DatabaseConnectionOrm(PodcastPlaybackService.this); List audioPodcasts= new ArrayList<>(); for(PodcastFeedItem podcastFeed : dbConn.getListOfFeedsWithAudioPodcasts()) { long id = podcastFeed.mFeed.getId(); audioPodcasts.addAll(dbConn.getListOfAudioPodcastsForFeed(PodcastPlaybackService.this, id)); } return audioPodcasts; } private void initMediaSessions() { //String packageName = PodcastNotificationToggle.class.getPackage().getName(); //ComponentName receiver = new ComponentName(packageName, PodcastNotificationToggle.class.getName()); ComponentName mediaButtonReceiver = new ComponentName(this, MediaButtonReceiver.class); mSession = new MediaSessionCompat(this, "PlayerService", mediaButtonReceiver, null); mSession.setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS | MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS); mSession.setPlaybackState(new PlaybackStateCompat.Builder() .setState(PlaybackStateCompat.STATE_NONE, 0, 0) .setActions(buildPlaybackActions(PlaybackStateCompat.STATE_PAUSED, false)).build()); mSession.setCallback(new MediaSessionCallback()); //Intent mediaButtonIntent = new Intent(Intent.ACTION_MEDIA_BUTTON); //mediaButtonIntent.setClass(mContext, MediaButtonReceiver.class); //PendingIntent pendingIntent = PendingIntent.getBroadcast(mContext, 0, mediaButtonIntent, 0); //mSession.setMediaButtonReceiver(pendingIntent); updateMetadata(null); } private PlaybackService.VideoType getCurrentlyPlayedMediaType() { if(mPlaybackService != null) { return mPlaybackService.getVideoType(); } else { return PlaybackService.VideoType.None; } } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/services/SyncItemStateService.java ================================================ /* * Android ownCloud News * * @author David Luhmer * @copyright 2013 David Luhmer david-dev@live.de * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE * License as published by the Free Software Foundation; either * version 3 of the License, or any later version. * * This library 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 AFFERO GENERAL PUBLIC LICENSE for more details. * * You should have received a copy of the GNU Affero General Public * License along with this library. If not, see . * */ package de.luhmer.owncloudnewsreader.services; import android.app.ActivityManager; import android.app.ActivityManager.RunningServiceInfo; import android.content.Context; import android.content.Intent; import android.util.Log; import androidx.annotation.NonNull; import androidx.core.app.JobIntentService; import java.io.IOException; import javax.inject.Inject; import de.luhmer.owncloudnewsreader.NewsReaderApplication; import de.luhmer.owncloudnewsreader.database.DatabaseConnectionOrm; import de.luhmer.owncloudnewsreader.di.ApiProvider; import de.luhmer.owncloudnewsreader.reader.nextcloud.ItemStateSync; public class SyncItemStateService extends JobIntentService { /** * Unique job/channel ID for this service. */ private static final int JOB_ID = 1001; private static final String TAG = SyncItemStateService.class.getCanonicalName(); protected @Inject ApiProvider mApi; /** * Convenience method for enqueuing work in to this service. */ public static void enqueueWork(Context context, Intent work) { enqueueWork(context, SyncItemStateService.class, JOB_ID, work); } @Override public void onCreate() { ((NewsReaderApplication) getApplication()).getAppComponent().injectService(this); super.onCreate(); } @Override protected void onHandleWork(@NonNull Intent intent) { if(mApi.getNewsAPI() == null) { Log.w(TAG, "API is not initialized"); return; } final DatabaseConnectionOrm dbConn = new DatabaseConnectionOrm(this); try { ItemStateSync.PerformItemStateSync(mApi.getNewsAPI(), dbConn); Log.v(TAG, "SyncItemStateService finished."); } catch (IOException e) { Log.e(TAG, "SyncItemState failed:" + e.toString()); e.printStackTrace(); } } public static boolean isMyServiceRunning(Context context) { ActivityManager manager = (ActivityManager) context.getSystemService(ACTIVITY_SERVICE); for (RunningServiceInfo service : manager.getRunningServices(Integer.MAX_VALUE)) { if ("de.luhmer.owncloudnewsreader.services.SyncItemStateService".equals(service.service.getClassName())) { return true; } } return false; } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/services/events/StopWebArchiveDownloadEvent.kt ================================================ package de.luhmer.owncloudnewsreader.services.events class StopWebArchiveDownloadEvent ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/services/events/SyncFailedEvent.kt ================================================ package de.luhmer.owncloudnewsreader.services.events /** * Created by David on 26.08.2016. */ class SyncFailedEvent( throwable: Throwable, ) : Throwable(throwable) ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/services/events/SyncFinishedEvent.kt ================================================ package de.luhmer.owncloudnewsreader.services.events /** * Created by David on 26.08.2016. */ class SyncFinishedEvent ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/services/events/SyncStartedEvent.kt ================================================ package de.luhmer.owncloudnewsreader.services.events /** * Created by David on 26.08.2016. */ class SyncStartedEvent ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/services/podcast/MediaPlayerPlaybackService.java ================================================ package de.luhmer.owncloudnewsreader.services.podcast; import android.content.Context; import android.media.MediaPlayer; import android.os.Build; import android.support.v4.media.session.PlaybackStateCompat; import android.util.Log; import android.view.SurfaceHolder; import android.view.SurfaceView; import android.widget.Toast; import java.io.IOException; import de.luhmer.owncloudnewsreader.model.MediaItem; import de.luhmer.owncloudnewsreader.model.PodcastItem; /** * Created by david on 31.01.17. */ public class MediaPlayerPlaybackService extends PlaybackService { private static final String TAG = MediaPlayerPlaybackService.class.getCanonicalName(); private final MediaPlayer mMediaPlayer; //private View parentView; public MediaPlayerPlaybackService(final Context context, PodcastStatusListener podcastStatusListener, MediaItem mediaItem) { super(podcastStatusListener, mediaItem); mMediaPlayer = new MediaPlayer(); // disable video view on launch (e.g. for Android Auto) setVideoView(null); //mMediaPlayer.setOnVideoSizeChangedListener((mp, width, height) -> configureVideo(width, height)); mMediaPlayer.setOnErrorListener((mediaPlayer, i, i2) -> { setStatus(PlaybackStateCompat.STATE_ERROR); Toast.makeText(context, "Failed to open podcast", Toast.LENGTH_LONG).show(); return false; }); mMediaPlayer.setOnPreparedListener(mediaPlayer -> { podcastStatusListener.podcastStatusUpdated(); setStatus(PlaybackStateCompat.STATE_PAUSED); play(); }); mMediaPlayer.setOnCompletionListener(mediaPlayer -> { pause(); //Send the over signal podcastCompleted(); }); try { setStatus(PlaybackStateCompat.STATE_CONNECTING); mMediaPlayer.setDataSource(mediaItem.link); mMediaPlayer.prepareAsync(); } catch (IOException e) { e.printStackTrace(); setStatus(PlaybackStateCompat.STATE_ERROR); } } @Override public void destroy() { mMediaPlayer.stop(); mMediaPlayer.reset(); mMediaPlayer.release(); } @Override public void play() { try { int progress = mMediaPlayer.getCurrentPosition() / mMediaPlayer.getDuration(); if (progress >= 1) { mMediaPlayer.seekTo(0); } setStatus(PlaybackStateCompat.STATE_PLAYING); } catch (Exception ex) { Log.e(TAG, "Error while playing", ex); } mMediaPlayer.start(); //populateVideo(); } @Override public void pause() { if (mMediaPlayer.isPlaying()) { mMediaPlayer.pause(); } setStatus(PlaybackStateCompat.STATE_PAUSED); } @Override public void playbackSpeedChanged(float currentPlaybackSpeed) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { mMediaPlayer.setPlaybackParams(mMediaPlayer.getPlaybackParams().setSpeed(currentPlaybackSpeed)); } } @Override public void seekTo(int position) { double totalDuration = mMediaPlayer.getDuration(); Log.d(TAG, "seekTo position: " + position + " totalDuration: " + totalDuration); //int position = (int) ((totalDuration / 100d) * percent); mMediaPlayer.seekTo(position); } @Override public int getCurrentPosition() { if (mMediaPlayer != null && isMediaLoaded()) { return mMediaPlayer.getCurrentPosition(); } return 0; } @Override public int getTotalDuration() { if (mMediaPlayer != null && isMediaLoaded()) { return mMediaPlayer.getDuration(); } return 0; } @Override public VideoType getVideoType() { return ((PodcastItem) getMediaItem()).isVideoPodcast ? VideoType.Video : VideoType.None; } /* private void populateVideo() { double videoHeightRel = (double) mSurfaceWidth / (double) mMediaPlayer.getVideoWidth(); int videoHeight = (int) (mMediaPlayer.getVideoHeight() * videoHeightRel); if (mSurfaceWidth != 0 && videoHeight != 0 && mSurfaceHolder != null) { //mSurfaceHolder.setFixedSize(mSurfaceWidth, videoHeight); parentView.getLayoutParams().height = videoHeight; parentView.setLayoutParams(parentView.getLayoutParams()); } }*/ public long getVideoWidth() { return mMediaPlayer.getVideoWidth(); } public void setVideoView(SurfaceView surfaceView) { if (surfaceView == null) { mMediaPlayer.setDisplay(null); //Log.v(TAG, "Disable Screen output!"); mMediaPlayer.setScreenOnWhilePlaying(false); } else { if (surfaceView.getHolder() != mSurfaceHolder) { //this.parentView = parentResizableView; surfaceView.getHolder().addCallback(mSHCallback); //videoOutput.surfaceView.getHolder().setType(SurfaceHolder.SURFACE_TYPE_PUSH_BUFFERS); //holder.setType(SurfaceHolder.SURFACE_TYPE_GPU); } } } //private int mSurfaceWidth; //private int mSurfaceHeight; private SurfaceHolder mSurfaceHolder; private final SurfaceHolder.Callback mSHCallback = new SurfaceHolder.Callback() { public void surfaceChanged(SurfaceHolder holder, int format, int surfaceWidth, int surfaceHeight) { Log.v(TAG, "surfaceChanged() called with: holder = [" + holder + "], format = [" + format + "], surfaceWidth = [" + surfaceWidth + "], surfaceHeight = [" + surfaceHeight + "]"); //mSurfaceWidth = surfaceWidth; //mSurfaceHeight = surfaceHeight; //populateVideo(); } public void surfaceCreated(SurfaceHolder holder) { Log.v(TAG, "surfaceCreated() called with: holder = [" + holder + "]"); mSurfaceHolder = holder; mMediaPlayer.setDisplay(mSurfaceHolder); mMediaPlayer.setScreenOnWhilePlaying(true); } public void surfaceDestroyed(SurfaceHolder holder) { Log.d(TAG, "surfaceDestroyed"); } }; } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/services/podcast/PlaybackService.java ================================================ package de.luhmer.owncloudnewsreader.services.podcast; import android.support.v4.media.session.PlaybackStateCompat; import de.luhmer.owncloudnewsreader.model.MediaItem; /** * Created by david on 31.01.17. */ public abstract class PlaybackService { public enum VideoType { None, Video, VideoType, YouTube } private @PlaybackStateCompat.State int mStatus = PlaybackStateCompat.STATE_NONE; private final PodcastStatusListener podcastStatusListener; private final MediaItem mediaItem; public interface PodcastStatusListener { void podcastStatusUpdated(); void podcastCompleted(); } public PlaybackService(PodcastStatusListener podcastStatusListener, MediaItem mediaItem) { this.podcastStatusListener = podcastStatusListener; this.mediaItem = mediaItem; } public abstract void destroy(); public abstract void play(); public abstract void pause(); public abstract void playbackSpeedChanged(float currentPlaybackSpeed); public void seekTo(int position) { } public int getCurrentPosition() { return 0; } public int getTotalDuration() { return 0; } public VideoType getVideoType() { return VideoType.None; } public MediaItem getMediaItem() { return mediaItem; } public @PlaybackStateCompat.State int getStatus() { return mStatus; } protected void setStatus(@PlaybackStateCompat.State int status) { this.mStatus = status; podcastStatusListener.podcastStatusUpdated(); } protected void podcastCompleted() { podcastStatusListener.podcastCompleted(); } public boolean isMediaLoaded() { return getStatus() != PlaybackStateCompat.STATE_NONE && getStatus() != PlaybackStateCompat.STATE_CONNECTING && getStatus() != PlaybackStateCompat.STATE_ERROR; } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/services/podcast/TTSPlaybackService.java ================================================ package de.luhmer.owncloudnewsreader.services.podcast; import android.content.Context; import android.speech.tts.TextToSpeech; import android.speech.tts.UtteranceProgressListener; import android.support.v4.media.session.PlaybackStateCompat; import android.util.Log; import java.util.HashMap; import de.luhmer.owncloudnewsreader.model.MediaItem; import de.luhmer.owncloudnewsreader.model.TTSItem; /** * Created by david on 31.01.17. */ public class TTSPlaybackService extends PlaybackService implements TextToSpeech.OnInitListener { private TextToSpeech ttsController; public TTSPlaybackService(Context context, PodcastStatusListener podcastStatusListener, MediaItem mediaItem) { super(podcastStatusListener, mediaItem); try { ttsController = new TextToSpeech(context, this); setStatus(PlaybackStateCompat.STATE_CONNECTING); if(ttsController != null) { ttsController.setOnUtteranceProgressListener(new UtteranceProgressListener() { @Override public void onDone(String utteranceId) { podcastCompleted(); } @Override public void onStart(String utteranceId) {} @Override public void onError(String utteranceId) {} }); } else { onInit(TextToSpeech.SUCCESS); } } catch (Exception e) { e.printStackTrace(); } } @Override public void destroy() { pause(); ttsController.shutdown(); ttsController = null; } @Override public void play() { onInit(TextToSpeech.SUCCESS);//restart last tts } @Override public void pause() { if (ttsController.isSpeaking()) { ttsController.stop(); setStatus(PlaybackStateCompat.STATE_PAUSED); } } @Override public void playbackSpeedChanged(float currentPlaybackSpeed) { ttsController.setSpeechRate(currentPlaybackSpeed); } @Override public void onInit(int status) { if (status == TextToSpeech.SUCCESS) { /* int result = ttsController.setLanguage(Locale.US); if (result == TextToSpeech.LANG_MISSING_DATA || result == TextToSpeech.LANG_NOT_SUPPORTED) { Log.e("TTS", "This Language is not supported"); } else { ttsController.speak(text, TextToSpeech.QUEUE_FLUSH, null); }*/ HashMap ttsParams = new HashMap<>(); ttsParams.put(TextToSpeech.Engine.KEY_PARAM_UTTERANCE_ID,"dummyId"); ttsController.speak(((TTSItem)getMediaItem()).text, TextToSpeech.QUEUE_FLUSH, ttsParams); setStatus(PlaybackStateCompat.STATE_PLAYING); } else { Log.e("TTS", "Initialization Failed!"); ttsController = null; } } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/ssl/MTMDecision.java ================================================ package de.luhmer.owncloudnewsreader.ssl; /* MemorizingTrustManager - a TrustManager which asks the user about invalid * certificates and memorizes their decision. * * Copyright (c) 2010 Georg Lukas * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ class MTMDecision { public final static int DECISION_INVALID = 0; public final static int DECISION_ABORT = 1; public final static int DECISION_ONCE = 2; public final static int DECISION_ALWAYS = 3; int state = DECISION_INVALID; } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/ssl/MemorizingDialogFragment.java ================================================ package de.luhmer.owncloudnewsreader.ssl; /* MemorizingTrustManager - a TrustManager which asks the user about invalid * certificates and memorizes their decision. * * Copyright (c) 2010 Georg Lukas * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ import android.content.DialogInterface; import android.content.DialogInterface.OnCancelListener; import android.content.DialogInterface.OnClickListener; import android.content.Intent; import android.os.Bundle; import android.util.Log; import androidx.annotation.NonNull; import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.DialogFragment; import de.luhmer.owncloudnewsreader.R; public class MemorizingDialogFragment extends DialogFragment implements OnClickListener,OnCancelListener { final static String TAG = "MemorizingDialogFrg"; int decisionId; String app; String cert; public MemorizingDialogFragment() { } @Override public void onCreate(Bundle savedInstanceState) { Log.d(TAG, "onCreate"); Bundle args = requireArguments(); app = args.getString(MemorizingTrustManager.DECISION_INTENT_APP); decisionId = args.getInt(MemorizingTrustManager.DECISION_INTENT_ID, MTMDecision.DECISION_INVALID); cert = args.getString(MemorizingTrustManager.DECISION_INTENT_CERT); //Log.d(TAG, "onResume with " + i.getExtras() + " decId=" + decisionId); //Log.d(TAG, "data: " + i.getData()); super.onCreate(savedInstanceState); } @Override public void onResume() { super.onResume(); new AlertDialog.Builder(requireContext()).setTitle(R.string.mtm_accept_cert) .setMessage(cert) .setPositiveButton(R.string.mtm_decision_always, this) .setNegativeButton(R.string.mtm_decision_abort, this) .setOnCancelListener(this) .create().show(); } void sendDecision(int decision) { Log.d(TAG, "Sending decision to " + app + ": " + decision); Intent i = new Intent(MemorizingTrustManager.DECISION_INTENT + "/" + app); i.putExtra(MemorizingTrustManager.DECISION_INTENT_ID, decisionId); i.putExtra(MemorizingTrustManager.DECISION_INTENT_CHOICE, decision); requireActivity().sendBroadcast(i); //finish(); requireDialog().dismiss(); } // react on AlertDialog button press public void onClick(DialogInterface dialog, int btnId) { int decision; dialog.dismiss(); switch (btnId) { case DialogInterface.BUTTON_POSITIVE: decision = MTMDecision.DECISION_ALWAYS; break; case DialogInterface.BUTTON_NEUTRAL: decision = MTMDecision.DECISION_ONCE; break; default: decision = MTMDecision.DECISION_ABORT; } sendDecision(decision); } public void onCancel(@NonNull DialogInterface dialog) { sendDecision(MTMDecision.DECISION_ABORT); } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/ssl/MemorizingTrustManager.java ================================================ package de.luhmer.owncloudnewsreader.ssl; /* MemorizingTrustManager - a TrustManager which asks the user about invalid * certificates and memorizes their decision. * * Copyright (c) 2010 Georg Lukas * * MemorizingTrustManager.java contains the actual trust manager and interface * code to create a MemorizingActivity and obtain the results. * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ import android.app.Activity; import android.app.Application; import android.app.Notification; import android.app.NotificationManager; import android.app.PendingIntent; import android.app.Service; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.graphics.BitmapFactory; import android.net.Uri; import android.os.Bundle; import android.os.Handler; import android.util.Log; import android.util.SparseArray; import androidx.core.app.NotificationCompat; import androidx.fragment.app.DialogFragment; import androidx.fragment.app.FragmentActivity; import java.io.File; import java.io.IOException; import java.lang.ref.WeakReference; import java.security.KeyStore; import java.security.KeyStoreException; import java.security.MessageDigest; import java.security.cert.Certificate; import java.security.cert.CertificateException; import java.security.cert.CertificateExpiredException; import java.security.cert.X509Certificate; import java.util.Arrays; import java.util.Enumeration; import javax.net.ssl.TrustManager; import javax.net.ssl.TrustManagerFactory; import javax.net.ssl.X509TrustManager; import de.luhmer.owncloudnewsreader.R; /** * A X509 trust manager implementation which asks the user about invalid * certificates and memorizes their decision. *

* The certificate validity is checked using the system default X509 * TrustManager, creating a query Dialog if the check fails. *

* WARNING: This only works if a dedicated thread is used for * opening sockets! */ public class MemorizingTrustManager implements X509TrustManager { final static String TAG = "MemorizingTrustManager"; final static String DECISION_INTENT = "de.duenndns.ssl.DECISION"; final static String DECISION_INTENT_APP = DECISION_INTENT + ".app"; final static String DECISION_INTENT_ID = DECISION_INTENT + ".decisionId"; final static String DECISION_INTENT_CERT = DECISION_INTENT + ".cert"; final static String DECISION_INTENT_CHOICE = DECISION_INTENT + ".decisionChoice"; private final static int NOTIFICATION_ID = 100509; static String KEYSTORE_DIR = "KeyStore"; static String KEYSTORE_FILE = "KeyStore.bks"; WeakReference foregroundAct; NotificationManager notificationManager; private static int decisionId = 0; private static final SparseArray openDecisions = new SparseArray<>(); Handler masterHandler; private final File keyStoreFile; private final KeyStore appKeyStore; private final X509TrustManager defaultTrustManager; private X509TrustManager appTrustManager; private final Context mContext; /** Creates an instance of the MemorizingTrustManager class. * * You need to supply the application context. This has to be one of: * - Application * - Activity * - Service * * The context is used for file management, to display the dialog / * notification and for obtaining translated strings. * * @param m Context for the application. */ public MemorizingTrustManager(Context m) { mContext = m; masterHandler = new Handler(mContext.getMainLooper()); notificationManager = (NotificationManager)mContext.getSystemService(Context.NOTIFICATION_SERVICE); Application app; if (m instanceof Application) { app = (Application)m; } else if (m instanceof Service) { app = ((Service)m).getApplication(); } else if (m instanceof Activity) { app = ((Activity)m).getApplication(); } else throw new ClassCastException("MemorizingTrustManager context must be either Activity or Service!"); File dir = app.getDir(KEYSTORE_DIR, Context.MODE_PRIVATE); keyStoreFile = new File(dir + File.separator + KEYSTORE_FILE); appKeyStore = loadAppKeyStore(); defaultTrustManager = getTrustManager(null); appTrustManager = getTrustManager(appKeyStore); } /** * Returns a X509TrustManager list containing a new instance of * TrustManagerFactory. * * This function is meant for convenience only. You can use it * as follows to integrate TrustManagerFactory for HTTPS sockets: * *

	 *     SSLContext sc = SSLContext.getInstance("TLS");
	 *     sc.init(null, MemorizingTrustManager.getInstanceList(this),
	 *         new java.security.SecureRandom());
	 *     HttpsURLConnection.setDefaultSSLSocketFactory(sc.getSocketFactory());
	 * 
* @param c Activity or Service to show the Dialog / Notification */ public static X509TrustManager[] getInstanceList(Context c) { return new X509TrustManager[] { new MemorizingTrustManager(c) }; } /** * Binds an Activity to the MTM for displaying the query dialog. * * This is useful if your connection is run from a service that is * triggered by user interaction -- in such cases the activity is * visible and the user tends to ignore the service notification. * * You should never have a hidden activity bound to MTM! Use this * function in onResume() and @see unbindDisplayActivity in onPause(). * * @param act Activity to be bound */ public void bindDisplayActivity(Activity act) { foregroundAct = new WeakReference<>(act); } /** * Removes an Activity from the MTM display stack. * * Always call this function when the Activity added with * @see #bindDisplayActivity is hidden. * * @param act Activity to be unbound */ public void unbindDisplayActivity(Activity act) { // do not remove if it was overridden by a different activity if (foregroundAct != null && foregroundAct.get() == act) { foregroundAct = null; } } /** * Changes the path for the KeyStore file. * * The actual filename relative to the app's directory will be * app_dirname/filename. * * @param dirname directory to store the KeyStore. * @param filename file name for the KeyStore. */ public static void setKeyStoreFile(String dirname, String filename) { KEYSTORE_DIR = dirname; KEYSTORE_FILE = filename; } /** * Get a list of all certificate aliases stored in MTM. * * @return an {@link Enumeration} of all certificates */ public Enumeration getCertificates() { try { return appKeyStore.aliases(); } catch (KeyStoreException e) { // this should never happen, however... throw new RuntimeException(e); } } /** * Get a certificate for a given alias. * * @param alias the certificate's alias as returned by {@link #getCertificates()}. * * @return the certificate associated with the alias or null if none found. */ public Certificate getCertificate(String alias) { try { return appKeyStore.getCertificate(alias); } catch (KeyStoreException e) { // this should never happen, however... throw new RuntimeException(e); } } /** * Removes the given certificate from MTMs key store. * *

* WARNING: this does not immediately invalidate the certificate. It is * well possible that (a) data is transmitted over still existing connections or * (b) new connections are created using TLS renegotiation, without a new cert * check. *

* @param alias the certificate's alias as returned by {@link #getCertificates()}. * * @throws KeyStoreException if the certificate could not be deleted. */ public void deleteCertificate(String alias) throws KeyStoreException { appKeyStore.deleteEntry(alias); keyStoreUpdated(); } void keyStoreUpdated() { // reload appTrustManager appTrustManager = getTrustManager(appKeyStore); // store KeyStore to file java.io.FileOutputStream fos = null; try { fos = new java.io.FileOutputStream(keyStoreFile); appKeyStore.store(fos, "MTM".toCharArray()); } catch (Exception e) { e.printStackTrace(); //LOGGER.log(Level.SEVERE, "storeCert(" + keyStoreFile + ")", e); } finally { if (fos != null) { try { fos.close(); } catch (IOException e) { e.printStackTrace(); //LOGGER.log(Level.SEVERE, "storeCert(" + keyStoreFile + ")", e); } } } } X509TrustManager getTrustManager(KeyStore ks) { try { TrustManagerFactory tmf = TrustManagerFactory.getInstance("X509"); tmf.init(ks); for (TrustManager t : tmf.getTrustManagers()) { if (t instanceof X509TrustManager) { return (X509TrustManager)t; } } } catch (Exception e) { // Here, we are covering up errors. It might be more useful // however to throw them out of the constructor so the // embedding app knows something went wrong. Log.e(TAG, "getTrustManager(" + ks + ")", e); } return null; } KeyStore loadAppKeyStore() { KeyStore ks; try { ks = KeyStore.getInstance(KeyStore.getDefaultType()); } catch (KeyStoreException e) { Log.e(TAG, "getAppKeyStore()", e); return null; } try { ks.load(null, null); if(keyStoreFile.canRead()) { ks.load(new java.io.FileInputStream(keyStoreFile), "MTM".toCharArray()); } } catch (Exception e) { Log.e(TAG, "getAppKeyStore(" + keyStoreFile + ")", e); } return ks; } void storeCert(X509Certificate[] chain) { // add all certs from chain to appKeyStore try { for (X509Certificate c : chain) appKeyStore.setCertificateEntry(c.getSubjectDN().toString(), c); } catch (KeyStoreException e) { Log.e(TAG, "storeCert(" + Arrays.toString(chain) + ")", e); return; } // reload appTrustManager appTrustManager = getTrustManager(appKeyStore); // store KeyStore to file try { java.io.FileOutputStream fos = new java.io.FileOutputStream(keyStoreFile); appKeyStore.store(fos, "MTM".toCharArray()); fos.close(); } catch (Exception e) { Log.e(TAG, "storeCert(" + keyStoreFile + ")", e); } } // if the certificate is stored in the app key store, it is considered "known" private boolean isCertKnown(X509Certificate cert) { try { return appKeyStore.getCertificateAlias(cert) != null; } catch (KeyStoreException e) { return false; } } private boolean isExpiredException(Throwable e) { do { if (e instanceof CertificateExpiredException) return true; e = e.getCause(); } while (e != null); return false; } public void checkCertTrusted(X509Certificate[] chain, String authType, boolean isServer) throws CertificateException { //Log.d(TAG, "checkCertTrusted(" + Arrays.toString(chain) + ", " + authType + ", " + isServer + ")"); try { Log.d(TAG, "checkCertTrusted: trying defaultTrustManager"); if (isServer) defaultTrustManager.checkServerTrusted(chain, authType); else defaultTrustManager.checkClientTrusted(chain, authType); } catch (CertificateException ae) { try { Log.d(TAG, "checkCertTrusted: trying appTrustManager"); if (isServer) appTrustManager.checkServerTrusted(chain, authType); else appTrustManager.checkClientTrusted(chain, authType); } catch (CertificateException e) { // if the cert is stored in our appTrustManager, we ignore expiredness if (isExpiredException(e)) { Log.i(TAG, "checkCertTrusted: accepting expired certificate from keystore"); return; } if (isCertKnown(chain[0])) { Log.i(TAG, "checkCertTrusted: accepting cert already stored in keystore"); return; } e.printStackTrace(); interact(chain, e); } } } public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { checkCertTrusted(chain, authType, false); } public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException { checkCertTrusted(chain, authType, true); } public X509Certificate[] getAcceptedIssuers() { Log.d(TAG, "getAcceptedIssuers()"); return defaultTrustManager.getAcceptedIssuers(); } private int createDecisionId(MTMDecision d) { int myId; synchronized(openDecisions) { myId = decisionId; openDecisions.put(myId, d); decisionId += 1; } return myId; } private static String hexString(byte[] data) { StringBuilder si = new StringBuilder(); for (int i = 0; i < data.length; i++) { si.append(String.format("%02x", data[i])); if (i < data.length - 1) si.append(":"); } return si.toString(); } private static String certHash(final X509Certificate cert, String digest) { try { MessageDigest md = MessageDigest.getInstance(digest); md.update(cert.getEncoded()); return hexString(md.digest()); } catch (java.security.cert.CertificateEncodingException e) { return e.getMessage(); } catch (java.security.NoSuchAlgorithmException e) { return e.getMessage(); } } private String certChainMessage(final X509Certificate[] chain, CertificateException cause) { Throwable e = cause; Log.d(TAG, "certChainMessage for " + e); StringBuilder si = new StringBuilder(); if (e.getCause() != null) { e = e.getCause(); si.append(e.getLocalizedMessage()); //si.append("\n"); } for (X509Certificate c : chain) { si.append("\n\n"); si.append(c.getSubjectDN().toString()); si.append("\nMD5: "); si.append(certHash(c, "MD5")); si.append("\nSHA1: "); si.append(certHash(c, "SHA-1")); si.append("\nSigned by: "); si.append(c.getIssuerDN().toString()); } return si.toString(); } void startActivityNotification(Intent intent, String certName) { PendingIntent call = PendingIntent.getActivity(mContext, 0, intent, PendingIntent.FLAG_IMMUTABLE); Notification n = new NotificationCompat.Builder(mContext) .setLargeIcon(BitmapFactory.decodeResource(mContext.getResources(), android.R.drawable.ic_lock_lock)) .setSmallIcon(android.R.drawable.ic_lock_lock) .setContentTitle(mContext.getString(R.string.app_name)) .setContentText(mContext.getString(R.string.mtm_notification)) .setStyle(new NotificationCompat.BigTextStyle().bigText(certName)) .setContentIntent(call).build(); n.flags |= Notification.FLAG_AUTO_CANCEL; notificationManager.notify(NOTIFICATION_ID, n); } /** * Returns the top-most entry of the activity stack. * * @return the Context of the currently bound UI or the master context if none is bound */ Context getUI() { return (foregroundAct != null && foregroundAct.get() != null) ? foregroundAct.get() : mContext; } void interact(final X509Certificate[] chain, CertificateException cause) throws CertificateException { /* prepare the MTMDecision blocker object */ MTMDecision choice = new MTMDecision(); final int myId = createDecisionId(choice); final String certMessage = certChainMessage(chain, cause); BroadcastReceiver decisionReceiver = new BroadcastReceiver() { public void onReceive(Context ctx, Intent i) { interactResult(i); } }; mContext.registerReceiver(decisionReceiver, new IntentFilter(DECISION_INTENT + "/" + mContext.getPackageName())); masterHandler.post(() -> { Intent ni = new Intent(mContext, MemorizingDialogFragment.class); ni.setData(Uri.parse(MemorizingTrustManager.class.getName() + "/" + myId)); Bundle bundle = new Bundle(); bundle.putString(DECISION_INTENT_APP, mContext.getPackageName()); bundle.putInt(DECISION_INTENT_ID, myId); bundle.putString(DECISION_INTENT_CERT, certMessage); DialogFragment dialog = new MemorizingDialogFragment(); dialog.setArguments(bundle); try { dialog.show(((FragmentActivity) getUI()).getSupportFragmentManager(), "NoticeDialogFragment"); } catch (Exception ex) { Log.e(TAG, "startActivity: " + ex); startActivityNotification(ni, certMessage); } }); Log.d(TAG, "openDecisions: " + openDecisions); Log.d(TAG, "waiting on " + myId); try { synchronized(choice) { choice.wait(); } } catch (InterruptedException e) { e.printStackTrace(); } mContext.unregisterReceiver(decisionReceiver); Log.d(TAG, "finished wait on " + myId + ": " + choice.state); switch (choice.state) { case MTMDecision.DECISION_ALWAYS: storeCert(chain); case MTMDecision.DECISION_ONCE: break; default: throw (cause); } } public static void interactResult(Intent i) { int decisionId = i.getIntExtra(DECISION_INTENT_ID, MTMDecision.DECISION_INVALID); int choice = i.getIntExtra(DECISION_INTENT_CHOICE, MTMDecision.DECISION_INVALID); Log.d(TAG, "interactResult: " + decisionId + " chose " + choice); Log.d(TAG, "openDecisions: " + openDecisions); MTMDecision d; synchronized(openDecisions) { d = openDecisions.get(decisionId); openDecisions.remove(decisionId); } if (d == null) { Log.e(TAG, "interactResult: aborting due to stale decision reference!"); return; } synchronized(d) { d.state = choice; d.notify(); } } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/ssl/OkHttpSSLClient.java ================================================ package de.luhmer.owncloudnewsreader.ssl; import android.annotation.SuppressLint; import android.content.SharedPreferences; import android.os.Build; import androidx.annotation.NonNull; import com.google.gson.JsonParseException; import java.io.IOException; import java.security.GeneralSecurityException; import java.security.KeyManagementException; import java.security.KeyStore; import java.security.NoSuchAlgorithmException; import java.util.Arrays; import java.util.concurrent.TimeUnit; import javax.net.ssl.HostnameVerifier; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLSession; import javax.net.ssl.TrustManager; import javax.net.ssl.TrustManagerFactory; import javax.net.ssl.X509TrustManager; import de.luhmer.owncloudnewsreader.SettingsActivity; import okhttp3.ConnectionSpec; import okhttp3.Credentials; import okhttp3.HttpUrl; import okhttp3.Interceptor; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.Response; import okhttp3.logging.HttpLoggingInterceptor; /** * Created by david on 26.05.17. */ public class OkHttpSSLClient { public static OkHttpClient GetSslClient(HttpUrl baseUrl, String username, String password, SharedPreferences sp, MemorizingTrustManager mtm) { // set location of the keystore MemorizingTrustManager.setKeyStoreFile("private", "sslkeys.bks"); HttpLoggingInterceptor interceptor = new HttpLoggingInterceptor(); //interceptor.setLevel(HttpLoggingInterceptor.Level.BODY); OkHttpClient.Builder clientBuilder = new OkHttpClient.Builder() .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.MINUTES) .addInterceptor(new AuthorizationInterceptor(baseUrl, Credentials.basic(username, password))) .addInterceptor(interceptor); // register MemorizingTrustManager for HTTPS try { SSLContext sc = SSLContext.getInstance("TLS"); //sc.init(null, MemorizingTrustManager.getInstanceList(context), new java.security.SecureRandom()); sc.init(null, new X509TrustManager[] { mtm }, new java.security.SecureRandom()); // enables TLSv1.1/1.2 for Jelly Bean Devices TLSSocketFactory tlsSocketFactory = new TLSSocketFactory(sc); clientBuilder.sslSocketFactory(tlsSocketFactory, systemDefaultTrustManager()); // Workaround for Android 7.0 TLS bug if (Build.VERSION.SDK_INT == Build.VERSION_CODES.N) { String[] suites = tlsSocketFactory.getDefaultCipherSuites(); ConnectionSpec tlsSpec = new ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS).cipherSuites(suites).build(); clientBuilder.connectionSpecs(Arrays.asList(tlsSpec, ConnectionSpec.CLEARTEXT)); } } catch (KeyManagementException | NoSuchAlgorithmException e) { e.printStackTrace(); } clientBuilder.connectTimeout(10, TimeUnit.SECONDS); clientBuilder.readTimeout(120, TimeUnit.SECONDS); // disable hostname verification, when preference is set // (this still shows a certification dialog, which requires user interaction!) if(sp.getBoolean(SettingsActivity.CB_DISABLE_HOSTNAME_VERIFICATION_STRING, false)) clientBuilder.hostnameVerifier(new HostnameVerifier() { @SuppressLint("BadHostnameVerifier") @Override public boolean verify(String hostname, SSLSession session) { return true; } }); return clientBuilder.build(); } public static T HandleExceptions(T ex) { if(ex.getMessage() != null && ex.getMessage().startsWith("Not a JSON Object: \" = socketFactory.defaultCipherSuites override fun getSupportedCipherSuites(): Array = socketFactory.supportedCipherSuites // NoTLS override fun createSocket( s: String, i: Int, ): Socket = super.createSocket() override fun createSocket( s: String, i: Int, inetAddress: InetAddress, i2: Int, ): Socket = super.createSocket() override fun createSocket( inetAddress: InetAddress, i: Int, ): Socket = super.createSocket() override fun createSocket( inetAddress: InetAddress, i: Int, inetAddress2: InetAddress, i2: Int, ): Socket = super.createSocket() } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/view/AnimatingProgressBar.java ================================================ package de.luhmer.owncloudnewsreader.view; import android.animation.ValueAnimator; import android.content.Context; import android.util.AttributeSet; import android.view.animation.DecelerateInterpolator; import android.view.animation.Interpolator; import android.widget.ProgressBar; /** * Like a standard android progress bar but with an animated progress * on Honeycomb and above. Use it like a normal progress bar. */ public class AnimatingProgressBar extends ProgressBar { /** * easing of the bar animation */ private static final Interpolator DEFAULT_INTERPOLATOR = new DecelerateInterpolator(); /** * animation dureation in milliseconds */ private static final int ANIMATION_DURATION = 350; /** * Factor by which the progress bar resolution will be increased. E.g. the max * value is set to 5 and the resolution to 100: the bar can animate internally * between the values 0 and 500. */ private static final int RESOLUTION = 100; private ValueAnimator animator; private ValueAnimator animatorSecondary; private boolean animate = true; public AnimatingProgressBar(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); } public AnimatingProgressBar(Context context, AttributeSet attrs) { super(context, attrs); } public AnimatingProgressBar(Context context) { super(context); } public boolean isAnimate() { return animate; } public void setAnimate(boolean animate) { this.animate = animate; } @Override public synchronized void setMax(int max) { super.setMax(max * RESOLUTION); } @Override public synchronized int getMax() { return super.getMax() / RESOLUTION; } @Override public synchronized int getProgress() { return super.getProgress() / RESOLUTION; } @Override public synchronized int getSecondaryProgress() { return super.getSecondaryProgress() / RESOLUTION; } @Override public synchronized void setProgress(int progress) { if (!animate) { super.setProgress(progress); return; } if (animator == null) { animator = ValueAnimator.ofInt(getProgress() * RESOLUTION, progress * RESOLUTION); animator.setInterpolator(DEFAULT_INTERPOLATOR); animator.setDuration(ANIMATION_DURATION); animator.addUpdateListener(animation -> AnimatingProgressBar.super.setProgress((Integer) animation.getAnimatedValue())); } animator.cancel(); animator.setIntValues(getProgress() * RESOLUTION, progress * RESOLUTION); animator.start(); } @Override public synchronized void setSecondaryProgress(int secondaryProgress) { if (!animate) { super.setSecondaryProgress(secondaryProgress); return; } if (animatorSecondary == null) { animatorSecondary = ValueAnimator.ofInt(getSecondaryProgress() * RESOLUTION, secondaryProgress * RESOLUTION); animatorSecondary.setInterpolator(DEFAULT_INTERPOLATOR); animatorSecondary.setDuration(ANIMATION_DURATION); animatorSecondary.addUpdateListener(animation -> AnimatingProgressBar.super.setSecondaryProgress((Integer) animation.getAnimatedValue())); } animatorSecondary.cancel(); animatorSecondary.setIntValues(getSecondaryProgress() * RESOLUTION, secondaryProgress * RESOLUTION); animatorSecondary.start(); } @Override protected void onDetachedFromWindow() { super.onDetachedFromWindow(); if (animator != null) { animator.cancel(); } if (animatorSecondary != null) { animatorSecondary.cancel(); } } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/view/ChangeLogFileListView.kt ================================================ package de.luhmer.owncloudnewsreader.view import android.content.Context import android.util.AttributeSet import it.gmariotti.changelibs.library.view.ChangeLogListView /** * Thin wrapper around changeloglib to load local xml files by path * after the view has already been instantiated. */ class ChangeLogFileListView @JvmOverloads constructor( context: Context?, attrs: AttributeSet? = null, defStyle: Int = 0, ) : ChangeLogListView(context, attrs, defStyle) { /** * @param path local xml path staring with "file://" */ fun loadFile(path: String) { mChangeLogFileResourceUrl = path super.initAdapter() } override fun initAdapter() { // do nothing yet - will be called in loadFile() } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/view/PodcastNotification.java ================================================ package de.luhmer.owncloudnewsreader.view; import android.app.Notification; import android.app.NotificationManager; import android.content.Context; import android.support.v4.media.session.MediaSessionCompat; import android.support.v4.media.session.PlaybackStateCompat; import android.util.Log; import androidx.core.app.NotificationCompat; import org.greenrobot.eventbus.Subscribe; import java.util.Locale; import de.luhmer.owncloudnewsreader.notification.NextcloudNotificationManager; public class PodcastNotification { private static final String TAG = PodcastNotification.class.getCanonicalName(); public static final String ACTION_PLAY = "action_play"; public static final String ACTION_PAUSE = "action_pause"; //public static final String ACTION_NEXT = "action_next"; //public static final String ACTION_PREVIOUS = "action_previous"; //public static final String ACTION_STOP = "action_stop"; private final Context mContext; private final NotificationManager notificationManager; private NotificationCompat.Builder notificationBuilder; private final String CHANNEL_ID = "Podcast Notification"; private final MediaSessionCompat mSession; private @PlaybackStateCompat.State int lastStatus = PlaybackStateCompat.STATE_NONE; public final static int NOTIFICATION_ID = 1111; public PodcastNotification(Context context, MediaSessionCompat session) { this.mContext = context; this.mSession = session; this.notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); this.notificationBuilder = NextcloudNotificationManager.buildPodcastNotification(mContext, CHANNEL_ID, mSession); //EventBus.getDefault().register(this); } @Subscribe public void updateStateOfNotification(@PlaybackStateCompat.State int status, long currentPosition, long totalDuration) { if(mSession == null) { Log.v(TAG, "Session null.. ignore UpdatePodcastStatusEvent"); return; } if (status != lastStatus) { lastStatus = status; /* if(podcast.isPlaying()) { //Prevent the Podcast Player from getting killed because of low memory //For more info see: http://developer.android.com/reference/android/app/Service.html#startForeground(int, android.app.Notification) ((PodcastPlaybackService)mContext).startForeground(NOTIFICATION_ID, notificationBuilder.build()); notificationBuilder.setOngoing(true); // Non cancelable (sort above the others) } else { ((PodcastPlaybackService)mContext).stopForeground(false); notificationBuilder.setOngoing(false); // cancelable } */ //Lock screen notification /* mSession.setMetadata(new MediaMetadataCompat.Builder() .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, podcast.getMax()) .build()); */ notificationBuilder = NextcloudNotificationManager.buildPodcastNotification(mContext, CHANNEL_ID, mSession); //int drawableId = podcast.isPlaying() ? android.R.drawable.ic_media_pause : android.R.drawable.ic_media_play; //String actionText = podcast.isPlaying() ? "Pause" : "Play"; //notificationBuilder.addAction(new NotificationCompat.Action(drawableId, actionText, intent)); notificationManager.notify(NOTIFICATION_ID, notificationBuilder.build()); } int hours = (int) (currentPosition / (1000*60*60)); int minutes = (int) ((currentPosition % (1000*60*60)) / (1000*60)); int seconds = (int) ((currentPosition % (1000*60*60)) % (1000*60) / 1000); minutes += hours * 60; String fromText = (String.format(Locale.getDefault(), "%02d:%02d", minutes, seconds)); hours = (int) (totalDuration / (1000*60*60)); minutes = (int) ((totalDuration % (1000*60*60)) / (1000*60)); seconds = (int) ((totalDuration % (1000*60*60)) % (1000*60) / 1000); minutes += hours * 60; String toText = (String.format(Locale.getDefault(),"%02d:%02d", minutes, seconds)); double progressDouble = ((double)currentPosition / (double)totalDuration) * 100d; int progress = ((int) progressDouble); notificationBuilder .setContentText(fromText + " - " + toText) .setProgress(100, progress, status == PlaybackStateCompat.STATE_CONNECTING); // TODO IMPLEMENT THIS!!!! notificationManager.notify(NOTIFICATION_ID, notificationBuilder.build()); } public void cancel() { if(notificationManager != null) { notificationManager.cancel(NOTIFICATION_ID); } /* if(mSession != null) { mSession.setActive(false); } */ } public void createPodcastNotification() { /* MediaItem podcastItem = ((PodcastPlaybackService)mContext).getCurrentlyPlayingPodcast(); */ /* String favIconUrl = podcastItem.favIcon; DisplayImageOptions displayImageOptions = new DisplayImageOptions.Builder(). showImageOnLoading(R.drawable.default_feed_icon_light). showImageForEmptyUri(R.drawable.default_feed_icon_light). showImageOnFail(R.drawable.default_feed_icon_light). build(); */ //Bitmap bmpAlbumArt = ImageLoader.getInstance().loadImageSync(favIconUrl, displayImageOptions); /* mSession.setMetadata(new MediaMetadataCompat.Builder() .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, podcastItem.author) .putString(MediaMetadataCompat.METADATA_KEY_TITLE, podcastItem.title) .build()); */ /* mSession.setMetadata(new MediaMetadataCompat.Builder() .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, "NA") .putString(MediaMetadataCompat.METADATA_KEY_ALBUM, "") .putString(MediaMetadataCompat.METADATA_KEY_TITLE, "NA") //.putString(MediaMetadataCompat.METADATA_KEY_TITLE, podcastItem.title) .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, 100) //.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bmpAlbumArt) //.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, BitmapFactory.decodeResource(mContext.getResources(), R.drawable.ic_launcher)) .build()); */ this.notificationBuilder = NextcloudNotificationManager.buildPodcastNotification(mContext, CHANNEL_ID, mSession); notificationManager.notify(NOTIFICATION_ID, notificationBuilder.build()); } public Notification getNotification() { return notificationBuilder.build(); } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/view/PodcastSlidingUpPanelLayout.java ================================================ package de.luhmer.owncloudnewsreader.view; import android.content.Context; import android.util.AttributeSet; import android.view.View; import android.view.animation.Animation; import android.view.animation.Transformation; import com.sothree.slidinguppanel.SlidingUpPanelLayout; public class PodcastSlidingUpPanelLayout extends SlidingUpPanelLayout { public PodcastSlidingUpPanelLayout(Context context) { super(context); } public PodcastSlidingUpPanelLayout(Context context, AttributeSet attrs) { super(context, attrs); } public PodcastSlidingUpPanelLayout(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); } private View mDragView; private View mSlideableView; public void setSlideableView(View view) { this.mSlideableView = view; } @Override public void setDragView(View dragView) { this.mDragView = dragView; super.setDragView(dragView); } private boolean isDragViewHit(int x, int y) { //original implementation - only allow dragging on mDragView View v = getPanelState().equals(SlidingUpPanelLayout.PanelState.EXPANDED) ? mDragView : mSlideableView; if (v == null) return false; int[] viewLocation = new int[2]; v.getLocationOnScreen(viewLocation); int[] parentLocation = new int[2]; this.getLocationOnScreen(parentLocation); int screenX = parentLocation[0] + x; int screenY = parentLocation[1] + y; return screenX >= viewLocation[0] && screenX < viewLocation[0] + v.getWidth() && screenY >= viewLocation[1] && screenY < viewLocation[1] + v.getHeight(); } public static void expand(final View v) { v.measure(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); final int targtetHeight = v.getMeasuredHeight(); v.getLayoutParams().height = 0; v.setVisibility(View.VISIBLE); Animation a = new Animation() { @Override protected void applyTransformation(float interpolatedTime, Transformation t) { v.getLayoutParams().height = interpolatedTime == 1 ? LayoutParams.WRAP_CONTENT : (int)(targtetHeight * interpolatedTime); v.requestLayout(); } @Override public boolean willChangeBounds() { return true; } }; // 1dp/ms a.setDuration((int)(targtetHeight / v.getContext().getResources().getDisplayMetrics().density)); v.startAnimation(a); } public static void collapse(final View v) { final int initialHeight = v.getMeasuredHeight(); Animation a = new Animation() { @Override protected void applyTransformation(float interpolatedTime, Transformation t) { if(interpolatedTime == 1){ v.setVisibility(View.GONE); }else{ v.getLayoutParams().height = initialHeight - (int)(initialHeight * interpolatedTime); v.requestLayout(); } } @Override public boolean willChangeBounds() { return true; } }; // 1dp/ms a.setDuration((int)(initialHeight / v.getContext().getResources().getDisplayMetrics().density)); v.startAnimation(a); } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/widget/WidgetNewsViewsFactory.java ================================================ /* * Android ownCloud News * * @author David Luhmer * @copyright 2013 David Luhmer david-dev@live.de * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE * License as published by the Free Software Foundation; either * version 3 of the License, or any later version. * * This library 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 AFFERO GENERAL PUBLIC LICENSE for more details. * * You should have received a copy of the GNU Affero General Public * License along with this library. If not, see . * */ package de.luhmer.owncloudnewsreader.widget; import android.annotation.SuppressLint; import android.appwidget.AppWidgetManager; import android.content.Context; import android.content.Intent; import android.graphics.Typeface; import android.os.Bundle; import android.text.Html; import android.text.SpannableStringBuilder; import android.text.style.StyleSpan; import android.util.Log; import android.widget.RemoteViews; import android.widget.RemoteViewsService; import java.text.SimpleDateFormat; import java.util.Date; import de.greenrobot.dao.query.LazyList; import de.luhmer.owncloudnewsreader.Constants; import de.luhmer.owncloudnewsreader.R; import de.luhmer.owncloudnewsreader.database.DatabaseConnectionOrm; import de.luhmer.owncloudnewsreader.database.model.RssItem; import de.luhmer.owncloudnewsreader.helper.ThemeChooser; public class WidgetNewsViewsFactory implements RemoteViewsService.RemoteViewsFactory { private static final String TAG = WidgetNewsViewsFactory.class.getCanonicalName(); private DatabaseConnectionOrm dbConn; private LazyList rssItems; private final Context context; private final int appWidgetId; public WidgetNewsViewsFactory(Context context, Intent intent) { this.context = context; appWidgetId = intent.getExtras().getInt(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID); if (Constants.debugModeWidget) { Log.d(TAG, "CONSTRUCTOR CALLED - " + appWidgetId); } } @Override public void onCreate() { if (Constants.debugModeWidget) { Log.d(TAG, "onCreate"); } dbConn = new DatabaseConnectionOrm(context); } @Override public void onDestroy() { if (Constants.debugModeWidget) { Log.d(TAG, "onDestroy"); } if (rssItems != null) { rssItems.close(); } } @Override public int getCount() { if (Constants.debugModeWidget) { Log.v(TAG, "getCount - rssItems is " + ((rssItems != null) ? "NOT " : "") + "null"); } if (rssItems == null) { return 0; } else { return rssItems.size(); } } // Given the position (index) of a WidgetItem in the array, use the item's text value in // combination with the app widget item XML file to construct a RemoteViews object. @SuppressLint("SimpleDateFormat") public RemoteViews getViewAt(int position) { // if(Constants.debugModeWidget) { // Log.d(TAG, "getViewAt: " + position); // } RemoteViews rv = new RemoteViews(context.getPackageName(), R.layout.widget_item); try { RssItem rssItem = rssItems.get(position); String header = rssItem.getFeed().getFeedTitle(); // String colorString = rssItem.getFeed().getAvgColour(); String authorOfArticle = rssItem.getAuthor(); header += authorOfArticle == null || authorOfArticle.isEmpty() ? "" : " - " + authorOfArticle.trim(); String title = Html.fromHtml(rssItem.getTitle()).toString(); long id = rssItem.getId(); Date date = rssItem.getPubDate(); String dateString = ""; if (date != null) { SimpleDateFormat formater = new SimpleDateFormat(); dateString = formater.format(date); } rv.setTextViewText(R.id.feed_datetime, dateString); rv.setTextViewText(R.id.feed_author_source, header); SpannableStringBuilder titleSpan = new SpannableStringBuilder(title); if (!rssItem.getRead_temp()) { titleSpan.setSpan(new StyleSpan(Typeface.BOLD), 0, titleSpan.length(), 0); } rv.setTextViewText(R.id.feed_title, titleSpan); int resId; if (ThemeChooser.getSelectedTheme() == ThemeChooser.THEME.LIGHT) { resId = rssItem.getRead_temp() ? R.drawable.ic_checkbox_black : R.drawable.ic_checkbox_outline_black; } else { resId = rssItem.getRead_temp() ? R.drawable.ic_checkbox_white : R.drawable.ic_checkbox_outline_white; } int contentDescriptionId = rssItem.getRead_temp() ? R.string.content_desc_mark_as_unread : R.string.content_desc_mark_as_read; rv.setInt(R.id.cb_lv_item_read, "setBackgroundResource", resId); rv.setContentDescription(R.id.cb_lv_item_read, context.getString(contentDescriptionId)); // if(colorString != null) { // rv.setInt(R.id.color_line_feed, "setBackgroundColor", Integer.parseInt(colorString)); // } Bundle mBundle = new Bundle(); mBundle.putLong(WidgetProvider.RSS_ITEM_ID, id); // Get a fresh new intent Intent rowIntent = new Intent(); rowIntent.putExtras(mBundle); // Set it on the list remote view rv.setOnClickFillInIntent(R.id.widget_row_layout, rowIntent); // Get a fresh new intent Intent ei = new Intent(); ei.putExtras(mBundle); // Set it on the list remote view rv.setOnClickFillInIntent(R.id.cb_lv_item_read_wrapper, ei); // Get a fresh new intent Intent iCheck = new Intent(); iCheck.putExtra(WidgetProvider.ACTION_CHECKED_CLICK, true); iCheck.putExtras(mBundle); rv.setOnClickFillInIntent(R.id.cb_lv_item_read, iCheck); } catch(Exception ex) { Log.e(TAG, "Error while getting view for widget at position: " + position, ex); } // Return the RemoteViews object. return rv; } @Override public RemoteViews getLoadingView() { if (Constants.debugModeWidget) { Log.v(TAG, "getLoadingView"); } return(null); } @Override public int getViewTypeCount() { return(1); } @Override public long getItemId(int position) { // if(Constants.debugModeWidget) { // Log.v(TAG, "getItemId: " + position); // } return(position); } @Override public boolean hasStableIds() { if (Constants.debugModeWidget) { Log.v(TAG, "hasStableIds: " + appWidgetId); } return(true); } @Override public void onDataSetChanged() { if (Constants.debugModeWidget) { Log.v(TAG, "DataSetChanged - WidgetID: " + appWidgetId); } if (rssItems != null && !rssItems.isClosed()) { rssItems.close(); } rssItems = dbConn.getAllUnreadRssItemsForWidget(); Log.v(TAG, "DataSetChanged finished!"); } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/widget/WidgetProvider.java ================================================ /* * Android ownCloud News * * @author David Luhmer * @copyright 2013 David Luhmer david-dev@live.de * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE * License as published by the Free Software Foundation; either * version 3 of the License, or any later version. * * This library 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 AFFERO GENERAL PUBLIC LICENSE for more details. * * You should have received a copy of the GNU Affero General Public * License along with this library. If not, see . * */ package de.luhmer.owncloudnewsreader.widget; import android.app.PendingIntent; import android.appwidget.AppWidgetManager; import android.appwidget.AppWidgetProvider; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.net.Uri; import android.os.Bundle; import android.util.Log; import android.widget.RemoteViews; import androidx.core.app.PendingIntentCompat; import java.util.Arrays; import javax.inject.Inject; import de.luhmer.owncloudnewsreader.Constants; import de.luhmer.owncloudnewsreader.NewsDetailActivity; import de.luhmer.owncloudnewsreader.NewsReaderApplication; import de.luhmer.owncloudnewsreader.R; import de.luhmer.owncloudnewsreader.database.DatabaseConnectionOrm; import de.luhmer.owncloudnewsreader.database.model.RssItem; public class WidgetProvider extends AppWidgetProvider { //private static final String ACTION_CLICK = "ACTION_CLICK"; public static final String ACTION_WIDGET_CONFIGURE = "ConfigureWidget"; public static final String ACTION_WIDGET_RECEIVER = "ActionReceiverWidget"; public static final String ACTION_LIST_CLICK = "ACTION_LIST_CLICK"; public static final String ACTION_CHECKED_CLICK = "ACTION_CHECKED_CLICK"; public static final String RSS_ITEM_ID = "RSS_ITEM_ID"; public static final String EXTRA_ITEM = null; private static final String TAG = "WidgetProvider"; protected @Inject SharedPreferences mPrefs; public static void UpdateWidget(Context context) { int[] ids = AppWidgetManager.getInstance(context).getAppWidgetIds(new ComponentName(context, WidgetProvider.class)); for(int appWidgetId : ids) { AppWidgetManager.getInstance(context).notifyAppWidgetViewDataChanged(appWidgetId, R.id.list_view); } /* Intent intent = new Intent(context, WidgetProvider.class); intent.setAction(AppWidgetManager.ACTION_APPWIDGET_UPDATE); // Use an array and EXTRA_APPWIDGET_IDS instead of AppWidgetManager.EXTRA_APPWIDGET_ID, // since it seems the onUpdate() is only fired on that: int ids[] = AppWidgetManager.getInstance(context).getAppWidgetIds(new ComponentName(context, WidgetProvider.class)); intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS,ids); intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID,ids); context.sendBroadcast(intent); */ } @Override public void onReceive(Context context, Intent intent) { inject(context); final int[] appWidgetId; if(intent.hasExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS)) appWidgetId = intent.getIntArrayExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS); else if(intent.hasExtra(AppWidgetManager.EXTRA_APPWIDGET_ID)) appWidgetId = new int[] { intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID) }; else appWidgetId = new int[] { AppWidgetManager.INVALID_APPWIDGET_ID }; String action = intent.getAction(); Log.v(TAG, "onRecieve - WidgetID: " + Arrays.toString(appWidgetId) + " - " + action); for (int anAppWidgetId : appWidgetId) { if (AppWidgetManager.ACTION_APPWIDGET_DELETED.equals(action)) { if (anAppWidgetId != AppWidgetManager.INVALID_APPWIDGET_ID) { this.onDeleted(context, new int[]{anAppWidgetId}); } } /*else if (intent.getAction().equals(ACTION_WIDGET_RECEIVER)) { Intent intentRefresh = new Intent(context, WidgetProvider.class); intentRefresh.setAction("android.appwidget.action.APPWIDGET_UPDATE"); // Use an array and EXTRA_APPWIDGET_IDS instead of AppWidgetManager.EXTRA_APPWIDGET_ID, // since it seems the onUpdate() is only fired on that: intentRefresh.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, new int[] { appWidgetId[i] }); context.sendBroadcast(intentRefresh); } */ else if (action.equals(ACTION_LIST_CLICK)) { try { Bundle bundle = intent.getExtras(); if (bundle != null) { for (String key : bundle.keySet()) { Log.e(TAG, key + ": " + (bundle.get(key) != null ? bundle.get(key) : "NULL")); } } long rssItemId = intent.getExtras().getLong(RSS_ITEM_ID, -1); if (intent.hasExtra(ACTION_CHECKED_CLICK)) { DatabaseConnectionOrm dbConn = new DatabaseConnectionOrm(context); RssItem rssItem = dbConn.getRssItemById(rssItemId); rssItem.setRead_temp(!rssItem.getRead_temp()); //rssItem.setRead_temp(true); AppWidgetManager.getInstance(context) .notifyAppWidgetViewDataChanged(anAppWidgetId, R.id.list_view); Log.v(TAG, "I'm here!!! Widget update works!"); } else { //Intent intentToDoListAct = new Intent(context, TodoListActivity.class); Intent intentToDoListAct = new Intent(context, NewsDetailActivity.class); intentToDoListAct.putExtra(RSS_ITEM_ID, rssItemId); intentToDoListAct.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); context.startActivity(intentToDoListAct); } Log.v(TAG, "ListItem Clicked Starting Activity for Item: " + rssItemId); } catch (Exception ex) { ex.printStackTrace(); } } /*else if(action.equals("android.appwidget.action.APPWIDGET_UPDATE") || action.equals(ACTION_WIDGET_RECEIVER)) { onUpdate(context, AppWidgetManager.getInstance(context), new int[] { appWidgetId[i] }); }*/ } super.onReceive(context, intent); } @Override public void onDeleted(Context context, int[] appWidgetIds) { inject(context); SharedPreferences.Editor mPrefsEditor = mPrefs.edit(); for(int appWidgetId : appWidgetIds) { mPrefsEditor.remove("widget_" + appWidgetId); if(Constants.debugModeWidget) Log.d(TAG, "DELETE WIDGET - WIDGET_ID: " + appWidgetId); } /* //Delete All App Widgets for(int appWidgetId = 0; appWidgetId < 1000; appWidgetId++) { mPrefsEditor.remove("widget_" + appWidgetId); if(Constants.debugModeWidget) Log.d(TAG, "DELETE WIDGET - WIDGET_ID: " + appWidgetId); }*/ mPrefsEditor.commit(); super.onDeleted(context, appWidgetIds); } @Override public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) { inject(context); if(Constants.debugModeWidget) Log.d(TAG, "onUpdate"); // update each of the app widgets with the remote adapter for (int appWidgetId : appWidgetIds) { updateAppWidget(context, appWidgetManager, appWidgetId); //appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetId, R.id.list_view); } //int appWidgetIds[] = appWidgetManager.getAppWidgetIds(new ComponentName(context, WidgetProvider.class)); super.onUpdate(context, appWidgetManager, appWidgetIds); } public static void updateAppWidget(Context context, AppWidgetManager appWidgetManager, int appWidgetId) { RemoteViews rv = new RemoteViews(context.getPackageName(), R.layout.widget_layout); Intent intent = new Intent(context, WidgetService.class); intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId); //rv.setRemoteAdapter(appWidgetId, R.id.list_view, intent); rv.setRemoteAdapter(R.id.list_view, intent); Intent onListClickIntent = new Intent(context, WidgetProvider.class); onListClickIntent.setAction(ACTION_LIST_CLICK); onListClickIntent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId); onListClickIntent.setData(Uri.parse(onListClickIntent.toUri(Intent.URI_INTENT_SCHEME))); final PendingIntent onListClickPendingIntent = PendingIntentCompat.getBroadcast( context, 0, onListClickIntent, PendingIntent.FLAG_UPDATE_CURRENT, true ); rv.setPendingIntentTemplate(R.id.list_view, onListClickPendingIntent); /* Intent intentWidget = new Intent(context, WidgetProvider.class); PendingIntent pendingWidgetIntent = PendingIntent.getBroadcast(context, 0, intentWidget, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); rv.setOnClickPendingIntent(R.id.cb_lv_item_read_wrapper, pendingWidgetIntent); */ // Intent intentToDoListAct = new Intent(context, NewsReaderListActivity.class); // PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intentToDoListAct, PendingIntent.FLAG_IMMUTABLE); // rv.setOnClickPendingIntent(R.id.tV_widget_header, pendingIntent); appWidgetManager.updateAppWidget(appWidgetId, rv); if (Constants.debugModeWidget) Log.d(TAG, "updateAppWidget - WidgetID: " + appWidgetId); } private void inject(Context context) { ((NewsReaderApplication) context.getApplicationContext()).getAppComponent().injectWidget(this); } } ================================================ FILE: News-Android-App/src/main/java/de/luhmer/owncloudnewsreader/widget/WidgetService.kt ================================================ /* * Android ownCloud News * * @author David Luhmer * @copyright 2013 David Luhmer david-dev@live.de * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE * License as published by the Free Software Foundation; either * version 3 of the License, or any later version. * * This library 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 AFFERO GENERAL PUBLIC LICENSE for more details. * * You should have received a copy of the GNU Affero General Public * License along with this library. If not, see . * */ package de.luhmer.owncloudnewsreader.widget import android.content.Intent import android.widget.RemoteViewsService class WidgetService : RemoteViewsService() { override fun onGetViewFactory(intent: Intent): RemoteViewsFactory = WidgetNewsViewsFactory(this.applicationContext, intent) } ================================================ FILE: News-Android-App/src/main/res/anim/all_read_success.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/anim/slide_in_left.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/anim/slide_in_right.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/anim/slide_out_left.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/anim/slide_out_right.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/color/options_menu_item.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/color/options_menu_item_night.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/drawable/background_with_shadow.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/drawable/checkbox_background_holo_dark.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/drawable/cursor.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/drawable/custom_progress.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/drawable/fa_all_read_target.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/drawable/fa_all_read_target_success.xml ================================================ * ================================================ FILE: News-Android-App/src/main/res/drawable/fa_bg.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/drawable/feed_icon.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/drawable/ic_action_delete_24.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/drawable/ic_action_delete_24_theme_aware.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/drawable/ic_action_download_24.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/drawable/ic_action_expand_less_24.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/drawable/ic_action_open_in_browser_24.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/drawable/ic_action_open_in_browser_24_theme_aware.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/drawable/ic_action_pause_24.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/drawable/ic_add_black_24dp.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/drawable/ic_baseline_account_circle_24.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/drawable/ic_baseline_create_new_folder_24_black.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/drawable/ic_baseline_folder_24.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/drawable/ic_baseline_play_arrow_24.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/drawable/ic_baseline_play_arrow_24_theme_aware.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/drawable/ic_checkbox_black.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/drawable/ic_checkbox_outline_black.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/drawable/ic_checkbox_outline_theme_aware.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/drawable/ic_checkbox_outline_white.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/drawable/ic_checkbox_theme_aware.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/drawable/ic_checkbox_white.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/drawable/ic_done_all.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/drawable/ic_forward_30_24.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/drawable/ic_launcher_background.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/drawable/ic_launcher_foreground.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/drawable/ic_replay_10_24.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/drawable/ic_search_24dp_theme_aware.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/drawable/ic_settings_black_24dp.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/drawable/ic_share_theme_aware.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/drawable/ic_share_white.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/drawable/ic_slow_motion_video_24.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/drawable/ic_star_24_theme_aware.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/drawable/ic_star_black_24dp.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/drawable/ic_star_border_24dp_theme_aware.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/drawable/ic_star_white_24.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/drawable/ic_visibility_24.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/drawable/incognito.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/drawable/rounded_rectangle.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/drawable/shadow.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/drawable/swipe_markasread.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/drawable/swipe_openinbrowser.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/drawable/swipe_setstarred.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/drawable/swipe_share.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/drawable/widget_background.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/layout/activity_login_dialog.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/layout/activity_new_feed.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/layout/activity_news_detail.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/layout/activity_newsreader.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/layout/activity_pip_video_playback.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/layout/activity_settings.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/layout/dialog_list_folder.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/layout/dialog_version_info.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/layout/empty_content_view.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/layout/fragment_dialog_add_folder.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/layout/fragment_dialog_feedoptions.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/layout/fragment_dialog_folderoptions.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/layout/fragment_dialog_image.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/layout/fragment_dialog_listviewitem.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/layout/fragment_dialog_opml_import.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/layout/fragment_news_detail.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/layout/fragment_newsreader_detail.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/layout/fragment_newsreader_list.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/layout/fragment_newsreader_list_footer.xml ================================================ ================================================ FILE: News-Android-App/src/main/res/layout/fragment_podcast.xml ================================================