Merged the latest changes

This commit is contained in:
Avently 2020-07-13 04:17:21 +03:00
commit d2aaa6f691
1254 changed files with 39193 additions and 18652 deletions

View file

@ -5,16 +5,17 @@ import android.content.res.Configuration;
import android.content.res.Resources;
import android.os.Bundle;
import android.preference.PreferenceManager;
import androidx.fragment.app.Fragment;
import androidx.appcompat.app.ActionBar;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import android.util.Log;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.View;
import androidx.appcompat.app.ActionBar;
import androidx.fragment.app.Fragment;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import org.schabi.newpipe.R;
import org.schabi.newpipe.fragments.BaseStateFragment;
import org.schabi.newpipe.fragments.list.ListViewContract;
@ -25,10 +26,14 @@ import static org.schabi.newpipe.util.AnimationUtils.animateView;
* This fragment is design to be used with persistent data such as
* {@link org.schabi.newpipe.database.LocalItem}, and does not cache the data contained
* in the list adapter to avoid extra writes when the it exits or re-enters its lifecycle.
*
* <p>
* This fragment destroys its adapter and views when {@link Fragment#onDestroyView()} is
* called and is memory efficient when in backstack.
* */
* </p>
*
* @param <I> List of {@link org.schabi.newpipe.database.LocalItem}s
* @param <N> {@link Void}
*/
public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I>
implements ListViewContract<I, N>, SharedPreferences.OnSharedPreferenceChangeListener {
@ -36,21 +41,19 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I>
// Views
//////////////////////////////////////////////////////////////////////////*/
protected View headerRootView;
protected View footerRootView;
private static final int LIST_MODE_UPDATE_FLAG = 0x32;
private View headerRootView;
private View footerRootView;
protected LocalItemListAdapter itemListAdapter;
protected RecyclerView itemsList;
private int updateFlags = 0;
private static final int LIST_MODE_UPDATE_FLAG = 0x32;
/*//////////////////////////////////////////////////////////////////////////
// Lifecycle - Creation
//////////////////////////////////////////////////////////////////////////*/
@Override
public void onCreate(Bundle savedInstanceState) {
public void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setHasOptionsMenu(true);
PreferenceManager.getDefaultSharedPreferences(activity)
@ -70,8 +73,9 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I>
if (updateFlags != 0) {
if ((updateFlags & LIST_MODE_UPDATE_FLAG) != 0) {
final boolean useGrid = isGridLayout();
itemsList.setLayoutManager(useGrid ? getGridLayoutManager() : getListLayoutManager());
itemListAdapter.setGridItemVariants(useGrid);
itemsList.setLayoutManager(
useGrid ? getGridLayoutManager() : getListLayoutManager());
itemListAdapter.setUseGridVariant(useGrid);
itemListAdapter.notifyDataSetChanged();
}
updateFlags = 0;
@ -94,7 +98,8 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I>
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 int spanCount = (int) Math.floor(resources.getDisplayMetrics().widthPixels
/ (double) width);
final GridLayoutManager lm = new GridLayoutManager(activity, spanCount);
lm.setSpanSizeLookup(itemListAdapter.getSpanSizeLookup(spanCount));
return lm;
@ -105,7 +110,7 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I>
}
@Override
protected void initViews(View rootView, Bundle savedInstanceState) {
protected void initViews(final View rootView, final Bundle savedInstanceState) {
super.initViews(rootView, savedInstanceState);
itemListAdapter = new LocalItemListAdapter(activity);
@ -114,9 +119,11 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I>
itemsList = rootView.findViewById(R.id.items_list);
itemsList.setLayoutManager(useGrid ? getGridLayoutManager() : getListLayoutManager());
itemListAdapter.setGridItemVariants(useGrid);
itemListAdapter.setHeader(headerRootView = getListHeader());
itemListAdapter.setFooter(footerRootView = getListFooter());
itemListAdapter.setUseGridVariant(useGrid);
headerRootView = getListHeader();
itemListAdapter.setHeader(headerRootView);
footerRootView = getListFooter();
itemListAdapter.setFooter(footerRootView);
itemsList.setAdapter(itemListAdapter);
}
@ -131,13 +138,17 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I>
//////////////////////////////////////////////////////////////////////////*/
@Override
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) {
super.onCreateOptionsMenu(menu, inflater);
if (DEBUG) Log.d(TAG, "onCreateOptionsMenu() called with: menu = [" + menu +
"], inflater = [" + inflater + "]");
if (DEBUG) {
Log.d(TAG, "onCreateOptionsMenu() called with: "
+ "menu = [" + menu + "], inflater = [" + inflater + "]");
}
final ActionBar supportActionBar = activity.getSupportActionBar();
if (supportActionBar == null) return;
if (supportActionBar == null) {
return;
}
supportActionBar.setDisplayShowTitleEnabled(true);
}
@ -158,7 +169,7 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I>
//////////////////////////////////////////////////////////////////////////*/
@Override
public void startLoading(boolean forceLoad) {
public void startLoading(final boolean forceLoad) {
super.startLoading(forceLoad);
resetFragment();
}
@ -166,24 +177,36 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I>
@Override
public void showLoading() {
super.showLoading();
if (itemsList != null) animateView(itemsList, false, 200);
if (headerRootView != null) animateView(headerRootView, false, 200);
if (itemsList != null) {
animateView(itemsList, false, 200);
}
if (headerRootView != null) {
animateView(headerRootView, false, 200);
}
}
@Override
public void hideLoading() {
super.hideLoading();
if (itemsList != null) animateView(itemsList, true, 200);
if (headerRootView != null) animateView(headerRootView, true, 200);
if (itemsList != null) {
animateView(itemsList, true, 200);
}
if (headerRootView != null) {
animateView(headerRootView, true, 200);
}
}
@Override
public void showError(String message, boolean showRetryButton) {
public void showError(final String message, final boolean showRetryButton) {
super.showError(message, showRetryButton);
showListFooter(false);
if (itemsList != null) animateView(itemsList, false, 200);
if (headerRootView != null) animateView(headerRootView, false, 200);
if (itemsList != null) {
animateView(itemsList, false, 200);
}
if (headerRootView != null) {
animateView(headerRootView, false, 200);
}
}
@Override
@ -194,14 +217,18 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I>
@Override
public void showListFooter(final boolean show) {
if (itemsList == null) return;
if (itemsList == null) {
return;
}
itemsList.post(() -> {
if (itemListAdapter != null) itemListAdapter.showFooter(show);
if (itemListAdapter != null) {
itemListAdapter.showFooter(show);
}
});
}
@Override
public void handleNextItems(N result) {
public void handleNextItems(final N result) {
isLoading.set(false);
}
@ -210,30 +237,35 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I>
//////////////////////////////////////////////////////////////////////////*/
protected void resetFragment() {
if (itemListAdapter != null) itemListAdapter.clearStreamItemList();
if (itemListAdapter != null) {
itemListAdapter.clearStreamItemList();
}
}
@Override
protected boolean onError(Throwable exception) {
protected boolean onError(final Throwable exception) {
resetFragment();
return super.onError(exception);
}
@Override
public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences,
final 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 String listMode = PreferenceManager.getDefaultSharedPreferences(activity)
.getString(getString(R.string.list_view_mode_key),
getString(R.string.list_view_mode_value));
if ("auto".equals(listMode)) {
final Configuration configuration = getResources().getConfiguration();
return configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
&& configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE);
} else {
return "grid".equals(list_mode);
return "grid".equals(listMode);
}
}
}

View file

@ -1,13 +1,14 @@
package org.schabi.newpipe.local;
import androidx.recyclerview.widget.RecyclerView;
import android.view.View;
import androidx.recyclerview.widget.RecyclerView;
public class HeaderFooterHolder extends RecyclerView.ViewHolder {
public View view;
public HeaderFooterHolder(View v) {
public HeaderFooterHolder(final View v) {
super(v);
view = v;
}
}
}

View file

@ -30,14 +30,12 @@ import org.schabi.newpipe.util.OnClickGesture;
*/
public class LocalItemBuilder {
private static final String TAG = LocalItemBuilder.class.toString();
private final Context context;
private final ImageLoader imageLoader = ImageLoader.getInstance();
private OnClickGesture<LocalItem> onSelectedListener;
public LocalItemBuilder(Context context) {
public LocalItemBuilder(final Context context) {
this.context = context;
}
@ -54,7 +52,7 @@ public class LocalItemBuilder {
return onSelectedListener;
}
public void setOnItemSelectedListener(OnClickGesture<LocalItem> listener) {
public void setOnItemSelectedListener(final OnClickGesture<LocalItem> listener) {
this.onSelectedListener = listener;
}
}

View file

@ -1,13 +1,14 @@
package org.schabi.newpipe.local;
import android.content.Context;
import android.util.Log;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import android.util.Log;
import android.view.View;
import android.view.ViewGroup;
import org.schabi.newpipe.database.LocalItem;
import org.schabi.newpipe.database.stream.model.StreamStateEntity;
@ -50,7 +51,6 @@ import java.util.List;
*/
public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
private static final String TAG = LocalItemListAdapter.class.getSimpleName();
private static final boolean DEBUG = false;
@ -63,8 +63,8 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
private static final int STREAM_PLAYLIST_GRID_HOLDER_TYPE = 0x1004;
private static final int LOCAL_PLAYLIST_HOLDER_TYPE = 0x2000;
private static final int REMOTE_PLAYLIST_HOLDER_TYPE = 0x2001;
private static final int LOCAL_PLAYLIST_GRID_HOLDER_TYPE = 0x2002;
private static final int REMOTE_PLAYLIST_GRID_HOLDER_TYPE = 0x2004;
private static final int LOCAL_PLAYLIST_GRID_HOLDER_TYPE = 0x2002;
private static final int REMOTE_PLAYLIST_GRID_HOLDER_TYPE = 0x2004;
private final LocalItemBuilder localItemBuilder;
private final ArrayList<LocalItem> localItems;
@ -76,7 +76,7 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
private View header = null;
private View footer = null;
public LocalItemListAdapter(Context context) {
public LocalItemListAdapter(final Context context) {
recordManager = new HistoryRecordManager(context);
localItemBuilder = new LocalItemBuilder(context);
localItems = new ArrayList<>();
@ -84,7 +84,7 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
Localization.getPreferredLocale(context));
}
public void setSelectedListener(OnClickGesture<LocalItem> listener) {
public void setSelectedListener(final OnClickGesture<LocalItem> listener) {
localItemBuilder.setOnItemSelectedListener(listener);
}
@ -92,28 +92,34 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
localItemBuilder.setOnItemSelectedListener(null);
}
public void addItems(@Nullable List<? extends LocalItem> data) {
public void addItems(@Nullable final List<? extends LocalItem> data) {
if (data == null) {
return;
}
if (DEBUG) Log.d(TAG, "addItems() before > localItems.size() = " +
localItems.size() + ", data.size() = " + data.size());
if (DEBUG) {
Log.d(TAG, "addItems() before > localItems.size() = "
+ localItems.size() + ", data.size() = " + data.size());
}
int offsetStart = sizeConsideringHeader();
localItems.addAll(data);
if (DEBUG) Log.d(TAG, "addItems() after > offsetStart = " + offsetStart +
", localItems.size() = " + localItems.size() +
", header = " + header + ", footer = " + footer +
", showFooter = " + showFooter);
if (DEBUG) {
Log.d(TAG, "addItems() after > offsetStart = " + offsetStart + ", "
+ "localItems.size() = " + localItems.size() + ", "
+ "header = " + header + ", footer = " + footer + ", "
+ "showFooter = " + showFooter);
}
notifyItemRangeInserted(offsetStart, data.size());
if (footer != null && showFooter) {
int footerNow = sizeConsideringHeader();
notifyItemMoved(offsetStart, footerNow);
if (DEBUG) Log.d(TAG, "addItems() footer from " + offsetStart +
" to " + footerNow);
if (DEBUG) {
Log.d(TAG, "addItems() footer from " + offsetStart
+ " to " + footerNow);
}
}
}
@ -123,12 +129,16 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
notifyItemRemoved(index + (header != null ? 1 : 0));
}
public boolean swapItems(int fromAdapterPosition, int toAdapterPosition) {
public boolean swapItems(final int fromAdapterPosition, final int toAdapterPosition) {
final int actualFrom = adapterOffsetWithoutHeader(fromAdapterPosition);
final int actualTo = adapterOffsetWithoutHeader(toAdapterPosition);
if (actualFrom < 0 || actualTo < 0) return false;
if (actualFrom >= localItems.size() || actualTo >= localItems.size()) return false;
if (actualFrom < 0 || actualTo < 0) {
return false;
}
if (actualFrom >= localItems.size() || actualTo >= localItems.size()) {
return false;
}
localItems.add(actualTo, localItems.remove(actualFrom));
notifyItemMoved(fromAdapterPosition, toAdapterPosition);
@ -143,27 +153,36 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
notifyDataSetChanged();
}
public void setGridItemVariants(boolean useGridVariant) {
public void setUseGridVariant(final boolean useGridVariant) {
this.useGridVariant = useGridVariant;
}
public void setHeader(View header) {
public void setHeader(final View header) {
boolean changed = header != this.header;
this.header = header;
if (changed) notifyDataSetChanged();
if (changed) {
notifyDataSetChanged();
}
}
public void setFooter(View view) {
public void setFooter(final View view) {
this.footer = view;
}
public void showFooter(boolean show) {
if (DEBUG) Log.d(TAG, "showFooter() called with: show = [" + show + "]");
if (show == showFooter) return;
public void showFooter(final boolean show) {
if (DEBUG) {
Log.d(TAG, "showFooter() called with: show = [" + show + "]");
}
if (show == showFooter) {
return;
}
showFooter = show;
if (show) notifyItemInserted(sizeConsideringHeader());
else notifyItemRemoved(sizeConsideringHeader());
if (show) {
notifyItemInserted(sizeConsideringHeader());
} else {
notifyItemRemoved(sizeConsideringHeader());
}
}
private int adapterOffsetWithoutHeader(final int offset) {
@ -181,21 +200,27 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
@Override
public int getItemCount() {
int count = localItems.size();
if (header != null) count++;
if (footer != null && showFooter) count++;
if (header != null) {
count++;
}
if (footer != null && showFooter) {
count++;
}
if (DEBUG) {
Log.d(TAG, "getItemCount() called, count = " + count +
", localItems.size() = " + localItems.size() +
", header = " + header + ", footer = " + footer +
", showFooter = " + showFooter);
Log.d(TAG, "getItemCount() called, count = " + count + ", "
+ "localItems.size() = " + localItems.size() + ", "
+ "header = " + header + ", footer = " + footer + ", "
+ "showFooter = " + showFooter);
}
return count;
}
@Override
public int getItemViewType(int position) {
if (DEBUG) Log.d(TAG, "getItemViewType() called with: position = [" + position + "]");
if (DEBUG) {
Log.d(TAG, "getItemViewType() called with: position = [" + position + "]");
}
if (header != null && position == 0) {
return HEADER_TYPE;
@ -208,23 +233,34 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
final LocalItem item = localItems.get(position);
switch (item.getLocalItemType()) {
case PLAYLIST_LOCAL_ITEM: return useGridVariant ? LOCAL_PLAYLIST_GRID_HOLDER_TYPE : LOCAL_PLAYLIST_HOLDER_TYPE;
case PLAYLIST_REMOTE_ITEM: return useGridVariant ? REMOTE_PLAYLIST_GRID_HOLDER_TYPE : REMOTE_PLAYLIST_HOLDER_TYPE;
case PLAYLIST_LOCAL_ITEM:
return useGridVariant
? LOCAL_PLAYLIST_GRID_HOLDER_TYPE : LOCAL_PLAYLIST_HOLDER_TYPE;
case PLAYLIST_REMOTE_ITEM:
return useGridVariant
? REMOTE_PLAYLIST_GRID_HOLDER_TYPE : REMOTE_PLAYLIST_HOLDER_TYPE;
case PLAYLIST_STREAM_ITEM: return useGridVariant ? STREAM_PLAYLIST_GRID_HOLDER_TYPE : STREAM_PLAYLIST_HOLDER_TYPE;
case STATISTIC_STREAM_ITEM: return useGridVariant ? STREAM_STATISTICS_GRID_HOLDER_TYPE : STREAM_STATISTICS_HOLDER_TYPE;
case PLAYLIST_STREAM_ITEM:
return useGridVariant
? STREAM_PLAYLIST_GRID_HOLDER_TYPE : STREAM_PLAYLIST_HOLDER_TYPE;
case STATISTIC_STREAM_ITEM:
return useGridVariant
? STREAM_STATISTICS_GRID_HOLDER_TYPE : STREAM_STATISTICS_HOLDER_TYPE;
default:
Log.e(TAG, "No holder type has been considered for item: [" +
item.getLocalItemType() + "]");
Log.e(TAG, "No holder type has been considered for item: ["
+ item.getLocalItemType() + "]");
return -1;
}
}
@NonNull
@Override
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int type) {
if (DEBUG) Log.d(TAG, "onCreateViewHolder() called with: parent = [" +
parent + "], type = [" + type + "]");
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull final ViewGroup parent,
final int type) {
if (DEBUG) {
Log.d(TAG, "onCreateViewHolder() called with: "
+ "parent = [" + parent + "], type = [" + type + "]");
}
switch (type) {
case HEADER_TYPE:
return new HeaderFooterHolder(header);
@ -253,15 +289,21 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
}
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
if (DEBUG) Log.d(TAG, "onBindViewHolder() called with: holder = [" +
holder.getClass().getSimpleName() + "], position = [" + position + "]");
public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder, int position) {
if (DEBUG) {
Log.d(TAG, "onBindViewHolder() called with: "
+ "holder = [" + holder.getClass().getSimpleName() + "], "
+ "position = [" + position + "]");
}
if (holder instanceof LocalItemHolder) {
// If header isn't null, offset the items by -1
if (header != null) position--;
if (header != null) {
position--;
}
((LocalItemHolder) holder).updateFromItem(localItems.get(position), recordManager, dateFormat);
((LocalItemHolder) holder)
.updateFromItem(localItems.get(position), recordManager, dateFormat);
} else if (holder instanceof HeaderFooterHolder && position == 0 && header != null) {
((HeaderFooterHolder) holder).view = header;
} else if (holder instanceof HeaderFooterHolder && position == sizeConsideringHeader()
@ -271,13 +313,16 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
}
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position, @NonNull List<Object> payloads) {
public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder, final int position,
@NonNull final List<Object> payloads) {
if (!payloads.isEmpty() && holder instanceof LocalItemHolder) {
for (Object payload : payloads) {
if (payload instanceof StreamStateEntity) {
((LocalItemHolder) holder).updateState(localItems.get(header == null ? position : position - 1), recordManager);
((LocalItemHolder) holder).updateState(localItems
.get(header == null ? position : position - 1), recordManager);
} else if (payload instanceof Boolean) {
((LocalItemHolder) holder).updateState(localItems.get(header == null ? position : position - 1), recordManager);
((LocalItemHolder) holder).updateState(localItems
.get(header == null ? position : position - 1), recordManager);
}
}
} else {
@ -288,7 +333,7 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
public GridLayoutManager.SpanSizeLookup getSpanSizeLookup(final int spanCount) {
return new GridLayoutManager.SpanSizeLookup() {
@Override
public int getSpanSize(int position) {
public int getSpanSize(final int position) {
final int type = getItemViewType(position);
return type == HEADER_TYPE || type == FOOTER_TYPE ? spanCount : 1;
}

View file

@ -1,14 +1,18 @@
package org.schabi.newpipe.local.bookmark;
import android.app.AlertDialog;
import android.app.AlertDialog.Builder;
import android.os.Bundle;
import android.os.Parcelable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.FragmentManager;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.EditText;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.FragmentManager;
import org.reactivestreams.Subscriber;
import org.reactivestreams.Subscription;
@ -26,8 +30,6 @@ import org.schabi.newpipe.report.UserAction;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.OnClickGesture;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import icepick.State;
@ -35,10 +37,9 @@ import io.reactivex.Flowable;
import io.reactivex.Single;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.disposables.CompositeDisposable;
import io.reactivex.disposables.Disposable;
public final class BookmarkFragment
extends BaseLocalListFragment<List<PlaylistLocalItem>, Void> {
public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistLocalItem>, Void> {
@State
protected Parcelable itemsListState;
@ -52,9 +53,11 @@ public final class BookmarkFragment
///////////////////////////////////////////////////////////////////////////
@Override
public void onCreate(Bundle savedInstanceState) {
public void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (activity == null) return;
if (activity == null) {
return;
}
final AppDatabase database = NewPipeDatabase.getInstance(activity);
localPlaylistManager = new LocalPlaylistManager(database);
remotePlaylistManager = new RemotePlaylistManager(database);
@ -63,19 +66,18 @@ public final class BookmarkFragment
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater,
@Nullable ViewGroup container,
Bundle savedInstanceState) {
public View onCreateView(@NonNull final LayoutInflater inflater,
@Nullable final ViewGroup container,
final Bundle savedInstanceState) {
if(!useAsFrontPage) {
if (!useAsFrontPage) {
setTitle(activity.getString(R.string.tab_bookmarks));
}
return inflater.inflate(R.layout.fragment_bookmarks, container, false);
}
@Override
public void setUserVisibleHint(boolean isVisibleToUser) {
public void setUserVisibleHint(final boolean isVisibleToUser) {
super.setUserVisibleHint(isVisibleToUser);
if (activity != null && isVisibleToUser) {
setTitle(activity.getString(R.string.tab_bookmarks));
@ -87,7 +89,7 @@ public final class BookmarkFragment
///////////////////////////////////////////////////////////////////////////
@Override
protected void initViews(View rootView, Bundle savedInstanceState) {
protected void initViews(final View rootView, final Bundle savedInstanceState) {
super.initViews(rootView, savedInstanceState);
}
@ -97,7 +99,7 @@ public final class BookmarkFragment
itemListAdapter.setSelectedListener(new OnClickGesture<LocalItem>() {
@Override
public void selected(LocalItem selectedItem) {
public void selected(final LocalItem selectedItem) {
final FragmentManager fragmentManager = getFM();
if (selectedItem instanceof PlaylistMetadataEntry) {
@ -116,10 +118,9 @@ public final class BookmarkFragment
}
@Override
public void held(LocalItem selectedItem) {
public void held(final LocalItem selectedItem) {
if (selectedItem instanceof PlaylistMetadataEntry) {
showLocalDeleteDialog((PlaylistMetadataEntry) selectedItem);
showLocalDialog((PlaylistMetadataEntry) selectedItem);
} else if (selectedItem instanceof PlaylistRemoteEntity) {
showRemoteDeleteDialog((PlaylistRemoteEntity) selectedItem);
}
@ -132,16 +133,14 @@ public final class BookmarkFragment
///////////////////////////////////////////////////////////////////////////
@Override
public void startLoading(boolean forceLoad) {
public void startLoading(final boolean forceLoad) {
super.startLoading(forceLoad);
Flowable.combineLatest(
localPlaylistManager.getPlaylists(),
remotePlaylistManager.getPlaylists(),
BookmarkFragment::merge
).onBackpressureLatest()
.observeOn(AndroidSchedulers.mainThread())
.subscribe(getPlaylistsSubscriber());
Flowable.combineLatest(localPlaylistManager.getPlaylists(),
remotePlaylistManager.getPlaylists(), PlaylistLocalItem::merge)
.onBackpressureLatest()
.observeOn(AndroidSchedulers.mainThread())
.subscribe(getPlaylistsSubscriber());
}
///////////////////////////////////////////////////////////////////////////
@ -158,8 +157,12 @@ public final class BookmarkFragment
public void onDestroyView() {
super.onDestroyView();
if (disposables != null) disposables.clear();
if (databaseSubscription != null) databaseSubscription.cancel();
if (disposables != null) {
disposables.clear();
}
if (databaseSubscription != null) {
databaseSubscription.cancel();
}
databaseSubscription = null;
}
@ -167,7 +170,9 @@ public final class BookmarkFragment
@Override
public void onDestroy() {
super.onDestroy();
if (disposables != null) disposables.dispose();
if (disposables != null) {
disposables.dispose();
}
disposables = null;
localPlaylistManager = null;
@ -182,32 +187,35 @@ public final class BookmarkFragment
private Subscriber<List<PlaylistLocalItem>> getPlaylistsSubscriber() {
return new Subscriber<List<PlaylistLocalItem>>() {
@Override
public void onSubscribe(Subscription s) {
public void onSubscribe(final Subscription s) {
showLoading();
if (databaseSubscription != null) databaseSubscription.cancel();
if (databaseSubscription != null) {
databaseSubscription.cancel();
}
databaseSubscription = s;
databaseSubscription.request(1);
}
@Override
public void onNext(List<PlaylistLocalItem> subscriptions) {
public void onNext(final List<PlaylistLocalItem> subscriptions) {
handleResult(subscriptions);
if (databaseSubscription != null) databaseSubscription.request(1);
if (databaseSubscription != null) {
databaseSubscription.request(1);
}
}
@Override
public void onError(Throwable exception) {
public void onError(final Throwable exception) {
BookmarkFragment.this.onError(exception);
}
@Override
public void onComplete() {
}
public void onComplete() { }
};
}
@Override
public void handleResult(@NonNull List<PlaylistLocalItem> result) {
public void handleResult(@NonNull final List<PlaylistLocalItem> result) {
super.handleResult(result);
itemListAdapter.clearStreamItemList();
@ -224,13 +232,16 @@ public final class BookmarkFragment
}
hideLoading();
}
///////////////////////////////////////////////////////////////////////////
// Fragment Error Handling
///////////////////////////////////////////////////////////////////////////
@Override
protected boolean onError(Throwable exception) {
if (super.onError(exception)) return true;
protected boolean onError(final Throwable exception) {
if (super.onError(exception)) {
return true;
}
onUnrecoverableError(exception, UserAction.SOMETHING_ELSE,
"none", "Bookmark", R.string.general_error);
@ -240,23 +251,43 @@ public final class BookmarkFragment
@Override
protected void resetFragment() {
super.resetFragment();
if (disposables != null) disposables.clear();
if (disposables != null) {
disposables.clear();
}
}
///////////////////////////////////////////////////////////////////////////
// Utils
///////////////////////////////////////////////////////////////////////////
private void showLocalDeleteDialog(final PlaylistMetadataEntry item) {
showDeleteDialog(item.name, localPlaylistManager.deletePlaylist(item.uid));
}
private void showRemoteDeleteDialog(final PlaylistRemoteEntity item) {
showDeleteDialog(item.getName(), remotePlaylistManager.deletePlaylist(item.getUid()));
}
private void showLocalDialog(final PlaylistMetadataEntry selectedItem) {
View dialogView = View.inflate(getContext(), R.layout.dialog_bookmark, null);
EditText editText = dialogView.findViewById(R.id.playlist_name_edit_text);
editText.setText(selectedItem.name);
Builder builder = new AlertDialog.Builder(activity);
builder.setView(dialogView)
.setPositiveButton(R.string.rename_playlist, (dialog, which) -> {
changeLocalPlaylistName(selectedItem.uid, editText.getText().toString());
})
.setNegativeButton(R.string.cancel, null)
.setNeutralButton(R.string.delete, (dialog, which) -> {
showDeleteDialog(selectedItem.name,
localPlaylistManager.deletePlaylist(selectedItem.uid));
dialog.dismiss();
})
.create()
.show();
}
private void showDeleteDialog(final String name, final Single<Integer> deleteReactor) {
if (activity == null || disposables == null) return;
if (activity == null || disposables == null) {
return;
}
new AlertDialog.Builder(activity)
.setTitle(name)
@ -265,23 +296,27 @@ public final class BookmarkFragment
.setPositiveButton(R.string.delete, (dialog, i) ->
disposables.add(deleteReactor
.observeOn(AndroidSchedulers.mainThread())
.subscribe(ignored -> {/*Do nothing on success*/}, this::onError))
.subscribe(ignored -> { /*Do nothing on success*/ }, this::onError))
)
.setNegativeButton(R.string.cancel, null)
.show();
}
private static List<PlaylistLocalItem> merge(final List<PlaylistMetadataEntry> localPlaylists,
final List<PlaylistRemoteEntity> remotePlaylists) {
List<PlaylistLocalItem> items = new ArrayList<>(
localPlaylists.size() + remotePlaylists.size());
items.addAll(localPlaylists);
items.addAll(remotePlaylists);
private void changeLocalPlaylistName(final long id, final String name) {
if (localPlaylistManager == null) {
return;
}
Collections.sort(items, (left, right) ->
left.getOrderingName().compareToIgnoreCase(right.getOrderingName()));
if (DEBUG) {
Log.d(TAG, "Updating playlist id=[" + id + "] "
+ "with new name=[" + name + "] items");
}
return items;
localPlaylistManager.renamePlaylist(id, name);
final Disposable disposable = localPlaylistManager.renamePlaylist(id, name)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(longs -> { /*Do nothing on success*/ }, this::onError);
disposables.add(disposable);
}
}

View file

@ -1,15 +1,16 @@
package org.schabi.newpipe.local.dialog;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import org.schabi.newpipe.NewPipeDatabase;
import org.schabi.newpipe.R;
import org.schabi.newpipe.database.LocalItem;
@ -68,13 +69,13 @@ public final class PlaylistAppendDialog extends PlaylistDialog {
//////////////////////////////////////////////////////////////////////////*/
@Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container,
final Bundle savedInstanceState) {
return inflater.inflate(R.layout.dialog_playlists, container);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
final LocalPlaylistManager playlistManager =
@ -83,9 +84,10 @@ public final class PlaylistAppendDialog extends PlaylistDialog {
playlistAdapter = new LocalItemListAdapter(getActivity());
playlistAdapter.setSelectedListener(new OnClickGesture<LocalItem>() {
@Override
public void selected(LocalItem selectedItem) {
if (!(selectedItem instanceof PlaylistMetadataEntry) || getStreams() == null)
public void selected(final LocalItem selectedItem) {
if (!(selectedItem instanceof PlaylistMetadataEntry) || getStreams() == null) {
return;
}
onPlaylistSelected(playlistManager, (PlaylistMetadataEntry) selectedItem,
getStreams());
}
@ -125,7 +127,9 @@ public final class PlaylistAppendDialog extends PlaylistDialog {
//////////////////////////////////////////////////////////////////////////*/
public void openCreatePlaylistDialog() {
if (getStreams() == null || getFragmentManager() == null) return;
if (getStreams() == null || getFragmentManager() == null) {
return;
}
PlaylistCreationDialog.newInstance(getStreams()).show(getFragmentManager(), TAG);
getDialog().dismiss();
@ -144,14 +148,23 @@ public final class PlaylistAppendDialog extends PlaylistDialog {
}
}
private void onPlaylistSelected(@NonNull LocalPlaylistManager manager,
@NonNull PlaylistMetadataEntry playlist,
@NonNull List<StreamEntity> streams) {
if (getStreams() == null) return;
private void onPlaylistSelected(@NonNull final LocalPlaylistManager manager,
@NonNull final PlaylistMetadataEntry playlist,
@NonNull final List<StreamEntity> streams) {
if (getStreams() == null) {
return;
}
final Toast successToast = Toast.makeText(getContext(),
R.string.playlist_add_stream_success, Toast.LENGTH_SHORT);
if (playlist.thumbnailUrl.equals("drawable://" + R.drawable.dummy_thumbnail_playlist)) {
playlistDisposables.add(manager
.changePlaylistThumbnail(playlist.uid, streams.get(0).getThumbnailUrl())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(ignored -> successToast.show()));
}
playlistDisposables.add(manager.appendToPlaylist(playlist.uid, streams)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(ignored -> successToast.show()));

View file

@ -3,12 +3,13 @@ package org.schabi.newpipe.local.dialog;
import android.app.AlertDialog;
import android.app.Dialog;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import android.view.View;
import android.widget.EditText;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.schabi.newpipe.NewPipeDatabase;
import org.schabi.newpipe.R;
import org.schabi.newpipe.database.stream.model.StreamEntity;
@ -19,8 +20,6 @@ import java.util.List;
import io.reactivex.android.schedulers.AndroidSchedulers;
public final class PlaylistCreationDialog extends PlaylistDialog {
private static final String TAG = PlaylistCreationDialog.class.getCanonicalName();
public static PlaylistCreationDialog newInstance(final List<StreamEntity> streams) {
PlaylistCreationDialog dialog = new PlaylistCreationDialog();
dialog.setInfo(streams);
@ -33,8 +32,10 @@ public final class PlaylistCreationDialog extends PlaylistDialog {
@NonNull
@Override
public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
if (getStreams() == null) return super.onCreateDialog(savedInstanceState);
public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) {
if (getStreams() == null) {
return super.onCreateDialog(savedInstanceState);
}
View dialogView = View.inflate(getContext(), R.layout.dialog_playlist_name, null);
EditText nameInput = dialogView.findViewById(R.id.playlist_name);

View file

@ -2,10 +2,11 @@ package org.schabi.newpipe.local.dialog;
import android.app.Dialog;
import android.os.Bundle;
import android.view.Window;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.DialogFragment;
import android.view.Window;
import org.schabi.newpipe.database.stream.model.StreamEntity;
import org.schabi.newpipe.util.StateSaver;
@ -14,7 +15,6 @@ import java.util.List;
import java.util.Queue;
public abstract class PlaylistDialog extends DialogFragment implements StateSaver.WriteRead {
private List<StreamEntity> streamEntities;
private StateSaver.SavedState savedState;
@ -32,7 +32,7 @@ public abstract class PlaylistDialog extends DialogFragment implements StateSave
//////////////////////////////////////////////////////////////////////////*/
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
public void onCreate(@Nullable final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
savedState = StateSaver.tryToRestore(savedInstanceState, this);
}
@ -45,7 +45,7 @@ public abstract class PlaylistDialog extends DialogFragment implements StateSave
@NonNull
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
public Dialog onCreateDialog(final Bundle savedInstanceState) {
final Dialog dialog = super.onCreateDialog(savedInstanceState);
//remove title
final Window window = dialog.getWindow();
@ -66,18 +66,18 @@ public abstract class PlaylistDialog extends DialogFragment implements StateSave
}
@Override
public void writeTo(Queue<Object> objectsToSave) {
public void writeTo(final Queue<Object> objectsToSave) {
objectsToSave.add(streamEntities);
}
@Override
@SuppressWarnings("unchecked")
public void readFrom(@NonNull Queue<Object> savedObjects) {
public void readFrom(@NonNull final Queue<Object> savedObjects) {
streamEntities = (List<StreamEntity>) savedObjects.poll();
}
@Override
public void onSaveInstanceState(Bundle outState) {
public void onSaveInstanceState(final Bundle outState) {
super.onSaveInstanceState(outState);
if (getActivity() != null) {
savedState = StateSaver.tryToSave(getActivity().isChangingConfigurations(),

View file

@ -0,0 +1,168 @@
package org.schabi.newpipe.local.feed
import android.content.Context
import android.util.Log
import io.reactivex.Completable
import io.reactivex.Flowable
import io.reactivex.Maybe
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
import java.util.Calendar
import java.util.Date
import org.schabi.newpipe.MainActivity.DEBUG
import org.schabi.newpipe.NewPipeDatabase
import org.schabi.newpipe.database.feed.model.FeedEntity
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity
import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.extractor.stream.StreamType
import org.schabi.newpipe.local.subscription.FeedGroupIcon
class FeedDatabaseManager(context: Context) {
private val database = NewPipeDatabase.getInstance(context)
private val feedTable = database.feedDAO()
private val feedGroupTable = database.feedGroupDAO()
private val streamTable = database.streamDAO()
companion object {
/**
* Only items that are newer than this will be saved.
*/
val FEED_OLDEST_ALLOWED_DATE: Calendar = Calendar.getInstance().apply {
add(Calendar.WEEK_OF_YEAR, -13)
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}
}
fun groups() = feedGroupTable.getAll()
fun database() = database
fun asStreamItems(groupId: Long = FeedGroupEntity.GROUP_ALL_ID): Flowable<List<StreamInfoItem>> {
val streams = when (groupId) {
FeedGroupEntity.GROUP_ALL_ID -> feedTable.getAllStreams()
else -> feedTable.getAllStreamsFromGroup(groupId)
}
return streams.map<List<StreamInfoItem>> {
val items = ArrayList<StreamInfoItem>(it.size)
for (streamEntity in it) items.add(streamEntity.toStreamInfoItem())
return@map items
}
}
fun outdatedSubscriptions(outdatedThreshold: Date) = feedTable.getAllOutdated(outdatedThreshold)
fun notLoadedCount(groupId: Long = FeedGroupEntity.GROUP_ALL_ID): Flowable<Long> {
return when (groupId) {
FeedGroupEntity.GROUP_ALL_ID -> feedTable.notLoadedCount()
else -> feedTable.notLoadedCountForGroup(groupId)
}
}
fun outdatedSubscriptionsForGroup(groupId: Long = FeedGroupEntity.GROUP_ALL_ID, outdatedThreshold: Date) =
feedTable.getAllOutdatedForGroup(groupId, outdatedThreshold)
fun markAsOutdated(subscriptionId: Long) = feedTable
.setLastUpdatedForSubscription(FeedLastUpdatedEntity(subscriptionId, null))
fun upsertAll(
subscriptionId: Long,
items: List<StreamInfoItem>,
oldestAllowedDate: Date = FEED_OLDEST_ALLOWED_DATE.time
) {
val itemsToInsert = ArrayList<StreamInfoItem>()
loop@ for (streamItem in items) {
val uploadDate = streamItem.uploadDate
itemsToInsert += when {
uploadDate == null && streamItem.streamType == StreamType.LIVE_STREAM -> streamItem
uploadDate != null && uploadDate.date().time >= oldestAllowedDate -> streamItem
else -> continue@loop
}
}
feedTable.unlinkOldLivestreams(subscriptionId)
if (itemsToInsert.isNotEmpty()) {
val streamEntities = itemsToInsert.map { StreamEntity(it) }
val streamIds = streamTable.upsertAll(streamEntities)
val feedEntities = streamIds.map { FeedEntity(it, subscriptionId) }
feedTable.insertAll(feedEntities)
}
feedTable.setLastUpdatedForSubscription(FeedLastUpdatedEntity(subscriptionId, Calendar.getInstance().time))
}
fun removeOrphansOrOlderStreams(oldestAllowedDate: Date = FEED_OLDEST_ALLOWED_DATE.time) {
feedTable.unlinkStreamsOlderThan(oldestAllowedDate)
streamTable.deleteOrphans()
}
fun clear() {
feedTable.deleteAll()
val deletedOrphans = streamTable.deleteOrphans()
if (DEBUG) Log.d(this::class.java.simpleName, "clear() → streamTable.deleteOrphans() → $deletedOrphans")
}
// /////////////////////////////////////////////////////////////////////////
// Feed Groups
// /////////////////////////////////////////////////////////////////////////
fun subscriptionIdsForGroup(groupId: Long): Flowable<List<Long>> {
return feedGroupTable.getSubscriptionIdsFor(groupId)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
}
fun updateSubscriptionsForGroup(groupId: Long, subscriptionIds: List<Long>): Completable {
return Completable.fromCallable { feedGroupTable.updateSubscriptionsForGroup(groupId, subscriptionIds) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
}
fun createGroup(name: String, icon: FeedGroupIcon): Maybe<Long> {
return Maybe.fromCallable { feedGroupTable.insert(FeedGroupEntity(0, name, icon)) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
}
fun getGroup(groupId: Long): Maybe<FeedGroupEntity> {
return feedGroupTable.getGroup(groupId)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
}
fun updateGroup(feedGroupEntity: FeedGroupEntity): Completable {
return Completable.fromCallable { feedGroupTable.update(feedGroupEntity) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
}
fun deleteGroup(groupId: Long): Completable {
return Completable.fromCallable { feedGroupTable.delete(groupId) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
}
fun updateGroupsOrder(groupIdList: List<Long>): Completable {
var index = 0L
val orderMap = groupIdList.associateBy({ it }, { index++ })
return Completable.fromCallable { feedGroupTable.updateOrder(orderMap) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
}
fun oldestSubscriptionUpdate(groupId: Long): Flowable<List<Date>> {
return when (groupId) {
FeedGroupEntity.GROUP_ALL_ID -> feedTable.oldestSubscriptionUpdateFromAll()
else -> feedTable.oldestSubscriptionUpdate(groupId)
}
}
}

View file

@ -1,444 +0,0 @@
package org.schabi.newpipe.local.feed;
import android.os.Bundle;
import android.os.Handler;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.ActionBar;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.View;
import android.view.ViewGroup;
import org.reactivestreams.Subscriber;
import org.reactivestreams.Subscription;
import org.schabi.newpipe.R;
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.channel.ChannelInfo;
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
import org.schabi.newpipe.fragments.list.BaseListFragment;
import org.schabi.newpipe.local.subscription.SubscriptionService;
import org.schabi.newpipe.report.UserAction;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Queue;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import io.reactivex.Flowable;
import io.reactivex.MaybeObserver;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.disposables.CompositeDisposable;
import io.reactivex.disposables.Disposable;
public class FeedFragment extends BaseListFragment<List<SubscriptionEntity>, Void> {
private static final int OFF_SCREEN_ITEMS_COUNT = 3;
private static final int MIN_ITEMS_INITIAL_LOAD = 8;
private int FEED_LOAD_COUNT = MIN_ITEMS_INITIAL_LOAD;
private int subscriptionPoolSize;
private SubscriptionService subscriptionService;
private AtomicBoolean allItemsLoaded = new AtomicBoolean(false);
private HashSet<String> itemsLoaded = new HashSet<>();
private final AtomicInteger requestLoadedAtomic = new AtomicInteger();
private CompositeDisposable compositeDisposable = new CompositeDisposable();
private Disposable subscriptionObserver;
private Subscription feedSubscriber;
/*//////////////////////////////////////////////////////////////////////////
// Fragment LifeCycle
//////////////////////////////////////////////////////////////////////////*/
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
subscriptionService = SubscriptionService.getInstance(activity);
FEED_LOAD_COUNT = howManyItemsToLoad();
}
@Override
public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState) {
if(!useAsFrontPage) {
setTitle(activity.getString(R.string.fragment_whats_new));
}
return inflater.inflate(R.layout.fragment_feed, container, false);
}
@Override
public void onPause() {
super.onPause();
disposeEverything();
}
@Override
public void onResume() {
super.onResume();
if (wasLoading.get()) doInitialLoadLogic();
}
@Override
public void onDestroy() {
super.onDestroy();
disposeEverything();
subscriptionService = null;
compositeDisposable = null;
subscriptionObserver = null;
feedSubscriber = null;
}
@Override
public void onDestroyView() {
// Do not monitor for updates when user is not viewing the feed fragment.
// This is a waste of bandwidth.
disposeEverything();
super.onDestroyView();
}
@Override
public void setUserVisibleHint(boolean isVisibleToUser) {
super.setUserVisibleHint(isVisibleToUser);
if (activity != null && isVisibleToUser) {
setTitle(activity.getString(R.string.fragment_whats_new));
}
}
@Override
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
super.onCreateOptionsMenu(menu, inflater);
ActionBar supportActionBar = activity.getSupportActionBar();
if(useAsFrontPage) {
supportActionBar.setDisplayShowTitleEnabled(true);
//supportActionBar.setDisplayShowTitleEnabled(false);
}
}
@Override
public void reloadContent() {
resetFragment();
super.reloadContent();
}
/*//////////////////////////////////////////////////////////////////////////
// StateSaving
//////////////////////////////////////////////////////////////////////////*/
@Override
public void writeTo(Queue<Object> objectsToSave) {
super.writeTo(objectsToSave);
objectsToSave.add(allItemsLoaded);
objectsToSave.add(itemsLoaded);
}
@Override
@SuppressWarnings("unchecked")
public void readFrom(@NonNull Queue<Object> savedObjects) throws Exception {
super.readFrom(savedObjects);
allItemsLoaded = (AtomicBoolean) savedObjects.poll();
itemsLoaded = (HashSet<String>) savedObjects.poll();
}
/*//////////////////////////////////////////////////////////////////////////
// Feed Loader
//////////////////////////////////////////////////////////////////////////*/
@Override
public void startLoading(boolean forceLoad) {
if (DEBUG) Log.d(TAG, "startLoading() called with: forceLoad = [" + forceLoad + "]");
if (subscriptionObserver != null) subscriptionObserver.dispose();
if (allItemsLoaded.get()) {
if (infoListAdapter.getItemsList().size() == 0) {
showEmptyState();
} else {
showListFooter(false);
hideLoading();
}
isLoading.set(false);
return;
}
isLoading.set(true);
showLoading();
showListFooter(true);
subscriptionObserver = subscriptionService.getSubscription()
.onErrorReturnItem(Collections.emptyList())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(this::handleResult, this::onError);
}
@Override
public void handleResult(@androidx.annotation.NonNull List<SubscriptionEntity> result) {
super.handleResult(result);
if (result.isEmpty()) {
infoListAdapter.clearStreamItemList();
showEmptyState();
return;
}
subscriptionPoolSize = result.size();
Flowable.fromIterable(result)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(getSubscriptionObserver());
}
/**
* Responsible for reacting to user pulling request and starting a request for new feed stream.
* <p>
* On initialization, it automatically requests the amount of feed needed to display
* a minimum amount required (FEED_LOAD_SIZE).
* <p>
* Upon receiving a user pull, it creates a Single Observer to fetch the ChannelInfo
* containing the feed streams.
**/
private Subscriber<SubscriptionEntity> getSubscriptionObserver() {
return new Subscriber<SubscriptionEntity>() {
@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.
* <p>
* Currently, the feed uses the first into from the list of streams.
* <p>
* If chosen feed already displayed, then we request another feed from another
* subscription, until the subscription table runs out of new items.
* <p>
* 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.
* <p>
* 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<ChannelInfo> getChannelInfoObserver(final int serviceId, final String url) {
return new MaybeObserver<ChannelInfo>() {
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<InfoItem> 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;
}
}

View file

@ -0,0 +1,342 @@
/*
* Copyright 2019 Mauricio Colli <mauriciocolli@outlook.com>
* 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 <http://www.gnu.org/licenses/>.
*/
package org.schabi.newpipe.local.feed
import android.content.Intent
import android.os.Bundle
import android.os.Parcelable
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import androidx.preference.PreferenceManager
import icepick.State
import java.util.Calendar
import kotlinx.android.synthetic.main.error_retry.error_button_retry
import kotlinx.android.synthetic.main.error_retry.error_message_view
import kotlinx.android.synthetic.main.fragment_feed.empty_state_view
import kotlinx.android.synthetic.main.fragment_feed.error_panel
import kotlinx.android.synthetic.main.fragment_feed.items_list
import kotlinx.android.synthetic.main.fragment_feed.loading_progress_bar
import kotlinx.android.synthetic.main.fragment_feed.loading_progress_text
import kotlinx.android.synthetic.main.fragment_feed.refresh_root_view
import kotlinx.android.synthetic.main.fragment_feed.refresh_subtitle_text
import kotlinx.android.synthetic.main.fragment_feed.refresh_text
import org.schabi.newpipe.R
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
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<FeedState, Unit>() {
private lateinit var viewModel: FeedViewModel
@State
@JvmField
var listState: Parcelable? = null
private var groupId = FeedGroupEntity.GROUP_ALL_ID
private var groupName = ""
private var oldestSubscriptionUpdate: Calendar? = null
init {
setHasOptionsMenu(true)
setUseDefaultStateSaving(false)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
groupId = arguments?.getLong(KEY_GROUP_ID, FeedGroupEntity.GROUP_ALL_ID)
?: FeedGroupEntity.GROUP_ALL_ID
groupName = arguments?.getString(KEY_GROUP_NAME) ?: ""
}
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_feed_title)
activity.supportActionBar?.subtitle = groupName
inflater.inflate(R.menu.menu_feed_fragment, menu)
if (useAsFrontPage) {
menu.findItem(R.id.menu_item_feed_help).setShowAsAction(MenuItem.SHOW_AS_ACTION_NEVER)
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == R.id.menu_item_feed_help) {
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
val usingDedicatedMethod = sharedPreferences.getBoolean(getString(R.string.feed_use_dedicated_fetch_method_key), false)
val enableDisableButtonText = when {
usingDedicatedMethod -> R.string.feed_use_dedicated_fetch_method_disable_button
else -> R.string.feed_use_dedicated_fetch_method_enable_button
}
AlertDialog.Builder(requireContext())
.setMessage(R.string.feed_use_dedicated_fetch_method_help_text)
.setNeutralButton(enableDisableButtonText) { _, _ ->
sharedPreferences.edit()
.putBoolean(getString(R.string.feed_use_dedicated_fetch_method_key), !usingDedicatedMethod)
.apply()
}
.setPositiveButton(resources.getString(R.string.finish), null)
.create()
.show()
return true
}
return super.onOptionsItemSelected(item)
}
override fun onDestroyOptionsMenu() {
super.onDestroyOptionsMenu()
activity?.supportActionBar?.subtitle = null
}
override fun onDestroy() {
super.onDestroy()
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)
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
}
oldestSubscriptionUpdate = loadedState.oldestUpdate
if (loadedState.notLoadedCount > 0) {
refresh_subtitle_text.visibility = View.VISIBLE
refresh_subtitle_text.text = getString(R.string.feed_subscription_not_loaded_count, loadedState.notLoadedCount)
} else {
refresh_subtitle_text.visibility = View.GONE
}
if (loadedState.itemsErrors.isNotEmpty()) {
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 oldestSubscriptionUpdateText = when {
oldestSubscriptionUpdate != null -> Localization.relativeTime(oldestSubscriptionUpdate!!)
else -> ""
}
refresh_text?.text = getString(R.string.feed_oldest_subscription_update, oldestSubscriptionUpdateText)
}
// /////////////////////////////////////////////////////////////////////////
// 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).apply {
putExtra(FeedLoadService.EXTRA_GROUP_ID, groupId)
})
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 = FeedGroupEntity.GROUP_ALL_ID, groupName: String? = null): FeedFragment {
val feedFragment = FeedFragment()
feedFragment.arguments = Bundle().apply {
putLong(KEY_GROUP_ID, groupId)
putString(KEY_GROUP_NAME, groupName)
}
return feedFragment
}
}
}

View file

@ -0,0 +1,24 @@
package org.schabi.newpipe.local.feed
import androidx.annotation.StringRes
import java.util.Calendar
import org.schabi.newpipe.extractor.stream.StreamInfoItem
sealed class FeedState {
data class ProgressState(
val currentProgress: Int = -1,
val maxProgress: Int = -1,
@StringRes val progressMessage: Int = 0
) : FeedState()
data class LoadedState(
val items: List<StreamInfoItem>,
val oldestUpdate: Calendar? = null,
val notLoadedCount: Long,
val itemsErrors: List<Throwable> = emptyList()
) : FeedState()
data class ErrorState(
val error: Throwable? = null
) : FeedState()
}

View file

@ -0,0 +1,75 @@
package org.schabi.newpipe.local.feed
import android.content.Context
import androidx.lifecycle.LiveData
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.Function4
import io.reactivex.schedulers.Schedulers
import java.util.Calendar
import java.util.Date
import java.util.concurrent.TimeUnit
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.local.feed.service.FeedEventManager
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ErrorResultEvent
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.IdleEvent
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ProgressEvent
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.SuccessResultEvent
import org.schabi.newpipe.util.DEFAULT_THROTTLE_TIMEOUT
class FeedViewModel(applicationContext: Context, val groupId: Long = FeedGroupEntity.GROUP_ALL_ID) : ViewModel() {
class Factory(val context: Context, val groupId: Long = FeedGroupEntity.GROUP_ALL_ID) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return FeedViewModel(context.applicationContext, groupId) as T
}
}
private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(applicationContext)
private val mutableStateLiveData = MutableLiveData<FeedState>()
val stateLiveData: LiveData<FeedState> = mutableStateLiveData
private var combineDisposable = Flowable
.combineLatest(
FeedEventManager.events(),
feedDatabaseManager.asStreamItems(groupId),
feedDatabaseManager.notLoadedCount(groupId),
feedDatabaseManager.oldestSubscriptionUpdate(groupId),
Function4 { t1: FeedEventManager.Event, t2: List<StreamInfoItem>, t3: Long, t4: List<Date> ->
return@Function4 CombineResultHolder(t1, t2, t3, t4.firstOrNull())
}
)
.throttleLatest(DEFAULT_THROTTLE_TIMEOUT, TimeUnit.MILLISECONDS)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
val (event, listFromDB, notLoadedCount, oldestUpdate) = it
val oldestUpdateCalendar =
oldestUpdate?.let { Calendar.getInstance().apply { time = it } }
mutableStateLiveData.postValue(when (event) {
is IdleEvent -> FeedState.LoadedState(listFromDB, oldestUpdateCalendar, notLoadedCount)
is ProgressEvent -> FeedState.ProgressState(event.currentProgress, event.maxProgress, event.progressMessage)
is SuccessResultEvent -> FeedState.LoadedState(listFromDB, oldestUpdateCalendar, notLoadedCount, event.itemsErrors)
is ErrorResultEvent -> FeedState.ErrorState(event.error)
})
if (event is ErrorResultEvent || event is SuccessResultEvent) {
FeedEventManager.reset()
}
}
override fun onCleared() {
super.onCleared()
combineDisposable.dispose()
}
private data class CombineResultHolder(val t1: FeedEventManager.Event, val t2: List<StreamInfoItem>, val t3: Long, val t4: Date?)
}

View file

@ -0,0 +1,37 @@
package org.schabi.newpipe.local.feed.service
import androidx.annotation.StringRes
import io.reactivex.Flowable
import io.reactivex.processors.BehaviorProcessor
import java.util.concurrent.atomic.AtomicBoolean
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.IdleEvent
object FeedEventManager {
private var processor: BehaviorProcessor<Event> = BehaviorProcessor.create()
private var ignoreUpstream = AtomicBoolean()
private var eventsFlowable = processor.startWith(IdleEvent)
fun postEvent(event: Event) {
processor.onNext(event)
}
fun events(): Flowable<Event> {
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<Throwable> = emptyList()) : Event()
data class ErrorResultEvent(val error: Throwable) : Event()
}
}

View file

@ -0,0 +1,466 @@
/*
* Copyright 2019 Mauricio Colli <mauriciocolli@outlook.com>
* 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 <http://www.gnu.org/licenses/>.
*/
package org.schabi.newpipe.local.feed.service
import android.app.PendingIntent
import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.os.IBinder
import android.preference.PreferenceManager
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 java.io.IOException
import java.util.Calendar
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import org.reactivestreams.Subscriber
import org.reactivestreams.Subscription
import org.schabi.newpipe.MainActivity.DEBUG
import org.schabi.newpipe.R
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
import org.schabi.newpipe.extractor.ListInfo
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.local.feed.FeedDatabaseManager
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ErrorResultEvent
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.IdleEvent
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ProgressEvent
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.SuccessResultEvent
import org.schabi.newpipe.local.feed.service.FeedEventManager.postEvent
import org.schabi.newpipe.local.subscription.SubscriptionManager
import org.schabi.newpipe.util.ExceptionUtils
import org.schabi.newpipe.util.ExtractorHelper
class FeedLoadService : Service() {
companion object {
private val TAG = FeedLoadService::class.java.simpleName
private const val NOTIFICATION_ID = 7293450
private const val ACTION_CANCEL = "org.schabi.newpipe.local.feed.service.FeedLoadService.CANCEL"
/**
* 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
const val EXTRA_GROUP_ID: String = "FeedLoadService.EXTRA_GROUP_ID"
}
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<String>()
// /////////////////////////////////////////////////////////////////////////
// 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()
setupBroadcastReceiver()
val defaultSharedPreferences = PreferenceManager.getDefaultSharedPreferences(this)
val groupId = intent.getLongExtra(EXTRA_GROUP_ID, FeedGroupEntity.GROUP_ALL_ID)
val useFeedExtractor = defaultSharedPreferences
.getBoolean(getString(R.string.feed_use_dedicated_fetch_method_key), false)
val thresholdOutdatedSecondsString = defaultSharedPreferences
.getString(getString(R.string.feed_update_threshold_key), getString(R.string.feed_update_threshold_default_value))
val thresholdOutdatedSeconds = thresholdOutdatedSecondsString!!.toInt()
startLoading(groupId, useFeedExtractor, thresholdOutdatedSeconds)
return START_NOT_STICKY
}
private fun disposeAll() {
unregisterReceiver(broadcastReceiver)
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(val subscriptionId: Long, message: String, cause: Throwable) : Exception(message, cause) {
companion object {
fun wrapList(subscriptionId: Long, info: ListInfo<StreamInfoItem>): List<Throwable> {
val toReturn = ArrayList<Throwable>(info.errors.size)
for (error in info.errors) {
toReturn.add(RequestException(subscriptionId, info.serviceId.toString() + ":" + info.url, error))
}
return toReturn
}
}
}
private fun startLoading(groupId: Long = FeedGroupEntity.GROUP_ALL_ID, useFeedExtractor: Boolean, thresholdOutdatedSeconds: Int) {
feedResultsHolder = ResultsHolder()
val outdatedThreshold = Calendar.getInstance().apply {
add(Calendar.SECOND, -thresholdOutdatedSeconds)
}.time
val subscriptions = when (groupId) {
FeedGroupEntity.GROUP_ALL_ID -> feedDatabaseManager.outdatedSubscriptions(outdatedThreshold)
else -> feedDatabaseManager.outdatedSubscriptionsForGroup(groupId, outdatedThreshold)
}
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) }
.takeWhile { !cancelSignal.get() }
.parallel(PARALLEL_EXTRACTIONS, PARALLEL_EXTRACTIONS * 2)
.runOn(Schedulers.io(), PARALLEL_EXTRACTIONS * 2)
.filter { !cancelSignal.get() }
.map { subscriptionEntity ->
try {
val listInfo = if (useFeedExtractor) {
ExtractorHelper
.getFeedInfoFallbackToChannelInfo(subscriptionEntity.serviceId, subscriptionEntity.url)
.blockingGet()
} else {
ExtractorHelper
.getChannelInfo(subscriptionEntity.serviceId, subscriptionEntity.url, true)
.blockingGet()
} as ListInfo<StreamInfoItem>
return@map Notification.createOnNext(Pair(subscriptionEntity.uid, listInfo))
} catch (e: Throwable) {
val request = "${subscriptionEntity.serviceId}:${subscriptionEntity.url}"
val wrapper = RequestException(subscriptionEntity.uid, request, e)
return@map Notification.createOnError<Pair<Long, ListInfo<StreamInfoItem>>>(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<List<Notification<Pair<Long, ListInfo<StreamInfoItem>>>>> {
override fun onSubscribe(s: Subscription) {
loadingSubscription = s
s.request(java.lang.Long.MAX_VALUE)
}
override fun onNext(notification: List<Notification<Pair<Long, ListInfo<StreamInfoItem>>>>) {
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()
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<List<Notification<Pair<Long, ListInfo<StreamInfoItem>>>>>
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(subscriptionId, info))
feedDatabaseManager.markAsOutdated(subscriptionId)
}
} else if (notification.isOnError) {
val error = notification.error!!
feedResultsHolder.addError(error)
if (error is RequestException) {
feedDatabaseManager.markAsOutdated(error.subscriptionId)
}
}
}
}
}
private val errorHandlingConsumer: Consumer<Notification<Pair<Long, ListInfo<StreamInfoItem>>>>
get() = Consumer {
if (it.isOnError) {
var error = it.error!!
if (error is RequestException) error = error.cause!!
val cause = error.cause
when {
error is ReCaptchaException -> throw error
cause is ReCaptchaException -> throw cause
error is IOException -> throw error
cause is IOException -> throw cause
ExceptionUtils.isNetworkRelated(error) -> throw IOException(error)
}
}
}
private val notificationsConsumer: Consumer<Notification<Pair<Long, ListInfo<StreamInfoItem>>>>
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 {
val cancelActionIntent = PendingIntent.getBroadcast(this,
NOTIFICATION_ID, Intent(ACTION_CANCEL), 0)
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)
.addAction(0, getString(R.string.cancel), cancelActionIntent)
.setContentTitle(getString(R.string.feed_notification_loading))
}
private fun setupNotification() {
notificationManager = NotificationManagerCompat.from(this)
notificationBuilder = createNotification()
val throttleAfterFirstEmission = Function { flow: Flowable<String> ->
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())
}
// /////////////////////////////////////////////////////////////////////////
// Notification Actions
// /////////////////////////////////////////////////////////////////////////
private lateinit var broadcastReceiver: BroadcastReceiver
private val cancelSignal = AtomicBoolean()
private fun setupBroadcastReceiver() {
broadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == ACTION_CANCEL) {
cancelSignal.set(true)
}
}
}
registerReceiver(broadcastReceiver, IntentFilter(ACTION_CANCEL))
}
// /////////////////////////////////////////////////////////////////////////
// Error handling
// /////////////////////////////////////////////////////////////////////////
private fun handleError(error: Throwable) {
postEvent(ErrorResultEvent(error))
stopService()
}
// /////////////////////////////////////////////////////////////////////////
// Results Holder
// /////////////////////////////////////////////////////////////////////////
class ResultsHolder {
/**
* List of errors that may have happen during loading.
*/
internal lateinit var itemsErrors: List<Throwable>
private val itemsErrorsHolder: MutableList<Throwable> = ArrayList()
fun addError(error: Throwable) {
itemsErrorsHolder.add(error)
}
fun addErrors(errors: List<Throwable>) {
itemsErrorsHolder.addAll(errors)
}
fun ready() {
itemsErrors = itemsErrorsHolder.toList()
}
}
}

View file

@ -1,6 +1,7 @@
package org.schabi.newpipe.local.history;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
@ -14,19 +15,19 @@ import java.util.Date;
/**
* Adapter for history entries
* @param <E> the type of the entries
* This is an adapter for history entries.
*
* @param <E> the type of the entries
* @param <VH> the type of the view holder
*/
public abstract class HistoryEntryAdapter<E, VH extends RecyclerView.ViewHolder> extends RecyclerView.Adapter<VH> {
public abstract class HistoryEntryAdapter<E, VH extends RecyclerView.ViewHolder>
extends RecyclerView.Adapter<VH> {
private final ArrayList<E> mEntries;
private final DateFormat mDateFormat;
private final Context mContext;
private OnHistoryItemClickListener<E> onHistoryItemClickListener = null;
public HistoryEntryAdapter(Context context) {
public HistoryEntryAdapter(final Context context) {
super();
mContext = context;
mEntries = new ArrayList<>();
@ -34,7 +35,7 @@ public abstract class HistoryEntryAdapter<E, VH extends RecyclerView.ViewHolder>
Localization.getPreferredLocale(context));
}
public void setEntries(@NonNull Collection<E> historyEntries) {
public void setEntries(@NonNull final Collection<E> historyEntries) {
mEntries.clear();
mEntries.addAll(historyEntries);
notifyDataSetChanged();
@ -49,7 +50,7 @@ public abstract class HistoryEntryAdapter<E, VH extends RecyclerView.ViewHolder>
notifyDataSetChanged();
}
protected String getFormattedDate(Date date) {
protected String getFormattedDate(final Date date) {
return mDateFormat.format(date);
}
@ -63,10 +64,10 @@ public abstract class HistoryEntryAdapter<E, VH extends RecyclerView.ViewHolder>
}
@Override
public void onBindViewHolder(VH holder, int position) {
public void onBindViewHolder(final VH holder, final int position) {
final E entry = mEntries.get(position);
holder.itemView.setOnClickListener(v -> {
if(onHistoryItemClickListener != null) {
if (onHistoryItemClickListener != null) {
onHistoryItemClickListener.onHistoryItemClick(entry);
}
});
@ -83,14 +84,15 @@ public abstract class HistoryEntryAdapter<E, VH extends RecyclerView.ViewHolder>
}
@Override
public void onViewRecycled(VH holder) {
public void onViewRecycled(final VH holder) {
super.onViewRecycled(holder);
holder.itemView.setOnClickListener(null);
}
abstract void onBindViewHolder(VH holder, E entry, int position);
public void setOnHistoryItemClickListener(@Nullable OnHistoryItemClickListener<E> onHistoryItemClickListener) {
public void setOnHistoryItemClickListener(
@Nullable final OnHistoryItemClickListener<E> onHistoryItemClickListener) {
this.onHistoryItemClickListener = onHistoryItemClickListener;
}
@ -100,6 +102,7 @@ public abstract class HistoryEntryAdapter<E, VH extends RecyclerView.ViewHolder>
public interface OnHistoryItemClickListener<E> {
void onHistoryItemClick(E item);
void onHistoryItemLongClick(E item);
}
}

View file

@ -1,34 +0,0 @@
package org.schabi.newpipe.local.history;
import androidx.annotation.Nullable;
import org.schabi.newpipe.extractor.stream.AudioStream;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.extractor.stream.VideoStream;
public interface HistoryListener {
/**
* Called when a video is played
*
* @param streamInfo the stream info
* @param videoStream the video stream that is played. Can be null if it's not sure what
* quality was viewed (e.g. with Kodi).
*/
void onVideoPlayed(StreamInfo streamInfo, @Nullable VideoStream videoStream);
/**
* Called when the audio is played in the background
*
* @param streamInfo the stream info
* @param audioStream the audio stream that is played
*/
void onAudioPlayed(StreamInfo streamInfo, AudioStream audioStream);
/**
* Called when the user searched for something
*
* @param serviceId which service the search was done
* @param query what the user searched for
*/
void onSearch(int serviceId, String query);
}

View file

@ -21,6 +21,7 @@ package org.schabi.newpipe.local.history;
import android.content.Context;
import android.content.SharedPreferences;
import android.preference.PreferenceManager;
import androidx.annotation.NonNull;
import org.schabi.newpipe.NewPipeDatabase;
@ -55,7 +56,6 @@ import io.reactivex.Single;
import io.reactivex.schedulers.Schedulers;
public class HistoryRecordManager {
private final AppDatabase database;
private final StreamDAO streamTable;
private final StreamHistoryDAO streamHistoryTable;
@ -81,7 +81,9 @@ public class HistoryRecordManager {
///////////////////////////////////////////////////////
public Maybe<Long> onViewed(final StreamInfo info) {
if (!isStreamHistoryEnabled()) return Maybe.empty();
if (!isStreamHistoryEnabled()) {
return Maybe.empty();
}
final Date currentTime = new Date();
return Maybe.fromCallable(() -> database.runInTransaction(() -> {
@ -118,6 +120,10 @@ public class HistoryRecordManager {
return streamHistoryTable.getHistory().subscribeOn(Schedulers.io());
}
public Flowable<List<StreamHistoryEntry>> getStreamHistorySortedById() {
return streamHistoryTable.getHistorySortedById().subscribeOn(Schedulers.io());
}
public Flowable<List<StreamStatisticsEntry>> getStreamStatistics() {
return streamHistoryTable.getStatistics().subscribeOn(Schedulers.io());
}
@ -149,7 +155,9 @@ public class HistoryRecordManager {
///////////////////////////////////////////////////////
public Maybe<Long> onSearched(final int serviceId, final String search) {
if (!isSearchHistoryEnabled()) return Maybe.empty();
if (!isSearchHistoryEnabled()) {
return Maybe.empty();
}
final Date currentTime = new Date();
final SearchHistoryEntry newEntry = new SearchHistoryEntry(currentTime, serviceId, search);
@ -231,11 +239,13 @@ public class HistoryRecordManager {
public Single<StreamStateEntity[]> loadStreamState(final InfoItem info) {
return Single.fromCallable(() -> {
final List<StreamEntity> entities = streamTable.getStream(info.getServiceId(), info.getUrl()).blockingFirst();
final List<StreamEntity> entities = streamTable
.getStream(info.getServiceId(), info.getUrl()).blockingFirst();
if (entities.isEmpty()) {
return new StreamStateEntity[]{null};
}
final List<StreamStateEntity> states = streamStateTable.getState(entities.get(0).getUid()).blockingFirst();
final List<StreamStateEntity> states = streamStateTable
.getState(entities.get(0).getUid()).blockingFirst();
if (states.isEmpty()) {
return new StreamStateEntity[]{null};
}
@ -247,12 +257,14 @@ public class HistoryRecordManager {
return Single.fromCallable(() -> {
final List<StreamStateEntity> result = new ArrayList<>(infos.size());
for (InfoItem info : infos) {
final List<StreamEntity> entities = streamTable.getStream(info.getServiceId(), info.getUrl()).blockingFirst();
final List<StreamEntity> entities = streamTable
.getStream(info.getServiceId(), info.getUrl()).blockingFirst();
if (entities.isEmpty()) {
result.add(null);
continue;
}
final List<StreamStateEntity> states = streamStateTable.getState(entities.get(0).getUid()).blockingFirst();
final List<StreamStateEntity> states = streamStateTable
.getState(entities.get(0).getUid()).blockingFirst();
if (states.isEmpty()) {
result.add(null);
continue;
@ -263,22 +275,24 @@ public class HistoryRecordManager {
}).subscribeOn(Schedulers.io());
}
public Single<List<StreamStateEntity>> loadLocalStreamStateBatch(final List<? extends LocalItem> items) {
public Single<List<StreamStateEntity>> loadLocalStreamStateBatch(
final List<? extends LocalItem> items) {
return Single.fromCallable(() -> {
final List<StreamStateEntity> result = new ArrayList<>(items.size());
for (LocalItem item : items) {
long streamId;
if (item instanceof StreamStatisticsEntry) {
streamId = ((StreamStatisticsEntry) item).streamId;
streamId = ((StreamStatisticsEntry) item).getStreamId();
} else if (item instanceof PlaylistStreamEntity) {
streamId = ((PlaylistStreamEntity) item).getStreamUid();
} else if (item instanceof PlaylistStreamEntry) {
streamId = ((PlaylistStreamEntry) item).streamId;
streamId = ((PlaylistStreamEntry) item).getStreamId();
} else {
result.add(null);
continue;
}
final List<StreamStateEntity> states = streamStateTable.getState(streamId).blockingFirst();
final List<StreamStateEntity> states = streamStateTable.getState(streamId)
.blockingFirst();
if (states.isEmpty()) {
result.add(null);
continue;

View file

@ -4,10 +4,6 @@ import android.app.Activity;
import android.content.Context;
import android.os.Bundle;
import android.os.Parcelable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.android.material.snackbar.Snackbar;
import androidx.appcompat.app.AlertDialog;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
@ -18,6 +14,12 @@ import android.widget.ImageView;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import com.google.android.material.snackbar.Snackbar;
import org.reactivestreams.Subscriber;
import org.reactivestreams.Subscription;
import org.schabi.newpipe.R;
@ -48,7 +50,10 @@ import io.reactivex.disposables.Disposable;
public class StatisticsPlaylistFragment
extends BaseLocalListFragment<List<StreamStatisticsEntry>, Void> {
private final CompositeDisposable disposables = new CompositeDisposable();
@State
Parcelable itemsListState;
private StatisticSortMode sortMode = StatisticSortMode.LAST_PLAYED;
private View headerPlayAllButton;
private View headerPopupButton;
private View headerBackgroundButton;
@ -56,33 +61,22 @@ public class StatisticsPlaylistFragment
private View sortButton;
private ImageView sortButtonIcon;
private TextView sortButtonText;
@State
protected Parcelable itemsListState;
/* Used for independent events */
private Subscription databaseSubscription;
private HistoryRecordManager recordManager;
private final CompositeDisposable disposables = new CompositeDisposable();
private enum StatisticSortMode {
LAST_PLAYED,
MOST_PLAYED,
}
StatisticSortMode sortMode = StatisticSortMode.LAST_PLAYED;
protected List<StreamStatisticsEntry> processResult(final List<StreamStatisticsEntry> results) {
private List<StreamStatisticsEntry> processResult(final List<StreamStatisticsEntry> results) {
switch (sortMode) {
case LAST_PLAYED:
Collections.sort(results, (left, right) ->
right.latestAccessDate.compareTo(left.latestAccessDate));
right.getLatestAccessDate().compareTo(left.getLatestAccessDate()));
return results;
case MOST_PLAYED:
Collections.sort(results, (left, right) ->
Long.compare(right.watchCount, left.watchCount));
Long.compare(right.getWatchCount(), left.getWatchCount()));
return results;
default: return null;
default:
return null;
}
}
@ -91,20 +85,20 @@ public class StatisticsPlaylistFragment
///////////////////////////////////////////////////////////////////////////
@Override
public void onCreate(Bundle savedInstanceState) {
public void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
recordManager = new HistoryRecordManager(getContext());
}
@Override
public View onCreateView(@NonNull LayoutInflater inflater,
@Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
public View onCreateView(@NonNull final LayoutInflater inflater,
@Nullable final ViewGroup container,
@Nullable final Bundle savedInstanceState) {
return inflater.inflate(R.layout.fragment_playlist, container, false);
}
@Override
public void setUserVisibleHint(boolean isVisibleToUser) {
public void setUserVisibleHint(final boolean isVisibleToUser) {
super.setUserVisibleHint(isVisibleToUser);
if (activity != null && isVisibleToUser) {
setTitle(activity.getString(R.string.title_activity_history));
@ -112,7 +106,7 @@ public class StatisticsPlaylistFragment
}
@Override
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) {
super.onCreateOptionsMenu(menu, inflater);
inflater.inflate(R.menu.menu_history, menu);
}
@ -122,17 +116,17 @@ public class StatisticsPlaylistFragment
///////////////////////////////////////////////////////////////////////////
@Override
protected void initViews(View rootView, Bundle savedInstanceState) {
protected void initViews(final View rootView, final Bundle savedInstanceState) {
super.initViews(rootView, savedInstanceState);
if(!useAsFrontPage) {
if (!useAsFrontPage) {
setTitle(getString(R.string.title_last_played));
}
}
@Override
protected View getListHeader() {
final View headerRootLayout = activity.getLayoutInflater().inflate(R.layout.statistic_playlist_control,
itemsList, false);
final View headerRootLayout = activity.getLayoutInflater()
.inflate(R.layout.statistic_playlist_control, itemsList, false);
playlistCtrl = headerRootLayout.findViewById(R.id.playlist_control);
headerPlayAllButton = headerRootLayout.findViewById(R.id.playlist_ctrl_play_all_button);
headerPopupButton = headerRootLayout.findViewById(R.id.playlist_ctrl_play_popup_button);
@ -149,18 +143,18 @@ public class StatisticsPlaylistFragment
itemListAdapter.setSelectedListener(new OnClickGesture<LocalItem>() {
@Override
public void selected(LocalItem selectedItem) {
public void selected(final LocalItem selectedItem) {
if (selectedItem instanceof StreamStatisticsEntry) {
final StreamStatisticsEntry item = (StreamStatisticsEntry) selectedItem;
NavigationHelper.openVideoDetailFragment(getFM(),
item.serviceId,
item.url,
item.title);
item.getStreamEntity().getServiceId(),
item.getStreamEntity().getUrl(),
item.getStreamEntity().getTitle());
}
}
@Override
public void held(LocalItem selectedItem) {
public void held(final LocalItem selectedItem) {
if (selectedItem instanceof StreamStatisticsEntry) {
showStreamDialog((StreamStatisticsEntry) selectedItem);
}
@ -169,7 +163,7 @@ public class StatisticsPlaylistFragment
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
public boolean onOptionsItemSelected(final MenuItem item) {
switch (item.getItemId()) {
case R.id.action_history_clear:
new AlertDialog.Builder(activity)
@ -194,7 +188,8 @@ public class StatisticsPlaylistFragment
final Disposable onClearOrphans = recordManager.removeOrphanedRecords()
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
howManyDeleted -> {},
howManyDeleted -> {
},
throwable -> ErrorActivity.reportError(getContext(),
throwable,
SettingsActivity.class, null,
@ -220,7 +215,7 @@ public class StatisticsPlaylistFragment
///////////////////////////////////////////////////////////////////////////
@Override
public void startLoading(boolean forceLoad) {
public void startLoading(final boolean forceLoad) {
super.startLoading(forceLoad);
recordManager.getStreamStatistics()
.observeOn(AndroidSchedulers.mainThread())
@ -241,12 +236,22 @@ public class StatisticsPlaylistFragment
public void onDestroyView() {
super.onDestroyView();
if (itemListAdapter != null) itemListAdapter.unsetSelectedListener();
if (headerBackgroundButton != null) headerBackgroundButton.setOnClickListener(null);
if (headerPlayAllButton != null) headerPlayAllButton.setOnClickListener(null);
if (headerPopupButton != null) headerPopupButton.setOnClickListener(null);
if (itemListAdapter != null) {
itemListAdapter.unsetSelectedListener();
}
if (headerBackgroundButton != null) {
headerBackgroundButton.setOnClickListener(null);
}
if (headerPlayAllButton != null) {
headerPlayAllButton.setOnClickListener(null);
}
if (headerPopupButton != null) {
headerPopupButton.setOnClickListener(null);
}
if (databaseSubscription != null) databaseSubscription.cancel();
if (databaseSubscription != null) {
databaseSubscription.cancel();
}
databaseSubscription = null;
}
@ -264,22 +269,26 @@ public class StatisticsPlaylistFragment
private Subscriber<List<StreamStatisticsEntry>> getHistoryObserver() {
return new Subscriber<List<StreamStatisticsEntry>>() {
@Override
public void onSubscribe(Subscription s) {
public void onSubscribe(final Subscription s) {
showLoading();
if (databaseSubscription != null) databaseSubscription.cancel();
if (databaseSubscription != null) {
databaseSubscription.cancel();
}
databaseSubscription = s;
databaseSubscription.request(1);
}
@Override
public void onNext(List<StreamStatisticsEntry> streams) {
public void onNext(final List<StreamStatisticsEntry> streams) {
handleResult(streams);
if (databaseSubscription != null) databaseSubscription.request(1);
if (databaseSubscription != null) {
databaseSubscription.request(1);
}
}
@Override
public void onError(Throwable exception) {
public void onError(final Throwable exception) {
StatisticsPlaylistFragment.this.onError(exception);
}
@ -290,9 +299,11 @@ public class StatisticsPlaylistFragment
}
@Override
public void handleResult(@NonNull List<StreamStatisticsEntry> result) {
public void handleResult(@NonNull final List<StreamStatisticsEntry> result) {
super.handleResult(result);
if (itemListAdapter == null) return;
if (itemListAdapter == null) {
return;
}
playlistCtrl.setVisibility(View.VISIBLE);
@ -319,6 +330,7 @@ public class StatisticsPlaylistFragment
hideLoading();
}
///////////////////////////////////////////////////////////////////////////
// Fragment Error Handling
///////////////////////////////////////////////////////////////////////////
@ -326,12 +338,16 @@ public class StatisticsPlaylistFragment
@Override
protected void resetFragment() {
super.resetFragment();
if (databaseSubscription != null) databaseSubscription.cancel();
if (databaseSubscription != null) {
databaseSubscription.cancel();
}
}
@Override
protected boolean onError(Throwable exception) {
if (super.onError(exception)) return true;
protected boolean onError(final Throwable exception) {
if (super.onError(exception)) {
return true;
}
onUnrecoverableError(exception, UserAction.SOMETHING_ELSE,
"none", "History Statistics", R.string.general_error);
@ -343,28 +359,32 @@ public class StatisticsPlaylistFragment
//////////////////////////////////////////////////////////////////////////*/
private void toggleSortMode() {
if(sortMode == StatisticSortMode.LAST_PLAYED) {
if (sortMode == StatisticSortMode.LAST_PLAYED) {
sortMode = StatisticSortMode.MOST_PLAYED;
setTitle(getString(R.string.title_most_played));
sortButtonIcon.setImageResource(ThemeHelper.getIconByAttr(R.attr.history, getContext()));
sortButtonIcon.setImageResource(
ThemeHelper.resolveResourceIdFromAttr(requireContext(), R.attr.ic_history));
sortButtonText.setText(R.string.title_last_played);
} else {
sortMode = StatisticSortMode.LAST_PLAYED;
setTitle(getString(R.string.title_last_played));
sortButtonIcon.setImageResource(ThemeHelper.getIconByAttr(R.attr.filter, getContext()));
sortButtonIcon.setImageResource(
ThemeHelper.resolveResourceIdFromAttr(requireContext(), R.attr.ic_filter_list));
sortButtonText.setText(R.string.title_most_played);
}
startLoading(true);
}
private PlayQueue getPlayQueueStartingAt(StreamStatisticsEntry infoItem) {
private PlayQueue getPlayQueueStartingAt(final StreamStatisticsEntry infoItem) {
return getPlayQueue(Math.max(itemListAdapter.getItemsList().indexOf(infoItem), 0));
}
private void showStreamDialog(final StreamStatisticsEntry item) {
final Context context = getContext();
final Activity activity = getActivity();
if (context == null || context.getResources() == null || activity == null) return;
if (context == null || context.getResources() == null || activity == null) {
return;
}
final StreamInfoItem infoItem = item.toStreamInfoItem();
if (infoItem.getStreamType() == StreamType.AUDIO_STREAM) {
@ -384,29 +404,31 @@ public class StatisticsPlaylistFragment
StreamDialogEntry.append_playlist,
StreamDialogEntry.share);
StreamDialogEntry.start_here_on_popup.setCustomAction(
(fragment, infoItemDuplicate) -> NavigationHelper.playOnPopupPlayer(context, getPlayQueueStartingAt(item), true));
StreamDialogEntry.start_here_on_popup.setCustomAction((fragment, infoItemDuplicate) ->
NavigationHelper
.playOnPopupPlayer(context, getPlayQueueStartingAt(item), true));
}
StreamDialogEntry.start_here_on_background.setCustomAction(
(fragment, infoItemDuplicate) -> NavigationHelper.playOnBackgroundPlayer(context, getPlayQueueStartingAt(item), true));
StreamDialogEntry.start_here_on_background.setCustomAction((fragment, infoItemDuplicate) ->
NavigationHelper
.playOnBackgroundPlayer(context, getPlayQueueStartingAt(item), true));
StreamDialogEntry.delete.setCustomAction((fragment, infoItemDuplicate) ->
deleteEntry(Math.max(itemListAdapter.getItemsList().indexOf(item), 0)));
deleteEntry(Math.max(itemListAdapter.getItemsList().indexOf(item), 0)));
new InfoItemDialog(activity, infoItem, StreamDialogEntry.getCommands(context), (dialog, which) ->
StreamDialogEntry.clickOn(which, this, infoItem)).show();
new InfoItemDialog(activity, infoItem, StreamDialogEntry.getCommands(context),
(dialog, which) -> StreamDialogEntry.clickOn(which, this, infoItem)).show();
}
private void deleteEntry(final int index) {
final LocalItem infoItem = itemListAdapter.getItemsList()
.get(index);
if(infoItem instanceof StreamStatisticsEntry) {
if (infoItem instanceof StreamStatisticsEntry) {
final StreamStatisticsEntry entry = (StreamStatisticsEntry) infoItem;
final Disposable onDelete = recordManager.deleteStreamHistory(entry.streamId)
final Disposable onDelete = recordManager.deleteStreamHistory(entry.getStreamId())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
howManyDeleted -> {
if(getView() != null) {
if (getView() != null) {
Snackbar.make(getView(), R.string.one_item_deleted,
Snackbar.LENGTH_SHORT).show();
} else {
@ -441,5 +463,10 @@ public class StatisticsPlaylistFragment
}
return new SinglePlayQueue(streamInfoItems, index);
}
private enum StatisticSortMode {
LAST_PLAYED,
MOST_PLAYED,
}
}

View file

@ -1,9 +1,10 @@
package org.schabi.newpipe.local.holder;
import androidx.recyclerview.widget.RecyclerView;
import android.view.LayoutInflater;
import android.view.ViewGroup;
import androidx.recyclerview.widget.RecyclerView;
import org.schabi.newpipe.database.LocalItem;
import org.schabi.newpipe.local.LocalItemBuilder;
import org.schabi.newpipe.local.history.HistoryRecordManager;
@ -33,14 +34,15 @@ import java.text.DateFormat;
public abstract class LocalItemHolder extends RecyclerView.ViewHolder {
protected final LocalItemBuilder itemBuilder;
public LocalItemHolder(LocalItemBuilder itemBuilder, int layoutId, ViewGroup parent) {
super(LayoutInflater.from(itemBuilder.getContext())
.inflate(layoutId, parent, false));
public LocalItemHolder(final LocalItemBuilder itemBuilder, final int layoutId,
final ViewGroup parent) {
super(LayoutInflater.from(itemBuilder.getContext()).inflate(layoutId, parent, false));
this.itemBuilder = itemBuilder;
}
public abstract void updateFromItem(final LocalItem item, HistoryRecordManager historyRecordManager, final DateFormat dateFormat);
public abstract void updateFromItem(LocalItem item, HistoryRecordManager historyRecordManager,
DateFormat dateFormat);
public void updateState(final LocalItem localItem, HistoryRecordManager historyRecordManager) {
}
public void updateState(final LocalItem localItem,
final HistoryRecordManager historyRecordManager) { }
}

View file

@ -6,8 +6,8 @@ import org.schabi.newpipe.R;
import org.schabi.newpipe.local.LocalItemBuilder;
public class LocalPlaylistGridItemHolder extends LocalPlaylistItemHolder {
public LocalPlaylistGridItemHolder(LocalItemBuilder infoItemBuilder, ViewGroup parent) {
super(infoItemBuilder, R.layout.list_playlist_grid_item, parent);
}
public LocalPlaylistGridItemHolder(final LocalItemBuilder infoItemBuilder,
final ViewGroup parent) {
super(infoItemBuilder, R.layout.list_playlist_grid_item, parent);
}
}

View file

@ -8,26 +8,32 @@ import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry;
import org.schabi.newpipe.local.LocalItemBuilder;
import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.util.ImageDisplayConstants;
import org.schabi.newpipe.util.Localization;
import java.text.DateFormat;
public class LocalPlaylistItemHolder extends PlaylistItemHolder {
public LocalPlaylistItemHolder(LocalItemBuilder infoItemBuilder, ViewGroup parent) {
public LocalPlaylistItemHolder(final LocalItemBuilder infoItemBuilder, final ViewGroup parent) {
super(infoItemBuilder, parent);
}
LocalPlaylistItemHolder(LocalItemBuilder infoItemBuilder, int layoutId, ViewGroup parent) {
LocalPlaylistItemHolder(final LocalItemBuilder infoItemBuilder, final int layoutId,
final ViewGroup parent) {
super(infoItemBuilder, layoutId, parent);
}
@Override
public void updateFromItem(final LocalItem localItem, HistoryRecordManager historyRecordManager, final DateFormat dateFormat) {
if (!(localItem instanceof PlaylistMetadataEntry)) return;
public void updateFromItem(final LocalItem localItem,
final HistoryRecordManager historyRecordManager,
final DateFormat dateFormat) {
if (!(localItem instanceof PlaylistMetadataEntry)) {
return;
}
final PlaylistMetadataEntry item = (PlaylistMetadataEntry) localItem;
itemTitleView.setText(item.name);
itemStreamCountView.setText(String.valueOf(item.streamCount));
itemStreamCountView.setText(Localization.localizeStreamCountMini(
itemStreamCountView.getContext(), item.streamCount));
itemUploaderView.setVisibility(View.INVISIBLE);
itemBuilder.displayImage(item.thumbnailUrl, itemThumbnailView,

View file

@ -6,8 +6,8 @@ import org.schabi.newpipe.R;
import org.schabi.newpipe.local.LocalItemBuilder;
public class LocalPlaylistStreamGridItemHolder extends LocalPlaylistStreamItemHolder {
public LocalPlaylistStreamGridItemHolder(LocalItemBuilder infoItemBuilder, ViewGroup parent) {
super(infoItemBuilder, R.layout.list_stream_playlist_grid_item, parent); //TODO
}
public LocalPlaylistStreamGridItemHolder(final LocalItemBuilder infoItemBuilder,
final ViewGroup parent) {
super(infoItemBuilder, R.layout.list_stream_playlist_grid_item, parent); // TODO
}
}

View file

@ -1,12 +1,13 @@
package org.schabi.newpipe.local.holder;
import androidx.core.content.ContextCompat;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.core.content.ContextCompat;
import org.schabi.newpipe.R;
import org.schabi.newpipe.database.LocalItem;
import org.schabi.newpipe.database.playlist.PlaylistStreamEntry;
@ -24,15 +25,15 @@ import java.util.ArrayList;
import java.util.concurrent.TimeUnit;
public class LocalPlaylistStreamItemHolder extends LocalItemHolder {
public final ImageView itemThumbnailView;
public final TextView itemVideoTitleView;
public final TextView itemAdditionalDetailsView;
private final TextView itemAdditionalDetailsView;
public final TextView itemDurationView;
public final View itemHandleView;
public final AnimatedProgressBar itemProgressView;
private final View itemHandleView;
private final AnimatedProgressBar itemProgressView;
LocalPlaylistStreamItemHolder(LocalItemBuilder infoItemBuilder, int layoutId, ViewGroup parent) {
LocalPlaylistStreamItemHolder(final LocalItemBuilder infoItemBuilder, final int layoutId,
final ViewGroup parent) {
super(infoItemBuilder, layoutId, parent);
itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView);
@ -43,30 +44,41 @@ public class LocalPlaylistStreamItemHolder extends LocalItemHolder {
itemProgressView = itemView.findViewById(R.id.itemProgressView);
}
public LocalPlaylistStreamItemHolder(LocalItemBuilder infoItemBuilder, ViewGroup parent) {
public LocalPlaylistStreamItemHolder(final LocalItemBuilder infoItemBuilder,
final ViewGroup parent) {
this(infoItemBuilder, R.layout.list_stream_playlist_item, parent);
}
@Override
public void updateFromItem(final LocalItem localItem, HistoryRecordManager historyRecordManager, final DateFormat dateFormat) {
if (!(localItem instanceof PlaylistStreamEntry)) return;
public void updateFromItem(final LocalItem localItem,
final HistoryRecordManager historyRecordManager,
final DateFormat dateFormat) {
if (!(localItem instanceof PlaylistStreamEntry)) {
return;
}
final PlaylistStreamEntry item = (PlaylistStreamEntry) localItem;
itemVideoTitleView.setText(item.title);
itemAdditionalDetailsView.setText(Localization.concatenateStrings(item.uploader,
NewPipe.getNameOfService(item.serviceId)));
itemVideoTitleView.setText(item.getStreamEntity().getTitle());
itemAdditionalDetailsView.setText(Localization
.concatenateStrings(item.getStreamEntity().getUploader(),
NewPipe.getNameOfService(item.getStreamEntity().getServiceId())));
if (item.duration > 0) {
itemDurationView.setText(Localization.getDurationString(item.duration));
if (item.getStreamEntity().getDuration() > 0) {
itemDurationView.setText(Localization
.getDurationString(item.getStreamEntity().getDuration()));
itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(),
R.color.duration_background_color));
itemDurationView.setVisibility(View.VISIBLE);
StreamStateEntity state = historyRecordManager.loadLocalStreamStateBatch(new ArrayList<LocalItem>() {{ add(localItem); }}).blockingGet().get(0);
StreamStateEntity state = historyRecordManager
.loadLocalStreamStateBatch(new ArrayList<LocalItem>() {{
add(localItem);
}}).blockingGet().get(0);
if (state != null) {
itemProgressView.setVisibility(View.VISIBLE);
itemProgressView.setMax((int) item.duration);
itemProgressView.setProgress((int) TimeUnit.MILLISECONDS.toSeconds(state.getProgressTime()));
itemProgressView.setMax((int) item.getStreamEntity().getDuration());
itemProgressView.setProgress((int) TimeUnit.MILLISECONDS
.toSeconds(state.getProgressTime()));
} else {
itemProgressView.setVisibility(View.GONE);
}
@ -75,7 +87,7 @@ public class LocalPlaylistStreamItemHolder extends LocalItemHolder {
}
// Default thumbnail is shown on error, while loading and if the url is empty
itemBuilder.displayImage(item.thumbnailUrl, itemThumbnailView,
itemBuilder.displayImage(item.getStreamEntity().getThumbnailUrl(), itemThumbnailView,
ImageDisplayConstants.DISPLAY_THUMBNAIL_OPTIONS);
itemView.setOnClickListener(view -> {
@ -97,17 +109,25 @@ public class LocalPlaylistStreamItemHolder extends LocalItemHolder {
}
@Override
public void updateState(LocalItem localItem, HistoryRecordManager historyRecordManager) {
if (!(localItem instanceof PlaylistStreamEntry)) return;
public void updateState(final LocalItem localItem,
final HistoryRecordManager historyRecordManager) {
if (!(localItem instanceof PlaylistStreamEntry)) {
return;
}
final PlaylistStreamEntry item = (PlaylistStreamEntry) localItem;
StreamStateEntity state = historyRecordManager.loadLocalStreamStateBatch(new ArrayList<LocalItem>() {{ add(localItem); }}).blockingGet().get(0);
if (state != null && item.duration > 0) {
itemProgressView.setMax((int) item.duration);
StreamStateEntity state = historyRecordManager
.loadLocalStreamStateBatch(new ArrayList<LocalItem>() {{
add(localItem);
}}).blockingGet().get(0);
if (state != null && item.getStreamEntity().getDuration() > 0) {
itemProgressView.setMax((int) item.getStreamEntity().getDuration());
if (itemProgressView.getVisibility() == View.VISIBLE) {
itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS.toSeconds(state.getProgressTime()));
itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS
.toSeconds(state.getProgressTime()));
} else {
itemProgressView.setProgress((int) TimeUnit.MILLISECONDS.toSeconds(state.getProgressTime()));
itemProgressView.setProgress((int) TimeUnit.MILLISECONDS
.toSeconds(state.getProgressTime()));
AnimationUtils.animateView(itemProgressView, true, 500);
}
} else if (itemProgressView.getVisibility() == View.VISIBLE) {
@ -118,8 +138,8 @@ public class LocalPlaylistStreamItemHolder extends LocalItemHolder {
private View.OnTouchListener getOnTouchListener(final PlaylistStreamEntry item) {
return (view, motionEvent) -> {
view.performClick();
if (itemBuilder != null && itemBuilder.getOnItemSelectedListener() != null &&
motionEvent.getActionMasked() == MotionEvent.ACTION_DOWN) {
if (itemBuilder != null && itemBuilder.getOnItemSelectedListener() != null
&& motionEvent.getActionMasked() == MotionEvent.ACTION_DOWN) {
itemBuilder.getOnItemSelectedListener().drag(item,
LocalPlaylistStreamItemHolder.this);
}

View file

@ -6,8 +6,8 @@ import org.schabi.newpipe.R;
import org.schabi.newpipe.local.LocalItemBuilder;
public class LocalStatisticStreamGridItemHolder extends LocalStatisticStreamItemHolder {
public LocalStatisticStreamGridItemHolder(LocalItemBuilder infoItemBuilder, ViewGroup parent) {
super(infoItemBuilder, R.layout.list_stream_grid_item, parent);
}
public LocalStatisticStreamGridItemHolder(final LocalItemBuilder infoItemBuilder,
final ViewGroup parent) {
super(infoItemBuilder, R.layout.list_stream_grid_item, parent);
}
}

View file

@ -1,12 +1,13 @@
package org.schabi.newpipe.local.holder;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import org.schabi.newpipe.R;
import org.schabi.newpipe.database.LocalItem;
import org.schabi.newpipe.database.stream.StreamStatisticsEntry;
@ -44,20 +45,21 @@ import java.util.concurrent.TimeUnit;
*/
public class LocalStatisticStreamItemHolder extends LocalItemHolder {
public final ImageView itemThumbnailView;
public final TextView itemVideoTitleView;
public final TextView itemUploaderView;
public final TextView itemDurationView;
@Nullable
public final TextView itemAdditionalDetails;
public final AnimatedProgressBar itemProgressView;
private final AnimatedProgressBar itemProgressView;
public LocalStatisticStreamItemHolder(LocalItemBuilder itemBuilder, ViewGroup parent) {
public LocalStatisticStreamItemHolder(final LocalItemBuilder itemBuilder,
final ViewGroup parent) {
this(itemBuilder, R.layout.list_stream_item, parent);
}
LocalStatisticStreamItemHolder(LocalItemBuilder infoItemBuilder, int layoutId, ViewGroup parent) {
LocalStatisticStreamItemHolder(final LocalItemBuilder infoItemBuilder, final int layoutId,
final ViewGroup parent) {
super(infoItemBuilder, layoutId, parent);
itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView);
@ -70,32 +72,41 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder {
private String getStreamInfoDetailLine(final StreamStatisticsEntry entry,
final DateFormat dateFormat) {
final String watchCount = Localization.shortViewCount(itemBuilder.getContext(),
entry.watchCount);
final String uploadDate = dateFormat.format(entry.latestAccessDate);
final String serviceName = NewPipe.getNameOfService(entry.serviceId);
final String watchCount = Localization
.shortViewCount(itemBuilder.getContext(), entry.getWatchCount());
final String uploadDate = dateFormat.format(entry.getLatestAccessDate());
final String serviceName = NewPipe.getNameOfService(entry.getStreamEntity().getServiceId());
return Localization.concatenateStrings(watchCount, uploadDate, serviceName);
}
@Override
public void updateFromItem(final LocalItem localItem, HistoryRecordManager historyRecordManager, final DateFormat dateFormat) {
if (!(localItem instanceof StreamStatisticsEntry)) return;
public void updateFromItem(final LocalItem localItem,
final HistoryRecordManager historyRecordManager,
final DateFormat dateFormat) {
if (!(localItem instanceof StreamStatisticsEntry)) {
return;
}
final StreamStatisticsEntry item = (StreamStatisticsEntry) localItem;
itemVideoTitleView.setText(item.title);
itemUploaderView.setText(item.uploader);
itemVideoTitleView.setText(item.getStreamEntity().getTitle());
itemUploaderView.setText(item.getStreamEntity().getUploader());
if (item.duration > 0) {
itemDurationView.setText(Localization.getDurationString(item.duration));
if (item.getStreamEntity().getDuration() > 0) {
itemDurationView.
setText(Localization.getDurationString(item.getStreamEntity().getDuration()));
itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(),
R.color.duration_background_color));
itemDurationView.setVisibility(View.VISIBLE);
StreamStateEntity state = historyRecordManager.loadLocalStreamStateBatch(new ArrayList<LocalItem>() {{ add(localItem); }}).blockingGet().get(0);
StreamStateEntity state = historyRecordManager
.loadLocalStreamStateBatch(new ArrayList<LocalItem>() {{
add(localItem);
}}).blockingGet().get(0);
if (state != null) {
itemProgressView.setVisibility(View.VISIBLE);
itemProgressView.setMax((int) item.duration);
itemProgressView.setProgress((int) TimeUnit.MILLISECONDS.toSeconds(state.getProgressTime()));
itemProgressView.setMax((int) item.getStreamEntity().getDuration());
itemProgressView.setProgress((int) TimeUnit.MILLISECONDS
.toSeconds(state.getProgressTime()));
} else {
itemProgressView.setVisibility(View.GONE);
}
@ -109,7 +120,7 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder {
}
// Default thumbnail is shown on error, while loading and if the url is empty
itemBuilder.displayImage(item.thumbnailUrl, itemThumbnailView,
itemBuilder.displayImage(item.getStreamEntity().getThumbnailUrl(), itemThumbnailView,
ImageDisplayConstants.DISPLAY_THUMBNAIL_OPTIONS);
itemView.setOnClickListener(view -> {
@ -128,17 +139,25 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder {
}
@Override
public void updateState(LocalItem localItem, HistoryRecordManager historyRecordManager) {
if (!(localItem instanceof StreamStatisticsEntry)) return;
public void updateState(final LocalItem localItem,
final HistoryRecordManager historyRecordManager) {
if (!(localItem instanceof StreamStatisticsEntry)) {
return;
}
final StreamStatisticsEntry item = (StreamStatisticsEntry) localItem;
StreamStateEntity state = historyRecordManager.loadLocalStreamStateBatch(new ArrayList<LocalItem>() {{ add(localItem); }}).blockingGet().get(0);
if (state != null && item.duration > 0) {
itemProgressView.setMax((int) item.duration);
StreamStateEntity state = historyRecordManager
.loadLocalStreamStateBatch(new ArrayList<LocalItem>() {{
add(localItem);
}}).blockingGet().get(0);
if (state != null && item.getStreamEntity().getDuration() > 0) {
itemProgressView.setMax((int) item.getStreamEntity().getDuration());
if (itemProgressView.getVisibility() == View.VISIBLE) {
itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS.toSeconds(state.getProgressTime()));
itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS
.toSeconds(state.getProgressTime()));
} else {
itemProgressView.setProgress((int) TimeUnit.MILLISECONDS.toSeconds(state.getProgressTime()));
itemProgressView.setProgress((int) TimeUnit.MILLISECONDS
.toSeconds(state.getProgressTime()));
AnimationUtils.animateView(itemProgressView, true, 500);
}
} else if (itemProgressView.getVisibility() == View.VISIBLE) {

View file

@ -13,12 +13,12 @@ import java.text.DateFormat;
public abstract class PlaylistItemHolder extends LocalItemHolder {
public final ImageView itemThumbnailView;
public final TextView itemStreamCountView;
final TextView itemStreamCountView;
public final TextView itemTitleView;
public final TextView itemUploaderView;
public PlaylistItemHolder(LocalItemBuilder infoItemBuilder,
int layoutId, ViewGroup parent) {
public PlaylistItemHolder(final LocalItemBuilder infoItemBuilder, final int layoutId,
final ViewGroup parent) {
super(infoItemBuilder, layoutId, parent);
itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView);
@ -27,12 +27,14 @@ public abstract class PlaylistItemHolder extends LocalItemHolder {
itemUploaderView = itemView.findViewById(R.id.itemUploaderView);
}
public PlaylistItemHolder(LocalItemBuilder infoItemBuilder, ViewGroup parent) {
public PlaylistItemHolder(final LocalItemBuilder infoItemBuilder, final ViewGroup parent) {
this(infoItemBuilder, R.layout.list_playlist_mini_item, parent);
}
@Override
public void updateFromItem(final LocalItem localItem, HistoryRecordManager historyRecordManager, final DateFormat dateFormat) {
public void updateFromItem(final LocalItem localItem,
final HistoryRecordManager historyRecordManager,
final DateFormat dateFormat) {
itemView.setOnClickListener(view -> {
if (itemBuilder.getOnItemSelectedListener() != null) {
itemBuilder.getOnItemSelectedListener().selected(localItem);

View file

@ -6,8 +6,8 @@ import org.schabi.newpipe.R;
import org.schabi.newpipe.local.LocalItemBuilder;
public class RemotePlaylistGridItemHolder extends RemotePlaylistItemHolder {
public RemotePlaylistGridItemHolder(LocalItemBuilder infoItemBuilder, ViewGroup parent) {
super(infoItemBuilder, R.layout.list_playlist_grid_item, parent);
}
public RemotePlaylistGridItemHolder(final LocalItemBuilder infoItemBuilder,
final ViewGroup parent) {
super(infoItemBuilder, R.layout.list_playlist_grid_item, parent);
}
}

View file

@ -1,5 +1,6 @@
package org.schabi.newpipe.local.holder;
import android.text.TextUtils;
import android.view.ViewGroup;
import org.schabi.newpipe.database.LocalItem;
@ -10,30 +11,35 @@ import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.util.ImageDisplayConstants;
import org.schabi.newpipe.util.Localization;
import android.text.TextUtils;
import java.text.DateFormat;
public class RemotePlaylistItemHolder extends PlaylistItemHolder {
public RemotePlaylistItemHolder(LocalItemBuilder infoItemBuilder, ViewGroup parent) {
public RemotePlaylistItemHolder(final LocalItemBuilder infoItemBuilder,
final ViewGroup parent) {
super(infoItemBuilder, parent);
}
RemotePlaylistItemHolder(LocalItemBuilder infoItemBuilder, int layoutId, ViewGroup parent) {
RemotePlaylistItemHolder(final LocalItemBuilder infoItemBuilder, final int layoutId,
final ViewGroup parent) {
super(infoItemBuilder, layoutId, parent);
}
@Override
public void updateFromItem(final LocalItem localItem, HistoryRecordManager historyRecordManager, final DateFormat dateFormat) {
if (!(localItem instanceof PlaylistRemoteEntity)) return;
public void updateFromItem(final LocalItem localItem,
final HistoryRecordManager historyRecordManager,
final DateFormat dateFormat) {
if (!(localItem instanceof PlaylistRemoteEntity)) {
return;
}
final PlaylistRemoteEntity item = (PlaylistRemoteEntity) localItem;
itemTitleView.setText(item.getName());
itemStreamCountView.setText(String.valueOf(item.getStreamCount()));
itemStreamCountView.setText(Localization.localizeStreamCountMini(
itemStreamCountView.getContext(), item.getStreamCount()));
// Here is where the uploader name is set in the bookmarked playlists library
if (!TextUtils.isEmpty(item.getUploader())) {
itemUploaderView.setText(Localization.concatenateStrings(item.getUploader(),
NewPipe.getNameOfService(item.getServiceId())));
NewPipe.getNameOfService(item.getServiceId())));
} else {
itemUploaderView.setText(NewPipe.getNameOfService(item.getServiceId()));
}

View file

@ -2,32 +2,40 @@ package org.schabi.newpipe.local.playlist;
import android.app.Activity;
import android.content.Context;
import android.content.DialogInterface;
import android.os.Bundle;
import android.os.Parcelable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.ItemTouchHelper;
import android.text.TextUtils;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.EditText;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.RecyclerView;
import org.reactivestreams.Subscriber;
import org.reactivestreams.Subscription;
import org.schabi.newpipe.NewPipeDatabase;
import org.schabi.newpipe.R;
import org.schabi.newpipe.database.LocalItem;
import org.schabi.newpipe.database.history.model.StreamHistoryEntry;
import org.schabi.newpipe.database.playlist.PlaylistStreamEntry;
import org.schabi.newpipe.database.stream.model.StreamStateEntity;
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.extractor.stream.StreamType;
import org.schabi.newpipe.info_list.InfoItemDialog;
import org.schabi.newpipe.local.BaseLocalListFragment;
import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
import org.schabi.newpipe.report.UserAction;
@ -38,40 +46,41 @@ import org.schabi.newpipe.util.StreamDialogEntry;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import icepick.State;
import io.reactivex.Flowable;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.disposables.CompositeDisposable;
import io.reactivex.disposables.Disposable;
import io.reactivex.disposables.Disposables;
import io.reactivex.schedulers.Schedulers;
import io.reactivex.subjects.PublishSubject;
import static org.schabi.newpipe.util.AnimationUtils.animateView;
public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistStreamEntry>, Void> {
// Save the list 10 seconds after the last change occurred
private static final long SAVE_DEBOUNCE_MILLIS = 10000;
private static final int MINIMUM_INITIAL_DRAG_VELOCITY = 12;
private View headerRootLayout;
private TextView headerTitleView;
private TextView headerStreamCount;
private View playlistControl;
private View headerPlayAllButton;
private View headerPopupButton;
private View headerBackgroundButton;
@State
protected Long playlistId;
@State
protected String name;
@State
protected Parcelable itemsListState;
Parcelable itemsListState;
private View headerRootLayout;
private TextView headerTitleView;
private TextView headerStreamCount;
private View playlistControl;
private View headerPlayAllButton;
private View headerPopupButton;
private View headerBackgroundButton;
private ItemTouchHelper itemTouchHelper;
@ -85,8 +94,10 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
private AtomicBoolean isLoadingComplete;
/* Has the playlist been modified (e.g. items reordered or deleted) */
private AtomicBoolean isModified;
/* Is the playlist currently being processed to remove watched videos */
private boolean isRemovingWatched = false;
public static LocalPlaylistFragment getInstance(long playlistId, String name) {
public static LocalPlaylistFragment getInstance(final long playlistId, final String name) {
LocalPlaylistFragment instance = new LocalPlaylistFragment();
instance.setInitialData(playlistId, name);
return instance;
@ -97,7 +108,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
///////////////////////////////////////////////////////////////////////////
@Override
public void onCreate(Bundle savedInstanceState) {
public void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
playlistManager = new LocalPlaylistManager(NewPipeDatabase.getInstance(getContext()));
debouncedSaveSignal = PublishSubject.create();
@ -109,9 +120,9 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
}
@Override
public View onCreateView(@NonNull LayoutInflater inflater,
@Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
public View onCreateView(@NonNull final LayoutInflater inflater,
@Nullable final ViewGroup container,
@Nullable final Bundle savedInstanceState) {
return inflater.inflate(R.layout.fragment_playlist, container, false);
}
@ -129,15 +140,15 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
}
@Override
protected void initViews(View rootView, Bundle savedInstanceState) {
protected void initViews(final View rootView, final Bundle savedInstanceState) {
super.initViews(rootView, savedInstanceState);
setTitle(name);
}
@Override
protected View getListHeader() {
headerRootLayout = activity.getLayoutInflater().inflate(R.layout.local_playlist_header,
itemsList, false);
headerRootLayout = activity.getLayoutInflater()
.inflate(R.layout.local_playlist_header, itemsList, false);
headerTitleView = headerRootLayout.findViewById(R.id.playlist_title_view);
headerTitleView.setSelected(true);
@ -163,24 +174,28 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
itemListAdapter.setSelectedListener(new OnClickGesture<LocalItem>() {
@Override
public void selected(LocalItem selectedItem) {
public void selected(final LocalItem selectedItem) {
if (selectedItem instanceof PlaylistStreamEntry) {
final PlaylistStreamEntry item = (PlaylistStreamEntry) selectedItem;
NavigationHelper.openVideoDetailFragment(getFragmentManager(),
item.serviceId, item.url, item.title);
item.getStreamEntity().getServiceId(), item.getStreamEntity().getUrl(),
item.getStreamEntity().getTitle());
}
}
@Override
public void held(LocalItem selectedItem) {
public void held(final LocalItem selectedItem) {
if (selectedItem instanceof PlaylistStreamEntry) {
showStreamItemDialog((PlaylistStreamEntry) selectedItem);
}
}
@Override
public void drag(LocalItem selectedItem, RecyclerView.ViewHolder viewHolder) {
if (itemTouchHelper != null) itemTouchHelper.startDrag(viewHolder);
public void drag(final LocalItem selectedItem,
final RecyclerView.ViewHolder viewHolder) {
if (itemTouchHelper != null) {
itemTouchHelper.startDrag(viewHolder);
}
}
});
}
@ -192,22 +207,32 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
@Override
public void showLoading() {
super.showLoading();
if (headerRootLayout != null) animateView(headerRootLayout, false, 200);
if (playlistControl != null) animateView(playlistControl, false, 200);
if (headerRootLayout != null) {
animateView(headerRootLayout, false, 200);
}
if (playlistControl != null) {
animateView(playlistControl, false, 200);
}
}
@Override
public void hideLoading() {
super.hideLoading();
if (headerRootLayout != null) animateView(headerRootLayout, true, 200);
if (playlistControl != null) animateView(playlistControl, true, 200);
if (headerRootLayout != null) {
animateView(headerRootLayout, true, 200);
}
if (playlistControl != null) {
animateView(playlistControl, true, 200);
}
}
@Override
public void startLoading(boolean forceLoad) {
public void startLoading(final boolean forceLoad) {
super.startLoading(forceLoad);
if (disposables != null) disposables.clear();
if (disposables != null) {
disposables.clear();
}
disposables.add(getDebouncedSaver());
isLoadingComplete.set(false);
@ -232,17 +257,39 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
saveImmediate();
}
@Override
public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) {
if (DEBUG) {
Log.d(TAG, "onCreateOptionsMenu() called with: "
+ "menu = [" + menu + "], inflater = [" + inflater + "]");
}
super.onCreateOptionsMenu(menu, inflater);
inflater.inflate(R.menu.menu_local_playlist, menu);
}
@Override
public void onDestroyView() {
super.onDestroyView();
if (itemListAdapter != null) itemListAdapter.unsetSelectedListener();
if (headerBackgroundButton != null) headerBackgroundButton.setOnClickListener(null);
if (headerPlayAllButton != null) headerPlayAllButton.setOnClickListener(null);
if (headerPopupButton != null) headerPopupButton.setOnClickListener(null);
if (itemListAdapter != null) {
itemListAdapter.unsetSelectedListener();
}
if (headerBackgroundButton != null) {
headerBackgroundButton.setOnClickListener(null);
}
if (headerPlayAllButton != null) {
headerPlayAllButton.setOnClickListener(null);
}
if (headerPopupButton != null) {
headerPopupButton.setOnClickListener(null);
}
if (databaseSubscription != null) databaseSubscription.cancel();
if (disposables != null) disposables.clear();
if (databaseSubscription != null) {
databaseSubscription.cancel();
}
if (disposables != null) {
disposables.clear();
}
databaseSubscription = null;
itemTouchHelper = null;
@ -251,8 +298,12 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
@Override
public void onDestroy() {
super.onDestroy();
if (debouncedSaveSignal != null) debouncedSaveSignal.onComplete();
if (disposables != null) disposables.dispose();
if (debouncedSaveSignal != null) {
debouncedSaveSignal.onComplete();
}
if (disposables != null) {
disposables.dispose();
}
debouncedSaveSignal = null;
playlistManager = null;
@ -269,40 +320,162 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
private Subscriber<List<PlaylistStreamEntry>> getPlaylistObserver() {
return new Subscriber<List<PlaylistStreamEntry>>() {
@Override
public void onSubscribe(Subscription s) {
public void onSubscribe(final Subscription s) {
showLoading();
isLoadingComplete.set(false);
if (databaseSubscription != null) databaseSubscription.cancel();
if (databaseSubscription != null) {
databaseSubscription.cancel();
}
databaseSubscription = s;
databaseSubscription.request(1);
}
@Override
public void onNext(List<PlaylistStreamEntry> streams) {
public void onNext(final List<PlaylistStreamEntry> streams) {
// Skip handling the result after it has been modified
if (isModified == null || !isModified.get()) {
handleResult(streams);
isLoadingComplete.set(true);
}
if (databaseSubscription != null) databaseSubscription.request(1);
if (databaseSubscription != null) {
databaseSubscription.request(1);
}
}
@Override
public void onError(Throwable exception) {
public void onError(final Throwable exception) {
LocalPlaylistFragment.this.onError(exception);
}
@Override
public void onComplete() {}
public void onComplete() { }
};
}
@Override
public void handleResult(@NonNull List<PlaylistStreamEntry> result) {
public boolean onOptionsItemSelected(final MenuItem item) {
switch (item.getItemId()) {
case R.id.menu_item_remove_watched:
if (!isRemovingWatched) {
new AlertDialog.Builder(requireContext())
.setMessage(R.string.remove_watched_popup_warning)
.setTitle(R.string.remove_watched_popup_title)
.setPositiveButton(R.string.yes,
(DialogInterface d, int id) -> removeWatchedStreams(false))
.setNeutralButton(
R.string.remove_watched_popup_yes_and_partially_watched_videos,
(DialogInterface d, int id) -> removeWatchedStreams(true))
.setNegativeButton(R.string.cancel,
(DialogInterface d, int id) -> d.cancel())
.create()
.show();
}
break;
default:
return super.onOptionsItemSelected(item);
}
return true;
}
public void removeWatchedStreams(final boolean removePartiallyWatched) {
if (isRemovingWatched) {
return;
}
isRemovingWatched = true;
showLoading();
disposables.add(playlistManager.getPlaylistStreams(playlistId)
.subscribeOn(Schedulers.io())
.map((List<PlaylistStreamEntry> playlist) -> {
// Playlist data
final Iterator<PlaylistStreamEntry> playlistIter = playlist.iterator();
// History data
final HistoryRecordManager recordManager
= new HistoryRecordManager(getContext());
final Iterator<StreamHistoryEntry> historyIter = recordManager
.getStreamHistorySortedById().blockingFirst().iterator();
// Remove Watched, Functionality data
final List<PlaylistStreamEntry> notWatchedItems = new ArrayList<>();
boolean thumbnailVideoRemoved = false;
// already sorted by ^ getStreamHistorySortedById(), binary search can be used
final ArrayList<Long> historyStreamIds = new ArrayList<>();
while (historyIter.hasNext()) {
historyStreamIds.add(historyIter.next().getStreamId());
}
if (removePartiallyWatched) {
while (playlistIter.hasNext()) {
final PlaylistStreamEntry playlistItem = playlistIter.next();
int indexInHistory = Collections.binarySearch(historyStreamIds,
playlistItem.getStreamId());
if (indexInHistory < 0) {
notWatchedItems.add(playlistItem);
} else if (!thumbnailVideoRemoved
&& playlistManager.getPlaylistThumbnail(playlistId)
.equals(playlistItem.getStreamEntity().getThumbnailUrl())) {
thumbnailVideoRemoved = true;
}
}
} else {
final Iterator<StreamStateEntity> streamStatesIter = recordManager
.loadLocalStreamStateBatch(playlist).blockingGet().iterator();
while (playlistIter.hasNext()) {
PlaylistStreamEntry playlistItem = playlistIter.next();
final int indexInHistory = Collections.binarySearch(historyStreamIds,
playlistItem.getStreamId());
final boolean hasState = streamStatesIter.next() != null;
if (indexInHistory < 0 || hasState) {
notWatchedItems.add(playlistItem);
} else if (!thumbnailVideoRemoved
&& playlistManager.getPlaylistThumbnail(playlistId)
.equals(playlistItem.getStreamEntity().getThumbnailUrl())) {
thumbnailVideoRemoved = true;
}
}
}
return Flowable.just(notWatchedItems, thumbnailVideoRemoved);
})
.observeOn(AndroidSchedulers.mainThread())
.subscribe(flow -> {
final List<PlaylistStreamEntry> notWatchedItems =
(List<PlaylistStreamEntry>) flow.blockingFirst();
final boolean thumbnailVideoRemoved = (Boolean) flow.blockingLast();
itemListAdapter.clearStreamItemList();
itemListAdapter.addItems(notWatchedItems);
saveChanges();
if (thumbnailVideoRemoved) {
updateThumbnailUrl();
}
final long videoCount = itemListAdapter.getItemsList().size();
setVideoCount(videoCount);
if (videoCount == 0) {
showEmptyState();
}
hideLoading();
isRemovingWatched = false;
}, this::onError));
}
@Override
public void handleResult(@NonNull final List<PlaylistStreamEntry> result) {
super.handleResult(result);
if (itemListAdapter == null) return;
if (itemListAdapter == null) {
return;
}
itemListAdapter.clearStreamItemList();
@ -325,6 +498,16 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
headerBackgroundButton.setOnClickListener(view ->
NavigationHelper.playOnBackgroundPlayer(activity, getPlayQueue(), false));
headerPopupButton.setOnLongClickListener(view -> {
NavigationHelper.enqueueOnPopupPlayer(activity, getPlayQueue(), true);
return true;
});
headerBackgroundButton.setOnLongClickListener(view -> {
NavigationHelper.enqueueOnBackgroundPlayer(activity, getPlayQueue(), true);
return true;
});
hideLoading();
}
@ -335,12 +518,16 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
@Override
protected void resetFragment() {
super.resetFragment();
if (databaseSubscription != null) databaseSubscription.cancel();
if (databaseSubscription != null) {
databaseSubscription.cancel();
}
}
@Override
protected boolean onError(Throwable exception) {
if (super.onError(exception)) return true;
protected boolean onError(final Throwable exception) {
if (super.onError(exception)) {
return true;
}
onUnrecoverableError(exception, UserAction.SOMETHING_ELSE,
"none", "Local Playlist", R.string.general_error);
@ -352,7 +539,9 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
//////////////////////////////////////////////////////////////////////////*/
private void createRenameDialog() {
if (playlistId == null || name == null || getContext() == null) return;
if (playlistId == null || name == null || getContext() == null) {
return;
}
final View dialogView = View.inflate(getContext(), R.layout.dialog_playlist_name, null);
EditText nameEdit = dialogView.findViewById(R.id.playlist_name);
@ -371,30 +560,38 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
dialogBuilder.show();
}
private void changePlaylistName(final String name) {
if (playlistManager == null) return;
private void changePlaylistName(final String title) {
if (playlistManager == null) {
return;
}
this.name = name;
setTitle(name);
this.name = title;
setTitle(title);
Log.d(TAG, "Updating playlist id=[" + playlistId +
"] with new name=[" + name + "] items");
if (DEBUG) {
Log.d(TAG, "Updating playlist id=[" + playlistId + "] "
+ "with new title=[" + title + "] items");
}
final Disposable disposable = playlistManager.renamePlaylist(playlistId, name)
final Disposable disposable = playlistManager.renamePlaylist(playlistId, title)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(longs -> {/*Do nothing on success*/}, this::onError);
.subscribe(longs -> { /*Do nothing on success*/ }, this::onError);
disposables.add(disposable);
}
private void changeThumbnailUrl(final String thumbnailUrl) {
if (playlistManager == null) return;
if (playlistManager == null) {
return;
}
final Toast successToast = Toast.makeText(getActivity(),
R.string.playlist_thumbnail_change_success,
Toast.LENGTH_SHORT);
Log.d(TAG, "Updating playlist id=[" + playlistId +
"] with new thumbnail url=[" + thumbnailUrl + "]");
if (DEBUG) {
Log.d(TAG, "Updating playlist id=[" + playlistId + "] "
+ "with new thumbnail url=[" + thumbnailUrl + "]");
}
final Disposable disposable = playlistManager
.changePlaylistThumbnail(playlistId, thumbnailUrl)
@ -403,23 +600,47 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
disposables.add(disposable);
}
private void updateThumbnailUrl() {
String newThumbnailUrl;
if (!itemListAdapter.getItemsList().isEmpty()) {
newThumbnailUrl = ((PlaylistStreamEntry) itemListAdapter.getItemsList().get(0))
.getStreamEntity().getThumbnailUrl();
} else {
newThumbnailUrl = "drawable://" + R.drawable.dummy_thumbnail_playlist;
}
changeThumbnailUrl(newThumbnailUrl);
}
private void deleteItem(final PlaylistStreamEntry item) {
if (itemListAdapter == null) return;
if (itemListAdapter == null) {
return;
}
itemListAdapter.removeItem(item);
if (playlistManager.getPlaylistThumbnail(playlistId)
.equals(item.getStreamEntity().getThumbnailUrl())) {
updateThumbnailUrl();
}
setVideoCount(itemListAdapter.getItemsList().size());
saveChanges();
}
private void saveChanges() {
if (isModified == null || debouncedSaveSignal == null) return;
if (isModified == null || debouncedSaveSignal == null) {
return;
}
isModified.set(true);
debouncedSaveSignal.onNext(System.currentTimeMillis());
}
private Disposable getDebouncedSaver() {
if (debouncedSaveSignal == null) return Disposables.empty();
if (debouncedSaveSignal == null) {
return Disposables.empty();
}
return debouncedSaveSignal
.debounce(SAVE_DEBOUNCE_MILLIS, TimeUnit.MILLISECONDS)
@ -428,13 +649,15 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
}
private void saveImmediate() {
if (playlistManager == null || itemListAdapter == null) return;
if (playlistManager == null || itemListAdapter == null) {
return;
}
// List must be loaded and modified in order to save
if (isLoadingComplete == null || isModified == null ||
!isLoadingComplete.get() || !isModified.get()) {
Log.w(TAG, "Attempting to save playlist when local playlist " +
"is not loaded or not modified: playlist id=[" + playlistId + "]");
if (isLoadingComplete == null || isModified == null
|| !isLoadingComplete.get() || !isModified.get()) {
Log.w(TAG, "Attempting to save playlist when local playlist "
+ "is not loaded or not modified: playlist id=[" + playlistId + "]");
return;
}
@ -442,17 +665,23 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
List<Long> streamIds = new ArrayList<>(items.size());
for (final LocalItem item : items) {
if (item instanceof PlaylistStreamEntry) {
streamIds.add(((PlaylistStreamEntry) item).streamId);
streamIds.add(((PlaylistStreamEntry) item).getStreamId());
}
}
Log.d(TAG, "Updating playlist id=[" + playlistId +
"] with [" + streamIds.size() + "] items");
if (DEBUG) {
Log.d(TAG, "Updating playlist id=[" + playlistId + "] "
+ "with [" + streamIds.size() + "] items");
}
final Disposable disposable = playlistManager.updateJoin(playlistId, streamIds)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
() -> { if (isModified != null) isModified.set(false); },
() -> {
if (isModified != null) {
isModified.set(false);
}
},
this::onError
);
disposables.add(disposable);
@ -467,28 +696,33 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
return new ItemTouchHelper.SimpleCallback(directions,
ItemTouchHelper.ACTION_STATE_IDLE) {
@Override
public int interpolateOutOfBoundsScroll(RecyclerView recyclerView, int viewSize,
int viewSizeOutOfBounds, int totalSize,
long msSinceStartScroll) {
final int standardSpeed = super.interpolateOutOfBoundsScroll(recyclerView, viewSize,
viewSizeOutOfBounds, totalSize, msSinceStartScroll);
public int interpolateOutOfBoundsScroll(final RecyclerView recyclerView,
final int viewSize,
final int viewSizeOutOfBounds,
final int totalSize,
final long msSinceStartScroll) {
final int standardSpeed = super.interpolateOutOfBoundsScroll(recyclerView,
viewSize, viewSizeOutOfBounds, totalSize, msSinceStartScroll);
final int minimumAbsVelocity = Math.max(MINIMUM_INITIAL_DRAG_VELOCITY,
Math.abs(standardSpeed));
return minimumAbsVelocity * (int) Math.signum(viewSizeOutOfBounds);
}
@Override
public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder source,
RecyclerView.ViewHolder target) {
if (source.getItemViewType() != target.getItemViewType() ||
itemListAdapter == null) {
public boolean onMove(final RecyclerView recyclerView,
final RecyclerView.ViewHolder source,
final RecyclerView.ViewHolder target) {
if (source.getItemViewType() != target.getItemViewType()
|| itemListAdapter == null) {
return false;
}
final int sourceIndex = source.getAdapterPosition();
final int targetIndex = target.getAdapterPosition();
final boolean isSwapped = itemListAdapter.swapItems(sourceIndex, targetIndex);
if (isSwapped) saveChanges();
if (isSwapped) {
saveChanges();
}
return isSwapped;
}
@ -503,7 +737,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
}
@Override
public void onSwiped(RecyclerView.ViewHolder viewHolder, int swipeDir) {}
public void onSwiped(final RecyclerView.ViewHolder viewHolder, final int swipeDir) { }
};
}
@ -511,14 +745,16 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
// Utils
//////////////////////////////////////////////////////////////////////////*/
private PlayQueue getPlayQueueStartingAt(PlaylistStreamEntry infoItem) {
private PlayQueue getPlayQueueStartingAt(final PlaylistStreamEntry infoItem) {
return getPlayQueue(Math.max(itemListAdapter.getItemsList().indexOf(infoItem), 0));
}
protected void showStreamItemDialog(final PlaylistStreamEntry item) {
final Context context = getContext();
final Activity activity = getActivity();
if (context == null || context.getResources() == null || activity == null) return;
if (context == null || context.getResources() == null || activity == null) {
return;
}
final StreamInfoItem infoItem = item.toStreamInfoItem();
if (infoItem.getStreamType() == StreamType.AUDIO_STREAM) {
@ -541,23 +777,26 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
StreamDialogEntry.share);
StreamDialogEntry.start_here_on_popup.setCustomAction(
(fragment, infoItemDuplicate) -> NavigationHelper.playOnPopupPlayer(context, getPlayQueueStartingAt(item), true));
(fragment, infoItemDuplicate) -> NavigationHelper.
playOnPopupPlayer(context, getPlayQueueStartingAt(item), true));
}
StreamDialogEntry.start_here_on_background.setCustomAction(
(fragment, infoItemDuplicate) -> NavigationHelper.playOnBackgroundPlayer(context, getPlayQueueStartingAt(item), true));
StreamDialogEntry.start_here_on_background.setCustomAction((fragment, infoItemDuplicate) ->
NavigationHelper.playOnBackgroundPlayer(context,
getPlayQueueStartingAt(item), true));
StreamDialogEntry.set_as_playlist_thumbnail.setCustomAction(
(fragment, infoItemDuplicate) -> changeThumbnailUrl(item.thumbnailUrl));
StreamDialogEntry.delete.setCustomAction(
(fragment, infoItemDuplicate) -> deleteItem(item));
(fragment, infoItemDuplicate) ->
changeThumbnailUrl(item.getStreamEntity().getThumbnailUrl()));
StreamDialogEntry.delete.setCustomAction((fragment, infoItemDuplicate) ->
deleteItem(item));
new InfoItemDialog(activity, infoItem, StreamDialogEntry.getCommands(context), (dialog, which) ->
StreamDialogEntry.clickOn(which, this, infoItem)).show();
new InfoItemDialog(activity, infoItem, StreamDialogEntry.getCommands(context),
(dialog, which) -> StreamDialogEntry.clickOn(which, this, infoItem)).show();
}
private void setInitialData(long playlistId, String name) {
this.playlistId = playlistId;
this.name = !TextUtils.isEmpty(name) ? name : "";
private void setInitialData(final long pid, final String title) {
this.playlistId = pid;
this.name = !TextUtils.isEmpty(title) ? title : "";
}
private void setVideoCount(final long count) {

View file

@ -22,7 +22,6 @@ import io.reactivex.Single;
import io.reactivex.schedulers.Schedulers;
public class LocalPlaylistManager {
private final AppDatabase database;
private final StreamDAO streamTable;
private final PlaylistDAO playlistTable;
@ -37,7 +36,9 @@ public class LocalPlaylistManager {
public Maybe<List<Long>> createPlaylist(final String name, final List<StreamEntity> streams) {
// Disallow creation of empty playlists
if (streams.isEmpty()) return Maybe.empty();
if (streams.isEmpty()) {
return Maybe.empty();
}
final StreamEntity defaultStream = streams.get(0);
final PlaylistEntity newPlaylist =
new PlaylistEntity(name, defaultStream.getThumbnailUrl());
@ -103,6 +104,10 @@ public class LocalPlaylistManager {
return modifyPlaylist(playlistId, null, thumbnailUrl);
}
public String getPlaylistThumbnail(final long playlistId) {
return playlistTable.getPlaylist(playlistId).blockingFirst().get(0).getThumbnailUrl();
}
private Maybe<Integer> modifyPlaylist(final long playlistId,
@Nullable final String name,
@Nullable final String thumbnailUrl) {
@ -111,8 +116,12 @@ public class LocalPlaylistManager {
.filter(playlistEntities -> !playlistEntities.isEmpty())
.map(playlistEntities -> {
PlaylistEntity playlist = playlistEntities.get(0);
if (name != null) playlist.setName(name);
if (thumbnailUrl != null) playlist.setThumbnailUrl(thumbnailUrl);
if (name != null) {
playlist.setName(name);
}
if (thumbnailUrl != null) {
playlist.setThumbnailUrl(thumbnailUrl);
}
return playlistTable.update(playlist);
}).subscribeOn(Schedulers.io());
}

View file

@ -0,0 +1,63 @@
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_center),
SPACE(4, R.attr.ic_telescope),
COMPUTER(5, R.attr.ic_computer),
GAMING(6, R.attr.ic_videogame_asset),
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.ic_palette),
PERSON(16, R.attr.ic_person),
PEOPLE(17, R.attr.ic_people),
MONEY(18, R.attr.ic_money),
KIDS(19, R.attr.ic_child_care),
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.ic_headset),
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_kiosk_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),
RSS(37, R.attr.ic_rss);
@DrawableRes
fun getDrawableRes(context: Context): Int {
return ThemeHelper.resolveResourceIdFromAttr(context, drawableResourceAttr)
}
}

View file

@ -4,6 +4,7 @@ import android.app.AlertDialog;
import android.app.Dialog;
import android.content.Intent;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.DialogFragment;
@ -15,30 +16,36 @@ import org.schabi.newpipe.util.ThemeHelper;
import icepick.Icepick;
import icepick.State;
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
public class ImportConfirmationDialog extends DialogFragment {
@State
protected Intent resultServiceIntent;
public void setResultServiceIntent(Intent resultServiceIntent) {
this.resultServiceIntent = resultServiceIntent;
}
public static void show(@NonNull Fragment fragment, @NonNull Intent resultServiceIntent) {
if (fragment.getFragmentManager() == null) return;
public static void show(@NonNull final Fragment fragment,
@NonNull final Intent resultServiceIntent) {
if (fragment.getFragmentManager() == null) {
return;
}
final ImportConfirmationDialog confirmationDialog = new ImportConfirmationDialog();
confirmationDialog.setResultServiceIntent(resultServiceIntent);
confirmationDialog.show(fragment.getFragmentManager(), null);
}
public void setResultServiceIntent(final Intent resultServiceIntent) {
this.resultServiceIntent = resultServiceIntent;
}
@NonNull
@Override
public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) {
assureCorrectAppLanguage(getContext());
return new AlertDialog.Builder(getContext(), ThemeHelper.getDialogTheme(getContext()))
.setMessage(R.string.import_network_expensive_warning)
.setCancelable(true)
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(android.R.string.ok, (dialogInterface, i) -> {
.setPositiveButton(R.string.finish, (dialogInterface, i) -> {
if (resultServiceIntent != null && getContext() != null) {
getContext().startService(resultServiceIntent);
}
@ -48,16 +55,18 @@ public class ImportConfirmationDialog extends DialogFragment {
}
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
public void onCreate(@Nullable final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (resultServiceIntent == null) throw new IllegalStateException("Result intent is null");
if (resultServiceIntent == null) {
throw new IllegalStateException("Result intent is null");
}
Icepick.restoreInstanceState(this, savedInstanceState);
}
@Override
public void onSaveInstanceState(Bundle outState) {
public void onSaveInstanceState(final Bundle outState) {
super.onSaveInstanceState(outState);
Icepick.saveInstanceState(this, outState);
}

View file

@ -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<List<SubscriptionEntity>> 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<SubscriptionExtractor.ContentSource> 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<ChannelInfoItem>() {
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<List<SubscriptionEntity>> getDeleteObserver() {
return new Observer<List<SubscriptionEntity>>() {
@Override
public void onSubscribe(Disposable d) {
disposables.add(d);
}
@Override
public void onNext(List<SubscriptionEntity> 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<List<SubscriptionEntity>> getSubscriptionObserver() {
return new Observer<List<SubscriptionEntity>>() {
@Override
public void onSubscribe(Disposable d) {
showLoading();
disposables.add(d);
}
@Override
public void onNext(List<SubscriptionEntity> subscriptions) {
handleResult(subscriptions);
}
@Override
public void onError(Throwable exception) {
SubscriptionFragment.this.onError(exception);
}
@Override
public void onComplete() {
}
};
}
@Override
public void handleResult(@NonNull List<SubscriptionEntity> 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<InfoItem> getSubscriptionItems(List<SubscriptionEntity> subscriptions) {
List<InfoItem> 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);
}
}
}

View file

@ -0,0 +1,446 @@
package org.schabi.newpipe.local.subscription
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.res.Configuration
import android.os.Bundle
import android.os.Environment
import android.os.Parcelable
import android.preference.PreferenceManager
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.lifecycle.ViewModelProviders
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.recyclerview.widget.GridLayoutManager
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.GroupieViewHolder
import icepick.State
import io.reactivex.disposables.CompositeDisposable
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlin.math.floor
import kotlin.math.max
import kotlinx.android.synthetic.main.dialog_title.view.itemAdditionalDetails
import kotlinx.android.synthetic.main.dialog_title.view.itemTitleView
import kotlinx.android.synthetic.main.fragment_subscription.items_list
import org.schabi.newpipe.R
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
import org.schabi.newpipe.extractor.channel.ChannelInfoItem
import org.schabi.newpipe.fragments.BaseStateFragment
import org.schabi.newpipe.local.subscription.SubscriptionViewModel.SubscriptionState
import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialog
import org.schabi.newpipe.local.subscription.dialog.FeedGroupReorderDialog
import org.schabi.newpipe.local.subscription.item.ChannelItem
import org.schabi.newpipe.local.subscription.item.EmptyPlaceholderItem
import org.schabi.newpipe.local.subscription.item.FeedGroupAddItem
import org.schabi.newpipe.local.subscription.item.FeedGroupCardItem
import org.schabi.newpipe.local.subscription.item.FeedGroupCarouselItem
import org.schabi.newpipe.local.subscription.item.FeedImportExportItem
import org.schabi.newpipe.local.subscription.item.HeaderWithMenuItem
import org.schabi.newpipe.local.subscription.item.HeaderWithMenuItem.Companion.PAYLOAD_UPDATE_VISIBILITY_MENU_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_COMPLETE_ACTION
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_MODE
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_VALUE
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.PREVIOUS_EXPORT_MODE
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 org.schabi.newpipe.util.ThemeHelper
class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
private lateinit var viewModel: SubscriptionViewModel
private lateinit var subscriptionManager: SubscriptionManager
private val disposables: CompositeDisposable = CompositeDisposable()
private var subscriptionBroadcastReceiver: BroadcastReceiver? = null
private val groupAdapter = GroupAdapter<GroupieViewHolder>()
private val feedGroupsSection = Section()
private var feedGroupsCarousel: FeedGroupCarouselItem? = null
private lateinit var importExportItem: FeedImportExportItem
private lateinit var feedGroupsSortMenuItem: HeaderWithMenuItem
private val subscriptionsSection = Section()
@State
@JvmField
var itemsListState: Parcelable? = null
@State
@JvmField
var feedGroupsListState: Parcelable? = null
@State
@JvmField
var importExportItemExpandedState: Boolean? = null
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)
}
private fun openReorderDialog() {
FeedGroupReorderDialog().show(requireFragmentManager(), null)
}
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<GroupieViewHolder>()
carouselAdapter.add(FeedGroupCardItem(-1, getString(R.string.all), FeedGroupIcon.RSS))
carouselAdapter.add(feedGroupsSection)
carouselAdapter.add(FeedGroupAddItem())
carouselAdapter.setOnItemClickListener { item, _ ->
listenerFeedGroups.selected(item)
}
carouselAdapter.setOnItemLongClickListener { item, _ ->
if (item is FeedGroupCardItem) {
if (item.groupId == FeedGroupEntity.GROUP_ALL_ID) {
return@setOnItemLongClickListener false
}
}
listenerFeedGroups.held(item)
return@setOnItemLongClickListener true
}
feedGroupsCarousel = FeedGroupCarouselItem(requireContext(), carouselAdapter)
feedGroupsSortMenuItem = HeaderWithMenuItem(
getString(R.string.feed_groups_header_title),
ThemeHelper.resolveResourceIdFromAttr(requireContext(), R.attr.ic_sort),
menuItemOnClickListener = ::openReorderDialog
)
add(Section(feedGroupsSortMenuItem, listOf(feedGroupsCarousel)))
groupAdapter.add(this)
}
subscriptionsSection.setPlaceholder(EmptyPlaceholderItem())
subscriptionsSection.setHideWhenEmpty(true)
importExportItem = FeedImportExportItem(
{ onImportPreviousSelected() },
{ onImportFromServiceSelected(it) },
{ onExportSelected() },
importExportItemExpandedState ?: false)
groupAdapter.add(Section(importExportItem, listOf(subscriptionsSection)))
}
override fun initViews(rootView: View, savedInstanceState: Bundle?) {
super.initViews(rootView, savedInstanceState)
val shouldUseGridLayout = shouldUseGridLayout()
groupAdapter.spanCount = if (shouldUseGridLayout) getGridSpanCount() else 1
items_list.layoutManager = GridLayoutManager(requireContext(), groupAdapter.spanCount).apply {
spanSizeLookup = groupAdapter.spanSizeLookup
}
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<Item<*>>() {
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<ChannelInfoItem>() {
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)
val shouldUseGridLayout = shouldUseGridLayout()
when (result) {
is SubscriptionState.LoadedState -> {
result.subscriptions.forEach {
if (it is ChannelItem) {
it.gesturesListener = listenerChannelItem
it.itemVersion = when {
shouldUseGridLayout -> ChannelItem.ItemVersion.GRID
else -> ChannelItem.ItemVersion.MINI
}
}
}
subscriptionsSection.update(result.subscriptions)
subscriptionsSection.setHideWhenEmpty(false)
if (result.subscriptions.isEmpty() && importExportItemExpandedState == null) {
items_list.post {
importExportItem.isExpanded = true
importExportItem.notifyChanged(FeedImportExportItem.REFRESH_EXPANDED_STATUS)
}
}
if (itemsListState != null) {
items_list.layoutManager?.onRestoreInstanceState(itemsListState)
itemsListState = null
}
}
is SubscriptionState.ErrorState -> {
result.error?.let { onError(result.error) }
}
}
}
private fun handleFeedGroups(groups: List<Group>) {
feedGroupsSection.update(groups)
if (feedGroupsListState != null) {
feedGroupsCarousel?.onRestoreInstanceState(feedGroupsListState)
feedGroupsListState = null
}
feedGroupsSortMenuItem.showMenuItem = groups.size > 1
items_list.post { feedGroupsSortMenuItem.notifyChanged(PAYLOAD_UPDATE_VISIBILITY_MENU_ITEM) }
}
// /////////////////////////////////////////////////////////////////////////
// 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: Move these out of this class, as it can be reused
private fun shouldUseGridLayout(): Boolean {
val listMode = PreferenceManager.getDefaultSharedPreferences(requireContext())
.getString(getString(R.string.list_view_mode_key), getString(R.string.list_view_mode_value))
return when (listMode) {
getString(R.string.list_view_mode_auto_key) -> {
val configuration = resources.configuration
(configuration.orientation == Configuration.ORIENTATION_LANDSCAPE &&
configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE))
}
getString(R.string.list_view_mode_grid_key) -> true
else -> false
}
}
private fun getGridSpanCount(): Int {
val minWidth = resources.getDimensionPixelSize(R.dimen.channel_item_grid_min_width)
return max(1, floor(resources.displayMetrics.widthPixels / minWidth.toDouble()).toInt())
}
companion object {
private const val REQUEST_EXPORT_CODE = 666
private const val REQUEST_IMPORT_CODE = 667
}
}

View file

@ -0,0 +1,95 @@
package org.schabi.newpipe.local.subscription
import android.content.Context
import io.reactivex.Completable
import io.reactivex.Flowable
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
import org.schabi.newpipe.NewPipeDatabase
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
import org.schabi.newpipe.database.subscription.SubscriptionDAO
import org.schabi.newpipe.database.subscription.SubscriptionEntity
import org.schabi.newpipe.extractor.ListInfo
import org.schabi.newpipe.extractor.channel.ChannelInfo
import org.schabi.newpipe.extractor.feed.FeedInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem
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 getSubscriptions(
currentGroupId: Long = FeedGroupEntity.GROUP_ALL_ID,
filterQuery: String = "",
showOnlyUngrouped: Boolean = false
): Flowable<List<SubscriptionEntity>> {
return when {
filterQuery.isNotEmpty() -> {
return if (showOnlyUngrouped) {
subscriptionTable.getSubscriptionsOnlyUngroupedFiltered(
currentGroupId, filterQuery)
} else {
subscriptionTable.getSubscriptionsFiltered(filterQuery)
}
}
showOnlyUngrouped -> subscriptionTable.getSubscriptionsOnlyUngrouped(currentGroupId)
else -> subscriptionTable.all
}
}
fun upsertAll(infoList: List<ChannelInfo>): List<SubscriptionEntity> {
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: ListInfo<StreamInfoItem>) {
val subscriptionEntity = subscriptionTable.getSubscription(subscriptionId)
if (info is FeedInfo) {
subscriptionEntity.name = info.name
} else if (info is ChannelInfo) {
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)
}
}

View file

@ -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<List<SubscriptionEntity>> 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<List<SubscriptionEntity>> 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.
* <p>
* This observer may be subscribed multiple times, where each subscriber obtains
* the latest synchronized changes available, effectively share the same data
* across all subscribers.
* <p>
* 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<List<SubscriptionEntity>> getSubscription() {
return subscription;
}
public Maybe<ChannelInfo> 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<List<SubscriptionEntity>, CompletableSource> update = new Function<List<SubscriptionEntity>, CompletableSource>() {
@Override
public CompletableSource apply(@NonNull List<SubscriptionEntity> 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<SubscriptionEntity> upsertAll(final List<ChannelInfo> infoList) {
final List<SubscriptionEntity> 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);
}
}

View file

@ -0,0 +1,52 @@
package org.schabi.newpipe.local.subscription
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.xwray.groupie.Group
import io.reactivex.schedulers.Schedulers
import java.util.concurrent.TimeUnit
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
class SubscriptionViewModel(application: Application) : AndroidViewModel(application) {
private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(application)
private var subscriptionManager = SubscriptionManager(application)
private val mutableStateLiveData = MutableLiveData<SubscriptionState>()
private val mutableFeedGroupsLiveData = MutableLiveData<List<Group>>()
val stateLiveData: LiveData<SubscriptionState> = mutableStateLiveData
val feedGroupsLiveData: LiveData<List<Group>> = mutableFeedGroupsLiveData
private var feedGroupItemsDisposable = feedDatabaseManager.groups()
.throttleLatest(DEFAULT_THROTTLE_TIMEOUT, TimeUnit.MILLISECONDS)
.map { it.map(::FeedGroupCardItem) }
.subscribeOn(Schedulers.io())
.subscribe(
{ mutableFeedGroupsLiveData.postValue(it) },
{ mutableStateLiveData.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(
{ mutableStateLiveData.postValue(SubscriptionState.LoadedState(it)) },
{ mutableStateLiveData.postValue(SubscriptionState.ErrorState(it)) }
)
override fun onCleared() {
super.onCleared()
stateItemsDisposable.dispose()
feedGroupItemsDisposable.dispose()
}
sealed class SubscriptionState {
data class LoadedState(val subscriptions: List<Group>) : SubscriptionState()
data class ErrorState(val error: Throwable? = null) : SubscriptionState()
}
}

View file

@ -3,11 +3,6 @@ package org.schabi.newpipe.local.subscription;
import android.app.Activity;
import android.content.Intent;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.core.text.util.LinkifyCompat;
import androidx.appcompat.app.ActionBar;
import android.text.TextUtils;
import android.text.util.Linkify;
import android.view.LayoutInflater;
@ -17,6 +12,12 @@ import android.widget.Button;
import android.widget.EditText;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.appcompat.app.ActionBar;
import androidx.core.text.util.LinkifyCompat;
import com.nononsenseapps.filepicker.Utils;
import org.schabi.newpipe.BaseFragment;
@ -24,9 +25,9 @@ import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor;
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService;
import org.schabi.newpipe.report.ErrorActivity;
import org.schabi.newpipe.report.UserAction;
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService;
import org.schabi.newpipe.util.Constants;
import org.schabi.newpipe.util.FilePickerActivityHelper;
import org.schabi.newpipe.util.ServiceHelper;
@ -46,51 +47,52 @@ public class SubscriptionsImportFragment extends BaseFragment {
private static final int REQUEST_IMPORT_FILE_CODE = 666;
@State
protected int currentServiceId = Constants.NO_SERVICE_ID;
int currentServiceId = Constants.NO_SERVICE_ID;
private List<SubscriptionExtractor.ContentSource> supportedSources;
private String relatedUrl;
@StringRes
private int instructionsString;
public static SubscriptionsImportFragment getInstance(int serviceId) {
SubscriptionsImportFragment instance = new SubscriptionsImportFragment();
instance.setInitialData(serviceId);
return instance;
}
public void setInitialData(int serviceId) {
this.currentServiceId = serviceId;
}
/*//////////////////////////////////////////////////////////////////////////
// Views
//////////////////////////////////////////////////////////////////////////*/
private TextView infoTextView;
private EditText inputText;
private Button inputButton;
public static SubscriptionsImportFragment getInstance(final int serviceId) {
SubscriptionsImportFragment instance = new SubscriptionsImportFragment();
instance.setInitialData(serviceId);
return instance;
}
private void setInitialData(final int serviceId) {
this.currentServiceId = serviceId;
}
///////////////////////////////////////////////////////////////////////////
// Fragment LifeCycle
///////////////////////////////////////////////////////////////////////////
@Override
public void onCreate(Bundle savedInstanceState) {
public void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setupServiceVariables();
if (supportedSources.isEmpty() && currentServiceId != Constants.NO_SERVICE_ID) {
ErrorActivity.reportError(activity, Collections.emptyList(), null, null, ErrorActivity.ErrorInfo.make(UserAction.SOMETHING_ELSE,
NewPipe.getNameOfService(currentServiceId), "Service don't support importing", R.string.general_error));
ErrorActivity.reportError(activity, Collections.emptyList(), null, null,
ErrorActivity.ErrorInfo.make(UserAction.SOMETHING_ELSE,
NewPipe.getNameOfService(currentServiceId),
"Service don't support importing", R.string.general_error));
activity.finish();
}
}
@Override
public void setUserVisibleHint(boolean isVisibleToUser) {
public void setUserVisibleHint(final boolean isVisibleToUser) {
super.setUserVisibleHint(isVisibleToUser);
if (isVisibleToUser) {
setTitle(getString(R.string.import_title));
@ -99,7 +101,9 @@ public class SubscriptionsImportFragment extends BaseFragment {
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState) {
public View onCreateView(@NonNull final LayoutInflater inflater,
@Nullable final ViewGroup container,
final Bundle savedInstanceState) {
return inflater.inflate(R.layout.fragment_import, container, false);
}
@ -108,7 +112,7 @@ public class SubscriptionsImportFragment extends BaseFragment {
/////////////////////////////////////////////////////////////////////////*/
@Override
protected void initViews(View rootView, Bundle savedInstanceState) {
protected void initViews(final View rootView, final Bundle savedInstanceState) {
super.initViews(rootView, savedInstanceState);
inputButton = rootView.findViewById(R.id.input_button);
@ -116,7 +120,8 @@ public class SubscriptionsImportFragment extends BaseFragment {
infoTextView = rootView.findViewById(R.id.info_text_view);
// TODO: Support services that can import from more than one source (show the option to the user)
// TODO: Support services that can import from more than one source
// (show the option to the user)
if (supportedSources.contains(CHANNEL_URL)) {
inputButton.setText(R.string.import_title);
inputText.setVisibility(View.VISIBLE);
@ -151,13 +156,15 @@ public class SubscriptionsImportFragment extends BaseFragment {
private void onImportClicked() {
if (inputText.getVisibility() == View.VISIBLE) {
final String value = inputText.getText().toString();
if (!value.isEmpty()) onImportUrl(value);
if (!value.isEmpty()) {
onImportUrl(value);
}
} else {
onImportFile();
}
}
public void onImportUrl(String value) {
public void onImportUrl(final String value) {
ImportConfirmationDialog.show(this, new Intent(activity, SubscriptionsImportService.class)
.putExtra(KEY_MODE, CHANNEL_URL_MODE)
.putExtra(KEY_VALUE, value)
@ -165,20 +172,24 @@ public class SubscriptionsImportFragment extends BaseFragment {
}
public void onImportFile() {
startActivityForResult(FilePickerActivityHelper.chooseSingleFile(activity), REQUEST_IMPORT_FILE_CODE);
startActivityForResult(FilePickerActivityHelper.chooseSingleFile(activity),
REQUEST_IMPORT_FILE_CODE);
}
@Override
public void onActivityResult(int requestCode, int resultCode, Intent data) {
public void onActivityResult(final int requestCode, final int resultCode, final Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (data == null) return;
if (data == null) {
return;
}
if (resultCode == Activity.RESULT_OK && requestCode == REQUEST_IMPORT_FILE_CODE && data.getData() != null) {
if (resultCode == Activity.RESULT_OK && requestCode == REQUEST_IMPORT_FILE_CODE
&& data.getData() != null) {
final String path = Utils.getFileForUri(data.getData()).getAbsolutePath();
ImportConfirmationDialog.show(this, new Intent(activity, SubscriptionsImportService.class)
.putExtra(KEY_MODE, INPUT_STREAM_MODE)
.putExtra(KEY_VALUE, path)
.putExtra(Constants.KEY_SERVICE_ID, currentServiceId));
ImportConfirmationDialog.show(this,
new Intent(activity, SubscriptionsImportService.class)
.putExtra(KEY_MODE, INPUT_STREAM_MODE).putExtra(KEY_VALUE, path)
.putExtra(Constants.KEY_SERVICE_ID, currentServiceId));
}
}
@ -189,7 +200,8 @@ public class SubscriptionsImportFragment extends BaseFragment {
private void setupServiceVariables() {
if (currentServiceId != Constants.NO_SERVICE_ID) {
try {
final SubscriptionExtractor extractor = NewPipe.getService(currentServiceId).getSubscriptionExtractor();
final SubscriptionExtractor extractor = NewPipe.getService(currentServiceId)
.getSubscriptionExtractor();
supportedSources = extractor.getSupportedSources();
relatedUrl = extractor.getRelatedUrl();
instructionsString = ServiceHelper.getImportInstructions(currentServiceId);
@ -203,7 +215,7 @@ public class SubscriptionsImportFragment extends BaseFragment {
instructionsString = 0;
}
private void setInfoText(String infoString) {
private void setInfoText(final String infoString) {
infoTextView.setText(infoString);
LinkifyCompat.addLinks(infoTextView, Linkify.WEB_URLS);
}

View file

@ -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
}
}
}

View file

@ -0,0 +1,512 @@
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.TextUtils
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.ViewModelProvider
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.xwray.groupie.GroupAdapter
import com.xwray.groupie.OnItemClickListener
import com.xwray.groupie.Section
import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
import icepick.Icepick
import icepick.State
import java.io.Serializable
import kotlin.collections.contains
import kotlinx.android.synthetic.main.dialog_feed_group_create.*
import kotlinx.android.synthetic.main.toolbar_search_layout.*
import org.schabi.newpipe.R
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
import org.schabi.newpipe.fragments.BackPressable
import org.schabi.newpipe.local.subscription.FeedGroupIcon
import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialog.ScreenState.DeleteScreen
import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialog.ScreenState.IconPickerScreen
import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialog.ScreenState.InitialScreen
import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialog.ScreenState.SubscriptionsPickerScreen
import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialogViewModel.DialogEvent.ProcessingEvent
import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialogViewModel.DialogEvent.SuccessEvent
import org.schabi.newpipe.local.subscription.item.EmptyPlaceholderItem
import org.schabi.newpipe.local.subscription.item.PickerIconItem
import org.schabi.newpipe.local.subscription.item.PickerSubscriptionItem
import org.schabi.newpipe.util.AndroidTvUtils
import org.schabi.newpipe.util.ThemeHelper
class FeedGroupDialog : DialogFragment(), BackPressable {
private lateinit var viewModel: FeedGroupDialogViewModel
private var groupId: Long = NO_GROUP_SELECTED
private var groupIcon: FeedGroupIcon? = null
private var groupSortOrder: Long = -1
sealed class ScreenState : Serializable {
object InitialScreen : ScreenState()
object IconPickerScreen : ScreenState()
object SubscriptionsPickerScreen : ScreenState()
object DeleteScreen : ScreenState()
}
@State @JvmField var selectedIcon: FeedGroupIcon? = null
@State @JvmField var selectedSubscriptions: HashSet<Long> = HashSet()
@State @JvmField var wasSubscriptionSelectionChanged: Boolean = false
@State @JvmField var currentScreen: ScreenState = InitialScreen
@State @JvmField var subscriptionsListState: Parcelable? = null
@State @JvmField var iconsListState: Parcelable? = null
@State @JvmField var wasSearchSubscriptionsVisible = false
@State @JvmField var subscriptionsCurrentSearchQuery = ""
@State @JvmField var subscriptionsShowOnlyUngrouped = false
private val subscriptionMainSection = Section()
private val subscriptionEmptyFooter = Section()
private lateinit var subscriptionGroupAdapter: GroupAdapter<GroupieViewHolder>
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 (!this@FeedGroupDialog.onBackPressed()) {
super.onBackPressed()
}
}
}
}
override fun onPause() {
super.onPause()
wasSearchSubscriptionsVisible = isSearchVisible()
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
iconsListState = icon_selector.layoutManager?.onSaveInstanceState()
subscriptionsListState = subscriptions_selector_list.layoutManager?.onSaveInstanceState()
Icepick.saveInstanceState(this, outState)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel = ViewModelProvider(this,
FeedGroupDialogViewModel.Factory(requireContext(),
groupId, subscriptionsCurrentSearchQuery, subscriptionsShowOnlyUngrouped)
).get(FeedGroupDialogViewModel::class.java)
viewModel.groupLiveData.observe(viewLifecycleOwner, Observer(::handleGroup))
viewModel.subscriptionsLiveData.observe(viewLifecycleOwner, Observer {
setupSubscriptionPicker(it.first, it.second)
})
viewModel.dialogEventLiveData.observe(viewLifecycleOwner, Observer {
when (it) {
ProcessingEvent -> disableInput()
SuccessEvent -> dismiss()
}
})
subscriptionGroupAdapter = GroupAdapter<GroupieViewHolder>().apply {
add(subscriptionMainSection)
add(subscriptionEmptyFooter)
spanCount = 4
}
subscriptions_selector_list.apply {
// Disable animations, too distracting.
itemAnimator = null
adapter = subscriptionGroupAdapter
layoutManager = GridLayoutManager(requireContext(), subscriptionGroupAdapter.spanCount,
RecyclerView.VERTICAL, false).apply {
spanSizeLookup = subscriptionGroupAdapter.spanSizeLookup
}
}
setupIconPicker()
setupListeners()
showScreen(currentScreen)
if (currentScreen == SubscriptionsPickerScreen && wasSearchSubscriptionsVisible) {
showSearch()
} else if (currentScreen == InitialScreen && groupId == NO_GROUP_SELECTED) {
showKeyboard()
}
}
override fun onDestroyView() {
super.onDestroyView()
subscriptions_selector_list?.adapter = null
icon_selector?.adapter = null
}
/*///////////////////////////////////////////////////////////////////////////
// Setup
////////////////////////////////////////////////////////////////////////// */
override fun onBackPressed(): Boolean {
if (currentScreen is SubscriptionsPickerScreen && isSearchVisible()) {
hideSearch()
return true
} else if (currentScreen !is InitialScreen) {
showScreen(InitialScreen)
return true
}
return false
}
private fun setupListeners() {
delete_button.setOnClickListener { showScreen(DeleteScreen) }
cancel_button.setOnClickListener {
when (currentScreen) {
InitialScreen -> dismiss()
else -> showScreen(InitialScreen)
}
}
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 { handlePositiveButton() }
select_channel_button.setOnClickListener {
subscriptions_selector_list.scrollToPosition(0)
showScreen(SubscriptionsPickerScreen)
}
val headerMenu = subscriptions_header_toolbar.menu
requireActivity().menuInflater.inflate(R.menu.menu_feed_group_dialog, headerMenu)
headerMenu.findItem(R.id.action_search).setOnMenuItemClickListener {
showSearch()
true
}
headerMenu.findItem(R.id.feed_group_toggle_show_only_ungrouped_subscriptions).apply {
isChecked = subscriptionsShowOnlyUngrouped
setOnMenuItemClickListener {
subscriptionsShowOnlyUngrouped = !subscriptionsShowOnlyUngrouped
it.isChecked = subscriptionsShowOnlyUngrouped
viewModel.toggleShowOnlyUngrouped(subscriptionsShowOnlyUngrouped)
true
}
}
toolbar_search_clear.setOnClickListener {
if (TextUtils.isEmpty(toolbar_search_edit_text.text)) {
hideSearch()
return@setOnClickListener
}
resetSearch()
showKeyboardSearch()
}
toolbar_search_edit_text.setOnClickListener {
if (AndroidTvUtils.isTv(context)) {
showKeyboardSearch()
}
}
toolbar_search_edit_text.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) = Unit
override fun afterTextChanged(s: Editable) = Unit
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
val newQuery: String = toolbar_search_edit_text.text.toString()
subscriptionsCurrentSearchQuery = newQuery
viewModel.filterSubscriptionsBy(newQuery)
}
})
subscriptionGroupAdapter?.setOnItemClickListener(subscriptionPickerItemListener)
}
private fun handlePositiveButton() = when {
currentScreen is InitialScreen -> handlePositiveButtonInitialScreen()
currentScreen is DeleteScreen -> viewModel.deleteGroup()
currentScreen is SubscriptionsPickerScreen && isSearchVisible() -> hideSearch()
else -> showScreen(InitialScreen)
}
private fun handlePositiveButtonInitialScreen() {
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
} 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
}
when (groupId) {
NO_GROUP_SELECTED -> viewModel.createGroup(name, icon, selectedSubscriptions)
else -> viewModel.updateGroup(name, icon, selectedSubscriptions, groupSortOrder)
}
}
private fun handleGroup(feedGroupEntity: FeedGroupEntity? = null) {
val icon = feedGroupEntity?.icon ?: FeedGroupIcon.ALL
val name = feedGroupEntity?.name ?: ""
groupIcon = feedGroupEntity?.icon
groupSortOrder = feedGroupEntity?.sortOrder ?: -1
val feedGroupIcon = if (selectedIcon == null) icon else selectedIcon!!
icon_preview.setImageResource(feedGroupIcon.getDrawableRes(requireContext()))
if (group_name_input.text.isNullOrBlank()) {
group_name_input.setText(name)
}
}
private val subscriptionPickerItemListener = OnItemClickListener { item, view ->
if (item is PickerSubscriptionItem) {
val subscriptionId = item.subscriptionEntity.uid
wasSubscriptionSelectionChanged = true
val isSelected = if (this.selectedSubscriptions.contains(subscriptionId)) {
this.selectedSubscriptions.remove(subscriptionId)
false
} else {
this.selectedSubscriptions.add(subscriptionId)
true
}
item.updateSelected(view, isSelected)
updateSubscriptionSelectedCount()
}
}
private fun setupSubscriptionPicker(
subscriptions: List<PickerSubscriptionItem>,
selectedSubscriptions: Set<Long>
) {
if (!wasSubscriptionSelectionChanged) {
this.selectedSubscriptions.addAll(selectedSubscriptions)
}
updateSubscriptionSelectedCount()
if (subscriptions.isEmpty()) {
subscriptionEmptyFooter.clear()
subscriptionEmptyFooter.add(EmptyPlaceholderItem())
} else {
subscriptionEmptyFooter.clear()
}
subscriptions.forEach {
it.isSelected = this@FeedGroupDialog.selectedSubscriptions
.contains(it.subscriptionEntity.uid)
}
subscriptionMainSection.update(subscriptions, false)
if (subscriptionsListState != null) {
subscriptions_selector_list.layoutManager?.onRestoreInstanceState(subscriptionsListState)
subscriptionsListState = null
} else {
subscriptions_selector_list.scrollToPosition(0)
}
}
private fun updateSubscriptionSelectedCount() {
val selectedCount = this.selectedSubscriptions.size
val selectedCountText = resources.getQuantityString(
R.plurals.feed_group_dialog_selection_count,
selectedCount, selectedCount)
selected_subscription_count_view.text = selectedCountText
subscriptions_header_info.text = selectedCountText
}
private fun setupIconPicker() {
val groupAdapter = GroupAdapter<GroupieViewHolder>()
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)
showScreen(InitialScreen)
}
}
}
icon_preview.setOnClickListener {
icon_selector.scrollToPosition(0)
showScreen(IconPickerScreen)
}
if (groupId == NO_GROUP_SELECTED) {
val icon = selectedIcon ?: FeedGroupIcon.ALL
icon_preview.setImageResource(icon.getDrawableRes(requireContext()))
}
}
/*///////////////////////////////////////////////////////////////////////////
// Screen Selector
////////////////////////////////////////////////////////////////////////// */
private fun showScreen(screen: ScreenState) {
currentScreen = screen
options_root.onlyVisibleIn(InitialScreen)
icon_selector.onlyVisibleIn(IconPickerScreen)
subscriptions_selector.onlyVisibleIn(SubscriptionsPickerScreen)
delete_screen_message.onlyVisibleIn(DeleteScreen)
separator.onlyVisibleIn(SubscriptionsPickerScreen, IconPickerScreen)
cancel_button.onlyVisibleIn(InitialScreen, DeleteScreen)
confirm_button.setText(when {
currentScreen == InitialScreen && groupId == NO_GROUP_SELECTED -> R.string.create
else -> android.R.string.ok
})
delete_button.visibility = when {
currentScreen != InitialScreen -> View.GONE
groupId == NO_GROUP_SELECTED -> View.GONE
else -> View.VISIBLE
}
hideKeyboard()
hideSearch()
}
private fun View.onlyVisibleIn(vararg screens: ScreenState) {
visibility = when (currentScreen) {
in screens -> View.VISIBLE
else -> View.GONE
}
}
/*///////////////////////////////////////////////////////////////////////////
// Utils
////////////////////////////////////////////////////////////////////////// */
private fun isSearchVisible() = subscriptions_header_search_container?.visibility == View.VISIBLE
private fun resetSearch() {
toolbar_search_edit_text.setText("")
subscriptionsCurrentSearchQuery = ""
viewModel.clearSubscriptionsFilter()
}
private fun hideSearch() {
resetSearch()
subscriptions_header_search_container.visibility = View.GONE
subscriptions_header_info_container.visibility = View.VISIBLE
subscriptions_header_toolbar.menu.findItem(R.id.action_search).isVisible = true
hideKeyboardSearch()
}
private fun showSearch() {
subscriptions_header_search_container.visibility = View.VISIBLE
subscriptions_header_info_container.visibility = View.GONE
subscriptions_header_toolbar.menu.findItem(R.id.action_search).isVisible = false
showKeyboardSearch()
}
private val inputMethodManager by lazy {
requireActivity().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
}
private fun showKeyboardSearch() {
if (toolbar_search_edit_text.requestFocus()) {
inputMethodManager.showSoftInput(toolbar_search_edit_text, InputMethodManager.SHOW_IMPLICIT)
}
}
private fun hideKeyboardSearch() {
inputMethodManager.hideSoftInputFromWindow(toolbar_search_edit_text.windowToken,
InputMethodManager.RESULT_UNCHANGED_SHOWN)
toolbar_search_edit_text.clearFocus()
}
private fun showKeyboard() {
if (group_name_input.requestFocus()) {
inputMethodManager.showSoftInput(group_name_input, InputMethodManager.SHOW_IMPLICIT)
}
}
private fun hideKeyboard() {
inputMethodManager.hideSoftInputFromWindow(group_name_input.windowToken,
InputMethodManager.RESULT_UNCHANGED_SHOWN)
group_name_input.clearFocus()
}
private fun disableInput() {
delete_button?.isEnabled = false
confirm_button?.isEnabled = false
cancel_button?.isEnabled = false
isCancelable = false
hideKeyboard()
}
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
}
}
}

View file

@ -0,0 +1,127 @@
package org.schabi.newpipe.local.subscription.dialog
import android.content.Context
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.Completable
import io.reactivex.Flowable
import io.reactivex.disposables.Disposable
import io.reactivex.functions.BiFunction
import io.reactivex.processors.BehaviorProcessor
import io.reactivex.schedulers.Schedulers
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
import org.schabi.newpipe.local.feed.FeedDatabaseManager
import org.schabi.newpipe.local.subscription.FeedGroupIcon
import org.schabi.newpipe.local.subscription.SubscriptionManager
import org.schabi.newpipe.local.subscription.item.PickerSubscriptionItem
class FeedGroupDialogViewModel(
applicationContext: Context,
private val groupId: Long = FeedGroupEntity.GROUP_ALL_ID,
initialQuery: String = "",
initialShowOnlyUngrouped: Boolean = false
) : ViewModel() {
private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(applicationContext)
private var subscriptionManager = SubscriptionManager(applicationContext)
private var filterSubscriptions = BehaviorProcessor.create<String>()
private var toggleShowOnlyUngrouped = BehaviorProcessor.create<Boolean>()
private var subscriptionsFlowable = Flowable
.combineLatest(
filterSubscriptions.startWith(initialQuery),
toggleShowOnlyUngrouped.startWith(initialShowOnlyUngrouped),
BiFunction { t1: String, t2: Boolean -> Filter(t1, t2) }
)
.distinctUntilChanged()
.switchMap { filter ->
subscriptionManager.getSubscriptions(groupId, filter.query, filter.showOnlyUngrouped)
}.map { list -> list.map { PickerSubscriptionItem(it) } }
private val mutableGroupLiveData = MutableLiveData<FeedGroupEntity>()
private val mutableSubscriptionsLiveData = MutableLiveData<Pair<List<PickerSubscriptionItem>, Set<Long>>>()
private val mutableDialogEventLiveData = MutableLiveData<DialogEvent>()
val groupLiveData: LiveData<FeedGroupEntity> = mutableGroupLiveData
val subscriptionsLiveData: LiveData<Pair<List<PickerSubscriptionItem>, Set<Long>>> = mutableSubscriptionsLiveData
val dialogEventLiveData: LiveData<DialogEvent> = mutableDialogEventLiveData
private var actionProcessingDisposable: Disposable? = null
private var feedGroupDisposable = feedDatabaseManager.getGroup(groupId)
.subscribeOn(Schedulers.io())
.subscribe(mutableGroupLiveData::postValue)
private var subscriptionsDisposable = Flowable
.combineLatest(subscriptionsFlowable, feedDatabaseManager.subscriptionIdsForGroup(groupId),
BiFunction { t1: List<PickerSubscriptionItem>, t2: List<Long> -> t1 to t2.toSet() })
.subscribeOn(Schedulers.io())
.subscribe(mutableSubscriptionsLiveData::postValue)
override fun onCleared() {
super.onCleared()
actionProcessingDisposable?.dispose()
subscriptionsDisposable.dispose()
feedGroupDisposable.dispose()
}
fun createGroup(name: String, selectedIcon: FeedGroupIcon, selectedSubscriptions: Set<Long>) {
doAction(feedDatabaseManager.createGroup(name, selectedIcon)
.flatMapCompletable {
feedDatabaseManager.updateSubscriptionsForGroup(it, selectedSubscriptions.toList())
})
}
fun updateGroup(name: String, selectedIcon: FeedGroupIcon, selectedSubscriptions: Set<Long>, sortOrder: Long) {
doAction(feedDatabaseManager.updateSubscriptionsForGroup(groupId, selectedSubscriptions.toList())
.andThen(feedDatabaseManager.updateGroup(FeedGroupEntity(groupId, name, selectedIcon, sortOrder))))
}
fun deleteGroup() {
doAction(feedDatabaseManager.deleteGroup(groupId))
}
private fun doAction(completable: Completable) {
if (actionProcessingDisposable == null) {
mutableDialogEventLiveData.value = DialogEvent.ProcessingEvent
actionProcessingDisposable = completable
.subscribeOn(Schedulers.io())
.subscribe { mutableDialogEventLiveData.postValue(DialogEvent.SuccessEvent) }
}
}
fun filterSubscriptionsBy(query: String) {
filterSubscriptions.onNext(query)
}
fun clearSubscriptionsFilter() {
filterSubscriptions.onNext("")
}
fun toggleShowOnlyUngrouped(showOnlyUngrouped: Boolean) {
toggleShowOnlyUngrouped.onNext(showOnlyUngrouped)
}
sealed class DialogEvent {
object ProcessingEvent : DialogEvent()
object SuccessEvent : DialogEvent()
}
data class Filter(val query: String, val showOnlyUngrouped: Boolean)
class Factory(
private val context: Context,
private val groupId: Long = FeedGroupEntity.GROUP_ALL_ID,
private val initialQuery: String = "",
private val initialShowOnlyUngrouped: Boolean = false
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return FeedGroupDialogViewModel(context.applicationContext,
groupId, initialQuery, initialShowOnlyUngrouped) as T
}
}
}

View file

@ -0,0 +1,115 @@
package org.schabi.newpipe.local.subscription.dialog
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.ItemTouchHelper.SimpleCallback
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.xwray.groupie.GroupAdapter
import com.xwray.groupie.TouchCallback
import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
import icepick.Icepick
import icepick.State
import java.util.Collections
import kotlinx.android.synthetic.main.dialog_feed_group_reorder.confirm_button
import kotlinx.android.synthetic.main.dialog_feed_group_reorder.feed_groups_list
import org.schabi.newpipe.R
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
import org.schabi.newpipe.local.subscription.dialog.FeedGroupReorderDialogViewModel.DialogEvent.ProcessingEvent
import org.schabi.newpipe.local.subscription.dialog.FeedGroupReorderDialogViewModel.DialogEvent.SuccessEvent
import org.schabi.newpipe.local.subscription.item.FeedGroupReorderItem
import org.schabi.newpipe.util.ThemeHelper
class FeedGroupReorderDialog : DialogFragment() {
private lateinit var viewModel: FeedGroupReorderDialogViewModel
@State
@JvmField
var groupOrderedIdList = ArrayList<Long>()
private val groupAdapter = GroupAdapter<GroupieViewHolder>()
private val itemTouchHelper = ItemTouchHelper(getItemTouchCallback())
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Icepick.restoreInstanceState(this, savedInstanceState)
setStyle(STYLE_NO_TITLE, ThemeHelper.getMinWidthDialogTheme(requireContext()))
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.dialog_feed_group_reorder, container)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel = ViewModelProviders.of(this).get(FeedGroupReorderDialogViewModel::class.java)
viewModel.groupsLiveData.observe(viewLifecycleOwner, Observer(::handleGroups))
viewModel.dialogEventLiveData.observe(viewLifecycleOwner, Observer {
when (it) {
ProcessingEvent -> disableInput()
SuccessEvent -> dismiss()
}
})
feed_groups_list.layoutManager = LinearLayoutManager(requireContext())
feed_groups_list.adapter = groupAdapter
itemTouchHelper.attachToRecyclerView(feed_groups_list)
confirm_button.setOnClickListener {
viewModel.updateOrder(groupOrderedIdList)
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
Icepick.saveInstanceState(this, outState)
}
private fun handleGroups(list: List<FeedGroupEntity>) {
val groupList: List<FeedGroupEntity>
if (groupOrderedIdList.isEmpty()) {
groupList = list
groupOrderedIdList.addAll(groupList.map { it.uid })
} else {
groupList = list.sortedBy { groupOrderedIdList.indexOf(it.uid) }
}
groupAdapter.update(groupList.map { FeedGroupReorderItem(it, itemTouchHelper) })
}
private fun disableInput() {
confirm_button?.isEnabled = false
isCancelable = false
}
private fun getItemTouchCallback(): SimpleCallback {
return object : TouchCallback() {
override fun onMove(
recyclerView: RecyclerView,
source: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
val sourceIndex = source.adapterPosition
val targetIndex = target.adapterPosition
groupAdapter.notifyItemMoved(sourceIndex, targetIndex)
Collections.swap(groupOrderedIdList, sourceIndex, targetIndex)
return true
}
override fun isLongPressDragEnabled(): Boolean = false
override fun isItemViewSwipeEnabled(): Boolean = false
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, swipeDir: Int) {}
}
}
}

View file

@ -0,0 +1,52 @@
package org.schabi.newpipe.local.subscription.dialog
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import io.reactivex.Completable
import io.reactivex.disposables.Disposable
import io.reactivex.schedulers.Schedulers
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
import org.schabi.newpipe.local.feed.FeedDatabaseManager
class FeedGroupReorderDialogViewModel(application: Application) : AndroidViewModel(application) {
private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(application)
private val mutableGroupsLiveData = MutableLiveData<List<FeedGroupEntity>>()
private val mutableDialogEventLiveData = MutableLiveData<DialogEvent>()
val groupsLiveData: LiveData<List<FeedGroupEntity>> = mutableGroupsLiveData
val dialogEventLiveData: LiveData<DialogEvent> = mutableDialogEventLiveData
private var actionProcessingDisposable: Disposable? = null
private var groupsDisposable = feedDatabaseManager.groups()
.limit(1)
.subscribeOn(Schedulers.io())
.subscribe(mutableGroupsLiveData::postValue)
override fun onCleared() {
super.onCleared()
actionProcessingDisposable?.dispose()
groupsDisposable.dispose()
}
fun updateOrder(groupIdList: List<Long>) {
doAction(feedDatabaseManager.updateGroupsOrder(groupIdList))
}
private fun doAction(completable: Completable) {
if (actionProcessingDisposable == null) {
mutableDialogEventLiveData.value = DialogEvent.ProcessingEvent
actionProcessingDisposable = completable
.subscribeOn(Schedulers.io())
.subscribe { mutableDialogEventLiveData.postValue(DialogEvent.SuccessEvent) }
}
}
sealed class DialogEvent {
object ProcessingEvent : DialogEvent()
object SuccessEvent : DialogEvent()
}
}

View file

@ -0,0 +1,67 @@
package org.schabi.newpipe.local.subscription.item
import android.content.Context
import com.nostra13.universalimageloader.core.ImageLoader
import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
import com.xwray.groupie.kotlinandroidextensions.Item
import kotlinx.android.synthetic.main.list_channel_item.itemAdditionalDetails
import kotlinx.android.synthetic.main.list_channel_item.itemChannelDescriptionView
import kotlinx.android.synthetic.main.list_channel_item.itemThumbnailView
import kotlinx.android.synthetic.main.list_channel_item.itemTitleView
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,
var itemVersion: ItemVersion = ItemVersion.NORMAL,
var gesturesListener: OnClickGesture<ChannelInfoItem>? = 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: GroupieViewHolder, 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
}
}

View file

@ -0,0 +1,11 @@
package org.schabi.newpipe.local.subscription.item
import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
import com.xwray.groupie.kotlinandroidextensions.Item
import org.schabi.newpipe.R
class EmptyPlaceholderItem : Item() {
override fun getLayout(): Int = R.layout.list_empty_view
override fun bind(viewHolder: GroupieViewHolder, position: Int) {}
override fun getSpanSize(spanCount: Int, position: Int): Int = spanCount
}

View file

@ -0,0 +1,10 @@
package org.schabi.newpipe.local.subscription.item
import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
import com.xwray.groupie.kotlinandroidextensions.Item
import org.schabi.newpipe.R
class FeedGroupAddItem : Item() {
override fun getLayout(): Int = R.layout.feed_group_add_new_item
override fun bind(viewHolder: GroupieViewHolder, position: Int) {}
}

View file

@ -0,0 +1,31 @@
package org.schabi.newpipe.local.subscription.item
import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
import com.xwray.groupie.kotlinandroidextensions.Item
import kotlinx.android.synthetic.main.feed_group_card_item.icon
import kotlinx.android.synthetic.main.feed_group_card_item.title
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 = FeedGroupEntity.GROUP_ALL_ID,
val name: String,
val icon: FeedGroupIcon
) : Item() {
constructor (feedGroupEntity: FeedGroupEntity) : this(feedGroupEntity.uid, feedGroupEntity.name, feedGroupEntity.icon)
override fun getId(): Long {
return when (groupId) {
FeedGroupEntity.GROUP_ALL_ID -> super.getId()
else -> groupId
}
}
override fun getLayout(): Int = R.layout.feed_group_card_item
override fun bind(viewHolder: GroupieViewHolder, position: Int) {
viewHolder.title.text = name
viewHolder.icon.setImageResource(icon.getDrawableRes(viewHolder.containerView.context))
}
}

View file

@ -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.GroupieViewHolder
import com.xwray.groupie.kotlinandroidextensions.Item
import kotlinx.android.synthetic.main.feed_item_carousel.recycler_view
import org.schabi.newpipe.R
import org.schabi.newpipe.local.subscription.decoration.FeedGroupCarouselDecoration
class FeedGroupCarouselItem(context: Context, private val carouselAdapter: GroupAdapter<GroupieViewHolder>) : 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): GroupieViewHolder {
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: GroupieViewHolder, position: Int) {
viewHolder.recycler_view.apply { adapter = carouselAdapter }
linearLayoutManager?.onRestoreInstanceState(listState)
}
override fun unbind(viewHolder: GroupieViewHolder) {
super.unbind(viewHolder)
listState = linearLayoutManager?.onSaveInstanceState()
}
}

View file

@ -0,0 +1,50 @@
package org.schabi.newpipe.local.subscription.item
import android.view.MotionEvent
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.ItemTouchHelper.DOWN
import androidx.recyclerview.widget.ItemTouchHelper.UP
import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
import com.xwray.groupie.kotlinandroidextensions.Item
import kotlinx.android.synthetic.main.feed_group_reorder_item.group_icon
import kotlinx.android.synthetic.main.feed_group_reorder_item.group_name
import kotlinx.android.synthetic.main.feed_group_reorder_item.handle
import org.schabi.newpipe.R
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
import org.schabi.newpipe.local.subscription.FeedGroupIcon
data class FeedGroupReorderItem(
val groupId: Long = FeedGroupEntity.GROUP_ALL_ID,
val name: String,
val icon: FeedGroupIcon,
val dragCallback: ItemTouchHelper
) : Item() {
constructor (feedGroupEntity: FeedGroupEntity, dragCallback: ItemTouchHelper) :
this(feedGroupEntity.uid, feedGroupEntity.name, feedGroupEntity.icon, dragCallback)
override fun getId(): Long {
return when (groupId) {
FeedGroupEntity.GROUP_ALL_ID -> super.getId()
else -> groupId
}
}
override fun getLayout(): Int = R.layout.feed_group_reorder_item
override fun bind(viewHolder: GroupieViewHolder, position: Int) {
viewHolder.group_name.text = name
viewHolder.group_icon.setImageResource(icon.getDrawableRes(viewHolder.containerView.context))
viewHolder.handle.setOnTouchListener { _, event ->
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
dragCallback.startDrag(viewHolder)
return@setOnTouchListener true
}
false
}
}
override fun getDragDirs(): Int {
return UP or DOWN
}
}

View file

@ -0,0 +1,119 @@
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.GroupieViewHolder
import com.xwray.groupie.kotlinandroidextensions.Item
import kotlinx.android.synthetic.main.feed_import_export_group.export_to_options
import kotlinx.android.synthetic.main.feed_import_export_group.import_export
import kotlinx.android.synthetic.main.feed_import_export_group.import_export_expand_icon
import kotlinx.android.synthetic.main.feed_import_export_group.import_export_options
import kotlinx.android.synthetic.main.feed_import_export_group.import_from_options
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: GroupieViewHolder, position: Int, payloads: MutableList<Any>) {
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: GroupieViewHolder, 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(viewHolder: GroupieViewHolder) {
super.unbind(viewHolder)
expandIconListener?.let { viewHolder.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<TextView>(android.R.id.text1)
val iconView = itemRoot.findViewById<ImageView>(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<ImageView>(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() }
}
}

View file

@ -0,0 +1,19 @@
package org.schabi.newpipe.local.subscription.item
import android.view.View.OnClickListener
import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
import com.xwray.groupie.kotlinandroidextensions.Item
import kotlinx.android.synthetic.main.header_item.header_title
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: GroupieViewHolder, position: Int) {
viewHolder.header_title.text = title
val listener: OnClickListener? = if (onClickListener != null) OnClickListener { onClickListener.invoke() } else null
viewHolder.root.setOnClickListener(listener)
}
}

View file

@ -0,0 +1,52 @@
package org.schabi.newpipe.local.subscription.item
import android.view.View.GONE
import android.view.View.OnClickListener
import android.view.View.VISIBLE
import androidx.annotation.DrawableRes
import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
import com.xwray.groupie.kotlinandroidextensions.Item
import kotlinx.android.synthetic.main.header_with_menu_item.header_menu_item
import kotlinx.android.synthetic.main.header_with_menu_item.header_title
import org.schabi.newpipe.R
class HeaderWithMenuItem(
val title: String,
@DrawableRes val itemIcon: Int = 0,
var showMenuItem: Boolean = true,
private val onClickListener: (() -> Unit)? = null,
private val menuItemOnClickListener: (() -> Unit)? = null
) : Item() {
companion object {
const val PAYLOAD_UPDATE_VISIBILITY_MENU_ITEM = 1
}
override fun getLayout(): Int = R.layout.header_with_menu_item
override fun bind(viewHolder: GroupieViewHolder, position: Int, payloads: MutableList<Any>) {
if (payloads.contains(PAYLOAD_UPDATE_VISIBILITY_MENU_ITEM)) {
updateMenuItemVisibility(viewHolder)
return
}
super.bind(viewHolder, position, payloads)
}
override fun bind(viewHolder: GroupieViewHolder, position: Int) {
viewHolder.header_title.text = title
viewHolder.header_menu_item.setImageResource(itemIcon)
val listener: OnClickListener? =
onClickListener?.let { OnClickListener { onClickListener.invoke() } }
viewHolder.root.setOnClickListener(listener)
val menuItemListener: OnClickListener? =
menuItemOnClickListener?.let { OnClickListener { menuItemOnClickListener.invoke() } }
viewHolder.header_menu_item.setOnClickListener(menuItemListener)
updateMenuItemVisibility(viewHolder)
}
private fun updateMenuItemVisibility(viewHolder: GroupieViewHolder) {
viewHolder.header_menu_item.visibility = if (showMenuItem) VISIBLE else GONE
}
}

View file

@ -0,0 +1,20 @@
package org.schabi.newpipe.local.subscription.item
import android.content.Context
import androidx.annotation.DrawableRes
import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
import com.xwray.groupie.kotlinandroidextensions.Item
import kotlinx.android.synthetic.main.picker_icon_item.icon_view
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: GroupieViewHolder, position: Int) {
viewHolder.icon_view.setImageResource(iconRes)
}
}

View file

@ -0,0 +1,44 @@
package org.schabi.newpipe.local.subscription.item
import android.view.View
import com.nostra13.universalimageloader.core.ImageLoader
import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
import com.xwray.groupie.kotlinandroidextensions.Item
import kotlinx.android.synthetic.main.picker_subscription_item.*
import kotlinx.android.synthetic.main.picker_subscription_item.view.*
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() {
override fun getId(): Long = subscriptionEntity.uid
override fun getLayout(): Int = R.layout.picker_subscription_item
override fun getSpanSize(spanCount: Int, position: Int): Int = 1
override fun bind(viewHolder: GroupieViewHolder, position: Int) {
ImageLoader.getInstance().displayImage(subscriptionEntity.avatarUrl,
viewHolder.thumbnail_view, ImageDisplayConstants.DISPLAY_AVATAR_OPTIONS)
viewHolder.title_view.text = subscriptionEntity.name
viewHolder.selected_highlight.visibility = if (isSelected) View.VISIBLE else View.GONE
}
override fun unbind(viewHolder: GroupieViewHolder) {
super.unbind(viewHolder)
viewHolder.selected_highlight.animate().setListener(null).cancel()
viewHolder.selected_highlight.visibility = View.GONE
viewHolder.selected_highlight.alpha = 1F
}
fun updateSelected(containerView: View, isSelected: Boolean) {
this.isSelected = isSelected
animateView(containerView.selected_highlight,
AnimationUtils.Type.LIGHT_SCALE_AND_ALPHA, isSelected, 150)
}
}

View file

@ -23,24 +23,24 @@ import android.app.Service;
import android.content.Intent;
import android.os.Build;
import android.os.IBinder;
import android.text.TextUtils;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
import android.text.TextUtils;
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 org.schabi.newpipe.util.ExceptionUtils;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.util.Collections;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
@ -54,23 +54,43 @@ import io.reactivex.processors.PublishProcessor;
public abstract class BaseImportExportService extends Service {
protected final String TAG = this.getClass().getSimpleName();
protected NotificationManagerCompat notificationManager;
protected NotificationCompat.Builder notificationBuilder;
protected SubscriptionService subscriptionService;
protected final CompositeDisposable disposables = new CompositeDisposable();
protected final PublishProcessor<String> notificationUpdater = PublishProcessor.create();
protected NotificationManagerCompat notificationManager;
protected NotificationCompat.Builder notificationBuilder;
protected SubscriptionManager subscriptionManager;
private static final int NOTIFICATION_SAMPLING_PERIOD = 2500;
protected final AtomicInteger currentProgress = new AtomicInteger(-1);
protected final AtomicInteger maxProgress = new AtomicInteger(-1);
protected final ImportExportEventListener eventListener = new ImportExportEventListener() {
@Override
public void onSizeReceived(final int size) {
maxProgress.set(size);
currentProgress.set(0);
}
@Override
public void onItemCompleted(final String itemName) {
currentProgress.incrementAndGet();
notificationUpdater.onNext(itemName);
}
};
protected Toast toast;
@Nullable
@Override
public IBinder onBind(Intent intent) {
public IBinder onBind(final Intent intent) {
return null;
}
@Override
public void onCreate() {
super.onCreate();
subscriptionService = SubscriptionService.getInstance(this);
subscriptionManager = new SubscriptionManager(this);
setupNotification();
}
@ -88,25 +108,8 @@ public abstract class BaseImportExportService extends Service {
// Notification Impl
//////////////////////////////////////////////////////////////////////////*/
private static final int NOTIFICATION_SAMPLING_PERIOD = 2500;
protected final AtomicInteger currentProgress = new AtomicInteger(-1);
protected final AtomicInteger maxProgress = new AtomicInteger(-1);
protected final ImportExportEventListener eventListener = new ImportExportEventListener() {
@Override
public void onSizeReceived(int size) {
maxProgress.set(size);
currentProgress.set(0);
}
@Override
public void onItemCompleted(String itemName) {
currentProgress.incrementAndGet();
notificationUpdater.onNext(itemName);
}
};
protected abstract int getNotificationId();
@StringRes
public abstract int getTitle();
@ -115,8 +118,9 @@ public abstract class BaseImportExportService extends Service {
notificationBuilder = createNotification();
startForeground(getNotificationId(), notificationBuilder.build());
final Function<Flowable<String>, Publisher<String>> throttleAfterFirstEmission = flow -> flow.limit(1)
.concatWith(flow.skip(1).throttleLast(NOTIFICATION_SAMPLING_PERIOD, TimeUnit.MILLISECONDS));
final Function<Flowable<String>, Publisher<String>> throttleAfterFirstEmission = flow ->
flow.limit(1).concatWith(flow.skip(1)
.throttleLast(NOTIFICATION_SAMPLING_PERIOD, TimeUnit.MILLISECONDS));
disposables.add(notificationUpdater
.filter(s -> !s.isEmpty())
@ -125,17 +129,20 @@ public abstract class BaseImportExportService extends Service {
.subscribe(this::updateNotification));
}
protected void updateNotification(String text) {
notificationBuilder.setProgress(maxProgress.get(), currentProgress.get(), maxProgress.get() == -1);
protected void updateNotification(final String text) {
notificationBuilder
.setProgress(maxProgress.get(), currentProgress.get(), maxProgress.get() == -1);
final String progressText = currentProgress + "/" + maxProgress;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
if (!TextUtils.isEmpty(text)) text = text + " (" + progressText + ")";
if (!TextUtils.isEmpty(text)) {
notificationBuilder.setContentText(text + " (" + progressText + ")");
}
} else {
notificationBuilder.setContentInfo(progressText);
notificationBuilder.setContentText(text);
}
if (!TextUtils.isEmpty(text)) notificationBuilder.setContentText(text);
notificationManager.notify(getNotificationId(), notificationBuilder.build());
}
@ -143,16 +150,16 @@ public abstract class BaseImportExportService extends Service {
postErrorResult(null, null);
}
protected void stopAndReportError(@Nullable Throwable error, String request) {
protected void stopAndReportError(@Nullable final Throwable error, final String request) {
stopService();
final ErrorActivity.ErrorInfo errorInfo = ErrorActivity.ErrorInfo.make(UserAction.SUBSCRIPTION, "unknown",
request, R.string.general_error);
ErrorActivity.reportError(this, error != null ? Collections.singletonList(error) : Collections.emptyList(),
null, null, errorInfo);
final ErrorActivity.ErrorInfo errorInfo = ErrorActivity.ErrorInfo
.make(UserAction.SUBSCRIPTION, "unknown", request, R.string.general_error);
ErrorActivity.reportError(this, error != null ? Collections.singletonList(error)
: Collections.emptyList(), null, null, errorInfo);
}
protected void postErrorResult(String title, String text) {
protected void postErrorResult(final String title, final String text) {
disposeAll();
stopForeground(true);
stopSelf();
@ -161,13 +168,14 @@ public abstract class BaseImportExportService extends Service {
return;
}
text = text == null ? "" : text;
notificationBuilder = new NotificationCompat.Builder(this, getString(R.string.notification_channel_id))
final String textOrEmpty = text == null ? "" : text;
notificationBuilder = new NotificationCompat
.Builder(this, getString(R.string.notification_channel_id))
.setSmallIcon(R.drawable.ic_newpipe_triangle_white)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentTitle(title)
.setStyle(new NotificationCompat.BigTextStyle().bigText(text))
.setContentText(text);
.setStyle(new NotificationCompat.BigTextStyle().bigText(textOrEmpty))
.setContentText(textOrEmpty);
notificationManager.notify(getNotificationId(), notificationBuilder.build());
}
@ -184,14 +192,14 @@ public abstract class BaseImportExportService extends Service {
// Toast
//////////////////////////////////////////////////////////////////////////*/
protected Toast toast;
protected void showToast(@StringRes int message) {
protected void showToast(@StringRes final int message) {
showToast(getString(message));
}
protected void showToast(String message) {
if (toast != null) toast.cancel();
protected void showToast(final String message) {
if (toast != null) {
toast.cancel();
}
toast = Toast.makeText(this, message, Toast.LENGTH_SHORT);
toast.show();
@ -201,7 +209,7 @@ public abstract class BaseImportExportService extends Service {
// Error handling
//////////////////////////////////////////////////////////////////////////*/
protected void handleError(@StringRes int errorTitle, @NonNull Throwable error) {
protected void handleError(@StringRes final int errorTitle, @NonNull final Throwable error) {
String message = getErrorMessage(error);
if (TextUtils.isEmpty(message)) {
@ -213,13 +221,13 @@ public abstract class BaseImportExportService extends Service {
postErrorResult(getString(errorTitle), message);
}
protected String getErrorMessage(Throwable error) {
protected String getErrorMessage(final Throwable error) {
String message = null;
if (error instanceof SubscriptionExtractor.InvalidSourceException) {
message = getString(R.string.invalid_source);
} else if (error instanceof FileNotFoundException) {
message = getString(R.string.invalid_file);
} else if (error instanceof IOException) {
} else if (ExceptionUtils.isNetworkRelated(error)) {
message = getString(R.string.network_error);
}
return message;

View file

@ -1,4 +1,4 @@
package org.schabi.newpipe.local.subscription;
package org.schabi.newpipe.local.subscription.services;
public interface ImportExportEventListener {
/**
@ -14,4 +14,4 @@ public interface ImportExportEventListener {
* @param itemName the name of the subscription item
*/
void onItemCompleted(String itemName);
}
}

View file

@ -17,7 +17,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.schabi.newpipe.local.subscription;
package org.schabi.newpipe.local.subscription.services;
import androidx.annotation.Nullable;
@ -41,8 +41,7 @@ import java.util.List;
* A JSON implementation capable of importing and exporting subscriptions, it has the advantage
* of being able to transfer subscriptions to any device.
*/
public class ImportExportJsonHelper {
public final class ImportExportJsonHelper {
/*//////////////////////////////////////////////////////////////////////////
// Json implementation
//////////////////////////////////////////////////////////////////////////*/
@ -56,26 +55,37 @@ public class ImportExportJsonHelper {
private static final String JSON_URL_KEY = "url";
private static final String JSON_NAME_KEY = "name";
private ImportExportJsonHelper() { }
/**
* Read a JSON source through the input stream and return the parsed subscription items.
* Read a JSON source through the input stream.
*
* @param in the input stream (e.g. a file)
* @param eventListener listener for the events generated
* @return the parsed subscription items
*/
public static List<SubscriptionItem> readFrom(InputStream in, @Nullable ImportExportEventListener eventListener) throws InvalidSourceException {
if (in == null) throw new InvalidSourceException("input is null");
public static List<SubscriptionItem> readFrom(
final InputStream in, @Nullable final ImportExportEventListener eventListener)
throws InvalidSourceException {
if (in == null) {
throw new InvalidSourceException("input is null");
}
final List<SubscriptionItem> channels = new ArrayList<>();
try {
JsonObject parentObject = JsonParser.object().from(in);
JsonArray channelsArray = parentObject.getArray(JSON_SUBSCRIPTIONS_ARRAY_KEY);
if (eventListener != null) eventListener.onSizeReceived(channelsArray.size());
final JsonObject parentObject = JsonParser.object().from(in);
if (channelsArray == null) {
if (!parentObject.has(JSON_SUBSCRIPTIONS_ARRAY_KEY)) {
throw new InvalidSourceException("Channels array is null");
}
final JsonArray channelsArray = parentObject.getArray(JSON_SUBSCRIPTIONS_ARRAY_KEY);
if (eventListener != null) {
eventListener.onSizeReceived(channelsArray.size());
}
for (Object o : channelsArray) {
if (o instanceof JsonObject) {
JsonObject itemObject = (JsonObject) o;
@ -85,7 +95,9 @@ public class ImportExportJsonHelper {
if (url != null && name != null && !url.isEmpty() && !name.isEmpty()) {
channels.add(new SubscriptionItem(serviceId, url, name));
if (eventListener != null) eventListener.onItemCompleted(name);
if (eventListener != null) {
eventListener.onItemCompleted(name);
}
}
}
}
@ -103,7 +115,8 @@ public class ImportExportJsonHelper {
* @param out the output stream (e.g. a file)
* @param eventListener listener for the events generated
*/
public static void writeTo(List<SubscriptionItem> items, OutputStream out, @Nullable ImportExportEventListener eventListener) {
public static void writeTo(final List<SubscriptionItem> items, final OutputStream out,
@Nullable final ImportExportEventListener eventListener) {
JsonAppendableWriter writer = JsonWriter.on(out);
writeTo(items, writer, eventListener);
writer.done();
@ -111,9 +124,15 @@ public class ImportExportJsonHelper {
/**
* @see #writeTo(List, OutputStream, ImportExportEventListener)
* @param items the list of subscriptions items
* @param writer the output {@link JsonSink}
* @param eventListener listener for the events generated
*/
public static void writeTo(List<SubscriptionItem> items, JsonSink writer, @Nullable ImportExportEventListener eventListener) {
if (eventListener != null) eventListener.onSizeReceived(items.size());
public static void writeTo(final List<SubscriptionItem> items, final JsonSink writer,
@Nullable final ImportExportEventListener eventListener) {
if (eventListener != null) {
eventListener.onSizeReceived(items.size());
}
writer.object();
@ -128,11 +147,12 @@ public class ImportExportJsonHelper {
writer.value(JSON_NAME_KEY, item.getName());
writer.end();
if (eventListener != null) eventListener.onItemCompleted(item.getName());
if (eventListener != null) {
eventListener.onItemCompleted(item.getName());
}
}
writer.end();
writer.end();
}
}

View file

@ -20,16 +20,16 @@
package org.schabi.newpipe.local.subscription.services;
import android.content.Intent;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import android.text.TextUtils;
import android.util.Log;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import org.reactivestreams.Subscriber;
import org.reactivestreams.Subscription;
import org.schabi.newpipe.R;
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
import org.schabi.newpipe.extractor.subscription.SubscriptionItem;
import org.schabi.newpipe.local.subscription.ImportExportJsonHelper;
import java.io.File;
import java.io.FileNotFoundException;
@ -47,26 +47,33 @@ public class SubscriptionsExportService extends BaseImportExportService {
public static final String KEY_FILE_PATH = "key_file_path";
/**
* A {@link LocalBroadcastManager local broadcast} will be made with this action when the export is successfully completed.
* A {@link LocalBroadcastManager local broadcast} will be made with this action
* when the export is successfully completed.
*/
public static final String EXPORT_COMPLETE_ACTION = "org.schabi.newpipe.local.subscription.services.SubscriptionsExportService.EXPORT_COMPLETE";
public static final String EXPORT_COMPLETE_ACTION = "org.schabi.newpipe.local.subscription"
+ ".services.SubscriptionsExportService.EXPORT_COMPLETE";
private Subscription subscription;
private File outFile;
private FileOutputStream outputStream;
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
if (intent == null || subscription != null) return START_NOT_STICKY;
public int onStartCommand(final Intent intent, final int flags, final int startId) {
if (intent == null || subscription != null) {
return START_NOT_STICKY;
}
final String path = intent.getStringExtra(KEY_FILE_PATH);
if (TextUtils.isEmpty(path)) {
stopAndReportError(new IllegalStateException("Exporting to a file, but the path is empty or null"), "Exporting subscriptions");
stopAndReportError(new IllegalStateException(
"Exporting to a file, but the path is empty or null"),
"Exporting subscriptions");
return START_NOT_STICKY;
}
try {
outputStream = new FileOutputStream(outFile = new File(path));
outFile = new File(path);
outputStream = new FileOutputStream(outFile);
} catch (FileNotFoundException e) {
handleError(e);
return START_NOT_STICKY;
@ -90,19 +97,21 @@ public class SubscriptionsExportService extends BaseImportExportService {
@Override
protected void disposeAll() {
super.disposeAll();
if (subscription != null) subscription.cancel();
if (subscription != null) {
subscription.cancel();
}
}
private void startExport() {
showToast(R.string.export_ongoing);
subscriptionService.subscriptionTable()
.getAll()
.take(1)
subscriptionManager.subscriptionTable().getAll().take(1)
.map(subscriptionEntities -> {
final List<SubscriptionItem> result = new ArrayList<>(subscriptionEntities.size());
final List<SubscriptionItem> result
= new ArrayList<>(subscriptionEntities.size());
for (SubscriptionEntity entity : subscriptionEntities) {
result.add(new SubscriptionItem(entity.getServiceId(), entity.getUrl(), entity.getName()));
result.add(new SubscriptionItem(entity.getServiceId(), entity.getUrl(),
entity.getName()));
}
return result;
})
@ -115,25 +124,28 @@ public class SubscriptionsExportService extends BaseImportExportService {
private Subscriber<File> getSubscriber() {
return new Subscriber<File>() {
@Override
public void onSubscribe(Subscription s) {
public void onSubscribe(final Subscription s) {
subscription = s;
s.request(1);
}
@Override
public void onNext(File file) {
if (DEBUG) Log.d(TAG, "startExport() success: file = " + file);
public void onNext(final File file) {
if (DEBUG) {
Log.d(TAG, "startExport() success: file = " + file);
}
}
@Override
public void onError(Throwable error) {
public void onError(final Throwable error) {
Log.e(TAG, "onError() called with: error = [" + error + "]", error);
handleError(error);
}
@Override
public void onComplete() {
LocalBroadcastManager.getInstance(SubscriptionsExportService.this).sendBroadcast(new Intent(EXPORT_COMPLETE_ACTION));
LocalBroadcastManager.getInstance(SubscriptionsExportService.this)
.sendBroadcast(new Intent(EXPORT_COMPLETE_ACTION));
showToast(R.string.export_complete_toast);
stopService();
}
@ -147,7 +159,7 @@ public class SubscriptionsExportService extends BaseImportExportService {
};
}
protected void handleError(Throwable error) {
protected void handleError(final Throwable error) {
super.handleError(R.string.subscriptions_export_unsuccessful, error);
}
}

View file

@ -20,11 +20,12 @@
package org.schabi.newpipe.local.subscription.services;
import android.content.Intent;
import android.text.TextUtils;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import android.text.TextUtils;
import android.util.Log;
import org.reactivestreams.Subscriber;
import org.reactivestreams.Subscription;
@ -33,8 +34,8 @@ 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.ExceptionUtils;
import org.schabi.newpipe.util.ExtractorHelper;
import java.io.File;
@ -62,22 +63,36 @@ public class SubscriptionsImportService extends BaseImportExportService {
public static final String KEY_VALUE = "key_value";
/**
* A {@link LocalBroadcastManager local broadcast} will be made with this action when the import is successfully completed.
* A {@link LocalBroadcastManager local broadcast} will be made with this action
* when the import is successfully completed.
*/
public static final String IMPORT_COMPLETE_ACTION = "org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.IMPORT_COMPLETE";
public static final String IMPORT_COMPLETE_ACTION = "org.schabi.newpipe.local.subscription"
+ ".services.SubscriptionsImportService.IMPORT_COMPLETE";
/**
* How many extractions running in parallel.
*/
public static final int PARALLEL_EXTRACTIONS = 8;
/**
* Number of items to buffer to mass-insert in the subscriptions table,
* this leads to a better performance as we can then use db transactions.
*/
public static final int BUFFER_COUNT_BEFORE_INSERT = 50;
private Subscription subscription;
private int currentMode;
private int currentServiceId;
@Nullable
private String channelUrl;
@Nullable
private InputStream inputStream;
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
if (intent == null || subscription != null) return START_NOT_STICKY;
public int onStartCommand(final Intent intent, final int flags, final int startId) {
if (intent == null || subscription != null) {
return START_NOT_STICKY;
}
currentMode = intent.getIntExtra(KEY_MODE, -1);
currentServiceId = intent.getIntExtra(Constants.KEY_SERVICE_ID, Constants.NO_SERVICE_ID);
@ -87,7 +102,9 @@ public class SubscriptionsImportService extends BaseImportExportService {
} else {
final String filePath = intent.getStringExtra(KEY_VALUE);
if (TextUtils.isEmpty(filePath)) {
stopAndReportError(new IllegalStateException("Importing from input stream, but file path is empty or null"), "Importing subscriptions");
stopAndReportError(new IllegalStateException(
"Importing from input stream, but file path is empty or null"),
"Importing subscriptions");
return START_NOT_STICKY;
}
@ -100,8 +117,12 @@ public class SubscriptionsImportService extends BaseImportExportService {
}
if (currentMode == -1 || currentMode == CHANNEL_URL_MODE && channelUrl == null) {
final String errorDescription = "Some important field is null or in illegal state: currentMode=[" + currentMode + "], channelUrl=[" + channelUrl + "], inputStream=[" + inputStream + "]";
stopAndReportError(new IllegalStateException(errorDescription), "Importing subscriptions");
final String errorDescription = "Some important field is null or in illegal state: "
+ "currentMode=[" + currentMode + "], "
+ "channelUrl=[" + channelUrl + "], "
+ "inputStream=[" + inputStream + "]";
stopAndReportError(new IllegalStateException(errorDescription),
"Importing subscriptions");
return START_NOT_STICKY;
}
@ -122,24 +143,15 @@ public class SubscriptionsImportService extends BaseImportExportService {
@Override
protected void disposeAll() {
super.disposeAll();
if (subscription != null) subscription.cancel();
if (subscription != null) {
subscription.cancel();
}
}
/*//////////////////////////////////////////////////////////////////////////
// Imports
//////////////////////////////////////////////////////////////////////////*/
/**
* How many extractions running in parallel.
*/
public static final int PARALLEL_EXTRACTIONS = 8;
/**
* Number of items to buffer to mass-insert in the subscriptions table, this leads to
* a better performance as we can then use db transactions.
*/
public static final int BUFFER_COUNT_BEFORE_INSERT = 50;
private void startImport() {
showToast(R.string.import_ongoing);
@ -157,12 +169,14 @@ public class SubscriptionsImportService extends BaseImportExportService {
}
if (flowable == null) {
final String message = "Flowable given by \"importFrom\" is null (current mode: " + currentMode + ")";
final String message = "Flowable given by \"importFrom\" is null "
+ "(current mode: " + currentMode + ")";
stopAndReportError(new IllegalStateException(message), "Importing subscriptions");
return;
}
flowable.doOnNext(subscriptionItems -> eventListener.onSizeReceived(subscriptionItems.size()))
flowable.doOnNext(subscriptionItems ->
eventListener.onSizeReceived(subscriptionItems.size()))
.flatMap(Flowable::fromIterable)
.parallel(PARALLEL_EXTRACTIONS)
@ -170,7 +184,8 @@ public class SubscriptionsImportService extends BaseImportExportService {
.map((Function<SubscriptionItem, Notification<ChannelInfo>>) subscriptionItem -> {
try {
return Notification.createOnNext(ExtractorHelper
.getChannelInfo(subscriptionItem.getServiceId(), subscriptionItem.getUrl(), true)
.getChannelInfo(subscriptionItem.getServiceId(),
subscriptionItem.getUrl(), true)
.blockingGet());
} catch (Throwable e) {
return Notification.createOnError(e);
@ -180,6 +195,7 @@ public class SubscriptionsImportService extends BaseImportExportService {
.observeOn(Schedulers.io())
.doOnNext(getNotificationsConsumer())
.buffer(BUFFER_COUNT_BEFORE_INSERT)
.map(upsertBatch())
@ -190,26 +206,30 @@ public class SubscriptionsImportService extends BaseImportExportService {
private Subscriber<List<SubscriptionEntity>> getSubscriber() {
return new Subscriber<List<SubscriptionEntity>>() {
@Override
public void onSubscribe(Subscription s) {
public void onSubscribe(final Subscription s) {
subscription = s;
s.request(Long.MAX_VALUE);
}
@Override
public void onNext(List<SubscriptionEntity> successfulInserted) {
if (DEBUG) Log.d(TAG, "startImport() " + successfulInserted.size() + " items successfully inserted into the database");
public void onNext(final List<SubscriptionEntity> successfulInserted) {
if (DEBUG) {
Log.d(TAG, "startImport() " + successfulInserted.size()
+ " items successfully inserted into the database");
}
}
@Override
public void onError(Throwable error) {
public void onError(final Throwable error) {
Log.e(TAG, "Got an error!", error);
handleError(error);
}
@Override
public void onComplete() {
LocalBroadcastManager.getInstance(SubscriptionsImportService.this).sendBroadcast(new Intent(IMPORT_COMPLETE_ACTION));
LocalBroadcastManager.getInstance(SubscriptionsImportService.this)
.sendBroadcast(new Intent(IMPORT_COMPLETE_ACTION));
showToast(R.string.import_complete_toast);
stopService();
}
@ -226,8 +246,10 @@ public class SubscriptionsImportService extends BaseImportExportService {
final Throwable cause = error.getCause();
if (error instanceof IOException) {
throw (IOException) error;
} else if (cause != null && cause instanceof IOException) {
} else if (cause instanceof IOException) {
throw (IOException) cause;
} else if (ExceptionUtils.isNetworkRelated(error)) {
throw new IOException(error);
}
eventListener.onItemCompleted("");
@ -239,10 +261,12 @@ public class SubscriptionsImportService extends BaseImportExportService {
return notificationList -> {
final List<ChannelInfo> infoList = new ArrayList<>(notificationList.size());
for (Notification<ChannelInfo> n : notificationList) {
if (n.isOnNext()) infoList.add(n.getValue());
if (n.isOnNext()) {
infoList.add(n.getValue());
}
}
return subscriptionService.upsertAll(infoList);
return subscriptionManager.upsertAll(infoList);
};
}
@ -262,7 +286,7 @@ public class SubscriptionsImportService extends BaseImportExportService {
return Flowable.fromCallable(() -> ImportExportJsonHelper.readFrom(inputStream, null));
}
protected void handleError(@NonNull Throwable error) {
protected void handleError(@NonNull final Throwable error) {
super.handleError(R.string.subscriptions_import_unsuccessful, error);
}
}
}