1
0
mirror of https://github.com/TeamNewPipe/NewPipe synced 2025-01-08 16:30:34 +00:00

Convert subscription export service to a worker

This commit is contained in:
Isira Seneviratne 2024-11-26 07:55:37 +05:30
parent 00e2d66dd5
commit 173ad83e0e
13 changed files with 238 additions and 322 deletions

View File

@ -9,6 +9,7 @@ plugins {
alias libs.plugins.kotlin.compose alias libs.plugins.kotlin.compose
alias libs.plugins.kotlin.kapt alias libs.plugins.kotlin.kapt
alias libs.plugins.kotlin.parcelize alias libs.plugins.kotlin.parcelize
alias libs.plugins.kotlinx.serialization
alias libs.plugins.checkstyle alias libs.plugins.checkstyle
alias libs.plugins.sonarqube alias libs.plugins.sonarqube
alias libs.plugins.hilt alias libs.plugins.hilt
@ -16,7 +17,7 @@ plugins {
} }
android { android {
compileSdk 34 compileSdk 35
namespace 'org.schabi.newpipe' namespace 'org.schabi.newpipe'
defaultConfig { defaultConfig {
@ -310,6 +311,9 @@ dependencies {
// Scroll // Scroll
implementation libs.lazycolumnscrollbar implementation libs.lazycolumnscrollbar
// Kotlinx Serialization
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3'
/** Debugging **/ /** Debugging **/
// Memory leak detection // Memory leak detection
debugImplementation libs.leakcanary.object.watcher debugImplementation libs.leakcanary.object.watcher

View File

@ -27,3 +27,18 @@
## For some reason NotificationModeConfigFragment wasn't kept (only referenced in a preference xml) ## For some reason NotificationModeConfigFragment wasn't kept (only referenced in a preference xml)
-keep class org.schabi.newpipe.settings.notifications.** { *; } -keep class org.schabi.newpipe.settings.notifications.** { *; }
## Keep Kotlinx Serialization classes
-keepclassmembers class kotlinx.serialization.json.** {
*** Companion;
}
-keepclasseswithmembers class kotlinx.serialization.json.** {
kotlinx.serialization.KSerializer serializer(...);
}
-keep,includedescriptorclasses class org.schabi.newpipe.**$$serializer { *; }
-keepclassmembers class org.schabi.newpipe.** {
*** Companion;
}
-keepclasseswithmembers class org.schabi.newpipe.** {
kotlinx.serialization.KSerializer serializer(...);
}

View File

@ -9,6 +9,7 @@
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" /> <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<!-- We need to be able to open links in the browser on API 30+ --> <!-- We need to be able to open links in the browser on API 30+ -->
@ -87,8 +88,11 @@
android:exported="false" android:exported="false"
android:label="@string/title_activity_about" /> android:label="@string/title_activity_about" />
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
tools:node="merge" />
<service android:name=".local.subscription.services.SubscriptionsImportService" /> <service android:name=".local.subscription.services.SubscriptionsImportService" />
<service android:name=".local.subscription.services.SubscriptionsExportService" />
<service android:name=".local.feed.service.FeedLoadService" /> <service android:name=".local.feed.service.FeedLoadService" />
<activity <activity

View File

@ -49,11 +49,11 @@ import org.schabi.newpipe.local.subscription.item.FeedGroupCarouselItem
import org.schabi.newpipe.local.subscription.item.GroupsHeader import org.schabi.newpipe.local.subscription.item.GroupsHeader
import org.schabi.newpipe.local.subscription.item.Header import org.schabi.newpipe.local.subscription.item.Header
import org.schabi.newpipe.local.subscription.item.ImportSubscriptionsHintPlaceholderItem import org.schabi.newpipe.local.subscription.item.ImportSubscriptionsHintPlaceholderItem
import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService 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_MODE
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_VALUE 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.services.SubscriptionsImportService.PREVIOUS_EXPORT_MODE
import org.schabi.newpipe.local.subscription.workers.SubscriptionExportWorker
import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard
import org.schabi.newpipe.streams.io.StoredFileHelper import org.schabi.newpipe.streams.io.StoredFileHelper
import org.schabi.newpipe.ui.emptystate.setEmptyStateComposable import org.schabi.newpipe.ui.emptystate.setEmptyStateComposable
@ -224,11 +224,9 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
} }
private fun requestExportResult(result: ActivityResult) { private fun requestExportResult(result: ActivityResult) {
if (result.data != null && result.resultCode == Activity.RESULT_OK) { val data = result.data?.data
activity.startService( if (data != null && result.resultCode == Activity.RESULT_OK) {
Intent(activity, SubscriptionsExportService::class.java) SubscriptionExportWorker.schedule(activity, data)
.putExtra(SubscriptionsExportService.KEY_FILE_PATH, result.data?.data)
)
} }
} }

View File

@ -17,94 +17,44 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
package org.schabi.newpipe.local.subscription.services; package org.schabi.newpipe.local.subscription.services
import androidx.annotation.Nullable; import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import com.grack.nanojson.JsonAppendableWriter; import kotlinx.serialization.json.decodeFromStream
import com.grack.nanojson.JsonArray; import kotlinx.serialization.json.encodeToStream
import com.grack.nanojson.JsonObject; import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor.InvalidSourceException
import com.grack.nanojson.JsonParser; import org.schabi.newpipe.local.subscription.workers.SubscriptionData
import com.grack.nanojson.JsonWriter; import org.schabi.newpipe.local.subscription.workers.SubscriptionItem
import java.io.InputStream
import org.schabi.newpipe.BuildConfig; import java.io.OutputStream
import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor.InvalidSourceException;
import org.schabi.newpipe.extractor.subscription.SubscriptionItem;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.List;
/** /**
* A JSON implementation capable of importing and exporting subscriptions, it has the advantage * A JSON implementation capable of importing and exporting subscriptions, it has the advantage
* of being able to transfer subscriptions to any device. * of being able to transfer subscriptions to any device.
*/ */
public final class ImportExportJsonHelper { object ImportExportJsonHelper {
/*////////////////////////////////////////////////////////////////////////// private val json = Json { encodeDefaults = true }
// Json implementation
//////////////////////////////////////////////////////////////////////////*/
private static final String JSON_APP_VERSION_KEY = "app_version";
private static final String JSON_APP_VERSION_INT_KEY = "app_version_int";
private static final String JSON_SUBSCRIPTIONS_ARRAY_KEY = "subscriptions";
private static final String JSON_SERVICE_ID_KEY = "service_id";
private static final String JSON_URL_KEY = "url";
private static final String JSON_NAME_KEY = "name";
private ImportExportJsonHelper() { }
/** /**
* Read a JSON source through the input stream. * Read a JSON source through the input stream.
* *
* @param in the input stream (e.g. a file) * @param in the input stream (e.g. a file)
* @param eventListener listener for the events generated
* @return the parsed subscription items * @return the parsed subscription items
*/ */
public static List<SubscriptionItem> readFrom( @JvmStatic
final InputStream in, @Nullable final ImportExportEventListener eventListener) @Throws(InvalidSourceException::class)
throws InvalidSourceException { fun readFrom(`in`: InputStream?): List<SubscriptionItem> {
if (in == null) { if (`in` == null) {
throw new InvalidSourceException("input is null"); throw InvalidSourceException("input is null")
} }
final List<SubscriptionItem> channels = new ArrayList<>();
try { try {
final JsonObject parentObject = JsonParser.object().from(in); @OptIn(ExperimentalSerializationApi::class)
return json.decodeFromStream<SubscriptionData>(`in`).subscriptions
if (!parentObject.has(JSON_SUBSCRIPTIONS_ARRAY_KEY)) { } catch (e: Throwable) {
throw new InvalidSourceException("Channels array is null"); throw InvalidSourceException("Couldn't parse json", e)
}
final JsonArray channelsArray = parentObject.getArray(JSON_SUBSCRIPTIONS_ARRAY_KEY);
if (eventListener != null) {
eventListener.onSizeReceived(channelsArray.size());
}
for (final Object o : channelsArray) {
if (o instanceof JsonObject) {
final JsonObject itemObject = (JsonObject) o;
final int serviceId = itemObject.getInt(JSON_SERVICE_ID_KEY, 0);
final String url = itemObject.getString(JSON_URL_KEY);
final String name = itemObject.getString(JSON_NAME_KEY);
if (url != null && name != null && !url.isEmpty() && !name.isEmpty()) {
channels.add(new SubscriptionItem(serviceId, url, name));
if (eventListener != null) {
eventListener.onItemCompleted(name);
}
}
}
}
} catch (final Throwable e) {
throw new InvalidSourceException("Couldn't parse json", e);
} }
return channels;
} }
/** /**
@ -112,47 +62,13 @@ public final class ImportExportJsonHelper {
* *
* @param items the list of subscriptions items * @param items the list of subscriptions items
* @param out the output stream (e.g. a file) * @param out the output stream (e.g. a file)
* @param eventListener listener for the events generated
*/ */
public static void writeTo(final List<SubscriptionItem> items, final OutputStream out, @OptIn(ExperimentalSerializationApi::class)
@Nullable final ImportExportEventListener eventListener) { @JvmStatic
final JsonAppendableWriter writer = JsonWriter.on(out); fun writeTo(
writeTo(items, writer, eventListener); items: List<SubscriptionItem>,
writer.done(); out: OutputStream,
} ) {
json.encodeToStream(SubscriptionData(items), out)
/**
* @see #writeTo(List, OutputStream, ImportExportEventListener)
* @param items the list of subscriptions items
* @param writer the output {@link JsonAppendableWriter}
* @param eventListener listener for the events generated
*/
public static void writeTo(final List<SubscriptionItem> items,
final JsonAppendableWriter writer,
@Nullable final ImportExportEventListener eventListener) {
if (eventListener != null) {
eventListener.onSizeReceived(items.size());
}
writer.object();
writer.value(JSON_APP_VERSION_KEY, BuildConfig.VERSION_NAME);
writer.value(JSON_APP_VERSION_INT_KEY, BuildConfig.VERSION_CODE);
writer.array(JSON_SUBSCRIPTIONS_ARRAY_KEY);
for (final SubscriptionItem item : items) {
writer.object();
writer.value(JSON_SERVICE_ID_KEY, item.getServiceId());
writer.value(JSON_URL_KEY, item.getUrl());
writer.value(JSON_NAME_KEY, item.getName());
writer.end();
if (eventListener != null) {
eventListener.onItemCompleted(item.getName());
}
}
writer.end();
writer.end();
} }
} }

View File

@ -1,168 +0,0 @@
/*
* Copyright 2018 Mauricio Colli <mauriciocolli@outlook.com>
* SubscriptionsExportService.java is part of NewPipe
*
* License: GPL-3.0+
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.schabi.newpipe.local.subscription.services;
import static org.schabi.newpipe.MainActivity.DEBUG;
import android.content.Intent;
import android.net.Uri;
import android.util.Log;
import androidx.core.content.IntentCompat;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import org.reactivestreams.Subscriber;
import org.reactivestreams.Subscription;
import org.schabi.newpipe.App;
import org.schabi.newpipe.R;
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
import org.schabi.newpipe.extractor.subscription.SubscriptionItem;
import org.schabi.newpipe.streams.io.SharpOutputStream;
import org.schabi.newpipe.streams.io.StoredFileHelper;
import java.io.IOException;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.List;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.functions.Function;
import io.reactivex.rxjava3.schedulers.Schedulers;
public class SubscriptionsExportService extends BaseImportExportService {
public static final String KEY_FILE_PATH = "key_file_path";
/**
* A {@link LocalBroadcastManager local broadcast} will be made with this action
* when the export is successfully completed.
*/
public static final String EXPORT_COMPLETE_ACTION = App.PACKAGE_NAME + ".local.subscription"
+ ".services.SubscriptionsExportService.EXPORT_COMPLETE";
private Subscription subscription;
private StoredFileHelper outFile;
private OutputStream outputStream;
@Override
public int onStartCommand(final Intent intent, final int flags, final int startId) {
if (intent == null || subscription != null) {
return START_NOT_STICKY;
}
final Uri path = IntentCompat.getParcelableExtra(intent, KEY_FILE_PATH, Uri.class);
if (path == null) {
stopAndReportError(new IllegalStateException(
"Exporting to a file, but the path is null"),
"Exporting subscriptions");
return START_NOT_STICKY;
}
try {
outFile = new StoredFileHelper(this, path, "application/json");
outputStream = new SharpOutputStream(outFile.getStream());
} catch (final IOException e) {
handleError(e);
return START_NOT_STICKY;
}
startExport();
return START_NOT_STICKY;
}
@Override
protected int getNotificationId() {
return 4567;
}
@Override
public int getTitle() {
return R.string.export_ongoing;
}
@Override
protected void disposeAll() {
super.disposeAll();
if (subscription != null) {
subscription.cancel();
}
}
private void startExport() {
showToast(R.string.export_ongoing);
subscriptionManager.subscriptionTable().getAll().take(1)
.map(subscriptionEntities -> {
final List<SubscriptionItem> result =
new ArrayList<>(subscriptionEntities.size());
for (final SubscriptionEntity entity : subscriptionEntities) {
result.add(new SubscriptionItem(entity.getServiceId(), entity.getUrl(),
entity.getName()));
}
return result;
})
.map(exportToFile())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(getSubscriber());
}
private Subscriber<StoredFileHelper> getSubscriber() {
return new Subscriber<StoredFileHelper>() {
@Override
public void onSubscribe(final Subscription s) {
subscription = s;
s.request(1);
}
@Override
public void onNext(final StoredFileHelper file) {
if (DEBUG) {
Log.d(TAG, "startExport() success: file = " + file);
}
}
@Override
public void onError(final Throwable error) {
Log.e(TAG, "onError() called with: error = [" + error + "]", error);
handleError(error);
}
@Override
public void onComplete() {
LocalBroadcastManager.getInstance(SubscriptionsExportService.this)
.sendBroadcast(new Intent(EXPORT_COMPLETE_ACTION));
showToast(R.string.export_complete_toast);
stopService();
}
};
}
private Function<List<SubscriptionItem>, StoredFileHelper> exportToFile() {
return subscriptionItems -> {
ImportExportJsonHelper.writeTo(subscriptionItems, outputStream, eventListener);
return outFile;
};
}
protected void handleError(final Throwable error) {
super.handleError(R.string.subscriptions_export_unsuccessful, error);
}
}

View File

@ -41,8 +41,8 @@ import org.schabi.newpipe.database.subscription.SubscriptionEntity;
import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.channel.ChannelInfo; import org.schabi.newpipe.extractor.channel.ChannelInfo;
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo; 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.ktx.ExceptionUtils;
import org.schabi.newpipe.local.subscription.workers.SubscriptionItem;
import org.schabi.newpipe.streams.io.SharpInputStream; import org.schabi.newpipe.streams.io.SharpInputStream;
import org.schabi.newpipe.streams.io.StoredFileHelper; import org.schabi.newpipe.streams.io.StoredFileHelper;
import org.schabi.newpipe.util.Constants; import org.schabi.newpipe.util.Constants;
@ -50,10 +50,10 @@ import org.schabi.newpipe.util.ExtractorHelper;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.stream.Collectors;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Flowable; import io.reactivex.rxjava3.core.Flowable;
@ -177,18 +177,12 @@ public class SubscriptionsImportService extends BaseImportExportService {
private void startImport() { private void startImport() {
showToast(R.string.import_ongoing); showToast(R.string.import_ongoing);
Flowable<List<SubscriptionItem>> flowable = null; final var flowable = switch (currentMode) {
switch (currentMode) { case CHANNEL_URL_MODE -> importFromChannelUrl();
case CHANNEL_URL_MODE: case INPUT_STREAM_MODE -> importFromInputStream();
flowable = importFromChannelUrl(); case PREVIOUS_EXPORT_MODE -> importFromPreviousExport();
break; default -> null;
case INPUT_STREAM_MODE: };
flowable = importFromInputStream();
break;
case PREVIOUS_EXPORT_MODE:
flowable = importFromPreviousExport();
break;
}
if (flowable == null) { if (flowable == null) {
final String message = "Flowable given by \"importFrom\" is null " final String message = "Flowable given by \"importFrom\" is null "
@ -290,13 +284,10 @@ public class SubscriptionsImportService extends BaseImportExportService {
private Function<List<Notification<Pair<ChannelInfo, List<ChannelTabInfo>>>>, private Function<List<Notification<Pair<ChannelInfo, List<ChannelTabInfo>>>>,
List<SubscriptionEntity>> upsertBatch() { List<SubscriptionEntity>> upsertBatch() {
return notificationList -> { return notificationList -> {
final List<Pair<ChannelInfo, List<ChannelTabInfo>>> infoList = final var infoList = notificationList.stream()
new ArrayList<>(notificationList.size()); .filter(Notification::isOnNext)
for (final Notification<Pair<ChannelInfo, List<ChannelTabInfo>>> n : notificationList) { .map(Notification::getValue)
if (n.isOnNext()) { .collect(Collectors.toList());
infoList.add(n.getValue());
}
}
return subscriptionManager.upsertAll(infoList); return subscriptionManager.upsertAll(infoList);
}; };
@ -305,7 +296,11 @@ public class SubscriptionsImportService extends BaseImportExportService {
private Flowable<List<SubscriptionItem>> importFromChannelUrl() { private Flowable<List<SubscriptionItem>> importFromChannelUrl() {
return Flowable.fromCallable(() -> NewPipe.getService(currentServiceId) return Flowable.fromCallable(() -> NewPipe.getService(currentServiceId)
.getSubscriptionExtractor() .getSubscriptionExtractor()
.fromChannelUrl(channelUrl)); .fromChannelUrl(channelUrl))
.map(list -> list.stream()
.map(item -> new SubscriptionItem(item.getServiceId(), item.getUrl(),
item.getName()))
.collect(Collectors.toList()));
} }
private Flowable<List<SubscriptionItem>> importFromInputStream() { private Flowable<List<SubscriptionItem>> importFromInputStream() {
@ -314,11 +309,15 @@ public class SubscriptionsImportService extends BaseImportExportService {
return Flowable.fromCallable(() -> NewPipe.getService(currentServiceId) return Flowable.fromCallable(() -> NewPipe.getService(currentServiceId)
.getSubscriptionExtractor() .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<List<SubscriptionItem>> importFromPreviousExport() { private Flowable<List<SubscriptionItem>> importFromPreviousExport() {
return Flowable.fromCallable(() -> ImportExportJsonHelper.readFrom(inputStream, null)); return Flowable.fromCallable(() -> ImportExportJsonHelper.readFrom(inputStream));
} }
protected void handleError(@NonNull final Throwable error) { protected void handleError(@NonNull final Throwable error) {

View File

@ -0,0 +1,24 @@
package org.schabi.newpipe.local.subscription.workers
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import org.schabi.newpipe.BuildConfig
@Serializable
class SubscriptionData(
val subscriptions: List<SubscriptionItem>
) {
@SerialName("app_version")
private val appVersion = BuildConfig.VERSION_NAME
@SerialName("app_version_int")
private val appVersionInt = BuildConfig.VERSION_CODE
}
@Serializable
class SubscriptionItem(
@SerialName("service_id")
val serviceId: Int,
val url: String,
val name: String
)

View File

@ -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<SubscriptionExportWorker>()
.setInputData(data)
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build()
WorkManager
.getInstance(context)
.enqueueUniqueWork(WORK_NAME, ExistingWorkPolicy.APPEND_OR_REPLACE, workRequest)
}
}
}

View File

@ -862,4 +862,8 @@
<item quantity="one">%d comment</item> <item quantity="one">%d comment</item>
<item quantity="other">%d comments</item> <item quantity="other">%d comments</item>
</plurals> </plurals>
<plurals name="export_subscriptions">
<item quantity="one">Exporting %d subscription…</item>
<item quantity="other">Exporting %d subscriptions…</item>
</plurals>
</resources> </resources>

View File

@ -5,7 +5,7 @@ import static org.junit.Assert.fail;
import org.junit.Test; import org.junit.Test;
import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor; import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor;
import org.schabi.newpipe.extractor.subscription.SubscriptionItem; import org.schabi.newpipe.local.subscription.workers.SubscriptionItem;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
@ -24,7 +24,7 @@ public class ImportExportJsonHelperTest {
"{\"app_version\":\"0.11.6\",\"app_version_int\": 47,\"subscriptions\":[]}"; "{\"app_version\":\"0.11.6\",\"app_version_int\": 47,\"subscriptions\":[]}";
final List<SubscriptionItem> items = ImportExportJsonHelper.readFrom( final List<SubscriptionItem> items = ImportExportJsonHelper.readFrom(
new ByteArrayInputStream(emptySource.getBytes(StandardCharsets.UTF_8)), null); new ByteArrayInputStream(emptySource.getBytes(StandardCharsets.UTF_8)));
assertTrue(items.isEmpty()); assertTrue(items.isEmpty());
} }
@ -40,9 +40,9 @@ public class ImportExportJsonHelperTest {
try { try {
if (invalidContent != null) { if (invalidContent != null) {
final byte[] bytes = invalidContent.getBytes(StandardCharsets.UTF_8); final byte[] bytes = invalidContent.getBytes(StandardCharsets.UTF_8);
ImportExportJsonHelper.readFrom((new ByteArrayInputStream(bytes)), null); ImportExportJsonHelper.readFrom(new ByteArrayInputStream(bytes));
} else { } else {
ImportExportJsonHelper.readFrom(null, null); ImportExportJsonHelper.readFrom(null);
} }
fail("didn't throw exception"); fail("didn't throw exception");
@ -89,7 +89,7 @@ public class ImportExportJsonHelperTest {
final InputStream inputStream = getClass().getClassLoader().getResourceAsStream( final InputStream inputStream = getClass().getClassLoader().getResourceAsStream(
"import_export_test.json"); "import_export_test.json");
final List<SubscriptionItem> itemsFromFile = ImportExportJsonHelper.readFrom( final List<SubscriptionItem> itemsFromFile = ImportExportJsonHelper.readFrom(
inputStream, null); inputStream);
if (itemsFromFile.isEmpty()) { if (itemsFromFile.isEmpty()) {
fail("ImportExportJsonHelper.readFrom(input) returned a null or empty list"); fail("ImportExportJsonHelper.readFrom(input) returned a null or empty list");
@ -98,10 +98,10 @@ public class ImportExportJsonHelperTest {
return itemsFromFile; return itemsFromFile;
} }
private String testWriteTo(final List<SubscriptionItem> itemsFromFile) throws Exception { private String testWriteTo(final List<SubscriptionItem> itemsFromFile) {
final ByteArrayOutputStream out = new ByteArrayOutputStream(); final ByteArrayOutputStream out = new ByteArrayOutputStream();
ImportExportJsonHelper.writeTo(itemsFromFile, out, null); ImportExportJsonHelper.writeTo(itemsFromFile, out);
final String jsonOut = out.toString("UTF-8"); final String jsonOut = out.toString(StandardCharsets.UTF_8);
if (jsonOut.isEmpty()) { if (jsonOut.isEmpty()) {
fail("JSON returned by writeTo was empty"); fail("JSON returned by writeTo was empty");
@ -114,7 +114,7 @@ public class ImportExportJsonHelperTest {
final ByteArrayInputStream inputStream = new ByteArrayInputStream( final ByteArrayInputStream inputStream = new ByteArrayInputStream(
jsonOut.getBytes(StandardCharsets.UTF_8)); jsonOut.getBytes(StandardCharsets.UTF_8));
final List<SubscriptionItem> secondReadItems = ImportExportJsonHelper.readFrom( final List<SubscriptionItem> secondReadItems = ImportExportJsonHelper.readFrom(
inputStream, null); inputStream);
if (secondReadItems.isEmpty()) { if (secondReadItems.isEmpty()) {
fail("second call to readFrom returned an empty list"); fail("second call to readFrom returned an empty list");

View File

@ -11,6 +11,7 @@ buildscript {
classpath libs.kotlin.gradle.plugin classpath libs.kotlin.gradle.plugin
classpath libs.hilt.android.gradle.plugin classpath libs.hilt.android.gradle.plugin
classpath libs.aboutlibraries.plugin classpath libs.aboutlibraries.plugin
classpath libs.kotlinx.serialization
// NOTE: Do not place your application dependencies here; they belong // NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files // in the individual module build.gradle files

View File

@ -56,7 +56,7 @@ teamnewpipe-filepicker = "5.0.0"
teamnewpipe-nanojson = "1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751" teamnewpipe-nanojson = "1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751"
teamnewpipe-newpipe-extractor = "d3d5f2b3f03a5f2b479b9f6fdf1c2555cbb9de0e" teamnewpipe-newpipe-extractor = "d3d5f2b3f03a5f2b479b9f6fdf1c2555cbb9de0e"
viewpager2 = "1.1.0-beta02" viewpager2 = "1.1.0-beta02"
work = "2.8.1" work = "2.10.0"
[plugins] [plugins]
aboutlibraries = { id = "com.mikepenz.aboutlibraries.plugin" } aboutlibraries = { id = "com.mikepenz.aboutlibraries.plugin" }
@ -67,6 +67,7 @@ kotlin-android = { id = "kotlin-android" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-kapt = { id = "kotlin-kapt" } kotlin-kapt = { id = "kotlin-kapt" }
kotlin-parcelize = { id = "kotlin-parcelize" } kotlin-parcelize = { id = "kotlin-parcelize" }
kotlinx-serialization = { id = "kotlinx-serialization" }
sonarqube = { id = "org.sonarqube", version.ref = "sonarqube" } sonarqube = { id = "org.sonarqube", version.ref = "sonarqube" }
[libraries] [libraries]
@ -132,6 +133,7 @@ junit = { group = "junit", name = "junit", version.ref = "junit" }
kotlin-gradle-plugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } kotlin-gradle-plugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" }
kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlin" } kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlin" }
kotlinx-coroutines-rx3 = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-rx3", version.ref = "kotlinxCoroutinesRx3" } kotlinx-coroutines-rx3 = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-rx3", version.ref = "kotlinxCoroutinesRx3" }
kotlinx-serialization = { group = "org.jetbrains.kotlin", name = "kotlin-serialization", version.ref = "kotlin" }
lazycolumnscrollbar = { group = "com.github.nanihadesuka", name = "LazyColumnScrollbar", version.ref = "lazycolumnscrollbar" } lazycolumnscrollbar = { group = "com.github.nanihadesuka", name = "LazyColumnScrollbar", version.ref = "lazycolumnscrollbar" }
leakcanary-android-core = { module = "com.squareup.leakcanary:leakcanary-android-core", version.ref = "leakcanary" } leakcanary-android-core = { module = "com.squareup.leakcanary:leakcanary-android-core", version.ref = "leakcanary" }
leakcanary-object-watcher = { group = "com.squareup.leakcanary", name = "leakcanary-object-watcher-android", version.ref = "leakcanary" } leakcanary-object-watcher = { group = "com.squareup.leakcanary", name = "leakcanary-object-watcher-android", version.ref = "leakcanary" }