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 onNext = new Consumer() { + @Override + public void accept(Object o) throws Exception { + requestFeed(FEED_LOAD_SIZE); + } + }; + + final Consumer onError = new Consumer() { + @Override + public void accept(Throwable throwable) throws Exception { + onRxError(throwable, "Load Button Reactor"); + } + }; + + return RxView.clicks(itemLoader) + .debounce(LOAD_ITEM_DEBOUNCE_INTERVAL, TimeUnit.MILLISECONDS) + .subscribe(onNext, onError); + } + + /////////////////////////////////////////////////////////////////////////// + // Fragment Error Handling + /////////////////////////////////////////////////////////////////////////// + + private void onRxError(final Throwable exception, final String tag) { + if (exception instanceof IOException) { + onRecoverableError(R.string.network_error); + } else { + onUnrecoverableError(exception, tag); + } + } + + private void onRecoverableError(int messageId) { + if (!this.isAdded()) return; + + if (DEBUG) Log.d(TAG, "onError() called with: messageId = [" + messageId + "]"); + setErrorMessage(getString(messageId), true); + } + + private void onUnrecoverableError(Throwable exception, final String tag) { + if (DEBUG) Log.d(TAG, "onUnrecoverableError() called with: exception = [" + exception + "]"); + ErrorActivity.reportError(getContext(), exception, MainActivity.class, null, ErrorActivity.ErrorInfo.make(REQUESTED_CHANNEL, "Feed", tag, R.string.general_error)); + + activity.finish(); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/MainFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/MainFragment.java index f8e460123..f7a59c1c9 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/MainFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/MainFragment.java @@ -3,7 +3,11 @@ package org.schabi.newpipe.fragments; import android.content.Context; import android.os.Bundle; import android.support.annotation.Nullable; +import android.support.design.widget.TabLayout; import android.support.v4.app.Fragment; +import android.support.v4.app.FragmentManager; +import android.support.v4.app.FragmentPagerAdapter; +import android.support.v4.view.ViewPager; import android.support.v7.app.ActionBar; import android.support.v7.app.AppCompatActivity; import android.util.Log; @@ -18,12 +22,14 @@ import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.R; import org.schabi.newpipe.util.NavigationHelper; -public class MainFragment extends Fragment { +public class MainFragment extends Fragment implements TabLayout.OnTabSelectedListener { private final String TAG = "MainFragment@" + Integer.toHexString(hashCode()); private static final boolean DEBUG = MainActivity.DEBUG; private AppCompatActivity activity; + private ViewPager viewPager; + /*////////////////////////////////////////////////////////////////////////// // Fragment's LifeCycle //////////////////////////////////////////////////////////////////////////*/ @@ -45,7 +51,19 @@ public class MainFragment extends Fragment { @Override public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { if (DEBUG) Log.d(TAG, "onCreateView() called with: inflater = [" + inflater + "], container = [" + container + "], savedInstanceState = [" + savedInstanceState + "]"); - return inflater.inflate(R.layout.fragment_main, container, false); + View inflatedView = inflater.inflate(R.layout.fragment_main, container, false); + + TabLayout tabLayout = (TabLayout) inflatedView.findViewById(R.id.main_tab_layout); + viewPager = (ViewPager) inflatedView.findViewById(R.id.pager); + + /* Nested fragment, use child fragment here to maintain backstack in view pager. */ + PagerAdapter adapter = new PagerAdapter(getChildFragmentManager()); + viewPager.setAdapter(adapter); + viewPager.setOffscreenPageLimit(adapter.getCount()); + + tabLayout.setupWithViewPager(viewPager); + + return inflatedView; } /*////////////////////////////////////////////////////////////////////////// @@ -74,4 +92,47 @@ public class MainFragment extends Fragment { } return super.onOptionsItemSelected(item); } + + @Override + public void onTabSelected(TabLayout.Tab tab) { + viewPager.setCurrentItem(tab.getPosition()); + } + + @Override + public void onTabUnselected(TabLayout.Tab tab) {} + + @Override + public void onTabReselected(TabLayout.Tab tab) {} + + private class PagerAdapter extends FragmentPagerAdapter { + + private int[] tabTitles = new int[]{ + R.string.tab_main, + R.string.tab_subscriptions + }; + + PagerAdapter(FragmentManager fm) { + super(fm); + } + + @Override + public Fragment getItem(int position) { + switch ( position ) { + case 1: + return new SubscriptionFragment(); + default: + return new BlankFragment(); + } + } + + @Override + public CharSequence getPageTitle(int position) { + return getString(this.tabTitles[position]); + } + + @Override + public int getCount() { + return this.tabTitles.length; + } + } } diff --git a/app/src/main/java/org/schabi/newpipe/fragments/SubscriptionFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/SubscriptionFragment.java new file mode 100644 index 000000000..f2db9018d --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/SubscriptionFragment.java @@ -0,0 +1,278 @@ +package org.schabi.newpipe.fragments; + +import android.os.Bundle; +import android.os.Parcelable; +import android.support.annotation.Nullable; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +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.ChannelInfoItem; +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.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +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.report.UserAction.REQUESTED_CHANNEL; +import static org.schabi.newpipe.util.AnimationUtils.animateView; + +public class SubscriptionFragment extends BaseFragment { + private static final String VIEW_STATE_KEY = "view_state_key"; + private final String TAG = "SubscriptionFragment@" + Integer.toHexString(hashCode()); + + private View inflatedView; + private View emptyPanel; + private View headerRootLayout; + private View whatsNewView; + + private InfoListAdapter infoListAdapter; + private RecyclerView resultRecyclerView; + private Parcelable viewState; + + /* Used for independent events */ + private CompositeDisposable disposables; + private SubscriptionService subscriptionService; + + /////////////////////////////////////////////////////////////////////////// + // Fragment LifeCycle + /////////////////////////////////////////////////////////////////////////// + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + disposables = new CompositeDisposable(); + subscriptionService = SubscriptionService.getInstance( getContext() ); + + if (savedInstanceState != null) { + viewState = savedInstanceState.getParcelable(VIEW_STATE_KEY); + } + } + + @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); + + outState.putParcelable(VIEW_STATE_KEY, viewState); + } + + @Override + public void onDestroyView() { + if (disposables != null) disposables.clear(); + + headerRootLayout = null; + whatsNewView = null; + + super.onDestroyView(); + } + + @Override + public void onDestroy() { + if (disposables != null) disposables.dispose(); + disposables = null; + + subscriptionService = null; + + super.onDestroy(); + } + + /////////////////////////////////////////////////////////////////////////// + // Fragment Views + /////////////////////////////////////////////////////////////////////////// + + 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(); + } + } + }; + } + + private View.OnClickListener getWhatsNewOnClickListener() { + return new View.OnClickListener() { + @Override + public void onClick(View view) { + NavigationHelper.openWhatsNewFragment(getParentFragment().getFragmentManager()); + } + }; + } + + @Override + protected void initViews(View rootView, Bundle savedInstanceState) { + super.initViews(rootView, savedInstanceState); + + emptyPanel = rootView.findViewById(R.id.empty_panel); + + resultRecyclerView = rootView.findViewById(R.id.result_list_view); + resultRecyclerView.setLayoutManager(new LinearLayoutManager(activity)); + resultRecyclerView.addOnScrollListener(getOnScrollListener()); + + if (infoListAdapter == null) { + infoListAdapter = new InfoListAdapter(getActivity()); + infoListAdapter.setFooter(activity.getLayoutInflater().inflate(R.layout.pignate_footer, resultRecyclerView, false)); + infoListAdapter.showFooter(false); + infoListAdapter.setOnChannelInfoItemSelectedListener(new InfoItemBuilder.OnInfoItemSelectedListener() { + @Override + public void selected(int serviceId, String url, String title) { + /* Requires the parent fragment to find holder for fragment replacement */ + NavigationHelper.openChannelFragment(getParentFragment().getFragmentManager(), serviceId, url, title); + } + }); + } + + headerRootLayout = activity.getLayoutInflater().inflate(R.layout.subscription_header, resultRecyclerView, false); + infoListAdapter.setHeader(headerRootLayout); + + whatsNewView = headerRootLayout.findViewById(R.id.whatsNew); + whatsNewView.setOnClickListener(getWhatsNewOnClickListener()); + + resultRecyclerView.setAdapter(infoListAdapter); + + populateView(); + } + + @Override + protected void reloadContent() { + populateView(); + } + + @Override + protected void setErrorMessage(String message, boolean showRetryButton) { + super.setErrorMessage(message, showRetryButton); + resetFragment(); + } + + private void resetFragment() { + if (disposables != null) disposables.clear(); + if (infoListAdapter != null) infoListAdapter.clearStreamItemList(); + } + + /////////////////////////////////////////////////////////////////////////// + // Subscriptions Loader + /////////////////////////////////////////////////////////////////////////// + + private void populateView() { + resetFragment(); + + animateView(loadingProgressBar, true, 200); + animateView(errorPanel, false, 200); + + subscriptionService.getSubscription().toObservable() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(getSubscriptionObserver()); + } + + private Observer> getSubscriptionObserver() { + return new Observer>() { + @Override + public void onSubscribe(Disposable d) { + animateView(loadingProgressBar, true, 200); + + disposables.add( d ); + } + + @Override + public void onNext(List subscriptions) { + animateView(loadingProgressBar, true, 200); + + infoListAdapter.clearStreamItemList(); + infoListAdapter.addInfoItemList( getSubscriptionItems(subscriptions) ); + + animateView(loadingProgressBar, false, 200); + + emptyPanel.setVisibility(subscriptions.isEmpty() ? View.VISIBLE : View.INVISIBLE); + + if (viewState != null && resultRecyclerView != null) { + resultRecyclerView.getLayoutManager().onRestoreInstanceState(viewState); + } + } + + @Override + public void onError(Throwable exception) { + if (exception instanceof IOException) { + onRecoverableError(R.string.network_error); + } else { + onUnrecoverableError(exception); + } + } + + @Override + public void onComplete() { + + } + }; + } + + private List getSubscriptionItems(List subscriptions) { + List items = new ArrayList<>(); + for (final SubscriptionEntity subscription: subscriptions) { + ChannelInfoItem item = new ChannelInfoItem(); + item.webPageUrl = subscription.getUrl(); + item.serviceId = subscription.getServiceId(); + item.channelName = subscription.getTitle(); + item.thumbnailUrl = subscription.getThumbnailUrl(); + item.subscriberCount = subscription.getSubscriberCount(); + item.description = subscription.getDescription(); + + items.add( item ); + } + Collections.sort(items, new Comparator() { + @Override + public int compare(InfoItem o1, InfoItem o2) { + return o1.getTitle().compareToIgnoreCase(o2.getTitle()); + } + }); + + return items; + } + + /////////////////////////////////////////////////////////////////////////// + // Fragment Error Handling + /////////////////////////////////////////////////////////////////////////// + + private void onRecoverableError(int messageId) { + if (!this.isAdded()) return; + + if (DEBUG) Log.d(TAG, "onError() called with: messageId = [" + messageId + "]"); + setErrorMessage(getString(messageId), true); + } + + private void onUnrecoverableError(Throwable exception) { + if (DEBUG) Log.d(TAG, "onUnrecoverableError() called with: exception = [" + exception + "]"); + ErrorActivity.reportError(getContext(), exception, MainActivity.class, null, ErrorActivity.ErrorInfo.make(REQUESTED_CHANNEL, "unknown", "unknown", R.string.general_error)); + activity.finish(); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/SubscriptionService.java b/app/src/main/java/org/schabi/newpipe/fragments/SubscriptionService.java new file mode 100644 index 000000000..369d65664 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/SubscriptionService.java @@ -0,0 +1,170 @@ +package org.schabi.newpipe.fragments; + +import android.content.Context; + +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.NewPipe; +import org.schabi.newpipe.extractor.StreamingService; +import org.schabi.newpipe.extractor.channel.ChannelExtractor; +import org.schabi.newpipe.extractor.channel.ChannelInfo; +import org.schabi.newpipe.extractor.exceptions.ExtractionException; + +import java.util.List; +import java.util.concurrent.Callable; +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.annotations.NonNull; +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 SubscriptionService sInstance; + private static final Object LOCK = new Object(); + + public static SubscriptionService getInstance(Context context) { + if (sInstance == null) { + synchronized (LOCK) { + if (sInstance == null) { + sInstance = new SubscriptionService(context); + } + } + } + return sInstance; + } + + protected final String TAG = "SubscriptionService@" + Integer.toHexString(hashCode()); + private static final int SUBSCRIPTION_DEBOUNCE_INTERVAL = 500; + private static final int SUBSCRIPTION_THREAD_POOL_SIZE = 4; + + private AppDatabase db; + private Flowable> subscription; + + private Scheduler subscriptionScheduler; + + private SubscriptionService(Context context) { + db = NewPipeDatabase.getInstance( context ); + 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().findAll() + // 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. + * */ + @android.support.annotation.NonNull + public Flowable> getSubscription() { + return subscription; + } + + public Maybe getChannelInfo(final SubscriptionEntity subscriptionEntity) { + final StreamingService service = getService(subscriptionEntity.getServiceId()); + if (service == null) return Maybe.empty(); + + final String url = subscriptionEntity.getUrl(); + final Callable callable = new Callable() { + @Override + public ChannelInfo call() throws Exception { + final ChannelExtractor extractor = service.getChannelExtractorInstance(url, 0); + return ChannelInfo.getInfo(extractor); + } + }; + + return Maybe.fromCallable(callable).subscribeOn(subscriptionScheduler); + } + + private StreamingService getService(final int serviceId) { + try { + return NewPipe.getService(serviceId); + } catch (ExtractionException e) { + return null; + } + } + + /** Returns the database access interface for subscription table. */ + public SubscriptionDAO subscriptionTable() { + return db.subscriptionDAO(); + } + + public Completable updateChannelInfo(final int serviceId, + final String channelUrl, + final ChannelInfo info) { + final Function, CompletableSource> update = new Function, CompletableSource>() { + @Override + public CompletableSource apply(@NonNull List subscriptionEntities) throws Exception { + 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(channelUrl, info, subscription)) { + subscription.setData(info.channel_name, info.avatar_url, "", info.subscriberCount); + + return update(subscription); + } + } + + return Completable.complete(); + } + }; + + return subscriptionTable().findAll(serviceId, channelUrl) + .firstOrError() + .flatMapCompletable(update); + } + + private Completable update(final SubscriptionEntity updatedSubscription) { + return Completable.fromRunnable(new Runnable() { + @Override + public void run() { + subscriptionTable().update(updatedSubscription); + } + }); + } + + private boolean isSubscriptionUpToDate(final String channelUrl, + final ChannelInfo info, + final SubscriptionEntity entity) { + return channelUrl.equals( entity.getUrl() ) && + info.service_id == entity.getServiceId() && + info.channel_name.equals( entity.getTitle() ) && + info.avatar_url.equals( entity.getThumbnailUrl() ) && + info.subscriberCount == entity.getSubscriberCount(); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/channel/ChannelFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/channel/ChannelFragment.java index 1bb7ddee7..583f00f0c 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/channel/ChannelFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/channel/ChannelFragment.java @@ -20,12 +20,17 @@ import android.view.ViewGroup; import android.widget.Button; import android.widget.ImageView; import android.widget.TextView; +import android.widget.Toast; + +import com.jakewharton.rxbinding2.view.RxView; import org.schabi.newpipe.ImageErrorLoadingListener; 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.fragments.BaseFragment; +import org.schabi.newpipe.fragments.SubscriptionService; import org.schabi.newpipe.fragments.search.OnScrollBelowItemsListener; import org.schabi.newpipe.info_list.InfoItemBuilder; import org.schabi.newpipe.info_list.InfoListAdapter; @@ -36,16 +41,30 @@ import org.schabi.newpipe.workers.ChannelExtractorWorker; import java.io.Serializable; import java.text.NumberFormat; import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import io.reactivex.Observer; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.annotations.NonNull; +import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.disposables.Disposable; +import io.reactivex.functions.Action; +import io.reactivex.functions.Consumer; +import io.reactivex.functions.Function; +import io.reactivex.schedulers.Schedulers; import static org.schabi.newpipe.util.AnimationUtils.animateView; public class ChannelFragment extends BaseFragment implements ChannelExtractorWorker.OnChannelInfoReceive { - private final String TAG = "ChannelFragment@" + Integer.toHexString(hashCode()); +private final String TAG = "ChannelFragment@" + Integer.toHexString(hashCode()); private static final String INFO_LIST_KEY = "info_list_key"; private static final String CHANNEL_INFO_KEY = "channel_info_key"; private static final String PAGE_NUMBER_KEY = "page_number_key"; + private static final int BUTTON_DEBOUNCE_INTERVAL = 100; + private InfoListAdapter infoListAdapter; private ChannelExtractorWorker currentChannelWorker; @@ -53,9 +72,15 @@ public class ChannelFragment extends BaseFragment implements ChannelExtractorWor private int serviceId = -1; private String channelName = ""; private String channelUrl = ""; + private String feedUrl = ""; private int pageNumber = 0; private boolean hasNextPage = true; + private SubscriptionService subscriptionService; + + private CompositeDisposable disposables; + private Disposable subscribeButtonMonitor; + /*////////////////////////////////////////////////////////////////////////// // Views //////////////////////////////////////////////////////////////////////////*/ @@ -67,7 +92,7 @@ public class ChannelFragment extends BaseFragment implements ChannelExtractorWor private ImageView headerAvatarView; private TextView headerTitleView; private TextView headerSubscribersTextView; - private Button headerRssButton; + private Button headerSubscribeButton; /*////////////////////////////////////////////////////////////////////////*/ @@ -127,7 +152,13 @@ public class ChannelFragment extends BaseFragment implements ChannelExtractorWor headerAvatarView = null; headerTitleView = null; headerSubscribersTextView = null; - headerRssButton = null; + headerSubscribeButton = null; + + if (disposables != null) disposables.dispose(); + if (subscribeButtonMonitor != null) subscribeButtonMonitor.dispose(); + disposables = null; + subscribeButtonMonitor = null; + subscriptionService = null; super.onDestroyView(); } @@ -176,6 +207,7 @@ public class ChannelFragment extends BaseFragment implements ChannelExtractorWor supportActionBar.setDisplayShowTitleEnabled(true); supportActionBar.setDisplayHomeAsUpEnabled(true); } + menu.findItem(R.id.menu_item_rss).setVisible( !TextUtils.isEmpty(feedUrl) ); } @Override @@ -190,13 +222,21 @@ public class ChannelFragment extends BaseFragment implements ChannelExtractorWor startActivity(Intent.createChooser(intent, getString(R.string.choose_browser))); return true; } - case R.id.menu_item_share: + case R.id.menu_item_rss: { + Intent intent = new Intent(); + intent.setAction(Intent.ACTION_VIEW); + intent.setData(Uri.parse(currentChannelInfo.feed_url)); + startActivity(intent); + return true; + } + case R.id.menu_item_share: { Intent intent = new Intent(); intent.setAction(Intent.ACTION_SEND); intent.putExtra(Intent.EXTRA_TEXT, channelUrl); intent.setType("text/plain"); startActivity(Intent.createChooser(intent, getString(R.string.share_dialog_title))); return true; + } default: return super.onOptionsItemSelected(item); } @@ -231,7 +271,10 @@ public class ChannelFragment extends BaseFragment implements ChannelExtractorWor headerAvatarView = (ImageView) headerRootLayout.findViewById(R.id.channel_avatar_view); headerTitleView = (TextView) headerRootLayout.findViewById(R.id.channel_title_view); headerSubscribersTextView = (TextView) headerRootLayout.findViewById(R.id.channel_subscriber_view); - headerRssButton = (Button) headerRootLayout.findViewById(R.id.channel_rss_button); + headerSubscribeButton = (Button) headerRootLayout.findViewById(R.id.channel_subscribe_button); + + disposables = new CompositeDisposable(); + subscriptionService = SubscriptionService.getInstance( getContext() ); } protected void initListeners() { @@ -255,17 +298,9 @@ public class ChannelFragment extends BaseFragment implements ChannelExtractorWor } } }); - - headerRssButton.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View view) { - if (DEBUG) Log.d(TAG, "onClick() called with: view = [" + view + "] feed url > " + currentChannelInfo.feed_url); - Intent i = new Intent(Intent.ACTION_VIEW, Uri.parse(currentChannelInfo.feed_url)); - startActivity(i); - } - }); } + @Override protected void reloadContent() { if (DEBUG) Log.d(TAG, "reloadContent() called"); @@ -274,6 +309,133 @@ public class ChannelFragment extends BaseFragment implements ChannelExtractorWor loadPage(0); } + /*////////////////////////////////////////////////////////////////////////// + // Channel Subscription + //////////////////////////////////////////////////////////////////////////*/ + + private void monitorSubscription(final int serviceId, + final String channelUrl, + final ChannelInfo info) { + subscriptionService.subscriptionTable().findAll(serviceId, channelUrl) + .toObservable() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(getSubscribeButtonMonitor(serviceId, channelUrl, info)); + } + + private Function mapOnSubscribe(final SubscriptionEntity subscription) { + return new Function() { + @Override + public Object apply(@NonNull Object o) throws Exception { + subscriptionService.subscriptionTable().insert( subscription ); + return o; + } + }; + } + + private Function mapOnUnsubscribe(final SubscriptionEntity subscription) { + return new Function() { + @Override + public Object apply(@NonNull Object o) throws Exception { + subscriptionService.subscriptionTable().delete( subscription ); + return o; + } + }; + } + + private Observer> getSubscribeButtonMonitor(final int serviceId, + final String channelUrl, + final ChannelInfo info) { + return new Observer>() { + @Override + public void onSubscribe(Disposable d) { + disposables.add( d ); + } + + @Override + public void onNext(List subscriptionEntities) { + if (subscribeButtonMonitor != null) subscribeButtonMonitor.dispose(); + + if (subscriptionEntities.isEmpty()) { + if (DEBUG) Log.d(TAG, "No subscription to this channel!"); + SubscriptionEntity channel = new SubscriptionEntity(); + channel.setServiceId( serviceId ); + channel.setUrl( channelUrl ); + channel.setData(info.channel_name, info.avatar_url, "", info.subscriberCount); + + subscribeButtonMonitor = monitorSubscribeButton(headerSubscribeButton, mapOnSubscribe(channel)); + + headerSubscribeButton.setText(R.string.subscribe_button_title); + } else { + if (DEBUG) Log.d(TAG, "Found subscription to this channel!"); + final SubscriptionEntity subscription = subscriptionEntities.get(0); + subscribeButtonMonitor = monitorSubscribeButton(headerSubscribeButton, mapOnUnsubscribe(subscription)); + + headerSubscribeButton.setText(R.string.subscribed_button_title); + } + + headerSubscribeButton.setVisibility(View.VISIBLE); + } + + @Override + public void onError(Throwable throwable) { + Log.e(TAG, "Status get failed", throwable); + headerSubscribeButton.setVisibility(View.INVISIBLE); + } + + @Override + public void onComplete() {} + }; + } + + private Disposable monitorSubscribeButton(final Button subscribeButton, + final Function action) { + final Consumer onNext = new Consumer() { + @Override + public void accept(@NonNull Object o) throws Exception { + if (DEBUG) Log.d(TAG, "Changed subscription status to this channel!"); + } + }; + + final Consumer onError = new Consumer() { + @Override + public void accept(@NonNull Throwable throwable) throws Exception { + if (DEBUG) Log.e(TAG, "Subscription Fatal Error: ", throwable.getCause()); + Toast.makeText(getContext(), R.string.subscription_change_failed, Toast.LENGTH_SHORT).show(); + } + }; + + /* Emit clicks from main thread unto io thread */ + return RxView.clicks(subscribeButton) + .subscribeOn(AndroidSchedulers.mainThread()) + .observeOn(Schedulers.io()) + .debounce(BUTTON_DEBOUNCE_INTERVAL, TimeUnit.MILLISECONDS) // Ignore rapid clicks + .map(action) + .subscribe(onNext, onError); + } + + private Disposable updateSubscription(final int serviceId, + final String channelUrl, + final ChannelInfo info) { + final Action onComplete = new Action() { + @Override + public void run() throws Exception { + if (DEBUG) Log.d(TAG, "Updated subscription: " + channelUrl); + } + }; + + final Consumer onError = new Consumer() { + @Override + public void accept(@NonNull Throwable throwable) throws Exception { + Log.e(TAG, "Subscription Update Fatal Error: ", throwable); + Toast.makeText(getContext(), R.string.subscription_update_failed, Toast.LENGTH_SHORT).show(); + } + }; + + return subscriptionService.updateChannelInfo(serviceId, channelUrl, info) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(onComplete, onError); + } + /*////////////////////////////////////////////////////////////////////////// // Utils //////////////////////////////////////////////////////////////////////////*/ @@ -297,7 +459,7 @@ public class ChannelFragment extends BaseFragment implements ChannelExtractorWor imageLoader.cancelDisplayTask(headerChannelBanner); imageLoader.cancelDisplayTask(headerAvatarView); - headerRssButton.setVisibility(View.GONE); + headerSubscribeButton.setVisibility(View.GONE); headerSubscribersTextView.setVisibility(View.GONE); headerTitleView.setText(channelName != null ? channelName : ""); @@ -331,6 +493,9 @@ public class ChannelFragment extends BaseFragment implements ChannelExtractorWor animateView(loadingProgressBar, false, 200); if (!onlyVideos) { + feedUrl = info.feed_url; + if (activity.getSupportActionBar() != null) activity.getSupportActionBar().invalidateOptionsMenu(); + headerRootLayout.setVisibility(View.VISIBLE); //animateView(loadingProgressBar, false, 200, null); @@ -354,8 +519,10 @@ public class ChannelFragment extends BaseFragment implements ChannelExtractorWor headerSubscribersTextView.setVisibility(View.VISIBLE); } else headerSubscribersTextView.setVisibility(View.GONE); - if (!TextUtils.isEmpty(info.feed_url)) headerRssButton.setVisibility(View.VISIBLE); - else headerRssButton.setVisibility(View.INVISIBLE); + if (disposables != null) disposables.clear(); + if (subscribeButtonMonitor != null) subscribeButtonMonitor.dispose(); + disposables.add( updateSubscription(serviceId, channelUrl, info) ); + monitorSubscription(serviceId, channelUrl, info); infoListAdapter.showFooter(true); } diff --git a/app/src/main/java/org/schabi/newpipe/info_list/InfoItemBuilder.java b/app/src/main/java/org/schabi/newpipe/info_list/InfoItemBuilder.java index 1bed5f22d..8033d281b 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/InfoItemBuilder.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/InfoItemBuilder.java @@ -231,14 +231,13 @@ public class InfoItemBuilder { holder.itemRoot.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { - if(onStreamInfoItemSelectedListener != null) { + if(onChannelInfoItemSelectedListener != null) { onChannelInfoItemSelectedListener.selected(info.serviceId, info.getLink(), info.channelName); } } }); } - public String shortViewCount(Long viewCount) { if (viewCount >= 1000000000) { return Long.toString(viewCount / 1000000000) + billion + " " + viewsS; diff --git a/app/src/main/java/org/schabi/newpipe/info_list/InfoListAdapter.java b/app/src/main/java/org/schabi/newpipe/info_list/InfoListAdapter.java index df0d9ac9f..0881801f5 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/InfoListAdapter.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/InfoListAdapter.java @@ -2,6 +2,7 @@ package org.schabi.newpipe.info_list; import android.app.Activity; import android.support.v7.widget.RecyclerView; +import android.text.Layout; import android.util.Log; import android.view.LayoutInflater; import android.view.View; @@ -9,6 +10,7 @@ import android.view.ViewGroup; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.InfoItem; +import org.schabi.newpipe.extractor.channel.ChannelInfoItem; import java.util.ArrayList; import java.util.List; @@ -77,6 +79,13 @@ public class InfoListAdapter extends RecyclerView.Adapter