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 (17043 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 aWordWithAccentsOrInvertedMarks() {
41         val pt = "— Não me parece bonito — disse ela, à porta."
42         assertEquals("Não", word(pt, pt.indexOf("Não")))
43         assertEquals("à", word(pt, pt.indexOf("à")))
44         assertNull("a dash", word(pt, 0))
45         val es = "¿Qué es esto? ¡Ñandú, señor!"
46         assertEquals("Qué", word(es, es.indexOf("Qué")))
47         assertEquals("Ñandú", word(es, es.indexOf("Ñandú")))
48         assertNull("the inverted mark", word(es, 0))
49         val ru = "«Ёлка, — сказал он, — и её огни»."
50         assertEquals("Ёлка", word(ru, ru.indexOf("Ёлка")))
51         assertEquals("её", word(ru, ru.indexOf("её")))
52     }
53 
54     @Test
55     fun aWordOfJapaneseThatHasNoSpaces() {
56         val text = "女のいない男たちは、東京で暮らしている。"
57         // The dictionary of ICU decides the cut. The test asks for what each good cut has:
58         // a word is shorter than the sentence, stops at the comma, and has the character under the finger.
59         for (at in listOf(0, text.indexOf("男"), text.indexOf("東京"), text.indexOf("暮"))) {
60             val range = Words.at(text, at)!!
61             assertTrue("$range has $at", at in range)
62             assertTrue("$range is a word, not the sentence", range.last - range.first < 6)
63             assertTrue("no comma in a word", '、' !in text.substring(range.first, range.last + 1))
64         }
65         assertEquals("東京", word(text, text.indexOf("東京")))
66         assertNull(word(text, text.indexOf('、')))
67     }
68 
69     @Test
70     fun aDragSelectsFromTheFirstWordToTheWordUnderTheFinger() {
71         val text = "one two three four"
72         val anchor = Words.at(text, text.indexOf("two"))!!
73         assertEquals("two three", Words.between(text, anchor, text.indexOf("three")).let { text.substring(it.first, it.last + 1) })
74         assertEquals("one two", Words.between(text, anchor, 0).let { text.substring(it.first, it.last + 1) })
75         assertEquals("a drag over a space keeps the selection", anchor, Words.between(text, anchor, 3))
76     }
77 }
78 
79 /** The panel, in the window of an activity: the test needs no permission that way. */
80 @RunWith(AndroidJUnit4::class)
81 class OverlayViewTest {
82 
83     private val lookedUp = CopyOnWriteArrayList<String>()
84     private var touches = 0
85     private val shared = CopyOnWriteArrayList<String>()
86     private val events = object : OverlayView.Events {
87         override fun onDrag(dx: Float, dy: Float) = Unit
88         override fun onDragEnd() = Unit
89         override fun onClose() = Unit
90         override fun onTouchWord() { touches++ }
91         override fun onLookUp(word: String) { lookedUp += word }
92         override fun onShare(word: String) { shared += word }
93         override fun onTogglePlay() = Unit
94         override fun onShiftLines(steps: Int) = Unit
95         override fun onNudge(ms: Long) = Unit
96     }
97 
98     /** The x of the middle of the character at [offset], in the panel. */
99     private fun OverlayView.middleOf(offset: Int): Float {
100         val layout = text.layout
101         return text.totalPaddingLeft + (layout.getPrimaryHorizontal(offset) + layout.getPrimaryHorizontal(offset + 1)) / 2
102     }
103 
104     /** A y just over the baseline of the row of the character at [offset], in the panel. */
105     private fun OverlayView.rowOf(offset: Int): Float =
106         text.totalPaddingTop + text.layout.getLineBaseline(text.layout.getLineForOffset(offset)) - 5f
107 
108     /** Gives a touch event at [x], [y] of the text view to the text view. */
109     private fun OverlayView.touch(action: Int, x: Float, y: Float) {
110         val now = SystemClock.uptimeMillis()
111         val event = android.view.MotionEvent.obtain(now, now, action, x, y, 0)
112         text.dispatchTouchEvent(event)
113         event.recycle()
114     }
115 
116     @Test
117     fun aTouchPausesAtOnceAndTheLiftLooksUpTheSelection() {
118         ActivityScenario.launch(MainActivity::class.java).use { scenario ->
119             lateinit var panel: OverlayView
120             scenario.onActivity {
121                 panel = OverlayView(it, events)
122                 panel.setTextSize(30f)
123                 it.addContentView(panel, ViewGroup.LayoutParams(-1, -2))
124                 panel.showLine("reading along is easy")
125             }
126             InstrumentationRegistry.getInstrumentation().waitForIdleSync()
127             scenario.onActivity {
128                 val start = panel.line.indexOf("along")
129                 val last = panel.line.lastIndex
130                 panel.touch(android.view.MotionEvent.ACTION_DOWN, panel.middleOf(start + 2), panel.rowOf(start + 2))
131                 assertEquals("the finger on a word pauses the player", 1, touches)
132                 assertEquals("no lookup before the finger lifts", emptyList<String>(), lookedUp)
133                 panel.touch(android.view.MotionEvent.ACTION_MOVE, panel.middleOf(last), panel.rowOf(last))
134                 panel.touch(android.view.MotionEvent.ACTION_UP, panel.middleOf(last), panel.rowOf(last))
135                 assertEquals(listOf("along is easy"), lookedUp)
136 
137                 val found = arrayListOf<android.view.View>()
138                 panel.findViewsWithText(found, "Look up", android.view.View.FIND_VIEWS_WITH_TEXT)
139                 assertEquals("no \"Look up\" button", 0, found.size)
140 
141                 // A tap beside the words neither pauses nor looks up.
142                 val right = panel.text.width - 1f
143                 panel.touch(android.view.MotionEvent.ACTION_DOWN, right, panel.rowOf(last))
144                 panel.touch(android.view.MotionEvent.ACTION_UP, right, panel.rowOf(last))
145                 assertNull(panel.selection)
146                 assertEquals(1, touches)
147                 assertEquals(listOf("along is easy"), lookedUp)
148             }
149         }
150     }
151 
152     @Test
153     fun aTapSelectsTheWordUnderTheFingerAndADragSelectsMore() {
154         ActivityScenario.launch(MainActivity::class.java).use { scenario ->
155             lateinit var panel: OverlayView
156             scenario.onActivity {
157                 panel = OverlayView(it, events)
158                 panel.setTextSize(30f)
159                 it.addContentView(panel, ViewGroup.LayoutParams(-1, -2))
160                 panel.showLine("reading along is easy")
161             }
162             InstrumentationRegistry.getInstrumentation().waitForIdleSync()
163             scenario.onActivity {
164                 val start = panel.line.indexOf("along")
165                 // The middle of the third letter of "along".
166                 panel.selectAt(panel.middleOf(start + 2), panel.rowOf(start + 2))
167                 assertEquals(start..start + 4, panel.selection)
168 
169                 // A drag to the last word selects the three words.
170                 val last = panel.line.lastIndex
171                 panel.selectAt(panel.middleOf(last), panel.rowOf(last), extend = true)
172                 assertEquals(start..last, panel.selection)
173 
174                 val found = arrayListOf<android.view.View>()
175                 panel.findViewsWithText(found, "Share", android.view.View.FIND_VIEWS_WITH_TEXT)
176                 found.single().performClick()
177                 assertEquals(listOf("along is easy"), shared)
178 
179                 // The next line has no selection from the line before.
180                 panel.showLine("the next line")
181                 assertNull(panel.selection)
182             }
183         }
184     }
185 
186     @Test
187     fun theLinesAroundAreOnTheirOwnRowsAndAWordOfThemCanBeSelected() {
188         ActivityScenario.launch(MainActivity::class.java).use { scenario ->
189             lateinit var panel: OverlayView
190             scenario.onActivity {
191                 panel = OverlayView(it, events)
192                 panel.setTextSize(30f)
193                 it.addContentView(panel, ViewGroup.LayoutParams(-1, -2))
194                 panel.showLine("the line of now", "the line before", "the line after")
195             }
196             InstrumentationRegistry.getInstrumentation().waitForIdleSync()
197             scenario.onActivity {
198                 assertEquals("the line before\nthe line of now\nthe line after", panel.line)
199                 val layout = panel.text.layout
200                 val now = panel.line.indexOf("the line of now")
201                 val start = panel.line.indexOf("after")
202                 assertTrue("each line on its own row", layout.getLineForOffset(0) < layout.getLineForOffset(now))
203                 assertTrue("each line on its own row", layout.getLineForOffset(now) < layout.getLineForOffset(start))
204                 panel.selectAt(panel.middleOf(start + 1), panel.rowOf(start + 1))
205                 assertEquals(start..start + 4, panel.selection)
206 
207                 // The same lines again change nothing; one line alone is one row again.
208                 panel.showLine("the line of now", "the line before", "the line after")
209                 assertEquals(start..start + 4, panel.selection)
210                 panel.showLine("the line of now")
211                 assertEquals("the line of now", panel.line)
212                 assertNull(panel.selection)
213             }
214         }
215     }
216 
217     @Test
218     fun theTransparencyIsTheAlphaOfTheBackground() {
219         ActivityScenario.launch(MainActivity::class.java).use { scenario ->
220             scenario.onActivity {
221                 val panel = OverlayView(it, events)
222                 panel.setTransparency(40)
223                 assertEquals(153, panel.background.alpha)
224                 panel.setTransparency(0)
225                 assertEquals(255, panel.background.alpha)
226             }
227         }
228     }
229 }
230 
231 /** A media session of the test plays the part of Voice, VLC or YouTube. */
232 @RunWith(AndroidJUnit4::class)
233 class FollowerTest {
234 
235     private val context = InstrumentationRegistry.getInstrumentation().targetContext
236     private val main = Handler(Looper.getMainLooper())
237     private val lines = CopyOnWriteArrayList<String?>()
238     private val index = CueIndex(listOf(Cue(1_000, 2_000, "one"), Cue(60_000, 61_000, "two"), Cue(60_400, 61_000, "three")))
239 
240     private fun state(state: Int, position: Long, speed: Float = 1f) = PlaybackState.Builder()
241         .setState(state, position, speed, SystemClock.elapsedRealtime()).build()
242 
243     private fun onMain(block: () -> Unit) = InstrumentationRegistry.getInstrumentation().runOnMainSync(block)
244 
245     private fun waitFor(line: String?) {
246         val until = SystemClock.elapsedRealtime() + 5_000
247         while (lines.lastOrNull() != line && SystemClock.elapsedRealtime() < until) Thread.sleep(20)
248         assertEquals(lines.toString(), line, lines.lastOrNull())
249     }
250 
251     @Test
252     fun theLineFollowsThePlayerThroughPauseSeekSpeedAndOffset() {
253         val session = MediaSession(context, "test player")
254         lateinit var follower: Follower
255         try {
256             session.setPlaybackState(state(PlaybackState.STATE_PAUSED, 1_500))
257             session.isActive = true
258             onMain {
259                 follower = Follower(main) { at, _, _ -> lines += follower.index.cues.getOrNull(at)?.text }
260                 follower.index = index
261                 follower.follow(listOf(MediaController(context, session.sessionToken)))
262             }
263             waitFor("one")
264 
265             // A seek. The player is paused: the line changes at once and then stays.
266             session.setPlaybackState(state(PlaybackState.STATE_PAUSED, 500))
267             waitFor(null)
268 
269             // Play at double speed from 59.6 s: "two" is 0.2 s away, "three" 0.2 s after it.
270             val started = SystemClock.elapsedRealtime()
271             session.setPlaybackState(state(PlaybackState.STATE_PLAYING, 59_600, speed = 2f))
272             waitFor("one")
273             waitFor("two")
274             waitFor("three")
275             val took = SystemClock.elapsedRealtime() - started
276             assertTrue("the follower waited $took ms; at double speed 0.8 s of media is 0.4 s", took in 350..1_500)
277 
278             // The subtitles are 60 s late for this media: the user shifts them.
279             session.setPlaybackState(state(PlaybackState.STATE_PAUSED, 100))
280             waitFor(null)
281             onMain { follower.offsetMs = 60_000 }
282             waitFor("two")
283 
284             // "line ▶" makes the next line the line of now.
285             onMain { follower.shiftLines(1) }
286             waitFor("three")
287             onMain { assertEquals(60_400L - 100L, follower.offsetMs) }
288         } finally {
289             onMain { follower.stop() }
290             session.release()
291         }
292     }
293 
294     @Test
295     fun aPlayerWithoutAPositionKeepsItsLineWhenItPauses() {
296         val session = MediaSession(context, "player without position")
297         lateinit var follower: Follower
298         try {
299             session.setPlaybackState(state(PlaybackState.STATE_PLAYING, PlaybackState.PLAYBACK_POSITION_UNKNOWN))
300             session.isActive = true
301             onMain {
302                 follower = Follower(main) { at, _, _ -> lines += follower.index.cues.getOrNull(at)?.text }
303                 follower.index = CueIndex(listOf(Cue(0, 300, "first"), Cue(400, 900, "second")))
304                 follower.follow(listOf(MediaController(context, session.sessionToken)))
305             }
306             // Android counts from zero for a player that plays and gives no position.
307             waitFor("second")
308             // In a pause there is no position at all. The first version went back to "before the first line".
309             session.setPlaybackState(state(PlaybackState.STATE_PAUSED, PlaybackState.PLAYBACK_POSITION_UNKNOWN))
310             Thread.sleep(500)
311             assertEquals(lines.toString(), "second", lines.last())
312         } finally {
313             onMain { follower.stop() }
314             session.release()
315         }
316     }
317 
318     @Test
319     fun noPlayerIsSaidSo() {
320         val players = CopyOnWriteArrayList<Boolean>()
321         onMain {
322             val follower = Follower(main) { _, hasPlayer, _ -> players += hasPlayer }
323             follower.follow(emptyList())
324         }
325         assertEquals(false, players.last())
326     }
327 
328     @Test
329     fun aLookupPausesThePlayerAndTheButtonStartsItAgain() {
330         val session = MediaSession(context, "test player")
331         val playing = CopyOnWriteArrayList<Boolean>()
332         var plays = 0
333         var pauses = 0
334         session.setCallback(object : MediaSession.Callback() {
335             override fun onPlay() { plays++ }
336             override fun onPause() { pauses++ }
337         }, main)
338         lateinit var follower: Follower
339         try {
340             session.setPlaybackState(state(PlaybackState.STATE_PLAYING, 1_500))
341             session.isActive = true
342             onMain {
343                 follower = Follower(main) { _, _, isPlaying -> playing += isPlaying }
344                 follower.index = index
345                 follower.follow(listOf(MediaController(context, session.sessionToken)))
346             }
347             val until = SystemClock.elapsedRealtime() + 5_000
348             while (playing.lastOrNull() != true && SystemClock.elapsedRealtime() < until) Thread.sleep(20)
349             assertEquals(true, playing.last())
350 
351             onMain { assertTrue("a player that plays is paused", follower.pause()) }
352             session.setPlaybackState(state(PlaybackState.STATE_PAUSED, 1_500))
353             while (playing.lastOrNull() != false && SystemClock.elapsedRealtime() < until) Thread.sleep(20)
354             assertEquals(false, playing.last())
355             onMain { assertEquals("a paused player is not paused again", false, follower.pause()) }
356             onMain { follower.play() }
357             while ((plays < 1 || pauses < 1) && SystemClock.elapsedRealtime() < until) Thread.sleep(20)
358             assertEquals(1, pauses)
359             assertEquals(1, plays)
360         } finally {
361             onMain { follower.stop() }
362             session.release()
363         }
364     }
365 }