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:
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user