From 00e2d66dd55d5b2d8ea34aca17cf43171352230c Mon Sep 17 00:00:00 2001 From: Isira Seneviratne Date: Tue, 26 Nov 2024 07:55:36 +0530 Subject: [PATCH 01/11] Rename .java to .kt --- .../{ImportExportJsonHelper.java => ImportExportJsonHelper.kt} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename app/src/main/java/org/schabi/newpipe/local/subscription/services/{ImportExportJsonHelper.java => ImportExportJsonHelper.kt} (100%) diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelper.java b/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelper.kt similarity index 100% rename from app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelper.java rename to app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelper.kt From 173ad83e0ee166597dd177654d1870361aeb5669 Mon Sep 17 00:00:00 2001 From: Isira Seneviratne Date: Tue, 26 Nov 2024 07:55:37 +0530 Subject: [PATCH 02/11] Convert subscription export service to a worker --- app/build.gradle | 6 +- app/proguard-rules.pro | 15 ++ app/src/main/AndroidManifest.xml | 6 +- .../subscription/SubscriptionFragment.kt | 10 +- .../services/ImportExportJsonHelper.kt | 142 +++------------ .../services/SubscriptionsExportService.java | 168 ------------------ .../services/SubscriptionsImportService.java | 47 +++-- .../subscription/workers/SubscriptionData.kt | 24 +++ .../workers/SubscriptionExportWorker.kt | 117 ++++++++++++ app/src/main/res/values/strings.xml | 4 + .../services/ImportExportJsonHelperTest.java | 18 +- build.gradle | 1 + gradle/libs.versions.toml | 4 +- 13 files changed, 239 insertions(+), 323 deletions(-) delete mode 100644 app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsExportService.java create mode 100644 app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionData.kt create mode 100644 app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionExportWorker.kt diff --git a/app/build.gradle b/app/build.gradle index aad6b34d145..cbb9b4a80b3 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -9,6 +9,7 @@ plugins { alias libs.plugins.kotlin.compose alias libs.plugins.kotlin.kapt alias libs.plugins.kotlin.parcelize + alias libs.plugins.kotlinx.serialization alias libs.plugins.checkstyle alias libs.plugins.sonarqube alias libs.plugins.hilt @@ -16,7 +17,7 @@ plugins { } android { - compileSdk 34 + compileSdk 35 namespace 'org.schabi.newpipe' defaultConfig { @@ -310,6 +311,9 @@ dependencies { // Scroll implementation libs.lazycolumnscrollbar + // Kotlinx Serialization + implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3' + /** Debugging **/ // Memory leak detection debugImplementation libs.leakcanary.object.watcher diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 215df0da58b..42d24c5b524 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -27,3 +27,18 @@ ## For some reason NotificationModeConfigFragment wasn't kept (only referenced in a preference xml) -keep class org.schabi.newpipe.settings.notifications.** { *; } + +## Keep Kotlinx Serialization classes +-keepclassmembers class kotlinx.serialization.json.** { + *** Companion; +} +-keepclasseswithmembers class kotlinx.serialization.json.** { + kotlinx.serialization.KSerializer serializer(...); +} +-keep,includedescriptorclasses class org.schabi.newpipe.**$$serializer { *; } +-keepclassmembers class org.schabi.newpipe.** { + *** Companion; +} +-keepclasseswithmembers class org.schabi.newpipe.** { + kotlinx.serialization.KSerializer serializer(...); +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c44f8bf2c4e..240dd511c7f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -9,6 +9,7 @@ + @@ -87,8 +88,11 @@ android:exported="false" android:label="@string/title_activity_about" /> + - () { } private fun requestExportResult(result: ActivityResult) { - if (result.data != null && result.resultCode == Activity.RESULT_OK) { - activity.startService( - Intent(activity, SubscriptionsExportService::class.java) - .putExtra(SubscriptionsExportService.KEY_FILE_PATH, result.data?.data) - ) + val data = result.data?.data + if (data != null && result.resultCode == Activity.RESULT_OK) { + SubscriptionExportWorker.schedule(activity, data) } } diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelper.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelper.kt index 611a1cd30bc..cd09b477eab 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelper.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelper.kt @@ -17,94 +17,44 @@ * along with this program. If not, see . */ -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; +package org.schabi.newpipe.local.subscription.services + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import kotlinx.serialization.json.encodeToStream +import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor.InvalidSourceException +import org.schabi.newpipe.local.subscription.workers.SubscriptionData +import org.schabi.newpipe.local.subscription.workers.SubscriptionItem +import java.io.InputStream +import java.io.OutputStream /** * 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() { } +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) - * @param eventListener listener for the events generated * @return the parsed subscription items */ - public static List readFrom( - final InputStream in, @Nullable final ImportExportEventListener eventListener) - throws InvalidSourceException { - if (in == null) { - throw new InvalidSourceException("input is null"); + @JvmStatic + @Throws(InvalidSourceException::class) + fun readFrom(`in`: InputStream?): List { + if (`in` == null) { + throw InvalidSourceException("input is null") } - final List 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); + @OptIn(ExperimentalSerializationApi::class) + return json.decodeFromStream(`in`).subscriptions + } catch (e: Throwable) { + throw InvalidSourceException("Couldn't parse json", e) } - - return channels; } /** @@ -112,47 +62,13 @@ public final class ImportExportJsonHelper { * * @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 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 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(); + @OptIn(ExperimentalSerializationApi::class) + @JvmStatic + fun writeTo( + items: List, + out: OutputStream, + ) { + json.encodeToStream(SubscriptionData(items), out) } } diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsExportService.java b/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsExportService.java deleted file mode 100644 index 54809068ac8..00000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsExportService.java +++ /dev/null @@ -1,168 +0,0 @@ -/* - * Copyright 2018 Mauricio Colli - * 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 . - */ - -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"); - outputStream = new SharpOutputStream(outFile.getStream()); - } 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 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 getSubscriber() { - return new Subscriber() { - @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, 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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java b/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java index 442c7fddb8b..fe326da29cb 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java @@ -41,8 +41,8 @@ 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.local.subscription.workers.SubscriptionItem; import org.schabi.newpipe.streams.io.SharpInputStream; import org.schabi.newpipe.streams.io.StoredFileHelper; import org.schabi.newpipe.util.Constants; @@ -50,10 +50,10 @@ 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 java.util.stream.Collectors; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.core.Flowable; @@ -177,18 +177,12 @@ protected void disposeAll() { private void startImport() { showToast(R.string.import_ongoing); - Flowable> 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; - } + final var flowable = switch (currentMode) { + case CHANNEL_URL_MODE -> importFromChannelUrl(); + case INPUT_STREAM_MODE -> importFromInputStream(); + case PREVIOUS_EXPORT_MODE -> importFromPreviousExport(); + default -> null; + }; if (flowable == null) { final String message = "Flowable given by \"importFrom\" is null " @@ -290,13 +284,10 @@ List>>> getNotificationsConsumer() { private Function>>>, List> upsertBatch() { return notificationList -> { - final List>> infoList = - new ArrayList<>(notificationList.size()); - for (final Notification>> n : notificationList) { - if (n.isOnNext()) { - infoList.add(n.getValue()); - } - } + final var infoList = notificationList.stream() + .filter(Notification::isOnNext) + .map(Notification::getValue) + .collect(Collectors.toList()); return subscriptionManager.upsertAll(infoList); }; @@ -305,7 +296,11 @@ List> upsertBatch() { private Flowable> importFromChannelUrl() { return Flowable.fromCallable(() -> NewPipe.getService(currentServiceId) .getSubscriptionExtractor() - .fromChannelUrl(channelUrl)); + .fromChannelUrl(channelUrl)) + .map(list -> list.stream() + .map(item -> new SubscriptionItem(item.getServiceId(), item.getUrl(), + item.getName())) + .collect(Collectors.toList())); } private Flowable> importFromInputStream() { @@ -314,11 +309,15 @@ private Flowable> importFromInputStream() { return Flowable.fromCallable(() -> NewPipe.getService(currentServiceId) .getSubscriptionExtractor() - .fromInputStream(inputStream, inputStreamType)); + .fromInputStream(inputStream, inputStreamType)) + .map(list -> list.stream() + .map(item -> new SubscriptionItem(item.getServiceId(), item.getUrl(), + item.getName())) + .collect(Collectors.toList())); } private Flowable> importFromPreviousExport() { - return Flowable.fromCallable(() -> ImportExportJsonHelper.readFrom(inputStream, null)); + return Flowable.fromCallable(() -> ImportExportJsonHelper.readFrom(inputStream)); } protected void handleError(@NonNull final Throwable error) { diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionData.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionData.kt new file mode 100644 index 00000000000..dbb49508e7e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionData.kt @@ -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 +) { + @SerialName("app_version") + private val appVersion = BuildConfig.VERSION_NAME + + @SerialName("app_version_int") + private val appVersionInt = BuildConfig.VERSION_CODE +} + +@Serializable +class SubscriptionItem( + @SerialName("service_id") + val serviceId: Int, + val url: String, + val name: String +) diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionExportWorker.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionExportWorker.kt new file mode 100644 index 00000000000..3a83adcb6c3 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionExportWorker.kt @@ -0,0 +1,117 @@ +package org.schabi.newpipe.local.subscription.workers + +import android.app.Notification +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 +import org.schabi.newpipe.local.subscription.services.ImportExportJsonHelper + +class SubscriptionExportWorker( + appContext: Context, + params: WorkerParameters, +) : CoroutineWorker(appContext, params) { + // This is needed for API levels < 31 (Android S). + override suspend fun getForegroundInfo(): ForegroundInfo { + val notification = createNotification(applicationContext.getString(R.string.export_ongoing)) + return createForegroundInfo(notification) + } + + 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(createNotification(title))) + + withContext(Dispatchers.IO) { + applicationContext.contentResolver.openOutputStream(uri)?.use { + ImportExportJsonHelper.writeTo(subscriptions, it) + } + } + + if (BuildConfig.DEBUG) { + Log.i(TAG, "Exported $qty subscriptions") + } + + 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 createNotification(title: String): 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() + + private fun createForegroundInfo(notification: Notification): ForegroundInfo { + val serviceType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC else 0 + return ForegroundInfo(NOTIFICATION_ID, notification, serviceType) + } + + companion object { + private const val 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() + .setInputData(data) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .build() + + WorkManager + .getInstance(context) + .enqueueUniqueWork(WORK_NAME, ExistingWorkPolicy.APPEND_OR_REPLACE, workRequest) + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1bcb98ca939..e7226334d8f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -862,4 +862,8 @@ %d comment %d comments + + Exporting %d subscription… + Exporting %d subscriptions… + diff --git a/app/src/test/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelperTest.java b/app/src/test/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelperTest.java index 4f0f125ecc1..2ccf1f58cd0 100644 --- a/app/src/test/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelperTest.java +++ b/app/src/test/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelperTest.java @@ -5,7 +5,7 @@ import org.junit.Test; import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor; -import org.schabi.newpipe.extractor.subscription.SubscriptionItem; +import org.schabi.newpipe.local.subscription.workers.SubscriptionItem; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; @@ -24,7 +24,7 @@ public void testEmptySource() throws Exception { "{\"app_version\":\"0.11.6\",\"app_version_int\": 47,\"subscriptions\":[]}"; final List items = ImportExportJsonHelper.readFrom( - new ByteArrayInputStream(emptySource.getBytes(StandardCharsets.UTF_8)), null); + new ByteArrayInputStream(emptySource.getBytes(StandardCharsets.UTF_8))); assertTrue(items.isEmpty()); } @@ -40,9 +40,9 @@ public void testInvalidSource() { try { if (invalidContent != null) { final byte[] bytes = invalidContent.getBytes(StandardCharsets.UTF_8); - ImportExportJsonHelper.readFrom((new ByteArrayInputStream(bytes)), null); + ImportExportJsonHelper.readFrom(new ByteArrayInputStream(bytes)); } else { - ImportExportJsonHelper.readFrom(null, null); + ImportExportJsonHelper.readFrom(null); } fail("didn't throw exception"); @@ -89,7 +89,7 @@ private List readFromFile() throws Exception { final InputStream inputStream = getClass().getClassLoader().getResourceAsStream( "import_export_test.json"); final List itemsFromFile = ImportExportJsonHelper.readFrom( - inputStream, null); + inputStream); if (itemsFromFile.isEmpty()) { fail("ImportExportJsonHelper.readFrom(input) returned a null or empty list"); @@ -98,10 +98,10 @@ private List readFromFile() throws Exception { return itemsFromFile; } - private String testWriteTo(final List itemsFromFile) throws Exception { + private String testWriteTo(final List itemsFromFile) { final ByteArrayOutputStream out = new ByteArrayOutputStream(); - ImportExportJsonHelper.writeTo(itemsFromFile, out, null); - final String jsonOut = out.toString("UTF-8"); + ImportExportJsonHelper.writeTo(itemsFromFile, out); + final String jsonOut = out.toString(StandardCharsets.UTF_8); if (jsonOut.isEmpty()) { fail("JSON returned by writeTo was empty"); @@ -114,7 +114,7 @@ private List readFromWriteTo(final String jsonOut) throws Exce final ByteArrayInputStream inputStream = new ByteArrayInputStream( jsonOut.getBytes(StandardCharsets.UTF_8)); final List secondReadItems = ImportExportJsonHelper.readFrom( - inputStream, null); + inputStream); if (secondReadItems.isEmpty()) { fail("second call to readFrom returned an empty list"); diff --git a/build.gradle b/build.gradle index f3772ac8734..f5e00283020 100644 --- a/build.gradle +++ b/build.gradle @@ -11,6 +11,7 @@ buildscript { classpath libs.kotlin.gradle.plugin classpath libs.hilt.android.gradle.plugin classpath libs.aboutlibraries.plugin + classpath libs.kotlinx.serialization // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c39beb6e56b..ab724cfa2ff 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -56,7 +56,7 @@ teamnewpipe-filepicker = "5.0.0" teamnewpipe-nanojson = "1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751" teamnewpipe-newpipe-extractor = "d3d5f2b3f03a5f2b479b9f6fdf1c2555cbb9de0e" viewpager2 = "1.1.0-beta02" -work = "2.8.1" +work = "2.10.0" [plugins] aboutlibraries = { id = "com.mikepenz.aboutlibraries.plugin" } @@ -67,6 +67,7 @@ kotlin-android = { id = "kotlin-android" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-kapt = { id = "kotlin-kapt" } kotlin-parcelize = { id = "kotlin-parcelize" } +kotlinx-serialization = { id = "kotlinx-serialization" } sonarqube = { id = "org.sonarqube", version.ref = "sonarqube" } [libraries] @@ -132,6 +133,7 @@ junit = { group = "junit", name = "junit", version.ref = "junit" } kotlin-gradle-plugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlin" } kotlinx-coroutines-rx3 = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-rx3", version.ref = "kotlinxCoroutinesRx3" } +kotlinx-serialization = { group = "org.jetbrains.kotlin", name = "kotlin-serialization", version.ref = "kotlin" } lazycolumnscrollbar = { group = "com.github.nanihadesuka", name = "LazyColumnScrollbar", version.ref = "lazycolumnscrollbar" } leakcanary-android-core = { module = "com.squareup.leakcanary:leakcanary-android-core", version.ref = "leakcanary" } leakcanary-object-watcher = { group = "com.squareup.leakcanary", name = "leakcanary-object-watcher-android", version.ref = "leakcanary" } From affd64938b26463b17474b25637394cb14fd60d2 Mon Sep 17 00:00:00 2001 From: Isira Seneviratne Date: Thu, 28 Nov 2024 06:34:39 +0530 Subject: [PATCH 03/11] Improve import/export tests --- .../subscription/workers/SubscriptionData.kt | 2 +- .../services/ImportExportJsonHelperTest.java | 43 +++++-------------- 2 files changed, 12 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionData.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionData.kt index dbb49508e7e..174ae758557 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionData.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionData.kt @@ -16,7 +16,7 @@ class SubscriptionData( } @Serializable -class SubscriptionItem( +data class SubscriptionItem( @SerialName("service_id") val serviceId: Int, val url: String, diff --git a/app/src/test/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelperTest.java b/app/src/test/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelperTest.java index 2ccf1f58cd0..3243ad9fd3b 100644 --- a/app/src/test/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelperTest.java +++ b/app/src/test/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelperTest.java @@ -9,7 +9,6 @@ import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; -import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.List; @@ -23,18 +22,14 @@ public void testEmptySource() throws Exception { final String emptySource = "{\"app_version\":\"0.11.6\",\"app_version_int\": 47,\"subscriptions\":[]}"; - final List items = ImportExportJsonHelper.readFrom( + final var items = ImportExportJsonHelper.readFrom( new ByteArrayInputStream(emptySource.getBytes(StandardCharsets.UTF_8))); assertTrue(items.isEmpty()); } @Test public void testInvalidSource() { - final List invalidList = Arrays.asList( - "{}", - "", - null, - "gibberish"); + final var invalidList = Arrays.asList("{}", "", null, "gibberish"); for (final String invalidContent : invalidList) { try { @@ -58,38 +53,24 @@ public void testInvalidSource() { @Test public void ultimateTest() throws Exception { // Read from file - final List itemsFromFile = readFromFile(); + final var itemsFromFile = readFromFile(); // Test writing to an output final String jsonOut = testWriteTo(itemsFromFile); // Read again - final List itemsSecondRead = readFromWriteTo(jsonOut); + final var itemsSecondRead = readFromWriteTo(jsonOut); // Check if both lists have the exact same items - if (itemsFromFile.size() != itemsSecondRead.size()) { + if (!itemsFromFile.equals(itemsSecondRead)) { fail("The list of items were different from each other"); } - - for (int i = 0; i < itemsFromFile.size(); i++) { - final SubscriptionItem item1 = itemsFromFile.get(i); - final SubscriptionItem item2 = itemsSecondRead.get(i); - - final boolean equals = item1.getServiceId() == item2.getServiceId() - && item1.getUrl().equals(item2.getUrl()) - && item1.getName().equals(item2.getName()); - - if (!equals) { - fail("The list of items were different from each other"); - } - } } private List readFromFile() throws Exception { - final InputStream inputStream = getClass().getClassLoader().getResourceAsStream( - "import_export_test.json"); - final List itemsFromFile = ImportExportJsonHelper.readFrom( - inputStream); + final var inputStream = getClass().getClassLoader() + .getResourceAsStream("import_export_test.json"); + final var itemsFromFile = ImportExportJsonHelper.readFrom(inputStream); if (itemsFromFile.isEmpty()) { fail("ImportExportJsonHelper.readFrom(input) returned a null or empty list"); @@ -99,7 +80,7 @@ private List readFromFile() throws Exception { } private String testWriteTo(final List itemsFromFile) { - final ByteArrayOutputStream out = new ByteArrayOutputStream(); + final var out = new ByteArrayOutputStream(); ImportExportJsonHelper.writeTo(itemsFromFile, out); final String jsonOut = out.toString(StandardCharsets.UTF_8); @@ -111,10 +92,8 @@ private String testWriteTo(final List itemsFromFile) { } private List readFromWriteTo(final String jsonOut) throws Exception { - final ByteArrayInputStream inputStream = new ByteArrayInputStream( - jsonOut.getBytes(StandardCharsets.UTF_8)); - final List secondReadItems = ImportExportJsonHelper.readFrom( - inputStream); + final var inputStream = new ByteArrayInputStream(jsonOut.getBytes(StandardCharsets.UTF_8)); + final var secondReadItems = ImportExportJsonHelper.readFrom(inputStream); if (secondReadItems.isEmpty()) { fail("second call to readFrom returned an empty list"); From 6046e9642b4836b1bf7b10d1cba5df942226fec6 Mon Sep 17 00:00:00 2001 From: Isira Seneviratne Date: Thu, 28 Nov 2024 09:53:25 +0530 Subject: [PATCH 04/11] Convert subscription import service to a worker --- app/src/main/AndroidManifest.xml | 1 - .../ImportConfirmationDialog.java | 62 +++- .../subscription/SubscriptionFragment.kt | 15 +- .../SubscriptionsImportFragment.java | 26 +- .../services/BaseImportExportService.java | 233 ------------- .../services/ImportExportEventListener.java | 17 - .../services/SubscriptionsImportService.java | 326 ------------------ .../ImportExportJsonHelper.kt | 4 +- .../workers/SubscriptionExportWorker.kt | 1 - .../workers/SubscriptionImportWorker.kt | 153 ++++++++ app/src/main/res/values/strings.xml | 8 + .../services/ImportExportJsonHelperTest.java | 1 + 12 files changed, 222 insertions(+), 625 deletions(-) delete mode 100644 app/src/main/java/org/schabi/newpipe/local/subscription/services/BaseImportExportService.java delete mode 100644 app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportEventListener.java delete mode 100644 app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java rename app/src/main/java/org/schabi/newpipe/local/subscription/{services => workers}/ImportExportJsonHelper.kt (92%) create mode 100644 app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionImportWorker.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 240dd511c7f..d9a63fcde79 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -92,7 +92,6 @@ android:name="androidx.work.impl.foreground.SystemForegroundService" android:foregroundServiceType="dataSync" tools:node="merge" /> - { - if (resultServiceIntent != null && getContext() != null) { - getContext().startService(resultServiceIntent); - } + final var inputData = new Data.Builder() + .putString(SubscriptionImportWorker.KEY_VALUE, value) + .putInt(SubscriptionImportWorker.KEY_MODE, mode) + .putInt(Constants.KEY_SERVICE_ID, serviceId) + .build(); + final var constraints = new Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build(); + + final var req = new OneTimeWorkRequest.Builder(SubscriptionImportWorker.class) + .setInputData(inputData) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .setConstraints(constraints) + .build(); + + WorkManager.getInstance(context) + .enqueueUniqueWork(SubscriptionImportWorker.WORK_NAME, + ExistingWorkPolicy.APPEND_OR_REPLACE, req); + dismiss(); }) .create(); @@ -53,8 +85,8 @@ public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) { public void onCreate(@Nullable final Bundle savedInstanceState) { super.onCreate(savedInstanceState); - if (resultServiceIntent == null) { - throw new IllegalStateException("Result intent is null"); + if (mode == 0 && value == null && serviceId == 0) { + throw new IllegalStateException("Input data not provided"); } Bridge.restoreInstanceState(this, savedInstanceState); diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt index 25d81287a6c..cdc7ae179fc 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt @@ -3,7 +3,6 @@ package org.schabi.newpipe.local.subscription import android.app.Activity import android.content.Context import android.content.DialogInterface -import android.content.Intent import android.os.Bundle import android.os.Parcelable import android.view.LayoutInflater @@ -49,14 +48,12 @@ import org.schabi.newpipe.local.subscription.item.FeedGroupCarouselItem import org.schabi.newpipe.local.subscription.item.GroupsHeader import org.schabi.newpipe.local.subscription.item.Header import org.schabi.newpipe.local.subscription.item.ImportSubscriptionsHintPlaceholderItem -import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService -import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_MODE -import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_VALUE -import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.PREVIOUS_EXPORT_MODE import org.schabi.newpipe.local.subscription.workers.SubscriptionExportWorker +import org.schabi.newpipe.local.subscription.workers.SubscriptionImportWorker import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard import org.schabi.newpipe.streams.io.StoredFileHelper import org.schabi.newpipe.ui.emptystate.setEmptyStateComposable +import org.schabi.newpipe.util.NO_SERVICE_ID import org.schabi.newpipe.util.NavigationHelper import org.schabi.newpipe.util.OnClickGesture import org.schabi.newpipe.util.ServiceHelper @@ -231,12 +228,10 @@ class SubscriptionFragment : BaseStateFragment() { } private fun requestImportResult(result: ActivityResult) { - if (result.data != null && result.resultCode == Activity.RESULT_OK) { + val data = result.data?.dataString + if (data != null && result.resultCode == Activity.RESULT_OK) { ImportConfirmationDialog.show( - this, - Intent(activity, SubscriptionsImportService::class.java) - .putExtra(KEY_MODE, PREVIOUS_EXPORT_MODE) - .putExtra(KEY_VALUE, result.data?.data) + this, SubscriptionImportWorker.PREVIOUS_EXPORT_MODE, data, NO_SERVICE_ID ) } } diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionsImportFragment.java b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionsImportFragment.java index 77a70afa9dc..a1d244df812 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionsImportFragment.java +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionsImportFragment.java @@ -1,10 +1,6 @@ package org.schabi.newpipe.local.subscription; import static org.schabi.newpipe.extractor.subscription.SubscriptionExtractor.ContentSource.CHANNEL_URL; -import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.CHANNEL_URL_MODE; -import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.INPUT_STREAM_MODE; -import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_MODE; -import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_VALUE; import android.app.Activity; import android.content.Intent; @@ -37,7 +33,7 @@ import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor; -import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService; +import org.schabi.newpipe.local.subscription.workers.SubscriptionImportWorker; import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard; import org.schabi.newpipe.streams.io.StoredFileHelper; import org.schabi.newpipe.util.Constants; @@ -168,10 +164,8 @@ private void onImportClicked() { } public void onImportUrl(final String value) { - ImportConfirmationDialog.show(this, new Intent(activity, SubscriptionsImportService.class) - .putExtra(KEY_MODE, CHANNEL_URL_MODE) - .putExtra(KEY_VALUE, value) - .putExtra(Constants.KEY_SERVICE_ID, currentServiceId)); + ImportConfirmationDialog.show(this, SubscriptionImportWorker.CHANNEL_URL_MODE, value, + currentServiceId); } public void onImportFile() { @@ -186,16 +180,10 @@ public void onImportFile() { } private void requestImportFileResult(final ActivityResult result) { - if (result.getData() == null) { - return; - } - - if (result.getResultCode() == Activity.RESULT_OK && result.getData().getData() != null) { - ImportConfirmationDialog.show(this, - new Intent(activity, SubscriptionsImportService.class) - .putExtra(KEY_MODE, INPUT_STREAM_MODE) - .putExtra(KEY_VALUE, result.getData().getData()) - .putExtra(Constants.KEY_SERVICE_ID, currentServiceId)); + final String data = result.getData() != null ? result.getData().getDataString() : null; + if (result.getResultCode() == Activity.RESULT_OK && data != null) { + ImportConfirmationDialog.show(this, SubscriptionImportWorker.INPUT_STREAM_MODE, + data, currentServiceId); } } diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/services/BaseImportExportService.java b/app/src/main/java/org/schabi/newpipe/local/subscription/services/BaseImportExportService.java deleted file mode 100644 index b7c11b1605d..00000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/services/BaseImportExportService.java +++ /dev/null @@ -1,233 +0,0 @@ -/* - * Copyright 2018 Mauricio Colli - * BaseImportExportService.java is part of NewPipe - * - * License: GPL-3.0+ - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.schabi.newpipe.local.subscription.services; - -import android.app.Service; -import android.content.Intent; -import android.os.Build; -import android.os.IBinder; -import android.text.TextUtils; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.StringRes; -import androidx.core.app.NotificationCompat; -import androidx.core.app.NotificationManagerCompat; -import androidx.core.app.ServiceCompat; - -import org.reactivestreams.Publisher; -import org.schabi.newpipe.R; -import org.schabi.newpipe.error.ErrorInfo; -import org.schabi.newpipe.error.ErrorUtil; -import org.schabi.newpipe.error.UserAction; -import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor; -import org.schabi.newpipe.ktx.ExceptionUtils; -import org.schabi.newpipe.local.subscription.SubscriptionManager; - -import java.io.FileNotFoundException; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Flowable; -import io.reactivex.rxjava3.disposables.CompositeDisposable; -import io.reactivex.rxjava3.functions.Function; -import io.reactivex.rxjava3.processors.PublishProcessor; - -public abstract class BaseImportExportService extends Service { - protected final String TAG = this.getClass().getSimpleName(); - - protected final CompositeDisposable disposables = new CompositeDisposable(); - protected final PublishProcessor notificationUpdater = PublishProcessor.create(); - - protected NotificationManagerCompat notificationManager; - protected NotificationCompat.Builder notificationBuilder; - protected SubscriptionManager subscriptionManager; - - private static final int NOTIFICATION_SAMPLING_PERIOD = 2500; - - protected final AtomicInteger currentProgress = new AtomicInteger(-1); - protected final AtomicInteger maxProgress = new AtomicInteger(-1); - protected final ImportExportEventListener eventListener = new ImportExportEventListener() { - @Override - public void onSizeReceived(final int size) { - maxProgress.set(size); - currentProgress.set(0); - } - - @Override - public void onItemCompleted(final String itemName) { - currentProgress.incrementAndGet(); - notificationUpdater.onNext(itemName); - } - }; - - protected Toast toast; - - @Nullable - @Override - public IBinder onBind(final Intent intent) { - return null; - } - - @Override - public void onCreate() { - super.onCreate(); - subscriptionManager = new SubscriptionManager(this); - setupNotification(); - } - - @Override - public void onDestroy() { - super.onDestroy(); - disposeAll(); - } - - protected void disposeAll() { - disposables.clear(); - } - - /*////////////////////////////////////////////////////////////////////////// - // Notification Impl - //////////////////////////////////////////////////////////////////////////*/ - - protected abstract int getNotificationId(); - - @StringRes - public abstract int getTitle(); - - protected void setupNotification() { - notificationManager = NotificationManagerCompat.from(this); - notificationBuilder = createNotification(); - startForeground(getNotificationId(), notificationBuilder.build()); - - final Function, Publisher> throttleAfterFirstEmission = flow -> - flow.take(1).concatWith(flow.skip(1) - .throttleLast(NOTIFICATION_SAMPLING_PERIOD, TimeUnit.MILLISECONDS)); - - disposables.add(notificationUpdater - .filter(s -> !s.isEmpty()) - .publish(throttleAfterFirstEmission) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(this::updateNotification)); - } - - protected void updateNotification(final String text) { - notificationBuilder - .setProgress(maxProgress.get(), currentProgress.get(), maxProgress.get() == -1); - - final String progressText = currentProgress + "/" + maxProgress; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - if (!TextUtils.isEmpty(text)) { - notificationBuilder.setContentText(text + " (" + progressText + ")"); - } - } else { - notificationBuilder.setContentInfo(progressText); - notificationBuilder.setContentText(text); - } - - notificationManager.notify(getNotificationId(), notificationBuilder.build()); - } - - protected void stopService() { - postErrorResult(null, null); - } - - protected void stopAndReportError(final Throwable throwable, final String request) { - stopService(); - ErrorUtil.createNotification(this, new ErrorInfo( - throwable, UserAction.SUBSCRIPTION_IMPORT_EXPORT, request)); - } - - protected void postErrorResult(final String title, final String text) { - disposeAll(); - ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE); - stopSelf(); - - if (title == null) { - return; - } - - final String textOrEmpty = text == null ? "" : text; - notificationBuilder = new NotificationCompat - .Builder(this, getString(R.string.notification_channel_id)) - .setSmallIcon(R.drawable.ic_newpipe_triangle_white) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setContentTitle(title) - .setStyle(new NotificationCompat.BigTextStyle().bigText(textOrEmpty)) - .setContentText(textOrEmpty); - notificationManager.notify(getNotificationId(), notificationBuilder.build()); - } - - protected NotificationCompat.Builder createNotification() { - return new NotificationCompat.Builder(this, getString(R.string.notification_channel_id)) - .setOngoing(true) - .setProgress(-1, -1, true) - .setSmallIcon(R.drawable.ic_newpipe_triangle_white) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setContentTitle(getString(getTitle())); - } - - /*////////////////////////////////////////////////////////////////////////// - // Toast - //////////////////////////////////////////////////////////////////////////*/ - - protected void showToast(@StringRes final int message) { - showToast(getString(message)); - } - - protected void showToast(final String message) { - if (toast != null) { - toast.cancel(); - } - - toast = Toast.makeText(this, message, Toast.LENGTH_SHORT); - toast.show(); - } - - /*////////////////////////////////////////////////////////////////////////// - // Error handling - //////////////////////////////////////////////////////////////////////////*/ - - protected void handleError(@StringRes final int errorTitle, @NonNull final Throwable error) { - String message = getErrorMessage(error); - - if (TextUtils.isEmpty(message)) { - final String errorClassName = error.getClass().getName(); - message = getString(R.string.error_occurred_detail, errorClassName); - } - - showToast(errorTitle); - postErrorResult(getString(errorTitle), message); - } - - protected String getErrorMessage(final Throwable error) { - String message = null; - if (error instanceof SubscriptionExtractor.InvalidSourceException) { - message = getString(R.string.invalid_source); - } else if (error instanceof FileNotFoundException) { - message = getString(R.string.invalid_file); - } else if (ExceptionUtils.isNetworkRelated(error)) { - message = getString(R.string.network_error); - } - return message; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportEventListener.java b/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportEventListener.java deleted file mode 100644 index 7352d1f12da..00000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportEventListener.java +++ /dev/null @@ -1,17 +0,0 @@ -package org.schabi.newpipe.local.subscription.services; - -public interface ImportExportEventListener { - /** - * Called when the size has been resolved. - * - * @param size how many items there are to import/export - */ - void onSizeReceived(int size); - - /** - * Called every time an item has been parsed/resolved. - * - * @param itemName the name of the subscription item - */ - void onItemCompleted(String itemName); -} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java b/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java deleted file mode 100644 index fe326da29cb..00000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java +++ /dev/null @@ -1,326 +0,0 @@ -/* - * Copyright 2018 Mauricio Colli - * SubscriptionsImportService.java is part of NewPipe - * - * License: GPL-3.0+ - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.schabi.newpipe.local.subscription.services; - -import static org.schabi.newpipe.MainActivity.DEBUG; -import static org.schabi.newpipe.streams.io.StoredFileHelper.DEFAULT_MIME; - -import android.content.Intent; -import android.net.Uri; -import android.text.TextUtils; -import android.util.Log; -import android.util.Pair; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.content.IntentCompat; -import androidx.localbroadcastmanager.content.LocalBroadcastManager; - -import org.reactivestreams.Subscriber; -import org.reactivestreams.Subscription; -import org.schabi.newpipe.App; -import org.schabi.newpipe.R; -import org.schabi.newpipe.database.subscription.SubscriptionEntity; -import org.schabi.newpipe.extractor.NewPipe; -import org.schabi.newpipe.extractor.channel.ChannelInfo; -import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo; -import org.schabi.newpipe.ktx.ExceptionUtils; -import org.schabi.newpipe.local.subscription.workers.SubscriptionItem; -import org.schabi.newpipe.streams.io.SharpInputStream; -import org.schabi.newpipe.streams.io.StoredFileHelper; -import org.schabi.newpipe.util.Constants; -import org.schabi.newpipe.util.ExtractorHelper; - -import java.io.IOException; -import java.io.InputStream; -import java.util.Collections; -import java.util.List; -import java.util.Objects; -import java.util.stream.Collectors; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Flowable; -import io.reactivex.rxjava3.core.Notification; -import io.reactivex.rxjava3.functions.Consumer; -import io.reactivex.rxjava3.functions.Function; -import io.reactivex.rxjava3.schedulers.Schedulers; - -public class SubscriptionsImportService extends BaseImportExportService { - public static final int CHANNEL_URL_MODE = 0; - public static final int INPUT_STREAM_MODE = 1; - public static final int PREVIOUS_EXPORT_MODE = 2; - public static final String KEY_MODE = "key_mode"; - public static final String KEY_VALUE = "key_value"; - - /** - * A {@link LocalBroadcastManager local broadcast} will be made with this action - * when the import is successfully completed. - */ - public static final String IMPORT_COMPLETE_ACTION = App.PACKAGE_NAME + ".local.subscription" - + ".services.SubscriptionsImportService.IMPORT_COMPLETE"; - - /** - * How many extractions running in parallel. - */ - public static final int PARALLEL_EXTRACTIONS = 8; - - /** - * Number of items to buffer to mass-insert in the subscriptions table, - * this leads to a better performance as we can then use db transactions. - */ - public static final int BUFFER_COUNT_BEFORE_INSERT = 50; - - private Subscription subscription; - private int currentMode; - private int currentServiceId; - @Nullable - private String channelUrl; - @Nullable - private InputStream inputStream; - @Nullable - private String inputStreamType; - - @Override - public int onStartCommand(final Intent intent, final int flags, final int startId) { - if (intent == null || subscription != null) { - return START_NOT_STICKY; - } - - currentMode = intent.getIntExtra(KEY_MODE, -1); - currentServiceId = intent.getIntExtra(Constants.KEY_SERVICE_ID, Constants.NO_SERVICE_ID); - - if (currentMode == CHANNEL_URL_MODE) { - channelUrl = intent.getStringExtra(KEY_VALUE); - } else { - final Uri uri = IntentCompat.getParcelableExtra(intent, KEY_VALUE, Uri.class); - if (uri == null) { - stopAndReportError(new IllegalStateException( - "Importing from input stream, but file path is null"), - "Importing subscriptions"); - return START_NOT_STICKY; - } - - try { - final StoredFileHelper fileHelper = new StoredFileHelper(this, uri, DEFAULT_MIME); - inputStream = new SharpInputStream(fileHelper.getStream()); - inputStreamType = fileHelper.getType(); - - if (inputStreamType == null || inputStreamType.equals(DEFAULT_MIME)) { - // mime type could not be determined, just take file extension - final String name = fileHelper.getName(); - final int pointIndex = name.lastIndexOf('.'); - if (pointIndex == -1 || pointIndex >= name.length() - 1) { - inputStreamType = DEFAULT_MIME; // no extension, will fail in the extractor - } else { - inputStreamType = name.substring(pointIndex + 1); - } - } - } catch (final IOException e) { - handleError(e); - return START_NOT_STICKY; - } - } - - if (currentMode == -1 || currentMode == CHANNEL_URL_MODE && channelUrl == null) { - final String errorDescription = "Some important field is null or in illegal state: " - + "currentMode=[" + currentMode + "], " - + "channelUrl=[" + channelUrl + "], " - + "inputStream=[" + inputStream + "]"; - stopAndReportError(new IllegalStateException(errorDescription), - "Importing subscriptions"); - return START_NOT_STICKY; - } - - startImport(); - return START_NOT_STICKY; - } - - @Override - protected int getNotificationId() { - return 4568; - } - - @Override - public int getTitle() { - return R.string.import_ongoing; - } - - @Override - protected void disposeAll() { - super.disposeAll(); - if (subscription != null) { - subscription.cancel(); - } - } - - /*////////////////////////////////////////////////////////////////////////// - // Imports - //////////////////////////////////////////////////////////////////////////*/ - - private void startImport() { - showToast(R.string.import_ongoing); - - final var flowable = switch (currentMode) { - case CHANNEL_URL_MODE -> importFromChannelUrl(); - case INPUT_STREAM_MODE -> importFromInputStream(); - case PREVIOUS_EXPORT_MODE -> importFromPreviousExport(); - default -> null; - }; - - if (flowable == null) { - final String message = "Flowable given by \"importFrom\" is null " - + "(current mode: " + currentMode + ")"; - stopAndReportError(new IllegalStateException(message), "Importing subscriptions"); - return; - } - - flowable.doOnNext(subscriptionItems -> - eventListener.onSizeReceived(subscriptionItems.size())) - .flatMap(Flowable::fromIterable) - - .parallel(PARALLEL_EXTRACTIONS) - .runOn(Schedulers.io()) - .map((Function>>>) subscriptionItem -> { - try { - final ChannelInfo channelInfo = ExtractorHelper - .getChannelInfo(subscriptionItem.getServiceId(), - subscriptionItem.getUrl(), true) - .blockingGet(); - return Notification.createOnNext(new Pair<>(channelInfo, - Collections.singletonList( - ExtractorHelper.getChannelTab( - subscriptionItem.getServiceId(), - channelInfo.getTabs().get(0), true).blockingGet() - ))); - } catch (final Throwable e) { - return Notification.createOnError(e); - } - }) - .sequential() - - .observeOn(Schedulers.io()) - .doOnNext(getNotificationsConsumer()) - - .buffer(BUFFER_COUNT_BEFORE_INSERT) - .map(upsertBatch()) - - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(getSubscriber()); - } - - private Subscriber> getSubscriber() { - return new Subscriber<>() { - @Override - public void onSubscribe(final Subscription s) { - subscription = s; - s.request(Long.MAX_VALUE); - } - - @Override - public void onNext(final List successfulInserted) { - if (DEBUG) { - Log.d(TAG, "startImport() " + successfulInserted.size() - + " items successfully inserted into the database"); - } - } - - @Override - public void onError(final Throwable error) { - Log.e(TAG, "Got an error!", error); - handleError(error); - } - - @Override - public void onComplete() { - LocalBroadcastManager.getInstance(SubscriptionsImportService.this) - .sendBroadcast(new Intent(IMPORT_COMPLETE_ACTION)); - showToast(R.string.import_complete_toast); - stopService(); - } - }; - } - - private Consumer>>> getNotificationsConsumer() { - return notification -> { - if (notification.isOnNext()) { - final String name = notification.getValue().first.getName(); - eventListener.onItemCompleted(!TextUtils.isEmpty(name) ? name : ""); - } else if (notification.isOnError()) { - final Throwable error = notification.getError(); - final Throwable cause = error.getCause(); - if (error instanceof IOException) { - throw error; - } else if (cause instanceof IOException) { - throw cause; - } else if (ExceptionUtils.isNetworkRelated(error)) { - throw new IOException(error); - } - - eventListener.onItemCompleted(""); - } - }; - } - - private Function>>>, - List> upsertBatch() { - return notificationList -> { - final var infoList = notificationList.stream() - .filter(Notification::isOnNext) - .map(Notification::getValue) - .collect(Collectors.toList()); - - return subscriptionManager.upsertAll(infoList); - }; - } - - private Flowable> importFromChannelUrl() { - return Flowable.fromCallable(() -> NewPipe.getService(currentServiceId) - .getSubscriptionExtractor() - .fromChannelUrl(channelUrl)) - .map(list -> list.stream() - .map(item -> new SubscriptionItem(item.getServiceId(), item.getUrl(), - item.getName())) - .collect(Collectors.toList())); - } - - private Flowable> importFromInputStream() { - Objects.requireNonNull(inputStream); - Objects.requireNonNull(inputStreamType); - - return Flowable.fromCallable(() -> NewPipe.getService(currentServiceId) - .getSubscriptionExtractor() - .fromInputStream(inputStream, inputStreamType)) - .map(list -> list.stream() - .map(item -> new SubscriptionItem(item.getServiceId(), item.getUrl(), - item.getName())) - .collect(Collectors.toList())); - } - - private Flowable> importFromPreviousExport() { - return Flowable.fromCallable(() -> ImportExportJsonHelper.readFrom(inputStream)); - } - - protected void handleError(@NonNull final Throwable error) { - super.handleError(R.string.subscriptions_import_unsuccessful, error); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelper.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/workers/ImportExportJsonHelper.kt similarity index 92% rename from app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelper.kt rename to app/src/main/java/org/schabi/newpipe/local/subscription/workers/ImportExportJsonHelper.kt index cd09b477eab..d71f5fa8976 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelper.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/workers/ImportExportJsonHelper.kt @@ -17,15 +17,13 @@ * along with this program. If not, see . */ -package org.schabi.newpipe.local.subscription.services +package org.schabi.newpipe.local.subscription.workers import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json import kotlinx.serialization.json.decodeFromStream import kotlinx.serialization.json.encodeToStream import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor.InvalidSourceException -import org.schabi.newpipe.local.subscription.workers.SubscriptionData -import org.schabi.newpipe.local.subscription.workers.SubscriptionItem import java.io.InputStream import java.io.OutputStream diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionExportWorker.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionExportWorker.kt index 3a83adcb6c3..42b77e21cb1 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionExportWorker.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionExportWorker.kt @@ -23,7 +23,6 @@ import kotlinx.coroutines.withContext import org.schabi.newpipe.BuildConfig import org.schabi.newpipe.NewPipeDatabase import org.schabi.newpipe.R -import org.schabi.newpipe.local.subscription.services.ImportExportJsonHelper class SubscriptionExportWorker( appContext: Context, diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionImportWorker.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionImportWorker.kt new file mode 100644 index 00000000000..4e5c2a5410c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionImportWorker.kt @@ -0,0 +1,153 @@ +package org.schabi.newpipe.local.subscription.workers + +import android.app.Notification +import android.content.Context +import android.content.pm.ServiceInfo +import android.os.Build +import android.util.Pair +import android.webkit.MimeTypeMap +import androidx.core.app.NotificationCompat +import androidx.core.net.toUri +import androidx.work.CoroutineWorker +import androidx.work.ForegroundInfo +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.rx3.await +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.schabi.newpipe.R +import org.schabi.newpipe.extractor.NewPipe +import org.schabi.newpipe.local.subscription.SubscriptionManager +import org.schabi.newpipe.util.ExtractorHelper +import org.schabi.newpipe.util.KEY_SERVICE_ID +import org.schabi.newpipe.util.NO_SERVICE_ID + +class SubscriptionImportWorker( + appContext: Context, + params: WorkerParameters, +) : CoroutineWorker(appContext, params) { + // This is needed for API levels < 31 (Android S). + override suspend fun getForegroundInfo(): ForegroundInfo { + val title = applicationContext.getString(R.string.import_ongoing) + return createForegroundInfo(createNotification(title, null, 0, 0)) + } + + override suspend fun doWork(): Result { + val mode = inputData.getInt(KEY_MODE, CHANNEL_URL_MODE) + val extractor = NewPipe.getService(inputData.getInt(KEY_SERVICE_ID, NO_SERVICE_ID)) + .subscriptionExtractor + val value = inputData.getString(KEY_VALUE) ?: "" + + val subscriptions = withContext(Dispatchers.IO) { + if (mode == CHANNEL_URL_MODE) { + extractor + .fromChannelUrl(value) + .map { SubscriptionItem(it.serviceId, it.url, it.name) } + } else { + applicationContext.contentResolver.openInputStream(value.toUri())?.use { + if (mode == INPUT_STREAM_MODE) { + val contentType = MimeTypeMap.getFileExtensionFromUrl(value).ifEmpty { DEFAULT_MIME } + extractor + .fromInputStream(it, contentType) + .map { SubscriptionItem(it.serviceId, it.url, it.name) } + } else { + ImportExportJsonHelper.readFrom(it) + } + } ?: emptyList() + } + } + + val mutex = Mutex() + var index = 1 + val qty = subscriptions.size + var title = + applicationContext.resources.getQuantityString(R.plurals.load_subscriptions, qty, qty) + + val channelInfoList = withContext(Dispatchers.IO.limitedParallelism(PARALLEL_EXTRACTIONS)) { + subscriptions + .map { + async { + val channelInfo = + ExtractorHelper.getChannelInfo(it.serviceId, it.url, true).await() + val channelTab = + ExtractorHelper.getChannelTab(it.serviceId, channelInfo.tabs[0], true).await() + + val currentIndex = mutex.withLock { index++ } + val notification = createNotification(title, channelInfo.name, currentIndex, qty) + setForeground(createForegroundInfo(notification)) + + Pair(channelInfo, listOf(channelTab)) + } + }.awaitAll() + } + + title = applicationContext.resources.getQuantityString(R.plurals.import_subscriptions, qty, qty) + setForeground(createForegroundInfo(createNotification(title, null, 0, 0))) + index = 0 + + val subscriptionManager = SubscriptionManager(applicationContext) + for (chunk in channelInfoList.chunked(BUFFER_COUNT_BEFORE_INSERT)) { + withContext(Dispatchers.IO) { + subscriptionManager.upsertAll(chunk) + } + index += chunk.size + setForeground(createForegroundInfo(createNotification(title, null, index, qty))) + } + + return Result.success() + } + + private fun createNotification( + title: String, + text: String?, + currentProgress: Int, + maxProgress: Int, + ): Notification = + NotificationCompat + .Builder(applicationContext, NOTIFICATION_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_newpipe_triangle_white) + .setOngoing(true) + .setProgress(maxProgress, currentProgress, currentProgress == 0) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + .setContentTitle(title) + .setContentText(text) + .addAction( + R.drawable.ic_close, + applicationContext.getString(R.string.cancel), + WorkManager.getInstance(applicationContext).createCancelPendingIntent(id), + ).apply { + if (currentProgress > 0 && maxProgress > 0) { + val progressText = "$currentProgress/$maxProgress" + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + setSubText(progressText) + } else { + setContentInfo(progressText) + } + } + }.build() + + private fun createForegroundInfo(notification: Notification): ForegroundInfo { + val serviceType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC else 0 + return ForegroundInfo(NOTIFICATION_ID, notification, serviceType) + } + + companion object { + private const val NOTIFICATION_ID = 4568 + private const val NOTIFICATION_CHANNEL_ID = "newpipe" + private const val DEFAULT_MIME = "application/octet-stream" + private const val PARALLEL_EXTRACTIONS = 8 + private const val BUFFER_COUNT_BEFORE_INSERT = 50 + + const val WORK_NAME = "SubscriptionImportWorker" + const val CHANNEL_URL_MODE = 0 + const val INPUT_STREAM_MODE = 1 + const val PREVIOUS_EXPORT_MODE = 2 + const val KEY_MODE = "key_mode" + const val KEY_VALUE = "key_value" + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e7226334d8f..2fad25e8a5d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -866,4 +866,12 @@ Exporting %d subscription… Exporting %d subscriptions… + + Loading %d subscription… + Loading %d subscriptions… + + + Importing %d subscription… + Importing %d subscriptions… + diff --git a/app/src/test/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelperTest.java b/app/src/test/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelperTest.java index 3243ad9fd3b..96bca973311 100644 --- a/app/src/test/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelperTest.java +++ b/app/src/test/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelperTest.java @@ -5,6 +5,7 @@ import org.junit.Test; import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor; +import org.schabi.newpipe.local.subscription.workers.ImportExportJsonHelper; import org.schabi.newpipe.local.subscription.workers.SubscriptionItem; import java.io.ByteArrayInputStream; From d4c4710b2d5cd62762516b9a56c5af014091eba8 Mon Sep 17 00:00:00 2001 From: Isira Seneviratne Date: Fri, 29 Nov 2024 07:05:06 +0530 Subject: [PATCH 05/11] Added success toasts --- .../local/subscription/workers/SubscriptionExportWorker.kt | 6 ++++++ .../local/subscription/workers/SubscriptionImportWorker.kt | 7 +++++++ 2 files changed, 13 insertions(+) diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionExportWorker.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionExportWorker.kt index 42b77e21cb1..e41bf3b38a8 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionExportWorker.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionExportWorker.kt @@ -58,6 +58,12 @@ class SubscriptionExportWorker( 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) { diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionImportWorker.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionImportWorker.kt index 4e5c2a5410c..e66e2a5dfe8 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionImportWorker.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionImportWorker.kt @@ -6,6 +6,7 @@ import android.content.pm.ServiceInfo import android.os.Build import android.util.Pair import android.webkit.MimeTypeMap +import android.widget.Toast import androidx.core.app.NotificationCompat import androidx.core.net.toUri import androidx.work.CoroutineWorker @@ -98,6 +99,12 @@ class SubscriptionImportWorker( setForeground(createForegroundInfo(createNotification(title, null, index, qty))) } + withContext(Dispatchers.Main) { + Toast + .makeText(applicationContext, R.string.import_complete_toast, Toast.LENGTH_SHORT) + .show() + } + return Result.success() } From 0bf953accf6ead9f696dedd8a432fee24cd0b239 Mon Sep 17 00:00:00 2001 From: Isira Seneviratne Date: Fri, 29 Nov 2024 07:23:37 +0530 Subject: [PATCH 06/11] Moved Kotlinx Serialization to library catalog --- app/build.gradle | 2 +- gradle/libs.versions.toml | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/build.gradle b/app/build.gradle index cbb9b4a80b3..a8a541e0a4c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -312,7 +312,7 @@ dependencies { implementation libs.lazycolumnscrollbar // Kotlinx Serialization - implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3' + implementation libs.kotlinx.serialization.json /** Debugging **/ // Memory leak detection diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ab724cfa2ff..2809e113345 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,6 +24,7 @@ jsoup = "1.17.2" junit = "4.13.2" kotlin = "2.0.21" kotlinxCoroutinesRx3 = "1.8.1" +kotlinxSerializationJson = "1.7.3" ktlint = "0.45.2" lazycolumnscrollbar = "2.2.0" leakcanary = "2.12" @@ -134,6 +135,7 @@ kotlin-gradle-plugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-p kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlin" } kotlinx-coroutines-rx3 = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-rx3", version.ref = "kotlinxCoroutinesRx3" } kotlinx-serialization = { group = "org.jetbrains.kotlin", name = "kotlin-serialization", version.ref = "kotlin" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } lazycolumnscrollbar = { group = "com.github.nanihadesuka", name = "LazyColumnScrollbar", version.ref = "lazycolumnscrollbar" } leakcanary-android-core = { module = "com.squareup.leakcanary:leakcanary-android-core", version.ref = "leakcanary" } leakcanary-object-watcher = { group = "com.squareup.leakcanary", name = "leakcanary-object-watcher-android", version.ref = "leakcanary" } From dbf19607c2418f85c562ae86570c461481d466fd Mon Sep 17 00:00:00 2001 From: Isira Seneviratne Date: Sat, 30 Nov 2024 07:23:37 +0530 Subject: [PATCH 07/11] Combine notification and ForegroundInfo creation methods --- .../workers/SubscriptionExportWorker.kt | 32 +++++----- .../workers/SubscriptionImportWorker.kt | 63 +++++++++---------- 2 files changed, 44 insertions(+), 51 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionExportWorker.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionExportWorker.kt index e41bf3b38a8..a124fc666d2 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionExportWorker.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionExportWorker.kt @@ -1,6 +1,5 @@ package org.schabi.newpipe.local.subscription.workers -import android.app.Notification import android.content.Context import android.content.pm.ServiceInfo import android.net.Uri @@ -30,8 +29,7 @@ class SubscriptionExportWorker( ) : CoroutineWorker(appContext, params) { // This is needed for API levels < 31 (Android S). override suspend fun getForegroundInfo(): ForegroundInfo { - val notification = createNotification(applicationContext.getString(R.string.export_ongoing)) - return createForegroundInfo(notification) + return createForegroundInfo(applicationContext.getString(R.string.export_ongoing)) } override suspend fun doWork(): Result { @@ -44,9 +42,8 @@ class SubscriptionExportWorker( .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(createNotification(title))) + val title = applicationContext.resources.getQuantityString(R.plurals.export_subscriptions, qty, qty) + setForeground(createForegroundInfo(title)) withContext(Dispatchers.IO) { applicationContext.contentResolver.openOutputStream(uri)?.use { @@ -80,18 +77,17 @@ class SubscriptionExportWorker( } } - private fun createNotification(title: String): 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() - - private fun createForegroundInfo(notification: Notification): ForegroundInfo { + 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) } diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionImportWorker.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionImportWorker.kt index e66e2a5dfe8..3556ac883e3 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionImportWorker.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionImportWorker.kt @@ -1,6 +1,5 @@ package org.schabi.newpipe.local.subscription.workers -import android.app.Notification import android.content.Context import android.content.pm.ServiceInfo import android.os.Build @@ -33,8 +32,7 @@ class SubscriptionImportWorker( ) : CoroutineWorker(appContext, params) { // This is needed for API levels < 31 (Android S). override suspend fun getForegroundInfo(): ForegroundInfo { - val title = applicationContext.getString(R.string.import_ongoing) - return createForegroundInfo(createNotification(title, null, 0, 0)) + return createForegroundInfo(applicationContext.getString(R.string.import_ongoing), null, 0, 0) } override suspend fun doWork(): Result { @@ -78,8 +76,7 @@ class SubscriptionImportWorker( ExtractorHelper.getChannelTab(it.serviceId, channelInfo.tabs[0], true).await() val currentIndex = mutex.withLock { index++ } - val notification = createNotification(title, channelInfo.name, currentIndex, qty) - setForeground(createForegroundInfo(notification)) + setForeground(createForegroundInfo(title, channelInfo.name, currentIndex, qty)) Pair(channelInfo, listOf(channelTab)) } @@ -87,7 +84,7 @@ class SubscriptionImportWorker( } title = applicationContext.resources.getQuantityString(R.plurals.import_subscriptions, qty, qty) - setForeground(createForegroundInfo(createNotification(title, null, 0, 0))) + setForeground(createForegroundInfo(title, null, 0, 0)) index = 0 val subscriptionManager = SubscriptionManager(applicationContext) @@ -96,7 +93,7 @@ class SubscriptionImportWorker( subscriptionManager.upsertAll(chunk) } index += chunk.size - setForeground(createForegroundInfo(createNotification(title, null, index, qty))) + setForeground(createForegroundInfo(title, null, index, qty)) } withContext(Dispatchers.Main) { @@ -108,38 +105,38 @@ class SubscriptionImportWorker( return Result.success() } - private fun createNotification( + private fun createForegroundInfo( title: String, text: String?, currentProgress: Int, maxProgress: Int, - ): Notification = - NotificationCompat - .Builder(applicationContext, NOTIFICATION_CHANNEL_ID) - .setSmallIcon(R.drawable.ic_newpipe_triangle_white) - .setOngoing(true) - .setProgress(maxProgress, currentProgress, currentProgress == 0) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) - .setContentTitle(title) - .setContentText(text) - .addAction( - R.drawable.ic_close, - applicationContext.getString(R.string.cancel), - WorkManager.getInstance(applicationContext).createCancelPendingIntent(id), - ).apply { - if (currentProgress > 0 && maxProgress > 0) { - val progressText = "$currentProgress/$maxProgress" - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - setSubText(progressText) - } else { - setContentInfo(progressText) + ): 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() - - private fun createForegroundInfo(notification: Notification): ForegroundInfo { + }.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) } From 673993272b63f468b1aff4f58ebc6a6c766a8a6a Mon Sep 17 00:00:00 2001 From: Isira Seneviratne Date: Sat, 30 Nov 2024 07:35:12 +0530 Subject: [PATCH 08/11] Remove LocalBroadcastManager --- app/build.gradle | 1 - gradle/libs.versions.toml | 2 -- 2 files changed, 3 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index a8a541e0a4c..84d91e9f316 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -216,7 +216,6 @@ dependencies { implementation libs.androidx.fragment.compose implementation libs.androidx.lifecycle.livedata implementation libs.androidx.lifecycle.viewmodel - implementation libs.androidx.localbroadcastmanager implementation libs.androidx.media implementation libs.androidx.preference implementation libs.androidx.recyclerview diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2809e113345..7c175fc44a5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,7 +29,6 @@ ktlint = "0.45.2" lazycolumnscrollbar = "2.2.0" leakcanary = "2.12" lifecycle = "2.6.2" -localbroadcastmanager = "1.1.0" markwon = "4.6.2" material = "1.11.0" media = "1.7.0" @@ -96,7 +95,6 @@ androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "a androidx-lifecycle-livedata = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycle" } androidx-lifecycle-viewmodel = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" } androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose" } -androidx-localbroadcastmanager = { group = "androidx.localbroadcastmanager", name = "localbroadcastmanager", version.ref = "localbroadcastmanager" } androidx-material = { group = "com.google.android.material", name = "material", version.ref = "material" } androidx-media = { group = "androidx.media", name = "media", version.ref = "media" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } From bcfe6285a4900da784d87553e69bc40016773f6d Mon Sep 17 00:00:00 2001 From: Isira Seneviratne Date: Sun, 1 Dec 2024 06:24:26 +0530 Subject: [PATCH 09/11] Only get subscription extractor when needed --- .../subscription/workers/SubscriptionImportWorker.kt | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionImportWorker.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionImportWorker.kt index 3556ac883e3..cdcb335eebb 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionImportWorker.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionImportWorker.kt @@ -37,20 +37,19 @@ class SubscriptionImportWorker( override suspend fun doWork(): Result { val mode = inputData.getInt(KEY_MODE, CHANNEL_URL_MODE) - val extractor = NewPipe.getService(inputData.getInt(KEY_SERVICE_ID, NO_SERVICE_ID)) - .subscriptionExtractor - val value = inputData.getString(KEY_VALUE) ?: "" + val serviceId = inputData.getInt(KEY_SERVICE_ID, NO_SERVICE_ID) + val value = inputData.getString(KEY_VALUE)!! val subscriptions = withContext(Dispatchers.IO) { if (mode == CHANNEL_URL_MODE) { - extractor + NewPipe.getService(serviceId).subscriptionExtractor .fromChannelUrl(value) .map { SubscriptionItem(it.serviceId, it.url, it.name) } } else { applicationContext.contentResolver.openInputStream(value.toUri())?.use { if (mode == INPUT_STREAM_MODE) { val contentType = MimeTypeMap.getFileExtensionFromUrl(value).ifEmpty { DEFAULT_MIME } - extractor + NewPipe.getService(serviceId).subscriptionExtractor .fromInputStream(it, contentType) .map { SubscriptionItem(it.serviceId, it.url, it.name) } } else { From d6eaa4e66ce910e6a45dd377c663d5f9dfc599e8 Mon Sep 17 00:00:00 2001 From: Isira Seneviratne Date: Sun, 1 Dec 2024 09:51:12 +0530 Subject: [PATCH 10/11] Improve subscription upsert methods --- .../database/subscription/SubscriptionDAO.kt | 4 +--- .../local/subscription/SubscriptionManager.kt | 17 +++++------------ .../workers/SubscriptionImportWorker.kt | 2 +- 3 files changed, 7 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.kt b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.kt index 47b6f4dd9aa..358741a485a 100644 --- a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.kt +++ b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.kt @@ -90,7 +90,7 @@ abstract class SubscriptionDAO : BasicDAO { internal abstract fun silentInsertAllInternal(entities: List): List @Transaction - open fun upsertAll(entities: List): List { + open fun upsertAll(entities: List) { val insertUidList = silentInsertAllInternal(entities) insertUidList.forEachIndexed { index: Int, uidFromInsert: Long -> @@ -106,7 +106,5 @@ abstract class SubscriptionDAO : BasicDAO { update(entity) } } - - return entities } } diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt index 474add4f41b..7b666f357ac 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt @@ -48,23 +48,16 @@ class SubscriptionManager(context: Context) { } } - fun upsertAll(infoList: List>>): List { - val listEntities = subscriptionTable.upsertAll( - infoList.map { SubscriptionEntity.from(it.first) } - ) + fun upsertAll(infoList: List>) { + val listEntities = infoList.map { SubscriptionEntity.from(it.first) } + subscriptionTable.upsertAll(listEntities) database.runInTransaction { infoList.forEachIndexed { index, info -> - info.second.forEach { - feedDatabaseManager.upsertAll( - listEntities[index].uid, - it.relatedItems.filterIsInstance() - ) - } + val streams = info.second.relatedItems.filterIsInstance() + feedDatabaseManager.upsertAll(listEntities[index].uid, streams) } } - - return listEntities } fun updateChannelInfo(info: ChannelInfo): Completable = diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionImportWorker.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionImportWorker.kt index cdcb335eebb..b2a8a3a5029 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionImportWorker.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionImportWorker.kt @@ -77,7 +77,7 @@ class SubscriptionImportWorker( val currentIndex = mutex.withLock { index++ } setForeground(createForegroundInfo(title, channelInfo.name, currentIndex, qty)) - Pair(channelInfo, listOf(channelTab)) + Pair(channelInfo, channelTab) } }.awaitAll() } From 63837770c446ddea101303e81e1c2d4a73ff7c11 Mon Sep 17 00:00:00 2001 From: Isira Seneviratne Date: Sat, 21 Dec 2024 10:10:42 +0530 Subject: [PATCH 11/11] Use Kotlin Pair --- .../schabi/newpipe/local/subscription/SubscriptionManager.kt | 1 - .../local/subscription/workers/SubscriptionImportWorker.kt | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt index 7b666f357ac..8de2c94db4a 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt @@ -1,7 +1,6 @@ package org.schabi.newpipe.local.subscription import android.content.Context -import android.util.Pair import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.Flowable diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionImportWorker.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionImportWorker.kt index b2a8a3a5029..e97eb760e60 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionImportWorker.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionImportWorker.kt @@ -3,7 +3,6 @@ package org.schabi.newpipe.local.subscription.workers import android.content.Context import android.content.pm.ServiceInfo import android.os.Build -import android.util.Pair import android.webkit.MimeTypeMap import android.widget.Toast import androidx.core.app.NotificationCompat @@ -77,7 +76,7 @@ class SubscriptionImportWorker( val currentIndex = mutex.withLock { index++ } setForeground(createForegroundInfo(title, channelInfo.name, currentIndex, qty)) - Pair(channelInfo, channelTab) + channelInfo to channelTab } }.awaitAll() }