diff --git a/app/build.gradle b/app/build.gradle index 630e6ba4d..814006051 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -72,7 +72,7 @@ dependencies { implementation 'com.nostra13.universalimageloader:universal-image-loader:1.9.5' implementation 'de.hdodenhof:circleimageview:2.2.0' implementation 'com.github.nirhart:ParallaxScroll:dd53d1f9d1' - implementation 'com.nononsenseapps:filepicker:3.0.1' + implementation 'com.nononsenseapps:filepicker:4.2.1' implementation 'com.google.android.exoplayer:exoplayer:2.7.0' debugImplementation 'com.facebook.stetho:stetho:1.5.0' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e15d9abf8..1be8c1f2c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -70,6 +70,9 @@ android:name=".history.HistoryActivity" android:label="@string/title_activity_history"/> + + + + android:resource="@xml/nnf_provider_paths"/> { +public abstract class SubscriptionDAO implements BasicDAO { @Override @Query("SELECT * FROM " + SUBSCRIPTION_TABLE) - Flowable> getAll(); + public abstract Flowable> getAll(); @Override @Query("DELETE FROM " + SUBSCRIPTION_TABLE) - int deleteAll(); + public abstract int deleteAll(); @Override @Query("SELECT * FROM " + SUBSCRIPTION_TABLE + " WHERE " + SUBSCRIPTION_SERVICE_ID + " = :serviceId") - Flowable> listByService(int serviceId); + public abstract Flowable> listByService(int serviceId); @Query("SELECT * FROM " + SUBSCRIPTION_TABLE + " WHERE " + SUBSCRIPTION_URL + " LIKE :url AND " + SUBSCRIPTION_SERVICE_ID + " = :serviceId") - Flowable> getSubscription(int serviceId, String url); + public abstract Flowable> getSubscription(int serviceId, String url); + + @Query("SELECT " + SUBSCRIPTION_UID + " FROM " + SUBSCRIPTION_TABLE + " WHERE " + + SUBSCRIPTION_URL + " LIKE :url AND " + + SUBSCRIPTION_SERVICE_ID + " = :serviceId") + abstract Long getSubscriptionIdInternal(int serviceId, String url); + + @Insert(onConflict = OnConflictStrategy.IGNORE) + abstract Long insertInternal(final SubscriptionEntity entities); + + @Transaction + public List upsertAll(List entities) { + for (SubscriptionEntity entity : entities) { + Long uid = insertInternal(entity); + + if (uid != -1) { + entity.setUid(uid); + continue; + } + + uid = getSubscriptionIdInternal(entity.getServiceId(), entity.getUrl()); + entity.setUid(uid); + + if (uid == -1) { + throw new IllegalStateException("Invalid subscription id (-1)"); + } + + update(entity); + } + + return entities; + } } diff --git a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.java b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.java index 60eb0c3d3..9328fff6a 100644 --- a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.java +++ b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.java @@ -5,7 +5,9 @@ import android.arch.persistence.room.Entity; import android.arch.persistence.room.Ignore; import android.arch.persistence.room.Index; import android.arch.persistence.room.PrimaryKey; +import android.support.annotation.NonNull; +import org.schabi.newpipe.extractor.channel.ChannelInfo; import org.schabi.newpipe.extractor.channel.ChannelInfoItem; import org.schabi.newpipe.util.Constants; @@ -17,6 +19,7 @@ import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCR indices = {@Index(value = {SUBSCRIPTION_SERVICE_ID, SUBSCRIPTION_URL}, unique = true)}) public class SubscriptionEntity { + final static String SUBSCRIPTION_UID = "uid"; final static String SUBSCRIPTION_TABLE = "subscriptions"; final static String SUBSCRIPTION_SERVICE_ID = "service_id"; final static String SUBSCRIPTION_URL = "url"; @@ -116,9 +119,18 @@ public class SubscriptionEntity { @Ignore public ChannelInfoItem toChannelInfoItem() { ChannelInfoItem item = new ChannelInfoItem(getServiceId(), getUrl(), getName()); - item.thumbnail_url = getAvatarUrl(); - item.subscriber_count = getSubscriberCount(); - item.description = getDescription(); + item.setThumbnailUrl(getAvatarUrl()); + item.setSubscriberCount(getSubscriberCount()); + item.setDescription(getDescription()); return item; } + + @Ignore + public static SubscriptionEntity from(@NonNull ChannelInfo info) { + SubscriptionEntity result = new SubscriptionEntity(); + result.setServiceId(info.getServiceId()); + result.setUrl(info.getUrl()); + result.setData(info.getName(), info.getAvatarUrl(), info.getDescription(), info.getSubscriberCount()); + return result; + } } diff --git a/app/src/main/java/org/schabi/newpipe/fragments/BaseStateFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/BaseStateFragment.java index a75c8561f..cb9ce8947 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/BaseStateFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/BaseStateFragment.java @@ -246,13 +246,6 @@ public abstract class BaseStateFragment extends BaseFragment implements ViewC // Utils //////////////////////////////////////////////////////////////////////////*/ - public void setTitle(String title) { - if (DEBUG) Log.d(TAG, "setTitle() called with: title = [" + title + "]"); - if (activity != null && activity.getSupportActionBar() != null) { - activity.getSupportActionBar().setTitle(title); - } - } - protected void openUrlInBrowser(String url) { Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); startActivity(Intent.createChooser(intent, activity.getString(R.string.share_dialog_title))); diff --git a/app/src/main/java/org/schabi/newpipe/fragments/MainFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/MainFragment.java index abc150e7d..20607b3a0 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/MainFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/MainFragment.java @@ -106,7 +106,7 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte super.onCreateOptionsMenu(menu, inflater); if (DEBUG) Log.d(TAG, "onCreateOptionsMenu() called with: menu = [" + menu + "], inflater = [" + inflater + "]"); inflater.inflate(R.menu.main_fragment_menu, menu); - SubMenu kioskMenu = menu.addSubMenu(getString(R.string.kiosk)); + SubMenu kioskMenu = menu.addSubMenu(Menu.NONE, Menu.NONE, 200, getString(R.string.kiosk)); try { createKioskMenu(kioskMenu, inflater); } catch (Exception e) { diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java index 8e8179b9a..4d935dbce 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java @@ -16,7 +16,6 @@ import android.support.v4.content.ContextCompat; import android.support.v4.view.animation.FastOutSlowInInterpolator; import android.support.v7.app.ActionBar; import android.support.v7.app.AlertDialog; -import android.support.v7.widget.Toolbar; import android.text.Html; import android.text.Spanned; import android.text.TextUtils; @@ -92,8 +91,6 @@ import io.reactivex.Single; import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.disposables.CompositeDisposable; import io.reactivex.disposables.Disposable; -import io.reactivex.functions.Consumer; -import io.reactivex.functions.Function; import io.reactivex.schedulers.Schedulers; import static org.schabi.newpipe.util.AnimationUtils.animateView; @@ -138,9 +135,9 @@ public class VideoDetailFragment /*////////////////////////////////////////////////////////////////////////// // Views //////////////////////////////////////////////////////////////////////////*/ + private Menu menu; - private Toolbar toolbar; private Spinner spinnerToolbar; private ParallaxScrollView parallaxScrollRootView; @@ -160,6 +157,7 @@ public class VideoDetailFragment private TextView detailControlsAddToPlaylist; private TextView detailControlsDownload; private TextView appendControlsDetail; + private TextView detailDurationView; private LinearLayout videoDescriptionRootLayout; private TextView videoUploadDateView; @@ -461,9 +459,7 @@ public class VideoDetailFragment @Override protected void initViews(View rootView, Bundle savedInstanceState) { super.initViews(rootView, savedInstanceState); - - toolbar = activity.findViewById(R.id.toolbar); - spinnerToolbar = toolbar.findViewById(R.id.toolbar_spinner); + spinnerToolbar = activity.findViewById(R.id.toolbar).findViewById(R.id.toolbar_spinner); parallaxScrollRootView = rootView.findViewById(R.id.detail_main_content); @@ -483,6 +479,7 @@ public class VideoDetailFragment detailControlsAddToPlaylist = rootView.findViewById(R.id.detail_controls_playlist_append); detailControlsDownload = rootView.findViewById(R.id.detail_controls_download); appendControlsDetail = rootView.findViewById(R.id.touch_append_detail); + detailDurationView = rootView.findViewById(R.id.detail_duration_view); videoDescriptionRootLayout = rootView.findViewById(R.id.detail_description_root_layout); videoUploadDateView = rootView.findViewById(R.id.detail_upload_date_view); @@ -1010,9 +1007,6 @@ public class VideoDetailFragment int height = isPortrait ? (int) (metrics.widthPixels / (16.0f / 9.0f)) : (int) (metrics.heightPixels / 2f); - thumbnailImageView.setScaleType(isPortrait - ? ImageView.ScaleType.CENTER_CROP - : ImageView.ScaleType.FIT_CENTER); thumbnailImageView.setLayoutParams( new FrameLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, height)); thumbnailImageView.setMinimumHeight(height); @@ -1098,6 +1092,7 @@ public class VideoDetailFragment animateView(contentRootLayoutHiding, false, 200); animateView(spinnerToolbar, false, 200); animateView(thumbnailPlayButton, false, 50); + animateView(detailDurationView, false, 100); videoTitleTextView.setText(name != null ? name : ""); videoTitleTextView.setMaxLines(1); @@ -1168,6 +1163,18 @@ public class VideoDetailFragment thumbsDisabledTextView.setVisibility(View.GONE); } + if (info.getDuration() > 0) { + detailDurationView.setText(Localization.getDurationString(info.getDuration())); + detailDurationView.setBackgroundColor(ContextCompat.getColor(activity, R.color.duration_background_color)); + animateView(detailDurationView, true, 100); + } else if (info.getStreamType() == StreamType.LIVE_STREAM) { + detailDurationView.setText(R.string.duration_live); + detailDurationView.setBackgroundColor(ContextCompat.getColor(activity, R.color.live_duration_background_color)); + animateView(detailDurationView, true, 100); + } else { + detailDurationView.setVisibility(View.GONE); + } + videoTitleRoot.setClickable(true); videoTitleToggleArrow.setVisibility(View.VISIBLE); videoTitleToggleArrow.setImageResource(R.drawable.arrow_down); @@ -1201,7 +1208,6 @@ public class VideoDetailFragment case AUDIO_LIVE_STREAM: detailControlsDownload.setVisibility(View.GONE); spinnerToolbar.setVisibility(View.GONE); - toolbar.setTitle(R.string.live); break; default: if (!info.video_streams.isEmpty() || !info.video_only_streams.isEmpty()) break; diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java index 8c9945149..580e16825 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java @@ -141,8 +141,7 @@ public abstract class BaseListFragment extends BaseStateFragment implem @Override public void selected(StreamInfoItem selectedItem) { onItemSelected(selectedItem); - NavigationHelper.openVideoDetailFragment( - useAsFrontPage?getParentFragment().getFragmentManager():getFragmentManager(), + NavigationHelper.openVideoDetailFragment(useAsFrontPage ? getParentFragment().getFragmentManager() : getFragmentManager(), selectedItem.getServiceId(), selectedItem.getUrl(), selectedItem.getName()); } @@ -156,8 +155,7 @@ public abstract class BaseListFragment extends BaseStateFragment implem @Override public void selected(ChannelInfoItem selectedItem) { onItemSelected(selectedItem); - NavigationHelper.openChannelFragment( - useAsFrontPage?getParentFragment().getFragmentManager():getFragmentManager(), + NavigationHelper.openChannelFragment(useAsFrontPage ? getParentFragment().getFragmentManager() : getFragmentManager(), selectedItem.getServiceId(), selectedItem.getUrl(), selectedItem.getName()); } }); @@ -166,8 +164,7 @@ public abstract class BaseListFragment extends BaseStateFragment implem @Override public void selected(PlaylistInfoItem selectedItem) { onItemSelected(selectedItem); - NavigationHelper.openPlaylistFragment( - useAsFrontPage?getParentFragment().getFragmentManager():getFragmentManager(), + NavigationHelper.openPlaylistFragment(useAsFrontPage ? getParentFragment().getFragmentManager() : getFragmentManager(), selectedItem.getServiceId(), selectedItem.getUrl(), selectedItem.getName()); } }); @@ -230,7 +227,7 @@ public abstract class BaseListFragment extends BaseStateFragment implem ActionBar supportActionBar = activity.getSupportActionBar(); if (supportActionBar != null) { supportActionBar.setDisplayShowTitleEnabled(true); - if(useAsFrontPage) { + if (useAsFrontPage) { supportActionBar.setDisplayHomeAsUpEnabled(false); } else { supportActionBar.setDisplayHomeAsUpEnabled(true); @@ -277,9 +274,8 @@ public abstract class BaseListFragment extends BaseStateFragment implem @Override public void showListFooter(final boolean show) { - itemsList.post(new Runnable() { - @Override - public void run() { + itemsList.post(() -> { + if (infoListAdapter != null && itemsList != null) { infoListAdapter.showFooter(show); } }); diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java index 19b0be8f8..3261e6dad 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java @@ -33,12 +33,12 @@ import org.schabi.newpipe.extractor.channel.ChannelInfo; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.fragments.list.BaseListInfoFragment; -import org.schabi.newpipe.fragments.subscription.SubscriptionService; import org.schabi.newpipe.info_list.InfoItemDialog; import org.schabi.newpipe.playlist.ChannelPlayQueue; import org.schabi.newpipe.playlist.PlayQueue; import org.schabi.newpipe.playlist.SinglePlayQueue; import org.schabi.newpipe.report.UserAction; +import org.schabi.newpipe.subscription.SubscriptionService; import org.schabi.newpipe.util.AnimationUtils; import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.Localization; @@ -108,11 +108,11 @@ public class ChannelFragment extends BaseListInfoFragment { @Override public void onAttach(Context context) { super.onAttach(context); - subscriptionService = SubscriptionService.getInstance(); + subscriptionService = SubscriptionService.getInstance(activity); } @Override - public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { return inflater.inflate(R.layout.fragment_channel, container, false); } diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/feed/FeedFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/feed/FeedFragment.java index a62593047..57841cb87 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/feed/FeedFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/feed/FeedFragment.java @@ -21,8 +21,8 @@ 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.fragments.subscription.SubscriptionService; import org.schabi.newpipe.report.UserAction; +import org.schabi.newpipe.subscription.SubscriptionService; import java.util.Collections; import java.util.HashSet; @@ -64,7 +64,7 @@ public class FeedFragment extends BaseListFragment, Voi @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - subscriptionService = SubscriptionService.getInstance(); + subscriptionService = SubscriptionService.getInstance(activity); FEED_LOAD_COUNT = howManyItemsToLoad(); } diff --git a/app/src/main/java/org/schabi/newpipe/fragments/local/dialog/PlaylistAppendDialog.java b/app/src/main/java/org/schabi/newpipe/fragments/local/dialog/PlaylistAppendDialog.java index 40637e149..da31ca3f8 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/local/dialog/PlaylistAppendDialog.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/local/dialog/PlaylistAppendDialog.java @@ -27,8 +27,6 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; -import javax.annotation.Nonnull; - import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.disposables.Disposable; @@ -147,7 +145,7 @@ public final class PlaylistAppendDialog extends PlaylistDialog { private void onPlaylistSelected(@NonNull LocalPlaylistManager manager, @NonNull PlaylistMetadataEntry playlist, - @Nonnull List streams) { + @NonNull List streams) { if (getStreams() == null) return; @SuppressLint("ShowToast") diff --git a/app/src/main/java/org/schabi/newpipe/fragments/subscription/ImportConfirmationDialog.java b/app/src/main/java/org/schabi/newpipe/fragments/subscription/ImportConfirmationDialog.java new file mode 100644 index 000000000..3a64c22c7 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/subscription/ImportConfirmationDialog.java @@ -0,0 +1,64 @@ +package org.schabi.newpipe.fragments.subscription; + +import android.app.AlertDialog; +import android.app.Dialog; +import android.content.Intent; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.app.DialogFragment; +import android.support.v4.app.Fragment; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.util.ThemeHelper; + +import icepick.Icepick; +import icepick.State; + +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; + + final ImportConfirmationDialog confirmationDialog = new ImportConfirmationDialog(); + confirmationDialog.setResultServiceIntent(resultServiceIntent); + confirmationDialog.show(fragment.getFragmentManager(), null); + } + + @NonNull + @Override + public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { + 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) -> { + if (resultServiceIntent != null && getContext() != null) { + getContext().startService(resultServiceIntent); + } + dismiss(); + }) + .create(); + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + if (resultServiceIntent == null) throw new IllegalStateException("Result intent is null"); + + Icepick.restoreInstanceState(this, savedInstanceState); + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + Icepick.saveInstanceState(this, outState); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/subscription/SubscriptionFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/subscription/SubscriptionFragment.java index a91cca908..1e69732b7 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/subscription/SubscriptionFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/subscription/SubscriptionFragment.java @@ -1,30 +1,62 @@ package org.schabi.newpipe.fragments.subscription; +import android.app.Activity; +import android.content.BroadcastReceiver; import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.graphics.Color; +import android.graphics.PorterDuff; import android.os.Bundle; +import android.os.Environment; import android.os.Parcelable; +import android.support.annotation.DrawableRes; import android.support.annotation.NonNull; import android.support.annotation.Nullable; +import android.support.v4.content.LocalBroadcastManager; +import android.support.v7.app.ActionBar; import android.support.v7.widget.LinearLayoutManager; import android.support.v7.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.report.UserAction; +import org.schabi.newpipe.subscription.SubscriptionService; +import org.schabi.newpipe.subscription.services.SubscriptionsExportService; +import org.schabi.newpipe.subscription.services.SubscriptionsImportService; +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.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.Comparator; +import java.util.Date; import java.util.List; +import java.util.Locale; import icepick.State; import io.reactivex.Observer; @@ -33,18 +65,29 @@ import io.reactivex.disposables.CompositeDisposable; import io.reactivex.disposables.Disposable; import io.reactivex.schedulers.Schedulers; +import static org.schabi.newpipe.subscription.services.SubscriptionsImportService.KEY_MODE; +import static org.schabi.newpipe.subscription.services.SubscriptionsImportService.KEY_VALUE; +import static org.schabi.newpipe.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> { - private View headerRootLayout; + private static final int REQUEST_EXPORT_CODE = 666; + private static final int REQUEST_IMPORT_CODE = 667; - private InfoListAdapter infoListAdapter; private RecyclerView itemsList; - @State protected Parcelable itemsListState; + private InfoListAdapter infoListAdapter; + + private View headerRootLayout; + private View whatsNewItemListHeader; + private View importExportListHeader; + + @State + protected Parcelable importExportOptionsState; + private CollapsibleView importExportOptions; - /* Used for independent events */ private CompositeDisposable disposables = new CompositeDisposable(); private SubscriptionService subscriptionService; @@ -52,39 +95,48 @@ public class SubscriptionFragment extends BaseStateFragment onImportPreviousSelected()); + + final int iconColor = ThemeHelper.isLightThemeSelected(getContext()) ? Color.BLACK : Color.WHITE; + final String[] services = getResources().getStringArray(R.array.service_list); + for (String serviceName : services) { + try { + final StreamingService service = NewPipe.getService(serviceName); + + final SubscriptionExtractor subscriptionExtractor = service.getSubscriptionExtractor(); + if (subscriptionExtractor == null) continue; + + final List supportedSources = subscriptionExtractor.getSupportedSources(); + if (supportedSources.isEmpty()) continue; + + final View itemView = addItemView(serviceName, ServiceHelper.getIcon(service.getServiceId()), listHolder); + final ImageView iconView = itemView.findViewById(android.R.id.icon1); + iconView.setColorFilter(iconColor, PorterDuff.Mode.SRC_IN); + + itemView.setOnClickListener(selectedItem -> onImportFromServiceSelected(service.getServiceId())); + } catch (ExtractionException e) { + throw new RuntimeException("Services array contains an entry that it's not a valid service name (" + serviceName + ")", e); + } + } + } + + private void setupExportToItems(final ViewGroup listHolder) { + final View previousBackupItem = addItemView(getString(R.string.file), ThemeHelper.resolveResourceIdFromAttr(getContext(), R.attr.ic_save), listHolder); + previousBackupItem.setOnClickListener(item -> onExportSelected()); + } + + private void onImportFromServiceSelected(int serviceId) { + if (getParentFragment() == null) return; + NavigationHelper.openSubscriptionsImportFragment(getParentFragment().getFragmentManager(), 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) { @@ -116,9 +290,27 @@ public class SubscriptionFragment extends BaseStateFragment animateRotation(iconView, 250, newState == CollapsibleView.COLLAPSED ? 0 : 180); } @Override @@ -130,12 +322,14 @@ public class SubscriptionFragment extends BaseStateFragment + //noinspection ConstantConditions + whatsNewItemListHeader.setOnClickListener(v -> NavigationHelper.openWhatsNewFragment(getParentFragment().getFragmentManager())); + importExportListHeader.setOnClickListener(v -> importExportOptions.switchState()); } private void resetFragment() { @@ -189,6 +383,7 @@ public class SubscriptionFragment extends BaseStateFragment 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; + + /////////////////////////////////////////////////////////////////////////// + // Fragment LifeCycle + /////////////////////////////////////////////////////////////////////////// + + + @Override + public void onCreate(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)); + activity.finish(); + } + } + + @Override + public void setUserVisibleHint(boolean isVisibleToUser) { + super.setUserVisibleHint(isVisibleToUser); + if (isVisibleToUser) { + setTitle(getString(R.string.import_title)); + } + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_import, container, false); + } + + /*///////////////////////////////////////////////////////////////////////// + // Fragment Views + /////////////////////////////////////////////////////////////////////////*/ + + @Override + protected void initViews(View rootView, Bundle savedInstanceState) { + super.initViews(rootView, savedInstanceState); + + inputButton = rootView.findViewById(R.id.input_button); + inputText = rootView.findViewById(R.id.input_text); + + 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) + if (supportedSources.contains(CHANNEL_URL)) { + inputButton.setText(R.string.import_title); + inputText.setVisibility(View.VISIBLE); + inputText.setHint(ServiceHelper.getImportInstructionsHint(currentServiceId)); + } else { + inputButton.setText(R.string.import_file_title); + } + + if (instructionsString != 0) { + if (TextUtils.isEmpty(relatedUrl)) { + setInfoText(getString(instructionsString)); + } else { + setInfoText(getString(instructionsString, relatedUrl)); + } + } else { + setInfoText(""); + } + + ActionBar supportActionBar = activity.getSupportActionBar(); + if (supportActionBar != null) { + supportActionBar.setDisplayShowTitleEnabled(true); + setTitle(getString(R.string.import_title)); + } + } + + @Override + protected void initListeners() { + super.initListeners(); + inputButton.setOnClickListener(v -> onImportClicked()); + } + + private void onImportClicked() { + if (inputText.getVisibility() == View.VISIBLE) { + final String value = inputText.getText().toString(); + if (!value.isEmpty()) onImportUrl(value); + } else { + onImportFile(); + } + } + + public void onImportUrl(String value) { + ImportConfirmationDialog.show(this, new Intent(activity, SubscriptionsImportService.class) + .putExtra(KEY_MODE, CHANNEL_URL_MODE) + .putExtra(KEY_VALUE, value) + .putExtra(Constants.KEY_SERVICE_ID, currentServiceId)); + } + + public void onImportFile() { + startActivityForResult(FilePickerActivityHelper.chooseSingleFile(activity), REQUEST_IMPORT_FILE_CODE); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (data == null) return; + + 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)); + } + } + + /////////////////////////////////////////////////////////////////////////// + // Subscriptions + /////////////////////////////////////////////////////////////////////////// + + private void setupServiceVariables() { + if (currentServiceId != Constants.NO_SERVICE_ID) { + try { + final SubscriptionExtractor extractor = NewPipe.getService(currentServiceId).getSubscriptionExtractor(); + supportedSources = extractor.getSupportedSources(); + relatedUrl = extractor.getRelatedUrl(); + instructionsString = ServiceHelper.getImportInstructions(currentServiceId); + return; + } catch (ExtractionException ignored) { + } + } + + supportedSources = Collections.emptyList(); + relatedUrl = null; + instructionsString = 0; + } + + private void setInfoText(String infoString) { + infoTextView.setText(infoString); + LinkifyCompat.addLinks(infoTextView, Linkify.WEB_URLS); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java index 87b0f701f..b34cec724 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java @@ -32,12 +32,8 @@ import java.util.List; import java.util.Locale; import java.util.Set; -import javax.annotation.Nonnull; - import static com.google.android.exoplayer2.ui.AspectRatioFrameLayout.RESIZE_MODE_FILL; import static com.google.android.exoplayer2.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT; -import static com.google.android.exoplayer2.ui.AspectRatioFrameLayout.RESIZE_MODE_FIXED_HEIGHT; -import static com.google.android.exoplayer2.ui.AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH; import static com.google.android.exoplayer2.ui.AspectRatioFrameLayout.RESIZE_MODE_ZOOM; public class PlayerHelper { @@ -162,7 +158,7 @@ public class PlayerHelper { return isUsingOldPlayer(context, false); } - public static boolean isRememberingPopupDimensions(@Nonnull final Context context) { + public static boolean isRememberingPopupDimensions(@NonNull final Context context) { return isRememberingPopupDimensions(context, true); } @@ -211,11 +207,11 @@ public class PlayerHelper { return true; } - public static int getShutdownFlingVelocity(@Nonnull final Context context) { + public static int getShutdownFlingVelocity(@NonNull final Context context) { return 10000; } - public static int getTossFlingVelocity(@Nonnull final Context context) { + public static int getTossFlingVelocity(@NonNull final Context context) { return 2500; } @@ -240,7 +236,7 @@ public class PlayerHelper { return getPreferences(context).getBoolean(context.getString(R.string.use_old_player_key), b); } - private static boolean isRememberingPopupDimensions(@Nonnull final Context context, final boolean b) { + private static boolean isRememberingPopupDimensions(@NonNull final Context context, final boolean b) { return getPreferences(context).getBoolean(context.getString(R.string.popup_remember_size_pos_key), b); } diff --git a/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java index 855594503..26278ac75 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java @@ -5,11 +5,14 @@ import android.app.AlertDialog; import android.content.DialogInterface; import android.content.Intent; import android.os.Bundle; +import android.support.annotation.NonNull; import android.support.v7.preference.ListPreference; import android.support.v7.preference.Preference; import android.util.Log; import android.widget.Toast; +import com.nononsenseapps.filepicker.Utils; + import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.StreamingService; @@ -34,8 +37,6 @@ import java.util.zip.ZipFile; import java.util.zip.ZipInputStream; import java.util.zip.ZipOutputStream; -import javax.annotation.Nonnull; - public class ContentSettingsFragment extends BasePreferenceFragment { private static final int REQUEST_IMPORT_PATH = 8945; @@ -140,15 +141,15 @@ public class ContentSettingsFragment extends BasePreferenceFragment { } @Override - public void onActivityResult(int requestCode, int resultCode, @Nonnull Intent data) { + public void onActivityResult(int requestCode, int resultCode, @NonNull Intent data) { super.onActivityResult(requestCode, resultCode, data); if (DEBUG) { Log.d(TAG, "onActivityResult() called with: requestCode = [" + requestCode + "], resultCode = [" + resultCode + "], data = [" + data + "]"); } if ((requestCode == REQUEST_IMPORT_PATH || requestCode == REQUEST_EXPORT_PATH) - && resultCode == Activity.RESULT_OK) { - String path = data.getData().getPath(); + && resultCode == Activity.RESULT_OK && data.getData() != null) { + String path = Utils.getFileForUri(data.getData()).getAbsolutePath(); if (requestCode == REQUEST_EXPORT_PATH) { SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US); exportDatabase(path + "/NewPipeData-" + sdf.format(new Date()) + ".zip"); diff --git a/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java index 9a065d9d8..8214d7b4b 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java @@ -7,6 +7,8 @@ import android.support.annotation.Nullable; import android.support.v7.preference.Preference; import android.util.Log; +import com.nononsenseapps.filepicker.Utils; + import org.schabi.newpipe.R; import org.schabi.newpipe.util.FilePickerActivityHelper; @@ -69,9 +71,11 @@ public class DownloadSettingsFragment extends BasePreferenceFragment { Log.d(TAG, "onActivityResult() called with: requestCode = [" + requestCode + "], resultCode = [" + resultCode + "], data = [" + data + "]"); } - if ((requestCode == REQUEST_DOWNLOAD_PATH || requestCode == REQUEST_DOWNLOAD_AUDIO_PATH) && resultCode == Activity.RESULT_OK) { + if ((requestCode == REQUEST_DOWNLOAD_PATH || requestCode == REQUEST_DOWNLOAD_AUDIO_PATH) + && resultCode == Activity.RESULT_OK && data.getData() != null) { String key = getString(requestCode == REQUEST_DOWNLOAD_PATH ? R.string.download_path_key : R.string.download_path_audio_key); - String path = data.getData().getPath(); + String path = Utils.getFileForUri(data.getData()).getAbsolutePath(); + defaultPreferences.edit().putString(key, path).apply(); updatePreferencesSummary(); } diff --git a/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java b/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java index 97af11f1b..c0eadfaa8 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java @@ -3,10 +3,10 @@ package org.schabi.newpipe.settings; import android.app.Activity; import android.content.DialogInterface; import android.os.Bundle; +import android.support.annotation.NonNull; import android.support.v4.app.DialogFragment; import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.RecyclerView; -import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -18,9 +18,9 @@ import com.nostra13.universalimageloader.core.ImageLoader; import org.schabi.newpipe.R; import org.schabi.newpipe.database.subscription.SubscriptionEntity; -import org.schabi.newpipe.fragments.subscription.SubscriptionService; import org.schabi.newpipe.report.ErrorActivity; import org.schabi.newpipe.report.UserAction; +import org.schabi.newpipe.subscription.SubscriptionService; import java.util.List; import java.util.Vector; @@ -87,7 +87,7 @@ public class SelectChannelFragment extends DialogFragment { @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { View v = inflater.inflate(R.layout.select_channel_fragment, container, false); recyclerView = (RecyclerView) v.findViewById(R.id.items_list); recyclerView.setLayoutManager(new LinearLayoutManager(getContext())); @@ -101,7 +101,7 @@ public class SelectChannelFragment extends DialogFragment { emptyView.setVisibility(View.GONE); - subscriptionService = SubscriptionService.getInstance(); + subscriptionService = SubscriptionService.getInstance(getContext()); subscriptionService.getSubscription().toObservable() .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) diff --git a/app/src/main/java/org/schabi/newpipe/subscription/ImportExportEventListener.java b/app/src/main/java/org/schabi/newpipe/subscription/ImportExportEventListener.java new file mode 100644 index 000000000..7560a2265 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/subscription/ImportExportEventListener.java @@ -0,0 +1,17 @@ +package org.schabi.newpipe.subscription; + +public interface ImportExportEventListener { + /** + * Called when the size has been resolved. + * + * @param size how many items there are to import/export + */ + void onSizeReceived(int size); + + /** + * Called everytime an item has been parsed/resolved. + * + * @param itemName the name of the subscription item + */ + void onItemCompleted(String itemName); +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/subscription/ImportExportJsonHelper.java b/app/src/main/java/org/schabi/newpipe/subscription/ImportExportJsonHelper.java new file mode 100644 index 000000000..04f402438 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/subscription/ImportExportJsonHelper.java @@ -0,0 +1,138 @@ +/* + * Copyright 2018 Mauricio Colli + * ImportExportJsonHelper.java is part of NewPipe + * + * License: GPL-3.0+ + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.schabi.newpipe.subscription; + +import android.support.annotation.Nullable; + +import com.grack.nanojson.JsonAppendableWriter; +import com.grack.nanojson.JsonArray; +import com.grack.nanojson.JsonObject; +import com.grack.nanojson.JsonParser; +import com.grack.nanojson.JsonSink; +import com.grack.nanojson.JsonWriter; + +import org.schabi.newpipe.BuildConfig; +import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor.InvalidSourceException; +import org.schabi.newpipe.extractor.subscription.SubscriptionItem; + +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +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 { + + /*////////////////////////////////////////////////////////////////////////// + // Json implementation + //////////////////////////////////////////////////////////////////////////*/ + + private static final String JSON_APP_VERSION_KEY = "app_version"; + private static final String JSON_APP_VERSION_INT_KEY = "app_version_int"; + + private static final String JSON_SUBSCRIPTIONS_ARRAY_KEY = "subscriptions"; + + private static final String JSON_SERVICE_ID_KEY = "service_id"; + private static final String JSON_URL_KEY = "url"; + private static final String JSON_NAME_KEY = "name"; + + /** + * Read a JSON source through the input stream and return the parsed subscription items. + * + * @param in the input stream (e.g. a file) + * @param eventListener listener for the events generated + */ + public static List readFrom(InputStream in, @Nullable ImportExportEventListener eventListener) throws InvalidSourceException { + if (in == null) throw new InvalidSourceException("input is null"); + + final List 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()); + + if (channelsArray == null) { + throw new InvalidSourceException("Channels array is null"); + } + + for (Object o : channelsArray) { + if (o instanceof JsonObject) { + JsonObject itemObject = (JsonObject) o; + int serviceId = itemObject.getInt(JSON_SERVICE_ID_KEY, 0); + String url = itemObject.getString(JSON_URL_KEY); + String name = itemObject.getString(JSON_NAME_KEY); + + if (url != null && name != null && !url.isEmpty() && !name.isEmpty()) { + channels.add(new SubscriptionItem(serviceId, url, name)); + if (eventListener != null) eventListener.onItemCompleted(name); + } + } + } + } catch (Throwable e) { + throw new InvalidSourceException("Couldn't parse json", e); + } + + return channels; + } + + /** + * Write the subscriptions items list as JSON to the output. + * + * @param items the list of subscriptions items + * @param out the output stream (e.g. a file) + * @param eventListener listener for the events generated + */ + public static void writeTo(List items, OutputStream out, @Nullable ImportExportEventListener eventListener) { + JsonAppendableWriter writer = JsonWriter.on(out); + writeTo(items, writer, eventListener); + writer.done(); + } + + /** + * @see #writeTo(List, OutputStream, ImportExportEventListener) + */ + public static void writeTo(List items, JsonSink writer, @Nullable ImportExportEventListener eventListener) { + if (eventListener != null) eventListener.onSizeReceived(items.size()); + + writer.object(); + + writer.value(JSON_APP_VERSION_KEY, BuildConfig.VERSION_NAME); + writer.value(JSON_APP_VERSION_INT_KEY, BuildConfig.VERSION_CODE); + + writer.array(JSON_SUBSCRIPTIONS_ARRAY_KEY); + for (SubscriptionItem item : items) { + writer.object(); + writer.value(JSON_SERVICE_ID_KEY, item.getServiceId()); + writer.value(JSON_URL_KEY, item.getUrl()); + writer.value(JSON_NAME_KEY, item.getName()); + writer.end(); + + if (eventListener != null) eventListener.onItemCompleted(item.getName()); + } + writer.end(); + + writer.end(); + } + +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/subscription/SubscriptionService.java b/app/src/main/java/org/schabi/newpipe/subscription/SubscriptionService.java similarity index 82% rename from app/src/main/java/org/schabi/newpipe/fragments/subscription/SubscriptionService.java rename to app/src/main/java/org/schabi/newpipe/subscription/SubscriptionService.java index c183f5889..3220643b3 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/subscription/SubscriptionService.java +++ b/app/src/main/java/org/schabi/newpipe/subscription/SubscriptionService.java @@ -1,5 +1,7 @@ -package org.schabi.newpipe.fragments.subscription; +package org.schabi.newpipe.subscription; +import android.content.Context; +import android.support.annotation.NonNull; import android.util.Log; import org.schabi.newpipe.MainActivity; @@ -10,6 +12,7 @@ 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; @@ -20,7 +23,6 @@ import io.reactivex.CompletableSource; import io.reactivex.Flowable; import io.reactivex.Maybe; import io.reactivex.Scheduler; -import io.reactivex.annotations.NonNull; import io.reactivex.functions.Function; import io.reactivex.schedulers.Schedulers; @@ -32,10 +34,20 @@ import io.reactivex.schedulers.Schedulers; */ public class SubscriptionService { - private static final SubscriptionService sInstance = new SubscriptionService(); + private static volatile SubscriptionService instance; - public static SubscriptionService getInstance() { - return sInstance; + 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()); @@ -48,8 +60,8 @@ public class SubscriptionService { private Scheduler subscriptionScheduler; - private SubscriptionService() { - db = NewPipeDatabase.getInstance(); + private SubscriptionService(Context context) { + db = NewPipeDatabase.getInstance(context.getApplicationContext()); subscription = getSubscriptionInfos(); final Executor subscriptionExecutor = Executors.newFixedThreadPool(SUBSCRIPTION_THREAD_POOL_SIZE); @@ -114,7 +126,7 @@ public class SubscriptionService { if (!isSubscriptionUpToDate(info, subscription)) { subscription.setData(info.getName(), info.getAvatarUrl(), info.getDescription(), info.getSubscriberCount()); - return update(subscription); + return Completable.fromRunnable(() -> subscriptionTable().update(subscription)); } } @@ -127,13 +139,11 @@ public class SubscriptionService { .flatMapCompletable(update); } - private Completable update(final SubscriptionEntity updatedSubscription) { - return Completable.fromRunnable(new Runnable() { - @Override - public void run() { - subscriptionTable().update(updatedSubscription); - } - }); + public List upsertAll(final List infoList) { + final List entityList = new ArrayList<>(); + for (ChannelInfo info : infoList) entityList.add(SubscriptionEntity.from(info)); + + return subscriptionTable().upsertAll(entityList); } private boolean isSubscriptionUpToDate(final ChannelInfo info, final SubscriptionEntity entity) { diff --git a/app/src/main/java/org/schabi/newpipe/subscription/services/BaseImportExportService.java b/app/src/main/java/org/schabi/newpipe/subscription/services/BaseImportExportService.java new file mode 100644 index 000000000..a26b7a6d1 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/subscription/services/BaseImportExportService.java @@ -0,0 +1,227 @@ +/* + * Copyright 2018 Mauricio Colli + * BaseImportExportService.java is part of NewPipe + * + * License: GPL-3.0+ + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.schabi.newpipe.subscription.services; + +import android.app.Service; +import android.content.Intent; +import android.os.Build; +import android.os.IBinder; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.annotation.StringRes; +import android.support.v4.app.NotificationCompat; +import android.support.v4.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.report.ErrorActivity; +import org.schabi.newpipe.report.UserAction; +import org.schabi.newpipe.subscription.ImportExportEventListener; +import org.schabi.newpipe.subscription.SubscriptionService; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.Collections; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import io.reactivex.Flowable; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.functions.Function; +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 CompositeDisposable disposables = new CompositeDisposable(); + protected PublishProcessor notificationUpdater = PublishProcessor.create(); + + @Nullable + @Override + public IBinder onBind(Intent intent) { + return null; + } + + @Override + public void onCreate() { + super.onCreate(); + subscriptionService = SubscriptionService.getInstance(this); + setupNotification(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + disposeAll(); + } + + protected void disposeAll() { + disposables.clear(); + } + + /*////////////////////////////////////////////////////////////////////////// + // Notification Impl + //////////////////////////////////////////////////////////////////////////*/ + + private static final int NOTIFICATION_SAMPLING_PERIOD = 2500; + + protected AtomicInteger currentProgress = new AtomicInteger(-1); + protected AtomicInteger maxProgress = new AtomicInteger(-1); + protected 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(); + + protected void setupNotification() { + notificationManager = NotificationManagerCompat.from(this); + notificationBuilder = createNotification(); + startForeground(getNotificationId(), notificationBuilder.build()); + + final Function, Publisher> throttleAfterFirstEmission = flow -> flow.limit(1) + .concatWith(flow.skip(1).throttleLast(NOTIFICATION_SAMPLING_PERIOD, TimeUnit.MILLISECONDS)); + + disposables.add(notificationUpdater + .filter(s -> !s.isEmpty()) + .publish(throttleAfterFirstEmission) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(this::updateNotification)); + } + + protected void updateNotification(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 + ")"; + } else { + notificationBuilder.setContentInfo(progressText); + } + + if (!TextUtils.isEmpty(text)) notificationBuilder.setContentText(text); + notificationManager.notify(getNotificationId(), notificationBuilder.build()); + } + + protected void stopService() { + postErrorResult(null, null); + } + + protected void stopAndReportError(@Nullable Throwable error, 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); + } + + protected void postErrorResult(String title, String text) { + disposeAll(); + stopForeground(true); + stopSelf(); + + if (title == null) { + return; + } + + text = 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); + notificationManager.notify(getNotificationId(), notificationBuilder.build()); + } + + protected NotificationCompat.Builder createNotification() { + return new NotificationCompat.Builder(this, getString(R.string.notification_channel_id)) + .setOngoing(true) + .setProgress(-1, -1, true) + .setSmallIcon(R.drawable.ic_newpipe_triangle_white) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setContentTitle(getString(getTitle())); + } + + /*////////////////////////////////////////////////////////////////////////// + // Toast + //////////////////////////////////////////////////////////////////////////*/ + + protected Toast toast; + + protected void showToast(@StringRes int message) { + showToast(getString(message), Toast.LENGTH_SHORT); + } + + protected void showToast(String message, int duration) { + if (toast != null) toast.cancel(); + + toast = Toast.makeText(this, message, duration); + toast.show(); + } + + /*////////////////////////////////////////////////////////////////////////// + // Error handling + //////////////////////////////////////////////////////////////////////////*/ + + protected void handleError(@StringRes int errorTitle, @NonNull Throwable error) { + String message = getErrorMessage(error); + + if (TextUtils.isEmpty(message)) { + final String errorClassName = error.getClass().getName(); + message = getString(R.string.error_occurred_detail, errorClassName); + } + + showToast(errorTitle); + postErrorResult(getString(errorTitle), message); + } + + protected String getErrorMessage(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) { + message = getString(R.string.network_error); + } + return message; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/subscription/services/SubscriptionsExportService.java b/app/src/main/java/org/schabi/newpipe/subscription/services/SubscriptionsExportService.java new file mode 100644 index 000000000..069195c65 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/subscription/services/SubscriptionsExportService.java @@ -0,0 +1,153 @@ +/* + * Copyright 2018 Mauricio Colli + * SubscriptionsExportService.java is part of NewPipe + * + * License: GPL-3.0+ + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.schabi.newpipe.subscription.services; + +import android.content.Intent; +import android.support.v4.content.LocalBroadcastManager; +import android.text.TextUtils; +import android.util.Log; + +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.subscription.ImportExportJsonHelper; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.util.ArrayList; +import java.util.List; + +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.functions.Function; +import io.reactivex.schedulers.Schedulers; + +import static org.schabi.newpipe.MainActivity.DEBUG; + +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. + */ + public static final String EXPORT_COMPLETE_ACTION = "org.schabi.newpipe.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; + + 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"); + return START_NOT_STICKY; + } + + try { + outputStream = new FileOutputStream(outFile = new File(path)); + } catch (FileNotFoundException e) { + handleError(e); + return START_NOT_STICKY; + } + + startExport(); + + return START_NOT_STICKY; + } + + @Override + protected int getNotificationId() { + return 4567; + } + + @Override + public int getTitle() { + return R.string.export_ongoing; + } + + @Override + protected void disposeAll() { + super.disposeAll(); + if (subscription != null) subscription.cancel(); + } + + private void startExport() { + showToast(R.string.export_ongoing); + + subscriptionService.subscriptionTable() + .getAll() + .take(1) + .map(subscriptionEntities -> { + final List result = new ArrayList<>(subscriptionEntities.size()); + for (SubscriptionEntity entity : subscriptionEntities) { + result.add(new SubscriptionItem(entity.getServiceId(), entity.getUrl(), entity.getName())); + } + return result; + }) + .map(exportToFile()) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(getSubscriber()); + } + + private Subscriber getSubscriber() { + return new Subscriber() { + @Override + public void onSubscribe(Subscription s) { + subscription = s; + s.request(1); + } + + @Override + public void onNext(File file) { + if (DEBUG) Log.d(TAG, "startExport() success: file = " + file); + } + + @Override + public void onError(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)); + showToast(R.string.export_complete_toast); + stopService(); + } + }; + } + + private Function, File> exportToFile() { + return subscriptionItems -> { + ImportExportJsonHelper.writeTo(subscriptionItems, outputStream, eventListener); + return outFile; + }; + } + + protected void handleError(Throwable error) { + super.handleError(R.string.subscriptions_export_unsuccessful, error); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/subscription/services/SubscriptionsImportService.java b/app/src/main/java/org/schabi/newpipe/subscription/services/SubscriptionsImportService.java new file mode 100644 index 000000000..259b1c2bd --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/subscription/services/SubscriptionsImportService.java @@ -0,0 +1,264 @@ +/* + * Copyright 2018 Mauricio Colli + * SubscriptionsImportService.java is part of NewPipe + * + * License: GPL-3.0+ + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.schabi.newpipe.subscription.services; + +import android.content.Intent; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.content.LocalBroadcastManager; +import android.text.TextUtils; +import android.util.Log; + +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.NewPipe; +import org.schabi.newpipe.extractor.channel.ChannelInfo; +import org.schabi.newpipe.extractor.subscription.SubscriptionItem; +import org.schabi.newpipe.subscription.ImportExportJsonHelper; +import org.schabi.newpipe.util.Constants; +import org.schabi.newpipe.util.ExtractorHelper; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +import io.reactivex.Flowable; +import io.reactivex.Notification; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.functions.Consumer; +import io.reactivex.functions.Function; +import io.reactivex.schedulers.Schedulers; + +import static org.schabi.newpipe.MainActivity.DEBUG; + +public class SubscriptionsImportService extends BaseImportExportService { + public static final int CHANNEL_URL_MODE = 0; + public static final int INPUT_STREAM_MODE = 1; + public static final int PREVIOUS_EXPORT_MODE = 2; + public static final String KEY_MODE = "key_mode"; + 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. + */ + public static final String IMPORT_COMPLETE_ACTION = "org.schabi.newpipe.subscription.services.SubscriptionsImportService.IMPORT_COMPLETE"; + + 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; + + currentMode = intent.getIntExtra(KEY_MODE, -1); + currentServiceId = intent.getIntExtra(Constants.KEY_SERVICE_ID, Constants.NO_SERVICE_ID); + + if (currentMode == CHANNEL_URL_MODE) { + channelUrl = intent.getStringExtra(KEY_VALUE); + } 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"); + return START_NOT_STICKY; + } + + try { + inputStream = new FileInputStream(new File(filePath)); + } catch (FileNotFoundException e) { + handleError(e); + return START_NOT_STICKY; + } + } + + 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"); + return START_NOT_STICKY; + } + + startImport(); + return START_NOT_STICKY; + } + + @Override + protected int getNotificationId() { + return 4568; + } + + @Override + public int getTitle() { + return R.string.import_ongoing; + } + + @Override + protected void disposeAll() { + super.disposeAll(); + 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); + + Flowable> flowable = null; + if (currentMode == CHANNEL_URL_MODE) { + flowable = importFromChannelUrl(); + } else if (currentMode == INPUT_STREAM_MODE) { + flowable = importFromInputStream(); + } else if (currentMode == PREVIOUS_EXPORT_MODE) { + flowable = importFromPreviousExport(); + } + + if (flowable == null) { + 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())) + .flatMap(Flowable::fromIterable) + + .parallel(PARALLEL_EXTRACTIONS) + .runOn(Schedulers.io()) + .map((Function>) subscriptionItem -> { + try { + return Notification.createOnNext(ExtractorHelper + .getChannelInfo(subscriptionItem.getServiceId(), subscriptionItem.getUrl(), true) + .blockingGet()); + } catch (Throwable e) { + return Notification.createOnError(e); + } + }) + .sequential() + + .observeOn(Schedulers.io()) + .doOnNext(getNotificationsConsumer()) + .buffer(BUFFER_COUNT_BEFORE_INSERT) + .map(upsertBatch()) + + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(getSubscriber()); + } + + private Subscriber> getSubscriber() { + return new Subscriber>() { + + @Override + public void onSubscribe(Subscription s) { + subscription = s; + s.request(Long.MAX_VALUE); + } + + @Override + public void onNext(List successfulInserted) { + if (DEBUG) Log.d(TAG, "startImport() " + successfulInserted.size() + " items successfully inserted into the database"); + } + + @Override + public void onError(Throwable error) { + handleError(error); + } + + @Override + public void onComplete() { + LocalBroadcastManager.getInstance(SubscriptionsImportService.this).sendBroadcast(new Intent(IMPORT_COMPLETE_ACTION)); + showToast(R.string.import_complete_toast); + stopService(); + } + }; + } + + private Consumer> getNotificationsConsumer() { + return notification -> { + if (notification.isOnNext()) { + String name = notification.getValue().getName(); + eventListener.onItemCompleted(!TextUtils.isEmpty(name) ? name : ""); + } else if (notification.isOnError()) { + final Throwable error = notification.getError(); + final Throwable cause = error.getCause(); + if (error instanceof IOException) { + throw (IOException) error; + } else if (cause != null && cause instanceof IOException) { + throw (IOException) cause; + } + + eventListener.onItemCompleted(""); + } + }; + } + + private Function>, List> upsertBatch() { + return notificationList -> { + final List infoList = new ArrayList<>(notificationList.size()); + for (Notification n : notificationList) { + if (n.isOnNext()) infoList.add(n.getValue()); + } + + return subscriptionService.upsertAll(infoList); + }; + } + + private Flowable> importFromChannelUrl() { + return Flowable.fromCallable(() -> NewPipe.getService(currentServiceId) + .getSubscriptionExtractor() + .fromChannelUrl(channelUrl)); + } + + private Flowable> importFromInputStream() { + return Flowable.fromCallable(() -> NewPipe.getService(currentServiceId) + .getSubscriptionExtractor() + .fromInputStream(inputStream)); + } + + private Flowable> importFromPreviousExport() { + return Flowable.fromCallable(() -> ImportExportJsonHelper.readFrom(inputStream, null)); + } + + protected void handleError(@NonNull Throwable error) { + super.handleError(R.string.subscriptions_import_unsuccessful, error); + } +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/util/AnimationUtils.java b/app/src/main/java/org/schabi/newpipe/util/AnimationUtils.java index c954211fa..3c5f16929 100644 --- a/app/src/main/java/org/schabi/newpipe/util/AnimationUtils.java +++ b/app/src/main/java/org/schabi/newpipe/util/AnimationUtils.java @@ -1,3 +1,22 @@ +/* + * Copyright 2018 Mauricio Colli + * AnimationUtils.java is part of NewPipe + * + * License: GPL-3.0+ + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + package org.schabi.newpipe.util; import android.animation.Animator; @@ -19,7 +38,9 @@ public class AnimationUtils { private static final boolean DEBUG = MainActivity.DEBUG; public enum Type { - ALPHA, SCALE_AND_ALPHA, LIGHT_SCALE_AND_ALPHA, SLIDE_AND_ALPHA, LIGHT_SLIDE_AND_ALPHA + ALPHA, + SCALE_AND_ALPHA, LIGHT_SCALE_AND_ALPHA, + SLIDE_AND_ALPHA, LIGHT_SLIDE_AND_ALPHA } public static void animateView(View view, boolean enterOrExit, long duration) { @@ -168,6 +189,58 @@ public class AnimationUtils { viewPropertyAnimator.start(); } + public static ValueAnimator animateHeight(final View view, long duration, int targetHeight) { + final int height = view.getHeight(); + if (DEBUG) { + Log.d(TAG, "animateHeight: duration = [" + duration + "], from " + height + " to → " + targetHeight + " in: " + view); + } + + ValueAnimator animator = ValueAnimator.ofFloat(height, targetHeight); + animator.setInterpolator(new FastOutSlowInInterpolator()); + animator.setDuration(duration); + animator.addUpdateListener(animation -> { + final float value = (float) animation.getAnimatedValue(); + view.getLayoutParams().height = (int) value; + view.requestLayout(); + }); + animator.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + view.getLayoutParams().height = targetHeight; + view.requestLayout(); + } + + @Override + public void onAnimationCancel(Animator animation) { + view.getLayoutParams().height = targetHeight; + view.requestLayout(); + } + }); + animator.start(); + + return animator; + } + + public static void animateRotation(final View view, long duration, int targetRotation) { + if (DEBUG) { + Log.d(TAG, "animateRotation: duration = [" + duration + "], from " + view.getRotation() + " to → " + targetRotation + " in: " + view); + } + view.animate().setListener(null).cancel(); + + view.animate().rotation(targetRotation).setDuration(duration).setInterpolator(new FastOutSlowInInterpolator()) + .setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationCancel(Animator animation) { + view.setRotation(targetRotation); + } + + @Override + public void onAnimationEnd(Animator animation) { + view.setRotation(targetRotation); + } + }).start(); + } + /*////////////////////////////////////////////////////////////////////////// // Internals //////////////////////////////////////////////////////////////////////////*/ diff --git a/app/src/main/java/org/schabi/newpipe/util/FilePickerActivityHelper.java b/app/src/main/java/org/schabi/newpipe/util/FilePickerActivityHelper.java index 5f588c5ca..20554ce59 100644 --- a/app/src/main/java/org/schabi/newpipe/util/FilePickerActivityHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/FilePickerActivityHelper.java @@ -1,10 +1,32 @@ package org.schabi.newpipe.util; +import android.content.Context; +import android.content.Intent; import android.os.Bundle; +import android.os.Environment; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.content.Loader; +import android.support.v7.util.SortedList; +import android.support.v7.widget.RecyclerView; +import android.util.TypedValue; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; +import android.widget.Toast; + +import com.nononsenseapps.filepicker.AbstractFilePickerFragment; +import com.nononsenseapps.filepicker.FilePickerFragment; + import org.schabi.newpipe.R; +import java.io.File; + public class FilePickerActivityHelper extends com.nononsenseapps.filepicker.FilePickerActivity { + private CustomFilePickerFragment currentFragment; + @Override public void onCreate(Bundle savedInstanceState) { if(ThemeHelper.isLightThemeSelected(this)) { @@ -14,4 +36,98 @@ public class FilePickerActivityHelper extends com.nononsenseapps.filepicker.File } super.onCreate(savedInstanceState); } + + @Override + public void onBackPressed() { + // If at top most level, normal behaviour + if (currentFragment.isBackTop()) { + super.onBackPressed(); + } else { + // Else go up + currentFragment.goUp(); + } + } + + @Override + protected AbstractFilePickerFragment getFragment(@Nullable String startPath, int mode, boolean allowMultiple, boolean allowCreateDir, boolean allowExistingFile, boolean singleClick) { + final CustomFilePickerFragment fragment = new CustomFilePickerFragment(); + fragment.setArgs(startPath != null ? startPath : Environment.getExternalStorageDirectory().getPath(), + mode, allowMultiple, allowCreateDir, allowExistingFile, singleClick); + return currentFragment = fragment; + } + + public static Intent chooseSingleFile(@NonNull Context context) { + return new Intent(context, FilePickerActivityHelper.class) + .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_MULTIPLE, false) + .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_CREATE_DIR, false) + .putExtra(FilePickerActivityHelper.EXTRA_SINGLE_CLICK, true) + .putExtra(FilePickerActivityHelper.EXTRA_MODE, FilePickerActivityHelper.MODE_FILE); + } + + public static Intent chooseFileToSave(@NonNull Context context, @Nullable String startPath) { + return new Intent(context, FilePickerActivityHelper.class) + .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_MULTIPLE, false) + .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_CREATE_DIR, true) + .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_EXISTING_FILE, true) + .putExtra(FilePickerActivityHelper.EXTRA_START_PATH, startPath) + .putExtra(FilePickerActivityHelper.EXTRA_MODE, FilePickerActivityHelper.MODE_NEW_FILE); + } + + /*////////////////////////////////////////////////////////////////////////// + // Internal + //////////////////////////////////////////////////////////////////////////*/ + + public static class CustomFilePickerFragment extends FilePickerFragment { + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + return super.onCreateView(inflater, container, savedInstanceState); + } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + final RecyclerView.ViewHolder viewHolder = super.onCreateViewHolder(parent, viewType); + + final View view = viewHolder.itemView.findViewById(android.R.id.text1); + if (view instanceof TextView) { + ((TextView) view).setTextSize(TypedValue.COMPLEX_UNIT_PX, getResources().getDimension(R.dimen.file_picker_items_text_size)); + } + + return viewHolder; + } + + @Override + public void onClickOk(@NonNull View view) { + if (mode == MODE_NEW_FILE && getNewFileName().isEmpty()) { + if (mToast != null) mToast.cancel(); + mToast = Toast.makeText(getActivity(), R.string.file_name_empty_error, Toast.LENGTH_SHORT); + mToast.show(); + return; + } + + super.onClickOk(view); + } + + public File getBackTop() { + if (getArguments() == null) return Environment.getExternalStorageDirectory(); + + final String path = getArguments().getString(KEY_START_PATH, "/"); + if (path.contains(Environment.getExternalStorageDirectory().getPath())) { + return Environment.getExternalStorageDirectory(); + } + + return getPath(path); + } + + public boolean isBackTop() { + return compareFiles(mCurrentPath, getBackTop()) == 0 || compareFiles(mCurrentPath, new File("/")) == 0; + } + + @Override + public void onLoadFinished(Loader> loader, SortedList data) { + super.onLoadFinished(loader, data); + layoutManager.scrollToPosition(0); + } + } } diff --git a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java index ee94ac81f..26088a64c 100644 --- a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java @@ -1,5 +1,6 @@ package org.schabi.newpipe.util; +import android.annotation.SuppressLint; import android.app.Activity; import android.content.ActivityNotFoundException; import android.content.Context; @@ -11,6 +12,7 @@ import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.v4.app.Fragment; import android.support.v4.app.FragmentManager; +import android.support.v4.app.FragmentTransaction; import android.support.v7.app.AlertDialog; import android.util.Log; import android.widget.Toast; @@ -38,6 +40,7 @@ import org.schabi.newpipe.fragments.list.search.SearchFragment; import org.schabi.newpipe.fragments.local.bookmark.LastPlayedFragment; import org.schabi.newpipe.fragments.local.bookmark.LocalPlaylistFragment; import org.schabi.newpipe.fragments.local.bookmark.MostPlayedFragment; +import org.schabi.newpipe.fragments.subscription.SubscriptionsImportFragment; import org.schabi.newpipe.history.HistoryActivity; import org.schabi.newpipe.player.BackgroundPlayer; import org.schabi.newpipe.player.BackgroundPlayerActivity; @@ -247,6 +250,12 @@ public class NavigationHelper { // Through FragmentManager //////////////////////////////////////////////////////////////////////////*/ + @SuppressLint("CommitTransaction") + private static FragmentTransaction defaultTransaction(FragmentManager fragmentManager) { + return fragmentManager.beginTransaction() + .setCustomAnimations(R.animator.custom_fade_in, R.animator.custom_fade_out, R.animator.custom_fade_in, R.animator.custom_fade_out); + } + public static void gotoMainFragment(FragmentManager fragmentManager) { ImageLoader.getInstance().clearMemoryCache(); @@ -258,8 +267,7 @@ public class NavigationHelper { InfoCache.getInstance().trimCache(); fragmentManager.popBackStackImmediate(null, FragmentManager.POP_BACK_STACK_INCLUSIVE); - fragmentManager.beginTransaction() - .setCustomAnimations(R.animator.custom_fade_in, R.animator.custom_fade_out, R.animator.custom_fade_in, R.animator.custom_fade_out) + defaultTransaction(fragmentManager) .replace(R.id.fragment_holder, new MainFragment()) .addToBackStack(MAIN_FRAGMENT_TAG) .commit(); @@ -276,8 +284,7 @@ public class NavigationHelper { } public static void openSearchFragment(FragmentManager fragmentManager, int serviceId, String query) { - fragmentManager.beginTransaction() - .setCustomAnimations(R.animator.custom_fade_in, R.animator.custom_fade_out, R.animator.custom_fade_in, R.animator.custom_fade_out) + defaultTransaction(fragmentManager) .replace(R.id.fragment_holder, SearchFragment.getInstance(serviceId, query)) .addToBackStack(SEARCH_FRAGMENT_TAG) .commit(); @@ -301,8 +308,7 @@ public class NavigationHelper { VideoDetailFragment instance = VideoDetailFragment.getInstance(serviceId, url, title); instance.setAutoplay(autoPlay); - fragmentManager.beginTransaction() - .setCustomAnimations(R.animator.custom_fade_in, R.animator.custom_fade_out, R.animator.custom_fade_in, R.animator.custom_fade_out) + defaultTransaction(fragmentManager) .replace(R.id.fragment_holder, instance) .addToBackStack(null) .commit(); @@ -310,8 +316,7 @@ public class NavigationHelper { public static void openChannelFragment(FragmentManager fragmentManager, int serviceId, String url, String name) { if (name == null) name = ""; - fragmentManager.beginTransaction() - .setCustomAnimations(R.animator.custom_fade_in, R.animator.custom_fade_out, R.animator.custom_fade_in, R.animator.custom_fade_out) + defaultTransaction(fragmentManager) .replace(R.id.fragment_holder, ChannelFragment.getInstance(serviceId, url, name)) .addToBackStack(null) .commit(); @@ -319,25 +324,21 @@ public class NavigationHelper { public static void openPlaylistFragment(FragmentManager fragmentManager, int serviceId, String url, String name) { if (name == null) name = ""; - fragmentManager.beginTransaction() - .setCustomAnimations(R.animator.custom_fade_in, R.animator.custom_fade_out, R.animator.custom_fade_in, R.animator.custom_fade_out) + defaultTransaction(fragmentManager) .replace(R.id.fragment_holder, PlaylistFragment.getInstance(serviceId, url, name)) .addToBackStack(null) .commit(); } public static void openWhatsNewFragment(FragmentManager fragmentManager) { - fragmentManager.beginTransaction() - .setCustomAnimations(R.animator.custom_fade_in, R.animator.custom_fade_out, R.animator.custom_fade_in, R.animator.custom_fade_out) + defaultTransaction(fragmentManager) .replace(R.id.fragment_holder, new FeedFragment()) .addToBackStack(null) .commit(); } - public static void openKioskFragment(FragmentManager fragmentManager, int serviceId, String kioskId) - throws ExtractionException { - fragmentManager.beginTransaction() - .setCustomAnimations(R.animator.custom_fade_in, R.animator.custom_fade_out, R.animator.custom_fade_in, R.animator.custom_fade_out) + public static void openKioskFragment(FragmentManager fragmentManager, int serviceId, String kioskId) throws ExtractionException { + defaultTransaction(fragmentManager) .replace(R.id.fragment_holder, KioskFragment.getInstance(serviceId, kioskId)) .addToBackStack(null) .commit(); @@ -345,28 +346,33 @@ public class NavigationHelper { public static void openLocalPlaylistFragment(FragmentManager fragmentManager, long playlistId, String name) { if (name == null) name = ""; - fragmentManager.beginTransaction() - .setCustomAnimations(R.animator.custom_fade_in, R.animator.custom_fade_out, R.animator.custom_fade_in, R.animator.custom_fade_out) + defaultTransaction(fragmentManager) .replace(R.id.fragment_holder, LocalPlaylistFragment.getInstance(playlistId, name)) .addToBackStack(null) .commit(); } public static void openLastPlayedFragment(FragmentManager fragmentManager) { - fragmentManager.beginTransaction() - .setCustomAnimations(R.animator.custom_fade_in, R.animator.custom_fade_out, R.animator.custom_fade_in, R.animator.custom_fade_out) + defaultTransaction(fragmentManager) .replace(R.id.fragment_holder, new LastPlayedFragment()) .addToBackStack(null) .commit(); } public static void openMostPlayedFragment(FragmentManager fragmentManager) { - fragmentManager.beginTransaction() - .setCustomAnimations(R.animator.custom_fade_in, R.animator.custom_fade_out, R.animator.custom_fade_in, R.animator.custom_fade_out) + defaultTransaction(fragmentManager) .replace(R.id.fragment_holder, new MostPlayedFragment()) .addToBackStack(null) .commit(); } + + public static void openSubscriptionsImportFragment(FragmentManager fragmentManager, int serviceId) { + defaultTransaction(fragmentManager) + .replace(R.id.fragment_holder, SubscriptionsImportFragment.getInstance(serviceId)) + .addToBackStack(null) + .commit(); + } + /*////////////////////////////////////////////////////////////////////////// // Through Intents //////////////////////////////////////////////////////////////////////////*/ diff --git a/app/src/main/java/org/schabi/newpipe/util/PopupMenuIconHacker.java b/app/src/main/java/org/schabi/newpipe/util/PopupMenuIconHacker.java deleted file mode 100644 index 70affb900..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/PopupMenuIconHacker.java +++ /dev/null @@ -1,48 +0,0 @@ -package org.schabi.newpipe.util; - -import android.widget.PopupMenu; - -import java.lang.reflect.Field; -import java.lang.reflect.Method; - -/** - * Created by Christian Schabesberger on 20.01.18. - * Copyright 2018 Christian Schabesberger - * PopupMenuIconHacker.java 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 . - */ - -public class PopupMenuIconHacker { - public static void setShowPopupIcon(PopupMenu menu) throws Exception { - try { - Field[] fields = menu.getClass().getDeclaredFields(); - for (Field field : fields) { - if ("mPopup".equals(field.getName())) { - field.setAccessible(true); - Object menuPopupHelper = field.get(menu); - Class classPopupHelper = Class.forName(menuPopupHelper - .getClass().getName()); - Method setForceIcons = classPopupHelper.getMethod( - "setForceShowIcon", boolean.class); - setForceIcons.invoke(menuPopupHelper, true); - break; - } - } - } catch (Exception e) { - throw new Exception("Could not make Popup menu show Icons", e); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/ServiceHelper.java b/app/src/main/java/org/schabi/newpipe/util/ServiceHelper.java index 55c6e68f2..7d71750eb 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ServiceHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ServiceHelper.java @@ -3,6 +3,7 @@ package org.schabi.newpipe.util; import android.content.Context; import android.preference.PreferenceManager; import android.support.annotation.DrawableRes; +import android.support.annotation.StringRes; import org.schabi.newpipe.BuildConfig; import org.schabi.newpipe.R; @@ -26,6 +27,39 @@ public class ServiceHelper { } } + /** + * Get a resource string with instructions for importing subscriptions for each service. + * + * @return the string resource containing the instructions or -1 if the service don't support it + */ + @StringRes + public static int getImportInstructions(int serviceId) { + switch (serviceId) { + case 0: + return R.string.import_youtube_instructions; + case 1: + return R.string.import_soundcloud_instructions; + default: + return -1; + } + } + + /** + * For services that support importing from a channel url, return a hint that will + * be used in the EditText that the user will type in his channel url. + * + * @return the hint's string resource or -1 if the service don't support it + */ + @StringRes + public static int getImportInstructionsHint(int serviceId) { + switch (serviceId) { + case 1: + return R.string.import_soundcloud_instructions_hint; + default: + return -1; + } + } + public static int getSelectedServiceId(Context context) { if (BuildConfig.BUILD_TYPE.equals("release")) return DEFAULT_FALLBACK_SERVICE.getServiceId(); diff --git a/app/src/main/java/org/schabi/newpipe/util/ThemeHelper.java b/app/src/main/java/org/schabi/newpipe/util/ThemeHelper.java index 824ac4a9d..1edc4dfec 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ThemeHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ThemeHelper.java @@ -1,3 +1,22 @@ +/* + * Copyright 2018 Mauricio Colli + * ThemeHelper.java is part of NewPipe + * + * License: GPL-3.0+ + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + package org.schabi.newpipe.util; import android.content.Context; @@ -5,6 +24,9 @@ import android.content.res.TypedArray; import android.preference.PreferenceManager; import android.support.annotation.AttrRes; import android.support.annotation.StyleRes; +import android.support.v4.content.ContextCompat; +import android.util.TypedValue; +import android.view.ContextThemeWrapper; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.NewPipe; @@ -41,16 +63,57 @@ public class ThemeHelper { * @param context context to get the preference */ public static boolean isLightThemeSelected(Context context) { - return getSelectedTheme(context).equals(context.getResources().getString(R.string.light_theme_key)); + return getSelectedThemeString(context).equals(context.getResources().getString(R.string.light_theme_key)); } + + /** + * Create and return a wrapped context with the default selected theme set. + * + * @param baseContext the base context for the wrapper + * @return a wrapped-styled context + */ + public static Context getThemedContext(Context baseContext) { + return new ContextThemeWrapper(baseContext, getThemeForService(baseContext, -1)); + } + + /** + * Return the selected theme without being styled to any service (see {@link #getThemeForService(Context, int)}). + * + * @param context context to get the selected theme + * @return the selected style (the default one) + */ + @StyleRes + public static int getDefaultTheme(Context context) { + return getThemeForService(context, -1); + } + + /** + * Return a dialog theme styled according to the (default) selected theme. + * + * @param context context to get the selected theme + * @return the dialog style (the default one) + */ + @StyleRes + public static int getDialogTheme(Context context) { + return isLightThemeSelected(context) ? R.style.LightDialogTheme : R.style.DarkDialogTheme; + } + + /** + * Return the selected theme styled according to the serviceId. + * + * @param context context to get the selected theme + * @param serviceId return a theme styled to this service, + * -1 to get the default + * @return the selected style (styled) + */ @StyleRes public static int getThemeForService(Context context, int serviceId) { String lightTheme = context.getResources().getString(R.string.light_theme_key); String darkTheme = context.getResources().getString(R.string.dark_theme_key); String blackTheme = context.getResources().getString(R.string.black_theme_key); - String selectedTheme = getSelectedTheme(context); + String selectedTheme = getSelectedThemeString(context); int defaultTheme = R.style.DarkTheme; if (selectedTheme.equals(lightTheme)) defaultTheme = R.style.LightTheme; @@ -83,19 +146,13 @@ public class ThemeHelper { return defaultTheme; } - public static String getSelectedTheme(Context context) { - String themeKey = context.getString(R.string.theme_key); - String defaultTheme = context.getResources().getString(R.string.default_theme_value); - return PreferenceManager.getDefaultSharedPreferences(context).getString(themeKey, defaultTheme); - } - @StyleRes public static int getSettingsThemeStyle(Context context) { String lightTheme = context.getResources().getString(R.string.light_theme_key); String darkTheme = context.getResources().getString(R.string.dark_theme_key); String blackTheme = context.getResources().getString(R.string.black_theme_key); - String selectedTheme = getSelectedTheme(context); + String selectedTheme = getSelectedThemeString(context); if (selectedTheme.equals(lightTheme)) return R.style.LightSettingsTheme; else if (selectedTheme.equals(blackTheme)) return R.style.BlackSettingsTheme; @@ -113,4 +170,24 @@ public class ThemeHelper { a.recycle(); return attributeResourceId; } + + /** + * Get a color from an attr styled according to the the context's theme. + */ + public static int resolveColorFromAttr(Context context, @AttrRes int attrColor) { + final TypedValue value = new TypedValue(); + context.getTheme().resolveAttribute(attrColor, value, true); + + if (value.resourceId != 0) { + return ContextCompat.getColor(context, value.resourceId); + } + + return value.data; + } + + private static String getSelectedThemeString(Context context) { + String themeKey = context.getString(R.string.theme_key); + String defaultTheme = context.getResources().getString(R.string.default_theme_value); + return PreferenceManager.getDefaultSharedPreferences(context).getString(themeKey, defaultTheme); + } } diff --git a/app/src/main/java/org/schabi/newpipe/views/CollapsibleView.java b/app/src/main/java/org/schabi/newpipe/views/CollapsibleView.java new file mode 100644 index 000000000..adef7e76f --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/views/CollapsibleView.java @@ -0,0 +1,230 @@ +/* + * Copyright 2018 Mauricio Colli + * CollapsibleView.java is part of NewPipe + * + * License: GPL-3.0+ + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.schabi.newpipe.views; + +import android.animation.ValueAnimator; +import android.content.Context; +import android.os.Build; +import android.os.Parcelable; +import android.support.annotation.IntDef; +import android.support.annotation.Nullable; +import android.support.annotation.RequiresApi; +import android.util.AttributeSet; +import android.util.Log; +import android.widget.LinearLayout; + +import org.schabi.newpipe.util.AnimationUtils; + +import java.lang.annotation.Retention; +import java.util.ArrayList; +import java.util.List; + +import icepick.Icepick; +import icepick.State; + +import static java.lang.annotation.RetentionPolicy.SOURCE; +import static org.schabi.newpipe.MainActivity.DEBUG; + +/** + * A view that can be fully collapsed and expanded. + */ +public class CollapsibleView extends LinearLayout { + private static final String TAG = CollapsibleView.class.getSimpleName(); + + public CollapsibleView(Context context) { + super(context); + } + + public CollapsibleView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + public CollapsibleView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + public CollapsibleView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + /*////////////////////////////////////////////////////////////////////////// + // Collapse/expand logic + //////////////////////////////////////////////////////////////////////////*/ + + private static final int ANIMATION_DURATION = 420; + public static final int COLLAPSED = 0, EXPANDED = 1; + + @Retention(SOURCE) + @IntDef({COLLAPSED, EXPANDED}) + public @interface ViewMode {} + + @State @ViewMode int currentState = COLLAPSED; + private boolean readyToChangeState; + + private int targetHeight = -1; + private ValueAnimator currentAnimator; + private List listeners = new ArrayList<>(); + + /** + * This method recalculates the height of this view so it must be called when + * some child changes (e.g. add new views, change text). + */ + public void ready() { + if (DEBUG) { + Log.d(TAG, getDebugLogString("ready() called")); + } + + measure(MeasureSpec.makeMeasureSpec(getWidth(), MeasureSpec.AT_MOST), MeasureSpec.UNSPECIFIED); + targetHeight = getMeasuredHeight(); + + getLayoutParams().height = currentState == COLLAPSED ? 0 : targetHeight; + requestLayout(); + broadcastState(); + + readyToChangeState = true; + + if (DEBUG) { + Log.d(TAG, getDebugLogString("ready() *after* measuring")); + } + } + + public void collapse() { + if (DEBUG) { + Log.d(TAG, getDebugLogString("collapse() called")); + } + + if (!readyToChangeState) return; + + final int height = getHeight(); + if (height == 0) { + setCurrentState(COLLAPSED); + return; + } + + if (currentAnimator != null && currentAnimator.isRunning()) currentAnimator.cancel(); + currentAnimator = AnimationUtils.animateHeight(this, ANIMATION_DURATION, 0); + + setCurrentState(COLLAPSED); + } + + public void expand() { + if (DEBUG) { + Log.d(TAG, getDebugLogString("expand() called")); + } + + if (!readyToChangeState) return; + + final int height = getHeight(); + if (height == this.targetHeight) { + setCurrentState(EXPANDED); + return; + } + + if (currentAnimator != null && currentAnimator.isRunning()) currentAnimator.cancel(); + currentAnimator = AnimationUtils.animateHeight(this, ANIMATION_DURATION, this.targetHeight); + setCurrentState(EXPANDED); + } + + public void switchState() { + if (!readyToChangeState) return; + + if (currentState == COLLAPSED) { + expand(); + } else { + collapse(); + } + } + + @ViewMode + public int getCurrentState() { + return currentState; + } + + public void setCurrentState(@ViewMode int currentState) { + this.currentState = currentState; + broadcastState(); + } + + public void broadcastState() { + for (StateListener listener : listeners) { + listener.onStateChanged(currentState); + } + } + + /** + * Add a listener which will be listening for changes in this view (i.e. collapsed or expanded). + */ + public void addListener(final StateListener listener) { + if (listeners.contains(listener)) { + throw new IllegalStateException("Trying to add the same listener multiple times"); + } + + listeners.add(listener); + } + + /** + * Remove a listener so it doesn't receive more state changes. + */ + public void removeListener(final StateListener listener) { + listeners.remove(listener); + } + + /** + * Simple interface used for listening state changes of the {@link CollapsibleView}. + */ + public interface StateListener { + /** + * Called when the state changes. + * + * @param newState the state that the {@link CollapsibleView} transitioned to,
+ * it's an integer being either {@link #COLLAPSED} or {@link #EXPANDED} + */ + void onStateChanged(@ViewMode int newState); + } + + /*////////////////////////////////////////////////////////////////////////// + // State Saving + //////////////////////////////////////////////////////////////////////////*/ + + @Nullable + @Override + public Parcelable onSaveInstanceState() { + return Icepick.saveInstanceState(this, super.onSaveInstanceState()); + } + + @Override + public void onRestoreInstanceState(Parcelable state) { + super.onRestoreInstanceState(Icepick.restoreInstanceState(this, state)); + + ready(); + } + + /*////////////////////////////////////////////////////////////////////////// + // Internal + //////////////////////////////////////////////////////////////////////////*/ + + public String getDebugLogString(String description) { + return String.format("%-100s → %s", + description, "readyToChangeState = [" + readyToChangeState + "], currentState = [" + currentState + "], targetHeight = [" + targetHeight + "]," + + " mW x mH = [" + getMeasuredWidth() + "x" + getMeasuredHeight() + "]" + + " W x H = [" + getWidth() + "x" + getHeight() + "]"); + } +} diff --git a/app/src/main/res/drawable-hdpi/ic_backup_black_24dp.png b/app/src/main/res/drawable-hdpi/ic_backup_black_24dp.png new file mode 100644 index 000000000..e0938f1dc Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_backup_black_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_backup_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_backup_white_24dp.png new file mode 100644 index 000000000..5e0b464cf Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_backup_white_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_import_export_black_24dp.png b/app/src/main/res/drawable-hdpi/ic_import_export_black_24dp.png new file mode 100644 index 000000000..b4466c849 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_import_export_black_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_import_export_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_import_export_white_24dp.png new file mode 100644 index 000000000..5b6c02010 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_import_export_white_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_save_black_24dp.png b/app/src/main/res/drawable-hdpi/ic_save_black_24dp.png new file mode 100644 index 000000000..b959dc4a8 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_save_black_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_save_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_save_white_24dp.png new file mode 100644 index 000000000..dd3f10664 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_save_white_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_backup_black_24dp.png b/app/src/main/res/drawable-mdpi/ic_backup_black_24dp.png new file mode 100644 index 000000000..4cd6741c0 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_backup_black_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_backup_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_backup_white_24dp.png new file mode 100644 index 000000000..aa640629a Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_backup_white_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_import_export_black_24dp.png b/app/src/main/res/drawable-mdpi/ic_import_export_black_24dp.png new file mode 100644 index 000000000..90f8c4567 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_import_export_black_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_import_export_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_import_export_white_24dp.png new file mode 100644 index 000000000..151188cf8 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_import_export_white_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_save_black_24dp.png b/app/src/main/res/drawable-mdpi/ic_save_black_24dp.png new file mode 100644 index 000000000..663479b73 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_save_black_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_save_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_save_white_24dp.png new file mode 100644 index 000000000..015062ed3 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_save_white_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_backup_black_24dp.png b/app/src/main/res/drawable-xhdpi/ic_backup_black_24dp.png new file mode 100644 index 000000000..81155da52 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_backup_black_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_backup_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_backup_white_24dp.png new file mode 100644 index 000000000..a9602d11b Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_backup_white_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_import_export_black_24dp.png b/app/src/main/res/drawable-xhdpi/ic_import_export_black_24dp.png new file mode 100644 index 000000000..9b643bd3b Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_import_export_black_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_import_export_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_import_export_white_24dp.png new file mode 100644 index 000000000..e22e18866 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_import_export_white_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_save_black_24dp.png b/app/src/main/res/drawable-xhdpi/ic_save_black_24dp.png new file mode 100644 index 000000000..eca2d92ec Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_save_black_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_save_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_save_white_24dp.png new file mode 100644 index 000000000..adda09575 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_save_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_backup_black_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_backup_black_24dp.png new file mode 100644 index 000000000..6506c7236 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_backup_black_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_backup_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_backup_white_24dp.png new file mode 100644 index 000000000..3ff57ad3e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_backup_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_import_export_black_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_import_export_black_24dp.png new file mode 100644 index 000000000..78e865dfa Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_import_export_black_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_import_export_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_import_export_white_24dp.png new file mode 100644 index 000000000..33c21c5c4 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_import_export_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_save_black_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_save_black_24dp.png new file mode 100644 index 000000000..871291b4e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_save_black_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_save_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_save_white_24dp.png new file mode 100644 index 000000000..3e0ce1a5f Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_save_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_backup_black_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_backup_black_24dp.png new file mode 100644 index 000000000..248289e97 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_backup_black_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_backup_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_backup_white_24dp.png new file mode 100644 index 000000000..2180f73e8 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_backup_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_import_export_black_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_import_export_black_24dp.png new file mode 100644 index 000000000..36aa872e5 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_import_export_black_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_import_export_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_import_export_white_24dp.png new file mode 100644 index 000000000..a5e55a470 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_import_export_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_save_black_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_save_black_24dp.png new file mode 100644 index 000000000..ba001835a Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_save_black_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_save_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_save_white_24dp.png new file mode 100644 index 000000000..bd80bf1f7 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_save_white_24dp.png differ diff --git a/app/src/main/res/layout/fragment_import.xml b/app/src/main/res/layout/fragment_import.xml new file mode 100644 index 000000000..b75a5d487 --- /dev/null +++ b/app/src/main/res/layout/fragment_import.xml @@ -0,0 +1,55 @@ + + + + + + + + + +