mirror of
				https://github.com/TeamNewPipe/NewPipe
				synced 2025-10-31 15:23:00 +00:00 
			
		
		
		
	Merge pull request #11759 from Isira-Seneviratne/Import-export-worker
Rewrite import and export subscriptions functionality using coroutines
This commit is contained in:
		| @@ -9,6 +9,7 @@ plugins { | ||||
|     alias libs.plugins.kotlin.compose | ||||
|     alias libs.plugins.kotlin.kapt | ||||
|     alias libs.plugins.kotlin.parcelize | ||||
|     alias libs.plugins.kotlinx.serialization | ||||
|     alias libs.plugins.checkstyle | ||||
|     alias libs.plugins.sonarqube | ||||
|     alias libs.plugins.hilt | ||||
| @@ -16,7 +17,7 @@ plugins { | ||||
| } | ||||
|  | ||||
| android { | ||||
|     compileSdk 34 | ||||
|     compileSdk 35 | ||||
|     namespace 'org.schabi.newpipe' | ||||
|  | ||||
|     defaultConfig { | ||||
| @@ -226,7 +227,6 @@ dependencies { | ||||
|     implementation libs.androidx.fragment.compose | ||||
|     implementation libs.androidx.lifecycle.livedata | ||||
|     implementation libs.androidx.lifecycle.viewmodel | ||||
|     implementation libs.androidx.localbroadcastmanager | ||||
|     implementation libs.androidx.media | ||||
|     implementation libs.androidx.preference | ||||
|     implementation libs.androidx.recyclerview | ||||
| @@ -319,6 +319,9 @@ dependencies { | ||||
|     // Scroll | ||||
|     implementation libs.lazycolumnscrollbar | ||||
|  | ||||
|     // Kotlinx Serialization | ||||
|     implementation libs.kotlinx.serialization.json | ||||
|  | ||||
| /** Debugging **/ | ||||
|     // Memory leak detection | ||||
|     debugImplementation libs.leakcanary.object.watcher | ||||
|   | ||||
							
								
								
									
										15
									
								
								app/proguard-rules.pro
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										15
									
								
								app/proguard-rules.pro
									
									
									
									
										vendored
									
									
								
							| @@ -34,3 +34,18 @@ | ||||
|  | ||||
| ## For some reason NotificationModeConfigFragment wasn't kept (only referenced in a preference xml) | ||||
| -keep class org.schabi.newpipe.settings.notifications.** { *; } | ||||
|  | ||||
| ## Keep Kotlinx Serialization classes | ||||
| -keepclassmembers class kotlinx.serialization.json.** { | ||||
|     *** Companion; | ||||
| } | ||||
| -keepclasseswithmembers class kotlinx.serialization.json.** { | ||||
|     kotlinx.serialization.KSerializer serializer(...); | ||||
| } | ||||
| -keep,includedescriptorclasses class org.schabi.newpipe.**$$serializer { *; } | ||||
| -keepclassmembers class org.schabi.newpipe.** { | ||||
|     *** Companion; | ||||
| } | ||||
| -keepclasseswithmembers class org.schabi.newpipe.** { | ||||
|     kotlinx.serialization.KSerializer serializer(...); | ||||
| } | ||||
|   | ||||
| @@ -9,6 +9,7 @@ | ||||
|     <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> | ||||
|     <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" /> | ||||
|     <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> | ||||
|     <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> | ||||
|     <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> | ||||
|  | ||||
|     <!-- We need to be able to open links in the browser on API 30+ --> | ||||
| @@ -90,8 +91,10 @@ | ||||
|             android:exported="false" | ||||
|             android:label="@string/title_activity_about" /> | ||||
|  | ||||
|         <service android:name=".local.subscription.services.SubscriptionsImportService" /> | ||||
|         <service android:name=".local.subscription.services.SubscriptionsExportService" /> | ||||
|         <service | ||||
|             android:name="androidx.work.impl.foreground.SystemForegroundService" | ||||
|             android:foregroundServiceType="dataSync" | ||||
|             tools:node="merge" /> | ||||
|         <service android:name=".local.feed.service.FeedLoadService" /> | ||||
|  | ||||
|         <activity | ||||
|   | ||||
| @@ -90,7 +90,7 @@ abstract class SubscriptionDAO : BasicDAO<SubscriptionEntity> { | ||||
|     internal abstract fun silentInsertAllInternal(entities: List<SubscriptionEntity>): List<Long> | ||||
|  | ||||
|     @Transaction | ||||
|     open fun upsertAll(entities: List<SubscriptionEntity>): List<SubscriptionEntity> { | ||||
|     open fun upsertAll(entities: List<SubscriptionEntity>) { | ||||
|         val insertUidList = silentInsertAllInternal(entities) | ||||
|  | ||||
|         insertUidList.forEachIndexed { index: Int, uidFromInsert: Long -> | ||||
| @@ -106,7 +106,5 @@ abstract class SubscriptionDAO : BasicDAO<SubscriptionEntity> { | ||||
|                 update(entity) | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return entities | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -3,47 +3,64 @@ package org.schabi.newpipe.local.subscription; | ||||
| import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; | ||||
|  | ||||
| import android.app.Dialog; | ||||
| import android.content.Intent; | ||||
| import android.os.Bundle; | ||||
|  | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Nullable; | ||||
| import androidx.appcompat.app.AlertDialog; | ||||
| import androidx.core.os.BundleCompat; | ||||
| import androidx.fragment.app.DialogFragment; | ||||
| import androidx.fragment.app.Fragment; | ||||
| import androidx.work.Constraints; | ||||
| import androidx.work.ExistingWorkPolicy; | ||||
| import androidx.work.NetworkType; | ||||
| import androidx.work.OneTimeWorkRequest; | ||||
| import androidx.work.OutOfQuotaPolicy; | ||||
| import androidx.work.WorkManager; | ||||
|  | ||||
| import com.evernote.android.state.State; | ||||
| import com.livefront.bridge.Bridge; | ||||
|  | ||||
| import org.schabi.newpipe.R; | ||||
| import org.schabi.newpipe.local.subscription.workers.SubscriptionImportInput; | ||||
| import org.schabi.newpipe.local.subscription.workers.SubscriptionImportWorker; | ||||
|  | ||||
| public class ImportConfirmationDialog extends DialogFragment { | ||||
|     @State | ||||
|     protected Intent resultServiceIntent; | ||||
|     private static final String INPUT = "input"; | ||||
|  | ||||
|     public static void show(@NonNull final Fragment fragment, | ||||
|                             @NonNull final Intent resultServiceIntent) { | ||||
|         final ImportConfirmationDialog confirmationDialog = new ImportConfirmationDialog(); | ||||
|         confirmationDialog.setResultServiceIntent(resultServiceIntent); | ||||
|     public static void show(@NonNull final Fragment fragment, final SubscriptionImportInput input) { | ||||
|         final var confirmationDialog = new ImportConfirmationDialog(); | ||||
|         final var arguments = new Bundle(); | ||||
|         arguments.putParcelable(INPUT, input); | ||||
|         confirmationDialog.setArguments(arguments); | ||||
|         confirmationDialog.show(fragment.getParentFragmentManager(), null); | ||||
|     } | ||||
|  | ||||
|     public void setResultServiceIntent(final Intent resultServiceIntent) { | ||||
|         this.resultServiceIntent = resultServiceIntent; | ||||
|     } | ||||
|  | ||||
|     @NonNull | ||||
|     @Override | ||||
|     public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) { | ||||
|         assureCorrectAppLanguage(getContext()); | ||||
|         return new AlertDialog.Builder(requireContext()) | ||||
|         final var context = requireContext(); | ||||
|         assureCorrectAppLanguage(context); | ||||
|         return new AlertDialog.Builder(context) | ||||
|                 .setMessage(R.string.import_network_expensive_warning) | ||||
|                 .setCancelable(true) | ||||
|                 .setNegativeButton(R.string.cancel, null) | ||||
|                 .setPositiveButton(R.string.ok, (dialogInterface, i) -> { | ||||
|                     if (resultServiceIntent != null && getContext() != null) { | ||||
|                         getContext().startService(resultServiceIntent); | ||||
|                     } | ||||
|                     final var constraints = new Constraints.Builder() | ||||
|                             .setRequiredNetworkType(NetworkType.CONNECTED) | ||||
|                             .build(); | ||||
|                     final var input = BundleCompat.getParcelable(requireArguments(), INPUT, | ||||
|                             SubscriptionImportInput.class); | ||||
|  | ||||
|                     final var req = new OneTimeWorkRequest.Builder(SubscriptionImportWorker.class) | ||||
|                             .setInputData(input.toData()) | ||||
|                             .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,10 +70,6 @@ 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"); | ||||
|         } | ||||
|  | ||||
|         Bridge.restoreInstanceState(this, savedInstanceState); | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -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,11 +48,8 @@ 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.SubscriptionsExportService | ||||
| 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.SubscriptionImportInput | ||||
| import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard | ||||
| import org.schabi.newpipe.streams.io.StoredFileHelper | ||||
| import org.schabi.newpipe.ui.emptystate.setEmptyStateComposable | ||||
| @@ -224,21 +220,17 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() { | ||||
|     } | ||||
|  | ||||
|     private fun requestExportResult(result: ActivityResult) { | ||||
|         if (result.data != null && result.resultCode == Activity.RESULT_OK) { | ||||
|             activity.startService( | ||||
|                 Intent(activity, SubscriptionsExportService::class.java) | ||||
|                     .putExtra(SubscriptionsExportService.KEY_FILE_PATH, result.data?.data) | ||||
|             ) | ||||
|         val data = result.data?.data | ||||
|         if (data != null && result.resultCode == Activity.RESULT_OK) { | ||||
|             SubscriptionExportWorker.schedule(activity, data) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     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, SubscriptionImportInput.PreviousExportMode(data) | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
|   | ||||
| @@ -1,7 +1,6 @@ | ||||
| package org.schabi.newpipe.local.subscription | ||||
|  | ||||
| import android.content.Context | ||||
| import android.util.Pair | ||||
| import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers | ||||
| import io.reactivex.rxjava3.core.Completable | ||||
| import io.reactivex.rxjava3.core.Flowable | ||||
| @@ -48,23 +47,16 @@ class SubscriptionManager(context: Context) { | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun upsertAll(infoList: List<Pair<ChannelInfo, List<ChannelTabInfo>>>): List<SubscriptionEntity> { | ||||
|         val listEntities = subscriptionTable.upsertAll( | ||||
|             infoList.map { SubscriptionEntity.from(it.first) } | ||||
|         ) | ||||
|     fun upsertAll(infoList: List<Pair<ChannelInfo, ChannelTabInfo>>) { | ||||
|         val listEntities = infoList.map { SubscriptionEntity.from(it.first) } | ||||
|         subscriptionTable.upsertAll(listEntities) | ||||
|  | ||||
|         database.runInTransaction { | ||||
|             infoList.forEachIndexed { index, info -> | ||||
|                 info.second.forEach { | ||||
|                     feedDatabaseManager.upsertAll( | ||||
|                         listEntities[index].uid, | ||||
|                         it.relatedItems.filterIsInstance<StreamInfoItem>() | ||||
|                     ) | ||||
|                 } | ||||
|                 val streams = info.second.relatedItems.filterIsInstance<StreamInfoItem>() | ||||
|                 feedDatabaseManager.upsertAll(listEntities[index].uid, streams) | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return listEntities | ||||
|     } | ||||
|  | ||||
|     fun updateChannelInfo(info: ChannelInfo): Completable = | ||||
|   | ||||
| @@ -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.SubscriptionImportInput; | ||||
| 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, | ||||
|                 new SubscriptionImportInput.ChannelUrlMode(currentServiceId, value)); | ||||
|     } | ||||
|  | ||||
|     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) { | ||||
|         final String data = result.getData() != null ? result.getData().getDataString() : null; | ||||
|         if (result.getResultCode() == Activity.RESULT_OK && data != 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)); | ||||
|                     new SubscriptionImportInput.InputStreamMode(currentServiceId, data)); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -1,233 +0,0 @@ | ||||
| /* | ||||
|  * 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.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<String> 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<Flowable<String>, Publisher<String>> 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; | ||||
|     } | ||||
| } | ||||
| @@ -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); | ||||
| } | ||||
| @@ -1,158 +0,0 @@ | ||||
| /* | ||||
|  * 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.local.subscription.services; | ||||
|  | ||||
| import androidx.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.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 final 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"; | ||||
|  | ||||
|     private ImportExportJsonHelper() { } | ||||
|  | ||||
|     /** | ||||
|      * Read a JSON source through the input stream. | ||||
|      * | ||||
|      * @param in            the input stream (e.g. a file) | ||||
|      * @param eventListener listener for the events generated | ||||
|      * @return the parsed subscription items | ||||
|      */ | ||||
|     public static List<SubscriptionItem> readFrom( | ||||
|             final InputStream in, @Nullable final ImportExportEventListener eventListener) | ||||
|             throws InvalidSourceException { | ||||
|         if (in == null) { | ||||
|             throw new InvalidSourceException("input is null"); | ||||
|         } | ||||
|  | ||||
|         final List<SubscriptionItem> channels = new ArrayList<>(); | ||||
|  | ||||
|         try { | ||||
|             final JsonObject parentObject = JsonParser.object().from(in); | ||||
|  | ||||
|             if (!parentObject.has(JSON_SUBSCRIPTIONS_ARRAY_KEY)) { | ||||
|                 throw new InvalidSourceException("Channels array is null"); | ||||
|             } | ||||
|  | ||||
|             final JsonArray channelsArray = parentObject.getArray(JSON_SUBSCRIPTIONS_ARRAY_KEY); | ||||
|  | ||||
|             if (eventListener != null) { | ||||
|                 eventListener.onSizeReceived(channelsArray.size()); | ||||
|             } | ||||
|  | ||||
|             for (final Object o : channelsArray) { | ||||
|                 if (o instanceof JsonObject) { | ||||
|                     final JsonObject itemObject = (JsonObject) o; | ||||
|                     final int serviceId = itemObject.getInt(JSON_SERVICE_ID_KEY, 0); | ||||
|                     final String url = itemObject.getString(JSON_URL_KEY); | ||||
|                     final 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 (final 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(final List<SubscriptionItem> items, final OutputStream out, | ||||
|                                @Nullable final ImportExportEventListener eventListener) { | ||||
|         final JsonAppendableWriter writer = JsonWriter.on(out); | ||||
|         writeTo(items, writer, eventListener); | ||||
|         writer.done(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @see #writeTo(List, OutputStream, ImportExportEventListener) | ||||
|      * @param items         the list of subscriptions items | ||||
|      * @param writer        the output {@link JsonAppendableWriter} | ||||
|      * @param eventListener listener for the events generated | ||||
|      */ | ||||
|     public static void writeTo(final List<SubscriptionItem> items, | ||||
|                                final JsonAppendableWriter writer, | ||||
|                                @Nullable final 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 (final 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(); | ||||
|     } | ||||
| } | ||||
| @@ -1,171 +0,0 @@ | ||||
| /* | ||||
|  * 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.local.subscription.services; | ||||
|  | ||||
| import static org.schabi.newpipe.MainActivity.DEBUG; | ||||
|  | ||||
| import android.content.Intent; | ||||
| import android.net.Uri; | ||||
| import android.util.Log; | ||||
|  | ||||
| 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.subscription.SubscriptionItem; | ||||
| import org.schabi.newpipe.streams.io.SharpOutputStream; | ||||
| import org.schabi.newpipe.streams.io.StoredFileHelper; | ||||
|  | ||||
| import java.io.IOException; | ||||
| import java.io.OutputStream; | ||||
| import java.util.ArrayList; | ||||
| import java.util.List; | ||||
|  | ||||
| import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; | ||||
| import io.reactivex.rxjava3.functions.Function; | ||||
| import io.reactivex.rxjava3.schedulers.Schedulers; | ||||
|  | ||||
| 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 = App.PACKAGE_NAME + ".local.subscription" | ||||
|             + ".services.SubscriptionsExportService.EXPORT_COMPLETE"; | ||||
|  | ||||
|     private Subscription subscription; | ||||
|     private StoredFileHelper outFile; | ||||
|     private OutputStream outputStream; | ||||
|  | ||||
|     @Override | ||||
|     public int onStartCommand(final Intent intent, final int flags, final int startId) { | ||||
|         if (intent == null || subscription != null) { | ||||
|             return START_NOT_STICKY; | ||||
|         } | ||||
|  | ||||
|         final Uri path = IntentCompat.getParcelableExtra(intent, KEY_FILE_PATH, Uri.class); | ||||
|         if (path == null) { | ||||
|             stopAndReportError(new IllegalStateException( | ||||
|                     "Exporting to a file, but the path is null"), | ||||
|                     "Exporting subscriptions"); | ||||
|             return START_NOT_STICKY; | ||||
|         } | ||||
|  | ||||
|         try { | ||||
|             outFile = new StoredFileHelper(this, path, "application/json"); | ||||
|             // truncate the file before writing to it, otherwise if the new content is smaller than | ||||
|             // the previous file size, the file will retain part of the previous content and be | ||||
|             // corrupted | ||||
|             outputStream = new SharpOutputStream(outFile.openAndTruncateStream()); | ||||
|         } catch (final IOException 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); | ||||
|  | ||||
|         subscriptionManager.subscriptionTable().getAll().take(1) | ||||
|                 .map(subscriptionEntities -> { | ||||
|                     final List<SubscriptionItem> result = | ||||
|                             new ArrayList<>(subscriptionEntities.size()); | ||||
|                     for (final 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<StoredFileHelper> getSubscriber() { | ||||
|         return new Subscriber<StoredFileHelper>() { | ||||
|             @Override | ||||
|             public void onSubscribe(final Subscription s) { | ||||
|                 subscription = s; | ||||
|                 s.request(1); | ||||
|             } | ||||
|  | ||||
|             @Override | ||||
|             public void onNext(final StoredFileHelper file) { | ||||
|                 if (DEBUG) { | ||||
|                     Log.d(TAG, "startExport() success: file = " + file); | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             @Override | ||||
|             public void onError(final 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>, StoredFileHelper> exportToFile() { | ||||
|         return subscriptionItems -> { | ||||
|             ImportExportJsonHelper.writeTo(subscriptionItems, outputStream, eventListener); | ||||
|             return outFile; | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     protected void handleError(final Throwable error) { | ||||
|         super.handleError(R.string.subscriptions_export_unsuccessful, error); | ||||
|     } | ||||
| } | ||||
| @@ -1,327 +0,0 @@ | ||||
| /* | ||||
|  * 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.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.extractor.subscription.SubscriptionItem; | ||||
| import org.schabi.newpipe.ktx.ExceptionUtils; | ||||
| 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.ArrayList; | ||||
| import java.util.Collections; | ||||
| import java.util.List; | ||||
| import java.util.Objects; | ||||
|  | ||||
| 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); | ||||
|  | ||||
|         Flowable<List<SubscriptionItem>> flowable = null; | ||||
|         switch (currentMode) { | ||||
|             case CHANNEL_URL_MODE: | ||||
|                 flowable = importFromChannelUrl(); | ||||
|                 break; | ||||
|             case INPUT_STREAM_MODE: | ||||
|                 flowable = importFromInputStream(); | ||||
|                 break; | ||||
|             case PREVIOUS_EXPORT_MODE: | ||||
|                 flowable = importFromPreviousExport(); | ||||
|                 break; | ||||
|         } | ||||
|  | ||||
|         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<Pair<ChannelInfo, | ||||
|                         List<ChannelTabInfo>>>>) 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<List<SubscriptionEntity>> getSubscriber() { | ||||
|         return new Subscriber<>() { | ||||
|             @Override | ||||
|             public void onSubscribe(final Subscription s) { | ||||
|                 subscription = s; | ||||
|                 s.request(Long.MAX_VALUE); | ||||
|             } | ||||
|  | ||||
|             @Override | ||||
|             public void onNext(final List<SubscriptionEntity> 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<Notification<Pair<ChannelInfo, | ||||
|             List<ChannelTabInfo>>>> 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<Notification<Pair<ChannelInfo, List<ChannelTabInfo>>>>, | ||||
|             List<SubscriptionEntity>> upsertBatch() { | ||||
|         return notificationList -> { | ||||
|             final List<Pair<ChannelInfo, List<ChannelTabInfo>>> infoList = | ||||
|                     new ArrayList<>(notificationList.size()); | ||||
|             for (final Notification<Pair<ChannelInfo, List<ChannelTabInfo>>> n : notificationList) { | ||||
|                 if (n.isOnNext()) { | ||||
|                     infoList.add(n.getValue()); | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             return subscriptionManager.upsertAll(infoList); | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     private Flowable<List<SubscriptionItem>> importFromChannelUrl() { | ||||
|         return Flowable.fromCallable(() -> NewPipe.getService(currentServiceId) | ||||
|                 .getSubscriptionExtractor() | ||||
|                 .fromChannelUrl(channelUrl)); | ||||
|     } | ||||
|  | ||||
|     private Flowable<List<SubscriptionItem>> importFromInputStream() { | ||||
|         Objects.requireNonNull(inputStream); | ||||
|         Objects.requireNonNull(inputStreamType); | ||||
|  | ||||
|         return Flowable.fromCallable(() -> NewPipe.getService(currentServiceId) | ||||
|                 .getSubscriptionExtractor() | ||||
|                 .fromInputStream(inputStream, inputStreamType)); | ||||
|     } | ||||
|  | ||||
|     private Flowable<List<SubscriptionItem>> importFromPreviousExport() { | ||||
|         return Flowable.fromCallable(() -> ImportExportJsonHelper.readFrom(inputStream, null)); | ||||
|     } | ||||
|  | ||||
|     protected void handleError(@NonNull final Throwable error) { | ||||
|         super.handleError(R.string.subscriptions_import_unsuccessful, error); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,72 @@ | ||||
| /* | ||||
|  * 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.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 java.io.InputStream | ||||
| import java.io.OutputStream | ||||
|  | ||||
| /** | ||||
|  * A JSON implementation capable of importing and exporting subscriptions, it has the advantage | ||||
|  * of being able to transfer subscriptions to any device. | ||||
|  */ | ||||
| object ImportExportJsonHelper { | ||||
|     private val json = Json { encodeDefaults = true } | ||||
|  | ||||
|     /** | ||||
|      * Read a JSON source through the input stream. | ||||
|      * | ||||
|      * @param in            the input stream (e.g. a file) | ||||
|      * @return the parsed subscription items | ||||
|      */ | ||||
|     @JvmStatic | ||||
|     @Throws(InvalidSourceException::class) | ||||
|     fun readFrom(`in`: InputStream?): List<SubscriptionItem> { | ||||
|         if (`in` == null) { | ||||
|             throw InvalidSourceException("input is null") | ||||
|         } | ||||
|  | ||||
|         try { | ||||
|             @OptIn(ExperimentalSerializationApi::class) | ||||
|             return json.decodeFromStream<SubscriptionData>(`in`).subscriptions | ||||
|         } catch (e: Throwable) { | ||||
|             throw InvalidSourceException("Couldn't parse json", e) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 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) | ||||
|      */ | ||||
|     @OptIn(ExperimentalSerializationApi::class) | ||||
|     @JvmStatic | ||||
|     fun writeTo( | ||||
|         items: List<SubscriptionItem>, | ||||
|         out: OutputStream, | ||||
|     ) { | ||||
|         json.encodeToStream(SubscriptionData(items), out) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,24 @@ | ||||
| package org.schabi.newpipe.local.subscription.workers | ||||
|  | ||||
| import kotlinx.serialization.SerialName | ||||
| import kotlinx.serialization.Serializable | ||||
| import org.schabi.newpipe.BuildConfig | ||||
|  | ||||
| @Serializable | ||||
| class SubscriptionData( | ||||
|     val subscriptions: List<SubscriptionItem> | ||||
| ) { | ||||
|     @SerialName("app_version") | ||||
|     private val appVersion = BuildConfig.VERSION_NAME | ||||
|  | ||||
|     @SerialName("app_version_int") | ||||
|     private val appVersionInt = BuildConfig.VERSION_CODE | ||||
| } | ||||
|  | ||||
| @Serializable | ||||
| data class SubscriptionItem( | ||||
|     @SerialName("service_id") | ||||
|     val serviceId: Int, | ||||
|     val url: String, | ||||
|     val name: String | ||||
| ) | ||||
| @@ -0,0 +1,119 @@ | ||||
| package org.schabi.newpipe.local.subscription.workers | ||||
|  | ||||
| import android.content.Context | ||||
| import android.content.pm.ServiceInfo | ||||
| import android.net.Uri | ||||
| import android.os.Build | ||||
| import android.util.Log | ||||
| import android.widget.Toast | ||||
| import androidx.core.app.NotificationCompat | ||||
| import androidx.core.net.toUri | ||||
| import androidx.work.CoroutineWorker | ||||
| import androidx.work.ExistingWorkPolicy | ||||
| import androidx.work.ForegroundInfo | ||||
| import androidx.work.OneTimeWorkRequestBuilder | ||||
| import androidx.work.OutOfQuotaPolicy | ||||
| import androidx.work.WorkManager | ||||
| import androidx.work.WorkerParameters | ||||
| import androidx.work.workDataOf | ||||
| import kotlinx.coroutines.Dispatchers | ||||
| import kotlinx.coroutines.reactive.awaitFirst | ||||
| import kotlinx.coroutines.withContext | ||||
| import org.schabi.newpipe.BuildConfig | ||||
| import org.schabi.newpipe.NewPipeDatabase | ||||
| import org.schabi.newpipe.R | ||||
|  | ||||
| class SubscriptionExportWorker( | ||||
|     appContext: Context, | ||||
|     params: WorkerParameters, | ||||
| ) : CoroutineWorker(appContext, params) { | ||||
|     // This is needed for API levels < 31 (Android S). | ||||
|     override suspend fun getForegroundInfo(): ForegroundInfo { | ||||
|         return createForegroundInfo(applicationContext.getString(R.string.export_ongoing)) | ||||
|     } | ||||
|  | ||||
|     override suspend fun doWork(): Result { | ||||
|         return try { | ||||
|             val uri = inputData.getString(EXPORT_PATH)!!.toUri() | ||||
|             val table = NewPipeDatabase.getInstance(applicationContext).subscriptionDAO() | ||||
|             val subscriptions = | ||||
|                 table.all | ||||
|                     .awaitFirst() | ||||
|                     .map { SubscriptionItem(it.serviceId, it.url, it.name) } | ||||
|  | ||||
|             val qty = subscriptions.size | ||||
|             val title = applicationContext.resources.getQuantityString(R.plurals.export_subscriptions, qty, qty) | ||||
|             setForeground(createForegroundInfo(title)) | ||||
|  | ||||
|             withContext(Dispatchers.IO) { | ||||
|                 // Truncate file if it already exists | ||||
|                 applicationContext.contentResolver.openOutputStream(uri, "wt")?.use { | ||||
|                     ImportExportJsonHelper.writeTo(subscriptions, it) | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             if (BuildConfig.DEBUG) { | ||||
|                 Log.i(TAG, "Exported $qty subscriptions") | ||||
|             } | ||||
|  | ||||
|             withContext(Dispatchers.Main) { | ||||
|                 Toast | ||||
|                     .makeText(applicationContext, R.string.export_complete_toast, Toast.LENGTH_SHORT) | ||||
|                     .show() | ||||
|             } | ||||
|  | ||||
|             Result.success() | ||||
|         } catch (e: Exception) { | ||||
|             if (BuildConfig.DEBUG) { | ||||
|                 Log.e(TAG, "Error while exporting subscriptions", e) | ||||
|             } | ||||
|  | ||||
|             withContext(Dispatchers.Main) { | ||||
|                 Toast | ||||
|                     .makeText(applicationContext, R.string.subscriptions_export_unsuccessful, Toast.LENGTH_SHORT) | ||||
|                     .show() | ||||
|             } | ||||
|  | ||||
|             return Result.failure() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun createForegroundInfo(title: String): ForegroundInfo { | ||||
|         val notification = | ||||
|             NotificationCompat | ||||
|                 .Builder(applicationContext, NOTIFICATION_CHANNEL_ID) | ||||
|                 .setSmallIcon(R.drawable.ic_newpipe_triangle_white) | ||||
|                 .setOngoing(true) | ||||
|                 .setProgress(-1, -1, true) | ||||
|                 .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) | ||||
|                 .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) | ||||
|                 .setContentTitle(title) | ||||
|                 .build() | ||||
|         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 TAG = "SubscriptionExportWork" | ||||
|         private const val NOTIFICATION_ID = 4567 | ||||
|         private const val NOTIFICATION_CHANNEL_ID = "newpipe" | ||||
|         private const val WORK_NAME = "exportSubscriptions" | ||||
|         private const val EXPORT_PATH = "exportPath" | ||||
|  | ||||
|         fun schedule( | ||||
|             context: Context, | ||||
|             uri: Uri, | ||||
|         ) { | ||||
|             val data = workDataOf(EXPORT_PATH to uri.toString()) | ||||
|             val workRequest = | ||||
|                 OneTimeWorkRequestBuilder<SubscriptionExportWorker>() | ||||
|                     .setInputData(data) | ||||
|                     .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) | ||||
|                     .build() | ||||
|  | ||||
|             WorkManager | ||||
|                 .getInstance(context) | ||||
|                 .enqueueUniqueWork(WORK_NAME, ExistingWorkPolicy.APPEND_OR_REPLACE, workRequest) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,237 @@ | ||||
| package org.schabi.newpipe.local.subscription.workers | ||||
|  | ||||
| import android.content.Context | ||||
| import android.content.pm.ServiceInfo | ||||
| import android.os.Build | ||||
| import android.os.Parcelable | ||||
| import android.util.Log | ||||
| import android.webkit.MimeTypeMap | ||||
| import android.widget.Toast | ||||
| import androidx.core.app.NotificationCompat | ||||
| import androidx.core.net.toUri | ||||
| import androidx.work.CoroutineWorker | ||||
| import androidx.work.Data | ||||
| import androidx.work.ForegroundInfo | ||||
| import androidx.work.WorkManager | ||||
| import androidx.work.WorkerParameters | ||||
| import androidx.work.workDataOf | ||||
| 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 kotlinx.parcelize.Parcelize | ||||
| import org.schabi.newpipe.BuildConfig | ||||
| import org.schabi.newpipe.R | ||||
| import org.schabi.newpipe.extractor.NewPipe | ||||
| import org.schabi.newpipe.local.subscription.SubscriptionManager | ||||
| import org.schabi.newpipe.util.ExtractorHelper | ||||
|  | ||||
| class SubscriptionImportWorker( | ||||
|     appContext: Context, | ||||
|     params: WorkerParameters, | ||||
| ) : CoroutineWorker(appContext, params) { | ||||
|     // This is needed for API levels < 31 (Android S). | ||||
|     override suspend fun getForegroundInfo(): ForegroundInfo { | ||||
|         return createForegroundInfo(applicationContext.getString(R.string.import_ongoing), null, 0, 0) | ||||
|     } | ||||
|  | ||||
|     override suspend fun doWork(): Result { | ||||
|         val subscriptions = | ||||
|             try { | ||||
|                 loadSubscriptionsFromInput(SubscriptionImportInput.fromData(inputData)) | ||||
|             } catch (e: Exception) { | ||||
|                 if (BuildConfig.DEBUG) { | ||||
|                     Log.e(TAG, "Error while loading subscriptions from path", e) | ||||
|                 } | ||||
|                 withContext(Dispatchers.Main) { | ||||
|                     Toast | ||||
|                         .makeText(applicationContext, R.string.subscriptions_import_unsuccessful, Toast.LENGTH_SHORT) | ||||
|                         .show() | ||||
|                 } | ||||
|                 return Result.failure() | ||||
|             } | ||||
|  | ||||
|         val mutex = Mutex() | ||||
|         var index = 1 | ||||
|         val qty = subscriptions.size | ||||
|         var title = | ||||
|             applicationContext.resources.getQuantityString(R.plurals.load_subscriptions, qty, qty) | ||||
|  | ||||
|         val channelInfoList = | ||||
|             try { | ||||
|                 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++ } | ||||
|                                 setForeground(createForegroundInfo(title, channelInfo.name, currentIndex, qty)) | ||||
|  | ||||
|                                 channelInfo to channelTab | ||||
|                             } | ||||
|                         }.awaitAll() | ||||
|                 } | ||||
|             } catch (e: Exception) { | ||||
|                 if (BuildConfig.DEBUG) { | ||||
|                     Log.e(TAG, "Error while loading subscription data", e) | ||||
|                 } | ||||
|                 withContext(Dispatchers.Main) { | ||||
|                     Toast.makeText(applicationContext, R.string.subscriptions_import_unsuccessful, Toast.LENGTH_SHORT) | ||||
|                         .show() | ||||
|                 } | ||||
|                 return Result.failure() | ||||
|             } | ||||
|  | ||||
|         title = applicationContext.resources.getQuantityString(R.plurals.import_subscriptions, qty, qty) | ||||
|         setForeground(createForegroundInfo(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(title, null, index, qty)) | ||||
|         } | ||||
|  | ||||
|         withContext(Dispatchers.Main) { | ||||
|             Toast.makeText(applicationContext, R.string.import_complete_toast, Toast.LENGTH_SHORT) | ||||
|                 .show() | ||||
|         } | ||||
|  | ||||
|         return Result.success() | ||||
|     } | ||||
|  | ||||
|     private suspend fun loadSubscriptionsFromInput(input: SubscriptionImportInput): List<SubscriptionItem> { | ||||
|         return withContext(Dispatchers.IO) { | ||||
|             when (input) { | ||||
|                 is SubscriptionImportInput.ChannelUrlMode -> | ||||
|                     NewPipe.getService(input.serviceId).subscriptionExtractor | ||||
|                         .fromChannelUrl(input.url) | ||||
|                         .map { SubscriptionItem(it.serviceId, it.url, it.name) } | ||||
|  | ||||
|                 is SubscriptionImportInput.InputStreamMode -> | ||||
|                     applicationContext.contentResolver.openInputStream(input.url.toUri())?.use { | ||||
|                         val contentType = | ||||
|                             MimeTypeMap.getFileExtensionFromUrl(input.url).ifEmpty { DEFAULT_MIME } | ||||
|                         NewPipe.getService(input.serviceId).subscriptionExtractor | ||||
|                             .fromInputStream(it, contentType) | ||||
|                             .map { SubscriptionItem(it.serviceId, it.url, it.name) } | ||||
|                     } | ||||
|  | ||||
|                 is SubscriptionImportInput.PreviousExportMode -> | ||||
|                     applicationContext.contentResolver.openInputStream(input.url.toUri())?.use { | ||||
|                         ImportExportJsonHelper.readFrom(it) | ||||
|                     } | ||||
|             } ?: emptyList() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun createForegroundInfo( | ||||
|         title: String, | ||||
|         text: String?, | ||||
|         currentProgress: Int, | ||||
|         maxProgress: Int, | ||||
|     ): ForegroundInfo { | ||||
|         val 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() | ||||
|         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 { | ||||
|         // Log tag length is limited to 23 characters on API levels < 24. | ||||
|         private const val TAG = "SubscriptionImport" | ||||
|  | ||||
|         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" | ||||
|     } | ||||
| } | ||||
|  | ||||
| sealed class SubscriptionImportInput : Parcelable { | ||||
|     @Parcelize | ||||
|     data class ChannelUrlMode(val serviceId: Int, val url: String) : SubscriptionImportInput() | ||||
|     @Parcelize | ||||
|     data class InputStreamMode(val serviceId: Int, val url: String) : SubscriptionImportInput() | ||||
|     @Parcelize | ||||
|     data class PreviousExportMode(val url: String) : SubscriptionImportInput() | ||||
|  | ||||
|     fun toData(): Data { | ||||
|         val (mode, serviceId, url) = when (this) { | ||||
|             is ChannelUrlMode -> Triple(CHANNEL_URL_MODE, serviceId, url) | ||||
|             is InputStreamMode -> Triple(INPUT_STREAM_MODE, serviceId, url) | ||||
|             is PreviousExportMode -> Triple(PREVIOUS_EXPORT_MODE, null, url) | ||||
|         } | ||||
|         return workDataOf("mode" to mode, "service_id" to serviceId, "url" to url) | ||||
|     } | ||||
|  | ||||
|     companion object { | ||||
|  | ||||
|         private const val CHANNEL_URL_MODE = 0 | ||||
|         private const val INPUT_STREAM_MODE = 1 | ||||
|         private const val PREVIOUS_EXPORT_MODE = 2 | ||||
|  | ||||
|         fun fromData(data: Data): SubscriptionImportInput { | ||||
|             val mode = data.getInt("mode", PREVIOUS_EXPORT_MODE) | ||||
|             when (mode) { | ||||
|                 CHANNEL_URL_MODE -> { | ||||
|                     val serviceId = data.getInt("service_id", -1) | ||||
|                     if (serviceId == -1) { | ||||
|                         throw IllegalArgumentException("No service id provided") | ||||
|                     } | ||||
|                     val url = data.getString("url")!! | ||||
|                     return ChannelUrlMode(serviceId, url) | ||||
|                 } | ||||
|                 INPUT_STREAM_MODE -> { | ||||
|                     val serviceId = data.getInt("service_id", -1) | ||||
|                     if (serviceId == -1) { | ||||
|                         throw IllegalArgumentException("No service id provided") | ||||
|                     } | ||||
|                     val url = data.getString("url")!! | ||||
|                     return InputStreamMode(serviceId, url) | ||||
|                 } | ||||
|                 PREVIOUS_EXPORT_MODE -> { | ||||
|                     val url = data.getString("url")!! | ||||
|                     return PreviousExportMode(url) | ||||
|                 } | ||||
|                 else -> throw IllegalArgumentException("Unknown mode: $mode") | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -864,4 +864,16 @@ | ||||
|         <item quantity="one">%d comment</item> | ||||
|         <item quantity="other">%d comments</item> | ||||
|     </plurals> | ||||
|     <plurals name="export_subscriptions"> | ||||
|         <item quantity="one">Exporting %d subscription…</item> | ||||
|         <item quantity="other">Exporting %d subscriptions…</item> | ||||
|     </plurals> | ||||
|     <plurals name="load_subscriptions"> | ||||
|         <item quantity="one">Loading %d subscription…</item> | ||||
|         <item quantity="other">Loading %d subscriptions…</item> | ||||
|     </plurals> | ||||
|     <plurals name="import_subscriptions"> | ||||
|         <item quantity="one">Importing %d subscription…</item> | ||||
|         <item quantity="other">Importing %d subscriptions…</item> | ||||
|     </plurals> | ||||
| </resources> | ||||
|   | ||||
| @@ -5,11 +5,11 @@ import static org.junit.Assert.fail; | ||||
|  | ||||
| import org.junit.Test; | ||||
| import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor; | ||||
| import org.schabi.newpipe.extractor.subscription.SubscriptionItem; | ||||
| import org.schabi.newpipe.local.subscription.workers.ImportExportJsonHelper; | ||||
| import org.schabi.newpipe.local.subscription.workers.SubscriptionItem; | ||||
|  | ||||
| import java.io.ByteArrayInputStream; | ||||
| import java.io.ByteArrayOutputStream; | ||||
| import java.io.InputStream; | ||||
| import java.nio.charset.StandardCharsets; | ||||
| import java.util.Arrays; | ||||
| import java.util.List; | ||||
| @@ -23,26 +23,22 @@ public class ImportExportJsonHelperTest { | ||||
|         final String emptySource = | ||||
|                 "{\"app_version\":\"0.11.6\",\"app_version_int\": 47,\"subscriptions\":[]}"; | ||||
|  | ||||
|         final List<SubscriptionItem> items = ImportExportJsonHelper.readFrom( | ||||
|                 new ByteArrayInputStream(emptySource.getBytes(StandardCharsets.UTF_8)), null); | ||||
|         final var items = ImportExportJsonHelper.readFrom( | ||||
|                 new ByteArrayInputStream(emptySource.getBytes(StandardCharsets.UTF_8))); | ||||
|         assertTrue(items.isEmpty()); | ||||
|     } | ||||
|  | ||||
|     @Test | ||||
|     public void testInvalidSource() { | ||||
|         final List<String> invalidList = Arrays.asList( | ||||
|                 "{}", | ||||
|                 "", | ||||
|                 null, | ||||
|                 "gibberish"); | ||||
|         final var invalidList = Arrays.asList("{}", "", null, "gibberish"); | ||||
|  | ||||
|         for (final String invalidContent : invalidList) { | ||||
|             try { | ||||
|                 if (invalidContent != null) { | ||||
|                     final byte[] bytes = invalidContent.getBytes(StandardCharsets.UTF_8); | ||||
|                     ImportExportJsonHelper.readFrom((new ByteArrayInputStream(bytes)), null); | ||||
|                     ImportExportJsonHelper.readFrom(new ByteArrayInputStream(bytes)); | ||||
|                 } else { | ||||
|                     ImportExportJsonHelper.readFrom(null, null); | ||||
|                     ImportExportJsonHelper.readFrom(null); | ||||
|                 } | ||||
|  | ||||
|                 fail("didn't throw exception"); | ||||
| @@ -58,38 +54,24 @@ public class ImportExportJsonHelperTest { | ||||
|     @Test | ||||
|     public void ultimateTest() throws Exception { | ||||
|         // Read from file | ||||
|         final List<SubscriptionItem> itemsFromFile = readFromFile(); | ||||
|         final var itemsFromFile = readFromFile(); | ||||
|  | ||||
|         // Test writing to an output | ||||
|         final String jsonOut = testWriteTo(itemsFromFile); | ||||
|  | ||||
|         // Read again | ||||
|         final List<SubscriptionItem> itemsSecondRead = readFromWriteTo(jsonOut); | ||||
|         final var itemsSecondRead = readFromWriteTo(jsonOut); | ||||
|  | ||||
|         // Check if both lists have the exact same items | ||||
|         if (itemsFromFile.size() != itemsSecondRead.size()) { | ||||
|         if (!itemsFromFile.equals(itemsSecondRead)) { | ||||
|             fail("The list of items were different from each other"); | ||||
|         } | ||||
|  | ||||
|         for (int i = 0; i < itemsFromFile.size(); i++) { | ||||
|             final SubscriptionItem item1 = itemsFromFile.get(i); | ||||
|             final SubscriptionItem item2 = itemsSecondRead.get(i); | ||||
|  | ||||
|             final boolean equals = item1.getServiceId() == item2.getServiceId() | ||||
|                     && item1.getUrl().equals(item2.getUrl()) | ||||
|                     && item1.getName().equals(item2.getName()); | ||||
|  | ||||
|             if (!equals) { | ||||
|                 fail("The list of items were different from each other"); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private List<SubscriptionItem> readFromFile() throws Exception { | ||||
|         final InputStream inputStream = getClass().getClassLoader().getResourceAsStream( | ||||
|                 "import_export_test.json"); | ||||
|         final List<SubscriptionItem> itemsFromFile = ImportExportJsonHelper.readFrom( | ||||
|                 inputStream, null); | ||||
|         final var inputStream = getClass().getClassLoader() | ||||
|                 .getResourceAsStream("import_export_test.json"); | ||||
|         final var itemsFromFile = ImportExportJsonHelper.readFrom(inputStream); | ||||
|  | ||||
|         if (itemsFromFile.isEmpty()) { | ||||
|             fail("ImportExportJsonHelper.readFrom(input) returned a null or empty list"); | ||||
| @@ -98,10 +80,10 @@ public class ImportExportJsonHelperTest { | ||||
|         return itemsFromFile; | ||||
|     } | ||||
|  | ||||
|     private String testWriteTo(final List<SubscriptionItem> itemsFromFile) throws Exception { | ||||
|         final ByteArrayOutputStream out = new ByteArrayOutputStream(); | ||||
|         ImportExportJsonHelper.writeTo(itemsFromFile, out, null); | ||||
|         final String jsonOut = out.toString("UTF-8"); | ||||
|     private String testWriteTo(final List<SubscriptionItem> itemsFromFile) { | ||||
|         final var out = new ByteArrayOutputStream(); | ||||
|         ImportExportJsonHelper.writeTo(itemsFromFile, out); | ||||
|         final String jsonOut = out.toString(StandardCharsets.UTF_8); | ||||
|  | ||||
|         if (jsonOut.isEmpty()) { | ||||
|             fail("JSON returned by writeTo was empty"); | ||||
| @@ -111,10 +93,8 @@ public class ImportExportJsonHelperTest { | ||||
|     } | ||||
|  | ||||
|     private List<SubscriptionItem> readFromWriteTo(final String jsonOut) throws Exception { | ||||
|         final ByteArrayInputStream inputStream = new ByteArrayInputStream( | ||||
|                 jsonOut.getBytes(StandardCharsets.UTF_8)); | ||||
|         final List<SubscriptionItem> secondReadItems = ImportExportJsonHelper.readFrom( | ||||
|                 inputStream, null); | ||||
|         final var inputStream = new ByteArrayInputStream(jsonOut.getBytes(StandardCharsets.UTF_8)); | ||||
|         final var secondReadItems = ImportExportJsonHelper.readFrom(inputStream); | ||||
|  | ||||
|         if (secondReadItems.isEmpty()) { | ||||
|             fail("second call to readFrom returned an empty list"); | ||||
|   | ||||
| @@ -11,6 +11,7 @@ buildscript { | ||||
|         classpath libs.kotlin.gradle.plugin | ||||
|         classpath libs.hilt.android.gradle.plugin | ||||
|         classpath libs.aboutlibraries.plugin | ||||
|         classpath libs.kotlinx.serialization | ||||
|  | ||||
|         // NOTE: Do not place your application dependencies here; they belong | ||||
|         // in the individual module build.gradle files | ||||
|   | ||||
| @@ -24,11 +24,11 @@ jsoup = "1.17.2" | ||||
| junit = "4.13.2" | ||||
| kotlin = "2.0.21" | ||||
| kotlinxCoroutinesRx3 = "1.8.1" | ||||
| kotlinxSerializationJson = "1.7.3" | ||||
| ktlint = "0.45.2" | ||||
| lazycolumnscrollbar = "2.2.0" | ||||
| leakcanary = "2.12" | ||||
| lifecycle = "2.6.2" | ||||
| localbroadcastmanager = "1.1.0" | ||||
| markwon = "4.6.2" | ||||
| material = "1.11.0" | ||||
| media = "1.7.0" | ||||
| @@ -60,7 +60,7 @@ teamnewpipe-nanojson = "1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751" | ||||
| # to cause jitpack to regenerate the artifact. | ||||
| teamnewpipe-newpipe-extractor = "v0.24.6" | ||||
| webkit = "1.9.0" | ||||
| work = "2.8.1" | ||||
| work = "2.10.0" | ||||
|  | ||||
| [plugins] | ||||
| aboutlibraries = { id = "com.mikepenz.aboutlibraries.plugin" } | ||||
| @@ -71,6 +71,7 @@ kotlin-android = { id = "kotlin-android" } | ||||
| kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } | ||||
| kotlin-kapt = { id = "kotlin-kapt" } | ||||
| kotlin-parcelize = { id = "kotlin-parcelize" } | ||||
| kotlinx-serialization = { id = "kotlinx-serialization" } | ||||
| sonarqube = { id = "org.sonarqube", version.ref = "sonarqube" } | ||||
|  | ||||
| [libraries] | ||||
| @@ -98,7 +99,6 @@ androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "a | ||||
| androidx-lifecycle-livedata = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycle" } | ||||
| androidx-lifecycle-viewmodel = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" } | ||||
| androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose" } | ||||
| androidx-localbroadcastmanager = { group = "androidx.localbroadcastmanager", name = "localbroadcastmanager", version.ref = "localbroadcastmanager" } | ||||
| androidx-material = { group = "com.google.android.material", name = "material", version.ref = "material" } | ||||
| androidx-media = { group = "androidx.media", name = "media", version.ref = "media" } | ||||
| androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } | ||||
| @@ -136,6 +136,8 @@ junit = { group = "junit", name = "junit", version.ref = "junit" } | ||||
| kotlin-gradle-plugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } | ||||
| kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlin" } | ||||
| kotlinx-coroutines-rx3 = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-rx3", version.ref = "kotlinxCoroutinesRx3" } | ||||
| kotlinx-serialization = { group = "org.jetbrains.kotlin", name = "kotlin-serialization", version.ref = "kotlin" } | ||||
| kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } | ||||
| lazycolumnscrollbar = { group = "com.github.nanihadesuka", name = "LazyColumnScrollbar", version.ref = "lazycolumnscrollbar" } | ||||
| leakcanary-android-core = { module = "com.squareup.leakcanary:leakcanary-android-core", version.ref = "leakcanary" } | ||||
| leakcanary-object-watcher = { group = "com.squareup.leakcanary", name = "leakcanary-object-watcher-android", version.ref = "leakcanary" } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Isira Seneviratne
					Isira Seneviratne