feat(android): add native Android client

Kotlin + Jetpack Compose client for a self-hosted instance: manual
server setup and health check, auth with Keystore-backed tokens, album
and photo browsing with SSE live updates, upload queue in WorkManager
with HEIC-to-JPEG conversion, and Room/Coil offline cache. Add an
android Makefile target and revise project.md §17.
This commit is contained in:
iu
2026-10-12 12:14:54 +03:00
parent e4975aafb5
commit aa2f7552bc
63 changed files with 4575 additions and 2 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.
Binary file not shown.
@@ -0,0 +1,147 @@
package com.galo.android
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.galo.android.data.network.GaloJson
import com.galo.android.data.network.dto.AlbumEnvelopeDto
import com.galo.android.data.network.dto.PhotoDto
import com.galo.android.data.network.dto.TokenEnvelopeDto
import com.galo.android.data.network.dto.UploadResponseDto
import com.galo.android.di.AppContainer
import kotlinx.coroutines.runBlocking
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.UUID
import java.util.concurrent.TimeUnit
/**
* Shared helpers for on-device integration tests. Assumes a galo server is
* reachable from the emulator at [BASE_URL] (host loopback) started with
* `HTTP_ADDR=:8088 ./bin/galo`.
*/
object E2E {
const val BASE_URL = "http://10.0.2.2:8088"
val client: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
fun context(): Context = ApplicationProvider.getApplicationContext()
/** Points the app at the LAN server with insecure HTTP allowed, then waits until applied. */
fun configure(container: AppContainer) {
runBlocking {
container.settingsRepository.setInsecureAllowed(true)
container.settingsRepository.setBaseUrl(BASE_URL)
}
val deadline = System.currentTimeMillis() + 10_000
while (System.currentTimeMillis() < deadline && container.baseUrl() != BASE_URL) {
Thread.sleep(100)
}
check(container.baseUrl() == BASE_URL) { "settings did not apply" }
}
fun register(): String = registerTokens().accessToken
/** Registers and stores the token in the app's Keystore-backed session. */
fun registerAuthenticated(container: AppContainer): String {
val tokens = registerTokens()
container.tokenStore.save(tokens.accessToken, tokens.refreshToken)
return tokens.accessToken
}
private fun registerTokens(): com.galo.android.data.network.dto.AuthTokensDto {
val email = "android-it-${System.currentTimeMillis()}-${UUID.randomUUID().toString().take(6)}@example.com"
val payload = """{"email":"$email","password":"secret123","name":"IT"}"""
.toRequestBody("application/json".toMediaType())
val request = Request.Builder().url("$BASE_URL/api/auth/register").post(payload).build()
client.newCall(request).execute().use { response ->
check(response.isSuccessful) { "register failed: HTTP ${response.code}" }
return GaloJson.decodeFromString<TokenEnvelopeDto>(response.body!!.string()).tokens
}
}
fun createAlbum(token: String, title: String): String {
val payload = """{"title":"$title"}""".toRequestBody("application/json".toMediaType())
val request = Request.Builder()
.url("$BASE_URL/api/albums")
.header("Authorization", "Bearer $token")
.post(payload)
.build()
client.newCall(request).execute().use { response ->
check(response.isSuccessful) { "create album failed: HTTP ${response.code}" }
return GaloJson.decodeFromString<AlbumEnvelopeDto>(response.body!!.string()).album.id
}
}
fun upload(token: String, albumId: String, filename: String, bytes: ByteArray, mime: String) {
val multipart = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("files", filename, bytes.toRequestBody(mime.toMediaType()))
.build()
val request = Request.Builder()
.url("$BASE_URL/api/albums/$albumId/photos")
.header("Authorization", "Bearer $token")
.post(multipart)
.build()
client.newCall(request).execute().use { response ->
check(response.isSuccessful) { "upload failed: HTTP ${response.code}" }
GaloJson.decodeFromString<UploadResponseDto>(response.body!!.string())
}
}
fun awaitPhotoWithPlayback(token: String, albumId: String, timeoutMs: Long): PhotoDto {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
val request = Request.Builder()
.url("$BASE_URL/api/albums/$albumId/photos?limit=5")
.header("Authorization", "Bearer $token")
.build()
client.newCall(request).execute().use { response ->
val body = response.body?.string().orEmpty()
val photo = runCatching {
GaloJson.decodeFromString<com.galo.android.data.network.dto.PhotoListDto>(body)
.photos.firstOrNull { !it.playbackUrl.isNullOrBlank() }
}.getOrNull()
if (photo != null) return photo
}
Thread.sleep(500)
}
error("no processed photo with playback_url within ${timeoutMs}ms")
}
fun readAsset(name: String): ByteArray =
androidx.test.platform.app.InstrumentationRegistry.getInstrumentation()
.context.assets.open(name).use { it.readBytes() }
fun absolute(path: String): String = BASE_URL + path
fun jpegBytes(size: Int = 96): ByteArray {
val bitmap = android.graphics.Bitmap.createBitmap(size, size, android.graphics.Bitmap.Config.ARGB_8888)
bitmap.eraseColor(android.graphics.Color.rgb(20, 140, 90))
val out = java.io.ByteArrayOutputStream()
bitmap.compress(android.graphics.Bitmap.CompressFormat.JPEG, 85, out)
bitmap.recycle()
return out.toByteArray()
}
fun writeTempFile(name: String, bytes: ByteArray): android.net.Uri {
val file = java.io.File(context().cacheDir, name)
file.writeBytes(bytes)
return android.net.Uri.fromFile(file)
}
/** Toggles airplane mode via UiAutomation (shell) and waits for the change to settle. */
fun setAirplane(enabled: Boolean) {
val ui = androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().uiAutomation
ui.executeShellCommand(
"cmd connectivity airplane-mode " + if (enabled) "enable" else "disable",
).close()
Thread.sleep(4_000)
}
}
@@ -0,0 +1,159 @@
package com.galo.android
import android.graphics.Bitmap
import android.graphics.Color
import android.net.Uri
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.galo.android.data.network.GaloJson
import com.galo.android.data.network.dto.AlbumEnvelopeDto
import com.galo.android.domain.model.UploadStatus
import com.galo.android.ui.MainActivity
import com.galo.android.ui.album.TAG_ALBUM_GRID
import com.galo.android.ui.album.TAG_PHOTO_PREFIX
import com.galo.android.ui.albums.TAG_ALBUM_CARD_PREFIX
import com.galo.android.ui.albums.TAG_ALBUMS_GRID
import com.galo.android.ui.auth.TAG_AUTH_EMAIL
import com.galo.android.ui.auth.TAG_AUTH_NAME
import com.galo.android.ui.auth.TAG_AUTH_PASSWORD
import com.galo.android.ui.auth.TAG_AUTH_SUBMIT
import com.galo.android.ui.setup.TAG_SETUP_CONNECT
import com.galo.android.ui.setup.TAG_SETUP_INSECURE
import com.galo.android.ui.setup.TAG_SETUP_URL
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
/**
* End-to-end flow against a galo instance reachable from the emulator at
* 10.0.2.2: onboarding → registration → browse → upload → photo appears.
*
* Start the server with `HTTP_ADDR=:8088 ./bin/galo` and run:
* `./gradlew :app:connectedDebugAndroidTest`.
*/
@RunWith(AndroidJUnit4::class)
class E2EFlowTest {
@get:Rule
val compose = createAndroidComposeRule<MainActivity>()
private val baseUrl = "http://10.0.2.2:8088"
private val client = OkHttpClient()
private fun app() = ApplicationProvider.getApplicationContext<GaloApplication>()
@Test
fun onboardingRegisterBrowseUpload() {
val stamp = System.currentTimeMillis()
val email = "android-e2e-$stamp@example.com"
// Onboarding: base URL + insecure mode (plain HTTP to the emulator host).
compose.onNodeWithTag(TAG_SETUP_URL).performTextInput(baseUrl)
compose.onNodeWithTag(TAG_SETUP_INSECURE).performClick()
compose.onNodeWithTag(TAG_SETUP_CONNECT).performClick()
// Registration.
compose.waitUntil(TIMEOUT) {
compose.onAllNodesWithTag(TAG_AUTH_EMAIL).fetchSemanticsNodes().isNotEmpty()
}
compose.onNodeWithText("Создать аккаунт").performClick()
compose.waitUntil(TIMEOUT) {
compose.onAllNodesWithTag(TAG_AUTH_NAME).fetchSemanticsNodes().isNotEmpty()
}
compose.onNodeWithTag(TAG_AUTH_NAME).performTextInput("E2E")
compose.onNodeWithTag(TAG_AUTH_EMAIL).performTextInput(email)
compose.onNodeWithTag(TAG_AUTH_PASSWORD).performTextInput("secret123")
compose.onNodeWithTag(TAG_AUTH_SUBMIT).performClick()
// Empty album list.
compose.waitUntil(TIMEOUT) {
compose.onAllNodesWithTag(TAG_ALBUMS_GRID).fetchSemanticsNodes().isNotEmpty() ||
compose.onAllNodesWithText("Пока нет альбомов").fetchSemanticsNodes().isNotEmpty()
}
// Seed an album (the client has no album-creation UI in v1).
val token = app().container.tokenStore.accessToken()
?: error("expected a stored access token after registration")
val albumId = createAlbum(token, "Android E2E $stamp")
runBlocking { app().container.albumRepository.refresh() }
compose.waitUntil(TIMEOUT) {
compose.onAllNodesWithTag(TAG_ALBUM_CARD_PREFIX + albumId).fetchSemanticsNodes().isNotEmpty()
}
compose.onNodeWithTag(TAG_ALBUM_CARD_PREFIX + albumId).performClick()
compose.waitUntil(TIMEOUT) {
compose.onAllNodesWithTag(TAG_ALBUM_GRID).fetchSemanticsNodes().isNotEmpty() ||
compose.onAllNodesWithText("В альбоме пока нет медиа").fetchSemanticsNodes().isNotEmpty()
}
// Upload a generated JPEG through the real WorkManager queue.
val uri = writeJpeg()
runBlocking { app().container.uploadRepository.enqueue(albumId, listOf(uri), wifiOnly = false) }
val photo = runBlocking {
val deadline = System.currentTimeMillis() + UPLOAD_TIMEOUT
while (System.currentTimeMillis() < deadline) {
val photos = app().container.photoRepository.observePhotos(albumId).first()
if (photos.isNotEmpty()) return@runBlocking photos.first()
val job = app().container.uploadRepository.observeJobs().first().firstOrNull()
if (job?.status == UploadStatus.ERROR) {
error("upload job failed: ${job.error}")
}
kotlinx.coroutines.delay(500)
}
val jobs = app().container.uploadRepository.observeJobs().first()
error("upload timed out; jobs=${jobs.map { it.status to it.error }}")
}
compose.waitUntil(TIMEOUT) {
compose.onAllNodesWithTag(TAG_PHOTO_PREFIX + photo.id).fetchSemanticsNodes().isNotEmpty()
}
compose.onNodeWithTag(TAG_PHOTO_PREFIX + photo.id).assertIsDisplayed()
}
private fun createAlbum(token: String, title: String): String {
val body = """{"title":"$title"}""".toRequestBody("application/json".toMediaType())
val request = Request.Builder()
.url("$baseUrl/api/albums")
.header("Authorization", "Bearer $token")
.post(body)
.build()
client.newCall(request).execute().use { response ->
check(response.isSuccessful) { "create album failed: HTTP ${response.code}" }
val text = response.body?.string().orEmpty()
return GaloJson.decodeFromString<AlbumEnvelopeDto>(text).album.id
}
}
private fun writeJpeg(): Uri {
val file = File(app().cacheDir, "e2e-upload.jpg")
val bitmap = Bitmap.createBitmap(96, 96, Bitmap.Config.ARGB_8888).apply {
eraseColor(Color.rgb(30, 120, 200))
}
file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.JPEG, 85, it) }
bitmap.recycle()
return Uri.fromFile(file)
}
private companion object {
const val TIMEOUT = 20_000L
const val UPLOAD_TIMEOUT = 60_000L
}
}
@@ -0,0 +1,80 @@
package com.galo.android
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.exifinterface.media.ExifInterface
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.galo.android.data.upload.HeicConverter
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
/**
* Device checks for task 6.3:
* - a real HEIC is decoded and re-encoded as JPEG with the same dimensions and a
* `.jpg` filename;
* - the EXIF copy step preserves capture date and geolocation (exercised with a
* source whose EXIF we control, since AndroidX ExifInterface can read but not
* write HEIC attributes).
*/
@RunWith(AndroidJUnit4::class)
class HeicConversionTest {
private fun app() = ApplicationProvider.getApplicationContext<GaloApplication>()
@Test
fun decodesRealHeicToJpeg() {
val source = File(app().cacheDir, "source.heic").apply { writeBytes(E2E.readAsset("sample.heic")) }
val sourceBounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(source.absolutePath, sourceBounds)
assertTrue("sample HEIC must decode on-device", sourceBounds.outWidth > 0)
val converted = runBlocking { HeicConverter.convert(app(), Uri.fromFile(source), "autumn.heic") }
assertEquals("autumn.jpg", converted.filename)
assertEquals("image/jpeg", converted.mimeType)
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(converted.file.absolutePath, bounds)
assertEquals("image/jpeg", bounds.outMimeType)
assertEquals(sourceBounds.outWidth, bounds.outWidth)
assertEquals(sourceBounds.outHeight, bounds.outHeight)
}
@Test
fun preservesExifDateAndLocationThroughConversion() {
val source = File(app().cacheDir, "with-exif.jpg")
Bitmap.createBitmap(64, 64, Bitmap.Config.ARGB_8888).apply {
eraseColor(android.graphics.Color.YELLOW)
source.outputStream().use { compress(Bitmap.CompressFormat.JPEG, 90, it) }
recycle()
}
val sourceExif = ExifInterface(source.absolutePath)
sourceExif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, DATE)
sourceExif.setLatLong(LAT, LONG)
sourceExif.saveAttributes()
val converted = runBlocking {
HeicConverter.convert(app(), Uri.fromFile(source), "IMG_20240715.heic")
}
assertEquals("IMG_20240715.jpg", converted.filename)
val outExif = ExifInterface(converted.file.absolutePath)
assertEquals("capture date must be preserved", DATE, outExif.getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL))
val latLong = outExif.latLong
assertNotNull("geolocation must be preserved", latLong)
assertTrue(Math.abs(latLong!![0] - LAT) < 0.01)
assertTrue(Math.abs(latLong[1] - LONG) < 0.01)
}
private companion object {
const val DATE = "2024:07:15 10:30:00"
const val LAT = 55.7558
const val LONG = 37.6173
}
}
@@ -0,0 +1,48 @@
package com.galo.android
import android.view.MotionEvent
import android.view.View
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.galo.android.ui.viewer.PanoramaView
import org.junit.Assert.assertNotEquals
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
/**
* Device check for task 4.3: a drag on the GL panorama view rotates the camera
* (same yaw/pitch mapping as the web viewer).
*/
@RunWith(AndroidJUnit4::class)
class PanoramaDragTest {
@Test
fun dragRotatesCamera() {
val app = ApplicationProvider.getApplicationContext<GaloApplication>()
val file = File(app.cacheDir, "equirect.jpg").apply { writeBytes(E2E.readAsset("equirect.jpg")) }
val url = file.toURI().toURL().toString()
lateinit var view: PanoramaView
InstrumentationRegistry.getInstrumentation().runOnMainSync {
val v = PanoramaView(app)
v.measure(
View.MeasureSpec.makeMeasureSpec(1080, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(1920, View.MeasureSpec.EXACTLY),
)
v.layout(0, 0, 1080, 1920)
v.load(url)
val down = MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 100f, 200f, 0)
val move = MotionEvent.obtain(0, 16, MotionEvent.ACTION_MOVE, 500f, 200f, 0)
v.dispatchTouchEvent(down)
v.dispatchTouchEvent(move)
down.recycle()
move.recycle()
view = v
}
assertNotEquals("horizontal drag should change yaw", 0f, view.currentYawForTest())
}
}
@@ -0,0 +1,102 @@
package com.galo.android
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.galo.android.di.AppContainer
import com.galo.android.domain.model.Photo
import com.galo.android.domain.model.UploadStatus
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
/**
* On-device checks for the upload pipeline and offline cache:
* 6.4 (upload completes without a visible UI), 6.5 (queued work waits for
* network and resumes) and 3.5 (album opens from cache while offline).
*/
@RunWith(AndroidJUnit4::class)
class UploadPipelineTest {
private fun container(): AppContainer =
ApplicationProvider.getApplicationContext<GaloApplication>().container
private fun awaitPhoto(container: AppContainer, albumId: String, timeoutMs: Long): Photo {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
val photos = runBlocking { container.photoRepository.observePhotos(albumId).first() }
if (photos.isNotEmpty()) return photos.first()
val job = runBlocking { container.uploadRepository.observeJobs().first().firstOrNull() }
if (job?.status == UploadStatus.ERROR) error("upload job failed: ${job.error}")
Thread.sleep(500)
}
error("upload did not complete within ${timeoutMs}ms")
}
@Test
fun uploadCompletesWithoutVisibleUi() {
val container = container()
E2E.configure(container)
val token = E2E.registerAuthenticated(container)
val album = E2E.createAlbum(token, "bg-${System.currentTimeMillis()}")
val uri = E2E.writeTempFile("bg.jpg", E2E.jpegBytes())
runBlocking { container.uploadRepository.enqueue(album, listOf(uri), wifiOnly = false) }
val photo = awaitPhoto(container, album, 60_000)
assertNotNull(photo.id)
}
@Test
fun queuedJobWaitsForNetworkThenResumes() {
val container = container()
E2E.configure(container)
val token = E2E.registerAuthenticated(container)
val album = E2E.createAlbum(token, "net-${System.currentTimeMillis()}")
E2E.setAirplane(true)
try {
val uri = E2E.writeTempFile("net.jpg", E2E.jpegBytes())
runBlocking { container.uploadRepository.enqueue(album, listOf(uri), wifiOnly = false) }
Thread.sleep(6_000)
val status = runBlocking { container.uploadRepository.observeJobs().first() }.first().status
assertTrue("job should stay queued without network", status == UploadStatus.QUEUED)
} finally {
E2E.setAirplane(false)
}
val photo = awaitPhoto(container, album, 60_000)
assertNotNull(photo.id)
}
@Test
fun offlineBrowsingServesCache() {
val container = container()
E2E.configure(container)
val token = E2E.registerAuthenticated(container)
val album = E2E.createAlbum(token, "cache-${System.currentTimeMillis()}")
E2E.upload(token, album, "cached.jpg", E2E.jpegBytes(), "image/jpeg")
// Warm the cache while online.
runBlocking { container.albumRepository.refresh() }
runBlocking { container.photoRepository.loadNextPage(album, null) }
assertTrue(runBlocking { container.albumRepository.observeAlbums().first().any { it.id == album } })
assertTrue(runBlocking { container.photoRepository.observePhotos(album).first().isNotEmpty() })
E2E.setAirplane(true)
try {
assertFalse("refresh must fail offline", runBlocking { container.albumRepository.refresh() })
val page = runBlocking { container.photoRepository.loadNextPage(album, null) }
assertFalse("page fetch must report offline", page.online)
assertTrue(
"cached photos must remain available offline",
runBlocking { container.photoRepository.observePhotos(album).first().isNotEmpty() },
)
} finally {
E2E.setAirplane(false)
}
}
}
@@ -0,0 +1,63 @@
package com.galo.android
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/**
* Device check for task 4.2: Media3 prepares and seeks a server-served video,
* exercising HTTP Range/seek end to end (server responds 206 Partial Content).
*/
@RunWith(AndroidJUnit4::class)
class VideoSeekTest {
@Test
fun playsAndSeeksServerVideo() {
val app = ApplicationProvider.getApplicationContext<GaloApplication>()
E2E.configure(app.container)
val token = E2E.register()
val album = E2E.createAlbum(token, "video-${System.currentTimeMillis()}")
E2E.upload(token, album, "test.mp4", E2E.readAsset("test.mp4"), "video/mp4")
val photo = E2E.awaitPhotoWithPlayback(token, album, 60_000)
val url = E2E.absolute(photo.playbackUrl!!)
val instrumentation = InstrumentationRegistry.getInstrumentation()
val ready = CountDownLatch(1)
var player: ExoPlayer? = null
instrumentation.runOnMainSync {
val p = ExoPlayer.Builder(app).build()
player = p
p.addListener(object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_READY) ready.countDown()
}
})
p.setMediaItem(MediaItem.fromUri(url))
p.prepare()
}
assertTrue("player did not reach STATE_READY", ready.await(30, TimeUnit.SECONDS))
instrumentation.runOnMainSync { player!!.seekTo(1_200) }
var position = 0L
val deadline = System.currentTimeMillis() + 10_000
while (System.currentTimeMillis() < deadline && position < 1_000) {
instrumentation.runOnMainSync { position = player!!.currentPosition }
Thread.sleep(150)
}
instrumentation.runOnMainSync { player!!.release() }
assertTrue("seek did not reach target (position=$position)", position >= 1_000)
}
}