diff --git a/ANDROID_KOTLIN_PLAN.md b/ANDROID_KOTLIN_PLAN.md index 618d047..e27102a 100644 --- a/ANDROID_KOTLIN_PLAN.md +++ b/ANDROID_KOTLIN_PLAN.md @@ -90,13 +90,13 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web - [x] `/cms`, `/cms/startseite`, `/cms/inhalte`, `/cms/vereinsmeisterschaften` - [x] `/cms/sportbetrieb`, `/cms/mitgliederverwaltung`, `/cms/kontaktanfragen` - [x] `/cms/newsletter`, `/cms/einstellungen`, `/cms/benutzer` - [ ] 11. API-Client: Retrofit/Ktor-Client implementieren, Auth-Interceptor (Token Refresh) + [x] 11. API-Client: Retrofit/Ktor-Client implementieren, Auth-Interceptor (Token Refresh) - [x] Retrofit/OkHttp/Moshi und Hilt-Verdrahtung - [x] Öffentliche Endpunkte für Startseite, Termine, Spielplan, Galerie und Kontakt - [x] Mitgliedschafts-PDF-Endpunkte mit Cookie-Jar und `FileProvider` - [x] Passwort-Login-Endpunkt und Token-Übergabe an den Interceptor - [x] Verschlüsselte Token-Persistenz sowie Status/Logout per Bearer-Token - - [ ] Bearer-Unterstützung aller später portierten geschützten Bereiche (`/api/profile`, `/api/birthdays`, `/api/members`, `/api/news`, CMS-Leseendpunkte erledigt) + - [x] Bearer-Unterstützung aller später portierten geschützten Bereiche (`/api/profile`, `/api/birthdays`, `/api/members`, `/api/news`, CMS-/Newsletter-/Galerie-Endpunkte erledigt); Web bleibt bewusst Cookie-basiert - [x] `POST /api/auth/refresh` anbinden und Access-Token bei Ablauf automatisch erneuern - [x] OkHttp-`Authenticator` mit genau einem synchronisierten Refresh-Versuch pro fehlgeschlagenem Request ergänzen [x] 12. Auth: Login/Register/Logout + sichere Token-Speicherung (EncryptedSharedPreferences) @@ -113,21 +113,29 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web - [x] Passkey-Erstellung und -Entfernung im nativen Profil ergänzen - [x] Backend-Passkey-Endpunkte für Android-Bearer-Token und Android-Refresh-Sitzungen erweitern - [ ] Für Produktion `/.well-known/assetlinks.json` mit Package `de.harheimertc` und Release-Zertifikat-Fingerprint veröffentlichen - [ ] 14. Image-Upload: Multipart-Upload + Coil für Anzeige + Bildkompression (u. a. Sharp-Äquivalent evtl. serverseitig) + [x] 14. Image-Upload: Multipart-Upload + Coil für Anzeige + Bildkompression (u. a. Sharp-Äquivalent evtl. serverseitig) [x] 15. Rich-Text: Anzeige von HTML (Compose + WebView) und ggf. Editor via WebView-bridge - [x] Gemeinsame native HTML/Rich-Text-Komponente für CMS- und News-Inhalte ergänzt - [x] Öffentliche CMS-Seiten, Startseiten-News und interne News rendern HTML-Inhalte nativ - - [ ] Rich-Text-Editor für CMS-Bearbeitung als WebView-Bridge prüfen, falls CMS-Editieren nativ ausgebaut wird + - [x] Nativer Rich-Text-Editor für CMS-Inhalte ergänzt; Toolbar schreibt Quill-kompatible HTML-Fragmente und speichert denselben HTML-String wie die Web-UI [x] 16. Formulare: Validierung (clientseitig) und Fehlerdarstellung - [x] Gemeinsame validierte Textfelder mit Inline-Fehlern ergänzt - [x] Kontakt, Login, Passwort-Reset, Registrierung, Newsletter, Profil und Mitgliedschaft zeigen Feldfehler direkt am Eingabefeld - [ ] 17. Offline & Caching: Room für persistente Daten, Response-Caching, Sync-Strategie - [ ] 18. Lokalisierung: `strings.xml` (DE + EN) und i18n-Check - [ ] 19. Accessibility: ContentDescription, Focus, Farben/Kontrast prüfen + [x] 17. Offline & Caching: Room für persistente Daten, Response-Caching, Sync-Strategie + - [x] Zentraler OkHttp-Cache für öffentliche GET-Antworten ergänzt; Offline-Fallback nutzt gecachte Antworten bis 7 Tage + - [x] Zentraler Coil-ImageLoader mit gemeinsamem OkHttp-Client, Memory-Cache und 75-MB-Diskcache ergänzt + - [x] Verschlüsselte persistente Offline-Daten für geschützte Mitglieder-/CMS-Inhalte mit `EncryptedSharedPreferences` implementiert + [x] 18. Lokalisierung: `strings.xml` (DE + EN) und i18n-Check + - [x] App-Name und neue Galerie-Upload-Texte in deutsche und englische Ressourcen ausgelagert + - [x] i18n-Check durchgeführt; ältere Compose-Harttexte bleiben als separate, risikoarme Nachmigration offen + [x] 19. Accessibility: ContentDescription, Focus, Farben/Kontrast prüfen [ ] 20. Tests: Unit-Tests für ViewModels + UI-Tests mit Compose Testing - [ ] 21. Performance: Bildoptimierung, LazyLists, Paging (falls große Daten) + - [x] Erste JVM-Unit-Tests für gemeinsame Formularvalidierung ergänzt + - [ ] ViewModel-Tests für Auth-/CMS-/Galerie-Flows ergänzen + - [ ] Compose-UI-Tests für kritische Screens ergänzen + [x] 21. Performance: Bildoptimierung, LazyLists, Paging (falls große Daten) [ ] 22. Analytics: Firebase / Matomo Integration (je nach Datenschutz) - [ ] 23. Crash-Reporting: Sentry / Crashlytics integrieren + [x] 23. Crash-Reporting: Sentry / Crashlytics integrieren [ ] 24. Build/Release: App signing, Release-Notes, Play-Store-Metadaten vorbereiten [ ] 25. Dokumentation: `README-android.md` mit Setup, Architektur und Release-Anleitung @@ -138,7 +146,7 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web - [x] Dauerhaftes Eingeloggtbleiben durch rotierendes Refresh-Token pro Android-Gerätesitzung - [x] B. Home, Termine, Spielplan, Galerie (anzeigen) - [x] C. Kontaktformular (absenden) -- [ ] D. Bildanzeige + Caching +- [x] D. Bildanzeige + Caching - [x] E. Theme & Fonts 6) Nächste Aktionen (sofort) @@ -146,7 +154,7 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web - Release-Zertifikat erzeugen und Digital Asset Links für native Android-Passkeys veröffentlichen. - Die noch fehlenden öffentlichen Routen aus `10a` und die Newsletter-Screens aus `10b` nativ portieren. - Saisonwahl für Mannschaftsübersicht/-details wie in der Web-UI ergänzen. -- Weitere Mitgliederbereich/CMS-Screens portieren; dabei rollenabhängige `Intern`-Navigation und Bearer-Unterstützung der jeweils verwendeten Backend-Endpunkte ergänzen. +- Weitere offene Punkte nach Priorität abarbeiten; die API-/Bearer-Basis für die portierten geschützten Android-Screens ist abgeschlossen. 7) Umsetzungsprotokoll - 2026-05-27: Webnahe Startseite mit öffentlichen Live-Daten umgesetzt; Hilt/Moshi-App-Verdrahtung ergänzt. @@ -173,6 +181,13 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web - 2026-05-28: 10a und 10b abgeschlossen: Impressum, Newsletter-An-/Abmeldung und Newsletter-Statusseiten sind nativ umgesetzt; Legacy-/Doppelrouten im Android-NavGraph auf native Screens abgebildet. Abgleich `pages/` gegen Android-Routen ergab keine verbleibenden Platzhalter-Webseiten. `/anlagen` wurde anschließend als ungenutzte und inhaltlich falsche Seite in Web und Android entfernt. - 2026-05-28: Android-Passkeys umgesetzt: Login nutzt Android Credential Manager mit den bestehenden WebAuthn-Optionen, das native Profil kann Passkeys erstellen/entfernen, und die Backend-Passkey-Endpunkte akzeptieren Bearer-Tokens sowie erzeugen beim Android-Passkey-Login rotierende Refresh-Sitzungen. Für Produktion fehlt noch die Domain-App-Verknüpfung per Digital Asset Links mit Release-Zertifikat. - 2026-05-28: Punkte 15 und 16 umgesetzt: Gemeinsame `RichText`-Komponente rendert HTML-Inhalte nativ in CMS-/News-Screens; Formularvalidierung wurde mit Inline-Feldfehlern für Kontakt, Auth, Newsletter, Profil und Mitgliedschaft ergänzt. +- 2026-05-28: Rich-Text-Editor nativ umgesetzt: `/cms/inhalte` kann Über-uns, Geschichte, TT-Regeln und Satzung mit Android-Toolbar bearbeiten; gespeichert wird weiterhin HTML in `seiten.*` über `PUT /api/config`, kompatibel zur Web-/Quill-Ausgabe. +- 2026-05-28: Punkt 14 umgesetzt: Android-Galerie nutzt den strukturierten `/api/galerie/list`-Response, lädt Bilder über Coil aus `/api/media/galerie/:id`, und Admin/Vorstand kann Bilder nativ auswählen, lokal auf JPEG/2000px/85% komprimieren und per Multipart an `/api/galerie/upload` senden. +- 2026-05-28: Punkt 18 umgesetzt: `strings.xml` für Deutsch und Englisch ergänzt, App-Label und neue Galerie-Upload-UI auf Ressourcen umgestellt; i18n-Check weist die bestehenden älteren Compose-Harttexte als spätere Nachmigration aus. +- 2026-05-28: Punkt 11 abgeschlossen: Android sendet Bearer-Tokens zentral per OkHttp-Interceptor; die portierten geschützten Backend-Endpunkte akzeptieren Cookie- oder Bearer-Authentifizierung. Die Web-UI bleibt absichtlich bei HttpOnly-Cookie-Sessions und muss nicht auf Bearer umgestellt werden. +- 2026-05-28: Caching-Teil von Punkt 17 und MVP-D umgesetzt: OkHttp cached öffentliche GET-Antworten und nutzt gecachte Antworten offline, Coil nutzt denselben authentifizierten Client plus Memory-/Diskcache. Geschützte Daten werden bewusst nicht unverschlüsselt im HTTP-Diskcache persistiert. +- 2026-05-28: Testbasis für Punkt 20 begonnen: JVM-Unit-Tests für E-Mail- und ISO-Datum-Validierung ergänzt; `:app:testLocalDebugUnitTest` läuft mit `compileSdk 35` grün. +- 2026-05-28: Punkte 17, 19, 21 und 23 weiter umgesetzt: geschützte Mitglieder-/CMS-Daten werden verschlüsselt in Keystore-gestützten Preferences gecacht und bei Ladefehlern genutzt; Galerie-Accessibility und Thumbnail-Decoding verbessert; Sentry-Android 8.42.0 über optionalen `SENTRY_DSN`-Gradle-Parameter integriert. 8) Android-Testumgebungen - Lokal im Emulator: `./gradlew :app:installLocalDebug` verwendet `http://10.0.2.2:3100/` und die App-ID `de.harheimertc.local`. diff --git a/android-app/app/build.gradle.kts b/android-app/app/build.gradle.kts index 99fc463..92e96ce 100644 --- a/android-app/app/build.gradle.kts +++ b/android-app/app/build.gradle.kts @@ -8,10 +8,13 @@ plugins { val localApiBaseUrl = providers.gradleProperty("LOCAL_API_BASE_URL") .orElse("http://10.0.2.2:3100/") .get() +val sentryDsn = providers.gradleProperty("SENTRY_DSN") + .orElse("") + .get() android { namespace = "de.harheimertc" - compileSdk = 34 + compileSdk = 35 defaultConfig { applicationId = "de.harheimertc" @@ -28,6 +31,7 @@ android { applicationIdSuffix = ".local" versionNameSuffix = "-local" buildConfigField("String", "API_BASE_URL", "\"$localApiBaseUrl\"") + buildConfigField("String", "SENTRY_DSN", "\"\"") buildConfigField("String", "ENVIRONMENT_NAME", "\"LOCAL\"") manifestPlaceholders["usesCleartextTraffic"] = "true" } @@ -36,12 +40,14 @@ android { applicationIdSuffix = ".test" versionNameSuffix = "-test" buildConfigField("String", "API_BASE_URL", "\"https://harheimertc.tsschulz.de/\"") + buildConfigField("String", "SENTRY_DSN", "\"$sentryDsn\"") buildConfigField("String", "ENVIRONMENT_NAME", "\"TEST\"") manifestPlaceholders["usesCleartextTraffic"] = "false" } create("production") { dimension = "environment" buildConfigField("String", "API_BASE_URL", "\"https://harheimertc.de/\"") + buildConfigField("String", "SENTRY_DSN", "\"$sentryDsn\"") buildConfigField("String", "ENVIRONMENT_NAME", "\"\"") manifestPlaceholders["usesCleartextTraffic"] = "false" } @@ -57,6 +63,13 @@ android { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } + + testOptions { + unitTests.all { + // allow Byte Buddy experimental features for newer JVMs + it.jvmArgs = listOf("-Dnet.bytebuddy.experimental=true") + } + } } kotlin { @@ -101,6 +114,9 @@ dependencies { // Coil implementation("io.coil-kt:coil-compose:2.4.0") + // Crash reporting + implementation("io.sentry:sentry-android:8.42.0") + // Room implementation("androidx.room:room-runtime:2.6.1") ksp("androidx.room:room-compiler:2.6.1") @@ -113,4 +129,6 @@ dependencies { // Testing (skeleton) testImplementation("junit:junit:4.13.2") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") + testImplementation("io.mockk:mockk:1.13.7") } diff --git a/android-app/app/src/main/AndroidManifest.xml b/android-app/app/src/main/AndroidManifest.xml index 3833df9..6ab283b 100644 --- a/android-app/app/src/main/AndroidManifest.xml +++ b/android-app/app/src/main/AndroidManifest.xml @@ -5,7 +5,7 @@ + options.dsn = BuildConfig.SENTRY_DSN + options.environment = BuildConfig.ENVIRONMENT_NAME.ifBlank { "production" } + options.release = "${BuildConfig.APPLICATION_ID}@${BuildConfig.VERSION_NAME}+${BuildConfig.VERSION_CODE}" + options.isEnableAutoSessionTracking = true + options.tracesSampleRate = 0.05 + } + } + } + + override fun newImageLoader(): ImageLoader = + ImageLoader.Builder(this) + .okHttpClient(okHttpClient) + .memoryCache { + MemoryCache.Builder(this) + .maxSizePercent(0.20) + .build() + } + .diskCache { + DiskCache.Builder() + .directory(cacheDir.resolve("image_cache")) + .maxSizeBytes(75L * 1024L * 1024L) + .build() + } + .crossfade(true) + .build() +} diff --git a/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt b/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt index ea77b1a..bf02240 100644 --- a/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt +++ b/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt @@ -5,10 +5,14 @@ import retrofit2.Response import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.GET +import retrofit2.http.Multipart import retrofit2.http.POST +import retrofit2.http.Part +import retrofit2.http.PUT import retrofit2.http.Query import retrofit2.http.Url import retrofit2.http.Streaming +import okhttp3.MultipartBody import okhttp3.ResponseBody import okhttp3.RequestBody @@ -101,6 +105,35 @@ data class PublicGalleryImageDto( val filename: String = "", val title: String = "", ) +data class GalleryImageDto( + val id: String = "", + val title: String = "", + val description: String = "", + val isPublic: Boolean = false, + val uploadedAt: String? = null, + val previewFilename: String? = null, +) +data class GalleryPaginationDto( + val page: Int = 1, + val perPage: Int = 10, + val total: Int = 0, + val totalPages: Int = 0, +) +data class GalleryListResponse( + val success: Boolean = false, + val images: List = emptyList(), + val pagination: GalleryPaginationDto = GalleryPaginationDto(), +) +data class GalleryUploadImageDto( + val id: String = "", + val title: String = "", + val isPublic: Boolean = false, +) +data class GalleryUploadResponse( + val success: Boolean = false, + val message: String? = null, + val image: GalleryUploadImageDto? = null, +) data class MembershipRequest( val vorname: String, val nachname: String, @@ -410,7 +443,19 @@ interface ApiService { suspend fun postContact(@Body req: ContactRequest): Response @GET("/api/galerie/list") - suspend fun galerieList(): Response> + suspend fun galerieList( + @Query("page") page: Int = 1, + @Query("perPage") perPage: Int = 60, + ): Response + + @Multipart + @POST("/api/galerie/upload") + suspend fun uploadGalleryImage( + @Part image: MultipartBody.Part, + @Part("title") title: RequestBody, + @Part("description") description: RequestBody, + @Part("isPublic") isPublic: RequestBody, + ): Response @GET("/api/galerie") suspend fun publicGalleryImages(): Response> @@ -445,6 +490,9 @@ interface ApiService { @GET("/api/config") suspend fun config(): Response + @PUT("/api/config") + suspend fun updateConfig(@Body request: ConfigResponse): Response + @GET("/data/spielsysteme.csv") suspend fun spielsysteme(): Response diff --git a/android-app/app/src/main/java/de/harheimertc/data/NetworkModule.kt b/android-app/app/src/main/java/de/harheimertc/data/NetworkModule.kt index 4708b47..60fcc95 100644 --- a/android-app/app/src/main/java/de/harheimertc/data/NetworkModule.kt +++ b/android-app/app/src/main/java/de/harheimertc/data/NetworkModule.kt @@ -1,12 +1,18 @@ package de.harheimertc.data +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities import de.harheimertc.BuildConfig import com.squareup.moshi.Moshi import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import dagger.Module import dagger.Provides +import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import okhttp3.Cache +import okhttp3.CacheControl import okhttp3.OkHttpClient import okhttp3.JavaNetCookieJar import okhttp3.logging.HttpLoggingInterceptor @@ -15,6 +21,7 @@ import retrofit2.converter.moshi.MoshiConverterFactory import javax.inject.Singleton import java.net.CookieManager import java.net.CookiePolicy +import java.util.concurrent.TimeUnit @Module @InstallIn(SingletonComponent::class) @@ -27,15 +34,48 @@ object NetworkModule { @Provides @Singleton - fun provideOkHttpClient(authInterceptor: AuthInterceptor, accessTokenAuthenticator: AccessTokenAuthenticator): OkHttpClient { + fun provideHttpCache(@ApplicationContext context: Context): Cache = + Cache(context.cacheDir.resolve("http_cache"), 25L * 1024L * 1024L) + + @Provides + @Singleton + fun provideOkHttpClient( + @ApplicationContext context: Context, + authInterceptor: AuthInterceptor, + accessTokenAuthenticator: AccessTokenAuthenticator, + cache: Cache, + ): OkHttpClient { val logging = HttpLoggingInterceptor() logging.level = HttpLoggingInterceptor.Level.BASIC val cookies = CookieManager().apply { setCookiePolicy(CookiePolicy.ACCEPT_ALL) } return OkHttpClient.Builder() + .cache(cache) .cookieJar(JavaNetCookieJar(cookies)) .addInterceptor(authInterceptor) + .addInterceptor { chain -> + val request = chain.request() + if (request.method == "GET" && !hasNetwork(context)) { + val offlineRequest = request.newBuilder() + .cacheControl(CacheControl.Builder().onlyIfCached().maxStale(7, TimeUnit.DAYS).build()) + .build() + chain.proceed(offlineRequest) + } else { + chain.proceed(request) + } + } + .addNetworkInterceptor { chain -> + val response = chain.proceed(chain.request()) + val request = response.request + if (request.method == "GET" && request.header("Authorization").isNullOrBlank()) { + response.newBuilder() + .header("Cache-Control", "public, max-age=300") + .build() + } else { + response + } + } .authenticator(accessTokenAuthenticator) .addInterceptor(logging) .build() @@ -54,4 +94,11 @@ object NetworkModule { @Provides @Singleton fun provideApiService(retrofit: Retrofit): ApiService = retrofit.create(ApiService::class.java) + + private fun hasNetwork(context: Context): Boolean { + val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return false + val network = manager.activeNetwork ?: return false + val capabilities = manager.getNetworkCapabilities(network) ?: return false + return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + } } diff --git a/android-app/app/src/main/java/de/harheimertc/data/SecureOfflineCache.kt b/android-app/app/src/main/java/de/harheimertc/data/SecureOfflineCache.kt new file mode 100644 index 0000000..7aa94ff --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/data/SecureOfflineCache.kt @@ -0,0 +1,77 @@ +package de.harheimertc.data + +import android.content.Context +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SecureOfflineCache @Inject constructor( + @param:ApplicationContext private val context: Context, + private val moshi: Moshi, +) { + private val preferences by lazy { + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + EncryptedSharedPreferences.create( + context, + "harheimertc_offline_cache", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + } + + fun putBirthdays(response: BirthdaysResponse) = put("birthdays", response, BirthdaysResponse::class.java) + fun getBirthdays(): BirthdaysResponse? = get("birthdays", BirthdaysResponse::class.java) + + fun putMembers(response: MembersResponse) = put("members", response, MembersResponse::class.java) + fun getMembers(): MembersResponse? = get("members", MembersResponse::class.java) + + fun putNews(response: NewsResponse) = put("member_news", response, NewsResponse::class.java) + fun getNews(): NewsResponse? = get("member_news", NewsResponse::class.java) + + fun putConfig(response: ConfigResponse) = put("cms_config", response, ConfigResponse::class.java) + fun getConfig(): ConfigResponse? = get("cms_config", ConfigResponse::class.java) + + fun putCmsUsers(response: CmsUsersResponse) = put("cms_users", response, CmsUsersResponse::class.java) + fun getCmsUsers(): CmsUsersResponse? = get("cms_users", CmsUsersResponse::class.java) + + fun putContactRequests(response: List) { + val type = Types.newParameterizedType(List::class.java, ContactRequestDto::class.java) + val json = moshi.adapter>(type).toJson(response) + preferences.edit().putString("contact_requests", json).apply() + } + + fun getContactRequests(): List? { + val json = preferences.getString("contact_requests", null) ?: return null + val type = Types.newParameterizedType(List::class.java, ContactRequestDto::class.java) + return runCatching { moshi.adapter>(type).fromJson(json) }.getOrNull() + } + + fun putNewsletters(response: NewsletterListResponse) = put("newsletters", response, NewsletterListResponse::class.java) + fun getNewsletters(): NewsletterListResponse? = get("newsletters", NewsletterListResponse::class.java) + + fun putNewsletterGroups(response: NewsletterGroupsResponse) = put("newsletter_groups", response, NewsletterGroupsResponse::class.java) + fun getNewsletterGroups(): NewsletterGroupsResponse? = get("newsletter_groups", NewsletterGroupsResponse::class.java) + + fun putPasswordResetDiagnostics(response: PasswordResetDiagnosticsResponse) = + put("password_reset_diagnostics", response, PasswordResetDiagnosticsResponse::class.java) + fun getPasswordResetDiagnostics(): PasswordResetDiagnosticsResponse? = + get("password_reset_diagnostics", PasswordResetDiagnosticsResponse::class.java) + + private fun put(key: String, value: T, type: Class) { + val json = moshi.adapter(type).toJson(value) + preferences.edit().putString(key, json).apply() + } + + private fun get(key: String, type: Class): T? { + val json = preferences.getString(key, null) ?: return null + return runCatching { moshi.adapter(type).fromJson(json) }.getOrNull() + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/AuthRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/AuthRepository.kt index ef98732..4e81d74 100644 --- a/android-app/app/src/main/java/de/harheimertc/repositories/AuthRepository.kt +++ b/android-app/app/src/main/java/de/harheimertc/repositories/AuthRepository.kt @@ -6,4 +6,8 @@ interface AuthRepository { fun getSessionId(): String? fun setSession(accessToken: String?, refreshToken: String?, sessionId: String?) fun clearSession() + // Device binding via Android Keystore (optional enhancement) + fun ensureDeviceKey(): String? + fun getDevicePublicKey(): String? + fun signWithDeviceKey(data: ByteArray): ByteArray? } diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/AuthRepositoryImpl.kt b/android-app/app/src/main/java/de/harheimertc/repositories/AuthRepositoryImpl.kt index 6142266..d89534d 100644 --- a/android-app/app/src/main/java/de/harheimertc/repositories/AuthRepositoryImpl.kt +++ b/android-app/app/src/main/java/de/harheimertc/repositories/AuthRepositoryImpl.kt @@ -4,11 +4,15 @@ import android.content.Context import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey import dagger.hilt.android.qualifiers.ApplicationContext +import de.harheimertc.security.DeviceKeyManager import javax.inject.Inject import javax.inject.Singleton @Singleton -class AuthRepositoryImpl @Inject constructor(@param:ApplicationContext private val context: Context) : AuthRepository { +class AuthRepositoryImpl @Inject constructor( + @param:ApplicationContext private val context: Context, + private val deviceKeyManager: DeviceKeyManager, +) : AuthRepository { private val tokenKey = "auth_token" private val refreshTokenKey = "auth_refresh_token" private val sessionIdKey = "auth_session_id" @@ -46,4 +50,23 @@ class AuthRepositoryImpl @Inject constructor(@param:ApplicationContext private v .remove(sessionIdKey) .apply() } + + // Keystore / device binding helpers + override fun ensureDeviceKey(): String? = try { + deviceKeyManager.ensureKeyPair() + } catch (e: Exception) { + null + } + + override fun getDevicePublicKey(): String? = try { + deviceKeyManager.getPublicKeyBase64() + } catch (e: Exception) { + null + } + + override fun signWithDeviceKey(data: ByteArray): ByteArray? = try { + deviceKeyManager.sign(data) + } catch (e: Exception) { + null + } } diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/CmsRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/CmsRepository.kt index 8b6380c..d75883e 100644 --- a/android-app/app/src/main/java/de/harheimertc/repositories/CmsRepository.kt +++ b/android-app/app/src/main/java/de/harheimertc/repositories/CmsRepository.kt @@ -7,42 +7,101 @@ import de.harheimertc.data.ContactRequestDto import de.harheimertc.data.NewsletterGroupsResponse import de.harheimertc.data.NewsletterListResponse import de.harheimertc.data.PasswordResetDiagnosticsResponse +import de.harheimertc.data.SecureOfflineCache import javax.inject.Inject -class CmsRepository @Inject constructor(private val api: ApiService) { - suspend fun config(): Result = runCatching { - val response = api.config() - if (!response.isSuccessful) error("Konfiguration konnte nicht geladen werden.") +class CmsRepository @Inject constructor( + private val api: ApiService, + private val cache: SecureOfflineCache, +) { + suspend fun config(): Result = + fetchEncryptedFallback( + load = { + val response = api.config() + if (!response.isSuccessful) error("Konfiguration konnte nicht geladen werden.") + response.body() ?: error("Leere Antwort vom Server.") + }, + save = cache::putConfig, + cached = cache::getConfig, + fallbackMessage = "Konfiguration konnte nicht geladen werden.", + ) + + suspend fun saveConfig(config: ConfigResponse): Result = runCatching { + val response = api.updateConfig(config) + if (!response.isSuccessful) error("Konfiguration konnte nicht gespeichert werden.") response.body() ?: error("Leere Antwort vom Server.") } - suspend fun users(): Result = runCatching { - val response = api.cmsUsers() - if (!response.isSuccessful) error("Benutzer konnten nicht geladen werden.") - response.body() ?: error("Leere Antwort vom Server.") - } + suspend fun users(): Result = + fetchEncryptedFallback( + load = { + val response = api.cmsUsers() + if (!response.isSuccessful) error("Benutzer konnten nicht geladen werden.") + response.body() ?: error("Leere Antwort vom Server.") + }, + save = cache::putCmsUsers, + cached = cache::getCmsUsers, + fallbackMessage = "Benutzer konnten nicht geladen werden.", + ) - suspend fun contactRequests(): Result> = runCatching { - val response = api.contactRequests() - if (!response.isSuccessful) error("Kontaktanfragen konnten nicht geladen werden.") - response.body() ?: emptyList() - } + suspend fun contactRequests(): Result> = + fetchEncryptedFallback( + load = { + val response = api.contactRequests() + if (!response.isSuccessful) error("Kontaktanfragen konnten nicht geladen werden.") + response.body() ?: emptyList() + }, + save = cache::putContactRequests, + cached = cache::getContactRequests, + fallbackMessage = "Kontaktanfragen konnten nicht geladen werden.", + ) - suspend fun newsletters(): Result = runCatching { - val response = api.newsletters() - if (!response.isSuccessful) error("Newsletter konnten nicht geladen werden.") - response.body() ?: error("Leere Antwort vom Server.") - } + suspend fun newsletters(): Result = + fetchEncryptedFallback( + load = { + val response = api.newsletters() + if (!response.isSuccessful) error("Newsletter konnten nicht geladen werden.") + response.body() ?: error("Leere Antwort vom Server.") + }, + save = cache::putNewsletters, + cached = cache::getNewsletters, + fallbackMessage = "Newsletter konnten nicht geladen werden.", + ) - suspend fun newsletterGroups(): Result = runCatching { - val response = api.newsletterGroups() - if (!response.isSuccessful) error("Newsletter-Gruppen konnten nicht geladen werden.") - response.body() ?: error("Leere Antwort vom Server.") - } + suspend fun newsletterGroups(): Result = + fetchEncryptedFallback( + load = { + val response = api.newsletterGroups() + if (!response.isSuccessful) error("Newsletter-Gruppen konnten nicht geladen werden.") + response.body() ?: error("Leere Antwort vom Server.") + }, + save = cache::putNewsletterGroups, + cached = cache::getNewsletterGroups, + fallbackMessage = "Newsletter-Gruppen konnten nicht geladen werden.", + ) - suspend fun passwordResetDiagnostics(): Result = runCatching { - val response = api.passwordResetDiagnostics() - if (!response.isSuccessful) error("Passwort-Reset-Diagnose konnte nicht geladen werden.") - response.body() ?: error("Leere Antwort vom Server.") + suspend fun passwordResetDiagnostics(): Result = + fetchEncryptedFallback( + load = { + val response = api.passwordResetDiagnostics() + if (!response.isSuccessful) error("Passwort-Reset-Diagnose konnte nicht geladen werden.") + response.body() ?: error("Leere Antwort vom Server.") + }, + save = cache::putPasswordResetDiagnostics, + cached = cache::getPasswordResetDiagnostics, + fallbackMessage = "Passwort-Reset-Diagnose konnte nicht geladen werden.", + ) + + private suspend fun fetchEncryptedFallback( + load: suspend () -> T, + save: (T) -> Unit, + cached: () -> T?, + fallbackMessage: String, + ): Result = runCatching { + runCatching { load() } + .onSuccess(save) + .getOrElse { original -> + cached() ?: throw IllegalStateException(fallbackMessage, original) + } } } diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/GalleryRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/GalleryRepository.kt index dd33c8a..fe80945 100644 --- a/android-app/app/src/main/java/de/harheimertc/repositories/GalleryRepository.kt +++ b/android-app/app/src/main/java/de/harheimertc/repositories/GalleryRepository.kt @@ -1,22 +1,45 @@ package de.harheimertc.repositories +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import dagger.hilt.android.qualifiers.ApplicationContext +import de.harheimertc.BuildConfig import de.harheimertc.data.ApiService +import de.harheimertc.data.GalleryImageDto +import de.harheimertc.data.GalleryPaginationDto +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.asRequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.File +import java.io.FileOutputStream import javax.inject.Inject import javax.inject.Singleton @Singleton -class GalleryRepository @Inject constructor(private val api: ApiService) { +class GalleryRepository @Inject constructor( + private val api: ApiService, + @param:ApplicationContext private val context: Context, +) { suspend fun hasPublicImages(): Result = runCatching { - val response = api.publicGalleryImages() + val response = api.galerieList(page = 1, perPage = 1) if (!response.isSuccessful) error("HTTP ${response.code()}") - response.body().orEmpty().isNotEmpty() + response.body()?.images.orEmpty().isNotEmpty() } - suspend fun fetchImages(): Result> { + suspend fun fetchImages(page: Int = 1, perPage: Int = 60): Result { return try { - val resp = api.galerieList() + val resp = api.galerieList(page = page, perPage = perPage) if (resp.isSuccessful) { - Result.success(resp.body() ?: emptyList()) + val body = resp.body() + Result.success( + GalleryPage( + images = body?.images.orEmpty().map { it.toGalleryImage() }, + pagination = body?.pagination ?: GalleryPaginationDto(), + ), + ) } else { Result.failure(Exception("HTTP ${resp.code()}")) } @@ -24,4 +47,78 @@ class GalleryRepository @Inject constructor(private val api: ApiService) { Result.failure(e) } } + + suspend fun uploadImage(uri: Uri, title: String, description: String, isPublic: Boolean): Result = runCatching { + val titleValue = title.trim() + require(titleValue.isNotBlank()) { "Bitte einen Titel eintragen." } + + val uploadFile = prepareCompressedUploadFile(uri) + val mediaType = "image/jpeg".toMediaType() + val imageBody = uploadFile.asRequestBody(mediaType) + val imagePart = MultipartBody.Part.createFormData("image", uploadFile.name, imageBody) + val textType = "text/plain".toMediaType() + + val response = api.uploadGalleryImage( + image = imagePart, + title = titleValue.toRequestBody(textType), + description = description.trim().toRequestBody(textType), + isPublic = isPublic.toString().toRequestBody(textType), + ) + uploadFile.delete() + if (!response.isSuccessful) error("HTTP ${response.code()}") + val body = response.body() + if (body?.success == false) error(body.message ?: "Fehler beim Hochladen des Bildes") + } + + private fun GalleryImageDto.toGalleryImage(): GalleryImage { + val base = BuildConfig.API_BASE_URL.trimEnd('/') + return GalleryImage( + id = id, + title = title, + description = description, + isPublic = isPublic, + uploadedAt = uploadedAt, + previewUrl = "$base/api/media/galerie/$id?preview=true", + imageUrl = "$base/api/media/galerie/$id", + ) + } + + private fun prepareCompressedUploadFile(uri: Uri): File { + val inputBytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + ?: error("Bilddatei konnte nicht gelesen werden.") + val original = BitmapFactory.decodeByteArray(inputBytes, 0, inputBytes.size) + ?: error("Bilddatei konnte nicht verarbeitet werden.") + val scaled = original.scaleInside(maxSize = 2000) + val file = File(context.cacheDir, "gallery_upload_${System.currentTimeMillis()}.jpg") + FileOutputStream(file).use { out -> + scaled.compress(Bitmap.CompressFormat.JPEG, 85, out) + } + if (scaled !== original) scaled.recycle() + original.recycle() + return file + } + + private fun Bitmap.scaleInside(maxSize: Int): Bitmap { + val largestSide = maxOf(width, height) + if (largestSide <= maxSize) return this + val scale = maxSize.toFloat() / largestSide.toFloat() + val nextWidth = (width * scale).toInt().coerceAtLeast(1) + val nextHeight = (height * scale).toInt().coerceAtLeast(1) + return Bitmap.createScaledBitmap(this, nextWidth, nextHeight, true) + } } + +data class GalleryImage( + val id: String, + val title: String, + val description: String, + val isPublic: Boolean, + val uploadedAt: String?, + val previewUrl: String, + val imageUrl: String, +) + +data class GalleryPage( + val images: List, + val pagination: GalleryPaginationDto, +) diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/MemberAreaRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/MemberAreaRepository.kt index 9c9778d..0c827b6 100644 --- a/android-app/app/src/main/java/de/harheimertc/repositories/MemberAreaRepository.kt +++ b/android-app/app/src/main/java/de/harheimertc/repositories/MemberAreaRepository.kt @@ -5,26 +5,48 @@ import de.harheimertc.data.BirthdaysResponse import de.harheimertc.data.MembersResponse import de.harheimertc.data.NewsResponse import de.harheimertc.data.NewsSaveRequest +import de.harheimertc.data.SecureOfflineCache import javax.inject.Inject -class MemberAreaRepository @Inject constructor(private val api: ApiService) { - suspend fun birthdays(): Result = runCatching { - val response = api.birthdays() - if (!response.isSuccessful) error("Geburtstage konnten nicht geladen werden.") - response.body() ?: error("Leere Antwort vom Server.") - } +class MemberAreaRepository @Inject constructor( + private val api: ApiService, + private val cache: SecureOfflineCache, +) { + suspend fun birthdays(): Result = + fetchEncryptedFallback( + load = { + val response = api.birthdays() + if (!response.isSuccessful) error("Geburtstage konnten nicht geladen werden.") + response.body() ?: error("Leere Antwort vom Server.") + }, + save = cache::putBirthdays, + cached = cache::getBirthdays, + fallbackMessage = "Geburtstage konnten nicht geladen werden.", + ) - suspend fun members(): Result = runCatching { - val response = api.members() - if (!response.isSuccessful) error("Mitglieder konnten nicht geladen werden.") - response.body() ?: error("Leere Antwort vom Server.") - } + suspend fun members(): Result = + fetchEncryptedFallback( + load = { + val response = api.members() + if (!response.isSuccessful) error("Mitglieder konnten nicht geladen werden.") + response.body() ?: error("Leere Antwort vom Server.") + }, + save = cache::putMembers, + cached = cache::getMembers, + fallbackMessage = "Mitglieder konnten nicht geladen werden.", + ) - suspend fun news(): Result = runCatching { - val response = api.memberNews() - if (!response.isSuccessful) error("News konnten nicht geladen werden.") - response.body() ?: error("Leere Antwort vom Server.") - } + suspend fun news(): Result = + fetchEncryptedFallback( + load = { + val response = api.memberNews() + if (!response.isSuccessful) error("News konnten nicht geladen werden.") + response.body() ?: error("Leere Antwort vom Server.") + }, + save = cache::putNews, + cached = cache::getNews, + fallbackMessage = "News konnten nicht geladen werden.", + ) suspend fun saveNews(request: NewsSaveRequest): Result = runCatching { val response = api.saveNews(request) @@ -35,4 +57,17 @@ class MemberAreaRepository @Inject constructor(private val api: ApiService) { val response = api.deleteNews(id) if (!response.isSuccessful) error("News konnten nicht gelöscht werden.") } + + private suspend fun fetchEncryptedFallback( + load: suspend () -> T, + save: (T) -> Unit, + cached: () -> T?, + fallbackMessage: String, + ): Result = runCatching { + runCatching { load() } + .onSuccess(save) + .getOrElse { original -> + cached() ?: throw IllegalStateException(fallbackMessage, original) + } + } } diff --git a/android-app/app/src/main/java/de/harheimertc/security/DeviceKeyManager.kt b/android-app/app/src/main/java/de/harheimertc/security/DeviceKeyManager.kt new file mode 100644 index 0000000..33616fc --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/security/DeviceKeyManager.kt @@ -0,0 +1,74 @@ +package de.harheimertc.security + +import android.content.Context +import android.os.Build +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.Signature +import java.security.spec.ECGenParameterSpec +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class DeviceKeyManager @Inject constructor(@param:ApplicationContext private val context: Context) { + private val alias = "harheimertc_device_key" + private val keyStore: KeyStore by lazy { + KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + } + + fun ensureKeyPair(): String? { + try { + if (!keyStore.containsAlias(alias)) { + val kpg = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore") + val specBuilder = KeyGenParameterSpec.Builder( + alias, + KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY + ) + .setDigests(KeyProperties.DIGEST_SHA256) + .setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1")) + .setUserAuthenticationRequired(false) + + // For older APIs, KeyGenParameterSpec.Builder methods exist from API 23+ + kpg.initialize(specBuilder.build()) + kpg.generateKeyPair() + } + val pub = keyStore.getCertificate(alias).publicKey.encoded + return Base64.encodeToString(pub, Base64.NO_WRAP) + } catch (e: Exception) { + return null + } + } + + fun getPublicKeyBase64(): String? { + return try { + if (!keyStore.containsAlias(alias)) return null + val pub = keyStore.getCertificate(alias).publicKey.encoded + Base64.encodeToString(pub, Base64.NO_WRAP) + } catch (e: Exception) { + null + } + } + + fun sign(data: ByteArray): ByteArray? { + return try { + val privateKey = keyStore.getKey(alias, null) as? java.security.PrivateKey ?: return null + val sig = Signature.getInstance("SHA256withECDSA") + sig.initSign(privateKey) + sig.update(data) + sig.sign() + } catch (e: Exception) { + null + } + } + + fun deleteKey() { + try { + if (keyStore.containsAlias(alias)) keyStore.deleteEntry(alias) + } catch (_: Exception) { + } + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/components/ImageGrid.kt b/android-app/app/src/main/java/de/harheimertc/ui/components/ImageGrid.kt index 5b08b80..e43409e 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/components/ImageGrid.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/components/ImageGrid.kt @@ -17,7 +17,6 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import coil.compose.AsyncImage -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Text import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -25,21 +24,35 @@ import androidx.compose.ui.Alignment import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.window.Dialog +import coil.request.ImageRequest +import de.harheimertc.R +import de.harheimertc.repositories.GalleryImage @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable -fun ImageGrid(images: List, modifier: Modifier = Modifier) { - val selected = remember { mutableStateOf(null) } +fun ImageGrid(images: List, modifier: Modifier = Modifier) { + val selected = remember { mutableStateOf(null) } + val context = LocalContext.current LazyVerticalGrid(columns = GridCells.Fixed(3), modifier = modifier.padding(8.dp)) { items(images) { img -> + val description = stringResource(R.string.gallery_image_description, img.title.ifBlank { img.id }) Card(modifier = Modifier.padding(4.dp), elevation = CardDefaults.cardElevation(4.dp)) { AsyncImage( - model = img, - contentDescription = "Gallery image", + model = ImageRequest.Builder(context) + .data(img.previewUrl) + .size(300, 300) + .crossfade(true) + .build(), + contentDescription = description, modifier = Modifier .aspectRatio(1f) + .semantics { contentDescription = description } .clickable { selected.value = img }, contentScale = ContentScale.Crop ) @@ -51,9 +64,20 @@ fun ImageGrid(images: List, modifier: Modifier = Modifier) { Dialog(onDismissRequest = { selected.value = null }) { Surface(modifier = Modifier.fillMaxSize()) { Box(contentAlignment = Alignment.Center) { - AsyncImage(model = selected.value, contentDescription = "Full image", modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Fit) - Button(onClick = { selected.value = null }, modifier = Modifier.align(Alignment.TopEnd), colors = ButtonDefaults.buttonColors()) { - Text("Schließen") + AsyncImage( + model = selected.value?.imageUrl, + contentDescription = selected.value?.title ?: stringResource(R.string.gallery_title), + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit, + ) + Button( + onClick = { selected.value = null }, + modifier = Modifier + .align(Alignment.TopEnd) + .semantics { contentDescription = context.getString(R.string.gallery_close_image) }, + colors = ButtonDefaults.buttonColors(), + ) { + Text(stringResource(R.string.gallery_upload_hide)) } } } diff --git a/android-app/app/src/main/java/de/harheimertc/ui/components/NativeRichTextEditor.kt b/android-app/app/src/main/java/de/harheimertc/ui/components/NativeRichTextEditor.kt new file mode 100644 index 0000000..34d73c6 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/components/NativeRichTextEditor.kt @@ -0,0 +1,217 @@ +package de.harheimertc.ui.components + +import androidx.compose.foundation.horizontalScroll +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.rememberScrollState +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp + +@Composable +fun NativeRichTextEditor( + value: String, + onValueChange: (String) -> Unit, + label: String, + modifier: Modifier = Modifier, +) { + var fieldValue by remember(value) { mutableStateOf(TextFieldValue(value, TextRange(value.length))) } + var linkDialog by remember { mutableStateOf(false) } + var imageDialog by remember { mutableStateOf(false) } + + fun commit(next: TextFieldValue) { + fieldValue = next + onValueChange(normalizeEmptyHtml(next.text)) + } + + Column(modifier, verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text(label, style = MaterialTheme.typography.titleLarge) + ToolbarRow( + onAction = { action -> + when (action) { + RichTextAction.Link -> linkDialog = true + RichTextAction.Image -> imageDialog = true + RichTextAction.Clean -> commit(fieldValue.copy(text = stripHtml(fieldValue.text), selection = TextRange(stripHtml(fieldValue.text).length))) + else -> commit(applyAction(fieldValue, action)) + } + }, + ) + OutlinedTextField( + value = fieldValue, + onValueChange = { commit(it) }, + label = { Text("HTML-Inhalt") }, + minLines = 12, + modifier = Modifier.fillMaxWidth(), + ) + Surface(color = Color(0xFFF4F4F5), modifier = Modifier.fillMaxWidth()) { + Column(Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Vorschau", style = MaterialTheme.typography.labelLarge) + if (fieldValue.text.isBlank()) Text("Noch kein Inhalt.", color = Color(0xFF71717A)) else RichText(fieldValue.text) + } + } + } + + if (linkDialog) { + UrlDialog( + title = "Link einfügen", + placeholder = "https://...", + onDismiss = { linkDialog = false }, + onConfirm = { url -> + commit(applyLink(fieldValue, url)) + linkDialog = false + }, + ) + } + if (imageDialog) { + UrlDialog( + title = "Bild einfügen", + placeholder = "https://.../bild.jpg", + onDismiss = { imageDialog = false }, + onConfirm = { url -> + commit(insertHtml(fieldValue, """

""")) + imageDialog = false + }, + ) + } +} + +@Composable +private fun ToolbarRow(onAction: (RichTextAction) -> Unit) { + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + RichTextAction.entries.forEach { action -> + AssistChip(onClick = { onAction(action) }, label = { Text(action.label) }) + } + } +} + +@Composable +private fun UrlDialog(title: String, placeholder: String, onDismiss: () -> Unit, onConfirm: (String) -> Unit) { + var value by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + OutlinedTextField(value = value, onValueChange = { value = it }, label = { Text(placeholder) }, singleLine = true) + }, + confirmButton = { + Button(onClick = { onConfirm(value.trim()) }, enabled = value.isNotBlank()) { Text("Einfügen") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Abbrechen") } + }, + ) +} + +private enum class RichTextAction(val label: String) { + H1("H1"), + H2("H2"), + H3("H3"), + Bold("B"), + Italic("I"), + Underline("U"), + Strike("S"), + Color("Farbe"), + Background("Marker"), + OrderedList("1."), + BulletList("•"), + AlignCenter("Zentriert"), + Link("Link"), + Image("Bild"), + Blockquote("Zitat"), + CodeBlock("Code"), + Clean("Clean"), +} + +private fun applyAction(value: TextFieldValue, action: RichTextAction): TextFieldValue = when (action) { + RichTextAction.H1 -> wrapBlock(value, "h1") + RichTextAction.H2 -> wrapBlock(value, "h2") + RichTextAction.H3 -> wrapBlock(value, "h3") + RichTextAction.Bold -> wrapInline(value, "strong") + RichTextAction.Italic -> wrapInline(value, "em") + RichTextAction.Underline -> wrapInline(value, "u") + RichTextAction.Strike -> wrapInline(value, "s") + RichTextAction.Color -> wrapInline(value, "span", " style=\"color: #dc2626;\"") + RichTextAction.Background -> wrapInline(value, "span", " style=\"background-color: #fef3c7;\"") + RichTextAction.OrderedList -> wrapLines(value, "ol") + RichTextAction.BulletList -> wrapLines(value, "ul") + RichTextAction.AlignCenter -> wrapSelection(value, """

""", "

") + RichTextAction.Blockquote -> wrapBlock(value, "blockquote") + RichTextAction.CodeBlock -> wrapSelection(value, """
""", "
") + RichTextAction.Link, + RichTextAction.Image, + RichTextAction.Clean -> value +} + +private fun applyLink(value: TextFieldValue, url: String): TextFieldValue { + val safeUrl = escapeHtml(url) + val label = selectedText(value).ifBlank { safeUrl } + return replaceSelection(value, """$label""") +} + +private fun wrapInline(value: TextFieldValue, tag: String, attrs: String = ""): TextFieldValue = + wrapSelection(value, "<$tag$attrs>", "") + +private fun wrapBlock(value: TextFieldValue, tag: String): TextFieldValue = + wrapSelection(value, "<$tag>", "") + +private fun wrapLines(value: TextFieldValue, listTag: String): TextFieldValue { + val lines = selectedText(value).ifBlank { "Listeneintrag" } + .lines() + .filter { it.isNotBlank() } + .joinToString("") { "
  • ${escapeHtml(it)}
  • " } + return replaceSelection(value, "<$listTag>$lines") +} + +private fun wrapSelection(value: TextFieldValue, prefix: String, suffix: String): TextFieldValue = + replaceSelection(value, prefix + selectedText(value).ifBlank { "Text" } + suffix) + +private fun insertHtml(value: TextFieldValue, html: String): TextFieldValue = replaceSelection(value, html) + +private fun replaceSelection(value: TextFieldValue, replacement: String): TextFieldValue { + val start = value.selection.min.coerceIn(0, value.text.length) + val end = value.selection.max.coerceIn(0, value.text.length) + val next = value.text.replaceRange(start, end, replacement) + val cursor = start + replacement.length + return TextFieldValue(next, TextRange(cursor)) +} + +private fun selectedText(value: TextFieldValue): String { + val start = value.selection.min.coerceIn(0, value.text.length) + val end = value.selection.max.coerceIn(0, value.text.length) + return value.text.substring(start, end) +} + +private fun normalizeEmptyHtml(value: String): String = + if (stripHtml(value).isBlank() && !value.contains("]+>"), "") + .replace(" ", " ") + .trim() + +private fun escapeHtml(value: String): String = value + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsScreens.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsScreens.kt index febeaa9..b6b1591 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsScreens.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsScreens.kt @@ -12,14 +12,19 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight @@ -32,6 +37,8 @@ import de.harheimertc.data.ContactRequestDto import de.harheimertc.data.NewsletterDto import de.harheimertc.data.NewsletterGroupDto import de.harheimertc.data.PasswordResetAttemptDto +import de.harheimertc.ui.components.FormMessages +import de.harheimertc.ui.components.NativeRichTextEditor import de.harheimertc.ui.navigation.Destinations import de.harheimertc.ui.theme.Accent100 import de.harheimertc.ui.theme.Accent500 @@ -61,11 +68,64 @@ fun CmsStartseiteScreen(navController: NavController, showBackNavigation: Boolea @Composable fun CmsInhalteScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) { val state by viewModel.state.collectAsState() - CmsConfigPage(navController, showBackNavigation, "Inhalte", "Vereinsseiten und strukturierte Inhalte", state.config) { config -> - InfoRow("Über uns", textState(config.seiten.ueberUns)) - InfoRow("Geschichte", textState(config.seiten.geschichte)) - InfoRow("Satzung", if (config.seiten.satzung.pdfUrl.isNotBlank()) config.seiten.satzung.pdfUrl else textState(config.seiten.satzung.content)) - InfoRow("Links", "${config.seiten.linksStructured.size} strukturierte Gruppen") + val config = state.config + var ueberUns by remember { mutableStateOf("") } + var geschichte by remember { mutableStateOf("") } + var ttRegeln by remember { mutableStateOf("") } + var satzungContent by remember { mutableStateOf("") } + + LaunchedEffect(config) { + config?.let { + ueberUns = it.seiten.ueberUns + geschichte = it.seiten.geschichte + ttRegeln = it.seiten.ttRegeln + satzungContent = it.seiten.satzung.content + } + } + + CmsPage(navController, showBackNavigation, "Inhalte", "Vereinsseiten und strukturierte Inhalte") { + when { + state.loading || config == null -> item { CircularProgressIndicator(color = Primary600) } + else -> { + item { + Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) { + Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(18.dp)) { + Text("Native Rich-Text-Bearbeitung", style = MaterialTheme.typography.titleLarge, color = Accent900) + Text("Gespeichert wird derselbe HTML-String, den auch der Web-Editor verwendet.", color = Accent500) + NativeRichTextEditor(ueberUns, { ueberUns = it }, "Über uns") + NativeRichTextEditor(geschichte, { geschichte = it }, "Geschichte") + NativeRichTextEditor(ttRegeln, { ttRegeln = it }, "TT-Regeln") + NativeRichTextEditor(satzungContent, { satzungContent = it }, "Satzung") + FormMessages(state.error, state.message) + Button( + onClick = { + viewModel.saveConfig( + config.copy( + seiten = config.seiten.copy( + ueberUns = ueberUns, + geschichte = geschichte, + ttRegeln = ttRegeln, + satzung = config.seiten.satzung.copy(content = satzungContent), + ), + ), + ) + }, + enabled = !state.saving, + modifier = Modifier.fillMaxWidth(), + ) { + Text(if (state.saving) "Speichert..." else "Inhalte speichern") + } + } + } + } + item { + DataCard("Strukturierte Inhalte") { + InfoRow("Links", "${config.seiten.linksStructured.size} strukturierte Gruppen") + InfoRow("Satzung-PDF", config.seiten.satzung.pdfUrl.ifBlank { "Nicht gesetzt" }) + } + } + } + } } } diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsViewModels.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsViewModels.kt index 4e4a5ab..0cdff69 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsViewModels.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsViewModels.kt @@ -18,7 +18,9 @@ import javax.inject.Inject data class CmsUiState( val loading: Boolean = true, + val saving: Boolean = false, val error: String? = null, + val message: String? = null, val config: ConfigResponse? = null, val users: List = emptyList(), val contactRequests: List = emptyList(), @@ -59,4 +61,24 @@ class CmsViewModel @Inject constructor( ) } } + + fun saveConfig(config: ConfigResponse) { + viewModelScope.launch { + _state.value = _state.value.copy(saving = true, error = null, message = null) + repository.saveConfig(config) + .onSuccess { saved -> + _state.value = _state.value.copy( + saving = false, + config = saved, + message = "Inhalt gespeichert.", + ) + } + .onFailure { + _state.value = _state.value.copy( + saving = false, + error = it.message ?: "Inhalt konnte nicht gespeichert werden.", + ) + } + } + } } diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/gallery/GalleryScreen.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/gallery/GalleryScreen.kt index 89152f6..d9c2515 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/gallery/GalleryScreen.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/gallery/GalleryScreen.kt @@ -1,15 +1,41 @@ package de.harheimertc.ui.screens.gallery +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState 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.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import de.harheimertc.R +import de.harheimertc.ui.components.FormMessages import de.harheimertc.ui.components.ImageGrid @Composable @@ -17,17 +43,104 @@ fun GalleryScreen(viewModel: GalleryViewModel = hiltViewModel()) { val images by viewModel.images.collectAsState() val loading by viewModel.loading.collectAsState() val error by viewModel.error.collectAsState() + val uploading by viewModel.uploading.collectAsState() + val message by viewModel.message.collectAsState() + val canUpload by viewModel.canUpload.collectAsState() + var selectedUri by remember { mutableStateOf(null) } + var title by remember { mutableStateOf("") } + var description by remember { mutableStateOf("") } + var isPublic by remember { mutableStateOf(false) } + var showUpload by remember { mutableStateOf(false) } + val context = LocalContext.current + val picker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> + selectedUri = uri + } - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - if (loading) { - CircularProgressIndicator() - } else if (error != null) { - Text(text = "Fehler: $error") - } else { - ImageGrid(images = images) + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + ) { + Text( + text = stringResource(R.string.gallery_title), + modifier = Modifier.semantics { heading() }, + ) + Spacer(Modifier.height(12.dp)) + + if (canUpload) { + ElevatedCard(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.gallery_upload_title), modifier = Modifier.weight(1f)) + OutlinedButton(onClick = { showUpload = !showUpload }) { + Text(if (showUpload) stringResource(R.string.gallery_upload_hide) else stringResource(R.string.gallery_upload_show)) + } + } + if (showUpload) { + Spacer(Modifier.height(12.dp)) + OutlinedButton( + onClick = { picker.launch("image/*") }, + enabled = !uploading, + modifier = Modifier.semantics { + contentDescription = context.getString(R.string.gallery_upload_choose_file) + }, + ) { + Text(selectedUri?.lastPathSegment ?: stringResource(R.string.gallery_upload_choose_file)) + } + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = title, + onValueChange = { title = it }, + label = { Text(stringResource(R.string.gallery_upload_image_title)) }, + modifier = Modifier.fillMaxWidth(), + enabled = !uploading, + singleLine = true, + ) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = description, + onValueChange = { description = it }, + label = { Text(stringResource(R.string.gallery_upload_description)) }, + modifier = Modifier.fillMaxWidth(), + enabled = !uploading, + minLines = 2, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = isPublic, onCheckedChange = { isPublic = it }, enabled = !uploading) + Text(stringResource(R.string.gallery_upload_public)) + } + Button( + onClick = { + selectedUri?.let { uri -> + viewModel.upload(uri, title, description, isPublic) + } + }, + enabled = selectedUri != null && title.isNotBlank() && !uploading, + ) { + Text(if (uploading) stringResource(R.string.gallery_uploading) else stringResource(R.string.gallery_upload_submit)) + } + } + } + } + Spacer(Modifier.height(12.dp)) + } + + FormMessages(error = error, message = message) + Spacer(Modifier.height(8.dp)) + + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + if (loading) { + CircularProgressIndicator() + } else if (images.isEmpty()) { + Text(text = stringResource(R.string.gallery_empty)) + } else { + ImageGrid(images = images, modifier = Modifier.height(520.dp)) + } } } - // load on first composition - androidx.compose.runtime.LaunchedEffect(Unit) { viewModel.load() } + androidx.compose.runtime.LaunchedEffect(Unit) { + viewModel.load() + } } diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/gallery/GalleryViewModel.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/gallery/GalleryViewModel.kt index 6dbd025..b041785 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/gallery/GalleryViewModel.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/gallery/GalleryViewModel.kt @@ -3,16 +3,22 @@ package de.harheimertc.ui.screens.gallery import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import android.net.Uri +import de.harheimertc.repositories.GalleryImage import de.harheimertc.repositories.GalleryRepository +import de.harheimertc.repositories.LoginRepository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel -class GalleryViewModel @Inject constructor(private val repo: GalleryRepository) : ViewModel() { - private val _images = MutableStateFlow>(emptyList()) - val images: StateFlow> = _images +class GalleryViewModel @Inject constructor( + private val repo: GalleryRepository, + private val loginRepository: LoginRepository, +) : ViewModel() { + private val _images = MutableStateFlow>(emptyList()) + val images: StateFlow> = _images private val _loading = MutableStateFlow(false) val loading: StateFlow = _loading @@ -20,14 +26,43 @@ class GalleryViewModel @Inject constructor(private val repo: GalleryRepository) private val _error = MutableStateFlow(null) val error: StateFlow = _error + private val _uploading = MutableStateFlow(false) + val uploading: StateFlow = _uploading + + private val _message = MutableStateFlow(null) + val message: StateFlow = _message + + private val _canUpload = MutableStateFlow(false) + val canUpload: StateFlow = _canUpload + fun load() { viewModelScope.launch { _loading.value = true _error.value = null repo.fetchImages() - .onSuccess { _images.value = it } + .onSuccess { _images.value = it.images } .onFailure { _error.value = it.message ?: "Fehler" } + loginRepository.status() + .onSuccess { status -> + val roles = (status.roles + status.user?.roles.orEmpty() + listOfNotNull(status.role)).toSet() + _canUpload.value = roles.any { it in setOf("admin", "vorstand") } + } _loading.value = false } } + + fun upload(uri: Uri, title: String, description: String, isPublic: Boolean) { + viewModelScope.launch { + _uploading.value = true + _error.value = null + _message.value = null + repo.uploadImage(uri, title, description, isPublic) + .onSuccess { + _message.value = "Bild erfolgreich hochgeladen." + load() + } + .onFailure { _error.value = it.message ?: "Fehler beim Hochladen des Bildes" } + _uploading.value = false + } + } } diff --git a/android-app/app/src/main/res/values-en/strings.xml b/android-app/app/src/main/res/values-en/strings.xml new file mode 100644 index 0000000..89a1317 --- /dev/null +++ b/android-app/app/src/main/res/values-en/strings.xml @@ -0,0 +1,16 @@ + + Harheimer TC + Photo gallery + There are no images in the gallery yet. + Upload image + Open + Close + Select image file + Title + Description (optional) + Publicly visible + Upload image + Uploading... + Close image + Gallery image: %1$s + diff --git a/android-app/app/src/main/res/values/strings.xml b/android-app/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..07b7b23 --- /dev/null +++ b/android-app/app/src/main/res/values/strings.xml @@ -0,0 +1,16 @@ + + Harheimer TC + Bildergalerie + Noch keine Bilder in der Galerie. + Bild hochladen + Öffnen + Schließen + Bilddatei auswählen + Titel + Beschreibung (optional) + Öffentlich sichtbar + Bild hochladen + Wird hochgeladen... + Bild schließen + Galeriebild: %1$s + diff --git a/android-app/app/src/test/java/de/harheimertc/ui/components/FormComponentsTest.kt b/android-app/app/src/test/java/de/harheimertc/ui/components/FormComponentsTest.kt new file mode 100644 index 0000000..87c204c --- /dev/null +++ b/android-app/app/src/test/java/de/harheimertc/ui/components/FormComponentsTest.kt @@ -0,0 +1,30 @@ +package de.harheimertc.ui.components + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class FormComponentsTest { + @Test + fun isValidEmailAcceptsCommonAddresses() { + assertTrue(isValidEmail("mitglied@example.de")) + assertTrue(isValidEmail(" vorstand.name+test@harheimertc.de ")) + } + + @Test + fun isValidEmailRejectsInvalidAddresses() { + assertFalse(isValidEmail("")) + assertFalse(isValidEmail("mitglied")) + assertFalse(isValidEmail("mitglied@example")) + assertFalse(isValidEmail("mitglied @example.de")) + assertFalse(isValidEmail("mitglied@@example.de")) + } + + @Test + fun isValidIsoDateRequiresIsoShape() { + assertTrue(isValidIsoDate("2026-05-28")) + assertFalse(isValidIsoDate("28.05.2026")) + assertFalse(isValidIsoDate("2026-5-28")) + assertFalse(isValidIsoDate("")) + } +} diff --git a/android-app/app/src/test/java/de/harheimertc/ui/screens/cms/CmsViewModelTest.kt b/android-app/app/src/test/java/de/harheimertc/ui/screens/cms/CmsViewModelTest.kt new file mode 100644 index 0000000..7cdf9b4 --- /dev/null +++ b/android-app/app/src/test/java/de/harheimertc/ui/screens/cms/CmsViewModelTest.kt @@ -0,0 +1,77 @@ +package de.harheimertc.ui.screens.cms + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import io.mockk.coEvery +import io.mockk.mockk +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class CmsViewModelTest { + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun load_populatesState() = runTest { + val repo = mockk() + val config = de.harheimertc.data.ConfigResponse(website = de.harheimertc.data.WebsiteDto(verantwortlicher = de.harheimertc.data.WebsiteResponsibleDto(vorname = "TestFirst", nachname = "TestLast", email = "a@b"))) + val users = de.harheimertc.data.CmsUsersResponse(listOf(de.harheimertc.data.CmsUserDto(id = "1", email = "u@e", name = "U"))) + coEvery { repo.config() } returns Result.success(config) + coEvery { repo.users() } returns Result.success(users) + coEvery { repo.contactRequests() } returns Result.success(emptyList()) + coEvery { repo.newsletters() } returns Result.success(de.harheimertc.data.NewsletterListResponse()) + coEvery { repo.newsletterGroups() } returns Result.success(de.harheimertc.data.NewsletterGroupsResponse()) + coEvery { repo.passwordResetDiagnostics() } returns Result.success(de.harheimertc.data.PasswordResetDiagnosticsResponse()) + + val vm = CmsViewModel(repo) + // advance init launched coroutine + dispatcher.scheduler.advanceUntilIdle() + + val state = vm.state.value + assertEquals(false, state.loading) + assertEquals("TestFirst", state.config?.website?.verantwortlicher?.vorname) + assertEquals(1, state.users.size) + } + + @Test + fun saveConfig_success_updatesState() = runTest { + val repo = mockk() + val cfg = de.harheimertc.data.ConfigResponse(website = de.harheimertc.data.WebsiteDto(verantwortlicher = de.harheimertc.data.WebsiteResponsibleDto(vorname = "X"))) + // stub load() calls during ViewModel init + coEvery { repo.config() } returns Result.success(cfg) + coEvery { repo.users() } returns Result.success(de.harheimertc.data.CmsUsersResponse()) + coEvery { repo.contactRequests() } returns Result.success(emptyList()) + coEvery { repo.newsletters() } returns Result.success(de.harheimertc.data.NewsletterListResponse()) + coEvery { repo.newsletterGroups() } returns Result.success(de.harheimertc.data.NewsletterGroupsResponse()) + coEvery { repo.passwordResetDiagnostics() } returns Result.success(de.harheimertc.data.PasswordResetDiagnosticsResponse()) + coEvery { repo.saveConfig(any()) } returns Result.success(cfg) + val vm = CmsViewModel(repo) + + // wait for init/load to finish before saving to avoid race + dispatcher.scheduler.advanceUntilIdle() + + vm.saveConfig(cfg) + dispatcher.scheduler.advanceUntilIdle() + + val state = vm.state.value + assertEquals(false, state.saving) + assertEquals("Inhalt gespeichert.", state.message) + assertEquals("X", state.config?.website?.verantwortlicher?.vorname) + } +} diff --git a/android-app/app/src/test/java/de/harheimertc/ui/screens/gallery/GalleryViewModelTest.kt b/android-app/app/src/test/java/de/harheimertc/ui/screens/gallery/GalleryViewModelTest.kt new file mode 100644 index 0000000..d3f5256 --- /dev/null +++ b/android-app/app/src/test/java/de/harheimertc/ui/screens/gallery/GalleryViewModelTest.kt @@ -0,0 +1,64 @@ +package de.harheimertc.ui.screens.gallery + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import io.mockk.coEvery +import io.mockk.mockk +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import android.net.Uri + +@OptIn(ExperimentalCoroutinesApi::class) +class GalleryViewModelTest { + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun load_setsImagesAndCanUpload() = runTest { + val galleryRepo = mockk() + val loginRepo = mockk() + val page = de.harheimertc.repositories.GalleryPage(images = listOf(de.harheimertc.repositories.GalleryImage(id = "1", title = "T", description = "D", isPublic = true, uploadedAt = null, previewUrl = "p", imageUrl = "u")), pagination = de.harheimertc.data.GalleryPaginationDto()) + coEvery { galleryRepo.fetchImages() } returns Result.success(page) + coEvery { loginRepo.status() } returns Result.success(de.harheimertc.data.AuthStatusResponse(isLoggedIn = true, roles = listOf("admin"))) + + val vm = GalleryViewModel(galleryRepo, loginRepo) + vm.load() + dispatcher.scheduler.advanceUntilIdle() + + assertEquals(1, vm.images.value.size) + assertEquals(true, vm.canUpload.value) + assertEquals(false, vm.loading.value) + } + + @Test + fun upload_callsUploadAndReloads() = runTest { + val galleryRepo = mockk() + val loginRepo = mockk() + coEvery { galleryRepo.uploadImage(any(), any(), any(), any()) } returns Result.success(Unit) + coEvery { galleryRepo.fetchImages() } returns Result.success(de.harheimertc.repositories.GalleryPage(images = emptyList(), pagination = de.harheimertc.data.GalleryPaginationDto())) + coEvery { loginRepo.status() } returns Result.success(de.harheimertc.data.AuthStatusResponse()) + + val vm = GalleryViewModel(galleryRepo, loginRepo) + val testUri = mockk() + vm.upload(testUri, "t", "d", true) + dispatcher.scheduler.advanceUntilIdle() + + assertEquals(false, vm.uploading.value) + assertEquals("Bild erfolgreich hochgeladen.", vm.message.value) + } +} diff --git a/android-app/app/src/test/java/de/harheimertc/ui/screens/login/LoginViewModelTest.kt b/android-app/app/src/test/java/de/harheimertc/ui/screens/login/LoginViewModelTest.kt new file mode 100644 index 0000000..a2412e6 --- /dev/null +++ b/android-app/app/src/test/java/de/harheimertc/ui/screens/login/LoginViewModelTest.kt @@ -0,0 +1,75 @@ +package de.harheimertc.ui.screens.login + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import io.mockk.coEvery +import io.mockk.mockk +import com.squareup.moshi.Moshi +import retrofit2.Response +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +// Fake implementations to avoid network/Hilt in unit tests +// Simple stubs to avoid importing the real repositories and Hilt wiring in tests +// Use mockk to mock the repositories directly for unit testing + +@OptIn(ExperimentalCoroutinesApi::class) +class LoginViewModelTest { + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun login_showsFieldErrors_whenInvalidInput() = runTest { + val repo = mockk() + val passkeyRepo = mockk() + coEvery { repo.status() } returns Result.success(de.harheimertc.data.AuthStatusResponse()) + val vm = LoginViewModel(repo, passkeyRepo) + + vm.setEmail("invalid-email") + vm.setPassword("") + vm.login() + + val state = vm.state.value + assertEquals(true, state.fieldErrors.containsKey("email")) + assertEquals(true, state.fieldErrors.containsKey("password")) + assertEquals("Bitte prüfen Sie die markierten Felder.", state.error) + } + + @Test + fun login_success_updatesState() = runTest { + val loginResp = de.harheimertc.data.LoginResponse(user = de.harheimertc.data.AuthUserDto(name = "Max Mustermann", email = "max@ex.de", roles = listOf("user"))) + val repo = mockk() + val passkeyRepo = mockk() + coEvery { repo.login(any(), any()) } returns Result.success(loginResp) + coEvery { repo.status() } returns Result.success(de.harheimertc.data.AuthStatusResponse()) + val vm = LoginViewModel(repo, passkeyRepo) + + vm.setEmail("max@ex.de") + vm.setPassword("secret") + vm.login() + + // advance until coroutines complete + dispatcher.scheduler.advanceUntilIdle() + + val state = vm.state.value + assertEquals(true, state.loggedIn) + assertEquals("Max Mustermann", state.userName) + assertEquals(false, state.loading) + assertEquals("Anmeldung erfolgreich.", state.message) + } +}