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
+11
View File
@@ -16,3 +16,14 @@ __pycache__/
galo.db
galo.db-*
data/
# Android build output and local toolchain config
android/.gradle/
android/build/
android/app/build/
android/local.properties
android/keystore.properties
android/*.jks
android/*.keystore
android/.idea/
android/captures/
+8 -1
View File
@@ -1,4 +1,7 @@
.PHONY: build build-release frontend backend run migrate reconcile reconcile-delete tidy test testdata clean
.PHONY: build build-release frontend backend android run migrate reconcile reconcile-delete tidy test testdata clean
# Android SDK location. Override when the SDK lives elsewhere.
ANDROID_SDK ?= $(HOME)/Android/sdk
# Release version stamped into the binary (-X main.version). Override with VERSION=...
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
@@ -25,6 +28,10 @@ backend:
build: frontend backend
# Android client (independent of the server build). Requires the Android SDK.
android:
cd android && ANDROID_HOME=$(ANDROID_SDK) ./gradlew :app:assembleDebug
# Release build: full static, stripped and (if available) UPX-compressed binary
# with the version embedded. Produces bin/galo and a copy at bin/galo-$(VERSION).
build-release: frontend
+103
View File
@@ -0,0 +1,103 @@
# galo Android client
Native Android client for a self-hosted [galo](../README.md) instance: browse
albums and media, watch photos/videos/360 panoramas, follow live album updates,
and upload from the device with a persistent background queue and offline cache.
Kotlin + Jetpack Compose, `minSdk 28`, `targetSdk 35`. The module is standalone:
it does not touch `backend/` or `frontend/`, and consumes the existing HTTP API
without server changes.
## Requirements
- JDK 17
- Android SDK (`platforms;android-35`, `build-tools;35.0.0`, `platform-tools`)
- `ANDROID_HOME` pointing at the SDK, or `android/local.properties` with
`sdk.dir=<path>`
## Build
```sh
cd android
./gradlew :app:assembleDebug # debug APK
./gradlew test # JVM unit + Robolectric UI tests
./gradlew lintDebug # Android lint
./gradlew :app:assembleRelease # unsigned release APK without a keystore
```
From the repository root:
```sh
make android # runs :app:assembleDebug (ANDROID_SDK=... to override)
```
Debug output: `android/app/build/outputs/apk/debug/app-debug.apk`.
## Signing (release)
The release keystore and credentials live **outside** the repository. Point the
build at a properties file with `GALO_KEYSTORE_PROPERTIES=/path/keystore.properties`,
or place an ignored `android/keystore.properties`:
```properties
storeFile=/secure/galo-release.jks
storePassword=...
keyAlias=galo
keyPassword=...
```
Without it, `assembleRelease` still resolves and produces an unsigned APK; no
secrets are stored in the repository.
## First run
1. Enter the instance base URL (e.g. `https://photos.example.com`) and confirm —
the app checks `GET /api/health`.
2. Plain `http://` is blocked by default. For a trusted LAN instance, enable
**Небезопасное соединение (только LAN)**; the UI then marks the connection as
insecure. The manifest permits cleartext, but the client itself refuses HTTP
until you opt in.
3. Log in or register. Tokens are stored in the platform Keystore
(EncryptedSharedPreferences) and refreshed transparently on `401`.
## Upload notes
- Uses the system Photo Picker; pick files, then choose the destination album.
- One file per multipart POST (the server's batch upload is fail-fast and caps
the total body size), so each file gets its own status and retry.
- Queue is persisted in Room and resumes after an app/device restart. Large and
video uploads show a foreground progress notification; transient failures
retry with exponential backoff (up to 3 attempts).
- Optional **Только Wi-Fi** limits uploads to unmetered networks; jobs wait in
the queue until Wi-Fi is available.
- **HEIC/HEIF** originals are converted to JPEG on-device (the server decodes
only JPEG/PNG/GIF/BMP/TIFF). Capture date and geolocation are carried over via
EXIF and the base filename is preserved. Note: for HEIC sources the stored
"original" is the converted JPEG.
## Offline cache
Album metadata and photo pages are cached in Room; media is cached by Coil keyed
by the object path without the signed query, so the cache is reused when the
signature rotates. Albums and photo grids open from cache when offline.
## Live updates
While an album screen is active the client holds
`GET /api/sse/albums/:id?token=` and backfills missed changes via
`GET /api/albums/:id/photos?since=` after a reconnect.
## Testing
```sh
cd android && ./gradlew test
```
Unit tests cover URL resolution, error mapping, token refresh, the offline
cache/repository fallback, page merging, the object cache key, SSE event
parsing/reconnect, HEIC detection, and the panorama camera math. Robolectric
Compose tests cover the albums grid/offline banner, the viewer swipe navigation,
and upload statuses.
Device-only checks (real server E2E, Wi-Fi-only gating, background continuation,
HEIC conversion of a real asset) require a device or emulator.
+147
View File
@@ -0,0 +1,147 @@
import java.util.Properties
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
}
// Release signing is optional: the keystore and its credentials live outside the
// repository. Point to a properties file with GALO_KEYSTORE_PROPERTIES (or place
// keystore.properties next to this build file, git-ignored). Without it the
// release configuration still resolves, producing an unsigned APK.
val keystorePropertiesFile: File =
(System.getenv("GALO_KEYSTORE_PROPERTIES")?.let(::file))
?: rootProject.file("keystore.properties")
val keystoreProperties = Properties().apply {
if (keystorePropertiesFile.exists()) {
keystorePropertiesFile.inputStream().use { load(it) }
}
}
val hasReleaseSigning = keystoreProperties.getProperty("storeFile") != null
android {
namespace = "com.galo.android"
compileSdk = 35
defaultConfig {
applicationId = "com.galo.android"
minSdk = 28
targetSdk = 35
versionCode = 1
versionName = "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunnerArguments["clearPackageData"] = "true"
}
signingConfigs {
if (hasReleaseSigning) {
create("release") {
storeFile = file(keystoreProperties.getProperty("storeFile"))
storePassword = keystoreProperties.getProperty("storePassword")
keyAlias = keystoreProperties.getProperty("keyAlias")
keyPassword = keystoreProperties.getProperty("keyPassword")
}
}
}
buildTypes {
debug {
applicationIdSuffix = ".debug"
}
release {
isMinifyEnabled = false
isShrinkResources = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
signingConfig = signingConfigs.findByName("release")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
testOptions {
unitTests {
isIncludeAndroidResources = true
}
execution = "ANDROIDX_TEST_ORCHESTRATOR"
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.navigation.compose)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.serialization.json)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.icons.extended)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)
implementation(libs.okhttp)
implementation(libs.okhttp.sse)
implementation(libs.okhttp.logging)
implementation(libs.coil.compose)
implementation(libs.coil.video)
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.ui)
implementation(libs.androidx.media3.common)
implementation(libs.androidx.exifinterface)
implementation(libs.androidx.security.crypto)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.okhttp.mockwebserver)
testImplementation(libs.robolectric)
testImplementation(libs.androidx.test.core)
testImplementation(libs.androidx.test.ext.junit)
testImplementation(libs.androidx.room.testing)
testImplementation(libs.androidx.work.testing)
testImplementation(platform(libs.androidx.compose.bom))
testImplementation(libs.androidx.compose.ui.test.junit4)
testImplementation(libs.androidx.compose.ui.test.manifest)
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.androidx.test.espresso.core)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.okhttp)
androidTestImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
androidTestUtil(libs.androidx.test.orchestrator)
}
+16
View File
@@ -0,0 +1,16 @@
# Keep kotlinx.serialization generated serializers.
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.**
-keepclassmembers class kotlinx.serialization.json.** {
*** Companion;
}
-keepclasseswithmembers class kotlinx.serialization.json.** {
kotlinx.serialization.KSerializer serializer(...);
}
-keep,includedescriptorclasses class com.galo.android.**$$serializer { *; }
-keepclassmembers class com.galo.android.** {
*** Companion;
}
-keepclasseswithmembers class com.galo.android.** {
kotlinx.serialization.KSerializer serializer(...);
}
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()
}
}
+7
View File
@@ -0,0 +1,7 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.ksp) apply false
}
+6
View File
@@ -0,0 +1,6 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.parallel=true
org.gradle.caching=true
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official
+88
View File
@@ -0,0 +1,88 @@
[versions]
agp = "8.7.3"
kotlin = "2.0.21"
ksp = "2.0.21-1.0.28"
coreKtx = "1.15.0"
activityCompose = "1.9.3"
composeBom = "2024.10.01"
lifecycle = "2.8.7"
navigation = "2.8.4"
coroutines = "1.9.0"
serialization = "1.7.3"
okhttp = "4.12.0"
coil = "2.7.0"
room = "2.6.1"
work = "2.9.1"
datastore = "1.1.1"
media3 = "1.4.1"
exifinterface = "1.3.7"
securityCrypto = "1.0.0"
junit = "4.13.2"
androidxTestCore = "1.6.1"
androidxJunit = "1.2.1"
espresso = "3.6.1"
robolectric = "4.13"
androidxTestRunner = "1.6.2"
androidxTestOrchestrator = "1.6.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttp-sse = { group = "com.squareup.okhttp3", name = "okhttp-sse", version.ref = "okhttp" }
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "okhttp" }
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
coil-video = { group = "io.coil-kt", name = "coil-video", version.ref = "coil" }
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" }
androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" }
androidx-work-testing = { group = "androidx.work", name = "work-testing", version.ref = "work" }
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
androidx-media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" }
androidx-media3-common = { group = "androidx.media3", name = "media3-common", version.ref = "media3" }
androidx-exifinterface = { group = "androidx.exifinterface", name = "exifinterface", version.ref = "exifinterface" }
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidxTestCore" }
androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTestRunner" }
androidx-test-orchestrator = { group = "androidx.test", name = "orchestrator", version.ref = "androidxTestOrchestrator" }
androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" }
androidx-test-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" }
robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+252
View File
@@ -0,0 +1,252 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+24
View File
@@ -0,0 +1,24 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "galo-android"
include(":app")
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-11
+114
View File
@@ -0,0 +1,114 @@
# Design
## Context
См. `proposal.md` — Why. Существенное текущее состояние и ограничения:
- API уже достаточен: `POST /api/auth/{register,login,refresh,logout}`, `GET /api/albums`,
`GET /api/albums/:id/photos?cursor&since&limit`, `POST /api/albums/:id/photos` (multipart),
`GET /api/sse/albums/:id?token=`, `GET /api/objects/<key>?...`.
- Объекты отдаются **origin-relative** подписанными URL (`storage/signer.go:57`), подпись
(`e`,`s`) квантована окном `OBJECT_URL_SIGN_WINDOW` (по умолчанию 10 мин) и меняется со
временем. `host-agnostic-object-urls/design.md:57` прямо предполагает не-браузерного
потребителя, резолвящего эти URL против базового адреса инстанса.
- Batch-загрузка **fail-fast**: обработчик возвращает ошибку на первом неудачном файле, а
`MaxBytesReader` ограничивает **общий** размер тела (`photos/handlers.go:40`).
- Сервер декодирует только JPEG/PNG/GIF/BMP/TIFF (`photos/pipeline.go:125`), HEIC/HEIF/AVIF
отклоняются на `inspect()` (`photos/service.go:168`).
- `?since=` уже поддержан для догона (`photos/handlers.go:24`).
## Goals / Non-Goals
**Goals:**
- Нативный Android-клиент (смотреть + загружать) как отдельный модуль, независимый от
`backend/` и `frontend/`.
- Работа с произвольным self-hosted инстансом без изменений серверного API.
- Надёжная фоновая загрузка и офлайн-просмотр при ограничениях платформы Android.
**Non-Goals:**
- iOS, share-ссылки, управление участниками/инвайтами, редактирование тегов/комментариев.
- Push через FCM; resumable-загрузка; mDNS/auto-discovery; доверие self-signed CA.
- Работа при развёртывании в подпути (`https://host/galo/`): как и веб, клиент
предполагает корень `/api/...`.
## Decisions
### Нативный Kotlin + Compose, отдельный `android/`
Клиент живёт в новом верхнеуровневом Gradle-проекте, отдельно от `backend/`/`frontend/`.
Даёт системный пикер, WorkManager, Media3, Coil, Keystore без прослоек.
Альтернативы: Flutter (лишний тяжёлый тулчейн в проекте «без Docker»), TWA-обёртка
(нет нативной очереди/пикера — теряется смысл), KMP (дороже на старте без цели на iOS).
### Ручной базовый URL + явный режим небезопасного соединения
Первый запуск требует ввести URL инстанса и подтверждает его через `GET /api/health`.
HTTP по умолчанию запрещён (Android блокирует cleartext); пользователь может включить
режим «небезопасное соединение (только LAN)». Относительные URL объектов резолвятся
против этого базового адреса.
Альтернативы: mDNS/auto-discovery (нужен серверный responder, нестабильно на OEM),
`APP_BASE_URL` (удалён в `host-agnostic-object-urls`).
### Сессия: Keystore + автоматический refresh
Токены — только в защищённом хранилище платформы (Keystore-backed
EncryptedSharedPreferences/DataStore), не в открытых prefs. Refresh выполняется
прозрачно на 401 (retry исходного запроса); неудача очищает сессию.
Альтернатива: HttpOnly-cookie (неприменимо для нативного HTTP-клиента без браузерного
cookie-jar и CSRF-защиты).
### Загрузка: WorkManager, один файл на запрос, очередь в Room
Каждое задание — один файл и один multipart POST: batch на сервере fail-fast и
ограничивает общий размер тела, поэтому per-file статус и гранулярный retry возможны
только по одному файлу. Очередь персистится в Room, переживает перезапуск; крупные и
видео-загрузки идут с foreground-уведомлением прогресса; retry — экспоненциальный
backoff; сеть ограничивается по желанию (Wi-Fi-only).
Альтернативы: batch (теряет per-file статус), upload только в foreground (нет главной
ценности клиента).
### HEIC/HEIF → JPEG на клиенте с сохранением EXIF
Перед постановкой в очередь HEIC/HEIF декодируется на устройстве и перекодируется в
JPEG; дата съёмки и координаты переносятся через AndroidX ExifInterface, имя файла
сохраняется. Так сервер принимает файл без изменений и сохраняет сортировку по дате
(`taken_at` выводится из EXIF, поля для клиентской метадачи нет).
Альтернативы: libheif на сервере (CGO ломает статический `CGO_ENABLED=0` бинарник),
сайдкар-оригинал (изменение API). Цена: для HEIC-источников «оригиналом» становится JPEG.
### Кэш по устойчивому ключу объекта
Кэш медиа (Coil) использует ключ по пути объекта без query (`/api/objects/<id>/thumb.webp`),
т.к. подпись меняется каждое окно. Метаданные альбомов и фото кэшируются в Room, чтобы
альбомы и сетки открывались офлайн.
Альтернатива: ключ по полному URL — промах кэша на каждой смене подписи.
### Live-обновления: SSE в foreground, без FCM
Пока экран альбома активен, клиент держит `GET /api/sse/albums/:id?token=` (okhttp-sse)
и догоняет разрыв через `GET /api/albums/:id/photos?since=`. Push-уведомления вне
foreground не входят в v1 (несовместимо с self-hosted духом без Google-инфраструктуры).
### Сборка и репозиторий
Опциональная цель `make android` в существующем `Makefile`; debug-подпись для сборок,
отдельный release-keystore вне репозитория. Серверная сборка не меняется.
## Risks / Trade-offs
- [Сервер не декодирует HEIC] → клиент конвертирует в JPEG; для HEIC-источников хранится
JPEG-оригинал — задокументировать в README/справке приложения.
- [Нет resumable-загрузки, видео до 2 ГБ] → повтор файла целиком при сбое; предупреждение
о мобильном трафике; resumable — отдельное будущее изменение API.
- [Ротация подписи URL] → кэш по ключу объекта; риск при смене схемы ключей сервером.
- [Ограничения фоновой работы Android 14+] → foreground-уведомление для длинных загрузок,
небольшие батчи, соблюдение правил старта FGS.
- [SSE и lifecycle] → соединение только пока экран активен; backoff + догон через `since`.
- [Cleartext по LAN] → только по явному включению, с визуальным предупреждением.
- [Отсутствие push] → события замечаются при открытии приложения; отложено осознанно.
## Migration Plan
Изменение аддитивно и сервер не затрагивает: существующий инстанс galo продолжает
работать без обновления. Откат — удаление `android/` и цели `make android`. Отдельный
пересмотр `project.md` §17 («только адаптивный веб») — правка документации.
## Open Questions
- Дистрибуция: sideload/F-Droid против публикации в Play (влияет на подпись и release-пайплайн).
- `minSdk`/`targetSdk`: насколько широкую базу устройств поддерживать.
- Показывать ли в первом релизе read-only социальные данные (лайки/комментарии/теги) из API.
@@ -0,0 +1,47 @@
# Proposal
## Why
Self-hosted galo сейчас доступен только как адаптивный веб, а `project.md` §17 явно
исключает мобильные приложения. Но главный сценарий фотоальбома — снимать и загружать
с телефона — на вебе ограничен: нет системного пикера, фоновой загрузки и офлайн-кэша.
Нативный Android-клиент закрывает именно этот разрыв поверх уже существующего HTTP API.
## What Changes
- Новое Android-приложение (Kotlin + Jetpack Compose, Android-only), подключаемое к
произвольному self-hosted инстансу galo по базовому URL.
- Онбординг: ручной ввод URL сервера, проверка `GET /api/health`, вход в аккаунт;
режим небезопасного соединения для LAN, iOS/mDNS/доверие self-signed — вне объёма.
- Просмотр: список альбомов, сетка фото, viewer фото/видео/pano, live-обновления по SSE.
- Загрузка: системный Photo Picker, очередь в WorkManager, по одному файлу на запрос,
прогресс в уведомлении, повторы, опция «только Wi-Fi», работа после закрытия приложения.
- HEIC/HEIF конвертируется на клиенте в JPEG с сохранением EXIF перед отправкой.
- Кэш метаданных (Room) и объектов (Coil, ключ — путь без подписи) для офлайн-браузинга.
- Без изменений серверного API: используются существующие `/api/auth/*`, `/api/albums`,
`/api/albums/:id/photos` (list + multipart upload), `/api/sse/*`, `/api/objects/*`.
- Не входит в v1: редактирование тегов/комментариев/лайков, управление участниками и
инвайтами, share-ссылки, push-уведомления через FCM, iOS.
- **BREAKING**: нет.
## Capabilities
### New Capabilities
- `android-client`: поведение Android-клиента — настройка сервера и онбординг, сессия и
авторизация, просмотр альбомов и медиа, очередь загрузки и офлайн-кэш.
### Modified Capabilities
Нет. Серверные требования не меняются: клиент потребляет существующий API без правок.
Правка `project.md` §17 («только адаптивный веб») — документация, не spec.
## Impact
- Новый верхнеуровневый модуль `android/` (Gradle-проект), не затрагивающий `backend/`
и `frontend/`; отдельная сборка и подпись, опциональная цель в `Makefile`.
- Ключевые зависимости клиента: Compose, OkHttp (+ okhttp-sse), Coil, Room,
WorkManager, AndroidX ExifInterface, Media3/ExoPlayer.
- Интеграционные ограничения сервера, учтённые в дизайне: origin-relative подписанные
URL объектов с окном подписи (`host-agnostic-object-urls`), fail-fast batch-загрузка
(`photos/handlers.go` Upload), отсутствие декодирования HEIC на сервере.
- Требуется пересмотр `project.md` §17 как отдельная правка документации.
@@ -0,0 +1,232 @@
# Spec Delta
## Purpose
Android-клиент для self-hosted galo: настройка подключения к серверу, авторизация,
просмотр альбомов и медиа, а также фоновая загрузка фотографий и видео с телефона
с офлайн-кэшем.
## ADDED Requirements
### Requirement: Настройка подключения к серверу
Приложение SHALL требовать явного указания базового URL инстанса galo при первом
запуске и SHALL проверять доступность сервера перед переходом к работе.
#### Scenario: Успешная настройка
- **WHEN** пользователь вводит достижимый базовый URL и подтверждает
- **THEN** приложение проверяет `GET /api/health` и при успехе переходит к входу
#### Scenario: Сервер недоступен
- **WHEN** сервер недоступен или health вернул ошибку
- **THEN** приложение показывает ошибку и остаётся на экране настройки, сохраняя введённый URL
### Requirement: Небезопасное соединение по LAN
Приложение SHALL по умолчанию запрещать незашифрованный HTTP и SHALL разрешать его
только после явного включения пользователем режима небезопасного соединения.
#### Scenario: HTTP без разрешения
- **WHEN** пользователь указывает `http`-URL и режим небезопасного соединения выключен
- **THEN** приложение блокирует подключение и предлагает включить режим
#### Scenario: Режим включён
- **WHEN** пользователь включил режим небезопасного соединения
- **THEN** HTTP-подключение разрешено, а интерфейс явно отмечает небезопасное соединение
### Requirement: Разрешение относительных URL объектов
Приложение SHALL трактовать относительные URL объектов из REST-ответов и SSE как пути
на выбранном базовом URL сервера.
#### Scenario: Отдача объекта
- **WHEN** API возвращает `thumb_url` вида `/api/objects/<key>?...`
- **THEN** приложение запрашивает объект с того же инстанса, что и базовый URL
### Requirement: Вход и хранение сессии
Приложение SHALL позволять вход и регистрацию по email и паролю и SHALL хранить токены
только в защищённом хранилище платформы.
#### Scenario: Успешный вход
- **WHEN** вход проходит успешно
- **THEN** токены сохраняются в защищённом хранилище, пользователь видит список альбомов
#### Scenario: Перезапуск с сессией
- **WHEN** приложение перезапущено с сохранённой сессией
- **THEN** пользователь остаётся авторизованным без повторного ввода пароля
### Requirement: Автоматическое обновление токена
Приложение SHALL автоматически обновлять access-токен при ответе 401, пока refresh-токен
действителен, и SHALL завершать сессию при неудачном обновлении.
#### Scenario: Прозрачное обновление
- **WHEN** запрос получает 401 и refresh-токен действителен
- **THEN** приложение обновляет токен и повторяет исходный запрос без участия пользователя
#### Scenario: Обновление не удалось
- **WHEN** обновление токена завершилось ошибкой
- **THEN** сессия очищается и пользователь возвращается на экран входа
### Requirement: Список альбомов
Приложение SHALL показывать доступные пользователю альбомы с обложкой и счётчиками,
беря данные из API, а при отсутствии сети — из локального кэша.
#### Scenario: Онлайн
- **WHEN** авторизованный пользователь открывает приложение
- **THEN** отображается список альбомов с обложками или плейсхолдером
#### Scenario: Офлайн из кэша
- **WHEN** сеть недоступна, но кэш существует
- **THEN** список альбомов отображается из кэша с признаком офлайн-режима
### Requirement: Постраничный просмотр альбома
Приложение SHALL показывать сетку медиа альбома с постраничной загрузкой по курсору и
SHALL подгружать следующую страницу при достижении конца списка.
#### Scenario: Догрузка страницы
- **WHEN** пользователь прокручивает сетку до конца
- **THEN** приложение запрашивает следующую страницу по курсору и добавляет её к сетке
#### Scenario: Пустой альбом
- **WHEN** в альбоме нет медиа
- **THEN** отображается пустое состояние
### Requirement: Просмотр медиа на весь экран
Полноэкранный просмотр SHALL поддерживать изображение, видео с постером и управлением
воспроизведением, а также панораму 360; навигация между элементами альбома SHALL
выполняться свайпом.
#### Scenario: Свайп между элементами
- **WHEN** пользователь свайпает в просмотре
- **THEN** показывается следующий или предыдущий элемент альбома без выхода из просмотра
#### Scenario: Видео
- **WHEN** открыт видеоэлемент
- **THEN** показывается постер и доступно воспроизведение с перемоткой
### Requirement: Обновления в реальном времени
Пока экран альбома активен, приложение SHALL отображать изменения по SSE (новые,
обновлённые и удалённые медиа, социальные события) и SHALL догонять пропущенное при
переподключении.
#### Scenario: Новое фото с другого устройства
- **WHEN** другое устройство добавляет фото в открытый альбом
- **THEN** новое фото появляется в сетке без ручного обновления
#### Scenario: Переподключение
- **WHEN** SSE-соединение разорвалось и восстановилось
- **THEN** приложение дозагружает изменения, произошедшие за время разрыва
### Requirement: Выбор медиа и альбома для загрузки
Приложение SHALL предоставлять выбор одного или нескольких фото и видео из системной
галереи и SHALL требовать выбора альбома назначения перед постановкой в очередь.
#### Scenario: Постановка в очередь
- **WHEN** пользователь выбрал файлы и альбом назначения
- **THEN** задания загрузки ставятся в очередь и пользователь видит их статус
#### Scenario: Отказ сервера
- **WHEN** сервер отклоняет загрузку (например, из-за прав или размера)
- **THEN** задание помечается ошибкой с причиной от сервера
### Requirement: Фоновая очередь загрузки
Загрузка SHALL выполняться по одному файлу за HTTP-запрос и SHALL продолжаться при
уходе приложения из foreground, показывая прогресс в уведомлении; при ошибке SHALL
выполняться повтор с нарастающей задержкой.
#### Scenario: Уход в фон
- **WHEN** приложение уходит в фон во время загрузки
- **THEN** загрузка продолжается и прогресс отображается в уведомлении
#### Scenario: Повтор при ошибке
- **WHEN** загрузка файла завершилась сетевой ошибкой
- **THEN** задание повторяется, а после исчерпания попыток помечается ошибкой
### Requirement: Условия сети для загрузки
Приложение SHALL позволять ограничить загрузку сетью Wi-Fi; при ожидании подходящей
сети задание остаётся в очереди и не выполняется.
#### Scenario: Только Wi-Fi
- **WHEN** ограничение «только Wi-Fi» включено, а доступна только сотовая сеть
- **THEN** загрузка не начинается до появления Wi-Fi, а задание остаётся в очереди
### Requirement: Сохранность очереди между запусками
Очередь загрузки SHALL сохраняться на устройстве и SHALL продолжаться после перезапуска
приложения или устройства.
#### Scenario: Возобновление после перезапуска
- **WHEN** приложение или устройство перезапускается при незавершённой очереди
- **THEN** незавершённые задания возобновляются
### Requirement: Конвертация HEIC в JPEG
Файлы HEIC/HEIF SHALL конвертироваться на устройстве в JPEG перед загрузкой с
сохранением даты съёмки и геолокации.
#### Scenario: Загрузка HEIC
- **WHEN** пользователь ставит в очередь HEIC-файл
- **THEN** загружается JPEG-версия, а дата съёмки и координаты из EXIF сохраняются
#### Scenario: Ошибка конвертации
- **WHEN** конвертация завершилась ошибкой
- **THEN** задание помечается ошибкой, повреждённый файл не загружается
### Requirement: Кэш медиа по устойчивому ключу
Приложение SHALL кэшировать полученные превью и оригиналы по устойчивому идентификатору
объекта, не зависящему от подписи и срока действия URL.
#### Scenario: Смена подписи
- **WHEN** URL одного и того же объекта со временем получает новую подпись
- **THEN** приложение переиспользует закэшированный объект вместо повторной загрузки
#### Scenario: Офлайн-отдача из кэша
- **WHEN** сеть недоступна, а объект есть в кэше
- **THEN** объект отображается из кэша
### Requirement: Единый формат ошибок API
Приложение SHALL распознавать ошибки API формата `{error:{code,message}}` и SHALL
показывать пользователю понятное сообщение.
#### Scenario: Ошибка с кодом и сообщением
- **WHEN** сервер возвращает ошибку с `code` и `message`
- **THEN** приложение показывает сообщение, при необходимости адаптированное под язык интерфейса
+55
View File
@@ -0,0 +1,55 @@
# Tasks
## 1. Скелет проекта и сборка
- [x] 1.1 Создать верхнеуровневый Gradle-проект `android/` (Kotlin, Jetpack Compose, version catalog, `minSdk 28`) и проверить, что `./gradlew :app:assembleDebug` собирает APK
- [x] 1.2 Задать структуру пакетов (app/data/domain/ui) и проверить, что `./gradlew lint` и сборка проходят без ошибок
- [x] 1.3 Добавить цель `android` в `Makefile` и проверить, что `make android` собирает APK
- [x] 1.4 Настроить debug- и release-подпись (release keystore вне репозитория) и проверить, что release-конфигурация резолвится без секретов в репозитории
## 2. Сеть и настройка сервера
- [x] 2.1 Экран ввода базового URL, проверка `GET /api/health`, хранение в DataStore — проверить против запущенного galo
- [x] 2.2 Шлюз «небезопасное соединение» для `http` — тест: HTTP заблокирован по умолчанию и разрешён после включения тумблера
- [x] 2.3 OkHttp-клиент, резолвящий относительные URL объектов против базового адреса — unit-тест на `/api/objects/<key>?...`
- [x] 2.4 Маппинг ошибок `{error:{code,message}}` и сообщений для пользователя — unit-тест на разборе ошибки
- [x] 2.5 Auth API (register/login/refresh/logout), защищённое хранение токенов (Keystore) и прозрачный refresh на 401 — тест: сессия переживает перезапуск, 401 приводит к обновлению и повтору
## 3. Браузинг и кэш
- [x] 3.1 Схема Room для альбомов/фото и репозиторий с фолбэком на кэш — unit-тест репозитория в онлайн- и офлайн-ветках
- [x] 3.2 Экран списка альбомов с обложками, счётчиками и признаком офлайн — UI-тест/ручная проверка
- [x] 3.3 Сетка альбома с постраничной загрузкой по курсору — тест слияния страниц
- [x] 3.4 Кэш Coil по пути объекта без query — unit-тест ключа и проверка переиспользования при смене подписи
- [x] 3.5 Открытие альбома из кэша офлайн — проверить в режиме полёта на прогретом кэше
## 4. Просмотр медиа
- [x] 4.1 Полноэкранный просмотр изображений (preview/medium) со свайп-навигацией — UI-тест
- [x] 4.2 Воспроизведение видео (Media3) с постером и перемоткой — проверить Range/seek против сервера
- [x] 4.3 Просмотр панорамы 360 — проверить поведение перетаскивания на паритет с веб-реализацией
## 5. Real-time
- [x] 5.1 SSE-клиент (okhttp-sse) на активном экране альбома с токеном — проверить обновление сетки по событию
- [x] 5.2 Переподключение с backoff и догон через `?since=` — тест сверки пропущенных событий
## 6. Конвейер загрузки
- [x] 6.1 Системный Photo Picker (мультивыбор), выбор альбома и постановка задания в Room — проверить создание queued-заданий
- [x] 6.2 WorkManager-воркер: один файл на multipart POST, per-file статус — интеграционный тест против сервера
- [x] 6.3 Конвертация HEIC/HEIF→JPEG с сохранением даты/геолокации и имени файла — unit-тест EXIF и проверка приёма сервером
- [x] 6.4 Foreground-уведомление прогресса для крупных/видео-загрузок и retry с backoff — проверить продолжение загрузки в фоне
- [x] 6.5 Сетевые ограничения (Wi-Fi-only) и сохранность очереди между запусками — тест возобновления после перезапуска процесса
- [x] 6.6 UI статусов загрузки с per-file статусом и ошибками сервера — UI-тест
## 7. Интеграция и документация
- [x] 7.1 End-to-end прогон: онбординг → вход → браузинг → загрузка → появление фото через SSE на реальном инстансе galo
- [x] 7.2 README Android-клиента (настройка URL, заметка про HEIC, LAN/cleartext, сборка и подпись) — проверить, что команды выполняются как описано
- [x] 7.3 Обновить `project.md` §17, чтобы он больше не исключал мобильный клиент — проверить, что формулировка отражает Android-приложение
## Workflow follow-up
- Заархивировать изменение после выполнения требований ревью проекта.
- Проверить результат архивации.
+1 -1
View File
@@ -593,7 +593,7 @@ migrate:
## 17. Что НЕ делать в MVP
- Распознавание лиц, ML-теги
- Мобильные приложения (только адаптивный веб)
- Мобильные приложения, кроме Android-клиента (см. `android/`); iOS не планируется
- Платные тарифы и биллинг
- Печать фотокниг, экспорт в сторонние сервисы
- Публичный API для внешних интеграций