diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 240dd511c..d9a63fcde 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -92,7 +92,6 @@ android:name="androidx.work.impl.foreground.SystemForegroundService" android:foregroundServiceType="dataSync" tools:node="merge" /> - { - if (resultServiceIntent != null && getContext() != null) { - getContext().startService(resultServiceIntent); - } + final var inputData = new Data.Builder() + .putString(SubscriptionImportWorker.KEY_VALUE, value) + .putInt(SubscriptionImportWorker.KEY_MODE, mode) + .putInt(Constants.KEY_SERVICE_ID, serviceId) + .build(); + final var constraints = new Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build(); + + final var req = new OneTimeWorkRequest.Builder(SubscriptionImportWorker.class) + .setInputData(inputData) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .setConstraints(constraints) + .build(); + + WorkManager.getInstance(context) + .enqueueUniqueWork(SubscriptionImportWorker.WORK_NAME, + ExistingWorkPolicy.APPEND_OR_REPLACE, req); + dismiss(); }) .create(); @@ -53,8 +85,8 @@ public class ImportConfirmationDialog extends DialogFragment { public void onCreate(@Nullable final Bundle savedInstanceState) { super.onCreate(savedInstanceState); - if (resultServiceIntent == null) { - throw new IllegalStateException("Result intent is null"); + if (mode == 0 && value == null && serviceId == 0) { + throw new IllegalStateException("Input data not provided"); } Bridge.restoreInstanceState(this, savedInstanceState); diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt index 25d81287a..cdc7ae179 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt @@ -3,7 +3,6 @@ package org.schabi.newpipe.local.subscription import android.app.Activity import android.content.Context import android.content.DialogInterface -import android.content.Intent import android.os.Bundle import android.os.Parcelable import android.view.LayoutInflater @@ -49,14 +48,12 @@ import org.schabi.newpipe.local.subscription.item.FeedGroupCarouselItem import org.schabi.newpipe.local.subscription.item.GroupsHeader import org.schabi.newpipe.local.subscription.item.Header import org.schabi.newpipe.local.subscription.item.ImportSubscriptionsHintPlaceholderItem -import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService -import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_MODE -import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_VALUE -import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.PREVIOUS_EXPORT_MODE import org.schabi.newpipe.local.subscription.workers.SubscriptionExportWorker +import org.schabi.newpipe.local.subscription.workers.SubscriptionImportWorker import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard import org.schabi.newpipe.streams.io.StoredFileHelper import org.schabi.newpipe.ui.emptystate.setEmptyStateComposable +import org.schabi.newpipe.util.NO_SERVICE_ID import org.schabi.newpipe.util.NavigationHelper import org.schabi.newpipe.util.OnClickGesture import org.schabi.newpipe.util.ServiceHelper @@ -231,12 +228,10 @@ class SubscriptionFragment : BaseStateFragment() { } private fun requestImportResult(result: ActivityResult) { - if (result.data != null && result.resultCode == Activity.RESULT_OK) { + val data = result.data?.dataString + if (data != null && result.resultCode == Activity.RESULT_OK) { ImportConfirmationDialog.show( - this, - Intent(activity, SubscriptionsImportService::class.java) - .putExtra(KEY_MODE, PREVIOUS_EXPORT_MODE) - .putExtra(KEY_VALUE, result.data?.data) + this, SubscriptionImportWorker.PREVIOUS_EXPORT_MODE, data, NO_SERVICE_ID ) } } diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionsImportFragment.java b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionsImportFragment.java index 77a70afa9..a1d244df8 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionsImportFragment.java +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionsImportFragment.java @@ -1,10 +1,6 @@ package org.schabi.newpipe.local.subscription; import static org.schabi.newpipe.extractor.subscription.SubscriptionExtractor.ContentSource.CHANNEL_URL; -import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.CHANNEL_URL_MODE; -import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.INPUT_STREAM_MODE; -import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_MODE; -import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_VALUE; import android.app.Activity; import android.content.Intent; @@ -37,7 +33,7 @@ import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor; -import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService; +import org.schabi.newpipe.local.subscription.workers.SubscriptionImportWorker; import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard; import org.schabi.newpipe.streams.io.StoredFileHelper; import org.schabi.newpipe.util.Constants; @@ -168,10 +164,8 @@ public class SubscriptionsImportFragment extends BaseFragment { } public void onImportUrl(final String value) { - ImportConfirmationDialog.show(this, new Intent(activity, SubscriptionsImportService.class) - .putExtra(KEY_MODE, CHANNEL_URL_MODE) - .putExtra(KEY_VALUE, value) - .putExtra(Constants.KEY_SERVICE_ID, currentServiceId)); + ImportConfirmationDialog.show(this, SubscriptionImportWorker.CHANNEL_URL_MODE, value, + currentServiceId); } public void onImportFile() { @@ -186,16 +180,10 @@ public class SubscriptionsImportFragment extends BaseFragment { } private void requestImportFileResult(final ActivityResult result) { - if (result.getData() == null) { - return; - } - - if (result.getResultCode() == Activity.RESULT_OK && result.getData().getData() != null) { - ImportConfirmationDialog.show(this, - new Intent(activity, SubscriptionsImportService.class) - .putExtra(KEY_MODE, INPUT_STREAM_MODE) - .putExtra(KEY_VALUE, result.getData().getData()) - .putExtra(Constants.KEY_SERVICE_ID, currentServiceId)); + final String data = result.getData() != null ? result.getData().getDataString() : null; + if (result.getResultCode() == Activity.RESULT_OK && data != null) { + ImportConfirmationDialog.show(this, SubscriptionImportWorker.INPUT_STREAM_MODE, + data, currentServiceId); } } diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/services/BaseImportExportService.java b/app/src/main/java/org/schabi/newpipe/local/subscription/services/BaseImportExportService.java deleted file mode 100644 index b7c11b160..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/services/BaseImportExportService.java +++ /dev/null @@ -1,233 +0,0 @@ -/* - * 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.local.subscription.services; - -import android.app.Service; -import android.content.Intent; -import android.os.Build; -import android.os.IBinder; -import android.text.TextUtils; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.StringRes; -import androidx.core.app.NotificationCompat; -import androidx.core.app.NotificationManagerCompat; -import androidx.core.app.ServiceCompat; - -import org.reactivestreams.Publisher; -import org.schabi.newpipe.R; -import org.schabi.newpipe.error.ErrorInfo; -import org.schabi.newpipe.error.ErrorUtil; -import org.schabi.newpipe.error.UserAction; -import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor; -import org.schabi.newpipe.ktx.ExceptionUtils; -import org.schabi.newpipe.local.subscription.SubscriptionManager; - -import java.io.FileNotFoundException; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Flowable; -import io.reactivex.rxjava3.disposables.CompositeDisposable; -import io.reactivex.rxjava3.functions.Function; -import io.reactivex.rxjava3.processors.PublishProcessor; - -public abstract class BaseImportExportService extends Service { - protected final String TAG = this.getClass().getSimpleName(); - - protected final CompositeDisposable disposables = new CompositeDisposable(); - protected final PublishProcessor notificationUpdater = PublishProcessor.create(); - - protected NotificationManagerCompat notificationManager; - protected NotificationCompat.Builder notificationBuilder; - protected SubscriptionManager subscriptionManager; - - private static final int NOTIFICATION_SAMPLING_PERIOD = 2500; - - protected final AtomicInteger currentProgress = new AtomicInteger(-1); - protected final AtomicInteger maxProgress = new AtomicInteger(-1); - protected final ImportExportEventListener eventListener = new ImportExportEventListener() { - @Override - public void onSizeReceived(final int size) { - maxProgress.set(size); - currentProgress.set(0); - } - - @Override - public void onItemCompleted(final String itemName) { - currentProgress.incrementAndGet(); - notificationUpdater.onNext(itemName); - } - }; - - protected Toast toast; - - @Nullable - @Override - public IBinder onBind(final Intent intent) { - return null; - } - - @Override - public void onCreate() { - super.onCreate(); - subscriptionManager = new SubscriptionManager(this); - setupNotification(); - } - - @Override - public void onDestroy() { - super.onDestroy(); - disposeAll(); - } - - protected void disposeAll() { - disposables.clear(); - } - - /*////////////////////////////////////////////////////////////////////////// - // Notification Impl - //////////////////////////////////////////////////////////////////////////*/ - - 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.take(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(final String text) { - notificationBuilder - .setProgress(maxProgress.get(), currentProgress.get(), maxProgress.get() == -1); - - final String progressText = currentProgress + "/" + maxProgress; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - if (!TextUtils.isEmpty(text)) { - notificationBuilder.setContentText(text + " (" + progressText + ")"); - } - } else { - notificationBuilder.setContentInfo(progressText); - notificationBuilder.setContentText(text); - } - - notificationManager.notify(getNotificationId(), notificationBuilder.build()); - } - - protected void stopService() { - postErrorResult(null, null); - } - - protected void stopAndReportError(final Throwable throwable, final String request) { - stopService(); - ErrorUtil.createNotification(this, new ErrorInfo( - throwable, UserAction.SUBSCRIPTION_IMPORT_EXPORT, request)); - } - - protected void postErrorResult(final String title, final String text) { - disposeAll(); - ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE); - stopSelf(); - - if (title == null) { - return; - } - - final String textOrEmpty = text == null ? "" : text; - notificationBuilder = new NotificationCompat - .Builder(this, getString(R.string.notification_channel_id)) - .setSmallIcon(R.drawable.ic_newpipe_triangle_white) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setContentTitle(title) - .setStyle(new NotificationCompat.BigTextStyle().bigText(textOrEmpty)) - .setContentText(textOrEmpty); - 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 void showToast(@StringRes final int message) { - showToast(getString(message)); - } - - protected void showToast(final String message) { - if (toast != null) { - toast.cancel(); - } - - toast = Toast.makeText(this, message, Toast.LENGTH_SHORT); - toast.show(); - } - - /*////////////////////////////////////////////////////////////////////////// - // Error handling - //////////////////////////////////////////////////////////////////////////*/ - - protected void handleError(@StringRes final int errorTitle, @NonNull final 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(final Throwable error) { - String message = null; - if (error instanceof SubscriptionExtractor.InvalidSourceException) { - message = getString(R.string.invalid_source); - } else if (error instanceof FileNotFoundException) { - message = getString(R.string.invalid_file); - } else if (ExceptionUtils.isNetworkRelated(error)) { - message = getString(R.string.network_error); - } - return message; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportEventListener.java b/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportEventListener.java deleted file mode 100644 index 7352d1f12..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportEventListener.java +++ /dev/null @@ -1,17 +0,0 @@ -package org.schabi.newpipe.local.subscription.services; - -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 every time an item has been parsed/resolved. - * - * @param itemName the name of the subscription item - */ - void onItemCompleted(String itemName); -} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java b/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java deleted file mode 100644 index fe326da29..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java +++ /dev/null @@ -1,326 +0,0 @@ -/* - * 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.local.subscription.services; - -import static org.schabi.newpipe.MainActivity.DEBUG; -import static org.schabi.newpipe.streams.io.StoredFileHelper.DEFAULT_MIME; - -import android.content.Intent; -import android.net.Uri; -import android.text.TextUtils; -import android.util.Log; -import android.util.Pair; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.content.IntentCompat; -import androidx.localbroadcastmanager.content.LocalBroadcastManager; - -import org.reactivestreams.Subscriber; -import org.reactivestreams.Subscription; -import org.schabi.newpipe.App; -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.channel.tabs.ChannelTabInfo; -import org.schabi.newpipe.ktx.ExceptionUtils; -import org.schabi.newpipe.local.subscription.workers.SubscriptionItem; -import org.schabi.newpipe.streams.io.SharpInputStream; -import org.schabi.newpipe.streams.io.StoredFileHelper; -import org.schabi.newpipe.util.Constants; -import org.schabi.newpipe.util.ExtractorHelper; - -import java.io.IOException; -import java.io.InputStream; -import java.util.Collections; -import java.util.List; -import java.util.Objects; -import java.util.stream.Collectors; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Flowable; -import io.reactivex.rxjava3.core.Notification; -import io.reactivex.rxjava3.functions.Consumer; -import io.reactivex.rxjava3.functions.Function; -import io.reactivex.rxjava3.schedulers.Schedulers; - -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 = App.PACKAGE_NAME + ".local.subscription" - + ".services.SubscriptionsImportService.IMPORT_COMPLETE"; - - /** - * How many extractions running in parallel. - */ - public static final int PARALLEL_EXTRACTIONS = 8; - - /** - * Number of items to buffer to mass-insert in the subscriptions table, - * this leads to a better performance as we can then use db transactions. - */ - public static final int BUFFER_COUNT_BEFORE_INSERT = 50; - - private Subscription subscription; - private int currentMode; - private int currentServiceId; - @Nullable - private String channelUrl; - @Nullable - private InputStream inputStream; - @Nullable - private String inputStreamType; - - @Override - public int onStartCommand(final Intent intent, final int flags, final int startId) { - if (intent == null || subscription != null) { - return START_NOT_STICKY; - } - - currentMode = intent.getIntExtra(KEY_MODE, -1); - currentServiceId = intent.getIntExtra(Constants.KEY_SERVICE_ID, Constants.NO_SERVICE_ID); - - if (currentMode == CHANNEL_URL_MODE) { - channelUrl = intent.getStringExtra(KEY_VALUE); - } else { - final Uri uri = IntentCompat.getParcelableExtra(intent, KEY_VALUE, Uri.class); - if (uri == null) { - stopAndReportError(new IllegalStateException( - "Importing from input stream, but file path is null"), - "Importing subscriptions"); - return START_NOT_STICKY; - } - - try { - final StoredFileHelper fileHelper = new StoredFileHelper(this, uri, DEFAULT_MIME); - inputStream = new SharpInputStream(fileHelper.getStream()); - inputStreamType = fileHelper.getType(); - - if (inputStreamType == null || inputStreamType.equals(DEFAULT_MIME)) { - // mime type could not be determined, just take file extension - final String name = fileHelper.getName(); - final int pointIndex = name.lastIndexOf('.'); - if (pointIndex == -1 || pointIndex >= name.length() - 1) { - inputStreamType = DEFAULT_MIME; // no extension, will fail in the extractor - } else { - inputStreamType = name.substring(pointIndex + 1); - } - } - } catch (final IOException 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 - //////////////////////////////////////////////////////////////////////////*/ - - private void startImport() { - showToast(R.string.import_ongoing); - - final var flowable = switch (currentMode) { - case CHANNEL_URL_MODE -> importFromChannelUrl(); - case INPUT_STREAM_MODE -> importFromInputStream(); - case PREVIOUS_EXPORT_MODE -> importFromPreviousExport(); - default -> null; - }; - - 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 { - final ChannelInfo channelInfo = ExtractorHelper - .getChannelInfo(subscriptionItem.getServiceId(), - subscriptionItem.getUrl(), true) - .blockingGet(); - return Notification.createOnNext(new Pair<>(channelInfo, - Collections.singletonList( - ExtractorHelper.getChannelTab( - subscriptionItem.getServiceId(), - channelInfo.getTabs().get(0), true).blockingGet() - ))); - } catch (final 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(final Subscription s) { - subscription = s; - s.request(Long.MAX_VALUE); - } - - @Override - public void onNext(final List successfulInserted) { - if (DEBUG) { - Log.d(TAG, "startImport() " + successfulInserted.size() - + " items successfully inserted into the database"); - } - } - - @Override - public void onError(final Throwable error) { - Log.e(TAG, "Got an error!", error); - handleError(error); - } - - @Override - public void onComplete() { - LocalBroadcastManager.getInstance(SubscriptionsImportService.this) - .sendBroadcast(new Intent(IMPORT_COMPLETE_ACTION)); - showToast(R.string.import_complete_toast); - stopService(); - } - }; - } - - private Consumer>>> getNotificationsConsumer() { - return notification -> { - if (notification.isOnNext()) { - final String name = notification.getValue().first.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 error; - } else if (cause instanceof IOException) { - throw cause; - } else if (ExceptionUtils.isNetworkRelated(error)) { - throw new IOException(error); - } - - eventListener.onItemCompleted(""); - } - }; - } - - private Function>>>, - List> upsertBatch() { - return notificationList -> { - final var infoList = notificationList.stream() - .filter(Notification::isOnNext) - .map(Notification::getValue) - .collect(Collectors.toList()); - - return subscriptionManager.upsertAll(infoList); - }; - } - - private Flowable> importFromChannelUrl() { - return Flowable.fromCallable(() -> NewPipe.getService(currentServiceId) - .getSubscriptionExtractor() - .fromChannelUrl(channelUrl)) - .map(list -> list.stream() - .map(item -> new SubscriptionItem(item.getServiceId(), item.getUrl(), - item.getName())) - .collect(Collectors.toList())); - } - - private Flowable> importFromInputStream() { - Objects.requireNonNull(inputStream); - Objects.requireNonNull(inputStreamType); - - return Flowable.fromCallable(() -> NewPipe.getService(currentServiceId) - .getSubscriptionExtractor() - .fromInputStream(inputStream, inputStreamType)) - .map(list -> list.stream() - .map(item -> new SubscriptionItem(item.getServiceId(), item.getUrl(), - item.getName())) - .collect(Collectors.toList())); - } - - private Flowable> importFromPreviousExport() { - return Flowable.fromCallable(() -> ImportExportJsonHelper.readFrom(inputStream)); - } - - protected void handleError(@NonNull final Throwable error) { - super.handleError(R.string.subscriptions_import_unsuccessful, error); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelper.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/workers/ImportExportJsonHelper.kt similarity index 92% rename from app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelper.kt rename to app/src/main/java/org/schabi/newpipe/local/subscription/workers/ImportExportJsonHelper.kt index cd09b477e..d71f5fa89 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelper.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/workers/ImportExportJsonHelper.kt @@ -17,15 +17,13 @@ * along with this program. If not, see . */ -package org.schabi.newpipe.local.subscription.services +package org.schabi.newpipe.local.subscription.workers import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json import kotlinx.serialization.json.decodeFromStream import kotlinx.serialization.json.encodeToStream import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor.InvalidSourceException -import org.schabi.newpipe.local.subscription.workers.SubscriptionData -import org.schabi.newpipe.local.subscription.workers.SubscriptionItem import java.io.InputStream import java.io.OutputStream diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionExportWorker.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionExportWorker.kt index 3a83adcb6..42b77e21c 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionExportWorker.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionExportWorker.kt @@ -23,7 +23,6 @@ import kotlinx.coroutines.withContext import org.schabi.newpipe.BuildConfig import org.schabi.newpipe.NewPipeDatabase import org.schabi.newpipe.R -import org.schabi.newpipe.local.subscription.services.ImportExportJsonHelper class SubscriptionExportWorker( appContext: Context, diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionImportWorker.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionImportWorker.kt new file mode 100644 index 000000000..4e5c2a541 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionImportWorker.kt @@ -0,0 +1,153 @@ +package org.schabi.newpipe.local.subscription.workers + +import android.app.Notification +import android.content.Context +import android.content.pm.ServiceInfo +import android.os.Build +import android.util.Pair +import android.webkit.MimeTypeMap +import androidx.core.app.NotificationCompat +import androidx.core.net.toUri +import androidx.work.CoroutineWorker +import androidx.work.ForegroundInfo +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.rx3.await +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.schabi.newpipe.R +import org.schabi.newpipe.extractor.NewPipe +import org.schabi.newpipe.local.subscription.SubscriptionManager +import org.schabi.newpipe.util.ExtractorHelper +import org.schabi.newpipe.util.KEY_SERVICE_ID +import org.schabi.newpipe.util.NO_SERVICE_ID + +class SubscriptionImportWorker( + appContext: Context, + params: WorkerParameters, +) : CoroutineWorker(appContext, params) { + // This is needed for API levels < 31 (Android S). + override suspend fun getForegroundInfo(): ForegroundInfo { + val title = applicationContext.getString(R.string.import_ongoing) + return createForegroundInfo(createNotification(title, null, 0, 0)) + } + + override suspend fun doWork(): Result { + val mode = inputData.getInt(KEY_MODE, CHANNEL_URL_MODE) + val extractor = NewPipe.getService(inputData.getInt(KEY_SERVICE_ID, NO_SERVICE_ID)) + .subscriptionExtractor + val value = inputData.getString(KEY_VALUE) ?: "" + + val subscriptions = withContext(Dispatchers.IO) { + if (mode == CHANNEL_URL_MODE) { + extractor + .fromChannelUrl(value) + .map { SubscriptionItem(it.serviceId, it.url, it.name) } + } else { + applicationContext.contentResolver.openInputStream(value.toUri())?.use { + if (mode == INPUT_STREAM_MODE) { + val contentType = MimeTypeMap.getFileExtensionFromUrl(value).ifEmpty { DEFAULT_MIME } + extractor + .fromInputStream(it, contentType) + .map { SubscriptionItem(it.serviceId, it.url, it.name) } + } else { + ImportExportJsonHelper.readFrom(it) + } + } ?: emptyList() + } + } + + val mutex = Mutex() + var index = 1 + val qty = subscriptions.size + var title = + applicationContext.resources.getQuantityString(R.plurals.load_subscriptions, qty, qty) + + val channelInfoList = withContext(Dispatchers.IO.limitedParallelism(PARALLEL_EXTRACTIONS)) { + subscriptions + .map { + async { + val channelInfo = + ExtractorHelper.getChannelInfo(it.serviceId, it.url, true).await() + val channelTab = + ExtractorHelper.getChannelTab(it.serviceId, channelInfo.tabs[0], true).await() + + val currentIndex = mutex.withLock { index++ } + val notification = createNotification(title, channelInfo.name, currentIndex, qty) + setForeground(createForegroundInfo(notification)) + + Pair(channelInfo, listOf(channelTab)) + } + }.awaitAll() + } + + title = applicationContext.resources.getQuantityString(R.plurals.import_subscriptions, qty, qty) + setForeground(createForegroundInfo(createNotification(title, null, 0, 0))) + index = 0 + + val subscriptionManager = SubscriptionManager(applicationContext) + for (chunk in channelInfoList.chunked(BUFFER_COUNT_BEFORE_INSERT)) { + withContext(Dispatchers.IO) { + subscriptionManager.upsertAll(chunk) + } + index += chunk.size + setForeground(createForegroundInfo(createNotification(title, null, index, qty))) + } + + return Result.success() + } + + private fun createNotification( + title: String, + text: String?, + currentProgress: Int, + maxProgress: Int, + ): Notification = + NotificationCompat + .Builder(applicationContext, NOTIFICATION_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_newpipe_triangle_white) + .setOngoing(true) + .setProgress(maxProgress, currentProgress, currentProgress == 0) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + .setContentTitle(title) + .setContentText(text) + .addAction( + R.drawable.ic_close, + applicationContext.getString(R.string.cancel), + WorkManager.getInstance(applicationContext).createCancelPendingIntent(id), + ).apply { + if (currentProgress > 0 && maxProgress > 0) { + val progressText = "$currentProgress/$maxProgress" + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + setSubText(progressText) + } else { + setContentInfo(progressText) + } + } + }.build() + + private fun createForegroundInfo(notification: Notification): ForegroundInfo { + val serviceType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC else 0 + return ForegroundInfo(NOTIFICATION_ID, notification, serviceType) + } + + companion object { + private const val NOTIFICATION_ID = 4568 + private const val NOTIFICATION_CHANNEL_ID = "newpipe" + private const val DEFAULT_MIME = "application/octet-stream" + private const val PARALLEL_EXTRACTIONS = 8 + private const val BUFFER_COUNT_BEFORE_INSERT = 50 + + const val WORK_NAME = "SubscriptionImportWorker" + const val CHANNEL_URL_MODE = 0 + const val INPUT_STREAM_MODE = 1 + const val PREVIOUS_EXPORT_MODE = 2 + const val KEY_MODE = "key_mode" + const val KEY_VALUE = "key_value" + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e7226334d..2fad25e8a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -866,4 +866,12 @@ Exporting %d subscription… Exporting %d subscriptions… + + Loading %d subscription… + Loading %d subscriptions… + + + Importing %d subscription… + Importing %d subscriptions… + diff --git a/app/src/test/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelperTest.java b/app/src/test/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelperTest.java index 3243ad9fd..96bca9733 100644 --- a/app/src/test/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelperTest.java +++ b/app/src/test/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelperTest.java @@ -5,6 +5,7 @@ import static org.junit.Assert.fail; import org.junit.Test; import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor; +import org.schabi.newpipe.local.subscription.workers.ImportExportJsonHelper; import org.schabi.newpipe.local.subscription.workers.SubscriptionItem; import java.io.ByteArrayInputStream;