From 7332c2c5f440edc8149bb5348abd9d05a0590db0 Mon Sep 17 00:00:00 2001 From: Josiah Campbell <9521010+jocmp@users.noreply.github.com> Date: Mon, 5 Jan 2026 22:45:25 -0600 Subject: [PATCH 1/2] Add media controls for lock screen and notification - Create MediaPlaybackService with skip forward/back 30s buttons - Refactor AudioPlayerController to use MediaController - Add instrumented tests for AudioPlayerController - Add CI job for instrumented tests using Gradle Managed Devices Ref #1658 --- .github/workflows/ci.yml | 23 ++ .../audio/AudioPlayerControllerTest.kt | 120 +++++++++ app/src/main/AndroidManifest.xml | 10 + .../app/ui/articles/ArticlesModule.kt | 4 +- .../articles/audio/AudioPlayerController.kt | 246 +++++++++++------- .../ui/articles/audio/MediaPlaybackService.kt | 181 +++++++++++++ .../app/ui/articles/audio/SkipCalculator.kt | 17 ++ app/src/main/res/values/strings.xml | 2 + .../ui/articles/audio/SkipCalculatorTest.kt | 48 ++++ 9 files changed, 547 insertions(+), 104 deletions(-) create mode 100644 app/src/androidTest/java/com/capyreader/app/ui/articles/audio/AudioPlayerControllerTest.kt create mode 100644 app/src/main/java/com/capyreader/app/ui/articles/audio/MediaPlaybackService.kt create mode 100644 app/src/main/java/com/capyreader/app/ui/articles/audio/SkipCalculator.kt create mode 100644 app/src/test/java/com/capyreader/app/ui/articles/audio/SkipCalculatorTest.kt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d371fc37c..579565a0c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,3 +35,26 @@ jobs: bundler-cache: true - name: Run tests run: make test + + instrumented-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4.2.1 + - name: Set up Java + uses: actions/setup-java@v4.4.0 + with: + distribution: 'zulu' + java-version: "21" + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + - name: Run instrumented tests + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 31 + arch: x86_64 + script: ./gradlew :app:connectedFreeDebugAndroidTest diff --git a/app/src/androidTest/java/com/capyreader/app/ui/articles/audio/AudioPlayerControllerTest.kt b/app/src/androidTest/java/com/capyreader/app/ui/articles/audio/AudioPlayerControllerTest.kt new file mode 100644 index 000000000..a6ea996f8 --- /dev/null +++ b/app/src/androidTest/java/com/capyreader/app/ui/articles/audio/AudioPlayerControllerTest.kt @@ -0,0 +1,120 @@ +package com.capyreader.app.ui.articles.audio + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.capyreader.app.common.AudioEnclosure +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AudioPlayerControllerTest { + private lateinit var controller: AudioPlayerController + + private val testAudio = AudioEnclosure( + url = "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3", + title = "Test Audio", + feedName = "Test Feed", + durationSeconds = 60, + artworkUrl = null, + ) + + @Before + fun setup() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + controller = AudioPlayerController(context) + } + + @After + fun teardown() { + controller.dismiss() + controller.release() + } + + @Test + fun initialState_hasNullCurrentAudio() = runBlocking { + val currentAudio = controller.currentAudio.first() + assertNull(currentAudio) + } + + @Test + fun initialState_isNotPlaying() = runBlocking { + val isPlaying = controller.isPlaying.first() + assertEquals(false, isPlaying) + } + + @Test + fun initialState_positionIsZero() = runBlocking { + val position = controller.currentPosition.first() + assertEquals(0L, position) + } + + @Test + fun play_setsCurrentAudio() = runBlocking { + controller.play(testAudio) + + withTimeout(5000) { + while (controller.currentAudio.value == null) { + delay(100) + } + } + + val currentAudio = controller.currentAudio.first() + assertNotNull(currentAudio) + assertEquals(testAudio.url, currentAudio?.url) + assertEquals(testAudio.title, currentAudio?.title) + } + + @Test + fun dismiss_clearsCurrentAudio() = runBlocking { + controller.play(testAudio) + + withTimeout(5000) { + while (controller.currentAudio.value == null) { + delay(100) + } + } + + controller.dismiss() + + withTimeout(2000) { + while (controller.currentAudio.value != null) { + delay(100) + } + } + + val currentAudio = controller.currentAudio.first() + assertNull(currentAudio) + } + + @Test + fun seekTo_updatesPosition() = runBlocking { + controller.play(testAudio) + + withTimeout(5000) { + while (controller.currentAudio.value == null) { + delay(100) + } + } + + val seekPosition = 30_000L + controller.seekTo(seekPosition) + + withTimeout(2000) { + while (controller.currentPosition.value != seekPosition) { + delay(100) + } + } + + val position = controller.currentPosition.first() + assertEquals(seekPosition, position) + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9bc203360..28dacecd8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + @@ -109,5 +110,14 @@ android:name="android.appwidget.provider" android:resource="@xml/headlines_widget" /> + + + + + + diff --git a/app/src/main/java/com/capyreader/app/ui/articles/ArticlesModule.kt b/app/src/main/java/com/capyreader/app/ui/articles/ArticlesModule.kt index dc2c637ab..ff4530af6 100644 --- a/app/src/main/java/com/capyreader/app/ui/articles/ArticlesModule.kt +++ b/app/src/main/java/com/capyreader/app/ui/articles/ArticlesModule.kt @@ -5,7 +5,6 @@ import com.capyreader.app.R import com.capyreader.app.preferences.AppPreferences import com.capyreader.app.ui.articles.audio.AudioPlayerController import com.capyreader.app.ui.articles.feeds.edit.EditFeedViewModel -import com.jocmp.capy.accounts.baseHttpClient import com.jocmp.capy.articles.ArticleRenderer import com.jocmp.capy.articles.AudioPlayerLabels import org.koin.androidx.viewmodel.dsl.viewModel @@ -20,8 +19,7 @@ internal val articlesModule = module { } single { AudioPlayerController( - context = get(), - okHttpClient = baseHttpClient() + context = get() ) } single { diff --git a/app/src/main/java/com/capyreader/app/ui/articles/audio/AudioPlayerController.kt b/app/src/main/java/com/capyreader/app/ui/articles/audio/AudioPlayerController.kt index 9c523f9c3..9e761b35c 100644 --- a/app/src/main/java/com/capyreader/app/ui/articles/audio/AudioPlayerController.kt +++ b/app/src/main/java/com/capyreader/app/ui/articles/audio/AudioPlayerController.kt @@ -1,58 +1,37 @@ package com.capyreader.app.ui.articles.audio +import android.content.ComponentName import android.content.Context -import android.os.Handler +import android.net.Uri import android.os.Looper import androidx.annotation.OptIn +import androidx.core.content.ContextCompat import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi -import androidx.media3.database.StandaloneDatabaseProvider -import androidx.media3.datasource.cache.CacheDataSource -import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor -import androidx.media3.datasource.cache.SimpleCache -import androidx.media3.datasource.okhttp.OkHttpDataSource -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.session.MediaController +import androidx.media3.session.SessionToken import com.capyreader.app.common.AudioEnclosure +import com.google.common.util.concurrent.ListenableFuture +import com.jocmp.capy.logging.CapyLog +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import okhttp3.OkHttpClient -import java.io.File - -private const val CACHE_SIZE_BYTES = 100L * 1024L * 1024L // 100 MB +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch class AudioPlayerController( private val context: Context, - okHttpClient: OkHttpClient, ) { - private val cache: SimpleCache - private val cacheDataSourceFactory: CacheDataSource.Factory - - init { - val cacheDir = File(context.cacheDir, "audio_cache") - val databaseProvider = StandaloneDatabaseProvider(context) - cache = SimpleCache(cacheDir, LeastRecentlyUsedCacheEvictor(CACHE_SIZE_BYTES), databaseProvider) - - val okHttpDataSourceFactory = OkHttpDataSource.Factory(okHttpClient) - cacheDataSourceFactory = CacheDataSource.Factory() - .setCache(cache) - .setUpstreamDataSourceFactory(okHttpDataSourceFactory) - } - private var player: ExoPlayer? = null - private val mainHandler = Handler(Looper.getMainLooper()) - private val positionUpdateRunnable = object : Runnable { - override fun run() { - player?.let { - _currentPosition.value = it.currentPosition - if (_duration.value == 0L && it.duration > 0) { - _duration.value = it.duration - } - } - mainHandler.postDelayed(this, 500) - } - } + private var controllerFuture: ListenableFuture? = null + private var mediaController: MediaController? = null + private var positionUpdateJob: Job? = null + private val mainScope = CoroutineScope(Dispatchers.Main) private val _isPlaying = MutableStateFlow(false) val isPlaying: StateFlow = _isPlaying.asStateFlow() @@ -66,88 +45,139 @@ class AudioPlayerController( private val _currentAudio = MutableStateFlow(null) val currentAudio: StateFlow = _currentAudio.asStateFlow() - @OptIn(UnstableApi::class) - fun play(audio: AudioEnclosure) { - mainHandler.post { - playOnMainThread(audio) + private fun ensureController(onReady: (MediaController) -> Unit) { + mediaController?.let { + if (it.isConnected) { + onReady(it) + return + } } - } - - @OptIn(UnstableApi::class) - private fun playOnMainThread(audio: AudioEnclosure) { - val currentUrl = _currentAudio.value?.url - if (currentUrl == audio.url && player != null) { - player?.play() - return - } + val sessionToken = SessionToken( + context, + ComponentName(context, MediaPlaybackService::class.java) + ) + + controllerFuture = MediaController.Builder(context, sessionToken) + .setApplicationLooper(Looper.getMainLooper()) + .buildAsync() + controllerFuture?.addListener({ + try { + val controller = controllerFuture?.get() + mediaController = controller + controller?.let { + setupPlayerListener(it) + onReady(it) + } + } catch (e: Exception) { + CapyLog.error("audio_player", e) + } + }, ContextCompat.getMainExecutor(context)) + } - releaseInternal() - - val mediaSourceFactory = DefaultMediaSourceFactory(context) - .setDataSourceFactory(cacheDataSourceFactory) - - player = ExoPlayer.Builder(context) - .setMediaSourceFactory(mediaSourceFactory) - .build() - .apply { - val mediaItem = MediaItem.fromUri(audio.url) - setMediaItem(mediaItem) - prepare() - playWhenReady = true - - addListener(object : Player.Listener { - override fun onIsPlayingChanged(isPlaying: Boolean) { - _isPlaying.value = isPlaying - if (isPlaying) { - startPositionUpdates() - } else { - stopPositionUpdates() - } + private fun setupPlayerListener(controller: MediaController) { + controller.addListener(object : Player.Listener { + override fun onIsPlayingChanged(isPlaying: Boolean) { + _isPlaying.value = isPlaying + if (isPlaying) { + startPositionUpdates() + } else { + stopPositionUpdates() } + } - override fun onPlaybackStateChanged(playbackState: Int) { - if (playbackState == Player.STATE_READY) { - _duration.value = duration - } - if (playbackState == Player.STATE_ENDED) { - _isPlaying.value = false - _currentPosition.value = 0L - seekTo(0) - } + override fun onPlaybackStateChanged(playbackState: Int) { + if (playbackState == Player.STATE_READY) { + _duration.value = controller.duration } - }) - } + if (playbackState == Player.STATE_ENDED) { + _isPlaying.value = false + _currentPosition.value = 0L + controller.seekTo(0) + } + } + }) + } + + @OptIn(UnstableApi::class) + fun play(audio: AudioEnclosure) { + mainScope.launch { + val currentUrl = _currentAudio.value?.url - _currentAudio.value = audio + if (currentUrl == audio.url && mediaController?.isConnected == true) { + mediaController?.play() + return@launch + } - audio.durationSeconds?.let { - _duration.value = it * 1000 + ensureController { controller -> + val mediaItem = MediaItem.Builder() + .setUri(audio.url) + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(audio.title) + .setArtist(audio.feedName) + .setArtworkUri(audio.artworkUrl?.let { Uri.parse(it) }) + .build() + ) + .build() + + controller.setMediaItem(mediaItem) + controller.prepare() + controller.playWhenReady = true + + _currentAudio.value = audio + audio.durationSeconds?.let { + _duration.value = it * 1000 + } + } } } fun pause() { - mainHandler.post { - player?.pause() + mainScope.launch { + mediaController?.pause() } } fun resume() { - mainHandler.post { - player?.play() + mainScope.launch { + mediaController?.play() } } fun seekTo(positionMs: Long) { - mainHandler.post { - player?.seekTo(positionMs) + mainScope.launch { + mediaController?.seekTo(positionMs) _currentPosition.value = positionMs } } + fun skipBack() { + mainScope.launch { + mediaController?.let { controller -> + val newPosition = SkipCalculator.skipBack(controller.currentPosition) + controller.seekTo(newPosition) + _currentPosition.value = newPosition + } + } + } + + fun skipForward() { + mainScope.launch { + mediaController?.let { controller -> + val newPosition = SkipCalculator.skipForward(controller.currentPosition, controller.duration) + controller.seekTo(newPosition) + _currentPosition.value = newPosition + } + } + } + fun dismiss() { - mainHandler.post { - releaseInternal() + mainScope.launch { + mediaController?.let { controller -> + controller.stop() + controller.clearMediaItems() + } _currentAudio.value = null _isPlaying.value = false _currentPosition.value = 0L @@ -155,18 +185,32 @@ class AudioPlayerController( } } - private fun releaseInternal() { + fun release() { stopPositionUpdates() - player?.release() - player = null + controllerFuture?.let { + MediaController.releaseFuture(it) + } + mediaController = null + controllerFuture = null } private fun startPositionUpdates() { stopPositionUpdates() - mainHandler.post(positionUpdateRunnable) + positionUpdateJob = mainScope.launch { + while (isActive) { + mediaController?.let { + _currentPosition.value = it.currentPosition + if (_duration.value == 0L && it.duration > 0) { + _duration.value = it.duration + } + } + delay(500) + } + } } private fun stopPositionUpdates() { - mainHandler.removeCallbacks(positionUpdateRunnable) + positionUpdateJob?.cancel() + positionUpdateJob = null } } diff --git a/app/src/main/java/com/capyreader/app/ui/articles/audio/MediaPlaybackService.kt b/app/src/main/java/com/capyreader/app/ui/articles/audio/MediaPlaybackService.kt new file mode 100644 index 000000000..26c3041e8 --- /dev/null +++ b/app/src/main/java/com/capyreader/app/ui/articles/audio/MediaPlaybackService.kt @@ -0,0 +1,181 @@ +package com.capyreader.app.ui.articles.audio + +import android.content.Intent +import android.os.Bundle +import androidx.annotation.OptIn +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C +import androidx.media3.common.ForwardingPlayer +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.database.StandaloneDatabaseProvider +import androidx.media3.datasource.cache.CacheDataSource +import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor +import androidx.media3.datasource.cache.SimpleCache +import androidx.media3.datasource.okhttp.OkHttpDataSource +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.session.CommandButton +import androidx.media3.session.DefaultMediaNotificationProvider +import androidx.media3.session.MediaSession +import androidx.media3.session.MediaSessionService +import androidx.media3.session.SessionCommand +import androidx.media3.session.SessionResult +import com.capyreader.app.R +import com.google.common.collect.ImmutableList +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture +import com.jocmp.capy.accounts.baseHttpClient +import java.io.File + +private const val CACHE_SIZE_BYTES = 100L * 1024L * 1024L + +@UnstableApi +class MediaPlaybackService : MediaSessionService() { + private var mediaSession: MediaSession? = null + private var cache: SimpleCache? = null + + companion object { + const val CUSTOM_COMMAND_SKIP_BACK = "SKIP_BACK_30" + const val CUSTOM_COMMAND_SKIP_FORWARD = "SKIP_FORWARD_30" + } + + @OptIn(UnstableApi::class) + override fun onCreate() { + super.onCreate() + + val cacheDir = File(cacheDir, "audio_cache") + val databaseProvider = StandaloneDatabaseProvider(this) + cache = + SimpleCache(cacheDir, LeastRecentlyUsedCacheEvictor(CACHE_SIZE_BYTES), databaseProvider) + + val okHttpClient = baseHttpClient() + val okHttpDataSourceFactory = OkHttpDataSource.Factory(okHttpClient) + val cacheDataSourceFactory = CacheDataSource.Factory() + .setUpstreamDataSourceFactory(okHttpDataSourceFactory) + .also { factory -> + cache?.let { + factory.setCache(it) + } + } + + val mediaSourceFactory = DefaultMediaSourceFactory(this) + .setDataSourceFactory(cacheDataSourceFactory) + + val exoPlayer = ExoPlayer.Builder(this) + .setMediaSourceFactory(mediaSourceFactory) + .setAudioAttributes( + AudioAttributes.Builder() + .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) + .setUsage(C.USAGE_MEDIA) + .build(), + true + ) + .setHandleAudioBecomingNoisy(true) + .build() + + val notificationProvider = DefaultMediaNotificationProvider(this) + notificationProvider.setSmallIcon(R.drawable.capy_icon_inline) + setMediaNotificationProvider(notificationProvider) + + val player = object : ForwardingPlayer(exoPlayer) { + override fun getAvailableCommands(): Player.Commands { + return super.getAvailableCommands().buildUpon() + .remove(Player.COMMAND_SEEK_TO_PREVIOUS) + .remove(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM) + .remove(Player.COMMAND_SEEK_TO_NEXT) + .remove(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) + .build() + } + + override fun isCommandAvailable(command: Int): Boolean { + return when (command) { + Player.COMMAND_SEEK_TO_PREVIOUS, + Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM, + Player.COMMAND_SEEK_TO_NEXT, + Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM -> false + + else -> super.isCommandAvailable(command) + } + } + } + + mediaSession = MediaSession.Builder(this, player) + .setCallback(MediaSessionCallback()) + .build() + } + + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? { + return mediaSession + } + + override fun onTaskRemoved(rootIntent: Intent?) { + val player = mediaSession?.player + if (player?.playWhenReady == false || player?.mediaItemCount == 0) { + stopSelf() + } + } + + override fun onDestroy() { + mediaSession?.run { + player.release() + release() + } + mediaSession = null + cache?.release() + cache = null + super.onDestroy() + } + + private inner class MediaSessionCallback : MediaSession.Callback { + @OptIn(UnstableApi::class) + override fun onConnect( + session: MediaSession, + controller: MediaSession.ControllerInfo + ): MediaSession.ConnectionResult { + val skipBackCommand = SessionCommand(CUSTOM_COMMAND_SKIP_BACK, Bundle.EMPTY) + val skipForwardCommand = SessionCommand(CUSTOM_COMMAND_SKIP_FORWARD, Bundle.EMPTY) + + val skipBackButton = CommandButton.Builder(CommandButton.ICON_SKIP_BACK_30) + .setDisplayName(getString(R.string.audio_player_skip_back)) + .setSessionCommand(skipBackCommand) + .build() + + val skipForwardButton = CommandButton.Builder(CommandButton.ICON_SKIP_FORWARD_30) + .setDisplayName(getString(R.string.audio_player_skip_forward)) + .setSessionCommand(skipForwardCommand) + .build() + + return MediaSession.ConnectionResult.AcceptedResultBuilder(session) + .setAvailableSessionCommands( + MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon() + .add(skipBackCommand) + .add(skipForwardCommand) + .build() + ) + .setMediaButtonPreferences(ImmutableList.of(skipBackButton, skipForwardButton)) + .build() + } + + override fun onCustomCommand( + session: MediaSession, + controller: MediaSession.ControllerInfo, + customCommand: SessionCommand, + args: Bundle + ): ListenableFuture { + val player = session.player + when (customCommand.customAction) { + CUSTOM_COMMAND_SKIP_BACK -> { + val newPosition = SkipCalculator.skipBack(player.currentPosition) + player.seekTo(newPosition) + } + + CUSTOM_COMMAND_SKIP_FORWARD -> { + val newPosition = SkipCalculator.skipForward(player.currentPosition, player.duration) + player.seekTo(newPosition) + } + } + return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) + } + } +} diff --git a/app/src/main/java/com/capyreader/app/ui/articles/audio/SkipCalculator.kt b/app/src/main/java/com/capyreader/app/ui/articles/audio/SkipCalculator.kt new file mode 100644 index 000000000..fd7c67ade --- /dev/null +++ b/app/src/main/java/com/capyreader/app/ui/articles/audio/SkipCalculator.kt @@ -0,0 +1,17 @@ +package com.capyreader.app.ui.articles.audio + +internal object SkipCalculator { + const val SKIP_DURATION_MS = 30_000L + + fun skipBack(currentPosition: Long): Long { + return maxOf(0L, currentPosition - SKIP_DURATION_MS) + } + + fun skipForward(currentPosition: Long, duration: Long): Long { + return if (duration > 0) { + minOf(duration, currentPosition + SKIP_DURATION_MS) + } else { + currentPosition + SKIP_DURATION_MS + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 88321574f..e01946308 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -312,4 +312,6 @@ Play Pause Close player + Skip back 30 seconds + Skip forward 30 seconds diff --git a/app/src/test/java/com/capyreader/app/ui/articles/audio/SkipCalculatorTest.kt b/app/src/test/java/com/capyreader/app/ui/articles/audio/SkipCalculatorTest.kt new file mode 100644 index 000000000..f36021870 --- /dev/null +++ b/app/src/test/java/com/capyreader/app/ui/articles/audio/SkipCalculatorTest.kt @@ -0,0 +1,48 @@ +package com.capyreader.app.ui.articles.audio + +import org.junit.Assert.assertEquals +import org.junit.Test + +class SkipCalculatorTest { + @Test + fun skipBack_fromMiddle() { + val result = SkipCalculator.skipBack(60_000L) + assertEquals(30_000L, result) + } + + @Test + fun skipBack_nearStart_clampsToZero() { + val result = SkipCalculator.skipBack(15_000L) + assertEquals(0L, result) + } + + @Test + fun skipBack_atStart_staysAtZero() { + val result = SkipCalculator.skipBack(0L) + assertEquals(0L, result) + } + + @Test + fun skipForward_fromMiddle() { + val result = SkipCalculator.skipForward(60_000L, 120_000L) + assertEquals(90_000L, result) + } + + @Test + fun skipForward_nearEnd_clampsToDuration() { + val result = SkipCalculator.skipForward(100_000L, 120_000L) + assertEquals(120_000L, result) + } + + @Test + fun skipForward_unknownDuration_addsSkipAmount() { + val result = SkipCalculator.skipForward(60_000L, 0L) + assertEquals(90_000L, result) + } + + @Test + fun skipForward_negativeDuration_addsSkipAmount() { + val result = SkipCalculator.skipForward(60_000L, -1L) + assertEquals(90_000L, result) + } +} From 584f71bd440760d26e7210e6003adfdbc1b40b93 Mon Sep 17 00:00:00 2001 From: Josiah Campbell <9521010+jocmp@users.noreply.github.com> Date: Mon, 5 Jan 2026 23:10:20 -0600 Subject: [PATCH 2/2] Split up instrumented tests in CI --- .../{ci.yml => instrumented-tests.yml} | 32 +--------------- .github/workflows/test.yml | 37 +++++++++++++++++++ .../audio/AudioPlayerControllerTest.kt | 32 ++++++++++++---- .../capyreader/ExampleInstrumentedTest.kt | 22 ----------- 4 files changed, 62 insertions(+), 61 deletions(-) rename .github/workflows/{ci.yml => instrumented-tests.yml} (52%) create mode 100644 .github/workflows/test.yml delete mode 100644 app/src/androidTest/java/com/jocmp/capyreader/ExampleInstrumentedTest.kt diff --git a/.github/workflows/ci.yml b/.github/workflows/instrumented-tests.yml similarity index 52% rename from .github/workflows/ci.yml rename to .github/workflows/instrumented-tests.yml index 579565a0c..759038f19 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/instrumented-tests.yml @@ -4,38 +4,8 @@ on: push: branches: - main - pull_request: jobs: - typecheck: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4.2.1 - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: "20" - - name: Type-check JavaScript - run: make check - - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4.2.1 - - name: Set up Java - uses: actions/setup-java@v4.4.0 - with: - distribution: 'zulu' - java-version: "21" - - name: Set up Gradle - uses: gradle/actions/setup-gradle@v4 - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - bundler-cache: true - - name: Run tests - run: make test - instrumented-test: runs-on: ubuntu-latest steps: @@ -43,7 +13,7 @@ jobs: - name: Set up Java uses: actions/setup-java@v4.4.0 with: - distribution: 'zulu' + distribution: "zulu" java-version: "21" - name: Set up Gradle uses: gradle/actions/setup-gradle@v4 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 000000000..d371fc37c --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,37 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + +jobs: + typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4.2.1 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + - name: Type-check JavaScript + run: make check + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4.2.1 + - name: Set up Java + uses: actions/setup-java@v4.4.0 + with: + distribution: 'zulu' + java-version: "21" + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + - name: Run tests + run: make test diff --git a/app/src/androidTest/java/com/capyreader/app/ui/articles/audio/AudioPlayerControllerTest.kt b/app/src/androidTest/java/com/capyreader/app/ui/articles/audio/AudioPlayerControllerTest.kt index a6ea996f8..d17e0f29d 100644 --- a/app/src/androidTest/java/com/capyreader/app/ui/articles/audio/AudioPlayerControllerTest.kt +++ b/app/src/androidTest/java/com/capyreader/app/ui/articles/audio/AudioPlayerControllerTest.kt @@ -3,9 +3,11 @@ package com.capyreader.app.ui.articles.audio import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.capyreader.app.common.AudioEnclosure +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import org.junit.After import org.junit.Assert.assertEquals @@ -30,13 +32,17 @@ class AudioPlayerControllerTest { @Before fun setup() { val context = InstrumentationRegistry.getInstrumentation().targetContext - controller = AudioPlayerController(context) + InstrumentationRegistry.getInstrumentation().runOnMainSync { + controller = AudioPlayerController(context) + } } @After fun teardown() { - controller.dismiss() - controller.release() + InstrumentationRegistry.getInstrumentation().runOnMainSync { + controller.dismiss() + controller.release() + } } @Test @@ -59,7 +65,9 @@ class AudioPlayerControllerTest { @Test fun play_setsCurrentAudio() = runBlocking { - controller.play(testAudio) + withContext(Dispatchers.Main) { + controller.play(testAudio) + } withTimeout(5000) { while (controller.currentAudio.value == null) { @@ -75,7 +83,9 @@ class AudioPlayerControllerTest { @Test fun dismiss_clearsCurrentAudio() = runBlocking { - controller.play(testAudio) + withContext(Dispatchers.Main) { + controller.play(testAudio) + } withTimeout(5000) { while (controller.currentAudio.value == null) { @@ -83,7 +93,9 @@ class AudioPlayerControllerTest { } } - controller.dismiss() + withContext(Dispatchers.Main) { + controller.dismiss() + } withTimeout(2000) { while (controller.currentAudio.value != null) { @@ -97,7 +109,9 @@ class AudioPlayerControllerTest { @Test fun seekTo_updatesPosition() = runBlocking { - controller.play(testAudio) + withContext(Dispatchers.Main) { + controller.play(testAudio) + } withTimeout(5000) { while (controller.currentAudio.value == null) { @@ -106,7 +120,9 @@ class AudioPlayerControllerTest { } val seekPosition = 30_000L - controller.seekTo(seekPosition) + withContext(Dispatchers.Main) { + controller.seekTo(seekPosition) + } withTimeout(2000) { while (controller.currentPosition.value != seekPosition) { diff --git a/app/src/androidTest/java/com/jocmp/capyreader/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/jocmp/capyreader/ExampleInstrumentedTest.kt deleted file mode 100644 index 96594012c..000000000 --- a/app/src/androidTest/java/com/jocmp/capyreader/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.capyreader.app - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import org.junit.Assert.assertEquals -import org.junit.Test -import org.junit.runner.RunWith - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.jocmp.basilreader", appContext.packageName) - } -}