Rollback of b6f5a263f7
*** Original commit ***
Rollforward of commit 5612ac50a332e425dc130c3c13a139b9e6fce9ec.
*** Reason for rollforward ***
Rollforward after making sure the handler is created
from the playback thread and not from an app thread.
*** Original change description ***
Rollback of e1beb1d194
*** Original commit ***
Expose experimental offload scheduling
Add a new scheduling mode that stops ExoPlayer main loop
when the audio offload buffer is full and resume it...
***
PiperOrigin-RevId: 316914147
This commit is contained in:
parent
a5bc91f09b
commit
ffa4ad0e77
@ -164,7 +164,6 @@
|
||||
* No longer use a `MediaCodec` in audio passthrough mode.
|
||||
* Check `DefaultAudioSink` supports passthrough, in addition to checking
|
||||
the `AudioCapabilities`
|
||||
* Add an experimental scheduling mode to save power in offload.
|
||||
([#7404](https://github.com/google/ExoPlayer/issues/7404)).
|
||||
* Adjust input timestamps in `MediaCodecRenderer` to account for the
|
||||
Codec2 MP3 decoder having lower timestamps on the output side.
|
||||
|
@ -219,20 +219,12 @@ public class DefaultRenderersFactory implements RenderersFactory {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether audio should be played using the offload path.
|
||||
*
|
||||
* <p>Audio offload disables ExoPlayer audio processing, but significantly reduces the energy
|
||||
* consumption of the playback when {@link
|
||||
* ExoPlayer#experimental_enableOffloadScheduling(boolean)} is enabled.
|
||||
*
|
||||
* <p>Most Android devices can only support one offload {@link android.media.AudioTrack} at a time
|
||||
* and can invalidate it at any time. Thus an app can never be guaranteed that it will be able to
|
||||
* play in offload.
|
||||
* Sets whether audio should be played using the offload path. Audio offload disables audio
|
||||
* processors (for example speed adjustment).
|
||||
*
|
||||
* <p>The default value is {@code false}.
|
||||
*
|
||||
* @param enableOffload Whether to enable use of audio offload for supported formats, if
|
||||
* available.
|
||||
* @param enableOffload If audio offload should be used.
|
||||
* @return This factory, for convenience.
|
||||
*/
|
||||
public DefaultRenderersFactory setEnableAudioOffload(boolean enableOffload) {
|
||||
@ -431,8 +423,7 @@ public class DefaultRenderersFactory implements RenderersFactory {
|
||||
* before output. May be empty.
|
||||
* @param eventHandler A handler to use when invoking event listeners and outputs.
|
||||
* @param eventListener An event listener.
|
||||
* @param enableOffload Whether to enable use of audio offload for supported formats, if
|
||||
* available.
|
||||
* @param enableOffload If the renderer should use audio offload for all supported formats.
|
||||
* @param out An array to which the built renderers should be appended.
|
||||
*/
|
||||
protected void buildAudioRenderers(
|
||||
|
@ -20,8 +20,6 @@ import android.os.Looper;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.VisibleForTesting;
|
||||
import com.google.android.exoplayer2.analytics.AnalyticsCollector;
|
||||
import com.google.android.exoplayer2.audio.AudioCapabilities;
|
||||
import com.google.android.exoplayer2.audio.DefaultAudioSink;
|
||||
import com.google.android.exoplayer2.audio.MediaCodecAudioRenderer;
|
||||
import com.google.android.exoplayer2.metadata.MetadataRenderer;
|
||||
import com.google.android.exoplayer2.source.ClippingMediaSource;
|
||||
@ -599,39 +597,4 @@ public interface ExoPlayer extends Player {
|
||||
* @see #setPauseAtEndOfMediaItems(boolean)
|
||||
*/
|
||||
boolean getPauseAtEndOfMediaItems();
|
||||
|
||||
/**
|
||||
* Enables audio offload scheduling, which runs ExoPlayer's main loop as rarely as possible when
|
||||
* playing an audio stream using audio offload.
|
||||
*
|
||||
* <p>Only use this scheduling mode if the player is not displaying anything to the user. For
|
||||
* example when the application is in the background, or the screen is off. The player state
|
||||
* (including position) is rarely updated (between 10s and 1min).
|
||||
*
|
||||
* <p>While offload scheduling is enabled, player events may be delivered severely delayed and
|
||||
* apps should not interact with the player. When returning to the foreground, disable offload
|
||||
* scheduling before interacting with the player
|
||||
*
|
||||
* <p>This mode should save significant power when the phone is playing offload audio with the
|
||||
* screen off.
|
||||
*
|
||||
* <p>This mode only has an effect when playing an audio track in offload mode, which requires all
|
||||
* the following:
|
||||
*
|
||||
* <ul>
|
||||
* <li>audio offload rendering is enabled in {@link
|
||||
* DefaultRenderersFactory#setEnableAudioOffload} or the equivalent option passed to {@link
|
||||
* com.google.android.exoplayer2.audio.DefaultAudioSink#DefaultAudioSink(AudioCapabilities,
|
||||
* DefaultAudioSink.AudioProcessorChain, boolean, boolean)}.
|
||||
* <li>an audio track is playing in a format which the device supports offloading (for example
|
||||
* MP3 or AAC).
|
||||
* <li>The {@link com.google.android.exoplayer2.audio.AudioSink} is playing with an offload
|
||||
* {@link android.media.AudioTrack}.
|
||||
* </ul>
|
||||
*
|
||||
* <p>This method is experimental, and will be renamed or removed in a future release.
|
||||
*
|
||||
* @param enableOffloadScheduling Whether to enable offload scheduling.
|
||||
*/
|
||||
void experimental_enableOffloadScheduling(boolean enableOffloadScheduling);
|
||||
}
|
||||
|
@ -202,11 +202,6 @@ import java.util.concurrent.TimeoutException;
|
||||
internalPlayer.experimental_throwWhenStuckBuffering();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void experimental_enableOffloadScheduling(boolean enableOffloadScheduling) {
|
||||
internalPlayer.experimental_enableOffloadScheduling(enableOffloadScheduling);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Nullable
|
||||
public AudioComponent getAudioComponent() {
|
||||
|
@ -94,15 +94,6 @@ import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
private static final int ACTIVE_INTERVAL_MS = 10;
|
||||
private static final int IDLE_INTERVAL_MS = 1000;
|
||||
/**
|
||||
* Duration under which pausing the main DO_SOME_WORK loop is not expected to yield significant
|
||||
* power saving.
|
||||
*
|
||||
* <p>This value is probably too high, power measurements are needed adjust it, but as renderer
|
||||
* sleep is currently only implemented for audio offload, which uses buffer much bigger than 2s,
|
||||
* this does not matter for now.
|
||||
*/
|
||||
private static final long MIN_RENDERER_SLEEP_DURATION_MS = 2000;
|
||||
|
||||
private final Renderer[] renderers;
|
||||
private final RendererCapabilities[] rendererCapabilities;
|
||||
@ -136,8 +127,6 @@ import java.util.concurrent.atomic.AtomicBoolean;
|
||||
@Player.RepeatMode private int repeatMode;
|
||||
private boolean shuffleModeEnabled;
|
||||
private boolean foregroundMode;
|
||||
private boolean requestForRendererSleep;
|
||||
private boolean offloadSchedulingEnabled;
|
||||
|
||||
private int enabledRendererCount;
|
||||
@Nullable private SeekPosition pendingInitialSeekPosition;
|
||||
@ -210,13 +199,6 @@ import java.util.concurrent.atomic.AtomicBoolean;
|
||||
throwWhenStuckBuffering = true;
|
||||
}
|
||||
|
||||
public void experimental_enableOffloadScheduling(boolean enableOffloadScheduling) {
|
||||
offloadSchedulingEnabled = enableOffloadScheduling;
|
||||
if (!enableOffloadScheduling) {
|
||||
handler.sendEmptyMessage(MSG_DO_SOME_WORK);
|
||||
}
|
||||
}
|
||||
|
||||
public void prepare() {
|
||||
handler.obtainMessage(MSG_PREPARE).sendToTarget();
|
||||
}
|
||||
@ -903,13 +885,12 @@ import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
if ((shouldPlayWhenReady() && playbackInfo.playbackState == Player.STATE_READY)
|
||||
|| playbackInfo.playbackState == Player.STATE_BUFFERING) {
|
||||
maybeScheduleWakeup(operationStartTimeMs, ACTIVE_INTERVAL_MS);
|
||||
scheduleNextWork(operationStartTimeMs, ACTIVE_INTERVAL_MS);
|
||||
} else if (enabledRendererCount != 0 && playbackInfo.playbackState != Player.STATE_ENDED) {
|
||||
scheduleNextWork(operationStartTimeMs, IDLE_INTERVAL_MS);
|
||||
} else {
|
||||
handler.removeMessages(MSG_DO_SOME_WORK);
|
||||
}
|
||||
requestForRendererSleep = false; // A sleep request is only valid for the current doSomeWork.
|
||||
|
||||
TraceUtil.endSection();
|
||||
}
|
||||
@ -919,14 +900,6 @@ import java.util.concurrent.atomic.AtomicBoolean;
|
||||
handler.sendEmptyMessageAtTime(MSG_DO_SOME_WORK, thisOperationStartTimeMs + intervalMs);
|
||||
}
|
||||
|
||||
private void maybeScheduleWakeup(long operationStartTimeMs, long intervalMs) {
|
||||
if (offloadSchedulingEnabled && requestForRendererSleep) {
|
||||
return;
|
||||
}
|
||||
|
||||
scheduleNextWork(operationStartTimeMs, intervalMs);
|
||||
}
|
||||
|
||||
private void seekToInternal(SeekPosition seekPosition) throws ExoPlaybackException {
|
||||
playbackInfoUpdate.incrementPendingOperationAcks(/* operationAcks= */ 1);
|
||||
|
||||
@ -2095,24 +2068,6 @@ import java.util.concurrent.atomic.AtomicBoolean;
|
||||
joining,
|
||||
mayRenderStartOfStream,
|
||||
periodHolder.getRendererOffset());
|
||||
|
||||
renderer.handleMessage(
|
||||
Renderer.MSG_SET_WAKEUP_LISTENER,
|
||||
new Renderer.WakeupListener() {
|
||||
@Override
|
||||
public void onSleep(long wakeupDeadlineMs) {
|
||||
// Do not sleep if the expected sleep time is not long enough to save significant power.
|
||||
if (wakeupDeadlineMs >= MIN_RENDERER_SLEEP_DURATION_MS) {
|
||||
requestForRendererSleep = true;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onWakeup() {
|
||||
handler.sendEmptyMessage(MSG_DO_SOME_WORK);
|
||||
}
|
||||
});
|
||||
|
||||
mediaClock.onRendererEnabled(renderer);
|
||||
// Start the renderer if playing.
|
||||
if (playing) {
|
||||
|
@ -46,30 +46,6 @@ import java.lang.annotation.RetentionPolicy;
|
||||
*/
|
||||
public interface Renderer extends PlayerMessage.Target {
|
||||
|
||||
/**
|
||||
* Some renderers can signal when {@link #render(long, long)} should be called.
|
||||
*
|
||||
* <p>That allows the player to sleep until the next wakeup, instead of calling {@link
|
||||
* #render(long, long)} in a tight loop. The aim of this interrupt based scheduling is to save
|
||||
* power.
|
||||
*/
|
||||
interface WakeupListener {
|
||||
|
||||
/**
|
||||
* The renderer no longer needs to render until the next wakeup.
|
||||
*
|
||||
* @param wakeupDeadlineMs Maximum time in milliseconds until {@link #onWakeup()} will be
|
||||
* called.
|
||||
*/
|
||||
void onSleep(long wakeupDeadlineMs);
|
||||
|
||||
/**
|
||||
* The renderer needs to render some frames. The client should call {@link #render(long, long)}
|
||||
* at its earliest convenience.
|
||||
*/
|
||||
void onWakeup();
|
||||
}
|
||||
|
||||
/**
|
||||
* The type of a message that can be passed to a video renderer via {@link
|
||||
* ExoPlayer#createMessage(Target)}. The message payload should be the target {@link Surface}, or
|
||||
@ -161,14 +137,6 @@ public interface Renderer extends PlayerMessage.Target {
|
||||
* representing the audio session ID that will be attached to the underlying audio track.
|
||||
*/
|
||||
int MSG_SET_AUDIO_SESSION_ID = 102;
|
||||
/**
|
||||
* A type of a message that can be passed to a {@link Renderer} via {@link
|
||||
* ExoPlayer#createMessage(Target)}, to inform the renderer that it can schedule waking up another
|
||||
* component.
|
||||
*
|
||||
* <p>The message payload must be a {@link WakeupListener} instance.
|
||||
*/
|
||||
int MSG_SET_WAKEUP_LISTENER = 103;
|
||||
/**
|
||||
* Applications or extensions may define custom {@code MSG_*} constants that can be passed to
|
||||
* renderers. These custom constants must be greater than or equal to this value.
|
||||
|
@ -633,11 +633,6 @@ public class SimpleExoPlayer extends BasePlayer
|
||||
C.TRACK_TYPE_AUDIO, Renderer.MSG_SET_SKIP_SILENCE_ENABLED, skipSilenceEnabled);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void experimental_enableOffloadScheduling(boolean enableOffloadScheduling) {
|
||||
player.experimental_enableOffloadScheduling(enableOffloadScheduling);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Nullable
|
||||
public AudioComponent getAudioComponent() {
|
||||
|
@ -90,17 +90,6 @@ public interface AudioSink {
|
||||
* @param skipSilenceEnabled Whether skipping silences is enabled.
|
||||
*/
|
||||
void onSkipSilenceEnabledChanged(boolean skipSilenceEnabled);
|
||||
|
||||
/** Called when the offload buffer has been partially emptied. */
|
||||
default void onOffloadBufferEmptying() {}
|
||||
|
||||
/**
|
||||
* Called when the offload buffer has been filled completely.
|
||||
*
|
||||
* @param bufferEmptyingDeadlineMs Maximum time in milliseconds until {@link
|
||||
* #onOffloadBufferEmptying()} will be called.
|
||||
*/
|
||||
default void onOffloadBufferFull(long bufferEmptyingDeadlineMs) {}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -335,11 +335,6 @@ import java.lang.reflect.Method;
|
||||
return bufferSize - bytesPending;
|
||||
}
|
||||
|
||||
/** Returns the duration of audio that is buffered but unplayed. */
|
||||
public long getPendingBufferDurationMs(long writtenFrames) {
|
||||
return C.usToMs(framesToDurationUs(writtenFrames - getPlaybackHeadPosition()));
|
||||
}
|
||||
|
||||
/** Returns whether the track is in an invalid state and must be recreated. */
|
||||
public boolean isStalled(long writtenFrames) {
|
||||
return forceResetWorkaroundTimeMs != C.TIME_UNSET
|
||||
|
@ -20,7 +20,6 @@ import android.media.AudioFormat;
|
||||
import android.media.AudioManager;
|
||||
import android.media.AudioTrack;
|
||||
import android.os.ConditionVariable;
|
||||
import android.os.Handler;
|
||||
import android.os.SystemClock;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.RequiresApi;
|
||||
@ -275,7 +274,6 @@ public final class DefaultAudioSink implements AudioSink {
|
||||
private final AudioTrackPositionTracker audioTrackPositionTracker;
|
||||
private final ArrayDeque<MediaPositionParameters> mediaPositionParametersCheckpoints;
|
||||
private final boolean enableOffload;
|
||||
@MonotonicNonNull private StreamEventCallback offloadStreamEventCallback;
|
||||
|
||||
@Nullable private Listener listener;
|
||||
/** Used to keep the audio session active on pre-V21 builds (see {@link #initialize(long)}). */
|
||||
@ -306,7 +304,7 @@ public final class DefaultAudioSink implements AudioSink {
|
||||
@Nullable private ByteBuffer inputBuffer;
|
||||
private int inputBufferAccessUnitCount;
|
||||
@Nullable private ByteBuffer outputBuffer;
|
||||
@MonotonicNonNull private byte[] preV21OutputBuffer;
|
||||
private byte[] preV21OutputBuffer;
|
||||
private int preV21OutputBufferOffset;
|
||||
private int drainingAudioProcessorIndex;
|
||||
private boolean handledEndOfStream;
|
||||
@ -368,10 +366,7 @@ public final class DefaultAudioSink implements AudioSink {
|
||||
* be available when float output is in use.
|
||||
* @param enableOffload Whether audio offloading is enabled. If an audio format can be both played
|
||||
* with offload and encoded audio passthrough, it will be played in offload. Audio offload is
|
||||
* supported starting with API 29 ({@link android.os.Build.VERSION_CODES#Q}). Most Android
|
||||
* devices can only support one offload {@link android.media.AudioTrack} at a time and can
|
||||
* invalidate it at any time. Thus an app can never be guaranteed that it will be able to play
|
||||
* in offload.
|
||||
* supported starting with API 29 ({@link android.os.Build.VERSION_CODES#Q}).
|
||||
*/
|
||||
public DefaultAudioSink(
|
||||
@Nullable AudioCapabilities audioCapabilities,
|
||||
@ -409,7 +404,6 @@ public final class DefaultAudioSink implements AudioSink {
|
||||
activeAudioProcessors = new AudioProcessor[0];
|
||||
outputBuffers = new ByteBuffer[0];
|
||||
mediaPositionParametersCheckpoints = new ArrayDeque<>();
|
||||
offloadStreamEventCallback = Util.SDK_INT >= 29 ? new StreamEventCallback() : null;
|
||||
}
|
||||
|
||||
// AudioSink implementation.
|
||||
@ -569,9 +563,6 @@ public final class DefaultAudioSink implements AudioSink {
|
||||
audioTrack =
|
||||
Assertions.checkNotNull(configuration)
|
||||
.buildAudioTrack(tunneling, audioAttributes, audioSessionId);
|
||||
if (isOffloadedPlayback(audioTrack)) {
|
||||
registerStreamEventCallback(audioTrack);
|
||||
}
|
||||
int audioSessionId = audioTrack.getAudioSessionId();
|
||||
if (enablePreV21AudioSessionWorkaround) {
|
||||
if (Util.SDK_INT < 21) {
|
||||
@ -753,16 +744,6 @@ public final class DefaultAudioSink implements AudioSink {
|
||||
return false;
|
||||
}
|
||||
|
||||
@RequiresApi(29)
|
||||
private void registerStreamEventCallback(AudioTrack audioTrack) {
|
||||
if (offloadStreamEventCallback == null) {
|
||||
// Must be lazily initialized to receive stream event callbacks on the current (playback)
|
||||
// thread as the constructor is not called in the playback thread.
|
||||
offloadStreamEventCallback = new StreamEventCallback();
|
||||
}
|
||||
offloadStreamEventCallback.register(audioTrack);
|
||||
}
|
||||
|
||||
private void processBuffers(long avSyncPresentationTimeUs) throws WriteException {
|
||||
int count = activeAudioProcessors.length;
|
||||
int index = count;
|
||||
@ -841,15 +822,6 @@ public final class DefaultAudioSink implements AudioSink {
|
||||
throw new WriteException(bytesWritten);
|
||||
}
|
||||
|
||||
if (playing
|
||||
&& listener != null
|
||||
&& bytesWritten < bytesRemaining
|
||||
&& isOffloadedPlayback(audioTrack)) {
|
||||
long pendingDurationMs =
|
||||
audioTrackPositionTracker.getPendingBufferDurationMs(writtenEncodedFrames);
|
||||
listener.onOffloadBufferFull(pendingDurationMs);
|
||||
}
|
||||
|
||||
if (configuration.isInputPcm) {
|
||||
writtenPcmBytes += bytesWritten;
|
||||
}
|
||||
@ -1068,9 +1040,6 @@ public final class DefaultAudioSink implements AudioSink {
|
||||
if (audioTrackPositionTracker.isPlaying()) {
|
||||
audioTrack.pause();
|
||||
}
|
||||
if (isOffloadedPlayback(audioTrack)) {
|
||||
Assertions.checkNotNull(offloadStreamEventCallback).unregister(audioTrack);
|
||||
}
|
||||
// AudioTrack.release can take some time, so we call it on a background thread.
|
||||
final AudioTrack toRelease = audioTrack;
|
||||
audioTrack = null;
|
||||
@ -1260,36 +1229,6 @@ public final class DefaultAudioSink implements AudioSink {
|
||||
audioFormat, audioAttributes.getAudioAttributesV21());
|
||||
}
|
||||
|
||||
private static boolean isOffloadedPlayback(AudioTrack audioTrack) {
|
||||
return Util.SDK_INT >= 29 && audioTrack.isOffloadedPlayback();
|
||||
}
|
||||
|
||||
@RequiresApi(29)
|
||||
private final class StreamEventCallback extends AudioTrack.StreamEventCallback {
|
||||
private final Handler handler;
|
||||
|
||||
public StreamEventCallback() {
|
||||
handler = new Handler();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDataRequest(AudioTrack track, int size) {
|
||||
Assertions.checkState(track == DefaultAudioSink.this.audioTrack);
|
||||
if (listener != null) {
|
||||
listener.onOffloadBufferEmptying();
|
||||
}
|
||||
}
|
||||
|
||||
public void register(AudioTrack audioTrack) {
|
||||
audioTrack.registerStreamEventCallback(handler::post, this);
|
||||
}
|
||||
|
||||
public void unregister(AudioTrack audioTrack) {
|
||||
audioTrack.unregisterStreamEventCallback(this);
|
||||
handler.removeCallbacksAndMessages(/* token= */ null);
|
||||
}
|
||||
}
|
||||
|
||||
private static AudioTrack initializeKeepSessionIdAudioTrack(int audioSessionId) {
|
||||
int sampleRate = 4000; // Equal to private AudioTrack.MIN_SAMPLE_RATE.
|
||||
int channelConfig = AudioFormat.CHANNEL_OUT_MONO;
|
||||
|
@ -92,8 +92,6 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media
|
||||
private boolean allowFirstBufferPositionDiscontinuity;
|
||||
private boolean allowPositionDiscontinuity;
|
||||
|
||||
@Nullable private WakeupListener wakeupListener;
|
||||
|
||||
/**
|
||||
* @param context A context.
|
||||
* @param mediaCodecSelector A decoder selector.
|
||||
@ -698,9 +696,6 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media
|
||||
case MSG_SET_AUDIO_SESSION_ID:
|
||||
audioSink.setAudioSessionId((Integer) message);
|
||||
break;
|
||||
case MSG_SET_WAKEUP_LISTENER:
|
||||
this.wakeupListener = (WakeupListener) message;
|
||||
break;
|
||||
default:
|
||||
super.handleMessage(messageType, message);
|
||||
break;
|
||||
@ -880,19 +875,5 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media
|
||||
eventDispatcher.skipSilenceEnabledChanged(skipSilenceEnabled);
|
||||
onAudioTrackSkipSilenceEnabledChanged(skipSilenceEnabled);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onOffloadBufferEmptying() {
|
||||
if (wakeupListener != null) {
|
||||
wakeupListener.onWakeup();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onOffloadBufferFull(long bufferEmptyingDeadlineMs) {
|
||||
if (wakeupListener != null) {
|
||||
wakeupListener.onSleep(bufferEmptyingDeadlineMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -465,9 +465,4 @@ public abstract class StubExoPlayer extends BasePlayer implements ExoPlayer {
|
||||
public boolean getPauseAtEndOfMediaItems() {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void experimental_enableOffloadScheduling(boolean enableOffloadScheduling) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user