Merge branch 'refactor' into Video-description-compose

This commit is contained in:
Isira Seneviratne 2025-05-14 21:06:24 +05:30
commit a9e3b16e9d
63 changed files with 831 additions and 1153 deletions

View File

@ -33,11 +33,11 @@ module.exports = async ({github, context}) => {
// Regex for finding images (simple variant) ![ALT_TEXT](https://*.githubusercontent.com/<number>/<variousHexStringsAnd->.<fileExtension>)
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');

View File

@ -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 {
@ -27,9 +28,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')
}
@ -226,7 +227,6 @@ dependencies {
implementation libs.androidx.fragment.compose
implementation libs.androidx.lifecycle.livedata
implementation libs.androidx.lifecycle.viewmodel
implementation libs.androidx.localbroadcastmanager
implementation libs.androidx.media
implementation libs.androidx.preference
implementation libs.androidx.recyclerview
@ -319,6 +319,9 @@ dependencies {
// Scroll
implementation libs.lazycolumnscrollbar
// Kotlinx Serialization
implementation libs.kotlinx.serialization.json
/** Debugging **/
// Memory leak detection
debugImplementation libs.leakcanary.object.watcher

View File

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

View File

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

View File

@ -90,7 +90,7 @@ abstract class SubscriptionDAO : BasicDAO<SubscriptionEntity> {
internal abstract fun silentInsertAllInternal(entities: List<SubscriptionEntity>): List<Long>
@Transaction
open fun upsertAll(entities: List<SubscriptionEntity>): List<SubscriptionEntity> {
open fun upsertAll(entities: List<SubscriptionEntity>) {
val insertUidList = silentInsertAllInternal(entities)
insertUidList.forEachIndexed { index: Int, uidFromInsert: Long ->
@ -106,7 +106,5 @@ abstract class SubscriptionDAO : BasicDAO<SubscriptionEntity> {
update(entity)
}
}
return entities
}
}

View File

@ -246,7 +246,7 @@ public final class VideoDetailFragment
// It will do nothing if the player is not in fullscreen mode
hideSystemUiIfNeeded();
final Optional<MainPlayerUi> playerUi = player.UIs().get(MainPlayerUi.class);
final Optional<MainPlayerUi> 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<View> getRoot() {
return Optional.ofNullable(player)
.flatMap(player1 -> player1.UIs().get(VideoPlayerUi.class))
.flatMap(player1 -> player1.UIs().getOpt(VideoPlayerUi.class))
.map(playerUi -> playerUi.getBinding().getRoot());
}

View File

@ -194,9 +194,6 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I>
if (itemsList != null) {
animateHideRecyclerViewAllowingScrolling(itemsList);
}
if (headerRootBinding != null) {
animate(headerRootBinding.getRoot(), false, 200);
}
}
@Override
@ -205,9 +202,6 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I>
if (itemsList != null) {
animate(itemsList, true, 200);
}
if (headerRootBinding != null) {
animate(headerRootBinding.getRoot(), true, 200);
}
}
@Override
@ -253,9 +247,6 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I>
if (itemsList != null) {
animateHideRecyclerViewAllowingScrolling(itemsList);
}
if (headerRootBinding != null) {
animate(headerRootBinding.getRoot(), false, 200);
}
}
@Override

View File

@ -3,47 +3,64 @@ package org.schabi.newpipe.local.subscription;
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
import android.app.Dialog;
import android.content.Intent;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.core.os.BundleCompat;
import androidx.fragment.app.DialogFragment;
import androidx.fragment.app.Fragment;
import androidx.work.Constraints;
import androidx.work.ExistingWorkPolicy;
import androidx.work.NetworkType;
import androidx.work.OneTimeWorkRequest;
import androidx.work.OutOfQuotaPolicy;
import androidx.work.WorkManager;
import com.evernote.android.state.State;
import com.livefront.bridge.Bridge;
import org.schabi.newpipe.R;
import org.schabi.newpipe.local.subscription.workers.SubscriptionImportInput;
import org.schabi.newpipe.local.subscription.workers.SubscriptionImportWorker;
public class ImportConfirmationDialog extends DialogFragment {
@State
protected Intent resultServiceIntent;
private static final String INPUT = "input";
public static void show(@NonNull final Fragment fragment,
@NonNull final Intent resultServiceIntent) {
final ImportConfirmationDialog confirmationDialog = new ImportConfirmationDialog();
confirmationDialog.setResultServiceIntent(resultServiceIntent);
public static void show(@NonNull final Fragment fragment, final SubscriptionImportInput input) {
final var confirmationDialog = new ImportConfirmationDialog();
final var arguments = new Bundle();
arguments.putParcelable(INPUT, input);
confirmationDialog.setArguments(arguments);
confirmationDialog.show(fragment.getParentFragmentManager(), null);
}
public void setResultServiceIntent(final Intent resultServiceIntent) {
this.resultServiceIntent = resultServiceIntent;
}
@NonNull
@Override
public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) {
assureCorrectAppLanguage(getContext());
return new AlertDialog.Builder(requireContext())
final var context = requireContext();
assureCorrectAppLanguage(context);
return new AlertDialog.Builder(context)
.setMessage(R.string.import_network_expensive_warning)
.setCancelable(true)
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.ok, (dialogInterface, i) -> {
if (resultServiceIntent != null && getContext() != null) {
getContext().startService(resultServiceIntent);
}
final var constraints = new Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build();
final var input = BundleCompat.getParcelable(requireArguments(), INPUT,
SubscriptionImportInput.class);
final var req = new OneTimeWorkRequest.Builder(SubscriptionImportWorker.class)
.setInputData(input.toData())
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.setConstraints(constraints)
.build();
WorkManager.getInstance(context)
.enqueueUniqueWork(SubscriptionImportWorker.WORK_NAME,
ExistingWorkPolicy.APPEND_OR_REPLACE, req);
dismiss();
})
.create();
@ -53,10 +70,6 @@ public class ImportConfirmationDialog extends DialogFragment {
public void onCreate(@Nullable final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (resultServiceIntent == null) {
throw new IllegalStateException("Result intent is null");
}
Bridge.restoreInstanceState(this, savedInstanceState);
}

View File

@ -3,7 +3,6 @@ package org.schabi.newpipe.local.subscription
import android.app.Activity
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.os.Bundle
import android.os.Parcelable
import android.view.LayoutInflater
@ -49,11 +48,8 @@ import org.schabi.newpipe.local.subscription.item.FeedGroupCarouselItem
import org.schabi.newpipe.local.subscription.item.GroupsHeader
import org.schabi.newpipe.local.subscription.item.Header
import org.schabi.newpipe.local.subscription.item.ImportSubscriptionsHintPlaceholderItem
import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_MODE
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_VALUE
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.PREVIOUS_EXPORT_MODE
import org.schabi.newpipe.local.subscription.workers.SubscriptionExportWorker
import org.schabi.newpipe.local.subscription.workers.SubscriptionImportInput
import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard
import org.schabi.newpipe.streams.io.StoredFileHelper
import org.schabi.newpipe.ui.emptystate.setEmptyStateComposable
@ -224,21 +220,17 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
}
private fun requestExportResult(result: ActivityResult) {
if (result.data != null && result.resultCode == Activity.RESULT_OK) {
activity.startService(
Intent(activity, SubscriptionsExportService::class.java)
.putExtra(SubscriptionsExportService.KEY_FILE_PATH, result.data?.data)
)
val data = result.data?.data
if (data != null && result.resultCode == Activity.RESULT_OK) {
SubscriptionExportWorker.schedule(activity, data)
}
}
private fun requestImportResult(result: ActivityResult) {
if (result.data != null && result.resultCode == Activity.RESULT_OK) {
val data = result.data?.dataString
if (data != null && result.resultCode == Activity.RESULT_OK) {
ImportConfirmationDialog.show(
this,
Intent(activity, SubscriptionsImportService::class.java)
.putExtra(KEY_MODE, PREVIOUS_EXPORT_MODE)
.putExtra(KEY_VALUE, result.data?.data)
this, SubscriptionImportInput.PreviousExportMode(data)
)
}
}

View File

@ -1,7 +1,6 @@
package org.schabi.newpipe.local.subscription
import android.content.Context
import android.util.Pair
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Flowable
@ -48,23 +47,16 @@ class SubscriptionManager(context: Context) {
}
}
fun upsertAll(infoList: List<Pair<ChannelInfo, List<ChannelTabInfo>>>): List<SubscriptionEntity> {
val listEntities = subscriptionTable.upsertAll(
infoList.map { SubscriptionEntity.from(it.first) }
)
fun upsertAll(infoList: List<Pair<ChannelInfo, ChannelTabInfo>>) {
val listEntities = infoList.map { SubscriptionEntity.from(it.first) }
subscriptionTable.upsertAll(listEntities)
database.runInTransaction {
infoList.forEachIndexed { index, info ->
info.second.forEach {
feedDatabaseManager.upsertAll(
listEntities[index].uid,
it.relatedItems.filterIsInstance<StreamInfoItem>()
)
}
val streams = info.second.relatedItems.filterIsInstance<StreamInfoItem>()
feedDatabaseManager.upsertAll(listEntities[index].uid, streams)
}
}
return listEntities
}
fun updateChannelInfo(info: ChannelInfo): Completable =

View File

@ -1,10 +1,6 @@
package org.schabi.newpipe.local.subscription;
import static org.schabi.newpipe.extractor.subscription.SubscriptionExtractor.ContentSource.CHANNEL_URL;
import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.CHANNEL_URL_MODE;
import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.INPUT_STREAM_MODE;
import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_MODE;
import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_VALUE;
import android.app.Activity;
import android.content.Intent;
@ -37,7 +33,7 @@ import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor;
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService;
import org.schabi.newpipe.local.subscription.workers.SubscriptionImportInput;
import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard;
import org.schabi.newpipe.streams.io.StoredFileHelper;
import org.schabi.newpipe.util.Constants;
@ -168,10 +164,8 @@ public class SubscriptionsImportFragment extends BaseFragment {
}
public void onImportUrl(final String value) {
ImportConfirmationDialog.show(this, new Intent(activity, SubscriptionsImportService.class)
.putExtra(KEY_MODE, CHANNEL_URL_MODE)
.putExtra(KEY_VALUE, value)
.putExtra(Constants.KEY_SERVICE_ID, currentServiceId));
ImportConfirmationDialog.show(this,
new SubscriptionImportInput.ChannelUrlMode(currentServiceId, value));
}
public void onImportFile() {
@ -186,16 +180,10 @@ public class SubscriptionsImportFragment extends BaseFragment {
}
private void requestImportFileResult(final ActivityResult result) {
if (result.getData() == null) {
return;
}
if (result.getResultCode() == Activity.RESULT_OK && result.getData().getData() != null) {
final String data = result.getData() != null ? result.getData().getDataString() : null;
if (result.getResultCode() == Activity.RESULT_OK && data != null) {
ImportConfirmationDialog.show(this,
new Intent(activity, SubscriptionsImportService.class)
.putExtra(KEY_MODE, INPUT_STREAM_MODE)
.putExtra(KEY_VALUE, result.getData().getData())
.putExtra(Constants.KEY_SERVICE_ID, currentServiceId));
new SubscriptionImportInput.InputStreamMode(currentServiceId, data));
}
}

View File

@ -1,233 +0,0 @@
/*
* Copyright 2018 Mauricio Colli <mauriciocolli@outlook.com>
* BaseImportExportService.java is part of NewPipe
*
* License: GPL-3.0+
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.schabi.newpipe.local.subscription.services;
import android.app.Service;
import android.content.Intent;
import android.os.Build;
import android.os.IBinder;
import android.text.TextUtils;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
import androidx.core.app.ServiceCompat;
import org.reactivestreams.Publisher;
import org.schabi.newpipe.R;
import org.schabi.newpipe.error.ErrorInfo;
import org.schabi.newpipe.error.ErrorUtil;
import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor;
import org.schabi.newpipe.ktx.ExceptionUtils;
import org.schabi.newpipe.local.subscription.SubscriptionManager;
import java.io.FileNotFoundException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Flowable;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.functions.Function;
import io.reactivex.rxjava3.processors.PublishProcessor;
public abstract class BaseImportExportService extends Service {
protected final String TAG = this.getClass().getSimpleName();
protected final CompositeDisposable disposables = new CompositeDisposable();
protected final PublishProcessor<String> notificationUpdater = PublishProcessor.create();
protected NotificationManagerCompat notificationManager;
protected NotificationCompat.Builder notificationBuilder;
protected SubscriptionManager subscriptionManager;
private static final int NOTIFICATION_SAMPLING_PERIOD = 2500;
protected final AtomicInteger currentProgress = new AtomicInteger(-1);
protected final AtomicInteger maxProgress = new AtomicInteger(-1);
protected final ImportExportEventListener eventListener = new ImportExportEventListener() {
@Override
public void onSizeReceived(final int size) {
maxProgress.set(size);
currentProgress.set(0);
}
@Override
public void onItemCompleted(final String itemName) {
currentProgress.incrementAndGet();
notificationUpdater.onNext(itemName);
}
};
protected Toast toast;
@Nullable
@Override
public IBinder onBind(final Intent intent) {
return null;
}
@Override
public void onCreate() {
super.onCreate();
subscriptionManager = new SubscriptionManager(this);
setupNotification();
}
@Override
public void onDestroy() {
super.onDestroy();
disposeAll();
}
protected void disposeAll() {
disposables.clear();
}
/*//////////////////////////////////////////////////////////////////////////
// Notification Impl
//////////////////////////////////////////////////////////////////////////*/
protected abstract int getNotificationId();
@StringRes
public abstract int getTitle();
protected void setupNotification() {
notificationManager = NotificationManagerCompat.from(this);
notificationBuilder = createNotification();
startForeground(getNotificationId(), notificationBuilder.build());
final Function<Flowable<String>, Publisher<String>> throttleAfterFirstEmission = flow ->
flow.take(1).concatWith(flow.skip(1)
.throttleLast(NOTIFICATION_SAMPLING_PERIOD, TimeUnit.MILLISECONDS));
disposables.add(notificationUpdater
.filter(s -> !s.isEmpty())
.publish(throttleAfterFirstEmission)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(this::updateNotification));
}
protected void updateNotification(final String text) {
notificationBuilder
.setProgress(maxProgress.get(), currentProgress.get(), maxProgress.get() == -1);
final String progressText = currentProgress + "/" + maxProgress;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
if (!TextUtils.isEmpty(text)) {
notificationBuilder.setContentText(text + " (" + progressText + ")");
}
} else {
notificationBuilder.setContentInfo(progressText);
notificationBuilder.setContentText(text);
}
notificationManager.notify(getNotificationId(), notificationBuilder.build());
}
protected void stopService() {
postErrorResult(null, null);
}
protected void stopAndReportError(final Throwable throwable, final String request) {
stopService();
ErrorUtil.createNotification(this, new ErrorInfo(
throwable, UserAction.SUBSCRIPTION_IMPORT_EXPORT, request));
}
protected void postErrorResult(final String title, final String text) {
disposeAll();
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE);
stopSelf();
if (title == null) {
return;
}
final String textOrEmpty = text == null ? "" : text;
notificationBuilder = new NotificationCompat
.Builder(this, getString(R.string.notification_channel_id))
.setSmallIcon(R.drawable.ic_newpipe_triangle_white)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentTitle(title)
.setStyle(new NotificationCompat.BigTextStyle().bigText(textOrEmpty))
.setContentText(textOrEmpty);
notificationManager.notify(getNotificationId(), notificationBuilder.build());
}
protected NotificationCompat.Builder createNotification() {
return new NotificationCompat.Builder(this, getString(R.string.notification_channel_id))
.setOngoing(true)
.setProgress(-1, -1, true)
.setSmallIcon(R.drawable.ic_newpipe_triangle_white)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentTitle(getString(getTitle()));
}
/*//////////////////////////////////////////////////////////////////////////
// Toast
//////////////////////////////////////////////////////////////////////////*/
protected void showToast(@StringRes final int message) {
showToast(getString(message));
}
protected void showToast(final String message) {
if (toast != null) {
toast.cancel();
}
toast = Toast.makeText(this, message, Toast.LENGTH_SHORT);
toast.show();
}
/*//////////////////////////////////////////////////////////////////////////
// Error handling
//////////////////////////////////////////////////////////////////////////*/
protected void handleError(@StringRes final int errorTitle, @NonNull final Throwable error) {
String message = getErrorMessage(error);
if (TextUtils.isEmpty(message)) {
final String errorClassName = error.getClass().getName();
message = getString(R.string.error_occurred_detail, errorClassName);
}
showToast(errorTitle);
postErrorResult(getString(errorTitle), message);
}
protected String getErrorMessage(final Throwable error) {
String message = null;
if (error instanceof SubscriptionExtractor.InvalidSourceException) {
message = getString(R.string.invalid_source);
} else if (error instanceof FileNotFoundException) {
message = getString(R.string.invalid_file);
} else if (ExceptionUtils.isNetworkRelated(error)) {
message = getString(R.string.network_error);
}
return message;
}
}

View File

@ -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);
}

View File

@ -1,158 +0,0 @@
/*
* Copyright 2018 Mauricio Colli <mauriciocolli@outlook.com>
* ImportExportJsonHelper.java is part of NewPipe
*
* License: GPL-3.0+
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.schabi.newpipe.local.subscription.services;
import androidx.annotation.Nullable;
import com.grack.nanojson.JsonAppendableWriter;
import com.grack.nanojson.JsonArray;
import com.grack.nanojson.JsonObject;
import com.grack.nanojson.JsonParser;
import com.grack.nanojson.JsonWriter;
import org.schabi.newpipe.BuildConfig;
import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor.InvalidSourceException;
import org.schabi.newpipe.extractor.subscription.SubscriptionItem;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.List;
/**
* A JSON implementation capable of importing and exporting subscriptions, it has the advantage
* of being able to transfer subscriptions to any device.
*/
public final class ImportExportJsonHelper {
/*//////////////////////////////////////////////////////////////////////////
// Json implementation
//////////////////////////////////////////////////////////////////////////*/
private static final String JSON_APP_VERSION_KEY = "app_version";
private static final String JSON_APP_VERSION_INT_KEY = "app_version_int";
private static final String JSON_SUBSCRIPTIONS_ARRAY_KEY = "subscriptions";
private static final String JSON_SERVICE_ID_KEY = "service_id";
private static final String JSON_URL_KEY = "url";
private static final String JSON_NAME_KEY = "name";
private ImportExportJsonHelper() { }
/**
* Read a JSON source through the input stream.
*
* @param in the input stream (e.g. a file)
* @param eventListener listener for the events generated
* @return the parsed subscription items
*/
public static List<SubscriptionItem> readFrom(
final InputStream in, @Nullable final ImportExportEventListener eventListener)
throws InvalidSourceException {
if (in == null) {
throw new InvalidSourceException("input is null");
}
final List<SubscriptionItem> channels = new ArrayList<>();
try {
final JsonObject parentObject = JsonParser.object().from(in);
if (!parentObject.has(JSON_SUBSCRIPTIONS_ARRAY_KEY)) {
throw new InvalidSourceException("Channels array is null");
}
final JsonArray channelsArray = parentObject.getArray(JSON_SUBSCRIPTIONS_ARRAY_KEY);
if (eventListener != null) {
eventListener.onSizeReceived(channelsArray.size());
}
for (final Object o : channelsArray) {
if (o instanceof JsonObject) {
final JsonObject itemObject = (JsonObject) o;
final int serviceId = itemObject.getInt(JSON_SERVICE_ID_KEY, 0);
final String url = itemObject.getString(JSON_URL_KEY);
final String name = itemObject.getString(JSON_NAME_KEY);
if (url != null && name != null && !url.isEmpty() && !name.isEmpty()) {
channels.add(new SubscriptionItem(serviceId, url, name));
if (eventListener != null) {
eventListener.onItemCompleted(name);
}
}
}
}
} catch (final Throwable e) {
throw new InvalidSourceException("Couldn't parse json", e);
}
return channels;
}
/**
* Write the subscriptions items list as JSON to the output.
*
* @param items the list of subscriptions items
* @param out the output stream (e.g. a file)
* @param eventListener listener for the events generated
*/
public static void writeTo(final List<SubscriptionItem> items, final OutputStream out,
@Nullable final ImportExportEventListener eventListener) {
final JsonAppendableWriter writer = JsonWriter.on(out);
writeTo(items, writer, eventListener);
writer.done();
}
/**
* @see #writeTo(List, OutputStream, ImportExportEventListener)
* @param items the list of subscriptions items
* @param writer the output {@link JsonAppendableWriter}
* @param eventListener listener for the events generated
*/
public static void writeTo(final List<SubscriptionItem> items,
final JsonAppendableWriter writer,
@Nullable final ImportExportEventListener eventListener) {
if (eventListener != null) {
eventListener.onSizeReceived(items.size());
}
writer.object();
writer.value(JSON_APP_VERSION_KEY, BuildConfig.VERSION_NAME);
writer.value(JSON_APP_VERSION_INT_KEY, BuildConfig.VERSION_CODE);
writer.array(JSON_SUBSCRIPTIONS_ARRAY_KEY);
for (final SubscriptionItem item : items) {
writer.object();
writer.value(JSON_SERVICE_ID_KEY, item.getServiceId());
writer.value(JSON_URL_KEY, item.getUrl());
writer.value(JSON_NAME_KEY, item.getName());
writer.end();
if (eventListener != null) {
eventListener.onItemCompleted(item.getName());
}
}
writer.end();
writer.end();
}
}

View File

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

View File

@ -1,327 +0,0 @@
/*
* Copyright 2018 Mauricio Colli <mauriciocolli@outlook.com>
* SubscriptionsImportService.java is part of NewPipe
*
* License: GPL-3.0+
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.schabi.newpipe.local.subscription.services;
import static org.schabi.newpipe.MainActivity.DEBUG;
import static org.schabi.newpipe.streams.io.StoredFileHelper.DEFAULT_MIME;
import android.content.Intent;
import android.net.Uri;
import android.text.TextUtils;
import android.util.Log;
import android.util.Pair;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.IntentCompat;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import org.reactivestreams.Subscriber;
import org.reactivestreams.Subscription;
import org.schabi.newpipe.App;
import org.schabi.newpipe.R;
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.channel.ChannelInfo;
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo;
import org.schabi.newpipe.extractor.subscription.SubscriptionItem;
import org.schabi.newpipe.ktx.ExceptionUtils;
import org.schabi.newpipe.streams.io.SharpInputStream;
import org.schabi.newpipe.streams.io.StoredFileHelper;
import org.schabi.newpipe.util.Constants;
import org.schabi.newpipe.util.ExtractorHelper;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Flowable;
import io.reactivex.rxjava3.core.Notification;
import io.reactivex.rxjava3.functions.Consumer;
import io.reactivex.rxjava3.functions.Function;
import io.reactivex.rxjava3.schedulers.Schedulers;
public class SubscriptionsImportService extends BaseImportExportService {
public static final int CHANNEL_URL_MODE = 0;
public static final int INPUT_STREAM_MODE = 1;
public static final int PREVIOUS_EXPORT_MODE = 2;
public static final String KEY_MODE = "key_mode";
public static final String KEY_VALUE = "key_value";
/**
* A {@link LocalBroadcastManager local broadcast} will be made with this action
* when the import is successfully completed.
*/
public static final String IMPORT_COMPLETE_ACTION = App.PACKAGE_NAME + ".local.subscription"
+ ".services.SubscriptionsImportService.IMPORT_COMPLETE";
/**
* How many extractions running in parallel.
*/
public static final int PARALLEL_EXTRACTIONS = 8;
/**
* Number of items to buffer to mass-insert in the subscriptions table,
* this leads to a better performance as we can then use db transactions.
*/
public static final int BUFFER_COUNT_BEFORE_INSERT = 50;
private Subscription subscription;
private int currentMode;
private int currentServiceId;
@Nullable
private String channelUrl;
@Nullable
private InputStream inputStream;
@Nullable
private String inputStreamType;
@Override
public int onStartCommand(final Intent intent, final int flags, final int startId) {
if (intent == null || subscription != null) {
return START_NOT_STICKY;
}
currentMode = intent.getIntExtra(KEY_MODE, -1);
currentServiceId = intent.getIntExtra(Constants.KEY_SERVICE_ID, Constants.NO_SERVICE_ID);
if (currentMode == CHANNEL_URL_MODE) {
channelUrl = intent.getStringExtra(KEY_VALUE);
} else {
final Uri uri = IntentCompat.getParcelableExtra(intent, KEY_VALUE, Uri.class);
if (uri == null) {
stopAndReportError(new IllegalStateException(
"Importing from input stream, but file path is null"),
"Importing subscriptions");
return START_NOT_STICKY;
}
try {
final StoredFileHelper fileHelper = new StoredFileHelper(this, uri, DEFAULT_MIME);
inputStream = new SharpInputStream(fileHelper.getStream());
inputStreamType = fileHelper.getType();
if (inputStreamType == null || inputStreamType.equals(DEFAULT_MIME)) {
// mime type could not be determined, just take file extension
final String name = fileHelper.getName();
final int pointIndex = name.lastIndexOf('.');
if (pointIndex == -1 || pointIndex >= name.length() - 1) {
inputStreamType = DEFAULT_MIME; // no extension, will fail in the extractor
} else {
inputStreamType = name.substring(pointIndex + 1);
}
}
} catch (final IOException e) {
handleError(e);
return START_NOT_STICKY;
}
}
if (currentMode == -1 || currentMode == CHANNEL_URL_MODE && channelUrl == null) {
final String errorDescription = "Some important field is null or in illegal state: "
+ "currentMode=[" + currentMode + "], "
+ "channelUrl=[" + channelUrl + "], "
+ "inputStream=[" + inputStream + "]";
stopAndReportError(new IllegalStateException(errorDescription),
"Importing subscriptions");
return START_NOT_STICKY;
}
startImport();
return START_NOT_STICKY;
}
@Override
protected int getNotificationId() {
return 4568;
}
@Override
public int getTitle() {
return R.string.import_ongoing;
}
@Override
protected void disposeAll() {
super.disposeAll();
if (subscription != null) {
subscription.cancel();
}
}
/*//////////////////////////////////////////////////////////////////////////
// Imports
//////////////////////////////////////////////////////////////////////////*/
private void startImport() {
showToast(R.string.import_ongoing);
Flowable<List<SubscriptionItem>> flowable = null;
switch (currentMode) {
case CHANNEL_URL_MODE:
flowable = importFromChannelUrl();
break;
case INPUT_STREAM_MODE:
flowable = importFromInputStream();
break;
case PREVIOUS_EXPORT_MODE:
flowable = importFromPreviousExport();
break;
}
if (flowable == null) {
final String message = "Flowable given by \"importFrom\" is null "
+ "(current mode: " + currentMode + ")";
stopAndReportError(new IllegalStateException(message), "Importing subscriptions");
return;
}
flowable.doOnNext(subscriptionItems ->
eventListener.onSizeReceived(subscriptionItems.size()))
.flatMap(Flowable::fromIterable)
.parallel(PARALLEL_EXTRACTIONS)
.runOn(Schedulers.io())
.map((Function<SubscriptionItem, Notification<Pair<ChannelInfo,
List<ChannelTabInfo>>>>) subscriptionItem -> {
try {
final ChannelInfo channelInfo = ExtractorHelper
.getChannelInfo(subscriptionItem.getServiceId(),
subscriptionItem.getUrl(), true)
.blockingGet();
return Notification.createOnNext(new Pair<>(channelInfo,
Collections.singletonList(
ExtractorHelper.getChannelTab(
subscriptionItem.getServiceId(),
channelInfo.getTabs().get(0), true).blockingGet()
)));
} catch (final Throwable e) {
return Notification.createOnError(e);
}
})
.sequential()
.observeOn(Schedulers.io())
.doOnNext(getNotificationsConsumer())
.buffer(BUFFER_COUNT_BEFORE_INSERT)
.map(upsertBatch())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(getSubscriber());
}
private Subscriber<List<SubscriptionEntity>> getSubscriber() {
return new Subscriber<>() {
@Override
public void onSubscribe(final Subscription s) {
subscription = s;
s.request(Long.MAX_VALUE);
}
@Override
public void onNext(final List<SubscriptionEntity> successfulInserted) {
if (DEBUG) {
Log.d(TAG, "startImport() " + successfulInserted.size()
+ " items successfully inserted into the database");
}
}
@Override
public void onError(final Throwable error) {
Log.e(TAG, "Got an error!", error);
handleError(error);
}
@Override
public void onComplete() {
LocalBroadcastManager.getInstance(SubscriptionsImportService.this)
.sendBroadcast(new Intent(IMPORT_COMPLETE_ACTION));
showToast(R.string.import_complete_toast);
stopService();
}
};
}
private Consumer<Notification<Pair<ChannelInfo,
List<ChannelTabInfo>>>> getNotificationsConsumer() {
return notification -> {
if (notification.isOnNext()) {
final String name = notification.getValue().first.getName();
eventListener.onItemCompleted(!TextUtils.isEmpty(name) ? name : "");
} else if (notification.isOnError()) {
final Throwable error = notification.getError();
final Throwable cause = error.getCause();
if (error instanceof IOException) {
throw error;
} else if (cause instanceof IOException) {
throw cause;
} else if (ExceptionUtils.isNetworkRelated(error)) {
throw new IOException(error);
}
eventListener.onItemCompleted("");
}
};
}
private Function<List<Notification<Pair<ChannelInfo, List<ChannelTabInfo>>>>,
List<SubscriptionEntity>> upsertBatch() {
return notificationList -> {
final List<Pair<ChannelInfo, List<ChannelTabInfo>>> infoList =
new ArrayList<>(notificationList.size());
for (final Notification<Pair<ChannelInfo, List<ChannelTabInfo>>> n : notificationList) {
if (n.isOnNext()) {
infoList.add(n.getValue());
}
}
return subscriptionManager.upsertAll(infoList);
};
}
private Flowable<List<SubscriptionItem>> importFromChannelUrl() {
return Flowable.fromCallable(() -> NewPipe.getService(currentServiceId)
.getSubscriptionExtractor()
.fromChannelUrl(channelUrl));
}
private Flowable<List<SubscriptionItem>> importFromInputStream() {
Objects.requireNonNull(inputStream);
Objects.requireNonNull(inputStreamType);
return Flowable.fromCallable(() -> NewPipe.getService(currentServiceId)
.getSubscriptionExtractor()
.fromInputStream(inputStream, inputStreamType));
}
private Flowable<List<SubscriptionItem>> importFromPreviousExport() {
return Flowable.fromCallable(() -> ImportExportJsonHelper.readFrom(inputStream, null));
}
protected void handleError(@NonNull final Throwable error) {
super.handleError(R.string.subscriptions_import_unsuccessful, error);
}
}

View File

@ -0,0 +1,72 @@
/*
* Copyright 2018 Mauricio Colli <mauriciocolli@outlook.com>
* ImportExportJsonHelper.java is part of NewPipe
*
* License: GPL-3.0+
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.schabi.newpipe.local.subscription.workers
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import kotlinx.serialization.json.encodeToStream
import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor.InvalidSourceException
import java.io.InputStream
import java.io.OutputStream
/**
* A JSON implementation capable of importing and exporting subscriptions, it has the advantage
* of being able to transfer subscriptions to any device.
*/
object ImportExportJsonHelper {
private val json = Json { encodeDefaults = true }
/**
* Read a JSON source through the input stream.
*
* @param in the input stream (e.g. a file)
* @return the parsed subscription items
*/
@JvmStatic
@Throws(InvalidSourceException::class)
fun readFrom(`in`: InputStream?): List<SubscriptionItem> {
if (`in` == null) {
throw InvalidSourceException("input is null")
}
try {
@OptIn(ExperimentalSerializationApi::class)
return json.decodeFromStream<SubscriptionData>(`in`).subscriptions
} catch (e: Throwable) {
throw InvalidSourceException("Couldn't parse json", e)
}
}
/**
* Write the subscriptions items list as JSON to the output.
*
* @param items the list of subscriptions items
* @param out the output stream (e.g. a file)
*/
@OptIn(ExperimentalSerializationApi::class)
@JvmStatic
fun writeTo(
items: List<SubscriptionItem>,
out: OutputStream,
) {
json.encodeToStream(SubscriptionData(items), out)
}
}

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
data class SubscriptionItem(
@SerialName("service_id")
val serviceId: Int,
val url: String,
val name: String
)

View File

@ -0,0 +1,119 @@
package org.schabi.newpipe.local.subscription.workers
import android.content.Context
import android.content.pm.ServiceInfo
import android.net.Uri
import android.os.Build
import android.util.Log
import android.widget.Toast
import androidx.core.app.NotificationCompat
import androidx.core.net.toUri
import androidx.work.CoroutineWorker
import androidx.work.ExistingWorkPolicy
import androidx.work.ForegroundInfo
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.reactive.awaitFirst
import kotlinx.coroutines.withContext
import org.schabi.newpipe.BuildConfig
import org.schabi.newpipe.NewPipeDatabase
import org.schabi.newpipe.R
class SubscriptionExportWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
// This is needed for API levels < 31 (Android S).
override suspend fun getForegroundInfo(): ForegroundInfo {
return createForegroundInfo(applicationContext.getString(R.string.export_ongoing))
}
override suspend fun doWork(): Result {
return try {
val uri = inputData.getString(EXPORT_PATH)!!.toUri()
val table = NewPipeDatabase.getInstance(applicationContext).subscriptionDAO()
val subscriptions =
table.all
.awaitFirst()
.map { SubscriptionItem(it.serviceId, it.url, it.name) }
val qty = subscriptions.size
val title = applicationContext.resources.getQuantityString(R.plurals.export_subscriptions, qty, qty)
setForeground(createForegroundInfo(title))
withContext(Dispatchers.IO) {
// Truncate file if it already exists
applicationContext.contentResolver.openOutputStream(uri, "wt")?.use {
ImportExportJsonHelper.writeTo(subscriptions, it)
}
}
if (BuildConfig.DEBUG) {
Log.i(TAG, "Exported $qty subscriptions")
}
withContext(Dispatchers.Main) {
Toast
.makeText(applicationContext, R.string.export_complete_toast, Toast.LENGTH_SHORT)
.show()
}
Result.success()
} catch (e: Exception) {
if (BuildConfig.DEBUG) {
Log.e(TAG, "Error while exporting subscriptions", e)
}
withContext(Dispatchers.Main) {
Toast
.makeText(applicationContext, R.string.subscriptions_export_unsuccessful, Toast.LENGTH_SHORT)
.show()
}
return Result.failure()
}
}
private fun createForegroundInfo(title: String): ForegroundInfo {
val notification =
NotificationCompat
.Builder(applicationContext, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_newpipe_triangle_white)
.setOngoing(true)
.setProgress(-1, -1, true)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.setContentTitle(title)
.build()
val serviceType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC else 0
return ForegroundInfo(NOTIFICATION_ID, notification, serviceType)
}
companion object {
private const val TAG = "SubscriptionExportWork"
private const val NOTIFICATION_ID = 4567
private const val NOTIFICATION_CHANNEL_ID = "newpipe"
private const val WORK_NAME = "exportSubscriptions"
private const val EXPORT_PATH = "exportPath"
fun schedule(
context: Context,
uri: Uri,
) {
val data = workDataOf(EXPORT_PATH to uri.toString())
val workRequest =
OneTimeWorkRequestBuilder<SubscriptionExportWorker>()
.setInputData(data)
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build()
WorkManager
.getInstance(context)
.enqueueUniqueWork(WORK_NAME, ExistingWorkPolicy.APPEND_OR_REPLACE, workRequest)
}
}
}

View File

@ -0,0 +1,237 @@
package org.schabi.newpipe.local.subscription.workers
import android.content.Context
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.Parcelable
import android.util.Log
import android.webkit.MimeTypeMap
import android.widget.Toast
import androidx.core.app.NotificationCompat
import androidx.core.net.toUri
import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.ForegroundInfo
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.rx3.await
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize
import org.schabi.newpipe.BuildConfig
import org.schabi.newpipe.R
import org.schabi.newpipe.extractor.NewPipe
import org.schabi.newpipe.local.subscription.SubscriptionManager
import org.schabi.newpipe.util.ExtractorHelper
class SubscriptionImportWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
// This is needed for API levels < 31 (Android S).
override suspend fun getForegroundInfo(): ForegroundInfo {
return createForegroundInfo(applicationContext.getString(R.string.import_ongoing), null, 0, 0)
}
override suspend fun doWork(): Result {
val subscriptions =
try {
loadSubscriptionsFromInput(SubscriptionImportInput.fromData(inputData))
} catch (e: Exception) {
if (BuildConfig.DEBUG) {
Log.e(TAG, "Error while loading subscriptions from path", e)
}
withContext(Dispatchers.Main) {
Toast
.makeText(applicationContext, R.string.subscriptions_import_unsuccessful, Toast.LENGTH_SHORT)
.show()
}
return Result.failure()
}
val mutex = Mutex()
var index = 1
val qty = subscriptions.size
var title =
applicationContext.resources.getQuantityString(R.plurals.load_subscriptions, qty, qty)
val channelInfoList =
try {
withContext(Dispatchers.IO.limitedParallelism(PARALLEL_EXTRACTIONS)) {
subscriptions
.map {
async {
val channelInfo =
ExtractorHelper.getChannelInfo(it.serviceId, it.url, true).await()
val channelTab =
ExtractorHelper.getChannelTab(it.serviceId, channelInfo.tabs[0], true).await()
val currentIndex = mutex.withLock { index++ }
setForeground(createForegroundInfo(title, channelInfo.name, currentIndex, qty))
channelInfo to channelTab
}
}.awaitAll()
}
} catch (e: Exception) {
if (BuildConfig.DEBUG) {
Log.e(TAG, "Error while loading subscription data", e)
}
withContext(Dispatchers.Main) {
Toast.makeText(applicationContext, R.string.subscriptions_import_unsuccessful, Toast.LENGTH_SHORT)
.show()
}
return Result.failure()
}
title = applicationContext.resources.getQuantityString(R.plurals.import_subscriptions, qty, qty)
setForeground(createForegroundInfo(title, null, 0, 0))
index = 0
val subscriptionManager = SubscriptionManager(applicationContext)
for (chunk in channelInfoList.chunked(BUFFER_COUNT_BEFORE_INSERT)) {
withContext(Dispatchers.IO) {
subscriptionManager.upsertAll(chunk)
}
index += chunk.size
setForeground(createForegroundInfo(title, null, index, qty))
}
withContext(Dispatchers.Main) {
Toast.makeText(applicationContext, R.string.import_complete_toast, Toast.LENGTH_SHORT)
.show()
}
return Result.success()
}
private suspend fun loadSubscriptionsFromInput(input: SubscriptionImportInput): List<SubscriptionItem> {
return withContext(Dispatchers.IO) {
when (input) {
is SubscriptionImportInput.ChannelUrlMode ->
NewPipe.getService(input.serviceId).subscriptionExtractor
.fromChannelUrl(input.url)
.map { SubscriptionItem(it.serviceId, it.url, it.name) }
is SubscriptionImportInput.InputStreamMode ->
applicationContext.contentResolver.openInputStream(input.url.toUri())?.use {
val contentType =
MimeTypeMap.getFileExtensionFromUrl(input.url).ifEmpty { DEFAULT_MIME }
NewPipe.getService(input.serviceId).subscriptionExtractor
.fromInputStream(it, contentType)
.map { SubscriptionItem(it.serviceId, it.url, it.name) }
}
is SubscriptionImportInput.PreviousExportMode ->
applicationContext.contentResolver.openInputStream(input.url.toUri())?.use {
ImportExportJsonHelper.readFrom(it)
}
} ?: emptyList()
}
}
private fun createForegroundInfo(
title: String,
text: String?,
currentProgress: Int,
maxProgress: Int,
): ForegroundInfo {
val notification =
NotificationCompat
.Builder(applicationContext, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_newpipe_triangle_white)
.setOngoing(true)
.setProgress(maxProgress, currentProgress, currentProgress == 0)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.setContentTitle(title)
.setContentText(text)
.addAction(
R.drawable.ic_close,
applicationContext.getString(R.string.cancel),
WorkManager.getInstance(applicationContext).createCancelPendingIntent(id),
).apply {
if (currentProgress > 0 && maxProgress > 0) {
val progressText = "$currentProgress/$maxProgress"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
setSubText(progressText)
} else {
setContentInfo(progressText)
}
}
}.build()
val serviceType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC else 0
return ForegroundInfo(NOTIFICATION_ID, notification, serviceType)
}
companion object {
// Log tag length is limited to 23 characters on API levels < 24.
private const val TAG = "SubscriptionImport"
private const val NOTIFICATION_ID = 4568
private const val NOTIFICATION_CHANNEL_ID = "newpipe"
private const val DEFAULT_MIME = "application/octet-stream"
private const val PARALLEL_EXTRACTIONS = 8
private const val BUFFER_COUNT_BEFORE_INSERT = 50
const val WORK_NAME = "SubscriptionImportWorker"
}
}
sealed class SubscriptionImportInput : Parcelable {
@Parcelize
data class ChannelUrlMode(val serviceId: Int, val url: String) : SubscriptionImportInput()
@Parcelize
data class InputStreamMode(val serviceId: Int, val url: String) : SubscriptionImportInput()
@Parcelize
data class PreviousExportMode(val url: String) : SubscriptionImportInput()
fun toData(): Data {
val (mode, serviceId, url) = when (this) {
is ChannelUrlMode -> Triple(CHANNEL_URL_MODE, serviceId, url)
is InputStreamMode -> Triple(INPUT_STREAM_MODE, serviceId, url)
is PreviousExportMode -> Triple(PREVIOUS_EXPORT_MODE, null, url)
}
return workDataOf("mode" to mode, "service_id" to serviceId, "url" to url)
}
companion object {
private const val CHANNEL_URL_MODE = 0
private const val INPUT_STREAM_MODE = 1
private const val PREVIOUS_EXPORT_MODE = 2
fun fromData(data: Data): SubscriptionImportInput {
val mode = data.getInt("mode", PREVIOUS_EXPORT_MODE)
when (mode) {
CHANNEL_URL_MODE -> {
val serviceId = data.getInt("service_id", -1)
if (serviceId == -1) {
throw IllegalArgumentException("No service id provided")
}
val url = data.getString("url")!!
return ChannelUrlMode(serviceId, url)
}
INPUT_STREAM_MODE -> {
val serviceId = data.getInt("service_id", -1)
if (serviceId == -1) {
throw IllegalArgumentException("No service id provided")
}
val url = data.getString("url")!!
return InputStreamMode(serviceId, url)
}
PREVIOUS_EXPORT_MODE -> {
val url = data.getString("url")!!
return PreviousExportMode(url)
}
else -> throw IllegalArgumentException("Unknown mode: $mode")
}
}
}
}

View File

@ -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;

View File

@ -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));
}

View File

@ -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

View File

@ -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);

View File

@ -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<PlayerUi> 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 <T> the class type parameter
*/
public <T> void destroyAll(final Class<T> 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 <T> the class type parameter
* @return the first player UI of the required type found in the list, or an empty {@link
* Optional} otherwise
*/
public <T> Optional<T> get(final Class<T> 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<PlayerUi> consumer) {
//noinspection SimplifyStreamApiCallChains
playerUis.stream().forEachOrdered(consumer);
}
}

View File

@ -0,0 +1,124 @@
package org.schabi.newpipe.player.ui
import org.schabi.newpipe.util.GuardedByMutex
import java.util.Optional
class PlayerUiList(vararg initialPlayerUis: PlayerUi) {
var playerUis = GuardedByMutex(mutableListOf<PlayerUi>())
/**
* 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.runWithLockSync {
lockData.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.runWithLockSync {
lockData.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 </T>
* */
fun <T> destroyAll(playerUiType: Class<T?>) {
val toDestroy = mutableListOf<PlayerUi>()
// short blocking removal from class to prevent interfering from other threads
playerUis.runWithLockSync {
val new = mutableListOf<PlayerUi>()
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()
}
}
/**
* @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 null
</T> */
fun <T> get(playerUiType: Class<T>): 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
}
/**
* @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
</T> */
@Deprecated("use get", ReplaceWith("get(playerUiType)"))
fun <T> getOpt(playerUiType: Class<T>): Optional<T & Any> =
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
*/
fun call(consumer: java.util.function.Consumer<PlayerUi>) {
// 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)
}
}
}

View File

@ -99,10 +99,12 @@ fun Comment(comment: CommentsInfoItem, onCommentAuthorOpened: () -> Unit) {
}
val nameAndDate = remember(comment) {
val date = Localization.relativeTimeOrTextual(
context, comment.uploadDate, comment.textualUploadDate
Localization.concatenateStrings(
Localization.localizeUserName(comment.uploaderName),
Localization.relativeTimeOrTextual(
context, comment.uploadDate, comment.textualUploadDate
)
)
Localization.concatenateStrings(comment.uploaderName, date)
}
Text(
text = nameAndDate,

View File

@ -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<T>(
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 <Y> runWithLockSync(
action: MutexData<T>.() -> 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 <Y> runWithLock(action: MutexData<T>.() -> 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<T>(data: T, val setFun: (T) -> Unit) {
/** The data inside this [GuardedByMutex] */
var lockData: T = data
set(t) {
setFun(t)
field = t
}
}

View File

@ -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 <code>@foobar</code>.
*
* Will correctly handle right-to-left usernames by using a {@link BidiFormatter}.
*
* @param plainName username, with an optional leading <code>@</code>
* @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

View File

@ -864,4 +864,16 @@
<item quantity="one">%d comment</item>
<item quantity="other">%d comments</item>
</plurals>
<plurals name="export_subscriptions">
<item quantity="one">Exporting %d subscription…</item>
<item quantity="other">Exporting %d subscriptions…</item>
</plurals>
<plurals name="load_subscriptions">
<item quantity="one">Loading %d subscription…</item>
<item quantity="other">Loading %d subscriptions…</item>
</plurals>
<plurals name="import_subscriptions">
<item quantity="one">Importing %d subscription…</item>
<item quantity="other">Importing %d subscriptions…</item>
</plurals>
</resources>

View File

@ -5,11 +5,11 @@ import static org.junit.Assert.fail;
import org.junit.Test;
import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor;
import org.schabi.newpipe.extractor.subscription.SubscriptionItem;
import org.schabi.newpipe.local.subscription.workers.ImportExportJsonHelper;
import org.schabi.newpipe.local.subscription.workers.SubscriptionItem;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.List;
@ -23,26 +23,22 @@ public class ImportExportJsonHelperTest {
final String emptySource =
"{\"app_version\":\"0.11.6\",\"app_version_int\": 47,\"subscriptions\":[]}";
final List<SubscriptionItem> items = ImportExportJsonHelper.readFrom(
new ByteArrayInputStream(emptySource.getBytes(StandardCharsets.UTF_8)), null);
final var items = ImportExportJsonHelper.readFrom(
new ByteArrayInputStream(emptySource.getBytes(StandardCharsets.UTF_8)));
assertTrue(items.isEmpty());
}
@Test
public void testInvalidSource() {
final List<String> invalidList = Arrays.asList(
"{}",
"",
null,
"gibberish");
final var invalidList = Arrays.asList("{}", "", null, "gibberish");
for (final String invalidContent : invalidList) {
try {
if (invalidContent != null) {
final byte[] bytes = invalidContent.getBytes(StandardCharsets.UTF_8);
ImportExportJsonHelper.readFrom((new ByteArrayInputStream(bytes)), null);
ImportExportJsonHelper.readFrom(new ByteArrayInputStream(bytes));
} else {
ImportExportJsonHelper.readFrom(null, null);
ImportExportJsonHelper.readFrom(null);
}
fail("didn't throw exception");
@ -58,38 +54,24 @@ public class ImportExportJsonHelperTest {
@Test
public void ultimateTest() throws Exception {
// Read from file
final List<SubscriptionItem> itemsFromFile = readFromFile();
final var itemsFromFile = readFromFile();
// Test writing to an output
final String jsonOut = testWriteTo(itemsFromFile);
// Read again
final List<SubscriptionItem> itemsSecondRead = readFromWriteTo(jsonOut);
final var itemsSecondRead = readFromWriteTo(jsonOut);
// Check if both lists have the exact same items
if (itemsFromFile.size() != itemsSecondRead.size()) {
if (!itemsFromFile.equals(itemsSecondRead)) {
fail("The list of items were different from each other");
}
for (int i = 0; i < itemsFromFile.size(); i++) {
final SubscriptionItem item1 = itemsFromFile.get(i);
final SubscriptionItem item2 = itemsSecondRead.get(i);
final boolean equals = item1.getServiceId() == item2.getServiceId()
&& item1.getUrl().equals(item2.getUrl())
&& item1.getName().equals(item2.getName());
if (!equals) {
fail("The list of items were different from each other");
}
}
}
private List<SubscriptionItem> readFromFile() throws Exception {
final InputStream inputStream = getClass().getClassLoader().getResourceAsStream(
"import_export_test.json");
final List<SubscriptionItem> itemsFromFile = ImportExportJsonHelper.readFrom(
inputStream, null);
final var inputStream = getClass().getClassLoader()
.getResourceAsStream("import_export_test.json");
final var itemsFromFile = ImportExportJsonHelper.readFrom(inputStream);
if (itemsFromFile.isEmpty()) {
fail("ImportExportJsonHelper.readFrom(input) returned a null or empty list");
@ -98,10 +80,10 @@ public class ImportExportJsonHelperTest {
return itemsFromFile;
}
private String testWriteTo(final List<SubscriptionItem> itemsFromFile) throws Exception {
final ByteArrayOutputStream out = new ByteArrayOutputStream();
ImportExportJsonHelper.writeTo(itemsFromFile, out, null);
final String jsonOut = out.toString("UTF-8");
private String testWriteTo(final List<SubscriptionItem> itemsFromFile) {
final var out = new ByteArrayOutputStream();
ImportExportJsonHelper.writeTo(itemsFromFile, out);
final String jsonOut = out.toString(StandardCharsets.UTF_8);
if (jsonOut.isEmpty()) {
fail("JSON returned by writeTo was empty");
@ -111,10 +93,8 @@ public class ImportExportJsonHelperTest {
}
private List<SubscriptionItem> readFromWriteTo(final String jsonOut) throws Exception {
final ByteArrayInputStream inputStream = new ByteArrayInputStream(
jsonOut.getBytes(StandardCharsets.UTF_8));
final List<SubscriptionItem> secondReadItems = ImportExportJsonHelper.readFrom(
inputStream, null);
final var inputStream = new ByteArrayInputStream(jsonOut.getBytes(StandardCharsets.UTF_8));
final var secondReadItems = ImportExportJsonHelper.readFrom(inputStream);
if (secondReadItems.isEmpty()) {
fail("second call to readFrom returned an empty list");

View File

@ -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

View File

@ -0,0 +1 @@
تم إصلاح YouTube الذي لا يقوم بتشغيل أي دفق

View File

@ -0,0 +1 @@
YouTube-un heç bir yayım oynatmaması düzəldildi

View File

@ -0,0 +1 @@
Opraveno nepřehrávání jakéhokoli streamu ve službě YouTube

View File

@ -0,0 +1 @@
Behoben, dass YouTube keinen Stream abspielte

View File

@ -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

View File

@ -0,0 +1 @@
Arreglo en YouTube no reproduciendo flujos

View File

@ -0,0 +1 @@
مشکل عدم نمایش پخش‌زنده برطرف شد

View File

@ -0,0 +1 @@
Correction de YouTube qui ne lisait aucun média

View File

@ -0,0 +1 @@
תוקנה התקלה ש־YouTube לא מנגן אף תזרים

View File

@ -0,0 +1 @@
फिक्स्ड YouTube कोई स्ट्रीम नहीं चला रहा है

View File

@ -0,0 +1 @@
Immáron minden YouTube videó lejátszásra kerül

View File

@ -0,0 +1 @@
Memperbaiki YouTube yang tidak memutar streaming apa pun

View File

@ -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

View File

@ -0,0 +1 @@
გაასწორა YouTube არ უკრავს არცერთ ნაკადს

View File

@ -0,0 +1 @@
YouTube에서 스트림을 재생하지 않는 문제 수정

View File

@ -0,0 +1 @@
YouTube speelt geen stream af opgelost

View File

@ -0,0 +1 @@
ਸਥਿਰ YouTube ਕੋਈ ਸਟ੍ਰੀਮ ਨਹੀਂ ਚਲਾ ਰਿਹਾ

View File

@ -0,0 +1 @@
Corrigido YouTube não reproduzir qualquer transmissão

View File

@ -0,0 +1 @@
Corrigido YouTube não reproduzir nenhuma transmissão

View File

@ -0,0 +1 @@
Corrigido YouTube não reproduzir nenhuma transmissão

View File

@ -0,0 +1 @@
Исправлено: YouTube не воспроизводил никакие потоки

View File

@ -0,0 +1 @@
Fixed YouTube not playing any stream

View File

@ -0,0 +1 @@
Åtgärdat att YouTube inte spelar någon stream

View File

@ -0,0 +1 @@
நிலையான யூடியூப் எந்த ச்ட்ரீமையும் இயக்கவில்லை

View File

@ -0,0 +1 @@
YouTube'un herhangi bir akışı oynatmaması düzeltildi

View File

@ -0,0 +1 @@
Виправлено проблему невідтворюваності трансляцій YouTube

View File

@ -0,0 +1 @@
Đã sửa lỗi YouTube không phát bất kỳ luồng nào

View File

@ -0,0 +1 @@
修复YouTube无法播放任何视频

View File

@ -0,0 +1 @@
修正 YouTube 無法播放任何串流

View File

@ -0,0 +1 @@
修正咗 YouTube 乜嘢實況串流都播唔到嘅問題

View File

@ -24,11 +24,11 @@ jsoup = "1.17.2"
junit = "4.13.2"
kotlin = "2.0.21"
kotlinxCoroutinesRx3 = "1.8.1"
kotlinxSerializationJson = "1.7.3"
ktlint = "0.45.2"
lazycolumnscrollbar = "2.2.0"
leakcanary = "2.12"
lifecycle = "2.6.2"
localbroadcastmanager = "1.1.0"
markwon = "4.6.2"
material = "1.11.0"
media = "1.7.0"
@ -54,9 +54,13 @@ swiperefreshlayout = "1.1.0"
# This works thanks to JitPack: https://jitpack.io/
teamnewpipe-filepicker = "5.0.0"
teamnewpipe-nanojson = "1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751"
teamnewpipe-newpipe-extractor = "67f3301d9"
# WORKAROUND: if you get errors with the NewPipeExtractor dependency, replace `v0.24.3` with
# the corresponding commit hash, since JitPack sometimes deletes artifacts.
# If theres already a git hash, just add more of it to the end (or remove a letter)
# to cause jitpack to regenerate the artifact.
teamnewpipe-newpipe-extractor = "v0.24.6"
webkit = "1.9.0"
work = "2.8.1"
work = "2.10.0"
[plugins]
aboutlibraries = { id = "com.mikepenz.aboutlibraries.plugin" }
@ -67,6 +71,7 @@ kotlin-android = { id = "kotlin-android" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-kapt = { id = "kotlin-kapt" }
kotlin-parcelize = { id = "kotlin-parcelize" }
kotlinx-serialization = { id = "kotlinx-serialization" }
sonarqube = { id = "org.sonarqube", version.ref = "sonarqube" }
[libraries]
@ -94,7 +99,6 @@ androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "a
androidx-lifecycle-livedata = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose" }
androidx-localbroadcastmanager = { group = "androidx.localbroadcastmanager", name = "localbroadcastmanager", version.ref = "localbroadcastmanager" }
androidx-material = { group = "com.google.android.material", name = "material", version.ref = "material" }
androidx-media = { group = "androidx.media", name = "media", version.ref = "media" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
@ -132,6 +136,8 @@ junit = { group = "junit", name = "junit", version.ref = "junit" }
kotlin-gradle-plugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" }
kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlin" }
kotlinx-coroutines-rx3 = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-rx3", version.ref = "kotlinxCoroutinesRx3" }
kotlinx-serialization = { group = "org.jetbrains.kotlin", name = "kotlin-serialization", version.ref = "kotlin" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
lazycolumnscrollbar = { group = "com.github.nanihadesuka", name = "LazyColumnScrollbar", version.ref = "lazycolumnscrollbar" }
leakcanary-android-core = { module = "com.squareup.leakcanary:leakcanary-android-core", version.ref = "leakcanary" }
leakcanary-object-watcher = { group = "com.squareup.leakcanary", name = "leakcanary-object-watcher-android", version.ref = "leakcanary" }