Implement subscriptions import/export

- Import subscriptions from YouTube and SoundCloud (all services that the extractor support)
- Import/export a JSON representation of the subscriptions
- [Minor] Remove some javax annotations in favor of the one provided by the android support library
This commit is contained in:
Mauricio Colli 2018-03-08 10:39:24 -03:00
parent e2ac0722c8
commit 83b084a90b
25 changed files with 1126 additions and 117 deletions

View file

@ -89,7 +89,6 @@ public class App extends Application {
SettingsActivity.initSettings(this);
NewPipe.init(getDownloader());
NewPipeDatabase.init(this);
StateSaver.init(this);
initNotificationChannel();

View file

@ -1,9 +1,7 @@
package org.schabi.newpipe;
import android.content.Context;
import android.content.res.TypedArray;
import android.os.Bundle;
import android.support.annotation.AttrRes;
import android.support.annotation.NonNull;
import android.support.v4.app.Fragment;
import android.support.v7.app.AppCompatActivity;
@ -86,6 +84,17 @@ public abstract class BaseFragment extends Fragment {
protected void initListeners() {
}
/*//////////////////////////////////////////////////////////////////////////
// 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);
}
}
/*//////////////////////////////////////////////////////////////////////////
// DisplayImageOptions default configurations
//////////////////////////////////////////////////////////////////////////*/

View file

@ -11,31 +11,32 @@ import static org.schabi.newpipe.database.Migrations.MIGRATION_11_12;
public final class NewPipeDatabase {
private static AppDatabase databaseInstance;
private static volatile AppDatabase databaseInstance;
private NewPipeDatabase() {
//no instance
}
public static void init(Context context) {
databaseInstance = Room
.databaseBuilder(context, AppDatabase.class, DATABASE_NAME)
private static AppDatabase getDatabase(Context context) {
return Room
.databaseBuilder(context.getApplicationContext(), AppDatabase.class, DATABASE_NAME)
.addMigrations(MIGRATION_11_12)
.fallbackToDestructiveMigration()
.build();
}
@NonNull
@Deprecated
public static AppDatabase getInstance() {
if (databaseInstance == null) throw new RuntimeException("Database not initialized");
public static AppDatabase getInstance(@NonNull Context context) {
AppDatabase result = databaseInstance;
if (result == null) {
synchronized (NewPipeDatabase.class) {
result = databaseInstance;
if (result == null) {
databaseInstance = (result = getDatabase(context));
}
}
}
return databaseInstance;
}
@NonNull
public static AppDatabase getInstance(Context context) {
if (databaseInstance == null) init(context);
return databaseInstance;
return result;
}
}

View file

@ -1,7 +1,10 @@
package org.schabi.newpipe.database.subscription;
import android.arch.persistence.room.Dao;
import android.arch.persistence.room.Insert;
import android.arch.persistence.room.OnConflictStrategy;
import android.arch.persistence.room.Query;
import android.arch.persistence.room.Transaction;
import org.schabi.newpipe.database.BasicDAO;
@ -11,24 +14,56 @@ import io.reactivex.Flowable;
import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_SERVICE_ID;
import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_TABLE;
import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_UID;
import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_URL;
@Dao
public interface SubscriptionDAO extends BasicDAO<SubscriptionEntity> {
public abstract class SubscriptionDAO implements BasicDAO<SubscriptionEntity> {
@Override
@Query("SELECT * FROM " + SUBSCRIPTION_TABLE)
Flowable<List<SubscriptionEntity>> getAll();
public abstract Flowable<List<SubscriptionEntity>> 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<List<SubscriptionEntity>> listByService(int serviceId);
public abstract Flowable<List<SubscriptionEntity>> listByService(int serviceId);
@Query("SELECT * FROM " + SUBSCRIPTION_TABLE + " WHERE " +
SUBSCRIPTION_URL + " LIKE :url AND " +
SUBSCRIPTION_SERVICE_ID + " = :serviceId")
Flowable<List<SubscriptionEntity>> getSubscription(int serviceId, String url);
public abstract Flowable<List<SubscriptionEntity>> 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<SubscriptionEntity> upsertAll(List<SubscriptionEntity> 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;
}
}

View file

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

View file

@ -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<ChannelInfo> {
@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);
}

View file

@ -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<List<SubscriptionEntity>, Voi
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
subscriptionService = SubscriptionService.getInstance();
subscriptionService = SubscriptionService.getInstance(activity);
FEED_LOAD_COUNT = howManyItemsToLoad();
}

View file

@ -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<StreamEntity> streams) {
@NonNull List<StreamEntity> streams) {
if (getStreams() == null) return;
@SuppressLint("ShowToast")

View file

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

View file

@ -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");

View file

@ -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();
}

View file

@ -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())

View file

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

View file

@ -0,0 +1,138 @@
/*
* Copyright 2018 Mauricio Colli <mauriciocolli@outlook.com>
* 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 <http://www.gnu.org/licenses/>.
*/
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<SubscriptionItem> readFrom(InputStream in, @Nullable ImportExportEventListener eventListener) throws InvalidSourceException {
if (in == null) throw new InvalidSourceException("input is null");
final List<SubscriptionItem> channels = new ArrayList<>();
try {
JsonObject parentObject = JsonParser.object().from(in);
JsonArray channelsArray = parentObject.getArray(JSON_SUBSCRIPTIONS_ARRAY_KEY);
if (eventListener != null) eventListener.onSizeReceived(channelsArray.size());
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<SubscriptionItem> 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<SubscriptionItem> 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();
}
}

View file

@ -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<SubscriptionEntity> upsertAll(final List<ChannelInfo> infoList) {
final List<SubscriptionEntity> entityList = new ArrayList<>();
for (ChannelInfo info : infoList) entityList.add(SubscriptionEntity.from(info));
return subscriptionTable().upsertAll(entityList);
}
private boolean isSubscriptionUpToDate(final ChannelInfo info, final SubscriptionEntity entity) {

View file

@ -0,0 +1,227 @@
/*
* Copyright 2018 Mauricio Colli <mauriciocolli@outlook.com>
* 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 <http://www.gnu.org/licenses/>.
*/
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<String> 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<Flowable<String>, Publisher<String>> throttleAfterFirstEmission = flow -> flow.limit(1)
.concatWith(flow.skip(1).throttleLast(NOTIFICATION_SAMPLING_PERIOD, TimeUnit.MILLISECONDS));
disposables.add(notificationUpdater
.filter(s -> !s.isEmpty())
.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;
}
}

View file

@ -0,0 +1,153 @@
/*
* Copyright 2018 Mauricio Colli <mauriciocolli@outlook.com>
* 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 <http://www.gnu.org/licenses/>.
*/
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<SubscriptionItem> 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<File> getSubscriber() {
return new Subscriber<File>() {
@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<List<SubscriptionItem>, File> exportToFile() {
return subscriptionItems -> {
ImportExportJsonHelper.writeTo(subscriptionItems, outputStream, eventListener);
return outFile;
};
}
protected void handleError(Throwable error) {
super.handleError(R.string.subscriptions_export_unsuccessful, error);
}
}

View file

@ -0,0 +1,264 @@
/*
* Copyright 2018 Mauricio Colli <mauriciocolli@outlook.com>
* 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 <http://www.gnu.org/licenses/>.
*/
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<List<SubscriptionItem>> 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, Notification<ChannelInfo>>) 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<List<SubscriptionEntity>> getSubscriber() {
return new Subscriber<List<SubscriptionEntity>>() {
@Override
public void onSubscribe(Subscription s) {
subscription = s;
s.request(Long.MAX_VALUE);
}
@Override
public void onNext(List<SubscriptionEntity> successfulInserted) {
if (DEBUG) Log.d(TAG, "startImport() " + successfulInserted.size() + " items successfully inserted into the database");
}
@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<Notification<ChannelInfo>> 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<Notification<ChannelInfo>>, List<SubscriptionEntity>> upsertBatch() {
return notificationList -> {
final List<ChannelInfo> infoList = new ArrayList<>(notificationList.size());
for (Notification<ChannelInfo> n : notificationList) {
if (n.isOnNext()) infoList.add(n.getValue());
}
return subscriptionService.upsertAll(infoList);
};
}
private Flowable<List<SubscriptionItem>> importFromChannelUrl() {
return Flowable.fromCallable(() -> NewPipe.getService(currentServiceId)
.getSubscriptionExtractor()
.fromChannelUrl(channelUrl));
}
private Flowable<List<SubscriptionItem>> importFromInputStream() {
return Flowable.fromCallable(() -> NewPipe.getService(currentServiceId)
.getSubscriptionExtractor()
.fromInputStream(inputStream));
}
private Flowable<List<SubscriptionItem>> importFromPreviousExport() {
return Flowable.fromCallable(() -> ImportExportJsonHelper.readFrom(inputStream, null));
}
protected void handleError(@NonNull Throwable error) {
super.handleError(R.string.subscriptions_import_unsuccessful, error);
}
}

View file

@ -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 <chris.schabesberger@mailbox.org>
* 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 <http://www.gnu.org/licenses/>.
*/
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);
}
}
}