diff --git a/app/src/main/java/org/schabi/newpipe/error/EnsureExceptionSerializable.java b/app/src/main/java/org/schabi/newpipe/error/EnsureExceptionSerializable.java new file mode 100644 index 000000000..db94de5e5 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/error/EnsureExceptionSerializable.java @@ -0,0 +1,103 @@ +package org.schabi.newpipe.error; + +import android.util.Log; + +import androidx.annotation.NonNull; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.ObjectOutputStream; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Ensures that a Exception is serializable. + * This is + */ +public final class EnsureExceptionSerializable { + private static final String TAG = "EnsureExSerializable"; + + private EnsureExceptionSerializable() { + // No instance + } + + /** + * Ensures that an exception is serializable. + *
+ * If that is not the case a {@link WorkaroundNotSerializableException} is created. + * + * @param exception + * @return if an exception is not serializable a new {@link WorkaroundNotSerializableException} + * otherwise the exception from the parameter + */ + public static Exception ensureSerializable(@NonNull final Exception exception) { + return checkIfSerializable(exception) + ? exception + : WorkaroundNotSerializableException.create(exception); + } + + public static boolean checkIfSerializable(@NonNull final Exception exception) { + try { + // Check by creating a new ObjectOutputStream which does the serialization + try (ByteArrayOutputStream bos = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(bos) + ) { + oos.writeObject(exception); + oos.flush(); + + bos.toByteArray(); + } + + return true; + } catch (final IOException ex) { + Log.d(TAG, "Exception is not serializable", ex); + return false; + } + } + + public static class WorkaroundNotSerializableException extends Exception { + protected WorkaroundNotSerializableException( + final Throwable notSerializableException, + final Throwable cause) { + super(notSerializableException.toString(), cause); + setStackTrace(notSerializableException.getStackTrace()); + } + + protected WorkaroundNotSerializableException(final Throwable notSerializableException) { + super(notSerializableException.toString()); + setStackTrace(notSerializableException.getStackTrace()); + } + + public static WorkaroundNotSerializableException create( + @NonNull final Exception notSerializableException + ) { + // Build a list of the exception + all causes + final List throwableList = new ArrayList<>(); + + int pos = 0; + Throwable throwableToProcess = notSerializableException; + + while (throwableToProcess != null) { + throwableList.add(throwableToProcess); + + pos++; + throwableToProcess = throwableToProcess.getCause(); + } + + // Reverse list so that it starts with the last one + Collections.reverse(throwableList); + + // Build exception stack + WorkaroundNotSerializableException cause = null; + for (final Throwable t : throwableList) { + cause = cause == null + ? new WorkaroundNotSerializableException(t) + : new WorkaroundNotSerializableException(t, cause); + } + + return cause; + } + + } +} 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 c0d88c8ec..db3a92d4f 100644 --- a/app/src/main/java/org/schabi/newpipe/error/ErrorActivity.java +++ b/app/src/main/java/org/schabi/newpipe/error/ErrorActivity.java @@ -77,6 +77,16 @@ public class ErrorActivity extends AppCompatActivity { private ActivityErrorBinding activityErrorBinding; + /** + * Reports a new error by starting a new activity. + *
+ * Ensure that the data within errorInfo is serializable otherwise + * an exception will be thrown!
+ * {@link EnsureExceptionSerializable} might help. + * + * @param context + * @param errorInfo + */ public static void reportError(final Context context, final ErrorInfo errorInfo) { final Intent intent = new Intent(context, ErrorActivity.class); intent.putExtra(ERROR_INFO, errorInfo); diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java index 5552cf73f..8c6e01537 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java @@ -594,6 +594,11 @@ public final class VideoDetailFragment // Init //////////////////////////////////////////////////////////////////////////*/ + @Override + public void onViewCreated(@NonNull final View rootView, final Bundle savedInstanceState) { + super.onViewCreated(rootView, savedInstanceState); + } + @Override // called from onViewCreated in {@link BaseFragment#onViewCreated} protected void initViews(final View rootView, final Bundle savedInstanceState) { super.initViews(rootView, savedInstanceState); @@ -604,6 +609,18 @@ public final class VideoDetailFragment binding.detailThumbnailRootLayout.requestFocus(); + binding.detailControlsPlayWithKodi.setVisibility( + KoreUtils.shouldShowPlayWithKodi(requireContext(), serviceId) + ? View.VISIBLE + : View.GONE + ); + binding.detailControlsCrashThePlayer.setVisibility( + DEBUG && PreferenceManager.getDefaultSharedPreferences(getContext()) + .getBoolean(getString(R.string.show_crash_the_player_key), false) + ? View.VISIBLE + : View.GONE + ); + if (DeviceUtils.isTv(getContext())) { // remove ripple effects from detail controls final int transparent = ContextCompat.getColor(requireContext(), @@ -638,8 +655,14 @@ public final class VideoDetailFragment binding.detailControlsShare.setOnClickListener(this); binding.detailControlsOpenInBrowser.setOnClickListener(this); binding.detailControlsPlayWithKodi.setOnClickListener(this); - binding.detailControlsPlayWithKodi.setVisibility(KoreUtils.shouldShowPlayWithKodi( - requireContext(), serviceId) ? View.VISIBLE : View.GONE); + if (DEBUG) { + binding.detailControlsCrashThePlayer.setOnClickListener( + v -> VideoDetailPlayerCrasher.onCrashThePlayer( + this.getContext(), + this.player, + getLayoutInflater()) + ); + } binding.overlayThumbnail.setOnClickListener(this); binding.overlayThumbnail.setOnLongClickListener(this); diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailPlayerCrasher.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailPlayerCrasher.java new file mode 100644 index 000000000..9309a8a49 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailPlayerCrasher.java @@ -0,0 +1,159 @@ +package org.schabi.newpipe.fragments.detail; + +import android.content.Context; +import android.util.Log; +import android.view.ContextThemeWrapper; +import android.view.LayoutInflater; +import android.view.ViewGroup; +import android.widget.RadioButton; +import android.widget.RadioGroup; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ExoPlaybackException; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.databinding.ListRadioIconItemBinding; +import org.schabi.newpipe.databinding.SingleChoiceDialogViewBinding; +import org.schabi.newpipe.player.Player; +import org.schabi.newpipe.util.ThemeHelper; + +import java.io.IOException; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.Supplier; + +/** + * Outsourced logic for crashing the player in the {@link VideoDetailFragment}. + */ +public final class VideoDetailPlayerCrasher { + + // This has to be <= 23 chars on devices running Android 7 or lower (API <= 25) + // or it fails with an IllegalArgumentException + // https://stackoverflow.com/a/54744028 + private static final String TAG = "VideoDetPlayerCrasher"; + + private static final Map> AVAILABLE_EXCEPTION_TYPES = + getExceptionTypes(); + + private VideoDetailPlayerCrasher() { + // No impls + } + + private static Map> getExceptionTypes() { + final String defaultMsg = "Dummy"; + final Map> exceptionTypes = new LinkedHashMap<>(); + exceptionTypes.put( + "Source", + () -> ExoPlaybackException.createForSource( + new IOException(defaultMsg) + ) + ); + exceptionTypes.put( + "Renderer", + () -> ExoPlaybackException.createForRenderer( + new Exception(defaultMsg), + "Dummy renderer", + 0, + null, + C.FORMAT_HANDLED + ) + ); + exceptionTypes.put( + "Unexpected", + () -> ExoPlaybackException.createForUnexpected( + new RuntimeException(defaultMsg) + ) + ); + exceptionTypes.put( + "Remote", + () -> ExoPlaybackException.createForRemote(defaultMsg) + ); + + return Collections.unmodifiableMap(exceptionTypes); + } + + private static Context getThemeWrapperContext(final Context context) { + return new ContextThemeWrapper( + context, + ThemeHelper.isLightThemeSelected(context) + ? R.style.LightTheme + : R.style.DarkTheme); + } + + public static void onCrashThePlayer( + @NonNull final Context context, + @Nullable final Player player, + @NonNull final LayoutInflater layoutInflater + ) { + if (player == null) { + Log.d(TAG, "Player is not available"); + Toast.makeText(context, "Player is not available", Toast.LENGTH_SHORT) + .show(); + + return; + } + + // -- Build the dialog/UI -- + + final Context themeWrapperContext = getThemeWrapperContext(context); + + final LayoutInflater inflater = LayoutInflater.from(themeWrapperContext); + final RadioGroup radioGroup = SingleChoiceDialogViewBinding.inflate(layoutInflater) + .list; + + final AlertDialog alertDialog = new AlertDialog.Builder(getThemeWrapperContext(context)) + .setTitle("Choose an exception") + .setView(radioGroup) + .setCancelable(true) + .setNegativeButton(R.string.cancel, null) + .create(); + + for (final Map.Entry> entry + : AVAILABLE_EXCEPTION_TYPES.entrySet()) { + final RadioButton radioButton = ListRadioIconItemBinding.inflate(inflater).getRoot(); + radioButton.setText(entry.getKey()); + radioButton.setChecked(false); + radioButton.setLayoutParams( + new RadioGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + ); + radioButton.setOnClickListener(v -> { + tryCrashPlayerWith(player, entry.getValue().get()); + if (alertDialog != null) { + alertDialog.cancel(); + } + }); + radioGroup.addView(radioButton); + } + + alertDialog.show(); + } + + /** + * Note that this method does not crash the underlying exoplayer directly (it's not possible). + * It simply supplies a Exception to {@link Player#onPlayerError(ExoPlaybackException)}. + * @param player + * @param exception + */ + private static void tryCrashPlayerWith( + @NonNull final Player player, + @NonNull final ExoPlaybackException exception + ) { + Log.d(TAG, "Crashing the player using player.onPlayerError(ex)"); + try { + player.onPlayerError(exception); + } catch (final Exception exPlayer) { + Log.e(TAG, + "Run into an exception while crashing the player:", + exPlayer); + } + } +} 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 ac1b76270..2d8c1a830 100644 --- a/app/src/main/java/org/schabi/newpipe/player/Player.java +++ b/app/src/main/java/org/schabi/newpipe/player/Player.java @@ -97,7 +97,6 @@ import android.widget.ProgressBar; import android.widget.RelativeLayout; import android.widget.SeekBar; import android.widget.TextView; -import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -166,6 +165,7 @@ import org.schabi.newpipe.player.playback.MediaSourceManager; import org.schabi.newpipe.player.playback.PlaybackListener; import org.schabi.newpipe.player.playback.PlayerMediaSession; import org.schabi.newpipe.player.playback.SurfaceHolderCallback; +import org.schabi.newpipe.player.playererror.PlayerErrorHandler; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueueAdapter; import org.schabi.newpipe.player.playqueue.PlayQueueItem; @@ -268,7 +268,7 @@ public final class Player implements @Nullable private MediaSourceTag currentMetadata; @Nullable private Bitmap currentThumbnail; - @Nullable private Toast errorToast; + @NonNull private PlayerErrorHandler playerErrorHandler; /*////////////////////////////////////////////////////////////////////////// // Player @@ -413,6 +413,8 @@ public final class Player implements videoResolver = new VideoPlaybackResolver(context, dataSource, getQualityResolver()); audioResolver = new AudioPlaybackResolver(context, dataSource); + playerErrorHandler = new PlayerErrorHandler(context); + windowManager = ContextCompat.getSystemService(context, WindowManager.class); } @@ -2512,30 +2514,33 @@ public final class Player implements */ @Override public void onPlayerError(@NonNull final ExoPlaybackException error) { - if (DEBUG) { - Log.d(TAG, "ExoPlayer - onPlayerError() called with: " + "error = [" + error + "]"); - } - if (errorToast != null) { - errorToast.cancel(); - errorToast = null; - } + Log.e(TAG, "ExoPlayer - onPlayerError() called with:", error); saveStreamProgressState(); switch (error.type) { case ExoPlaybackException.TYPE_SOURCE: processSourceError(error.getSourceException()); - showStreamError(error); + playerErrorHandler.showPlayerError( + error, + currentMetadata.getMetadata(), + R.string.player_stream_failure); break; case ExoPlaybackException.TYPE_UNEXPECTED: - showRecoverableError(error); + playerErrorHandler.showPlayerError( + error, + currentMetadata.getMetadata(), + R.string.player_recoverable_failure); setRecovery(); reloadPlayQueueManager(); break; case ExoPlaybackException.TYPE_REMOTE: case ExoPlaybackException.TYPE_RENDERER: default: - showUnrecoverableError(error); + playerErrorHandler.showPlayerError( + error, + currentMetadata.getMetadata(), + R.string.player_unrecoverable_failure); onPlaybackShutdown(); break; } @@ -2557,37 +2562,6 @@ public final class Player implements playQueue.error(); } } - - private void showStreamError(final Exception exception) { - exception.printStackTrace(); - - if (errorToast == null) { - errorToast = Toast - .makeText(context, R.string.player_stream_failure, Toast.LENGTH_SHORT); - errorToast.show(); - } - } - - private void showRecoverableError(final Exception exception) { - exception.printStackTrace(); - - if (errorToast == null) { - errorToast = Toast - .makeText(context, R.string.player_recoverable_failure, Toast.LENGTH_SHORT); - errorToast.show(); - } - } - - private void showUnrecoverableError(final Exception exception) { - exception.printStackTrace(); - - if (errorToast != null) { - errorToast.cancel(); - } - errorToast = Toast - .makeText(context, R.string.player_unrecoverable_failure, Toast.LENGTH_SHORT); - errorToast.show(); - } //endregion diff --git a/app/src/main/java/org/schabi/newpipe/player/playererror/PlayerErrorHandler.java b/app/src/main/java/org/schabi/newpipe/player/playererror/PlayerErrorHandler.java new file mode 100644 index 000000000..626200ae1 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/playererror/PlayerErrorHandler.java @@ -0,0 +1,89 @@ +package org.schabi.newpipe.player.playererror; + +import android.content.Context; +import android.util.Log; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.preference.PreferenceManager; + +import com.google.android.exoplayer2.ExoPlaybackException; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.error.EnsureExceptionSerializable; +import org.schabi.newpipe.error.ErrorActivity; +import org.schabi.newpipe.error.ErrorInfo; +import org.schabi.newpipe.error.UserAction; +import org.schabi.newpipe.extractor.Info; + +/** + * Handles (exoplayer)errors that occur in the player. + */ +public class PlayerErrorHandler { + // This has to be <= 23 chars on devices running Android 7 or lower (API <= 25) + // or it fails with an IllegalArgumentException + // https://stackoverflow.com/a/54744028 + private static final String TAG = "PlayerErrorHandler"; + + @Nullable + private Toast errorToast; + + @NonNull + private final Context context; + + public PlayerErrorHandler(@NonNull final Context context) { + this.context = context; + } + + public void showPlayerError( + @NonNull final ExoPlaybackException exception, + @NonNull final Info info, + @StringRes final int textResId + ) { + // Hide existing toast message + if (errorToast != null) { + Log.d(TAG, "Trying to cancel previous player error error toast"); + errorToast.cancel(); + errorToast = null; + } + + if (shouldReportError()) { + try { + reportError(exception, info); + // When a report pops up we need no toast + return; + } catch (final Exception ex) { + Log.w(TAG, "Unable to report error:", ex); + // This will show the toast as fallback + } + } + + Log.d(TAG, "Showing player error toast"); + errorToast = Toast.makeText(context, textResId, Toast.LENGTH_SHORT); + errorToast.show(); + } + + private void reportError(@NonNull final ExoPlaybackException exception, + @NonNull final Info info) { + ErrorActivity.reportError( + context, + new ErrorInfo( + EnsureExceptionSerializable.ensureSerializable(exception), + UserAction.PLAY_STREAM, + "Player error[type=" + exception.type + "] occurred while playing: " + + info.getUrl(), + info + ) + ); + } + + private boolean shouldReportError() { + return PreferenceManager + .getDefaultSharedPreferences(context) + .getBoolean( + context.getString(R.string.report_player_errors_key), + false); + } +} diff --git a/app/src/main/res/layout-large-land/fragment_video_detail.xml b/app/src/main/res/layout-large-land/fragment_video_detail.xml index fe12776e1..98b24f511 100644 --- a/app/src/main/res/layout-large-land/fragment_video_detail.xml +++ b/app/src/main/res/layout-large-land/fragment_video_detail.xml @@ -225,7 +225,7 @@ android:layout_below="@id/detail_title_root_layout" android:layout_marginTop="@dimen/video_item_detail_error_panel_margin" android:visibility="gone" - tools:visibility="visible" /> + tools:visibility="gone" /> + + + tools:visibility="gone" /> + + @string/never + report_player_errors_key + seekbar_preview_thumbnail_key seekbar_preview_thumbnail_high_quality seekbar_preview_thumbnail_low_quality @@ -188,6 +190,7 @@ disable_media_tunneling_key crash_the_app_key show_image_indicators_key + show_crash_the_player_key theme diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7c8fd98ab..a8bb4c788 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -52,6 +52,9 @@ org.xbmc.kore Show \"Play with Kodi\" option Display an option to play a video via Kodi media center + Crash the player + Report player errors + Reports player errors in full detail instead of showing a short-lived toast message (useful for diagnosing problems) Scale thumbnail to 1:1 aspect ratio Scale the video thumbnail shown in the notification from 16:9 to 1:1 aspect ratio (may introduce distortions) First action button @@ -473,6 +476,8 @@ Show image indicators Show Picasso colored ribbons on top of images indicating their source: red for network, blue for disk and green for memory Crash the app + Show \"crash the player\" + Shows a crash option when using the player Import Import from diff --git a/app/src/main/res/xml/debug_settings.xml b/app/src/main/res/xml/debug_settings.xml index 22abebcae..5e2cc28ed 100644 --- a/app/src/main/res/xml/debug_settings.xml +++ b/app/src/main/res/xml/debug_settings.xml @@ -49,9 +49,26 @@ android:title="@string/show_image_indicators_title" app:iconSpaceReserved="false" /> + + + + diff --git a/app/src/main/res/xml/video_audio_settings.xml b/app/src/main/res/xml/video_audio_settings.xml index f605fbe17..4dc5d5c9a 100644 --- a/app/src/main/res/xml/video_audio_settings.xml +++ b/app/src/main/res/xml/video_audio_settings.xml @@ -89,6 +89,7 @@ android:title="@string/show_play_with_kodi_title" app:singleLineTitle="false" app:iconSpaceReserved="false" /> +