From 82516dd75c455e2f6329935df03ac105ec1cf12a Mon Sep 17 00:00:00 2001 From: Isira Seneviratne Date: Tue, 26 Nov 2024 07:55:36 +0530 Subject: [PATCH 01/30] 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 8e9503cfe4d8f8d9d46533d98424bac0e50ca2e3 Mon Sep 17 00:00:00 2001 From: Isira Seneviratne Date: Tue, 26 Nov 2024 07:55:37 +0530 Subject: [PATCH 02/30] 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 | 140 +++----------- .../services/SubscriptionsExportService.java | 171 ------------------ .../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 | 5 +- 13 files changed, 239 insertions(+), 325 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 5830885cb..45336d7bd 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 { @@ -314,6 +315,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 215df0da5..42d24c5b5 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 c44f8bf2c..240dd511c 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 611a1cd30..cd09b477e 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; +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; +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 ab1a5a10c..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsExportService.java +++ /dev/null @@ -1,171 +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"); - // truncate the file before writing to it, otherwise if the new content is smaller than - // the previous file size, the file will retain part of the previous content and be - // corrupted - outputStream = new SharpOutputStream(outFile.openAndTruncateStream()); - } catch (final IOException e) { - handleError(e); - return START_NOT_STICKY; - } - - startExport(); - - return START_NOT_STICKY; - } - - @Override - protected int getNotificationId() { - return 4567; - } - - @Override - public int getTitle() { - return R.string.export_ongoing; - } - - @Override - protected void disposeAll() { - super.disposeAll(); - if (subscription != null) { - subscription.cancel(); - } - } - - private void startExport() { - showToast(R.string.export_ongoing); - - subscriptionManager.subscriptionTable().getAll().take(1) - .map(subscriptionEntities -> { - final List 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 442c7fddb..fe326da29 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.database.subscription.SubscriptionEntity; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.channel.ChannelInfo; import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo; -import org.schabi.newpipe.extractor.subscription.SubscriptionItem; import org.schabi.newpipe.ktx.ExceptionUtils; +import org.schabi.newpipe.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 org.schabi.newpipe.util.ExtractorHelper; import java.io.IOException; import java.io.InputStream; -import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Objects; +import java.util.stream.Collectors; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.core.Flowable; @@ -177,18 +177,12 @@ public class SubscriptionsImportService extends BaseImportExportService { 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 @@ public class SubscriptionsImportService extends BaseImportExportService { 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 @@ public class SubscriptionsImportService extends BaseImportExportService { 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 @@ public class SubscriptionsImportService extends BaseImportExportService { 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 000000000..dbb49508e --- /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 000000000..3a83adcb6 --- /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 b01b8697c..9486e88f4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -863,4 +863,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 4f0f125ec..2ccf1f58c 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 static org.junit.Assert.fail; import org.junit.Test; import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor; -import org.schabi.newpipe.extractor.subscription.SubscriptionItem; +import org.schabi.newpipe.local.subscription.workers.SubscriptionItem; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; @@ -24,7 +24,7 @@ public class ImportExportJsonHelperTest { "{\"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 class ImportExportJsonHelperTest { 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 @@ public class ImportExportJsonHelperTest { 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 @@ public class ImportExportJsonHelperTest { 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 @@ public class ImportExportJsonHelperTest { 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 f3772ac87..f5e002830 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 5e3f10e5c..2f4fc657c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -55,7 +55,8 @@ swiperefreshlayout = "1.1.0" teamnewpipe-filepicker = "5.0.0" teamnewpipe-nanojson = "1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751" teamnewpipe-newpipe-extractor = "v0.24.4" -work = "2.8.1" +viewpager2 = "1.1.0-beta02" +work = "2.10.0" [plugins] aboutlibraries = { id = "com.mikepenz.aboutlibraries.plugin" } @@ -66,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] @@ -130,6 +132,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 dfb035dfa5d8b4e2c721ffc7dd2bd056740c1c4f Mon Sep 17 00:00:00 2001 From: Isira Seneviratne Date: Thu, 28 Nov 2024 06:34:39 +0530 Subject: [PATCH 03/30] 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 dbb49508e..174ae7585 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 2ccf1f58c..3243ad9fd 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 org.schabi.newpipe.local.subscription.workers.SubscriptionItem; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; -import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.List; @@ -23,18 +22,14 @@ public class ImportExportJsonHelperTest { 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 class ImportExportJsonHelperTest { @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 @@ public class ImportExportJsonHelperTest { } 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 @@ public class ImportExportJsonHelperTest { } 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 fc7f1b0af05acb2a24866e6674fc32c4a5bcd49d Mon Sep 17 00:00:00 2001 From: Isira Seneviratne Date: Thu, 28 Nov 2024 09:53:25 +0530 Subject: [PATCH 04/30] 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 240dd511c..d9a63fcde 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -92,7 +92,6 @@ android:name="androidx.work.impl.foreground.SystemForegroundService" android:foregroundServiceType="dataSync" tools:node="merge" /> - { - if (resultServiceIntent != null && getContext() != null) { - getContext().startService(resultServiceIntent); - } + final var inputData = new Data.Builder() + .putString(SubscriptionImportWorker.KEY_VALUE, value) + .putInt(SubscriptionImportWorker.KEY_MODE, mode) + .putInt(Constants.KEY_SERVICE_ID, serviceId) + .build(); + final var constraints = new Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build(); + + final var req = new OneTimeWorkRequest.Builder(SubscriptionImportWorker.class) + .setInputData(inputData) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .setConstraints(constraints) + .build(); + + WorkManager.getInstance(context) + .enqueueUniqueWork(SubscriptionImportWorker.WORK_NAME, + ExistingWorkPolicy.APPEND_OR_REPLACE, req); + dismiss(); }) .create(); @@ -53,8 +85,8 @@ public class ImportConfirmationDialog extends DialogFragment { public void onCreate(@Nullable final Bundle savedInstanceState) { super.onCreate(savedInstanceState); - if (resultServiceIntent == null) { - throw new IllegalStateException("Result intent is null"); + if (mode == 0 && value == null && serviceId == 0) { + throw new IllegalStateException("Input data not provided"); } Bridge.restoreInstanceState(this, savedInstanceState); diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt index 25d81287a..cdc7ae179 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt @@ -3,7 +3,6 @@ package org.schabi.newpipe.local.subscription import android.app.Activity import android.content.Context import android.content.DialogInterface -import android.content.Intent import android.os.Bundle import android.os.Parcelable import android.view.LayoutInflater @@ -49,14 +48,12 @@ import org.schabi.newpipe.local.subscription.item.FeedGroupCarouselItem import org.schabi.newpipe.local.subscription.item.GroupsHeader import org.schabi.newpipe.local.subscription.item.Header import org.schabi.newpipe.local.subscription.item.ImportSubscriptionsHintPlaceholderItem -import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService -import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_MODE -import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_VALUE -import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.PREVIOUS_EXPORT_MODE import org.schabi.newpipe.local.subscription.workers.SubscriptionExportWorker +import org.schabi.newpipe.local.subscription.workers.SubscriptionImportWorker import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard import org.schabi.newpipe.streams.io.StoredFileHelper import org.schabi.newpipe.ui.emptystate.setEmptyStateComposable +import org.schabi.newpipe.util.NO_SERVICE_ID import org.schabi.newpipe.util.NavigationHelper import org.schabi.newpipe.util.OnClickGesture import org.schabi.newpipe.util.ServiceHelper @@ -231,12 +228,10 @@ class SubscriptionFragment : BaseStateFragment() { } private fun requestImportResult(result: ActivityResult) { - if (result.data != null && result.resultCode == Activity.RESULT_OK) { + val data = result.data?.dataString + if (data != null && result.resultCode == Activity.RESULT_OK) { ImportConfirmationDialog.show( - this, - Intent(activity, SubscriptionsImportService::class.java) - .putExtra(KEY_MODE, PREVIOUS_EXPORT_MODE) - .putExtra(KEY_VALUE, result.data?.data) + this, SubscriptionImportWorker.PREVIOUS_EXPORT_MODE, data, NO_SERVICE_ID ) } } diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionsImportFragment.java b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionsImportFragment.java index 77a70afa9..a1d244df8 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionsImportFragment.java +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionsImportFragment.java @@ -1,10 +1,6 @@ package org.schabi.newpipe.local.subscription; import static org.schabi.newpipe.extractor.subscription.SubscriptionExtractor.ContentSource.CHANNEL_URL; -import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.CHANNEL_URL_MODE; -import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.INPUT_STREAM_MODE; -import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_MODE; -import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_VALUE; import android.app.Activity; import android.content.Intent; @@ -37,7 +33,7 @@ import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor; -import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService; +import org.schabi.newpipe.local.subscription.workers.SubscriptionImportWorker; import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard; import org.schabi.newpipe.streams.io.StoredFileHelper; import org.schabi.newpipe.util.Constants; @@ -168,10 +164,8 @@ public class SubscriptionsImportFragment extends BaseFragment { } public void onImportUrl(final String value) { - ImportConfirmationDialog.show(this, new Intent(activity, SubscriptionsImportService.class) - .putExtra(KEY_MODE, CHANNEL_URL_MODE) - .putExtra(KEY_VALUE, value) - .putExtra(Constants.KEY_SERVICE_ID, currentServiceId)); + ImportConfirmationDialog.show(this, SubscriptionImportWorker.CHANNEL_URL_MODE, value, + currentServiceId); } public void onImportFile() { @@ -186,16 +180,10 @@ public class SubscriptionsImportFragment extends BaseFragment { } private void requestImportFileResult(final ActivityResult result) { - if (result.getData() == null) { - return; - } - - if (result.getResultCode() == Activity.RESULT_OK && result.getData().getData() != null) { - ImportConfirmationDialog.show(this, - new Intent(activity, SubscriptionsImportService.class) - .putExtra(KEY_MODE, INPUT_STREAM_MODE) - .putExtra(KEY_VALUE, result.getData().getData()) - .putExtra(Constants.KEY_SERVICE_ID, currentServiceId)); + final String data = result.getData() != null ? result.getData().getDataString() : null; + if (result.getResultCode() == Activity.RESULT_OK && data != null) { + ImportConfirmationDialog.show(this, SubscriptionImportWorker.INPUT_STREAM_MODE, + data, currentServiceId); } } diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/services/BaseImportExportService.java b/app/src/main/java/org/schabi/newpipe/local/subscription/services/BaseImportExportService.java deleted file mode 100644 index b7c11b160..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/services/BaseImportExportService.java +++ /dev/null @@ -1,233 +0,0 @@ -/* - * Copyright 2018 Mauricio Colli - * BaseImportExportService.java is part of NewPipe - * - * License: GPL-3.0+ - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.schabi.newpipe.local.subscription.services; - -import android.app.Service; -import android.content.Intent; -import android.os.Build; -import android.os.IBinder; -import android.text.TextUtils; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.StringRes; -import androidx.core.app.NotificationCompat; -import androidx.core.app.NotificationManagerCompat; -import androidx.core.app.ServiceCompat; - -import org.reactivestreams.Publisher; -import org.schabi.newpipe.R; -import org.schabi.newpipe.error.ErrorInfo; -import org.schabi.newpipe.error.ErrorUtil; -import org.schabi.newpipe.error.UserAction; -import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor; -import org.schabi.newpipe.ktx.ExceptionUtils; -import org.schabi.newpipe.local.subscription.SubscriptionManager; - -import java.io.FileNotFoundException; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Flowable; -import io.reactivex.rxjava3.disposables.CompositeDisposable; -import io.reactivex.rxjava3.functions.Function; -import io.reactivex.rxjava3.processors.PublishProcessor; - -public abstract class BaseImportExportService extends Service { - protected final String TAG = this.getClass().getSimpleName(); - - protected final CompositeDisposable disposables = new CompositeDisposable(); - protected final PublishProcessor notificationUpdater = PublishProcessor.create(); - - protected NotificationManagerCompat notificationManager; - protected NotificationCompat.Builder notificationBuilder; - protected SubscriptionManager subscriptionManager; - - private static final int NOTIFICATION_SAMPLING_PERIOD = 2500; - - protected final AtomicInteger currentProgress = new AtomicInteger(-1); - protected final AtomicInteger maxProgress = new AtomicInteger(-1); - protected final ImportExportEventListener eventListener = new ImportExportEventListener() { - @Override - public void onSizeReceived(final int size) { - maxProgress.set(size); - currentProgress.set(0); - } - - @Override - public void onItemCompleted(final String itemName) { - currentProgress.incrementAndGet(); - notificationUpdater.onNext(itemName); - } - }; - - protected Toast toast; - - @Nullable - @Override - public IBinder onBind(final Intent intent) { - return null; - } - - @Override - public void onCreate() { - super.onCreate(); - subscriptionManager = new SubscriptionManager(this); - setupNotification(); - } - - @Override - public void onDestroy() { - super.onDestroy(); - disposeAll(); - } - - protected void disposeAll() { - disposables.clear(); - } - - /*////////////////////////////////////////////////////////////////////////// - // Notification Impl - //////////////////////////////////////////////////////////////////////////*/ - - protected abstract int getNotificationId(); - - @StringRes - public abstract int getTitle(); - - protected void setupNotification() { - notificationManager = NotificationManagerCompat.from(this); - notificationBuilder = createNotification(); - startForeground(getNotificationId(), notificationBuilder.build()); - - final Function, Publisher> throttleAfterFirstEmission = flow -> - flow.take(1).concatWith(flow.skip(1) - .throttleLast(NOTIFICATION_SAMPLING_PERIOD, TimeUnit.MILLISECONDS)); - - disposables.add(notificationUpdater - .filter(s -> !s.isEmpty()) - .publish(throttleAfterFirstEmission) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(this::updateNotification)); - } - - protected void updateNotification(final String text) { - notificationBuilder - .setProgress(maxProgress.get(), currentProgress.get(), maxProgress.get() == -1); - - final String progressText = currentProgress + "/" + maxProgress; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - if (!TextUtils.isEmpty(text)) { - notificationBuilder.setContentText(text + " (" + progressText + ")"); - } - } else { - notificationBuilder.setContentInfo(progressText); - notificationBuilder.setContentText(text); - } - - notificationManager.notify(getNotificationId(), notificationBuilder.build()); - } - - protected void stopService() { - postErrorResult(null, null); - } - - protected void stopAndReportError(final Throwable throwable, final String request) { - stopService(); - ErrorUtil.createNotification(this, new ErrorInfo( - throwable, UserAction.SUBSCRIPTION_IMPORT_EXPORT, request)); - } - - protected void postErrorResult(final String title, final String text) { - disposeAll(); - ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE); - stopSelf(); - - if (title == null) { - return; - } - - final String textOrEmpty = text == null ? "" : text; - notificationBuilder = new NotificationCompat - .Builder(this, getString(R.string.notification_channel_id)) - .setSmallIcon(R.drawable.ic_newpipe_triangle_white) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setContentTitle(title) - .setStyle(new NotificationCompat.BigTextStyle().bigText(textOrEmpty)) - .setContentText(textOrEmpty); - notificationManager.notify(getNotificationId(), notificationBuilder.build()); - } - - protected NotificationCompat.Builder createNotification() { - return new NotificationCompat.Builder(this, getString(R.string.notification_channel_id)) - .setOngoing(true) - .setProgress(-1, -1, true) - .setSmallIcon(R.drawable.ic_newpipe_triangle_white) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setContentTitle(getString(getTitle())); - } - - /*////////////////////////////////////////////////////////////////////////// - // Toast - //////////////////////////////////////////////////////////////////////////*/ - - protected void showToast(@StringRes final int message) { - showToast(getString(message)); - } - - protected void showToast(final String message) { - if (toast != null) { - toast.cancel(); - } - - toast = Toast.makeText(this, message, Toast.LENGTH_SHORT); - toast.show(); - } - - /*////////////////////////////////////////////////////////////////////////// - // Error handling - //////////////////////////////////////////////////////////////////////////*/ - - protected void handleError(@StringRes final int errorTitle, @NonNull final Throwable error) { - String message = getErrorMessage(error); - - if (TextUtils.isEmpty(message)) { - final String errorClassName = error.getClass().getName(); - message = getString(R.string.error_occurred_detail, errorClassName); - } - - showToast(errorTitle); - postErrorResult(getString(errorTitle), message); - } - - protected String getErrorMessage(final Throwable error) { - String message = null; - if (error instanceof SubscriptionExtractor.InvalidSourceException) { - message = getString(R.string.invalid_source); - } else if (error instanceof FileNotFoundException) { - message = getString(R.string.invalid_file); - } else if (ExceptionUtils.isNetworkRelated(error)) { - message = getString(R.string.network_error); - } - return message; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportEventListener.java b/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportEventListener.java deleted file mode 100644 index 7352d1f12..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportEventListener.java +++ /dev/null @@ -1,17 +0,0 @@ -package org.schabi.newpipe.local.subscription.services; - -public interface ImportExportEventListener { - /** - * Called when the size has been resolved. - * - * @param size how many items there are to import/export - */ - void onSizeReceived(int size); - - /** - * Called every time an item has been parsed/resolved. - * - * @param itemName the name of the subscription item - */ - void onItemCompleted(String itemName); -} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java b/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java deleted file mode 100644 index fe326da29..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java +++ /dev/null @@ -1,326 +0,0 @@ -/* - * Copyright 2018 Mauricio Colli - * SubscriptionsImportService.java is part of NewPipe - * - * License: GPL-3.0+ - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.schabi.newpipe.local.subscription.services; - -import static org.schabi.newpipe.MainActivity.DEBUG; -import static org.schabi.newpipe.streams.io.StoredFileHelper.DEFAULT_MIME; - -import android.content.Intent; -import android.net.Uri; -import android.text.TextUtils; -import android.util.Log; -import android.util.Pair; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.content.IntentCompat; -import androidx.localbroadcastmanager.content.LocalBroadcastManager; - -import org.reactivestreams.Subscriber; -import org.reactivestreams.Subscription; -import org.schabi.newpipe.App; -import org.schabi.newpipe.R; -import org.schabi.newpipe.database.subscription.SubscriptionEntity; -import org.schabi.newpipe.extractor.NewPipe; -import org.schabi.newpipe.extractor.channel.ChannelInfo; -import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo; -import org.schabi.newpipe.ktx.ExceptionUtils; -import org.schabi.newpipe.local.subscription.workers.SubscriptionItem; -import org.schabi.newpipe.streams.io.SharpInputStream; -import org.schabi.newpipe.streams.io.StoredFileHelper; -import org.schabi.newpipe.util.Constants; -import org.schabi.newpipe.util.ExtractorHelper; - -import java.io.IOException; -import java.io.InputStream; -import java.util.Collections; -import java.util.List; -import java.util.Objects; -import java.util.stream.Collectors; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Flowable; -import io.reactivex.rxjava3.core.Notification; -import io.reactivex.rxjava3.functions.Consumer; -import io.reactivex.rxjava3.functions.Function; -import io.reactivex.rxjava3.schedulers.Schedulers; - -public class SubscriptionsImportService extends BaseImportExportService { - public static final int CHANNEL_URL_MODE = 0; - public static final int INPUT_STREAM_MODE = 1; - public static final int PREVIOUS_EXPORT_MODE = 2; - public static final String KEY_MODE = "key_mode"; - public static final String KEY_VALUE = "key_value"; - - /** - * A {@link LocalBroadcastManager local broadcast} will be made with this action - * when the import is successfully completed. - */ - public static final String IMPORT_COMPLETE_ACTION = App.PACKAGE_NAME + ".local.subscription" - + ".services.SubscriptionsImportService.IMPORT_COMPLETE"; - - /** - * How many extractions running in parallel. - */ - public static final int PARALLEL_EXTRACTIONS = 8; - - /** - * Number of items to buffer to mass-insert in the subscriptions table, - * this leads to a better performance as we can then use db transactions. - */ - public static final int BUFFER_COUNT_BEFORE_INSERT = 50; - - private Subscription subscription; - private int currentMode; - private int currentServiceId; - @Nullable - private String channelUrl; - @Nullable - private InputStream inputStream; - @Nullable - private String inputStreamType; - - @Override - public int onStartCommand(final Intent intent, final int flags, final int startId) { - if (intent == null || subscription != null) { - return START_NOT_STICKY; - } - - currentMode = intent.getIntExtra(KEY_MODE, -1); - currentServiceId = intent.getIntExtra(Constants.KEY_SERVICE_ID, Constants.NO_SERVICE_ID); - - if (currentMode == CHANNEL_URL_MODE) { - channelUrl = intent.getStringExtra(KEY_VALUE); - } else { - final Uri uri = IntentCompat.getParcelableExtra(intent, KEY_VALUE, Uri.class); - if (uri == null) { - stopAndReportError(new IllegalStateException( - "Importing from input stream, but file path is null"), - "Importing subscriptions"); - return START_NOT_STICKY; - } - - try { - final StoredFileHelper fileHelper = new StoredFileHelper(this, uri, DEFAULT_MIME); - inputStream = new SharpInputStream(fileHelper.getStream()); - inputStreamType = fileHelper.getType(); - - if (inputStreamType == null || inputStreamType.equals(DEFAULT_MIME)) { - // mime type could not be determined, just take file extension - final String name = fileHelper.getName(); - final int pointIndex = name.lastIndexOf('.'); - if (pointIndex == -1 || pointIndex >= name.length() - 1) { - inputStreamType = DEFAULT_MIME; // no extension, will fail in the extractor - } else { - inputStreamType = name.substring(pointIndex + 1); - } - } - } catch (final IOException e) { - handleError(e); - return START_NOT_STICKY; - } - } - - if (currentMode == -1 || currentMode == CHANNEL_URL_MODE && channelUrl == null) { - final String errorDescription = "Some important field is null or in illegal state: " - + "currentMode=[" + currentMode + "], " - + "channelUrl=[" + channelUrl + "], " - + "inputStream=[" + inputStream + "]"; - stopAndReportError(new IllegalStateException(errorDescription), - "Importing subscriptions"); - return START_NOT_STICKY; - } - - startImport(); - return START_NOT_STICKY; - } - - @Override - protected int getNotificationId() { - return 4568; - } - - @Override - public int getTitle() { - return R.string.import_ongoing; - } - - @Override - protected void disposeAll() { - super.disposeAll(); - if (subscription != null) { - subscription.cancel(); - } - } - - /*////////////////////////////////////////////////////////////////////////// - // Imports - //////////////////////////////////////////////////////////////////////////*/ - - private void startImport() { - showToast(R.string.import_ongoing); - - final var flowable = switch (currentMode) { - case CHANNEL_URL_MODE -> importFromChannelUrl(); - case INPUT_STREAM_MODE -> importFromInputStream(); - case PREVIOUS_EXPORT_MODE -> importFromPreviousExport(); - default -> null; - }; - - if (flowable == null) { - final String message = "Flowable given by \"importFrom\" is null " - + "(current mode: " + currentMode + ")"; - stopAndReportError(new IllegalStateException(message), "Importing subscriptions"); - return; - } - - flowable.doOnNext(subscriptionItems -> - eventListener.onSizeReceived(subscriptionItems.size())) - .flatMap(Flowable::fromIterable) - - .parallel(PARALLEL_EXTRACTIONS) - .runOn(Schedulers.io()) - .map((Function>>>) subscriptionItem -> { - try { - final ChannelInfo channelInfo = ExtractorHelper - .getChannelInfo(subscriptionItem.getServiceId(), - subscriptionItem.getUrl(), true) - .blockingGet(); - return Notification.createOnNext(new Pair<>(channelInfo, - Collections.singletonList( - ExtractorHelper.getChannelTab( - subscriptionItem.getServiceId(), - channelInfo.getTabs().get(0), true).blockingGet() - ))); - } catch (final Throwable e) { - return Notification.createOnError(e); - } - }) - .sequential() - - .observeOn(Schedulers.io()) - .doOnNext(getNotificationsConsumer()) - - .buffer(BUFFER_COUNT_BEFORE_INSERT) - .map(upsertBatch()) - - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(getSubscriber()); - } - - private Subscriber> getSubscriber() { - return new Subscriber<>() { - @Override - public void onSubscribe(final Subscription s) { - subscription = s; - s.request(Long.MAX_VALUE); - } - - @Override - public void onNext(final List successfulInserted) { - if (DEBUG) { - Log.d(TAG, "startImport() " + successfulInserted.size() - + " items successfully inserted into the database"); - } - } - - @Override - public void onError(final Throwable error) { - Log.e(TAG, "Got an error!", error); - handleError(error); - } - - @Override - public void onComplete() { - LocalBroadcastManager.getInstance(SubscriptionsImportService.this) - .sendBroadcast(new Intent(IMPORT_COMPLETE_ACTION)); - showToast(R.string.import_complete_toast); - stopService(); - } - }; - } - - private Consumer>>> getNotificationsConsumer() { - return notification -> { - if (notification.isOnNext()) { - final String name = notification.getValue().first.getName(); - eventListener.onItemCompleted(!TextUtils.isEmpty(name) ? name : ""); - } else if (notification.isOnError()) { - final Throwable error = notification.getError(); - final Throwable cause = error.getCause(); - if (error instanceof IOException) { - throw error; - } else if (cause instanceof IOException) { - throw cause; - } else if (ExceptionUtils.isNetworkRelated(error)) { - throw new IOException(error); - } - - eventListener.onItemCompleted(""); - } - }; - } - - private Function>>>, - List> upsertBatch() { - return notificationList -> { - final var infoList = notificationList.stream() - .filter(Notification::isOnNext) - .map(Notification::getValue) - .collect(Collectors.toList()); - - return subscriptionManager.upsertAll(infoList); - }; - } - - private Flowable> importFromChannelUrl() { - return Flowable.fromCallable(() -> NewPipe.getService(currentServiceId) - .getSubscriptionExtractor() - .fromChannelUrl(channelUrl)) - .map(list -> list.stream() - .map(item -> new SubscriptionItem(item.getServiceId(), item.getUrl(), - item.getName())) - .collect(Collectors.toList())); - } - - private Flowable> importFromInputStream() { - Objects.requireNonNull(inputStream); - Objects.requireNonNull(inputStreamType); - - return Flowable.fromCallable(() -> NewPipe.getService(currentServiceId) - .getSubscriptionExtractor() - .fromInputStream(inputStream, inputStreamType)) - .map(list -> list.stream() - .map(item -> new SubscriptionItem(item.getServiceId(), item.getUrl(), - item.getName())) - .collect(Collectors.toList())); - } - - private Flowable> importFromPreviousExport() { - return Flowable.fromCallable(() -> ImportExportJsonHelper.readFrom(inputStream)); - } - - protected void handleError(@NonNull final Throwable error) { - super.handleError(R.string.subscriptions_import_unsuccessful, error); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelper.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/workers/ImportExportJsonHelper.kt similarity index 92% rename from app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelper.kt rename to app/src/main/java/org/schabi/newpipe/local/subscription/workers/ImportExportJsonHelper.kt index cd09b477e..d71f5fa89 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelper.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/workers/ImportExportJsonHelper.kt @@ -17,15 +17,13 @@ * along with this program. If not, see . */ -package org.schabi.newpipe.local.subscription.services +package org.schabi.newpipe.local.subscription.workers import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json import kotlinx.serialization.json.decodeFromStream import kotlinx.serialization.json.encodeToStream import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor.InvalidSourceException -import org.schabi.newpipe.local.subscription.workers.SubscriptionData -import org.schabi.newpipe.local.subscription.workers.SubscriptionItem import java.io.InputStream import java.io.OutputStream diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionExportWorker.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionExportWorker.kt index 3a83adcb6..42b77e21c 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionExportWorker.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionExportWorker.kt @@ -23,7 +23,6 @@ import kotlinx.coroutines.withContext import org.schabi.newpipe.BuildConfig import org.schabi.newpipe.NewPipeDatabase import org.schabi.newpipe.R -import org.schabi.newpipe.local.subscription.services.ImportExportJsonHelper class SubscriptionExportWorker( appContext: Context, diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionImportWorker.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionImportWorker.kt new file mode 100644 index 000000000..4e5c2a541 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionImportWorker.kt @@ -0,0 +1,153 @@ +package org.schabi.newpipe.local.subscription.workers + +import android.app.Notification +import android.content.Context +import android.content.pm.ServiceInfo +import android.os.Build +import android.util.Pair +import android.webkit.MimeTypeMap +import androidx.core.app.NotificationCompat +import androidx.core.net.toUri +import androidx.work.CoroutineWorker +import androidx.work.ForegroundInfo +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.rx3.await +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.schabi.newpipe.R +import org.schabi.newpipe.extractor.NewPipe +import org.schabi.newpipe.local.subscription.SubscriptionManager +import org.schabi.newpipe.util.ExtractorHelper +import org.schabi.newpipe.util.KEY_SERVICE_ID +import org.schabi.newpipe.util.NO_SERVICE_ID + +class SubscriptionImportWorker( + appContext: Context, + params: WorkerParameters, +) : CoroutineWorker(appContext, params) { + // This is needed for API levels < 31 (Android S). + override suspend fun getForegroundInfo(): ForegroundInfo { + val title = applicationContext.getString(R.string.import_ongoing) + return createForegroundInfo(createNotification(title, null, 0, 0)) + } + + override suspend fun doWork(): Result { + val mode = inputData.getInt(KEY_MODE, CHANNEL_URL_MODE) + val extractor = NewPipe.getService(inputData.getInt(KEY_SERVICE_ID, NO_SERVICE_ID)) + .subscriptionExtractor + val value = inputData.getString(KEY_VALUE) ?: "" + + val subscriptions = withContext(Dispatchers.IO) { + if (mode == CHANNEL_URL_MODE) { + extractor + .fromChannelUrl(value) + .map { SubscriptionItem(it.serviceId, it.url, it.name) } + } else { + applicationContext.contentResolver.openInputStream(value.toUri())?.use { + if (mode == INPUT_STREAM_MODE) { + val contentType = MimeTypeMap.getFileExtensionFromUrl(value).ifEmpty { DEFAULT_MIME } + extractor + .fromInputStream(it, contentType) + .map { SubscriptionItem(it.serviceId, it.url, it.name) } + } else { + ImportExportJsonHelper.readFrom(it) + } + } ?: emptyList() + } + } + + val mutex = Mutex() + var index = 1 + val qty = subscriptions.size + var title = + applicationContext.resources.getQuantityString(R.plurals.load_subscriptions, qty, qty) + + val channelInfoList = withContext(Dispatchers.IO.limitedParallelism(PARALLEL_EXTRACTIONS)) { + subscriptions + .map { + async { + val channelInfo = + ExtractorHelper.getChannelInfo(it.serviceId, it.url, true).await() + val channelTab = + ExtractorHelper.getChannelTab(it.serviceId, channelInfo.tabs[0], true).await() + + val currentIndex = mutex.withLock { index++ } + val notification = createNotification(title, channelInfo.name, currentIndex, qty) + setForeground(createForegroundInfo(notification)) + + Pair(channelInfo, listOf(channelTab)) + } + }.awaitAll() + } + + title = applicationContext.resources.getQuantityString(R.plurals.import_subscriptions, qty, qty) + setForeground(createForegroundInfo(createNotification(title, null, 0, 0))) + index = 0 + + val subscriptionManager = SubscriptionManager(applicationContext) + for (chunk in channelInfoList.chunked(BUFFER_COUNT_BEFORE_INSERT)) { + withContext(Dispatchers.IO) { + subscriptionManager.upsertAll(chunk) + } + index += chunk.size + setForeground(createForegroundInfo(createNotification(title, null, index, qty))) + } + + return Result.success() + } + + private fun createNotification( + title: String, + text: String?, + currentProgress: Int, + maxProgress: Int, + ): Notification = + NotificationCompat + .Builder(applicationContext, NOTIFICATION_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_newpipe_triangle_white) + .setOngoing(true) + .setProgress(maxProgress, currentProgress, currentProgress == 0) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + .setContentTitle(title) + .setContentText(text) + .addAction( + R.drawable.ic_close, + applicationContext.getString(R.string.cancel), + WorkManager.getInstance(applicationContext).createCancelPendingIntent(id), + ).apply { + if (currentProgress > 0 && maxProgress > 0) { + val progressText = "$currentProgress/$maxProgress" + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + setSubText(progressText) + } else { + setContentInfo(progressText) + } + } + }.build() + + private fun createForegroundInfo(notification: Notification): ForegroundInfo { + val serviceType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC else 0 + return ForegroundInfo(NOTIFICATION_ID, notification, serviceType) + } + + companion object { + private const val NOTIFICATION_ID = 4568 + private const val NOTIFICATION_CHANNEL_ID = "newpipe" + private const val DEFAULT_MIME = "application/octet-stream" + private const val PARALLEL_EXTRACTIONS = 8 + private const val BUFFER_COUNT_BEFORE_INSERT = 50 + + const val WORK_NAME = "SubscriptionImportWorker" + const val CHANNEL_URL_MODE = 0 + const val INPUT_STREAM_MODE = 1 + const val PREVIOUS_EXPORT_MODE = 2 + const val KEY_MODE = "key_mode" + const val KEY_VALUE = "key_value" + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9486e88f4..245782dff 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -867,4 +867,12 @@ Exporting %d subscription… Exporting %d subscriptions… + + Loading %d subscription… + Loading %d subscriptions… + + + Importing %d subscription… + Importing %d subscriptions… + diff --git a/app/src/test/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelperTest.java b/app/src/test/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelperTest.java index 3243ad9fd..96bca9733 100644 --- a/app/src/test/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelperTest.java +++ b/app/src/test/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelperTest.java @@ -5,6 +5,7 @@ import static org.junit.Assert.fail; import org.junit.Test; import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor; +import org.schabi.newpipe.local.subscription.workers.ImportExportJsonHelper; import org.schabi.newpipe.local.subscription.workers.SubscriptionItem; import java.io.ByteArrayInputStream; From c0965a42a1cd937500f52886d34fed767a7e0545 Mon Sep 17 00:00:00 2001 From: Isira Seneviratne Date: Fri, 29 Nov 2024 07:05:06 +0530 Subject: [PATCH 05/30] 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 42b77e21c..e41bf3b38 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 4e5c2a541..e66e2a5df 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 4e31ccebf8c9b231afa8a3475c60b3fb48a3b439 Mon Sep 17 00:00:00 2001 From: Isira Seneviratne Date: Fri, 29 Nov 2024 07:23:37 +0530 Subject: [PATCH 06/30] 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 45336d7bd..9e19bb711 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -316,7 +316,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 2f4fc657c..bf1a2e4ab 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" @@ -133,6 +134,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 c9d155a33544d69364535b0d1b6ee8b81c37e1c2 Mon Sep 17 00:00:00 2001 From: Isira Seneviratne Date: Sat, 30 Nov 2024 07:23:37 +0530 Subject: [PATCH 07/30] 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 e41bf3b38..a124fc666 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 e66e2a5df..3556ac883 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 af8e5646a65d1563916e7b7f3bbcab8bac35f065 Mon Sep 17 00:00:00 2001 From: Isira Seneviratne Date: Sat, 30 Nov 2024 07:35:12 +0530 Subject: [PATCH 08/30] 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 9e19bb711..fec2e89a1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -223,7 +223,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 bf1a2e4ab..2f3dbce97 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 095155d35f4f776d561af5f484483278b73f91ad Mon Sep 17 00:00:00 2001 From: Isira Seneviratne Date: Sun, 1 Dec 2024 06:24:26 +0530 Subject: [PATCH 09/30] 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 3556ac883..cdcb335ee 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 60586c90d638d0030facb5c584f6a249c8279929 Mon Sep 17 00:00:00 2001 From: Isira Seneviratne Date: Sun, 1 Dec 2024 09:51:12 +0530 Subject: [PATCH 10/30] 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 47b6f4dd9..358741a48 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 474add4f4..7b666f357 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 cdcb335ee..b2a8a3a50 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 21973b362a6732e3fad2020421f6dc654c2dcf43 Mon Sep 17 00:00:00 2001 From: Isira Seneviratne Date: Sat, 21 Dec 2024 10:10:42 +0530 Subject: [PATCH 11/30] 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 7b666f357..8de2c94db 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 b2a8a3a50..e97eb760e 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() } From dbd11a6a8ddb139a506b0aa5ce89763a424ea8eb Mon Sep 17 00:00:00 2001 From: Profpatsch Date: Wed, 22 Jan 2025 15:34:04 +0100 Subject: [PATCH 12/30] SubscriptionImportWorker: inputs as sealed class --- .../ImportConfirmationDialog.java | 32 +----- .../subscription/SubscriptionFragment.kt | 5 +- .../SubscriptionsImportFragment.java | 10 +- .../workers/SubscriptionImportWorker.kt | 106 ++++++++++++++---- 4 files changed, 96 insertions(+), 57 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/ImportConfirmationDialog.java b/app/src/main/java/org/schabi/newpipe/local/subscription/ImportConfirmationDialog.java index 3db86a203..a193cc1b9 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/ImportConfirmationDialog.java +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/ImportConfirmationDialog.java @@ -11,7 +11,6 @@ import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.DialogFragment; import androidx.fragment.app.Fragment; import androidx.work.Constraints; -import androidx.work.Data; import androidx.work.ExistingWorkPolicy; import androidx.work.NetworkType; import androidx.work.OneTimeWorkRequest; @@ -22,31 +21,19 @@ import com.evernote.android.state.State; import com.livefront.bridge.Bridge; import org.schabi.newpipe.R; +import org.schabi.newpipe.local.subscription.workers.SubscriptionImportInput; import org.schabi.newpipe.local.subscription.workers.SubscriptionImportWorker; -import org.schabi.newpipe.util.Constants; public class ImportConfirmationDialog extends DialogFragment { @State - protected int mode; - @State - protected String value; - @State - protected int serviceId; + protected SubscriptionImportInput input; - public static void show(@NonNull final Fragment fragment, final int mode, - @Nullable final String value, final int serviceId) { + public static void show(@NonNull final Fragment fragment, final SubscriptionImportInput input) { final var confirmationDialog = new ImportConfirmationDialog(); - confirmationDialog.setData(mode, value, serviceId); + confirmationDialog.input = input; confirmationDialog.show(fragment.getParentFragmentManager(), null); } - @SuppressWarnings("HiddenField") - public void setData(final int mode, final String value, final int serviceId) { - this.mode = mode; - this.value = value; - this.serviceId = serviceId; - } - @NonNull @Override public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) { @@ -57,17 +44,12 @@ public class ImportConfirmationDialog extends DialogFragment { .setCancelable(true) .setNegativeButton(R.string.cancel, null) .setPositiveButton(R.string.ok, (dialogInterface, i) -> { - 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) + .setInputData(input.toData()) .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) .setConstraints(constraints) .build(); @@ -85,10 +67,6 @@ public class ImportConfirmationDialog extends DialogFragment { public void onCreate(@Nullable final Bundle savedInstanceState) { super.onCreate(savedInstanceState); - 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 cdc7ae179..91ff7cd27 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 @@ -49,11 +49,10 @@ 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.workers.SubscriptionExportWorker -import org.schabi.newpipe.local.subscription.workers.SubscriptionImportWorker +import org.schabi.newpipe.local.subscription.workers.SubscriptionImportInput import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard import org.schabi.newpipe.streams.io.StoredFileHelper import org.schabi.newpipe.ui.emptystate.setEmptyStateComposable -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,7 +230,7 @@ class SubscriptionFragment : BaseStateFragment() { val data = result.data?.dataString if (data != null && result.resultCode == Activity.RESULT_OK) { ImportConfirmationDialog.show( - this, SubscriptionImportWorker.PREVIOUS_EXPORT_MODE, data, NO_SERVICE_ID + this, SubscriptionImportInput.PreviousExportMode(data) ) } } 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 a1d244df8..aee7c0003 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 @@ -33,7 +33,7 @@ import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor; -import org.schabi.newpipe.local.subscription.workers.SubscriptionImportWorker; +import org.schabi.newpipe.local.subscription.workers.SubscriptionImportInput; import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard; import org.schabi.newpipe.streams.io.StoredFileHelper; import org.schabi.newpipe.util.Constants; @@ -164,8 +164,8 @@ public class SubscriptionsImportFragment extends BaseFragment { } public void onImportUrl(final String value) { - ImportConfirmationDialog.show(this, SubscriptionImportWorker.CHANNEL_URL_MODE, value, - currentServiceId); + ImportConfirmationDialog.show(this, + new SubscriptionImportInput.ChannelUrlMode(currentServiceId, value)); } public void onImportFile() { @@ -182,8 +182,8 @@ public class SubscriptionsImportFragment extends BaseFragment { private void requestImportFileResult(final ActivityResult result) { 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); + ImportConfirmationDialog.show(this, + new SubscriptionImportInput.InputStreamMode(currentServiceId, data)); } } 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 e97eb760e..4bcec5cb3 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,11 +3,13 @@ package org.schabi.newpipe.local.subscription.workers import android.content.Context import android.content.pm.ServiceInfo import android.os.Build +import android.os.Parcelable import android.webkit.MimeTypeMap import android.widget.Toast import androidx.core.app.NotificationCompat import androidx.core.net.toUri import androidx.work.CoroutineWorker +import androidx.work.Data import androidx.work.ForegroundInfo import androidx.work.WorkManager import androidx.work.WorkerParameters @@ -18,12 +20,11 @@ import kotlinx.coroutines.rx3.await import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import kotlinx.parcelize.Parcelize import org.schabi.newpipe.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, @@ -35,27 +36,29 @@ class SubscriptionImportWorker( } override suspend fun doWork(): Result { - val mode = inputData.getInt(KEY_MODE, CHANNEL_URL_MODE) - val serviceId = inputData.getInt(KEY_SERVICE_ID, NO_SERVICE_ID) - val value = inputData.getString(KEY_VALUE)!! + val input = SubscriptionImportInput.fromData(inputData) val subscriptions = withContext(Dispatchers.IO) { - if (mode == CHANNEL_URL_MODE) { - 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 } - NewPipe.getService(serviceId).subscriptionExtractor + when (input) { + is SubscriptionImportInput.ChannelUrlMode -> + NewPipe.getService(input.serviceId).subscriptionExtractor + .fromChannelUrl(input.url) + .map { SubscriptionItem(it.serviceId, it.url, it.name) } + + is SubscriptionImportInput.InputStreamMode -> + applicationContext.contentResolver.openInputStream(input.url.toUri())?.use { + val contentType = + MimeTypeMap.getFileExtensionFromUrl(input.url).ifEmpty { DEFAULT_MIME } + NewPipe.getService(input.serviceId).subscriptionExtractor .fromInputStream(it, contentType) .map { SubscriptionItem(it.serviceId, it.url, it.name) } - } else { + } + + is SubscriptionImportInput.PreviousExportMode -> + applicationContext.contentResolver.openInputStream(input.url.toUri())?.use { ImportExportJsonHelper.readFrom(it) } - } ?: emptyList() - } + } ?: emptyList() } val mutex = Mutex() @@ -146,10 +149,69 @@ class SubscriptionImportWorker( 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" + } +} + +sealed class SubscriptionImportInput : Parcelable { + @Parcelize + data class ChannelUrlMode(val serviceId: Int, val url: String) : SubscriptionImportInput() + @Parcelize + data class InputStreamMode(val serviceId: Int, val url: String) : SubscriptionImportInput() + @Parcelize + data class PreviousExportMode(val url: String) : SubscriptionImportInput() + + fun toData(): Data { + return when (this) { + is ChannelUrlMode -> Data.Builder() + .putInt("mode", CHANNEL_URL_MODE) + .putInt("service_id", serviceId) + .putString("url", url) + .build() + is InputStreamMode -> + Data.Builder() + .putInt("mode", INPUT_STREAM_MODE) + .putInt("service_id", serviceId) + .putString("url", url) + .build() + is PreviousExportMode -> + Data.Builder() + .putInt("mode", PREVIOUS_EXPORT_MODE) + .putString("url", url) + .build() + } + } + + companion object { + + private const val CHANNEL_URL_MODE = 0 + private const val INPUT_STREAM_MODE = 1 + private const val PREVIOUS_EXPORT_MODE = 2 + + fun fromData(data: Data): SubscriptionImportInput { + val mode = data.getInt("mode", PREVIOUS_EXPORT_MODE) + when (mode) { + CHANNEL_URL_MODE -> { + val serviceId = data.getInt("service_id", -1) + if (serviceId == -1) { + throw IllegalArgumentException("No service id provided") + } + val url = data.getString("url")!! + return ChannelUrlMode(serviceId, url) + } + INPUT_STREAM_MODE -> { + val serviceId = data.getInt("service_id", -1) + if (serviceId == -1) { + throw IllegalArgumentException("No service id provided") + } + val url = data.getString("url")!! + return InputStreamMode(serviceId, url) + } + PREVIOUS_EXPORT_MODE -> { + val url = data.getString("url")!! + return PreviousExportMode(url) + } + else -> throw IllegalArgumentException("Unknown mode: $mode") + } + } } } From a79516dfffc787695414f504d24838162e1c6697 Mon Sep 17 00:00:00 2001 From: Isira Seneviratne Date: Sat, 25 Jan 2025 05:30:14 +0530 Subject: [PATCH 13/30] Use fragment arguments --- .../local/subscription/ImportConfirmationDialog.java | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/ImportConfirmationDialog.java b/app/src/main/java/org/schabi/newpipe/local/subscription/ImportConfirmationDialog.java index a193cc1b9..a4b3ea399 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/ImportConfirmationDialog.java +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/ImportConfirmationDialog.java @@ -8,6 +8,7 @@ import android.os.Bundle; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; +import androidx.core.os.BundleCompat; import androidx.fragment.app.DialogFragment; import androidx.fragment.app.Fragment; import androidx.work.Constraints; @@ -17,7 +18,6 @@ import androidx.work.OneTimeWorkRequest; import androidx.work.OutOfQuotaPolicy; import androidx.work.WorkManager; -import com.evernote.android.state.State; import com.livefront.bridge.Bridge; import org.schabi.newpipe.R; @@ -25,12 +25,13 @@ import org.schabi.newpipe.local.subscription.workers.SubscriptionImportInput; import org.schabi.newpipe.local.subscription.workers.SubscriptionImportWorker; public class ImportConfirmationDialog extends DialogFragment { - @State - protected SubscriptionImportInput input; + private static final String INPUT = "input"; public static void show(@NonNull final Fragment fragment, final SubscriptionImportInput input) { final var confirmationDialog = new ImportConfirmationDialog(); - confirmationDialog.input = input; + final var arguments = new Bundle(); + arguments.putParcelable(INPUT, input); + confirmationDialog.setArguments(arguments); confirmationDialog.show(fragment.getParentFragmentManager(), null); } @@ -47,6 +48,8 @@ public class ImportConfirmationDialog extends DialogFragment { final var constraints = new Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .build(); + final var input = BundleCompat.getParcelable(requireArguments(), INPUT, + SubscriptionImportInput.class); final var req = new OneTimeWorkRequest.Builder(SubscriptionImportWorker.class) .setInputData(input.toData()) From af3ed992e5cd8759557a1052739a67af392db882 Mon Sep 17 00:00:00 2001 From: Isira Seneviratne Date: Sat, 25 Jan 2025 05:40:00 +0530 Subject: [PATCH 14/30] Add error handling for import --- .../workers/SubscriptionImportWorker.kt | 123 +++++++++++------- 1 file changed, 77 insertions(+), 46 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 4bcec5cb3..df058b8ee 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 @@ -4,6 +4,7 @@ import android.content.Context import android.content.pm.ServiceInfo import android.os.Build import android.os.Parcelable +import android.util.Log import android.webkit.MimeTypeMap import android.widget.Toast import androidx.core.app.NotificationCompat @@ -21,6 +22,7 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.parcelize.Parcelize +import org.schabi.newpipe.BuildConfig import org.schabi.newpipe.R import org.schabi.newpipe.extractor.NewPipe import org.schabi.newpipe.local.subscription.SubscriptionManager @@ -36,9 +38,79 @@ class SubscriptionImportWorker( } override suspend fun doWork(): Result { - val input = SubscriptionImportInput.fromData(inputData) + val subscriptions = + try { + loadSubscriptionsFromInput(SubscriptionImportInput.fromData(inputData)) + } catch (e: Exception) { + if (BuildConfig.DEBUG) { + Log.e(TAG, "Error while loading subscriptions from path", e) + } + withContext(Dispatchers.Main) { + Toast + .makeText(applicationContext, R.string.subscriptions_import_unsuccessful, Toast.LENGTH_SHORT) + .show() + } + return Result.failure() + } - val subscriptions = withContext(Dispatchers.IO) { + val mutex = Mutex() + var index = 1 + val qty = subscriptions.size + var title = + applicationContext.resources.getQuantityString(R.plurals.load_subscriptions, qty, qty) + + val channelInfoList = + try { + withContext(Dispatchers.IO.limitedParallelism(PARALLEL_EXTRACTIONS)) { + subscriptions + .map { + async { + val channelInfo = + ExtractorHelper.getChannelInfo(it.serviceId, it.url, true).await() + val channelTab = + ExtractorHelper.getChannelTab(it.serviceId, channelInfo.tabs[0], true).await() + + val currentIndex = mutex.withLock { index++ } + setForeground(createForegroundInfo(title, channelInfo.name, currentIndex, qty)) + + channelInfo to channelTab + } + }.awaitAll() + } + } catch (e: Exception) { + if (BuildConfig.DEBUG) { + Log.e(TAG, "Error while loading subscription data", e) + } + withContext(Dispatchers.Main) { + Toast.makeText(applicationContext, R.string.subscriptions_import_unsuccessful, Toast.LENGTH_SHORT) + .show() + } + return Result.failure() + } + + title = applicationContext.resources.getQuantityString(R.plurals.import_subscriptions, qty, qty) + setForeground(createForegroundInfo(title, null, 0, 0)) + index = 0 + + val subscriptionManager = SubscriptionManager(applicationContext) + for (chunk in channelInfoList.chunked(BUFFER_COUNT_BEFORE_INSERT)) { + withContext(Dispatchers.IO) { + subscriptionManager.upsertAll(chunk) + } + index += chunk.size + setForeground(createForegroundInfo(title, null, index, qty)) + } + + withContext(Dispatchers.Main) { + Toast.makeText(applicationContext, R.string.import_complete_toast, Toast.LENGTH_SHORT) + .show() + } + + return Result.success() + } + + private suspend fun loadSubscriptionsFromInput(input: SubscriptionImportInput): List { + return withContext(Dispatchers.IO) { when (input) { is SubscriptionImportInput.ChannelUrlMode -> NewPipe.getService(input.serviceId).subscriptionExtractor @@ -60,50 +132,6 @@ class SubscriptionImportWorker( } } ?: 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++ } - setForeground(createForegroundInfo(title, channelInfo.name, currentIndex, qty)) - - channelInfo to channelTab - } - }.awaitAll() - } - - title = applicationContext.resources.getQuantityString(R.plurals.import_subscriptions, qty, qty) - setForeground(createForegroundInfo(title, null, 0, 0)) - index = 0 - - val subscriptionManager = SubscriptionManager(applicationContext) - for (chunk in channelInfoList.chunked(BUFFER_COUNT_BEFORE_INSERT)) { - withContext(Dispatchers.IO) { - subscriptionManager.upsertAll(chunk) - } - index += chunk.size - setForeground(createForegroundInfo(title, null, index, qty)) - } - - withContext(Dispatchers.Main) { - Toast - .makeText(applicationContext, R.string.import_complete_toast, Toast.LENGTH_SHORT) - .show() - } - - return Result.success() } private fun createForegroundInfo( @@ -142,6 +170,9 @@ class SubscriptionImportWorker( } companion object { + // Log tag length is limited to 23 characters on API levels < 24. + private const val TAG = "SubscriptionImport" + private const val NOTIFICATION_ID = 4568 private const val NOTIFICATION_CHANNEL_ID = "newpipe" private const val DEFAULT_MIME = "application/octet-stream" From 32a88ab89027248c82512c821fb08df05464db75 Mon Sep 17 00:00:00 2001 From: Isira Seneviratne Date: Sat, 25 Jan 2025 07:57:01 +0530 Subject: [PATCH 15/30] Truncate existing file in export --- .../local/subscription/workers/SubscriptionExportWorker.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 a124fc666..6de91f174 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 @@ -46,7 +46,8 @@ class SubscriptionExportWorker( setForeground(createForegroundInfo(title)) withContext(Dispatchers.IO) { - applicationContext.contentResolver.openOutputStream(uri)?.use { + // Truncate file if it already exists + applicationContext.contentResolver.openOutputStream(uri, "wt")?.use { ImportExportJsonHelper.writeTo(subscriptions, it) } } From 48d682016e38e1301510cb3bfaee9107fb227949 Mon Sep 17 00:00:00 2001 From: Isira Seneviratne Date: Sat, 25 Jan 2025 11:21:58 +0530 Subject: [PATCH 16/30] Rm ViewPager version --- gradle/libs.versions.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2f3dbce97..bfafa7a80 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -55,7 +55,6 @@ swiperefreshlayout = "1.1.0" teamnewpipe-filepicker = "5.0.0" teamnewpipe-nanojson = "1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751" teamnewpipe-newpipe-extractor = "v0.24.4" -viewpager2 = "1.1.0-beta02" work = "2.10.0" [plugins] From d805679a5ed3704dfff67604197ee9e7b4e8a0bf Mon Sep 17 00:00:00 2001 From: Isira Seneviratne Date: Sat, 8 Mar 2025 09:18:30 +0530 Subject: [PATCH 17/30] Use workDataOf --- .../workers/SubscriptionImportWorker.kt | 23 +++++-------------- 1 file changed, 6 insertions(+), 17 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 df058b8ee..86b9c739a 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 @@ -14,6 +14,7 @@ import androidx.work.Data import androidx.work.ForegroundInfo import androidx.work.WorkManager import androidx.work.WorkerParameters +import androidx.work.workDataOf import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -192,24 +193,12 @@ sealed class SubscriptionImportInput : Parcelable { data class PreviousExportMode(val url: String) : SubscriptionImportInput() fun toData(): Data { - return when (this) { - is ChannelUrlMode -> Data.Builder() - .putInt("mode", CHANNEL_URL_MODE) - .putInt("service_id", serviceId) - .putString("url", url) - .build() - is InputStreamMode -> - Data.Builder() - .putInt("mode", INPUT_STREAM_MODE) - .putInt("service_id", serviceId) - .putString("url", url) - .build() - is PreviousExportMode -> - Data.Builder() - .putInt("mode", PREVIOUS_EXPORT_MODE) - .putString("url", url) - .build() + val (mode, serviceId, url) = when (this) { + is ChannelUrlMode -> Triple(CHANNEL_URL_MODE, serviceId, url) + is InputStreamMode -> Triple(INPUT_STREAM_MODE, serviceId, url) + is PreviousExportMode -> Triple(PREVIOUS_EXPORT_MODE, null, url) } + return workDataOf("mode" to mode, "service_id" to serviceId, "url" to url) } companion object { From ce16c6df5fe446d2bf9a42077faf3880bddec2d5 Mon Sep 17 00:00:00 2001 From: Naveen Singh <36371707+naveensingh@users.noreply.github.com> Date: Sun, 27 Apr 2025 19:35:31 -0400 Subject: [PATCH 18/30] Fix image minimizer pattern Added non-capturing group that matches either: - `user-attachments/assets` - `owner/repo/assets/digits` --- .github/workflows/image-minimizer.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/image-minimizer.js b/.github/workflows/image-minimizer.js index 4f9ad616d..0a1e56c56 100644 --- a/.github/workflows/image-minimizer.js +++ b/.github/workflows/image-minimizer.js @@ -33,11 +33,11 @@ module.exports = async ({github, context}) => { // Regex for finding images (simple variant) ![ALT_TEXT](https://*.githubusercontent.com//.) const REGEX_USER_CONTENT_IMAGE_LOOKUP = /\!\[([^\]]*)\]\((https:\/\/[-a-z0-9]+\.githubusercontent\.com\/\d+\/[-0-9a-f]{32,512}\.(jpg|gif|png))\)/gm; - const REGEX_ASSETS_IMAGE_LOCKUP = /\!\[([^\]]*)\]\((https:\/\/github\.com\/[-\w\d]+\/[-\w\d]+\/assets\/\d+\/[\-0-9a-f]{32,512})\)/gm; + const REGEX_ASSETS_IMAGE_LOOKUP = /\!\[([^\]]*)\]\((https:\/\/github\.com\/(?:user-attachments\/assets|[-\w\d]+\/[-\w\d]+\/assets\/\d+)\/[\-0-9a-f]{32,512})\)/gm; // Check if we found something let foundSimpleImages = REGEX_USER_CONTENT_IMAGE_LOOKUP.test(initialBody) - || REGEX_ASSETS_IMAGE_LOCKUP.test(initialBody); + || REGEX_ASSETS_IMAGE_LOOKUP.test(initialBody); if (!foundSimpleImages) { console.log('Found no simple images to process'); return; @@ -52,7 +52,7 @@ module.exports = async ({github, context}) => { // Try to find and replace the images with minimized ones let newBody = await replaceAsync(initialBody, REGEX_USER_CONTENT_IMAGE_LOOKUP, minimizeAsync); - newBody = await replaceAsync(newBody, REGEX_ASSETS_IMAGE_LOCKUP, minimizeAsync); + newBody = await replaceAsync(newBody, REGEX_ASSETS_IMAGE_LOOKUP, minimizeAsync); if (!wasMatchModified) { console.log('Nothing was modified. Skipping update'); From 5ca544bc426ef460b02bb994e069007c3f5e059b Mon Sep 17 00:00:00 2001 From: Profpatsch Date: Tue, 6 May 2025 10:48:20 +0200 Subject: [PATCH 19/30] build.gradle: Improve jitpack workaround doc & fix hash --- app/build.gradle | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index f3159f741..25a1b6a2e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -211,8 +211,10 @@ dependencies { // This works thanks to JitPack: https://jitpack.io/ implementation 'com.github.TeamNewPipe:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751' // WORKAROUND: if you get errors with the NewPipeExtractor dependency, replace `v0.24.3` with - // the corresponding commit hash, since JitPack is sometimes buggy - implementation 'com.github.TeamNewPipe:NewPipeExtractor:0b99100db' + // the corresponding commit hash, since JitPack sometimes deletes artifacts. + // If there’s already a git hash, just add more of it to the end (or remove a letter) + // to cause jitpack to regenerate the artifact. + implementation 'com.github.TeamNewPipe:NewPipeExtractor:0b99100dbddeca2f' implementation 'com.github.TeamNewPipe:NoNonsense-FilePicker:5.0.0' /** Checkstyle **/ From 48e826e91294ccaab2388a720f2e584a0a1e655b Mon Sep 17 00:00:00 2001 From: j-haldane Date: Tue, 6 May 2025 11:07:45 -0400 Subject: [PATCH 20/30] Fix header crash in History List view (#12214) * Adapt header handling changes from other recyclerview adapters to fix issue #4475 in StatisticsPlaylistFragment * Remove unneeded LayoutInflater * Revert "Remove unneeded LayoutInflater" This reverts commit ab73dc1e7290f9e133435222bf35dee54de837f4. * Revert "Adapt header handling changes from other recyclerview adapters to fix issue #4475 in StatisticsPlaylistFragment" This reverts commit 2abe71cc986114000ecdd14aaa17850f60b9549c. * Remove header animation causing view recycling issue --- .../org/schabi/newpipe/local/BaseLocalListFragment.java | 9 --------- 1 file changed, 9 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/local/BaseLocalListFragment.java b/app/src/main/java/org/schabi/newpipe/local/BaseLocalListFragment.java index 53fe1677b..90ef8c352 100644 --- a/app/src/main/java/org/schabi/newpipe/local/BaseLocalListFragment.java +++ b/app/src/main/java/org/schabi/newpipe/local/BaseLocalListFragment.java @@ -194,9 +194,6 @@ public abstract class BaseLocalListFragment extends BaseStateFragment if (itemsList != null) { animateHideRecyclerViewAllowingScrolling(itemsList); } - if (headerRootBinding != null) { - animate(headerRootBinding.getRoot(), false, 200); - } } @Override @@ -205,9 +202,6 @@ public abstract class BaseLocalListFragment extends BaseStateFragment if (itemsList != null) { animate(itemsList, true, 200); } - if (headerRootBinding != null) { - animate(headerRootBinding.getRoot(), true, 200); - } } @Override @@ -253,9 +247,6 @@ public abstract class BaseLocalListFragment extends BaseStateFragment if (itemsList != null) { animateHideRecyclerViewAllowingScrolling(itemsList); } - if (headerRootBinding != null) { - animate(headerRootBinding.getRoot(), false, 200); - } } @Override From e7f3750f5e7c80d0e22553ca8f752ccb2d65401f Mon Sep 17 00:00:00 2001 From: Andriana Date: Thu, 24 Apr 2025 16:08:27 +0300 Subject: [PATCH 21/30] Fix timestamps not working in comment replies Use LinkMovementMethodCompat for comment links Co-authored-by: Isira Seneviratne <31027858+Isira-Seneviratne@users.noreply.github.com> Update import Use LongPressLinkMovementMethod --- .../fragments/list/comments/CommentRepliesFragment.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentRepliesFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentRepliesFragment.java index 0514eefde..ce52c029d 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentRepliesFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentRepliesFragment.java @@ -28,6 +28,7 @@ import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.image.ImageStrategy; import org.schabi.newpipe.util.image.PicassoHelper; import org.schabi.newpipe.util.text.TextLinkifier; +import org.schabi.newpipe.util.text.LongPressLinkMovementMethod; import java.util.Queue; import java.util.function.Supplier; @@ -110,7 +111,7 @@ public final class CommentRepliesFragment TextLinkifier.fromDescription(binding.commentContent, item.getCommentText(), HtmlCompat.FROM_HTML_MODE_LEGACY, getServiceById(item.getServiceId()), item.getUrl(), disposables, null); - + binding.commentContent.setMovementMethod(LongPressLinkMovementMethod.getInstance()); return binding.getRoot(); }; } From 8d679626f0eeeb65aba4e858487daaddc1156362 Mon Sep 17 00:00:00 2001 From: VougJo23 Date: Wed, 16 Apr 2025 18:29:33 +0300 Subject: [PATCH 22/30] fix: support RTL usernames in comment header The `@` gets added by the youtube API and thus is a fixed member of the username, so we do some simple detection logic to handle that case (otherwise the `@` will be at the right side of a RTL username, which is different of how Youtube displays these usernames in the browser). Fixes https://github.com/TeamNewPipe/NewPipe/issues/12141 --- .../holder/CommentInfoItemHolder.java | 10 ++++++---- .../org/schabi/newpipe/util/Localization.java | 20 +++++++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentInfoItemHolder.java index 839aa1813..a19831cc7 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentInfoItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentInfoItemHolder.java @@ -101,14 +101,16 @@ public class CommentInfoItemHolder extends InfoItemHolder { } itemThumbnailView.setOnClickListener(view -> openCommentAuthor(item)); - // setup the top row, with pinned icon, author name and comment date itemPinnedView.setVisibility(item.isPinned() ? View.VISIBLE : View.GONE); - itemTitleView.setText(Localization.concatenateStrings(item.getUploaderName(), - Localization.relativeTimeOrTextual(itemBuilder.getContext(), item.getUploadDate(), + final String uploaderName = Localization.localizeUserName(item.getUploaderName()); + itemTitleView.setText(Localization.concatenateStrings( + uploaderName, + Localization.relativeTimeOrTextual( + itemBuilder.getContext(), + item.getUploadDate(), item.getTextualUploadDate()))); - // setup bottom row, with likes, heart and replies button itemLikesCountView.setText( Localization.likeCount(itemBuilder.getContext(), item.getLikeCount())); diff --git a/app/src/main/java/org/schabi/newpipe/util/Localization.java b/app/src/main/java/org/schabi/newpipe/util/Localization.java index 2146cf8bc..e92ad0b1c 100644 --- a/app/src/main/java/org/schabi/newpipe/util/Localization.java +++ b/app/src/main/java/org/schabi/newpipe/util/Localization.java @@ -11,6 +11,7 @@ import android.icu.text.CompactDecimalFormat; import android.os.Build; import android.text.TextUtils; import android.text.format.DateUtils; +import android.text.BidiFormatter; import android.util.DisplayMetrics; import android.util.Log; @@ -85,6 +86,25 @@ public final class Localization { .collect(Collectors.joining(delimiter)); } + /** + * Localize a user name like @foobar. + * + * Will correctly handle right-to-left usernames by using a {@link BidiFormatter}. + * + * @param plainName username, with an optional leading @ + * @return a usernames that can include RTL-characters + */ + @NonNull + public static String localizeUserName(final String plainName) { + final BidiFormatter bidi = BidiFormatter.getInstance(); + + if (plainName.startsWith("@")) { + return "@" + bidi.unicodeWrap(plainName.substring(1)); + } else { + return bidi.unicodeWrap(plainName); + } + } + public static org.schabi.newpipe.extractor.localization.Localization getPreferredLocalization( final Context context) { return org.schabi.newpipe.extractor.localization.Localization From 1dcb1953ba2dca88b1b3cab41f72346458a58a30 Mon Sep 17 00:00:00 2001 From: Stypox Date: Wed, 7 May 2025 12:36:02 +0200 Subject: [PATCH 23/30] Update NewPipeExtractor to v0.24.6 For some reason com.github.TeamNewPipe.NewPipeExtractor:v0.24.6 didn't work, but com.github.TeamNewPipe.NewPipeExtractor:NewPipeExtractor:v0.24.6 as suggested on https://jitpack.io/#TeamNewPipe/NewPipeExtractor/v0.24.6 worked... --- app/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle b/app/build.gradle index d03bd64e3..886db7b28 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -208,7 +208,7 @@ dependencies { implementation 'com.github.TeamNewPipe:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751' // WORKAROUND: if you get errors with the NewPipeExtractor dependency, replace `v0.24.3` with // the corresponding commit hash, since JitPack is sometimes buggy - implementation 'com.github.TeamNewPipe:NewPipeExtractor:9f83b385a' + implementation 'com.github.TeamNewPipe.NewPipeExtractor:NewPipeExtractor:v0.24.6' implementation 'com.github.TeamNewPipe:NoNonsense-FilePicker:5.0.0' /** Checkstyle **/ From b6aa07545a566ed84827561c8873feeadcdc28ae Mon Sep 17 00:00:00 2001 From: Stypox Date: Wed, 7 May 2025 12:48:59 +0200 Subject: [PATCH 24/30] Add changelog for v0.26.7 (1004) --- fastlane/metadata/android/en-US/changelogs/1004.txt | 3 +++ fastlane/metadata/android/it/changelogs/1004.txt | 3 +++ 2 files changed, 6 insertions(+) create mode 100644 fastlane/metadata/android/en-US/changelogs/1004.txt create mode 100644 fastlane/metadata/android/it/changelogs/1004.txt diff --git a/fastlane/metadata/android/en-US/changelogs/1004.txt b/fastlane/metadata/android/en-US/changelogs/1004.txt new file mode 100644 index 000000000..1d3485059 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/1004.txt @@ -0,0 +1,3 @@ +This release fixes YouTube only providing a 360p stream. + +Note that the solution employed in this version is likely temporary, and in the long run the SABR video protocol needs to be implemented, but TeamNewPipe members are currently busy so any help would be greatly appreciated! https://github.com/TeamNewPipe/NewPipe/issues/12248 diff --git a/fastlane/metadata/android/it/changelogs/1004.txt b/fastlane/metadata/android/it/changelogs/1004.txt new file mode 100644 index 000000000..3e04ffcb2 --- /dev/null +++ b/fastlane/metadata/android/it/changelogs/1004.txt @@ -0,0 +1,3 @@ +Questa versione risolve il problema di YouTube che permette di riprodurre video solo a 360p. + +La soluzione impiegata in questa versione è probabilmente temporanea, e a lungo termine c'è da implementare il protocollo video SABR, ma i membri del TeamNewPipe non hanno tempo al momento, quindi qualsiasi aiuto sarebbe molto apprezzato! https://github.com/TeamNewPipe/NewPipe/issues/12248 From 8407b5aefd2be6d8a381c1effbd27e9608f44730 Mon Sep 17 00:00:00 2001 From: Stypox Date: Wed, 7 May 2025 12:49:31 +0200 Subject: [PATCH 25/30] Add translated changelogs for v0.27.7 Copied from 985.txt --- fastlane/metadata/android/ar/changelogs/1004.txt | 1 + fastlane/metadata/android/az/changelogs/1004.txt | 1 + fastlane/metadata/android/cs/changelogs/1004.txt | 1 + fastlane/metadata/android/de/changelogs/1004.txt | 1 + fastlane/metadata/android/es/changelogs/1004.txt | 1 + fastlane/metadata/android/fa/changelogs/1004.txt | 1 + fastlane/metadata/android/fr/changelogs/1004.txt | 1 + fastlane/metadata/android/he/changelogs/1004.txt | 1 + fastlane/metadata/android/hi/changelogs/1004.txt | 1 + fastlane/metadata/android/hu/changelogs/1004.txt | 1 + fastlane/metadata/android/id/changelogs/1004.txt | 1 + fastlane/metadata/android/ka/changelogs/1004.txt | 1 + fastlane/metadata/android/ko/changelogs/1004.txt | 1 + fastlane/metadata/android/nl/changelogs/1004.txt | 1 + fastlane/metadata/android/pa/changelogs/1004.txt | 1 + fastlane/metadata/android/pt-BR/changelogs/1004.txt | 1 + fastlane/metadata/android/pt-PT/changelogs/1004.txt | 1 + fastlane/metadata/android/pt/changelogs/1004.txt | 1 + fastlane/metadata/android/ru/changelogs/1004.txt | 1 + fastlane/metadata/android/sk/changelogs/1004.txt | 1 + fastlane/metadata/android/sv/changelogs/1004.txt | 1 + fastlane/metadata/android/ta/changelogs/1004.txt | 1 + fastlane/metadata/android/tr/changelogs/1004.txt | 1 + fastlane/metadata/android/uk/changelogs/1004.txt | 1 + fastlane/metadata/android/vi/changelogs/1004.txt | 1 + fastlane/metadata/android/zh-Hans/changelogs/1004.txt | 1 + fastlane/metadata/android/zh-Hant/changelogs/1004.txt | 1 + fastlane/metadata/android/zh_Hant_HK/changelogs/1004.txt | 1 + 28 files changed, 28 insertions(+) create mode 100644 fastlane/metadata/android/ar/changelogs/1004.txt create mode 100644 fastlane/metadata/android/az/changelogs/1004.txt create mode 100644 fastlane/metadata/android/cs/changelogs/1004.txt create mode 100644 fastlane/metadata/android/de/changelogs/1004.txt create mode 100644 fastlane/metadata/android/es/changelogs/1004.txt create mode 100644 fastlane/metadata/android/fa/changelogs/1004.txt create mode 100644 fastlane/metadata/android/fr/changelogs/1004.txt create mode 100644 fastlane/metadata/android/he/changelogs/1004.txt create mode 100644 fastlane/metadata/android/hi/changelogs/1004.txt create mode 100644 fastlane/metadata/android/hu/changelogs/1004.txt create mode 100644 fastlane/metadata/android/id/changelogs/1004.txt create mode 100644 fastlane/metadata/android/ka/changelogs/1004.txt create mode 100644 fastlane/metadata/android/ko/changelogs/1004.txt create mode 100644 fastlane/metadata/android/nl/changelogs/1004.txt create mode 100644 fastlane/metadata/android/pa/changelogs/1004.txt create mode 100644 fastlane/metadata/android/pt-BR/changelogs/1004.txt create mode 100644 fastlane/metadata/android/pt-PT/changelogs/1004.txt create mode 100644 fastlane/metadata/android/pt/changelogs/1004.txt create mode 100644 fastlane/metadata/android/ru/changelogs/1004.txt create mode 100644 fastlane/metadata/android/sk/changelogs/1004.txt create mode 100644 fastlane/metadata/android/sv/changelogs/1004.txt create mode 100644 fastlane/metadata/android/ta/changelogs/1004.txt create mode 100644 fastlane/metadata/android/tr/changelogs/1004.txt create mode 100644 fastlane/metadata/android/uk/changelogs/1004.txt create mode 100644 fastlane/metadata/android/vi/changelogs/1004.txt create mode 100644 fastlane/metadata/android/zh-Hans/changelogs/1004.txt create mode 100644 fastlane/metadata/android/zh-Hant/changelogs/1004.txt create mode 100644 fastlane/metadata/android/zh_Hant_HK/changelogs/1004.txt diff --git a/fastlane/metadata/android/ar/changelogs/1004.txt b/fastlane/metadata/android/ar/changelogs/1004.txt new file mode 100644 index 000000000..562f16944 --- /dev/null +++ b/fastlane/metadata/android/ar/changelogs/1004.txt @@ -0,0 +1 @@ +تم إصلاح YouTube الذي لا يقوم بتشغيل أي دفق diff --git a/fastlane/metadata/android/az/changelogs/1004.txt b/fastlane/metadata/android/az/changelogs/1004.txt new file mode 100644 index 000000000..16a2e1013 --- /dev/null +++ b/fastlane/metadata/android/az/changelogs/1004.txt @@ -0,0 +1 @@ +YouTube-un heç bir yayım oynatmaması düzəldildi diff --git a/fastlane/metadata/android/cs/changelogs/1004.txt b/fastlane/metadata/android/cs/changelogs/1004.txt new file mode 100644 index 000000000..7035a1112 --- /dev/null +++ b/fastlane/metadata/android/cs/changelogs/1004.txt @@ -0,0 +1 @@ +Opraveno nepřehrávání jakéhokoli streamu ve službě YouTube diff --git a/fastlane/metadata/android/de/changelogs/1004.txt b/fastlane/metadata/android/de/changelogs/1004.txt new file mode 100644 index 000000000..43623578f --- /dev/null +++ b/fastlane/metadata/android/de/changelogs/1004.txt @@ -0,0 +1 @@ +Behoben, dass YouTube keinen Stream abspielte diff --git a/fastlane/metadata/android/es/changelogs/1004.txt b/fastlane/metadata/android/es/changelogs/1004.txt new file mode 100644 index 000000000..80b4efa55 --- /dev/null +++ b/fastlane/metadata/android/es/changelogs/1004.txt @@ -0,0 +1 @@ +Arreglo en YouTube no reproduciendo flujos diff --git a/fastlane/metadata/android/fa/changelogs/1004.txt b/fastlane/metadata/android/fa/changelogs/1004.txt new file mode 100644 index 000000000..ba5413d49 --- /dev/null +++ b/fastlane/metadata/android/fa/changelogs/1004.txt @@ -0,0 +1 @@ +مشکل عدم نمایش پخش‌زنده برطرف شد diff --git a/fastlane/metadata/android/fr/changelogs/1004.txt b/fastlane/metadata/android/fr/changelogs/1004.txt new file mode 100644 index 000000000..3ad3bf279 --- /dev/null +++ b/fastlane/metadata/android/fr/changelogs/1004.txt @@ -0,0 +1 @@ +Correction de YouTube qui ne lisait aucun média diff --git a/fastlane/metadata/android/he/changelogs/1004.txt b/fastlane/metadata/android/he/changelogs/1004.txt new file mode 100644 index 000000000..50731171e --- /dev/null +++ b/fastlane/metadata/android/he/changelogs/1004.txt @@ -0,0 +1 @@ +תוקנה התקלה ש־YouTube לא מנגן אף תזרים diff --git a/fastlane/metadata/android/hi/changelogs/1004.txt b/fastlane/metadata/android/hi/changelogs/1004.txt new file mode 100644 index 000000000..071ab64e3 --- /dev/null +++ b/fastlane/metadata/android/hi/changelogs/1004.txt @@ -0,0 +1 @@ +फिक्स्ड YouTube कोई स्ट्रीम नहीं चला रहा है diff --git a/fastlane/metadata/android/hu/changelogs/1004.txt b/fastlane/metadata/android/hu/changelogs/1004.txt new file mode 100644 index 000000000..f4de95e68 --- /dev/null +++ b/fastlane/metadata/android/hu/changelogs/1004.txt @@ -0,0 +1 @@ +Immáron minden YouTube videó lejátszásra kerül diff --git a/fastlane/metadata/android/id/changelogs/1004.txt b/fastlane/metadata/android/id/changelogs/1004.txt new file mode 100644 index 000000000..d3fea84ab --- /dev/null +++ b/fastlane/metadata/android/id/changelogs/1004.txt @@ -0,0 +1 @@ +Memperbaiki YouTube yang tidak memutar streaming apa pun diff --git a/fastlane/metadata/android/ka/changelogs/1004.txt b/fastlane/metadata/android/ka/changelogs/1004.txt new file mode 100644 index 000000000..d20512f17 --- /dev/null +++ b/fastlane/metadata/android/ka/changelogs/1004.txt @@ -0,0 +1 @@ +გაასწორა YouTube არ უკრავს არცერთ ნაკადს diff --git a/fastlane/metadata/android/ko/changelogs/1004.txt b/fastlane/metadata/android/ko/changelogs/1004.txt new file mode 100644 index 000000000..39ea56541 --- /dev/null +++ b/fastlane/metadata/android/ko/changelogs/1004.txt @@ -0,0 +1 @@ +YouTube에서 스트림을 재생하지 않는 문제 수정 diff --git a/fastlane/metadata/android/nl/changelogs/1004.txt b/fastlane/metadata/android/nl/changelogs/1004.txt new file mode 100644 index 000000000..9bd8adf86 --- /dev/null +++ b/fastlane/metadata/android/nl/changelogs/1004.txt @@ -0,0 +1 @@ +YouTube speelt geen stream af opgelost diff --git a/fastlane/metadata/android/pa/changelogs/1004.txt b/fastlane/metadata/android/pa/changelogs/1004.txt new file mode 100644 index 000000000..fe62a1330 --- /dev/null +++ b/fastlane/metadata/android/pa/changelogs/1004.txt @@ -0,0 +1 @@ +ਸਥਿਰ YouTube ਕੋਈ ਸਟ੍ਰੀਮ ਨਹੀਂ ਚਲਾ ਰਿਹਾ diff --git a/fastlane/metadata/android/pt-BR/changelogs/1004.txt b/fastlane/metadata/android/pt-BR/changelogs/1004.txt new file mode 100644 index 000000000..59fc6a5cd --- /dev/null +++ b/fastlane/metadata/android/pt-BR/changelogs/1004.txt @@ -0,0 +1 @@ +Corrigido YouTube não reproduzir qualquer transmissão diff --git a/fastlane/metadata/android/pt-PT/changelogs/1004.txt b/fastlane/metadata/android/pt-PT/changelogs/1004.txt new file mode 100644 index 000000000..93519d64d --- /dev/null +++ b/fastlane/metadata/android/pt-PT/changelogs/1004.txt @@ -0,0 +1 @@ +Corrigido YouTube não reproduzir nenhuma transmissão diff --git a/fastlane/metadata/android/pt/changelogs/1004.txt b/fastlane/metadata/android/pt/changelogs/1004.txt new file mode 100644 index 000000000..93519d64d --- /dev/null +++ b/fastlane/metadata/android/pt/changelogs/1004.txt @@ -0,0 +1 @@ +Corrigido YouTube não reproduzir nenhuma transmissão diff --git a/fastlane/metadata/android/ru/changelogs/1004.txt b/fastlane/metadata/android/ru/changelogs/1004.txt new file mode 100644 index 000000000..d3978869d --- /dev/null +++ b/fastlane/metadata/android/ru/changelogs/1004.txt @@ -0,0 +1 @@ +Исправлено: YouTube не воспроизводил никакие потоки diff --git a/fastlane/metadata/android/sk/changelogs/1004.txt b/fastlane/metadata/android/sk/changelogs/1004.txt new file mode 100644 index 000000000..2f96b8dc5 --- /dev/null +++ b/fastlane/metadata/android/sk/changelogs/1004.txt @@ -0,0 +1 @@ +Fixed YouTube not playing any stream diff --git a/fastlane/metadata/android/sv/changelogs/1004.txt b/fastlane/metadata/android/sv/changelogs/1004.txt new file mode 100644 index 000000000..35f298dbf --- /dev/null +++ b/fastlane/metadata/android/sv/changelogs/1004.txt @@ -0,0 +1 @@ +Åtgärdat att YouTube inte spelar någon stream diff --git a/fastlane/metadata/android/ta/changelogs/1004.txt b/fastlane/metadata/android/ta/changelogs/1004.txt new file mode 100644 index 000000000..e3c06dc59 --- /dev/null +++ b/fastlane/metadata/android/ta/changelogs/1004.txt @@ -0,0 +1 @@ +நிலையான யூடியூப் எந்த ச்ட்ரீமையும் இயக்கவில்லை diff --git a/fastlane/metadata/android/tr/changelogs/1004.txt b/fastlane/metadata/android/tr/changelogs/1004.txt new file mode 100644 index 000000000..e5979c68d --- /dev/null +++ b/fastlane/metadata/android/tr/changelogs/1004.txt @@ -0,0 +1 @@ +YouTube'un herhangi bir akışı oynatmaması düzeltildi diff --git a/fastlane/metadata/android/uk/changelogs/1004.txt b/fastlane/metadata/android/uk/changelogs/1004.txt new file mode 100644 index 000000000..a90cfff6b --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/1004.txt @@ -0,0 +1 @@ +Виправлено проблему невідтворюваності трансляцій YouTube diff --git a/fastlane/metadata/android/vi/changelogs/1004.txt b/fastlane/metadata/android/vi/changelogs/1004.txt new file mode 100644 index 000000000..d2086b62c --- /dev/null +++ b/fastlane/metadata/android/vi/changelogs/1004.txt @@ -0,0 +1 @@ +Đã sửa lỗi YouTube không phát bất kỳ luồng nào diff --git a/fastlane/metadata/android/zh-Hans/changelogs/1004.txt b/fastlane/metadata/android/zh-Hans/changelogs/1004.txt new file mode 100644 index 000000000..8a5424c9e --- /dev/null +++ b/fastlane/metadata/android/zh-Hans/changelogs/1004.txt @@ -0,0 +1 @@ +修复YouTube无法播放任何视频 diff --git a/fastlane/metadata/android/zh-Hant/changelogs/1004.txt b/fastlane/metadata/android/zh-Hant/changelogs/1004.txt new file mode 100644 index 000000000..4e8bf6537 --- /dev/null +++ b/fastlane/metadata/android/zh-Hant/changelogs/1004.txt @@ -0,0 +1 @@ +修正 YouTube 無法播放任何串流 diff --git a/fastlane/metadata/android/zh_Hant_HK/changelogs/1004.txt b/fastlane/metadata/android/zh_Hant_HK/changelogs/1004.txt new file mode 100644 index 000000000..9a4721551 --- /dev/null +++ b/fastlane/metadata/android/zh_Hant_HK/changelogs/1004.txt @@ -0,0 +1 @@ +修正咗 YouTube 乜嘢實況串流都播唔到嘅問題 From ef068e1eca9ae08e3152977e2f014128c6dd6d42 Mon Sep 17 00:00:00 2001 From: TobiGr Date: Sun, 16 Mar 2025 21:39:55 +0100 Subject: [PATCH 26/30] Update NewPipe Extractor and add new proguard rules New rules are required since Rhino and Rhino Engine 1.8.0 --- app/proguard-rules.pro | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 215df0da5..0cdffbe2e 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -5,10 +5,17 @@ ## Rules for NewPipeExtractor -keep class org.schabi.newpipe.extractor.timeago.patterns.** { *; } +## Rules for Rhino and Rhino Engine +-keep class org.mozilla.javascript.* { *; } -keep class org.mozilla.javascript.** { *; } +-keep class org.mozilla.javascript.engine.** { *; } -keep class org.mozilla.classfile.ClassFileWriter -dontwarn org.mozilla.javascript.JavaToJSONConverters -dontwarn org.mozilla.javascript.tools.** +-keep class javax.script.** { *; } +-dontwarn javax.script.** +-keep class jdk.dynalink.** { *; } +-dontwarn jdk.dynalink.** ## Rules for ExoPlayer -keep class com.google.android.exoplayer2.** { *; } From 81b4e3f970a7a7f6f626ea3dc500d316d985a7e9 Mon Sep 17 00:00:00 2001 From: Stypox Date: Wed, 7 May 2025 12:52:43 +0200 Subject: [PATCH 27/30] Hotfix release v0.27.7 (1004) --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 886db7b28..bd5e1c3e8 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -23,9 +23,9 @@ android { if (System.properties.containsKey('versionCodeOverride')) { versionCode System.getProperty('versionCodeOverride') as Integer } else { - versionCode 1003 + versionCode 1004 } - versionName "0.27.6" + versionName "0.27.7" if (System.properties.containsKey('versionNameSuffix')) { versionNameSuffix System.getProperty('versionNameSuffix') } From eccedc0ab0fc155c16bc767fdd9e31451db6e841 Mon Sep 17 00:00:00 2001 From: Profpatsch Date: Thu, 26 Dec 2024 11:58:27 +0100 Subject: [PATCH 28/30] PlayerUIList: transform to kotlin And simplify the code a little --- .../newpipe/player/ui/PlayerUiList.java | 90 ------------------ .../schabi/newpipe/player/ui/PlayerUiList.kt | 92 +++++++++++++++++++ 2 files changed, 92 insertions(+), 90 deletions(-) delete mode 100644 app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.kt diff --git a/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.java b/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.java deleted file mode 100644 index 24fec3b8a..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.java +++ /dev/null @@ -1,90 +0,0 @@ -package org.schabi.newpipe.player.ui; - -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; -import java.util.function.Consumer; - -public final class PlayerUiList { - final List playerUis = new ArrayList<>(); - - /** - * Creates a {@link PlayerUiList} starting with the provided player uis. The provided player uis - * will not be prepared like those passed to {@link #addAndPrepare(PlayerUi)}, because when - * the {@link PlayerUiList} constructor is called, the player is still not running and it - * wouldn't make sense to initialize uis then. Instead the player will initialize them by doing - * proper calls to {@link #call(Consumer)}. - * - * @param initialPlayerUis the player uis this list should start with; the order will be kept - */ - public PlayerUiList(final PlayerUi... initialPlayerUis) { - playerUis.addAll(List.of(initialPlayerUis)); - } - - /** - * Adds the provided player ui to the list and calls on it the initialization functions that - * apply based on the current player state. The preparation step needs to be done since when UIs - * are removed and re-added, the player will not call e.g. initPlayer again since the exoplayer - * is already initialized, but we need to notify the newly built UI that the player is ready - * nonetheless. - * @param playerUi the player ui to prepare and add to the list; its {@link - * PlayerUi#getPlayer()} will be used to query information about the player - * state - */ - public void addAndPrepare(final PlayerUi playerUi) { - if (playerUi.getPlayer().getFragmentListener().isPresent()) { - // make sure UIs know whether a service is connected or not - playerUi.onFragmentListenerSet(); - } - - if (!playerUi.getPlayer().exoPlayerIsNull()) { - playerUi.initPlayer(); - if (playerUi.getPlayer().getPlayQueue() != null) { - playerUi.initPlayback(); - } - } - - playerUis.add(playerUi); - } - - /** - * Destroys all matching player UIs and removes them from the list. - * @param playerUiType the class of the player UI to destroy; the {@link - * Class#isInstance(Object)} method will be used, so even subclasses will be - * destroyed and removed - * @param the class type parameter - */ - public void destroyAll(final Class playerUiType) { - playerUis.stream() - .filter(playerUiType::isInstance) - .forEach(playerUi -> { - playerUi.destroyPlayer(); - playerUi.destroy(); - }); - playerUis.removeIf(playerUiType::isInstance); - } - - /** - * @param playerUiType the class of the player UI to return; the {@link - * Class#isInstance(Object)} method will be used, so even subclasses could - * be returned - * @param the class type parameter - * @return the first player UI of the required type found in the list, or an empty {@link - * Optional} otherwise - */ - public Optional get(final Class playerUiType) { - return playerUis.stream() - .filter(playerUiType::isInstance) - .map(playerUiType::cast) - .findFirst(); - } - - /** - * Calls the provided consumer on all player UIs in the list, in order of addition. - * @param consumer the consumer to call with player UIs - */ - public void call(final Consumer consumer) { - //noinspection SimplifyStreamApiCallChains - playerUis.stream().forEachOrdered(consumer); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.kt b/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.kt new file mode 100644 index 000000000..46090285f --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.kt @@ -0,0 +1,92 @@ +package org.schabi.newpipe.player.ui + +import androidx.core.util.Consumer +import java.util.Optional + +class PlayerUiList(vararg initialPlayerUis: PlayerUi) { + val playerUis = mutableListOf() + + /** + * Creates a [PlayerUiList] starting with the provided player uis. The provided player uis + * will not be prepared like those passed to [.addAndPrepare], because when + * the [PlayerUiList] constructor is called, the player is still not running and it + * wouldn't make sense to initialize uis then. Instead the player will initialize them by doing + * proper calls to [.call]. + * + * @param initialPlayerUis the player uis this list should start with; the order will be kept + */ + init { + playerUis.addAll(listOf(*initialPlayerUis)) + } + + /** + * Adds the provided player ui to the list and calls on it the initialization functions that + * apply based on the current player state. The preparation step needs to be done since when UIs + * are removed and re-added, the player will not call e.g. initPlayer again since the exoplayer + * is already initialized, but we need to notify the newly built UI that the player is ready + * nonetheless. + * @param playerUi the player ui to prepare and add to the list; its [PlayerUi.getPlayer] + * will be used to query information about the player state + */ + fun addAndPrepare(playerUi: PlayerUi) { + if (playerUi.getPlayer().fragmentListener.isPresent) { + // make sure UIs know whether a service is connected or not + playerUi.onFragmentListenerSet() + } + + if (!playerUi.getPlayer().exoPlayerIsNull()) { + playerUi.initPlayer() + if (playerUi.getPlayer().playQueue != null) { + playerUi.initPlayback() + } + } + + playerUis.add(playerUi) + } + + /** + * Destroys all matching player UIs and removes them from the list. + * @param playerUiType the class of the player UI to destroy; + * the [Class.isInstance] method will be used, so even subclasses will be + * destroyed and removed + * @param T the class type parameter + * */ + fun destroyAll(playerUiType: Class) { + for (ui in playerUis) { + if (playerUiType.isInstance(ui)) { + ui.destroyPlayer() + ui.destroy() + playerUis.remove(ui) + } + } + } + + /** + * @param playerUiType the class of the player UI to return; + * the [Class.isInstance] method will be used, so even subclasses could be returned + * @param T the class type parameter + * @return the first player UI of the required type found in the list, or an empty + * [ ] otherwise + */ + fun get(playerUiType: Class): Optional { + for (ui in playerUis) { + if (playerUiType.isInstance(ui)) { + when (val r = playerUiType.cast(ui)) { + null -> continue + else -> return Optional.of(r) + } + } + } + return Optional.empty() + } + + /** + * Calls the provided consumer on all player UIs in the list, in order of addition. + * @param consumer the consumer to call with player UIs + */ + fun call(consumer: java.util.function.Consumer) { + for (ui in playerUis) { + consumer.accept(ui) + } + } +} From 3d069cdf5b1a4be5991926d1a95db7edd3aa8abc Mon Sep 17 00:00:00 2001 From: Profpatsch Date: Thu, 26 Dec 2024 12:05:10 +0100 Subject: [PATCH 29/30] PlayerUIList: rename get to getOpt and make get nullable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In Kotlin, dealing with nulls works better so we don’t need optional. --- .../fragments/detail/VideoDetailFragment.java | 30 +++++++++---------- .../org/schabi/newpipe/player/Player.java | 7 +++-- .../schabi/newpipe/player/PlayerService.java | 4 +-- .../mediasession/MediaSessionPlayerUi.java | 2 +- .../player/notification/NotificationUtil.java | 2 +- .../schabi/newpipe/player/ui/PlayerUiList.kt | 21 +++++++++---- 6 files changed, 39 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java index defa533ae..c516b6b4d 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java @@ -246,7 +246,7 @@ public final class VideoDetailFragment // It will do nothing if the player is not in fullscreen mode hideSystemUiIfNeeded(); - final Optional playerUi = player.UIs().get(MainPlayerUi.class); + final Optional playerUi = player.UIs().getOpt(MainPlayerUi.class); if (!player.videoPlayerSelected() && !playAfterConnect) { return; } @@ -529,7 +529,7 @@ public final class VideoDetailFragment binding.overlayPlayPauseButton.setOnClickListener(v -> { if (playerIsNotStopped()) { player.playPause(); - player.UIs().get(VideoPlayerUi.class).ifPresent(ui -> ui.hideControls(0, 0)); + player.UIs().getOpt(VideoPlayerUi.class).ifPresent(ui -> ui.hideControls(0, 0)); showSystemUi(); } else { autoPlayEnabled = true; // forcefully start playing @@ -688,7 +688,7 @@ public final class VideoDetailFragment @Override public boolean onKeyDown(final int keyCode) { return isPlayerAvailable() - && player.UIs().get(VideoPlayerUi.class) + && player.UIs().getOpt(VideoPlayerUi.class) .map(playerUi -> playerUi.onKeyDown(keyCode)).orElse(false); } @@ -1028,7 +1028,7 @@ public final class VideoDetailFragment // If a user watched video inside fullscreen mode and than chose another player // return to non-fullscreen mode if (isPlayerAvailable()) { - player.UIs().get(MainPlayerUi.class).ifPresent(playerUi -> { + player.UIs().getOpt(MainPlayerUi.class).ifPresent(playerUi -> { if (playerUi.isFullscreen()) { playerUi.toggleFullscreen(); } @@ -1244,7 +1244,7 @@ public final class VideoDetailFragment // setup the surface view height, so that it fits the video correctly setHeightThumbnail(); - player.UIs().get(MainPlayerUi.class).ifPresent(playerUi -> { + player.UIs().getOpt(MainPlayerUi.class).ifPresent(playerUi -> { // sometimes binding would be null here, even though getView() != null above u.u if (binding != null) { // prevent from re-adding a view multiple times @@ -1260,7 +1260,7 @@ public final class VideoDetailFragment makeDefaultHeightForVideoPlaceholder(); if (player != null) { - player.UIs().get(VideoPlayerUi.class).ifPresent(VideoPlayerUi::removeViewFromParent); + player.UIs().getOpt(VideoPlayerUi.class).ifPresent(VideoPlayerUi::removeViewFromParent); } } @@ -1327,7 +1327,7 @@ public final class VideoDetailFragment binding.detailThumbnailImageView.setMinimumHeight(newHeight); if (isPlayerAvailable()) { final int maxHeight = (int) (metrics.heightPixels * MAX_PLAYER_HEIGHT); - player.UIs().get(VideoPlayerUi.class).ifPresent(ui -> + player.UIs().getOpt(VideoPlayerUi.class).ifPresent(ui -> ui.getBinding().surfaceView.setHeights(newHeight, ui.isFullscreen() ? newHeight : maxHeight)); } @@ -1861,7 +1861,7 @@ public final class VideoDetailFragment public void onFullscreenStateChanged(final boolean fullscreen) { setupBrightness(); if (!isPlayerAndPlayerServiceAvailable() - || player.UIs().get(MainPlayerUi.class).isEmpty() + || player.UIs().getOpt(MainPlayerUi.class).isEmpty() || getRoot().map(View::getParent).isEmpty()) { return; } @@ -1890,7 +1890,7 @@ public final class VideoDetailFragment final boolean isLandscape = DeviceUtils.isLandscape(requireContext()); if (DeviceUtils.isTablet(activity) && (!globalScreenOrientationLocked(activity) || isLandscape)) { - player.UIs().get(MainPlayerUi.class).ifPresent(MainPlayerUi::toggleFullscreen); + player.UIs().getOpt(MainPlayerUi.class).ifPresent(MainPlayerUi::toggleFullscreen); return; } @@ -1990,7 +1990,7 @@ public final class VideoDetailFragment } private boolean isFullscreen() { - return isPlayerAvailable() && player.UIs().get(VideoPlayerUi.class) + return isPlayerAvailable() && player.UIs().getOpt(VideoPlayerUi.class) .map(VideoPlayerUi::isFullscreen).orElse(false); } @@ -2067,7 +2067,7 @@ public final class VideoDetailFragment setAutoPlay(true); } - player.UIs().get(MainPlayerUi.class).ifPresent(MainPlayerUi::checkLandscape); + player.UIs().getOpt(MainPlayerUi.class).ifPresent(MainPlayerUi::checkLandscape); // Let's give a user time to look at video information page if video is not playing if (globalScreenOrientationLocked(activity) && !player.isPlaying()) { player.play(); @@ -2332,7 +2332,7 @@ public final class VideoDetailFragment && player.isPlaying() && !isFullscreen() && !DeviceUtils.isTablet(activity)) { - player.UIs().get(MainPlayerUi.class) + player.UIs().getOpt(MainPlayerUi.class) .ifPresent(MainPlayerUi::toggleFullscreen); } setOverlayLook(binding.appBarLayout, behavior, 1); @@ -2346,7 +2346,7 @@ public final class VideoDetailFragment // Re-enable clicks setOverlayElementsClickable(true); if (isPlayerAvailable()) { - player.UIs().get(MainPlayerUi.class) + player.UIs().getOpt(MainPlayerUi.class) .ifPresent(MainPlayerUi::closeItemsList); } setOverlayLook(binding.appBarLayout, behavior, 0); @@ -2357,7 +2357,7 @@ public final class VideoDetailFragment showSystemUi(); } if (isPlayerAvailable()) { - player.UIs().get(MainPlayerUi.class).ifPresent(ui -> { + player.UIs().getOpt(MainPlayerUi.class).ifPresent(ui -> { if (ui.isControlsVisible()) { ui.hideControls(0, 0); } @@ -2454,7 +2454,7 @@ public final class VideoDetailFragment public Optional getRoot() { return Optional.ofNullable(player) - .flatMap(player1 -> player1.UIs().get(VideoPlayerUi.class)) + .flatMap(player1 -> player1.UIs().getOpt(VideoPlayerUi.class)) .map(playerUi -> playerUi.getBinding().getRoot()); } diff --git a/app/src/main/java/org/schabi/newpipe/player/Player.java b/app/src/main/java/org/schabi/newpipe/player/Player.java index f32606e0b..4d1accf26 100644 --- a/app/src/main/java/org/schabi/newpipe/player/Player.java +++ b/app/src/main/java/org/schabi/newpipe/player/Player.java @@ -473,14 +473,15 @@ public final class Player implements PlaybackListener, Listener { } private void initUIsForCurrentPlayerType() { - if ((UIs.get(MainPlayerUi.class).isPresent() && playerType == PlayerType.MAIN) - || (UIs.get(PopupPlayerUi.class).isPresent() && playerType == PlayerType.POPUP)) { + if ((UIs.getOpt(MainPlayerUi.class).isPresent() && playerType == PlayerType.MAIN) + || (UIs.getOpt(PopupPlayerUi.class).isPresent() + && playerType == PlayerType.POPUP)) { // correct UI already in place return; } // try to reuse binding if possible - final PlayerBinding binding = UIs.get(VideoPlayerUi.class).map(VideoPlayerUi::getBinding) + final PlayerBinding binding = UIs.getOpt(VideoPlayerUi.class).map(VideoPlayerUi::getBinding) .orElseGet(() -> { if (playerType == PlayerType.AUDIO) { return null; diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayerService.java b/app/src/main/java/org/schabi/newpipe/player/PlayerService.java index 1888bce01..f465bbe79 100644 --- a/app/src/main/java/org/schabi/newpipe/player/PlayerService.java +++ b/app/src/main/java/org/schabi/newpipe/player/PlayerService.java @@ -148,7 +148,7 @@ public final class PlayerService extends MediaBrowserServiceCompat { // no one already and starting the service in foreground should not create any issues. // If the service is already started in foreground, requesting it to be started // shouldn't do anything. - player.UIs().get(NotificationPlayerUi.class) + player.UIs().getOpt(NotificationPlayerUi.class) .ifPresent(NotificationPlayerUi::createNotificationAndStartForeground); if (playerWasNull && onPlayerStartedOrStopped != null) { @@ -173,7 +173,7 @@ public final class PlayerService extends MediaBrowserServiceCompat { if (player != null) { player.handleIntent(intent); - player.UIs().get(MediaSessionPlayerUi.class) + player.UIs().getOpt(MediaSessionPlayerUi.class) .ifPresent(ui -> ui.handleMediaButtonIntent(intent)); } diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java b/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java index fe884834b..085da5eb7 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java +++ b/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java @@ -138,7 +138,7 @@ public class MediaSessionPlayerUi extends PlayerUi public void play() { player.play(); // hide the player controls even if the play command came from the media session - player.UIs().get(VideoPlayerUi.class).ifPresent(ui -> ui.hideControls(0, 0)); + player.UIs().getOpt(VideoPlayerUi.class).ifPresent(ui -> ui.hideControls(0, 0)); } @Override diff --git a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java index 30420b0c7..5658693f2 100644 --- a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java +++ b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java @@ -102,7 +102,7 @@ public final class NotificationUtil { mediaStyle.setShowActionsInCompactView(compactSlots); } player.UIs() - .get(MediaSessionPlayerUi.class) + .getOpt(MediaSessionPlayerUi.class) .flatMap(MediaSessionPlayerUi::getSessionToken) .ifPresent(mediaStyle::setMediaSession); diff --git a/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.kt b/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.kt index 46090285f..43a4fceba 100644 --- a/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.kt +++ b/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.kt @@ -65,21 +65,32 @@ class PlayerUiList(vararg initialPlayerUis: PlayerUi) { * @param playerUiType the class of the player UI to return; * the [Class.isInstance] method will be used, so even subclasses could be returned * @param T the class type parameter - * @return the first player UI of the required type found in the list, or an empty - * [ ] otherwise + * @return the first player UI of the required type found in the list, or null */ - fun get(playerUiType: Class): Optional { + fun get(playerUiType: Class): T? { for (ui in playerUis) { if (playerUiType.isInstance(ui)) { when (val r = playerUiType.cast(ui)) { + // try all UIs before returning null null -> continue - else -> return Optional.of(r) + else -> return r } } } - return Optional.empty() + return null } + /** + * @param playerUiType the class of the player UI to return; + * the [Class.isInstance] method will be used, so even subclasses could be returned + * @param T the class type parameter + * @return the first player UI of the required type found in the list, or an empty + * [Optional] otherwise + */ + @Deprecated("use get", ReplaceWith("get(playerUiType)")) + fun getOpt(playerUiType: Class): Optional = + Optional.ofNullable(get(playerUiType)) + /** * Calls the provided consumer on all player UIs in the list, in order of addition. * @param consumer the consumer to call with player UIs From a8da9946d1c9e531328d6a2a05bedc13b2e6df5e Mon Sep 17 00:00:00 2001 From: Profpatsch Date: Thu, 26 Dec 2024 15:04:45 +0100 Subject: [PATCH 30/30] PlayerUiList: guard list actions with mutex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new implementation would throw `ConcurrentModificationExceptions` when destroying the UIs. So let’s play it safe and put the list behind a mutex. Adds a helper class `GuardedByMutex` that can be wrapped around a property to force all use-sites to acquire the lock before doing anything with the data. --- .../schabi/newpipe/player/ui/PlayerUiList.kt | 59 +++++++++++++------ .../org/schabi/newpipe/util/GuardedByMutex.kt | 47 +++++++++++++++ 2 files changed, 87 insertions(+), 19 deletions(-) create mode 100644 app/src/main/java/org/schabi/newpipe/util/GuardedByMutex.kt diff --git a/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.kt b/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.kt index 43a4fceba..e258d5ac1 100644 --- a/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.kt +++ b/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.kt @@ -1,10 +1,10 @@ package org.schabi.newpipe.player.ui -import androidx.core.util.Consumer +import org.schabi.newpipe.util.GuardedByMutex import java.util.Optional class PlayerUiList(vararg initialPlayerUis: PlayerUi) { - val playerUis = mutableListOf() + var playerUis = GuardedByMutex(mutableListOf()) /** * Creates a [PlayerUiList] starting with the provided player uis. The provided player uis @@ -16,7 +16,9 @@ class PlayerUiList(vararg initialPlayerUis: PlayerUi) { * @param initialPlayerUis the player uis this list should start with; the order will be kept */ init { - playerUis.addAll(listOf(*initialPlayerUis)) + playerUis.runWithLockSync { + lockData.addAll(listOf(*initialPlayerUis)) + } } /** @@ -41,7 +43,9 @@ class PlayerUiList(vararg initialPlayerUis: PlayerUi) { } } - playerUis.add(playerUi) + playerUis.runWithLockSync { + lockData.add(playerUi) + } } /** @@ -52,12 +56,24 @@ class PlayerUiList(vararg initialPlayerUis: PlayerUi) { * @param T the class type parameter * */ fun destroyAll(playerUiType: Class) { - for (ui in playerUis) { - if (playerUiType.isInstance(ui)) { - ui.destroyPlayer() - ui.destroy() - playerUis.remove(ui) + val toDestroy = mutableListOf() + + // short blocking removal from class to prevent interfering from other threads + playerUis.runWithLockSync { + val new = mutableListOf() + for (ui in lockData) { + if (playerUiType.isInstance(ui)) { + toDestroy.add(ui) + } else { + new.add(ui) + } } + lockData = new + } + // then actually destroy the UIs + for (ui in toDestroy) { + ui.destroyPlayer() + ui.destroy() } } @@ -67,18 +83,19 @@ class PlayerUiList(vararg initialPlayerUis: PlayerUi) { * @param T the class type parameter * @return the first player UI of the required type found in the list, or null */ - fun get(playerUiType: Class): T? { - for (ui in playerUis) { - if (playerUiType.isInstance(ui)) { - when (val r = playerUiType.cast(ui)) { - // try all UIs before returning null - null -> continue - else -> return r + fun get(playerUiType: Class): T? = + playerUis.runWithLockSync { + for (ui in lockData) { + if (playerUiType.isInstance(ui)) { + when (val r = playerUiType.cast(ui)) { + // try all UIs before returning null + null -> continue + else -> return@runWithLockSync r + } } } + return@runWithLockSync null } - return null - } /** * @param playerUiType the class of the player UI to return; @@ -96,7 +113,11 @@ class PlayerUiList(vararg initialPlayerUis: PlayerUi) { * @param consumer the consumer to call with player UIs */ fun call(consumer: java.util.function.Consumer) { - for (ui in playerUis) { + // copy the list out of the mutex before calling the consumer which might block + val new = playerUis.runWithLockSync { + lockData.toMutableList() + } + for (ui in new) { consumer.accept(ui) } } diff --git a/app/src/main/java/org/schabi/newpipe/util/GuardedByMutex.kt b/app/src/main/java/org/schabi/newpipe/util/GuardedByMutex.kt new file mode 100644 index 000000000..b3bd077f8 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/GuardedByMutex.kt @@ -0,0 +1,47 @@ +package org.schabi.newpipe.util + +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** Guard the given data so that it can only be accessed by locking the mutex first. + * + * Inspired by [this blog post](https://jonnyzzz.com/blog/2017/03/01/guarded-by-lock/) + * */ +class GuardedByMutex( + private var data: T, + private val lock: Mutex = Mutex(locked = false), +) { + + /** Lock the mutex and access the data, blocking the current thread. + * @param action to run with locked mutex + * */ + fun runWithLockSync( + action: MutexData.() -> Y + ) = + runBlocking { + lock.withLock { + MutexData(data, { d -> data = d }).action() + } + } + + /** Lock the mutex and access the data, suspending the coroutine. + * @param action to run with locked mutex + * */ + suspend fun runWithLock(action: MutexData.() -> Y) = + lock.withLock { + MutexData(data, { d -> data = d }).action() + } +} + +/** The data inside a [GuardedByMutex], which can be accessed via [lockData]. + * [lockData] is a `var`, so you can `set` it as well. + * */ +class MutexData(data: T, val setFun: (T) -> Unit) { + /** The data inside this [GuardedByMutex] */ + var lockData: T = data + set(t) { + setFun(t) + field = t + } +}