From 20a4bb093624da9d636777ee6b9ec3bba4f3c4da Mon Sep 17 00:00:00 2001 From: Mauricio Colli Date: Sun, 28 Apr 2019 17:43:54 -0300 Subject: [PATCH] Implement new feed and subscriptions groups - Introduce Groupie for easier lists implementations - Use some of the new components of the Android Architecture libraries - Add a bunch of icons for groups, using vectors, which still is compatible with older APIs through the compatibility layer --- app/build.gradle | 12 + .../3.json | 2 +- app/src/main/AndroidManifest.xml | 1 + .../java/org/schabi/newpipe/MainActivity.java | 2 +- .../schabi/newpipe/database/Converters.java | 15 + .../newpipe/database/feed/dao/FeedDAO.kt | 42 +- .../newpipe/database/feed/dao/FeedGroupDAO.kt | 33 +- .../database/feed/model/FeedGroupEntity.kt | 3 +- .../subscription/SubscriptionDAO.java | 72 --- .../database/subscription/SubscriptionDAO.kt | 60 ++ .../subscription/SubscriptionEntity.java | 16 +- .../fragments/list/BaseListFragment.java | 29 +- .../list/channel/ChannelFragment.java | 22 +- .../newpipe/info_list/InfoListAdapter.java | 8 +- .../newpipe/local/feed/FeedDatabaseManager.kt | 150 +++++ .../newpipe/local/feed/FeedFragment.java | 444 ------------- .../schabi/newpipe/local/feed/FeedFragment.kt | 288 +++++++++ .../schabi/newpipe/local/feed/FeedState.kt | 11 + .../newpipe/local/feed/FeedViewModel.kt | 66 ++ .../local/feed/service/FeedEventManager.kt | 38 ++ .../local/feed/service/FeedLoadService.kt | 399 ++++++++++++ .../local/subscription/FeedGroupIcon.kt | 62 ++ .../subscription/SubscriptionFragment.java | 595 ------------------ .../subscription/SubscriptionFragment.kt | 364 +++++++++++ .../local/subscription/SubscriptionManager.kt | 66 ++ .../subscription/SubscriptionService.java | 162 ----- .../subscription/SubscriptionViewModel.kt | 49 ++ .../decoration/FeedGroupCarouselDecoration.kt | 35 ++ .../subscription/dialog/FeedGroupDialog.kt | 355 +++++++++++ .../dialog/FeedGroupDialogViewModel.kt | 79 +++ .../local/subscription/item/ChannelItem.kt | 65 ++ .../subscription/item/EmptyPlaceholderItem.kt | 10 + .../subscription/item/FeedGroupAddItem.kt | 10 + .../subscription/item/FeedGroupCardItem.kt | 27 + .../item/FeedGroupCarouselItem.kt | 57 ++ .../subscription/item/FeedImportExportItem.kt | 116 ++++ .../local/subscription/item/HeaderItem.kt | 19 + .../subscription/item/HeaderTextSideItem.kt | 37 ++ .../local/subscription/item/PickerIconItem.kt | 19 + .../item/PickerSubscriptionItem.kt | 51 ++ .../services/BaseImportExportService.java | 7 +- .../ImportExportEventListener.java | 2 +- .../ImportExportJsonHelper.java | 2 +- .../services/SubscriptionsExportService.java | 3 +- .../services/SubscriptionsImportService.java | 5 +- .../org/schabi/newpipe/report/UserAction.java | 1 + .../settings/SelectChannelFragment.java | 6 +- .../org/schabi/newpipe/util/ConstantsKt.kt | 6 + .../schabi/newpipe/util/NavigationHelper.java | 8 +- .../org/schabi/newpipe/util/ThemeHelper.java | 11 + .../res/drawable/dark_focused_selector.xml | 5 + .../main/res/drawable/dashed_border_black.xml | 8 + .../main/res/drawable/dashed_border_dark.xml | 8 + .../main/res/drawable/dashed_border_light.xml | 8 + .../res/drawable/ic_asterisk_black_24dp.xml | 9 + .../res/drawable/ic_asterisk_white_24dp.xml | 9 + .../main/res/drawable/ic_car_black_24dp.xml | 9 + .../main/res/drawable/ic_car_white_24dp.xml | 9 + .../res/drawable/ic_computer_black_24dp.xml | 9 + .../res/drawable/ic_computer_white_24dp.xml | 9 + .../main/res/drawable/ic_edit_black_24dp.xml | 9 + .../main/res/drawable/ic_edit_white_24dp.xml | 9 + .../res/drawable/ic_emoticon_black_24dp.xml | 9 + .../res/drawable/ic_emoticon_white_24dp.xml | 9 + .../res/drawable/ic_explore_black_24dp.xml | 9 + .../res/drawable/ic_explore_white_24dp.xml | 9 + .../res/drawable/ic_fastfood_black_24dp.xml | 9 + .../res/drawable/ic_fastfood_white_24dp.xml | 9 + .../res/drawable/ic_fitness_black_24dp.xml | 9 + .../res/drawable/ic_fitness_white_24dp.xml | 9 + .../main/res/drawable/ic_heart_black_24dp.xml | 9 + .../main/res/drawable/ic_heart_white_24dp.xml | 9 + .../main/res/drawable/ic_kids_black_24dp.xml | 15 + .../main/res/drawable/ic_kids_white_24dp.xml | 15 + .../res/drawable/ic_megaphone_black_24dp.xml | 9 + .../res/drawable/ic_megaphone_white_24dp.xml | 9 + .../main/res/drawable/ic_mic_black_24dp.xml | 9 + .../main/res/drawable/ic_mic_white_24dp.xml | 9 + .../main/res/drawable/ic_money_black_24dp.xml | 9 + .../main/res/drawable/ic_money_white_24dp.xml | 9 + .../res/drawable/ic_motorcycle_black_24dp.xml | 9 + .../res/drawable/ic_motorcycle_white_24dp.xml | 9 + .../main/res/drawable/ic_movie_black_24dp.xml | 9 + .../main/res/drawable/ic_movie_white_24dp.xml | 9 + .../res/drawable/ic_music_note_black_24dp.xml | 9 + .../res/drawable/ic_music_note_white_24dp.xml | 9 + .../res/drawable/ic_people_black_24dp.xml | 9 + .../res/drawable/ic_people_white_24dp.xml | 9 + .../res/drawable/ic_person_black_24dp.xml | 9 + .../res/drawable/ic_person_white_24dp.xml | 9 + .../main/res/drawable/ic_pets_black_24dp.xml | 21 + .../main/res/drawable/ic_pets_white_24dp.xml | 21 + .../main/res/drawable/ic_radio_black_24dp.xml | 9 + .../main/res/drawable/ic_radio_white_24dp.xml | 9 + .../res/drawable/ic_refresh_black_24dp.xml | 9 + .../res/drawable/ic_refresh_white_24dp.xml | 9 + .../res/drawable/ic_restaurant_black_24dp.xml | 9 + .../res/drawable/ic_restaurant_white_24dp.xml | 9 + .../res/drawable/ic_school_black_24dp.xml | 9 + .../res/drawable/ic_school_white_24dp.xml | 9 + .../drawable/ic_shopping_cart_black_24dp.xml | 9 + .../drawable/ic_shopping_cart_white_24dp.xml | 9 + .../res/drawable/ic_sports_black_24dp.xml | 9 + .../res/drawable/ic_sports_white_24dp.xml | 9 + .../main/res/drawable/ic_stars_black_24dp.xml | 9 + .../main/res/drawable/ic_stars_white_24dp.xml | 9 + .../main/res/drawable/ic_sunny_black_24dp.xml | 9 + .../main/res/drawable/ic_sunny_white_24dp.xml | 9 + .../res/drawable/ic_telescope_black_24dp.xml | 9 + .../res/drawable/ic_telescope_white_24dp.xml | 9 + .../drawable/ic_trending_up_black_24dp.xml | 9 + .../drawable/ic_trending_up_white_24dp.xml | 9 + .../res/drawable/ic_videogame_black_24dp.xml | 9 + .../res/drawable/ic_videogame_white_24dp.xml | 9 + .../drawable/ic_watch_later_black_24dp.xml | 9 + .../drawable/ic_watch_later_white_24dp.xml | 9 + .../main/res/drawable/ic_work_black_24dp.xml | 9 + .../main/res/drawable/ic_work_white_24dp.xml | 9 + .../main/res/drawable/ic_world_black_24dp.xml | 9 + .../main/res/drawable/ic_world_white_24dp.xml | 9 + .../res/drawable/light_focused_selector.xml | 5 + .../res/layout/dialog_feed_group_create.xml | 153 +++++ .../res/layout/feed_group_add_new_item.xml | 46 ++ .../main/res/layout/feed_group_card_item.xml | 48 ++ .../res/layout/feed_import_export_group.xml | 119 ++++ .../main/res/layout/feed_item_carousel.xml | 7 + app/src/main/res/layout/fragment_feed.xml | 88 ++- .../main/res/layout/fragment_subscription.xml | 6 +- .../res/layout/header_divider_item_layout.xml | 32 + app/src/main/res/layout/header_item.xml | 16 + .../main/res/layout/header_with_text_item.xml | 36 ++ app/src/main/res/layout/list_empty_view.xml | 3 +- app/src/main/res/layout/picker_icon_item.xml | 15 + .../res/layout/picker_subscription_item.xml | 58 ++ .../main/res/layout/subscription_header.xml | 36 +- app/src/main/res/values/attrs.xml | 39 ++ app/src/main/res/values/colors.xml | 9 + app/src/main/res/values/dimens.xml | 7 + app/src/main/res/values/settings_keys.xml | 1 + app/src/main/res/values/strings.xml | 16 +- app/src/main/res/values/styles.xml | 97 +++ .../local/subscription/FeedGroupIconTest.kt | 30 + .../services/ImportExportJsonHelperTest.java | 1 - 143 files changed, 4099 insertions(+), 1370 deletions(-) delete mode 100644 app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.java create mode 100644 app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.kt create mode 100644 app/src/main/java/org/schabi/newpipe/local/feed/FeedDatabaseManager.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt create mode 100644 app/src/main/java/org/schabi/newpipe/local/feed/FeedState.kt create mode 100644 app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt create mode 100644 app/src/main/java/org/schabi/newpipe/local/feed/service/FeedEventManager.kt create mode 100644 app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadService.kt create mode 100644 app/src/main/java/org/schabi/newpipe/local/subscription/FeedGroupIcon.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt create mode 100644 app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionService.java create mode 100644 app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionViewModel.kt create mode 100644 app/src/main/java/org/schabi/newpipe/local/subscription/decoration/FeedGroupCarouselDecoration.kt create mode 100644 app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialog.kt create mode 100644 app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialogViewModel.kt create mode 100644 app/src/main/java/org/schabi/newpipe/local/subscription/item/ChannelItem.kt create mode 100644 app/src/main/java/org/schabi/newpipe/local/subscription/item/EmptyPlaceholderItem.kt create mode 100644 app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupAddItem.kt create mode 100644 app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupCardItem.kt create mode 100644 app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupCarouselItem.kt create mode 100644 app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedImportExportItem.kt create mode 100644 app/src/main/java/org/schabi/newpipe/local/subscription/item/HeaderItem.kt create mode 100644 app/src/main/java/org/schabi/newpipe/local/subscription/item/HeaderTextSideItem.kt create mode 100644 app/src/main/java/org/schabi/newpipe/local/subscription/item/PickerIconItem.kt create mode 100644 app/src/main/java/org/schabi/newpipe/local/subscription/item/PickerSubscriptionItem.kt rename app/src/main/java/org/schabi/newpipe/local/subscription/{ => services}/ImportExportEventListener.java (87%) rename app/src/main/java/org/schabi/newpipe/local/subscription/{ => services}/ImportExportJsonHelper.java (98%) create mode 100644 app/src/main/java/org/schabi/newpipe/util/ConstantsKt.kt create mode 100644 app/src/main/res/drawable/dark_focused_selector.xml create mode 100644 app/src/main/res/drawable/dashed_border_black.xml create mode 100644 app/src/main/res/drawable/dashed_border_dark.xml create mode 100644 app/src/main/res/drawable/dashed_border_light.xml create mode 100644 app/src/main/res/drawable/ic_asterisk_black_24dp.xml create mode 100644 app/src/main/res/drawable/ic_asterisk_white_24dp.xml create mode 100644 app/src/main/res/drawable/ic_car_black_24dp.xml create mode 100644 app/src/main/res/drawable/ic_car_white_24dp.xml create mode 100644 app/src/main/res/drawable/ic_computer_black_24dp.xml create mode 100644 app/src/main/res/drawable/ic_computer_white_24dp.xml create mode 100644 app/src/main/res/drawable/ic_edit_black_24dp.xml create mode 100644 app/src/main/res/drawable/ic_edit_white_24dp.xml create mode 100644 app/src/main/res/drawable/ic_emoticon_black_24dp.xml create mode 100644 app/src/main/res/drawable/ic_emoticon_white_24dp.xml create mode 100644 app/src/main/res/drawable/ic_explore_black_24dp.xml create mode 100644 app/src/main/res/drawable/ic_explore_white_24dp.xml create mode 100644 app/src/main/res/drawable/ic_fastfood_black_24dp.xml create mode 100644 app/src/main/res/drawable/ic_fastfood_white_24dp.xml create mode 100644 app/src/main/res/drawable/ic_fitness_black_24dp.xml create mode 100644 app/src/main/res/drawable/ic_fitness_white_24dp.xml create mode 100644 app/src/main/res/drawable/ic_heart_black_24dp.xml create mode 100644 app/src/main/res/drawable/ic_heart_white_24dp.xml create mode 100644 app/src/main/res/drawable/ic_kids_black_24dp.xml create mode 100644 app/src/main/res/drawable/ic_kids_white_24dp.xml create mode 100644 app/src/main/res/drawable/ic_megaphone_black_24dp.xml create mode 100644 app/src/main/res/drawable/ic_megaphone_white_24dp.xml create mode 100644 app/src/main/res/drawable/ic_mic_black_24dp.xml create mode 100644 app/src/main/res/drawable/ic_mic_white_24dp.xml create mode 100644 app/src/main/res/drawable/ic_money_black_24dp.xml create mode 100644 app/src/main/res/drawable/ic_money_white_24dp.xml create mode 100644 app/src/main/res/drawable/ic_motorcycle_black_24dp.xml create mode 100644 app/src/main/res/drawable/ic_motorcycle_white_24dp.xml create mode 100644 app/src/main/res/drawable/ic_movie_black_24dp.xml create mode 100644 app/src/main/res/drawable/ic_movie_white_24dp.xml create mode 100644 app/src/main/res/drawable/ic_music_note_black_24dp.xml create mode 100644 app/src/main/res/drawable/ic_music_note_white_24dp.xml create mode 100644 app/src/main/res/drawable/ic_people_black_24dp.xml create mode 100644 app/src/main/res/drawable/ic_people_white_24dp.xml create mode 100644 app/src/main/res/drawable/ic_person_black_24dp.xml create mode 100644 app/src/main/res/drawable/ic_person_white_24dp.xml create mode 100644 app/src/main/res/drawable/ic_pets_black_24dp.xml create mode 100644 app/src/main/res/drawable/ic_pets_white_24dp.xml create mode 100644 app/src/main/res/drawable/ic_radio_black_24dp.xml create mode 100644 app/src/main/res/drawable/ic_radio_white_24dp.xml create mode 100644 app/src/main/res/drawable/ic_refresh_black_24dp.xml create mode 100644 app/src/main/res/drawable/ic_refresh_white_24dp.xml create mode 100644 app/src/main/res/drawable/ic_restaurant_black_24dp.xml create mode 100644 app/src/main/res/drawable/ic_restaurant_white_24dp.xml create mode 100644 app/src/main/res/drawable/ic_school_black_24dp.xml create mode 100644 app/src/main/res/drawable/ic_school_white_24dp.xml create mode 100644 app/src/main/res/drawable/ic_shopping_cart_black_24dp.xml create mode 100644 app/src/main/res/drawable/ic_shopping_cart_white_24dp.xml create mode 100644 app/src/main/res/drawable/ic_sports_black_24dp.xml create mode 100644 app/src/main/res/drawable/ic_sports_white_24dp.xml create mode 100644 app/src/main/res/drawable/ic_stars_black_24dp.xml create mode 100644 app/src/main/res/drawable/ic_stars_white_24dp.xml create mode 100644 app/src/main/res/drawable/ic_sunny_black_24dp.xml create mode 100644 app/src/main/res/drawable/ic_sunny_white_24dp.xml create mode 100644 app/src/main/res/drawable/ic_telescope_black_24dp.xml create mode 100644 app/src/main/res/drawable/ic_telescope_white_24dp.xml create mode 100644 app/src/main/res/drawable/ic_trending_up_black_24dp.xml create mode 100644 app/src/main/res/drawable/ic_trending_up_white_24dp.xml create mode 100644 app/src/main/res/drawable/ic_videogame_black_24dp.xml create mode 100644 app/src/main/res/drawable/ic_videogame_white_24dp.xml create mode 100644 app/src/main/res/drawable/ic_watch_later_black_24dp.xml create mode 100644 app/src/main/res/drawable/ic_watch_later_white_24dp.xml create mode 100644 app/src/main/res/drawable/ic_work_black_24dp.xml create mode 100644 app/src/main/res/drawable/ic_work_white_24dp.xml create mode 100644 app/src/main/res/drawable/ic_world_black_24dp.xml create mode 100644 app/src/main/res/drawable/ic_world_white_24dp.xml create mode 100644 app/src/main/res/drawable/light_focused_selector.xml create mode 100644 app/src/main/res/layout/dialog_feed_group_create.xml create mode 100644 app/src/main/res/layout/feed_group_add_new_item.xml create mode 100644 app/src/main/res/layout/feed_group_card_item.xml create mode 100644 app/src/main/res/layout/feed_import_export_group.xml create mode 100644 app/src/main/res/layout/feed_item_carousel.xml create mode 100644 app/src/main/res/layout/header_divider_item_layout.xml create mode 100644 app/src/main/res/layout/header_item.xml create mode 100644 app/src/main/res/layout/header_with_text_item.xml create mode 100644 app/src/main/res/layout/picker_icon_item.xml create mode 100644 app/src/main/res/layout/picker_subscription_item.xml create mode 100644 app/src/test/java/org/schabi/newpipe/local/subscription/FeedGroupIconTest.kt diff --git a/app/build.gradle b/app/build.gradle index 3d4d82d97..11027a758 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -79,6 +79,11 @@ android { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } + + // Required and used only by groupie + androidExtensions { + experimental = true + } } ext { @@ -111,6 +116,13 @@ dependencies { implementation "androidx.cardview:cardview:${androidxLibVersion}" implementation 'androidx.constraintlayout:constraintlayout:1.1.3' + implementation 'com.xwray:groupie:2.3.0' + implementation 'com.xwray:groupie-kotlin-android-extensions:2.3.0' + + implementation 'androidx.lifecycle:lifecycle-livedata:2.0.0' + implementation 'androidx.lifecycle:lifecycle-viewmodel:2.0.0' + implementation 'androidx.lifecycle:lifecycle-extensions:2.0.0' + // Originally in NewPipeExtractor implementation 'com.grack:nanojson:1.1' implementation 'org.jsoup:jsoup:1.9.2' diff --git a/app/schemas/org.schabi.newpipe.database.AppDatabase/3.json b/app/schemas/org.schabi.newpipe.database.AppDatabase/3.json index fdfc3740e..86852c85e 100644 --- a/app/schemas/org.schabi.newpipe.database.AppDatabase/3.json +++ b/app/schemas/org.schabi.newpipe.database.AppDatabase/3.json @@ -564,7 +564,7 @@ "notNull": true }, { - "fieldPath": "iconId", + "fieldPath": "icon", "columnName": "icon_id", "affinity": "INTEGER", "notNull": true diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d0e204137..5ec46e7bd 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -74,6 +74,7 @@ + > @@ -36,12 +39,45 @@ abstract class FeedDAO { ON fg.uid = fgs.group_id WHERE fgs.group_id = :groupId + + ORDER BY s.upload_date IS NULL DESC, s.upload_date DESC, s.uploader ASC + LIMIT 500 """) abstract fun getAllStreamsFromGroup(groupId: Long): Flowable> - @Insert(onConflict = OnConflictStrategy.FAIL) + @Query(""" + DELETE FROM feed WHERE + + feed.stream_id IN ( + SELECT s.uid FROM streams s + + INNER JOIN feed f + ON s.uid = f.stream_id + + WHERE s.upload_date < :date + ) + """) + abstract fun unlinkStreamsOlderThan(date: Date) + + @Query(""" + DELETE FROM feed + + WHERE feed.subscription_id = :subscriptionId + + AND feed.stream_id IN ( + SELECT s.uid FROM streams s + + INNER JOIN feed f + ON s.uid = f.stream_id + + WHERE s.stream_type = "LIVE_STREAM" OR s.stream_type = "AUDIO_LIVE_STREAM" + ) + """) + abstract fun unlinkOldLivestreams(subscriptionId: Long) + + @Insert(onConflict = OnConflictStrategy.IGNORE) abstract fun insert(feedEntity: FeedEntity) - @Insert(onConflict = OnConflictStrategy.FAIL) + @Insert(onConflict = OnConflictStrategy.IGNORE) abstract fun insertAll(entities: List): List } diff --git a/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedGroupDAO.kt b/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedGroupDAO.kt index 233c5e064..bf2b12df0 100644 --- a/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedGroupDAO.kt +++ b/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedGroupDAO.kt @@ -2,16 +2,43 @@ package org.schabi.newpipe.database.feed.dao import androidx.room.* import io.reactivex.Flowable +import io.reactivex.Maybe import org.schabi.newpipe.database.feed.model.FeedGroupEntity +import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity @Dao abstract class FeedGroupDAO { - @Query("DELETE FROM feed_group") - abstract fun deleteAll(): Int @Query("SELECT * FROM feed_group") abstract fun getAll(): Flowable> + @Query("SELECT * FROM feed_group WHERE uid = :groupId") + abstract fun getGroup(groupId: Long): Maybe + @Insert(onConflict = OnConflictStrategy.ABORT) - abstract fun insert(feedEntity: FeedGroupEntity) + abstract fun insert(feedEntity: FeedGroupEntity): Long + + @Update(onConflict = OnConflictStrategy.IGNORE) + abstract fun update(feedGroupEntity: FeedGroupEntity): Int + + @Query("DELETE FROM feed_group") + abstract fun deleteAll(): Int + + @Query("DELETE FROM feed_group WHERE uid = :groupId") + abstract fun delete(groupId: Long): Int + + @Query("SELECT subscription_id FROM feed_group_subscription_join WHERE group_id = :groupId") + abstract fun getSubscriptionIdsFor(groupId: Long): Flowable> + + @Query("DELETE FROM feed_group_subscription_join WHERE group_id = :groupId") + abstract fun deleteSubscriptionsFromGroup(groupId: Long): Int + + @Insert(onConflict = OnConflictStrategy.IGNORE) + abstract fun insertSubscriptionsToGroup(entities: List): List + + @Transaction + open fun updateSubscriptionsForGroup(groupId: Long, subscriptionIds: List) { + deleteSubscriptionsFromGroup(groupId) + insertSubscriptionsToGroup(subscriptionIds.map { FeedGroupSubscriptionEntity(groupId, it) }) + } } diff --git a/app/src/main/java/org/schabi/newpipe/database/feed/model/FeedGroupEntity.kt b/app/src/main/java/org/schabi/newpipe/database/feed/model/FeedGroupEntity.kt index cd919ec05..ddefd590b 100644 --- a/app/src/main/java/org/schabi/newpipe/database/feed/model/FeedGroupEntity.kt +++ b/app/src/main/java/org/schabi/newpipe/database/feed/model/FeedGroupEntity.kt @@ -4,6 +4,7 @@ import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey import org.schabi.newpipe.database.feed.model.FeedGroupEntity.Companion.FEED_GROUP_TABLE +import org.schabi.newpipe.local.subscription.FeedGroupIcon @Entity(tableName = FEED_GROUP_TABLE) data class FeedGroupEntity( @@ -15,7 +16,7 @@ data class FeedGroupEntity( var name: String, @ColumnInfo(name = ICON) - var iconId: Int + var icon: FeedGroupIcon ) { companion object { const val FEED_GROUP_TABLE = "feed_group" diff --git a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.java b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.java deleted file mode 100644 index 03df797a4..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.java +++ /dev/null @@ -1,72 +0,0 @@ -package org.schabi.newpipe.database.subscription; - -import androidx.room.Dao; -import androidx.room.Insert; -import androidx.room.OnConflictStrategy; -import androidx.room.Query; -import androidx.room.Transaction; - -import org.schabi.newpipe.database.BasicDAO; - -import java.util.List; - -import io.reactivex.Flowable; - -import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_SERVICE_ID; -import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_TABLE; -import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_UID; -import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_URL; - -@Dao -public abstract class SubscriptionDAO implements BasicDAO { - @Override - @Query("SELECT * FROM " + SUBSCRIPTION_TABLE) - public abstract Flowable> getAll(); - - @Query("SELECT COUNT(*) FROM subscriptions") - public abstract Flowable rowCount(); - - @Override - @Query("DELETE FROM " + SUBSCRIPTION_TABLE) - public abstract int deleteAll(); - - @Override - @Query("SELECT * FROM " + SUBSCRIPTION_TABLE + " WHERE " + SUBSCRIPTION_SERVICE_ID + " = :serviceId") - public abstract Flowable> listByService(int serviceId); - - @Query("SELECT * FROM " + SUBSCRIPTION_TABLE + " WHERE " + - SUBSCRIPTION_URL + " LIKE :url AND " + - SUBSCRIPTION_SERVICE_ID + " = :serviceId") - public abstract Flowable> getSubscription(int serviceId, String url); - - @Query("SELECT " + SUBSCRIPTION_UID + " FROM " + SUBSCRIPTION_TABLE + " WHERE " + - SUBSCRIPTION_URL + " LIKE :url AND " + - SUBSCRIPTION_SERVICE_ID + " = :serviceId") - abstract Long getSubscriptionIdInternal(int serviceId, String url); - - @Insert(onConflict = OnConflictStrategy.IGNORE) - abstract Long insertInternal(final SubscriptionEntity entities); - - @Transaction - public List upsertAll(List entities) { - for (SubscriptionEntity entity : entities) { - Long uid = insertInternal(entity); - - if (uid != -1) { - entity.setUid(uid); - continue; - } - - uid = getSubscriptionIdInternal(entity.getServiceId(), entity.getUrl()); - entity.setUid(uid); - - if (uid == -1) { - throw new IllegalStateException("Invalid subscription id (-1)"); - } - - update(entity); - } - - return entities; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.kt b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.kt new file mode 100644 index 000000000..bd13d9088 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.kt @@ -0,0 +1,60 @@ +package org.schabi.newpipe.database.subscription + +import androidx.room.* +import io.reactivex.Flowable +import io.reactivex.Maybe +import org.schabi.newpipe.database.BasicDAO + +@Dao +abstract class SubscriptionDAO : BasicDAO { + @Query("SELECT COUNT(*) FROM subscriptions") + abstract fun rowCount(): Flowable + + @Query("SELECT * FROM subscriptions WHERE service_id = :serviceId") + abstract override fun listByService(serviceId: Int): Flowable> + + @Query("SELECT * FROM subscriptions ORDER BY name COLLATE NOCASE ASC") + abstract override fun getAll(): Flowable> + + @Query("SELECT * FROM subscriptions WHERE url LIKE :url AND service_id = :serviceId") + abstract fun getSubscriptionFlowable(serviceId: Int, url: String): Flowable> + + @Query("SELECT * FROM subscriptions WHERE url LIKE :url AND service_id = :serviceId") + abstract fun getSubscription(serviceId: Int, url: String): Maybe + + @Query("SELECT * FROM subscriptions WHERE uid = :subscriptionId") + abstract fun getSubscription(subscriptionId: Long): SubscriptionEntity + + @Query("DELETE FROM subscriptions") + abstract override fun deleteAll(): Int + + @Query("DELETE FROM subscriptions WHERE url LIKE :url AND service_id = :serviceId") + abstract fun deleteSubscription(serviceId: Int, url: String): Int + + @Query("SELECT uid FROM subscriptions WHERE url LIKE :url AND service_id = :serviceId") + internal abstract fun getSubscriptionIdInternal(serviceId: Int, url: String): Long? + + @Insert(onConflict = OnConflictStrategy.IGNORE) + internal abstract fun silentInsertAllInternal(entities: List): List + + @Transaction + open fun upsertAll(entities: List): List { + val insertUidList = silentInsertAllInternal(entities) + + insertUidList.forEachIndexed { index: Int, uidFromInsert: Long -> + val entity = entities[index] + + if (uidFromInsert != -1L) { + entity.uid = uidFromInsert + } else { + val subscriptionIdFromDb = getSubscriptionIdInternal(entity.serviceId, entity.url) + ?: throw IllegalStateException("Subscription cannot be null just after insertion.") + entity.uid = subscriptionIdFromDb + + update(entity) + } + } + + return entities + } +} diff --git a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.java b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.java index 2500dfc71..ec98c583a 100644 --- a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.java +++ b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.java @@ -19,14 +19,14 @@ import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCR indices = {@Index(value = {SUBSCRIPTION_SERVICE_ID, SUBSCRIPTION_URL}, unique = true)}) public class SubscriptionEntity { - public final static String SUBSCRIPTION_UID = "uid"; - final static String SUBSCRIPTION_TABLE = "subscriptions"; - final static String SUBSCRIPTION_SERVICE_ID = "service_id"; - final static String SUBSCRIPTION_URL = "url"; - final static String SUBSCRIPTION_NAME = "name"; - final static String SUBSCRIPTION_AVATAR_URL = "avatar_url"; - final static String SUBSCRIPTION_SUBSCRIBER_COUNT = "subscriber_count"; - final static String SUBSCRIPTION_DESCRIPTION = "description"; + public static final String SUBSCRIPTION_UID = "uid"; + public static final String SUBSCRIPTION_TABLE = "subscriptions"; + public static final String SUBSCRIPTION_SERVICE_ID = "service_id"; + public static final String SUBSCRIPTION_URL = "url"; + public static final String SUBSCRIPTION_NAME = "name"; + public static final String SUBSCRIPTION_AVATAR_URL = "avatar_url"; + public static final String SUBSCRIPTION_SUBSCRIBER_COUNT = "subscriber_count"; + public static final String SUBSCRIPTION_DESCRIPTION = "description"; @PrimaryKey(autoGenerate = true) private long uid = 0; diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java index d6fd1dd00..d55bf3f40 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java @@ -59,7 +59,10 @@ public abstract class BaseListFragment extends BaseStateFragment implem @Override public void onAttach(Context context) { super.onAttach(context); - infoListAdapter = new InfoListAdapter(activity); + + if (infoListAdapter == null) { + infoListAdapter = new InfoListAdapter(activity); + } } @Override @@ -78,7 +81,7 @@ public abstract class BaseListFragment extends BaseStateFragment implem @Override public void onDestroy() { super.onDestroy(); - StateSaver.onDestroy(savedState); + if (useDefaultStateSaving) StateSaver.onDestroy(savedState); PreferenceManager.getDefaultSharedPreferences(activity) .unregisterOnSharedPreferenceChangeListener(this); } @@ -103,6 +106,16 @@ public abstract class BaseListFragment extends BaseStateFragment implem //////////////////////////////////////////////////////////////////////////*/ protected StateSaver.SavedState savedState; + protected boolean useDefaultStateSaving = true; + + /** + * If the default implementation of {@link StateSaver.WriteRead} should be used. + * + * @see StateSaver + */ + public void useDefaultStateSaving(boolean useDefault) { + this.useDefaultStateSaving = useDefault; + } @Override public String generateSuffix() { @@ -112,26 +125,28 @@ public abstract class BaseListFragment extends BaseStateFragment implem @Override public void writeTo(Queue objectsToSave) { - objectsToSave.add(infoListAdapter.getItemsList()); + if (useDefaultStateSaving) objectsToSave.add(infoListAdapter.getItemsList()); } @Override @SuppressWarnings("unchecked") public void readFrom(@NonNull Queue savedObjects) throws Exception { - infoListAdapter.getItemsList().clear(); - infoListAdapter.getItemsList().addAll((List) savedObjects.poll()); + if (useDefaultStateSaving) { + infoListAdapter.getItemsList().clear(); + infoListAdapter.getItemsList().addAll((List) savedObjects.poll()); + } } @Override public void onSaveInstanceState(Bundle bundle) { super.onSaveInstanceState(bundle); - savedState = StateSaver.tryToSave(activity.isChangingConfigurations(), savedState, bundle, this); + if (useDefaultStateSaving) savedState = StateSaver.tryToSave(activity.isChangingConfigurations(), savedState, bundle, this); } @Override protected void onRestoreInstanceState(@NonNull Bundle bundle) { super.onRestoreInstanceState(bundle); - savedState = StateSaver.tryToRestore(bundle, this); + if (useDefaultStateSaving) savedState = StateSaver.tryToRestore(bundle, this); } /*////////////////////////////////////////////////////////////////////////// diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java index 4742fcca1..3615b0922 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java @@ -33,7 +33,7 @@ import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.fragments.list.BaseListInfoFragment; -import org.schabi.newpipe.local.subscription.SubscriptionService; +import org.schabi.newpipe.local.subscription.SubscriptionManager; import org.schabi.newpipe.player.playqueue.ChannelPlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.report.UserAction; @@ -66,7 +66,7 @@ public class ChannelFragment extends BaseListInfoFragment { private final CompositeDisposable disposables = new CompositeDisposable(); private Disposable subscribeButtonMonitor; - private SubscriptionService subscriptionService; + private SubscriptionManager subscriptionManager; /*////////////////////////////////////////////////////////////////////////// // Views @@ -109,7 +109,7 @@ public class ChannelFragment extends BaseListInfoFragment { @Override public void onAttach(Context context) { super.onAttach(context); - subscriptionService = SubscriptionService.getInstance(activity); + subscriptionManager = new SubscriptionManager(activity); } @Override @@ -212,8 +212,8 @@ public class ChannelFragment extends BaseListInfoFragment { 0); }; - final Observable> observable = subscriptionService.subscriptionTable() - .getSubscription(info.getServiceId(), info.getUrl()) + final Observable> observable = subscriptionManager.subscriptionTable() + .getSubscriptionFlowable(info.getServiceId(), info.getUrl()) .toObservable(); disposables.add(observable @@ -231,16 +231,16 @@ public class ChannelFragment extends BaseListInfoFragment { } - private Function mapOnSubscribe(final SubscriptionEntity subscription) { + private Function mapOnSubscribe(final SubscriptionEntity subscription, ChannelInfo info) { return (@NonNull Object o) -> { - subscriptionService.subscriptionTable().insert(subscription); + subscriptionManager.insertSubscription(subscription, info); return o; }; } private Function mapOnUnsubscribe(final SubscriptionEntity subscription) { return (@NonNull Object o) -> { - subscriptionService.subscriptionTable().delete(subscription); + subscriptionManager.deleteSubscription(subscription); return o; }; } @@ -258,7 +258,7 @@ public class ChannelFragment extends BaseListInfoFragment { "Updating Subscription for " + info.getUrl(), R.string.subscription_update_failed); - disposables.add(subscriptionService.updateChannelInfo(info) + disposables.add(subscriptionManager.updateChannelInfo(info) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(onComplete, onError)); @@ -288,7 +288,7 @@ public class ChannelFragment extends BaseListInfoFragment { private Consumer> getSubscribeUpdateMonitor(final ChannelInfo info) { return (List subscriptionEntities) -> { if (DEBUG) - Log.d(TAG, "subscriptionService.subscriptionTable.doOnNext() called with: subscriptionEntities = [" + subscriptionEntities + "]"); + Log.d(TAG, "subscriptionManager.subscriptionTable.doOnNext() called with: subscriptionEntities = [" + subscriptionEntities + "]"); if (subscribeButtonMonitor != null) subscribeButtonMonitor.dispose(); if (subscriptionEntities.isEmpty()) { @@ -300,7 +300,7 @@ public class ChannelFragment extends BaseListInfoFragment { info.getAvatarUrl(), info.getDescription(), info.getSubscriberCount()); - subscribeButtonMonitor = monitorSubscribeButton(headerSubscribeButton, mapOnSubscribe(channel)); + subscribeButtonMonitor = monitorSubscribeButton(headerSubscribeButton, mapOnSubscribe(channel, info)); } else { if (DEBUG) Log.d(TAG, "Found subscription to this channel!"); final SubscriptionEntity subscription = subscriptionEntities.get(0); diff --git a/app/src/main/java/org/schabi/newpipe/info_list/InfoListAdapter.java b/app/src/main/java/org/schabi/newpipe/info_list/InfoListAdapter.java index 594ec81af..54cb6326c 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/InfoListAdapter.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/InfoListAdapter.java @@ -122,7 +122,7 @@ public class InfoListAdapter extends RecyclerView.Adapter data) { + public void addInfoItemList(@Nullable final List data) { if (data == null) { return; } @@ -147,6 +147,12 @@ public class InfoListAdapter extends RecyclerView.Adapter data) { + infoItemList.clear(); + infoItemList.addAll(data); + notifyDataSetChanged(); + } + public void addInfoItem(@Nullable InfoItem data) { if (data == null) { return; diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedDatabaseManager.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedDatabaseManager.kt new file mode 100644 index 000000000..281a790b7 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedDatabaseManager.kt @@ -0,0 +1,150 @@ +package org.schabi.newpipe.local.feed + +import android.content.Context +import android.preference.PreferenceManager +import android.util.Log +import io.reactivex.Completable +import io.reactivex.Flowable +import io.reactivex.Maybe +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.schedulers.Schedulers +import org.schabi.newpipe.MainActivity.DEBUG +import org.schabi.newpipe.NewPipeDatabase +import org.schabi.newpipe.R +import org.schabi.newpipe.database.feed.model.FeedEntity +import org.schabi.newpipe.database.feed.model.FeedGroupEntity +import org.schabi.newpipe.database.stream.model.StreamEntity +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.extractor.stream.StreamType +import org.schabi.newpipe.local.subscription.FeedGroupIcon +import java.util.* +import kotlin.collections.ArrayList + +class FeedDatabaseManager(context: Context) { + private val database = NewPipeDatabase.getInstance(context) + private val feedTable = database.feedDAO() + private val feedGroupTable = database.feedGroupDAO() + private val streamTable = database.streamDAO() + + companion object { + /** + * Only items that are newer than this will be saved. + */ + val FEED_OLDEST_ALLOWED_DATE: Calendar = Calendar.getInstance().apply { + add(Calendar.WEEK_OF_YEAR, -13) + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + } + } + + fun groups() = feedGroupTable.getAll() + + fun database() = database + + fun asStreamItems(groupId: Long = -1): Flowable> { + val streams = + if (groupId >= 0) feedTable.getAllStreamsFromGroup(groupId) + else feedTable.getAllStreams() + + return streams.map> { + val items = ArrayList(it.size) + for (streamEntity in it) items.add(streamEntity.toStreamInfoItem()) + return@map items + } + } + + fun upsertAll(subscriptionId: Long, items: List, + oldestAllowedDate: Date = FEED_OLDEST_ALLOWED_DATE.time) { + val itemsToInsert = ArrayList() + loop@ for (streamItem in items) { + val uploadDate = streamItem.uploadDate + + itemsToInsert += when { + uploadDate == null && streamItem.streamType == StreamType.LIVE_STREAM -> streamItem + uploadDate != null && uploadDate.date().time >= oldestAllowedDate -> streamItem + else -> continue@loop + } + } + + feedTable.unlinkOldLivestreams(subscriptionId) + + if (itemsToInsert.isNotEmpty()) { + val streamEntities = itemsToInsert.map { StreamEntity(it) } + val streamIds = streamTable.upsertAll(streamEntities) + val feedEntities = streamIds.map { FeedEntity(it, subscriptionId) } + + feedTable.insertAll(feedEntities) + } + } + + fun getLastUpdated(context: Context): Calendar? { + val lastUpdatedMillis = PreferenceManager.getDefaultSharedPreferences(context) + .getLong(context.getString(R.string.feed_last_updated_key), -1) + + val calendar = Calendar.getInstance() + if (lastUpdatedMillis > 0) { + calendar.timeInMillis = lastUpdatedMillis + return calendar + } + + return null + } + + fun setLastUpdated(context: Context, lastUpdated: Calendar?) { + PreferenceManager.getDefaultSharedPreferences(context).edit() + .putLong(context.getString(R.string.feed_last_updated_key), lastUpdated?.timeInMillis ?: -1).apply() + } + + fun removeOrphansOrOlderStreams(oldestAllowedDate: Date = FEED_OLDEST_ALLOWED_DATE.time) { + feedTable.unlinkStreamsOlderThan(oldestAllowedDate) + streamTable.deleteOrphans() + } + + fun clear() { + feedTable.deleteAll() + val deletedOrphans = streamTable.deleteOrphans() + if (DEBUG) Log.d(this::class.java.simpleName, "clear() → streamTable.deleteOrphans() → $deletedOrphans") + } + + /////////////////////////////////////////////////////////////////////////// + // Feed Groups + /////////////////////////////////////////////////////////////////////////// + + fun subscriptionIdsForGroup(groupId: Long): Flowable> { + return feedGroupTable.getSubscriptionIdsFor(groupId) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + } + + fun updateSubscriptionsForGroup(groupId: Long, subscriptionIds: List): Completable { + return Completable.fromCallable { feedGroupTable.updateSubscriptionsForGroup(groupId, subscriptionIds) } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + } + + fun createGroup(name: String, icon: FeedGroupIcon): Maybe { + return Maybe.fromCallable { feedGroupTable.insert(FeedGroupEntity(0, name, icon)) } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + } + + fun getGroup(groupId: Long): Maybe { + return feedGroupTable.getGroup(groupId) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + } + + fun updateGroup(feedGroupEntity: FeedGroupEntity): Completable { + return Completable.fromCallable { feedGroupTable.update(feedGroupEntity) } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + } + + fun deleteGroup(groupId: Long): Completable { + return Completable.fromCallable { feedGroupTable.delete(groupId) } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.java b/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.java deleted file mode 100644 index 04406c3da..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.java +++ /dev/null @@ -1,444 +0,0 @@ -package org.schabi.newpipe.local.feed; - -import android.os.Bundle; -import android.os.Handler; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.ActionBar; -import android.util.Log; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.View; -import android.view.ViewGroup; - -import org.reactivestreams.Subscriber; -import org.reactivestreams.Subscription; -import org.schabi.newpipe.R; -import org.schabi.newpipe.database.subscription.SubscriptionEntity; -import org.schabi.newpipe.extractor.InfoItem; -import org.schabi.newpipe.extractor.NewPipe; -import org.schabi.newpipe.extractor.channel.ChannelInfo; -import org.schabi.newpipe.extractor.exceptions.ExtractionException; -import org.schabi.newpipe.fragments.list.BaseListFragment; -import org.schabi.newpipe.local.subscription.SubscriptionService; -import org.schabi.newpipe.report.UserAction; - -import java.util.Collections; -import java.util.HashSet; -import java.util.List; -import java.util.Queue; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; - -import io.reactivex.Flowable; -import io.reactivex.MaybeObserver; -import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.disposables.CompositeDisposable; -import io.reactivex.disposables.Disposable; - -public class FeedFragment extends BaseListFragment, Void> { - - private static final int OFF_SCREEN_ITEMS_COUNT = 3; - private static final int MIN_ITEMS_INITIAL_LOAD = 8; - private int FEED_LOAD_COUNT = MIN_ITEMS_INITIAL_LOAD; - - private int subscriptionPoolSize; - - private SubscriptionService subscriptionService; - - private AtomicBoolean allItemsLoaded = new AtomicBoolean(false); - private HashSet itemsLoaded = new HashSet<>(); - private final AtomicInteger requestLoadedAtomic = new AtomicInteger(); - - private CompositeDisposable compositeDisposable = new CompositeDisposable(); - private Disposable subscriptionObserver; - private Subscription feedSubscriber; - - /*////////////////////////////////////////////////////////////////////////// - // Fragment LifeCycle - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - subscriptionService = SubscriptionService.getInstance(activity); - - FEED_LOAD_COUNT = howManyItemsToLoad(); - } - - @Override - public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState) { - - if(!useAsFrontPage) { - setTitle(activity.getString(R.string.fragment_whats_new)); - } - return inflater.inflate(R.layout.fragment_feed, container, false); - } - - @Override - public void onPause() { - super.onPause(); - disposeEverything(); - } - - @Override - public void onResume() { - super.onResume(); - if (wasLoading.get()) doInitialLoadLogic(); - } - - @Override - public void onDestroy() { - super.onDestroy(); - - disposeEverything(); - subscriptionService = null; - compositeDisposable = null; - subscriptionObserver = null; - feedSubscriber = null; - } - - @Override - public void onDestroyView() { - // Do not monitor for updates when user is not viewing the feed fragment. - // This is a waste of bandwidth. - disposeEverything(); - super.onDestroyView(); - } - - @Override - public void setUserVisibleHint(boolean isVisibleToUser) { - super.setUserVisibleHint(isVisibleToUser); - if (activity != null && isVisibleToUser) { - setTitle(activity.getString(R.string.fragment_whats_new)); - } - } - - @Override - public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { - super.onCreateOptionsMenu(menu, inflater); - - ActionBar supportActionBar = activity.getSupportActionBar(); - - if(useAsFrontPage) { - supportActionBar.setDisplayShowTitleEnabled(true); - //supportActionBar.setDisplayShowTitleEnabled(false); - } - } - - @Override - public void reloadContent() { - resetFragment(); - super.reloadContent(); - } - - /*////////////////////////////////////////////////////////////////////////// - // StateSaving - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void writeTo(Queue objectsToSave) { - super.writeTo(objectsToSave); - objectsToSave.add(allItemsLoaded); - objectsToSave.add(itemsLoaded); - } - - @Override - @SuppressWarnings("unchecked") - public void readFrom(@NonNull Queue savedObjects) throws Exception { - super.readFrom(savedObjects); - allItemsLoaded = (AtomicBoolean) savedObjects.poll(); - itemsLoaded = (HashSet) savedObjects.poll(); - } - - /*////////////////////////////////////////////////////////////////////////// - // Feed Loader - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void startLoading(boolean forceLoad) { - if (DEBUG) Log.d(TAG, "startLoading() called with: forceLoad = [" + forceLoad + "]"); - if (subscriptionObserver != null) subscriptionObserver.dispose(); - - if (allItemsLoaded.get()) { - if (infoListAdapter.getItemsList().size() == 0) { - showEmptyState(); - } else { - showListFooter(false); - hideLoading(); - } - - isLoading.set(false); - return; - } - - isLoading.set(true); - showLoading(); - showListFooter(true); - subscriptionObserver = subscriptionService.getSubscription() - .onErrorReturnItem(Collections.emptyList()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(this::handleResult, this::onError); - } - - @Override - public void handleResult(@androidx.annotation.NonNull List result) { - super.handleResult(result); - - if (result.isEmpty()) { - infoListAdapter.clearStreamItemList(); - showEmptyState(); - return; - } - - subscriptionPoolSize = result.size(); - Flowable.fromIterable(result) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(getSubscriptionObserver()); - } - - /** - * Responsible for reacting to user pulling request and starting a request for new feed stream. - *

- * On initialization, it automatically requests the amount of feed needed to display - * a minimum amount required (FEED_LOAD_SIZE). - *

- * Upon receiving a user pull, it creates a Single Observer to fetch the ChannelInfo - * containing the feed streams. - **/ - private Subscriber getSubscriptionObserver() { - return new Subscriber() { - @Override - public void onSubscribe(Subscription s) { - if (feedSubscriber != null) feedSubscriber.cancel(); - feedSubscriber = s; - - int requestSize = FEED_LOAD_COUNT - infoListAdapter.getItemsList().size(); - if (wasLoading.getAndSet(false)) requestSize = FEED_LOAD_COUNT; - - boolean hasToLoad = requestSize > 0; - if (hasToLoad) { - requestLoadedAtomic.set(infoListAdapter.getItemsList().size()); - requestFeed(requestSize); - } - isLoading.set(hasToLoad); - } - - @Override - public void onNext(SubscriptionEntity subscriptionEntity) { - if (!itemsLoaded.contains(subscriptionEntity.getServiceId() + subscriptionEntity.getUrl())) { - subscriptionService.getChannelInfo(subscriptionEntity) - .observeOn(AndroidSchedulers.mainThread()) - .onErrorComplete( - (@io.reactivex.annotations.NonNull Throwable throwable) -> - FeedFragment.super.onError(throwable)) - .subscribe( - getChannelInfoObserver(subscriptionEntity.getServiceId(), - subscriptionEntity.getUrl())); - } else { - requestFeed(1); - } - } - - @Override - public void onError(Throwable exception) { - FeedFragment.this.onError(exception); - } - - @Override - public void onComplete() { - if (DEBUG) Log.d(TAG, "getSubscriptionObserver > onComplete() called"); - } - }; - } - - /** - * On each request, a subscription item from the updated table is transformed - * into a ChannelInfo, containing the latest streams from the channel. - *

- * Currently, the feed uses the first into from the list of streams. - *

- * If chosen feed already displayed, then we request another feed from another - * subscription, until the subscription table runs out of new items. - *

- * This Observer is self-contained and will close itself when complete. However, this - * does not obey the fragment lifecycle and may continue running in the background - * until it is complete. This is done due to RxJava2 no longer propagate errors once - * an observer is unsubscribed while the thread process is still running. - *

- * To solve the above issue, we can either set a global RxJava Error Handler, or - * manage exceptions case by case. This should be done if the current implementation is - * too costly when dealing with larger subscription sets. - * - * @param url + serviceId to put in {@link #allItemsLoaded} to signal that this specific entity has been loaded. - */ - private MaybeObserver getChannelInfoObserver(final int serviceId, final String url) { - return new MaybeObserver() { - private Disposable observer; - - @Override - public void onSubscribe(Disposable d) { - observer = d; - compositeDisposable.add(d); - isLoading.set(true); - } - - // Called only when response is non-empty - @Override - public void onSuccess(final ChannelInfo channelInfo) { - if (infoListAdapter == null || channelInfo.getRelatedItems().isEmpty()) { - onDone(); - return; - } - - final InfoItem item = channelInfo.getRelatedItems().get(0); - // Keep requesting new items if the current one already exists - boolean itemExists = doesItemExist(infoListAdapter.getItemsList(), item); - if (!itemExists) { - infoListAdapter.addInfoItem(item); - //updateSubscription(channelInfo); - } else { - requestFeed(1); - } - onDone(); - } - - @Override - public void onError(Throwable exception) { - showSnackBarError(exception, - UserAction.SUBSCRIPTION, - NewPipe.getNameOfService(serviceId), - url, 0); - requestFeed(1); - onDone(); - } - - // Called only when response is empty - @Override - public void onComplete() { - onDone(); - } - - private void onDone() { - if (observer.isDisposed()) { - return; - } - - itemsLoaded.add(serviceId + url); - compositeDisposable.remove(observer); - - int loaded = requestLoadedAtomic.incrementAndGet(); - if (loaded >= Math.min(FEED_LOAD_COUNT, subscriptionPoolSize)) { - requestLoadedAtomic.set(0); - isLoading.set(false); - } - - if (itemsLoaded.size() == subscriptionPoolSize) { - if (DEBUG) Log.d(TAG, "getChannelInfoObserver > All Items Loaded"); - allItemsLoaded.set(true); - showListFooter(false); - isLoading.set(false); - hideLoading(); - if (infoListAdapter.getItemsList().size() == 0) { - showEmptyState(); - } - } - } - }; - } - - @Override - protected void loadMoreItems() { - isLoading.set(true); - delayHandler.removeCallbacksAndMessages(null); - // Add a little of a delay when requesting more items because the cache is so fast, - // that the view seems stuck to the user when he scroll to the bottom - delayHandler.postDelayed(() -> requestFeed(FEED_LOAD_COUNT), 300); - } - - @Override - protected boolean hasMoreItems() { - return !allItemsLoaded.get(); - } - - private final Handler delayHandler = new Handler(); - - private void requestFeed(final int count) { - if (DEBUG) Log.d(TAG, "requestFeed() called with: count = [" + count + "], feedSubscriber = [" + feedSubscriber + "]"); - if (feedSubscriber == null) return; - - isLoading.set(true); - delayHandler.removeCallbacksAndMessages(null); - feedSubscriber.request(count); - } - - /*////////////////////////////////////////////////////////////////////////// - // Utils - //////////////////////////////////////////////////////////////////////////*/ - - private void resetFragment() { - if (DEBUG) Log.d(TAG, "resetFragment() called"); - if (subscriptionObserver != null) subscriptionObserver.dispose(); - if (compositeDisposable != null) compositeDisposable.clear(); - if (infoListAdapter != null) infoListAdapter.clearStreamItemList(); - - delayHandler.removeCallbacksAndMessages(null); - requestLoadedAtomic.set(0); - allItemsLoaded.set(false); - showListFooter(false); - itemsLoaded.clear(); - } - - private void disposeEverything() { - if (subscriptionObserver != null) subscriptionObserver.dispose(); - if (compositeDisposable != null) compositeDisposable.clear(); - if (feedSubscriber != null) feedSubscriber.cancel(); - delayHandler.removeCallbacksAndMessages(null); - } - - private boolean doesItemExist(final List items, final InfoItem item) { - for (final InfoItem existingItem : items) { - if (existingItem.getInfoType() == item.getInfoType() && - existingItem.getServiceId() == item.getServiceId() && - existingItem.getName().equals(item.getName()) && - existingItem.getUrl().equals(item.getUrl())) return true; - } - return false; - } - - private int howManyItemsToLoad() { - int heightPixels = getResources().getDisplayMetrics().heightPixels; - int itemHeightPixels = activity.getResources().getDimensionPixelSize(R.dimen.video_item_search_height); - - int items = itemHeightPixels > 0 - ? heightPixels / itemHeightPixels + OFF_SCREEN_ITEMS_COUNT - : MIN_ITEMS_INITIAL_LOAD; - return Math.max(MIN_ITEMS_INITIAL_LOAD, items); - } - - /*////////////////////////////////////////////////////////////////////////// - // Fragment Error Handling - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void showError(String message, boolean showRetryButton) { - resetFragment(); - super.showError(message, showRetryButton); - } - - @Override - protected boolean onError(Throwable exception) { - if (super.onError(exception)) return true; - - int errorId = exception instanceof ExtractionException - ? R.string.parsing_error - : R.string.general_error; - onUnrecoverableError(exception, - UserAction.SOMETHING_ELSE, - "none", - "Requesting feed", - errorId); - return true; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt new file mode 100644 index 000000000..64e4f2699 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt @@ -0,0 +1,288 @@ +/* + * Copyright 2019 Mauricio Colli + * FeedFragment.kt is part of NewPipe + * + * License: GPL-3.0+ + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.schabi.newpipe.local.feed + +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.os.Parcelable +import android.view.* +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProviders +import icepick.State +import io.reactivex.Completable +import io.reactivex.schedulers.Schedulers +import kotlinx.android.synthetic.main.error_retry.* +import kotlinx.android.synthetic.main.fragment_feed.* +import org.schabi.newpipe.R +import org.schabi.newpipe.fragments.list.BaseListFragment +import org.schabi.newpipe.local.feed.service.FeedLoadService +import org.schabi.newpipe.report.UserAction +import org.schabi.newpipe.util.AnimationUtils.animateView +import org.schabi.newpipe.util.Localization + +class FeedFragment : BaseListFragment() { + private lateinit var viewModel: FeedViewModel + private lateinit var feedDatabaseManager: FeedDatabaseManager + @State @JvmField var listState: Parcelable? = null + + private var groupId = -1L + private var groupName = "" + + init { + setHasOptionsMenu(true) + useDefaultStateSaving(false) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + groupId = arguments?.getLong(KEY_GROUP_ID, -1) ?: -1 + groupName = arguments?.getString(KEY_GROUP_NAME) ?: "" + + feedDatabaseManager = FeedDatabaseManager(requireContext()) + if (feedDatabaseManager.getLastUpdated(requireContext()) == null) { + triggerUpdate() + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.fragment_feed, container, false) + } + + override fun onViewCreated(rootView: View, savedInstanceState: Bundle?) { + super.onViewCreated(rootView, savedInstanceState) + + viewModel = ViewModelProviders.of(this, FeedViewModel.Factory(requireContext(), groupId)).get(FeedViewModel::class.java) + viewModel.stateLiveData.observe(viewLifecycleOwner, Observer { it?.let(::handleResult) }) + } + + override fun onPause() { + super.onPause() + listState = items_list?.layoutManager?.onSaveInstanceState() + } + + override fun onResume() { + super.onResume() + updateRelativeTimeViews() + } + + override fun setUserVisibleHint(isVisibleToUser: Boolean) { + super.setUserVisibleHint(isVisibleToUser) + + if (!isVisibleToUser && view != null) { + updateRelativeTimeViews() + } + } + + override fun initListeners() { + super.initListeners() + refresh_root_view.setOnClickListener { + triggerUpdate() + } + } + + /////////////////////////////////////////////////////////////////////////// + // Menu + /////////////////////////////////////////////////////////////////////////// + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + super.onCreateOptionsMenu(menu, inflater) + activity.supportActionBar?.setTitle(R.string.fragment_whats_new) + activity.supportActionBar?.subtitle = groupName + } + + override fun onDestroyOptionsMenu() { + super.onDestroyOptionsMenu() + activity.supportActionBar?.subtitle = null + } + + /////////////////////////////////////////////////////////////////////////// + // Handling + /////////////////////////////////////////////////////////////////////////// + + override fun showLoading() { + animateView(refresh_root_view, false, 0) + animateView(items_list, false, 0) + + animateView(loading_progress_bar, true, 200) + animateView(loading_progress_text, true, 200) + + empty_state_view?.let { animateView(it, false, 0) } + animateView(error_panel, false, 0) + } + + override fun hideLoading() { + animateView(refresh_root_view, true, 200) + animateView(items_list, true, 300) + + animateView(loading_progress_bar, false, 0) + animateView(loading_progress_text, false, 0) + + empty_state_view?.let { animateView(it, false, 0) } + animateView(error_panel, false, 0) + } + + override fun showEmptyState() { + animateView(refresh_root_view, true, 200) + animateView(items_list, false, 0) + + animateView(loading_progress_bar, false, 0) + animateView(loading_progress_text, false, 0) + + empty_state_view?.let { animateView(it, true, 800) } + animateView(error_panel, false, 0) + } + + override fun showError(message: String, showRetryButton: Boolean) { + infoListAdapter.clearStreamItemList() + animateView(refresh_root_view, false, 120) + animateView(items_list, false, 120) + + animateView(loading_progress_bar, false, 120) + animateView(loading_progress_text, false, 120) + + error_message_view.text = message + animateView(error_button_retry, showRetryButton, if (showRetryButton) 600 else 0) + animateView(error_panel, true, 300) + } + + override fun handleResult(result: FeedState) { + when (result) { + is FeedState.ProgressState -> handleProgressState(result) + is FeedState.LoadedState -> handleLoadedState(result) + is FeedState.ErrorState -> if (handleErrorState(result)) return + } + + updateRefreshViewState() + } + + private fun handleProgressState(progressState: FeedState.ProgressState) { + showLoading() + + val isIndeterminate = progressState.currentProgress == -1 && + progressState.maxProgress == -1 + + if (!isIndeterminate) { + loading_progress_text.text = "${progressState.currentProgress}/${progressState.maxProgress}" + } else if (progressState.progressMessage > 0) { + loading_progress_text?.setText(progressState.progressMessage) + } else { + loading_progress_text?.text = "∞/∞" + } + + loading_progress_bar.isIndeterminate = isIndeterminate || + (progressState.maxProgress > 0 && progressState.currentProgress == 0) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + loading_progress_bar?.setProgress(progressState.currentProgress, true) + } else { + loading_progress_bar.progress = progressState.currentProgress + } + + loading_progress_bar.max = progressState.maxProgress + } + + private fun handleLoadedState(loadedState: FeedState.LoadedState) { + infoListAdapter.setInfoItemList(loadedState.items) + listState?.run { + items_list.layoutManager?.onRestoreInstanceState(listState) + listState = null + } + + if (!loadedState.itemsErrors.isEmpty()) { + showSnackBarError(loadedState.itemsErrors, UserAction.REQUESTED_FEED, + "none", "Loading feed", R.string.general_error); + } + + if (loadedState.items.isEmpty()) { + showEmptyState() + } else { + hideLoading() + } + } + + + private fun handleErrorState(errorState: FeedState.ErrorState): Boolean { + hideLoading() + errorState.error?.let { + onError(errorState.error) + return true + } + return false + } + + private fun updateRelativeTimeViews() { + updateRefreshViewState() + infoListAdapter.notifyDataSetChanged() + } + + private fun updateRefreshViewState() { + val lastUpdated = feedDatabaseManager.getLastUpdated(requireContext()) + val updatedAt = when { + lastUpdated != null -> Localization.relativeTime(lastUpdated) + else -> "—" + } + + refresh_text?.text = getString(R.string.feed_last_updated, updatedAt) + } + + /////////////////////////////////////////////////////////////////////////// + // Load Service Handling + /////////////////////////////////////////////////////////////////////////// + + override fun doInitialLoadLogic() {} + override fun reloadContent() = triggerUpdate() + override fun loadMoreItems() {} + override fun hasMoreItems() = false + + private fun triggerUpdate() { + getActivity()?.startService(Intent(requireContext(), FeedLoadService::class.java)) + listState = null + } + + override fun onError(exception: Throwable): Boolean { + if (super.onError(exception)) return true + + if (useAsFrontPage) { + showSnackBarError(exception, UserAction.REQUESTED_FEED, "none", "Loading Feed", 0) + return true + } + + onUnrecoverableError(exception, UserAction.REQUESTED_FEED, "none", "Loading Feed", 0) + return true + } + + companion object { + const val KEY_GROUP_ID = "ARG_GROUP_ID" + const val KEY_GROUP_NAME = "ARG_GROUP_NAME" + + @JvmStatic + fun newInstance(groupId: Long = -1, groupName: String? = null): FeedFragment { + val feedFragment = FeedFragment() + + feedFragment.arguments = Bundle().apply { + putLong(KEY_GROUP_ID, groupId) + putString(KEY_GROUP_NAME, groupName) + } + + return feedFragment + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedState.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedState.kt new file mode 100644 index 000000000..1329d1ea4 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedState.kt @@ -0,0 +1,11 @@ +package org.schabi.newpipe.local.feed + +import androidx.annotation.StringRes +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import java.util.* + +sealed class FeedState { + data class ProgressState(val currentProgress: Int = -1, val maxProgress: Int = -1, @StringRes val progressMessage: Int = 0) : FeedState() + data class LoadedState(val lastUpdated: Calendar? = null, val items: List, var itemsErrors: List = emptyList()) : FeedState() + data class ErrorState(val error: Throwable? = null) : FeedState() +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt new file mode 100644 index 000000000..fa6e6bcfe --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt @@ -0,0 +1,66 @@ +package org.schabi.newpipe.local.feed + +import android.content.Context +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import io.reactivex.Flowable +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.functions.Function3 +import io.reactivex.schedulers.Schedulers +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.local.feed.service.FeedEventManager +import org.schabi.newpipe.local.subscription.SubscriptionManager +import org.schabi.newpipe.util.DEFAULT_THROTTLE_TIMEOUT +import java.util.concurrent.TimeUnit + +class FeedViewModel(applicationContext: Context, val groupId: Long = -1) : ViewModel() { + class Factory(val context: Context, val groupId: Long = -1) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return FeedViewModel(context.applicationContext, groupId) as T + } + } + + private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(applicationContext) + private var subscriptionManager: SubscriptionManager = SubscriptionManager(applicationContext) + + val stateLiveData = MutableLiveData() + + private var combineDisposable = Flowable + .combineLatest( + FeedEventManager.events(), + feedDatabaseManager.asStreamItems(groupId), + subscriptionManager.subscriptionTable().rowCount(), + + Function3 { t1: FeedEventManager.Event, t2: List, t3: Long -> return@Function3 Triple(first = t1, second = t2, third = t3) } + ) + .throttleLatest(DEFAULT_THROTTLE_TIMEOUT, TimeUnit.MILLISECONDS) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { + val (event, listFromDB, subsCount) = it + + var lastUpdated = feedDatabaseManager.getLastUpdated(applicationContext) + if (subsCount == 0L && lastUpdated != null) { + feedDatabaseManager.setLastUpdated(applicationContext, null) + lastUpdated = null + } + + stateLiveData.postValue(when (event) { + is FeedEventManager.Event.IdleEvent -> FeedState.LoadedState(lastUpdated, listFromDB) + is FeedEventManager.Event.ProgressEvent -> FeedState.ProgressState(event.currentProgress, event.maxProgress, event.progressMessage) + is FeedEventManager.Event.SuccessResultEvent -> FeedState.LoadedState(lastUpdated, listFromDB, event.itemsErrors) + is FeedEventManager.Event.ErrorResultEvent -> throw event.error + }) + + if (event is FeedEventManager.Event.ErrorResultEvent || event is FeedEventManager.Event.SuccessResultEvent) { + FeedEventManager.reset() + } + } + + override fun onCleared() { + super.onCleared() + combineDisposable.dispose() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedEventManager.kt b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedEventManager.kt new file mode 100644 index 000000000..e9012ff37 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedEventManager.kt @@ -0,0 +1,38 @@ +package org.schabi.newpipe.local.feed.service + +import androidx.annotation.StringRes +import io.reactivex.Flowable +import io.reactivex.processors.BehaviorProcessor +import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.IdleEvent +import java.util.concurrent.atomic.AtomicBoolean + +object FeedEventManager { + private var processor: BehaviorProcessor = BehaviorProcessor.create() + private var ignoreUpstream = AtomicBoolean() + private var eventsFlowable = processor.startWith(IdleEvent) + + fun postEvent(event: Event) { + processor.onNext(event) + } + + fun events(): Flowable { + return eventsFlowable.filter { !ignoreUpstream.get() } + } + + fun reset() { + ignoreUpstream.set(true) + postEvent(IdleEvent) + ignoreUpstream.set(false) + } + + sealed class Event { + object IdleEvent : Event() + data class ProgressEvent(val currentProgress: Int = -1, val maxProgress: Int = -1, @StringRes val progressMessage: Int = 0) : Event() { + constructor(@StringRes progressMessage: Int) : this(-1, -1, progressMessage) + } + + data class SuccessResultEvent(val itemsErrors: List = emptyList()) : Event() + data class ErrorResultEvent(val error: Throwable) : Event() + } + +} diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadService.kt b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadService.kt new file mode 100644 index 000000000..8f5e551da --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadService.kt @@ -0,0 +1,399 @@ +/* + * Copyright 2019 Mauricio Colli + * FeedLoadService.kt is part of NewPipe + * + * License: GPL-3.0+ + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.schabi.newpipe.local.feed.service + +import android.app.Service +import android.content.Intent +import android.os.Build +import android.os.IBinder +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import io.reactivex.Flowable +import io.reactivex.Notification +import io.reactivex.Single +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.CompositeDisposable +import io.reactivex.functions.Consumer +import io.reactivex.functions.Function +import io.reactivex.processors.PublishProcessor +import io.reactivex.schedulers.Schedulers +import org.reactivestreams.Subscriber +import org.reactivestreams.Subscription +import org.schabi.newpipe.MainActivity.DEBUG +import org.schabi.newpipe.R +import org.schabi.newpipe.extractor.channel.ChannelInfo +import org.schabi.newpipe.extractor.exceptions.ReCaptchaException +import org.schabi.newpipe.local.feed.FeedDatabaseManager +import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.* +import org.schabi.newpipe.local.feed.service.FeedEventManager.postEvent +import org.schabi.newpipe.local.subscription.SubscriptionManager +import org.schabi.newpipe.util.ExtractorHelper +import java.io.IOException +import java.util.* +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import kotlin.collections.ArrayList + +class FeedLoadService : Service() { + companion object { + private val TAG = FeedLoadService::class.java.simpleName + private const val NOTIFICATION_ID = 7293450 + + /** + * How often the notification will be updated. + */ + private const val NOTIFICATION_SAMPLING_PERIOD = 1500 + + /** + * How many extractions will be running in parallel. + */ + private const val PARALLEL_EXTRACTIONS = 6 + + /** + * Number of items to buffer to mass-insert in the database. + */ + private const val BUFFER_COUNT_BEFORE_INSERT = 20 + } + + private var loadingSubscription: Subscription? = null + private lateinit var subscriptionManager: SubscriptionManager + + private lateinit var feedDatabaseManager: FeedDatabaseManager + private lateinit var feedResultsHolder: ResultsHolder + + private var disposables = CompositeDisposable() + private var notificationUpdater = PublishProcessor.create() + + /////////////////////////////////////////////////////////////////////////// + // Lifecycle + /////////////////////////////////////////////////////////////////////////// + + override fun onCreate() { + super.onCreate() + subscriptionManager = SubscriptionManager(this) + feedDatabaseManager = FeedDatabaseManager(this) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (DEBUG) { + Log.d(TAG, "onStartCommand() called with: intent = [" + intent + "]," + + " flags = [" + flags + "], startId = [" + startId + "]") + } + + if (intent == null || loadingSubscription != null) { + return START_NOT_STICKY + } + + setupNotification() + startLoading() + return START_NOT_STICKY + } + + private fun disposeAll() { + loadingSubscription?.cancel() + loadingSubscription = null + + disposables.dispose() + } + + private fun stopService() { + disposeAll() + stopForeground(true) + notificationManager.cancel(NOTIFICATION_ID) + stopSelf() + } + + override fun onBind(intent: Intent): IBinder? { + return null + } + + /////////////////////////////////////////////////////////////////////////// + // Loading & Handling + /////////////////////////////////////////////////////////////////////////// + + private class RequestException(message: String, cause: Throwable) : Exception(message, cause) { + companion object { + fun wrapList(info: ChannelInfo): List { + val toReturn = ArrayList(info.errors.size) + for (error in info.errors) { + toReturn.add(RequestException(info.serviceId.toString() + ":" + info.url, error)) + } + return toReturn + } + } + } + + private fun startLoading() { + feedResultsHolder = ResultsHolder() + + subscriptionManager + .subscriptions() + .limit(1) + + .doOnNext { + currentProgress.set(0) + maxProgress.set(it.size) + } + .filter { it.isNotEmpty() } + + .observeOn(AndroidSchedulers.mainThread()) + .doOnNext { + startForeground(NOTIFICATION_ID, notificationBuilder.build()) + updateNotificationProgress(null) + broadcastProgress() + } + + .observeOn(Schedulers.io()) + .flatMap { Flowable.fromIterable(it) } + + .parallel(PARALLEL_EXTRACTIONS) + .runOn(Schedulers.io()) + .map { subscriptionEntity -> + try { + val channelInfo = ExtractorHelper + .getChannelInfo(subscriptionEntity.serviceId, subscriptionEntity.url, true) + .blockingGet() + return@map Notification.createOnNext(Pair(subscriptionEntity.uid, channelInfo)) + } catch (e: Throwable) { + val request = "${subscriptionEntity.serviceId}:${subscriptionEntity.url}" + val wrapper = RequestException(request, e) + return@map Notification.createOnError>(wrapper) + } + } + .sequential() + + .observeOn(AndroidSchedulers.mainThread()) + .doOnNext(errorHandlingConsumer) + + .observeOn(AndroidSchedulers.mainThread()) + .doOnNext(notificationsConsumer) + + .observeOn(Schedulers.io()) + .buffer(BUFFER_COUNT_BEFORE_INSERT) + .doOnNext(databaseConsumer) + + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(resultSubscriber) + } + + private fun broadcastProgress() { + postEvent(ProgressEvent(currentProgress.get(), maxProgress.get())) + } + + private val resultSubscriber + get() = object : Subscriber>>> { + + override fun onSubscribe(s: Subscription) { + loadingSubscription = s + s.request(java.lang.Long.MAX_VALUE) + } + + override fun onNext(notification: List>>) { + if (DEBUG) Log.v(TAG, "onNext() → $notification") + } + + override fun onError(error: Throwable) { + handleError(error) + } + + override fun onComplete() { + if (maxProgress.get() == 0) { + postEvent(IdleEvent) + stopService() + + return + } + + currentProgress.set(-1) + maxProgress.set(-1) + + notificationUpdater.onNext(getString(R.string.feed_processing_message)) + postEvent(ProgressEvent(R.string.feed_processing_message)) + + disposables.add(Single + .fromCallable { + feedResultsHolder.ready() + + postEvent(ProgressEvent(R.string.feed_processing_message)) + feedDatabaseManager.removeOrphansOrOlderStreams() + feedDatabaseManager.setLastUpdated(this@FeedLoadService, feedResultsHolder.lastUpdated) + + postEvent(SuccessResultEvent(feedResultsHolder.itemsErrors)) + true + } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { _, throwable -> + if (throwable != null) { + Log.e(TAG, "Error while storing result", throwable) + handleError(throwable) + return@subscribe + } + stopService() + }) + } + } + + private val databaseConsumer: Consumer>>> + get() = Consumer { + feedDatabaseManager.database().runInTransaction { + for (notification in it) { + + if (notification.isOnNext) { + val subscriptionId = notification.value!!.first + val info = notification.value!!.second + + feedDatabaseManager.upsertAll(subscriptionId, info.relatedItems) + subscriptionManager.updateFromInfo(subscriptionId, info) + + if (info.errors.isNotEmpty()) { + feedResultsHolder.addErrors(RequestException.wrapList(info)) + } + + } else if (notification.isOnError) { + feedResultsHolder.addError(notification.error!!) + } + } + } + } + + private val errorHandlingConsumer: Consumer>> + get() = Consumer { + if (it.isOnError) { + var error = it.error!! + if (error is RequestException) error = error.cause!! + val cause = error.cause + + when { + error is IOException -> throw error + cause is IOException -> throw cause + + error is ReCaptchaException -> throw error + cause is ReCaptchaException -> throw cause + } + } + } + + private val notificationsConsumer: Consumer>> + get() = Consumer { onItemCompleted(it.value?.second?.name) } + + private fun onItemCompleted(updateDescription: String?) { + currentProgress.incrementAndGet() + notificationUpdater.onNext(updateDescription ?: "") + + broadcastProgress() + } + + /////////////////////////////////////////////////////////////////////////// + // Notification + /////////////////////////////////////////////////////////////////////////// + + private lateinit var notificationManager: NotificationManagerCompat + private lateinit var notificationBuilder: NotificationCompat.Builder + + private var currentProgress = AtomicInteger(-1) + private var maxProgress = AtomicInteger(-1) + + private fun createNotification(): NotificationCompat.Builder { + return NotificationCompat.Builder(this, getString(R.string.notification_channel_id)) + .setOngoing(true) + .setProgress(-1, -1, true) + .setSmallIcon(R.drawable.ic_newpipe_triangle_white) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setContentTitle(getString(R.string.feed_notification_loading)) + } + + private fun setupNotification() { + notificationManager = NotificationManagerCompat.from(this) + notificationBuilder = createNotification() + + val throttleAfterFirstEmission = Function { flow: Flowable -> + flow.limit(1).concatWith(flow.skip(1).throttleLatest(NOTIFICATION_SAMPLING_PERIOD.toLong(), TimeUnit.MILLISECONDS)) + } + + disposables.add(notificationUpdater + .publish(throttleAfterFirstEmission) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(this::updateNotificationProgress)) + } + + private fun updateNotificationProgress(updateDescription: String?) { + notificationBuilder.setProgress(maxProgress.get(), currentProgress.get(), maxProgress.get() == -1) + + if (maxProgress.get() == -1) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) notificationBuilder.setContentInfo(null) + if (!updateDescription.isNullOrEmpty()) notificationBuilder.setContentText(updateDescription) + notificationBuilder.setContentText(updateDescription) + } else { + val progressText = this.currentProgress.toString() + "/" + maxProgress + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + if (!updateDescription.isNullOrEmpty()) notificationBuilder.setContentText("$updateDescription ($progressText)") + } else { + notificationBuilder.setContentInfo(progressText) + if (!updateDescription.isNullOrEmpty()) notificationBuilder.setContentText(updateDescription) + } + } + + notificationManager.notify(NOTIFICATION_ID, notificationBuilder.build()) + } + + /////////////////////////////////////////////////////////////////////////// + // Error handling + /////////////////////////////////////////////////////////////////////////// + + private fun handleError(error: Throwable) { + postEvent(ErrorResultEvent(error)) + stopService() + } + + /////////////////////////////////////////////////////////////////////////// + // Results Holder + /////////////////////////////////////////////////////////////////////////// + + class ResultsHolder { + /** + * The time the items have been loaded. + */ + internal lateinit var lastUpdated: Calendar + + /** + * List of errors that may have happen during loading. + */ + internal lateinit var itemsErrors: List + + private val itemsErrorsHolder: MutableList = ArrayList() + + fun addError(error: Throwable) { + itemsErrorsHolder.add(error) + } + + fun addErrors(errors: List) { + itemsErrorsHolder.addAll(errors) + } + + fun ready() { + itemsErrors = itemsErrorsHolder.toList() + lastUpdated = Calendar.getInstance() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/FeedGroupIcon.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/FeedGroupIcon.kt new file mode 100644 index 000000000..ce7659ef0 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/FeedGroupIcon.kt @@ -0,0 +1,62 @@ +package org.schabi.newpipe.local.subscription + +import android.content.Context +import androidx.annotation.AttrRes +import androidx.annotation.DrawableRes +import org.schabi.newpipe.R +import org.schabi.newpipe.util.ThemeHelper + +enum class FeedGroupIcon( + /** + * The id that will be used to store and retrieve icons from some persistent storage (e.g. DB). + */ + val id: Int, + + /** + * The attribute that points to a drawable resource. "R.attr" is used here to support multiple themes. + */ + @AttrRes val drawableResourceAttr: Int +) { + ALL(0, R.attr.ic_asterisk), + MUSIC(1, R.attr.ic_music_note), + EDUCATION(2, R.attr.ic_school), + FITNESS(3, R.attr.ic_fitness), + SPACE(4, R.attr.ic_telescope), + COMPUTER(5, R.attr.ic_computer), + GAMING(6, R.attr.ic_videogame), + SPORTS(7, R.attr.ic_sports), + NEWS(8, R.attr.ic_megaphone), + FAVORITES(9, R.attr.ic_heart), + CAR(10, R.attr.ic_car), + MOTORCYCLE(11, R.attr.ic_motorcycle), + TREND(12, R.attr.ic_trending_up), + MOVIE(13, R.attr.ic_movie), + BACKUP(14, R.attr.ic_backup), + ART(15, R.attr.palette), + PERSON(16, R.attr.ic_person), + PEOPLE(17, R.attr.ic_people), + MONEY(18, R.attr.ic_money), + KIDS(19, R.attr.ic_kids), + FOOD(20, R.attr.ic_fastfood), + SMILE(21, R.attr.ic_smile), + EXPLORE(22, R.attr.ic_explore), + RESTAURANT(23, R.attr.ic_restaurant), + MIC(24, R.attr.ic_mic), + HEADSET(25, R.attr.audio), + RADIO(26, R.attr.ic_radio), + SHOPPING_CART(27, R.attr.ic_shopping_cart), + WATCH_LATER(28, R.attr.ic_watch_later), + WORK(29, R.attr.ic_work), + HOT(30, R.attr.ic_hot), + CHANNEL(31, R.attr.ic_channel), + BOOKMARK(32, R.attr.ic_bookmark), + PETS(33, R.attr.ic_pets), + WORLD(34, R.attr.ic_world), + STAR(35, R.attr.ic_stars), + SUN(36, R.attr.ic_sunny); + + @DrawableRes + fun getDrawableRes(context: Context): Int { + return ThemeHelper.resolveResourceIdFromAttr(context, drawableResourceAttr) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.java b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.java deleted file mode 100644 index bff6c1b3a..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.java +++ /dev/null @@ -1,595 +0,0 @@ -package org.schabi.newpipe.local.subscription; - -import android.annotation.SuppressLint; -import android.app.Activity; -import android.app.AlertDialog; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.DialogInterface; -import android.content.Intent; -import android.content.IntentFilter; -import android.content.SharedPreferences; -import android.content.res.Configuration; -import android.content.res.Resources; -import android.graphics.Color; -import android.graphics.PorterDuff; -import android.os.Bundle; -import android.os.Environment; -import android.os.Parcelable; -import android.preference.PreferenceManager; -import androidx.annotation.DrawableRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.FragmentManager; -import androidx.localbroadcastmanager.content.LocalBroadcastManager; -import androidx.appcompat.app.ActionBar; -import androidx.recyclerview.widget.GridLayoutManager; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageView; -import android.widget.TextView; -import android.widget.Toast; - -import com.nononsenseapps.filepicker.Utils; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.database.subscription.SubscriptionEntity; -import org.schabi.newpipe.extractor.InfoItem; -import org.schabi.newpipe.extractor.NewPipe; -import org.schabi.newpipe.extractor.StreamingService; -import org.schabi.newpipe.extractor.channel.ChannelInfoItem; -import org.schabi.newpipe.extractor.exceptions.ExtractionException; -import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor; -import org.schabi.newpipe.fragments.BaseStateFragment; -import org.schabi.newpipe.info_list.InfoListAdapter; -import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService; -import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService; -import org.schabi.newpipe.report.UserAction; -import org.schabi.newpipe.util.FilePickerActivityHelper; -import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.OnClickGesture; -import org.schabi.newpipe.util.ServiceHelper; -import org.schabi.newpipe.util.ShareUtils; -import org.schabi.newpipe.util.ThemeHelper; -import org.schabi.newpipe.views.CollapsibleView; - -import java.io.File; -import java.text.SimpleDateFormat; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Date; -import java.util.List; -import java.util.Locale; - -import icepick.State; -import io.reactivex.Observer; -import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.disposables.CompositeDisposable; -import io.reactivex.disposables.Disposable; -import io.reactivex.schedulers.Schedulers; - -import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_MODE; -import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_VALUE; -import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.PREVIOUS_EXPORT_MODE; -import static org.schabi.newpipe.util.AnimationUtils.animateRotation; -import static org.schabi.newpipe.util.AnimationUtils.animateView; - -public class SubscriptionFragment extends BaseStateFragment> implements SharedPreferences.OnSharedPreferenceChangeListener { - private static final int REQUEST_EXPORT_CODE = 666; - private static final int REQUEST_IMPORT_CODE = 667; - - private RecyclerView itemsList; - @State - protected Parcelable itemsListState; - private InfoListAdapter infoListAdapter; - private int updateFlags = 0; - - private static final int LIST_MODE_UPDATE_FLAG = 0x32; - - private View whatsNewItemListHeader; - private View importExportListHeader; - - @State - protected Parcelable importExportOptionsState; - private CollapsibleView importExportOptions; - - private CompositeDisposable disposables = new CompositeDisposable(); - private SubscriptionService subscriptionService; - - /////////////////////////////////////////////////////////////////////////// - // Fragment LifeCycle - /////////////////////////////////////////////////////////////////////////// - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setHasOptionsMenu(true); - PreferenceManager.getDefaultSharedPreferences(activity) - .registerOnSharedPreferenceChangeListener(this); - } - - @Override - public void setUserVisibleHint(boolean isVisibleToUser) { - super.setUserVisibleHint(isVisibleToUser); - if (activity != null && isVisibleToUser) { - setTitle(activity.getString(R.string.tab_subscriptions)); - } - } - - @Override - public void onAttach(Context context) { - super.onAttach(context); - infoListAdapter = new InfoListAdapter(activity); - subscriptionService = SubscriptionService.getInstance(activity); - } - - @Override - public void onDetach() { - super.onDetach(); - } - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState) { - return inflater.inflate(R.layout.fragment_subscription, container, false); - } - - @Override - public void onResume() { - super.onResume(); - setupBroadcastReceiver(); - if (updateFlags != 0) { - if ((updateFlags & LIST_MODE_UPDATE_FLAG) != 0) { - final boolean useGrid = isGridLayout(); - itemsList.setLayoutManager(useGrid ? getGridLayoutManager() : getListLayoutManager()); - infoListAdapter.setGridItemVariants(useGrid); - infoListAdapter.notifyDataSetChanged(); - } - updateFlags = 0; - } - } - - @Override - public void onPause() { - super.onPause(); - itemsListState = itemsList.getLayoutManager().onSaveInstanceState(); - importExportOptionsState = importExportOptions.onSaveInstanceState(); - - if (subscriptionBroadcastReceiver != null && activity != null) { - LocalBroadcastManager.getInstance(activity).unregisterReceiver(subscriptionBroadcastReceiver); - } - } - - @Override - public void onDestroyView() { - if (disposables != null) disposables.clear(); - - super.onDestroyView(); - } - - @Override - public void onDestroy() { - if (disposables != null) disposables.dispose(); - disposables = null; - subscriptionService = null; - - PreferenceManager.getDefaultSharedPreferences(activity) - .unregisterOnSharedPreferenceChangeListener(this); - super.onDestroy(); - } - - protected RecyclerView.LayoutManager getListLayoutManager() { - return new LinearLayoutManager(activity); - } - - protected RecyclerView.LayoutManager getGridLayoutManager() { - final Resources resources = activity.getResources(); - int width = resources.getDimensionPixelSize(R.dimen.video_item_grid_thumbnail_image_width); - width += (24 * resources.getDisplayMetrics().density); - final int spanCount = (int) Math.floor(resources.getDisplayMetrics().widthPixels / (double)width); - final GridLayoutManager lm = new GridLayoutManager(activity, spanCount); - lm.setSpanSizeLookup(infoListAdapter.getSpanSizeLookup(spanCount)); - return lm; - } - - /*///////////////////////////////////////////////////////////////////////// - // Menu - /////////////////////////////////////////////////////////////////////////*/ - - @Override - public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { - super.onCreateOptionsMenu(menu, inflater); - - ActionBar supportActionBar = activity.getSupportActionBar(); - if (supportActionBar != null) { - supportActionBar.setDisplayShowTitleEnabled(true); - setTitle(getString(R.string.tab_subscriptions)); - } - } - - /*////////////////////////////////////////////////////////////////////////// - // Subscriptions import/export - //////////////////////////////////////////////////////////////////////////*/ - - private BroadcastReceiver subscriptionBroadcastReceiver; - - private void setupBroadcastReceiver() { - if (activity == null) return; - - if (subscriptionBroadcastReceiver != null) { - LocalBroadcastManager.getInstance(activity).unregisterReceiver(subscriptionBroadcastReceiver); - } - - final IntentFilter filters = new IntentFilter(); - filters.addAction(SubscriptionsExportService.EXPORT_COMPLETE_ACTION); - filters.addAction(SubscriptionsImportService.IMPORT_COMPLETE_ACTION); - subscriptionBroadcastReceiver = new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - if (importExportOptions != null) importExportOptions.collapse(); - } - }; - - LocalBroadcastManager.getInstance(activity).registerReceiver(subscriptionBroadcastReceiver, filters); - } - - private View addItemView(final String title, @DrawableRes final int icon, ViewGroup container) { - final View itemRoot = View.inflate(getContext(), R.layout.subscription_import_export_item, null); - final TextView titleView = itemRoot.findViewById(android.R.id.text1); - final ImageView iconView = itemRoot.findViewById(android.R.id.icon1); - - titleView.setText(title); - iconView.setImageResource(icon); - - container.addView(itemRoot); - return itemRoot; - } - - private void setupImportFromItems(final ViewGroup listHolder) { - final View previousBackupItem = addItemView(getString(R.string.previous_export), - ThemeHelper.resolveResourceIdFromAttr(getContext(), R.attr.ic_backup), listHolder); - previousBackupItem.setOnClickListener(item -> onImportPreviousSelected()); - - final int iconColor = ThemeHelper.isLightThemeSelected(getContext()) ? Color.BLACK : Color.WHITE; - final String[] services = getResources().getStringArray(R.array.service_list); - for (String serviceName : services) { - try { - final StreamingService service = NewPipe.getService(serviceName); - - final SubscriptionExtractor subscriptionExtractor = service.getSubscriptionExtractor(); - if (subscriptionExtractor == null) continue; - - final List supportedSources = subscriptionExtractor.getSupportedSources(); - if (supportedSources.isEmpty()) continue; - - final View itemView = addItemView(serviceName, ServiceHelper.getIcon(service.getServiceId()), listHolder); - final ImageView iconView = itemView.findViewById(android.R.id.icon1); - iconView.setColorFilter(iconColor, PorterDuff.Mode.SRC_IN); - - itemView.setOnClickListener(selectedItem -> onImportFromServiceSelected(service.getServiceId())); - } catch (ExtractionException e) { - throw new RuntimeException("Services array contains an entry that it's not a valid service name (" + serviceName + ")", e); - } - } - } - - private void setupExportToItems(final ViewGroup listHolder) { - final View previousBackupItem = addItemView(getString(R.string.file), ThemeHelper.resolveResourceIdFromAttr(getContext(), R.attr.ic_save), listHolder); - previousBackupItem.setOnClickListener(item -> onExportSelected()); - } - - private void onImportFromServiceSelected(int serviceId) { - FragmentManager fragmentManager = getFM(); - NavigationHelper.openSubscriptionsImportFragment(fragmentManager, serviceId); - } - - private void onImportPreviousSelected() { - startActivityForResult(FilePickerActivityHelper.chooseSingleFile(activity), REQUEST_IMPORT_CODE); - } - - private void onExportSelected() { - final String date = new SimpleDateFormat("yyyyMMddHHmm", Locale.ENGLISH).format(new Date()); - final String exportName = "newpipe_subscriptions_" + date + ".json"; - final File exportFile = new File(Environment.getExternalStorageDirectory(), exportName); - - startActivityForResult(FilePickerActivityHelper.chooseFileToSave(activity, exportFile.getAbsolutePath()), REQUEST_EXPORT_CODE); - } - - @Override - public void onActivityResult(int requestCode, int resultCode, Intent data) { - super.onActivityResult(requestCode, resultCode, data); - if (data != null && data.getData() != null && resultCode == Activity.RESULT_OK) { - if (requestCode == REQUEST_EXPORT_CODE) { - final File exportFile = Utils.getFileForUri(data.getData()); - if (!exportFile.getParentFile().canWrite() || !exportFile.getParentFile().canRead()) { - Toast.makeText(activity, R.string.invalid_directory, Toast.LENGTH_SHORT).show(); - } else { - activity.startService(new Intent(activity, SubscriptionsExportService.class) - .putExtra(SubscriptionsExportService.KEY_FILE_PATH, exportFile.getAbsolutePath())); - } - } else if (requestCode == REQUEST_IMPORT_CODE) { - final String path = Utils.getFileForUri(data.getData()).getAbsolutePath(); - ImportConfirmationDialog.show(this, new Intent(activity, SubscriptionsImportService.class) - .putExtra(KEY_MODE, PREVIOUS_EXPORT_MODE) - .putExtra(KEY_VALUE, path)); - } - } - } - /*///////////////////////////////////////////////////////////////////////// - // Fragment Views - /////////////////////////////////////////////////////////////////////////*/ - - @Override - protected void initViews(View rootView, Bundle savedInstanceState) { - super.initViews(rootView, savedInstanceState); - - final boolean useGrid = isGridLayout(); - infoListAdapter = new InfoListAdapter(getActivity()); - itemsList = rootView.findViewById(R.id.items_list); - itemsList.setLayoutManager(useGrid ? getGridLayoutManager() : getListLayoutManager()); - - View headerRootLayout; - infoListAdapter.setHeader(headerRootLayout = activity.getLayoutInflater().inflate(R.layout.subscription_header, itemsList, false)); - whatsNewItemListHeader = headerRootLayout.findViewById(R.id.whats_new); - importExportListHeader = headerRootLayout.findViewById(R.id.import_export); - importExportOptions = headerRootLayout.findViewById(R.id.import_export_options); - - infoListAdapter.useMiniItemVariants(true); - infoListAdapter.setGridItemVariants(useGrid); - itemsList.setAdapter(infoListAdapter); - - setupImportFromItems(headerRootLayout.findViewById(R.id.import_from_options)); - setupExportToItems(headerRootLayout.findViewById(R.id.export_to_options)); - - if (importExportOptionsState != null) { - importExportOptions.onRestoreInstanceState(importExportOptionsState); - importExportOptionsState = null; - } - - importExportOptions.addListener(getExpandIconSyncListener(headerRootLayout.findViewById(R.id.import_export_expand_icon))); - importExportOptions.ready(); - } - - private CollapsibleView.StateListener getExpandIconSyncListener(final ImageView iconView) { - return newState -> animateRotation(iconView, 250, newState == CollapsibleView.COLLAPSED ? 0 : 180); - } - - @Override - protected void initListeners() { - super.initListeners(); - - infoListAdapter.setOnChannelSelectedListener(new OnClickGesture() { - - public void selected(ChannelInfoItem selectedItem) { - final FragmentManager fragmentManager = getFM(); - NavigationHelper.openChannelFragment(fragmentManager, - selectedItem.getServiceId(), - selectedItem.getUrl(), - selectedItem.getName()); - } - - public void held(ChannelInfoItem selectedItem) { - showLongTapDialog(selectedItem); - } - - }); - - whatsNewItemListHeader.setOnClickListener(v -> { - FragmentManager fragmentManager = getFM(); - NavigationHelper.openWhatsNewFragment(fragmentManager); - }); - importExportListHeader.setOnClickListener(v -> importExportOptions.switchState()); - } - - private void showLongTapDialog(ChannelInfoItem selectedItem) { - final Context context = getContext(); - final Activity activity = getActivity(); - if (context == null || context.getResources() == null || getActivity() == null) return; - - final String[] commands = new String[]{ - context.getResources().getString(R.string.unsubscribe), - context.getResources().getString(R.string.share) - }; - - final DialogInterface.OnClickListener actions = (dialogInterface, i) -> { - switch (i) { - case 0: - deleteChannel(selectedItem); - break; - case 1: - shareChannel(selectedItem); - break; - default: - break; - } - }; - - final View bannerView = View.inflate(activity, R.layout.dialog_title, null); - bannerView.setSelected(true); - - TextView titleView = bannerView.findViewById(R.id.itemTitleView); - titleView.setText(selectedItem.getName()); - - TextView detailsView = bannerView.findViewById(R.id.itemAdditionalDetails); - detailsView.setVisibility(View.GONE); - - new AlertDialog.Builder(activity) - .setCustomTitle(bannerView) - .setItems(commands, actions) - .create() - .show(); - - } - - private void shareChannel(ChannelInfoItem selectedItem) { - ShareUtils.shareUrl(getContext(), selectedItem.getName(), selectedItem.getUrl()); - } - - @SuppressLint("CheckResult") - private void deleteChannel(ChannelInfoItem selectedItem) { - subscriptionService.subscriptionTable() - .getSubscription(selectedItem.getServiceId(), selectedItem.getUrl()) - .toObservable() - .observeOn(Schedulers.io()) - .subscribe(getDeleteObserver()); - - Toast.makeText(activity, getString(R.string.channel_unsubscribed), Toast.LENGTH_SHORT).show(); - } - - - - private Observer> getDeleteObserver() { - return new Observer>() { - @Override - public void onSubscribe(Disposable d) { - disposables.add(d); - } - - @Override - public void onNext(List subscriptionEntities) { - subscriptionService.subscriptionTable().delete(subscriptionEntities); - } - - @Override - public void onError(Throwable exception) { - SubscriptionFragment.this.onError(exception); - } - - @Override - public void onComplete() { } - }; - } - - private void resetFragment() { - if (disposables != null) disposables.clear(); - if (infoListAdapter != null) infoListAdapter.clearStreamItemList(); - } - - /////////////////////////////////////////////////////////////////////////// - // Subscriptions Loader - /////////////////////////////////////////////////////////////////////////// - - @Override - public void startLoading(boolean forceLoad) { - super.startLoading(forceLoad); - resetFragment(); - - subscriptionService.getSubscription().toObservable() - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(getSubscriptionObserver()); - } - - private Observer> getSubscriptionObserver() { - return new Observer>() { - @Override - public void onSubscribe(Disposable d) { - showLoading(); - disposables.add(d); - } - - @Override - public void onNext(List subscriptions) { - handleResult(subscriptions); - } - - @Override - public void onError(Throwable exception) { - SubscriptionFragment.this.onError(exception); - } - - @Override - public void onComplete() { - } - }; - } - - @Override - public void handleResult(@NonNull List result) { - super.handleResult(result); - - infoListAdapter.clearStreamItemList(); - - if (result.isEmpty()) { - whatsNewItemListHeader.setVisibility(View.GONE); - showEmptyState(); - } else { - infoListAdapter.addInfoItemList(getSubscriptionItems(result)); - if (itemsListState != null) { - itemsList.getLayoutManager().onRestoreInstanceState(itemsListState); - itemsListState = null; - } - whatsNewItemListHeader.setVisibility(View.VISIBLE); - hideLoading(); - } - } - - - private List getSubscriptionItems(List subscriptions) { - List items = new ArrayList<>(); - for (final SubscriptionEntity subscription : subscriptions) { - items.add(subscription.toChannelInfoItem()); - } - - Collections.sort(items, - (InfoItem o1, InfoItem o2) -> - o1.getName().compareToIgnoreCase(o2.getName())); - return items; - } - - /*////////////////////////////////////////////////////////////////////////// - // Contract - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void showLoading() { - super.showLoading(); - animateView(itemsList, false, 100); - } - - @Override - public void hideLoading() { - super.hideLoading(); - animateView(itemsList, true, 200); - } - - /////////////////////////////////////////////////////////////////////////// - // Fragment Error Handling - /////////////////////////////////////////////////////////////////////////// - - @Override - protected boolean onError(Throwable exception) { - resetFragment(); - if (super.onError(exception)) return true; - - onUnrecoverableError(exception, - UserAction.SOMETHING_ELSE, - "none", - "Subscriptions", - R.string.general_error); - return true; - } - - @Override - public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { - if (key.equals(getString(R.string.list_view_mode_key))) { - updateFlags |= LIST_MODE_UPDATE_FLAG; - } - } - - protected boolean isGridLayout() { - final String list_mode = PreferenceManager.getDefaultSharedPreferences(activity).getString(getString(R.string.list_view_mode_key), getString(R.string.list_view_mode_value)); - if ("auto".equals(list_mode)) { - final Configuration configuration = getResources().getConfiguration(); - return configuration.orientation == Configuration.ORIENTATION_LANDSCAPE - && configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE); - } else { - return "grid".equals(list_mode); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt new file mode 100644 index 000000000..ef677efa3 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt @@ -0,0 +1,364 @@ +package org.schabi.newpipe.local.subscription + +import android.app.Activity +import android.app.AlertDialog +import android.content.* +import android.os.Bundle +import android.os.Environment +import android.os.Parcelable +import android.view.* +import android.widget.Toast +import androidx.lifecycle.ViewModelProviders +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import androidx.recyclerview.widget.LinearLayoutManager +import com.nononsenseapps.filepicker.Utils +import com.xwray.groupie.Group +import com.xwray.groupie.GroupAdapter +import com.xwray.groupie.Item +import com.xwray.groupie.Section +import com.xwray.groupie.kotlinandroidextensions.ViewHolder +import icepick.State +import io.reactivex.disposables.CompositeDisposable +import kotlinx.android.synthetic.main.dialog_title.view.* +import kotlinx.android.synthetic.main.fragment_subscription.* +import org.schabi.newpipe.R +import org.schabi.newpipe.extractor.channel.ChannelInfoItem +import org.schabi.newpipe.fragments.BaseStateFragment +import org.schabi.newpipe.local.subscription.SubscriptionViewModel.* +import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialog +import org.schabi.newpipe.local.subscription.item.* +import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService +import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService.EXPORT_COMPLETE_ACTION +import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService.KEY_FILE_PATH +import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService +import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.* +import org.schabi.newpipe.report.UserAction +import org.schabi.newpipe.util.AnimationUtils.animateView +import org.schabi.newpipe.util.FilePickerActivityHelper +import org.schabi.newpipe.util.NavigationHelper +import org.schabi.newpipe.util.OnClickGesture +import org.schabi.newpipe.util.ShareUtils +import java.io.File +import java.text.SimpleDateFormat +import java.util.* + +class SubscriptionFragment : BaseStateFragment() { + private lateinit var viewModel: SubscriptionViewModel + private lateinit var subscriptionManager: SubscriptionManager + private val disposables: CompositeDisposable = CompositeDisposable() + + private var subscriptionBroadcastReceiver: BroadcastReceiver? = null + + private val groupAdapter = GroupAdapter() + private val feedGroupsSection = Section() + private var feedGroupsCarousel: FeedGroupCarouselItem? = null + private lateinit var importExportItem: FeedImportExportItem + private val subscriptionsSection = Section() + + @State @JvmField var itemsListState: Parcelable? = null + @State @JvmField var feedGroupsListState: Parcelable? = null + @State @JvmField var importExportItemExpandedState: Boolean = false + + init { + setHasOptionsMenu(true) + } + + /////////////////////////////////////////////////////////////////////////// + // Fragment LifeCycle + /////////////////////////////////////////////////////////////////////////// + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setupInitialLayout() + } + + override fun setUserVisibleHint(isVisibleToUser: Boolean) { + super.setUserVisibleHint(isVisibleToUser) + if (activity != null && isVisibleToUser) { + setTitle(activity.getString(R.string.tab_subscriptions)) + } + } + + override fun onAttach(context: Context) { + super.onAttach(context) + subscriptionManager = SubscriptionManager(requireContext()) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.fragment_subscription, container, false) + } + + override fun onResume() { + super.onResume() + setupBroadcastReceiver() + } + + override fun onPause() { + super.onPause() + itemsListState = items_list.layoutManager?.onSaveInstanceState() + feedGroupsListState = feedGroupsCarousel?.onSaveInstanceState() + importExportItemExpandedState = importExportItem.isExpanded + + if (subscriptionBroadcastReceiver != null && activity != null) { + LocalBroadcastManager.getInstance(activity).unregisterReceiver(subscriptionBroadcastReceiver!!) + } + } + + override fun onDestroy() { + super.onDestroy() + disposables.dispose() + } + + ////////////////////////////////////////////////////////////////////////// + // Menu + ////////////////////////////////////////////////////////////////////////// + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + super.onCreateOptionsMenu(menu, inflater) + + val supportActionBar = activity.supportActionBar + if (supportActionBar != null) { + supportActionBar.setDisplayShowTitleEnabled(true) + setTitle(getString(R.string.tab_subscriptions)) + } + } + + private fun setupBroadcastReceiver() { + if (activity == null) return + + if (subscriptionBroadcastReceiver != null) { + LocalBroadcastManager.getInstance(activity).unregisterReceiver(subscriptionBroadcastReceiver!!) + } + + val filters = IntentFilter() + filters.addAction(EXPORT_COMPLETE_ACTION) + filters.addAction(IMPORT_COMPLETE_ACTION) + subscriptionBroadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + items_list?.post { + importExportItem.isExpanded = false + importExportItem.notifyChanged(FeedImportExportItem.REFRESH_EXPANDED_STATUS) + } + + } + } + + LocalBroadcastManager.getInstance(activity).registerReceiver(subscriptionBroadcastReceiver!!, filters) + } + + private fun onImportFromServiceSelected(serviceId: Int) { + val fragmentManager = fm + NavigationHelper.openSubscriptionsImportFragment(fragmentManager, serviceId) + } + + private fun onImportPreviousSelected() { + startActivityForResult(FilePickerActivityHelper.chooseSingleFile(activity), REQUEST_IMPORT_CODE) + } + + private fun onExportSelected() { + val date = SimpleDateFormat("yyyyMMddHHmm", Locale.ENGLISH).format(Date()) + val exportName = "newpipe_subscriptions_$date.json" + val exportFile = File(Environment.getExternalStorageDirectory(), exportName) + + startActivityForResult(FilePickerActivityHelper.chooseFileToSave(activity, exportFile.absolutePath), REQUEST_EXPORT_CODE) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (data != null && data.data != null && resultCode == Activity.RESULT_OK) { + if (requestCode == REQUEST_EXPORT_CODE) { + val exportFile = Utils.getFileForUri(data.data!!) + if (!exportFile.parentFile.canWrite() || !exportFile.parentFile.canRead()) { + Toast.makeText(activity, R.string.invalid_directory, Toast.LENGTH_SHORT).show() + } else { + activity.startService(Intent(activity, SubscriptionsExportService::class.java) + .putExtra(KEY_FILE_PATH, exportFile.absolutePath)) + } + } else if (requestCode == REQUEST_IMPORT_CODE) { + val path = Utils.getFileForUri(data.data!!).absolutePath + ImportConfirmationDialog.show(this, Intent(activity, SubscriptionsImportService::class.java) + .putExtra(KEY_MODE, PREVIOUS_EXPORT_MODE) + .putExtra(KEY_VALUE, path)) + } + } + } + + ////////////////////////////////////////////////////////////////////////// + // Fragment Views + ////////////////////////////////////////////////////////////////////////// + + private fun setupInitialLayout() { + Section().apply { + val carouselAdapter = GroupAdapter() + + carouselAdapter.add(FeedGroupCardItem(-1, getString(R.string.all), FeedGroupIcon.ALL)) + carouselAdapter.add(feedGroupsSection) + carouselAdapter.add(FeedGroupAddItem()) + + carouselAdapter.setOnItemClickListener { item, _ -> + listenerFeedGroups.selected(item) + } + carouselAdapter.setOnItemLongClickListener { item, _ -> + if (item is FeedGroupCardItem) { + if (item.groupId == -1L) { + return@setOnItemLongClickListener false + } + } + listenerFeedGroups.held(item) + return@setOnItemLongClickListener true + } + + feedGroupsCarousel = FeedGroupCarouselItem(requireContext(), carouselAdapter) + add(Section(HeaderItem(getString(R.string.fragment_whats_new)), listOf(feedGroupsCarousel))) + + groupAdapter.add(this) + } + + subscriptionsSection.setPlaceholder(EmptyPlaceholderItem()) + subscriptionsSection.setHideWhenEmpty(true) + + importExportItem = FeedImportExportItem( + { onImportPreviousSelected() }, + { onImportFromServiceSelected(it) }, + { onExportSelected() }, + importExportItemExpandedState) + groupAdapter.add(Section(importExportItem, listOf(subscriptionsSection))) + + } + + override fun initViews(rootView: View, savedInstanceState: Bundle?) { + super.initViews(rootView, savedInstanceState) + + items_list.layoutManager = LinearLayoutManager(requireContext()) + items_list.adapter = groupAdapter + + viewModel = ViewModelProviders.of(this).get(SubscriptionViewModel::class.java) + viewModel.stateLiveData.observe(viewLifecycleOwner, androidx.lifecycle.Observer { it?.let(this::handleResult) }) + viewModel.feedGroupsLiveData.observe(viewLifecycleOwner, androidx.lifecycle.Observer { it?.let(this::handleFeedGroups) }) + } + + private fun showLongTapDialog(selectedItem: ChannelInfoItem) { + val commands = arrayOf( + getString(R.string.share), + getString(R.string.unsubscribe) + ) + + val actions = DialogInterface.OnClickListener { _, i -> + when (i) { + 0 -> ShareUtils.shareUrl(requireContext(), selectedItem.name, selectedItem.url) + 1 -> deleteChannel(selectedItem) + } + } + + val bannerView = View.inflate(requireContext(), R.layout.dialog_title, null) + bannerView.isSelected = true + bannerView.itemTitleView.text = selectedItem.name + bannerView.itemAdditionalDetails.visibility = View.GONE + + AlertDialog.Builder(requireContext()) + .setCustomTitle(bannerView) + .setItems(commands, actions) + .create() + .show() + } + + private fun deleteChannel(selectedItem: ChannelInfoItem) { + disposables.add(subscriptionManager.deleteSubscription(selectedItem.serviceId, selectedItem.url).subscribe { + Toast.makeText(requireContext(), getString(R.string.channel_unsubscribed), Toast.LENGTH_SHORT).show() + }) + } + + override fun doInitialLoadLogic() = Unit + override fun startLoading(forceLoad: Boolean) = Unit + + private val listenerFeedGroups = object : OnClickGesture>() { + override fun selected(selectedItem: Item<*>?) { + when (selectedItem) { + is FeedGroupCardItem -> NavigationHelper.openFeedFragment(fm, selectedItem.groupId, selectedItem.name) + is FeedGroupAddItem -> FeedGroupDialog.newInstance().show(fm, null) + } + } + + override fun held(selectedItem: Item<*>?) { + when (selectedItem) { + is FeedGroupCardItem -> FeedGroupDialog.newInstance(selectedItem.groupId).show(fm, null) + } + } + } + + private val listenerChannelItem = object : OnClickGesture() { + override fun selected(selectedItem: ChannelInfoItem) = NavigationHelper.openChannelFragment(fm, + selectedItem.serviceId, selectedItem.url, selectedItem.name) + + override fun held(selectedItem: ChannelInfoItem) = showLongTapDialog(selectedItem) + } + + override fun handleResult(result: SubscriptionState) { + super.handleResult(result) + + when (result) { + is SubscriptionState.LoadedState -> { + result.subscriptions.forEach { + if (it is ChannelItem) { + it.gesturesListener = listenerChannelItem + } + } + + subscriptionsSection.update(result.subscriptions) + subscriptionsSection.setHideWhenEmpty(false) + + if (itemsListState != null) { + items_list.layoutManager?.onRestoreInstanceState(itemsListState) + itemsListState = null + } + } + is SubscriptionState.ErrorState -> { + result.error?.let { onError(result.error) } + } + } + } + + private fun handleFeedGroups(groups: List) { + feedGroupsSection.update(groups) + + if (feedGroupsListState != null) { + feedGroupsCarousel?.onRestoreInstanceState(feedGroupsListState) + feedGroupsListState = null + } + } + + /////////////////////////////////////////////////////////////////////////// + // Contract + /////////////////////////////////////////////////////////////////////////// + + override fun showLoading() { + super.showLoading() + animateView(items_list, false, 100) + } + + override fun hideLoading() { + super.hideLoading() + animateView(items_list, true, 200) + } + + /////////////////////////////////////////////////////////////////////////// + // Fragment Error Handling + /////////////////////////////////////////////////////////////////////////// + + override fun onError(exception: Throwable): Boolean { + if (super.onError(exception)) return true + + onUnrecoverableError(exception, UserAction.SOMETHING_ELSE, "none", "Subscriptions", R.string.general_error) + return true + } + + /////////////////////////////////////////////////////////////////////////// + // Grid Mode + /////////////////////////////////////////////////////////////////////////// + // TODO: Re-implement grid mode selection + + companion object { + private const val REQUEST_EXPORT_CODE = 666 + private const val REQUEST_IMPORT_CODE = 667 + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt new file mode 100644 index 000000000..ecaadcc8b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt @@ -0,0 +1,66 @@ +package org.schabi.newpipe.local.subscription + +import android.content.Context +import io.reactivex.Completable +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.schedulers.Schedulers +import org.schabi.newpipe.NewPipeDatabase +import org.schabi.newpipe.database.subscription.SubscriptionDAO +import org.schabi.newpipe.database.subscription.SubscriptionEntity +import org.schabi.newpipe.extractor.channel.ChannelInfo +import org.schabi.newpipe.local.feed.FeedDatabaseManager + +class SubscriptionManager(context: Context) { + private val database = NewPipeDatabase.getInstance(context) + private val subscriptionTable = database.subscriptionDAO() + private val feedDatabaseManager = FeedDatabaseManager(context) + + fun subscriptionTable(): SubscriptionDAO = subscriptionTable + fun subscriptions() = subscriptionTable.all + + fun upsertAll(infoList: List): List { + val listEntities = subscriptionTable.upsertAll( + infoList.map { SubscriptionEntity.from(it) }) + + database.runInTransaction { + infoList.forEachIndexed { index, info -> + feedDatabaseManager.upsertAll(listEntities[index].uid, info.relatedItems) + } + } + + return listEntities + } + + fun updateChannelInfo(info: ChannelInfo): Completable = subscriptionTable.getSubscription(info.serviceId, info.url) + .flatMapCompletable { + Completable.fromRunnable { + it.setData(info.name, info.avatarUrl, info.description, info.subscriberCount) + subscriptionTable.update(it) + feedDatabaseManager.upsertAll(it.uid, info.relatedItems) + } + } + + fun updateFromInfo(subscriptionId: Long, info: ChannelInfo) { + val subscriptionEntity = subscriptionTable.getSubscription(subscriptionId) + subscriptionEntity.setData(info.name, info.avatarUrl, info.description, info.subscriberCount) + + subscriptionTable.update(subscriptionEntity) + } + + fun deleteSubscription(serviceId: Int, url: String): Completable { + return Completable.fromCallable { subscriptionTable.deleteSubscription(serviceId, url) } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + } + + fun insertSubscription(subscriptionEntity: SubscriptionEntity, info: ChannelInfo) { + database.runInTransaction { + val subscriptionId = subscriptionTable.insert(subscriptionEntity) + feedDatabaseManager.upsertAll(subscriptionId, info.relatedItems) + } + } + + fun deleteSubscription(subscriptionEntity: SubscriptionEntity) { + subscriptionTable.delete(subscriptionEntity) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionService.java b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionService.java deleted file mode 100644 index 7d6fa5158..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionService.java +++ /dev/null @@ -1,162 +0,0 @@ -package org.schabi.newpipe.local.subscription; - -import android.content.Context; -import androidx.annotation.NonNull; -import android.util.Log; - -import org.schabi.newpipe.MainActivity; -import org.schabi.newpipe.NewPipeDatabase; -import org.schabi.newpipe.database.AppDatabase; -import org.schabi.newpipe.database.subscription.SubscriptionDAO; -import org.schabi.newpipe.database.subscription.SubscriptionEntity; -import org.schabi.newpipe.extractor.channel.ChannelInfo; -import org.schabi.newpipe.util.ExtractorHelper; - -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.Executor; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; - -import io.reactivex.Completable; -import io.reactivex.CompletableSource; -import io.reactivex.Flowable; -import io.reactivex.Maybe; -import io.reactivex.Scheduler; -import io.reactivex.functions.Function; -import io.reactivex.schedulers.Schedulers; - -/** - * Subscription Service singleton: - * Provides a basis for channel Subscriptions. - * Provides access to subscription table in database as well as - * up-to-date observations on the subscribed channels - */ -public class SubscriptionService { - - private static volatile SubscriptionService instance; - - public static SubscriptionService getInstance(@NonNull Context context) { - SubscriptionService result = instance; - if (result == null) { - synchronized (SubscriptionService.class) { - result = instance; - if (result == null) { - instance = (result = new SubscriptionService(context)); - } - } - } - - return result; - } - - protected final String TAG = "SubscriptionService@" + Integer.toHexString(hashCode()); - protected static final boolean DEBUG = MainActivity.DEBUG; - private static final int SUBSCRIPTION_DEBOUNCE_INTERVAL = 500; - private static final int SUBSCRIPTION_THREAD_POOL_SIZE = 4; - - private final AppDatabase db; - private final Flowable> subscription; - - private final Scheduler subscriptionScheduler; - - private SubscriptionService(Context context) { - db = NewPipeDatabase.getInstance(context.getApplicationContext()); - subscription = getSubscriptionInfos(); - - final Executor subscriptionExecutor = Executors.newFixedThreadPool(SUBSCRIPTION_THREAD_POOL_SIZE); - subscriptionScheduler = Schedulers.from(subscriptionExecutor); - } - - /** - * Part of subscription observation pipeline - * - * @see SubscriptionService#getSubscription() - */ - private Flowable> getSubscriptionInfos() { - return subscriptionTable().getAll() - // Wait for a period of infrequent updates and return the latest update - .debounce(SUBSCRIPTION_DEBOUNCE_INTERVAL, TimeUnit.MILLISECONDS) - .share() // Share allows multiple subscribers on the same observable - .replay(1) // Replay synchronizes subscribers to the last emitted result - .autoConnect(); - } - - /** - * Provides an observer to the latest update to the subscription table. - *

- * This observer may be subscribed multiple times, where each subscriber obtains - * the latest synchronized changes available, effectively share the same data - * across all subscribers. - *

- * This observer has a debounce cooldown, meaning if multiple updates are observed - * in the cooldown interval, only the latest changes are emitted to the subscribers. - * This reduces the amount of observations caused by frequent updates to the database. - */ - @androidx.annotation.NonNull - public Flowable> getSubscription() { - return subscription; - } - - public Maybe getChannelInfo(final SubscriptionEntity subscriptionEntity) { - if (DEBUG) Log.d(TAG, "getChannelInfo() called with: subscriptionEntity = [" + subscriptionEntity + "]"); - - return Maybe.fromSingle(ExtractorHelper - .getChannelInfo(subscriptionEntity.getServiceId(), subscriptionEntity.getUrl(), false)) - .subscribeOn(subscriptionScheduler); - } - - /** - * Returns the database access interface for subscription table. - */ - public SubscriptionDAO subscriptionTable() { - return db.subscriptionDAO(); - } - - public Completable updateChannelInfo(final ChannelInfo info) { - final Function, CompletableSource> update = new Function, CompletableSource>() { - @Override - public CompletableSource apply(@NonNull List subscriptionEntities) { - if (DEBUG) Log.d(TAG, "updateChannelInfo() called with: subscriptionEntities = [" + subscriptionEntities + "]"); - if (subscriptionEntities.size() == 1) { - SubscriptionEntity subscription = subscriptionEntities.get(0); - - // Subscriber count changes very often, making this check almost unnecessary. - // Consider removing it later. - if (!isSubscriptionUpToDate(info, subscription)) { - subscription.setData(info.getName(), info.getAvatarUrl(), info.getDescription(), info.getSubscriberCount()); - - return Completable.fromRunnable(() -> subscriptionTable().update(subscription)); - } - } - - return Completable.complete(); - } - }; - - return subscriptionTable().getSubscription(info.getServiceId(), info.getUrl()) - .firstOrError() - .flatMapCompletable(update); - } - - public List upsertAll(final List infoList) { - final List entityList = new ArrayList<>(); - for (ChannelInfo info : infoList) entityList.add(SubscriptionEntity.from(info)); - - return subscriptionTable().upsertAll(entityList); - } - - private boolean isSubscriptionUpToDate(final ChannelInfo info, final SubscriptionEntity entity) { - return equalsAndNotNull(info.getUrl(), entity.getUrl()) && - info.getServiceId() == entity.getServiceId() && - info.getName().equals(entity.getName()) && - equalsAndNotNull(info.getAvatarUrl(), entity.getAvatarUrl()) && - equalsAndNotNull(info.getDescription(), entity.getDescription()) && - info.getSubscriberCount() == entity.getSubscriberCount(); - } - - private boolean equalsAndNotNull(final Object o1, final Object o2) { - return (o1 != null && o2 != null) - && o1.equals(o2); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionViewModel.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionViewModel.kt new file mode 100644 index 000000000..1a9c0e5b1 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionViewModel.kt @@ -0,0 +1,49 @@ +package org.schabi.newpipe.local.subscription + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.MutableLiveData +import com.xwray.groupie.Group +import io.reactivex.schedulers.Schedulers +import org.schabi.newpipe.local.feed.FeedDatabaseManager +import org.schabi.newpipe.local.subscription.item.ChannelItem +import org.schabi.newpipe.local.subscription.item.FeedGroupCardItem +import org.schabi.newpipe.util.DEFAULT_THROTTLE_TIMEOUT +import java.util.concurrent.TimeUnit + +class SubscriptionViewModel(application: Application) : AndroidViewModel(application) { + val stateLiveData = MutableLiveData() + val feedGroupsLiveData = MutableLiveData>() + + private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(application) + private var subscriptionManager = SubscriptionManager(application) + + private var feedGroupItemsDisposable = feedDatabaseManager.groups() + .throttleLatest(DEFAULT_THROTTLE_TIMEOUT, TimeUnit.MILLISECONDS) + .map { it.map(::FeedGroupCardItem) } + .subscribeOn(Schedulers.io()) + .subscribe( + { feedGroupsLiveData.postValue(it) }, + { stateLiveData.postValue(SubscriptionState.ErrorState(it)) } + ) + + private var stateItemsDisposable = subscriptionManager.subscriptions() + .throttleLatest(DEFAULT_THROTTLE_TIMEOUT, TimeUnit.MILLISECONDS) + .map { it.map { entity -> ChannelItem(entity.toChannelInfoItem(), entity.uid, ChannelItem.ItemVersion.MINI) } } + .subscribeOn(Schedulers.io()) + .subscribe( + { stateLiveData.postValue(SubscriptionState.LoadedState(it)) }, + { stateLiveData.postValue(SubscriptionState.ErrorState(it)) } + ) + + override fun onCleared() { + super.onCleared() + stateItemsDisposable.dispose() + feedGroupItemsDisposable.dispose() + } + + sealed class SubscriptionState { + data class LoadedState(val subscriptions: List) : SubscriptionState() + data class ErrorState(val error: Throwable? = null) : SubscriptionState() + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/decoration/FeedGroupCarouselDecoration.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/decoration/FeedGroupCarouselDecoration.kt new file mode 100644 index 000000000..24c8d9cb8 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/decoration/FeedGroupCarouselDecoration.kt @@ -0,0 +1,35 @@ +package org.schabi.newpipe.local.subscription.decoration + +import android.content.Context +import android.graphics.Rect +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import org.schabi.newpipe.R + +class FeedGroupCarouselDecoration(context: Context) : RecyclerView.ItemDecoration() { + + private val marginStartEnd: Int + private val marginTopBottom: Int + private val marginBetweenItems: Int + + init { + with(context.resources) { + marginStartEnd = getDimensionPixelOffset(R.dimen.feed_group_carousel_start_end_margin) + marginTopBottom = getDimensionPixelOffset(R.dimen.feed_group_carousel_top_bottom_margin) + marginBetweenItems = getDimensionPixelOffset(R.dimen.feed_group_carousel_between_items_margin) + } + } + + override fun getItemOffsets(outRect: Rect, child: View, parent: RecyclerView, state: RecyclerView.State) { + val childAdapterPosition = parent.getChildAdapterPosition(child) + val childAdapterCount = parent.adapter?.itemCount ?: 0 + + outRect.set(marginBetweenItems, marginTopBottom, 0, marginTopBottom) + + if (childAdapterPosition == 0) { + outRect.left = marginStartEnd + } else if (childAdapterPosition == childAdapterCount - 1) { + outRect.right = marginStartEnd + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialog.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialog.kt new file mode 100644 index 000000000..f91c617e2 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialog.kt @@ -0,0 +1,355 @@ +package org.schabi.newpipe.local.subscription.dialog + +import android.app.Dialog +import android.content.Context +import android.os.Bundle +import android.os.Parcelable +import android.text.Editable +import android.text.TextWatcher +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager +import android.widget.Toast +import androidx.fragment.app.DialogFragment +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProviders +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.xwray.groupie.GroupAdapter +import com.xwray.groupie.Section +import com.xwray.groupie.kotlinandroidextensions.ViewHolder +import icepick.Icepick +import icepick.State +import kotlinx.android.synthetic.main.dialog_feed_group_create.* +import org.schabi.newpipe.R +import org.schabi.newpipe.database.feed.model.FeedGroupEntity +import org.schabi.newpipe.database.subscription.SubscriptionEntity +import org.schabi.newpipe.local.subscription.FeedGroupIcon +import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialogViewModel.FeedDialogEvent +import org.schabi.newpipe.local.subscription.item.EmptyPlaceholderItem +import org.schabi.newpipe.local.subscription.item.HeaderTextSideItem +import org.schabi.newpipe.local.subscription.item.PickerIconItem +import org.schabi.newpipe.local.subscription.item.PickerSubscriptionItem +import org.schabi.newpipe.util.AnimationUtils.animateView +import org.schabi.newpipe.util.ThemeHelper +import java.io.Serializable + +class FeedGroupDialog : DialogFragment() { + private lateinit var viewModel: FeedGroupDialogViewModel + private var groupId: Long = NO_GROUP_SELECTED + private var groupIcon: FeedGroupIcon? = null + + sealed class ScreenState : Serializable { + object InitialScreen : ScreenState() + object SubscriptionsPicker : ScreenState() + object IconPickerList : ScreenState() + } + + @State @JvmField var selectedIcon: FeedGroupIcon? = null + @State @JvmField var selectedSubscriptions: HashSet = HashSet() + @State @JvmField var currentScreen: ScreenState = ScreenState.InitialScreen + + @State @JvmField var subscriptionsListState: Parcelable? = null + @State @JvmField var iconsListState: Parcelable? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + Icepick.restoreInstanceState(this, savedInstanceState) + + setStyle(STYLE_NO_TITLE, ThemeHelper.getMinWidthDialogTheme(requireContext())) + groupId = arguments?.getLong(KEY_GROUP_ID, NO_GROUP_SELECTED) ?: NO_GROUP_SELECTED + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.dialog_feed_group_create, container) + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return object : Dialog(requireActivity(), theme) { + override fun onBackPressed() { + if (currentScreen !is ScreenState.InitialScreen) { + showInitialScreen() + } else { + super.onBackPressed() + } + } + } + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + + iconsListState = icon_selector.layoutManager?.onSaveInstanceState() + subscriptionsListState = subscriptions_selector.layoutManager?.onSaveInstanceState() + + Icepick.saveInstanceState(this, outState) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + viewModel = ViewModelProviders.of(this, FeedGroupDialogViewModel.Factory(requireContext(), groupId)) + .get(FeedGroupDialogViewModel::class.java) + + viewModel.groupLiveData.observe(viewLifecycleOwner, Observer(::handleGroup)) + viewModel.subscriptionsLiveData.observe(viewLifecycleOwner, Observer { setupSubscriptionPicker(it.first, it.second) }) + viewModel.successLiveData.observe(viewLifecycleOwner, Observer { + when (it) { + is FeedDialogEvent.SuccessEvent -> dismiss() + } + }) + + setupIconPicker() + + delete_button.setOnClickListener { viewModel.deleteGroup() } + + cancel_button.setOnClickListener { + if (currentScreen !is ScreenState.InitialScreen) { + showInitialScreen() + } else { + dismiss() + } + } + + group_name_input_container.error = null + group_name_input.addTextChangedListener(object : TextWatcher { + override fun afterTextChanged(s: Editable?) {} + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { + if (group_name_input_container.isErrorEnabled && !s.isNullOrBlank()) { + group_name_input_container.error = null + } + } + }) + + confirm_button.setOnClickListener { + if (currentScreen is ScreenState.InitialScreen) { + val name = group_name_input.text.toString().trim() + val icon = selectedIcon ?: groupIcon ?: FeedGroupIcon.ALL + + if (name.isBlank()) { + group_name_input_container.error = getString(R.string.feed_group_dialog_empty_name) + group_name_input.text = null + group_name_input.requestFocus() + return@setOnClickListener + } else { + group_name_input_container.error = null + } + + if (selectedSubscriptions.isEmpty()) { + Toast.makeText(requireContext(), getString(R.string.feed_group_dialog_empty_selection), Toast.LENGTH_SHORT).show() + return@setOnClickListener + } + + when (groupId) { + NO_GROUP_SELECTED -> viewModel.createGroup(name, icon, selectedSubscriptions) + else -> viewModel.updateGroup(name, icon, selectedSubscriptions) + } + } else { + showInitialScreen() + } + } + + when (currentScreen) { + is ScreenState.InitialScreen -> showInitialScreen() + is ScreenState.IconPickerList -> showIconPicker() + is ScreenState.SubscriptionsPicker -> showSubscriptionsPicker() + } + } + + /////////////////////////////////////////////////////////////////////////// + // Setup + /////////////////////////////////////////////////////////////////////////// + + private fun handleGroup(feedGroupEntity: FeedGroupEntity? = null) { + val icon = feedGroupEntity?.icon ?: FeedGroupIcon.ALL + val name = feedGroupEntity?.name ?: "" + groupIcon = feedGroupEntity?.icon + + icon_preview.setImageResource((if (selectedIcon == null) icon else selectedIcon!!).getDrawableRes(requireContext())) + + if (group_name_input.text.isNullOrBlank()) { + group_name_input.setText(name) + } + } + + private fun setupSubscriptionPicker(subscriptions: List, selectedSubscriptions: Set) { + this.selectedSubscriptions.addAll(selectedSubscriptions) + val useGridLayout = subscriptions.isNotEmpty() + + val groupAdapter = GroupAdapter() + groupAdapter.spanCount = if (useGridLayout) 4 else 1 + + val selectedCountText = getString(R.string.feed_group_dialog_selection_count, this.selectedSubscriptions.size) + selected_subscription_count_view.text = selectedCountText + + val headerInfoItem = HeaderTextSideItem(getString(R.string.tab_subscriptions), selectedCountText) + groupAdapter.add(headerInfoItem) + + Section().apply { + addAll(subscriptions.map { + val isSelected = this@FeedGroupDialog.selectedSubscriptions.contains(it.uid) + PickerSubscriptionItem(it, isSelected) + }) + setPlaceholder(EmptyPlaceholderItem()) + + groupAdapter.add(this) + } + + subscriptions_selector.apply { + if (useGridLayout) { + layoutManager = GridLayoutManager(requireContext(), groupAdapter.spanCount, RecyclerView.VERTICAL, false).apply { + spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { + override fun getSpanSize(position: Int) = + if (position == 0) 4 else 1 + } + } + } else { + layoutManager = LinearLayoutManager(requireContext(), RecyclerView.VERTICAL, false) + } + + adapter = groupAdapter + + if (subscriptionsListState != null) { + layoutManager?.onRestoreInstanceState(subscriptionsListState) + subscriptionsListState = null + } + } + + groupAdapter.setOnItemClickListener { item, _ -> + when (item) { + is PickerSubscriptionItem -> { + val subscriptionId = item.subscriptionEntity.uid + + val isSelected = if (this.selectedSubscriptions.contains(subscriptionId)) { + this.selectedSubscriptions.remove(subscriptionId) + false + } else { + this.selectedSubscriptions.add(subscriptionId) + true + } + + item.isSelected = isSelected + item.notifyChanged(PickerSubscriptionItem.UPDATE_SELECTED) + + val updateSelectedCountText = getString(R.string.feed_group_dialog_selection_count, this.selectedSubscriptions.size) + selected_subscription_count_view.text = updateSelectedCountText + headerInfoItem.infoText = updateSelectedCountText + headerInfoItem.notifyChanged(HeaderTextSideItem.UPDATE_INFO) + } + } + } + + select_channel_button.setOnClickListener { + subscriptions_selector.scrollToPosition(0) + showSubscriptionsPicker() + } + } + + private fun setupIconPicker() { + val groupAdapter = GroupAdapter() + groupAdapter.addAll(FeedGroupIcon.values().map { PickerIconItem(requireContext(), it) }) + + icon_selector.apply { + layoutManager = GridLayoutManager(requireContext(), 7, RecyclerView.VERTICAL, false) + adapter = groupAdapter + + if (iconsListState != null) { + layoutManager?.onRestoreInstanceState(iconsListState) + iconsListState = null + } + } + + groupAdapter.setOnItemClickListener { item, _ -> + when (item) { + is PickerIconItem -> { + selectedIcon = item.icon + icon_preview.setImageResource(item.iconRes) + + showInitialScreen() + } + } + } + icon_preview.setOnClickListener { + icon_selector.scrollToPosition(0) + showIconPicker() + } + + if (groupId == NO_GROUP_SELECTED) { + val icon = selectedIcon ?: FeedGroupIcon.ALL + icon_preview.setImageResource(icon.getDrawableRes(requireContext())) + } + } + + /////////////////////////////////////////////////////////////////////////// + // Screen Selector + /////////////////////////////////////////////////////////////////////////// + + private fun showInitialScreen() { + currentScreen = ScreenState.InitialScreen + animateView(icon_selector, false, 0) + animateView(subscriptions_selector, false, 0) + animateView(options_root, true, 250) + + separator.visibility = View.GONE + confirm_button.setText(if (groupId == NO_GROUP_SELECTED) R.string.create else android.R.string.ok) + delete_button.visibility = if (groupId == NO_GROUP_SELECTED) View.GONE else View.VISIBLE + cancel_button.visibility = View.VISIBLE + } + + private fun showIconPicker() { + currentScreen = ScreenState.IconPickerList + animateView(icon_selector, true, 250) + animateView(subscriptions_selector, false, 0) + animateView(options_root, false, 0) + + separator.visibility = View.VISIBLE + confirm_button.setText(android.R.string.ok) + delete_button.visibility = View.GONE + cancel_button.visibility = View.GONE + + hideKeyboard() + } + + private fun showSubscriptionsPicker() { + currentScreen = ScreenState.SubscriptionsPicker + animateView(icon_selector, false, 0) + animateView(subscriptions_selector, true, 250) + animateView(options_root, false, 0) + + separator.visibility = View.VISIBLE + confirm_button.setText(android.R.string.ok) + delete_button.visibility = View.GONE + cancel_button.visibility = View.GONE + + hideKeyboard() + } + + /////////////////////////////////////////////////////////////////////////// + // Utils + /////////////////////////////////////////////////////////////////////////// + + private fun hideKeyboard() { + val inputMethodManager = requireActivity().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + inputMethodManager.hideSoftInputFromWindow(group_name_input.windowToken, InputMethodManager.RESULT_UNCHANGED_SHOWN) + group_name_input.clearFocus() + } + + companion object { + private const val KEY_GROUP_ID = "KEY_GROUP_ID" + private const val NO_GROUP_SELECTED = -1L + + fun newInstance(groupId: Long = NO_GROUP_SELECTED): FeedGroupDialog { + val dialog = FeedGroupDialog() + + dialog.arguments = Bundle().apply { + putLong(KEY_GROUP_ID, groupId) + } + + return dialog + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialogViewModel.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialogViewModel.kt new file mode 100644 index 000000000..249461935 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialogViewModel.kt @@ -0,0 +1,79 @@ +package org.schabi.newpipe.local.subscription.dialog + +import android.content.Context +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import io.reactivex.Flowable +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.CompositeDisposable +import io.reactivex.functions.BiFunction +import io.reactivex.schedulers.Schedulers +import org.schabi.newpipe.database.feed.model.FeedGroupEntity +import org.schabi.newpipe.database.subscription.SubscriptionEntity +import org.schabi.newpipe.local.feed.FeedDatabaseManager +import org.schabi.newpipe.local.subscription.FeedGroupIcon +import org.schabi.newpipe.local.subscription.SubscriptionManager + + +class FeedGroupDialogViewModel(applicationContext: Context, val groupId: Long = -1) : ViewModel() { + class Factory(val context: Context, val groupId: Long = -1) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return FeedGroupDialogViewModel(context.applicationContext, groupId) as T + } + } + + private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(applicationContext) + private var subscriptionManager = SubscriptionManager(applicationContext) + + val groupLiveData = MutableLiveData() + val subscriptionsLiveData = MutableLiveData, Set>>() + val successLiveData = MutableLiveData() + + private val disposables = CompositeDisposable() + + private var feedGroupDisposable = feedDatabaseManager.getGroup(groupId) + .subscribeOn(Schedulers.io()) + .subscribe(groupLiveData::postValue) + + private var subscriptionsDisposable = Flowable + .combineLatest(subscriptionManager.subscriptions(), feedDatabaseManager.subscriptionIdsForGroup(groupId), + BiFunction { t1: List, t2: List -> t1 to t2.toSet() }) + .subscribeOn(Schedulers.io()) + .subscribe(subscriptionsLiveData::postValue) + + override fun onCleared() { + super.onCleared() + subscriptionsDisposable.dispose() + feedGroupDisposable.dispose() + disposables.dispose() + } + + fun createGroup(name: String, selectedIcon: FeedGroupIcon, selectedSubscriptions: Set) { + disposables.add(feedDatabaseManager.createGroup(name, selectedIcon) + .flatMapCompletable { feedDatabaseManager.updateSubscriptionsForGroup(it, selectedSubscriptions.toList()) } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { successLiveData.postValue(FeedDialogEvent.SuccessEvent) }) + } + + fun updateGroup(name: String, selectedIcon: FeedGroupIcon, selectedSubscriptions: Set) { + disposables.add(feedDatabaseManager.updateSubscriptionsForGroup(groupId, selectedSubscriptions.toList()) + .andThen(feedDatabaseManager.updateGroup(FeedGroupEntity(groupId, name, selectedIcon))) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { successLiveData.postValue(FeedDialogEvent.SuccessEvent) }) + } + + fun deleteGroup() { + disposables.add(feedDatabaseManager.deleteGroup(groupId) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { successLiveData.postValue(FeedDialogEvent.SuccessEvent) }) + } + + sealed class FeedDialogEvent { + object SuccessEvent : FeedDialogEvent() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/ChannelItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/ChannelItem.kt new file mode 100644 index 000000000..926a208e8 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/ChannelItem.kt @@ -0,0 +1,65 @@ +package org.schabi.newpipe.local.subscription.item + +import android.content.Context +import com.nostra13.universalimageloader.core.ImageLoader +import com.xwray.groupie.kotlinandroidextensions.Item +import com.xwray.groupie.kotlinandroidextensions.ViewHolder +import kotlinx.android.synthetic.main.list_channel_item.* +import org.schabi.newpipe.R +import org.schabi.newpipe.extractor.channel.ChannelInfoItem +import org.schabi.newpipe.util.ImageDisplayConstants +import org.schabi.newpipe.util.Localization +import org.schabi.newpipe.util.OnClickGesture + + +class ChannelItem( + private val infoItem: ChannelInfoItem, + private val subscriptionId: Long = -1L, + private var itemVersion: ItemVersion = ItemVersion.NORMAL, + var gesturesListener: OnClickGesture? = null +) : Item() { + + override fun getId(): Long = if (subscriptionId == -1L) super.getId() else subscriptionId + + enum class ItemVersion { NORMAL, MINI, GRID } + + override fun getLayout(): Int = when (itemVersion) { + ItemVersion.NORMAL -> R.layout.list_channel_item + ItemVersion.MINI -> R.layout.list_channel_mini_item + ItemVersion.GRID -> R.layout.list_channel_grid_item + } + + override fun bind(viewHolder: ViewHolder, position: Int) { + viewHolder.itemTitleView.text = infoItem.name + viewHolder.itemAdditionalDetails.text = getDetailLine(viewHolder.root.context) + if (itemVersion == ItemVersion.NORMAL) viewHolder.itemChannelDescriptionView.text = infoItem.description + + ImageLoader.getInstance().displayImage(infoItem.thumbnailUrl, viewHolder.itemThumbnailView, + ImageDisplayConstants.DISPLAY_THUMBNAIL_OPTIONS) + + gesturesListener?.run { + viewHolder.containerView.setOnClickListener { selected(infoItem) } + viewHolder.containerView.setOnLongClickListener { held(infoItem); true } + } + } + + private fun getDetailLine(context: Context): String { + var details = if (infoItem.subscriberCount >= 0) { + Localization.shortSubscriberCount(context, infoItem.subscriberCount) + } else { + context.getString(R.string.subscribers_count_not_available) + } + + if (itemVersion == ItemVersion.NORMAL) { + if (infoItem.streamCount >= 0) { + val formattedVideoAmount = Localization.localizeStreamCount(context, infoItem.streamCount) + details = Localization.concatenateStrings(details, formattedVideoAmount) + } + } + return details + } + + override fun getSpanSize(spanCount: Int, position: Int): Int { + return if (itemVersion == ItemVersion.GRID) 1 else spanCount + } +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/EmptyPlaceholderItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/EmptyPlaceholderItem.kt new file mode 100644 index 000000000..40d8c9919 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/EmptyPlaceholderItem.kt @@ -0,0 +1,10 @@ +package org.schabi.newpipe.local.subscription.item + +import com.xwray.groupie.kotlinandroidextensions.Item +import com.xwray.groupie.kotlinandroidextensions.ViewHolder +import org.schabi.newpipe.R + +class EmptyPlaceholderItem : Item() { + override fun getLayout(): Int = R.layout.list_empty_view + override fun bind(viewHolder: ViewHolder, position: Int) {} +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupAddItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupAddItem.kt new file mode 100644 index 000000000..ce5b60104 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupAddItem.kt @@ -0,0 +1,10 @@ +package org.schabi.newpipe.local.subscription.item + +import com.xwray.groupie.kotlinandroidextensions.Item +import com.xwray.groupie.kotlinandroidextensions.ViewHolder +import org.schabi.newpipe.R + +class FeedGroupAddItem : Item() { + override fun getLayout(): Int = R.layout.feed_group_add_new_item + override fun bind(viewHolder: ViewHolder, position: Int) {} +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupCardItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupCardItem.kt new file mode 100644 index 000000000..1f069f023 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupCardItem.kt @@ -0,0 +1,27 @@ +package org.schabi.newpipe.local.subscription.item + +import com.xwray.groupie.kotlinandroidextensions.Item +import com.xwray.groupie.kotlinandroidextensions.ViewHolder +import kotlinx.android.synthetic.main.feed_group_card_item.* +import org.schabi.newpipe.R +import org.schabi.newpipe.database.feed.model.FeedGroupEntity +import org.schabi.newpipe.local.subscription.FeedGroupIcon + +data class FeedGroupCardItem( + val groupId: Long = -1, + val name: String, + val icon: FeedGroupIcon +) : Item() { + constructor (feedGroupEntity: FeedGroupEntity) : this(feedGroupEntity.uid, feedGroupEntity.name, feedGroupEntity.icon) + + override fun getId(): Long { + return if (groupId == -1L) super.getId() else groupId + } + + override fun getLayout(): Int = R.layout.feed_group_card_item + + override fun bind(viewHolder: ViewHolder, position: Int) { + viewHolder.title.text = name + viewHolder.icon.setImageResource(icon.getDrawableRes(viewHolder.containerView.context)) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupCarouselItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupCarouselItem.kt new file mode 100644 index 000000000..92bb16aa1 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupCarouselItem.kt @@ -0,0 +1,57 @@ +package org.schabi.newpipe.local.subscription.item + +import android.content.Context +import android.os.Parcelable +import android.view.View +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.xwray.groupie.GroupAdapter +import com.xwray.groupie.kotlinandroidextensions.Item +import com.xwray.groupie.kotlinandroidextensions.ViewHolder +import kotlinx.android.synthetic.main.feed_item_carousel.* +import org.schabi.newpipe.R +import org.schabi.newpipe.local.subscription.decoration.FeedGroupCarouselDecoration + +class FeedGroupCarouselItem(context: Context, private val carouselAdapter: GroupAdapter) : Item() { + private val feedGroupCarouselDecoration = FeedGroupCarouselDecoration(context) + + private var linearLayoutManager: LinearLayoutManager? = null + private var listState: Parcelable? = null + + override fun getLayout() = R.layout.feed_item_carousel + + fun onSaveInstanceState(): Parcelable? { + listState = linearLayoutManager?.onSaveInstanceState() + return listState + } + + fun onRestoreInstanceState(state: Parcelable?) { + linearLayoutManager?.onRestoreInstanceState(state) + listState = state + } + + override fun createViewHolder(itemView: View): ViewHolder { + val viewHolder = super.createViewHolder(itemView) + + linearLayoutManager = LinearLayoutManager(itemView.context, RecyclerView.HORIZONTAL, false) + + viewHolder.recycler_view.apply { + layoutManager = linearLayoutManager + adapter = carouselAdapter + addItemDecoration(feedGroupCarouselDecoration) + } + + return viewHolder + } + + override fun bind(viewHolder: ViewHolder, position: Int) { + viewHolder.recycler_view.apply { adapter = carouselAdapter } + linearLayoutManager?.onRestoreInstanceState(listState) + } + + override fun unbind(viewHolder: ViewHolder) { + super.unbind(viewHolder) + + listState = linearLayoutManager?.onSaveInstanceState() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedImportExportItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedImportExportItem.kt new file mode 100644 index 000000000..f7eba9d8d --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedImportExportItem.kt @@ -0,0 +1,116 @@ +package org.schabi.newpipe.local.subscription.item + +import android.graphics.Color +import android.graphics.PorterDuff +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.annotation.DrawableRes +import com.xwray.groupie.kotlinandroidextensions.Item +import com.xwray.groupie.kotlinandroidextensions.ViewHolder +import kotlinx.android.synthetic.main.feed_import_export_group.* +import org.schabi.newpipe.R +import org.schabi.newpipe.extractor.NewPipe +import org.schabi.newpipe.extractor.exceptions.ExtractionException +import org.schabi.newpipe.util.AnimationUtils +import org.schabi.newpipe.util.ServiceHelper +import org.schabi.newpipe.util.ThemeHelper +import org.schabi.newpipe.views.CollapsibleView + +class FeedImportExportItem( + val onImportPreviousSelected: () -> Unit, + val onImportFromServiceSelected: (Int) -> Unit, + val onExportSelected: () -> Unit, + var isExpanded: Boolean = false +) : Item() { + companion object { + const val REFRESH_EXPANDED_STATUS = 123 + } + + override fun bind(viewHolder: ViewHolder, position: Int, payloads: MutableList) { + if (payloads.contains(REFRESH_EXPANDED_STATUS)) { + viewHolder.import_export_options.apply { if (isExpanded) expand() else collapse() } + return + } + + super.bind(viewHolder, position, payloads) + } + + override fun getLayout(): Int = R.layout.feed_import_export_group + + override fun bind(viewHolder: ViewHolder, position: Int) { + if (viewHolder.import_from_options.childCount == 0) setupImportFromItems(viewHolder.import_from_options) + if (viewHolder.export_to_options.childCount == 0) setupExportToItems(viewHolder.export_to_options) + + expandIconListener?.let { viewHolder.import_export_options.removeListener(it) } + expandIconListener = CollapsibleView.StateListener { newState -> + AnimationUtils.animateRotation(viewHolder.import_export_expand_icon, + 250, if (newState == CollapsibleView.COLLAPSED) 0 else 180) + } + + viewHolder.import_export_options.currentState = if (isExpanded) CollapsibleView.EXPANDED else CollapsibleView.COLLAPSED + viewHolder.import_export_expand_icon.rotation = if (isExpanded) 180F else 0F + viewHolder.import_export_options.ready() + + viewHolder.import_export_options.addListener(expandIconListener) + viewHolder.import_export.setOnClickListener { + viewHolder.import_export_options.switchState() + isExpanded = viewHolder.import_export_options.currentState == CollapsibleView.EXPANDED + } + } + + override fun unbind(holder: ViewHolder) { + super.unbind(holder) + expandIconListener?.let { holder.import_export_options.removeListener(it) } + expandIconListener = null + } + + private var expandIconListener: CollapsibleView.StateListener? = null + + private fun addItemView(title: String, @DrawableRes icon: Int, container: ViewGroup): View { + val itemRoot = View.inflate(container.context, R.layout.subscription_import_export_item, null) + val titleView = itemRoot.findViewById(android.R.id.text1) + val iconView = itemRoot.findViewById(android.R.id.icon1) + + titleView.text = title + iconView.setImageResource(icon) + + container.addView(itemRoot) + return itemRoot + } + + private fun setupImportFromItems(listHolder: ViewGroup) { + val previousBackupItem = addItemView(listHolder.context.getString(R.string.previous_export), + ThemeHelper.resolveResourceIdFromAttr(listHolder.context, R.attr.ic_backup), listHolder) + previousBackupItem.setOnClickListener { onImportPreviousSelected() } + + val iconColor = if (ThemeHelper.isLightThemeSelected(listHolder.context)) Color.BLACK else Color.WHITE + val services = listHolder.context.resources.getStringArray(R.array.service_list) + for (serviceName in services) { + try { + val service = NewPipe.getService(serviceName) + + val subscriptionExtractor = service.subscriptionExtractor ?: continue + + val supportedSources = subscriptionExtractor.supportedSources + if (supportedSources.isEmpty()) continue + + val itemView = addItemView(serviceName, ServiceHelper.getIcon(service.serviceId), listHolder) + val iconView = itemView.findViewById(android.R.id.icon1) + iconView.setColorFilter(iconColor, PorterDuff.Mode.SRC_IN) + + itemView.setOnClickListener { onImportFromServiceSelected(service.serviceId) } + } catch (e: ExtractionException) { + throw RuntimeException("Services array contains an entry that it's not a valid service name ($serviceName)", e) + } + + } + } + + private fun setupExportToItems(listHolder: ViewGroup) { + val previousBackupItem = addItemView(listHolder.context.getString(R.string.file), + ThemeHelper.resolveResourceIdFromAttr(listHolder.context, R.attr.ic_save), listHolder) + previousBackupItem.setOnClickListener { onExportSelected() } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/HeaderItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/HeaderItem.kt new file mode 100644 index 000000000..6cf672d44 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/HeaderItem.kt @@ -0,0 +1,19 @@ +package org.schabi.newpipe.local.subscription.item + +import android.view.View.OnClickListener +import com.xwray.groupie.kotlinandroidextensions.Item +import com.xwray.groupie.kotlinandroidextensions.ViewHolder +import kotlinx.android.synthetic.main.header_item.* +import org.schabi.newpipe.R + +class HeaderItem(val title: String, private val onClickListener: (() -> Unit)? = null) : Item() { + + override fun getLayout(): Int = R.layout.header_item + + override fun bind(viewHolder: ViewHolder, position: Int) { + viewHolder.header_title.text = title + + val listener: OnClickListener? = if (onClickListener != null) OnClickListener { onClickListener.invoke() } else null + viewHolder.root.setOnClickListener(listener) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/HeaderTextSideItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/HeaderTextSideItem.kt new file mode 100644 index 000000000..9ed077496 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/HeaderTextSideItem.kt @@ -0,0 +1,37 @@ +package org.schabi.newpipe.local.subscription.item + +import android.view.View.OnClickListener +import com.xwray.groupie.kotlinandroidextensions.Item +import com.xwray.groupie.kotlinandroidextensions.ViewHolder +import kotlinx.android.synthetic.main.header_with_text_item.* +import org.schabi.newpipe.R + +class HeaderTextSideItem( + val title: String, + var infoText: String? = null, + private val onClickListener: (() -> Unit)? = null +) : Item() { + + companion object { + const val UPDATE_INFO = 123 + } + + override fun getLayout(): Int = R.layout.header_with_text_item + + override fun bind(viewHolder: ViewHolder, position: Int, payloads: MutableList) { + if (payloads.contains(UPDATE_INFO)) { + viewHolder.header_info.text = infoText + return + } + + super.bind(viewHolder, position, payloads) + } + + override fun bind(viewHolder: ViewHolder, position: Int) { + viewHolder.header_title.text = title + viewHolder.header_info.text = infoText + + val listener: OnClickListener? = if (onClickListener != null) OnClickListener { onClickListener.invoke() } else null + viewHolder.root.setOnClickListener(listener) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/PickerIconItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/PickerIconItem.kt new file mode 100644 index 000000000..6af07eb96 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/PickerIconItem.kt @@ -0,0 +1,19 @@ +package org.schabi.newpipe.local.subscription.item + +import android.content.Context +import androidx.annotation.DrawableRes +import com.xwray.groupie.kotlinandroidextensions.Item +import com.xwray.groupie.kotlinandroidextensions.ViewHolder +import kotlinx.android.synthetic.main.picker_icon_item.* +import org.schabi.newpipe.R +import org.schabi.newpipe.local.subscription.FeedGroupIcon + +class PickerIconItem(context: Context, val icon: FeedGroupIcon) : Item() { + @DrawableRes val iconRes: Int = icon.getDrawableRes(context) + + override fun getLayout(): Int = R.layout.picker_icon_item + + override fun bind(viewHolder: ViewHolder, position: Int) { + viewHolder.icon_view.setImageResource(iconRes) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/PickerSubscriptionItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/PickerSubscriptionItem.kt new file mode 100644 index 000000000..592f7793f --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/PickerSubscriptionItem.kt @@ -0,0 +1,51 @@ +package org.schabi.newpipe.local.subscription.item + +import android.view.View +import com.nostra13.universalimageloader.core.DisplayImageOptions +import com.nostra13.universalimageloader.core.ImageLoader +import com.xwray.groupie.kotlinandroidextensions.Item +import com.xwray.groupie.kotlinandroidextensions.ViewHolder +import kotlinx.android.synthetic.main.picker_subscription_item.* +import org.schabi.newpipe.R +import org.schabi.newpipe.database.subscription.SubscriptionEntity +import org.schabi.newpipe.util.AnimationUtils +import org.schabi.newpipe.util.AnimationUtils.animateView +import org.schabi.newpipe.util.ImageDisplayConstants + +data class PickerSubscriptionItem(val subscriptionEntity: SubscriptionEntity, var isSelected: Boolean = false) : Item() { + companion object { + const val UPDATE_SELECTED = 123 + + val IMAGE_LOADING_OPTIONS: DisplayImageOptions = ImageDisplayConstants.DISPLAY_AVATAR_OPTIONS + } + + override fun getLayout(): Int = R.layout.picker_subscription_item + + override fun bind(viewHolder: ViewHolder, position: Int, payloads: MutableList) { + if (payloads.contains(UPDATE_SELECTED)) { + animateView(viewHolder.selected_highlight, AnimationUtils.Type.LIGHT_SCALE_AND_ALPHA, isSelected, 150) + return + } + + super.bind(viewHolder, position, payloads) + } + + override fun bind(viewHolder: ViewHolder, position: Int) { + ImageLoader.getInstance().displayImage(subscriptionEntity.avatarUrl, viewHolder.thumbnail_view, IMAGE_LOADING_OPTIONS) + + viewHolder.title_view.text = subscriptionEntity.name + viewHolder.selected_highlight.visibility = if (isSelected) View.VISIBLE else View.GONE + } + + override fun unbind(viewHolder: ViewHolder) { + super.unbind(viewHolder) + + viewHolder.selected_highlight.animate().setListener(null).cancel() + viewHolder.selected_highlight.visibility = View.GONE + viewHolder.selected_highlight.alpha = 1F + } + + override fun getId(): Long { + return subscriptionEntity.uid + } +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/services/BaseImportExportService.java b/app/src/main/java/org/schabi/newpipe/local/subscription/services/BaseImportExportService.java index 6b607cdca..e970ebfa4 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/services/BaseImportExportService.java +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/services/BaseImportExportService.java @@ -34,10 +34,9 @@ import android.widget.Toast; import org.reactivestreams.Publisher; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor; +import org.schabi.newpipe.local.subscription.SubscriptionManager; import org.schabi.newpipe.report.ErrorActivity; import org.schabi.newpipe.report.UserAction; -import org.schabi.newpipe.local.subscription.ImportExportEventListener; -import org.schabi.newpipe.local.subscription.SubscriptionService; import java.io.FileNotFoundException; import java.io.IOException; @@ -57,7 +56,7 @@ public abstract class BaseImportExportService extends Service { protected NotificationManagerCompat notificationManager; protected NotificationCompat.Builder notificationBuilder; - protected SubscriptionService subscriptionService; + protected SubscriptionManager subscriptionManager; protected final CompositeDisposable disposables = new CompositeDisposable(); protected final PublishProcessor notificationUpdater = PublishProcessor.create(); @@ -70,7 +69,7 @@ public abstract class BaseImportExportService extends Service { @Override public void onCreate() { super.onCreate(); - subscriptionService = SubscriptionService.getInstance(this); + subscriptionManager = new SubscriptionManager(this); setupNotification(); } diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/ImportExportEventListener.java b/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportEventListener.java similarity index 87% rename from app/src/main/java/org/schabi/newpipe/local/subscription/ImportExportEventListener.java rename to app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportEventListener.java index 01c0427f3..788073ee5 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/ImportExportEventListener.java +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportEventListener.java @@ -1,4 +1,4 @@ -package org.schabi.newpipe.local.subscription; +package org.schabi.newpipe.local.subscription.services; public interface ImportExportEventListener { /** diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/ImportExportJsonHelper.java b/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelper.java similarity index 98% rename from app/src/main/java/org/schabi/newpipe/local/subscription/ImportExportJsonHelper.java rename to app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelper.java index ebfff9fe2..5b5ebf702 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/ImportExportJsonHelper.java +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelper.java @@ -17,7 +17,7 @@ * along with this program. If not, see . */ -package org.schabi.newpipe.local.subscription; +package org.schabi.newpipe.local.subscription.services; import androidx.annotation.Nullable; diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsExportService.java b/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsExportService.java index 31cd4b603..358024574 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsExportService.java +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsExportService.java @@ -29,7 +29,6 @@ import org.reactivestreams.Subscription; import org.schabi.newpipe.R; import org.schabi.newpipe.database.subscription.SubscriptionEntity; import org.schabi.newpipe.extractor.subscription.SubscriptionItem; -import org.schabi.newpipe.local.subscription.ImportExportJsonHelper; import java.io.File; import java.io.FileNotFoundException; @@ -96,7 +95,7 @@ public class SubscriptionsExportService extends BaseImportExportService { private void startExport() { showToast(R.string.export_ongoing); - subscriptionService.subscriptionTable() + subscriptionManager.subscriptionTable() .getAll() .take(1) .map(subscriptionEntities -> { diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java b/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java index 62c1dfeb9..0d2f3757f 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java @@ -33,7 +33,6 @@ import org.schabi.newpipe.database.subscription.SubscriptionEntity; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.channel.ChannelInfo; import org.schabi.newpipe.extractor.subscription.SubscriptionItem; -import org.schabi.newpipe.local.subscription.ImportExportJsonHelper; import org.schabi.newpipe.util.Constants; import org.schabi.newpipe.util.ExtractorHelper; @@ -180,6 +179,7 @@ public class SubscriptionsImportService extends BaseImportExportService { .observeOn(Schedulers.io()) .doOnNext(getNotificationsConsumer()) + .buffer(BUFFER_COUNT_BEFORE_INSERT) .map(upsertBatch()) @@ -204,6 +204,7 @@ public class SubscriptionsImportService extends BaseImportExportService { @Override public void onError(Throwable error) { + Log.e(TAG, "Got an error!", error); handleError(error); } @@ -242,7 +243,7 @@ public class SubscriptionsImportService extends BaseImportExportService { if (n.isOnNext()) infoList.add(n.getValue()); } - return subscriptionService.upsertAll(infoList); + return subscriptionManager.upsertAll(infoList); }; } diff --git a/app/src/main/java/org/schabi/newpipe/report/UserAction.java b/app/src/main/java/org/schabi/newpipe/report/UserAction.java index 2cca9305a..f4f3e31b6 100644 --- a/app/src/main/java/org/schabi/newpipe/report/UserAction.java +++ b/app/src/main/java/org/schabi/newpipe/report/UserAction.java @@ -16,6 +16,7 @@ public enum UserAction { REQUESTED_PLAYLIST("requested playlist"), REQUESTED_KIOSK("requested kiosk"), REQUESTED_COMMENTS("requested comments"), + REQUESTED_FEED("requested feed"), DELETE_FROM_HISTORY("delete from history"), PLAY_STREAM("Play stream"), DOWNLOAD_POSTPROCESSING("download post-processing"), diff --git a/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java b/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java index 7064aec33..9ee12facc 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java @@ -18,9 +18,9 @@ import com.nostra13.universalimageloader.core.ImageLoader; import org.schabi.newpipe.R; import org.schabi.newpipe.database.subscription.SubscriptionEntity; +import org.schabi.newpipe.local.subscription.SubscriptionManager; import org.schabi.newpipe.report.ErrorActivity; import org.schabi.newpipe.report.UserAction; -import org.schabi.newpipe.local.subscription.SubscriptionService; import java.util.List; import java.util.Vector; @@ -99,8 +99,8 @@ public class SelectChannelFragment extends DialogFragment { emptyView.setVisibility(View.GONE); - SubscriptionService subscriptionService = SubscriptionService.getInstance(getContext()); - subscriptionService.getSubscription().toObservable() + SubscriptionManager subscriptionManager = new SubscriptionManager(getContext()); + subscriptionManager.subscriptions().toObservable() .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(getSubscriptionObserver()); diff --git a/app/src/main/java/org/schabi/newpipe/util/ConstantsKt.kt b/app/src/main/java/org/schabi/newpipe/util/ConstantsKt.kt new file mode 100644 index 000000000..8d24cb04e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/ConstantsKt.kt @@ -0,0 +1,6 @@ +package org.schabi.newpipe.util + +/** + * Default duration when using throttle functions across the app, in milliseconds. + */ +const val DEFAULT_THROTTLE_TIMEOUT = 120L diff --git a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java index 98264e1bf..2de8dc2bd 100644 --- a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java @@ -343,9 +343,13 @@ public class NavigationHelper { .commit(); } - public static void openWhatsNewFragment(FragmentManager fragmentManager) { + public static void openFeedFragment(FragmentManager fragmentManager) { + openFeedFragment(fragmentManager, -1, null); + } + + public static void openFeedFragment(FragmentManager fragmentManager, long groupId, @Nullable String groupName) { defaultTransaction(fragmentManager) - .replace(R.id.fragment_holder, new FeedFragment()) + .replace(R.id.fragment_holder, FeedFragment.newInstance(groupId, groupName)) .addToBackStack(null) .commit(); } diff --git a/app/src/main/java/org/schabi/newpipe/util/ThemeHelper.java b/app/src/main/java/org/schabi/newpipe/util/ThemeHelper.java index 661aa47c1..bd51919c7 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ThemeHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ThemeHelper.java @@ -99,6 +99,17 @@ public class ThemeHelper { return isLightThemeSelected(context) ? R.style.LightDialogTheme : R.style.DarkDialogTheme; } + /** + * Return a min-width dialog theme styled according to the (default) selected theme. + * + * @param context context to get the selected theme + * @return the dialog style (the default one) + */ + @StyleRes + public static int getMinWidthDialogTheme(Context context) { + return isLightThemeSelected(context) ? R.style.LightDialogMinWidthTheme : R.style.DarkDialogMinWidthTheme; + } + /** * Return the selected theme styled according to the serviceId. * diff --git a/app/src/main/res/drawable/dark_focused_selector.xml b/app/src/main/res/drawable/dark_focused_selector.xml new file mode 100644 index 000000000..102f40d76 --- /dev/null +++ b/app/src/main/res/drawable/dark_focused_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/dashed_border_black.xml b/app/src/main/res/drawable/dashed_border_black.xml new file mode 100644 index 000000000..b6bac6252 --- /dev/null +++ b/app/src/main/res/drawable/dashed_border_black.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/dashed_border_dark.xml b/app/src/main/res/drawable/dashed_border_dark.xml new file mode 100644 index 000000000..5af152ecc --- /dev/null +++ b/app/src/main/res/drawable/dashed_border_dark.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/dashed_border_light.xml b/app/src/main/res/drawable/dashed_border_light.xml new file mode 100644 index 000000000..5d29112bd --- /dev/null +++ b/app/src/main/res/drawable/dashed_border_light.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_asterisk_black_24dp.xml b/app/src/main/res/drawable/ic_asterisk_black_24dp.xml new file mode 100644 index 000000000..fa16cd5e8 --- /dev/null +++ b/app/src/main/res/drawable/ic_asterisk_black_24dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_asterisk_white_24dp.xml b/app/src/main/res/drawable/ic_asterisk_white_24dp.xml new file mode 100644 index 000000000..bd487cb55 --- /dev/null +++ b/app/src/main/res/drawable/ic_asterisk_white_24dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_car_black_24dp.xml b/app/src/main/res/drawable/ic_car_black_24dp.xml new file mode 100644 index 000000000..6aa8cdd82 --- /dev/null +++ b/app/src/main/res/drawable/ic_car_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_car_white_24dp.xml b/app/src/main/res/drawable/ic_car_white_24dp.xml new file mode 100644 index 000000000..7ad263933 --- /dev/null +++ b/app/src/main/res/drawable/ic_car_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_computer_black_24dp.xml b/app/src/main/res/drawable/ic_computer_black_24dp.xml new file mode 100644 index 000000000..b03d9c0ce --- /dev/null +++ b/app/src/main/res/drawable/ic_computer_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_computer_white_24dp.xml b/app/src/main/res/drawable/ic_computer_white_24dp.xml new file mode 100644 index 000000000..c4bdad688 --- /dev/null +++ b/app/src/main/res/drawable/ic_computer_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_edit_black_24dp.xml b/app/src/main/res/drawable/ic_edit_black_24dp.xml new file mode 100644 index 000000000..43489826e --- /dev/null +++ b/app/src/main/res/drawable/ic_edit_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_edit_white_24dp.xml b/app/src/main/res/drawable/ic_edit_white_24dp.xml new file mode 100644 index 000000000..88f94780f --- /dev/null +++ b/app/src/main/res/drawable/ic_edit_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_emoticon_black_24dp.xml b/app/src/main/res/drawable/ic_emoticon_black_24dp.xml new file mode 100644 index 000000000..45f489d80 --- /dev/null +++ b/app/src/main/res/drawable/ic_emoticon_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_emoticon_white_24dp.xml b/app/src/main/res/drawable/ic_emoticon_white_24dp.xml new file mode 100644 index 000000000..89ca90fb5 --- /dev/null +++ b/app/src/main/res/drawable/ic_emoticon_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_explore_black_24dp.xml b/app/src/main/res/drawable/ic_explore_black_24dp.xml new file mode 100644 index 000000000..c898ed9a5 --- /dev/null +++ b/app/src/main/res/drawable/ic_explore_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_explore_white_24dp.xml b/app/src/main/res/drawable/ic_explore_white_24dp.xml new file mode 100644 index 000000000..65f2818a6 --- /dev/null +++ b/app/src/main/res/drawable/ic_explore_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_fastfood_black_24dp.xml b/app/src/main/res/drawable/ic_fastfood_black_24dp.xml new file mode 100644 index 000000000..fac047550 --- /dev/null +++ b/app/src/main/res/drawable/ic_fastfood_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_fastfood_white_24dp.xml b/app/src/main/res/drawable/ic_fastfood_white_24dp.xml new file mode 100644 index 000000000..39bbee49a --- /dev/null +++ b/app/src/main/res/drawable/ic_fastfood_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_fitness_black_24dp.xml b/app/src/main/res/drawable/ic_fitness_black_24dp.xml new file mode 100644 index 000000000..40a1cf9c1 --- /dev/null +++ b/app/src/main/res/drawable/ic_fitness_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_fitness_white_24dp.xml b/app/src/main/res/drawable/ic_fitness_white_24dp.xml new file mode 100644 index 000000000..1b2d3b4be --- /dev/null +++ b/app/src/main/res/drawable/ic_fitness_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_heart_black_24dp.xml b/app/src/main/res/drawable/ic_heart_black_24dp.xml new file mode 100644 index 000000000..25cb46e83 --- /dev/null +++ b/app/src/main/res/drawable/ic_heart_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_heart_white_24dp.xml b/app/src/main/res/drawable/ic_heart_white_24dp.xml new file mode 100644 index 000000000..02c6396ee --- /dev/null +++ b/app/src/main/res/drawable/ic_heart_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_kids_black_24dp.xml b/app/src/main/res/drawable/ic_kids_black_24dp.xml new file mode 100644 index 000000000..d1d8e01e7 --- /dev/null +++ b/app/src/main/res/drawable/ic_kids_black_24dp.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_kids_white_24dp.xml b/app/src/main/res/drawable/ic_kids_white_24dp.xml new file mode 100644 index 000000000..c5dda16c8 --- /dev/null +++ b/app/src/main/res/drawable/ic_kids_white_24dp.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_megaphone_black_24dp.xml b/app/src/main/res/drawable/ic_megaphone_black_24dp.xml new file mode 100644 index 000000000..21622c162 --- /dev/null +++ b/app/src/main/res/drawable/ic_megaphone_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_megaphone_white_24dp.xml b/app/src/main/res/drawable/ic_megaphone_white_24dp.xml new file mode 100644 index 000000000..90e6ff215 --- /dev/null +++ b/app/src/main/res/drawable/ic_megaphone_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_mic_black_24dp.xml b/app/src/main/res/drawable/ic_mic_black_24dp.xml new file mode 100644 index 000000000..25d8951a7 --- /dev/null +++ b/app/src/main/res/drawable/ic_mic_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_mic_white_24dp.xml b/app/src/main/res/drawable/ic_mic_white_24dp.xml new file mode 100644 index 000000000..36ee9ff81 --- /dev/null +++ b/app/src/main/res/drawable/ic_mic_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_money_black_24dp.xml b/app/src/main/res/drawable/ic_money_black_24dp.xml new file mode 100644 index 000000000..4019c2e46 --- /dev/null +++ b/app/src/main/res/drawable/ic_money_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_money_white_24dp.xml b/app/src/main/res/drawable/ic_money_white_24dp.xml new file mode 100644 index 000000000..2407a2b73 --- /dev/null +++ b/app/src/main/res/drawable/ic_money_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_motorcycle_black_24dp.xml b/app/src/main/res/drawable/ic_motorcycle_black_24dp.xml new file mode 100644 index 000000000..6009979dd --- /dev/null +++ b/app/src/main/res/drawable/ic_motorcycle_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_motorcycle_white_24dp.xml b/app/src/main/res/drawable/ic_motorcycle_white_24dp.xml new file mode 100644 index 000000000..b94c29f8f --- /dev/null +++ b/app/src/main/res/drawable/ic_motorcycle_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_movie_black_24dp.xml b/app/src/main/res/drawable/ic_movie_black_24dp.xml new file mode 100644 index 000000000..d70c00f00 --- /dev/null +++ b/app/src/main/res/drawable/ic_movie_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_movie_white_24dp.xml b/app/src/main/res/drawable/ic_movie_white_24dp.xml new file mode 100644 index 000000000..f73e76774 --- /dev/null +++ b/app/src/main/res/drawable/ic_movie_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_music_note_black_24dp.xml b/app/src/main/res/drawable/ic_music_note_black_24dp.xml new file mode 100644 index 000000000..698159295 --- /dev/null +++ b/app/src/main/res/drawable/ic_music_note_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_music_note_white_24dp.xml b/app/src/main/res/drawable/ic_music_note_white_24dp.xml new file mode 100644 index 000000000..1d38e6e22 --- /dev/null +++ b/app/src/main/res/drawable/ic_music_note_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_people_black_24dp.xml b/app/src/main/res/drawable/ic_people_black_24dp.xml new file mode 100644 index 000000000..d0fe31838 --- /dev/null +++ b/app/src/main/res/drawable/ic_people_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_people_white_24dp.xml b/app/src/main/res/drawable/ic_people_white_24dp.xml new file mode 100644 index 000000000..e6fa4c583 --- /dev/null +++ b/app/src/main/res/drawable/ic_people_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_person_black_24dp.xml b/app/src/main/res/drawable/ic_person_black_24dp.xml new file mode 100644 index 000000000..f0ff6a871 --- /dev/null +++ b/app/src/main/res/drawable/ic_person_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_person_white_24dp.xml b/app/src/main/res/drawable/ic_person_white_24dp.xml new file mode 100644 index 000000000..99f299963 --- /dev/null +++ b/app/src/main/res/drawable/ic_person_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_pets_black_24dp.xml b/app/src/main/res/drawable/ic_pets_black_24dp.xml new file mode 100644 index 000000000..b6247bd87 --- /dev/null +++ b/app/src/main/res/drawable/ic_pets_black_24dp.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_pets_white_24dp.xml b/app/src/main/res/drawable/ic_pets_white_24dp.xml new file mode 100644 index 000000000..46724a33d --- /dev/null +++ b/app/src/main/res/drawable/ic_pets_white_24dp.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_radio_black_24dp.xml b/app/src/main/res/drawable/ic_radio_black_24dp.xml new file mode 100644 index 000000000..00da9101f --- /dev/null +++ b/app/src/main/res/drawable/ic_radio_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_radio_white_24dp.xml b/app/src/main/res/drawable/ic_radio_white_24dp.xml new file mode 100644 index 000000000..df563ec1d --- /dev/null +++ b/app/src/main/res/drawable/ic_radio_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_refresh_black_24dp.xml b/app/src/main/res/drawable/ic_refresh_black_24dp.xml new file mode 100644 index 000000000..8229a9a64 --- /dev/null +++ b/app/src/main/res/drawable/ic_refresh_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_refresh_white_24dp.xml b/app/src/main/res/drawable/ic_refresh_white_24dp.xml new file mode 100644 index 000000000..a8175c316 --- /dev/null +++ b/app/src/main/res/drawable/ic_refresh_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_restaurant_black_24dp.xml b/app/src/main/res/drawable/ic_restaurant_black_24dp.xml new file mode 100644 index 000000000..0a8c6bde9 --- /dev/null +++ b/app/src/main/res/drawable/ic_restaurant_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_restaurant_white_24dp.xml b/app/src/main/res/drawable/ic_restaurant_white_24dp.xml new file mode 100644 index 000000000..c81618bb7 --- /dev/null +++ b/app/src/main/res/drawable/ic_restaurant_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_school_black_24dp.xml b/app/src/main/res/drawable/ic_school_black_24dp.xml new file mode 100644 index 000000000..8f52f0dde --- /dev/null +++ b/app/src/main/res/drawable/ic_school_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_school_white_24dp.xml b/app/src/main/res/drawable/ic_school_white_24dp.xml new file mode 100644 index 000000000..e3888411a --- /dev/null +++ b/app/src/main/res/drawable/ic_school_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_shopping_cart_black_24dp.xml b/app/src/main/res/drawable/ic_shopping_cart_black_24dp.xml new file mode 100644 index 000000000..452332095 --- /dev/null +++ b/app/src/main/res/drawable/ic_shopping_cart_black_24dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_shopping_cart_white_24dp.xml b/app/src/main/res/drawable/ic_shopping_cart_white_24dp.xml new file mode 100644 index 000000000..a55bf8a88 --- /dev/null +++ b/app/src/main/res/drawable/ic_shopping_cart_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_sports_black_24dp.xml b/app/src/main/res/drawable/ic_sports_black_24dp.xml new file mode 100644 index 000000000..5a54580c1 --- /dev/null +++ b/app/src/main/res/drawable/ic_sports_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_sports_white_24dp.xml b/app/src/main/res/drawable/ic_sports_white_24dp.xml new file mode 100644 index 000000000..611852728 --- /dev/null +++ b/app/src/main/res/drawable/ic_sports_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_stars_black_24dp.xml b/app/src/main/res/drawable/ic_stars_black_24dp.xml new file mode 100644 index 000000000..66a89110e --- /dev/null +++ b/app/src/main/res/drawable/ic_stars_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_stars_white_24dp.xml b/app/src/main/res/drawable/ic_stars_white_24dp.xml new file mode 100644 index 000000000..2de1fd808 --- /dev/null +++ b/app/src/main/res/drawable/ic_stars_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_sunny_black_24dp.xml b/app/src/main/res/drawable/ic_sunny_black_24dp.xml new file mode 100644 index 000000000..fee59df13 --- /dev/null +++ b/app/src/main/res/drawable/ic_sunny_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_sunny_white_24dp.xml b/app/src/main/res/drawable/ic_sunny_white_24dp.xml new file mode 100644 index 000000000..c6cb469ef --- /dev/null +++ b/app/src/main/res/drawable/ic_sunny_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_telescope_black_24dp.xml b/app/src/main/res/drawable/ic_telescope_black_24dp.xml new file mode 100644 index 000000000..9c6132ecc --- /dev/null +++ b/app/src/main/res/drawable/ic_telescope_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_telescope_white_24dp.xml b/app/src/main/res/drawable/ic_telescope_white_24dp.xml new file mode 100644 index 000000000..ea870fd87 --- /dev/null +++ b/app/src/main/res/drawable/ic_telescope_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_trending_up_black_24dp.xml b/app/src/main/res/drawable/ic_trending_up_black_24dp.xml new file mode 100644 index 000000000..706af95a4 --- /dev/null +++ b/app/src/main/res/drawable/ic_trending_up_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_trending_up_white_24dp.xml b/app/src/main/res/drawable/ic_trending_up_white_24dp.xml new file mode 100644 index 000000000..403674223 --- /dev/null +++ b/app/src/main/res/drawable/ic_trending_up_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_videogame_black_24dp.xml b/app/src/main/res/drawable/ic_videogame_black_24dp.xml new file mode 100644 index 000000000..df872c96c --- /dev/null +++ b/app/src/main/res/drawable/ic_videogame_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_videogame_white_24dp.xml b/app/src/main/res/drawable/ic_videogame_white_24dp.xml new file mode 100644 index 000000000..593e49e14 --- /dev/null +++ b/app/src/main/res/drawable/ic_videogame_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_watch_later_black_24dp.xml b/app/src/main/res/drawable/ic_watch_later_black_24dp.xml new file mode 100644 index 000000000..5a1b9ac74 --- /dev/null +++ b/app/src/main/res/drawable/ic_watch_later_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_watch_later_white_24dp.xml b/app/src/main/res/drawable/ic_watch_later_white_24dp.xml new file mode 100644 index 000000000..f9fffbc43 --- /dev/null +++ b/app/src/main/res/drawable/ic_watch_later_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_work_black_24dp.xml b/app/src/main/res/drawable/ic_work_black_24dp.xml new file mode 100644 index 000000000..2668f2c43 --- /dev/null +++ b/app/src/main/res/drawable/ic_work_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_work_white_24dp.xml b/app/src/main/res/drawable/ic_work_white_24dp.xml new file mode 100644 index 000000000..8a1db7828 --- /dev/null +++ b/app/src/main/res/drawable/ic_work_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_world_black_24dp.xml b/app/src/main/res/drawable/ic_world_black_24dp.xml new file mode 100644 index 000000000..48785e7d7 --- /dev/null +++ b/app/src/main/res/drawable/ic_world_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_world_white_24dp.xml b/app/src/main/res/drawable/ic_world_white_24dp.xml new file mode 100644 index 000000000..01583e467 --- /dev/null +++ b/app/src/main/res/drawable/ic_world_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/light_focused_selector.xml b/app/src/main/res/drawable/light_focused_selector.xml new file mode 100644 index 000000000..102f40d76 --- /dev/null +++ b/app/src/main/res/drawable/light_focused_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_feed_group_create.xml b/app/src/main/res/layout/dialog_feed_group_create.xml new file mode 100644 index 000000000..5adb4d9f3 --- /dev/null +++ b/app/src/main/res/layout/dialog_feed_group_create.xml @@ -0,0 +1,153 @@ + + + + + + + + + + + + + + + + +