Recently Written · git

subread-overlay

Shows the lines of an .srt over any Android media player, in time with it. Tap a word to look it up.

git clone https://github.com/equwal/subread-overlay

Log | Files | Refs


app/src/androidTest/kotlin/space/subread/overlay/OverlayTest.kt (14185 bytes)

1 package space.subread.overlay
2 
3 import android.media.session.MediaController
4 import android.media.session.MediaSession
5 import android.media.session.PlaybackState
6 import android.os.Handler
7 import android.os.Looper
8 import android.os.SystemClock
9 import android.view.ViewGroup
10 import androidx.test.core.app.ActivityScenario
11 import androidx.test.ext.junit.runners.AndroidJUnit4
12 import androidx.test.platform.app.InstrumentationRegistry
13 import org.junit.Assert.assertEquals
14 import org.junit.Assert.assertNull
15 import org.junit.Assert.assertTrue
16 import org.junit.Test
17 import org.junit.runner.RunWith
18 import space.subread.overlay.core.Cue
19 import space.subread.overlay.core.CueIndex
20 import java.util.concurrent.CopyOnWriteArrayList
21 
22 /** The word cutter of the device. It is ICU of Android, so the test runs on the device. */
23 @RunWith(AndroidJUnit4::class)
24 class WordsTest {
25 
26     private fun word(text: String, offset: Int) = Words.at(text, offset)?.let { text.substring(it.first, it.last + 1) }
27 
28     @Test
29     fun aWordOfALanguageWithSpaces() {
30         val text = "Он вышел из дома, and never came back."
31         assertEquals("вышел", word(text, text.indexOf("шел")))
32         assertEquals("never", word(text, text.indexOf("never")))
33         assertNull("a space", word(text, 2))
34         assertNull("a comma", word(text, text.indexOf(',')))
35         assertNull(word(text, text.length))
36         assertNull(word("", 0))
37     }
38 
39     @Test
40     fun aWordOfJapaneseThatHasNoSpaces() {
41         val text = "女のいない男たちは、東京で暮らしている。"
42         // The dictionary of ICU decides the cut. The test asks for what each good cut has:
43         // a word is shorter than the sentence, stops at the comma, and has the character under the finger.
44         for (at in listOf(0, text.indexOf("男"), text.indexOf("東京"), text.indexOf("暮"))) {
45             val range = Words.at(text, at)!!
46             assertTrue("$range has $at", at in range)
47             assertTrue("$range is a word, not the sentence", range.last - range.first < 6)
48             assertTrue("no comma in a word", '、' !in text.substring(range.first, range.last + 1))
49         }
50         assertEquals("東京", word(text, text.indexOf("東京")))
51         assertNull(word(text, text.indexOf('、')))
52     }
53 
54     @Test
55     fun aDragSelectsFromTheFirstWordToTheWordUnderTheFinger() {
56         val text = "one two three four"
57         val anchor = Words.at(text, text.indexOf("two"))!!
58         assertEquals("two three", Words.between(text, anchor, text.indexOf("three")).let { text.substring(it.first, it.last + 1) })
59         assertEquals("one two", Words.between(text, anchor, 0).let { text.substring(it.first, it.last + 1) })
60         assertEquals("a drag over a space keeps the selection", anchor, Words.between(text, anchor, 3))
61     }
62 }
63 
64 /** The panel, in the window of an activity: the test needs no permission that way. */
65 @RunWith(AndroidJUnit4::class)
66 class OverlayViewTest {
67 
68     private val lookedUp = CopyOnWriteArrayList<String>()
69     private val shared = CopyOnWriteArrayList<String>()
70     private val events = object : OverlayView.Events {
71         override fun onDrag(dx: Float, dy: Float) = Unit
72         override fun onDragEnd() = Unit
73         override fun onClose() = Unit
74         override fun onLookUp(word: String) { lookedUp += word }
75         override fun onShare(word: String) { shared += word }
76         override fun onTogglePlay() = Unit
77         override fun onShiftLines(steps: Int) = Unit
78         override fun onNudge(ms: Long) = Unit
79     }
80 
81     /** The x of the middle of the character at [offset], in the panel. */
82     private fun OverlayView.middleOf(offset: Int): Float {
83         val layout = text.layout
84         return text.totalPaddingLeft + (layout.getPrimaryHorizontal(offset) + layout.getPrimaryHorizontal(offset + 1)) / 2
85     }
86 
87     /** A y just over the baseline of the row of the character at [offset], in the panel. */
88     private fun OverlayView.rowOf(offset: Int): Float =
89         text.totalPaddingTop + text.layout.getLineBaseline(text.layout.getLineForOffset(offset)) - 5f
90 
91     @Test
92     fun aTapSelectsTheWordUnderTheFingerAndLookUpSendsIt() {
93         ActivityScenario.launch(MainActivity::class.java).use { scenario ->
94             lateinit var panel: OverlayView
95             scenario.onActivity {
96                 panel = OverlayView(it, events)
97                 panel.setTextSize(30f)
98                 it.addContentView(panel, ViewGroup.LayoutParams(-1, -2))
99                 panel.showLine("reading along is easy")
100             }
101             InstrumentationRegistry.getInstrumentation().waitForIdleSync()
102             scenario.onActivity {
103                 val start = panel.line.indexOf("along")
104                 // The middle of the third letter of "along".
105                 panel.selectAt(panel.middleOf(start + 2), panel.rowOf(start + 2))
106                 assertEquals(start..start + 4, panel.selection)
107 
108                 // A drag to the last word selects the three words.
109                 val last = panel.line.lastIndex
110                 panel.selectAt(panel.middleOf(last), panel.rowOf(last), extend = true)
111                 assertEquals(start..last, panel.selection)
112 
113                 val found = arrayListOf<android.view.View>()
114                 panel.findViewsWithText(found, "Look up", android.view.View.FIND_VIEWS_WITH_TEXT)
115                 found.single().performClick()
116                 assertEquals(listOf("along is easy"), lookedUp)
117                 found.clear()
118                 panel.findViewsWithText(found, "Share", android.view.View.FIND_VIEWS_WITH_TEXT)
119                 found.single().performClick()
120                 assertEquals(listOf("along is easy"), shared)
121 
122                 // The next line has no selection from the line before.
123                 panel.showLine("the next line")
124                 assertNull(panel.selection)
125             }
126         }
127     }
128 
129     @Test
130     fun theLinesAroundAreOnTheirOwnRowsAndAWordOfThemCanBeSelected() {
131         ActivityScenario.launch(MainActivity::class.java).use { scenario ->
132             lateinit var panel: OverlayView
133             scenario.onActivity {
134                 panel = OverlayView(it, events)
135                 panel.setTextSize(30f)
136                 it.addContentView(panel, ViewGroup.LayoutParams(-1, -2))
137                 panel.showLine("the line of now", "the line before", "the line after")
138             }
139             InstrumentationRegistry.getInstrumentation().waitForIdleSync()
140             scenario.onActivity {
141                 assertEquals("the line before\nthe line of now\nthe line after", panel.line)
142                 val layout = panel.text.layout
143                 val now = panel.line.indexOf("the line of now")
144                 val start = panel.line.indexOf("after")
145                 assertTrue("each line on its own row", layout.getLineForOffset(0) < layout.getLineForOffset(now))
146                 assertTrue("each line on its own row", layout.getLineForOffset(now) < layout.getLineForOffset(start))
147                 panel.selectAt(panel.middleOf(start + 1), panel.rowOf(start + 1))
148                 assertEquals(start..start + 4, panel.selection)
149 
150                 // The same lines again change nothing; one line alone is one row again.
151                 panel.showLine("the line of now", "the line before", "the line after")
152                 assertEquals(start..start + 4, panel.selection)
153                 panel.showLine("the line of now")
154                 assertEquals("the line of now", panel.line)
155                 assertNull(panel.selection)
156             }
157         }
158     }
159 
160     @Test
161     fun theTransparencyIsTheAlphaOfTheBackground() {
162         ActivityScenario.launch(MainActivity::class.java).use { scenario ->
163             scenario.onActivity {
164                 val panel = OverlayView(it, events)
165                 panel.setTransparency(40)
166                 assertEquals(153, panel.background.alpha)
167                 panel.setTransparency(0)
168                 assertEquals(255, panel.background.alpha)
169             }
170         }
171     }
172 }
173 
174 /** A media session of the test plays the part of Voice, VLC or YouTube. */
175 @RunWith(AndroidJUnit4::class)
176 class FollowerTest {
177 
178     private val context = InstrumentationRegistry.getInstrumentation().targetContext
179     private val main = Handler(Looper.getMainLooper())
180     private val lines = CopyOnWriteArrayList<String?>()
181     private val index = CueIndex(listOf(Cue(1_000, 2_000, "one"), Cue(60_000, 61_000, "two"), Cue(60_400, 61_000, "three")))
182 
183     private fun state(state: Int, position: Long, speed: Float = 1f) = PlaybackState.Builder()
184         .setState(state, position, speed, SystemClock.elapsedRealtime()).build()
185 
186     private fun onMain(block: () -> Unit) = InstrumentationRegistry.getInstrumentation().runOnMainSync(block)
187 
188     private fun waitFor(line: String?) {
189         val until = SystemClock.elapsedRealtime() + 5_000
190         while (lines.lastOrNull() != line && SystemClock.elapsedRealtime() < until) Thread.sleep(20)
191         assertEquals(lines.toString(), line, lines.lastOrNull())
192     }
193 
194     @Test
195     fun theLineFollowsThePlayerThroughPauseSeekSpeedAndOffset() {
196         val session = MediaSession(context, "test player")
197         lateinit var follower: Follower
198         try {
199             session.setPlaybackState(state(PlaybackState.STATE_PAUSED, 1_500))
200             session.isActive = true
201             onMain {
202                 follower = Follower(main) { at, _, _ -> lines += follower.index.cues.getOrNull(at)?.text }
203                 follower.index = index
204                 follower.follow(listOf(MediaController(context, session.sessionToken)))
205             }
206             waitFor("one")
207 
208             // A seek. The player is paused: the line changes at once and then stays.
209             session.setPlaybackState(state(PlaybackState.STATE_PAUSED, 500))
210             waitFor(null)
211 
212             // Play at double speed from 59.6 s: "two" is 0.2 s away, "three" 0.2 s after it.
213             val started = SystemClock.elapsedRealtime()
214             session.setPlaybackState(state(PlaybackState.STATE_PLAYING, 59_600, speed = 2f))
215             waitFor("one")
216             waitFor("two")
217             waitFor("three")
218             val took = SystemClock.elapsedRealtime() - started
219             assertTrue("the follower waited $took ms; at double speed 0.8 s of media is 0.4 s", took in 350..1_500)
220 
221             // The subtitles are 60 s late for this media: the user shifts them.
222             session.setPlaybackState(state(PlaybackState.STATE_PAUSED, 100))
223             waitFor(null)
224             onMain { follower.offsetMs = 60_000 }
225             waitFor("two")
226 
227             // "line ▶" makes the next line the line of now.
228             onMain { follower.shiftLines(1) }
229             waitFor("three")
230             onMain { assertEquals(60_400L - 100L, follower.offsetMs) }
231         } finally {
232             onMain { follower.stop() }
233             session.release()
234         }
235     }
236 
237     @Test
238     fun aPlayerWithoutAPositionKeepsItsLineWhenItPauses() {
239         val session = MediaSession(context, "player without position")
240         lateinit var follower: Follower
241         try {
242             session.setPlaybackState(state(PlaybackState.STATE_PLAYING, PlaybackState.PLAYBACK_POSITION_UNKNOWN))
243             session.isActive = true
244             onMain {
245                 follower = Follower(main) { at, _, _ -> lines += follower.index.cues.getOrNull(at)?.text }
246                 follower.index = CueIndex(listOf(Cue(0, 300, "first"), Cue(400, 900, "second")))
247                 follower.follow(listOf(MediaController(context, session.sessionToken)))
248             }
249             // Android counts from zero for a player that plays and gives no position.
250             waitFor("second")
251             // In a pause there is no position at all. The first version went back to "before the first line".
252             session.setPlaybackState(state(PlaybackState.STATE_PAUSED, PlaybackState.PLAYBACK_POSITION_UNKNOWN))
253             Thread.sleep(500)
254             assertEquals(lines.toString(), "second", lines.last())
255         } finally {
256             onMain { follower.stop() }
257             session.release()
258         }
259     }
260 
261     @Test
262     fun noPlayerIsSaidSo() {
263         val players = CopyOnWriteArrayList<Boolean>()
264         onMain {
265             val follower = Follower(main) { _, hasPlayer, _ -> players += hasPlayer }
266             follower.follow(emptyList())
267         }
268         assertEquals(false, players.last())
269     }
270 
271     @Test
272     fun aLookupPausesThePlayerAndTheButtonStartsItAgain() {
273         val session = MediaSession(context, "test player")
274         val playing = CopyOnWriteArrayList<Boolean>()
275         var plays = 0
276         var pauses = 0
277         session.setCallback(object : MediaSession.Callback() {
278             override fun onPlay() { plays++ }
279             override fun onPause() { pauses++ }
280         }, main)
281         lateinit var follower: Follower
282         try {
283             session.setPlaybackState(state(PlaybackState.STATE_PLAYING, 1_500))
284             session.isActive = true
285             onMain {
286                 follower = Follower(main) { _, _, isPlaying -> playing += isPlaying }
287                 follower.index = index
288                 follower.follow(listOf(MediaController(context, session.sessionToken)))
289             }
290             val until = SystemClock.elapsedRealtime() + 5_000
291             while (playing.lastOrNull() != true && SystemClock.elapsedRealtime() < until) Thread.sleep(20)
292             assertEquals(true, playing.last())
293 
294             onMain { assertTrue("a player that plays is paused", follower.pause()) }
295             session.setPlaybackState(state(PlaybackState.STATE_PAUSED, 1_500))
296             while (playing.lastOrNull() != false && SystemClock.elapsedRealtime() < until) Thread.sleep(20)
297             assertEquals(false, playing.last())
298             onMain { assertEquals("a paused player is not paused again", false, follower.pause()) }
299             onMain { follower.play() }
300             while ((plays < 1 || pauses < 1) && SystemClock.elapsedRealtime() < until) Thread.sleep(20)
301             assertEquals(1, pauses)
302             assertEquals(1, plays)
303         } finally {
304             onMain { follower.stop() }
305             session.release()
306         }
307     }
308 }