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:
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user