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)
}
}
+47
View File
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!--
Cleartext HTTP is gated in the application layer (see HttpGateway): the
platform permits it, but the client refuses plain HTTP until the user
explicitly enables the insecure LAN mode.
-->
<application
android:name=".GaloApplication"
android:allowBackup="false"
android:label="@string/app_name"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.Galo"
tools:targetApi="35">
<activity
android:name=".ui.MainActivity"
android:exported="true"
android:theme="@style/Theme.Galo">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge" />
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
tools:node="merge" />
</application>
</manifest>
@@ -0,0 +1,37 @@
package com.galo.android
import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import com.galo.android.di.AppContainer
class GaloApplication : Application() {
lateinit var container: AppContainer
private set
override fun onCreate() {
super.onCreate()
container = AppContainer(this).also { it.start() }
createNotificationChannels()
}
private fun createNotificationChannels() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channel = NotificationChannel(
UPLOAD_CHANNEL_ID,
getString(R.string.upload_channel_name),
NotificationManager.IMPORTANCE_LOW,
).apply {
description = getString(R.string.upload_channel_description)
}
manager.createNotificationChannel(channel)
}
companion object {
const val UPLOAD_CHANNEL_ID = "galo-uploads"
}
}
@@ -0,0 +1,90 @@
package com.galo.android.di
import android.content.Context
import com.galo.android.data.auth.SecureTokenStore
import com.galo.android.data.auth.TokenStore
import com.galo.android.data.db.GaloDatabase
import com.galo.android.data.network.AlbumSseClient
import com.galo.android.data.network.ApiClient
import com.galo.android.data.network.GaloApi
import com.galo.android.data.network.HealthChecker
import com.galo.android.data.prefs.SettingsRepository
import com.galo.android.data.prefs.SettingsState
import com.galo.android.data.repo.AlbumRepository
import com.galo.android.data.repo.ApiAlbumRemoteSource
import com.galo.android.data.repo.ApiPhotoRemoteSource
import com.galo.android.data.repo.PhotoRepository
import com.galo.android.data.repo.RoomAlbumLocalSource
import com.galo.android.data.repo.RoomPhotoLocalSource
import com.galo.android.data.upload.UploadRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
/**
* Manual dependency container. Created once by [com.galo.android.GaloApplication]
* and shared through an application-scoped ViewModel factory.
*/
class AppContainer(context: Context) {
private val appContext = context.applicationContext
val settingsRepository = SettingsRepository(appContext)
val tokenStore: TokenStore = SecureTokenStore(appContext)
@Volatile
private var settingsState: SettingsState? = null
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val apiClient = ApiClient(
tokenStore = tokenStore,
baseUrlProvider = { settingsState?.baseUrl },
insecureAllowedProvider = { settingsState?.insecureAllowed == true },
)
val api = GaloApi(apiClient)
val healthChecker = HealthChecker(apiClient.client)
val albumSseClient = AlbumSseClient(api, apiClient.sseClient)
private val database: GaloDatabase = GaloDatabase.create(appContext)
val albumRepository = AlbumRepository(
remote = ApiAlbumRemoteSource(api),
local = RoomAlbumLocalSource(database.albumDao()),
resolver = apiClient.objectUrls,
)
val photoRepository = PhotoRepository(
remote = ApiPhotoRemoteSource(api),
local = RoomPhotoLocalSource(database.photoDao()),
resolver = apiClient.objectUrls,
)
val uploadRepository = UploadRepository(appContext, database.uploadDao())
fun start() {
scope.launch {
settingsRepository.flow.collect { settingsState = it }
}
scope.launch {
runCatching { uploadRepository.requeuePending() }
}
}
fun currentSettings(): SettingsState? = settingsState
fun baseUrl(): String? = settingsState?.baseUrl
fun insecureAllowed(): Boolean = settingsState?.insecureAllowed == true
/** Clears the local session immediately and best-effort revokes it server-side. */
fun logout() {
val refreshToken = tokenStore.refreshToken()
tokenStore.clear()
if (refreshToken.isNullOrBlank()) return
scope.launch {
runCatching { api.logout(refreshToken) }
}
}
}
@@ -0,0 +1,29 @@
package com.galo.android.domain
import com.galo.android.domain.model.InsecureConnectionBlockedException
/**
* Application-level cleartext gate.
*
* Android's manifest permits cleartext so the user can enable LAN mode at
* runtime, but the client itself refuses `http://` URLs unless insecure mode is
* explicitly switched on.
*/
object ConnectionPolicy {
fun isInsecure(url: String): Boolean =
url.trim().lowercase().startsWith("http://")
fun isSecure(url: String): Boolean =
url.trim().lowercase().startsWith("https://")
/**
* @throws InsecureConnectionBlockedException when [url] is plain HTTP and
* [insecureAllowed] is false.
*/
fun check(url: String, insecureAllowed: Boolean) {
if (isInsecure(url) && !insecureAllowed) {
throw InsecureConnectionBlockedException()
}
}
}
@@ -0,0 +1,17 @@
package com.galo.android.domain.model
/** Normalised server error from the `{error:{code,message}}` envelope. */
data class ApiError(
val code: String,
val message: String,
val status: Int,
)
class ApiException(val error: ApiError) : Exception(error.message) {
constructor(status: Int, code: String, message: String) :
this(ApiError(code, message, status))
}
/** Raised when the user points at plain HTTP without enabling insecure mode. */
class InsecureConnectionBlockedException :
IllegalStateException("plain HTTP is disabled; enable insecure LAN mode")
@@ -0,0 +1,79 @@
package com.galo.android.domain.model
/**
* User-selected connection to a self-hosted galo instance.
*
* [insecureAllowed] mirrors the explicit "insecure LAN connection" toggle: plain
* HTTP stays blocked until the user opts in.
*/
data class ServerConfig(
val baseUrl: String,
val insecureAllowed: Boolean,
)
data class Session(
val accessToken: String,
val refreshToken: String,
)
data class Album(
val id: String,
val title: String,
val description: String,
val ownerId: String,
val coverPhotoId: String?,
val coverThumbUrl: String?,
val coverMediumUrl: String?,
val photosCount: Int,
val membersCount: Int,
val updatedAt: String?,
)
enum class MediaKind {
IMAGE,
VIDEO,
PANORAMA,
OTHER,
;
companion object {
const val PANO_TAG = "pano360"
fun from(raw: String?): MediaKind = when (raw?.lowercase()) {
"image", "photo" -> IMAGE
"video" -> VIDEO
"panorama", "pano" -> PANORAMA
else -> OTHER
}
fun isPanoTag(name: String): Boolean =
name.trim().equals(PANO_TAG, ignoreCase = true)
}
}
data class Photo(
val id: String,
val albumId: String,
val originalFilename: String,
val kind: MediaKind,
val mimeType: String?,
val width: Int,
val height: Int,
val durationMs: Long,
val size: Long,
val takenAt: String?,
val caption: String,
val isPanorama: Boolean,
val thumbUrl: String?,
val mediumUrl: String?,
val previewUrl: String?,
val playbackUrl: String?,
val originalUrl: String?,
val downloadUrl: String?,
val createdAt: String?,
)
data class Page<T>(
val items: List<T>,
val nextCursor: String?,
)
@@ -0,0 +1,28 @@
package com.galo.android.domain.model
enum class UploadStatus {
QUEUED,
RUNNING,
SUCCESS,
ERROR,
;
companion object {
fun from(raw: String): UploadStatus =
entries.firstOrNull { it.name == raw } ?: QUEUED
}
}
data class UploadJob(
val id: String,
val albumId: String,
val filename: String,
val mimeType: String?,
val size: Long,
val status: UploadStatus,
val progress: Int,
val error: String?,
val attempts: Int,
val wifiOnly: Boolean,
val createdAt: Long,
)
@@ -0,0 +1,46 @@
package com.galo.android.domain.viewer
/**
* Camera state for the 360 panorama viewer, mirroring the web `PanoViewer`
* math: horizontal drag rotates yaw, vertical drag changes pitch (clamped to
* ±89°), and the mapping constant is `fov / viewportHeight`. Zoom scales the
* field of view within [MIN_FOV, MAX_FOV].
*/
class PanoramaCamera(
yaw: Float = 0f,
pitch: Float = 0f,
fov: Float = DEFAULT_FOV,
) {
var yaw: Float = yaw
private set
var pitch: Float = pitch
private set
var fov: Float = fov.coerceIn(MIN_FOV, MAX_FOV)
private set
fun drag(dx: Float, dy: Float, scale: Float) {
yaw += dx * scale
pitch = (pitch + dy * scale).coerceIn(-PITCH_LIMIT, PITCH_LIMIT)
}
/** [factor] > 1 widens the view (zooms out). */
fun zoom(factor: Float) {
fov = (fov * factor).coerceIn(MIN_FOV, MAX_FOV)
}
fun reset() {
yaw = 0f
pitch = 0f
fov = DEFAULT_FOV
}
companion object {
val DEFAULT_FOV = (75 * Math.PI / 180).toFloat()
val MIN_FOV = (30 * Math.PI / 180).toFloat()
val MAX_FOV = (100 * Math.PI / 180).toFloat()
val PITCH_LIMIT = (89 * Math.PI / 180).toFloat()
}
}
@@ -0,0 +1,132 @@
package com.galo.android.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.galo.android.GaloApplication
import com.galo.android.data.prefs.SettingsState
import com.galo.android.ui.album.AlbumScreen
import com.galo.android.ui.albums.AlbumsScreen
import com.galo.android.ui.auth.AuthScreen
import com.galo.android.ui.setup.SetupScreen
import com.galo.android.ui.upload.UploadScreen
import com.galo.android.ui.viewer.ViewerScreen
object Routes {
const val SETUP = "setup"
const val AUTH = "auth"
const val ALBUMS = "albums"
const val UPLOADS = "uploads"
const val ALBUM = "album/{albumId}"
const val VIEWER = "album/{albumId}/viewer?index={index}"
fun album(albumId: String) = "album/$albumId"
fun viewer(albumId: String, index: Int) = "album/$albumId/viewer?index=$index"
}
@Composable
fun GaloNavHost(app: GaloApplication) {
val container = app.container
val factory = GaloViewModelFactory(app)
val settings: SettingsState? by container.settingsRepository.flow
.collectAsStateWithLifecycle(initialValue = container.currentSettings())
val current = settings
if (current == null) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
return
}
val navController = rememberNavController()
val start = when {
current.baseUrl.isBlank() -> Routes.SETUP
container.tokenStore.accessToken().isNullOrBlank() -> Routes.AUTH
else -> Routes.ALBUMS
}
NavHost(navController = navController, startDestination = start) {
composable(Routes.SETUP) {
SetupScreen(factory = factory) {
val next = if (container.tokenStore.accessToken().isNullOrBlank()) {
Routes.AUTH
} else {
Routes.ALBUMS
}
navController.navigate(next) {
popUpTo(Routes.SETUP) { inclusive = true }
}
}
}
composable(Routes.AUTH) {
AuthScreen(factory = factory) {
navController.navigate(Routes.ALBUMS) {
popUpTo(Routes.AUTH) { inclusive = true }
}
}
}
composable(Routes.ALBUMS) {
AlbumsScreen(
factory = factory,
onOpenAlbum = { id -> navController.navigate(Routes.album(id)) },
onOpenUploads = { navController.navigate(Routes.UPLOADS) },
onLoggedOut = {
navController.navigate(Routes.AUTH) {
popUpTo(Routes.ALBUMS) { inclusive = true }
}
},
)
}
composable(Routes.UPLOADS) {
UploadScreen(
factory = factory,
onBack = { navController.popBackStack() },
)
}
composable(
route = Routes.ALBUM,
arguments = listOf(navArgument("albumId") { type = NavType.StringType }),
) { entry ->
val albumId = entry.arguments?.getString("albumId").orEmpty()
AlbumScreen(
app = app,
albumId = albumId,
onOpenViewer = { index ->
navController.navigate(Routes.viewer(albumId, index))
},
onBack = { navController.popBackStack() },
)
}
composable(
route = Routes.VIEWER,
arguments = listOf(
navArgument("albumId") { type = NavType.StringType },
navArgument("index") {
type = NavType.IntType
defaultValue = 0
},
),
) { entry ->
val albumId = entry.arguments?.getString("albumId").orEmpty()
val index = entry.arguments?.getInt("index") ?: 0
ViewerScreen(
app = app,
albumId = albumId,
initialIndex = index,
onBack = { navController.popBackStack() },
)
}
}
}
@@ -0,0 +1,26 @@
package com.galo.android.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.galo.android.GaloApplication
import com.galo.android.ui.albums.AlbumsViewModel
import com.galo.android.ui.auth.AuthViewModel
import com.galo.android.ui.setup.SetupViewModel
import com.galo.android.ui.upload.UploadViewModel
/** Resolves ViewModels against the application's [com.galo.android.di.AppContainer]. */
class GaloViewModelFactory(private val app: GaloApplication) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
val container = app.container
val viewModel: ViewModel = when {
modelClass.isAssignableFrom(SetupViewModel::class.java) -> SetupViewModel(container)
modelClass.isAssignableFrom(AuthViewModel::class.java) -> AuthViewModel(container)
modelClass.isAssignableFrom(AlbumsViewModel::class.java) -> AlbumsViewModel(container)
modelClass.isAssignableFrom(UploadViewModel::class.java) -> UploadViewModel(container)
else -> throw IllegalArgumentException("Unknown ViewModel: ${modelClass.name}")
}
return viewModel as T
}
}
@@ -0,0 +1,30 @@
package com.galo.android.ui
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import com.galo.android.GaloApplication
import com.galo.android.ui.theme.GaloTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val app = application as GaloApplication
setContent {
GaloTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
GaloNavHost(app = app)
}
}
}
}
}
@@ -0,0 +1,154 @@
package com.galo.android.ui.album
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.galo.android.GaloApplication
import com.galo.android.domain.model.MediaKind
import com.galo.android.domain.model.Photo
import com.galo.android.ui.common.GaloAsyncImage
const val TAG_ALBUM_GRID = "album_grid"
const val TAG_ALBUM_OFFLINE = "album_offline"
const val TAG_PHOTO_PREFIX = "photo_"
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AlbumScreen(
app: GaloApplication,
albumId: String,
onOpenViewer: (Int) -> Unit,
onBack: () -> Unit,
) {
val factory = viewModelFactory {
initializer { AlbumViewModel(app.container, albumId) }
}
val viewModel: AlbumViewModel = viewModel(factory = factory)
val state by viewModel.state.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(
title = { Text(state.album?.title ?: "Альбом") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Назад")
}
},
actions = {
IconButton(onClick = viewModel::refresh) {
Icon(Icons.Filled.Refresh, contentDescription = "Обновить")
}
},
)
},
) { padding ->
Column(modifier = Modifier.fillMaxSize().padding(padding)) {
if (state.offline) {
Surface(
color = MaterialTheme.colorScheme.errorContainer,
modifier = Modifier.fillMaxWidth().testTag(TAG_ALBUM_OFFLINE),
) {
Text(
text = state.error ?: "Офлайн-режим",
color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
}
when {
state.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
state.photos.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("В альбоме пока нет медиа")
}
else -> LazyVerticalGrid(
columns = GridCells.Fixed(3),
contentPadding = PaddingValues(2.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.fillMaxSize().testTag(TAG_ALBUM_GRID),
) {
itemsIndexed(state.photos, key = { _, p -> p.id }) { index, photo ->
PhotoCell(photo = photo, onClick = { onOpenViewer(index) })
}
if (!state.endReached) {
item(span = { GridItemSpan(maxLineSpan) }) {
LaunchedEffect(state.photos.size) { viewModel.loadNextPage() }
Box(
Modifier.fillMaxWidth().padding(16.dp),
contentAlignment = Alignment.Center,
) {
if (state.loadingMore) CircularProgressIndicator(Modifier.size(24.dp))
}
}
}
}
}
}
}
}
@Composable
private fun PhotoCell(photo: Photo, onClick: () -> Unit) {
Box(
modifier = Modifier
.aspectRatio(1f)
.testTag(TAG_PHOTO_PREFIX + photo.id)
.clickable(onClick = onClick),
) {
GaloAsyncImage(
url = photo.thumbUrl ?: photo.mediumUrl ?: photo.previewUrl,
contentDescription = photo.originalFilename,
modifier = Modifier.fillMaxSize(),
)
if (photo.kind == MediaKind.VIDEO) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = "Видео",
tint = Color.White,
modifier = Modifier.align(Alignment.Center),
)
}
}
}
@@ -0,0 +1,126 @@
package com.galo.android.ui.album
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.galo.android.data.network.AlbumEvent
import com.galo.android.di.AppContainer
import com.galo.android.domain.model.Album
import com.galo.android.domain.model.Photo
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.time.Instant
class AlbumViewModel(
private val container: AppContainer,
private val albumId: String,
) : ViewModel() {
data class UiState(
val album: Album? = null,
val photos: List<Photo> = emptyList(),
val loading: Boolean = true,
val loadingMore: Boolean = false,
val offline: Boolean = false,
val endReached: Boolean = false,
val error: String? = null,
)
private data class Status(
val loaded: Boolean = false,
val loadingMore: Boolean = false,
val offline: Boolean = false,
val endReached: Boolean = false,
val error: String? = null,
val nextCursor: String? = null,
)
private val status = MutableStateFlow(Status())
/** Watermark for `?since=` backfill; advanced as events are applied. */
private var lastSync: String = Instant.now().toString()
val state: StateFlow<UiState> = combine(
container.albumRepository.observeAlbum(albumId),
container.photoRepository.observePhotos(albumId),
status,
) { album, photos, s ->
UiState(
album = album,
photos = photos,
loading = !s.loaded,
loadingMore = s.loadingMore,
offline = s.offline,
endReached = s.endReached,
error = s.error,
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UiState())
init {
viewModelScope.launch { container.albumRepository.loadAlbum(albumId) }
loadNextPage()
observeEvents()
}
/** Live updates while the screen is alive; reconnect backfills missed changes. */
private fun observeEvents() {
viewModelScope.launch {
container.albumSseClient.events(albumId).collect { event ->
when (event) {
is AlbumEvent.PhotoUpserted -> {
container.photoRepository.upsert(event.photo)
lastSync = Instant.now().toString()
}
is AlbumEvent.PhotoDeleted -> {
container.photoRepository.remove(event.photoId)
lastSync = Instant.now().toString()
}
is AlbumEvent.SocialChanged -> {
container.photoRepository.refreshPhoto(event.photoId)
}
AlbumEvent.AlbumChanged -> {
container.albumRepository.loadAlbum(albumId)
lastSync = Instant.now().toString()
}
AlbumEvent.Reconnected -> {
container.photoRepository.loadSince(albumId, lastSync)
lastSync = Instant.now().toString()
}
}
}
}
}
fun loadNextPage() {
val current = status.value
if (current.loadingMore || (current.loaded && current.endReached)) return
status.update { it.copy(loadingMore = it.loaded) }
viewModelScope.launch {
val result = container.photoRepository.loadNextPage(albumId, current.nextCursor)
status.update {
it.copy(
loaded = true,
loadingMore = false,
offline = !result.online,
endReached = result.online && result.nextCursor == null,
nextCursor = if (result.online) result.nextCursor else it.nextCursor,
error = if (result.online) null else "Нет сети — показаны кэшированные данные",
)
}
}
}
fun refresh() {
status.update { it.copy(loaded = false, endReached = false, nextCursor = null) }
viewModelScope.launch { container.albumRepository.loadAlbum(albumId) }
loadNextPage()
}
}
@@ -0,0 +1,168 @@
package com.galo.android.ui.albums
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Logout
import androidx.compose.material.icons.filled.CloudUpload
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.galo.android.domain.model.Album
import com.galo.android.ui.GaloViewModelFactory
import com.galo.android.ui.common.GaloAsyncImage
const val TAG_ALBUMS_GRID = "albums_grid"
const val TAG_ALBUMS_OFFLINE = "albums_offline"
const val TAG_ALBUM_CARD_PREFIX = "album_card_"
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AlbumsScreen(
factory: GaloViewModelFactory,
onOpenAlbum: (String) -> Unit,
onOpenUploads: () -> Unit,
onLoggedOut: () -> Unit,
) {
val viewModel: AlbumsViewModel = viewModel(factory = factory)
val state by viewModel.state.collectAsStateWithLifecycle()
AlbumsContent(
state = state,
onOpenAlbum = onOpenAlbum,
onOpenUploads = onOpenUploads,
onRefresh = viewModel::refresh,
onLogout = { viewModel.logout(onLoggedOut) },
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AlbumsContent(
state: AlbumsViewModel.UiState,
onOpenAlbum: (String) -> Unit,
onOpenUploads: () -> Unit,
onRefresh: () -> Unit,
onLogout: () -> Unit,
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Альбомы") },
actions = {
IconButton(onClick = onOpenUploads) {
Icon(Icons.Filled.CloudUpload, contentDescription = "Загрузки")
}
IconButton(onClick = onRefresh) {
Icon(Icons.Filled.Refresh, contentDescription = "Обновить")
}
IconButton(onClick = onLogout) {
Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = "Выйти")
}
},
)
},
) { padding ->
Column(modifier = Modifier.fillMaxSize().padding(padding)) {
if (state.offline) {
Surface(
color = MaterialTheme.colorScheme.errorContainer,
modifier = Modifier.fillMaxWidth().testTag(TAG_ALBUMS_OFFLINE),
) {
Text(
text = state.error ?: "Офлайн-режим",
color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
}
when {
state.loading && state.albums.isEmpty() -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
state.albums.isEmpty() -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Пока нет альбомов")
}
}
else -> {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
contentPadding = PaddingValues(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxSize().testTag(TAG_ALBUMS_GRID),
) {
items(state.albums, key = { it.id }) { album ->
AlbumCard(album = album, onClick = { onOpenAlbum(album.id) })
}
}
}
}
}
}
}
@Composable
private fun AlbumCard(album: Album, onClick: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.testTag(TAG_ALBUM_CARD_PREFIX + album.id)
.clickable(onClick = onClick),
) {
GaloAsyncImage(
url = album.coverThumbUrl ?: album.coverMediumUrl,
contentDescription = album.title,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.clip(MaterialTheme.shapes.medium),
)
Column(modifier = Modifier.padding(12.dp)) {
Text(
text = album.title,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = "${album.photosCount} фото · ${album.membersCount} участн.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -0,0 +1,61 @@
package com.galo.android.ui.albums
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.galo.android.di.AppContainer
import com.galo.android.domain.model.Album
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class AlbumsViewModel(private val container: AppContainer) : ViewModel() {
data class UiState(
val albums: List<Album> = emptyList(),
val loading: Boolean = false,
val offline: Boolean = false,
val error: String? = null,
)
private data class Status(
val loading: Boolean = false,
val offline: Boolean = false,
val error: String? = null,
)
private val status = MutableStateFlow(Status())
val state: StateFlow<UiState> = combine(
container.albumRepository.observeAlbums(),
status,
) { albums, s ->
UiState(albums = albums, loading = s.loading, offline = s.offline, error = s.error)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UiState(loading = true))
init {
refresh()
}
fun refresh() {
viewModelScope.launch {
status.update { it.copy(loading = true, error = null) }
val online = container.albumRepository.refresh()
status.update {
it.copy(
loading = false,
offline = !online,
error = if (online) null else "Нет сети — показаны кэшированные данные",
)
}
}
}
fun logout(onDone: () -> Unit) {
container.logout()
onDone()
}
}
@@ -0,0 +1,116 @@
package com.galo.android.ui.auth
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.galo.android.ui.GaloViewModelFactory
const val TAG_AUTH_EMAIL = "auth_email"
const val TAG_AUTH_PASSWORD = "auth_password"
const val TAG_AUTH_NAME = "auth_name"
const val TAG_AUTH_SUBMIT = "auth_submit"
@Composable
fun AuthScreen(
factory: GaloViewModelFactory,
onAuthenticated: () -> Unit,
) {
val viewModel: AuthViewModel = androidx.lifecycle.viewmodel.compose.viewModel(factory = factory)
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
viewModel.authenticated.collect { onAuthenticated() }
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
if (state.mode == AuthViewModel.Mode.LOGIN) "Вход" else "Регистрация",
style = MaterialTheme.typography.headlineSmall,
)
if (state.mode == AuthViewModel.Mode.REGISTER) {
OutlinedTextField(
value = state.name,
onValueChange = viewModel::onNameChange,
label = { Text("Имя") },
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag(TAG_AUTH_NAME),
)
}
OutlinedTextField(
value = state.email,
onValueChange = viewModel::onEmailChange,
label = { Text("Email") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
modifier = Modifier.fillMaxWidth().testTag(TAG_AUTH_EMAIL),
)
OutlinedTextField(
value = state.password,
onValueChange = viewModel::onPasswordChange,
label = { Text("Пароль") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
modifier = Modifier.fillMaxWidth().testTag(TAG_AUTH_PASSWORD),
)
state.error?.let {
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium)
}
Button(
onClick = viewModel::submit,
enabled = !state.loading,
modifier = Modifier.fillMaxWidth().testTag(TAG_AUTH_SUBMIT),
) {
Text(
when {
state.loading -> "Подождите…"
state.mode == AuthViewModel.Mode.LOGIN -> "Войти"
else -> "Зарегистрироваться"
},
)
}
Row {
TextButton(
onClick = {
viewModel.setMode(
if (state.mode == AuthViewModel.Mode.LOGIN) {
AuthViewModel.Mode.REGISTER
} else {
AuthViewModel.Mode.LOGIN
},
)
},
) {
Text(
if (state.mode == AuthViewModel.Mode.LOGIN) {
"Создать аккаунт"
} else {
"У меня уже есть аккаунт"
},
)
}
}
}
}
@@ -0,0 +1,74 @@
package com.galo.android.ui.auth
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.galo.android.data.network.ErrorMapper
import com.galo.android.di.AppContainer
import com.galo.android.domain.model.ApiException
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class AuthViewModel(private val container: AppContainer) : ViewModel() {
enum class Mode { LOGIN, REGISTER }
data class UiState(
val mode: Mode = Mode.LOGIN,
val email: String = "",
val password: String = "",
val name: String = "",
val loading: Boolean = false,
val error: String? = null,
)
private val _state = MutableStateFlow(UiState())
val state = _state.asStateFlow()
private val _authenticated = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val authenticated = _authenticated.asSharedFlow()
fun onEmailChange(value: String) = _state.update { it.copy(email = value, error = null) }
fun onPasswordChange(value: String) = _state.update { it.copy(password = value, error = null) }
fun onNameChange(value: String) = _state.update { it.copy(name = value, error = null) }
fun setMode(mode: Mode) = _state.update { it.copy(mode = mode, error = null) }
fun submit() {
val current = _state.value
val email = current.email.trim()
when {
email.isEmpty() || current.password.isEmpty() -> {
_state.update { it.copy(error = "Заполните email и пароль") }
return
}
current.mode == Mode.REGISTER && current.name.trim().isEmpty() -> {
_state.update { it.copy(error = "Укажите имя") }
return
}
}
_state.update { it.copy(loading = true, error = null) }
viewModelScope.launch {
try {
val tokens = when (current.mode) {
Mode.LOGIN -> container.api.login(email, current.password)
Mode.REGISTER -> container.api.register(email, current.password, current.name.trim())
}
container.tokenStore.save(tokens.accessToken, tokens.refreshToken)
_state.update { it.copy(loading = false) }
_authenticated.emit(Unit)
} catch (e: ApiException) {
_state.update { it.copy(loading = false, error = ErrorMapper.userMessage(e.error)) }
} catch (e: Exception) {
_state.update { it.copy(loading = false, error = e.message ?: "Ошибка входа") }
}
}
}
}
@@ -0,0 +1,54 @@
package com.galo.android.ui.common
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.galo.android.data.image.ObjectCacheKey
/**
* Coil image that caches by object path (stable across signature rotation) and
* shows a neutral placeholder while loading or when the URL is absent.
*/
@Composable
fun GaloAsyncImage(
url: String?,
contentDescription: String?,
modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Crop,
) {
if (url.isNullOrBlank()) {
Box(
modifier = modifier.background(MaterialTheme.colorScheme.surfaceVariant),
)
return
}
val context = LocalContext.current
val request = remember(url) {
val key = ObjectCacheKey.keyFor(url)
ImageRequest.Builder(context)
.data(url)
.memoryCacheKey(key)
.diskCacheKey(key)
.crossfade(true)
.build()
}
AsyncImage(
model = request,
contentDescription = contentDescription,
contentScale = contentScale,
modifier = modifier,
)
}
@Composable
fun Placeholder(modifier: Modifier = Modifier) {
Box(modifier.fillMaxSize().background(MaterialTheme.colorScheme.surfaceVariant))
}
@@ -0,0 +1,99 @@
package com.galo.android.ui.setup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.galo.android.ui.GaloViewModelFactory
const val TAG_SETUP_URL = "setup_url"
const val TAG_SETUP_INSECURE = "setup_insecure"
const val TAG_SETUP_CONNECT = "setup_connect"
@Composable
fun SetupScreen(
factory: GaloViewModelFactory,
onConnected: () -> Unit,
) {
val viewModel: SetupViewModel = androidx.lifecycle.viewmodel.compose.viewModel(factory = factory)
val state by viewModel.state.collectAsStateWithLifecycle()
androidx.compose.runtime.LaunchedEffect(Unit) {
viewModel.connected.collect { onConnected() }
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text("Подключение к galo", style = MaterialTheme.typography.headlineSmall)
Text(
"Введите адрес вашего self-hosted инстанса",
style = MaterialTheme.typography.bodyMedium,
)
OutlinedTextField(
value = state.baseUrl,
onValueChange = viewModel::onUrlChange,
label = { Text("Базовый URL") },
placeholder = { Text("https://photos.example.com") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier
.fillMaxWidth()
.testTag(TAG_SETUP_URL),
)
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(
checked = state.insecureAllowed,
onCheckedChange = viewModel::onInsecureChange,
modifier = Modifier.testTag(TAG_SETUP_INSECURE),
)
Spacer(Modifier.width(12.dp))
Text("Небезопасное соединение (только LAN)")
}
if (state.insecureAllowed) {
Text(
"HTTP-соединение не шифруется. Включайте только в доверенной локальной сети.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
state.error?.let {
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium)
}
Button(
onClick = viewModel::connect,
enabled = !state.loading,
modifier = Modifier
.fillMaxWidth()
.testTag(TAG_SETUP_CONNECT),
) {
if (state.loading) {
CircularProgressIndicator(modifier = Modifier.height(20.dp).width(20.dp))
} else {
Text("Подключить")
}
}
}
}
@@ -0,0 +1,92 @@
package com.galo.android.ui.setup
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.galo.android.data.prefs.SettingsState
import com.galo.android.di.AppContainer
import com.galo.android.domain.ConnectionPolicy
import com.galo.android.domain.model.InsecureConnectionBlockedException
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class SetupViewModel(private val container: AppContainer) : ViewModel() {
data class UiState(
val baseUrl: String = "",
val insecureAllowed: Boolean = false,
val loading: Boolean = false,
val error: String? = null,
)
private val _state = MutableStateFlow(UiState())
val state = _state.asStateFlow()
private val _connected = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val connected = _connected.asSharedFlow()
init {
container.currentSettings()?.let { applySettings(it) }
viewModelScope.launch {
container.settingsRepository.flow.collect { settings ->
if (_state.value.baseUrl.isEmpty() && settings.baseUrl.isNotEmpty()) {
applySettings(settings)
}
}
}
}
private fun applySettings(settings: SettingsState) {
_state.update {
it.copy(baseUrl = settings.baseUrl, insecureAllowed = settings.insecureAllowed)
}
}
fun onUrlChange(value: String) = _state.update { it.copy(baseUrl = value, error = null) }
fun onInsecureChange(value: Boolean) = _state.update { it.copy(insecureAllowed = value) }
fun connect() {
val url = _state.value.baseUrl.trim()
when {
url.isEmpty() -> {
_state.update { it.copy(error = "Укажите адрес сервера") }
return
}
!ConnectionPolicy.isSecure(url) && !ConnectionPolicy.isInsecure(url) -> {
_state.update { it.copy(error = "Адрес должен начинаться с http:// или https://") }
return
}
}
try {
ConnectionPolicy.check(url, _state.value.insecureAllowed)
} catch (_: InsecureConnectionBlockedException) {
_state.update { it.copy(error = "HTTP запрещён. Включите режим небезопасного соединения для LAN.") }
return
}
val insecure = _state.value.insecureAllowed
_state.update { it.copy(loading = true, error = null) }
viewModelScope.launch {
try {
container.healthChecker.check(url)
container.settingsRepository.setInsecureAllowed(insecure)
container.settingsRepository.setBaseUrl(url)
_state.update { it.copy(loading = false) }
_connected.emit(Unit)
} catch (e: Exception) {
_state.update {
it.copy(
loading = false,
error = "Не удалось подключиться: ${e.message ?: "сервер недоступен"}",
)
}
}
}
}
}
@@ -0,0 +1,32 @@
package com.galo.android.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val LightColors = lightColorScheme()
private val DarkColors = darkColorScheme()
@Composable
fun GaloTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit,
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColors
else -> LightColors
}
MaterialTheme(colorScheme = colorScheme, content = content)
}
@@ -0,0 +1,239 @@
package com.galo.android.ui.upload
import android.Manifest
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.DeleteSweep
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.galo.android.domain.model.Album
import com.galo.android.domain.model.UploadJob
import com.galo.android.domain.model.UploadStatus
import com.galo.android.ui.GaloViewModelFactory
const val TAG_UPLOAD_LIST = "upload_list"
const val TAG_UPLOAD_WIFI = "upload_wifi"
const val TAG_UPLOAD_ADD = "upload_add"
@Composable
fun UploadScreen(
factory: GaloViewModelFactory,
onBack: () -> Unit,
) {
val viewModel: UploadViewModel = viewModel(factory = factory)
val jobs by viewModel.jobs.collectAsStateWithLifecycle()
val albums by viewModel.albums.collectAsStateWithLifecycle()
val settings by viewModel.settings.collectAsStateWithLifecycle()
val message by viewModel.message.collectAsStateWithLifecycle()
var pendingUris by remember { mutableStateOf<List<android.net.Uri>>(emptyList()) }
var showAlbumPicker by remember { mutableStateOf(false) }
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { }
val picker = rememberLauncherForActivityResult(
ActivityResultContracts.PickMultipleVisualMedia(20),
) { uris ->
if (uris.isNotEmpty()) {
pendingUris = uris
showAlbumPicker = true
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
if (showAlbumPicker) {
AlbumChooserDialog(
albums = albums,
onDismiss = { showAlbumPicker = false },
onSelect = { albumId ->
viewModel.enqueue(albumId, pendingUris)
pendingUris = emptyList()
showAlbumPicker = false
},
)
}
UploadContent(
jobs = jobs,
wifiOnly = settings?.wifiOnlyUploads == true,
message = message,
onToggleWifi = viewModel::setWifiOnly,
onClearCompleted = viewModel::clearCompleted,
onAdd = {
picker.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo),
)
},
onBack = onBack,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UploadContent(
jobs: List<UploadJob>,
wifiOnly: Boolean,
message: String?,
onToggleWifi: (Boolean) -> Unit,
onClearCompleted: () -> Unit,
onAdd: () -> Unit,
onBack: () -> Unit,
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Загрузки") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Назад")
}
},
actions = {
IconButton(onClick = onClearCompleted) {
Icon(Icons.Filled.DeleteSweep, contentDescription = "Убрать завершённые")
}
},
)
},
) { padding ->
Column(modifier = Modifier.fillMaxSize().padding(padding)) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Switch(
checked = wifiOnly,
onCheckedChange = onToggleWifi,
modifier = Modifier.testTag(TAG_UPLOAD_WIFI),
)
Text("Только Wi-Fi", modifier = Modifier.padding(start = 12.dp))
}
message?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
Button(
onClick = onAdd,
modifier = Modifier.fillMaxWidth().padding(16.dp).testTag(TAG_UPLOAD_ADD),
) {
Icon(Icons.Filled.Add, contentDescription = null)
Text("Добавить фото и видео", modifier = Modifier.padding(start = 8.dp))
}
if (jobs.isEmpty()) {
Text(
"Очередь пуста",
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyMedium,
)
} else {
LazyColumn(modifier = Modifier.fillMaxSize().testTag(TAG_UPLOAD_LIST)) {
items(jobs, key = { it.id }) { job -> UploadRow(job) }
}
}
}
}
}
@Composable
private fun UploadRow(job: UploadJob) {
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(job.filename, style = MaterialTheme.typography.bodyMedium)
Text(statusLabel(job.status), style = MaterialTheme.typography.labelMedium)
}
if (job.status == UploadStatus.RUNNING) {
LinearProgressIndicator(
progress = { job.progress / 100f },
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
)
}
job.error?.let {
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
}
}
private fun statusLabel(status: UploadStatus): String = when (status) {
UploadStatus.QUEUED -> "В очереди"
UploadStatus.RUNNING -> "Загрузка"
UploadStatus.SUCCESS -> "Готово"
UploadStatus.ERROR -> "Ошибка"
}
@Composable
private fun AlbumChooserDialog(
albums: List<Album>,
onDismiss: () -> Unit,
onSelect: (String) -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Выберите альбом") },
text = {
if (albums.isEmpty()) {
Text("Нет доступных альбомов")
} else {
LazyColumn {
items(albums, key = { it.id }) { album ->
Text(
text = album.title,
modifier = Modifier
.fillMaxWidth()
.clickable { onSelect(album.id) }
.padding(vertical = 12.dp),
)
}
}
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Отмена") }
},
)
}
@@ -0,0 +1,56 @@
package com.galo.android.ui.upload
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.galo.android.data.prefs.SettingsState
import com.galo.android.di.AppContainer
import com.galo.android.domain.model.Album
import com.galo.android.domain.model.UploadJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class UploadViewModel(private val container: AppContainer) : ViewModel() {
val jobs: StateFlow<List<UploadJob>> = container.uploadRepository.observeJobs()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
val albums: StateFlow<List<Album>> = container.albumRepository.observeAlbums()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
val settings: StateFlow<SettingsState?> = container.settingsRepository.flow
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), container.currentSettings())
private val _message = MutableStateFlow<String?>(null)
val message: StateFlow<String?> = _message.asStateFlow()
init {
viewModelScope.launch { container.albumRepository.refresh() }
viewModelScope.launch { container.uploadRepository.requeuePending() }
}
fun enqueue(albumId: String, uris: List<Uri>) {
if (uris.isEmpty()) return
viewModelScope.launch {
val wifiOnly = settings.value?.wifiOnlyUploads == true
val queued = container.uploadRepository.enqueue(albumId, uris, wifiOnly)
_message.value = "В очередь добавлено файлов: $queued"
}
}
fun setWifiOnly(enabled: Boolean) {
viewModelScope.launch { container.settingsRepository.setWifiOnlyUploads(enabled) }
}
fun clearCompleted() {
viewModelScope.launch { container.uploadRepository.clearCompleted() }
}
fun consumeMessage() {
_message.value = null
}
}
@@ -0,0 +1,256 @@
package com.galo.android.ui.viewer
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.opengl.GLES20
import android.opengl.GLSurfaceView
import android.opengl.GLUtils
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.ScaleGestureDetector
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.FloatBuffer
import java.util.concurrent.atomic.AtomicBoolean
import javax.microedition.khronos.egl.EGLConfig
import javax.microedition.khronos.opengles.GL10
/**
* Equirectangular 360 panorama viewer mirrored from the web `PanoViewer`
* (`frontend/src/lib/components/PanoViewer.svelte`): same spherical projection,
* the same drag-to-look mapping (yaw/pitch scaled by `fov / viewportHeight`),
* pitch clamped to ±89°, and pinch/wheel zoom mapped to the field of view.
*/
class PanoramaView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
) : GLSurfaceView(context, attrs) {
private val renderer = PanoRenderer()
private val scaleDetector = ScaleGestureDetector(
context,
object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
override fun onScale(detector: ScaleGestureDetector): Boolean {
renderer.zoom(if (detector.scaleFactor > 1f) 1f / detector.scaleFactor else 1f)
return true
}
},
)
private var lastX = 0f
private var lastY = 0f
init {
setEGLContextClientVersion(2)
setRenderer(renderer)
renderMode = RENDERMODE_WHEN_DIRTY
}
fun load(url: String) {
renderer.loadImage(url)
}
@androidx.annotation.VisibleForTesting
internal fun currentYawForTest(): Float = renderer.camera.yaw
@androidx.annotation.VisibleForTesting
internal fun currentPitchForTest(): Float = renderer.camera.pitch
override fun onTouchEvent(event: MotionEvent): Boolean {
scaleDetector.onTouchEvent(event)
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
lastX = event.x
lastY = event.y
}
MotionEvent.ACTION_MOVE -> {
if (!scaleDetector.isInProgress) {
val k = renderer.camera.fov / height.coerceAtLeast(1)
renderer.drag(event.x - lastX, event.y - lastY, k)
lastX = event.x
lastY = event.y
}
}
}
return true
}
private inner class PanoRenderer : Renderer {
private var program = 0
private var texture = 0
val camera = com.galo.android.domain.viewer.PanoramaCamera()
private val disposed = AtomicBoolean(false)
private var pendingUrl: String? = null
private var vertexBuffer: FloatBuffer = ByteBuffer
.allocateDirect(VERTS.size * 4)
.order(ByteOrder.nativeOrder())
.asFloatBuffer()
.apply {
put(VERTS)
position(0)
}
fun drag(dx: Float, dy: Float, k: Float) {
camera.drag(dx, dy, k)
requestRender()
}
fun zoom(factor: Float) {
camera.zoom(factor)
requestRender()
}
fun loadImage(url: String) {
pendingUrl = url
queueEvent { downloadAndUpload(url) }
}
override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
program = buildProgram()
val locs = IntArray(5)
GLES20.glGetUniformLocation(program, "uRes").let { locs[0] = it }
GLES20.glGetUniformLocation(program, "uFov").let { locs[1] = it }
GLES20.glGetUniformLocation(program, "uYaw").let { locs[2] = it }
GLES20.glGetUniformLocation(program, "uPitch").let { locs[3] = it }
GLES20.glGetUniformLocation(program, "uTex").let { locs[4] = it }
pRes = locs[0]; pFov = locs[1]; pYaw = locs[2]; pPitch = locs[3]; pTex = locs[4]
texture = createTexture()
val aPos = GLES20.glGetAttribLocation(program, "aPos")
GLES20.glEnableVertexAttribArray(aPos)
GLES20.glVertexAttribPointer(aPos, 2, GLES20.GL_FLOAT, false, 0, vertexBuffer)
pendingUrl?.let { downloadAndUpload(it) }
}
override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) {
GLES20.glViewport(0, 0, width, height)
}
override fun onDrawFrame(gl: GL10?) {
if (program == 0) return
GLES20.glUseProgram(program)
GLES20.glUniform2f(pRes, width.toFloat().coerceAtLeast(1f), height.toFloat().coerceAtLeast(1f))
GLES20.glUniform1f(pFov, camera.fov)
GLES20.glUniform1f(pYaw, camera.yaw)
GLES20.glUniform1f(pPitch, camera.pitch)
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, texture)
GLES20.glUniform1i(pTex, 0)
GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, 3)
}
private var pRes = -1
private var pFov = -1
private var pYaw = -1
private var pPitch = -1
private var pTex = -1
private fun downloadAndUpload(url: String) {
if (disposed.get()) return
val bitmap = runCatching {
java.net.URL(url).openConnection().run {
connectTimeout = 15_000
readTimeout = 30_000
getInputStream().use { BitmapFactory.decodeStream(it) }
}
}.getOrNull() ?: return
if (disposed.get()) {
bitmap.recycle()
return
}
upload(bitmap)
}
private fun upload(bitmap: Bitmap) {
val maxSize = 4096
val scaled = if (maxOf(bitmap.width, bitmap.height) > maxSize) {
val scale = maxSize.toFloat() / maxOf(bitmap.width, bitmap.height)
Bitmap.createScaledBitmap(
bitmap,
(bitmap.width * scale).toInt().coerceAtLeast(1),
(bitmap.height * scale).toInt().coerceAtLeast(1),
true,
).also { if (it !== bitmap) bitmap.recycle() }
} else {
bitmap
}
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, texture)
GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, scaled, 0)
scaled.recycle()
requestRender()
}
private fun createTexture(): Int {
val handles = IntArray(1)
GLES20.glGenTextures(1, handles, 0)
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, handles[0])
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE)
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE)
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR)
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR)
return handles[0]
}
private fun buildProgram(): Int {
val vs = compile(GLES20.GL_VERTEX_SHADER, VERT)
val fs = compile(GLES20.GL_FRAGMENT_SHADER, FRAG)
val p = GLES20.glCreateProgram()
GLES20.glAttachShader(p, vs)
GLES20.glAttachShader(p, fs)
GLES20.glLinkProgram(p)
GLES20.glDeleteShader(vs)
GLES20.glDeleteShader(fs)
return p
}
private fun compile(type: Int, source: String): Int {
val shader = GLES20.glCreateShader(type)
GLES20.glShaderSource(shader, source)
GLES20.glCompileShader(shader)
return shader
}
}
companion object {
private val VERTS = floatArrayOf(-1f, -1f, 3f, -1f, -1f, 3f)
// Kept in sync with PanoViewer.svelte (VERT/FRAG).
private val VERT = """
attribute vec2 aPos;
varying vec2 vUv;
void main() {
vUv = aPos * 0.5 + 0.5;
gl_Position = vec4(aPos, 0.0, 1.0);
}
""".trimIndent()
private val FRAG = """
precision mediump float;
varying vec2 vUv;
uniform sampler2D uTex;
uniform vec2 uRes;
uniform float uFov;
uniform float uYaw;
uniform float uPitch;
const float PI = 3.141592653589793;
void main() {
vec2 ndc = vUv * 2.0 - 1.0;
float aspect = uRes.x / uRes.y;
float tanHalf = tan(uFov * 0.5);
vec3 dir = normalize(vec3(ndc.x * tanHalf * aspect, ndc.y * tanHalf, -1.0));
float cp = cos(uPitch), sp = sin(uPitch);
vec3 d1 = vec3(dir.x, dir.y * cp - dir.z * sp, dir.y * sp + dir.z * cp);
float cy = cos(uYaw), sy = sin(uYaw);
vec3 d2 = vec3(d1.x * cy + d1.z * sy, d1.y, -d1.x * sy + d1.z * cy);
float lon = atan(d2.x, -d2.z);
float lat = asin(clamp(d2.y, -1.0, 1.0));
vec2 uv = vec2(lon / (2.0 * PI) + 0.5, 0.5 - lat / PI);
gl_FragColor = texture2D(uTex, uv);
}
""".trimIndent()
}
}
@@ -0,0 +1,111 @@
package com.galo.android.ui.viewer
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import com.galo.android.ui.common.GaloAsyncImage
/**
* Media3 playback with a poster shown until playback starts. The built-in
* controller provides seek/scrubbing; the player is paused when the screen
* leaves the foreground.
*/
@androidx.annotation.OptIn(UnstableApi::class)
@Composable
fun VideoPlayer(
url: String,
posterUrl: String?,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
var showPoster by remember(url) { mutableStateOf(true) }
val player = remember(url) {
ExoPlayer.Builder(context).build().apply {
setMediaItem(MediaItem.fromUri(url))
prepare()
}
}
DisposableEffect(url) {
val listener = object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
if (isPlaying) showPoster = false
}
}
player.addListener(listener)
onDispose {
player.removeListener(listener)
player.release()
}
}
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_PAUSE) player.pause()
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
Box(modifier.background(Color.Black)) {
AndroidView(
factory = {
PlayerView(it).apply {
this.player = player
useController = true
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
}
},
modifier = Modifier.fillMaxSize(),
)
if (showPoster) {
Box(Modifier.fillMaxSize()) {
GaloAsyncImage(
url = posterUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
)
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = "Воспроизвести",
tint = Color.White,
modifier = Modifier
.align(Alignment.Center)
.clickable {
showPoster = false
player.play()
},
)
}
}
}
LaunchedEffect(url) { player.playWhenReady = false }
}
@@ -0,0 +1,164 @@
package com.galo.android.ui.viewer
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.galo.android.GaloApplication
import com.galo.android.domain.model.MediaKind
import com.galo.android.domain.model.Photo
import com.galo.android.ui.common.GaloAsyncImage
const val TAG_VIEWER_PAGER = "viewer_pager"
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ViewerScreen(
app: GaloApplication,
albumId: String,
initialIndex: Int,
onBack: () -> Unit,
) {
val factory = viewModelFactory {
initializer { ViewerViewModel(app.container, albumId) }
}
val viewModel: ViewerViewModel = viewModel(factory = factory)
val photos by viewModel.photos.collectAsStateWithLifecycle()
ViewerContent(photos = photos, initialIndex = initialIndex, onBack = onBack)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ViewerContent(
photos: List<Photo>,
initialIndex: Int,
onBack: () -> Unit,
) {
if (photos.isEmpty()) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Просмотр", style = MaterialTheme.typography.titleMedium) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Назад")
}
},
)
},
) { padding ->
Surface(color = Color.Black, modifier = Modifier.fillMaxSize().padding(padding)) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = Color.White)
}
}
}
return
}
val pagerState = rememberPagerState(
initialPage = initialIndex.coerceIn(0, photos.lastIndex),
) { photos.size }
val current = photos.getOrNull(pagerState.currentPage)
// A 360 panorama wraps horizontally forever, so on panorama pages the pager
// must not steal horizontal drags: they rotate the view instead.
val pagerScrollEnabled = current?.isPanorama != true
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
current?.originalFilename ?: "Просмотр",
style = MaterialTheme.typography.titleMedium,
)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Назад")
}
},
)
},
) { padding ->
Surface(
color = Color.Black,
modifier = Modifier.fillMaxSize().padding(padding),
) {
HorizontalPager(
state = pagerState,
userScrollEnabled = pagerScrollEnabled,
modifier = Modifier.fillMaxSize().testTag(TAG_VIEWER_PAGER),
key = { index -> photos.getOrNull(index)?.id ?: index },
) { page ->
val photo = photos.getOrNull(page) ?: return@HorizontalPager
MediaPage(photo)
}
}
}
}
@Composable
private fun MediaPage(photo: Photo) {
when {
photo.kind == MediaKind.VIDEO -> {
val playback = photo.playbackUrl ?: photo.originalUrl ?: photo.downloadUrl
if (playback != null) {
VideoPlayer(
url = playback,
posterUrl = photo.mediumUrl ?: photo.thumbUrl,
modifier = Modifier.fillMaxSize(),
)
}
}
photo.isPanorama -> {
val source = photo.originalUrl ?: photo.previewUrl ?: photo.mediumUrl
if (source != null) {
AndroidPanorama(source)
}
}
else -> {
GaloAsyncImage(
url = photo.previewUrl ?: photo.mediumUrl ?: photo.originalUrl ?: photo.thumbUrl,
contentDescription = photo.originalFilename,
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize(),
)
}
}
}
@Composable
private fun AndroidPanorama(url: String) {
androidx.compose.ui.viewinterop.AndroidView(
factory = { context -> PanoramaView(context) },
update = { view -> view.load(url) },
modifier = Modifier.fillMaxSize(),
)
}
@@ -0,0 +1,28 @@
package com.galo.android.ui.viewer
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.galo.android.di.AppContainer
import com.galo.android.domain.model.Photo
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class ViewerViewModel(
private val container: AppContainer,
private val albumId: String,
) : ViewModel() {
val photos: StateFlow<List<Photo>> = container.photoRepository.observePhotos(albumId)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
init {
// Cold start (e.g. restored route): make sure the cache is populated.
viewModelScope.launch {
if (photos.value.isEmpty()) {
container.photoRepository.loadNextPage(albumId, cursor = null)
}
}
}
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">galo</string>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="upload_channel_name">Загрузка в galo</string>
<string name="upload_channel_description">Прогресс фоновой загрузки фотографий и видео</string>
</resources>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Galo" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:windowBackground">@android:color/white</item>
</style>
</resources>
@@ -0,0 +1,35 @@
package com.galo.android.domain
import com.galo.android.domain.model.InsecureConnectionBlockedException
import org.junit.Assert.assertFalse
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
class ConnectionPolicyTest {
@Test
fun `http is blocked by default`() {
assertThrows(InsecureConnectionBlockedException::class.java) {
ConnectionPolicy.check("http://192.168.1.10:8080", insecureAllowed = false)
}
}
@Test
fun `http is allowed once insecure mode is enabled`() {
ConnectionPolicy.check("http://192.168.1.10:8080", insecureAllowed = true)
}
@Test
fun `https is always allowed`() {
ConnectionPolicy.check("https://photos.example.com", insecureAllowed = false)
ConnectionPolicy.check("https://photos.example.com", insecureAllowed = true)
}
@Test
fun `scheme detection is case and whitespace insensitive`() {
assertTrue(ConnectionPolicy.isInsecure(" HTTP://host"))
assertTrue(ConnectionPolicy.isSecure(" HTTPS://host"))
assertFalse(ConnectionPolicy.isInsecure("https://host"))
}
}
@@ -0,0 +1,41 @@
package com.galo.android.domain.viewer
import org.junit.Assert.assertEquals
import org.junit.Test
class PanoramaCameraTest {
@Test
fun `horizontal drag rotates yaw by dx times scale`() {
val camera = PanoramaCamera(fov = 1f)
camera.drag(dx = 100f, dy = 0f, scale = 0.01f)
assertEquals(1f, camera.yaw, 1e-4f)
}
@Test
fun `vertical drag changes pitch`() {
val camera = PanoramaCamera()
camera.drag(dx = 0f, dy = 50f, scale = 0.01f)
assertEquals(0.5f, camera.pitch, 1e-4f)
}
@Test
fun `pitch is clamped to plus minus 89 degrees`() {
val camera = PanoramaCamera()
camera.drag(dx = 0f, dy = 1_000f, scale = 1f)
assertEquals(PanoramaCamera.PITCH_LIMIT, camera.pitch, 1e-4f)
camera.drag(dx = 0f, dy = -10_000f, scale = 1f)
assertEquals(-PanoramaCamera.PITCH_LIMIT, camera.pitch, 1e-4f)
}
@Test
fun `zoom stays within fov bounds`() {
val camera = PanoramaCamera()
repeat(50) { camera.zoom(1.5f) }
assertEquals(PanoramaCamera.MAX_FOV, camera.fov, 1e-4f)
repeat(100) { camera.zoom(0.5f) }
assertEquals(PanoramaCamera.MIN_FOV, camera.fov, 1e-4f)
}
}
@@ -0,0 +1,89 @@
package com.galo.android.ui.albums
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import com.galo.android.domain.model.Album
import com.galo.android.ui.theme.GaloTheme
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class AlbumsContentTest {
@get:Rule
val compose = createComposeRule()
private fun album(id: String, title: String) = Album(
id = id,
title = title,
description = "",
ownerId = "u1",
coverPhotoId = null,
coverThumbUrl = null,
coverMediumUrl = null,
photosCount = 4,
membersCount = 2,
updatedAt = null,
)
@Test
fun `album grid shows cards with counters`() {
compose.setContent {
GaloTheme(dynamicColor = false) {
AlbumsContent(
state = AlbumsViewModel.UiState(albums = listOf(album("a1", "Summer"))),
onOpenAlbum = {},
onOpenUploads = {},
onRefresh = {},
onLogout = {},
)
}
}
compose.onNodeWithTag(TAG_ALBUMS_GRID).assertIsDisplayed()
compose.onNodeWithTag(TAG_ALBUM_CARD_PREFIX + "a1").assertIsDisplayed()
compose.onNodeWithText("Summer").assertIsDisplayed()
compose.onNodeWithText("4 фото · 2 участн.").assertIsDisplayed()
}
@Test
fun `offline banner is shown when offline`() {
compose.setContent {
GaloTheme(dynamicColor = false) {
AlbumsContent(
state = AlbumsViewModel.UiState(
albums = listOf(album("a1", "Summer")),
offline = true,
error = "Нет сети — показаны кэшированные данные",
),
onOpenAlbum = {},
onOpenUploads = {},
onRefresh = {},
onLogout = {},
)
}
}
compose.onNodeWithTag(TAG_ALBUMS_OFFLINE).assertIsDisplayed()
}
@Test
fun `empty state is shown without albums`() {
compose.setContent {
GaloTheme(dynamicColor = false) {
AlbumsContent(
state = AlbumsViewModel.UiState(),
onOpenAlbum = {},
onOpenUploads = {},
onRefresh = {},
onLogout = {},
)
}
}
compose.onNodeWithText("Пока нет альбомов").assertIsDisplayed()
}
}
@@ -0,0 +1,81 @@
package com.galo.android.ui.upload
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import com.galo.android.domain.model.UploadJob
import com.galo.android.domain.model.UploadStatus
import com.galo.android.ui.theme.GaloTheme
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class UploadContentTest {
@get:Rule
val compose = createComposeRule()
private fun job(id: String, name: String, status: UploadStatus, error: String? = null) = UploadJob(
id = id,
albumId = "a1",
filename = name,
mimeType = "image/jpeg",
size = 100,
status = status,
progress = 40,
error = error,
attempts = 0,
wifiOnly = false,
createdAt = 0,
)
private fun render(jobs: List<UploadJob>, message: String? = null, wifiOnly: Boolean = false) {
compose.setContent {
GaloTheme(dynamicColor = false) {
UploadContent(
jobs = jobs,
wifiOnly = wifiOnly,
message = message,
onToggleWifi = {},
onClearCompleted = {},
onAdd = {},
onBack = {},
)
}
}
}
@Test
fun `per-file status and progress are shown`() {
render(
listOf(
job("1", "queued.jpg", UploadStatus.QUEUED),
job("2", "running.mp4", UploadStatus.RUNNING),
job("3", "done.jpg", UploadStatus.SUCCESS),
),
)
compose.onNodeWithText("queued.jpg").assertIsDisplayed()
compose.onNodeWithText("В очереди").assertIsDisplayed()
compose.onNodeWithText("running.mp4").assertIsDisplayed()
compose.onNodeWithText("Загрузка").assertIsDisplayed()
compose.onNodeWithText("Готово").assertIsDisplayed()
}
@Test
fun `server error is shown`() {
render(listOf(job("4", "big.jpg", UploadStatus.ERROR, error = "Файл превышает допустимый размер")))
compose.onNodeWithText("Ошибка").assertIsDisplayed()
compose.onNodeWithText("Файл превышает допустимый размер").assertIsDisplayed()
}
@Test
fun `empty queue and wifi toggle are shown`() {
render(emptyList(), message = "Очередь пуста", wifiOnly = true)
compose.onNodeWithTag(TAG_UPLOAD_ADD).assertIsDisplayed()
}
}
@@ -0,0 +1,110 @@
package com.galo.android.ui.viewer
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeLeft
import com.galo.android.domain.model.MediaKind
import com.galo.android.domain.model.Photo
import com.galo.android.ui.theme.GaloTheme
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class ViewerContentTest {
@get:Rule
val compose = createComposeRule()
private fun photo(
id: String,
name: String,
kind: MediaKind = MediaKind.IMAGE,
isPanorama: Boolean = false,
) = Photo(
id = id,
albumId = "a1",
originalFilename = name,
kind = kind,
mimeType = null,
width = 100,
height = 100,
durationMs = 0,
size = 0,
takenAt = null,
caption = "",
isPanorama = isPanorama,
thumbUrl = null,
mediumUrl = null,
previewUrl = null,
playbackUrl = null,
originalUrl = null,
downloadUrl = null,
createdAt = null,
)
@Test
fun `swipe advances to the next item without leaving the viewer`() {
compose.setContent {
GaloTheme(dynamicColor = false) {
ViewerContent(
photos = listOf(photo("p1", "first.jpg"), photo("p2", "second.jpg")),
initialIndex = 0,
onBack = {},
)
}
}
compose.onNodeWithText("first.jpg").assertIsDisplayed()
compose.onNodeWithTag(TAG_VIEWER_PAGER).performTouchInput { swipeLeft() }
compose.waitForIdle()
compose.onNodeWithText("second.jpg").assertIsDisplayed()
}
@Test
fun `opens at the requested index`() {
compose.setContent {
GaloTheme(dynamicColor = false) {
ViewerContent(
photos = listOf(
photo("p1", "first.jpg"),
photo("p2", "second.jpg"),
photo("p3", "third.jpg"),
),
initialIndex = 2,
onBack = {},
)
}
}
compose.onNodeWithText("third.jpg").assertIsDisplayed()
}
@Test
fun `horizontal swipe does not page away from a panorama`() {
compose.setContent {
GaloTheme(dynamicColor = false) {
ViewerContent(
photos = listOf(
photo("p1", "first.jpg"),
photo("p2", "pano.jpg", isPanorama = true),
),
initialIndex = 1,
onBack = {},
)
}
}
compose.onNodeWithText("pano.jpg").assertIsDisplayed()
compose.onNodeWithTag(TAG_VIEWER_PAGER).performTouchInput { swipeLeft() }
compose.waitForIdle()
// Paging is disabled on panorama pages so the drag can rotate the view.
compose.onNodeWithText("pano.jpg").assertIsDisplayed()
}
}