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/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/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/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/res/menu/main_fragment_menu.xml b/app/src/main/res/menu/main_fragment_menu.xml index 8327e936d..acb992ebd 100644 --- a/app/src/main/res/menu/main_fragment_menu.xml +++ b/app/src/main/res/menu/main_fragment_menu.xml @@ -5,6 +5,7 @@ \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e64f15206..e168165e6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -132,6 +132,7 @@ Play All Always Just Once + File newpipe NewPipe Notification @@ -169,6 +170,11 @@ Invalid URL No video streams found No audio streams found + Invalid directory + Invalid file/content source + File doesn\'t exist or insufficient permission to read or write to it + File name cannot be empty + An error occurred: %1$s Sorry, that should not have happened. @@ -427,4 +433,24 @@ Report Out-of-Lifecycle Errors Force reporting of undeliverable Rx exceptions occurring outside of fragment or activity lifecycle after dispose + + Import/Export + Import + Import from + Export to + + Importing… + Exporting… + + Import file + Previous export + + Subscriptions import failed + Subscriptions export failed + + To import your YouTube subscriptions you will need the export file, which can be downloaded following these instructions:\n\n1. Go to this url: %1$s\n2. Log in to your account when asked\n3. A download should start (that\'s the export file) + To import your SoundCloud followings you have to know your profile url or id. If you do, just type either of them in the input below and you\'re ready to go.\n\nIf you don\'t, you can follow these steps:\n\n1. Enable \"desktop mode\" in some browser (the site is not available for mobile devices)\n2. Go to this url: %1$s\n3. Log in to your account when asked\n4. Copy the url that you were redirected to (that\'s your profile url) + yourid, soundcloud.com/yourid + + Keep in mind that this operation can be network expensive.\n\nDo you want to continue? diff --git a/app/src/test/java/org/schabi/newpipe/subscription/services/ImportExportJsonHelperTest.java b/app/src/test/java/org/schabi/newpipe/subscription/services/ImportExportJsonHelperTest.java new file mode 100644 index 000000000..a5e6b659f --- /dev/null +++ b/app/src/test/java/org/schabi/newpipe/subscription/services/ImportExportJsonHelperTest.java @@ -0,0 +1,117 @@ +package org.schabi.newpipe.subscription.services; + +import org.junit.Test; +import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor; +import org.schabi.newpipe.extractor.subscription.SubscriptionItem; +import org.schabi.newpipe.subscription.ImportExportJsonHelper; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.util.Arrays; +import java.util.List; + +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * @see ImportExportJsonHelper + */ +public class ImportExportJsonHelperTest { + @Test + public void testEmptySource() throws Exception { + String emptySource = "{\"app_version\":\"0.11.6\",\"app_version_int\": 47,\"subscriptions\":[]}"; + + List items = ImportExportJsonHelper.readFrom(new ByteArrayInputStream(emptySource.getBytes("UTF-8")), null); + assertTrue(items.isEmpty()); + } + + @Test + public void testInvalidSource() throws Exception { + List invalidList = Arrays.asList( + "{}", + "", + null, + "gibberish"); + + for (String invalidContent : invalidList) { + try { + if (invalidContent != null) { + byte[] bytes = invalidContent.getBytes("UTF-8"); + ImportExportJsonHelper.readFrom((new ByteArrayInputStream(bytes)), null); + } else { + ImportExportJsonHelper.readFrom(null, null); + } + + fail("didn't throw exception"); + } catch (Exception e) { + boolean isExpectedException = e instanceof SubscriptionExtractor.InvalidSourceException; + assertTrue("\"" + e.getClass().getSimpleName() + "\" is not the expected exception", isExpectedException); + } + } + } + + @Test + public void ultimateTest() throws Exception { + // Read from file + final List itemsFromFile = readFromFile(); + + // Test writing to an output + final String jsonOut = testWriteTo(itemsFromFile); + + // Read again + final List itemsSecondRead = readFromWriteTo(jsonOut); + + // Check if both lists have the exact same items + if (itemsFromFile.size() != itemsSecondRead.size()) { + fail("The list of items were different from each other"); + } + + for (int i = 0; i < itemsFromFile.size(); i++) { + final SubscriptionItem item1 = itemsFromFile.get(i); + final SubscriptionItem item2 = itemsSecondRead.get(i); + + final boolean equals = item1.getServiceId() == item2.getServiceId() && + item1.getUrl().equals(item2.getUrl()) && + item1.getName().equals(item2.getName()); + + if (!equals) { + fail("The list of items were different from each other"); + } + } + } + + private List readFromFile() throws Exception { + final InputStream inputStream = getClass().getClassLoader().getResourceAsStream("import_export_test.json"); + final List itemsFromFile = ImportExportJsonHelper.readFrom(inputStream, null); + + if (itemsFromFile == null || itemsFromFile.isEmpty()) { + fail("ImportExportJsonHelper.readFrom(input) returned a null or empty list"); + } + + return itemsFromFile; + } + + private String testWriteTo(List itemsFromFile) throws Exception { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + ImportExportJsonHelper.writeTo(itemsFromFile, out, null); + final String jsonOut = out.toString("UTF-8"); + + if (jsonOut.isEmpty()) { + fail("JSON returned by writeTo was empty"); + } + + return jsonOut; + } + + private List readFromWriteTo(String jsonOut) throws Exception { + final ByteArrayInputStream inputStream = new ByteArrayInputStream(jsonOut.getBytes("UTF-8")); + final List secondReadItems = ImportExportJsonHelper.readFrom(inputStream, null); + + if (secondReadItems == null || secondReadItems.isEmpty()) { + fail("second call to readFrom returned an empty list"); + } + + return secondReadItems; + } +} \ No newline at end of file diff --git a/app/src/test/resources/import_export_test.json b/app/src/test/resources/import_export_test.json new file mode 100644 index 000000000..477af7267 --- /dev/null +++ b/app/src/test/resources/import_export_test.json @@ -0,0 +1,46 @@ +{ + "app_version": "0.11.6", + "app_version_int": 47, + "subscriptions": [ + { + "service_id": 0, + "url": "https://www.youtube.com/channel/UCsXVk37bltHxD1rDPwtNM8Q", + "name": "Kurzgesagt \u2013 In a Nutshell" + }, + { + "service_id": 0, + "url": "https://www.youtube.com/channel/UCEOXxzW2vU0P-0THehuIIeg", + "name": "CaptainDisillusion" + }, + { + "service_id": 0, + "url": "https://www.youtube.com/channel/UCAuUUnT6oDeKwE6v1NGQxug", + "name": "TED" + }, + { + "service_id": 0, + "url": "https://www.youtube.com/channel/UCfIXdjDQH9Fau7y99_Orpjw", + "name": "Gorillaz" + }, + { + "service_id": 0, + "url": "https://www.youtube.com/channel/UCJ0-OtVpF0wOKEqT2Z1HEtA", + "name": "ElectroBOOM" + }, + { + "service_id": 0, + "url": "https://www.youtube.com/channel/UCsXVk37bltHxD1rDPwtNM8Q", + "name": "ⓤⓝⓘⓒⓞⓓⓔ" + }, + { + "service_id": 0, + "url": "https://www.youtube.com/channel/UCsXVk37bltHxD1rDPwtNM8Q", + "name": "中文" + }, + { + "service_id": 0, + "url": "https://www.youtube.com/channel/UCsXVk37bltHxD1rDPwtNM8Q", + "name": "हिंदी" + } + ] +} \ No newline at end of file