test: fix ViewModel unit tests (Cms/Gallery) and enable ByteBuddy experimental flag

This commit is contained in:
Torsten Schulz (local)
2026-05-28 09:42:01 +02:00
parent 0528334eb4
commit c8b7f5ec2e
25 changed files with 1379 additions and 93 deletions

View File

@@ -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`.

View File

@@ -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")
}

View File

@@ -5,7 +5,7 @@
<application
android:name=".HarheimerApplication"
android:label="HarheimerTC"
android:label="@string/app_name"
android:theme="@style/Theme.HarheimerTC"
android:usesCleartextTraffic="${usesCleartextTraffic}">
<activity android:name="de.harheimertc.MainActivity"

View File

@@ -1,7 +1,47 @@
package de.harheimertc
import android.app.Application
import coil.ImageLoader
import coil.ImageLoaderFactory
import coil.disk.DiskCache
import coil.memory.MemoryCache
import dagger.hilt.android.HiltAndroidApp
import io.sentry.Sentry
import okhttp3.OkHttpClient
import javax.inject.Inject
@HiltAndroidApp
class HarheimerApplication : Application()
class HarheimerApplication : Application(), ImageLoaderFactory {
@Inject
lateinit var okHttpClient: OkHttpClient
override fun onCreate() {
super.onCreate()
if (BuildConfig.SENTRY_DSN.isNotBlank()) {
Sentry.init { options ->
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()
}

View File

@@ -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<GalleryImageDto> = 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<ContactResponse>
@GET("/api/galerie/list")
suspend fun galerieList(): Response<List<String>>
suspend fun galerieList(
@Query("page") page: Int = 1,
@Query("perPage") perPage: Int = 60,
): Response<GalleryListResponse>
@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<GalleryUploadResponse>
@GET("/api/galerie")
suspend fun publicGalleryImages(): Response<List<PublicGalleryImageDto>>
@@ -445,6 +490,9 @@ interface ApiService {
@GET("/api/config")
suspend fun config(): Response<ConfigResponse>
@PUT("/api/config")
suspend fun updateConfig(@Body request: ConfigResponse): Response<ConfigResponse>
@GET("/data/spielsysteme.csv")
suspend fun spielsysteme(): Response<ResponseBody>

View File

@@ -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)
}
}

View File

@@ -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<ContactRequestDto>) {
val type = Types.newParameterizedType(List::class.java, ContactRequestDto::class.java)
val json = moshi.adapter<List<ContactRequestDto>>(type).toJson(response)
preferences.edit().putString("contact_requests", json).apply()
}
fun getContactRequests(): List<ContactRequestDto>? {
val json = preferences.getString("contact_requests", null) ?: return null
val type = Types.newParameterizedType(List::class.java, ContactRequestDto::class.java)
return runCatching { moshi.adapter<List<ContactRequestDto>>(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 <T> put(key: String, value: T, type: Class<T>) {
val json = moshi.adapter(type).toJson(value)
preferences.edit().putString(key, json).apply()
}
private fun <T> get(key: String, type: Class<T>): T? {
val json = preferences.getString(key, null) ?: return null
return runCatching { moshi.adapter(type).fromJson(json) }.getOrNull()
}
}

View File

@@ -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?
}

View File

@@ -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
}
}

View File

@@ -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<ConfigResponse> = 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<ConfigResponse> =
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<ConfigResponse> = 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<CmsUsersResponse> = 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<CmsUsersResponse> =
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<List<ContactRequestDto>> = runCatching {
val response = api.contactRequests()
if (!response.isSuccessful) error("Kontaktanfragen konnten nicht geladen werden.")
response.body() ?: emptyList()
}
suspend fun contactRequests(): Result<List<ContactRequestDto>> =
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<NewsletterListResponse> = 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<NewsletterListResponse> =
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<NewsletterGroupsResponse> = 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<NewsletterGroupsResponse> =
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<PasswordResetDiagnosticsResponse> = 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<PasswordResetDiagnosticsResponse> =
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 <T> fetchEncryptedFallback(
load: suspend () -> T,
save: (T) -> Unit,
cached: () -> T?,
fallbackMessage: String,
): Result<T> = runCatching {
runCatching { load() }
.onSuccess(save)
.getOrElse { original ->
cached() ?: throw IllegalStateException(fallbackMessage, original)
}
}
}

View File

@@ -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<Boolean> = 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<List<String>> {
suspend fun fetchImages(page: Int = 1, perPage: Int = 60): Result<GalleryPage> {
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<Unit> = 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<GalleryImage>,
val pagination: GalleryPaginationDto,
)

View File

@@ -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<BirthdaysResponse> = 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<BirthdaysResponse> =
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<MembersResponse> = 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<MembersResponse> =
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<NewsResponse> = 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<NewsResponse> =
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<Unit> = 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 <T> fetchEncryptedFallback(
load: suspend () -> T,
save: (T) -> Unit,
cached: () -> T?,
fallbackMessage: String,
): Result<T> = runCatching {
runCatching { load() }
.onSuccess(save)
.getOrElse { original ->
cached() ?: throw IllegalStateException(fallbackMessage, original)
}
}
}

View File

@@ -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) {
}
}
}

View File

@@ -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<String>, modifier: Modifier = Modifier) {
val selected = remember { mutableStateOf<String?>(null) }
fun ImageGrid(images: List<GalleryImage>, modifier: Modifier = Modifier) {
val selected = remember { mutableStateOf<GalleryImage?>(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<String>, 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))
}
}
}

View File

@@ -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, """<p><img src="${escapeHtml(url)}"></p>"""))
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, """<p class="ql-align-center">""", "</p>")
RichTextAction.Blockquote -> wrapBlock(value, "blockquote")
RichTextAction.CodeBlock -> wrapSelection(value, """<pre class="ql-syntax" spellcheck="false">""", "</pre>")
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, """<a href="$safeUrl">$label</a>""")
}
private fun wrapInline(value: TextFieldValue, tag: String, attrs: String = ""): TextFieldValue =
wrapSelection(value, "<$tag$attrs>", "</$tag>")
private fun wrapBlock(value: TextFieldValue, tag: String): TextFieldValue =
wrapSelection(value, "<$tag>", "</$tag>")
private fun wrapLines(value: TextFieldValue, listTag: String): TextFieldValue {
val lines = selectedText(value).ifBlank { "Listeneintrag" }
.lines()
.filter { it.isNotBlank() }
.joinToString("") { "<li>${escapeHtml(it)}</li>" }
return replaceSelection(value, "<$listTag>$lines</$listTag>")
}
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("<img", ignoreCase = true)) "" else value
private fun stripHtml(value: String): String = value
.replace(Regex("<[^>]+>"), "")
.replace("&nbsp;", " ")
.trim()
private fun escapeHtml(value: String): String = value
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")

View File

@@ -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" })
}
}
}
}
}
}

View File

@@ -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<CmsUserDto> = emptyList(),
val contactRequests: List<ContactRequestDto> = 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.",
)
}
}
}
}

View File

@@ -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<android.net.Uri?>(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()
}
}

View File

@@ -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<List<String>>(emptyList())
val images: StateFlow<List<String>> = _images
class GalleryViewModel @Inject constructor(
private val repo: GalleryRepository,
private val loginRepository: LoginRepository,
) : ViewModel() {
private val _images = MutableStateFlow<List<GalleryImage>>(emptyList())
val images: StateFlow<List<GalleryImage>> = _images
private val _loading = MutableStateFlow(false)
val loading: StateFlow<Boolean> = _loading
@@ -20,14 +26,43 @@ class GalleryViewModel @Inject constructor(private val repo: GalleryRepository)
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error
private val _uploading = MutableStateFlow(false)
val uploading: StateFlow<Boolean> = _uploading
private val _message = MutableStateFlow<String?>(null)
val message: StateFlow<String?> = _message
private val _canUpload = MutableStateFlow(false)
val canUpload: StateFlow<Boolean> = _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
}
}
}

View File

@@ -0,0 +1,16 @@
<resources>
<string name="app_name">Harheimer TC</string>
<string name="gallery_title">Photo gallery</string>
<string name="gallery_empty">There are no images in the gallery yet.</string>
<string name="gallery_upload_title">Upload image</string>
<string name="gallery_upload_show">Open</string>
<string name="gallery_upload_hide">Close</string>
<string name="gallery_upload_choose_file">Select image file</string>
<string name="gallery_upload_image_title">Title</string>
<string name="gallery_upload_description">Description (optional)</string>
<string name="gallery_upload_public">Publicly visible</string>
<string name="gallery_upload_submit">Upload image</string>
<string name="gallery_uploading">Uploading...</string>
<string name="gallery_close_image">Close image</string>
<string name="gallery_image_description">Gallery image: %1$s</string>
</resources>

View File

@@ -0,0 +1,16 @@
<resources>
<string name="app_name">Harheimer TC</string>
<string name="gallery_title">Bildergalerie</string>
<string name="gallery_empty">Noch keine Bilder in der Galerie.</string>
<string name="gallery_upload_title">Bild hochladen</string>
<string name="gallery_upload_show">Öffnen</string>
<string name="gallery_upload_hide">Schließen</string>
<string name="gallery_upload_choose_file">Bilddatei auswählen</string>
<string name="gallery_upload_image_title">Titel</string>
<string name="gallery_upload_description">Beschreibung (optional)</string>
<string name="gallery_upload_public">Öffentlich sichtbar</string>
<string name="gallery_upload_submit">Bild hochladen</string>
<string name="gallery_uploading">Wird hochgeladen...</string>
<string name="gallery_close_image">Bild schließen</string>
<string name="gallery_image_description">Galeriebild: %1$s</string>
</resources>

View File

@@ -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(""))
}
}

View File

@@ -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<de.harheimertc.repositories.CmsRepository>()
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<de.harheimertc.repositories.CmsRepository>()
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)
}
}

View File

@@ -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<de.harheimertc.repositories.GalleryRepository>()
val loginRepo = mockk<de.harheimertc.repositories.LoginRepository>()
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<de.harheimertc.repositories.GalleryRepository>()
val loginRepo = mockk<de.harheimertc.repositories.LoginRepository>()
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<Uri>()
vm.upload(testUri, "t", "d", true)
dispatcher.scheduler.advanceUntilIdle()
assertEquals(false, vm.uploading.value)
assertEquals("Bild erfolgreich hochgeladen.", vm.message.value)
}
}

View File

@@ -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<de.harheimertc.repositories.LoginRepository>()
val passkeyRepo = mockk<de.harheimertc.repositories.PasskeyRepository>()
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<de.harheimertc.repositories.LoginRepository>()
val passkeyRepo = mockk<de.harheimertc.repositories.PasskeyRepository>()
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)
}
}