Skip to content

Commit 8a8441c

Browse files
authored
Add media controls for lock screen and notification (#1659)
- 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
1 parent b2a6246 commit 8a8441c

11 files changed

Lines changed: 570 additions & 126 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
8+
jobs:
9+
instrumented-test:
10+
runs-on: ubuntu-latest
11+
steps:
12+
- uses: actions/checkout@v4.2.1
13+
- name: Set up Java
14+
uses: actions/setup-java@v4.4.0
15+
with:
16+
distribution: "zulu"
17+
java-version: "21"
18+
- name: Set up Gradle
19+
uses: gradle/actions/setup-gradle@v4
20+
- name: Enable KVM
21+
run: |
22+
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
23+
sudo udevadm control --reload-rules
24+
sudo udevadm trigger --name-match=kvm
25+
- name: Run instrumented tests
26+
uses: reactivecircus/android-emulator-runner@v2
27+
with:
28+
api-level: 31
29+
arch: x86_64
30+
script: ./gradlew :app:connectedFreeDebugAndroidTest
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
package com.capyreader.app.ui.articles.audio
2+
3+
import androidx.test.ext.junit.runners.AndroidJUnit4
4+
import androidx.test.platform.app.InstrumentationRegistry
5+
import com.capyreader.app.common.AudioEnclosure
6+
import kotlinx.coroutines.Dispatchers
7+
import kotlinx.coroutines.delay
8+
import kotlinx.coroutines.flow.first
9+
import kotlinx.coroutines.runBlocking
10+
import kotlinx.coroutines.withContext
11+
import kotlinx.coroutines.withTimeout
12+
import org.junit.After
13+
import org.junit.Assert.assertEquals
14+
import org.junit.Assert.assertNotNull
15+
import org.junit.Assert.assertNull
16+
import org.junit.Before
17+
import org.junit.Test
18+
import org.junit.runner.RunWith
19+
20+
@RunWith(AndroidJUnit4::class)
21+
class AudioPlayerControllerTest {
22+
private lateinit var controller: AudioPlayerController
23+
24+
private val testAudio = AudioEnclosure(
25+
url = "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3",
26+
title = "Test Audio",
27+
feedName = "Test Feed",
28+
durationSeconds = 60,
29+
artworkUrl = null,
30+
)
31+
32+
@Before
33+
fun setup() {
34+
val context = InstrumentationRegistry.getInstrumentation().targetContext
35+
InstrumentationRegistry.getInstrumentation().runOnMainSync {
36+
controller = AudioPlayerController(context)
37+
}
38+
}
39+
40+
@After
41+
fun teardown() {
42+
InstrumentationRegistry.getInstrumentation().runOnMainSync {
43+
controller.dismiss()
44+
controller.release()
45+
}
46+
}
47+
48+
@Test
49+
fun initialState_hasNullCurrentAudio() = runBlocking {
50+
val currentAudio = controller.currentAudio.first()
51+
assertNull(currentAudio)
52+
}
53+
54+
@Test
55+
fun initialState_isNotPlaying() = runBlocking {
56+
val isPlaying = controller.isPlaying.first()
57+
assertEquals(false, isPlaying)
58+
}
59+
60+
@Test
61+
fun initialState_positionIsZero() = runBlocking {
62+
val position = controller.currentPosition.first()
63+
assertEquals(0L, position)
64+
}
65+
66+
@Test
67+
fun play_setsCurrentAudio() = runBlocking {
68+
withContext(Dispatchers.Main) {
69+
controller.play(testAudio)
70+
}
71+
72+
withTimeout(5000) {
73+
while (controller.currentAudio.value == null) {
74+
delay(100)
75+
}
76+
}
77+
78+
val currentAudio = controller.currentAudio.first()
79+
assertNotNull(currentAudio)
80+
assertEquals(testAudio.url, currentAudio?.url)
81+
assertEquals(testAudio.title, currentAudio?.title)
82+
}
83+
84+
@Test
85+
fun dismiss_clearsCurrentAudio() = runBlocking {
86+
withContext(Dispatchers.Main) {
87+
controller.play(testAudio)
88+
}
89+
90+
withTimeout(5000) {
91+
while (controller.currentAudio.value == null) {
92+
delay(100)
93+
}
94+
}
95+
96+
withContext(Dispatchers.Main) {
97+
controller.dismiss()
98+
}
99+
100+
withTimeout(2000) {
101+
while (controller.currentAudio.value != null) {
102+
delay(100)
103+
}
104+
}
105+
106+
val currentAudio = controller.currentAudio.first()
107+
assertNull(currentAudio)
108+
}
109+
110+
@Test
111+
fun seekTo_updatesPosition() = runBlocking {
112+
withContext(Dispatchers.Main) {
113+
controller.play(testAudio)
114+
}
115+
116+
withTimeout(5000) {
117+
while (controller.currentAudio.value == null) {
118+
delay(100)
119+
}
120+
}
121+
122+
val seekPosition = 30_000L
123+
withContext(Dispatchers.Main) {
124+
controller.seekTo(seekPosition)
125+
}
126+
127+
withTimeout(2000) {
128+
while (controller.currentPosition.value != seekPosition) {
129+
delay(100)
130+
}
131+
}
132+
133+
val position = controller.currentPosition.first()
134+
assertEquals(seekPosition, position)
135+
}
136+
}

app/src/androidTest/java/com/jocmp/capyreader/ExampleInstrumentedTest.kt

Lines changed: 0 additions & 22 deletions
This file was deleted.

app/src/main/AndroidManifest.xml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
66
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
77
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
8+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
89
<uses-permission android:name="android.permission.INTERNET" />
910

1011
<queries>
@@ -109,5 +110,14 @@
109110
android:name="android.appwidget.provider"
110111
android:resource="@xml/headlines_widget" />
111112
</receiver>
113+
114+
<service
115+
android:name=".ui.articles.audio.MediaPlaybackService"
116+
android:foregroundServiceType="mediaPlayback"
117+
android:exported="true">
118+
<intent-filter>
119+
<action android:name="androidx.media3.session.MediaSessionService" />
120+
</intent-filter>
121+
</service>
112122
</application>
113123
</manifest>

app/src/main/java/com/capyreader/app/ui/articles/ArticlesModule.kt

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import com.capyreader.app.R
55
import com.capyreader.app.preferences.AppPreferences
66
import com.capyreader.app.ui.articles.audio.AudioPlayerController
77
import com.capyreader.app.ui.articles.feeds.edit.EditFeedViewModel
8-
import com.jocmp.capy.accounts.baseHttpClient
98
import com.jocmp.capy.articles.ArticleRenderer
109
import com.jocmp.capy.articles.AudioPlayerLabels
1110
import org.koin.androidx.viewmodel.dsl.viewModel
@@ -20,8 +19,7 @@ internal val articlesModule = module {
2019
}
2120
single {
2221
AudioPlayerController(
23-
context = get(),
24-
okHttpClient = baseHttpClient()
22+
context = get()
2523
)
2624
}
2725
single {

0 commit comments

Comments
 (0)