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 7faba84a09
commit 6b5c818cbf
25 changed files with 1126 additions and 117 deletions

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

@ -0,0 +1,157 @@
package org.schabi.newpipe.subscription;
import android.content.Context;
import android.support.annotation.NonNull;
import android.util.Log;
import org.schabi.newpipe.MainActivity;
import org.schabi.newpipe.NewPipeDatabase;
import org.schabi.newpipe.database.AppDatabase;
import org.schabi.newpipe.database.subscription.SubscriptionDAO;
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
import org.schabi.newpipe.extractor.channel.ChannelInfo;
import org.schabi.newpipe.util.ExtractorHelper;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import io.reactivex.Completable;
import io.reactivex.CompletableSource;
import io.reactivex.Flowable;
import io.reactivex.Maybe;
import io.reactivex.Scheduler;
import io.reactivex.functions.Function;
import io.reactivex.schedulers.Schedulers;
/**
* Subscription Service singleton:
* Provides a basis for channel Subscriptions.
* Provides access to subscription table in database as well as
* up-to-date observations on the subscribed channels
*/
public class SubscriptionService {
private static volatile SubscriptionService instance;
public static SubscriptionService getInstance(@NonNull Context context) {
SubscriptionService result = instance;
if (result == null) {
synchronized (SubscriptionService.class) {
result = instance;
if (result == null) {
instance = (result = new SubscriptionService(context));
}
}
}
return result;
}
protected final String TAG = "SubscriptionService@" + Integer.toHexString(hashCode());
protected static final boolean DEBUG = MainActivity.DEBUG;
private static final int SUBSCRIPTION_DEBOUNCE_INTERVAL = 500;
private static final int SUBSCRIPTION_THREAD_POOL_SIZE = 4;
private AppDatabase db;
private Flowable<List<SubscriptionEntity>> subscription;
private Scheduler subscriptionScheduler;
private SubscriptionService(Context context) {
db = NewPipeDatabase.getInstance(context.getApplicationContext());
subscription = getSubscriptionInfos();
final Executor subscriptionExecutor = Executors.newFixedThreadPool(SUBSCRIPTION_THREAD_POOL_SIZE);
subscriptionScheduler = Schedulers.from(subscriptionExecutor);
}
/**
* Part of subscription observation pipeline
*
* @see SubscriptionService#getSubscription()
*/
private Flowable<List<SubscriptionEntity>> getSubscriptionInfos() {
return subscriptionTable().getAll()
// Wait for a period of infrequent updates and return the latest update
.debounce(SUBSCRIPTION_DEBOUNCE_INTERVAL, TimeUnit.MILLISECONDS)
.share() // Share allows multiple subscribers on the same observable
.replay(1) // Replay synchronizes subscribers to the last emitted result
.autoConnect();
}
/**
* Provides an observer to the latest update to the subscription table.
* <p>
* This observer may be subscribed multiple times, where each subscriber obtains
* the latest synchronized changes available, effectively share the same data
* across all subscribers.
* <p>
* This observer has a debounce cooldown, meaning if multiple updates are observed
* in the cooldown interval, only the latest changes are emitted to the subscribers.
* This reduces the amount of observations caused by frequent updates to the database.
*/
@android.support.annotation.NonNull
public Flowable<List<SubscriptionEntity>> getSubscription() {
return subscription;
}
public Maybe<ChannelInfo> getChannelInfo(final SubscriptionEntity subscriptionEntity) {
if (DEBUG) Log.d(TAG, "getChannelInfo() called with: subscriptionEntity = [" + subscriptionEntity + "]");
return Maybe.fromSingle(ExtractorHelper
.getChannelInfo(subscriptionEntity.getServiceId(), subscriptionEntity.getUrl(), false))
.subscribeOn(subscriptionScheduler);
}
/**
* Returns the database access interface for subscription table.
*/
public SubscriptionDAO subscriptionTable() {
return db.subscriptionDAO();
}
public Completable updateChannelInfo(final ChannelInfo info) {
final Function<List<SubscriptionEntity>, CompletableSource> update = new Function<List<SubscriptionEntity>, CompletableSource>() {
@Override
public CompletableSource apply(@NonNull List<SubscriptionEntity> subscriptionEntities) throws Exception {
if (DEBUG) Log.d(TAG, "updateChannelInfo() called with: subscriptionEntities = [" + subscriptionEntities + "]");
if (subscriptionEntities.size() == 1) {
SubscriptionEntity subscription = subscriptionEntities.get(0);
// Subscriber count changes very often, making this check almost unnecessary.
// Consider removing it later.
if (!isSubscriptionUpToDate(info, subscription)) {
subscription.setData(info.getName(), info.getAvatarUrl(), info.getDescription(), info.getSubscriberCount());
return Completable.fromRunnable(() -> subscriptionTable().update(subscription));
}
}
return Completable.complete();
}
};
return subscriptionTable().getSubscription(info.getServiceId(), info.getUrl())
.firstOrError()
.flatMapCompletable(update);
}
public List<SubscriptionEntity> upsertAll(final List<ChannelInfo> infoList) {
final List<SubscriptionEntity> entityList = new ArrayList<>();
for (ChannelInfo info : infoList) entityList.add(SubscriptionEntity.from(info));
return subscriptionTable().upsertAll(entityList);
}
private boolean isSubscriptionUpToDate(final ChannelInfo info, final SubscriptionEntity entity) {
return info.getUrl().equals(entity.getUrl()) &&
info.getServiceId() == entity.getServiceId() &&
info.getName().equals(entity.getName()) &&
info.getAvatarUrl().equals(entity.getAvatarUrl()) &&
info.getDescription().equals(entity.getDescription()) &&
info.getSubscriberCount() == entity.getSubscriberCount();
}
}

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