diff --git a/.gitignore b/.gitignore index 7aa0ae8..6da98a1 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/Makefile b/Makefile index 8ddbc8c..334dcc3 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..234c7e7 --- /dev/null +++ b/android/README.md @@ -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=` + +## 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. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..2358cc2 --- /dev/null +++ b/android/app/build.gradle.kts @@ -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) +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..81c43b0 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -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(...); +} diff --git a/android/app/src/androidTest/assets/equirect.jpg b/android/app/src/androidTest/assets/equirect.jpg new file mode 100644 index 0000000..6b2f21e Binary files /dev/null and b/android/app/src/androidTest/assets/equirect.jpg differ diff --git a/android/app/src/androidTest/assets/sample.heic b/android/app/src/androidTest/assets/sample.heic new file mode 100644 index 0000000..00cc549 Binary files /dev/null and b/android/app/src/androidTest/assets/sample.heic differ diff --git a/android/app/src/androidTest/assets/test.mp4 b/android/app/src/androidTest/assets/test.mp4 new file mode 100644 index 0000000..98eeb37 Binary files /dev/null and b/android/app/src/androidTest/assets/test.mp4 differ diff --git a/android/app/src/androidTest/java/com/galo/android/E2E.kt b/android/app/src/androidTest/java/com/galo/android/E2E.kt new file mode 100644 index 0000000..bf4bbf6 --- /dev/null +++ b/android/app/src/androidTest/java/com/galo/android/E2E.kt @@ -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(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(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(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(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) + } +} diff --git a/android/app/src/androidTest/java/com/galo/android/E2EFlowTest.kt b/android/app/src/androidTest/java/com/galo/android/E2EFlowTest.kt new file mode 100644 index 0000000..7c664bc --- /dev/null +++ b/android/app/src/androidTest/java/com/galo/android/E2EFlowTest.kt @@ -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() + + private val baseUrl = "http://10.0.2.2:8088" + private val client = OkHttpClient() + + private fun app() = ApplicationProvider.getApplicationContext() + + @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(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 + } +} diff --git a/android/app/src/androidTest/java/com/galo/android/HeicConversionTest.kt b/android/app/src/androidTest/java/com/galo/android/HeicConversionTest.kt new file mode 100644 index 0000000..f238c87 --- /dev/null +++ b/android/app/src/androidTest/java/com/galo/android/HeicConversionTest.kt @@ -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() + + @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 + } +} diff --git a/android/app/src/androidTest/java/com/galo/android/PanoramaDragTest.kt b/android/app/src/androidTest/java/com/galo/android/PanoramaDragTest.kt new file mode 100644 index 0000000..a13ebef --- /dev/null +++ b/android/app/src/androidTest/java/com/galo/android/PanoramaDragTest.kt @@ -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() + 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()) + } +} diff --git a/android/app/src/androidTest/java/com/galo/android/UploadPipelineTest.kt b/android/app/src/androidTest/java/com/galo/android/UploadPipelineTest.kt new file mode 100644 index 0000000..01ad8d2 --- /dev/null +++ b/android/app/src/androidTest/java/com/galo/android/UploadPipelineTest.kt @@ -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().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) + } + } +} diff --git a/android/app/src/androidTest/java/com/galo/android/VideoSeekTest.kt b/android/app/src/androidTest/java/com/galo/android/VideoSeekTest.kt new file mode 100644 index 0000000..19a792b --- /dev/null +++ b/android/app/src/androidTest/java/com/galo/android/VideoSeekTest.kt @@ -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() + 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) + } +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..1e5d837 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/com/galo/android/GaloApplication.kt b/android/app/src/main/java/com/galo/android/GaloApplication.kt new file mode 100644 index 0000000..cfdc60b --- /dev/null +++ b/android/app/src/main/java/com/galo/android/GaloApplication.kt @@ -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" + } +} diff --git a/android/app/src/main/java/com/galo/android/di/AppContainer.kt b/android/app/src/main/java/com/galo/android/di/AppContainer.kt new file mode 100644 index 0000000..b4f1710 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/di/AppContainer.kt @@ -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) } + } + } +} diff --git a/android/app/src/main/java/com/galo/android/domain/ConnectionPolicy.kt b/android/app/src/main/java/com/galo/android/domain/ConnectionPolicy.kt new file mode 100644 index 0000000..ff4702e --- /dev/null +++ b/android/app/src/main/java/com/galo/android/domain/ConnectionPolicy.kt @@ -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() + } + } +} diff --git a/android/app/src/main/java/com/galo/android/domain/model/ApiError.kt b/android/app/src/main/java/com/galo/android/domain/model/ApiError.kt new file mode 100644 index 0000000..2d5e57a --- /dev/null +++ b/android/app/src/main/java/com/galo/android/domain/model/ApiError.kt @@ -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") diff --git a/android/app/src/main/java/com/galo/android/domain/model/Models.kt b/android/app/src/main/java/com/galo/android/domain/model/Models.kt new file mode 100644 index 0000000..8e623ec --- /dev/null +++ b/android/app/src/main/java/com/galo/android/domain/model/Models.kt @@ -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( + val items: List, + val nextCursor: String?, +) diff --git a/android/app/src/main/java/com/galo/android/domain/model/Upload.kt b/android/app/src/main/java/com/galo/android/domain/model/Upload.kt new file mode 100644 index 0000000..2875dbb --- /dev/null +++ b/android/app/src/main/java/com/galo/android/domain/model/Upload.kt @@ -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, +) diff --git a/android/app/src/main/java/com/galo/android/domain/viewer/PanoramaCamera.kt b/android/app/src/main/java/com/galo/android/domain/viewer/PanoramaCamera.kt new file mode 100644 index 0000000..052d15a --- /dev/null +++ b/android/app/src/main/java/com/galo/android/domain/viewer/PanoramaCamera.kt @@ -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() + } +} diff --git a/android/app/src/main/java/com/galo/android/ui/GaloNavHost.kt b/android/app/src/main/java/com/galo/android/ui/GaloNavHost.kt new file mode 100644 index 0000000..d9b31fa --- /dev/null +++ b/android/app/src/main/java/com/galo/android/ui/GaloNavHost.kt @@ -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() }, + ) + } + } +} diff --git a/android/app/src/main/java/com/galo/android/ui/GaloViewModelFactory.kt b/android/app/src/main/java/com/galo/android/ui/GaloViewModelFactory.kt new file mode 100644 index 0000000..9a90f50 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/ui/GaloViewModelFactory.kt @@ -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 create(modelClass: Class): 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 + } +} diff --git a/android/app/src/main/java/com/galo/android/ui/MainActivity.kt b/android/app/src/main/java/com/galo/android/ui/MainActivity.kt new file mode 100644 index 0000000..1f107bd --- /dev/null +++ b/android/app/src/main/java/com/galo/android/ui/MainActivity.kt @@ -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) + } + } + } + } +} diff --git a/android/app/src/main/java/com/galo/android/ui/album/AlbumScreen.kt b/android/app/src/main/java/com/galo/android/ui/album/AlbumScreen.kt new file mode 100644 index 0000000..3e961da --- /dev/null +++ b/android/app/src/main/java/com/galo/android/ui/album/AlbumScreen.kt @@ -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), + ) + } + } +} diff --git a/android/app/src/main/java/com/galo/android/ui/album/AlbumViewModel.kt b/android/app/src/main/java/com/galo/android/ui/album/AlbumViewModel.kt new file mode 100644 index 0000000..3641c79 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/ui/album/AlbumViewModel.kt @@ -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 = 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 = 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() + } +} diff --git a/android/app/src/main/java/com/galo/android/ui/albums/AlbumsScreen.kt b/android/app/src/main/java/com/galo/android/ui/albums/AlbumsScreen.kt new file mode 100644 index 0000000..21822dd --- /dev/null +++ b/android/app/src/main/java/com/galo/android/ui/albums/AlbumsScreen.kt @@ -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, + ) + } + } +} diff --git a/android/app/src/main/java/com/galo/android/ui/albums/AlbumsViewModel.kt b/android/app/src/main/java/com/galo/android/ui/albums/AlbumsViewModel.kt new file mode 100644 index 0000000..f718102 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/ui/albums/AlbumsViewModel.kt @@ -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 = 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 = 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() + } +} diff --git a/android/app/src/main/java/com/galo/android/ui/auth/AuthScreen.kt b/android/app/src/main/java/com/galo/android/ui/auth/AuthScreen.kt new file mode 100644 index 0000000..dbcfb57 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/ui/auth/AuthScreen.kt @@ -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 { + "У меня уже есть аккаунт" + }, + ) + } + } + } +} diff --git a/android/app/src/main/java/com/galo/android/ui/auth/AuthViewModel.kt b/android/app/src/main/java/com/galo/android/ui/auth/AuthViewModel.kt new file mode 100644 index 0000000..c028c7e --- /dev/null +++ b/android/app/src/main/java/com/galo/android/ui/auth/AuthViewModel.kt @@ -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(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 ?: "Ошибка входа") } + } + } + } +} diff --git a/android/app/src/main/java/com/galo/android/ui/common/GaloImage.kt b/android/app/src/main/java/com/galo/android/ui/common/GaloImage.kt new file mode 100644 index 0000000..b295fd6 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/ui/common/GaloImage.kt @@ -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)) +} diff --git a/android/app/src/main/java/com/galo/android/ui/setup/SetupScreen.kt b/android/app/src/main/java/com/galo/android/ui/setup/SetupScreen.kt new file mode 100644 index 0000000..38ea960 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/ui/setup/SetupScreen.kt @@ -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("Подключить") + } + } + } +} diff --git a/android/app/src/main/java/com/galo/android/ui/setup/SetupViewModel.kt b/android/app/src/main/java/com/galo/android/ui/setup/SetupViewModel.kt new file mode 100644 index 0000000..9a50f00 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/ui/setup/SetupViewModel.kt @@ -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(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 ?: "сервер недоступен"}", + ) + } + } + } + } +} diff --git a/android/app/src/main/java/com/galo/android/ui/theme/Theme.kt b/android/app/src/main/java/com/galo/android/ui/theme/Theme.kt new file mode 100644 index 0000000..bad9f0f --- /dev/null +++ b/android/app/src/main/java/com/galo/android/ui/theme/Theme.kt @@ -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) +} diff --git a/android/app/src/main/java/com/galo/android/ui/upload/UploadScreen.kt b/android/app/src/main/java/com/galo/android/ui/upload/UploadScreen.kt new file mode 100644 index 0000000..001e18d --- /dev/null +++ b/android/app/src/main/java/com/galo/android/ui/upload/UploadScreen.kt @@ -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>(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, + 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, + 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("Отмена") } + }, + ) +} diff --git a/android/app/src/main/java/com/galo/android/ui/upload/UploadViewModel.kt b/android/app/src/main/java/com/galo/android/ui/upload/UploadViewModel.kt new file mode 100644 index 0000000..53ec07d --- /dev/null +++ b/android/app/src/main/java/com/galo/android/ui/upload/UploadViewModel.kt @@ -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> = container.uploadRepository.observeJobs() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + val albums: StateFlow> = container.albumRepository.observeAlbums() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + val settings: StateFlow = container.settingsRepository.flow + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), container.currentSettings()) + + private val _message = MutableStateFlow(null) + val message: StateFlow = _message.asStateFlow() + + init { + viewModelScope.launch { container.albumRepository.refresh() } + viewModelScope.launch { container.uploadRepository.requeuePending() } + } + + fun enqueue(albumId: String, uris: List) { + 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 + } +} diff --git a/android/app/src/main/java/com/galo/android/ui/viewer/PanoramaView.kt b/android/app/src/main/java/com/galo/android/ui/viewer/PanoramaView.kt new file mode 100644 index 0000000..3bab750 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/ui/viewer/PanoramaView.kt @@ -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() + } +} diff --git a/android/app/src/main/java/com/galo/android/ui/viewer/VideoPlayer.kt b/android/app/src/main/java/com/galo/android/ui/viewer/VideoPlayer.kt new file mode 100644 index 0000000..7c6513f --- /dev/null +++ b/android/app/src/main/java/com/galo/android/ui/viewer/VideoPlayer.kt @@ -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 } +} diff --git a/android/app/src/main/java/com/galo/android/ui/viewer/ViewerScreen.kt b/android/app/src/main/java/com/galo/android/ui/viewer/ViewerScreen.kt new file mode 100644 index 0000000..f19c341 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/ui/viewer/ViewerScreen.kt @@ -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, + 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(), + ) +} diff --git a/android/app/src/main/java/com/galo/android/ui/viewer/ViewerViewModel.kt b/android/app/src/main/java/com/galo/android/ui/viewer/ViewerViewModel.kt new file mode 100644 index 0000000..f17bffe --- /dev/null +++ b/android/app/src/main/java/com/galo/android/ui/viewer/ViewerViewModel.kt @@ -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> = 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) + } + } + } +} diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..53c6564 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + galo + diff --git a/android/app/src/main/res/values/strings_upload.xml b/android/app/src/main/res/values/strings_upload.xml new file mode 100644 index 0000000..d1854ec --- /dev/null +++ b/android/app/src/main/res/values/strings_upload.xml @@ -0,0 +1,5 @@ + + + Загрузка в galo + Прогресс фоновой загрузки фотографий и видео + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..9dfd3ca --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,6 @@ + + + + diff --git a/android/app/src/test/java/com/galo/android/domain/ConnectionPolicyTest.kt b/android/app/src/test/java/com/galo/android/domain/ConnectionPolicyTest.kt new file mode 100644 index 0000000..7bdb90c --- /dev/null +++ b/android/app/src/test/java/com/galo/android/domain/ConnectionPolicyTest.kt @@ -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")) + } +} diff --git a/android/app/src/test/java/com/galo/android/domain/viewer/PanoramaCameraTest.kt b/android/app/src/test/java/com/galo/android/domain/viewer/PanoramaCameraTest.kt new file mode 100644 index 0000000..fd01be9 --- /dev/null +++ b/android/app/src/test/java/com/galo/android/domain/viewer/PanoramaCameraTest.kt @@ -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) + } +} diff --git a/android/app/src/test/java/com/galo/android/ui/albums/AlbumsContentTest.kt b/android/app/src/test/java/com/galo/android/ui/albums/AlbumsContentTest.kt new file mode 100644 index 0000000..97908e9 --- /dev/null +++ b/android/app/src/test/java/com/galo/android/ui/albums/AlbumsContentTest.kt @@ -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() + } +} diff --git a/android/app/src/test/java/com/galo/android/ui/upload/UploadContentTest.kt b/android/app/src/test/java/com/galo/android/ui/upload/UploadContentTest.kt new file mode 100644 index 0000000..2dfbd6c --- /dev/null +++ b/android/app/src/test/java/com/galo/android/ui/upload/UploadContentTest.kt @@ -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, 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() + } +} diff --git a/android/app/src/test/java/com/galo/android/ui/viewer/ViewerContentTest.kt b/android/app/src/test/java/com/galo/android/ui/viewer/ViewerContentTest.kt new file mode 100644 index 0000000..1497d5a --- /dev/null +++ b/android/app/src/test/java/com/galo/android/ui/viewer/ViewerContentTest.kt @@ -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() + } +} diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..614b661 --- /dev/null +++ b/android/build.gradle.kts @@ -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 +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..3f6f2ce --- /dev/null +++ b/android/gradle.properties @@ -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 diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml new file mode 100644 index 0000000..2a7d896 --- /dev/null +++ b/android/gradle/libs.versions.toml @@ -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" } diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..2c35211 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..09523c0 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..f5feea6 --- /dev/null +++ b/android/gradlew @@ -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" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..9b42019 --- /dev/null +++ b/android/gradlew.bat @@ -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 diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..b46ad47 --- /dev/null +++ b/android/settings.gradle.kts @@ -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") diff --git a/openspec/changes/add-android-app/.openspec.yaml b/openspec/changes/add-android-app/.openspec.yaml new file mode 100644 index 0000000..a369219 --- /dev/null +++ b/openspec/changes/add-android-app/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-11 diff --git a/openspec/changes/add-android-app/design.md b/openspec/changes/add-android-app/design.md new file mode 100644 index 0000000..0f84dd4 --- /dev/null +++ b/openspec/changes/add-android-app/design.md @@ -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/?...`. +- Объекты отдаются **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//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. diff --git a/openspec/changes/add-android-app/proposal.md b/openspec/changes/add-android-app/proposal.md new file mode 100644 index 0000000..32628d6 --- /dev/null +++ b/openspec/changes/add-android-app/proposal.md @@ -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 как отдельная правка документации. diff --git a/openspec/changes/add-android-app/specs/android-client/spec.md b/openspec/changes/add-android-app/specs/android-client/spec.md new file mode 100644 index 0000000..a6e0c30 --- /dev/null +++ b/openspec/changes/add-android-app/specs/android-client/spec.md @@ -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/?...` +- **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** приложение показывает сообщение, при необходимости адаптированное под язык интерфейса diff --git a/openspec/changes/add-android-app/tasks.md b/openspec/changes/add-android-app/tasks.md new file mode 100644 index 0000000..c0fb698 --- /dev/null +++ b/openspec/changes/add-android-app/tasks.md @@ -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/?...` +- [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 + +- Заархивировать изменение после выполнения требований ревью проекта. +- Проверить результат архивации. diff --git a/project.md b/project.md index e875b4b..31630c0 100644 --- a/project.md +++ b/project.md @@ -593,7 +593,7 @@ migrate: ## 17. Что НЕ делать в MVP - Распознавание лиц, ML-теги -- Мобильные приложения (только адаптивный веб) +- Мобильные приложения, кроме Android-клиента (см. `android/`); iOS не планируется - Платные тарифы и биллинг - Печать фотокниг, экспорт в сторонние сервисы - Публичный API для внешних интеграций