diff --git a/library/common/src/main/java/com/google/android/exoplayer2/ExoPlaybackException.java b/library/common/src/main/java/com/google/android/exoplayer2/ExoPlaybackException.java index 93f66123fc..4d9488bce2 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/ExoPlaybackException.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/ExoPlaybackException.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2; +import android.os.Bundle; +import android.os.RemoteException; import android.os.SystemClock; import android.text.TextUtils; import androidx.annotation.CheckResult; @@ -29,7 +31,7 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; /** Thrown when a non locally recoverable playback failure occurs. */ -public final class ExoPlaybackException extends Exception { +public final class ExoPlaybackException extends Exception implements Bundleable { /** * The type of source that produced the error. One of {@link #TYPE_SOURCE}, {@link #TYPE_RENDERER} @@ -378,4 +380,136 @@ public final class ExoPlaybackException extends Exception { } return message; } + + // Bundleable implementation. + // TODO(b/145954241): Revisit bundling fields when this class is split for Player and ExoPlayer. + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + FIELD_MESSAGE, + FIELD_TYPE, + FIELD_RENDERER_NAME, + FIELD_RENDERER_INDEX, + FIELD_RENDERER_FORMAT, + FIELD_RENDERER_FORMAT_SUPPORT, + FIELD_TIME_STAMP_MS, + FIELD_IS_RECOVERABLE, + FIELD_CAUSE_CLASS_NAME, + FIELD_CAUSE_MESSAGE + }) + private @interface FieldNumber {} + + private static final int FIELD_MESSAGE = 0; + private static final int FIELD_TYPE = 1; + private static final int FIELD_RENDERER_NAME = 2; + private static final int FIELD_RENDERER_INDEX = 3; + private static final int FIELD_RENDERER_FORMAT = 4; + private static final int FIELD_RENDERER_FORMAT_SUPPORT = 5; + private static final int FIELD_TIME_STAMP_MS = 6; + private static final int FIELD_IS_RECOVERABLE = 7; + private static final int FIELD_CAUSE_CLASS_NAME = 8; + private static final int FIELD_CAUSE_MESSAGE = 9; + + /** + * {@inheritDoc} + * + *

It omits the {@link #mediaPeriodId} field. The {@link #mediaPeriodId} of an instance + * restored by {@link #CREATOR} will always be {@code null}. + */ + @Override + public Bundle toBundle() { + Bundle bundle = new Bundle(); + bundle.putString(keyForField(FIELD_MESSAGE), getMessage()); + bundle.putInt(keyForField(FIELD_TYPE), type); + bundle.putString(keyForField(FIELD_RENDERER_NAME), rendererName); + bundle.putInt(keyForField(FIELD_RENDERER_INDEX), rendererIndex); + bundle.putParcelable(keyForField(FIELD_RENDERER_FORMAT), rendererFormat); + bundle.putInt(keyForField(FIELD_RENDERER_FORMAT_SUPPORT), rendererFormatSupport); + bundle.putLong(keyForField(FIELD_TIME_STAMP_MS), timestampMs); + bundle.putBoolean(keyForField(FIELD_IS_RECOVERABLE), isRecoverable); + if (cause != null) { + bundle.putString(keyForField(FIELD_CAUSE_CLASS_NAME), cause.getClass().getName()); + bundle.putString(keyForField(FIELD_CAUSE_MESSAGE), cause.getMessage()); + } + return bundle; + } + + /** Object that can restore {@link ExoPlaybackException} from a {@link Bundle}. */ + public static final Creator CREATOR = ExoPlaybackException::fromBundle; + + private static ExoPlaybackException fromBundle(Bundle bundle) { + int type = bundle.getInt(keyForField(FIELD_TYPE), /* defaultValue= */ TYPE_UNEXPECTED); + @Nullable String rendererName = bundle.getString(keyForField(FIELD_RENDERER_NAME)); + int rendererIndex = + bundle.getInt(keyForField(FIELD_RENDERER_INDEX), /* defaultValue= */ C.INDEX_UNSET); + @Nullable Format rendererFormat = bundle.getParcelable(keyForField(FIELD_RENDERER_FORMAT)); + int rendererFormatSupport = + bundle.getInt( + keyForField(FIELD_RENDERER_FORMAT_SUPPORT), /* defaultValue= */ C.FORMAT_HANDLED); + long timestampMs = + bundle.getLong( + keyForField(FIELD_TIME_STAMP_MS), /* defaultValue= */ SystemClock.elapsedRealtime()); + boolean isRecoverable = + bundle.getBoolean(keyForField(FIELD_IS_RECOVERABLE), /* defaultValue= */ false); + @Nullable String message = bundle.getString(keyForField(FIELD_MESSAGE)); + if (message == null) { + message = + deriveMessage( + type, + /* customMessage= */ null, + rendererName, + rendererIndex, + rendererFormat, + rendererFormatSupport); + } + + @Nullable String causeClassName = bundle.getString(keyForField(FIELD_CAUSE_CLASS_NAME)); + @Nullable String causeMessage = bundle.getString(keyForField(FIELD_CAUSE_MESSAGE)); + @Nullable Throwable cause = null; + if (!TextUtils.isEmpty(causeClassName)) { + final Class clazz; + try { + clazz = + Class.forName( + causeClassName, + /* initialize= */ true, + ExoPlaybackException.class.getClassLoader()); + if (Throwable.class.isAssignableFrom(clazz)) { + cause = createThrowable(clazz, causeMessage); + } + } catch (Throwable e) { + // Intentionally catch Throwable to catch both Exception and Error. + cause = createRemoteException(causeMessage); + } + } + + return new ExoPlaybackException( + message, + cause, + type, + rendererName, + rendererIndex, + rendererFormat, + rendererFormatSupport, + /* mediaPeriodId= */ null, + timestampMs, + isRecoverable); + } + + // Creates a new {@link Throwable} with possibly @{code null} message. + @SuppressWarnings("nullness:argument.type.incompatible") + private static Throwable createThrowable(Class throwableClazz, @Nullable String message) + throws Exception { + return (Throwable) throwableClazz.getConstructor(String.class).newInstance(message); + } + + // Creates a new {@link RemoteException} with possibly {@code null} message. + @SuppressWarnings("nullness:argument.type.incompatible") + private static RemoteException createRemoteException(@Nullable String message) { + return new RemoteException(message); + } + + private static String keyForField(@FieldNumber int field) { + return Integer.toString(field, Character.MAX_RADIX); + } } diff --git a/library/common/src/test/java/com/google/android/exoplayer2/ExoPlaybackExceptionTest.java b/library/common/src/test/java/com/google/android/exoplayer2/ExoPlaybackExceptionTest.java new file mode 100644 index 0000000000..0a12305059 --- /dev/null +++ b/library/common/src/test/java/com/google/android/exoplayer2/ExoPlaybackExceptionTest.java @@ -0,0 +1,85 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2; + +import static com.google.common.truth.Truth.assertThat; + +import android.os.RemoteException; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.util.Util; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit tests for {@link ExoPlaybackException}. */ +@RunWith(AndroidJUnit4.class) +public class ExoPlaybackExceptionTest { + + @Test + public void roundtripViaBundle_ofExoPlaybackExceptionTypeRemote_yieldsEqualInstance() { + ExoPlaybackException before = ExoPlaybackException.createForRemote(/* message= */ "test"); + ExoPlaybackException after = ExoPlaybackException.CREATOR.fromBundle(before.toBundle()); + assertThat(areEqual(before, after)).isTrue(); + } + + @Test + public void roundtripViaBundle_ofExoPlaybackExceptionTypeRenderer_yieldsEqualInstance() { + ExoPlaybackException before = + ExoPlaybackException.createForRenderer( + new IllegalStateException("ExoPlaybackExceptionTest"), + /* rendererName= */ "rendererName", + /* rendererIndex= */ 123, + /* rendererFormat= */ new Format.Builder().setCodecs("anyCodec").build(), + /* rendererFormatSupport= */ C.FORMAT_UNSUPPORTED_SUBTYPE, + /* isRecoverable= */ true); + + ExoPlaybackException after = ExoPlaybackException.CREATOR.fromBundle(before.toBundle()); + assertThat(areEqual(before, after)).isTrue(); + } + + @Test + public void + roundtripViaBundle_ofExoPlaybackExceptionTypeRendererWithPrivateCause_yieldsRemoteExceptionWithSameMessage() { + ExoPlaybackException before = + ExoPlaybackException.createForRenderer( + new Exception(/* message= */ "anonymous exception that class loader cannot know") {}); + ExoPlaybackException after = ExoPlaybackException.CREATOR.fromBundle(before.toBundle()); + + assertThat(after.getCause()).isInstanceOf(RemoteException.class); + assertThat(after.getCause()).hasMessageThat().isEqualTo(before.getCause().getMessage()); + } + + private static boolean areEqual(ExoPlaybackException a, ExoPlaybackException b) { + if (a == null || b == null) { + return a == b; + } + return Util.areEqual(a.getMessage(), b.getMessage()) + && a.type == b.type + && Util.areEqual(a.rendererName, b.rendererName) + && a.rendererIndex == b.rendererIndex + && Util.areEqual(a.rendererFormat, b.rendererFormat) + && a.rendererFormatSupport == b.rendererFormatSupport + && a.timestampMs == b.timestampMs + && a.isRecoverable == b.isRecoverable + && areEqual(a.getCause(), b.getCause()); + } + + private static boolean areEqual(Throwable a, Throwable b) { + if (a == null || b == null) { + return a == b; + } + return a.getClass() == b.getClass() && Util.areEqual(a.getMessage(), b.getMessage()); + } +}