Allow ErrorInfo messages with formatArgs

- ErrorInfo.getMessage() now returns an ErrorMessage instance that can be formatted into a string using a context (this allows the construction of an ErrorInfo to remain independent of a Context)
- now the service ID is used in ErrorInfo.getMessage() to customize some messages based on the currently selected service
- player HTTP invalid statuses are now included in the message
- building a custom error message for AccountTerminatedException was moved from ErrorPanelHelper to ErrorInfo
This commit is contained in:
Stypox 2025-08-30 14:33:04 +02:00
parent 1bde2dcd9f
commit a369deeef4
No known key found for this signature in database
GPG key ID: 4BDF1B40A49FDD23
72 changed files with 154 additions and 146 deletions

View file

@ -276,7 +276,7 @@ public class RouterActivity extends AppCompatActivity {
|| errorInfo.getThrowable() instanceof ContentNotSupportedException) {
// 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.getMessageStringId(), Toast.LENGTH_LONG).show();
Toast.makeText(context, errorInfo.getMessage(context), Toast.LENGTH_LONG).show();
} else {
ErrorUtil.createNotification(context, errorInfo);
}

View file

@ -36,7 +36,7 @@ public class AcraReportSender implements ReportSender {
ErrorUtil.openActivity(context, new ErrorInfo(
new String[]{report.getString(ReportField.STACK_TRACE)},
UserAction.UI_ERROR,
ErrorInfo.SERVICE_NONE,
null,
"ACRA report",
R.string.app_ui_crash));
}

View file

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

View file

@ -1,7 +1,9 @@
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 com.google.android.exoplayer2.upstream.HttpDataSource
import com.google.android.exoplayer2.upstream.Loader
@ -9,6 +11,8 @@ import kotlinx.parcelize.IgnoredOnParcel
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
@ -18,22 +22,21 @@ 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.extractor.exceptions.YoutubeSignInConfirmNotBotException
import org.schabi.newpipe.ktx.isNetworkRelated
import org.schabi.newpipe.player.mediasource.FailedMediaSource
import org.schabi.newpipe.player.resolver.PlaybackResolver
import org.schabi.newpipe.util.ServiceHelper
@Parcelize
class ErrorInfo(
class ErrorInfo private constructor(
val stackTraces: Array<String>,
val userAction: UserAction,
val serviceName: String,
val serviceId: Int?,
val request: String,
val messageStringId: Int
private val message: ErrorMessage,
) : Parcelable {
// no need to store throwable, all data for report is in other variables
@ -44,14 +47,14 @@ class ErrorInfo(
private constructor(
throwable: Throwable,
userAction: UserAction,
serviceName: String,
serviceId: Int?,
request: String
) : this(
throwableToStringList(throwable),
userAction,
serviceName,
serviceId,
request,
getMessageStringId(throwable, userAction)
getMessage(throwable, userAction, serviceId)
) {
this.throwable = throwable
}
@ -59,97 +62,176 @@ class ErrorInfo(
private constructor(
throwable: List<Throwable>,
userAction: UserAction,
serviceName: String,
serviceId: Int?,
request: String
) : this(
throwableListToStringList(throwable),
userAction,
serviceName,
serviceId,
request,
getMessageStringId(throwable.firstOrNull(), userAction)
getMessage(throwable.firstOrNull(), userAction, serviceId)
) {
this.throwable = throwable.firstOrNull()
}
// constructor to manually build ErrorInfo
constructor(stackTraces: Array<String>, userAction: UserAction, serviceId: Int?, request: String, @StringRes message: Int) :
this(stackTraces, userAction, serviceId, request, ErrorMessage(message))
// constructors with single throwable
constructor(throwable: Throwable, userAction: UserAction, request: String) :
this(throwable, userAction, SERVICE_NONE, request)
this(throwable, userAction, null, request)
constructor(throwable: Throwable, userAction: UserAction, request: String, serviceId: Int) :
this(throwable, userAction, ServiceHelper.getNameOfServiceById(serviceId), request)
this(throwable, userAction, serviceId, request)
constructor(throwable: Throwable, userAction: UserAction, request: String, info: Info?) :
this(throwable, userAction, getInfoServiceName(info), request)
this(throwable, userAction, info?.serviceId, request)
// constructors with list of throwables
constructor(throwable: List<Throwable>, userAction: UserAction, request: String) :
this(throwable, userAction, SERVICE_NONE, request)
this(throwable, userAction, null, request)
constructor(throwable: List<Throwable>, userAction: UserAction, request: String, serviceId: Int) :
this(throwable, userAction, ServiceHelper.getNameOfServiceById(serviceId), request)
this(throwable, userAction, serviceId, request)
constructor(throwable: List<Throwable>, userAction: UserAction, request: String, info: Info?) :
this(throwable, userAction, getInfoServiceName(info), request)
this(throwable, userAction, info?.serviceId, request)
fun getServiceName(): String {
return getServiceName(serviceId)
}
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 = "<unknown_service>"
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<Throwable>) =
throwableList.map { it.stackTraceToString() }.toTypedArray()
private fun getInfoServiceName(info: Info?) =
if (info == null) SERVICE_NONE else ServiceHelper.getNameOfServiceById(info.serviceId)
@StringRes
fun getMessageStringId(
fun getMessage(
throwable: Throwable?,
action: UserAction?
): Int {
action: UserAction?,
serviceId: Int?,
): ErrorMessage {
return when {
// player exceptions
// some may be IOException, so do these checks before isNetworkRelated!
throwable is ExoPlaybackException -> {
val cause = throwable.cause
when {
cause is HttpDataSource.InvalidResponseCodeException && cause.responseCode == 403 -> R.string.player_error_403
cause is Loader.UnexpectedLoaderException && cause.cause is ExtractionException -> getMessageStringId(throwable, action)
throwable.type == ExoPlaybackException.TYPE_SOURCE -> R.string.player_stream_failure
throwable.type == ExoPlaybackException.TYPE_UNEXPECTED -> R.string.player_recoverable_failure
else -> R.string.player_unrecoverable_failure
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)
}
}
throwable is FailedMediaSource.FailedMediaSourceException -> getMessageStringId(throwable.cause, action)
throwable is PlaybackResolver.ResolverException -> R.string.player_stream_failure
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 -> R.string.account_terminated
throwable is AgeRestrictedContentException -> R.string.restricted_video_no_stream
throwable is GeographicRestrictionException -> R.string.georestricted_content
throwable is PaidContentException -> R.string.paid_content
throwable is PrivateContentException -> R.string.private_content
throwable is SoundCloudGoPlusContentException -> R.string.soundcloud_go_plus_content
throwable is UnsupportedContentInCountryException -> R.string.unsupported_content_in_country
throwable is YoutubeMusicPremiumContentException -> R.string.youtube_music_premium_content
throwable is YoutubeSignInConfirmNotBotException -> R.string.youtube_sign_in_confirm_not_bot_error
throwable is ContentNotAvailableException -> R.string.content_not_available
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 -> R.string.content_not_supported
throwable is ContentNotSupportedException ->
ErrorMessage(R.string.content_not_supported)
// ReCaptchas should have already been handled elsewhere,
// but return an error message here just in case
throwable is ReCaptchaException -> R.string.recaptcha_request_toast
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 -> R.string.network_error
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 -> R.string.parsing_error
throwable is ExtractionException ->
ErrorMessage(R.string.parsing_error)
// user actions (in case the exception is null or unrecognizable)
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.error_snackbar_message
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)
}
}
}

View file

@ -14,14 +14,11 @@ 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.ContentNotAvailableException
import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
import org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty
import org.schabi.newpipe.ktx.animate
import org.schabi.newpipe.ktx.isInterruptedCaused
import org.schabi.newpipe.util.ServiceHelper
import org.schabi.newpipe.util.external_communication.ShareUtils
import java.util.concurrent.TimeUnit
@ -99,20 +96,6 @@ class ErrorPanelHelper(
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 ?: "<unknown>"
)
errorServiceInfoTextView.isVisible = true
errorServiceExplanationTextView.text =
(errorInfo.throwable as AccountTerminatedException).message
errorServiceExplanationTextView.isVisible = true
}
} else {
showAndSetErrorButtonAction(
R.string.error_snackbar_action
@ -120,7 +103,7 @@ class ErrorPanelHelper(
ErrorUtil.openActivity(context, errorInfo)
}
errorTextView.setText(errorInfo.messageStringId)
errorTextView.text = errorInfo.getMessage(context)
if (errorInfo.throwable !is ContentNotAvailableException &&
errorInfo.throwable !is ContentNotSupportedException

View file

@ -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,7 +156,7 @@ class ErrorUtil {
// fallback to showing a notification if no root view is available
createNotification(context, errorInfo)
} else {
Snackbar.make(rootView, errorInfo.messageStringId, 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)

View file

@ -34,7 +34,8 @@ public enum UserAction {
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"),
PLAY_ON_POPUP("play on popup");
PLAY_ON_POPUP("play on popup"),
SUBSCRIPTIONS("loading subscriptions"),;
private final String message;

View file

@ -89,7 +89,7 @@ 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),
currentServiceId,
"Service does not support importing subscriptions",
R.string.general_error));
activity.finish();

View file

@ -118,7 +118,7 @@ class MediaBrowserPlaybackPreparer(
private fun onPrepareError(throwable: Throwable) {
setMediaSessionError.accept(
ContextCompat.getString(context, ErrorInfo.getMessageStringId(throwable, null)),
ErrorInfo.getMessage(throwable, null, null).getString(context),
PlaybackStateCompat.ERROR_CODE_APP_ERROR
)
}

View file

@ -563,11 +563,11 @@ public class MissionAdapter extends Adapter<ViewHolder> implements Handler.Callb
}
request.append("]");
String service;
Integer service;
try {
service = NewPipe.getServiceByUrl(mission.source).getServiceInfo().getName();
service = NewPipe.getServiceByUrl(mission.source).getServiceId();
} catch (Exception e) {
service = ErrorInfo.SERVICE_NONE;
service = null;
}
ErrorUtil.createNotification(mContext,