Commit 2a0dc414 by kimvde Committed by Rohit Singh

Add Transformer internal thread

This thread just starts the player and handles the player callbacks for
now. Sample pipelines are still run on the playback thread.

PiperOrigin-RevId: 491299671
parent bf77290f
...@@ -63,6 +63,7 @@ import com.google.android.exoplayer2.video.VideoRendererEventListener; ...@@ -63,6 +63,7 @@ import com.google.android.exoplayer2.video.VideoRendererEventListener;
void onError(Exception e); void onError(Exception e);
} }
private final MediaItem mediaItem;
private final ExoPlayer player; private final ExoPlayer player;
public ExoPlayerAssetLoader( public ExoPlayerAssetLoader(
...@@ -71,8 +72,10 @@ import com.google.android.exoplayer2.video.VideoRendererEventListener; ...@@ -71,8 +72,10 @@ import com.google.android.exoplayer2.video.VideoRendererEventListener;
boolean removeAudio, boolean removeAudio,
boolean removeVideo, boolean removeVideo,
MediaSource.Factory mediaSourceFactory, MediaSource.Factory mediaSourceFactory,
Looper looper,
Listener listener, Listener listener,
Clock clock) { Clock clock) {
this.mediaItem = mediaItem;
DefaultTrackSelector trackSelector = new DefaultTrackSelector(context); DefaultTrackSelector trackSelector = new DefaultTrackSelector(context);
trackSelector.setParameters( trackSelector.setParameters(
new DefaultTrackSelector.Parameters.Builder(context) new DefaultTrackSelector.Parameters.Builder(context)
...@@ -92,7 +95,8 @@ import com.google.android.exoplayer2.video.VideoRendererEventListener; ...@@ -92,7 +95,8 @@ import com.google.android.exoplayer2.video.VideoRendererEventListener;
new ExoPlayer.Builder(context, new RenderersFactoryImpl(removeAudio, removeVideo, listener)) new ExoPlayer.Builder(context, new RenderersFactoryImpl(removeAudio, removeVideo, listener))
.setMediaSourceFactory(mediaSourceFactory) .setMediaSourceFactory(mediaSourceFactory)
.setTrackSelector(trackSelector) .setTrackSelector(trackSelector)
.setLoadControl(loadControl); .setLoadControl(loadControl)
.setLooper(looper);
if (clock != Clock.DEFAULT) { if (clock != Clock.DEFAULT) {
// Transformer.Builder#setClock is also @VisibleForTesting, so if we're using a non-default // Transformer.Builder#setClock is also @VisibleForTesting, so if we're using a non-default
// clock we must be in a test context. // clock we must be in a test context.
...@@ -101,11 +105,11 @@ import com.google.android.exoplayer2.video.VideoRendererEventListener; ...@@ -101,11 +105,11 @@ import com.google.android.exoplayer2.video.VideoRendererEventListener;
} }
player = playerBuilder.build(); player = playerBuilder.build();
player.setMediaItem(mediaItem);
player.addListener(new PlayerListener(listener)); player.addListener(new PlayerListener(listener));
} }
public void start() { public void start() {
player.setMediaItem(mediaItem);
player.prepare(); player.prepare();
} }
...@@ -113,10 +117,6 @@ import com.google.android.exoplayer2.video.VideoRendererEventListener; ...@@ -113,10 +117,6 @@ import com.google.android.exoplayer2.video.VideoRendererEventListener;
player.release(); player.release();
} }
public Looper getPlaybackLooper() {
return player.getPlaybackLooper();
}
private static final class RenderersFactoryImpl implements RenderersFactory { private static final class RenderersFactoryImpl implements RenderersFactory {
private final TransformerMediaClock mediaClock; private final TransformerMediaClock mediaClock;
......
...@@ -16,12 +16,10 @@ ...@@ -16,12 +16,10 @@
package com.google.android.exoplayer2.transformer; package com.google.android.exoplayer2.transformer;
import static com.google.android.exoplayer2.transformer.TransformerInternal.END_TRANSFORMATION_REASON_CANCELLED;
import static com.google.android.exoplayer2.util.Assertions.checkState; import static com.google.android.exoplayer2.util.Assertions.checkState;
import static java.lang.annotation.ElementType.TYPE_USE; import static java.lang.annotation.ElementType.TYPE_USE;
import android.content.Context; import android.content.Context;
import android.os.Handler;
import android.os.Looper; import android.os.Looper;
import android.os.ParcelFileDescriptor; import android.os.ParcelFileDescriptor;
import androidx.annotation.IntDef; import androidx.annotation.IntDef;
...@@ -43,6 +41,7 @@ import com.google.android.exoplayer2.util.Clock; ...@@ -43,6 +41,7 @@ import com.google.android.exoplayer2.util.Clock;
import com.google.android.exoplayer2.util.DebugViewProvider; import com.google.android.exoplayer2.util.DebugViewProvider;
import com.google.android.exoplayer2.util.Effect; import com.google.android.exoplayer2.util.Effect;
import com.google.android.exoplayer2.util.FrameProcessor; import com.google.android.exoplayer2.util.FrameProcessor;
import com.google.android.exoplayer2.util.HandlerWrapper;
import com.google.android.exoplayer2.util.ListenerSet; import com.google.android.exoplayer2.util.ListenerSet;
import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.MimeTypes;
import com.google.android.exoplayer2.util.Util; import com.google.android.exoplayer2.util.Util;
...@@ -732,6 +731,7 @@ public final class Transformer { ...@@ -732,6 +731,7 @@ public final class Transformer {
encoderFactory, encoderFactory,
frameProcessorFactory, frameProcessorFactory,
muxerFactory, muxerFactory,
looper,
transformerInternalListener, transformerInternalListener,
fallbackListener, fallbackListener,
debugViewProvider, debugViewProvider,
...@@ -778,11 +778,10 @@ public final class Transformer { ...@@ -778,11 +778,10 @@ public final class Transformer {
return; return;
} }
try { try {
transformerInternal.release(END_TRANSFORMATION_REASON_CANCELLED); transformerInternal.cancel();
} catch (TransformationException impossible) { } finally {
throw new IllegalStateException(impossible); transformerInternal = null;
} }
transformerInternal = null;
} }
private void verifyApplicationThread() { private void verifyApplicationThread() {
...@@ -794,18 +793,17 @@ public final class Transformer { ...@@ -794,18 +793,17 @@ public final class Transformer {
private final class TransformerInternalListener implements TransformerInternal.Listener { private final class TransformerInternalListener implements TransformerInternal.Listener {
private final MediaItem mediaItem; private final MediaItem mediaItem;
private final Handler handler; private final HandlerWrapper handler;
public TransformerInternalListener(MediaItem mediaItem) { public TransformerInternalListener(MediaItem mediaItem) {
this.mediaItem = mediaItem; this.mediaItem = mediaItem;
handler = Util.createHandlerForCurrentLooper(); handler = clock.createHandler(looper, /* callback= */ null);
} }
@Override @Override
public void onTransformationCompleted(TransformationResult transformationResult) { public void onTransformationCompleted(TransformationResult transformationResult) {
// TODO(b/213341814): Add event flags for Transformer events. // TODO(b/213341814): Add event flags for Transformer events.
Util.postOrRun( handler.post(
handler,
() -> { () -> {
transformerInternal = null; transformerInternal = null;
listeners.queueEvent( listeners.queueEvent(
...@@ -817,8 +815,7 @@ public final class Transformer { ...@@ -817,8 +815,7 @@ public final class Transformer {
@Override @Override
public void onTransformationError(TransformationException exception) { public void onTransformationError(TransformationException exception) {
Util.postOrRun( handler.post(
handler,
() -> { () -> {
transformerInternal = null; transformerInternal = null;
listeners.queueEvent( listeners.queueEvent(
......
...@@ -16,8 +16,8 @@ ...@@ -16,8 +16,8 @@
package com.google.android.exoplayer2.transformer; package com.google.android.exoplayer2.transformer;
import static com.google.android.exoplayer2.transformer.TransformationException.ERROR_CODE_MUXING_FAILED;
import static com.google.android.exoplayer2.transformer.Transformer.PROGRESS_STATE_AVAILABLE; import static com.google.android.exoplayer2.transformer.Transformer.PROGRESS_STATE_AVAILABLE;
import static com.google.android.exoplayer2.transformer.Transformer.PROGRESS_STATE_NO_TRANSFORMATION;
import static com.google.android.exoplayer2.transformer.Transformer.PROGRESS_STATE_UNAVAILABLE; import static com.google.android.exoplayer2.transformer.Transformer.PROGRESS_STATE_UNAVAILABLE;
import static com.google.android.exoplayer2.transformer.Transformer.PROGRESS_STATE_WAITING_FOR_AVAILABILITY; import static com.google.android.exoplayer2.transformer.Transformer.PROGRESS_STATE_WAITING_FOR_AVAILABILITY;
import static com.google.android.exoplayer2.util.Assertions.checkNotNull; import static com.google.android.exoplayer2.util.Assertions.checkNotNull;
...@@ -25,7 +25,9 @@ import static java.lang.Math.min; ...@@ -25,7 +25,9 @@ import static java.lang.Math.min;
import static java.lang.annotation.ElementType.TYPE_USE; import static java.lang.annotation.ElementType.TYPE_USE;
import android.content.Context; import android.content.Context;
import android.os.Handler; import android.os.HandlerThread;
import android.os.Looper;
import android.os.Message;
import android.os.ParcelFileDescriptor; import android.os.ParcelFileDescriptor;
import androidx.annotation.IntDef; import androidx.annotation.IntDef;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
...@@ -62,26 +64,24 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; ...@@ -62,26 +64,24 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
} }
/** /**
* Represents a reason for ending a transformation. May be one of {@link * Represents a reason for ending a transformation. May be one of {@link #END_REASON_COMPLETED},
* #END_TRANSFORMATION_REASON_COMPLETED}, {@link #END_TRANSFORMATION_REASON_CANCELLED} or {@link * {@link #END_REASON_CANCELLED} or {@link #END_REASON_ERROR}.
* #END_TRANSFORMATION_REASON_ERROR}.
*/ */
@Documented @Documented
@Retention(RetentionPolicy.SOURCE) @Retention(RetentionPolicy.SOURCE)
@Target(TYPE_USE) @Target(TYPE_USE)
@IntDef({ @IntDef({END_REASON_COMPLETED, END_REASON_CANCELLED, END_REASON_ERROR})
END_TRANSFORMATION_REASON_COMPLETED, private @interface EndReason {}
END_TRANSFORMATION_REASON_CANCELLED,
END_TRANSFORMATION_REASON_ERROR
})
public @interface EndTransformationReason {}
/** The transformation completed successfully. */ /** The transformation completed successfully. */
public static final int END_TRANSFORMATION_REASON_COMPLETED = 0; private static final int END_REASON_COMPLETED = 0;
/** The transformation was cancelled. */ /** The transformation was cancelled. */
public static final int END_TRANSFORMATION_REASON_CANCELLED = 1; private static final int END_REASON_CANCELLED = 1;
/** An error occurred during the transformation. */ /** An error occurred during the transformation. */
public static final int END_TRANSFORMATION_REASON_ERROR = 2; private static final int END_REASON_ERROR = 2;
// Internal messages.
private static final int MSG_START = 0;
private static final int MSG_END = 1;
private final Context context; private final Context context;
private final TransformationRequest transformationRequest; private final TransformationRequest transformationRequest;
...@@ -93,17 +93,18 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; ...@@ -93,17 +93,18 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
private final Listener listener; private final Listener listener;
private final DebugViewProvider debugViewProvider; private final DebugViewProvider debugViewProvider;
private final Clock clock; private final Clock clock;
private final Handler handler; private final HandlerWrapper applicationHandler;
private final HandlerThread internalHandlerThread;
private final HandlerWrapper internalHandler;
private final ExoPlayerAssetLoader exoPlayerAssetLoader; private final ExoPlayerAssetLoader exoPlayerAssetLoader;
private final MuxerWrapper muxerWrapper; private final MuxerWrapper muxerWrapper;
private final ConditionVariable releasingMuxerConditionVariable; private final ConditionVariable cancellingConditionVariable;
private @Transformer.ProgressState int progressState; private @Transformer.ProgressState int progressState;
private long progressPositionMs; private long progressPositionMs;
private long durationMs; private long durationMs;
private boolean released; private boolean released;
private volatile @MonotonicNonNull TransformationResult transformationResult; private @MonotonicNonNull RuntimeException cancelException;
private volatile @MonotonicNonNull TransformationException releaseMuxerException;
public TransformerInternal( public TransformerInternal(
Context context, Context context,
...@@ -120,6 +121,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; ...@@ -120,6 +121,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
Codec.EncoderFactory encoderFactory, Codec.EncoderFactory encoderFactory,
FrameProcessor.Factory frameProcessorFactory, FrameProcessor.Factory frameProcessorFactory,
Muxer.Factory muxerFactory, Muxer.Factory muxerFactory,
Looper applicationLooper,
Listener listener, Listener listener,
FallbackListener fallbackListener, FallbackListener fallbackListener,
DebugViewProvider debugViewProvider, DebugViewProvider debugViewProvider,
...@@ -134,14 +136,11 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; ...@@ -134,14 +136,11 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
this.listener = listener; this.listener = listener;
this.debugViewProvider = debugViewProvider; this.debugViewProvider = debugViewProvider;
this.clock = clock; this.clock = clock;
handler = Util.createHandlerForCurrentLooper(); applicationHandler = clock.createHandler(applicationLooper, /* callback= */ null);
internalHandlerThread = new HandlerThread("Transformer:Internal");
internalHandlerThread.start();
Looper internalLooper = internalHandlerThread.getLooper();
ComponentListener componentListener = new ComponentListener(mediaItem, fallbackListener); ComponentListener componentListener = new ComponentListener(mediaItem, fallbackListener);
muxerWrapper =
new MuxerWrapper(
outputPath,
outputParcelFileDescriptor,
muxerFactory,
/* errorConsumer= */ componentListener::onTransformationError);
exoPlayerAssetLoader = exoPlayerAssetLoader =
new ExoPlayerAssetLoader( new ExoPlayerAssetLoader(
context, context,
...@@ -149,14 +148,26 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; ...@@ -149,14 +148,26 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
removeAudio, removeAudio,
removeVideo, removeVideo,
mediaSourceFactory, mediaSourceFactory,
internalLooper,
componentListener, componentListener,
clock); clock);
releasingMuxerConditionVariable = new ConditionVariable(); muxerWrapper =
new MuxerWrapper(
outputPath,
outputParcelFileDescriptor,
muxerFactory,
/* errorConsumer= */ componentListener::onTransformationError);
cancellingConditionVariable = new ConditionVariable();
progressState = PROGRESS_STATE_WAITING_FOR_AVAILABILITY; progressState = PROGRESS_STATE_WAITING_FOR_AVAILABILITY;
// It's safe to use "this" because we don't send a message before exiting the constructor.
@SuppressWarnings("nullness:methodref.receiver.bound")
HandlerWrapper internalHandler =
clock.createHandler(internalLooper, /* callback= */ this::handleMessage);
this.internalHandler = internalHandler;
} }
public void start() { public void start() {
exoPlayerAssetLoader.start(); internalHandler.sendEmptyMessage(MSG_START);
} }
public @Transformer.ProgressState int getProgress(ProgressHolder progressHolder) { public @Transformer.ProgressState int getProgress(ProgressHolder progressHolder) {
...@@ -166,26 +177,53 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; ...@@ -166,26 +177,53 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
return progressState; return progressState;
} }
/** public void cancel() {
* Releases the resources. internalHandler
* .obtainMessage(
* @param endTransformationReason The {@linkplain EndTransformationReason reason} for ending the MSG_END, END_REASON_CANCELLED, /* unused */ 0, /* transformationException */ null)
* transformation. .sendToTarget();
* @throws TransformationException If the muxer is in the wrong state and {@code clock.onThreadBlocked();
* endTransformationReason} is not {@link #END_TRANSFORMATION_REASON_CANCELLED}. cancellingConditionVariable.blockUninterruptible();
*/ if (cancelException != null) {
public void release(@EndTransformationReason int endTransformationReason) throw cancelException;
throws TransformationException { }
if (released) { }
return;
private boolean handleMessage(Message msg) {
try {
switch (msg.what) {
case MSG_START:
startInternal();
break;
case MSG_END:
endInternal(
/* endReason= */ msg.arg1,
/* transformationException= */ (TransformationException) msg.obj);
break;
default:
return false;
}
} catch (RuntimeException e) {
endInternal(END_REASON_ERROR, TransformationException.createForUnexpected(e));
} }
progressState = PROGRESS_STATE_NO_TRANSFORMATION; return true;
released = true; }
HandlerWrapper playbackHandler =
clock.createHandler(exoPlayerAssetLoader.getPlaybackLooper(), /* callback= */ null); private void startInternal() {
playbackHandler.post( exoPlayerAssetLoader.start();
() -> { }
if (endTransformationReason == END_TRANSFORMATION_REASON_COMPLETED) {
private void endInternal(
@EndReason int endReason, @Nullable TransformationException transformationException) {
@Nullable TransformationResult transformationResult = null;
boolean forCancellation = endReason == END_REASON_CANCELLED;
@Nullable TransformationException releaseTransformationException = null;
if (!released) {
released = true;
try {
try {
exoPlayerAssetLoader.release();
if (endReason == END_REASON_COMPLETED) {
transformationResult = transformationResult =
new TransformationResult.Builder() new TransformationResult.Builder()
.setDurationMs(checkNotNull(muxerWrapper).getDurationMs()) .setDurationMs(checkNotNull(muxerWrapper).getDurationMs())
...@@ -195,24 +233,37 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; ...@@ -195,24 +233,37 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
.setFileSizeBytes(muxerWrapper.getCurrentOutputSizeBytes()) .setFileSizeBytes(muxerWrapper.getCurrentOutputSizeBytes())
.build(); .build();
} }
try { } finally {
muxerWrapper.release( muxerWrapper.release(forCancellation);
/* forCancellation= */ endTransformationReason }
== END_TRANSFORMATION_REASON_CANCELLED); } catch (Muxer.MuxerException e) {
} catch (Muxer.MuxerException e) { releaseTransformationException =
releaseMuxerException = TransformationException.createForMuxer(e, ERROR_CODE_MUXING_FAILED);
TransformationException.createForMuxer( } catch (RuntimeException e) {
e, TransformationException.ERROR_CODE_MUXING_FAILED); releaseTransformationException = TransformationException.createForUnexpected(e);
} finally { cancelException = e;
releasingMuxerConditionVariable.open(); }
} }
});
clock.onThreadBlocked(); if (!forCancellation) {
releasingMuxerConditionVariable.blockUninterruptible(); TransformationException exception = transformationException;
exoPlayerAssetLoader.release(); if (exception == null) {
if (releaseMuxerException != null) { // We only report the exception caused by releasing the resources if there is no other
throw releaseMuxerException; // exception. It is more intuitive to call the error callback only once and reporting the
// exception caused by releasing the resources can be confusing if it is a consequence of
// the first exception.
exception = releaseTransformationException;
}
if (exception != null) {
listener.onTransformationError(exception);
} else {
listener.onTransformationCompleted(checkNotNull(transformationResult));
}
} }
internalHandlerThread.quitSafely();
cancellingConditionVariable.open();
} }
private class ComponentListener private class ComponentListener
...@@ -236,14 +287,17 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; ...@@ -236,14 +287,17 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
@Override @Override
public void onDurationMs(long durationMs) { public void onDurationMs(long durationMs) {
// Make progress permanently unavailable if the duration is unknown, so that it doesn't jump applicationHandler.post(
// to a high value at the end of the transformation if the duration is set once the media is () -> {
// entirely loaded. // Make progress permanently unavailable if the duration is unknown, so that it doesn't
progressState = // jump to a high value at the end of the transformation if the duration is set once the
durationMs <= 0 || durationMs == C.TIME_UNSET // media is entirely loaded.
? PROGRESS_STATE_UNAVAILABLE progressState =
: PROGRESS_STATE_AVAILABLE; durationMs <= 0 || durationMs == C.TIME_UNSET
TransformerInternal.this.durationMs = durationMs; ? PROGRESS_STATE_UNAVAILABLE
: PROGRESS_STATE_AVAILABLE;
TransformerInternal.this.durationMs = durationMs;
});
} }
@Override @Override
...@@ -278,12 +332,15 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; ...@@ -278,12 +332,15 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
} else { } else {
transformationException = TransformationException.createForUnexpected(e); transformationException = TransformationException.createForUnexpected(e);
} }
handleTransformationEnded(transformationException); onTransformationError(transformationException);
} }
@Override @Override
public void onEnded() { public void onEnded() {
handleTransformationEnded(/* transformationException= */ null); internalHandler
.obtainMessage(
MSG_END, END_REASON_COMPLETED, /* unused */ 0, /* transformationException */ null)
.sendToTarget();
} }
// SamplePipeline.Listener implementation. // SamplePipeline.Listener implementation.
...@@ -295,15 +352,17 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; ...@@ -295,15 +352,17 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
if (elapsedTimeMs > lastProgressUpdateMs + MIN_DURATION_BETWEEN_PROGRESS_UPDATES_MS if (elapsedTimeMs > lastProgressUpdateMs + MIN_DURATION_BETWEEN_PROGRESS_UPDATES_MS
&& positionMs > lastProgressPositionMs) { && positionMs > lastProgressPositionMs) {
lastProgressUpdateMs = elapsedTimeMs; lastProgressUpdateMs = elapsedTimeMs;
// Store positionMs in a local variable to make sure the thread reads the latest value. // Store positionMs in a variable to make sure the thread reads the latest value.
lastProgressPositionMs = positionMs; lastProgressPositionMs = positionMs;
handler.post(() -> progressPositionMs = positionMs); applicationHandler.post(() -> progressPositionMs = positionMs);
} }
} }
@Override @Override
public void onTransformationError(TransformationException transformationException) { public void onTransformationError(TransformationException transformationException) {
handleTransformationEnded(transformationException); internalHandler
.obtainMessage(MSG_END, END_REASON_ERROR, /* unused */ 0, transformationException)
.sendToTarget();
} }
private SamplePipeline getSamplePipeline( private SamplePipeline getSamplePipeline(
...@@ -427,37 +486,5 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; ...@@ -427,37 +486,5 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
} }
return false; return false;
} }
private void handleTransformationEnded(
@Nullable TransformationException transformationException) {
handler.post(
() -> {
@Nullable TransformationException releaseException = null;
try {
release(
transformationException == null
? END_TRANSFORMATION_REASON_COMPLETED
: END_TRANSFORMATION_REASON_ERROR);
} catch (TransformationException e) {
releaseException = e;
} catch (RuntimeException e) {
releaseException = TransformationException.createForUnexpected(e);
}
TransformationException exception = transformationException;
if (exception == null) {
// We only report the exception caused by releasing the resources if there is no other
// exception. It is more intuitive to call the error callback only once and reporting
// the exception caused by releasing the resources can be confusing if it is a
// consequence of the first exception.
exception = releaseException;
}
if (exception != null) {
listener.onTransformationError(exception);
} else {
listener.onTransformationCompleted(checkNotNull(transformationResult));
}
});
}
} }
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment