feat(android): add native Android client
Kotlin + Jetpack Compose client for a self-hosted instance: manual server setup and health check, auth with Keystore-backed tokens, album and photo browsing with SSE live updates, upload queue in WorkManager with HEIC-to-JPEG conversion, and Room/Coil offline cache. Add an android Makefile target and revise project.md §17.
This commit is contained in:
+11
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# galo Android client
|
||||
|
||||
Native Android client for a self-hosted [galo](../README.md) instance: browse
|
||||
albums and media, watch photos/videos/360 panoramas, follow live album updates,
|
||||
and upload from the device with a persistent background queue and offline cache.
|
||||
|
||||
Kotlin + Jetpack Compose, `minSdk 28`, `targetSdk 35`. The module is standalone:
|
||||
it does not touch `backend/` or `frontend/`, and consumes the existing HTTP API
|
||||
without server changes.
|
||||
|
||||
## Requirements
|
||||
|
||||
- JDK 17
|
||||
- Android SDK (`platforms;android-35`, `build-tools;35.0.0`, `platform-tools`)
|
||||
- `ANDROID_HOME` pointing at the SDK, or `android/local.properties` with
|
||||
`sdk.dir=<path>`
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
cd android
|
||||
./gradlew :app:assembleDebug # debug APK
|
||||
./gradlew test # JVM unit + Robolectric UI tests
|
||||
./gradlew lintDebug # Android lint
|
||||
./gradlew :app:assembleRelease # unsigned release APK without a keystore
|
||||
```
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
make android # runs :app:assembleDebug (ANDROID_SDK=... to override)
|
||||
```
|
||||
|
||||
Debug output: `android/app/build/outputs/apk/debug/app-debug.apk`.
|
||||
|
||||
## Signing (release)
|
||||
|
||||
The release keystore and credentials live **outside** the repository. Point the
|
||||
build at a properties file with `GALO_KEYSTORE_PROPERTIES=/path/keystore.properties`,
|
||||
or place an ignored `android/keystore.properties`:
|
||||
|
||||
```properties
|
||||
storeFile=/secure/galo-release.jks
|
||||
storePassword=...
|
||||
keyAlias=galo
|
||||
keyPassword=...
|
||||
```
|
||||
|
||||
Without it, `assembleRelease` still resolves and produces an unsigned APK; no
|
||||
secrets are stored in the repository.
|
||||
|
||||
## First run
|
||||
|
||||
1. Enter the instance base URL (e.g. `https://photos.example.com`) and confirm —
|
||||
the app checks `GET /api/health`.
|
||||
2. Plain `http://` is blocked by default. For a trusted LAN instance, enable
|
||||
**Небезопасное соединение (только LAN)**; the UI then marks the connection as
|
||||
insecure. The manifest permits cleartext, but the client itself refuses HTTP
|
||||
until you opt in.
|
||||
3. Log in or register. Tokens are stored in the platform Keystore
|
||||
(EncryptedSharedPreferences) and refreshed transparently on `401`.
|
||||
|
||||
## Upload notes
|
||||
|
||||
- Uses the system Photo Picker; pick files, then choose the destination album.
|
||||
- One file per multipart POST (the server's batch upload is fail-fast and caps
|
||||
the total body size), so each file gets its own status and retry.
|
||||
- Queue is persisted in Room and resumes after an app/device restart. Large and
|
||||
video uploads show a foreground progress notification; transient failures
|
||||
retry with exponential backoff (up to 3 attempts).
|
||||
- Optional **Только Wi-Fi** limits uploads to unmetered networks; jobs wait in
|
||||
the queue until Wi-Fi is available.
|
||||
- **HEIC/HEIF** originals are converted to JPEG on-device (the server decodes
|
||||
only JPEG/PNG/GIF/BMP/TIFF). Capture date and geolocation are carried over via
|
||||
EXIF and the base filename is preserved. Note: for HEIC sources the stored
|
||||
"original" is the converted JPEG.
|
||||
|
||||
## Offline cache
|
||||
|
||||
Album metadata and photo pages are cached in Room; media is cached by Coil keyed
|
||||
by the object path without the signed query, so the cache is reused when the
|
||||
signature rotates. Albums and photo grids open from cache when offline.
|
||||
|
||||
## Live updates
|
||||
|
||||
While an album screen is active the client holds
|
||||
`GET /api/sse/albums/:id?token=` and backfills missed changes via
|
||||
`GET /api/albums/:id/photos?since=` after a reconnect.
|
||||
|
||||
## Testing
|
||||
|
||||
```sh
|
||||
cd android && ./gradlew test
|
||||
```
|
||||
|
||||
Unit tests cover URL resolution, error mapping, token refresh, the offline
|
||||
cache/repository fallback, page merging, the object cache key, SSE event
|
||||
parsing/reconnect, HEIC detection, and the panorama camera math. Robolectric
|
||||
Compose tests cover the albums grid/offline banner, the viewer swipe navigation,
|
||||
and upload statuses.
|
||||
|
||||
Device-only checks (real server E2E, Wi-Fi-only gating, background continuation,
|
||||
HEIC conversion of a real asset) require a device or emulator.
|
||||
@@ -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)
|
||||
}
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
# Keep kotlinx.serialization generated serializers.
|
||||
-keepattributes *Annotation*, InnerClasses
|
||||
-dontnote kotlinx.serialization.**
|
||||
-keepclassmembers class kotlinx.serialization.json.** {
|
||||
*** Companion;
|
||||
}
|
||||
-keepclasseswithmembers class kotlinx.serialization.json.** {
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
-keep,includedescriptorclasses class com.galo.android.**$$serializer { *; }
|
||||
-keepclassmembers class com.galo.android.** {
|
||||
*** Companion;
|
||||
}
|
||||
-keepclasseswithmembers class com.galo.android.** {
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,147 @@
|
||||
package com.galo.android
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.galo.android.data.network.GaloJson
|
||||
import com.galo.android.data.network.dto.AlbumEnvelopeDto
|
||||
import com.galo.android.data.network.dto.PhotoDto
|
||||
import com.galo.android.data.network.dto.TokenEnvelopeDto
|
||||
import com.galo.android.data.network.dto.UploadResponseDto
|
||||
import com.galo.android.di.AppContainer
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Shared helpers for on-device integration tests. Assumes a galo server is
|
||||
* reachable from the emulator at [BASE_URL] (host loopback) started with
|
||||
* `HTTP_ADDR=:8088 ./bin/galo`.
|
||||
*/
|
||||
object E2E {
|
||||
|
||||
const val BASE_URL = "http://10.0.2.2:8088"
|
||||
|
||||
val client: OkHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
fun context(): Context = ApplicationProvider.getApplicationContext()
|
||||
|
||||
/** Points the app at the LAN server with insecure HTTP allowed, then waits until applied. */
|
||||
fun configure(container: AppContainer) {
|
||||
runBlocking {
|
||||
container.settingsRepository.setInsecureAllowed(true)
|
||||
container.settingsRepository.setBaseUrl(BASE_URL)
|
||||
}
|
||||
val deadline = System.currentTimeMillis() + 10_000
|
||||
while (System.currentTimeMillis() < deadline && container.baseUrl() != BASE_URL) {
|
||||
Thread.sleep(100)
|
||||
}
|
||||
check(container.baseUrl() == BASE_URL) { "settings did not apply" }
|
||||
}
|
||||
|
||||
fun register(): String = registerTokens().accessToken
|
||||
|
||||
/** Registers and stores the token in the app's Keystore-backed session. */
|
||||
fun registerAuthenticated(container: AppContainer): String {
|
||||
val tokens = registerTokens()
|
||||
container.tokenStore.save(tokens.accessToken, tokens.refreshToken)
|
||||
return tokens.accessToken
|
||||
}
|
||||
|
||||
private fun registerTokens(): com.galo.android.data.network.dto.AuthTokensDto {
|
||||
val email = "android-it-${System.currentTimeMillis()}-${UUID.randomUUID().toString().take(6)}@example.com"
|
||||
val payload = """{"email":"$email","password":"secret123","name":"IT"}"""
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
val request = Request.Builder().url("$BASE_URL/api/auth/register").post(payload).build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
check(response.isSuccessful) { "register failed: HTTP ${response.code}" }
|
||||
return GaloJson.decodeFromString<TokenEnvelopeDto>(response.body!!.string()).tokens
|
||||
}
|
||||
}
|
||||
|
||||
fun createAlbum(token: String, title: String): String {
|
||||
val payload = """{"title":"$title"}""".toRequestBody("application/json".toMediaType())
|
||||
val request = Request.Builder()
|
||||
.url("$BASE_URL/api/albums")
|
||||
.header("Authorization", "Bearer $token")
|
||||
.post(payload)
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
check(response.isSuccessful) { "create album failed: HTTP ${response.code}" }
|
||||
return GaloJson.decodeFromString<AlbumEnvelopeDto>(response.body!!.string()).album.id
|
||||
}
|
||||
}
|
||||
|
||||
fun upload(token: String, albumId: String, filename: String, bytes: ByteArray, mime: String) {
|
||||
val multipart = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart("files", filename, bytes.toRequestBody(mime.toMediaType()))
|
||||
.build()
|
||||
val request = Request.Builder()
|
||||
.url("$BASE_URL/api/albums/$albumId/photos")
|
||||
.header("Authorization", "Bearer $token")
|
||||
.post(multipart)
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
check(response.isSuccessful) { "upload failed: HTTP ${response.code}" }
|
||||
GaloJson.decodeFromString<UploadResponseDto>(response.body!!.string())
|
||||
}
|
||||
}
|
||||
|
||||
fun awaitPhotoWithPlayback(token: String, albumId: String, timeoutMs: Long): PhotoDto {
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
val request = Request.Builder()
|
||||
.url("$BASE_URL/api/albums/$albumId/photos?limit=5")
|
||||
.header("Authorization", "Bearer $token")
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
val body = response.body?.string().orEmpty()
|
||||
val photo = runCatching {
|
||||
GaloJson.decodeFromString<com.galo.android.data.network.dto.PhotoListDto>(body)
|
||||
.photos.firstOrNull { !it.playbackUrl.isNullOrBlank() }
|
||||
}.getOrNull()
|
||||
if (photo != null) return photo
|
||||
}
|
||||
Thread.sleep(500)
|
||||
}
|
||||
error("no processed photo with playback_url within ${timeoutMs}ms")
|
||||
}
|
||||
|
||||
fun readAsset(name: String): ByteArray =
|
||||
androidx.test.platform.app.InstrumentationRegistry.getInstrumentation()
|
||||
.context.assets.open(name).use { it.readBytes() }
|
||||
|
||||
fun absolute(path: String): String = BASE_URL + path
|
||||
|
||||
fun jpegBytes(size: Int = 96): ByteArray {
|
||||
val bitmap = android.graphics.Bitmap.createBitmap(size, size, android.graphics.Bitmap.Config.ARGB_8888)
|
||||
bitmap.eraseColor(android.graphics.Color.rgb(20, 140, 90))
|
||||
val out = java.io.ByteArrayOutputStream()
|
||||
bitmap.compress(android.graphics.Bitmap.CompressFormat.JPEG, 85, out)
|
||||
bitmap.recycle()
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
fun writeTempFile(name: String, bytes: ByteArray): android.net.Uri {
|
||||
val file = java.io.File(context().cacheDir, name)
|
||||
file.writeBytes(bytes)
|
||||
return android.net.Uri.fromFile(file)
|
||||
}
|
||||
|
||||
/** Toggles airplane mode via UiAutomation (shell) and waits for the change to settle. */
|
||||
fun setAirplane(enabled: Boolean) {
|
||||
val ui = androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().uiAutomation
|
||||
ui.executeShellCommand(
|
||||
"cmd connectivity airplane-mode " + if (enabled) "enable" else "disable",
|
||||
).close()
|
||||
Thread.sleep(4_000)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package com.galo.android
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.net.Uri
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.galo.android.data.network.GaloJson
|
||||
import com.galo.android.data.network.dto.AlbumEnvelopeDto
|
||||
import com.galo.android.domain.model.UploadStatus
|
||||
import com.galo.android.ui.MainActivity
|
||||
import com.galo.android.ui.album.TAG_ALBUM_GRID
|
||||
import com.galo.android.ui.album.TAG_PHOTO_PREFIX
|
||||
import com.galo.android.ui.albums.TAG_ALBUM_CARD_PREFIX
|
||||
import com.galo.android.ui.albums.TAG_ALBUMS_GRID
|
||||
import com.galo.android.ui.auth.TAG_AUTH_EMAIL
|
||||
import com.galo.android.ui.auth.TAG_AUTH_NAME
|
||||
import com.galo.android.ui.auth.TAG_AUTH_PASSWORD
|
||||
import com.galo.android.ui.auth.TAG_AUTH_SUBMIT
|
||||
import com.galo.android.ui.setup.TAG_SETUP_CONNECT
|
||||
import com.galo.android.ui.setup.TAG_SETUP_INSECURE
|
||||
import com.galo.android.ui.setup.TAG_SETUP_URL
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* End-to-end flow against a galo instance reachable from the emulator at
|
||||
* 10.0.2.2: onboarding → registration → browse → upload → photo appears.
|
||||
*
|
||||
* Start the server with `HTTP_ADDR=:8088 ./bin/galo` and run:
|
||||
* `./gradlew :app:connectedDebugAndroidTest`.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class E2EFlowTest {
|
||||
|
||||
@get:Rule
|
||||
val compose = createAndroidComposeRule<MainActivity>()
|
||||
|
||||
private val baseUrl = "http://10.0.2.2:8088"
|
||||
private val client = OkHttpClient()
|
||||
|
||||
private fun app() = ApplicationProvider.getApplicationContext<GaloApplication>()
|
||||
|
||||
@Test
|
||||
fun onboardingRegisterBrowseUpload() {
|
||||
val stamp = System.currentTimeMillis()
|
||||
val email = "android-e2e-$stamp@example.com"
|
||||
|
||||
// Onboarding: base URL + insecure mode (plain HTTP to the emulator host).
|
||||
compose.onNodeWithTag(TAG_SETUP_URL).performTextInput(baseUrl)
|
||||
compose.onNodeWithTag(TAG_SETUP_INSECURE).performClick()
|
||||
compose.onNodeWithTag(TAG_SETUP_CONNECT).performClick()
|
||||
|
||||
// Registration.
|
||||
compose.waitUntil(TIMEOUT) {
|
||||
compose.onAllNodesWithTag(TAG_AUTH_EMAIL).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
compose.onNodeWithText("Создать аккаунт").performClick()
|
||||
compose.waitUntil(TIMEOUT) {
|
||||
compose.onAllNodesWithTag(TAG_AUTH_NAME).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
compose.onNodeWithTag(TAG_AUTH_NAME).performTextInput("E2E")
|
||||
compose.onNodeWithTag(TAG_AUTH_EMAIL).performTextInput(email)
|
||||
compose.onNodeWithTag(TAG_AUTH_PASSWORD).performTextInput("secret123")
|
||||
compose.onNodeWithTag(TAG_AUTH_SUBMIT).performClick()
|
||||
|
||||
// Empty album list.
|
||||
compose.waitUntil(TIMEOUT) {
|
||||
compose.onAllNodesWithTag(TAG_ALBUMS_GRID).fetchSemanticsNodes().isNotEmpty() ||
|
||||
compose.onAllNodesWithText("Пока нет альбомов").fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
|
||||
// Seed an album (the client has no album-creation UI in v1).
|
||||
val token = app().container.tokenStore.accessToken()
|
||||
?: error("expected a stored access token after registration")
|
||||
val albumId = createAlbum(token, "Android E2E $stamp")
|
||||
runBlocking { app().container.albumRepository.refresh() }
|
||||
|
||||
compose.waitUntil(TIMEOUT) {
|
||||
compose.onAllNodesWithTag(TAG_ALBUM_CARD_PREFIX + albumId).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
compose.onNodeWithTag(TAG_ALBUM_CARD_PREFIX + albumId).performClick()
|
||||
|
||||
compose.waitUntil(TIMEOUT) {
|
||||
compose.onAllNodesWithTag(TAG_ALBUM_GRID).fetchSemanticsNodes().isNotEmpty() ||
|
||||
compose.onAllNodesWithText("В альбоме пока нет медиа").fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
|
||||
// Upload a generated JPEG through the real WorkManager queue.
|
||||
val uri = writeJpeg()
|
||||
runBlocking { app().container.uploadRepository.enqueue(albumId, listOf(uri), wifiOnly = false) }
|
||||
|
||||
val photo = runBlocking {
|
||||
val deadline = System.currentTimeMillis() + UPLOAD_TIMEOUT
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
val photos = app().container.photoRepository.observePhotos(albumId).first()
|
||||
if (photos.isNotEmpty()) return@runBlocking photos.first()
|
||||
val job = app().container.uploadRepository.observeJobs().first().firstOrNull()
|
||||
if (job?.status == UploadStatus.ERROR) {
|
||||
error("upload job failed: ${job.error}")
|
||||
}
|
||||
kotlinx.coroutines.delay(500)
|
||||
}
|
||||
val jobs = app().container.uploadRepository.observeJobs().first()
|
||||
error("upload timed out; jobs=${jobs.map { it.status to it.error }}")
|
||||
}
|
||||
|
||||
compose.waitUntil(TIMEOUT) {
|
||||
compose.onAllNodesWithTag(TAG_PHOTO_PREFIX + photo.id).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
compose.onNodeWithTag(TAG_PHOTO_PREFIX + photo.id).assertIsDisplayed()
|
||||
}
|
||||
|
||||
private fun createAlbum(token: String, title: String): String {
|
||||
val body = """{"title":"$title"}""".toRequestBody("application/json".toMediaType())
|
||||
val request = Request.Builder()
|
||||
.url("$baseUrl/api/albums")
|
||||
.header("Authorization", "Bearer $token")
|
||||
.post(body)
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
check(response.isSuccessful) { "create album failed: HTTP ${response.code}" }
|
||||
val text = response.body?.string().orEmpty()
|
||||
return GaloJson.decodeFromString<AlbumEnvelopeDto>(text).album.id
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeJpeg(): Uri {
|
||||
val file = File(app().cacheDir, "e2e-upload.jpg")
|
||||
val bitmap = Bitmap.createBitmap(96, 96, Bitmap.Config.ARGB_8888).apply {
|
||||
eraseColor(Color.rgb(30, 120, 200))
|
||||
}
|
||||
file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.JPEG, 85, it) }
|
||||
bitmap.recycle()
|
||||
return Uri.fromFile(file)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TIMEOUT = 20_000L
|
||||
const val UPLOAD_TIMEOUT = 60_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.galo.android
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.galo.android.data.upload.HeicConverter
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Device checks for task 6.3:
|
||||
* - a real HEIC is decoded and re-encoded as JPEG with the same dimensions and a
|
||||
* `.jpg` filename;
|
||||
* - the EXIF copy step preserves capture date and geolocation (exercised with a
|
||||
* source whose EXIF we control, since AndroidX ExifInterface can read but not
|
||||
* write HEIC attributes).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class HeicConversionTest {
|
||||
|
||||
private fun app() = ApplicationProvider.getApplicationContext<GaloApplication>()
|
||||
|
||||
@Test
|
||||
fun decodesRealHeicToJpeg() {
|
||||
val source = File(app().cacheDir, "source.heic").apply { writeBytes(E2E.readAsset("sample.heic")) }
|
||||
val sourceBounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeFile(source.absolutePath, sourceBounds)
|
||||
assertTrue("sample HEIC must decode on-device", sourceBounds.outWidth > 0)
|
||||
|
||||
val converted = runBlocking { HeicConverter.convert(app(), Uri.fromFile(source), "autumn.heic") }
|
||||
|
||||
assertEquals("autumn.jpg", converted.filename)
|
||||
assertEquals("image/jpeg", converted.mimeType)
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeFile(converted.file.absolutePath, bounds)
|
||||
assertEquals("image/jpeg", bounds.outMimeType)
|
||||
assertEquals(sourceBounds.outWidth, bounds.outWidth)
|
||||
assertEquals(sourceBounds.outHeight, bounds.outHeight)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun preservesExifDateAndLocationThroughConversion() {
|
||||
val source = File(app().cacheDir, "with-exif.jpg")
|
||||
Bitmap.createBitmap(64, 64, Bitmap.Config.ARGB_8888).apply {
|
||||
eraseColor(android.graphics.Color.YELLOW)
|
||||
source.outputStream().use { compress(Bitmap.CompressFormat.JPEG, 90, it) }
|
||||
recycle()
|
||||
}
|
||||
val sourceExif = ExifInterface(source.absolutePath)
|
||||
sourceExif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, DATE)
|
||||
sourceExif.setLatLong(LAT, LONG)
|
||||
sourceExif.saveAttributes()
|
||||
|
||||
val converted = runBlocking {
|
||||
HeicConverter.convert(app(), Uri.fromFile(source), "IMG_20240715.heic")
|
||||
}
|
||||
|
||||
assertEquals("IMG_20240715.jpg", converted.filename)
|
||||
val outExif = ExifInterface(converted.file.absolutePath)
|
||||
assertEquals("capture date must be preserved", DATE, outExif.getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL))
|
||||
val latLong = outExif.latLong
|
||||
assertNotNull("geolocation must be preserved", latLong)
|
||||
assertTrue(Math.abs(latLong!![0] - LAT) < 0.01)
|
||||
assertTrue(Math.abs(latLong[1] - LONG) < 0.01)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DATE = "2024:07:15 10:30:00"
|
||||
const val LAT = 55.7558
|
||||
const val LONG = 37.6173
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.galo.android
|
||||
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.galo.android.ui.viewer.PanoramaView
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Device check for task 4.3: a drag on the GL panorama view rotates the camera
|
||||
* (same yaw/pitch mapping as the web viewer).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PanoramaDragTest {
|
||||
|
||||
@Test
|
||||
fun dragRotatesCamera() {
|
||||
val app = ApplicationProvider.getApplicationContext<GaloApplication>()
|
||||
val file = File(app.cacheDir, "equirect.jpg").apply { writeBytes(E2E.readAsset("equirect.jpg")) }
|
||||
val url = file.toURI().toURL().toString()
|
||||
|
||||
lateinit var view: PanoramaView
|
||||
InstrumentationRegistry.getInstrumentation().runOnMainSync {
|
||||
val v = PanoramaView(app)
|
||||
v.measure(
|
||||
View.MeasureSpec.makeMeasureSpec(1080, View.MeasureSpec.EXACTLY),
|
||||
View.MeasureSpec.makeMeasureSpec(1920, View.MeasureSpec.EXACTLY),
|
||||
)
|
||||
v.layout(0, 0, 1080, 1920)
|
||||
v.load(url)
|
||||
|
||||
val down = MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 100f, 200f, 0)
|
||||
val move = MotionEvent.obtain(0, 16, MotionEvent.ACTION_MOVE, 500f, 200f, 0)
|
||||
v.dispatchTouchEvent(down)
|
||||
v.dispatchTouchEvent(move)
|
||||
down.recycle()
|
||||
move.recycle()
|
||||
view = v
|
||||
}
|
||||
|
||||
assertNotEquals("horizontal drag should change yaw", 0f, view.currentYawForTest())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.galo.android
|
||||
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.galo.android.di.AppContainer
|
||||
import com.galo.android.domain.model.Photo
|
||||
import com.galo.android.domain.model.UploadStatus
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* On-device checks for the upload pipeline and offline cache:
|
||||
* 6.4 (upload completes without a visible UI), 6.5 (queued work waits for
|
||||
* network and resumes) and 3.5 (album opens from cache while offline).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class UploadPipelineTest {
|
||||
|
||||
private fun container(): AppContainer =
|
||||
ApplicationProvider.getApplicationContext<GaloApplication>().container
|
||||
|
||||
private fun awaitPhoto(container: AppContainer, albumId: String, timeoutMs: Long): Photo {
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
val photos = runBlocking { container.photoRepository.observePhotos(albumId).first() }
|
||||
if (photos.isNotEmpty()) return photos.first()
|
||||
val job = runBlocking { container.uploadRepository.observeJobs().first().firstOrNull() }
|
||||
if (job?.status == UploadStatus.ERROR) error("upload job failed: ${job.error}")
|
||||
Thread.sleep(500)
|
||||
}
|
||||
error("upload did not complete within ${timeoutMs}ms")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uploadCompletesWithoutVisibleUi() {
|
||||
val container = container()
|
||||
E2E.configure(container)
|
||||
val token = E2E.registerAuthenticated(container)
|
||||
val album = E2E.createAlbum(token, "bg-${System.currentTimeMillis()}")
|
||||
val uri = E2E.writeTempFile("bg.jpg", E2E.jpegBytes())
|
||||
|
||||
runBlocking { container.uploadRepository.enqueue(album, listOf(uri), wifiOnly = false) }
|
||||
|
||||
val photo = awaitPhoto(container, album, 60_000)
|
||||
assertNotNull(photo.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun queuedJobWaitsForNetworkThenResumes() {
|
||||
val container = container()
|
||||
E2E.configure(container)
|
||||
val token = E2E.registerAuthenticated(container)
|
||||
val album = E2E.createAlbum(token, "net-${System.currentTimeMillis()}")
|
||||
|
||||
E2E.setAirplane(true)
|
||||
try {
|
||||
val uri = E2E.writeTempFile("net.jpg", E2E.jpegBytes())
|
||||
runBlocking { container.uploadRepository.enqueue(album, listOf(uri), wifiOnly = false) }
|
||||
Thread.sleep(6_000)
|
||||
val status = runBlocking { container.uploadRepository.observeJobs().first() }.first().status
|
||||
assertTrue("job should stay queued without network", status == UploadStatus.QUEUED)
|
||||
} finally {
|
||||
E2E.setAirplane(false)
|
||||
}
|
||||
|
||||
val photo = awaitPhoto(container, album, 60_000)
|
||||
assertNotNull(photo.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun offlineBrowsingServesCache() {
|
||||
val container = container()
|
||||
E2E.configure(container)
|
||||
val token = E2E.registerAuthenticated(container)
|
||||
val album = E2E.createAlbum(token, "cache-${System.currentTimeMillis()}")
|
||||
E2E.upload(token, album, "cached.jpg", E2E.jpegBytes(), "image/jpeg")
|
||||
|
||||
// Warm the cache while online.
|
||||
runBlocking { container.albumRepository.refresh() }
|
||||
runBlocking { container.photoRepository.loadNextPage(album, null) }
|
||||
assertTrue(runBlocking { container.albumRepository.observeAlbums().first().any { it.id == album } })
|
||||
assertTrue(runBlocking { container.photoRepository.observePhotos(album).first().isNotEmpty() })
|
||||
|
||||
E2E.setAirplane(true)
|
||||
try {
|
||||
assertFalse("refresh must fail offline", runBlocking { container.albumRepository.refresh() })
|
||||
val page = runBlocking { container.photoRepository.loadNextPage(album, null) }
|
||||
assertFalse("page fetch must report offline", page.online)
|
||||
assertTrue(
|
||||
"cached photos must remain available offline",
|
||||
runBlocking { container.photoRepository.observePhotos(album).first().isNotEmpty() },
|
||||
)
|
||||
} finally {
|
||||
E2E.setAirplane(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.galo.android
|
||||
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Device check for task 4.2: Media3 prepares and seeks a server-served video,
|
||||
* exercising HTTP Range/seek end to end (server responds 206 Partial Content).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class VideoSeekTest {
|
||||
|
||||
@Test
|
||||
fun playsAndSeeksServerVideo() {
|
||||
val app = ApplicationProvider.getApplicationContext<GaloApplication>()
|
||||
E2E.configure(app.container)
|
||||
val token = E2E.register()
|
||||
val album = E2E.createAlbum(token, "video-${System.currentTimeMillis()}")
|
||||
E2E.upload(token, album, "test.mp4", E2E.readAsset("test.mp4"), "video/mp4")
|
||||
|
||||
val photo = E2E.awaitPhotoWithPlayback(token, album, 60_000)
|
||||
val url = E2E.absolute(photo.playbackUrl!!)
|
||||
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
val ready = CountDownLatch(1)
|
||||
var player: ExoPlayer? = null
|
||||
|
||||
instrumentation.runOnMainSync {
|
||||
val p = ExoPlayer.Builder(app).build()
|
||||
player = p
|
||||
p.addListener(object : Player.Listener {
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
if (playbackState == Player.STATE_READY) ready.countDown()
|
||||
}
|
||||
})
|
||||
p.setMediaItem(MediaItem.fromUri(url))
|
||||
p.prepare()
|
||||
}
|
||||
|
||||
assertTrue("player did not reach STATE_READY", ready.await(30, TimeUnit.SECONDS))
|
||||
|
||||
instrumentation.runOnMainSync { player!!.seekTo(1_200) }
|
||||
|
||||
var position = 0L
|
||||
val deadline = System.currentTimeMillis() + 10_000
|
||||
while (System.currentTimeMillis() < deadline && position < 1_000) {
|
||||
instrumentation.runOnMainSync { position = player!!.currentPosition }
|
||||
Thread.sleep(150)
|
||||
}
|
||||
|
||||
instrumentation.runOnMainSync { player!!.release() }
|
||||
assertTrue("seek did not reach target (position=$position)", position >= 1_000)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<!--
|
||||
Cleartext HTTP is gated in the application layer (see HttpGateway): the
|
||||
platform permits it, but the client refuses plain HTTP until the user
|
||||
explicitly enables the insecure LAN mode.
|
||||
-->
|
||||
<application
|
||||
android:name=".GaloApplication"
|
||||
android:allowBackup="false"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:theme="@style/Theme.Galo"
|
||||
tools:targetApi="35">
|
||||
|
||||
<activity
|
||||
android:name=".ui.MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.Galo">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
android:exported="false"
|
||||
tools:node="merge" />
|
||||
|
||||
<service
|
||||
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
tools:node="merge" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.galo.android
|
||||
|
||||
import android.app.Application
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import com.galo.android.di.AppContainer
|
||||
|
||||
class GaloApplication : Application() {
|
||||
|
||||
lateinit var container: AppContainer
|
||||
private set
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
container = AppContainer(this).also { it.start() }
|
||||
createNotificationChannels()
|
||||
}
|
||||
|
||||
private fun createNotificationChannels() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
val channel = NotificationChannel(
|
||||
UPLOAD_CHANNEL_ID,
|
||||
getString(R.string.upload_channel_name),
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply {
|
||||
description = getString(R.string.upload_channel_description)
|
||||
}
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val UPLOAD_CHANNEL_ID = "galo-uploads"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.galo.android.di
|
||||
|
||||
import android.content.Context
|
||||
import com.galo.android.data.auth.SecureTokenStore
|
||||
import com.galo.android.data.auth.TokenStore
|
||||
import com.galo.android.data.db.GaloDatabase
|
||||
import com.galo.android.data.network.AlbumSseClient
|
||||
import com.galo.android.data.network.ApiClient
|
||||
import com.galo.android.data.network.GaloApi
|
||||
import com.galo.android.data.network.HealthChecker
|
||||
import com.galo.android.data.prefs.SettingsRepository
|
||||
import com.galo.android.data.prefs.SettingsState
|
||||
import com.galo.android.data.repo.AlbumRepository
|
||||
import com.galo.android.data.repo.ApiAlbumRemoteSource
|
||||
import com.galo.android.data.repo.ApiPhotoRemoteSource
|
||||
import com.galo.android.data.repo.PhotoRepository
|
||||
import com.galo.android.data.repo.RoomAlbumLocalSource
|
||||
import com.galo.android.data.repo.RoomPhotoLocalSource
|
||||
import com.galo.android.data.upload.UploadRepository
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Manual dependency container. Created once by [com.galo.android.GaloApplication]
|
||||
* and shared through an application-scoped ViewModel factory.
|
||||
*/
|
||||
class AppContainer(context: Context) {
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
|
||||
val settingsRepository = SettingsRepository(appContext)
|
||||
val tokenStore: TokenStore = SecureTokenStore(appContext)
|
||||
|
||||
@Volatile
|
||||
private var settingsState: SettingsState? = null
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
val apiClient = ApiClient(
|
||||
tokenStore = tokenStore,
|
||||
baseUrlProvider = { settingsState?.baseUrl },
|
||||
insecureAllowedProvider = { settingsState?.insecureAllowed == true },
|
||||
)
|
||||
val api = GaloApi(apiClient)
|
||||
val healthChecker = HealthChecker(apiClient.client)
|
||||
val albumSseClient = AlbumSseClient(api, apiClient.sseClient)
|
||||
|
||||
private val database: GaloDatabase = GaloDatabase.create(appContext)
|
||||
|
||||
val albumRepository = AlbumRepository(
|
||||
remote = ApiAlbumRemoteSource(api),
|
||||
local = RoomAlbumLocalSource(database.albumDao()),
|
||||
resolver = apiClient.objectUrls,
|
||||
)
|
||||
|
||||
val photoRepository = PhotoRepository(
|
||||
remote = ApiPhotoRemoteSource(api),
|
||||
local = RoomPhotoLocalSource(database.photoDao()),
|
||||
resolver = apiClient.objectUrls,
|
||||
)
|
||||
|
||||
val uploadRepository = UploadRepository(appContext, database.uploadDao())
|
||||
|
||||
fun start() {
|
||||
scope.launch {
|
||||
settingsRepository.flow.collect { settingsState = it }
|
||||
}
|
||||
scope.launch {
|
||||
runCatching { uploadRepository.requeuePending() }
|
||||
}
|
||||
}
|
||||
|
||||
fun currentSettings(): SettingsState? = settingsState
|
||||
|
||||
fun baseUrl(): String? = settingsState?.baseUrl
|
||||
|
||||
fun insecureAllowed(): Boolean = settingsState?.insecureAllowed == true
|
||||
|
||||
/** Clears the local session immediately and best-effort revokes it server-side. */
|
||||
fun logout() {
|
||||
val refreshToken = tokenStore.refreshToken()
|
||||
tokenStore.clear()
|
||||
if (refreshToken.isNullOrBlank()) return
|
||||
scope.launch {
|
||||
runCatching { api.logout(refreshToken) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.galo.android.domain
|
||||
|
||||
import com.galo.android.domain.model.InsecureConnectionBlockedException
|
||||
|
||||
/**
|
||||
* Application-level cleartext gate.
|
||||
*
|
||||
* Android's manifest permits cleartext so the user can enable LAN mode at
|
||||
* runtime, but the client itself refuses `http://` URLs unless insecure mode is
|
||||
* explicitly switched on.
|
||||
*/
|
||||
object ConnectionPolicy {
|
||||
|
||||
fun isInsecure(url: String): Boolean =
|
||||
url.trim().lowercase().startsWith("http://")
|
||||
|
||||
fun isSecure(url: String): Boolean =
|
||||
url.trim().lowercase().startsWith("https://")
|
||||
|
||||
/**
|
||||
* @throws InsecureConnectionBlockedException when [url] is plain HTTP and
|
||||
* [insecureAllowed] is false.
|
||||
*/
|
||||
fun check(url: String, insecureAllowed: Boolean) {
|
||||
if (isInsecure(url) && !insecureAllowed) {
|
||||
throw InsecureConnectionBlockedException()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.galo.android.domain.model
|
||||
|
||||
/** Normalised server error from the `{error:{code,message}}` envelope. */
|
||||
data class ApiError(
|
||||
val code: String,
|
||||
val message: String,
|
||||
val status: Int,
|
||||
)
|
||||
|
||||
class ApiException(val error: ApiError) : Exception(error.message) {
|
||||
constructor(status: Int, code: String, message: String) :
|
||||
this(ApiError(code, message, status))
|
||||
}
|
||||
|
||||
/** Raised when the user points at plain HTTP without enabling insecure mode. */
|
||||
class InsecureConnectionBlockedException :
|
||||
IllegalStateException("plain HTTP is disabled; enable insecure LAN mode")
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.galo.android.domain.model
|
||||
|
||||
/**
|
||||
* User-selected connection to a self-hosted galo instance.
|
||||
*
|
||||
* [insecureAllowed] mirrors the explicit "insecure LAN connection" toggle: plain
|
||||
* HTTP stays blocked until the user opts in.
|
||||
*/
|
||||
data class ServerConfig(
|
||||
val baseUrl: String,
|
||||
val insecureAllowed: Boolean,
|
||||
)
|
||||
|
||||
data class Session(
|
||||
val accessToken: String,
|
||||
val refreshToken: String,
|
||||
)
|
||||
|
||||
data class Album(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val description: String,
|
||||
val ownerId: String,
|
||||
val coverPhotoId: String?,
|
||||
val coverThumbUrl: String?,
|
||||
val coverMediumUrl: String?,
|
||||
val photosCount: Int,
|
||||
val membersCount: Int,
|
||||
val updatedAt: String?,
|
||||
)
|
||||
|
||||
enum class MediaKind {
|
||||
IMAGE,
|
||||
VIDEO,
|
||||
PANORAMA,
|
||||
OTHER,
|
||||
;
|
||||
|
||||
companion object {
|
||||
const val PANO_TAG = "pano360"
|
||||
|
||||
fun from(raw: String?): MediaKind = when (raw?.lowercase()) {
|
||||
"image", "photo" -> IMAGE
|
||||
"video" -> VIDEO
|
||||
"panorama", "pano" -> PANORAMA
|
||||
else -> OTHER
|
||||
}
|
||||
|
||||
fun isPanoTag(name: String): Boolean =
|
||||
name.trim().equals(PANO_TAG, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
||||
data class Photo(
|
||||
val id: String,
|
||||
val albumId: String,
|
||||
val originalFilename: String,
|
||||
val kind: MediaKind,
|
||||
val mimeType: String?,
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
val durationMs: Long,
|
||||
val size: Long,
|
||||
val takenAt: String?,
|
||||
val caption: String,
|
||||
val isPanorama: Boolean,
|
||||
val thumbUrl: String?,
|
||||
val mediumUrl: String?,
|
||||
val previewUrl: String?,
|
||||
val playbackUrl: String?,
|
||||
val originalUrl: String?,
|
||||
val downloadUrl: String?,
|
||||
val createdAt: String?,
|
||||
)
|
||||
|
||||
data class Page<T>(
|
||||
val items: List<T>,
|
||||
val nextCursor: String?,
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.galo.android.domain.model
|
||||
|
||||
enum class UploadStatus {
|
||||
QUEUED,
|
||||
RUNNING,
|
||||
SUCCESS,
|
||||
ERROR,
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun from(raw: String): UploadStatus =
|
||||
entries.firstOrNull { it.name == raw } ?: QUEUED
|
||||
}
|
||||
}
|
||||
|
||||
data class UploadJob(
|
||||
val id: String,
|
||||
val albumId: String,
|
||||
val filename: String,
|
||||
val mimeType: String?,
|
||||
val size: Long,
|
||||
val status: UploadStatus,
|
||||
val progress: Int,
|
||||
val error: String?,
|
||||
val attempts: Int,
|
||||
val wifiOnly: Boolean,
|
||||
val createdAt: Long,
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.galo.android.domain.viewer
|
||||
|
||||
/**
|
||||
* Camera state for the 360 panorama viewer, mirroring the web `PanoViewer`
|
||||
* math: horizontal drag rotates yaw, vertical drag changes pitch (clamped to
|
||||
* ±89°), and the mapping constant is `fov / viewportHeight`. Zoom scales the
|
||||
* field of view within [MIN_FOV, MAX_FOV].
|
||||
*/
|
||||
class PanoramaCamera(
|
||||
yaw: Float = 0f,
|
||||
pitch: Float = 0f,
|
||||
fov: Float = DEFAULT_FOV,
|
||||
) {
|
||||
|
||||
var yaw: Float = yaw
|
||||
private set
|
||||
|
||||
var pitch: Float = pitch
|
||||
private set
|
||||
|
||||
var fov: Float = fov.coerceIn(MIN_FOV, MAX_FOV)
|
||||
private set
|
||||
|
||||
fun drag(dx: Float, dy: Float, scale: Float) {
|
||||
yaw += dx * scale
|
||||
pitch = (pitch + dy * scale).coerceIn(-PITCH_LIMIT, PITCH_LIMIT)
|
||||
}
|
||||
|
||||
/** [factor] > 1 widens the view (zooms out). */
|
||||
fun zoom(factor: Float) {
|
||||
fov = (fov * factor).coerceIn(MIN_FOV, MAX_FOV)
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
yaw = 0f
|
||||
pitch = 0f
|
||||
fov = DEFAULT_FOV
|
||||
}
|
||||
|
||||
companion object {
|
||||
val DEFAULT_FOV = (75 * Math.PI / 180).toFloat()
|
||||
val MIN_FOV = (30 * Math.PI / 180).toFloat()
|
||||
val MAX_FOV = (100 * Math.PI / 180).toFloat()
|
||||
val PITCH_LIMIT = (89 * Math.PI / 180).toFloat()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package com.galo.android.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.galo.android.GaloApplication
|
||||
import com.galo.android.data.prefs.SettingsState
|
||||
import com.galo.android.ui.album.AlbumScreen
|
||||
import com.galo.android.ui.albums.AlbumsScreen
|
||||
import com.galo.android.ui.auth.AuthScreen
|
||||
import com.galo.android.ui.setup.SetupScreen
|
||||
import com.galo.android.ui.upload.UploadScreen
|
||||
import com.galo.android.ui.viewer.ViewerScreen
|
||||
|
||||
object Routes {
|
||||
const val SETUP = "setup"
|
||||
const val AUTH = "auth"
|
||||
const val ALBUMS = "albums"
|
||||
const val UPLOADS = "uploads"
|
||||
const val ALBUM = "album/{albumId}"
|
||||
const val VIEWER = "album/{albumId}/viewer?index={index}"
|
||||
|
||||
fun album(albumId: String) = "album/$albumId"
|
||||
|
||||
fun viewer(albumId: String, index: Int) = "album/$albumId/viewer?index=$index"
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GaloNavHost(app: GaloApplication) {
|
||||
val container = app.container
|
||||
val factory = GaloViewModelFactory(app)
|
||||
val settings: SettingsState? by container.settingsRepository.flow
|
||||
.collectAsStateWithLifecycle(initialValue = container.currentSettings())
|
||||
|
||||
val current = settings
|
||||
if (current == null) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val navController = rememberNavController()
|
||||
val start = when {
|
||||
current.baseUrl.isBlank() -> Routes.SETUP
|
||||
container.tokenStore.accessToken().isNullOrBlank() -> Routes.AUTH
|
||||
else -> Routes.ALBUMS
|
||||
}
|
||||
|
||||
NavHost(navController = navController, startDestination = start) {
|
||||
composable(Routes.SETUP) {
|
||||
SetupScreen(factory = factory) {
|
||||
val next = if (container.tokenStore.accessToken().isNullOrBlank()) {
|
||||
Routes.AUTH
|
||||
} else {
|
||||
Routes.ALBUMS
|
||||
}
|
||||
navController.navigate(next) {
|
||||
popUpTo(Routes.SETUP) { inclusive = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
composable(Routes.AUTH) {
|
||||
AuthScreen(factory = factory) {
|
||||
navController.navigate(Routes.ALBUMS) {
|
||||
popUpTo(Routes.AUTH) { inclusive = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
composable(Routes.ALBUMS) {
|
||||
AlbumsScreen(
|
||||
factory = factory,
|
||||
onOpenAlbum = { id -> navController.navigate(Routes.album(id)) },
|
||||
onOpenUploads = { navController.navigate(Routes.UPLOADS) },
|
||||
onLoggedOut = {
|
||||
navController.navigate(Routes.AUTH) {
|
||||
popUpTo(Routes.ALBUMS) { inclusive = true }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(Routes.UPLOADS) {
|
||||
UploadScreen(
|
||||
factory = factory,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Routes.ALBUM,
|
||||
arguments = listOf(navArgument("albumId") { type = NavType.StringType }),
|
||||
) { entry ->
|
||||
val albumId = entry.arguments?.getString("albumId").orEmpty()
|
||||
AlbumScreen(
|
||||
app = app,
|
||||
albumId = albumId,
|
||||
onOpenViewer = { index ->
|
||||
navController.navigate(Routes.viewer(albumId, index))
|
||||
},
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Routes.VIEWER,
|
||||
arguments = listOf(
|
||||
navArgument("albumId") { type = NavType.StringType },
|
||||
navArgument("index") {
|
||||
type = NavType.IntType
|
||||
defaultValue = 0
|
||||
},
|
||||
),
|
||||
) { entry ->
|
||||
val albumId = entry.arguments?.getString("albumId").orEmpty()
|
||||
val index = entry.arguments?.getInt("index") ?: 0
|
||||
ViewerScreen(
|
||||
app = app,
|
||||
albumId = albumId,
|
||||
initialIndex = index,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.galo.android.ui
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import com.galo.android.GaloApplication
|
||||
import com.galo.android.ui.albums.AlbumsViewModel
|
||||
import com.galo.android.ui.auth.AuthViewModel
|
||||
import com.galo.android.ui.setup.SetupViewModel
|
||||
import com.galo.android.ui.upload.UploadViewModel
|
||||
|
||||
/** Resolves ViewModels against the application's [com.galo.android.di.AppContainer]. */
|
||||
class GaloViewModelFactory(private val app: GaloApplication) : ViewModelProvider.Factory {
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
val container = app.container
|
||||
val viewModel: ViewModel = when {
|
||||
modelClass.isAssignableFrom(SetupViewModel::class.java) -> SetupViewModel(container)
|
||||
modelClass.isAssignableFrom(AuthViewModel::class.java) -> AuthViewModel(container)
|
||||
modelClass.isAssignableFrom(AlbumsViewModel::class.java) -> AlbumsViewModel(container)
|
||||
modelClass.isAssignableFrom(UploadViewModel::class.java) -> UploadViewModel(container)
|
||||
else -> throw IllegalArgumentException("Unknown ViewModel: ${modelClass.name}")
|
||||
}
|
||||
return viewModel as T
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.galo.android.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.galo.android.GaloApplication
|
||||
import com.galo.android.ui.theme.GaloTheme
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
val app = application as GaloApplication
|
||||
setContent {
|
||||
GaloTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
) {
|
||||
GaloNavHost(app = app)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package com.galo.android.ui.album
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.galo.android.GaloApplication
|
||||
import com.galo.android.domain.model.MediaKind
|
||||
import com.galo.android.domain.model.Photo
|
||||
import com.galo.android.ui.common.GaloAsyncImage
|
||||
|
||||
const val TAG_ALBUM_GRID = "album_grid"
|
||||
const val TAG_ALBUM_OFFLINE = "album_offline"
|
||||
const val TAG_PHOTO_PREFIX = "photo_"
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AlbumScreen(
|
||||
app: GaloApplication,
|
||||
albumId: String,
|
||||
onOpenViewer: (Int) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val factory = viewModelFactory {
|
||||
initializer { AlbumViewModel(app.container, albumId) }
|
||||
}
|
||||
val viewModel: AlbumViewModel = viewModel(factory = factory)
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(state.album?.title ?: "Альбом") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Назад")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = viewModel::refresh) {
|
||||
Icon(Icons.Filled.Refresh, contentDescription = "Обновить")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(modifier = Modifier.fillMaxSize().padding(padding)) {
|
||||
if (state.offline) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
modifier = Modifier.fillMaxWidth().testTag(TAG_ALBUM_OFFLINE),
|
||||
) {
|
||||
Text(
|
||||
text = state.error ?: "Офлайн-режим",
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
when {
|
||||
state.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
|
||||
state.photos.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("В альбоме пока нет медиа")
|
||||
}
|
||||
|
||||
else -> LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(3),
|
||||
contentPadding = PaddingValues(2.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
modifier = Modifier.fillMaxSize().testTag(TAG_ALBUM_GRID),
|
||||
) {
|
||||
itemsIndexed(state.photos, key = { _, p -> p.id }) { index, photo ->
|
||||
PhotoCell(photo = photo, onClick = { onOpenViewer(index) })
|
||||
}
|
||||
if (!state.endReached) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
LaunchedEffect(state.photos.size) { viewModel.loadNextPage() }
|
||||
Box(
|
||||
Modifier.fillMaxWidth().padding(16.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (state.loadingMore) CircularProgressIndicator(Modifier.size(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PhotoCell(photo: Photo, onClick: () -> Unit) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.aspectRatio(1f)
|
||||
.testTag(TAG_PHOTO_PREFIX + photo.id)
|
||||
.clickable(onClick = onClick),
|
||||
) {
|
||||
GaloAsyncImage(
|
||||
url = photo.thumbUrl ?: photo.mediumUrl ?: photo.previewUrl,
|
||||
contentDescription = photo.originalFilename,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
if (photo.kind == MediaKind.VIDEO) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.PlayArrow,
|
||||
contentDescription = "Видео",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package com.galo.android.ui.album
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.galo.android.data.network.AlbumEvent
|
||||
import com.galo.android.di.AppContainer
|
||||
import com.galo.android.domain.model.Album
|
||||
import com.galo.android.domain.model.Photo
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.Instant
|
||||
|
||||
class AlbumViewModel(
|
||||
private val container: AppContainer,
|
||||
private val albumId: String,
|
||||
) : ViewModel() {
|
||||
|
||||
data class UiState(
|
||||
val album: Album? = null,
|
||||
val photos: List<Photo> = emptyList(),
|
||||
val loading: Boolean = true,
|
||||
val loadingMore: Boolean = false,
|
||||
val offline: Boolean = false,
|
||||
val endReached: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
private data class Status(
|
||||
val loaded: Boolean = false,
|
||||
val loadingMore: Boolean = false,
|
||||
val offline: Boolean = false,
|
||||
val endReached: Boolean = false,
|
||||
val error: String? = null,
|
||||
val nextCursor: String? = null,
|
||||
)
|
||||
|
||||
private val status = MutableStateFlow(Status())
|
||||
|
||||
/** Watermark for `?since=` backfill; advanced as events are applied. */
|
||||
private var lastSync: String = Instant.now().toString()
|
||||
|
||||
val state: StateFlow<UiState> = combine(
|
||||
container.albumRepository.observeAlbum(albumId),
|
||||
container.photoRepository.observePhotos(albumId),
|
||||
status,
|
||||
) { album, photos, s ->
|
||||
UiState(
|
||||
album = album,
|
||||
photos = photos,
|
||||
loading = !s.loaded,
|
||||
loadingMore = s.loadingMore,
|
||||
offline = s.offline,
|
||||
endReached = s.endReached,
|
||||
error = s.error,
|
||||
)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UiState())
|
||||
|
||||
init {
|
||||
viewModelScope.launch { container.albumRepository.loadAlbum(albumId) }
|
||||
loadNextPage()
|
||||
observeEvents()
|
||||
}
|
||||
|
||||
/** Live updates while the screen is alive; reconnect backfills missed changes. */
|
||||
private fun observeEvents() {
|
||||
viewModelScope.launch {
|
||||
container.albumSseClient.events(albumId).collect { event ->
|
||||
when (event) {
|
||||
is AlbumEvent.PhotoUpserted -> {
|
||||
container.photoRepository.upsert(event.photo)
|
||||
lastSync = Instant.now().toString()
|
||||
}
|
||||
|
||||
is AlbumEvent.PhotoDeleted -> {
|
||||
container.photoRepository.remove(event.photoId)
|
||||
lastSync = Instant.now().toString()
|
||||
}
|
||||
|
||||
is AlbumEvent.SocialChanged -> {
|
||||
container.photoRepository.refreshPhoto(event.photoId)
|
||||
}
|
||||
|
||||
AlbumEvent.AlbumChanged -> {
|
||||
container.albumRepository.loadAlbum(albumId)
|
||||
lastSync = Instant.now().toString()
|
||||
}
|
||||
|
||||
AlbumEvent.Reconnected -> {
|
||||
container.photoRepository.loadSince(albumId, lastSync)
|
||||
lastSync = Instant.now().toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadNextPage() {
|
||||
val current = status.value
|
||||
if (current.loadingMore || (current.loaded && current.endReached)) return
|
||||
status.update { it.copy(loadingMore = it.loaded) }
|
||||
viewModelScope.launch {
|
||||
val result = container.photoRepository.loadNextPage(albumId, current.nextCursor)
|
||||
status.update {
|
||||
it.copy(
|
||||
loaded = true,
|
||||
loadingMore = false,
|
||||
offline = !result.online,
|
||||
endReached = result.online && result.nextCursor == null,
|
||||
nextCursor = if (result.online) result.nextCursor else it.nextCursor,
|
||||
error = if (result.online) null else "Нет сети — показаны кэшированные данные",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
status.update { it.copy(loaded = false, endReached = false, nextCursor = null) }
|
||||
viewModelScope.launch { container.albumRepository.loadAlbum(albumId) }
|
||||
loadNextPage()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package com.galo.android.ui.albums
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Logout
|
||||
import androidx.compose.material.icons.filled.CloudUpload
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.galo.android.domain.model.Album
|
||||
import com.galo.android.ui.GaloViewModelFactory
|
||||
import com.galo.android.ui.common.GaloAsyncImage
|
||||
|
||||
const val TAG_ALBUMS_GRID = "albums_grid"
|
||||
const val TAG_ALBUMS_OFFLINE = "albums_offline"
|
||||
const val TAG_ALBUM_CARD_PREFIX = "album_card_"
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AlbumsScreen(
|
||||
factory: GaloViewModelFactory,
|
||||
onOpenAlbum: (String) -> Unit,
|
||||
onOpenUploads: () -> Unit,
|
||||
onLoggedOut: () -> Unit,
|
||||
) {
|
||||
val viewModel: AlbumsViewModel = viewModel(factory = factory)
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
AlbumsContent(
|
||||
state = state,
|
||||
onOpenAlbum = onOpenAlbum,
|
||||
onOpenUploads = onOpenUploads,
|
||||
onRefresh = viewModel::refresh,
|
||||
onLogout = { viewModel.logout(onLoggedOut) },
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AlbumsContent(
|
||||
state: AlbumsViewModel.UiState,
|
||||
onOpenAlbum: (String) -> Unit,
|
||||
onOpenUploads: () -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Альбомы") },
|
||||
actions = {
|
||||
IconButton(onClick = onOpenUploads) {
|
||||
Icon(Icons.Filled.CloudUpload, contentDescription = "Загрузки")
|
||||
}
|
||||
IconButton(onClick = onRefresh) {
|
||||
Icon(Icons.Filled.Refresh, contentDescription = "Обновить")
|
||||
}
|
||||
IconButton(onClick = onLogout) {
|
||||
Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = "Выйти")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(modifier = Modifier.fillMaxSize().padding(padding)) {
|
||||
if (state.offline) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
modifier = Modifier.fillMaxWidth().testTag(TAG_ALBUMS_OFFLINE),
|
||||
) {
|
||||
Text(
|
||||
text = state.error ?: "Офлайн-режим",
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
when {
|
||||
state.loading && state.albums.isEmpty() -> {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
state.albums.isEmpty() -> {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("Пока нет альбомов")
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(2),
|
||||
contentPadding = PaddingValues(8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxSize().testTag(TAG_ALBUMS_GRID),
|
||||
) {
|
||||
items(state.albums, key = { it.id }) { album ->
|
||||
AlbumCard(album = album, onClick = { onOpenAlbum(album.id) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AlbumCard(album: Album, onClick: () -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TAG_ALBUM_CARD_PREFIX + album.id)
|
||||
.clickable(onClick = onClick),
|
||||
) {
|
||||
GaloAsyncImage(
|
||||
url = album.coverThumbUrl ?: album.coverMediumUrl,
|
||||
contentDescription = album.title,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.clip(MaterialTheme.shapes.medium),
|
||||
)
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Text(
|
||||
text = album.title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = "${album.photosCount} фото · ${album.membersCount} участн.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.galo.android.ui.albums
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.galo.android.di.AppContainer
|
||||
import com.galo.android.domain.model.Album
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class AlbumsViewModel(private val container: AppContainer) : ViewModel() {
|
||||
|
||||
data class UiState(
|
||||
val albums: List<Album> = emptyList(),
|
||||
val loading: Boolean = false,
|
||||
val offline: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
private data class Status(
|
||||
val loading: Boolean = false,
|
||||
val offline: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
private val status = MutableStateFlow(Status())
|
||||
|
||||
val state: StateFlow<UiState> = combine(
|
||||
container.albumRepository.observeAlbums(),
|
||||
status,
|
||||
) { albums, s ->
|
||||
UiState(albums = albums, loading = s.loading, offline = s.offline, error = s.error)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UiState(loading = true))
|
||||
|
||||
init {
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
viewModelScope.launch {
|
||||
status.update { it.copy(loading = true, error = null) }
|
||||
val online = container.albumRepository.refresh()
|
||||
status.update {
|
||||
it.copy(
|
||||
loading = false,
|
||||
offline = !online,
|
||||
error = if (online) null else "Нет сети — показаны кэшированные данные",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun logout(onDone: () -> Unit) {
|
||||
container.logout()
|
||||
onDone()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.galo.android.ui.auth
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.galo.android.ui.GaloViewModelFactory
|
||||
|
||||
const val TAG_AUTH_EMAIL = "auth_email"
|
||||
const val TAG_AUTH_PASSWORD = "auth_password"
|
||||
const val TAG_AUTH_NAME = "auth_name"
|
||||
const val TAG_AUTH_SUBMIT = "auth_submit"
|
||||
|
||||
@Composable
|
||||
fun AuthScreen(
|
||||
factory: GaloViewModelFactory,
|
||||
onAuthenticated: () -> Unit,
|
||||
) {
|
||||
val viewModel: AuthViewModel = androidx.lifecycle.viewmodel.compose.viewModel(factory = factory)
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.authenticated.collect { onAuthenticated() }
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
if (state.mode == AuthViewModel.Mode.LOGIN) "Вход" else "Регистрация",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
)
|
||||
if (state.mode == AuthViewModel.Mode.REGISTER) {
|
||||
OutlinedTextField(
|
||||
value = state.name,
|
||||
onValueChange = viewModel::onNameChange,
|
||||
label = { Text("Имя") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().testTag(TAG_AUTH_NAME),
|
||||
)
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = state.email,
|
||||
onValueChange = viewModel::onEmailChange,
|
||||
label = { Text("Email") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||
modifier = Modifier.fillMaxWidth().testTag(TAG_AUTH_EMAIL),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.password,
|
||||
onValueChange = viewModel::onPasswordChange,
|
||||
label = { Text("Пароль") },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
modifier = Modifier.fillMaxWidth().testTag(TAG_AUTH_PASSWORD),
|
||||
)
|
||||
state.error?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
Button(
|
||||
onClick = viewModel::submit,
|
||||
enabled = !state.loading,
|
||||
modifier = Modifier.fillMaxWidth().testTag(TAG_AUTH_SUBMIT),
|
||||
) {
|
||||
Text(
|
||||
when {
|
||||
state.loading -> "Подождите…"
|
||||
state.mode == AuthViewModel.Mode.LOGIN -> "Войти"
|
||||
else -> "Зарегистрироваться"
|
||||
},
|
||||
)
|
||||
}
|
||||
Row {
|
||||
TextButton(
|
||||
onClick = {
|
||||
viewModel.setMode(
|
||||
if (state.mode == AuthViewModel.Mode.LOGIN) {
|
||||
AuthViewModel.Mode.REGISTER
|
||||
} else {
|
||||
AuthViewModel.Mode.LOGIN
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
if (state.mode == AuthViewModel.Mode.LOGIN) {
|
||||
"Создать аккаунт"
|
||||
} else {
|
||||
"У меня уже есть аккаунт"
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.galo.android.ui.auth
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.galo.android.data.network.ErrorMapper
|
||||
import com.galo.android.di.AppContainer
|
||||
import com.galo.android.domain.model.ApiException
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class AuthViewModel(private val container: AppContainer) : ViewModel() {
|
||||
|
||||
enum class Mode { LOGIN, REGISTER }
|
||||
|
||||
data class UiState(
|
||||
val mode: Mode = Mode.LOGIN,
|
||||
val email: String = "",
|
||||
val password: String = "",
|
||||
val name: String = "",
|
||||
val loading: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
private val _state = MutableStateFlow(UiState())
|
||||
val state = _state.asStateFlow()
|
||||
|
||||
private val _authenticated = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
val authenticated = _authenticated.asSharedFlow()
|
||||
|
||||
fun onEmailChange(value: String) = _state.update { it.copy(email = value, error = null) }
|
||||
|
||||
fun onPasswordChange(value: String) = _state.update { it.copy(password = value, error = null) }
|
||||
|
||||
fun onNameChange(value: String) = _state.update { it.copy(name = value, error = null) }
|
||||
|
||||
fun setMode(mode: Mode) = _state.update { it.copy(mode = mode, error = null) }
|
||||
|
||||
fun submit() {
|
||||
val current = _state.value
|
||||
val email = current.email.trim()
|
||||
when {
|
||||
email.isEmpty() || current.password.isEmpty() -> {
|
||||
_state.update { it.copy(error = "Заполните email и пароль") }
|
||||
return
|
||||
}
|
||||
|
||||
current.mode == Mode.REGISTER && current.name.trim().isEmpty() -> {
|
||||
_state.update { it.copy(error = "Укажите имя") }
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
_state.update { it.copy(loading = true, error = null) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val tokens = when (current.mode) {
|
||||
Mode.LOGIN -> container.api.login(email, current.password)
|
||||
Mode.REGISTER -> container.api.register(email, current.password, current.name.trim())
|
||||
}
|
||||
container.tokenStore.save(tokens.accessToken, tokens.refreshToken)
|
||||
_state.update { it.copy(loading = false) }
|
||||
_authenticated.emit(Unit)
|
||||
} catch (e: ApiException) {
|
||||
_state.update { it.copy(loading = false, error = ErrorMapper.userMessage(e.error)) }
|
||||
} catch (e: Exception) {
|
||||
_state.update { it.copy(loading = false, error = e.message ?: "Ошибка входа") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.galo.android.ui.common
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.galo.android.data.image.ObjectCacheKey
|
||||
|
||||
/**
|
||||
* Coil image that caches by object path (stable across signature rotation) and
|
||||
* shows a neutral placeholder while loading or when the URL is absent.
|
||||
*/
|
||||
@Composable
|
||||
fun GaloAsyncImage(
|
||||
url: String?,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
contentScale: ContentScale = ContentScale.Crop,
|
||||
) {
|
||||
if (url.isNullOrBlank()) {
|
||||
Box(
|
||||
modifier = modifier.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
)
|
||||
return
|
||||
}
|
||||
val context = LocalContext.current
|
||||
val request = remember(url) {
|
||||
val key = ObjectCacheKey.keyFor(url)
|
||||
ImageRequest.Builder(context)
|
||||
.data(url)
|
||||
.memoryCacheKey(key)
|
||||
.diskCacheKey(key)
|
||||
.crossfade(true)
|
||||
.build()
|
||||
}
|
||||
AsyncImage(
|
||||
model = request,
|
||||
contentDescription = contentDescription,
|
||||
contentScale = contentScale,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Placeholder(modifier: Modifier = Modifier) {
|
||||
Box(modifier.fillMaxSize().background(MaterialTheme.colorScheme.surfaceVariant))
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.galo.android.ui.setup
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.galo.android.ui.GaloViewModelFactory
|
||||
|
||||
const val TAG_SETUP_URL = "setup_url"
|
||||
const val TAG_SETUP_INSECURE = "setup_insecure"
|
||||
const val TAG_SETUP_CONNECT = "setup_connect"
|
||||
|
||||
@Composable
|
||||
fun SetupScreen(
|
||||
factory: GaloViewModelFactory,
|
||||
onConnected: () -> Unit,
|
||||
) {
|
||||
val viewModel: SetupViewModel = androidx.lifecycle.viewmodel.compose.viewModel(factory = factory)
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
androidx.compose.runtime.LaunchedEffect(Unit) {
|
||||
viewModel.connected.collect { onConnected() }
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text("Подключение к galo", style = MaterialTheme.typography.headlineSmall)
|
||||
Text(
|
||||
"Введите адрес вашего self-hosted инстанса",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.baseUrl,
|
||||
onValueChange = viewModel::onUrlChange,
|
||||
label = { Text("Базовый URL") },
|
||||
placeholder = { Text("https://photos.example.com") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TAG_SETUP_URL),
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Switch(
|
||||
checked = state.insecureAllowed,
|
||||
onCheckedChange = viewModel::onInsecureChange,
|
||||
modifier = Modifier.testTag(TAG_SETUP_INSECURE),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text("Небезопасное соединение (только LAN)")
|
||||
}
|
||||
if (state.insecureAllowed) {
|
||||
Text(
|
||||
"HTTP-соединение не шифруется. Включайте только в доверенной локальной сети.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
state.error?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
Button(
|
||||
onClick = viewModel::connect,
|
||||
enabled = !state.loading,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TAG_SETUP_CONNECT),
|
||||
) {
|
||||
if (state.loading) {
|
||||
CircularProgressIndicator(modifier = Modifier.height(20.dp).width(20.dp))
|
||||
} else {
|
||||
Text("Подключить")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.galo.android.ui.setup
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.galo.android.data.prefs.SettingsState
|
||||
import com.galo.android.di.AppContainer
|
||||
import com.galo.android.domain.ConnectionPolicy
|
||||
import com.galo.android.domain.model.InsecureConnectionBlockedException
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class SetupViewModel(private val container: AppContainer) : ViewModel() {
|
||||
|
||||
data class UiState(
|
||||
val baseUrl: String = "",
|
||||
val insecureAllowed: Boolean = false,
|
||||
val loading: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
private val _state = MutableStateFlow(UiState())
|
||||
val state = _state.asStateFlow()
|
||||
|
||||
private val _connected = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
val connected = _connected.asSharedFlow()
|
||||
|
||||
init {
|
||||
container.currentSettings()?.let { applySettings(it) }
|
||||
viewModelScope.launch {
|
||||
container.settingsRepository.flow.collect { settings ->
|
||||
if (_state.value.baseUrl.isEmpty() && settings.baseUrl.isNotEmpty()) {
|
||||
applySettings(settings)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun applySettings(settings: SettingsState) {
|
||||
_state.update {
|
||||
it.copy(baseUrl = settings.baseUrl, insecureAllowed = settings.insecureAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
fun onUrlChange(value: String) = _state.update { it.copy(baseUrl = value, error = null) }
|
||||
|
||||
fun onInsecureChange(value: Boolean) = _state.update { it.copy(insecureAllowed = value) }
|
||||
|
||||
fun connect() {
|
||||
val url = _state.value.baseUrl.trim()
|
||||
when {
|
||||
url.isEmpty() -> {
|
||||
_state.update { it.copy(error = "Укажите адрес сервера") }
|
||||
return
|
||||
}
|
||||
|
||||
!ConnectionPolicy.isSecure(url) && !ConnectionPolicy.isInsecure(url) -> {
|
||||
_state.update { it.copy(error = "Адрес должен начинаться с http:// или https://") }
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
ConnectionPolicy.check(url, _state.value.insecureAllowed)
|
||||
} catch (_: InsecureConnectionBlockedException) {
|
||||
_state.update { it.copy(error = "HTTP запрещён. Включите режим небезопасного соединения для LAN.") }
|
||||
return
|
||||
}
|
||||
|
||||
val insecure = _state.value.insecureAllowed
|
||||
_state.update { it.copy(loading = true, error = null) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
container.healthChecker.check(url)
|
||||
container.settingsRepository.setInsecureAllowed(insecure)
|
||||
container.settingsRepository.setBaseUrl(url)
|
||||
_state.update { it.copy(loading = false) }
|
||||
_connected.emit(Unit)
|
||||
} catch (e: Exception) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
loading = false,
|
||||
error = "Не удалось подключиться: ${e.message ?: "сервер недоступен"}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.galo.android.ui.theme
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
private val LightColors = lightColorScheme()
|
||||
private val DarkColors = darkColorScheme()
|
||||
|
||||
@Composable
|
||||
fun GaloTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
|
||||
darkTheme -> DarkColors
|
||||
else -> LightColors
|
||||
}
|
||||
MaterialTheme(colorScheme = colorScheme, content = content)
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package com.galo.android.ui.upload
|
||||
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.PickVisualMediaRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.DeleteSweep
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.galo.android.domain.model.Album
|
||||
import com.galo.android.domain.model.UploadJob
|
||||
import com.galo.android.domain.model.UploadStatus
|
||||
import com.galo.android.ui.GaloViewModelFactory
|
||||
|
||||
const val TAG_UPLOAD_LIST = "upload_list"
|
||||
const val TAG_UPLOAD_WIFI = "upload_wifi"
|
||||
const val TAG_UPLOAD_ADD = "upload_add"
|
||||
|
||||
@Composable
|
||||
fun UploadScreen(
|
||||
factory: GaloViewModelFactory,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val viewModel: UploadViewModel = viewModel(factory = factory)
|
||||
val jobs by viewModel.jobs.collectAsStateWithLifecycle()
|
||||
val albums by viewModel.albums.collectAsStateWithLifecycle()
|
||||
val settings by viewModel.settings.collectAsStateWithLifecycle()
|
||||
val message by viewModel.message.collectAsStateWithLifecycle()
|
||||
|
||||
var pendingUris by remember { mutableStateOf<List<android.net.Uri>>(emptyList()) }
|
||||
var showAlbumPicker by remember { mutableStateOf(false) }
|
||||
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { }
|
||||
|
||||
val picker = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.PickMultipleVisualMedia(20),
|
||||
) { uris ->
|
||||
if (uris.isNotEmpty()) {
|
||||
pendingUris = uris
|
||||
showAlbumPicker = true
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showAlbumPicker) {
|
||||
AlbumChooserDialog(
|
||||
albums = albums,
|
||||
onDismiss = { showAlbumPicker = false },
|
||||
onSelect = { albumId ->
|
||||
viewModel.enqueue(albumId, pendingUris)
|
||||
pendingUris = emptyList()
|
||||
showAlbumPicker = false
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
UploadContent(
|
||||
jobs = jobs,
|
||||
wifiOnly = settings?.wifiOnlyUploads == true,
|
||||
message = message,
|
||||
onToggleWifi = viewModel::setWifiOnly,
|
||||
onClearCompleted = viewModel::clearCompleted,
|
||||
onAdd = {
|
||||
picker.launch(
|
||||
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo),
|
||||
)
|
||||
},
|
||||
onBack = onBack,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun UploadContent(
|
||||
jobs: List<UploadJob>,
|
||||
wifiOnly: Boolean,
|
||||
message: String?,
|
||||
onToggleWifi: (Boolean) -> Unit,
|
||||
onClearCompleted: () -> Unit,
|
||||
onAdd: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Загрузки") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Назад")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = onClearCompleted) {
|
||||
Icon(Icons.Filled.DeleteSweep, contentDescription = "Убрать завершённые")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(modifier = Modifier.fillMaxSize().padding(padding)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Switch(
|
||||
checked = wifiOnly,
|
||||
onCheckedChange = onToggleWifi,
|
||||
modifier = Modifier.testTag(TAG_UPLOAD_WIFI),
|
||||
)
|
||||
Text("Только Wi-Fi", modifier = Modifier.padding(start = 12.dp))
|
||||
}
|
||||
message?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = onAdd,
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp).testTag(TAG_UPLOAD_ADD),
|
||||
) {
|
||||
Icon(Icons.Filled.Add, contentDescription = null)
|
||||
Text("Добавить фото и видео", modifier = Modifier.padding(start = 8.dp))
|
||||
}
|
||||
if (jobs.isEmpty()) {
|
||||
Text(
|
||||
"Очередь пуста",
|
||||
modifier = Modifier.padding(16.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize().testTag(TAG_UPLOAD_LIST)) {
|
||||
items(jobs, key = { it.id }) { job -> UploadRow(job) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UploadRow(job: UploadJob) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(job.filename, style = MaterialTheme.typography.bodyMedium)
|
||||
Text(statusLabel(job.status), style = MaterialTheme.typography.labelMedium)
|
||||
}
|
||||
if (job.status == UploadStatus.RUNNING) {
|
||||
LinearProgressIndicator(
|
||||
progress = { job.progress / 100f },
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
job.error?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun statusLabel(status: UploadStatus): String = when (status) {
|
||||
UploadStatus.QUEUED -> "В очереди"
|
||||
UploadStatus.RUNNING -> "Загрузка"
|
||||
UploadStatus.SUCCESS -> "Готово"
|
||||
UploadStatus.ERROR -> "Ошибка"
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AlbumChooserDialog(
|
||||
albums: List<Album>,
|
||||
onDismiss: () -> Unit,
|
||||
onSelect: (String) -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Выберите альбом") },
|
||||
text = {
|
||||
if (albums.isEmpty()) {
|
||||
Text("Нет доступных альбомов")
|
||||
} else {
|
||||
LazyColumn {
|
||||
items(albums, key = { it.id }) { album ->
|
||||
Text(
|
||||
text = album.title,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onSelect(album.id) }
|
||||
.padding(vertical = 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Отмена") }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.galo.android.ui.upload
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.galo.android.data.prefs.SettingsState
|
||||
import com.galo.android.di.AppContainer
|
||||
import com.galo.android.domain.model.Album
|
||||
import com.galo.android.domain.model.UploadJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class UploadViewModel(private val container: AppContainer) : ViewModel() {
|
||||
|
||||
val jobs: StateFlow<List<UploadJob>> = container.uploadRepository.observeJobs()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
val albums: StateFlow<List<Album>> = container.albumRepository.observeAlbums()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
val settings: StateFlow<SettingsState?> = container.settingsRepository.flow
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), container.currentSettings())
|
||||
|
||||
private val _message = MutableStateFlow<String?>(null)
|
||||
val message: StateFlow<String?> = _message.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch { container.albumRepository.refresh() }
|
||||
viewModelScope.launch { container.uploadRepository.requeuePending() }
|
||||
}
|
||||
|
||||
fun enqueue(albumId: String, uris: List<Uri>) {
|
||||
if (uris.isEmpty()) return
|
||||
viewModelScope.launch {
|
||||
val wifiOnly = settings.value?.wifiOnlyUploads == true
|
||||
val queued = container.uploadRepository.enqueue(albumId, uris, wifiOnly)
|
||||
_message.value = "В очередь добавлено файлов: $queued"
|
||||
}
|
||||
}
|
||||
|
||||
fun setWifiOnly(enabled: Boolean) {
|
||||
viewModelScope.launch { container.settingsRepository.setWifiOnlyUploads(enabled) }
|
||||
}
|
||||
|
||||
fun clearCompleted() {
|
||||
viewModelScope.launch { container.uploadRepository.clearCompleted() }
|
||||
}
|
||||
|
||||
fun consumeMessage() {
|
||||
_message.value = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package com.galo.android.ui.viewer
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.opengl.GLES20
|
||||
import android.opengl.GLSurfaceView
|
||||
import android.opengl.GLUtils
|
||||
import android.util.AttributeSet
|
||||
import android.view.MotionEvent
|
||||
import android.view.ScaleGestureDetector
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.nio.FloatBuffer
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.microedition.khronos.egl.EGLConfig
|
||||
import javax.microedition.khronos.opengles.GL10
|
||||
|
||||
/**
|
||||
* Equirectangular 360 panorama viewer mirrored from the web `PanoViewer`
|
||||
* (`frontend/src/lib/components/PanoViewer.svelte`): same spherical projection,
|
||||
* the same drag-to-look mapping (yaw/pitch scaled by `fov / viewportHeight`),
|
||||
* pitch clamped to ±89°, and pinch/wheel zoom mapped to the field of view.
|
||||
*/
|
||||
class PanoramaView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
) : GLSurfaceView(context, attrs) {
|
||||
|
||||
private val renderer = PanoRenderer()
|
||||
private val scaleDetector = ScaleGestureDetector(
|
||||
context,
|
||||
object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
|
||||
override fun onScale(detector: ScaleGestureDetector): Boolean {
|
||||
renderer.zoom(if (detector.scaleFactor > 1f) 1f / detector.scaleFactor else 1f)
|
||||
return true
|
||||
}
|
||||
},
|
||||
)
|
||||
private var lastX = 0f
|
||||
private var lastY = 0f
|
||||
|
||||
init {
|
||||
setEGLContextClientVersion(2)
|
||||
setRenderer(renderer)
|
||||
renderMode = RENDERMODE_WHEN_DIRTY
|
||||
}
|
||||
|
||||
fun load(url: String) {
|
||||
renderer.loadImage(url)
|
||||
}
|
||||
|
||||
@androidx.annotation.VisibleForTesting
|
||||
internal fun currentYawForTest(): Float = renderer.camera.yaw
|
||||
|
||||
@androidx.annotation.VisibleForTesting
|
||||
internal fun currentPitchForTest(): Float = renderer.camera.pitch
|
||||
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
scaleDetector.onTouchEvent(event)
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
lastX = event.x
|
||||
lastY = event.y
|
||||
}
|
||||
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
if (!scaleDetector.isInProgress) {
|
||||
val k = renderer.camera.fov / height.coerceAtLeast(1)
|
||||
renderer.drag(event.x - lastX, event.y - lastY, k)
|
||||
lastX = event.x
|
||||
lastY = event.y
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private inner class PanoRenderer : Renderer {
|
||||
private var program = 0
|
||||
private var texture = 0
|
||||
|
||||
val camera = com.galo.android.domain.viewer.PanoramaCamera()
|
||||
|
||||
private val disposed = AtomicBoolean(false)
|
||||
private var pendingUrl: String? = null
|
||||
private var vertexBuffer: FloatBuffer = ByteBuffer
|
||||
.allocateDirect(VERTS.size * 4)
|
||||
.order(ByteOrder.nativeOrder())
|
||||
.asFloatBuffer()
|
||||
.apply {
|
||||
put(VERTS)
|
||||
position(0)
|
||||
}
|
||||
|
||||
fun drag(dx: Float, dy: Float, k: Float) {
|
||||
camera.drag(dx, dy, k)
|
||||
requestRender()
|
||||
}
|
||||
|
||||
fun zoom(factor: Float) {
|
||||
camera.zoom(factor)
|
||||
requestRender()
|
||||
}
|
||||
|
||||
fun loadImage(url: String) {
|
||||
pendingUrl = url
|
||||
queueEvent { downloadAndUpload(url) }
|
||||
}
|
||||
|
||||
override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
|
||||
program = buildProgram()
|
||||
val locs = IntArray(5)
|
||||
GLES20.glGetUniformLocation(program, "uRes").let { locs[0] = it }
|
||||
GLES20.glGetUniformLocation(program, "uFov").let { locs[1] = it }
|
||||
GLES20.glGetUniformLocation(program, "uYaw").let { locs[2] = it }
|
||||
GLES20.glGetUniformLocation(program, "uPitch").let { locs[3] = it }
|
||||
GLES20.glGetUniformLocation(program, "uTex").let { locs[4] = it }
|
||||
pRes = locs[0]; pFov = locs[1]; pYaw = locs[2]; pPitch = locs[3]; pTex = locs[4]
|
||||
|
||||
texture = createTexture()
|
||||
val aPos = GLES20.glGetAttribLocation(program, "aPos")
|
||||
GLES20.glEnableVertexAttribArray(aPos)
|
||||
GLES20.glVertexAttribPointer(aPos, 2, GLES20.GL_FLOAT, false, 0, vertexBuffer)
|
||||
|
||||
pendingUrl?.let { downloadAndUpload(it) }
|
||||
}
|
||||
|
||||
override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) {
|
||||
GLES20.glViewport(0, 0, width, height)
|
||||
}
|
||||
|
||||
override fun onDrawFrame(gl: GL10?) {
|
||||
if (program == 0) return
|
||||
GLES20.glUseProgram(program)
|
||||
GLES20.glUniform2f(pRes, width.toFloat().coerceAtLeast(1f), height.toFloat().coerceAtLeast(1f))
|
||||
GLES20.glUniform1f(pFov, camera.fov)
|
||||
GLES20.glUniform1f(pYaw, camera.yaw)
|
||||
GLES20.glUniform1f(pPitch, camera.pitch)
|
||||
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
|
||||
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, texture)
|
||||
GLES20.glUniform1i(pTex, 0)
|
||||
GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, 3)
|
||||
}
|
||||
|
||||
private var pRes = -1
|
||||
private var pFov = -1
|
||||
private var pYaw = -1
|
||||
private var pPitch = -1
|
||||
private var pTex = -1
|
||||
|
||||
private fun downloadAndUpload(url: String) {
|
||||
if (disposed.get()) return
|
||||
val bitmap = runCatching {
|
||||
java.net.URL(url).openConnection().run {
|
||||
connectTimeout = 15_000
|
||||
readTimeout = 30_000
|
||||
getInputStream().use { BitmapFactory.decodeStream(it) }
|
||||
}
|
||||
}.getOrNull() ?: return
|
||||
if (disposed.get()) {
|
||||
bitmap.recycle()
|
||||
return
|
||||
}
|
||||
upload(bitmap)
|
||||
}
|
||||
|
||||
private fun upload(bitmap: Bitmap) {
|
||||
val maxSize = 4096
|
||||
val scaled = if (maxOf(bitmap.width, bitmap.height) > maxSize) {
|
||||
val scale = maxSize.toFloat() / maxOf(bitmap.width, bitmap.height)
|
||||
Bitmap.createScaledBitmap(
|
||||
bitmap,
|
||||
(bitmap.width * scale).toInt().coerceAtLeast(1),
|
||||
(bitmap.height * scale).toInt().coerceAtLeast(1),
|
||||
true,
|
||||
).also { if (it !== bitmap) bitmap.recycle() }
|
||||
} else {
|
||||
bitmap
|
||||
}
|
||||
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, texture)
|
||||
GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, scaled, 0)
|
||||
scaled.recycle()
|
||||
requestRender()
|
||||
}
|
||||
|
||||
private fun createTexture(): Int {
|
||||
val handles = IntArray(1)
|
||||
GLES20.glGenTextures(1, handles, 0)
|
||||
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, handles[0])
|
||||
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE)
|
||||
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE)
|
||||
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR)
|
||||
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR)
|
||||
return handles[0]
|
||||
}
|
||||
|
||||
private fun buildProgram(): Int {
|
||||
val vs = compile(GLES20.GL_VERTEX_SHADER, VERT)
|
||||
val fs = compile(GLES20.GL_FRAGMENT_SHADER, FRAG)
|
||||
val p = GLES20.glCreateProgram()
|
||||
GLES20.glAttachShader(p, vs)
|
||||
GLES20.glAttachShader(p, fs)
|
||||
GLES20.glLinkProgram(p)
|
||||
GLES20.glDeleteShader(vs)
|
||||
GLES20.glDeleteShader(fs)
|
||||
return p
|
||||
}
|
||||
|
||||
private fun compile(type: Int, source: String): Int {
|
||||
val shader = GLES20.glCreateShader(type)
|
||||
GLES20.glShaderSource(shader, source)
|
||||
GLES20.glCompileShader(shader)
|
||||
return shader
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val VERTS = floatArrayOf(-1f, -1f, 3f, -1f, -1f, 3f)
|
||||
|
||||
// Kept in sync with PanoViewer.svelte (VERT/FRAG).
|
||||
private val VERT = """
|
||||
attribute vec2 aPos;
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = aPos * 0.5 + 0.5;
|
||||
gl_Position = vec4(aPos, 0.0, 1.0);
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
private val FRAG = """
|
||||
precision mediump float;
|
||||
varying vec2 vUv;
|
||||
uniform sampler2D uTex;
|
||||
uniform vec2 uRes;
|
||||
uniform float uFov;
|
||||
uniform float uYaw;
|
||||
uniform float uPitch;
|
||||
const float PI = 3.141592653589793;
|
||||
void main() {
|
||||
vec2 ndc = vUv * 2.0 - 1.0;
|
||||
float aspect = uRes.x / uRes.y;
|
||||
float tanHalf = tan(uFov * 0.5);
|
||||
vec3 dir = normalize(vec3(ndc.x * tanHalf * aspect, ndc.y * tanHalf, -1.0));
|
||||
float cp = cos(uPitch), sp = sin(uPitch);
|
||||
vec3 d1 = vec3(dir.x, dir.y * cp - dir.z * sp, dir.y * sp + dir.z * cp);
|
||||
float cy = cos(uYaw), sy = sin(uYaw);
|
||||
vec3 d2 = vec3(d1.x * cy + d1.z * sy, d1.y, -d1.x * sy + d1.z * cy);
|
||||
float lon = atan(d2.x, -d2.z);
|
||||
float lat = asin(clamp(d2.y, -1.0, 1.0));
|
||||
vec2 uv = vec2(lon / (2.0 * PI) + 0.5, 0.5 - lat / PI);
|
||||
gl_FragColor = texture2D(uTex, uv);
|
||||
}
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.galo.android.ui.viewer
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.ui.AspectRatioFrameLayout
|
||||
import androidx.media3.ui.PlayerView
|
||||
import com.galo.android.ui.common.GaloAsyncImage
|
||||
|
||||
/**
|
||||
* Media3 playback with a poster shown until playback starts. The built-in
|
||||
* controller provides seek/scrubbing; the player is paused when the screen
|
||||
* leaves the foreground.
|
||||
*/
|
||||
@androidx.annotation.OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
fun VideoPlayer(
|
||||
url: String,
|
||||
posterUrl: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
var showPoster by remember(url) { mutableStateOf(true) }
|
||||
|
||||
val player = remember(url) {
|
||||
ExoPlayer.Builder(context).build().apply {
|
||||
setMediaItem(MediaItem.fromUri(url))
|
||||
prepare()
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(url) {
|
||||
val listener = object : Player.Listener {
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
if (isPlaying) showPoster = false
|
||||
}
|
||||
}
|
||||
player.addListener(listener)
|
||||
onDispose {
|
||||
player.removeListener(listener)
|
||||
player.release()
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_PAUSE) player.pause()
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
Box(modifier.background(Color.Black)) {
|
||||
AndroidView(
|
||||
factory = {
|
||||
PlayerView(it).apply {
|
||||
this.player = player
|
||||
useController = true
|
||||
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
if (showPoster) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
GaloAsyncImage(
|
||||
url = posterUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Filled.PlayArrow,
|
||||
contentDescription = "Воспроизвести",
|
||||
tint = Color.White,
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.clickable {
|
||||
showPoster = false
|
||||
player.play()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(url) { player.playWhenReady = false }
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package com.galo.android.ui.viewer
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.galo.android.GaloApplication
|
||||
import com.galo.android.domain.model.MediaKind
|
||||
import com.galo.android.domain.model.Photo
|
||||
import com.galo.android.ui.common.GaloAsyncImage
|
||||
|
||||
const val TAG_VIEWER_PAGER = "viewer_pager"
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ViewerScreen(
|
||||
app: GaloApplication,
|
||||
albumId: String,
|
||||
initialIndex: Int,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val factory = viewModelFactory {
|
||||
initializer { ViewerViewModel(app.container, albumId) }
|
||||
}
|
||||
val viewModel: ViewerViewModel = viewModel(factory = factory)
|
||||
val photos by viewModel.photos.collectAsStateWithLifecycle()
|
||||
ViewerContent(photos = photos, initialIndex = initialIndex, onBack = onBack)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ViewerContent(
|
||||
photos: List<Photo>,
|
||||
initialIndex: Int,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
if (photos.isEmpty()) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Просмотр", style = MaterialTheme.typography.titleMedium) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Назад")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Surface(color = Color.Black, modifier = Modifier.fillMaxSize().padding(padding)) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val pagerState = rememberPagerState(
|
||||
initialPage = initialIndex.coerceIn(0, photos.lastIndex),
|
||||
) { photos.size }
|
||||
val current = photos.getOrNull(pagerState.currentPage)
|
||||
// A 360 panorama wraps horizontally forever, so on panorama pages the pager
|
||||
// must not steal horizontal drags: they rotate the view instead.
|
||||
val pagerScrollEnabled = current?.isPanorama != true
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
current?.originalFilename ?: "Просмотр",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Назад")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Surface(
|
||||
color = Color.Black,
|
||||
modifier = Modifier.fillMaxSize().padding(padding),
|
||||
) {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
userScrollEnabled = pagerScrollEnabled,
|
||||
modifier = Modifier.fillMaxSize().testTag(TAG_VIEWER_PAGER),
|
||||
key = { index -> photos.getOrNull(index)?.id ?: index },
|
||||
) { page ->
|
||||
val photo = photos.getOrNull(page) ?: return@HorizontalPager
|
||||
MediaPage(photo)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MediaPage(photo: Photo) {
|
||||
when {
|
||||
photo.kind == MediaKind.VIDEO -> {
|
||||
val playback = photo.playbackUrl ?: photo.originalUrl ?: photo.downloadUrl
|
||||
if (playback != null) {
|
||||
VideoPlayer(
|
||||
url = playback,
|
||||
posterUrl = photo.mediumUrl ?: photo.thumbUrl,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
photo.isPanorama -> {
|
||||
val source = photo.originalUrl ?: photo.previewUrl ?: photo.mediumUrl
|
||||
if (source != null) {
|
||||
AndroidPanorama(source)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
GaloAsyncImage(
|
||||
url = photo.previewUrl ?: photo.mediumUrl ?: photo.originalUrl ?: photo.thumbUrl,
|
||||
contentDescription = photo.originalFilename,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AndroidPanorama(url: String) {
|
||||
androidx.compose.ui.viewinterop.AndroidView(
|
||||
factory = { context -> PanoramaView(context) },
|
||||
update = { view -> view.load(url) },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.galo.android.ui.viewer
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.galo.android.di.AppContainer
|
||||
import com.galo.android.domain.model.Photo
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ViewerViewModel(
|
||||
private val container: AppContainer,
|
||||
private val albumId: String,
|
||||
) : ViewModel() {
|
||||
|
||||
val photos: StateFlow<List<Photo>> = container.photoRepository.observePhotos(albumId)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
init {
|
||||
// Cold start (e.g. restored route): make sure the cache is populated.
|
||||
viewModelScope.launch {
|
||||
if (photos.value.isEmpty()) {
|
||||
container.photoRepository.loadNextPage(albumId, cursor = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">galo</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="upload_channel_name">Загрузка в galo</string>
|
||||
<string name="upload_channel_description">Прогресс фоновой загрузки фотографий и видео</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.Galo" parent="android:Theme.Material.Light.NoActionBar">
|
||||
<item name="android:windowBackground">@android:color/white</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.galo.android.domain
|
||||
|
||||
import com.galo.android.domain.model.InsecureConnectionBlockedException
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ConnectionPolicyTest {
|
||||
|
||||
@Test
|
||||
fun `http is blocked by default`() {
|
||||
assertThrows(InsecureConnectionBlockedException::class.java) {
|
||||
ConnectionPolicy.check("http://192.168.1.10:8080", insecureAllowed = false)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `http is allowed once insecure mode is enabled`() {
|
||||
ConnectionPolicy.check("http://192.168.1.10:8080", insecureAllowed = true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `https is always allowed`() {
|
||||
ConnectionPolicy.check("https://photos.example.com", insecureAllowed = false)
|
||||
ConnectionPolicy.check("https://photos.example.com", insecureAllowed = true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scheme detection is case and whitespace insensitive`() {
|
||||
assertTrue(ConnectionPolicy.isInsecure(" HTTP://host"))
|
||||
assertTrue(ConnectionPolicy.isSecure(" HTTPS://host"))
|
||||
assertFalse(ConnectionPolicy.isInsecure("https://host"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.galo.android.domain.viewer
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class PanoramaCameraTest {
|
||||
|
||||
@Test
|
||||
fun `horizontal drag rotates yaw by dx times scale`() {
|
||||
val camera = PanoramaCamera(fov = 1f)
|
||||
camera.drag(dx = 100f, dy = 0f, scale = 0.01f)
|
||||
assertEquals(1f, camera.yaw, 1e-4f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vertical drag changes pitch`() {
|
||||
val camera = PanoramaCamera()
|
||||
camera.drag(dx = 0f, dy = 50f, scale = 0.01f)
|
||||
assertEquals(0.5f, camera.pitch, 1e-4f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pitch is clamped to plus minus 89 degrees`() {
|
||||
val camera = PanoramaCamera()
|
||||
camera.drag(dx = 0f, dy = 1_000f, scale = 1f)
|
||||
assertEquals(PanoramaCamera.PITCH_LIMIT, camera.pitch, 1e-4f)
|
||||
|
||||
camera.drag(dx = 0f, dy = -10_000f, scale = 1f)
|
||||
assertEquals(-PanoramaCamera.PITCH_LIMIT, camera.pitch, 1e-4f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zoom stays within fov bounds`() {
|
||||
val camera = PanoramaCamera()
|
||||
repeat(50) { camera.zoom(1.5f) }
|
||||
assertEquals(PanoramaCamera.MAX_FOV, camera.fov, 1e-4f)
|
||||
|
||||
repeat(100) { camera.zoom(0.5f) }
|
||||
assertEquals(PanoramaCamera.MIN_FOV, camera.fov, 1e-4f)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.galo.android.ui.albums
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import com.galo.android.domain.model.Album
|
||||
import com.galo.android.ui.theme.GaloTheme
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [33])
|
||||
class AlbumsContentTest {
|
||||
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
private fun album(id: String, title: String) = Album(
|
||||
id = id,
|
||||
title = title,
|
||||
description = "",
|
||||
ownerId = "u1",
|
||||
coverPhotoId = null,
|
||||
coverThumbUrl = null,
|
||||
coverMediumUrl = null,
|
||||
photosCount = 4,
|
||||
membersCount = 2,
|
||||
updatedAt = null,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `album grid shows cards with counters`() {
|
||||
compose.setContent {
|
||||
GaloTheme(dynamicColor = false) {
|
||||
AlbumsContent(
|
||||
state = AlbumsViewModel.UiState(albums = listOf(album("a1", "Summer"))),
|
||||
onOpenAlbum = {},
|
||||
onOpenUploads = {},
|
||||
onRefresh = {},
|
||||
onLogout = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
compose.onNodeWithTag(TAG_ALBUMS_GRID).assertIsDisplayed()
|
||||
compose.onNodeWithTag(TAG_ALBUM_CARD_PREFIX + "a1").assertIsDisplayed()
|
||||
compose.onNodeWithText("Summer").assertIsDisplayed()
|
||||
compose.onNodeWithText("4 фото · 2 участн.").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `offline banner is shown when offline`() {
|
||||
compose.setContent {
|
||||
GaloTheme(dynamicColor = false) {
|
||||
AlbumsContent(
|
||||
state = AlbumsViewModel.UiState(
|
||||
albums = listOf(album("a1", "Summer")),
|
||||
offline = true,
|
||||
error = "Нет сети — показаны кэшированные данные",
|
||||
),
|
||||
onOpenAlbum = {},
|
||||
onOpenUploads = {},
|
||||
onRefresh = {},
|
||||
onLogout = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
compose.onNodeWithTag(TAG_ALBUMS_OFFLINE).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty state is shown without albums`() {
|
||||
compose.setContent {
|
||||
GaloTheme(dynamicColor = false) {
|
||||
AlbumsContent(
|
||||
state = AlbumsViewModel.UiState(),
|
||||
onOpenAlbum = {},
|
||||
onOpenUploads = {},
|
||||
onRefresh = {},
|
||||
onLogout = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
compose.onNodeWithText("Пока нет альбомов").assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.galo.android.ui.upload
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import com.galo.android.domain.model.UploadJob
|
||||
import com.galo.android.domain.model.UploadStatus
|
||||
import com.galo.android.ui.theme.GaloTheme
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [33])
|
||||
class UploadContentTest {
|
||||
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
private fun job(id: String, name: String, status: UploadStatus, error: String? = null) = UploadJob(
|
||||
id = id,
|
||||
albumId = "a1",
|
||||
filename = name,
|
||||
mimeType = "image/jpeg",
|
||||
size = 100,
|
||||
status = status,
|
||||
progress = 40,
|
||||
error = error,
|
||||
attempts = 0,
|
||||
wifiOnly = false,
|
||||
createdAt = 0,
|
||||
)
|
||||
|
||||
private fun render(jobs: List<UploadJob>, message: String? = null, wifiOnly: Boolean = false) {
|
||||
compose.setContent {
|
||||
GaloTheme(dynamicColor = false) {
|
||||
UploadContent(
|
||||
jobs = jobs,
|
||||
wifiOnly = wifiOnly,
|
||||
message = message,
|
||||
onToggleWifi = {},
|
||||
onClearCompleted = {},
|
||||
onAdd = {},
|
||||
onBack = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `per-file status and progress are shown`() {
|
||||
render(
|
||||
listOf(
|
||||
job("1", "queued.jpg", UploadStatus.QUEUED),
|
||||
job("2", "running.mp4", UploadStatus.RUNNING),
|
||||
job("3", "done.jpg", UploadStatus.SUCCESS),
|
||||
),
|
||||
)
|
||||
compose.onNodeWithText("queued.jpg").assertIsDisplayed()
|
||||
compose.onNodeWithText("В очереди").assertIsDisplayed()
|
||||
compose.onNodeWithText("running.mp4").assertIsDisplayed()
|
||||
compose.onNodeWithText("Загрузка").assertIsDisplayed()
|
||||
compose.onNodeWithText("Готово").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `server error is shown`() {
|
||||
render(listOf(job("4", "big.jpg", UploadStatus.ERROR, error = "Файл превышает допустимый размер")))
|
||||
compose.onNodeWithText("Ошибка").assertIsDisplayed()
|
||||
compose.onNodeWithText("Файл превышает допустимый размер").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty queue and wifi toggle are shown`() {
|
||||
render(emptyList(), message = "Очередь пуста", wifiOnly = true)
|
||||
compose.onNodeWithTag(TAG_UPLOAD_ADD).assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.galo.android.ui.viewer
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performTouchInput
|
||||
import androidx.compose.ui.test.swipeLeft
|
||||
import com.galo.android.domain.model.MediaKind
|
||||
import com.galo.android.domain.model.Photo
|
||||
import com.galo.android.ui.theme.GaloTheme
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [33])
|
||||
class ViewerContentTest {
|
||||
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
private fun photo(
|
||||
id: String,
|
||||
name: String,
|
||||
kind: MediaKind = MediaKind.IMAGE,
|
||||
isPanorama: Boolean = false,
|
||||
) = Photo(
|
||||
id = id,
|
||||
albumId = "a1",
|
||||
originalFilename = name,
|
||||
kind = kind,
|
||||
mimeType = null,
|
||||
width = 100,
|
||||
height = 100,
|
||||
durationMs = 0,
|
||||
size = 0,
|
||||
takenAt = null,
|
||||
caption = "",
|
||||
isPanorama = isPanorama,
|
||||
thumbUrl = null,
|
||||
mediumUrl = null,
|
||||
previewUrl = null,
|
||||
playbackUrl = null,
|
||||
originalUrl = null,
|
||||
downloadUrl = null,
|
||||
createdAt = null,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `swipe advances to the next item without leaving the viewer`() {
|
||||
compose.setContent {
|
||||
GaloTheme(dynamicColor = false) {
|
||||
ViewerContent(
|
||||
photos = listOf(photo("p1", "first.jpg"), photo("p2", "second.jpg")),
|
||||
initialIndex = 0,
|
||||
onBack = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithText("first.jpg").assertIsDisplayed()
|
||||
compose.onNodeWithTag(TAG_VIEWER_PAGER).performTouchInput { swipeLeft() }
|
||||
compose.waitForIdle()
|
||||
compose.onNodeWithText("second.jpg").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `opens at the requested index`() {
|
||||
compose.setContent {
|
||||
GaloTheme(dynamicColor = false) {
|
||||
ViewerContent(
|
||||
photos = listOf(
|
||||
photo("p1", "first.jpg"),
|
||||
photo("p2", "second.jpg"),
|
||||
photo("p3", "third.jpg"),
|
||||
),
|
||||
initialIndex = 2,
|
||||
onBack = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithText("third.jpg").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `horizontal swipe does not page away from a panorama`() {
|
||||
compose.setContent {
|
||||
GaloTheme(dynamicColor = false) {
|
||||
ViewerContent(
|
||||
photos = listOf(
|
||||
photo("p1", "first.jpg"),
|
||||
photo("p2", "pano.jpg", isPanorama = true),
|
||||
),
|
||||
initialIndex = 1,
|
||||
onBack = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithText("pano.jpg").assertIsDisplayed()
|
||||
compose.onNodeWithTag(TAG_VIEWER_PAGER).performTouchInput { swipeLeft() }
|
||||
compose.waitForIdle()
|
||||
// Paging is disabled on panorama pages so the drag can rotate the view.
|
||||
compose.onNodeWithText("pano.jpg").assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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" }
|
||||
BIN
Binary file not shown.
@@ -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
|
||||
+252
@@ -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" "$@"
|
||||
Vendored
+94
@@ -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
|
||||
@@ -0,0 +1,24 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google {
|
||||
content {
|
||||
includeGroupByRegex("com\\.android.*")
|
||||
includeGroupByRegex("com\\.google.*")
|
||||
includeGroupByRegex("androidx.*")
|
||||
}
|
||||
}
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "galo-android"
|
||||
include(":app")
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-11
|
||||
@@ -0,0 +1,114 @@
|
||||
# Design
|
||||
|
||||
## Context
|
||||
|
||||
См. `proposal.md` — Why. Существенное текущее состояние и ограничения:
|
||||
|
||||
- API уже достаточен: `POST /api/auth/{register,login,refresh,logout}`, `GET /api/albums`,
|
||||
`GET /api/albums/:id/photos?cursor&since&limit`, `POST /api/albums/:id/photos` (multipart),
|
||||
`GET /api/sse/albums/:id?token=`, `GET /api/objects/<key>?...`.
|
||||
- Объекты отдаются **origin-relative** подписанными URL (`storage/signer.go:57`), подпись
|
||||
(`e`,`s`) квантована окном `OBJECT_URL_SIGN_WINDOW` (по умолчанию 10 мин) и меняется со
|
||||
временем. `host-agnostic-object-urls/design.md:57` прямо предполагает не-браузерного
|
||||
потребителя, резолвящего эти URL против базового адреса инстанса.
|
||||
- Batch-загрузка **fail-fast**: обработчик возвращает ошибку на первом неудачном файле, а
|
||||
`MaxBytesReader` ограничивает **общий** размер тела (`photos/handlers.go:40`).
|
||||
- Сервер декодирует только JPEG/PNG/GIF/BMP/TIFF (`photos/pipeline.go:125`), HEIC/HEIF/AVIF
|
||||
отклоняются на `inspect()` (`photos/service.go:168`).
|
||||
- `?since=` уже поддержан для догона (`photos/handlers.go:24`).
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Нативный Android-клиент (смотреть + загружать) как отдельный модуль, независимый от
|
||||
`backend/` и `frontend/`.
|
||||
- Работа с произвольным self-hosted инстансом без изменений серверного API.
|
||||
- Надёжная фоновая загрузка и офлайн-просмотр при ограничениях платформы Android.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- iOS, share-ссылки, управление участниками/инвайтами, редактирование тегов/комментариев.
|
||||
- Push через FCM; resumable-загрузка; mDNS/auto-discovery; доверие self-signed CA.
|
||||
- Работа при развёртывании в подпути (`https://host/galo/`): как и веб, клиент
|
||||
предполагает корень `/api/...`.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Нативный Kotlin + Compose, отдельный `android/`
|
||||
Клиент живёт в новом верхнеуровневом Gradle-проекте, отдельно от `backend/`/`frontend/`.
|
||||
Даёт системный пикер, WorkManager, Media3, Coil, Keystore без прослоек.
|
||||
Альтернативы: Flutter (лишний тяжёлый тулчейн в проекте «без Docker»), TWA-обёртка
|
||||
(нет нативной очереди/пикера — теряется смысл), KMP (дороже на старте без цели на iOS).
|
||||
|
||||
### Ручной базовый URL + явный режим небезопасного соединения
|
||||
Первый запуск требует ввести URL инстанса и подтверждает его через `GET /api/health`.
|
||||
HTTP по умолчанию запрещён (Android блокирует cleartext); пользователь может включить
|
||||
режим «небезопасное соединение (только LAN)». Относительные URL объектов резолвятся
|
||||
против этого базового адреса.
|
||||
Альтернативы: mDNS/auto-discovery (нужен серверный responder, нестабильно на OEM),
|
||||
`APP_BASE_URL` (удалён в `host-agnostic-object-urls`).
|
||||
|
||||
### Сессия: Keystore + автоматический refresh
|
||||
Токены — только в защищённом хранилище платформы (Keystore-backed
|
||||
EncryptedSharedPreferences/DataStore), не в открытых prefs. Refresh выполняется
|
||||
прозрачно на 401 (retry исходного запроса); неудача очищает сессию.
|
||||
Альтернатива: HttpOnly-cookie (неприменимо для нативного HTTP-клиента без браузерного
|
||||
cookie-jar и CSRF-защиты).
|
||||
|
||||
### Загрузка: WorkManager, один файл на запрос, очередь в Room
|
||||
Каждое задание — один файл и один multipart POST: batch на сервере fail-fast и
|
||||
ограничивает общий размер тела, поэтому per-file статус и гранулярный retry возможны
|
||||
только по одному файлу. Очередь персистится в Room, переживает перезапуск; крупные и
|
||||
видео-загрузки идут с foreground-уведомлением прогресса; retry — экспоненциальный
|
||||
backoff; сеть ограничивается по желанию (Wi-Fi-only).
|
||||
Альтернативы: batch (теряет per-file статус), upload только в foreground (нет главной
|
||||
ценности клиента).
|
||||
|
||||
### HEIC/HEIF → JPEG на клиенте с сохранением EXIF
|
||||
Перед постановкой в очередь HEIC/HEIF декодируется на устройстве и перекодируется в
|
||||
JPEG; дата съёмки и координаты переносятся через AndroidX ExifInterface, имя файла
|
||||
сохраняется. Так сервер принимает файл без изменений и сохраняет сортировку по дате
|
||||
(`taken_at` выводится из EXIF, поля для клиентской метадачи нет).
|
||||
Альтернативы: libheif на сервере (CGO ломает статический `CGO_ENABLED=0` бинарник),
|
||||
сайдкар-оригинал (изменение API). Цена: для HEIC-источников «оригиналом» становится JPEG.
|
||||
|
||||
### Кэш по устойчивому ключу объекта
|
||||
Кэш медиа (Coil) использует ключ по пути объекта без query (`/api/objects/<id>/thumb.webp`),
|
||||
т.к. подпись меняется каждое окно. Метаданные альбомов и фото кэшируются в Room, чтобы
|
||||
альбомы и сетки открывались офлайн.
|
||||
Альтернатива: ключ по полному URL — промах кэша на каждой смене подписи.
|
||||
|
||||
### Live-обновления: SSE в foreground, без FCM
|
||||
Пока экран альбома активен, клиент держит `GET /api/sse/albums/:id?token=` (okhttp-sse)
|
||||
и догоняет разрыв через `GET /api/albums/:id/photos?since=`. Push-уведомления вне
|
||||
foreground не входят в v1 (несовместимо с self-hosted духом без Google-инфраструктуры).
|
||||
|
||||
### Сборка и репозиторий
|
||||
Опциональная цель `make android` в существующем `Makefile`; debug-подпись для сборок,
|
||||
отдельный release-keystore вне репозитория. Серверная сборка не меняется.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Сервер не декодирует HEIC] → клиент конвертирует в JPEG; для HEIC-источников хранится
|
||||
JPEG-оригинал — задокументировать в README/справке приложения.
|
||||
- [Нет resumable-загрузки, видео до 2 ГБ] → повтор файла целиком при сбое; предупреждение
|
||||
о мобильном трафике; resumable — отдельное будущее изменение API.
|
||||
- [Ротация подписи URL] → кэш по ключу объекта; риск при смене схемы ключей сервером.
|
||||
- [Ограничения фоновой работы Android 14+] → foreground-уведомление для длинных загрузок,
|
||||
небольшие батчи, соблюдение правил старта FGS.
|
||||
- [SSE и lifecycle] → соединение только пока экран активен; backoff + догон через `since`.
|
||||
- [Cleartext по LAN] → только по явному включению, с визуальным предупреждением.
|
||||
- [Отсутствие push] → события замечаются при открытии приложения; отложено осознанно.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Изменение аддитивно и сервер не затрагивает: существующий инстанс galo продолжает
|
||||
работать без обновления. Откат — удаление `android/` и цели `make android`. Отдельный
|
||||
пересмотр `project.md` §17 («только адаптивный веб») — правка документации.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Дистрибуция: sideload/F-Droid против публикации в Play (влияет на подпись и release-пайплайн).
|
||||
- `minSdk`/`targetSdk`: насколько широкую базу устройств поддерживать.
|
||||
- Показывать ли в первом релизе read-only социальные данные (лайки/комментарии/теги) из API.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Proposal
|
||||
|
||||
## Why
|
||||
|
||||
Self-hosted galo сейчас доступен только как адаптивный веб, а `project.md` §17 явно
|
||||
исключает мобильные приложения. Но главный сценарий фотоальбома — снимать и загружать
|
||||
с телефона — на вебе ограничен: нет системного пикера, фоновой загрузки и офлайн-кэша.
|
||||
Нативный Android-клиент закрывает именно этот разрыв поверх уже существующего HTTP API.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Новое Android-приложение (Kotlin + Jetpack Compose, Android-only), подключаемое к
|
||||
произвольному self-hosted инстансу galo по базовому URL.
|
||||
- Онбординг: ручной ввод URL сервера, проверка `GET /api/health`, вход в аккаунт;
|
||||
режим небезопасного соединения для LAN, iOS/mDNS/доверие self-signed — вне объёма.
|
||||
- Просмотр: список альбомов, сетка фото, viewer фото/видео/pano, live-обновления по SSE.
|
||||
- Загрузка: системный Photo Picker, очередь в WorkManager, по одному файлу на запрос,
|
||||
прогресс в уведомлении, повторы, опция «только Wi-Fi», работа после закрытия приложения.
|
||||
- HEIC/HEIF конвертируется на клиенте в JPEG с сохранением EXIF перед отправкой.
|
||||
- Кэш метаданных (Room) и объектов (Coil, ключ — путь без подписи) для офлайн-браузинга.
|
||||
- Без изменений серверного API: используются существующие `/api/auth/*`, `/api/albums`,
|
||||
`/api/albums/:id/photos` (list + multipart upload), `/api/sse/*`, `/api/objects/*`.
|
||||
- Не входит в v1: редактирование тегов/комментариев/лайков, управление участниками и
|
||||
инвайтами, share-ссылки, push-уведомления через FCM, iOS.
|
||||
- **BREAKING**: нет.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `android-client`: поведение Android-клиента — настройка сервера и онбординг, сессия и
|
||||
авторизация, просмотр альбомов и медиа, очередь загрузки и офлайн-кэш.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
Нет. Серверные требования не меняются: клиент потребляет существующий API без правок.
|
||||
Правка `project.md` §17 («только адаптивный веб») — документация, не spec.
|
||||
|
||||
## Impact
|
||||
|
||||
- Новый верхнеуровневый модуль `android/` (Gradle-проект), не затрагивающий `backend/`
|
||||
и `frontend/`; отдельная сборка и подпись, опциональная цель в `Makefile`.
|
||||
- Ключевые зависимости клиента: Compose, OkHttp (+ okhttp-sse), Coil, Room,
|
||||
WorkManager, AndroidX ExifInterface, Media3/ExoPlayer.
|
||||
- Интеграционные ограничения сервера, учтённые в дизайне: origin-relative подписанные
|
||||
URL объектов с окном подписи (`host-agnostic-object-urls`), fail-fast batch-загрузка
|
||||
(`photos/handlers.go` Upload), отсутствие декодирования HEIC на сервере.
|
||||
- Требуется пересмотр `project.md` §17 как отдельная правка документации.
|
||||
@@ -0,0 +1,232 @@
|
||||
# Spec Delta
|
||||
|
||||
## Purpose
|
||||
|
||||
Android-клиент для self-hosted galo: настройка подключения к серверу, авторизация,
|
||||
просмотр альбомов и медиа, а также фоновая загрузка фотографий и видео с телефона
|
||||
с офлайн-кэшем.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Настройка подключения к серверу
|
||||
|
||||
Приложение SHALL требовать явного указания базового URL инстанса galo при первом
|
||||
запуске и SHALL проверять доступность сервера перед переходом к работе.
|
||||
|
||||
#### Scenario: Успешная настройка
|
||||
|
||||
- **WHEN** пользователь вводит достижимый базовый URL и подтверждает
|
||||
- **THEN** приложение проверяет `GET /api/health` и при успехе переходит к входу
|
||||
|
||||
#### Scenario: Сервер недоступен
|
||||
|
||||
- **WHEN** сервер недоступен или health вернул ошибку
|
||||
- **THEN** приложение показывает ошибку и остаётся на экране настройки, сохраняя введённый URL
|
||||
|
||||
### Requirement: Небезопасное соединение по LAN
|
||||
|
||||
Приложение SHALL по умолчанию запрещать незашифрованный HTTP и SHALL разрешать его
|
||||
только после явного включения пользователем режима небезопасного соединения.
|
||||
|
||||
#### Scenario: HTTP без разрешения
|
||||
|
||||
- **WHEN** пользователь указывает `http`-URL и режим небезопасного соединения выключен
|
||||
- **THEN** приложение блокирует подключение и предлагает включить режим
|
||||
|
||||
#### Scenario: Режим включён
|
||||
|
||||
- **WHEN** пользователь включил режим небезопасного соединения
|
||||
- **THEN** HTTP-подключение разрешено, а интерфейс явно отмечает небезопасное соединение
|
||||
|
||||
### Requirement: Разрешение относительных URL объектов
|
||||
|
||||
Приложение SHALL трактовать относительные URL объектов из REST-ответов и SSE как пути
|
||||
на выбранном базовом URL сервера.
|
||||
|
||||
#### Scenario: Отдача объекта
|
||||
|
||||
- **WHEN** API возвращает `thumb_url` вида `/api/objects/<key>?...`
|
||||
- **THEN** приложение запрашивает объект с того же инстанса, что и базовый URL
|
||||
|
||||
### Requirement: Вход и хранение сессии
|
||||
|
||||
Приложение SHALL позволять вход и регистрацию по email и паролю и SHALL хранить токены
|
||||
только в защищённом хранилище платформы.
|
||||
|
||||
#### Scenario: Успешный вход
|
||||
|
||||
- **WHEN** вход проходит успешно
|
||||
- **THEN** токены сохраняются в защищённом хранилище, пользователь видит список альбомов
|
||||
|
||||
#### Scenario: Перезапуск с сессией
|
||||
|
||||
- **WHEN** приложение перезапущено с сохранённой сессией
|
||||
- **THEN** пользователь остаётся авторизованным без повторного ввода пароля
|
||||
|
||||
### Requirement: Автоматическое обновление токена
|
||||
|
||||
Приложение SHALL автоматически обновлять access-токен при ответе 401, пока refresh-токен
|
||||
действителен, и SHALL завершать сессию при неудачном обновлении.
|
||||
|
||||
#### Scenario: Прозрачное обновление
|
||||
|
||||
- **WHEN** запрос получает 401 и refresh-токен действителен
|
||||
- **THEN** приложение обновляет токен и повторяет исходный запрос без участия пользователя
|
||||
|
||||
#### Scenario: Обновление не удалось
|
||||
|
||||
- **WHEN** обновление токена завершилось ошибкой
|
||||
- **THEN** сессия очищается и пользователь возвращается на экран входа
|
||||
|
||||
### Requirement: Список альбомов
|
||||
|
||||
Приложение SHALL показывать доступные пользователю альбомы с обложкой и счётчиками,
|
||||
беря данные из API, а при отсутствии сети — из локального кэша.
|
||||
|
||||
#### Scenario: Онлайн
|
||||
|
||||
- **WHEN** авторизованный пользователь открывает приложение
|
||||
- **THEN** отображается список альбомов с обложками или плейсхолдером
|
||||
|
||||
#### Scenario: Офлайн из кэша
|
||||
|
||||
- **WHEN** сеть недоступна, но кэш существует
|
||||
- **THEN** список альбомов отображается из кэша с признаком офлайн-режима
|
||||
|
||||
### Requirement: Постраничный просмотр альбома
|
||||
|
||||
Приложение SHALL показывать сетку медиа альбома с постраничной загрузкой по курсору и
|
||||
SHALL подгружать следующую страницу при достижении конца списка.
|
||||
|
||||
#### Scenario: Догрузка страницы
|
||||
|
||||
- **WHEN** пользователь прокручивает сетку до конца
|
||||
- **THEN** приложение запрашивает следующую страницу по курсору и добавляет её к сетке
|
||||
|
||||
#### Scenario: Пустой альбом
|
||||
|
||||
- **WHEN** в альбоме нет медиа
|
||||
- **THEN** отображается пустое состояние
|
||||
|
||||
### Requirement: Просмотр медиа на весь экран
|
||||
|
||||
Полноэкранный просмотр SHALL поддерживать изображение, видео с постером и управлением
|
||||
воспроизведением, а также панораму 360; навигация между элементами альбома SHALL
|
||||
выполняться свайпом.
|
||||
|
||||
#### Scenario: Свайп между элементами
|
||||
|
||||
- **WHEN** пользователь свайпает в просмотре
|
||||
- **THEN** показывается следующий или предыдущий элемент альбома без выхода из просмотра
|
||||
|
||||
#### Scenario: Видео
|
||||
|
||||
- **WHEN** открыт видеоэлемент
|
||||
- **THEN** показывается постер и доступно воспроизведение с перемоткой
|
||||
|
||||
### Requirement: Обновления в реальном времени
|
||||
|
||||
Пока экран альбома активен, приложение SHALL отображать изменения по SSE (новые,
|
||||
обновлённые и удалённые медиа, социальные события) и SHALL догонять пропущенное при
|
||||
переподключении.
|
||||
|
||||
#### Scenario: Новое фото с другого устройства
|
||||
|
||||
- **WHEN** другое устройство добавляет фото в открытый альбом
|
||||
- **THEN** новое фото появляется в сетке без ручного обновления
|
||||
|
||||
#### Scenario: Переподключение
|
||||
|
||||
- **WHEN** SSE-соединение разорвалось и восстановилось
|
||||
- **THEN** приложение дозагружает изменения, произошедшие за время разрыва
|
||||
|
||||
### Requirement: Выбор медиа и альбома для загрузки
|
||||
|
||||
Приложение SHALL предоставлять выбор одного или нескольких фото и видео из системной
|
||||
галереи и SHALL требовать выбора альбома назначения перед постановкой в очередь.
|
||||
|
||||
#### Scenario: Постановка в очередь
|
||||
|
||||
- **WHEN** пользователь выбрал файлы и альбом назначения
|
||||
- **THEN** задания загрузки ставятся в очередь и пользователь видит их статус
|
||||
|
||||
#### Scenario: Отказ сервера
|
||||
|
||||
- **WHEN** сервер отклоняет загрузку (например, из-за прав или размера)
|
||||
- **THEN** задание помечается ошибкой с причиной от сервера
|
||||
|
||||
### Requirement: Фоновая очередь загрузки
|
||||
|
||||
Загрузка SHALL выполняться по одному файлу за HTTP-запрос и SHALL продолжаться при
|
||||
уходе приложения из foreground, показывая прогресс в уведомлении; при ошибке SHALL
|
||||
выполняться повтор с нарастающей задержкой.
|
||||
|
||||
#### Scenario: Уход в фон
|
||||
|
||||
- **WHEN** приложение уходит в фон во время загрузки
|
||||
- **THEN** загрузка продолжается и прогресс отображается в уведомлении
|
||||
|
||||
#### Scenario: Повтор при ошибке
|
||||
|
||||
- **WHEN** загрузка файла завершилась сетевой ошибкой
|
||||
- **THEN** задание повторяется, а после исчерпания попыток помечается ошибкой
|
||||
|
||||
### Requirement: Условия сети для загрузки
|
||||
|
||||
Приложение SHALL позволять ограничить загрузку сетью Wi-Fi; при ожидании подходящей
|
||||
сети задание остаётся в очереди и не выполняется.
|
||||
|
||||
#### Scenario: Только Wi-Fi
|
||||
|
||||
- **WHEN** ограничение «только Wi-Fi» включено, а доступна только сотовая сеть
|
||||
- **THEN** загрузка не начинается до появления Wi-Fi, а задание остаётся в очереди
|
||||
|
||||
### Requirement: Сохранность очереди между запусками
|
||||
|
||||
Очередь загрузки SHALL сохраняться на устройстве и SHALL продолжаться после перезапуска
|
||||
приложения или устройства.
|
||||
|
||||
#### Scenario: Возобновление после перезапуска
|
||||
|
||||
- **WHEN** приложение или устройство перезапускается при незавершённой очереди
|
||||
- **THEN** незавершённые задания возобновляются
|
||||
|
||||
### Requirement: Конвертация HEIC в JPEG
|
||||
|
||||
Файлы HEIC/HEIF SHALL конвертироваться на устройстве в JPEG перед загрузкой с
|
||||
сохранением даты съёмки и геолокации.
|
||||
|
||||
#### Scenario: Загрузка HEIC
|
||||
|
||||
- **WHEN** пользователь ставит в очередь HEIC-файл
|
||||
- **THEN** загружается JPEG-версия, а дата съёмки и координаты из EXIF сохраняются
|
||||
|
||||
#### Scenario: Ошибка конвертации
|
||||
|
||||
- **WHEN** конвертация завершилась ошибкой
|
||||
- **THEN** задание помечается ошибкой, повреждённый файл не загружается
|
||||
|
||||
### Requirement: Кэш медиа по устойчивому ключу
|
||||
|
||||
Приложение SHALL кэшировать полученные превью и оригиналы по устойчивому идентификатору
|
||||
объекта, не зависящему от подписи и срока действия URL.
|
||||
|
||||
#### Scenario: Смена подписи
|
||||
|
||||
- **WHEN** URL одного и того же объекта со временем получает новую подпись
|
||||
- **THEN** приложение переиспользует закэшированный объект вместо повторной загрузки
|
||||
|
||||
#### Scenario: Офлайн-отдача из кэша
|
||||
|
||||
- **WHEN** сеть недоступна, а объект есть в кэше
|
||||
- **THEN** объект отображается из кэша
|
||||
|
||||
### Requirement: Единый формат ошибок API
|
||||
|
||||
Приложение SHALL распознавать ошибки API формата `{error:{code,message}}` и SHALL
|
||||
показывать пользователю понятное сообщение.
|
||||
|
||||
#### Scenario: Ошибка с кодом и сообщением
|
||||
|
||||
- **WHEN** сервер возвращает ошибку с `code` и `message`
|
||||
- **THEN** приложение показывает сообщение, при необходимости адаптированное под язык интерфейса
|
||||
@@ -0,0 +1,55 @@
|
||||
# Tasks
|
||||
|
||||
## 1. Скелет проекта и сборка
|
||||
|
||||
- [x] 1.1 Создать верхнеуровневый Gradle-проект `android/` (Kotlin, Jetpack Compose, version catalog, `minSdk 28`) и проверить, что `./gradlew :app:assembleDebug` собирает APK
|
||||
- [x] 1.2 Задать структуру пакетов (app/data/domain/ui) и проверить, что `./gradlew lint` и сборка проходят без ошибок
|
||||
- [x] 1.3 Добавить цель `android` в `Makefile` и проверить, что `make android` собирает APK
|
||||
- [x] 1.4 Настроить debug- и release-подпись (release keystore вне репозитория) и проверить, что release-конфигурация резолвится без секретов в репозитории
|
||||
|
||||
## 2. Сеть и настройка сервера
|
||||
|
||||
- [x] 2.1 Экран ввода базового URL, проверка `GET /api/health`, хранение в DataStore — проверить против запущенного galo
|
||||
- [x] 2.2 Шлюз «небезопасное соединение» для `http` — тест: HTTP заблокирован по умолчанию и разрешён после включения тумблера
|
||||
- [x] 2.3 OkHttp-клиент, резолвящий относительные URL объектов против базового адреса — unit-тест на `/api/objects/<key>?...`
|
||||
- [x] 2.4 Маппинг ошибок `{error:{code,message}}` и сообщений для пользователя — unit-тест на разборе ошибки
|
||||
- [x] 2.5 Auth API (register/login/refresh/logout), защищённое хранение токенов (Keystore) и прозрачный refresh на 401 — тест: сессия переживает перезапуск, 401 приводит к обновлению и повтору
|
||||
|
||||
## 3. Браузинг и кэш
|
||||
|
||||
- [x] 3.1 Схема Room для альбомов/фото и репозиторий с фолбэком на кэш — unit-тест репозитория в онлайн- и офлайн-ветках
|
||||
- [x] 3.2 Экран списка альбомов с обложками, счётчиками и признаком офлайн — UI-тест/ручная проверка
|
||||
- [x] 3.3 Сетка альбома с постраничной загрузкой по курсору — тест слияния страниц
|
||||
- [x] 3.4 Кэш Coil по пути объекта без query — unit-тест ключа и проверка переиспользования при смене подписи
|
||||
- [x] 3.5 Открытие альбома из кэша офлайн — проверить в режиме полёта на прогретом кэше
|
||||
|
||||
## 4. Просмотр медиа
|
||||
|
||||
- [x] 4.1 Полноэкранный просмотр изображений (preview/medium) со свайп-навигацией — UI-тест
|
||||
- [x] 4.2 Воспроизведение видео (Media3) с постером и перемоткой — проверить Range/seek против сервера
|
||||
- [x] 4.3 Просмотр панорамы 360 — проверить поведение перетаскивания на паритет с веб-реализацией
|
||||
|
||||
## 5. Real-time
|
||||
|
||||
- [x] 5.1 SSE-клиент (okhttp-sse) на активном экране альбома с токеном — проверить обновление сетки по событию
|
||||
- [x] 5.2 Переподключение с backoff и догон через `?since=` — тест сверки пропущенных событий
|
||||
|
||||
## 6. Конвейер загрузки
|
||||
|
||||
- [x] 6.1 Системный Photo Picker (мультивыбор), выбор альбома и постановка задания в Room — проверить создание queued-заданий
|
||||
- [x] 6.2 WorkManager-воркер: один файл на multipart POST, per-file статус — интеграционный тест против сервера
|
||||
- [x] 6.3 Конвертация HEIC/HEIF→JPEG с сохранением даты/геолокации и имени файла — unit-тест EXIF и проверка приёма сервером
|
||||
- [x] 6.4 Foreground-уведомление прогресса для крупных/видео-загрузок и retry с backoff — проверить продолжение загрузки в фоне
|
||||
- [x] 6.5 Сетевые ограничения (Wi-Fi-only) и сохранность очереди между запусками — тест возобновления после перезапуска процесса
|
||||
- [x] 6.6 UI статусов загрузки с per-file статусом и ошибками сервера — UI-тест
|
||||
|
||||
## 7. Интеграция и документация
|
||||
|
||||
- [x] 7.1 End-to-end прогон: онбординг → вход → браузинг → загрузка → появление фото через SSE на реальном инстансе galo
|
||||
- [x] 7.2 README Android-клиента (настройка URL, заметка про HEIC, LAN/cleartext, сборка и подпись) — проверить, что команды выполняются как описано
|
||||
- [x] 7.3 Обновить `project.md` §17, чтобы он больше не исключал мобильный клиент — проверить, что формулировка отражает Android-приложение
|
||||
|
||||
## Workflow follow-up
|
||||
|
||||
- Заархивировать изменение после выполнения требований ревью проекта.
|
||||
- Проверить результат архивации.
|
||||
+1
-1
@@ -593,7 +593,7 @@ migrate:
|
||||
## 17. Что НЕ делать в MVP
|
||||
|
||||
- Распознавание лиц, ML-теги
|
||||
- Мобильные приложения (только адаптивный веб)
|
||||
- Мобильные приложения, кроме Android-клиента (см. `android/`); iOS не планируется
|
||||
- Платные тарифы и биллинг
|
||||
- Печать фотокниг, экспорт в сторонние сервисы
|
||||
- Публичный API для внешних интеграций
|
||||
|
||||
Reference in New Issue
Block a user