diff --git a/app/build.gradle b/app/build.gradle
index 02146c5f8..ec7bc3776 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -28,9 +28,9 @@ android {
if (System.properties.containsKey('versionCodeOverride')) {
versionCode System.getProperty('versionCodeOverride') as Integer
} else {
- versionCode 1004
+ versionCode 1005
}
- versionName "0.27.7"
+ versionName "0.28.0"
if (System.properties.containsKey('versionNameSuffix')) {
versionNameSuffix System.getProperty('versionNameSuffix')
}
diff --git a/app/src/androidTest/java/org/schabi/newpipe/error/ErrorInfoTest.java b/app/src/androidTest/java/org/schabi/newpipe/error/ErrorInfoTest.java
index 891824a55..892d1df0f 100644
--- a/app/src/androidTest/java/org/schabi/newpipe/error/ErrorInfoTest.java
+++ b/app/src/androidTest/java/org/schabi/newpipe/error/ErrorInfoTest.java
@@ -12,6 +12,7 @@ import org.schabi.newpipe.extractor.ServiceList;
import org.schabi.newpipe.extractor.exceptions.ParsingException;
import java.util.Arrays;
+import java.util.Objects;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
@@ -23,8 +24,23 @@ import static org.junit.Assert.assertTrue;
@LargeTest
public class ErrorInfoTest {
+ /**
+ * @param errorInfo the error info to access
+ * @return the private field errorInfo.message.stringRes using reflection
+ */
+ private int getMessageFromErrorInfo(final ErrorInfo errorInfo)
+ throws NoSuchFieldException, IllegalAccessException {
+ final var message = ErrorInfo.class.getDeclaredField("message");
+ message.setAccessible(true);
+ final var messageValue = (ErrorInfo.Companion.ErrorMessage) message.get(errorInfo);
+
+ final var stringRes = ErrorInfo.Companion.ErrorMessage.class.getDeclaredField("stringRes");
+ stringRes.setAccessible(true);
+ return (int) Objects.requireNonNull(stringRes.get(messageValue));
+ }
+
@Test
- public void errorInfoTestParcelable() {
+ public void errorInfoTestParcelable() throws NoSuchFieldException, IllegalAccessException {
final ErrorInfo info = new ErrorInfo(new ParsingException("Hello"),
UserAction.USER_REPORT, "request", ServiceList.YouTube.getServiceId());
// Obtain a Parcel object and write the parcelable object to it:
@@ -39,7 +55,7 @@ public class ErrorInfoTest {
assertEquals(ServiceList.YouTube.getServiceInfo().getName(),
infoFromParcel.getServiceName());
assertEquals("request", infoFromParcel.getRequest());
- assertEquals(R.string.parsing_error, infoFromParcel.getMessageStringId());
+ assertEquals(R.string.parsing_error, getMessageFromErrorInfo(infoFromParcel));
parcel.recycle();
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 0ac368898..c788385e5 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -435,6 +435,7 @@
diff --git a/app/src/main/java/org/schabi/newpipe/DownloaderImpl.java b/app/src/main/java/org/schabi/newpipe/DownloaderImpl.java
index 79c390063..41f81f136 100644
--- a/app/src/main/java/org/schabi/newpipe/DownloaderImpl.java
+++ b/app/src/main/java/org/schabi/newpipe/DownloaderImpl.java
@@ -29,7 +29,7 @@ import okhttp3.ResponseBody;
public final class DownloaderImpl extends Downloader {
public static final String USER_AGENT =
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0";
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:140.0) Gecko/20100101 Firefox/140.0";
public static final String YOUTUBE_RESTRICTED_MODE_COOKIE_KEY =
"youtube_restricted_mode_key";
public static final String YOUTUBE_RESTRICTED_MODE_COOKIE = "PREF=f2=8000000";
diff --git a/app/src/main/java/org/schabi/newpipe/MainActivity.java b/app/src/main/java/org/schabi/newpipe/MainActivity.java
index aafa0cc9f..1950f7160 100644
--- a/app/src/main/java/org/schabi/newpipe/MainActivity.java
+++ b/app/src/main/java/org/schabi/newpipe/MainActivity.java
@@ -75,8 +75,8 @@ import org.schabi.newpipe.player.Player;
import org.schabi.newpipe.player.event.OnKeyDownListener;
import org.schabi.newpipe.player.helper.PlayerHolder;
import org.schabi.newpipe.player.playqueue.PlayQueue;
-import org.schabi.newpipe.settings.SettingMigrations;
import org.schabi.newpipe.settings.UpdateSettingsFragment;
+import org.schabi.newpipe.settings.migration.MigrationManager;
import org.schabi.newpipe.util.Constants;
import org.schabi.newpipe.util.DeviceUtils;
import org.schabi.newpipe.util.KioskTranslator;
@@ -192,7 +192,7 @@ public class MainActivity extends AppCompatActivity {
UpdateSettingsFragment.askForConsentToUpdateChecks(this);
}
- SettingMigrations.showUserInfoIfPresent(this);
+ MigrationManager.showUserInfoIfPresent(this);
}
@Override
@@ -260,19 +260,6 @@ public class MainActivity extends AppCompatActivity {
*/
private void addDrawerMenuForCurrentService() throws ExtractionException {
//Tabs
- final int currentServiceId = ServiceHelper.getSelectedServiceId(this);
- final StreamingService service = NewPipe.getService(currentServiceId);
-
- int kioskMenuItemId = 0;
-
- for (final String ks : service.getKioskList().getAvailableKiosks()) {
- drawerLayoutBinding.navigation.getMenu()
- .add(R.id.menu_tabs_group, kioskMenuItemId, 0, KioskTranslator
- .getTranslatedKioskName(ks, this))
- .setIcon(KioskTranslator.getKioskIcon(ks));
- kioskMenuItemId++;
- }
-
drawerLayoutBinding.navigation.getMenu()
.add(R.id.menu_tabs_group, ITEM_ID_SUBSCRIPTIONS, ORDER,
R.string.tab_subscriptions)
@@ -290,6 +277,20 @@ public class MainActivity extends AppCompatActivity {
.add(R.id.menu_tabs_group, ITEM_ID_HISTORY, ORDER, R.string.action_history)
.setIcon(R.drawable.ic_history);
+ //Kiosks
+ final int currentServiceId = ServiceHelper.getSelectedServiceId(this);
+ final StreamingService service = NewPipe.getService(currentServiceId);
+
+ int kioskMenuItemId = 0;
+
+ for (final String ks : service.getKioskList().getAvailableKiosks()) {
+ drawerLayoutBinding.navigation.getMenu()
+ .add(R.id.menu_kiosks_group, kioskMenuItemId, 0, KioskTranslator
+ .getTranslatedKioskName(ks, this))
+ .setIcon(KioskTranslator.getKioskIcon(ks));
+ kioskMenuItemId++;
+ }
+
//Settings and About
drawerLayoutBinding.navigation.getMenu()
.add(R.id.menu_options_about_group, ITEM_ID_SETTINGS, ORDER, R.string.settings)
@@ -309,10 +310,13 @@ public class MainActivity extends AppCompatActivity {
changeService(item);
break;
case R.id.menu_tabs_group:
+ tabSelected(item);
+ break;
+ case R.id.menu_kiosks_group:
try {
- tabSelected(item);
+ kioskSelected(item);
} catch (final Exception e) {
- ErrorUtil.showUiErrorSnackbar(this, "Selecting main page tab", e);
+ ErrorUtil.showUiErrorSnackbar(this, "Selecting drawer kiosk", e);
}
break;
case R.id.menu_options_about_group:
@@ -336,7 +340,7 @@ public class MainActivity extends AppCompatActivity {
.setChecked(true);
}
- private void tabSelected(final MenuItem item) throws ExtractionException {
+ private void tabSelected(final MenuItem item) {
switch (item.getItemId()) {
case ITEM_ID_SUBSCRIPTIONS:
NavigationHelper.openSubscriptionFragment(getSupportFragmentManager());
@@ -353,18 +357,19 @@ public class MainActivity extends AppCompatActivity {
case ITEM_ID_HISTORY:
NavigationHelper.openStatisticFragment(getSupportFragmentManager());
break;
- default:
- final StreamingService currentService = ServiceHelper.getSelectedService(this);
- int kioskMenuItemId = 0;
- for (final String kioskId : currentService.getKioskList().getAvailableKiosks()) {
- if (kioskMenuItemId == item.getItemId()) {
- NavigationHelper.openKioskFragment(getSupportFragmentManager(),
- currentService.getServiceId(), kioskId);
- break;
- }
- kioskMenuItemId++;
- }
+ }
+ }
+
+ private void kioskSelected(final MenuItem item) throws ExtractionException {
+ final StreamingService currentService = ServiceHelper.getSelectedService(this);
+ int kioskMenuItemId = 0;
+ for (final String kioskId : currentService.getKioskList().getAvailableKiosks()) {
+ if (kioskMenuItemId == item.getItemId()) {
+ NavigationHelper.openKioskFragment(getSupportFragmentManager(),
+ currentService.getServiceId(), kioskId);
break;
+ }
+ kioskMenuItemId++;
}
}
@@ -405,6 +410,7 @@ public class MainActivity extends AppCompatActivity {
drawerLayoutBinding.navigation.getMenu().removeGroup(R.id.menu_services_group);
drawerLayoutBinding.navigation.getMenu().removeGroup(R.id.menu_tabs_group);
+ drawerLayoutBinding.navigation.getMenu().removeGroup(R.id.menu_kiosks_group);
drawerLayoutBinding.navigation.getMenu().removeGroup(R.id.menu_options_about_group);
// Show up or down arrow
diff --git a/app/src/main/java/org/schabi/newpipe/RouterActivity.java b/app/src/main/java/org/schabi/newpipe/RouterActivity.java
index d434c21ee..3933ade92 100644
--- a/app/src/main/java/org/schabi/newpipe/RouterActivity.java
+++ b/app/src/main/java/org/schabi/newpipe/RouterActivity.java
@@ -58,20 +58,10 @@ import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.extractor.StreamingService.LinkType;
import org.schabi.newpipe.extractor.channel.ChannelInfo;
-import org.schabi.newpipe.extractor.exceptions.AgeRestrictedContentException;
-import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException;
-import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException;
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
-import org.schabi.newpipe.extractor.exceptions.GeographicRestrictionException;
-import org.schabi.newpipe.extractor.exceptions.PaidContentException;
-import org.schabi.newpipe.extractor.exceptions.PrivateContentException;
-import org.schabi.newpipe.extractor.exceptions.ReCaptchaException;
-import org.schabi.newpipe.extractor.exceptions.SoundCloudGoPlusContentException;
-import org.schabi.newpipe.extractor.exceptions.YoutubeMusicPremiumContentException;
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler;
import org.schabi.newpipe.extractor.playlist.PlaylistInfo;
import org.schabi.newpipe.extractor.stream.StreamInfo;
-import org.schabi.newpipe.ktx.ExceptionUtils;
import org.schabi.newpipe.local.dialog.PlaylistDialog;
import org.schabi.newpipe.player.PlayerType;
import org.schabi.newpipe.player.helper.PlayerHelper;
@@ -260,7 +250,8 @@ public class RouterActivity extends AppCompatActivity {
showUnsupportedUrlDialog(url);
}
}, throwable -> handleError(this, new ErrorInfo(throwable,
- UserAction.SHARE_TO_NEWPIPE, "Getting service from url: " + url))));
+ UserAction.SHARE_TO_NEWPIPE, "Getting service from url: " + url,
+ null, url))));
}
/**
@@ -269,40 +260,19 @@ public class RouterActivity extends AppCompatActivity {
* @param errorInfo the error information
*/
private static void handleError(final Context context, final ErrorInfo errorInfo) {
- if (errorInfo.getThrowable() != null) {
- errorInfo.getThrowable().printStackTrace();
- }
-
- if (errorInfo.getThrowable() instanceof ReCaptchaException) {
+ if (errorInfo.getRecaptchaUrl() != null) {
Toast.makeText(context, R.string.recaptcha_request_toast, Toast.LENGTH_LONG).show();
// Starting ReCaptcha Challenge Activity
final Intent intent = new Intent(context, ReCaptchaActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ intent.putExtra(ReCaptchaActivity.RECAPTCHA_URL_EXTRA, errorInfo.getRecaptchaUrl());
context.startActivity(intent);
- } else if (errorInfo.getThrowable() != null
- && ExceptionUtils.isNetworkRelated(errorInfo.getThrowable())) {
- Toast.makeText(context, R.string.network_error, Toast.LENGTH_LONG).show();
- } else if (errorInfo.getThrowable() instanceof AgeRestrictedContentException) {
- Toast.makeText(context, R.string.restricted_video_no_stream,
- Toast.LENGTH_LONG).show();
- } else if (errorInfo.getThrowable() instanceof GeographicRestrictionException) {
- Toast.makeText(context, R.string.georestricted_content, Toast.LENGTH_LONG).show();
- } else if (errorInfo.getThrowable() instanceof PaidContentException) {
- Toast.makeText(context, R.string.paid_content, Toast.LENGTH_LONG).show();
- } else if (errorInfo.getThrowable() instanceof PrivateContentException) {
- Toast.makeText(context, R.string.private_content, Toast.LENGTH_LONG).show();
- } else if (errorInfo.getThrowable() instanceof SoundCloudGoPlusContentException) {
- Toast.makeText(context, R.string.soundcloud_go_plus_content,
- Toast.LENGTH_LONG).show();
- } else if (errorInfo.getThrowable() instanceof YoutubeMusicPremiumContentException) {
- Toast.makeText(context, R.string.youtube_music_premium_content,
- Toast.LENGTH_LONG).show();
- } else if (errorInfo.getThrowable() instanceof ContentNotAvailableException) {
- Toast.makeText(context, R.string.content_not_available, Toast.LENGTH_LONG).show();
- } else if (errorInfo.getThrowable() instanceof ContentNotSupportedException) {
- Toast.makeText(context, R.string.content_not_supported, Toast.LENGTH_LONG).show();
- } else {
+ } else if (errorInfo.isReportable()) {
ErrorUtil.createNotification(context, errorInfo);
+ } else {
+ // this exception does not usually indicate a problem that should be reported,
+ // so just show a toast instead of the notification
+ Toast.makeText(context, errorInfo.getMessage(context), Toast.LENGTH_LONG).show();
}
if (context instanceof RouterActivity) {
@@ -665,7 +635,8 @@ public class RouterActivity extends AppCompatActivity {
startActivity(intent);
finish();
}, throwable -> handleError(this, new ErrorInfo(throwable,
- UserAction.SHARE_TO_NEWPIPE, "Starting info activity: " + currentUrl)))
+ UserAction.SHARE_TO_NEWPIPE, "Starting info activity: " + currentUrl,
+ null, currentUrl)))
);
return;
}
@@ -852,10 +823,10 @@ public class RouterActivity extends AppCompatActivity {
})
)),
throwable -> runOnVisible(ctx -> handleError(ctx, new ErrorInfo(
- throwable,
- UserAction.REQUESTED_STREAM,
+ throwable, UserAction.REQUESTED_STREAM,
"Tried to add " + currentUrl + " to a playlist",
- ((RouterActivity) ctx).currentService.getServiceId())
+ ((RouterActivity) ctx).currentService.getServiceId(),
+ currentUrl)
))
)
);
@@ -995,7 +966,7 @@ public class RouterActivity extends AppCompatActivity {
}
}, throwable -> handleError(this, new ErrorInfo(throwable, finalUserAction,
choice.url + " opened with " + choice.playerChoice,
- choice.serviceId)));
+ choice.serviceId, choice.url)));
}
}
diff --git a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java
index 003aa5893..0857fa339 100644
--- a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java
+++ b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java
@@ -389,8 +389,7 @@ public class DownloadDialog extends DialogFragment
}
}, throwable -> ErrorUtil.showSnackbar(context,
new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG,
- "Downloading video stream size",
- currentInfo.getServiceId()))));
+ "Downloading video stream size", currentInfo))));
disposables.add(StreamInfoWrapper.fetchMoreInfoForWrapper(getWrappedAudioStreams())
.subscribe(result -> {
if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()
@@ -399,8 +398,7 @@ public class DownloadDialog extends DialogFragment
}
}, throwable -> ErrorUtil.showSnackbar(context,
new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG,
- "Downloading audio stream size",
- currentInfo.getServiceId()))));
+ "Downloading audio stream size", currentInfo))));
disposables.add(StreamInfoWrapper.fetchMoreInfoForWrapper(wrappedSubtitleStreams)
.subscribe(result -> {
if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()
@@ -409,8 +407,7 @@ public class DownloadDialog extends DialogFragment
}
}, throwable -> ErrorUtil.showSnackbar(context,
new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG,
- "Downloading subtitle stream size",
- currentInfo.getServiceId()))));
+ "Downloading subtitle stream size", currentInfo))));
}
private void setupAudioTrackSpinner() {
diff --git a/app/src/main/java/org/schabi/newpipe/error/AcraReportSender.java b/app/src/main/java/org/schabi/newpipe/error/AcraReportSender.java
index 4d9966364..90d8f4797 100644
--- a/app/src/main/java/org/schabi/newpipe/error/AcraReportSender.java
+++ b/app/src/main/java/org/schabi/newpipe/error/AcraReportSender.java
@@ -36,8 +36,8 @@ public class AcraReportSender implements ReportSender {
ErrorUtil.openActivity(context, new ErrorInfo(
new String[]{report.getString(ReportField.STACK_TRACE)},
UserAction.UI_ERROR,
- ErrorInfo.SERVICE_NONE,
"ACRA report",
+ null,
R.string.app_ui_crash));
}
}
diff --git a/app/src/main/java/org/schabi/newpipe/error/ErrorActivity.java b/app/src/main/java/org/schabi/newpipe/error/ErrorActivity.java
index a07b9b0b5..160dcca4d 100644
--- a/app/src/main/java/org/schabi/newpipe/error/ErrorActivity.java
+++ b/app/src/main/java/org/schabi/newpipe/error/ErrorActivity.java
@@ -115,7 +115,7 @@ public class ErrorActivity extends AppCompatActivity {
// normal bugreport
buildInfo(errorInfo);
- activityErrorBinding.errorMessageView.setText(errorInfo.getMessageStringId());
+ activityErrorBinding.errorMessageView.setText(errorInfo.getMessage(this));
activityErrorBinding.errorView.setText(formErrorText(errorInfo.getStackTraces()));
// print stack trace once again for debugging:
diff --git a/app/src/main/java/org/schabi/newpipe/error/ErrorInfo.kt b/app/src/main/java/org/schabi/newpipe/error/ErrorInfo.kt
index 6d8c1bd63..609fbb336 100644
--- a/app/src/main/java/org/schabi/newpipe/error/ErrorInfo.kt
+++ b/app/src/main/java/org/schabi/newpipe/error/ErrorInfo.kt
@@ -1,115 +1,304 @@
package org.schabi.newpipe.error
+import android.content.Context
import android.os.Parcelable
import androidx.annotation.StringRes
+import androidx.core.content.ContextCompat
import com.google.android.exoplayer2.ExoPlaybackException
-import kotlinx.parcelize.IgnoredOnParcel
+import com.google.android.exoplayer2.upstream.HttpDataSource
+import com.google.android.exoplayer2.upstream.Loader
import kotlinx.parcelize.Parcelize
import org.schabi.newpipe.R
import org.schabi.newpipe.extractor.Info
+import org.schabi.newpipe.extractor.ServiceList
+import org.schabi.newpipe.extractor.ServiceList.YouTube
import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException
+import org.schabi.newpipe.extractor.exceptions.AgeRestrictedContentException
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException
import org.schabi.newpipe.extractor.exceptions.ExtractionException
+import org.schabi.newpipe.extractor.exceptions.GeographicRestrictionException
+import org.schabi.newpipe.extractor.exceptions.PaidContentException
+import org.schabi.newpipe.extractor.exceptions.PrivateContentException
+import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
+import org.schabi.newpipe.extractor.exceptions.SignInConfirmNotBotException
+import org.schabi.newpipe.extractor.exceptions.SoundCloudGoPlusContentException
+import org.schabi.newpipe.extractor.exceptions.UnsupportedContentInCountryException
+import org.schabi.newpipe.extractor.exceptions.YoutubeMusicPremiumContentException
import org.schabi.newpipe.ktx.isNetworkRelated
-import org.schabi.newpipe.util.ServiceHelper
+import org.schabi.newpipe.player.mediasource.FailedMediaSource
+import org.schabi.newpipe.player.resolver.PlaybackResolver
+import java.net.UnknownHostException
+/**
+ * An error has occurred in the app. This class contains plain old parcelable data that can be used
+ * to report the error and to show it to the user along with correct action buttons.
+ */
@Parcelize
-class ErrorInfo(
+class ErrorInfo private constructor(
val stackTraces: Array,
val userAction: UserAction,
- val serviceName: String,
val request: String,
- val messageStringId: Int
+ val serviceId: Int?,
+ private val message: ErrorMessage,
+ /**
+ * If `true`, a report button will be shown for this error. Otherwise the error is not something
+ * that can really be reported (e.g. a network issue, or content not being available at all).
+ */
+ val isReportable: Boolean,
+ /**
+ * If `true`, the process causing this error can be retried, otherwise not.
+ */
+ val isRetryable: Boolean,
+ /**
+ * If present, indicates that the exception was a ReCaptchaException, and this is the URL
+ * provided by the service that can be used to solve the ReCaptcha challenge.
+ */
+ val recaptchaUrl: String?,
+ /**
+ * If present, this resource can alternatively be opened in browser (useful if NewPipe is
+ * badly broken).
+ */
+ val openInBrowserUrl: String?,
) : Parcelable {
- // no need to store throwable, all data for report is in other variables
- // also, the throwable might not be serializable, see TeamNewPipe/NewPipe#7302
- @IgnoredOnParcel
- var throwable: Throwable? = null
-
- private constructor(
+ @JvmOverloads
+ constructor(
throwable: Throwable,
userAction: UserAction,
- serviceName: String,
- request: String
+ request: String,
+ serviceId: Int? = null,
+ openInBrowserUrl: String? = null,
) : this(
throwableToStringList(throwable),
userAction,
- serviceName,
request,
- getMessageStringId(throwable, userAction)
- ) {
- this.throwable = throwable
- }
+ serviceId,
+ getMessage(throwable, userAction, serviceId),
+ isReportable(throwable),
+ isRetryable(throwable),
+ (throwable as? ReCaptchaException)?.url,
+ openInBrowserUrl,
+ )
- private constructor(
- throwable: List,
+ @JvmOverloads
+ constructor(
+ throwables: List,
userAction: UserAction,
- serviceName: String,
- request: String
+ request: String,
+ serviceId: Int? = null,
+ openInBrowserUrl: String? = null,
) : this(
- throwableListToStringList(throwable),
+ throwableListToStringList(throwables),
userAction,
- serviceName,
request,
- getMessageStringId(throwable.firstOrNull(), userAction)
- ) {
- this.throwable = throwable.firstOrNull()
+ serviceId,
+ getMessage(throwables.firstOrNull(), userAction, serviceId),
+ throwables.any(::isReportable),
+ throwables.isEmpty() || throwables.any(::isRetryable),
+ throwables.firstNotNullOfOrNull { it as? ReCaptchaException }?.url,
+ openInBrowserUrl,
+ )
+
+ // constructor to manually build ErrorInfo when no throwable is available
+ constructor(
+ stackTraces: Array,
+ userAction: UserAction,
+ request: String,
+ serviceId: Int?,
+ @StringRes message: Int
+ ) :
+ this(
+ stackTraces, userAction, request, serviceId, ErrorMessage(message),
+ true, false, null, null
+ )
+
+ // constructor with only one throwable to extract service id and openInBrowserUrl from an Info
+ constructor(
+ throwable: Throwable,
+ userAction: UserAction,
+ request: String,
+ info: Info?,
+ ) :
+ this(throwable, userAction, request, info?.serviceId, info?.url)
+
+ // constructor with multiple throwables to extract service id and openInBrowserUrl from an Info
+ constructor(
+ throwables: List,
+ userAction: UserAction,
+ request: String,
+ info: Info?,
+ ) :
+ this(throwables, userAction, request, info?.serviceId, info?.url)
+
+ fun getServiceName(): String {
+ return getServiceName(serviceId)
}
- // constructors with single throwable
- constructor(throwable: Throwable, userAction: UserAction, request: String) :
- this(throwable, userAction, SERVICE_NONE, request)
- constructor(throwable: Throwable, userAction: UserAction, request: String, serviceId: Int) :
- this(throwable, userAction, ServiceHelper.getNameOfServiceById(serviceId), request)
- constructor(throwable: Throwable, userAction: UserAction, request: String, info: Info?) :
- this(throwable, userAction, getInfoServiceName(info), request)
-
- // constructors with list of throwables
- constructor(throwable: List, userAction: UserAction, request: String) :
- this(throwable, userAction, SERVICE_NONE, request)
- constructor(throwable: List, userAction: UserAction, request: String, serviceId: Int) :
- this(throwable, userAction, ServiceHelper.getNameOfServiceById(serviceId), request)
- constructor(throwable: List, userAction: UserAction, request: String, info: Info?) :
- this(throwable, userAction, getInfoServiceName(info), request)
+ fun getMessage(context: Context): String {
+ return message.getString(context)
+ }
companion object {
- const val SERVICE_NONE = "none"
+ @Parcelize
+ class ErrorMessage(
+ @StringRes
+ private val stringRes: Int,
+ private vararg val formatArgs: String,
+ ) : Parcelable {
+ fun getString(context: Context): String {
+ return if (formatArgs.isEmpty()) {
+ // use ContextCompat.getString() just in case context is not AppCompatActivity
+ ContextCompat.getString(context, stringRes)
+ } else {
+ // ContextCompat.getString() with formatArgs does not exist, so we just
+ // replicate its source code but with formatArgs
+ ContextCompat.getContextForLanguage(context).getString(stringRes, *formatArgs)
+ }
+ }
+ }
+
+ const val SERVICE_NONE = ""
+
+ private fun getServiceName(serviceId: Int?) =
+ // not using getNameOfServiceById since we want to accept a nullable serviceId and we
+ // want to default to SERVICE_NONE
+ ServiceList.all()?.firstOrNull { it.serviceId == serviceId }?.serviceInfo?.name
+ ?: SERVICE_NONE
fun throwableToStringList(throwable: Throwable) = arrayOf(throwable.stackTraceToString())
fun throwableListToStringList(throwableList: List) =
throwableList.map { it.stackTraceToString() }.toTypedArray()
- private fun getInfoServiceName(info: Info?) =
- if (info == null) SERVICE_NONE else ServiceHelper.getNameOfServiceById(info.serviceId)
-
- @StringRes
- private fun getMessageStringId(
+ fun getMessage(
throwable: Throwable?,
- action: UserAction
- ): Int {
+ action: UserAction?,
+ serviceId: Int?,
+ ): ErrorMessage {
return when {
- throwable is AccountTerminatedException -> R.string.account_terminated
- throwable is ContentNotAvailableException -> R.string.content_not_available
- throwable != null && throwable.isNetworkRelated -> R.string.network_error
- throwable is ContentNotSupportedException -> R.string.content_not_supported
- throwable is ExtractionException -> R.string.parsing_error
+ // player exceptions
+ // some may be IOException, so do these checks before isNetworkRelated!
throwable is ExoPlaybackException -> {
- when (throwable.type) {
- ExoPlaybackException.TYPE_SOURCE -> R.string.player_stream_failure
- ExoPlaybackException.TYPE_UNEXPECTED -> R.string.player_recoverable_failure
- else -> R.string.player_unrecoverable_failure
+ val cause = throwable.cause
+ when {
+ cause is HttpDataSource.InvalidResponseCodeException -> {
+ if (cause.responseCode == 403) {
+ if (serviceId == YouTube.serviceId) {
+ ErrorMessage(R.string.youtube_player_http_403)
+ } else {
+ ErrorMessage(R.string.player_http_403)
+ }
+ } else {
+ ErrorMessage(R.string.player_http_invalid_status, cause.responseCode.toString())
+ }
+ }
+ cause is Loader.UnexpectedLoaderException && cause.cause is ExtractionException ->
+ getMessage(throwable, action, serviceId)
+ throwable.type == ExoPlaybackException.TYPE_SOURCE ->
+ ErrorMessage(R.string.player_stream_failure)
+ throwable.type == ExoPlaybackException.TYPE_UNEXPECTED ->
+ ErrorMessage(R.string.player_recoverable_failure)
+ else ->
+ ErrorMessage(R.string.player_unrecoverable_failure)
}
}
- action == UserAction.UI_ERROR -> R.string.app_ui_crash
- action == UserAction.REQUESTED_COMMENTS -> R.string.error_unable_to_load_comments
- action == UserAction.SUBSCRIPTION_CHANGE -> R.string.subscription_change_failed
- action == UserAction.SUBSCRIPTION_UPDATE -> R.string.subscription_update_failed
- action == UserAction.LOAD_IMAGE -> R.string.could_not_load_thumbnails
- action == UserAction.DOWNLOAD_OPEN_DIALOG -> R.string.could_not_setup_download_menu
- else -> R.string.general_error
+ throwable is FailedMediaSource.FailedMediaSourceException ->
+ getMessage(throwable.cause, action, serviceId)
+ throwable is PlaybackResolver.ResolverException ->
+ ErrorMessage(R.string.player_stream_failure)
+
+ // content not available exceptions
+ throwable is AccountTerminatedException ->
+ throwable.message
+ ?.takeIf { reason -> !reason.isEmpty() }
+ ?.let { reason ->
+ ErrorMessage(
+ R.string.account_terminated_service_provides_reason,
+ getServiceName(serviceId),
+ reason
+ )
+ }
+ ?: ErrorMessage(R.string.account_terminated)
+ throwable is AgeRestrictedContentException ->
+ ErrorMessage(R.string.restricted_video_no_stream)
+ throwable is GeographicRestrictionException ->
+ ErrorMessage(R.string.georestricted_content)
+ throwable is PaidContentException ->
+ ErrorMessage(R.string.paid_content)
+ throwable is PrivateContentException ->
+ ErrorMessage(R.string.private_content)
+ throwable is SoundCloudGoPlusContentException ->
+ ErrorMessage(R.string.soundcloud_go_plus_content)
+ throwable is UnsupportedContentInCountryException ->
+ ErrorMessage(R.string.unsupported_content_in_country)
+ throwable is YoutubeMusicPremiumContentException ->
+ ErrorMessage(R.string.youtube_music_premium_content)
+ throwable is SignInConfirmNotBotException ->
+ ErrorMessage(R.string.sign_in_confirm_not_bot_error, getServiceName(serviceId))
+ throwable is ContentNotAvailableException ->
+ ErrorMessage(R.string.content_not_available)
+
+ // other extractor exceptions
+ throwable is ContentNotSupportedException ->
+ ErrorMessage(R.string.content_not_supported)
+ // ReCaptchas will be handled in a special way anyway
+ throwable is ReCaptchaException ->
+ ErrorMessage(R.string.recaptcha_request_toast)
+ // test this at the end as many exceptions could be a subclass of IOException
+ throwable != null && throwable.isNetworkRelated ->
+ ErrorMessage(R.string.network_error)
+ // an extraction exception unrelated to the network
+ // is likely an issue with parsing the website
+ throwable is ExtractionException ->
+ ErrorMessage(R.string.parsing_error)
+
+ // user actions (in case the exception is null or unrecognizable)
+ action == UserAction.UI_ERROR ->
+ ErrorMessage(R.string.app_ui_crash)
+ action == UserAction.REQUESTED_COMMENTS ->
+ ErrorMessage(R.string.error_unable_to_load_comments)
+ action == UserAction.SUBSCRIPTION_CHANGE ->
+ ErrorMessage(R.string.subscription_change_failed)
+ action == UserAction.SUBSCRIPTION_UPDATE ->
+ ErrorMessage(R.string.subscription_update_failed)
+ action == UserAction.LOAD_IMAGE ->
+ ErrorMessage(R.string.could_not_load_thumbnails)
+ action == UserAction.DOWNLOAD_OPEN_DIALOG ->
+ ErrorMessage(R.string.could_not_setup_download_menu)
+ else ->
+ ErrorMessage(R.string.error_snackbar_message)
+ }
+ }
+
+ fun isReportable(throwable: Throwable?): Boolean {
+ return when (throwable) {
+ // we don't have an exception, so this is a manually built error, which likely
+ // indicates that it's important and is thus reportable
+ null -> true
+ // the service explicitly said that content is not available (e.g. age restrictions,
+ // video deleted, etc.), there is no use in letting users report it
+ is ContentNotAvailableException -> false
+ // we know the content is not supported, no need to let the user report it
+ is ContentNotSupportedException -> false
+ // happens often when there is no internet connection; we don't use
+ // `throwable.isNetworkRelated` since any `IOException` would make that function
+ // return true, but not all `IOException`s are network related
+ is UnknownHostException -> false
+ // by default, this is an unexpected exception, which the user could report
+ else -> true
+ }
+ }
+
+ fun isRetryable(throwable: Throwable?): Boolean {
+ return when (throwable) {
+ // we know the content is not available, retrying won't help
+ is ContentNotAvailableException -> false
+ // we know the content is not supported, retrying won't help
+ is ContentNotSupportedException -> false
+ // by default (including if throwable is null), enable retrying (though the retry
+ // button will be shown only if a way to perform the retry is implemented)
+ else -> true
}
}
}
diff --git a/app/src/main/java/org/schabi/newpipe/error/ErrorPanelHelper.kt b/app/src/main/java/org/schabi/newpipe/error/ErrorPanelHelper.kt
index 14ec41148..4ec5f58c3 100644
--- a/app/src/main/java/org/schabi/newpipe/error/ErrorPanelHelper.kt
+++ b/app/src/main/java/org/schabi/newpipe/error/ErrorPanelHelper.kt
@@ -2,7 +2,6 @@ package org.schabi.newpipe.error
import android.content.Context
import android.content.Intent
-import android.util.Log
import android.view.View
import android.widget.Button
import android.widget.TextView
@@ -14,21 +13,7 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.disposables.Disposable
import org.schabi.newpipe.MainActivity
import org.schabi.newpipe.R
-import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException
-import org.schabi.newpipe.extractor.exceptions.AgeRestrictedContentException
-import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
-import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException
-import org.schabi.newpipe.extractor.exceptions.GeographicRestrictionException
-import org.schabi.newpipe.extractor.exceptions.PaidContentException
-import org.schabi.newpipe.extractor.exceptions.PrivateContentException
-import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
-import org.schabi.newpipe.extractor.exceptions.SoundCloudGoPlusContentException
-import org.schabi.newpipe.extractor.exceptions.YoutubeMusicPremiumContentException
-import org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty
import org.schabi.newpipe.ktx.animate
-import org.schabi.newpipe.ktx.isInterruptedCaused
-import org.schabi.newpipe.ktx.isNetworkRelated
-import org.schabi.newpipe.util.ServiceHelper
import org.schabi.newpipe.util.external_communication.ShareUtils
import java.util.concurrent.TimeUnit
@@ -78,64 +63,32 @@ class ErrorPanelHelper(
}
fun showError(errorInfo: ErrorInfo) {
-
- if (errorInfo.throwable != null && errorInfo.throwable!!.isInterruptedCaused) {
- if (DEBUG) {
- Log.w(TAG, "onError() isInterruptedCaused! = [$errorInfo.throwable]")
- }
- return
- }
-
ensureDefaultVisibility()
+ errorTextView.text = errorInfo.getMessage(context)
- if (errorInfo.throwable is ReCaptchaException) {
- errorTextView.setText(R.string.recaptcha_request_toast)
-
- showAndSetErrorButtonAction(
- R.string.recaptcha_solve
- ) {
+ if (errorInfo.recaptchaUrl != null) {
+ showAndSetErrorButtonAction(R.string.recaptcha_solve) {
// Starting ReCaptcha Challenge Activity
val intent = Intent(context, ReCaptchaActivity::class.java)
- intent.putExtra(
- ReCaptchaActivity.RECAPTCHA_URL_EXTRA,
- (errorInfo.throwable as ReCaptchaException).url
- )
+ intent.putExtra(ReCaptchaActivity.RECAPTCHA_URL_EXTRA, errorInfo.recaptchaUrl)
fragment.startActivityForResult(intent, ReCaptchaActivity.RECAPTCHA_REQUEST)
errorActionButton.setOnClickListener(null)
}
-
- errorRetryButton.isVisible = retryShouldBeShown
- showAndSetOpenInBrowserButtonAction(errorInfo)
- } else if (errorInfo.throwable is AccountTerminatedException) {
- errorTextView.setText(R.string.account_terminated)
-
- if (!isNullOrEmpty((errorInfo.throwable as AccountTerminatedException).message)) {
- errorServiceInfoTextView.text = context.resources.getString(
- R.string.service_provides_reason,
- ServiceHelper.getSelectedService(context)?.serviceInfo?.name ?: ""
- )
- errorServiceInfoTextView.isVisible = true
-
- errorServiceExplanationTextView.text =
- (errorInfo.throwable as AccountTerminatedException).message
- errorServiceExplanationTextView.isVisible = true
- }
- } else {
- showAndSetErrorButtonAction(
- R.string.error_snackbar_action
- ) {
+ } else if (errorInfo.isReportable) {
+ showAndSetErrorButtonAction(R.string.error_snackbar_action) {
ErrorUtil.openActivity(context, errorInfo)
}
+ }
- errorTextView.setText(getExceptionDescription(errorInfo.throwable))
+ if (errorInfo.isRetryable) {
+ errorRetryButton.isVisible = retryShouldBeShown
+ }
- if (errorInfo.throwable !is ContentNotAvailableException &&
- errorInfo.throwable !is ContentNotSupportedException
- ) {
- // show retry button only for content which is not unavailable or unsupported
- errorRetryButton.isVisible = retryShouldBeShown
+ if (errorInfo.openInBrowserUrl != null) {
+ errorOpenInBrowserButton.isVisible = true
+ errorOpenInBrowserButton.setOnClickListener {
+ ShareUtils.openUrlInBrowser(context, errorInfo.openInBrowserUrl)
}
- showAndSetOpenInBrowserButtonAction(errorInfo)
}
setRootVisible()
@@ -153,15 +106,6 @@ class ErrorPanelHelper(
errorActionButton.setOnClickListener(listener)
}
- fun showAndSetOpenInBrowserButtonAction(
- errorInfo: ErrorInfo
- ) {
- errorOpenInBrowserButton.isVisible = true
- errorOpenInBrowserButton.setOnClickListener {
- ShareUtils.openUrlInBrowser(context, errorInfo.request)
- }
- }
-
fun showTextError(errorString: String) {
ensureDefaultVisibility()
@@ -192,27 +136,5 @@ class ErrorPanelHelper(
companion object {
val TAG: String = ErrorPanelHelper::class.simpleName!!
val DEBUG: Boolean = MainActivity.DEBUG
-
- @StringRes
- fun getExceptionDescription(throwable: Throwable?): Int {
- return when (throwable) {
- is AgeRestrictedContentException -> R.string.restricted_video_no_stream
- is GeographicRestrictionException -> R.string.georestricted_content
- is PaidContentException -> R.string.paid_content
- is PrivateContentException -> R.string.private_content
- is SoundCloudGoPlusContentException -> R.string.soundcloud_go_plus_content
- is YoutubeMusicPremiumContentException -> R.string.youtube_music_premium_content
- is ContentNotAvailableException -> R.string.content_not_available
- is ContentNotSupportedException -> R.string.content_not_supported
- else -> {
- // show retry button only for content which is not unavailable or unsupported
- if (throwable != null && throwable.isNetworkRelated) {
- R.string.network_error
- } else {
- R.string.error_snackbar_message
- }
- }
- }
- }
}
}
diff --git a/app/src/main/java/org/schabi/newpipe/error/ErrorUtil.kt b/app/src/main/java/org/schabi/newpipe/error/ErrorUtil.kt
index e74711b88..b358a5fd2 100644
--- a/app/src/main/java/org/schabi/newpipe/error/ErrorUtil.kt
+++ b/app/src/main/java/org/schabi/newpipe/error/ErrorUtil.kt
@@ -122,7 +122,7 @@ class ErrorUtil {
)
.setSmallIcon(R.drawable.ic_bug_report)
.setContentTitle(context.getString(R.string.error_report_notification_title))
- .setContentText(context.getString(errorInfo.messageStringId))
+ .setContentText(errorInfo.getMessage(context))
.setAutoCancel(true)
.setContentIntent(
PendingIntentCompat.getActivity(
@@ -156,10 +156,10 @@ class ErrorUtil {
// fallback to showing a notification if no root view is available
createNotification(context, errorInfo)
} else {
- Snackbar.make(rootView, R.string.error_snackbar_message, Snackbar.LENGTH_LONG)
+ Snackbar.make(rootView, errorInfo.getMessage(context), Snackbar.LENGTH_LONG)
.setActionTextColor(Color.YELLOW)
.setAction(context.getString(R.string.error_snackbar_action).uppercase()) {
- openActivity(context, errorInfo)
+ context.startActivity(getErrorActivityIntent(context, errorInfo))
}.show()
}
}
diff --git a/app/src/main/java/org/schabi/newpipe/error/UserAction.java b/app/src/main/java/org/schabi/newpipe/error/UserAction.java
index afb880a29..d3af9d32e 100644
--- a/app/src/main/java/org/schabi/newpipe/error/UserAction.java
+++ b/app/src/main/java/org/schabi/newpipe/error/UserAction.java
@@ -33,7 +33,9 @@ public enum UserAction {
SHARE_TO_NEWPIPE("share to newpipe"),
CHECK_FOR_NEW_APP_VERSION("check for new app version"),
OPEN_INFO_ITEM_DIALOG("open info item dialog"),
- GETTING_MAIN_SCREEN_TAB("getting main screen tab");
+ GETTING_MAIN_SCREEN_TAB("getting main screen tab"),
+ PLAY_ON_POPUP("play on popup"),
+ SUBSCRIPTIONS("loading subscriptions");
private final String message;
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt
index e75acedc4..cff37dd5a 100644
--- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt
+++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt
@@ -775,7 +775,7 @@ class VideoDetailFragment :
},
{ throwable ->
showError(
- ErrorInfo(throwable, UserAction.REQUESTED_STREAM, url ?: "no url", serviceId)
+ ErrorInfo(throwable, UserAction.REQUESTED_STREAM, url ?: "no url", serviceId, url)
)
}
)
@@ -1465,7 +1465,7 @@ class VideoDetailFragment :
if (!info.errors.isEmpty()) {
showSnackBarError(
- ErrorInfo(info.errors, UserAction.REQUESTED_STREAM, info.url, info)
+ ErrorInfo(info.errors, UserAction.REQUESTED_STREAM, "Some info not extracted: " + info.url, info)
)
}
}
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.java
index 7f594734a..848dfe6f5 100644
--- a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.java
@@ -153,7 +153,7 @@ public abstract class BaseListInfoFragment
showError(new ErrorInfo(throwable, errorUserAction,
- "Start loading: " + url, serviceId)));
+ "Start loading: " + url, serviceId, url)));
}
/**
@@ -184,7 +184,7 @@ public abstract class BaseListInfoFragment
dynamicallyShowErrorPanelOrSnackbar(new ErrorInfo(throwable,
- errorUserAction, "Loading more items: " + url, serviceId)));
+ errorUserAction, "Loading more items: " + url, serviceId, url)));
}
private void forbidDownwardFocusScroll() {
@@ -210,7 +210,7 @@ public abstract class BaseListInfoFragment
protected void initViews(final View rootView, final Bundle savedInstanceState) {
super.initViews(rootView, savedInstanceState);
- EmptyStateUtil.setEmptyStateComposable(
- binding.emptyStateView,
- EmptyStateSpec.Companion.getContentNotSupported()
- );
+ setEmptyStateComposable(binding.emptyStateView, EmptyStateSpec.ContentNotSupported);
tabAdapter = new TabAdapter(getChildFragmentManager());
binding.viewPager.setAdapter(tabAdapter);
@@ -583,7 +580,7 @@ public class ChannelFragment extends BaseStateFragment
isLoading.set(false);
handleResult(result);
}, throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_CHANNEL,
- url == null ? "No URL" : url, serviceId)));
+ url == null ? "No URL" : url, serviceId, url)));
}
@Override
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java
index 06f9e30c6..af1da9602 100644
--- a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java
@@ -3,6 +3,7 @@ package org.schabi.newpipe.fragments.list.search;
import static androidx.recyclerview.widget.ItemTouchHelper.Callback.makeMovementFlags;
import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
import static org.schabi.newpipe.ktx.ViewUtils.animate;
+import static org.schabi.newpipe.ui.emptystate.EmptyStateUtil.setEmptyStateComposable;
import static org.schabi.newpipe.util.ExtractorHelper.showMetaInfoInTextView;
import static java.util.Arrays.asList;
@@ -54,6 +55,7 @@ import org.schabi.newpipe.extractor.MetaInfo;
import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.Page;
import org.schabi.newpipe.extractor.StreamingService;
+import org.schabi.newpipe.extractor.exceptions.ParsingException;
import org.schabi.newpipe.extractor.search.SearchExtractor;
import org.schabi.newpipe.extractor.search.SearchInfo;
import org.schabi.newpipe.extractor.services.peertube.linkHandler.PeertubeSearchQueryHandlerFactory;
@@ -65,7 +67,6 @@ import org.schabi.newpipe.ktx.ExceptionUtils;
import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.settings.NewPipeSettings;
import org.schabi.newpipe.ui.emptystate.EmptyStateSpec;
-import org.schabi.newpipe.ui.emptystate.EmptyStateUtil;
import org.schabi.newpipe.util.Constants;
import org.schabi.newpipe.util.DeviceUtils;
import org.schabi.newpipe.util.ExtractorHelper;
@@ -356,9 +357,7 @@ public class SearchFragment extends BaseListFragment result) {
showListFooter(false);
infoListAdapter.addInfoItemList(result.getItems());
- nextPage = result.getNextPage();
- if (!result.getErrors().isEmpty() && nextPage != null) {
- showSnackBarError(new ErrorInfo(result.getErrors(), UserAction.SEARCHED,
- "\"" + searchString + "\" → pageUrl: " + nextPage.getUrl() + ", "
- + "pageIds: " + nextPage.getIds() + ", "
- + "pageCookies: " + nextPage.getCookies(),
- serviceId));
+ if (!result.getErrors().isEmpty()) {
+ // nextPage should be non-null at this point, because it refers to the page
+ // whose results are handled here, but let's check it anyway
+ if (nextPage == null) {
+ showSnackBarError(new ErrorInfo(result.getErrors(), UserAction.SEARCHED,
+ "\"" + searchString + "\" → nextPage == null", serviceId,
+ getOpenInBrowserUrlForErrors()));
+ } else {
+ showSnackBarError(new ErrorInfo(result.getErrors(), UserAction.SEARCHED,
+ "\"" + searchString + "\" → pageUrl: " + nextPage.getUrl() + ", "
+ + "pageIds: " + nextPage.getIds() + ", "
+ + "pageCookies: " + nextPage.getCookies(),
+ serviceId, getOpenInBrowserUrlForErrors()));
+ }
}
+
+ // keep the reassignment of nextPage after the error handling to ensure that nextPage
+ // still holds the correct value during the error handling
+ nextPage = result.getNextPage();
super.handleNextItems(result);
}
diff --git a/app/src/main/java/org/schabi/newpipe/local/bookmark/BookmarkFragment.java b/app/src/main/java/org/schabi/newpipe/local/bookmark/BookmarkFragment.java
index 4f60e36ae..499625332 100644
--- a/app/src/main/java/org/schabi/newpipe/local/bookmark/BookmarkFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/local/bookmark/BookmarkFragment.java
@@ -15,6 +15,7 @@ import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
+import androidx.compose.ui.platform.ComposeView;
import androidx.fragment.app.FragmentManager;
import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.RecyclerView;
@@ -125,10 +126,8 @@ public final class BookmarkFragment extends BaseLocalListFragment= Build.VERSION_CODES.Q) ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC else 0
+ setForegroundAsync(ForegroundInfo(FeedLoadService.NOTIFICATION_ID, notification, serviceType))
}
companion object {
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionsImportFragment.java b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionsImportFragment.java
index aee7c0003..fbadbb876 100644
--- a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionsImportFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionsImportFragment.java
@@ -85,8 +85,8 @@ public class SubscriptionsImportFragment extends BaseFragment {
if (supportedSources.isEmpty() && currentServiceId != Constants.NO_SERVICE_ID) {
ErrorUtil.showSnackbar(activity,
new ErrorInfo(new String[]{}, UserAction.SUBSCRIPTION_IMPORT_EXPORT,
- ServiceHelper.getNameOfServiceById(currentServiceId),
"Service does not support importing subscriptions",
+ currentServiceId,
R.string.general_error));
activity.finish();
}
diff --git a/app/src/main/java/org/schabi/newpipe/player/Player.java b/app/src/main/java/org/schabi/newpipe/player/Player.java
index b09593c17..4d6647d12 100644
--- a/app/src/main/java/org/schabi/newpipe/player/Player.java
+++ b/app/src/main/java/org/schabi/newpipe/player/Player.java
@@ -1283,7 +1283,8 @@ public final class Player implements PlaybackListener, Listener {
UserAction.PLAY_STREAM,
"Loading failed for [" + currentMetadata.getTitle()
+ "]: " + currentMetadata.getStreamUrl(),
- currentMetadata.getServiceId());
+ currentMetadata.getServiceId(),
+ currentMetadata.getStreamUrl());
ErrorUtil.createNotification(context, errorInfo);
}
@@ -1499,7 +1500,7 @@ public final class Player implements PlaybackListener, Listener {
errorInfo = new ErrorInfo(error, UserAction.PLAY_STREAM,
"Player error[type=" + error.getErrorCodeName()
+ "] occurred while playing " + currentMetadata.getStreamUrl(),
- currentMetadata.getServiceId());
+ currentMetadata.getServiceId(), currentMetadata.getStreamUrl());
}
ErrorUtil.createNotification(context, errorInfo);
}
diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java
index a110a80d6..266d65f36 100644
--- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java
+++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java
@@ -33,11 +33,9 @@ import com.google.android.exoplayer2.trackselection.ExoTrackSelection;
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout;
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout.ResizeMode;
import com.google.android.exoplayer2.ui.CaptionStyleCompat;
-import com.google.android.exoplayer2.util.MimeTypes;
import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.InfoItem;
-import org.schabi.newpipe.extractor.MediaFormat;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.extractor.stream.SubtitlesStream;
@@ -47,13 +45,14 @@ import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
import org.schabi.newpipe.util.ListHelper;
+import org.schabi.newpipe.util.Localization;
import java.lang.annotation.Retention;
import java.text.DecimalFormat;
+import java.text.DecimalFormatSymbols;
import java.text.NumberFormat;
import java.util.ArrayList;
import java.util.Collections;
-import java.util.Formatter;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
@@ -62,11 +61,7 @@ import java.util.Set;
import java.util.concurrent.TimeUnit;
public final class PlayerHelper {
- private static final StringBuilder STRING_BUILDER = new StringBuilder();
- private static final Formatter STRING_FORMATTER =
- new Formatter(STRING_BUILDER, Locale.getDefault());
- private static final NumberFormat SPEED_FORMATTER = new DecimalFormat("0.##x");
- private static final NumberFormat PITCH_FORMATTER = new DecimalFormat("##%");
+ private static final FormattersProvider FORMATTERS_PROVIDER = new FormattersProvider();
@Retention(SOURCE)
@IntDef({AUTOPLAY_TYPE_ALWAYS, AUTOPLAY_TYPE_WIFI,
@@ -89,9 +84,11 @@ public final class PlayerHelper {
private PlayerHelper() {
}
- ////////////////////////////////////////////////////////////////////////////
- // Exposed helpers
- ////////////////////////////////////////////////////////////////////////////
+ // region Exposed helpers
+
+ public static void resetFormat() {
+ FORMATTERS_PROVIDER.reset();
+ }
@NonNull
public static String getTimeString(final int milliSeconds) {
@@ -100,35 +97,24 @@ public final class PlayerHelper {
final int hours = (milliSeconds % 86400000) / 3600000;
final int days = (milliSeconds % (86400000 * 7)) / 86400000;
- STRING_BUILDER.setLength(0);
- return (days > 0
- ? STRING_FORMATTER.format("%d:%02d:%02d:%02d", days, hours, minutes, seconds)
- : hours > 0
- ? STRING_FORMATTER.format("%d:%02d:%02d", hours, minutes, seconds)
- : STRING_FORMATTER.format("%02d:%02d", minutes, seconds)
- ).toString();
+ final Formatters formatters = FORMATTERS_PROVIDER.formatters();
+ if (days > 0) {
+ return formatters.stringFormat("%d:%02d:%02d:%02d", days, hours, minutes, seconds);
+ }
+
+ return hours > 0
+ ? formatters.stringFormat("%d:%02d:%02d", hours, minutes, seconds)
+ : formatters.stringFormat("%02d:%02d", minutes, seconds);
}
@NonNull
public static String formatSpeed(final double speed) {
- return SPEED_FORMATTER.format(speed);
+ return FORMATTERS_PROVIDER.formatters().speed().format(speed);
}
@NonNull
public static String formatPitch(final double pitch) {
- return PITCH_FORMATTER.format(pitch);
- }
-
- @NonNull
- public static String subtitleMimeTypesOf(@NonNull final MediaFormat format) {
- switch (format) {
- case VTT:
- return MimeTypes.TEXT_VTT;
- case TTML:
- return MimeTypes.APPLICATION_TTML;
- default:
- throw new IllegalArgumentException("Unrecognized mime type: " + format.name());
- }
+ return FORMATTERS_PROVIDER.formatters().pitch().format(pitch);
}
@NonNull
@@ -219,9 +205,8 @@ public final class PlayerHelper {
? null : getAutoQueuedSinglePlayQueue(autoQueueItems.get(0));
}
- ////////////////////////////////////////////////////////////////////////////
- // Settings Resolution
- ////////////////////////////////////////////////////////////////////////////
+ // endregion
+ // region Resolution
public static boolean isResumeAfterAudioFocusGain(@NonNull final Context context) {
return getPreferences(context)
@@ -405,9 +390,8 @@ public final class PlayerHelper {
return Integer.parseInt(preferredIntervalBytes) * 1024;
}
- ////////////////////////////////////////////////////////////////////////////
- // Private helpers
- ////////////////////////////////////////////////////////////////////////////
+ // endregion
+ // region Private helpers
@NonNull
private static SharedPreferences getPreferences(@NonNull final Context context) {
@@ -427,9 +411,8 @@ public final class PlayerHelper {
}
- ////////////////////////////////////////////////////////////////////////////
- // Utils used by player
- ////////////////////////////////////////////////////////////////////////////
+ // endregion
+ // region Utils used by player
@RepeatMode
public static int nextRepeatMode(@RepeatMode final int repeatMode) {
@@ -503,4 +486,43 @@ public final class PlayerHelper {
player.getContext().getString(R.string.seek_duration_key),
player.getContext().getString(R.string.seek_duration_default_value))));
}
+
+ // endregion
+ // region Format
+
+ static class FormattersProvider {
+ private Formatters formatters;
+
+ public Formatters formatters() {
+ if (formatters == null) {
+ formatters = Formatters.create();
+ }
+ return formatters;
+ }
+
+ public void reset() {
+ formatters = null;
+ }
+ }
+
+ record Formatters(
+ Locale locale,
+ NumberFormat speed,
+ NumberFormat pitch) {
+
+ static Formatters create() {
+ final Locale locale = Localization.getAppLocale();
+ final DecimalFormatSymbols dfs = DecimalFormatSymbols.getInstance(locale);
+ return new Formatters(
+ locale,
+ new DecimalFormat("0.##x", dfs),
+ new DecimalFormat("##%", dfs));
+ }
+
+ String stringFormat(final String format, final Object... args) {
+ return String.format(locale, format, args);
+ }
+ }
+
+ // endregion
}
diff --git a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserPlaybackPreparer.kt b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserPlaybackPreparer.kt
index 2948eeaf8..4815965a3 100644
--- a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserPlaybackPreparer.kt
+++ b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserPlaybackPreparer.kt
@@ -17,6 +17,7 @@ import io.reactivex.rxjava3.schedulers.Schedulers
import org.schabi.newpipe.MainActivity
import org.schabi.newpipe.NewPipeDatabase
import org.schabi.newpipe.R
+import org.schabi.newpipe.error.ErrorInfo
import org.schabi.newpipe.extractor.InfoItem.InfoType
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler
@@ -84,7 +85,7 @@ class MediaBrowserPlaybackPreparer(
},
{ throwable ->
Log.e(TAG, "Failed to start playback of media ID [$mediaId]", throwable)
- onPrepareError()
+ onPrepareError(throwable)
}
)
}
@@ -115,9 +116,9 @@ class MediaBrowserPlaybackPreparer(
)
}
- private fun onPrepareError() {
+ private fun onPrepareError(throwable: Throwable) {
setMediaSessionError.accept(
- ContextCompat.getString(context, R.string.error_snackbar_message),
+ ErrorInfo.getMessage(throwable, null, null).getString(context),
PlaybackStateCompat.ERROR_CODE_APP_ERROR
)
}
diff --git a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java
index cc3889973..0894d22be 100644
--- a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java
+++ b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java
@@ -167,19 +167,17 @@ public final class NotificationUtil {
&& notificationBuilder.mActions.get(2).actionIntent != null);
}
-
public void createNotificationAndStartForeground() {
if (notificationBuilder == null) {
notificationBuilder = createNotification();
}
updateNotification();
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- player.getService().startForeground(NOTIFICATION_ID, notificationBuilder.build(),
- ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK);
- } else {
- player.getService().startForeground(NOTIFICATION_ID, notificationBuilder.build());
- }
+ // ServiceInfo constants are not used below Android Q, so 0 is set here
+ final int serviceType = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
+ ? ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK : 0;
+ ServiceCompat.startForeground(player.getService(), NOTIFICATION_ID,
+ notificationBuilder.build(), serviceType);
}
public void cancelNotificationAndStopForeground() {
diff --git a/app/src/main/java/org/schabi/newpipe/settings/BackupRestoreSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/BackupRestoreSettingsFragment.java
index 031f0d36c..e794ace72 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/BackupRestoreSettingsFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/BackupRestoreSettingsFragment.java
@@ -57,9 +57,7 @@ public class BackupRestoreSettingsFragment extends BasePreferenceFragment {
@Override
public void onCreatePreferences(@Nullable final Bundle savedInstanceState,
@Nullable final String rootKey) {
- final var dbDir = requireContext().getDatabasePath(BackupFileLocator.FILE_NAME_DB).toPath()
- .getParent();
- manager = new ImportExportManager(new BackupFileLocator(dbDir));
+ manager = new ImportExportManager(new BackupFileLocator(requireContext()));
importExportDataPathKey = getString(R.string.import_export_data_path);
diff --git a/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java
index 690634d0c..7e1f22776 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java
@@ -16,6 +16,7 @@ import androidx.preference.Preference;
import org.schabi.newpipe.DownloaderImpl;
import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.NewPipe;
+import org.schabi.newpipe.player.helper.PlayerHelper;
import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.image.ImageStrategy;
import org.schabi.newpipe.util.image.PreferredImageQuality;
@@ -106,5 +107,6 @@ public class ContentSettingsFragment extends BasePreferenceFragment {
NewPipe.setupLocalization(
Localization.getPreferredLocalization(context),
Localization.getPreferredContentCountry(context));
+ PlayerHelper.resetFormat();
}
}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java b/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java
index 9fe5240cc..5daa3ad82 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java
@@ -13,6 +13,7 @@ import androidx.preference.PreferenceManager;
import org.schabi.newpipe.App;
import org.schabi.newpipe.R;
+import org.schabi.newpipe.settings.migration.MigrationManager;
import org.schabi.newpipe.util.DeviceUtils;
import java.io.File;
@@ -46,7 +47,7 @@ public final class NewPipeSettings {
public static void initSettings(final Context context) {
// first run migrations, then setDefaultValues, since the latter requires the correct types
- SettingMigrations.runMigrationsIfNeeded(context);
+ MigrationManager.runMigrationsIfNeeded(context);
// readAgain is true so that if new settings are added their default value is set
PreferenceManager.setDefaultValues(context, R.xml.main_settings, true);
diff --git a/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java b/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java
index cbd6b0656..2bc5f5396 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java
@@ -95,8 +95,7 @@ public class SelectChannelFragment extends DialogFragment {
progressBar = v.findViewById(R.id.progressBar);
emptyView = v.findViewById(R.id.empty_state_view);
- EmptyStateUtil.setEmptyStateComposable(emptyView,
- EmptyStateSpec.Companion.getNoSubscriptions());
+ EmptyStateUtil.setEmptyStateComposable(emptyView, EmptyStateSpec.NoSubscriptions);
progressBar.setVisibility(View.VISIBLE);
recyclerView.setVisibility(View.GONE);
emptyView.setVisibility(View.GONE);
diff --git a/app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.java
index 6227d95a9..cb2252b86 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.java
@@ -65,8 +65,7 @@ public class SelectPlaylistFragment extends DialogFragment {
recyclerView = v.findViewById(R.id.items_list);
emptyView = v.findViewById(R.id.empty_state_view);
- EmptyStateUtil.setEmptyStateComposable(emptyView,
- EmptyStateSpec.Companion.getNoBookmarkedPlaylist());
+ EmptyStateUtil.setEmptyStateComposable(emptyView, EmptyStateSpec.NoBookmarkedPlaylist);
recyclerView.setLayoutManager(new LinearLayoutManager(getContext()));
final SelectPlaylistAdapter playlistAdapter = new SelectPlaylistAdapter();
recyclerView.setAdapter(playlistAdapter);
diff --git a/app/src/main/java/org/schabi/newpipe/settings/export/BackupFileLocator.kt b/app/src/main/java/org/schabi/newpipe/settings/export/BackupFileLocator.kt
index 0ce2d5f4d..38227e10e 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/export/BackupFileLocator.kt
+++ b/app/src/main/java/org/schabi/newpipe/settings/export/BackupFileLocator.kt
@@ -1,12 +1,13 @@
package org.schabi.newpipe.settings.export
+import android.content.Context
import java.nio.file.Path
import kotlin.io.path.div
/**
* Locates specific files of NewPipe based on the home directory of the app.
*/
-class BackupFileLocator(homeDir: Path) {
+class BackupFileLocator(context: Context) {
companion object {
const val FILE_NAME_DB = "newpipe.db"
@Deprecated(
@@ -17,9 +18,8 @@ class BackupFileLocator(homeDir: Path) {
const val FILE_NAME_JSON_PREFS = "preferences.json"
}
- val dbDir = homeDir / "databases"
- val db = homeDir / FILE_NAME_DB
- val dbJournal = homeDir / "$FILE_NAME_DB-journal"
- val dbShm = dbDir / "$FILE_NAME_DB-shm"
- val dbWal = dbDir / "$FILE_NAME_DB-wal"
+ val db: Path = context.getDatabasePath(FILE_NAME_DB).toPath()
+ val dbJournal: Path = db.resolveSibling("$FILE_NAME_DB-journal")
+ val dbShm: Path = db.resolveSibling("$FILE_NAME_DB-shm")
+ val dbWal: Path = db.resolveSibling("$FILE_NAME_DB-wal")
}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/export/ImportExportManager.kt b/app/src/main/java/org/schabi/newpipe/settings/export/ImportExportManager.kt
index 6b0eb7eb9..cbf860d2c 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/export/ImportExportManager.kt
+++ b/app/src/main/java/org/schabi/newpipe/settings/export/ImportExportManager.kt
@@ -12,7 +12,7 @@ import java.io.FileNotFoundException
import java.io.IOException
import java.io.ObjectOutputStream
import java.util.zip.ZipOutputStream
-import kotlin.io.path.createDirectories
+import kotlin.io.path.createParentDirectories
import kotlin.io.path.deleteIfExists
class ImportExportManager(private val fileLocator: BackupFileLocator) {
@@ -63,7 +63,7 @@ class ImportExportManager(private val fileLocator: BackupFileLocator) {
*/
@Throws(IOException::class)
fun ensureDbDirectoryExists() {
- fileLocator.dbDir.createDirectories()
+ fileLocator.db.createParentDirectories()
}
/**
diff --git a/app/src/main/java/org/schabi/newpipe/settings/migration/MigrationManager.java b/app/src/main/java/org/schabi/newpipe/settings/migration/MigrationManager.java
new file mode 100644
index 000000000..d5b0e783d
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/settings/migration/MigrationManager.java
@@ -0,0 +1,103 @@
+package org.schabi.newpipe.settings.migration;
+
+import android.content.Context;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AlertDialog;
+import androidx.core.util.Consumer;
+
+import org.schabi.newpipe.R;
+import org.schabi.newpipe.error.ErrorUtil;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * MigrationManager is responsible for running migrations and showing the user information about
+ * the migrations that were applied.
+ */
+public final class MigrationManager {
+
+ private static final String TAG = MigrationManager.class.getSimpleName();
+ /**
+ * List of UI actions that are performed after the UI is initialized (e.g. showing alert
+ * dialogs) to inform the user about changes that were applied by migrations.
+ */
+ private static final List> MIGRATION_INFO = new ArrayList<>();
+
+ private MigrationManager() {
+ // MigrationManager is a utility class that is completely static
+ }
+
+ /**
+ * Run all migrations that are needed for the current version of NewPipe.
+ * This method should be called at the start of the application, before any other operations
+ * that depend on the settings.
+ *
+ * @param context Context that can be used to run migrations
+ */
+ public static void runMigrationsIfNeeded(@NonNull final Context context) {
+ SettingMigrations.runMigrationsIfNeeded(context);
+ }
+
+ /**
+ * Perform UI actions informing about migrations that took place if they are present.
+ * @param context Context that can be used to show dialogs/snackbars/toasts
+ */
+ public static void showUserInfoIfPresent(@NonNull final Context context) {
+ if (MIGRATION_INFO.isEmpty()) {
+ return;
+ }
+
+ try {
+ MIGRATION_INFO.get(0).accept(context);
+ } catch (final Exception e) {
+ ErrorUtil.showUiErrorSnackbar(context, "Showing migration info to the user", e);
+ // Remove the migration that caused the error and continue with the next one
+ MIGRATION_INFO.remove(0);
+ showUserInfoIfPresent(context);
+ }
+ }
+
+ /**
+ * Add a migration info action that will be executed after the UI is initialized.
+ * This can be used to show dialogs/snackbars/toasts to inform the user about changes that
+ * were applied by migrations.
+ *
+ * @param info the action to be executed
+ */
+ public static void addMigrationInfo(final Consumer info) {
+ MIGRATION_INFO.add(info);
+ }
+
+ /**
+ * This method should be called when the user dismisses the migration info
+ * to check if there are any more migration info actions to be shown.
+ * @param context Context that can be used to show dialogs/snackbars/toasts
+ */
+ public static void onMigrationInfoDismissed(@NonNull final Context context) {
+ MIGRATION_INFO.remove(0);
+ showUserInfoIfPresent(context);
+ }
+
+ /**
+ * Creates a dialog to inform the user about the migration.
+ * @param uiContext Context that can be used to show dialogs/snackbars/toasts
+ * @param title the title of the dialog
+ * @param message the message of the dialog
+ * @return the dialog that can be shown to the user with a custom dismiss listener
+ */
+ static AlertDialog createMigrationInfoDialog(@NonNull final Context uiContext,
+ @NonNull final String title,
+ @NonNull final String message) {
+ return new AlertDialog.Builder(uiContext)
+ .setTitle(title)
+ .setMessage(message)
+ .setPositiveButton(R.string.ok, null)
+ .setOnDismissListener(dialog ->
+ MigrationManager.onMigrationInfoDismissed(uiContext))
+ .setCancelable(false) // prevents the dialog from being dismissed accidentally
+ .create();
+ }
+
+}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/SettingMigrations.java b/app/src/main/java/org/schabi/newpipe/settings/migration/SettingMigrations.java
similarity index 75%
rename from app/src/main/java/org/schabi/newpipe/settings/SettingMigrations.java
rename to app/src/main/java/org/schabi/newpipe/settings/migration/SettingMigrations.java
index 2134c7649..92520ec7e 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/SettingMigrations.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/migration/SettingMigrations.java
@@ -1,11 +1,14 @@
-package org.schabi.newpipe.settings;
+package org.schabi.newpipe.settings.migration;
+
+import static org.schabi.newpipe.MainActivity.DEBUG;
+import static org.schabi.newpipe.extractor.ServiceList.SoundCloud;
+import static org.schabi.newpipe.extractor.ServiceList.YouTube;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Log;
import androidx.annotation.NonNull;
-import androidx.appcompat.app.AlertDialog;
import androidx.core.util.Consumer;
import androidx.preference.PreferenceManager;
@@ -18,34 +21,34 @@ import org.schabi.newpipe.settings.tabs.Tab;
import org.schabi.newpipe.settings.tabs.TabsManager;
import org.schabi.newpipe.util.DeviceUtils;
-import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
-import static org.schabi.newpipe.MainActivity.DEBUG;
-import static org.schabi.newpipe.extractor.ServiceList.SoundCloud;
-
/**
- * In order to add a migration, follow these steps, given P is the previous version:
- * - in the class body add a new {@code MIGRATION_P_P+1 = new Migration(P, P+1) { ... }} and put in
- * the {@code migrate()} method the code that need to be run when migrating from P to P+1
- * - add {@code MIGRATION_P_P+1} at the end of {@link SettingMigrations#SETTING_MIGRATIONS}
- * - increment {@link SettingMigrations#VERSION}'s value by 1 (so it should become P+1)
+ * This class contains the code to migrate the settings from one version to another.
+ * Migrations are run automatically when the app is started and the settings version changed.
+ *
+ * In order to add a migration, follow these steps, given {@code P} is the previous version:
+ *
+ *
in the class body add a new {@code MIGRATION_P_P+1 = new Migration(P, P+1) { ... }} and put
+ * in the {@code migrate()} method the code that need to be run
+ * when migrating from {@code P} to {@code P+1}
+ *
add {@code MIGRATION_P_P+1} at the end of {@link SettingMigrations#SETTING_MIGRATIONS}
+ *
increment {@link SettingMigrations#VERSION}'s value by 1
+ * (so it becomes {@code P+1})