() {
- @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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/feed_group_add_new_item.xml b/app/src/main/res/layout/feed_group_add_new_item.xml
new file mode 100644
index 000000000..3424762e2
--- /dev/null
+++ b/app/src/main/res/layout/feed_group_add_new_item.xml
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/feed_group_card_item.xml b/app/src/main/res/layout/feed_group_card_item.xml
new file mode 100644
index 000000000..b6bf8656b
--- /dev/null
+++ b/app/src/main/res/layout/feed_group_card_item.xml
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/feed_import_export_group.xml b/app/src/main/res/layout/feed_import_export_group.xml
new file mode 100644
index 000000000..2049db65e
--- /dev/null
+++ b/app/src/main/res/layout/feed_import_export_group.xml
@@ -0,0 +1,119 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/feed_item_carousel.xml b/app/src/main/res/layout/feed_item_carousel.xml
new file mode 100644
index 000000000..db3d9cb11
--- /dev/null
+++ b/app/src/main/res/layout/feed_item_carousel.xml
@@ -0,0 +1,7 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_feed.xml b/app/src/main/res/layout/fragment_feed.xml
index 71217eea3..c81d0ee00 100644
--- a/app/src/main/res/layout/fragment_feed.xml
+++ b/app/src/main/res/layout/fragment_feed.xml
@@ -6,13 +6,92 @@
android:layout_width="match_parent"
android:layout_height="match_parent">
+
+
+
+
+
+
+
+
+
+ android:visibility="gone"
+ tools:visibility="visible"
+ tools:listitem="@layout/list_stream_item"/>
+
+
+
+
+
+
+
-
+ android:layout_alignParentTop="true"
+ android:background="?attr/toolbar_shadow_drawable"/>
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_subscription.xml b/app/src/main/res/layout/fragment_subscription.xml
index 979993a56..d1281f462 100644
--- a/app/src/main/res/layout/fragment_subscription.xml
+++ b/app/src/main/res/layout/fragment_subscription.xml
@@ -1,7 +1,6 @@
@@ -9,11 +8,10 @@
+ tools:listitem="@layout/list_channel_item"/>
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/header_item.xml b/app/src/main/res/layout/header_item.xml
new file mode 100644
index 000000000..4d4e1b884
--- /dev/null
+++ b/app/src/main/res/layout/header_item.xml
@@ -0,0 +1,16 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/header_with_text_item.xml b/app/src/main/res/layout/header_with_text_item.xml
new file mode 100644
index 000000000..871893ad6
--- /dev/null
+++ b/app/src/main/res/layout/header_with_text_item.xml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/list_empty_view.xml b/app/src/main/res/layout/list_empty_view.xml
index e1833f243..094353324 100644
--- a/app/src/main/res/layout/list_empty_view.xml
+++ b/app/src/main/res/layout/list_empty_view.xml
@@ -3,7 +3,8 @@
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
- android:layout_height="match_parent"
+ android:layout_height="wrap_content"
+ android:minHeight="128dp"
android:gravity="center"
android:orientation="vertical">
diff --git a/app/src/main/res/layout/picker_icon_item.xml b/app/src/main/res/layout/picker_icon_item.xml
new file mode 100644
index 000000000..f156772b6
--- /dev/null
+++ b/app/src/main/res/layout/picker_icon_item.xml
@@ -0,0 +1,15 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/picker_subscription_item.xml b/app/src/main/res/layout/picker_subscription_item.xml
new file mode 100644
index 000000000..f78e6b720
--- /dev/null
+++ b/app/src/main/res/layout/picker_subscription_item.xml
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/subscription_header.xml b/app/src/main/res/layout/subscription_header.xml
index 821e1b2f4..9deabada0 100644
--- a/app/src/main/res/layout/subscription_header.xml
+++ b/app/src/main/res/layout/subscription_header.xml
@@ -7,37 +7,6 @@
android:orientation="vertical"
android:paddingBottom="12dp">
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index 5741d1b4f..425886cdc 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -14,6 +14,9 @@
#ffffff
#212121
#c8ffffff
+ #F8F8F8
+ #E9E9E9
+ #33000000
#222222
@@ -27,12 +30,18 @@
#424242
#ffffff
#af000000
+ #313131
+ #474747
+ #33FFFFFF
#000000
@color/dark_settings_accent_color
#1effffff
#23454545
+ #0F0F0F
+ #202020
+ #25FFFFFF
#ddffffff
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
index 582c4bade..ae1791870 100644
--- a/app/src/main/res/values/dimens.xml
+++ b/app/src/main/res/values/dimens.xml
@@ -28,6 +28,7 @@
180dp
150dp
+ 32dp
42dp
24dp
@@ -89,4 +90,10 @@
12sp
12sp
+
+
+ 12dp
+ 2dp
+ 4dp
+
diff --git a/app/src/main/res/values/settings_keys.xml b/app/src/main/res/values/settings_keys.xml
index fe096c9fd..1ffb84db4 100644
--- a/app/src/main/res/values/settings_keys.xml
+++ b/app/src/main/res/values/settings_keys.xml
@@ -9,6 +9,7 @@
@string/youtube
saved_tabs_key
+ feed_last_updated
download_path
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 2934769d0..172ae1248 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -36,7 +36,6 @@
Bookmarked Playlists
New Tab
Choose Tab
- What\'s New
Background
Popup
Add To
@@ -598,4 +597,17 @@
- %s second
- %s seconds
-
+
+
+ What\'s New
+ Check out your feed groups
+ Last updated: %s
+ Loading feed…
+ Processing feed…
+ Select subscriptions
+ %d selected
+ No subscription selected
+ Empty group name
+ Name
+ New
+
\ No newline at end of file
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
index d72a1b092..0383e6f0d 100644
--- a/app/src/main/res/values/styles.xml
+++ b/app/src/main/res/values/styles.xml
@@ -61,15 +61,53 @@
- @drawable/ic_delete_black_24dp
- @drawable/ic_settings_update_black
- @drawable/ic_done_black_24dp
+ - @drawable/ic_refresh_black_24dp
+ - @drawable/ic_computer_black_24dp
+ - @drawable/ic_videogame_black_24dp
+ - @drawable/ic_music_note_black_24dp
+ - @drawable/ic_stars_black_24dp
+ - @drawable/ic_sports_black_24dp
+ - @drawable/ic_money_black_24dp
+ - @drawable/ic_person_black_24dp
+ - @drawable/ic_people_black_24dp
+ - @drawable/ic_heart_black_24dp
+ - @drawable/ic_kids_black_24dp
+ - @drawable/ic_fastfood_black_24dp
+ - @drawable/ic_car_black_24dp
+ - @drawable/ic_motorcycle_black_24dp
+ - @drawable/ic_trending_up_black_24dp
+ - @drawable/ic_school_black_24dp
+ - @drawable/ic_asterisk_black_24dp
+ - @drawable/ic_emoticon_black_24dp
+ - @drawable/ic_edit_black_24dp
+ - @drawable/ic_explore_black_24dp
+ - @drawable/ic_fitness_black_24dp
+ - @drawable/ic_restaurant_black_24dp
+ - @drawable/ic_mic_black_24dp
+ - @drawable/ic_radio_black_24dp
+ - @drawable/ic_shopping_cart_black_24dp
+ - @drawable/ic_watch_later_black_24dp
+ - @drawable/ic_work_black_24dp
+ - @drawable/ic_movie_black_24dp
+ - @drawable/ic_pets_black_24dp
+ - @drawable/ic_world_black_24dp
+ - @drawable/ic_sunny_black_24dp
+ - @drawable/ic_telescope_black_24dp
+ - @drawable/ic_megaphone_black_24dp
- @color/light_separator_color
- @color/light_contrast_background_color
- @drawable/light_checked_selector
+ - @drawable/light_focused_selector
- @color/light_queue_background_color
- @drawable/toolbar_shadow_light
- @drawable/light_selector
- @color/light_ripple_color
- @drawable/progress_youtube_horizontal_light
+ - @color/light_card_item_background_color
+ - @color/light_card_item_contrast_color
+ - @color/light_border_color
+ - @drawable/dashed_border_light
- @style/PreferenceThemeOverlay.v14.Material
@@ -127,15 +165,53 @@
- @drawable/ic_pause_white_24dp
- @drawable/ic_settings_update_white
- @drawable/ic_done_white_24dp
+ - @drawable/ic_refresh_white_24dp
+ - @drawable/ic_computer_white_24dp
+ - @drawable/ic_videogame_white_24dp
+ - @drawable/ic_music_note_white_24dp
+ - @drawable/ic_stars_white_24dp
+ - @drawable/ic_sports_white_24dp
+ - @drawable/ic_money_white_24dp
+ - @drawable/ic_person_white_24dp
+ - @drawable/ic_people_white_24dp
+ - @drawable/ic_heart_white_24dp
+ - @drawable/ic_kids_white_24dp
+ - @drawable/ic_fastfood_white_24dp
+ - @drawable/ic_car_white_24dp
+ - @drawable/ic_motorcycle_white_24dp
+ - @drawable/ic_trending_up_white_24dp
+ - @drawable/ic_school_white_24dp
+ - @drawable/ic_asterisk_white_24dp
+ - @drawable/ic_emoticon_white_24dp
+ - @drawable/ic_edit_white_24dp
+ - @drawable/ic_explore_white_24dp
+ - @drawable/ic_fitness_white_24dp
+ - @drawable/ic_restaurant_white_24dp
+ - @drawable/ic_mic_white_24dp
+ - @drawable/ic_radio_white_24dp
+ - @drawable/ic_shopping_cart_white_24dp
+ - @drawable/ic_watch_later_white_24dp
+ - @drawable/ic_work_white_24dp
+ - @drawable/ic_movie_white_24dp
+ - @drawable/ic_pets_white_24dp
+ - @drawable/ic_world_white_24dp
+ - @drawable/ic_sunny_white_24dp
+ - @drawable/ic_telescope_white_24dp
+ - @drawable/ic_megaphone_white_24dp
- @color/dark_separator_color
- @color/dark_contrast_background_color
- @drawable/dark_checked_selector
+ - @drawable/dark_focused_selector
- @color/dark_queue_background_color
- @drawable/toolbar_shadow_dark
- @drawable/dark_selector
- @color/dark_ripple_color
- @drawable/progress_youtube_horizontal_dark
+ - @color/dark_card_item_background_color
+ - @color/dark_card_item_contrast_color
+ - @color/dark_border_color
+ - @drawable/dashed_border_dark
- @style/PreferenceThemeOverlay.v14.Material
@@ -148,6 +224,11 @@
- @color/black_separator_color
- @color/black_contrast_background_color
+
+ - @color/black_card_item_background_color
+ - @color/black_card_item_contrast_color
+ - @color/black_border_color
+ - @drawable/dashed_border_black
@@ -167,6 +248,22 @@
- @color/dark_dialog_background_color
+
+
+
+