mirror of
				https://github.com/TeamNewPipe/NewPipe
				synced 2025-10-31 07:13: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.compose | ||||||
|     alias libs.plugins.kotlin.kapt |     alias libs.plugins.kotlin.kapt | ||||||
|     alias libs.plugins.kotlin.parcelize |     alias libs.plugins.kotlin.parcelize | ||||||
|  |     alias libs.plugins.kotlinx.serialization | ||||||
|     alias libs.plugins.checkstyle |     alias libs.plugins.checkstyle | ||||||
|     alias libs.plugins.sonarqube |     alias libs.plugins.sonarqube | ||||||
|     alias libs.plugins.hilt |     alias libs.plugins.hilt | ||||||
| @@ -16,7 +17,7 @@ plugins { | |||||||
| } | } | ||||||
|  |  | ||||||
| android { | android { | ||||||
|     compileSdk 34 |     compileSdk 35 | ||||||
|     namespace 'org.schabi.newpipe' |     namespace 'org.schabi.newpipe' | ||||||
|  |  | ||||||
|     defaultConfig { |     defaultConfig { | ||||||
| @@ -226,7 +227,6 @@ dependencies { | |||||||
|     implementation libs.androidx.fragment.compose |     implementation libs.androidx.fragment.compose | ||||||
|     implementation libs.androidx.lifecycle.livedata |     implementation libs.androidx.lifecycle.livedata | ||||||
|     implementation libs.androidx.lifecycle.viewmodel |     implementation libs.androidx.lifecycle.viewmodel | ||||||
|     implementation libs.androidx.localbroadcastmanager |  | ||||||
|     implementation libs.androidx.media |     implementation libs.androidx.media | ||||||
|     implementation libs.androidx.preference |     implementation libs.androidx.preference | ||||||
|     implementation libs.androidx.recyclerview |     implementation libs.androidx.recyclerview | ||||||
| @@ -319,6 +319,9 @@ dependencies { | |||||||
|     // Scroll |     // Scroll | ||||||
|     implementation libs.lazycolumnscrollbar |     implementation libs.lazycolumnscrollbar | ||||||
|  |  | ||||||
|  |     // Kotlinx Serialization | ||||||
|  |     implementation libs.kotlinx.serialization.json | ||||||
|  |  | ||||||
| /** Debugging **/ | /** Debugging **/ | ||||||
|     // Memory leak detection |     // Memory leak detection | ||||||
|     debugImplementation libs.leakcanary.object.watcher |     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) | ## For some reason NotificationModeConfigFragment wasn't kept (only referenced in a preference xml) | ||||||
| -keep class org.schabi.newpipe.settings.notifications.** { *; } | -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.WRITE_EXTERNAL_STORAGE" /> | ||||||
|     <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" /> |     <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" /> | ||||||
|  |     <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> | ||||||
|     <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> |     <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> | ||||||
|  |  | ||||||
|     <!-- We need to be able to open links in the browser on API 30+ --> |     <!-- We need to be able to open links in the browser on API 30+ --> | ||||||
| @@ -90,8 +91,10 @@ | |||||||
|             android:exported="false" |             android:exported="false" | ||||||
|             android:label="@string/title_activity_about" /> |             android:label="@string/title_activity_about" /> | ||||||
|  |  | ||||||
|         <service android:name=".local.subscription.services.SubscriptionsImportService" /> |         <service | ||||||
|         <service android:name=".local.subscription.services.SubscriptionsExportService" /> |             android:name="androidx.work.impl.foreground.SystemForegroundService" | ||||||
|  |             android:foregroundServiceType="dataSync" | ||||||
|  |             tools:node="merge" /> | ||||||
|         <service android:name=".local.feed.service.FeedLoadService" /> |         <service android:name=".local.feed.service.FeedLoadService" /> | ||||||
|  |  | ||||||
|         <activity |         <activity | ||||||
|   | |||||||
| @@ -90,7 +90,7 @@ abstract class SubscriptionDAO : BasicDAO<SubscriptionEntity> { | |||||||
|     internal abstract fun silentInsertAllInternal(entities: List<SubscriptionEntity>): List<Long> |     internal abstract fun silentInsertAllInternal(entities: List<SubscriptionEntity>): List<Long> | ||||||
|  |  | ||||||
|     @Transaction |     @Transaction | ||||||
|     open fun upsertAll(entities: List<SubscriptionEntity>): List<SubscriptionEntity> { |     open fun upsertAll(entities: List<SubscriptionEntity>) { | ||||||
|         val insertUidList = silentInsertAllInternal(entities) |         val insertUidList = silentInsertAllInternal(entities) | ||||||
|  |  | ||||||
|         insertUidList.forEachIndexed { index: Int, uidFromInsert: Long -> |         insertUidList.forEachIndexed { index: Int, uidFromInsert: Long -> | ||||||
| @@ -106,7 +106,5 @@ abstract class SubscriptionDAO : BasicDAO<SubscriptionEntity> { | |||||||
|                 update(entity) |                 update(entity) | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         return entities |  | ||||||
|     } |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -3,47 +3,64 @@ package org.schabi.newpipe.local.subscription; | |||||||
| import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; | import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; | ||||||
|  |  | ||||||
| import android.app.Dialog; | import android.app.Dialog; | ||||||
| import android.content.Intent; |  | ||||||
| import android.os.Bundle; | import android.os.Bundle; | ||||||
|  |  | ||||||
| import androidx.annotation.NonNull; | import androidx.annotation.NonNull; | ||||||
| import androidx.annotation.Nullable; | import androidx.annotation.Nullable; | ||||||
| import androidx.appcompat.app.AlertDialog; | import androidx.appcompat.app.AlertDialog; | ||||||
|  | import androidx.core.os.BundleCompat; | ||||||
| import androidx.fragment.app.DialogFragment; | import androidx.fragment.app.DialogFragment; | ||||||
| import androidx.fragment.app.Fragment; | 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 com.livefront.bridge.Bridge; | ||||||
|  |  | ||||||
| import org.schabi.newpipe.R; | 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 { | public class ImportConfirmationDialog extends DialogFragment { | ||||||
|     @State |     private static final String INPUT = "input"; | ||||||
|     protected Intent resultServiceIntent; |  | ||||||
|  |  | ||||||
|     public static void show(@NonNull final Fragment fragment, |     public static void show(@NonNull final Fragment fragment, final SubscriptionImportInput input) { | ||||||
|                             @NonNull final Intent resultServiceIntent) { |         final var confirmationDialog = new ImportConfirmationDialog(); | ||||||
|         final ImportConfirmationDialog confirmationDialog = new ImportConfirmationDialog(); |         final var arguments = new Bundle(); | ||||||
|         confirmationDialog.setResultServiceIntent(resultServiceIntent); |         arguments.putParcelable(INPUT, input); | ||||||
|  |         confirmationDialog.setArguments(arguments); | ||||||
|         confirmationDialog.show(fragment.getParentFragmentManager(), null); |         confirmationDialog.show(fragment.getParentFragmentManager(), null); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public void setResultServiceIntent(final Intent resultServiceIntent) { |  | ||||||
|         this.resultServiceIntent = resultServiceIntent; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     @NonNull |     @NonNull | ||||||
|     @Override |     @Override | ||||||
|     public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) { |     public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) { | ||||||
|         assureCorrectAppLanguage(getContext()); |         final var context = requireContext(); | ||||||
|         return new AlertDialog.Builder(requireContext()) |         assureCorrectAppLanguage(context); | ||||||
|  |         return new AlertDialog.Builder(context) | ||||||
|                 .setMessage(R.string.import_network_expensive_warning) |                 .setMessage(R.string.import_network_expensive_warning) | ||||||
|                 .setCancelable(true) |                 .setCancelable(true) | ||||||
|                 .setNegativeButton(R.string.cancel, null) |                 .setNegativeButton(R.string.cancel, null) | ||||||
|                 .setPositiveButton(R.string.ok, (dialogInterface, i) -> { |                 .setPositiveButton(R.string.ok, (dialogInterface, i) -> { | ||||||
|                     if (resultServiceIntent != null && getContext() != null) { |                     final var constraints = new Constraints.Builder() | ||||||
|                         getContext().startService(resultServiceIntent); |                             .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(); |                     dismiss(); | ||||||
|                 }) |                 }) | ||||||
|                 .create(); |                 .create(); | ||||||
| @@ -53,10 +70,6 @@ public class ImportConfirmationDialog extends DialogFragment { | |||||||
|     public void onCreate(@Nullable final Bundle savedInstanceState) { |     public void onCreate(@Nullable final Bundle savedInstanceState) { | ||||||
|         super.onCreate(savedInstanceState); |         super.onCreate(savedInstanceState); | ||||||
|  |  | ||||||
|         if (resultServiceIntent == null) { |  | ||||||
|             throw new IllegalStateException("Result intent is null"); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         Bridge.restoreInstanceState(this, savedInstanceState); |         Bridge.restoreInstanceState(this, savedInstanceState); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -3,7 +3,6 @@ package org.schabi.newpipe.local.subscription | |||||||
| import android.app.Activity | import android.app.Activity | ||||||
| import android.content.Context | import android.content.Context | ||||||
| import android.content.DialogInterface | import android.content.DialogInterface | ||||||
| import android.content.Intent |  | ||||||
| import android.os.Bundle | import android.os.Bundle | ||||||
| import android.os.Parcelable | import android.os.Parcelable | ||||||
| import android.view.LayoutInflater | 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.GroupsHeader | ||||||
| import org.schabi.newpipe.local.subscription.item.Header | import org.schabi.newpipe.local.subscription.item.Header | ||||||
| import org.schabi.newpipe.local.subscription.item.ImportSubscriptionsHintPlaceholderItem | import org.schabi.newpipe.local.subscription.item.ImportSubscriptionsHintPlaceholderItem | ||||||
| import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService | import org.schabi.newpipe.local.subscription.workers.SubscriptionExportWorker | ||||||
| import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService | import org.schabi.newpipe.local.subscription.workers.SubscriptionImportInput | ||||||
| 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.streams.io.NoFileManagerSafeGuard | import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard | ||||||
| import org.schabi.newpipe.streams.io.StoredFileHelper | import org.schabi.newpipe.streams.io.StoredFileHelper | ||||||
| import org.schabi.newpipe.ui.emptystate.setEmptyStateComposable | import org.schabi.newpipe.ui.emptystate.setEmptyStateComposable | ||||||
| @@ -224,21 +220,17 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     private fun requestExportResult(result: ActivityResult) { |     private fun requestExportResult(result: ActivityResult) { | ||||||
|         if (result.data != null && result.resultCode == Activity.RESULT_OK) { |         val data = result.data?.data | ||||||
|             activity.startService( |         if (data != null && result.resultCode == Activity.RESULT_OK) { | ||||||
|                 Intent(activity, SubscriptionsExportService::class.java) |             SubscriptionExportWorker.schedule(activity, data) | ||||||
|                     .putExtra(SubscriptionsExportService.KEY_FILE_PATH, result.data?.data) |  | ||||||
|             ) |  | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     private fun requestImportResult(result: ActivityResult) { |     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( |             ImportConfirmationDialog.show( | ||||||
|                 this, |                 this, SubscriptionImportInput.PreviousExportMode(data) | ||||||
|                 Intent(activity, SubscriptionsImportService::class.java) |  | ||||||
|                     .putExtra(KEY_MODE, PREVIOUS_EXPORT_MODE) |  | ||||||
|                     .putExtra(KEY_VALUE, result.data?.data) |  | ||||||
|             ) |             ) | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -1,7 +1,6 @@ | |||||||
| package org.schabi.newpipe.local.subscription | package org.schabi.newpipe.local.subscription | ||||||
|  |  | ||||||
| import android.content.Context | import android.content.Context | ||||||
| import android.util.Pair |  | ||||||
| import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers | import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers | ||||||
| import io.reactivex.rxjava3.core.Completable | import io.reactivex.rxjava3.core.Completable | ||||||
| import io.reactivex.rxjava3.core.Flowable | import io.reactivex.rxjava3.core.Flowable | ||||||
| @@ -48,23 +47,16 @@ class SubscriptionManager(context: Context) { | |||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     fun upsertAll(infoList: List<Pair<ChannelInfo, List<ChannelTabInfo>>>): List<SubscriptionEntity> { |     fun upsertAll(infoList: List<Pair<ChannelInfo, ChannelTabInfo>>) { | ||||||
|         val listEntities = subscriptionTable.upsertAll( |         val listEntities = infoList.map { SubscriptionEntity.from(it.first) } | ||||||
|             infoList.map { SubscriptionEntity.from(it.first) } |         subscriptionTable.upsertAll(listEntities) | ||||||
|         ) |  | ||||||
|  |  | ||||||
|         database.runInTransaction { |         database.runInTransaction { | ||||||
|             infoList.forEachIndexed { index, info -> |             infoList.forEachIndexed { index, info -> | ||||||
|                 info.second.forEach { |                 val streams = info.second.relatedItems.filterIsInstance<StreamInfoItem>() | ||||||
|                     feedDatabaseManager.upsertAll( |                 feedDatabaseManager.upsertAll(listEntities[index].uid, streams) | ||||||
|                         listEntities[index].uid, |  | ||||||
|                         it.relatedItems.filterIsInstance<StreamInfoItem>() |  | ||||||
|                     ) |  | ||||||
|                 } |  | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         return listEntities |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     fun updateChannelInfo(info: ChannelInfo): Completable = |     fun updateChannelInfo(info: ChannelInfo): Completable = | ||||||
|   | |||||||
| @@ -1,10 +1,6 @@ | |||||||
| package org.schabi.newpipe.local.subscription; | package org.schabi.newpipe.local.subscription; | ||||||
|  |  | ||||||
| import static org.schabi.newpipe.extractor.subscription.SubscriptionExtractor.ContentSource.CHANNEL_URL; | 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.app.Activity; | ||||||
| import android.content.Intent; | 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.NewPipe; | ||||||
| import org.schabi.newpipe.extractor.exceptions.ExtractionException; | import org.schabi.newpipe.extractor.exceptions.ExtractionException; | ||||||
| import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor; | 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.NoFileManagerSafeGuard; | ||||||
| import org.schabi.newpipe.streams.io.StoredFileHelper; | import org.schabi.newpipe.streams.io.StoredFileHelper; | ||||||
| import org.schabi.newpipe.util.Constants; | import org.schabi.newpipe.util.Constants; | ||||||
| @@ -168,10 +164,8 @@ public class SubscriptionsImportFragment extends BaseFragment { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     public void onImportUrl(final String value) { |     public void onImportUrl(final String value) { | ||||||
|         ImportConfirmationDialog.show(this, new Intent(activity, SubscriptionsImportService.class) |         ImportConfirmationDialog.show(this, | ||||||
|                 .putExtra(KEY_MODE, CHANNEL_URL_MODE) |                 new SubscriptionImportInput.ChannelUrlMode(currentServiceId, value)); | ||||||
|                 .putExtra(KEY_VALUE, value) |  | ||||||
|                 .putExtra(Constants.KEY_SERVICE_ID, currentServiceId)); |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public void onImportFile() { |     public void onImportFile() { | ||||||
| @@ -186,16 +180,10 @@ public class SubscriptionsImportFragment extends BaseFragment { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     private void requestImportFileResult(final ActivityResult result) { |     private void requestImportFileResult(final ActivityResult result) { | ||||||
|         if (result.getData() == null) { |         final String data = result.getData() != null ? result.getData().getDataString() : null; | ||||||
|             return; |         if (result.getResultCode() == Activity.RESULT_OK && data != null) { | ||||||
|         } |  | ||||||
|  |  | ||||||
|         if (result.getResultCode() == Activity.RESULT_OK && result.getData().getData() != null) { |  | ||||||
|             ImportConfirmationDialog.show(this, |             ImportConfirmationDialog.show(this, | ||||||
|                     new Intent(activity, SubscriptionsImportService.class) |                     new SubscriptionImportInput.InputStreamMode(currentServiceId, data)); | ||||||
|                             .putExtra(KEY_MODE, INPUT_STREAM_MODE) |  | ||||||
|                             .putExtra(KEY_VALUE, result.getData().getData()) |  | ||||||
|                             .putExtra(Constants.KEY_SERVICE_ID, currentServiceId)); |  | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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="one">%d comment</item> | ||||||
|         <item quantity="other">%d comments</item> |         <item quantity="other">%d comments</item> | ||||||
|     </plurals> |     </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> | </resources> | ||||||
|   | |||||||
| @@ -5,11 +5,11 @@ import static org.junit.Assert.fail; | |||||||
|  |  | ||||||
| import org.junit.Test; | import org.junit.Test; | ||||||
| import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor; | 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.ByteArrayInputStream; | ||||||
| import java.io.ByteArrayOutputStream; | import java.io.ByteArrayOutputStream; | ||||||
| import java.io.InputStream; |  | ||||||
| import java.nio.charset.StandardCharsets; | import java.nio.charset.StandardCharsets; | ||||||
| import java.util.Arrays; | import java.util.Arrays; | ||||||
| import java.util.List; | import java.util.List; | ||||||
| @@ -23,26 +23,22 @@ public class ImportExportJsonHelperTest { | |||||||
|         final String emptySource = |         final String emptySource = | ||||||
|                 "{\"app_version\":\"0.11.6\",\"app_version_int\": 47,\"subscriptions\":[]}"; |                 "{\"app_version\":\"0.11.6\",\"app_version_int\": 47,\"subscriptions\":[]}"; | ||||||
|  |  | ||||||
|         final List<SubscriptionItem> items = ImportExportJsonHelper.readFrom( |         final var items = ImportExportJsonHelper.readFrom( | ||||||
|                 new ByteArrayInputStream(emptySource.getBytes(StandardCharsets.UTF_8)), null); |                 new ByteArrayInputStream(emptySource.getBytes(StandardCharsets.UTF_8))); | ||||||
|         assertTrue(items.isEmpty()); |         assertTrue(items.isEmpty()); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     @Test |     @Test | ||||||
|     public void testInvalidSource() { |     public void testInvalidSource() { | ||||||
|         final List<String> invalidList = Arrays.asList( |         final var invalidList = Arrays.asList("{}", "", null, "gibberish"); | ||||||
|                 "{}", |  | ||||||
|                 "", |  | ||||||
|                 null, |  | ||||||
|                 "gibberish"); |  | ||||||
|  |  | ||||||
|         for (final String invalidContent : invalidList) { |         for (final String invalidContent : invalidList) { | ||||||
|             try { |             try { | ||||||
|                 if (invalidContent != null) { |                 if (invalidContent != null) { | ||||||
|                     final byte[] bytes = invalidContent.getBytes(StandardCharsets.UTF_8); |                     final byte[] bytes = invalidContent.getBytes(StandardCharsets.UTF_8); | ||||||
|                     ImportExportJsonHelper.readFrom((new ByteArrayInputStream(bytes)), null); |                     ImportExportJsonHelper.readFrom(new ByteArrayInputStream(bytes)); | ||||||
|                 } else { |                 } else { | ||||||
|                     ImportExportJsonHelper.readFrom(null, null); |                     ImportExportJsonHelper.readFrom(null); | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|                 fail("didn't throw exception"); |                 fail("didn't throw exception"); | ||||||
| @@ -58,38 +54,24 @@ public class ImportExportJsonHelperTest { | |||||||
|     @Test |     @Test | ||||||
|     public void ultimateTest() throws Exception { |     public void ultimateTest() throws Exception { | ||||||
|         // Read from file |         // Read from file | ||||||
|         final List<SubscriptionItem> itemsFromFile = readFromFile(); |         final var itemsFromFile = readFromFile(); | ||||||
|  |  | ||||||
|         // Test writing to an output |         // Test writing to an output | ||||||
|         final String jsonOut = testWriteTo(itemsFromFile); |         final String jsonOut = testWriteTo(itemsFromFile); | ||||||
|  |  | ||||||
|         // Read again |         // Read again | ||||||
|         final List<SubscriptionItem> itemsSecondRead = readFromWriteTo(jsonOut); |         final var itemsSecondRead = readFromWriteTo(jsonOut); | ||||||
|  |  | ||||||
|         // Check if both lists have the exact same items |         // 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"); |             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 { |     private List<SubscriptionItem> readFromFile() throws Exception { | ||||||
|         final InputStream inputStream = getClass().getClassLoader().getResourceAsStream( |         final var inputStream = getClass().getClassLoader() | ||||||
|                 "import_export_test.json"); |                 .getResourceAsStream("import_export_test.json"); | ||||||
|         final List<SubscriptionItem> itemsFromFile = ImportExportJsonHelper.readFrom( |         final var itemsFromFile = ImportExportJsonHelper.readFrom(inputStream); | ||||||
|                 inputStream, null); |  | ||||||
|  |  | ||||||
|         if (itemsFromFile.isEmpty()) { |         if (itemsFromFile.isEmpty()) { | ||||||
|             fail("ImportExportJsonHelper.readFrom(input) returned a null or empty list"); |             fail("ImportExportJsonHelper.readFrom(input) returned a null or empty list"); | ||||||
| @@ -98,10 +80,10 @@ public class ImportExportJsonHelperTest { | |||||||
|         return itemsFromFile; |         return itemsFromFile; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     private String testWriteTo(final List<SubscriptionItem> itemsFromFile) throws Exception { |     private String testWriteTo(final List<SubscriptionItem> itemsFromFile) { | ||||||
|         final ByteArrayOutputStream out = new ByteArrayOutputStream(); |         final var out = new ByteArrayOutputStream(); | ||||||
|         ImportExportJsonHelper.writeTo(itemsFromFile, out, null); |         ImportExportJsonHelper.writeTo(itemsFromFile, out); | ||||||
|         final String jsonOut = out.toString("UTF-8"); |         final String jsonOut = out.toString(StandardCharsets.UTF_8); | ||||||
|  |  | ||||||
|         if (jsonOut.isEmpty()) { |         if (jsonOut.isEmpty()) { | ||||||
|             fail("JSON returned by writeTo was empty"); |             fail("JSON returned by writeTo was empty"); | ||||||
| @@ -111,10 +93,8 @@ public class ImportExportJsonHelperTest { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     private List<SubscriptionItem> readFromWriteTo(final String jsonOut) throws Exception { |     private List<SubscriptionItem> readFromWriteTo(final String jsonOut) throws Exception { | ||||||
|         final ByteArrayInputStream inputStream = new ByteArrayInputStream( |         final var inputStream = new ByteArrayInputStream(jsonOut.getBytes(StandardCharsets.UTF_8)); | ||||||
|                 jsonOut.getBytes(StandardCharsets.UTF_8)); |         final var secondReadItems = ImportExportJsonHelper.readFrom(inputStream); | ||||||
|         final List<SubscriptionItem> secondReadItems = ImportExportJsonHelper.readFrom( |  | ||||||
|                 inputStream, null); |  | ||||||
|  |  | ||||||
|         if (secondReadItems.isEmpty()) { |         if (secondReadItems.isEmpty()) { | ||||||
|             fail("second call to readFrom returned an empty list"); |             fail("second call to readFrom returned an empty list"); | ||||||
|   | |||||||
| @@ -11,6 +11,7 @@ buildscript { | |||||||
|         classpath libs.kotlin.gradle.plugin |         classpath libs.kotlin.gradle.plugin | ||||||
|         classpath libs.hilt.android.gradle.plugin |         classpath libs.hilt.android.gradle.plugin | ||||||
|         classpath libs.aboutlibraries.plugin |         classpath libs.aboutlibraries.plugin | ||||||
|  |         classpath libs.kotlinx.serialization | ||||||
|  |  | ||||||
|         // NOTE: Do not place your application dependencies here; they belong |         // NOTE: Do not place your application dependencies here; they belong | ||||||
|         // in the individual module build.gradle files |         // in the individual module build.gradle files | ||||||
|   | |||||||
| @@ -24,11 +24,11 @@ jsoup = "1.17.2" | |||||||
| junit = "4.13.2" | junit = "4.13.2" | ||||||
| kotlin = "2.0.21" | kotlin = "2.0.21" | ||||||
| kotlinxCoroutinesRx3 = "1.8.1" | kotlinxCoroutinesRx3 = "1.8.1" | ||||||
|  | kotlinxSerializationJson = "1.7.3" | ||||||
| ktlint = "0.45.2" | ktlint = "0.45.2" | ||||||
| lazycolumnscrollbar = "2.2.0" | lazycolumnscrollbar = "2.2.0" | ||||||
| leakcanary = "2.12" | leakcanary = "2.12" | ||||||
| lifecycle = "2.6.2" | lifecycle = "2.6.2" | ||||||
| localbroadcastmanager = "1.1.0" |  | ||||||
| markwon = "4.6.2" | markwon = "4.6.2" | ||||||
| material = "1.11.0" | material = "1.11.0" | ||||||
| media = "1.7.0" | media = "1.7.0" | ||||||
| @@ -60,7 +60,7 @@ teamnewpipe-nanojson = "1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751" | |||||||
| # to cause jitpack to regenerate the artifact. | # to cause jitpack to regenerate the artifact. | ||||||
| teamnewpipe-newpipe-extractor = "v0.24.6" | teamnewpipe-newpipe-extractor = "v0.24.6" | ||||||
| webkit = "1.9.0" | webkit = "1.9.0" | ||||||
| work = "2.8.1" | work = "2.10.0" | ||||||
|  |  | ||||||
| [plugins] | [plugins] | ||||||
| aboutlibraries = { id = "com.mikepenz.aboutlibraries.plugin" } | 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-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } | ||||||
| kotlin-kapt = { id = "kotlin-kapt" } | kotlin-kapt = { id = "kotlin-kapt" } | ||||||
| kotlin-parcelize = { id = "kotlin-parcelize" } | kotlin-parcelize = { id = "kotlin-parcelize" } | ||||||
|  | kotlinx-serialization = { id = "kotlinx-serialization" } | ||||||
| sonarqube = { id = "org.sonarqube", version.ref = "sonarqube" } | sonarqube = { id = "org.sonarqube", version.ref = "sonarqube" } | ||||||
|  |  | ||||||
| [libraries] | [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-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 = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" } | ||||||
| androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose" } | 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-material = { group = "com.google.android.material", name = "material", version.ref = "material" } | ||||||
| androidx-media = { group = "androidx.media", name = "media", version.ref = "media" } | androidx-media = { group = "androidx.media", name = "media", version.ref = "media" } | ||||||
| androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } | 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-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" } | 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-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" } | lazycolumnscrollbar = { group = "com.github.nanihadesuka", name = "LazyColumnScrollbar", version.ref = "lazycolumnscrollbar" } | ||||||
| leakcanary-android-core = { module = "com.squareup.leakcanary:leakcanary-android-core", version.ref = "leakcanary" } | 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" } | 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