-Added loader eviction to avoid spawning too many threads in MediaSourceManager.

-Added nonnull and final constraints to variables in MediaSourceManager.
-Added nonnull and final constraints on context related objects in BasePlayer.
-Fixed Hls livestreams crashing player when behind live window for too long.
-Fixed cache miss when InfoCache key mismatch between StreamInfo and StreamInfoItem.
This commit is contained in:
John Zhen Mo 2018-03-03 11:42:23 -08:00
parent 9ea08c8a4b
commit 0c17f0825b
10 changed files with 395 additions and 277 deletions

View File

@ -55,7 +55,7 @@ dependencies {
exclude module: 'support-annotations'
}
implementation 'com.github.karyogamy:NewPipeExtractor:4cf4ee394f'
implementation 'com.github.karyogamy:NewPipeExtractor:b4206479cb'
testImplementation 'junit:junit:4.12'
testImplementation 'org.mockito:mockito-core:1.10.19'

View File

@ -322,7 +322,7 @@ public class VideoDetailFragment
if (serializable instanceof StreamInfo) {
//noinspection unchecked
currentInfo = (StreamInfo) serializable;
InfoCache.getInstance().putInfo(currentInfo);
InfoCache.getInstance().putInfo(serviceId, url, currentInfo);
}
serializable = savedState.getSerializable(STACK_KEY);

View File

@ -33,6 +33,7 @@ import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v4.app.NotificationCompat;
import android.util.Log;
import android.view.View;
import android.widget.RemoteViews;
import com.google.android.exoplayer2.PlaybackParameters;
@ -292,15 +293,15 @@ public final class BackgroundPlayer extends Service {
}
@Override
public void onThumbnailReceived(Bitmap thumbnail) {
super.onThumbnailReceived(thumbnail);
public void onLoadingComplete(String imageUri, View view, Bitmap loadedImage) {
super.onLoadingComplete(imageUri, view, loadedImage);
if (thumbnail != null) {
if (loadedImage != null) {
// rebuild notification here since remote view does not release bitmaps, causing memory leaks
resetNotification();
if (notRemoteView != null) notRemoteView.setImageViewBitmap(R.id.notificationCover, thumbnail);
if (bigNotRemoteView != null) bigNotRemoteView.setImageViewBitmap(R.id.notificationCover, thumbnail);
if (notRemoteView != null) notRemoteView.setImageViewBitmap(R.id.notificationCover, loadedImage);
if (bigNotRemoteView != null) bigNotRemoteView.setImageViewBitmap(R.id.notificationCover, loadedImage);
updateNotification(-1);
}

File diff suppressed because it is too large Load Diff

View File

@ -419,13 +419,15 @@ public final class PopupVideoPlayer extends Service {
}
@Override
public void onThumbnailReceived(Bitmap thumbnail) {
super.onThumbnailReceived(thumbnail);
if (thumbnail != null) {
public void onLoadingComplete(String imageUri, View view, Bitmap loadedImage) {
super.onLoadingComplete(imageUri, view, loadedImage);
if (loadedImage != null) {
// rebuild notification here since remote view does not release bitmaps, causing memory leaks
notBuilder = createNotification();
if (notRemoteView != null) notRemoteView.setImageViewBitmap(R.id.notificationCover, thumbnail);
if (notRemoteView != null) {
notRemoteView.setImageViewBitmap(R.id.notificationCover, loadedImage);
}
updateNotification(-1);
}

View File

@ -160,7 +160,6 @@ public abstract class VideoPlayer extends BasePlayer
public VideoPlayer(String debugTag, Context context) {
super(context);
this.TAG = debugTag;
this.context = context;
}
public void setup(View rootView) {
@ -617,9 +616,9 @@ public abstract class VideoPlayer extends BasePlayer
}
@Override
public void onThumbnailReceived(Bitmap thumbnail) {
super.onThumbnailReceived(thumbnail);
if (thumbnail != null) endScreen.setImageBitmap(thumbnail);
public void onLoadingComplete(String imageUri, View view, Bitmap loadedImage) {
super.onLoadingComplete(imageUri, view, loadedImage);
if (loadedImage != null) endScreen.setImageBitmap(loadedImage);
}
protected void onFullScreenButtonClicked() {

View File

@ -26,6 +26,7 @@ import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import io.reactivex.Single;
import io.reactivex.android.schedulers.AndroidSchedulers;
@ -33,6 +34,7 @@ import io.reactivex.disposables.CompositeDisposable;
import io.reactivex.disposables.Disposable;
import io.reactivex.disposables.SerialDisposable;
import io.reactivex.functions.Consumer;
import io.reactivex.internal.subscriptions.EmptySubscription;
import io.reactivex.subjects.PublishSubject;
import static org.schabi.newpipe.playlist.PlayQueue.DEBUG;
@ -40,66 +42,105 @@ import static org.schabi.newpipe.playlist.PlayQueue.DEBUG;
public class MediaSourceManager {
@NonNull private final static String TAG = "MediaSourceManager";
// WINDOW_SIZE determines how many streams AFTER the current stream should be loaded.
// The default value (1) ensures seamless playback under typical network settings.
/**
* Determines how many streams before and after the current stream should be loaded.
* The default value (1) ensures seamless playback under typical network settings.
* <br><br>
* The streams after the current will be loaded into the playlist timeline while the
* streams before will only be cached for future usage.
*
* @see #onMediaSourceReceived(PlayQueueItem, ManagedMediaSource)
* @see #update(int, MediaSource)
* */
private final static int WINDOW_SIZE = 1;
@NonNull private final PlaybackListener playbackListener;
@NonNull private final PlayQueue playQueue;
// Once a MediaSource item has been detected to be expired, the manager will immediately
// trigger a reload on the associated PlayQueueItem, which may disrupt playback,
// if the item is being played
private final long expirationTimeMillis;
/**
* Determines how long NEIGHBOURING {@link LoadedMediaSource} window of a currently playing
* {@link MediaSource} is allowed to stay in the playlist timeline. This is to ensure
* the {@link StreamInfo} used in subsequent playback is up-to-date.
* <br><br>
* Once a {@link LoadedMediaSource} has expired, a new source will be reloaded to
* replace the expired one on whereupon {@link #loadImmediate()} is called.
*
* @see #loadImmediate()
* @see #isCorrectionNeeded(PlayQueueItem)
* */
private final long windowRefreshTimeMillis;
// Process only the last load order when receiving a stream of load orders (lessens I/O)
// The higher it is, the less loading occurs during rapid noncritical timeline changes
// Not recommended to go below 100ms
/**
* Process only the last load order when receiving a stream of load orders (lessens I/O).
* <br><br>
* The higher it is, the less loading occurs during rapid noncritical timeline changes.
* <br><br>
* Not recommended to go below 100ms.
*
* @see #loadDebounced()
* */
private final long loadDebounceMillis;
@NonNull private final Disposable debouncedLoader;
@NonNull private final PublishSubject<Long> debouncedSignal;
private DynamicConcatenatingMediaSource sources;
@NonNull private Subscription playQueueReactor;
private Subscription playQueueReactor;
private CompositeDisposable loaderReactor;
/**
* Determines the maximum number of disposables allowed in the {@link #loaderReactor}.
* Once exceeded, new calls to {@link #loadImmediate()} will evict all disposables in the
* {@link #loaderReactor} in order to load a new set of items.
*
* @see #loadImmediate()
* @see #maybeLoadItem(PlayQueueItem)
* */
private final static int MAXIMUM_LOADER_SIZE = WINDOW_SIZE * 2 + 1;
@NonNull private final CompositeDisposable loaderReactor;
@NonNull private Set<PlayQueueItem> loadingItems;
@NonNull private final SerialDisposable syncReactor;
private boolean isBlocked;
@NonNull private final AtomicBoolean isBlocked;
private SerialDisposable syncReactor;
private PlayQueueItem syncedItem;
private Set<PlayQueueItem> loadingItems;
@NonNull private DynamicConcatenatingMediaSource sources;
@Nullable private PlayQueueItem syncedItem;
public MediaSourceManager(@NonNull final PlaybackListener listener,
@NonNull final PlayQueue playQueue) {
this(listener, playQueue,
/*loadDebounceMillis=*/400L,
/*expirationTimeMillis=*/TimeUnit.MILLISECONDS.convert(30, TimeUnit.MINUTES));
/*windowRefreshTimeMillis=*/TimeUnit.MILLISECONDS.convert(10, TimeUnit.MINUTES));
}
private MediaSourceManager(@NonNull final PlaybackListener listener,
@NonNull final PlayQueue playQueue,
final long loadDebounceMillis,
final long expirationTimeMillis) {
final long windowRefreshTimeMillis) {
if (playQueue.getBroadcastReceiver() == null) {
throw new IllegalArgumentException("Play Queue has not been initialized.");
}
this.playbackListener = listener;
this.playQueue = playQueue;
this.loadDebounceMillis = loadDebounceMillis;
this.expirationTimeMillis = expirationTimeMillis;
this.loaderReactor = new CompositeDisposable();
this.windowRefreshTimeMillis = windowRefreshTimeMillis;
this.loadDebounceMillis = loadDebounceMillis;
this.debouncedSignal = PublishSubject.create();
this.debouncedLoader = getDebouncedLoader();
this.playQueueReactor = EmptySubscription.INSTANCE;
this.loaderReactor = new CompositeDisposable();
this.syncReactor = new SerialDisposable();
this.isBlocked = new AtomicBoolean(false);
this.sources = new DynamicConcatenatingMediaSource();
this.syncReactor = new SerialDisposable();
this.loadingItems = Collections.synchronizedSet(new HashSet<>());
if (playQueue.getBroadcastReceiver() != null) {
playQueue.getBroadcastReceiver()
.observeOn(AndroidSchedulers.mainThread())
.subscribe(getReactor());
}
playQueue.getBroadcastReceiver()
.observeOn(AndroidSchedulers.mainThread())
.subscribe(getReactor());
}
/*//////////////////////////////////////////////////////////////////////////
@ -114,16 +155,12 @@ public class MediaSourceManager {
debouncedSignal.onComplete();
debouncedLoader.dispose();
if (playQueueReactor != null) playQueueReactor.cancel();
if (loaderReactor != null) loaderReactor.dispose();
if (syncReactor != null) syncReactor.dispose();
if (sources != null) sources.releaseSource();
playQueueReactor.cancel();
loaderReactor.dispose();
syncReactor.dispose();
sources.releaseSource();
playQueueReactor = null;
loaderReactor = null;
syncReactor = null;
syncedItem = null;
sources = null;
}
/**
@ -158,14 +195,14 @@ public class MediaSourceManager {
return new Subscriber<PlayQueueEvent>() {
@Override
public void onSubscribe(@NonNull Subscription d) {
if (playQueueReactor != null) playQueueReactor.cancel();
playQueueReactor.cancel();
playQueueReactor = d;
playQueueReactor.request(1);
}
@Override
public void onNext(@NonNull PlayQueueEvent playQueueMessage) {
if (playQueueReactor != null) onPlayQueueChanged(playQueueMessage);
onPlayQueueChanged(playQueueMessage);
}
@Override
@ -227,7 +264,7 @@ public class MediaSourceManager {
tryBlock();
playQueue.fetch();
}
if (playQueueReactor != null) playQueueReactor.request(1);
playQueueReactor.request(1);
}
/*//////////////////////////////////////////////////////////////////////////
@ -240,7 +277,7 @@ public class MediaSourceManager {
}
private boolean isPlaybackReady() {
if (sources == null || sources.getSize() != playQueue.size()) return false;
if (sources.getSize() != playQueue.size()) return false;
final MediaSource mediaSource = sources.getMediaSource(playQueue.getIndex());
final PlayQueueItem playQueueItem = playQueue.getItem();
@ -256,19 +293,19 @@ public class MediaSourceManager {
private void tryBlock() {
if (DEBUG) Log.d(TAG, "tryBlock() called.");
if (isBlocked) return;
if (isBlocked.get()) return;
playbackListener.block();
resetSources();
isBlocked = true;
isBlocked.set(true);
}
private void tryUnblock() {
if (DEBUG) Log.d(TAG, "tryUnblock() called.");
if (isPlayQueueReady() && isPlaybackReady() && isBlocked && sources != null) {
isBlocked = false;
if (isPlayQueueReady() && isPlaybackReady() && isBlocked.get()) {
isBlocked.set(false);
playbackListener.unblock(sources);
}
}
@ -281,7 +318,7 @@ public class MediaSourceManager {
if (DEBUG) Log.d(TAG, "sync() called.");
final PlayQueueItem currentItem = playQueue.getItem();
if (isBlocked || currentItem == null) return;
if (isBlocked.get() || currentItem == null) return;
final Consumer<StreamInfo> onSuccess = info -> syncInternal(currentItem, info);
final Consumer<Throwable> onError = throwable -> syncInternal(currentItem, null);
@ -295,11 +332,11 @@ public class MediaSourceManager {
}
}
private void syncInternal(@android.support.annotation.NonNull final PlayQueueItem item,
private void syncInternal(@NonNull final PlayQueueItem item,
@Nullable final StreamInfo info) {
// Ensure the current item is up to date with the play queue
if (playQueue.getItem() == item && playQueue.getItem() == syncedItem) {
playbackListener.sync(syncedItem, info);
playbackListener.sync(item, info);
}
}
@ -323,6 +360,12 @@ public class MediaSourceManager {
final int currentIndex = playQueue.getIndex();
final PlayQueueItem currentItem = playQueue.getItem(currentIndex);
if (currentItem == null) return;
// Evict the items being loaded to free up memory
if (!loadingItems.contains(currentItem) && loaderReactor.size() > MAXIMUM_LOADER_SIZE) {
loaderReactor.clear();
loadingItems.clear();
}
maybeLoadItem(currentItem);
// The rest are just for seamless playback
@ -347,34 +390,17 @@ public class MediaSourceManager {
private void maybeLoadItem(@NonNull final PlayQueueItem item) {
if (DEBUG) Log.d(TAG, "maybeLoadItem() called.");
if (sources == null) return;
final int index = playQueue.indexOf(item);
if (index > sources.getSize() - 1) return;
final Consumer<ManagedMediaSource> onDone = mediaSource -> {
if (DEBUG) Log.d(TAG, " Loaded: [" + item.getTitle() +
"] with url: " + item.getUrl());
final int itemIndex = playQueue.indexOf(item);
// Only update the playlist timeline for items at the current index or after.
if (itemIndex >= playQueue.getIndex() && isCorrectionNeeded(item)) {
update(itemIndex, mediaSource);
}
loadingItems.remove(item);
tryUnblock();
sync();
};
if (playQueue.indexOf(item) >= sources.getSize()) return;
if (!loadingItems.contains(item) && isCorrectionNeeded(item)) {
if (DEBUG) Log.d(TAG, "Loading: [" + item.getTitle() +
if (DEBUG) Log.d(TAG, "MediaSource - Loading: [" + item.getTitle() +
"] with url: " + item.getUrl());
loadingItems.add(item);
final Disposable loader = getLoadedMediaSource(item)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(onDone);
/* No exception handling since getLoadedMediaSource guarantees nonnull return */
.subscribe(mediaSource -> onMediaSourceReceived(item, mediaSource));
loaderReactor.add(loader);
}
@ -392,14 +418,32 @@ public class MediaSourceManager {
", audio count: " + streamInfo.audio_streams.size() +
", video count: " + streamInfo.video_only_streams.size() +
streamInfo.video_streams.size());
return new FailedMediaSource(stream, new IllegalStateException(exception));
return new FailedMediaSource(stream, exception);
}
final long expiration = System.currentTimeMillis() + expirationTimeMillis;
final long expiration = System.currentTimeMillis() + windowRefreshTimeMillis;
return new LoadedMediaSource(source, stream, expiration);
}).onErrorReturn(throwable -> new FailedMediaSource(stream, throwable));
}
private void onMediaSourceReceived(@NonNull final PlayQueueItem item,
@NonNull final ManagedMediaSource mediaSource) {
if (DEBUG) Log.d(TAG, "MediaSource - Loaded: [" + item.getTitle() +
"] with url: " + item.getUrl());
final int itemIndex = playQueue.indexOf(item);
// Only update the playlist timeline for items at the current index or after.
if (itemIndex >= playQueue.getIndex() && isCorrectionNeeded(item)) {
if (DEBUG) Log.d(TAG, "MediaSource - Updating: [" + item.getTitle() +
"] with url: " + item.getUrl());
update(itemIndex, mediaSource);
}
loadingItems.remove(item);
tryUnblock();
sync();
}
/**
* Checks if the corresponding MediaSource in {@link DynamicConcatenatingMediaSource}
* for a given {@link PlayQueueItem} needs replacement, either due to gapless playback
@ -411,8 +455,6 @@ public class MediaSourceManager {
* {@link ManagedMediaSource}.
* */
private boolean isCorrectionNeeded(@NonNull final PlayQueueItem item) {
if (sources == null) return false;
final int index = playQueue.indexOf(item);
if (index == -1 || index >= sources.getSize()) return false;
@ -432,13 +474,13 @@ public class MediaSourceManager {
private void resetSources() {
if (DEBUG) Log.d(TAG, "resetSources() called.");
if (this.sources != null) this.sources.releaseSource();
this.sources.releaseSource();
this.sources = new DynamicConcatenatingMediaSource();
}
private void populateSources() {
if (DEBUG) Log.d(TAG, "populateSources() called.");
if (sources == null || sources.getSize() >= playQueue.size()) return;
if (sources.getSize() >= playQueue.size()) return;
for (int index = sources.getSize() - 1; index < playQueue.size(); index++) {
emplace(index, new PlaceholderMediaSource());
@ -451,12 +493,11 @@ public class MediaSourceManager {
/**
* Places a {@link MediaSource} into the {@link DynamicConcatenatingMediaSource}
* with position * in respect to the play queue only if no {@link MediaSource}
* with position in respect to the play queue only if no {@link MediaSource}
* already exists at the given index.
* */
private void emplace(final int index, @NonNull final MediaSource source) {
if (sources == null) return;
if (index < 0 || index < sources.getSize()) return;
private synchronized void emplace(final int index, @NonNull final MediaSource source) {
if (index < sources.getSize()) return;
sources.addMediaSource(index, source);
}
@ -465,8 +506,7 @@ public class MediaSourceManager {
* Removes a {@link MediaSource} from {@link DynamicConcatenatingMediaSource}
* at the given index. If this index is out of bound, then the removal is ignored.
* */
private void remove(final int index) {
if (sources == null) return;
private synchronized void remove(final int index) {
if (index < 0 || index > sources.getSize()) return;
sources.removeMediaSource(index);
@ -477,8 +517,7 @@ public class MediaSourceManager {
* from the given source index to the target index. If either index is out of bound,
* then the call is ignored.
* */
private void move(final int source, final int target) {
if (sources == null) return;
private synchronized void move(final int source, final int target) {
if (source < 0 || target < 0) return;
if (source >= sources.getSize() || target >= sources.getSize()) return;
@ -491,15 +530,13 @@ public class MediaSourceManager {
* then the replacement is ignored.
* <br><br>
* Not recommended to use on indices LESS THAN the currently playing index, since
* this will modify the playback timeline prior to the index and cause desynchronization
* this will modify the playback timeline prior to the index and may cause desynchronization
* on the playing item between {@link PlayQueue} and {@link DynamicConcatenatingMediaSource}.
* */
private synchronized void update(final int index, @NonNull final MediaSource source) {
if (sources == null) return;
if (index < 0 || index >= sources.getSize()) return;
sources.addMediaSource(index + 1, source, () -> {
if (sources != null) sources.removeMediaSource(index);
});
sources.addMediaSource(index + 1, source, () ->
sources.removeMediaSource(index));
}
}

View File

@ -63,22 +63,18 @@ public class PlayQueueAdapter extends RecyclerView.Adapter<RecyclerView.ViewHold
}
public PlayQueueAdapter(final Context context, final PlayQueue playQueue) {
if (playQueue.getBroadcastReceiver() == null) {
throw new IllegalStateException("Play Queue has not been initialized.");
}
this.playQueueItemBuilder = new PlayQueueItemBuilder(context);
this.playQueue = playQueue;
startReactor();
playQueue.getBroadcastReceiver().toObservable().subscribe(getReactor());
}
public void setSelectedListener(final PlayQueueItemBuilder.OnSelectedListener listener) {
playQueueItemBuilder.setOnSelectedListener(listener);
}
public void unsetSelectedListener() {
playQueueItemBuilder.setOnSelectedListener(null);
}
private void startReactor() {
final Observer<PlayQueueEvent> observer = new Observer<PlayQueueEvent>() {
private Observer<PlayQueueEvent> getReactor() {
return new Observer<PlayQueueEvent>() {
@Override
public void onSubscribe(@NonNull Disposable d) {
if (playQueueReactor != null) playQueueReactor.dispose();
@ -99,9 +95,6 @@ public class PlayQueueAdapter extends RecyclerView.Adapter<RecyclerView.ViewHold
}
};
if (playQueue.getBroadcastReceiver() != null) {
playQueue.getBroadcastReceiver().toObservable().subscribe(observer);
}
}
private void onPlayQueueChanged(final PlayQueueEvent message) {
@ -148,6 +141,14 @@ public class PlayQueueAdapter extends RecyclerView.Adapter<RecyclerView.ViewHold
playQueueReactor = null;
}
public void setSelectedListener(final PlayQueueItemBuilder.OnSelectedListener listener) {
playQueueItemBuilder.setOnSelectedListener(listener);
}
public void unsetSelectedListener() {
playQueueItemBuilder.setOnSelectedListener(null);
}
public void setFooter(View footer) {
this.footer = footer;
notifyItemChanged(playQueue.size());

View File

@ -172,7 +172,7 @@ public final class ExtractorHelper {
String url,
Single<I> loadFromNetwork) {
checkServiceId(serviceId);
loadFromNetwork = loadFromNetwork.doOnSuccess((@NonNull I i) -> cache.putInfo(i));
loadFromNetwork = loadFromNetwork.doOnSuccess(info -> cache.putInfo(serviceId, url, info));
Single<I> load;
if (forceLoad) {

View File

@ -20,6 +20,7 @@
package org.schabi.newpipe.util;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v4.util.LruCache;
import android.util.Log;
@ -29,6 +30,8 @@ import org.schabi.newpipe.extractor.Info;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import static org.schabi.newpipe.extractor.ServiceList.SoundCloud;
public final class InfoCache {
private static final boolean DEBUG = MainActivity.DEBUG;
@ -52,6 +55,7 @@ public final class InfoCache {
return instance;
}
@Nullable
public Info getFromKey(int serviceId, @NonNull String url) {
if (DEBUG) Log.d(TAG, "getFromKey() called with: serviceId = [" + serviceId + "], url = [" + url + "]");
synchronized (lruCache) {
@ -59,18 +63,19 @@ public final class InfoCache {
}
}
public void putInfo(@NonNull Info info) {
public void putInfo(int serviceId, @NonNull String url, @NonNull Info info) {
if (DEBUG) Log.d(TAG, "putInfo() called with: info = [" + info + "]");
synchronized (lruCache) {
final CacheData data = new CacheData(info, DEFAULT_TIMEOUT_HOURS, TimeUnit.HOURS);
lruCache.put(keyOf(info), data);
}
}
public void removeInfo(@NonNull Info info) {
if (DEBUG) Log.d(TAG, "removeInfo() called with: info = [" + info + "]");
final long expirationMillis;
if (info.getServiceId() == SoundCloud.getServiceId()) {
expirationMillis = TimeUnit.MILLISECONDS.convert(15, TimeUnit.MINUTES);
} else {
expirationMillis = TimeUnit.MILLISECONDS.convert(DEFAULT_TIMEOUT_HOURS, TimeUnit.HOURS);
}
synchronized (lruCache) {
lruCache.remove(keyOf(info));
final CacheData data = new CacheData(info, expirationMillis);
lruCache.put(keyOf(serviceId, url), data);
}
}
@ -102,10 +107,7 @@ public final class InfoCache {
}
}
private static String keyOf(@NonNull final Info info) {
return keyOf(info.getServiceId(), info.getUrl());
}
@NonNull
private static String keyOf(final int serviceId, @NonNull final String url) {
return serviceId + url;
}
@ -119,6 +121,7 @@ public final class InfoCache {
}
}
@Nullable
private static Info getInfo(@NonNull final LruCache<String, CacheData> cache,
@NonNull final String key) {
final CacheData data = cache.get(key);
@ -136,12 +139,8 @@ public final class InfoCache {
final private long expireTimestamp;
final private Info info;
private CacheData(@NonNull final Info info,
final long timeout,
@NonNull final TimeUnit timeUnit) {
this.expireTimestamp = System.currentTimeMillis() +
TimeUnit.MILLISECONDS.convert(timeout, timeUnit);
private CacheData(@NonNull final Info info, final long timeoutMillis) {
this.expireTimestamp = System.currentTimeMillis() + timeoutMillis;
this.info = info;
}