diff --git a/app/build.gradle b/app/build.gradle
index 7d0ce971c..b7e216a02 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -18,6 +18,12 @@ android {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
+ debug {
+ multiDexEnabled true
+
+ debuggable true
+ applicationIdSuffix ".debug"
+ }
}
lintOptions {
@@ -58,4 +64,16 @@ dependencies {
compile 'com.github.nirhart:parallaxscroll:1.0'
compile 'com.nononsenseapps:filepicker:3.0.0'
compile 'com.google.android.exoplayer:exoplayer:r2.4.2'
+
+ debugCompile 'com.facebook.stetho:stetho:1.5.0'
+ debugCompile 'com.facebook.stetho:stetho-urlconnection:1.5.0'
+ debugCompile 'com.android.support:multidex:1.0.1'
+
+ compile "android.arch.persistence.room:runtime:1.0.0-alpha8"
+ annotationProcessor "android.arch.persistence.room:compiler:1.0.0-alpha8"
+
+ compile "io.reactivex.rxjava2:rxjava:2.1.2"
+ compile "io.reactivex.rxjava2:rxandroid:2.0.1"
+ compile 'com.jakewharton.rxbinding2:rxbinding:2.0.0'
+ compile "android.arch.persistence.room:rxjava2:1.0.0-alpha8"
}
diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml
new file mode 100644
index 000000000..614f93faf
--- /dev/null
+++ b/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/debug/java/org/schabi/newpipe/DebugApp.java b/app/src/debug/java/org/schabi/newpipe/DebugApp.java
new file mode 100644
index 000000000..964d7c099
--- /dev/null
+++ b/app/src/debug/java/org/schabi/newpipe/DebugApp.java
@@ -0,0 +1,63 @@
+package org.schabi.newpipe;
+
+import android.content.Context;
+import android.support.multidex.MultiDex;
+
+import com.facebook.stetho.Stetho;
+
+/**
+ * Copyright (C) Hans-Christoph Steiner 2016
+ * App.java is part of NewPipe.
+ *
+ * NewPipe 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.
+ *
+ * NewPipe 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 NewPipe. If not, see .
+ */
+
+public class DebugApp extends App {
+ private static final String TAG = DebugApp.class.toString();
+
+ @Override
+ protected void attachBaseContext(Context base) {
+ super.attachBaseContext(base);
+ MultiDex.install(this);
+ }
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+
+ initStetho();
+ }
+
+ private void initStetho() {
+ // Create an InitializerBuilder
+ Stetho.InitializerBuilder initializerBuilder =
+ Stetho.newInitializerBuilder(this);
+
+ // Enable Chrome DevTools
+ initializerBuilder.enableWebKitInspector(
+ Stetho.defaultInspectorModulesProvider(this)
+ );
+
+ // Enable command line interface
+ initializerBuilder.enableDumpapp(
+ Stetho.defaultDumperPluginsProvider(getApplicationContext())
+ );
+
+ // Use the InitializerBuilder to generate an Initializer
+ Stetho.Initializer initializer = initializerBuilder.build();
+
+ // Initialize Stetho with the Initializer
+ Stetho.initialize(initializer);
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/App.java b/app/src/main/java/org/schabi/newpipe/App.java
index ee1de0196..94a3daf4f 100644
--- a/app/src/main/java/org/schabi/newpipe/App.java
+++ b/app/src/main/java/org/schabi/newpipe/App.java
@@ -3,6 +3,7 @@ package org.schabi.newpipe;
import android.app.Application;
import android.content.Context;
+import com.facebook.stetho.Stetho;
import com.nostra13.universalimageloader.core.ImageLoader;
import com.nostra13.universalimageloader.core.ImageLoaderConfiguration;
@@ -64,6 +65,8 @@ public class App extends Application {
"Could not initialize ACRA crash report", R.string.app_ui_crash));
}
+ NewPipeDatabase.getInstance( getApplicationContext() );
+
//init NewPipe
NewPipe.init(Downloader.getInstance());
diff --git a/app/src/main/java/org/schabi/newpipe/MainActivity.java b/app/src/main/java/org/schabi/newpipe/MainActivity.java
index 9cc43bb44..c3782965e 100644
--- a/app/src/main/java/org/schabi/newpipe/MainActivity.java
+++ b/app/src/main/java/org/schabi/newpipe/MainActivity.java
@@ -24,7 +24,11 @@ import android.content.Intent;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.preference.PreferenceManager;
+import android.support.design.widget.TabLayout;
import android.support.v4.app.Fragment;
+import android.support.v4.app.FragmentManager;
+import android.support.v4.app.FragmentStatePagerAdapter;
+import android.support.v4.view.ViewPager;
import android.support.v7.app.ActionBar;
import android.support.v7.app.AppCompatActivity;
import android.support.v7.widget.Toolbar;
@@ -36,6 +40,9 @@ import android.view.View;
import org.schabi.newpipe.download.DownloadActivity;
import org.schabi.newpipe.extractor.StreamingService;
+import org.schabi.newpipe.fragments.FeedFragment;
+import org.schabi.newpipe.fragments.MainFragment;
+import org.schabi.newpipe.fragments.SubscriptionFragment;
import org.schabi.newpipe.fragments.detail.VideoDetailFragment;
import org.schabi.newpipe.fragments.search.SearchFragment;
import org.schabi.newpipe.settings.SettingsActivity;
diff --git a/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java b/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java
new file mode 100644
index 000000000..3e3c4d9db
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java
@@ -0,0 +1,34 @@
+package org.schabi.newpipe;
+
+import android.arch.persistence.room.Room;
+import android.content.Context;
+import android.support.annotation.NonNull;
+
+import org.schabi.newpipe.database.AppDatabase;
+
+import static org.schabi.newpipe.database.AppDatabase.DATABASE_NAME;
+
+public class NewPipeDatabase {
+
+ private static AppDatabase sInstance;
+
+ // For Singleton instantiation
+ private static final Object LOCK = new Object();
+
+ @NonNull
+ public synchronized static AppDatabase getInstance(Context context) {
+ if (sInstance == null) {
+ synchronized (LOCK) {
+ if (sInstance == null) {
+
+ sInstance = Room.databaseBuilder(
+ context.getApplicationContext(),
+ AppDatabase.class,
+ DATABASE_NAME
+ ).build();
+ }
+ }
+ }
+ return sInstance;
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/database/AppDatabase.java b/app/src/main/java/org/schabi/newpipe/database/AppDatabase.java
new file mode 100644
index 000000000..8ce33d32d
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/AppDatabase.java
@@ -0,0 +1,15 @@
+package org.schabi.newpipe.database;
+
+import android.arch.persistence.room.Database;
+import android.arch.persistence.room.RoomDatabase;
+
+import org.schabi.newpipe.database.subscription.SubscriptionDAO;
+import org.schabi.newpipe.database.subscription.SubscriptionEntity;
+
+@Database(entities = {SubscriptionEntity.class}, version = 1, exportSchema = false)
+public abstract class AppDatabase extends RoomDatabase{
+
+ public static final String DATABASE_NAME = "newpipe.db";
+
+ public abstract SubscriptionDAO subscriptionDAO();
+}
diff --git a/app/src/main/java/org/schabi/newpipe/database/BasicDAO.java b/app/src/main/java/org/schabi/newpipe/database/BasicDAO.java
new file mode 100644
index 000000000..beb5f4b77
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/BasicDAO.java
@@ -0,0 +1,48 @@
+package org.schabi.newpipe.database;
+
+import android.arch.persistence.room.Dao;
+import android.arch.persistence.room.Delete;
+import android.arch.persistence.room.Insert;
+import android.arch.persistence.room.OnConflictStrategy;
+import android.arch.persistence.room.Update;
+
+import java.util.Collection;
+import java.util.List;
+
+import io.reactivex.Completable;
+import io.reactivex.Flowable;
+
+@Dao
+public interface BasicDAO {
+ /* Inserts */
+ @Insert(onConflict = OnConflictStrategy.FAIL)
+ long insert(final Entity entity);
+
+ @Insert(onConflict = OnConflictStrategy.FAIL)
+ List insertAll(final Entity... entities);
+
+ @Insert(onConflict = OnConflictStrategy.FAIL)
+ List insertAll(final Collection entities);
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ long upsert(final Entity entity);
+
+ /* Searches */
+ Flowable> findAll();
+
+ Flowable> listByService(int serviceId);
+
+ /* Deletes */
+ @Delete
+ int delete(final Entity entity);
+
+ @Delete
+ int delete(final Collection entities);
+
+ /* Updates */
+ @Update
+ int update(final Entity entity);
+
+ @Update
+ int update(final Collection entities);
+}
diff --git a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.java b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.java
new file mode 100644
index 000000000..c34048a3e
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.java
@@ -0,0 +1,30 @@
+package org.schabi.newpipe.database.subscription;
+
+import android.arch.persistence.room.Dao;
+import android.arch.persistence.room.Query;
+
+import org.schabi.newpipe.database.BasicDAO;
+
+import java.util.List;
+
+import io.reactivex.Flowable;
+
+import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_SERVICE_ID;
+import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_TABLE;
+import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_URL;
+
+@Dao
+public interface SubscriptionDAO extends BasicDAO {
+ @Override
+ @Query("SELECT * FROM " + SUBSCRIPTION_TABLE)
+ Flowable> findAll();
+
+ @Override
+ @Query("SELECT * FROM " + SUBSCRIPTION_TABLE + " WHERE " + SUBSCRIPTION_SERVICE_ID + " = :serviceId")
+ Flowable> listByService(int serviceId);
+
+ @Query("SELECT * FROM " + SUBSCRIPTION_TABLE + " WHERE " +
+ SUBSCRIPTION_URL + " LIKE :url AND " +
+ SUBSCRIPTION_SERVICE_ID + " = :serviceId")
+ Flowable> findAll(int serviceId, String url);
+}
diff --git a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.java b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.java
new file mode 100644
index 000000000..1e0a63bcd
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.java
@@ -0,0 +1,113 @@
+package org.schabi.newpipe.database.subscription;
+
+import android.arch.persistence.room.ColumnInfo;
+import android.arch.persistence.room.Entity;
+import android.arch.persistence.room.Ignore;
+import android.arch.persistence.room.Index;
+import android.arch.persistence.room.PrimaryKey;
+
+import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_SERVICE_ID;
+import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_TABLE;
+import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_URL;
+
+@Entity(tableName = SUBSCRIPTION_TABLE,
+ indices = {@Index(value = {SUBSCRIPTION_SERVICE_ID, SUBSCRIPTION_URL}, unique = true)})
+public class SubscriptionEntity {
+
+ final static String SUBSCRIPTION_TABLE = "subscriptions";
+ final static String SUBSCRIPTION_SERVICE_ID = "service_id";
+ final static String SUBSCRIPTION_URL = "url";
+ final static String SUBSCRIPTION_TITLE = "title";
+ final static String SUBSCRIPTION_THUMBNAIL_URL = "thumbnail_url";
+ final static String SUBSCRIPTION_SUBSCRIBER_COUNT = "subscriber_count";
+ final static String SUBSCRIPTION_DESCRIPTION = "description";
+
+ @PrimaryKey(autoGenerate = true)
+ private long uid = 0;
+
+ @ColumnInfo(name = SUBSCRIPTION_SERVICE_ID)
+ private int serviceId = -1;
+
+ @ColumnInfo(name = SUBSCRIPTION_URL)
+ private String url;
+
+ @ColumnInfo(name = SUBSCRIPTION_TITLE)
+ private String title;
+
+ @ColumnInfo(name = SUBSCRIPTION_THUMBNAIL_URL)
+ private String thumbnailUrl;
+
+ @ColumnInfo(name = SUBSCRIPTION_SUBSCRIBER_COUNT)
+ private Long subscriberCount;
+
+ @ColumnInfo(name = SUBSCRIPTION_DESCRIPTION)
+ private String description;
+
+ public long getUid() {
+ return uid;
+ }
+
+ /* Keep this package-private since UID should always be auto generated by Room impl */
+ void setUid(long uid) {
+ this.uid = uid;
+ }
+
+ public int getServiceId() {
+ return serviceId;
+ }
+
+ public void setServiceId(int serviceId) {
+ this.serviceId = serviceId;
+ }
+
+ public String getUrl() {
+ return url;
+ }
+
+ public void setUrl(String url) {
+ this.url = url;
+ }
+
+ public String getTitle() {
+ return title;
+ }
+
+ public void setTitle(String title) {
+ this.title = title;
+ }
+
+ public String getThumbnailUrl() {
+ return thumbnailUrl;
+ }
+
+ public void setThumbnailUrl(String thumbnailUrl) {
+ this.thumbnailUrl = thumbnailUrl;
+ }
+
+ public Long getSubscriberCount() {
+ return subscriberCount;
+ }
+
+ public void setSubscriberCount(Long subscriberCount) {
+ this.subscriberCount = subscriberCount;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public void setDescription(String description) {
+ this.description = description;
+ }
+
+ @Ignore
+ public void setData(final String title,
+ final String thumbnailUrl,
+ final String description,
+ final Long subscriberCount) {
+ this.setTitle(title);
+ this.setThumbnailUrl(thumbnailUrl);
+ this.setDescription(description);
+ this.setSubscriberCount(subscriberCount);
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/BlankFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/BlankFragment.java
new file mode 100644
index 000000000..ef92622e6
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/fragments/BlankFragment.java
@@ -0,0 +1,22 @@
+package org.schabi.newpipe.fragments;
+
+import android.os.Bundle;
+import android.support.annotation.Nullable;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import org.schabi.newpipe.R;
+
+public class BlankFragment extends BaseFragment {
+ @Nullable
+ @Override
+ public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState) {
+ return inflater.inflate(R.layout.fragment_blank, container, false);
+ }
+
+ @Override
+ protected void reloadContent() {
+
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/FeedFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/FeedFragment.java
new file mode 100644
index 000000000..155f1ba00
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/fragments/FeedFragment.java
@@ -0,0 +1,495 @@
+package org.schabi.newpipe.fragments;
+
+import android.os.Bundle;
+import android.os.Parcelable;
+import android.support.annotation.Nullable;
+import android.support.v7.app.ActionBar;
+import android.support.v7.widget.LinearLayoutManager;
+import android.support.v7.widget.RecyclerView;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import com.jakewharton.rxbinding2.view.RxView;
+
+import org.reactivestreams.Subscriber;
+import org.reactivestreams.Subscription;
+import org.schabi.newpipe.MainActivity;
+import org.schabi.newpipe.R;
+import org.schabi.newpipe.database.subscription.SubscriptionEntity;
+import org.schabi.newpipe.extractor.InfoItem;
+import org.schabi.newpipe.extractor.channel.ChannelInfo;
+import org.schabi.newpipe.info_list.InfoItemBuilder;
+import org.schabi.newpipe.info_list.InfoListAdapter;
+import org.schabi.newpipe.report.ErrorActivity;
+import org.schabi.newpipe.util.NavigationHelper;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import io.reactivex.Flowable;
+import io.reactivex.MaybeObserver;
+import io.reactivex.android.schedulers.AndroidSchedulers;
+import io.reactivex.annotations.NonNull;
+import io.reactivex.disposables.Disposable;
+import io.reactivex.functions.Consumer;
+
+import static org.schabi.newpipe.report.UserAction.REQUESTED_CHANNEL;
+import static org.schabi.newpipe.util.AnimationUtils.animateView;
+
+public class FeedFragment extends BaseFragment {
+ private static final String VIEW_STATE_KEY = "view_state_key";
+ private static final String INFO_ITEMS_KEY = "info_items_key";
+
+ private static final int FEED_LOAD_SIZE = 4;
+ private static final int LOAD_ITEM_DEBOUNCE_INTERVAL = 500;
+
+ private final String TAG = "FeedFragment@" + Integer.toHexString(hashCode());
+
+ private View inflatedView;
+ private View emptyPanel;
+ private View loadItemFooter;
+
+ private InfoListAdapter infoListAdapter;
+ private RecyclerView resultRecyclerView;
+
+ private Parcelable viewState;
+ private AtomicBoolean retainFeedItems;
+
+ private SubscriptionService subscriptionService;
+
+ private Disposable loadItemObserver;
+ private Disposable subscriptionObserver;
+ private Subscription feedSubscriber;
+
+ ///////////////////////////////////////////////////////////////////////////
+ // Fragment LifeCycle
+ ///////////////////////////////////////////////////////////////////////////
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ subscriptionService = SubscriptionService.getInstance(getContext());
+
+ retainFeedItems = new AtomicBoolean(false);
+
+ if (infoListAdapter == null) {
+ infoListAdapter = new InfoListAdapter(getActivity());
+ }
+
+ if (savedInstanceState != null) {
+ // Get recycler view state
+ viewState = savedInstanceState.getParcelable(VIEW_STATE_KEY);
+
+ // Deserialize and get recycler adapter list
+ final Object[] serializedInfoItems = (Object[]) savedInstanceState.getSerializable(INFO_ITEMS_KEY);
+ if (serializedInfoItems != null) {
+ final InfoItem[] infoItems = Arrays.copyOf(
+ serializedInfoItems,
+ serializedInfoItems.length,
+ InfoItem[].class
+ );
+ final List feedInfos = Arrays.asList(infoItems);
+ infoListAdapter.addInfoItemList( feedInfos );
+ }
+
+ // Already displayed feed items survive configuration changes
+ retainFeedItems.set(true);
+ }
+ }
+
+ @Nullable
+ @Override
+ public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState) {
+ if (inflatedView == null) {
+ inflatedView = inflater.inflate(R.layout.fragment_subscription, container, false);
+ }
+ return inflatedView;
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+
+ if (resultRecyclerView != null) {
+ outState.putParcelable(
+ VIEW_STATE_KEY,
+ resultRecyclerView.getLayoutManager().onSaveInstanceState()
+ );
+ }
+
+ if (infoListAdapter != null) {
+ outState.putSerializable(INFO_ITEMS_KEY, infoListAdapter.getItemsList().toArray());
+ }
+ }
+
+ @Override
+ public void onDestroyView() {
+ // Do not monitor for updates when user is not viewing the feed fragment.
+ // This is a waste of bandwidth.
+ if (loadItemObserver != null) loadItemObserver.dispose();
+ if (subscriptionObserver != null) subscriptionObserver.dispose();
+ if (feedSubscriber != null) feedSubscriber.cancel();
+
+ loadItemObserver = null;
+ subscriptionObserver = null;
+ feedSubscriber = null;
+
+ loadItemFooter = null;
+
+ // Retain the already displayed items for backstack pops
+ retainFeedItems.set(true);
+
+ super.onDestroyView();
+ }
+
+ @Override
+ public void onDestroy() {
+ subscriptionService = null;
+
+ super.onDestroy();
+ }
+
+ ///////////////////////////////////////////////////////////////////////////
+ // Fragment Views
+ ///////////////////////////////////////////////////////////////////////////
+
+ @Override
+ public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
+ if (DEBUG) Log.d(TAG, "onCreateOptionsMenu() called with: menu = [" + menu + "], inflater = [" + inflater + "]");
+ super.onCreateOptionsMenu(menu, inflater);
+
+ ActionBar supportActionBar = activity.getSupportActionBar();
+ if (supportActionBar != null) {
+ supportActionBar.setDisplayShowTitleEnabled(true);
+ supportActionBar.setDisplayHomeAsUpEnabled(true);
+ }
+ }
+
+ private RecyclerView.OnScrollListener getOnScrollListener() {
+ return new RecyclerView.OnScrollListener() {
+ @Override
+ public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
+ super.onScrollStateChanged(recyclerView, newState);
+ if (newState == RecyclerView.SCROLL_STATE_IDLE) {
+ viewState = recyclerView.getLayoutManager().onSaveInstanceState();
+ }
+ }
+ };
+ }
+
+ @Override
+ protected void initViews(View rootView, Bundle savedInstanceState) {
+ super.initViews(rootView, savedInstanceState);
+
+ if (infoListAdapter == null) return;
+
+ animateView(errorPanel, false, 200);
+ animateView(loadingProgressBar, true, 200);
+
+ emptyPanel = rootView.findViewById(R.id.empty_panel);
+
+ resultRecyclerView = rootView.findViewById(R.id.result_list_view);
+ resultRecyclerView.setLayoutManager(new LinearLayoutManager(activity));
+
+ loadItemFooter = activity.getLayoutInflater().inflate(R.layout.load_item_footer, resultRecyclerView, false);
+ infoListAdapter.setFooter(loadItemFooter);
+ infoListAdapter.showFooter(false);
+ infoListAdapter.setOnStreamInfoItemSelectedListener(new InfoItemBuilder.OnInfoItemSelectedListener() {
+ @Override
+ public void selected(int serviceId, String url, String title) {
+ NavigationHelper.openVideoDetailFragment(getFragmentManager(), serviceId, url, title);
+ }
+ });
+
+ resultRecyclerView.setAdapter(infoListAdapter);
+ resultRecyclerView.addOnScrollListener(getOnScrollListener());
+
+ if (viewState != null) {
+ resultRecyclerView.getLayoutManager().onRestoreInstanceState(viewState);
+ viewState = null;
+ }
+
+ if (activity.getSupportActionBar() != null) activity.getSupportActionBar().setTitle(R.string.fragment_whats_new);
+
+ populateFeed();
+ }
+
+ private void resetFragment() {
+ if (subscriptionObserver != null) subscriptionObserver.dispose();
+ if (infoListAdapter != null) infoListAdapter.clearStreamItemList();
+ }
+
+ @Override
+ protected void reloadContent() {
+ resetFragment();
+ populateFeed();
+ }
+
+ @Override
+ protected void setErrorMessage(String message, boolean showRetryButton) {
+ super.setErrorMessage(message, showRetryButton);
+
+ resetFragment();
+ }
+
+ /**
+ * Changes the state of the load item footer.
+ *
+ * If the current state of the feed is loaded, this displays the load item button and
+ * starts its reactor.
+ *
+ * Otherwise, show a spinner in place of the loader button. */
+ private void setLoader(final boolean isLoaded) {
+ if (loadItemFooter == null) return;
+
+ if (loadItemObserver != null) loadItemObserver.dispose();
+
+ if (isLoaded) {
+ loadItemObserver = getLoadItemObserver(loadItemFooter);
+ }
+
+ loadItemFooter.findViewById(R.id.paginate_progress_bar).setVisibility(isLoaded ? View.GONE : View.VISIBLE);
+ loadItemFooter.findViewById(R.id.load_more_text).setVisibility(isLoaded ? View.VISIBLE : View.GONE);
+ }
+
+ ///////////////////////////////////////////////////////////////////////////
+ // Feeds Loader
+ ///////////////////////////////////////////////////////////////////////////
+
+ /**
+ * Responsible for reacting to subscription database updates and displaying feeds.
+ *
+ * Upon each update, the feed info list is cleared unless the fragment is
+ * recently recovered from a configuration change or backstack.
+ *
+ * All existing and pending feed requests are dropped.
+ *
+ * The newly received list of subscriptions is then transformed into a
+ * flowable, reacting to pulling requests.
+ *
+ * Pulled requests are transformed first into ChannelInfo, then Stream Info items and
+ * displayed on the feed fragment.
+ **/
+ private void populateFeed() {
+ final Consumer> consumer = new Consumer>() {
+ @Override
+ public void accept(@NonNull List subscriptionEntities) throws Exception {
+ animateView(loadingProgressBar, false, 200);
+
+ if (subscriptionEntities.isEmpty()) {
+ infoListAdapter.clearStreamItemList();
+ emptyPanel.setVisibility(View.VISIBLE);
+ } else {
+ emptyPanel.setVisibility(View.INVISIBLE);
+ }
+
+ // show progress bar on receiving a non-empty updated list of subscriptions
+ if (!retainFeedItems.get() && !subscriptionEntities.isEmpty()) {
+ infoListAdapter.clearStreamItemList();
+ animateView(loadingProgressBar, true, 200);
+ }
+
+ retainFeedItems.set(false);
+ Flowable.fromIterable(subscriptionEntities)
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(getSubscriptionObserver());
+ }
+ };
+
+ final Consumer onError = new Consumer() {
+ @Override
+ public void accept(@NonNull Throwable exception) throws Exception {
+ onRxError(exception, "Subscription Database Reactor");
+ }
+ };
+
+ if (subscriptionObserver != null) subscriptionObserver.dispose();
+ subscriptionObserver = subscriptionService.getSubscription()
+ .onErrorReturnItem(Collections.emptyList())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(consumer, onError);
+ }
+
+ /**
+ * Responsible for reacting to user pulling request and starting a request for new feed stream.
+ *
+ * On initialization, it automatically requests the amount of feed needed to display
+ * a minimum amount required (FEED_LOAD_SIZE).
+ *
+ * Upon receiving a user pull, it creates a Single Observer to fetch the ChannelInfo
+ * containing the feed streams.
+ **/
+ private Subscriber getSubscriptionObserver() {
+ return new Subscriber() {
+ @Override
+ public void onSubscribe(Subscription s) {
+ if (feedSubscriber != null) feedSubscriber.cancel();
+ feedSubscriber = s;
+
+ final int requestSize = FEED_LOAD_SIZE - infoListAdapter.getItemsList().size();
+ if (requestSize > 0) {
+ requestFeed(requestSize);
+ } else {
+ setLoader(true);
+ }
+
+ animateView(loadingProgressBar, false, 200);
+ // Footer spinner persists until subscription list is exhausted.
+ infoListAdapter.showFooter(true);
+ }
+
+ @Override
+ public void onNext(SubscriptionEntity subscriptionEntity) {
+ setLoader(false);
+
+ subscriptionService.getChannelInfo(subscriptionEntity)
+ .observeOn(AndroidSchedulers.mainThread())
+ .onErrorComplete()
+ .subscribe(getChannelInfoObserver());
+ }
+
+ @Override
+ public void onError(Throwable exception) {
+ onRxError(exception, "Feed Pull Reactor");
+ }
+
+ @Override
+ public void onComplete() {
+ infoListAdapter.showFooter(false);
+ }
+ };
+ }
+
+ /**
+ * 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 dispose 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.
+ **/
+ private MaybeObserver getChannelInfoObserver() {
+ return new MaybeObserver() {
+ Disposable observer;
+ @Override
+ public void onSubscribe(Disposable d) {
+ observer = d;
+ }
+
+ // Called only when response is non-empty
+ @Override
+ public void onSuccess(ChannelInfo channelInfo) {
+ emptyPanel.setVisibility(View.INVISIBLE);
+
+ if (infoListAdapter == null || channelInfo.related_streams.isEmpty()) return;
+
+ final InfoItem item = channelInfo.related_streams.get(0);
+ // Keep requesting new items if the current one already exists
+ if (!doesItemExist(infoListAdapter.getItemsList(), item)) {
+ infoListAdapter.addInfoItem(item);
+ } else {
+ requestFeed(1);
+ }
+ onDone();
+ }
+
+ @Override
+ public void onError(Throwable exception) {
+ onRxError(exception, "Feed Display Reactor");
+ onDone();
+ }
+
+ // Called only when response is empty
+ @Override
+ public void onComplete() {
+ onDone();
+ }
+
+ private void onDone() {
+ setLoader(true);
+
+ observer.dispose();
+ observer = null;
+ }
+ };
+ }
+
+ private boolean doesItemExist(final List items, final InfoItem item) {
+ for (final InfoItem existingItem: items) {
+ if (existingItem.infoType() == item.infoType() &&
+ existingItem.getTitle().equals(item.getTitle()) &&
+ existingItem.getLink().equals(item.getLink())) return true;
+ }
+ return false;
+ }
+
+ private void requestFeed(final int count) {
+ if (feedSubscriber == null) return;
+
+ feedSubscriber.request(count);
+ }
+
+ private Disposable getLoadItemObserver(@NonNull final View itemLoader) {
+ final Consumer