diff --git a/android/build.gradle b/android/build.gradle index ef93dd09d582e..cc504f8c1d885 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,12 +1,23 @@ apply plugin: 'com.android.library' apply plugin: 'maven' +apply plugin: 'kotlin-android' group = 'host.exp.exponent' version = '8.1.0' -// Simple helper that allows the root project to override versions declared by this library. -def safeExtGet(prop, fallback) { - rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback +buildscript { + // Simple helper that allows the root project to override versions declared by this library. + ext.safeExtGet = { prop, fallback -> + rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback + } + + repositories { + mavenCentral() + } + + dependencies { + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${safeExtGet("kotlinVersion", "1.3.50")}") + } } // Upload android library to maven with javadoc and android sources @@ -50,6 +61,13 @@ android { sourceCompatibility = '1.8' targetCompatibility = '1.8' } + + testOptions { + unitTests.all { + useJUnitPlatform() + } + } + } if (new File(rootProject.projectDir.parentFile, 'package.json').exists()) { @@ -75,4 +93,9 @@ dependencies { api "com.squareup.okhttp3:okhttp-urlconnection:3.10.0" api "com.android.support:support-annotations:${safeExtGet("supportLibVersion", "28.0.0")}" + + testImplementation 'org.junit.jupiter:junit-jupiter-api:5.5.1' + testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:5.5.1" + + testImplementation 'io.mockk:mockk:1.9' } diff --git a/android/src/main/java/expo/modules/av/player/PlayerData.java b/android/src/main/java/expo/modules/av/player/PlayerData.java index d34949ca1e927..5af837c32af4e 100644 --- a/android/src/main/java/expo/modules/av/player/PlayerData.java +++ b/android/src/main/java/expo/modules/av/player/PlayerData.java @@ -3,18 +3,19 @@ import android.content.Context; import android.net.Uri; import android.os.Bundle; -import android.os.Handler; import android.util.Pair; import android.view.Surface; -import java.lang.ref.WeakReference; -import java.util.Map; - import org.unimodules.core.Promise; import org.unimodules.core.arguments.ReadableArguments; + +import java.util.Map; + import expo.modules.av.AVManagerInterface; import expo.modules.av.AudioEventHandler; import expo.modules.av.AudioFocusNotAcquiredException; +import expo.modules.av.progress.AndroidLooperTimeMachine; +import expo.modules.av.progress.ProgressLooper; public abstract class PlayerData implements AudioEventHandler { static final String STATUS_ANDROID_IMPLEMENTATION_KEY_PATH = "androidImplementation"; @@ -76,25 +77,8 @@ public interface FullscreenPresenter { final Uri mUri; final Map mRequestHeaders; - private Handler mHandler = new Handler(); - private Runnable mProgressUpdater = new ProgressUpdater(this); - - private class ProgressUpdater implements Runnable { - private WeakReference mPlayerDataWeakReference; + private ProgressLooper mProgressUpdater = new ProgressLooper(new AndroidLooperTimeMachine()); - private ProgressUpdater(PlayerData playerData) { - mPlayerDataWeakReference = new WeakReference<>(playerData); - } - - @Override - public void run() { - final PlayerData playerData = mPlayerDataWeakReference.get(); - if (playerData != null) { - playerData.callStatusUpdateListener(); - playerData.progressUpdateLoop(); - } - } - } private FullscreenPresenter mFullscreenPresenter = null; private StatusUpdateListener mStatusUpdateListener = null; @@ -125,7 +109,7 @@ public static PlayerData createUnloadedPlayerData(final AVManagerInterface avMod final Uri uri = Uri.parse(uriString); if (status.containsKey(STATUS_ANDROID_IMPLEMENTATION_KEY_PATH) - && status.getString(STATUS_ANDROID_IMPLEMENTATION_KEY_PATH).equals(MediaPlayerData.IMPLEMENTATION_NAME)) { + && status.getString(STATUS_ANDROID_IMPLEMENTATION_KEY_PATH).equals(MediaPlayerData.IMPLEMENTATION_NAME)) { return new MediaPlayerData(avModule, context, uri, requestHeaders); } else { return new SimpleExoPlayerData(avModule, context, uri, uriOverridingExtension, requestHeaders); @@ -161,19 +145,25 @@ final void callStatusUpdateListener() { abstract boolean shouldContinueUpdatingProgress(); final void stopUpdatingProgressIfNecessary() { - mHandler.removeCallbacks(mProgressUpdater); + mProgressUpdater.stopLooping(); } private void progressUpdateLoop() { if (!shouldContinueUpdatingProgress()) { stopUpdatingProgressIfNecessary(); } else { - mHandler.postDelayed(mProgressUpdater, mProgressUpdateIntervalMillis); + mProgressUpdater.loop(mProgressUpdateIntervalMillis, () -> { + this.callStatusUpdateListener(); + return null; + }); } } final void beginUpdatingProgressIfNecessary() { - mHandler.post(mProgressUpdater); + mProgressUpdater.loop(mProgressUpdateIntervalMillis, () -> { + this.callStatusUpdateListener(); + return null; + }); } public final void setStatusUpdateListener(final StatusUpdateListener listener) { @@ -198,7 +188,7 @@ final boolean shouldPlayerPlay() { abstract void playPlayerWithRateAndMuteIfNecessary() throws AudioFocusNotAcquiredException; abstract void applyNewStatus(final Integer newPositionMillis, final Boolean newIsLooping) - throws AudioFocusNotAcquiredException, IllegalStateException; + throws AudioFocusNotAcquiredException, IllegalStateException; final void setStatusWithListener(final Bundle status, final SetStatusCompletionListener setStatusCompletionListener) { if (status.containsKey(STATUS_PROGRESS_UPDATE_INTERVAL_MILLIS_KEY_PATH)) { diff --git a/android/src/main/java/expo/modules/av/progress/AndroidLooperTimeMachine.kt b/android/src/main/java/expo/modules/av/progress/AndroidLooperTimeMachine.kt new file mode 100644 index 0000000000000..dda3b8b8f2d99 --- /dev/null +++ b/android/src/main/java/expo/modules/av/progress/AndroidLooperTimeMachine.kt @@ -0,0 +1,13 @@ +package expo.modules.av.progress + +import android.os.Handler + +class AndroidLooperTimeMachine : TimeMachine { + + override fun scheduleAt(intervalMillis: Long, callback: TimeMachineTick) { + Handler().postDelayed(callback, intervalMillis) + } + + override val time: Long + get() = System.currentTimeMillis() +} diff --git a/android/src/main/java/expo/modules/av/progress/ProgressLooper.kt b/android/src/main/java/expo/modules/av/progress/ProgressLooper.kt new file mode 100644 index 0000000000000..64235da32f666 --- /dev/null +++ b/android/src/main/java/expo/modules/av/progress/ProgressLooper.kt @@ -0,0 +1,67 @@ +package expo.modules.av.progress + +typealias TimeMachineTick = () -> Unit + +interface TimeMachine { + fun scheduleAt(intervalMillis: Long, callback: TimeMachineTick) + val time: Long +} + +typealias PlayerProgressListener = () -> Unit + +class ProgressLooper(private val timeMachine: TimeMachine) { + + private var interval = 0L + private var nextExpectedTick = -1L + private var waiting = false + + private var shouldLoop: Boolean + get() = interval > 0 && nextExpectedTick >= 0 && !waiting + set(value) { + if (!value) { + interval = 0L + nextExpectedTick = -1L + waiting = false + } + } + + private var listener: PlayerProgressListener? = null + + fun setListener(listener: PlayerProgressListener) { + this.listener = listener + } + + fun loop(interval: Long, listener: PlayerProgressListener) { + this.listener = listener + this.interval = interval + scheduleNextTick() + } + + fun stopLooping() { + this.shouldLoop = false + this.listener = null + } + + private fun scheduleNextTick() { + if (nextExpectedTick == -1L) { + nextExpectedTick = timeMachine.time + } + if (shouldLoop) { + nextExpectedTick += calculateNextInterval() + waiting = true + timeMachine.scheduleAt(nextExpectedTick - timeMachine.time) { + waiting = false + listener?.invoke() + scheduleNextTick() + } + } + } + + private fun calculateNextInterval() = + if (nextExpectedTick > timeMachine.time) { + interval + } else { + (((timeMachine.time - nextExpectedTick) / interval) + 1) * interval + } + +} diff --git a/android/src/test/java/expo/modules/av/progress/ProgressLooperTest.kt b/android/src/test/java/expo/modules/av/progress/ProgressLooperTest.kt new file mode 100644 index 0000000000000..da98822a3e9a5 --- /dev/null +++ b/android/src/test/java/expo/modules/av/progress/ProgressLooperTest.kt @@ -0,0 +1,222 @@ +package expo.modules.av.progress + +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.spyk +import io.mockk.verify +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +internal class ProgressLooperTest { + + interface TestTimeMachine : TimeMachine { + fun advanceBy(interval: Long) + fun triggerListeners(by: Long = Long.MAX_VALUE) + } + + object TimeMachineInstance : TestTimeMachine { + + override var time = 0L + + var callbacks: Map = HashMap() + + override fun advanceBy(interval: Long) { + time += interval + } + + override fun triggerListeners(by: Long) { + val toInvoke = callbacks.filter { it.value < by } + callbacks = callbacks.filter { it.value >= by } + toInvoke.forEach { + it.key() + } + } + + override fun scheduleAt(intervalMillis: Long, callback: TimeMachineTick) { + if (intervalMillis > 0) { + callbacks = callbacks.plus(callback to time + intervalMillis) + } + } + + fun reset() { + callbacks = HashMap() + time = 0 + } + } + + lateinit var looper: ProgressLooper + lateinit var callback: TimeMachineTick + lateinit var timeMachine: TestTimeMachine + + @BeforeEach + fun setUp() { + TimeMachineInstance.reset() + timeMachine = spyk(TimeMachineInstance) + looper = ProgressLooper(timeMachine) + callback = mockk() + every { callback() } just Runs + } + + @Test + fun `callback not invoked prematurely`() { + looper.loop(1000, callback) + verify(exactly = 0) { callback() } + } + + @Test + fun `callback invoked once after time passed`() { + looper.loop(1000L, callback) + timeMachine.advanceBy(1000L) + timeMachine.triggerListeners() + verify(exactly = 1) { callback() } + } + + @Test + fun `callback invoked twice after two timeouts`() { + looper.loop(1000, callback) + timeMachine.advanceBy(1100) + timeMachine.triggerListeners() + timeMachine.advanceBy(1100) + timeMachine.triggerListeners() + verify(exactly = 2) { callback() } + } + + @Test + fun `callback invoked once after twice too big timeout`() { + looper.loop(1000, callback) + timeMachine.advanceBy(2200) + timeMachine.triggerListeners(2200) + verify(exactly = 1) { callback() } + } + + @Test + fun `callback not invoked after looping stopped`() { + looper.loop(1000L, callback) + timeMachine.advanceBy(1001) + timeMachine.triggerListeners() + verify(exactly = 1) { callback() } + looper.stopLooping() + timeMachine.advanceBy(1001) + timeMachine.triggerListeners() + verify(exactly = 1) { callback() } + } + + @Test + fun `callback not invoked earlier if interval shortened`() { + looper.loop(1000L, callback) + + looper.loop(100, callback) + timeMachine.advanceBy(110) + timeMachine.triggerListeners(110) + verify(exactly = 0) { callback() } + + timeMachine.advanceBy(900) + timeMachine.triggerListeners(1010) + verify(exactly = 1) { callback() } + + timeMachine.advanceBy(100) + timeMachine.triggerListeners(1110) + verify(exactly = 2) { callback() } + } + + @Test + fun `callback invoked earlier even if interval lengthened`() { + looper.loop(1000, callback) + + looper.loop(2000, callback) + timeMachine.advanceBy(1100) + timeMachine.triggerListeners(1100) + + verify(exactly = 1) { callback() } + } + + @Test + fun `callback not invoked later even if interval lengthened`() { + looper.loop(1000, callback) + + looper.loop(2000, callback) + timeMachine.advanceBy(1110) + timeMachine.triggerListeners(1100) + + verify(exactly = 1) { callback() } + + timeMachine.advanceBy(1110) + timeMachine.triggerListeners(2200) + + verify(exactly = 1) { callback() } + } + + @Test + fun `next tick scheduled with adjustment to passed time when invoked too late`() { + looper.loop(1000L, callback) + + timeMachine.advanceBy(1100) + timeMachine.triggerListeners(1100) + + verify(exactly = 1) { timeMachine.scheduleAt(900, any()) } + } + + @Test + fun `next tick scheduled with adjustment to passed time when invoked too early`() { + looper.loop(1000L, callback) + + timeMachine.advanceBy(900) + timeMachine.triggerListeners() + + verify(exactly = 1) { timeMachine.scheduleAt(1100, any()) } + } + + @Test + fun `old listener not notified after new is registered`() { + looper.loop(1000, callback) + + timeMachine.advanceBy(1100) + timeMachine.triggerListeners() + verify(exactly = 1) { callback() } + + looper.setListener { } + timeMachine.advanceBy(1100) + timeMachine.triggerListeners() + + verify(exactly = 1) { callback() } + } + + @Test + fun `new listener is notified after registration`() { + looper.loop(1000) { } + + timeMachine.advanceBy(1100) + timeMachine.triggerListeners() + looper.setListener(callback) + timeMachine.advanceBy(1100) + timeMachine.triggerListeners() + + verify(exactly = 1) { callback() } + } + + @Test + fun `time machine not called if no looping started`() { + timeMachine.advanceBy(1100) + timeMachine.triggerListeners() + + verify(exactly = 0) { timeMachine.scheduleAt(any(), any()) } + } + + @Test + fun `time machine not called after looping stopped`() { + looper.loop(1000) {} + verify(exactly = 1) { timeMachine.scheduleAt(any(), any()) } + + timeMachine.advanceBy(1100) + timeMachine.triggerListeners() + verify(exactly = 2) { timeMachine.scheduleAt(any(), any()) } + + looper.stopLooping() + timeMachine.advanceBy(100000) + timeMachine.triggerListeners() + verify(exactly = 2) { timeMachine.scheduleAt(any(), any()) } + } + +}