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
|
||||||
galo.db-*
|
galo.db-*
|
||||||
data/
|
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=...
|
# Release version stamped into the binary (-X main.version). Override with VERSION=...
|
||||||
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||||
@@ -25,6 +28,10 @@ backend:
|
|||||||
|
|
||||||
build: frontend 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
|
# 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).
|
# with the version embedded. Produces bin/galo and a copy at bin/galo-$(VERSION).
|
||||||
build-release: frontend
|
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
|
## 17. Что НЕ делать в MVP
|
||||||
|
|
||||||
- Распознавание лиц, ML-теги
|
- Распознавание лиц, ML-теги
|
||||||
- Мобильные приложения (только адаптивный веб)
|
- Мобильные приложения, кроме Android-клиента (см. `android/`); iOS не планируется
|
||||||
- Платные тарифы и биллинг
|
- Платные тарифы и биллинг
|
||||||
- Печать фотокниг, экспорт в сторонние сервисы
|
- Печать фотокниг, экспорт в сторонние сервисы
|
||||||
- Публичный API для внешних интеграций
|
- Публичный API для внешних интеграций
|
||||||
|
|||||||
Reference in New Issue
Block a user