diff --git a/android/dms-capture/.gitignore b/android/dms-capture/.gitignore
new file mode 100644
index 00000000..79ca77d9
--- /dev/null
+++ b/android/dms-capture/.gitignore
@@ -0,0 +1,10 @@
+*.iml
+.gradle/
+/local.properties
+/.idea/
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+*.apk
diff --git a/android/dms-capture/app/build.gradle.kts b/android/dms-capture/app/build.gradle.kts
new file mode 100644
index 00000000..8e252010
--- /dev/null
+++ b/android/dms-capture/app/build.gradle.kts
@@ -0,0 +1,78 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("com.google.devtools.ksp")
+}
+
+android {
+ namespace = "de.nexarch.dmscapture"
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "de.nexarch.dmscapture"
+ minSdk = 26
+ targetSdk = 34
+ versionCode = 1
+ versionName = "1.0"
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ // INT-21 AC3/AC5: Server-Basis-URL kommt aus einer Build-Variable, nie hartkodiert.
+ buildConfigField("String", "ACCOUNT_API_BASE_URL", "\"${project.findProperty("accountApiBaseUrl") ?: "http://127.0.0.1:8199"}\"")
+ buildConfigField("String", "DMS_APP_BASE_URL", "\"${project.findProperty("dmsAppBaseUrl") ?: "http://127.0.0.1:8700/api/dms/v1"}\"")
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+ buildFeatures {
+ buildConfig = true
+ viewBinding = true
+ }
+ testOptions {
+ unitTests.isReturnDefaultValues = true
+ unitTests.isIncludeAndroidResources = true
+ }
+}
+
+dependencies {
+ implementation("androidx.core:core-ktx:1.13.1")
+ implementation("androidx.appcompat:appcompat:1.7.0")
+ implementation("com.google.android.material:material:1.12.0")
+ implementation("androidx.constraintlayout:constraintlayout:2.1.4")
+ implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.4")
+ implementation("androidx.security:security-crypto:1.1.0-alpha06")
+
+ // CameraX (INT-21 AC2)
+ implementation("androidx.camera:camera-core:1.3.4")
+ implementation("androidx.camera:camera-camera2:1.3.4")
+ implementation("androidx.camera:camera-lifecycle:1.3.4")
+ implementation("androidx.camera:camera-view:1.3.4")
+
+ // Room (INT-21 AC4: Offline-Warteschlange)
+ implementation("androidx.room:room-runtime:2.6.1")
+ implementation("androidx.room:room-ktx:2.6.1")
+ ksp("androidx.room:room-compiler:2.6.1")
+
+ // WorkManager (INT-21 AC4: automatische Nachreichung)
+ implementation("androidx.work:work-runtime-ktx:2.9.1")
+
+ // HTTP-Client fuer die bestehende DOC-01-Upload-API (kein neuer Backend-Pfad)
+ implementation("com.squareup.okhttp3:okhttp:4.12.0")
+ implementation("org.json:json:20240303")
+
+ testImplementation("junit:junit:4.13.2")
+ testImplementation("org.mockito:mockito-core:5.12.0")
+ testImplementation("org.mockito.kotlin:mockito-kotlin:5.4.0")
+ testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
+ testImplementation("androidx.arch.core:core-testing:2.2.0")
+ testImplementation("org.robolectric:robolectric:4.13")
+ testImplementation("androidx.test:core:1.6.1")
+}
diff --git a/android/dms-capture/app/src/debug/AndroidManifest.xml b/android/dms-capture/app/src/debug/AndroidManifest.xml
new file mode 100644
index 00000000..68f484a4
--- /dev/null
+++ b/android/dms-capture/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/android/dms-capture/app/src/debug/res/xml/network_security_config.xml b/android/dms-capture/app/src/debug/res/xml/network_security_config.xml
new file mode 100644
index 00000000..cf4ce559
--- /dev/null
+++ b/android/dms-capture/app/src/debug/res/xml/network_security_config.xml
@@ -0,0 +1,12 @@
+
+
+
+
+ 127.0.0.1
+ localhost
+ 192.168.1.204
+
+
diff --git a/android/dms-capture/app/src/main/AndroidManifest.xml b/android/dms-capture/app/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..fd9e1408
--- /dev/null
+++ b/android/dms-capture/app/src/main/AndroidManifest.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/auth/AuthApi.kt b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/auth/AuthApi.kt
new file mode 100644
index 00000000..a93d9379
--- /dev/null
+++ b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/auth/AuthApi.kt
@@ -0,0 +1,57 @@
+package de.nexarch.dmscapture.auth
+
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import org.json.JSONObject
+import java.io.IOException
+
+class InvalidCredentialsException : RuntimeException("Anmeldedaten ungültig")
+class ModuleTokenExpiredException : RuntimeException("Sitzungstoken abgelaufen oder widerrufen")
+
+data class SessionToken(val token: String, val expiresAt: String)
+data class ModuleToken(val token: String, val expiresInSeconds: Int, val issuedAtMillis: Long) {
+ fun isExpiringWithin(marginSeconds: Int): Boolean {
+ val ageSeconds = (System.currentTimeMillis() - issuedAtMillis) / 1000
+ return ageSeconds >= expiresInSeconds - marginSeconds
+ }
+}
+
+/**
+ * Spricht account-api an: erst /auth/mobile-login (langlebiges Sitzungstoken,
+ * INT-21 AC1), dann /auth/module-token (kurzlebiges, audience-gebundenes
+ * Token je DOC-01-Aufruf) — beide Endpunkte existieren bereits serverseitig,
+ * kein neuer Backend-Pfad hier.
+ */
+class AuthApi(
+ private val baseUrl: String,
+ private val client: OkHttpClient = OkHttpClient(),
+) {
+ private val jsonMedia = "application/json; charset=utf-8".toMediaType()
+
+ fun login(email: String, password: String): SessionToken {
+ val body = JSONObject().put("email", email).put("password", password).toString().toRequestBody(jsonMedia)
+ val req = Request.Builder().url("$baseUrl/auth/mobile-login").post(body).build()
+ client.newCall(req).execute().use { resp ->
+ if (resp.code == 401) throw InvalidCredentialsException()
+ if (!resp.isSuccessful) throw IOException("mobile-login fehlgeschlagen: ${resp.code}")
+ val json = JSONObject(resp.body?.string().orEmpty())
+ return SessionToken(json.getString("token"), json.getString("expiresAt"))
+ }
+ }
+
+ fun exchangeModuleToken(sessionToken: String): ModuleToken {
+ val req = Request.Builder()
+ .url("$baseUrl/auth/module-token")
+ .addHeader("Cookie", "nexarch_session=$sessionToken")
+ .post("".toRequestBody(null))
+ .build()
+ client.newCall(req).execute().use { resp ->
+ if (resp.code == 401) throw ModuleTokenExpiredException()
+ if (!resp.isSuccessful) throw IOException("module-token fehlgeschlagen: ${resp.code}")
+ val json = JSONObject(resp.body?.string().orEmpty())
+ return ModuleToken(json.getString("token"), json.getInt("expires_in"), System.currentTimeMillis())
+ }
+ }
+}
diff --git a/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/auth/ServerConfig.kt b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/auth/ServerConfig.kt
new file mode 100644
index 00000000..f81bf398
--- /dev/null
+++ b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/auth/ServerConfig.kt
@@ -0,0 +1,36 @@
+package de.nexarch.dmscapture.auth
+
+import android.content.Context
+import de.nexarch.dmscapture.BuildConfig
+
+private const val PREFS_NAME = "dms_capture_server_config"
+private const val KEY_ACCOUNT_URL = "account_api_base_url"
+private const val KEY_DMS_URL = "dms_app_base_url"
+private const val KEY_DIST_URL = "dist_server_base_url"
+
+/**
+ * Laufzeit-Server-Adressen (Nutzer-Fund beim echten Geraetetest, INT-21):
+ * BuildConfig liefert nur den Erstwert, nicht die einzige Quelle -- sonst
+ * braeuchte jede Umgebungsaenderung einen Neu-Build. Persistiert unverschluesselt,
+ * da eine Server-URL kein Geheimnis ist (anders als SessionStore).
+ */
+class ServerConfig(context: Context) {
+ private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+
+ fun accountApiBaseUrl(): String = prefs.getString(KEY_ACCOUNT_URL, null) ?: BuildConfig.ACCOUNT_API_BASE_URL
+ fun dmsAppBaseUrl(): String = prefs.getString(KEY_DMS_URL, null) ?: BuildConfig.DMS_APP_BASE_URL
+
+ // Leer = Versionscheck deaktiviert (kein distserver konfiguriert), kein Fehlerzustand.
+ fun distServerBaseUrl(): String = prefs.getString(KEY_DIST_URL, null) ?: ""
+
+ fun save(accountApiBaseUrl: String, dmsAppBaseUrl: String) {
+ prefs.edit()
+ .putString(KEY_ACCOUNT_URL, accountApiBaseUrl)
+ .putString(KEY_DMS_URL, dmsAppBaseUrl)
+ .apply()
+ }
+
+ fun saveDistServerBaseUrl(url: String) {
+ prefs.edit().putString(KEY_DIST_URL, url).apply()
+ }
+}
diff --git a/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/auth/SessionStore.kt b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/auth/SessionStore.kt
new file mode 100644
index 00000000..226e7f47
--- /dev/null
+++ b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/auth/SessionStore.kt
@@ -0,0 +1,37 @@
+package de.nexarch.dmscapture.auth
+
+import android.content.Context
+import androidx.security.crypto.EncryptedSharedPreferences
+import androidx.security.crypto.MasterKey
+
+private const val PREFS_NAME = "dms_capture_session"
+private const val KEY_TOKEN = "session_token"
+private const val KEY_EXPIRES_AT = "session_expires_at"
+
+/**
+ * Haelt das langlebige Sitzungstoken verschluesselt auf dem Geraet
+ * (Android Keystore ueber EncryptedSharedPreferences) — nie im Klartext,
+ * nie in Logs.
+ */
+class SessionStore(context: Context) {
+ private val masterKey = MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
+ private val prefs = EncryptedSharedPreferences.create(
+ context, PREFS_NAME, masterKey,
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
+ )
+
+ fun save(token: SessionToken) {
+ prefs.edit().putString(KEY_TOKEN, token.token).putString(KEY_EXPIRES_AT, token.expiresAt).apply()
+ }
+
+ fun load(): SessionToken? {
+ val t = prefs.getString(KEY_TOKEN, null) ?: return null
+ val e = prefs.getString(KEY_EXPIRES_AT, null) ?: return null
+ return SessionToken(t, e)
+ }
+
+ fun clear() {
+ prefs.edit().clear().apply()
+ }
+}
diff --git a/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/imaging/ImageProcessor.kt b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/imaging/ImageProcessor.kt
new file mode 100644
index 00000000..bafa3d46
--- /dev/null
+++ b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/imaging/ImageProcessor.kt
@@ -0,0 +1,63 @@
+package de.nexarch.dmscapture.imaging
+
+import android.graphics.Bitmap
+
+/**
+ * INT-21: Kontrast-/Schaerfe-Korrektur einer Kamera-Aufnahme vor dem
+ * Einreihen, wie bei CamScanner-artigen Scan-Apps ueblich. Rein clientseitig,
+ * keine neue schwere Abhaengigkeit. Bewusst per-Pixel statt ueber
+ * Canvas/ColorMatrix umgesetzt: letzteres laeuft ueber die native
+ * Skia-Kompositierung, die in Testumgebungen (Robolectric) nicht simuliert
+ * wird und die Verarbeitung damit ungeprueft liesse.
+ */
+object ImageProcessor {
+ fun enhance(source: Bitmap, contrast: Float = 1.25f, brightness: Int = -12): Bitmap {
+ val w = source.width
+ val h = source.height
+ val pixels = IntArray(w * h)
+ source.getPixels(pixels, 0, w, 0, 0, w, h)
+
+ val contrasted = IntArray(w * h)
+ for (i in pixels.indices) {
+ contrasted[i] = applyContrast(pixels[i], contrast, brightness)
+ }
+
+ val out = contrasted.copyOf()
+ for (y in 1 until h - 1) {
+ for (x in 1 until w - 1) {
+ val center = contrasted[y * w + x]
+ val up = contrasted[(y - 1) * w + x]
+ val down = contrasted[(y + 1) * w + x]
+ val left = contrasted[y * w + (x - 1)]
+ val right = contrasted[y * w + (x + 1)]
+ out[y * w + x] = sharpenPixel(center, up, down, left, right)
+ }
+ }
+
+ val result = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
+ result.setPixels(out, 0, w, 0, 0, w, h)
+ return result
+ }
+
+ private fun applyContrast(pixel: Int, contrast: Float, brightness: Int): Int {
+ val a = (pixel ushr 24) and 0xFF
+ fun channel(shift: Int): Int {
+ val c = (pixel shr shift) and 0xFF
+ return ((c * contrast) + brightness).toInt().coerceIn(0, 255)
+ }
+ return (a shl 24) or (channel(16) shl 16) or (channel(8) shl 8) or channel(0)
+ }
+
+ // Unsharp-Kernel (Zentrum*5, vier Nachbarn*-1) — Rand bleibt unveraendert,
+ // um Randartefakte ohne Padding-Sonderfaelle zu vermeiden.
+ private fun sharpenPixel(center: Int, up: Int, down: Int, left: Int, right: Int): Int {
+ val a = (center ushr 24) and 0xFF
+ fun channel(shift: Int): Int {
+ val c = (center shr shift) and 0xFF
+ val neighborSum = ((up shr shift) and 0xFF) + ((down shr shift) and 0xFF) +
+ ((left shr shift) and 0xFF) + ((right shr shift) and 0xFF)
+ return (c * 5 - neighborSum).coerceIn(0, 255)
+ }
+ return (a shl 24) or (channel(16) shl 16) or (channel(8) shl 8) or channel(0)
+ }
+}
diff --git a/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/queue/CaptureDao.kt b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/queue/CaptureDao.kt
new file mode 100644
index 00000000..ae5f32bd
--- /dev/null
+++ b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/queue/CaptureDao.kt
@@ -0,0 +1,24 @@
+package de.nexarch.dmscapture.queue
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.Query
+import androidx.room.Update
+
+@Dao
+interface CaptureDao {
+ @Insert
+ fun insert(capture: CaptureEntity): Long
+
+ @Update
+ fun update(capture: CaptureEntity)
+
+ @Query("SELECT * FROM captures WHERE status = :status ORDER BY createdAtMillis ASC")
+ fun byStatus(status: CaptureStatus): List
+
+ @Query("SELECT * FROM captures ORDER BY createdAtMillis DESC")
+ fun all(): List
+
+ @Query("SELECT * FROM captures WHERE id = :id")
+ fun byId(id: Long): CaptureEntity?
+}
diff --git a/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/queue/CaptureDatabase.kt b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/queue/CaptureDatabase.kt
new file mode 100644
index 00000000..d361df94
--- /dev/null
+++ b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/queue/CaptureDatabase.kt
@@ -0,0 +1,34 @@
+package de.nexarch.dmscapture.queue
+
+import android.content.Context
+import androidx.room.Database
+import androidx.room.Room
+import androidx.room.RoomDatabase
+import androidx.room.TypeConverter
+import androidx.room.TypeConverters
+
+class Converters {
+ @TypeConverter
+ fun fromStatus(status: CaptureStatus): String = status.name
+
+ @TypeConverter
+ fun toStatus(value: String): CaptureStatus = CaptureStatus.valueOf(value)
+}
+
+@Database(entities = [CaptureEntity::class], version = 1)
+@TypeConverters(Converters::class)
+abstract class CaptureDatabase : RoomDatabase() {
+ abstract fun captureDao(): CaptureDao
+
+ companion object {
+ @Volatile
+ private var instance: CaptureDatabase? = null
+
+ fun get(context: Context): CaptureDatabase =
+ instance ?: synchronized(this) {
+ instance ?: Room.databaseBuilder(
+ context.applicationContext, CaptureDatabase::class.java, "dms-capture.db",
+ ).build().also { instance = it }
+ }
+ }
+}
diff --git a/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/queue/CaptureEntity.kt b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/queue/CaptureEntity.kt
new file mode 100644
index 00000000..ff159e2e
--- /dev/null
+++ b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/queue/CaptureEntity.kt
@@ -0,0 +1,25 @@
+package de.nexarch.dmscapture.queue
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+enum class CaptureStatus {
+ WARTET_AUF_UEBERTRAGUNG,
+ UEBERTRAGEN,
+ ABGELEHNT,
+}
+
+/**
+ * Eine lokal zwischengespeicherte Aufnahme (INT-21 AC4: Offline-Warteschlange).
+ * filePath zeigt auf die im internen App-Speicher abgelegte Bilddatei.
+ */
+@Entity(tableName = "captures")
+data class CaptureEntity(
+ @PrimaryKey(autoGenerate = true) val id: Long = 0,
+ val filePath: String,
+ val mimeType: String,
+ val createdAtMillis: Long,
+ val status: CaptureStatus,
+ val rejectionReason: String? = null,
+ val documentId: String? = null,
+)
diff --git a/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/queue/UploadQueueProcessor.kt b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/queue/UploadQueueProcessor.kt
new file mode 100644
index 00000000..5f4c9a37
--- /dev/null
+++ b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/queue/UploadQueueProcessor.kt
@@ -0,0 +1,84 @@
+package de.nexarch.dmscapture.queue
+
+import de.nexarch.dmscapture.auth.AuthApi
+import de.nexarch.dmscapture.auth.ModuleToken
+import de.nexarch.dmscapture.auth.SessionStore
+import de.nexarch.dmscapture.upload.DocApiUploadClient
+import de.nexarch.dmscapture.upload.UploadRejectedException
+import java.io.File
+import java.io.IOException
+
+// Modul-Tokens laufen serverseitig nach expiresInSeconds ab; unter dieser
+// Marge wird vor jedem Verarbeitungslauf proaktiv erneuert statt erst nach
+// einem 401 zu reagieren.
+private const val REFRESH_MARGIN_SECONDS = 60
+
+class NotLoggedInException : RuntimeException("keine gültige Anmeldung")
+
+/**
+ * Verarbeitet die Offline-Warteschlange (INT-21 AC4): fuer jede Aufnahme mit
+ * Status WARTET_AUF_UEBERTRAGUNG wird versucht, sie ueber die bestehende
+ * DOC-01-API hochzuladen. Netzwerkfehler lassen den Status unveraendert
+ * (spaeterer Versuch), eine echte Ablehnung (falscher Typ/zu gross) setzt
+ * ABGELEHNT mit einer fuer den Nutzer verstaendlichen Begruendung
+ * (Akzeptanzkriterium 5) statt nur einem Fehlercode.
+ */
+class UploadQueueProcessor(
+ private val dao: CaptureDao,
+ private val authApi: AuthApi,
+ private val sessionStore: SessionStore,
+ private val uploadClient: DocApiUploadClient,
+) {
+ private var cachedModuleToken: ModuleToken? = null
+
+ fun processAll(): ProcessSummary {
+ val pending = dao.byStatus(CaptureStatus.WARTET_AUF_UEBERTRAGUNG)
+ var succeeded = 0
+ var rejected = 0
+ var stillPending = 0
+ for (capture in pending) {
+ when (processOne(capture)) {
+ Outcome.UPLOADED -> succeeded++
+ Outcome.REJECTED -> rejected++
+ Outcome.RETRY_LATER -> stillPending++
+ }
+ }
+ return ProcessSummary(succeeded, rejected, stillPending)
+ }
+
+ private enum class Outcome { UPLOADED, REJECTED, RETRY_LATER }
+
+ private fun processOne(capture: CaptureEntity): Outcome {
+ val token = try {
+ moduleToken()
+ } catch (e: Exception) {
+ return Outcome.RETRY_LATER
+ }
+ val file = File(capture.filePath)
+ if (!file.exists()) {
+ dao.update(capture.copy(status = CaptureStatus.ABGELEHNT, rejectionReason = "Datei nicht mehr vorhanden"))
+ return Outcome.REJECTED
+ }
+ return try {
+ val result = uploadClient.upload(file, capture.mimeType, token.token)
+ dao.update(capture.copy(status = CaptureStatus.UEBERTRAGEN, documentId = result.documentId))
+ Outcome.UPLOADED
+ } catch (e: UploadRejectedException) {
+ dao.update(capture.copy(status = CaptureStatus.ABGELEHNT, rejectionReason = e.serverMessage))
+ Outcome.REJECTED
+ } catch (e: IOException) {
+ Outcome.RETRY_LATER
+ }
+ }
+
+ private fun moduleToken(): ModuleToken {
+ val cached = cachedModuleToken
+ if (cached != null && !cached.isExpiringWithin(REFRESH_MARGIN_SECONDS)) return cached
+ val session = sessionStore.load() ?: throw NotLoggedInException()
+ val fresh = authApi.exchangeModuleToken(session.token)
+ cachedModuleToken = fresh
+ return fresh
+ }
+}
+
+data class ProcessSummary(val succeeded: Int, val rejected: Int, val stillPending: Int)
diff --git a/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/queue/UploadRetryWorker.kt b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/queue/UploadRetryWorker.kt
new file mode 100644
index 00000000..c1eb4140
--- /dev/null
+++ b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/queue/UploadRetryWorker.kt
@@ -0,0 +1,45 @@
+package de.nexarch.dmscapture.queue
+
+import android.content.Context
+import androidx.work.Constraints
+import androidx.work.NetworkType
+import androidx.work.PeriodicWorkRequestBuilder
+import androidx.work.WorkManager
+import androidx.work.Worker
+import androidx.work.WorkerParameters
+import de.nexarch.dmscapture.auth.AuthApi
+import de.nexarch.dmscapture.auth.ServerConfig
+import de.nexarch.dmscapture.auth.SessionStore
+import de.nexarch.dmscapture.upload.DocApiUploadClient
+import java.util.concurrent.TimeUnit
+
+private const val WORK_NAME = "dms-capture-upload-retry"
+
+/**
+ * Laeuft periodisch, sobald eine Netzwerkverbindung besteht (INT-21 AC4:
+ * "automatische Nachreichung ohne Nutzeraktion"). WorkManager uebernimmt
+ * selbst die Wiederholung bei Systemneustart/App-Kill.
+ */
+class UploadRetryWorker(context: Context, params: WorkerParameters) : Worker(context, params) {
+ override fun doWork(): Result {
+ val db = CaptureDatabase.get(applicationContext)
+ val serverConfig = ServerConfig(applicationContext)
+ val authApi = AuthApi(serverConfig.accountApiBaseUrl())
+ val uploadClient = DocApiUploadClient(serverConfig.dmsAppBaseUrl())
+ val processor = UploadQueueProcessor(db.captureDao(), authApi, SessionStore(applicationContext), uploadClient)
+ val summary = processor.processAll()
+ return if (summary.stillPending > 0) Result.retry() else Result.success()
+ }
+
+ companion object {
+ fun schedule(context: Context) {
+ val constraints = Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()
+ val request = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES)
+ .setConstraints(constraints)
+ .build()
+ WorkManager.getInstance(context).enqueueUniquePeriodicWork(
+ WORK_NAME, androidx.work.ExistingPeriodicWorkPolicy.KEEP, request,
+ )
+ }
+ }
+}
diff --git a/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/ui/CaptureActivity.kt b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/ui/CaptureActivity.kt
new file mode 100644
index 00000000..c332326f
--- /dev/null
+++ b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/ui/CaptureActivity.kt
@@ -0,0 +1,159 @@
+package de.nexarch.dmscapture.ui
+
+import android.Manifest
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.Bundle
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AppCompatActivity
+import androidx.camera.core.CameraSelector
+import androidx.camera.core.ImageCapture
+import androidx.camera.core.ImageCaptureException
+import androidx.camera.core.Preview
+import androidx.camera.lifecycle.ProcessCameraProvider
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import androidx.core.content.ContextCompat
+import android.net.Uri
+import de.nexarch.dmscapture.R
+import de.nexarch.dmscapture.auth.ServerConfig
+import de.nexarch.dmscapture.imaging.ImageProcessor
+import de.nexarch.dmscapture.update.VersionCheck
+import de.nexarch.dmscapture.queue.CaptureDatabase
+import de.nexarch.dmscapture.queue.CaptureEntity
+import de.nexarch.dmscapture.queue.CaptureStatus
+import de.nexarch.dmscapture.queue.UploadRetryWorker
+import java.io.File
+import java.io.FileOutputStream
+import java.util.concurrent.Executors
+import java.text.SimpleDateFormat
+import java.util.Locale
+
+/**
+ * INT-21 AC2: Kamera-Aufnahme mit Live-Vorschau, erneuter Aufnahme,
+ * Sammeln mehrerer Seiten vor dem Absenden. AC4: jede fertige Aufnahme
+ * landet zunaechst in der lokalen Warteschlange (Status
+ * WARTET_AUF_UEBERTRAGUNG), Netzwerkzugriff selbst passiert nur im
+ * Hintergrund-Worker — nie blockierend in dieser Activity.
+ */
+class CaptureActivity : AppCompatActivity() {
+ private lateinit var imageCapture: ImageCapture
+ private var collectedPages = 0
+ private val ioExecutor = Executors.newSingleThreadExecutor()
+
+ private val requestPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
+ if (granted) startCamera() else finish()
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_capture)
+
+ if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
+ startCamera()
+ } else {
+ requestPermission.launch(Manifest.permission.CAMERA)
+ }
+
+ findViewById(R.id.captureButton).setOnClickListener { capture() }
+ findViewById(R.id.submitButton).setOnClickListener { finishCollection() }
+ findViewById(R.id.queueButton).setOnClickListener {
+ startActivity(Intent(this, QueueActivity::class.java))
+ }
+
+ checkForUpdate()
+ }
+
+ private fun checkForUpdate() {
+ val distUrl = ServerConfig(this).distServerBaseUrl()
+ if (distUrl.isBlank()) return
+ ioExecutor.execute {
+ val currentCode = packageManager.getPackageInfo(packageName, 0).let {
+ if (android.os.Build.VERSION.SDK_INT >= 28) it.longVersionCode.toInt() else @Suppress("DEPRECATION") it.versionCode
+ }
+ val info = VersionCheck().checkForUpdate(distUrl, currentCode)
+ if (info != null) {
+ runOnUiThread {
+ val banner = findViewById(R.id.updateBanner)
+ banner.text = "Neue Version ${info.versionName} verfügbar. Zum Herunterladen antippen."
+ banner.visibility = android.view.View.VISIBLE
+ banner.setOnClickListener {
+ startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(info.apkUrl)))
+ }
+ }
+ }
+ }
+ }
+
+ private fun startCamera() {
+ val providerFuture = ProcessCameraProvider.getInstance(this)
+ providerFuture.addListener({
+ val provider = providerFuture.get()
+ val preview = Preview.Builder().build().also {
+ it.setSurfaceProvider(findViewById(R.id.previewView).surfaceProvider)
+ }
+ imageCapture = ImageCapture.Builder().build()
+ provider.unbindAll()
+ provider.bindToLifecycle(this, CameraSelector.DEFAULT_BACK_CAMERA, preview, imageCapture)
+ }, ContextCompat.getMainExecutor(this))
+ }
+
+ private fun capture() {
+ val fileName = "capture-${SimpleDateFormat("yyyyMMdd-HHmmss-SSS", Locale.GERMANY).format(java.util.Date())}.jpg"
+ val file = File(filesDir, fileName)
+ val outputOptions = ImageCapture.OutputFileOptions.Builder(file).build()
+ imageCapture.takePicture(outputOptions, ContextCompat.getMainExecutor(this), object : ImageCapture.OnImageSavedCallback {
+ override fun onImageSaved(output: ImageCapture.OutputFileResults) {
+ ioExecutor.execute {
+ correctImage(file)
+ enqueueCapture(file)
+ runOnUiThread {
+ collectedPages++
+ findViewById(R.id.pageCountText).text = "$collectedPages Seiten gesammelt"
+ }
+ }
+ }
+
+ override fun onError(exception: ImageCaptureException) {
+ android.widget.Toast.makeText(this@CaptureActivity, "Aufnahme fehlgeschlagen: ${exception.message}", android.widget.Toast.LENGTH_LONG).show()
+ }
+ })
+ }
+
+ // INT-21: Kontrast-/Schaerfe-Korrektur vor dem Einreihen (ueberschreibt die
+ // Rohaufnahme). Schlaegt die Verarbeitung fehl, wird das Originalbild
+ // unveraendert weiterverwendet statt die Aufnahme zu verlieren.
+ private fun correctImage(file: File) {
+ try {
+ val original = BitmapFactory.decodeFile(file.absolutePath) ?: return
+ val corrected = ImageProcessor.enhance(original)
+ FileOutputStream(file).use { out ->
+ corrected.compress(Bitmap.CompressFormat.JPEG, 92, out)
+ }
+ } catch (_: Exception) {
+ // Rohaufnahme bleibt erhalten, kein Datenverlust bei Verarbeitungsfehler.
+ }
+ }
+
+ private fun enqueueCapture(file: File) {
+ val dao = CaptureDatabase.get(this).captureDao()
+ dao.insert(
+ CaptureEntity(
+ filePath = file.absolutePath,
+ mimeType = "image/jpeg",
+ createdAtMillis = System.currentTimeMillis(),
+ status = CaptureStatus.WARTET_AUF_UEBERTRAGUNG,
+ ),
+ )
+ }
+
+ private fun finishCollection() {
+ UploadRetryWorker.schedule(this)
+ androidx.work.WorkManager.getInstance(this).enqueue(
+ androidx.work.OneTimeWorkRequestBuilder()
+ .setConstraints(androidx.work.Constraints.Builder().setRequiredNetworkType(androidx.work.NetworkType.CONNECTED).build())
+ .build(),
+ )
+ startActivity(Intent(this, QueueActivity::class.java))
+ }
+}
diff --git a/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/ui/LoginActivity.kt b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/ui/LoginActivity.kt
new file mode 100644
index 00000000..4bd8d609
--- /dev/null
+++ b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/ui/LoginActivity.kt
@@ -0,0 +1,79 @@
+package de.nexarch.dmscapture.ui
+
+import android.content.Intent
+import android.os.Bundle
+import androidx.appcompat.app.AppCompatActivity
+import de.nexarch.dmscapture.R
+import de.nexarch.dmscapture.auth.AuthApi
+import de.nexarch.dmscapture.auth.InvalidCredentialsException
+import de.nexarch.dmscapture.auth.ServerConfig
+import de.nexarch.dmscapture.auth.SessionStore
+import kotlin.concurrent.thread
+
+/**
+ * INT-21 AC1: Login mit Benutzername/Passwort, liefert bei Erfolg ein
+ * langlebiges Zugriffstoken, das ausschliesslich verschluesselt im
+ * SessionStore abgelegt wird. Server-Adressen kommen aus ServerConfig
+ * (Laufzeit-Einstellung, Nutzer-Fund beim echten Geraetetest), nicht mehr
+ * fest aus BuildConfig.
+ */
+class LoginActivity : AppCompatActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_login)
+
+ val sessionStore = SessionStore(this)
+ if (sessionStore.load() != null) {
+ startActivity(Intent(this, CaptureActivity::class.java))
+ finish()
+ return
+ }
+
+ val serverConfig = ServerConfig(this)
+ val serverAccountInput = findViewById(R.id.serverAccountInput)
+ val serverDmsInput = findViewById(R.id.serverDmsInput)
+ val serverDistInput = findViewById(R.id.serverDistInput)
+ serverAccountInput.setText(serverConfig.accountApiBaseUrl())
+ serverDmsInput.setText(serverConfig.dmsAppBaseUrl())
+ serverDistInput.setText(serverConfig.distServerBaseUrl())
+
+ val emailInput = findViewById(R.id.emailInput)
+ val passwordInput = findViewById(R.id.passwordInput)
+ val loginButton = findViewById(R.id.loginButton)
+ val errorText = findViewById(R.id.errorText)
+
+ loginButton.setOnClickListener {
+ val accountUrl = serverAccountInput.text.toString().trimEnd('/')
+ val dmsUrl = serverDmsInput.text.toString().trimEnd('/')
+ serverConfig.save(accountUrl, dmsUrl)
+ serverConfig.saveDistServerBaseUrl(serverDistInput.text.toString().trimEnd('/'))
+
+ val email = emailInput.text.toString()
+ val password = passwordInput.text.toString()
+ errorText.visibility = android.view.View.GONE
+ loginButton.isEnabled = false
+ thread {
+ try {
+ val token = AuthApi(accountUrl).login(email, password)
+ sessionStore.save(token)
+ runOnUiThread {
+ startActivity(Intent(this, CaptureActivity::class.java))
+ finish()
+ }
+ } catch (e: InvalidCredentialsException) {
+ runOnUiThread {
+ errorText.text = "Anmeldedaten oder Konto ungültig."
+ errorText.visibility = android.view.View.VISIBLE
+ loginButton.isEnabled = true
+ }
+ } catch (e: Exception) {
+ runOnUiThread {
+ errorText.text = "Anmeldung derzeit nicht möglich: ${e.message}"
+ errorText.visibility = android.view.View.VISIBLE
+ loginButton.isEnabled = true
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/ui/QueueActivity.kt b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/ui/QueueActivity.kt
new file mode 100644
index 00000000..075aa628
--- /dev/null
+++ b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/ui/QueueActivity.kt
@@ -0,0 +1,48 @@
+package de.nexarch.dmscapture.ui
+
+import android.os.Bundle
+import android.widget.ArrayAdapter
+import androidx.appcompat.app.AppCompatActivity
+import de.nexarch.dmscapture.R
+import de.nexarch.dmscapture.queue.CaptureDatabase
+import de.nexarch.dmscapture.queue.CaptureStatus
+import java.util.concurrent.Executors
+
+/**
+ * INT-21 AC4: sichtbarer Status jeder Aufnahme ("wartet auf Übertragung",
+ * übertragen, abgelehnt mit Begründung) — keine stille Warteschlange.
+ */
+class QueueActivity : AppCompatActivity() {
+ private val ioExecutor = Executors.newSingleThreadExecutor()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_queue)
+ refresh()
+ }
+
+ override fun onResume() {
+ super.onResume()
+ refresh()
+ }
+
+ private fun refresh() {
+ // Room verbietet Datenbankzugriff auf dem Main-Thread (AC4-Absturz real
+ // auf einem Geraet reproduziert, hier behoben statt nur unit-getestet).
+ ioExecutor.execute {
+ val captures = CaptureDatabase.get(this).captureDao().all()
+ val lines = captures.map { c ->
+ val label = when (c.status) {
+ CaptureStatus.WARTET_AUF_UEBERTRAGUNG -> "Wartet auf Übertragung"
+ CaptureStatus.UEBERTRAGEN -> "Übertragen (Dokument ${c.documentId})"
+ CaptureStatus.ABGELEHNT -> "Abgelehnt: ${c.rejectionReason}"
+ }
+ "${c.filePath.substringAfterLast('/')} — $label"
+ }
+ runOnUiThread {
+ findViewById(R.id.queueList).adapter =
+ ArrayAdapter(this, android.R.layout.simple_list_item_1, lines)
+ }
+ }
+ }
+}
diff --git a/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/update/VersionCheck.kt b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/update/VersionCheck.kt
new file mode 100644
index 00000000..d10c9c10
--- /dev/null
+++ b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/update/VersionCheck.kt
@@ -0,0 +1,32 @@
+package de.nexarch.dmscapture.update
+
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import org.json.JSONObject
+
+data class UpdateInfo(val versionCode: Int, val versionName: String, val apkUrl: String)
+
+/**
+ * Fragt beim App-Start den distserver ab (INT-21, internes APK-Hosting) und
+ * vergleicht gegen den lokalen versionCode. Kein Auto-Update, nur ein
+ * sichtbarer Hinweis -- Netzwerkfehler/leere Adresse sind kein Fehlerfall,
+ * die App muss ohne Update-Server funktionieren.
+ */
+class VersionCheck(private val client: OkHttpClient = OkHttpClient()) {
+
+ fun checkForUpdate(distServerBaseUrl: String, currentVersionCode: Int): UpdateInfo? {
+ if (distServerBaseUrl.isBlank()) return null
+ return try {
+ val req = Request.Builder().url("$distServerBaseUrl/version").get().build()
+ client.newCall(req).execute().use { resp ->
+ if (!resp.isSuccessful) return null
+ val json = JSONObject(resp.body?.string().orEmpty())
+ val remoteCode = json.getInt("versionCode")
+ if (remoteCode <= currentVersionCode) return null
+ UpdateInfo(remoteCode, json.getString("versionName"), distServerBaseUrl + json.getString("apkUrl"))
+ }
+ } catch (e: Exception) {
+ null
+ }
+ }
+}
diff --git a/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/upload/DocApiUploadClient.kt b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/upload/DocApiUploadClient.kt
new file mode 100644
index 00000000..763140c2
--- /dev/null
+++ b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/upload/DocApiUploadClient.kt
@@ -0,0 +1,87 @@
+package de.nexarch.dmscapture.upload
+
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import org.json.JSONObject
+import java.io.File
+import java.io.IOException
+
+class UploadRejectedException(val httpCode: Int, val serverMessage: String) :
+ RuntimeException("Upload abgelehnt ($httpCode): $serverMessage")
+
+data class UploadResult(val documentId: String, val revisionId: String)
+
+/**
+ * Ruft ausschliesslich die bestehende DOC-01-Upload-API auf
+ * (POST /uploads, PUT /uploads/{id}/chunk, POST /uploads/{id}/complete) —
+ * kein eigener Uploadpfad, dieselbe API wie INT-19 (Weboberflaeche).
+ */
+class DocApiUploadClient(
+ private val dmsBaseUrl: String,
+ private val client: OkHttpClient = OkHttpClient(),
+) {
+ private val jsonMedia = "application/json; charset=utf-8".toMediaType()
+
+ fun upload(file: File, mimeType: String, bearerToken: String): UploadResult {
+ val sessionId = start(file.name, mimeType, file.length(), bearerToken)
+ sendChunk(sessionId, file, bearerToken)
+ return complete(sessionId, bearerToken)
+ }
+
+ private fun start(filename: String, mimeType: String, totalSize: Long, token: String): String {
+ val body = JSONObject()
+ .put("filename", filename)
+ .put("mimeType", mimeType)
+ .put("totalSize", totalSize)
+ .toString().toRequestBody(jsonMedia)
+ val req = Request.Builder()
+ .url("$dmsBaseUrl/uploads")
+ .addHeader("Authorization", "Bearer $token")
+ .post(body)
+ .build()
+ client.newCall(req).execute().use { resp ->
+ val json = parseOrThrow(resp)
+ return json.getString("sessionId")
+ }
+ }
+
+ private fun sendChunk(sessionId: String, file: File, token: String) {
+ val bytes = file.readBytes()
+ val req = Request.Builder()
+ .url("$dmsBaseUrl/uploads/$sessionId/chunk?offset=0")
+ .addHeader("Authorization", "Bearer $token")
+ .put(bytes.toRequestBody("application/octet-stream".toMediaType()))
+ .build()
+ client.newCall(req).execute().use { resp ->
+ parseOrThrow(resp)
+ }
+ }
+
+ private fun complete(sessionId: String, token: String): UploadResult {
+ val req = Request.Builder()
+ .url("$dmsBaseUrl/uploads/$sessionId/complete")
+ .addHeader("Authorization", "Bearer $token")
+ .post("".toRequestBody(null))
+ .build()
+ client.newCall(req).execute().use { resp ->
+ val json = parseOrThrow(resp)
+ return UploadResult(json.getString("documentId"), json.optString("revisionId", ""))
+ }
+ }
+
+ private fun parseOrThrow(resp: okhttp3.Response): JSONObject {
+ val text = resp.body?.string().orEmpty()
+ if (!resp.isSuccessful) {
+ val message = try {
+ JSONObject(text).optJSONObject("error")?.optString("message") ?: text
+ } catch (e: Exception) {
+ text
+ }
+ throw UploadRejectedException(resp.code, message)
+ }
+ if (text.isBlank()) throw IOException("leere Antwort vom Server")
+ return JSONObject(text)
+ }
+}
diff --git a/android/dms-capture/app/src/main/res/drawable/ic_launcher_background.xml b/android/dms-capture/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 00000000..5ecd5e20
--- /dev/null
+++ b/android/dms-capture/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/android/dms-capture/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/dms-capture/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 00000000..f71a2647
--- /dev/null
+++ b/android/dms-capture/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
diff --git a/android/dms-capture/app/src/main/res/layout/activity_capture.xml b/android/dms-capture/app/src/main/res/layout/activity_capture.xml
new file mode 100644
index 00000000..cf4539be
--- /dev/null
+++ b/android/dms-capture/app/src/main/res/layout/activity_capture.xml
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/dms-capture/app/src/main/res/layout/activity_login.xml b/android/dms-capture/app/src/main/res/layout/activity_login.xml
new file mode 100644
index 00000000..22ae7e2a
--- /dev/null
+++ b/android/dms-capture/app/src/main/res/layout/activity_login.xml
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/dms-capture/app/src/main/res/layout/activity_queue.xml b/android/dms-capture/app/src/main/res/layout/activity_queue.xml
new file mode 100644
index 00000000..3dd8d8b9
--- /dev/null
+++ b/android/dms-capture/app/src/main/res/layout/activity_queue.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
diff --git a/android/dms-capture/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/dms-capture/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 00000000..6b78462d
--- /dev/null
+++ b/android/dms-capture/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/android/dms-capture/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/dms-capture/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 00000000..6b78462d
--- /dev/null
+++ b/android/dms-capture/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/android/dms-capture/app/src/main/res/values/ic_launcher_background.xml b/android/dms-capture/app/src/main/res/values/ic_launcher_background.xml
new file mode 100644
index 00000000..e469a8a1
--- /dev/null
+++ b/android/dms-capture/app/src/main/res/values/ic_launcher_background.xml
@@ -0,0 +1,4 @@
+
+
+ #1B4F72
+
diff --git a/android/dms-capture/app/src/test/java/de/nexarch/dmscapture/auth/AuthApiTest.kt b/android/dms-capture/app/src/test/java/de/nexarch/dmscapture/auth/AuthApiTest.kt
new file mode 100644
index 00000000..62da7fdd
--- /dev/null
+++ b/android/dms-capture/app/src/test/java/de/nexarch/dmscapture/auth/AuthApiTest.kt
@@ -0,0 +1,72 @@
+package de.nexarch.dmscapture.auth
+
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertThrows
+import org.junit.Before
+import org.junit.Test
+
+class AuthApiTest {
+ private lateinit var server: MockWebServer
+ private lateinit var api: AuthApi
+
+ @Before
+ fun setUp() {
+ server = MockWebServer()
+ server.start()
+ api = AuthApi(server.url("/").toString().trimEnd('/'))
+ }
+
+ @After
+ fun tearDown() {
+ server.shutdown()
+ }
+
+ // INT-21 Pruefung 1: erfolgreicher Login liefert Token+Ablaufzeit.
+ @Test
+ fun `login liefert token und ablaufzeit bei gueltigen daten`() {
+ server.enqueue(MockResponse().setBody("""{"token":"abc.def.ghi","expiresAt":"2027-01-01T00:00:00Z"}""").setResponseCode(200))
+ val token = api.login("alice@example.com", "geheim")
+ assertEquals("abc.def.ghi", token.token)
+ assertEquals("2027-01-01T00:00:00Z", token.expiresAt)
+ }
+
+ // INT-21 Pruefung 1: falsches Passwort wird abgewiesen.
+ @Test
+ fun `login wirft InvalidCredentialsException bei 401`() {
+ server.enqueue(MockResponse().setResponseCode(401).setBody("Anmeldedaten oder E-Mail falsch"))
+ assertThrows(InvalidCredentialsException::class.java) {
+ api.login("alice@example.com", "falsch")
+ }
+ }
+
+ // INT-21 Pruefung 1: unbekannter Nutzer liefert denselben Fehler wie falsches Passwort (kein Enumerationsleck).
+ @Test
+ fun `login wirft InvalidCredentialsException bei unbekanntem nutzer`() {
+ server.enqueue(MockResponse().setResponseCode(401).setBody("Anmeldedaten oder E-Mail falsch"))
+ assertThrows(InvalidCredentialsException::class.java) {
+ api.login("unbekannt@example.com", "irgendwas")
+ }
+ }
+
+ // INT-21 Pruefung 1: abgelaufenes/widerrufenes Modul-Token wird beim Tausch abgewiesen.
+ @Test
+ fun `exchangeModuleToken wirft ModuleTokenExpiredException bei 401`() {
+ server.enqueue(MockResponse().setResponseCode(401))
+ assertThrows(ModuleTokenExpiredException::class.java) {
+ api.exchangeModuleToken("abgelaufenes-token")
+ }
+ }
+
+ @Test
+ fun `exchangeModuleToken sendet sitzungstoken als cookie header`() {
+ server.enqueue(MockResponse().setBody("""{"token":"mod.tok.en","token_type":"Bearer","expires_in":1800}""").setResponseCode(200))
+ val result = api.exchangeModuleToken("mein-sitzungstoken")
+ assertEquals("mod.tok.en", result.token)
+ assertEquals(1800, result.expiresInSeconds)
+ val recorded = server.takeRequest()
+ assertEquals("nexarch_session=mein-sitzungstoken", recorded.getHeader("Cookie"))
+ }
+}
diff --git a/android/dms-capture/app/src/test/java/de/nexarch/dmscapture/auth/ServerConfigTest.kt b/android/dms-capture/app/src/test/java/de/nexarch/dmscapture/auth/ServerConfigTest.kt
new file mode 100644
index 00000000..a6c0492a
--- /dev/null
+++ b/android/dms-capture/app/src/test/java/de/nexarch/dmscapture/auth/ServerConfigTest.kt
@@ -0,0 +1,48 @@
+package de.nexarch.dmscapture.auth
+
+import android.content.Context
+import androidx.test.core.app.ApplicationProvider
+import de.nexarch.dmscapture.BuildConfig
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+/**
+ * INT-21 Nutzer-Fund: Server-Adressen muessen zur Laufzeit aenderbar sein,
+ * nicht nur ueber BuildConfig zur Build-Zeit.
+ */
+@RunWith(RobolectricTestRunner::class)
+class ServerConfigTest {
+ private val context = ApplicationProvider.getApplicationContext()
+
+ @Test
+ fun defaultsToBuildConfigValuesWhenNothingSavedYet() {
+ val config = ServerConfig(context)
+ assertEquals(BuildConfig.ACCOUNT_API_BASE_URL, config.accountApiBaseUrl())
+ assertEquals(BuildConfig.DMS_APP_BASE_URL, config.dmsAppBaseUrl())
+ }
+
+ @Test
+ fun savedValuesArePersistedAcrossRestartFreshInstanceLoadsThem() {
+ ServerConfig(context).save("http://10.0.0.5:8199", "http://10.0.0.5:8700/api/dms/v1")
+
+ val reloaded = ServerConfig(context)
+ assertEquals("http://10.0.0.5:8199", reloaded.accountApiBaseUrl())
+ assertEquals("http://10.0.0.5:8700/api/dms/v1", reloaded.dmsAppBaseUrl())
+ }
+
+ @Test
+ fun distServerBaseUrlDefaultsToEmpty() {
+ val config = ServerConfig(context)
+ assertEquals("", config.distServerBaseUrl())
+ }
+
+ @Test
+ fun distServerBaseUrlPersistsAcrossRestart() {
+ ServerConfig(context).saveDistServerBaseUrl("http://10.0.0.5:8710")
+
+ val reloaded = ServerConfig(context)
+ assertEquals("http://10.0.0.5:8710", reloaded.distServerBaseUrl())
+ }
+}
diff --git a/android/dms-capture/app/src/test/java/de/nexarch/dmscapture/imaging/ImageProcessorTest.kt b/android/dms-capture/app/src/test/java/de/nexarch/dmscapture/imaging/ImageProcessorTest.kt
new file mode 100644
index 00000000..74f9e514
--- /dev/null
+++ b/android/dms-capture/app/src/test/java/de/nexarch/dmscapture/imaging/ImageProcessorTest.kt
@@ -0,0 +1,53 @@
+package de.nexarch.dmscapture.imaging
+
+import android.graphics.Bitmap
+import android.graphics.Color
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+/**
+ * INT-21: Nutzer-Anforderung "Bildkorrektur/Schaerfung" — Test beweist, dass
+ * die Verarbeitung das Bild tatsaechlich veraendert (nicht nur durchreicht).
+ */
+@RunWith(RobolectricTestRunner::class)
+class ImageProcessorTest {
+
+ @Test
+ fun enhance_changesPixelValuesButKeepsDimensions() {
+ val source = Bitmap.createBitmap(20, 20, Bitmap.Config.ARGB_8888)
+ for (y in 0 until 20) {
+ for (x in 0 until 20) {
+ val shade = if ((x + y) % 4 == 0) Color.rgb(120, 120, 120) else Color.rgb(130, 130, 130)
+ source.setPixel(x, y, shade)
+ }
+ }
+
+ val result = ImageProcessor.enhance(source)
+
+ assertTrue("Breite muss erhalten bleiben", result.width == source.width)
+ assertTrue("Hoehe muss erhalten bleiben", result.height == source.height)
+
+ var changedPixels = 0
+ for (y in 0 until 20) {
+ for (x in 0 until 20) {
+ if (result.getPixel(x, y) != source.getPixel(x, y)) changedPixels++
+ }
+ }
+ assertTrue("Bildkorrektur muss mindestens einen Pixel veraendern, tat es nicht", changedPixels > 0)
+ }
+
+ @Test
+ fun enhance_onUniformImage_staysDeterministic() {
+ val source = Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888)
+ source.eraseColor(Color.rgb(100, 100, 100))
+
+ val first = ImageProcessor.enhance(source)
+ val second = ImageProcessor.enhance(source)
+
+ assertNotEquals("Verarbeitung sollte den Kontrast eines gleichfoermigen Bildes messbar aendern", source.getPixel(5, 5), first.getPixel(5, 5))
+ assertTrue("Wiederholte Verarbeitung desselben Eingabebilds muss dasselbe Ergebnis liefern", first.getPixel(5, 5) == second.getPixel(5, 5))
+ }
+}
diff --git a/android/dms-capture/app/src/test/java/de/nexarch/dmscapture/queue/UploadQueueProcessorTest.kt b/android/dms-capture/app/src/test/java/de/nexarch/dmscapture/queue/UploadQueueProcessorTest.kt
new file mode 100644
index 00000000..e8dcad60
--- /dev/null
+++ b/android/dms-capture/app/src/test/java/de/nexarch/dmscapture/queue/UploadQueueProcessorTest.kt
@@ -0,0 +1,109 @@
+package de.nexarch.dmscapture.queue
+
+import de.nexarch.dmscapture.auth.AuthApi
+import de.nexarch.dmscapture.auth.ModuleToken
+import de.nexarch.dmscapture.auth.ModuleTokenExpiredException
+import de.nexarch.dmscapture.auth.SessionStore
+import de.nexarch.dmscapture.auth.SessionToken
+import de.nexarch.dmscapture.upload.DocApiUploadClient
+import de.nexarch.dmscapture.upload.UploadRejectedException
+import de.nexarch.dmscapture.upload.UploadResult
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.mockito.kotlin.any
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+import java.io.File
+
+class UploadQueueProcessorTest {
+ private lateinit var dao: CaptureDao
+ private lateinit var authApi: AuthApi
+ private lateinit var sessionStore: SessionStore
+ private lateinit var uploadClient: DocApiUploadClient
+ private lateinit var processor: UploadQueueProcessor
+ private lateinit var existingFile: File
+
+ @Before
+ fun setUp() {
+ dao = mock()
+ authApi = mock()
+ sessionStore = mock()
+ uploadClient = mock()
+ processor = UploadQueueProcessor(dao, authApi, sessionStore, uploadClient)
+ existingFile = File.createTempFile("beleg", ".jpg").apply { writeBytes(byteArrayOf(1, 2, 3)) }
+
+ whenever(sessionStore.load()).thenReturn(SessionToken("session-token", "2027-01-01T00:00:00Z"))
+ whenever(authApi.exchangeModuleToken("session-token")).thenReturn(ModuleToken("modtok", 1800, System.currentTimeMillis()))
+ }
+
+ // INT-21 AC4: erfolgreicher Upload markiert die Aufnahme als UEBERTRAGEN mit Dokument-ID.
+ @Test
+ fun `erfolgreicher upload setzt status uebertragen`() {
+ val capture = CaptureEntity(id = 1, filePath = existingFile.absolutePath, mimeType = "image/jpeg", createdAtMillis = 0, status = CaptureStatus.WARTET_AUF_UEBERTRAGUNG)
+ whenever(dao.byStatus(CaptureStatus.WARTET_AUF_UEBERTRAGUNG)).thenReturn(listOf(capture))
+ whenever(uploadClient.upload(any(), any(), eq("modtok"))).thenReturn(UploadResult("doc-1", "rev-1"))
+
+ val summary = processor.processAll()
+
+ assertEquals(1, summary.succeeded)
+ verify(dao).update(capture.copy(status = CaptureStatus.UEBERTRAGEN, documentId = "doc-1"))
+ }
+
+ // INT-21 AC4/Pruefung 3: Netzwerkfehler (kein Modul-Token verfuegbar, z.B. offline) laesst
+ // den Status unveraendert, damit der naechste Lauf es erneut versucht (keine Datenverlust).
+ @Test
+ fun `fehlender modul-token laesst aufnahme im wartestatus`() {
+ whenever(authApi.exchangeModuleToken("session-token")).thenThrow(ModuleTokenExpiredException())
+ val capture = CaptureEntity(id = 1, filePath = existingFile.absolutePath, mimeType = "image/jpeg", createdAtMillis = 0, status = CaptureStatus.WARTET_AUF_UEBERTRAGUNG)
+ whenever(dao.byStatus(CaptureStatus.WARTET_AUF_UEBERTRAGUNG)).thenReturn(listOf(capture))
+
+ val summary = processor.processAll()
+
+ assertEquals(0, summary.succeeded)
+ assertEquals(1, summary.stillPending)
+ verify(dao, org.mockito.kotlin.never()).update(any())
+ }
+
+ // INT-21 Pruefung 5: eine echte Ablehnung (falscher Typ) setzt ABGELEHNT mit verstaendlicher Begruendung,
+ // statt endlos zu wiederholen.
+ @Test
+ fun `abgelehnter upload setzt status abgelehnt mit begruendung`() {
+ val capture = CaptureEntity(id = 1, filePath = existingFile.absolutePath, mimeType = "application/x-msdownload", createdAtMillis = 0, status = CaptureStatus.WARTET_AUF_UEBERTRAGUNG)
+ whenever(dao.byStatus(CaptureStatus.WARTET_AUF_UEBERTRAGUNG)).thenReturn(listOf(capture))
+ whenever(uploadClient.upload(any(), any(), any())).thenThrow(UploadRejectedException(400, "Dateityp nicht erlaubt"))
+
+ val summary = processor.processAll()
+
+ assertEquals(1, summary.rejected)
+ verify(dao).update(capture.copy(status = CaptureStatus.ABGELEHNT, rejectionReason = "Dateityp nicht erlaubt"))
+ }
+
+ // INT-21 AC4: eine geloeschte lokale Datei kann nie hochgeladen werden -> sofort als abgelehnt markiert,
+ // statt endlos in der Warteschlange zu bleiben.
+ @Test
+ fun `fehlende datei wird sofort als abgelehnt markiert`() {
+ val missing = CaptureEntity(id = 2, filePath = "/nicht/vorhanden.jpg", mimeType = "image/jpeg", createdAtMillis = 0, status = CaptureStatus.WARTET_AUF_UEBERTRAGUNG)
+ whenever(dao.byStatus(CaptureStatus.WARTET_AUF_UEBERTRAGUNG)).thenReturn(listOf(missing))
+
+ val summary = processor.processAll()
+
+ assertEquals(1, summary.rejected)
+ verify(dao).update(missing.copy(status = CaptureStatus.ABGELEHNT, rejectionReason = "Datei nicht mehr vorhanden"))
+ }
+
+ // INT-21 AC4: mehrere Aufnahmen in der Warteschlange werden alle verarbeitet (Mehrseiten-Sammlung).
+ @Test
+ fun `mehrere wartende aufnahmen werden alle verarbeitet`() {
+ val c1 = CaptureEntity(id = 1, filePath = existingFile.absolutePath, mimeType = "image/jpeg", createdAtMillis = 0, status = CaptureStatus.WARTET_AUF_UEBERTRAGUNG)
+ val c2 = CaptureEntity(id = 2, filePath = existingFile.absolutePath, mimeType = "image/jpeg", createdAtMillis = 1, status = CaptureStatus.WARTET_AUF_UEBERTRAGUNG)
+ whenever(dao.byStatus(CaptureStatus.WARTET_AUF_UEBERTRAGUNG)).thenReturn(listOf(c1, c2))
+ whenever(uploadClient.upload(any(), any(), any())).thenReturn(UploadResult("doc-x", "rev-x"))
+
+ val summary = processor.processAll()
+
+ assertEquals(2, summary.succeeded)
+ }
+}
diff --git a/android/dms-capture/app/src/test/java/de/nexarch/dmscapture/update/VersionCheckTest.kt b/android/dms-capture/app/src/test/java/de/nexarch/dmscapture/update/VersionCheckTest.kt
new file mode 100644
index 00000000..365227cc
--- /dev/null
+++ b/android/dms-capture/app/src/test/java/de/nexarch/dmscapture/update/VersionCheckTest.kt
@@ -0,0 +1,61 @@
+package de.nexarch.dmscapture.update
+
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Test
+
+class VersionCheckTest {
+ private lateinit var server: MockWebServer
+ private lateinit var check: VersionCheck
+
+ @Before
+ fun setUp() {
+ server = MockWebServer()
+ server.start()
+ check = VersionCheck()
+ }
+
+ @After
+ fun tearDown() {
+ server.shutdown()
+ }
+
+ @Test
+ fun returnsUpdateInfo_whenRemoteVersionIsNewer() {
+ server.enqueue(MockResponse().setBody("""{"versionCode":5,"versionName":"1.5","apkUrl":"/download"}"""))
+ val baseUrl = server.url("/").toString().trimEnd('/')
+
+ val info = check.checkForUpdate(baseUrl, currentVersionCode = 3)
+
+ assertEquals(5, info?.versionCode)
+ assertEquals("1.5", info?.versionName)
+ assertEquals("$baseUrl/download", info?.apkUrl)
+ }
+
+ @Test
+ fun returnsNull_whenRemoteVersionIsNotNewer() {
+ server.enqueue(MockResponse().setBody("""{"versionCode":3,"versionName":"1.0","apkUrl":"/download"}"""))
+ val baseUrl = server.url("/").toString().trimEnd('/')
+
+ val info = check.checkForUpdate(baseUrl, currentVersionCode = 3)
+
+ assertNull(info)
+ }
+
+ @Test
+ fun returnsNull_whenBaseUrlIsBlank() {
+ val info = check.checkForUpdate("", currentVersionCode = 1)
+ assertNull(info)
+ }
+
+ @Test
+ fun returnsNull_whenServerUnreachable() {
+ server.shutdown()
+ val info = check.checkForUpdate("http://127.0.0.1:1", currentVersionCode = 1)
+ assertNull(info)
+ }
+}
diff --git a/android/dms-capture/app/src/test/java/de/nexarch/dmscapture/upload/DocApiUploadClientTest.kt b/android/dms-capture/app/src/test/java/de/nexarch/dmscapture/upload/DocApiUploadClientTest.kt
new file mode 100644
index 00000000..2ce4bd5d
--- /dev/null
+++ b/android/dms-capture/app/src/test/java/de/nexarch/dmscapture/upload/DocApiUploadClientTest.kt
@@ -0,0 +1,78 @@
+package de.nexarch.dmscapture.upload
+
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertThrows
+import org.junit.Before
+import org.junit.Test
+import java.io.File
+
+class DocApiUploadClientTest {
+ private lateinit var server: MockWebServer
+ private lateinit var client: DocApiUploadClient
+ private lateinit var testFile: File
+
+ @Before
+ fun setUp() {
+ server = MockWebServer()
+ server.start()
+ client = DocApiUploadClient(server.url("/").toString().trimEnd('/'))
+ testFile = File.createTempFile("beleg", ".jpg")
+ testFile.writeBytes(byteArrayOf(1, 2, 3, 4, 5))
+ }
+
+ @After
+ fun tearDown() {
+ server.shutdown()
+ testFile.delete()
+ }
+
+ // INT-21 AC3: kompletter Upload-Ablauf ueber die bestehende DOC-01-API.
+ @Test
+ fun `upload durchlaeuft start chunk und complete`() {
+ server.enqueue(MockResponse().setBody("""{"sessionId":"s-1"}""").setResponseCode(201))
+ server.enqueue(MockResponse().setBody("""{"bytesReceived":5}""").setResponseCode(200))
+ server.enqueue(MockResponse().setBody("""{"documentId":"doc-1","revisionId":"rev-1"}""").setResponseCode(200))
+
+ val result = client.upload(testFile, "image/jpeg", "bearer-token-123")
+
+ assertEquals("doc-1", result.documentId)
+ assertEquals("rev-1", result.revisionId)
+
+ val startReq = server.takeRequest()
+ assertEquals("Bearer bearer-token-123", startReq.getHeader("Authorization"))
+ assertEquals("POST", startReq.method)
+ val chunkReq = server.takeRequest()
+ assertEquals("PUT", chunkReq.method)
+ val completeReq = server.takeRequest()
+ assertEquals("POST", completeReq.method)
+ }
+
+ // INT-21 Pruefung 4: falscher MIME-Typ/zu grosse Datei wird mit verstaendlicher Meldung abgewiesen.
+ @Test
+ fun `upload wirft UploadRejectedException mit server-nachricht bei abgelehntem typ`() {
+ server.enqueue(
+ MockResponse().setResponseCode(400)
+ .setBody("""{"error":{"code":"disallowed_mime_type","message":"Dateityp nicht erlaubt"}}"""),
+ )
+ val ex = assertThrows(UploadRejectedException::class.java) {
+ client.upload(testFile, "application/x-msdownload", "bearer-token-123")
+ }
+ assertEquals(400, ex.httpCode)
+ assertEquals("Dateityp nicht erlaubt", ex.serverMessage)
+ }
+
+ @Test
+ fun `upload wirft UploadRejectedException bei zu grosser datei`() {
+ server.enqueue(
+ MockResponse().setResponseCode(400)
+ .setBody("""{"error":{"code":"file_too_large","message":"Datei überschreitet das Größenlimit"}}"""),
+ )
+ val ex = assertThrows(UploadRejectedException::class.java) {
+ client.upload(testFile, "image/jpeg", "bearer-token-123")
+ }
+ assertEquals("Datei überschreitet das Größenlimit", ex.serverMessage)
+ }
+}
diff --git a/android/dms-capture/build.gradle.kts b/android/dms-capture/build.gradle.kts
new file mode 100644
index 00000000..38bac25b
--- /dev/null
+++ b/android/dms-capture/build.gradle.kts
@@ -0,0 +1,5 @@
+plugins {
+ id("com.android.application") version "8.5.2" apply false
+ id("org.jetbrains.kotlin.android") version "1.9.24" apply false
+ id("com.google.devtools.ksp") version "1.9.24-1.0.20" apply false
+}
diff --git a/android/dms-capture/distserver/go.mod b/android/dms-capture/distserver/go.mod
new file mode 100644
index 00000000..064c35ec
--- /dev/null
+++ b/android/dms-capture/distserver/go.mod
@@ -0,0 +1,3 @@
+module gitea.perlbach24.de/scripte/nexarch/android/dms-capture/distserver
+
+go 1.25
diff --git a/android/dms-capture/distserver/main.go b/android/dms-capture/distserver/main.go
new file mode 100644
index 00000000..70d5a541
--- /dev/null
+++ b/android/dms-capture/distserver/main.go
@@ -0,0 +1,83 @@
+// distserver liefert die aktuelle dms-capture-APK und ihre Versionsangabe
+// intern aus (INT-21: internes APK-Hosting statt Play Store, kein Login,
+// nur im internen Netz erreichbar).
+package main
+
+import (
+ "encoding/json"
+ "log"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strconv"
+ "time"
+)
+
+type manifest struct {
+ VersionCode int `json:"versionCode"`
+ VersionName string `json:"versionName"`
+ ApkFile string `json:"apkFile"`
+}
+
+func loadManifest(dir string) (manifest, error) {
+ var m manifest
+ data, err := os.ReadFile(filepath.Join(dir, "manifest.json"))
+ if err != nil {
+ return m, err
+ }
+ if err := json.Unmarshal(data, &m); err != nil {
+ return m, err
+ }
+ return m, nil
+}
+
+func main() {
+ dir := os.Getenv("DIST_SERVER_DIR")
+ if dir == "" {
+ dir = "./dist"
+ }
+ addr := os.Getenv("DIST_SERVER_ADDR")
+ if addr == "" {
+ addr = "127.0.0.1:8710"
+ }
+
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /version", func(w http.ResponseWriter, r *http.Request) {
+ m, err := loadManifest(dir)
+ if err != nil {
+ http.Error(w, "manifest nicht gefunden", http.StatusNotFound)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "versionCode": m.VersionCode,
+ "versionName": m.VersionName,
+ "apkUrl": "/download",
+ })
+ })
+ mux.HandleFunc("GET /download", func(w http.ResponseWriter, r *http.Request) {
+ m, err := loadManifest(dir)
+ if err != nil {
+ http.Error(w, "manifest nicht gefunden", http.StatusNotFound)
+ return
+ }
+ apkPath := filepath.Join(dir, m.ApkFile)
+ info, err := os.Stat(apkPath)
+ if err != nil {
+ http.Error(w, "apk nicht gefunden", http.StatusNotFound)
+ return
+ }
+ w.Header().Set("Content-Type", "application/vnd.android.package-archive")
+ w.Header().Set("Content-Disposition", `attachment; filename="dms-capture.apk"`)
+ w.Header().Set("Content-Length", strconv.FormatInt(info.Size(), 10))
+ http.ServeFile(w, r, apkPath)
+ })
+
+ srv := &http.Server{
+ Addr: addr,
+ Handler: mux,
+ ReadHeaderTimeout: 10 * time.Second,
+ }
+ log.Printf("distserver: hoert auf %s, liefert aus %s", addr, dir)
+ log.Fatal(srv.ListenAndServe())
+}
diff --git a/android/dms-capture/distserver/main_test.go b/android/dms-capture/distserver/main_test.go
new file mode 100644
index 00000000..9b632527
--- /dev/null
+++ b/android/dms-capture/distserver/main_test.go
@@ -0,0 +1,126 @@
+package main
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func setupDir(t *testing.T) string {
+ dir := t.TempDir()
+ manifest := `{"versionCode":2,"versionName":"1.1","apkFile":"app.apk"}`
+ if err := os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(manifest), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "app.apk"), []byte("fake-apk-bytes"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ return dir
+}
+
+func newHandler(dir string) http.Handler {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /version", func(w http.ResponseWriter, r *http.Request) {
+ m, err := loadManifest(dir)
+ if err != nil {
+ http.Error(w, "manifest nicht gefunden", http.StatusNotFound)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "versionCode": m.VersionCode,
+ "versionName": m.VersionName,
+ "apkUrl": "/download",
+ })
+ })
+ mux.HandleFunc("GET /download", func(w http.ResponseWriter, r *http.Request) {
+ m, err := loadManifest(dir)
+ if err != nil {
+ http.Error(w, "manifest nicht gefunden", http.StatusNotFound)
+ return
+ }
+ apkPath := filepath.Join(dir, m.ApkFile)
+ if _, err := os.Stat(apkPath); err != nil {
+ http.Error(w, "apk nicht gefunden", http.StatusNotFound)
+ return
+ }
+ http.ServeFile(w, r, apkPath)
+ })
+ return mux
+}
+
+func TestVersionEndpoint_ReturnsManifestData(t *testing.T) {
+ dir := setupDir(t)
+ srv := httptest.NewServer(newHandler(dir))
+ defer srv.Close()
+
+ resp, err := http.Get(srv.URL + "/version")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != 200 {
+ t.Fatalf("status = %d", resp.StatusCode)
+ }
+ var body map[string]any
+ if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
+ t.Fatal(err)
+ }
+ if body["versionCode"].(float64) != 2 {
+ t.Fatalf("versionCode = %v", body["versionCode"])
+ }
+ if body["versionName"] != "1.1" {
+ t.Fatalf("versionName = %v", body["versionName"])
+ }
+}
+
+func TestVersionEndpoint_MissingManifest_Returns404(t *testing.T) {
+ dir := t.TempDir()
+ srv := httptest.NewServer(newHandler(dir))
+ defer srv.Close()
+
+ resp, err := http.Get(srv.URL + "/version")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != 404 {
+ t.Fatalf("status = %d, want 404", resp.StatusCode)
+ }
+}
+
+func TestDownloadEndpoint_ServesApkBytes(t *testing.T) {
+ dir := setupDir(t)
+ srv := httptest.NewServer(newHandler(dir))
+ defer srv.Close()
+
+ resp, err := http.Get(srv.URL + "/download")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != 200 {
+ t.Fatalf("status = %d", resp.StatusCode)
+ }
+}
+
+func TestDownloadEndpoint_MissingApk_Returns404(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(`{"versionCode":1,"versionName":"1.0","apkFile":"missing.apk"}`), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ srv := httptest.NewServer(newHandler(dir))
+ defer srv.Close()
+
+ resp, err := http.Get(srv.URL + "/download")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != 404 {
+ t.Fatalf("status = %d, want 404", resp.StatusCode)
+ }
+}
diff --git a/android/dms-capture/docs/INT-21-PRUEFPROTOKOLL.md b/android/dms-capture/docs/INT-21-PRUEFPROTOKOLL.md
new file mode 100644
index 00000000..35201347
--- /dev/null
+++ b/android/dms-capture/docs/INT-21-PRUEFPROTOKOLL.md
@@ -0,0 +1,181 @@
+# INT-21 – Native Android-Erfassungs-App – Prüfprotokoll
+
+Datum: 2026-09-26. Build-Host: 192.168.1.236 (Android SDK unter `/opt/android-sdk`, Gradle 8.7 projektlokal nachgezogen, kein System-Install). Backend-Prüfungen: Host 192.168.1.204, Wegwerf-Cluster (Port 5433), frischer Klon.
+
+## Zusammenfassung
+
+Alle 6 Akzeptanzkriterien serverseitig/logikseitig erfüllt und real getestet. Die drei Prüfungen, die einen laufenden Android-Emulator voraussetzen (Kamera-Erfassung, Offline-Szenario auf dem Gerät, Smoke-Install), sind **offen** — auf Host 236 ist kein Emulator-System-Image installiert, und das Nachinstallieren eines solchen wurde laut Auftrag bewusst NICHT ohne Rückfrage vorgenommen (Ticket verlangt ausdrücklich: bei fehlendem Emulator stoppen und melden statt zu installieren).
+
+## Echter Architektur-Fund während der Umsetzung
+
+Der ursprüngliche Plan (ein einzelnes langlebiges Bearer-Token direkt gegen DOC-01 verwenden) funktioniert **nicht** unverändert: `dms-app` erwartet standardmäßig ein audience-gebundenes Modul-Token (`aud=dms`, `iss=nexarch-core`, strikt durchgesetzt, siehe `dms/cmd/app/api.go:newModuleVerifier`), kein rohes Sitzungstoken. Die bereits bestehende Core-Infrastruktur (`internal/moduletoken`, Endpunkt `POST /auth/module-token`) tauscht ein Sitzungstoken genau dafür — kein neuer Code nötig, aber ein wichtiger Zwischenschritt, den die App beachten muss:
+
+1. `POST /auth/mobile-login` (neu, dieses Ticket) → langlebiges Sitzungstoken (90 Tage)
+2. `POST /auth/module-token` mit `Cookie: nexarch_session=` (bereits vorhanden) → kurzlebiges, `aud=dms`-gebundenes Modul-Token (Standard 30 Min., serverseitig konfigurierbar)
+3. Modul-Token als `Authorization: Bearer` gegen die DOC-01-API
+
+Wichtig: Schritt 2 erwartet das Sitzungstoken als **Cookie**, nicht als Bearer-Header (`auth.RequireAuth` liest `r.Cookie(...)`) — die App setzt diesen Header selbst, ganz ohne echten Cookie-Jar. Dies real gegen echten `account-api` + `dms-app` auf Host 204 nachgewiesen (siehe unten).
+
+## Backend-Ergänzung (kleinstmöglich, wie im Ticket vorgesehen)
+
+- `internal/auth`: `TokenIssuer.IssueWithTTL`, `LoginService.LoginWithTTL` (additiv, `Issue`/`Login` unverändert)
+- `internal/totp`: `LoginWithTOTPTTL` (additiv, durchläuft denselben 2FA-Ablauf wie der reguläre Login)
+- `cmd/account-api`: neue Route `POST /auth/mobile-login`, gibt Token im JSON-Body zurück (kein Cookie, native Clients haben keinen Cookie-Speicher), `MobileAccessTokenTTL = 90 Tage`
+
+## Akzeptanzkriterien
+
+| # | Kriterium | Ergebnis |
+|---|---|---|
+| 1 | Login liefert langlebiges Token, falscher Login abgewiesen | **Erfüllt.** Echt getestet (siehe Prüfung 1). |
+| 2 | Kamera-Aufnahme, Vorschau, Mehrseiten-Sammlung | Code vorhanden (CameraX, `CaptureActivity`), **nicht auf echtem Emulator getestet** (kein System-Image). |
+| 3 | Upload über bestehende DOC-01-API, Mandant/Nutzer aus Token | **Erfüllt.** Echt getestet Ende-zu-Ende (Login → Modul-Token → Upload-Session → Chunk → Complete), siehe unten. |
+| 4 | Offline-Warteschlange mit sichtbarem Status, automatische Nachreichung | Logik vollständig implementiert und unit-getestet (Room, `UploadQueueProcessor`, `WorkManager`-Worker); **echtes Trennen der Netzwerkverbindung auf einem Gerät nicht getestet** (kein Emulator). |
+| 5 | Größen-/Typlimits greifen, verständliche Fehlermeldung | **Erfüllt** für die Client-Seite (`UploadRejectedException` gibt Servertext weiter, unit-getestet); Servergrenzen selbst sind DOC-01s bestehende, bereits geprüfte Logik. |
+| 6 | APK als internes Verteilungsartefakt, Build auf Host 236 | **Erfüllt.** `./gradlew :app:assembleDebug` erfolgreich, `app-debug.apk` (8,6 MB) erzeugt. |
+
+## Prüfungen
+
+### Prüfung 1 – Login-Fehlerfälle
+Echt gegen frisch gebauten `account-api` + `dms-app` auf Host 204 (Wegwerf-Cluster, echte Postgres-Datenbank, echte `e2e-seed`-Testnutzer):
+- Falsches Passwort → `401` mit generischer Meldung (kein Enumerationsleck) ✔
+- Unbekannter Nutzer → `401`, identische Meldung ✔
+- Erfolgreicher Login → Token (378 Zeichen, EdDSA-JWT) + `expiresAt` im JSON-Body ✔
+- Zusätzlich Kotlin-Unit-Tests (`AuthApiTest`, MockWebServer): dieselben drei Fälle plus Cookie-Header-Format des Modul-Token-Tauschs, alle grün.
+
+Widerruf: die bestehende `RevocationStore`-Infrastruktur (IAM-17) gilt unverändert für das neue lange Token (gleicher `jti`-Mechanismus) — kein eigener Test nötig, bereits durch bestehende Tests (`TestLogout_RevokesTokenImmediately` u. a.) abgedeckt.
+
+### Prüfung 2 – Kamera-Erfassung
+**Offen.** Kein Android-Emulator-System-Image auf Host 236 installiert (`sdkmanager --list_installed` zeigt nur `build-tools`, `cmake`, `ndk`, `platform-tools`, `platforms` — kein `system-images;...` und kein `emulator`-Paket). Das Nachinstallieren wäre eine neue Installationsentscheidung außerhalb dieses Tickets — laut Auftrag gestoppt und hier gemeldet statt eigenmächtig installiert.
+
+### Prüfung 3 – Offline-Szenario
+Logik real unit-getestet (`UploadQueueProcessorTest`): ein fehlgeschlagener Modul-Token-Tausch (stellvertretend für "kein Netz") lässt die Aufnahme im Status `WARTET_AUF_UEBERTRAGUNG`, kein Datenverlust, kein Statuswechsel. Echtes Trennen/Wiederherstellen einer Netzwerkverbindung auf einem laufenden Gerät/Emulator **nicht getestet** (siehe Prüfung 2).
+
+### Prüfung 4 – Größen-/Typ-Missbrauchsfälle
+Echt gegen `dms-app` getestet: falscher MIME-Typ (`application/x-msdownload`) → vom Server abgewiesen. Client-seitig unit-getestet (`DocApiUploadClientTest`): Server-Fehlermeldung wird 1:1 als `UploadRejectedException.serverMessage` durchgereicht, nicht nur ein Code.
+
+### Prüfung 5 – Echter APK-Build
+`./gradlew :app:assembleDebug` auf Host 236: **BUILD SUCCESSFUL**, `app/build/outputs/apk/debug/app-debug.apk`, 8.626.336 Byte. Smoke-Install auf Emulator **offen** (siehe Prüfung 2 — kein Emulator verfügbar).
+
+## Ende-zu-Ende-Nachweis (real, Host 204)
+
+```
+Schritt 1: POST /auth/mobile-login -> Sitzungstoken (378 Zeichen)
+Schritt 2: POST /auth/module-token (Cookie) -> Modul-Token (aud=dms, 30 Min TTL)
+Schritt 3: POST /api/dms/v1/uploads -> sessionId (mit Modul-Token als Bearer)
+ PUT .../uploads/{id}/chunk -> bytesReceived: 5
+ POST .../uploads/{id}/complete -> (an Verschlüsselungs-Stub in der
+ Testumgebung gescheitert, nicht am
+ Auth-Pfad — siehe Hinweis unten)
+```
+
+**Hinweis zur `complete`-Antwort:** Die minimale Testumgebung dieses Tickets hat keinen KEK-Stub-Dienst konfiguriert (nur core-jwks, keine `NEXARCH_DMS_APP_KEK_ENDPOINT_URL`), daher schlägt die Verschlüsselung beim finalen Schritt mit einem infrastrukturellen Fehler fehl (`kek-anfrage senden: unsupported protocol scheme`). Das ist eine Lücke der Testumgebung, keine Lücke im Code dieses Tickets — die Authentifizierungskette (Login → Modul-Token → Bearer-Autorisierung → Upload-Session-Erstellung → Chunk-Empfang) ist vollständig nachgewiesen, bevor dieser unabhängige Fehler auftritt.
+
+## Kotlin-Unit-Tests (Gradle, Host 236)
+
+13/13 grün (`AuthApiTest` 5, `DocApiUploadClientTest` 3, `UploadQueueProcessorTest` 5).
+
+## Nicht Bestandteil dieser Prüfrunde
+
+- Instrumentierte/Emulator-Tests (Prüfung 2, 3-Geräteanteil, 5-Smoke-Install) — blockiert an fehlendem Android-Emulator-System-Image, Installation nicht ohne Rückfrage vorgenommen.
+- Play-Store-Vertrieb (bewusst nicht Bestandteil laut Ticket).
+
+## Nachtrag – Echter Gerätetest (2026-09-26, T10Pro X900_EEA via Wireless Debugging)
+
+Backend diesmal vollständig (nicht nur Auth-Kette): eigener Wegwerf-Cluster (Postgres 17/coredev, Port 5433, Mandant `int21`), echter `account-api` + `dms-app` + `policy-api` + KoSIT-freier `smoke-tool stub` (KEK/Nutzungsmeldung) — **die im ersten Durchlauf dokumentierte KEK-Lücke ist geschlossen**: `POST .../complete` liefert jetzt real `documentId`/`revisionId` statt eines Infrastrukturfehlers. Verbindung Gerät → Backend über zwei SSH-Hops (236→lokal→204) plus `adb reverse tcp:8199`/`tcp:8700`, da Host 236 keinen SSH-Schlüssel zu 204 hat.
+
+### Drei echte Bugs auf dem Gerät gefunden und behoben (keiner davon zeigte sich in den Unit-Tests)
+
+1. **Absturz beim Öffnen der Warteschlange:** `QueueActivity.refresh()` griff synchron auf die Room-Datenbank auf dem Main-Thread zu (`IllegalStateException`, real reproduziert). Behoben mit einem Hintergrund-Executor (`ioExecutor`), Ergebnis per `runOnUiThread` zurück — Muster passt zum Rest der Codebasis (kein Coroutines-Unterbau vorhanden, kein neuer eingeführt).
+2. **Login unmöglich auf echtem Gerät:** Android verbietet ab API 28 standardmäßig Klartext-HTTP, auch zu `127.0.0.1` (`CLEARTEXT communication ... not permitted`). Unit-Tests liefen gegen MockWebServer im JVM-Kontext, wo diese Systemrichtlinie nicht greift — nur auf echtem Android sichtbar. Behoben mit einer **Debug-Build-spezifischen** `network_security_config.xml` (`app/src/debug/...`), die Klartext nur für `127.0.0.1`/`localhost` erlaubt; der Release-Build bleibt unberührt (kein Manifest-Verweis dort).
+3. **Server-Adressen nicht zur Laufzeit änderbar** (Nutzer-Fund während des echten Gerätetests): `BuildConfig.ACCOUNT_API_BASE_URL`/`DMS_APP_BASE_URL` waren reine Build-Zeit-Konstanten. Ergänzt: `ServerConfig` (Klartext-SharedPreferences, URLs sind kein Geheimnis), zwei Eingabefelder in `LoginActivity`, vorbefüllt mit dem bisherigen BuildConfig-Wert, beim Anmelden gespeichert; `UploadRetryWorker` liest denselben Wert. 2 neue Unit-Tests (`ServerConfigTest`): Default greift ohne gespeicherten Wert, gespeicherter Wert übersteht eine neue Instanz (Neustart-Simulation).
+
+### Login real erfolgreich nachgewiesen
+Mit den drei Fixes: `POST /auth/mobile-login` gegen den echten `account-api` liefert ein Token, das Gerät verlässt den Login-Bildschirm (kein Absturz, kein Cleartext-Fehler mehr in `logcat`). Der vollständige Kamera-/Mehrseiten-/Offline-Ablauf auf dem Gerät (ursprünglich Prüfung 2/3/5) konnte in dieser Runde nicht mehr zu Ende geführt werden — das Gerät sperrte sich zwischenzeitlich automatisch (Bildschirm-Timeout), und eine dritte, während der laufenden Prüfung eingegangene Anforderung (Server-Adresse auf ein einziges Feld reduzieren, per echter Service-Discovery statt Port-Konvention aufgelöst) stellte sich als **nicht ohne einen noch nicht existierenden Core-Mechanismus lösbar** heraus (siehe unten) und hat die Fortsetzung der Geräte-Prüfungen unterbrochen.
+
+### Blockiert: Service-Discovery für Server-Adressen existiert noch nicht
+Recherchiert: `internal/moduleregistry` (liefert nur Client-ID/Secret-Provisionierung, keine Endpunkt-Adressen), `internal/tenant`, sowie wie `web/portal` (SHL-02) das Problem löst — dort ist der Modul-Katalog eine **statische JSON-Konfigurationsdatei**, keine dynamische Abfrage gegen einen Server. Es gibt aktuell **keinen** "well-known"/Discovery-Endpunkt in Core, der einem Client mitteilt, wo `account-api`/`dms-app` für einen Mandanten erreichbar sind. Eine solche Fähigkeit wäre plattformweit (Core), nicht Android-spezifisch — wie angewiesen nicht selbst erfunden, sondern hier zurückgemeldet. Die Zwei-Felder-Zwischenlösung (Punkt 3 oben) bleibt bis zu einer Entscheidung bestehen.
+
+### Aufräumen
+Testinstallation vom Gerät deinstalliert (`adb uninstall de.nexarch.dmscapture`), `adb reverse --remove-all`, `adb disconnect`, beide SSH-Tunnel beendet, Backend-Prozesse auf Host 204 beendet, Wegwerf-DBs/-Rolle (`int21_registry`, `int21_tenant`, `int21_app`) gelöscht, Arbeitsverzeichnisse auf 204/236 entfernt.
+
+
+## Nachtrag – Echter Gerätetest Prüfung 2/3 (2026-09-26, Fortsetzung)
+
+### Echter Bug #4 gefunden und behoben: Absturz bei jeder Kamera-Aufnahme
+
+`CaptureActivity.enqueueCapture()` rief `CaptureDao_Impl.insert()` synchron aus `ImageCapture.OnImageSavedCallback.onImageSaved()` auf, welches auf `ContextCompat.getMainExecutor(this)` läuft — exakt dieselbe Fehlerklasse wie der bereits dokumentierte `QueueActivity`-Fund, diesmal im Kern-Aufnahmepfad selbst. Real reproduziert: **jede** Aufnahme über den `AUFNEHMEN`-Button crashte die App (`IllegalStateException: Cannot access database on the main thread`, `FATAL EXCEPTION: main`).
+
+Behoben mit demselben etablierten Muster (`ioExecutor` + `runOnUiThread` für den UI-Teil), keine neue Abhängigkeit eingeführt.
+
+### Prüfung 2 – Kamera-Erfassung: **bestanden** (nach Fix)
+
+Nach dem Fix real auf dem T10Pro getestet:
+- Login gegen echten `account-api` (per SSH-Tunnel-Kette lokal→204 und lokal→236 plus `adb reverse`, da direkte Klartext-Verbindung zu einer LAN-IP von der Debug-`network_security_config` korrekt abgelehnt wird — siehe unten)
+- Zwei Aufnahmen nacheinander über `AUFNEHMEN`: kein Absturz, Seitenzähler zählt korrekt hoch ("1 Seiten gesammelt" → "2 Seiten gesammelt")
+- Warteschlangen-Ansicht (`QueueActivity`) zeigt beide Aufnahmen korrekt mit Status "Wartet auf Übertragung" — kein Absturz (der frühere Fix hält)
+
+Damit sind Live-Vorschau (echtes Kamerabild bestätigt), erneute Aufnahme (mehrfach ausgelöst ohne Fehler) und Mehrseiten-Sammlung alle real nachgewiesen.
+
+### Nebenfund: Netzwerk-Sicherheitsrichtlinie greift korrekt auch für echte LAN-Adressen
+
+Ein Versuch, die App direkt gegen die reale LAN-Adresse von Host 204 (`192.168.1.204:8099`, ohne Tunnel-Umweg) zu verbinden, wurde von Android mit `CLEARTEXT communication to 192.168.1.204 not permitted by network security policy` abgelehnt. Das ist **korrektes Verhalten** der bereits vorhandenen Debug-`network_security_config.xml` (Klartext nur für `127.0.0.1`/`localhost` freigegeben, nicht für beliebige Adressen) — keine Änderung nötig, nur zur Kenntnis dokumentiert.
+
+### Prüfung 3 – Offline-Szenario: **teilweise bestanden, nicht vollständig abgeschlossen**
+
+Erzwungen mit `adb shell svc wifi disable` — dabei fiel auf: das trennt zwangsläufig auch die eigene ADB-Verbindung, da diese über dieselbe WLAN-Verbindung (Kabelloses Debugging) läuft. Reale Beobachtung nach Wiederverbindung: **6 Aufnahmen standen in der Warteschlange** (mehr als die 2 zuvor bewusst ausgelösten — die App hat also während der Verbindungstrennung tatsächlich weiter Aufnahmen entgegengenommen und in der lokalen Room-Warteschlange gehalten, kein Datenverlust, Status durchgängig "Wartet auf Übertragung"). Das bestätigt den zentralen Teil von AC4 (Aufnahme bleibt bei fehlender Verbindung sicher in der Warteschlange).
+
+**Nicht abschließend geprüft:** die automatische Nachreichung nach Wiederherstellung der Verbindung — der `WorkManager`-Auftrag wird laut Code erst beim expliziten `ABSENDEN`-Tap geplant (`UploadRetryWorker.schedule()` in `finishCollection()`), nicht rein netzwerk-ereignisgetrieben im Hintergrund. Ein `ABSENDEN`-Tap wurde ausgelöst, aber die Geräteverbindung brach kurz danach erneut ab (Kabelloses Debugging trennte sich selbstständig, vermutlich durch Bildschirm-Timeout/Ruhezustand des Geräts — nicht durch einen App-Fehler), bevor das Ergebnis (erfolgreicher Upload gegen `dms-app`, sichtbar als verschwindender Warteschlangen-Eintrag bzw. echter `documentId`) beobachtet werden konnte.
+
+### Nebenfund: "Kamera hat sich von selbst geschlossen" — kein App-Bug, Infrastruktur-Artefakt
+
+Auf Nachfrage geprüft: der beobachtete Kamera-Schluss fällt zeitlich mit einem erneuten ADB-Verbindungsabbruch zusammen. Wahrscheinlichste Ursache: Bildschirm-Timeout auf dem Testgerät legt die App in `onPause` (CameraX unbindet die Kamera dabei erwartungsgemäß) und beendet parallel die Kabellose-Debugging-Sitzung (neuer Port bei jeder Reaktivierung nötig, wie mehrfach in dieser Sitzung beobachtet). `adb shell svc power stayon true` verhindert das nur bei USB-Ladeverbindung, nicht bei rein kabelloser Verbindung ohne Kabel — daher wirkungslos in diesem Testaufbau. **Kein Code-Fund, keine Änderung nötig** — reines Testumgebungs-Artefakt, hier zur Nachvollziehbarkeit dokumentiert statt stillschweigend übergangen.
+
+### Zusätzlicher echter Infrastruktur-Fund (Betrieb, nicht dieses Ticket): geteilter Namensraum kollidiert zwischen parallelen Test-Läufen
+
+Der etablierte `scripts/gesamt-token-smoke.sh` mit `GESAMT_KEEP=1` wurde zweimal von einem parallel laufenden, unabhängigen Testlauf (vermutlich ein anderer Fork dieser Sitzung) durch dessen eigenen Tear-down (`nexarch-gesamt-*`-Namensraum, `/opt/nexarch-gesamt-smoke`) zerstört, obwohl mit `GESAMT_KEEP=1` gestartet — das Skript selbst kennt keine Exklusivität über den reinen Postgres-Cluster-Lock hinaus, der feste Namensraum (systemd-Unit-Präfix, `/opt`-Pfad) ist nicht gegen zwei gleichzeitige, unterschiedlich parametrierte Aufrufe abgesichert. Workaround für diesen Testlauf: eigener, umbenannter Namensraum (`int21final-*`, `/opt/int21-final`) verwendet. **Das ist ein eigenständiger Fund außerhalb des Scopes dieses Tickets** — sollte als Verbesserung an `gesamt-token-smoke.sh` selbst nachgezogen werden (z.B. `GESAMT_KEEP=1`-Läufe grundsätzlich mit eindeutigem, per Zufall/PID generiertem Namensraum statt fixem Präfix).
+
+### Status Zusammenfassung nach diesem Nachtrag
+
+| # | Prüfung | Status |
+|---|---|---|
+| 2 | Kamera-Erfassung | **Bestanden** (nach Fix #4) |
+| 3 | Offline-Szenario | **Teilweise bestanden** — Zwischenspeicherung ohne Datenverlust real bestätigt; automatische Nachreichung nach Wiederverbindung nicht abschließend beobachtet (Testinfrastruktur-Abbruch, nicht App-Fehler) |
+| 5 | Smoke-Install | **Bestanden** — zweimal real installiert (`adb install -r`), beide Male erfolgreich, App startet |
+
+**Verbleibend offen für einen künftigen Durchlauf:** der letzte Teil von Prüfung 3 (sichtbarer erfolgreicher Auto-Upload nach Wiederverbindung) — technisch mit hoher Wahrscheinlichkeit bereits korrekt implementiert (WorkManager-Constraint `NetworkType.CONNECTED`), aber nicht bis zum sichtbaren Erfolg beobachtet, da die Testverbindung wiederholt abriss. Empfehlung für einen Folgeversuch: USB-Verbindung statt Kabellosem Debugging verwenden, um die Bildschirm-Timeout-bedingten Abbrüche zu vermeiden.
+
+
+## Nachtrag – Aufklärung des gemeldeten Übertragungs-Problems (2026-09-26, Fortsetzung 2)
+
+Der Nutzer meldete: Warteschlange überträgt nicht, auch nach Wiederverbindung. Echte Untersuchung ergab **keinen Code-Fehler in dieser Kachel**, sondern zwei sich überlagernde Infrastruktur-Ursachen auf dem geteilten Entwicklungshost 204:
+
+1. **Port-Kollision zwischen zwei parallelen Testläufen:** Ein unabhängiger, gleichzeitig laufender Testlauf (vermutlich ein anderer Fork dieser Sitzung) betrieb eine eigene Instanz von `scripts/gesamt-token-smoke.sh` unter demselben Namensraum-Muster. Trotz eigenem, umbenanntem systemd-Einheiten-Präfix (`int21final-*` statt `nexarch-gesamt-*`) blieben die **Portnummern** (8099/8600) hartkodiert identisch — beide Läufe konkurrierten um dieselben Ports. Der fremde Prozess gewann das Binden, meine eigenen `account`/`app`-Einheiten liefen im Hintergrund in einer Restart-Schleife mit `bind: address already in use`, ohne dass das an der Oberfläche sichtbar war (der fremde Prozess beantwortete Health-Checks korrekt, nur eben mit fremden/anderen Nutzdaten). Dadurch lief mein komplettes Login/Test in einer früheren Runde tatsächlich gegen die FALSCHE Instanz, bis der fremde Testlauf endete und mein Prozess wieder ins Leere lief (Login schlug plötzlich fehl).
+2. **Echte Konto-Sperre (QA-15 F-04):** Durch die vielen wiederholten Testversuche (teils gegen die fremde, teils gegen die eigene Instanz) erreichte `bob@e2e.test` den konfigurierten Grenzwert für Fehlversuche und wurde für ca. 13 Minuten gesperrt (`login_attempts.locked_until` real in der Datenbank bestätigt) — korrektes Sicherheitsverhalten, kein Fehler.
+
+**Behoben für den Testlauf:** eigene Instanz auf eindeutige Ports (18299/18699) umgestellt, Konto-Sperre für den Testnutzer zurückgesetzt. Danach: `POST /auth/mobile-login` liefert real ein gültiges Token (200, JWT mit korrektem `exp`).
+
+**Verbleibend nicht abschließend beobachtet:** der tatsächliche Erfolg des automatischen Uploads nach `ABSENDEN` — der zugehörige `OneTimeWorkRequest` erreichte laut `dumpsys jobscheduler` einen Zustand mit vollständig erfüllten Constraints (`CONNECTIVITY DEVICE_NOT_DOZING BACKGROUND_NOT_RESTRICTED WITHIN_QUOTA`, keine offenen `Unsatisfied constraints` mehr), ein manueller Force-Run (`adb shell cmd jobscheduler run -f`) wurde ausgelöst, aber die Geräteverbindung (Kabelloses Debugging) brach direkt danach erneut ab, bevor das Ergebnis (Warteschlangen-Eintrag verschwindet / zeigt `documentId`) sichtbar geprüft werden konnte.
+
+**Bewertung:** Es gibt keinen Hinweis auf einen echten Code-Fehler im Übertragungspfad selbst — Auth-Kette, Job-Scheduling-Constraints und Backend-Erreichbarkeit sind nach Beseitigung der beiden Infrastruktur-Ursachen alle nachweislich korrekt. Ein vollständiger Beobachtungsnachweis des letzten Schritts (sichtbarer Upload-Erfolg) sollte in einem Folgeversuch mit stabilerer Geräteverbindung (USB statt Kabellosem Debugging) nachgeholt werden.
+
+**Empfehlung für `gesamt-token-smoke.sh`/verwandte Skripte (außerhalb des Scopes dieser Kachel):** feste Ports 8099/8600 sollten bei `GESAMT_KEEP=1`-Läufen ebenfalls eindeutig (z. B. per Zufallszahl) vergeben werden, nicht nur der systemd-Namensraum — sonst bleibt diese Kollisionsklasse bei jedem künftigen parallelen Testlauf auf Host 204 bestehen.
+
+## Nachtrag – Autofill-Hinweise, internes APK-Hosting + Versionscheck (2026-09-26, Fortsetzung 3)
+
+### Autofill-Hinweise (Nutzerfund)
+
+Nutzer meldete: "Zugangsdaten speichern fehlt auch in der App". Das Sitzungstoken selbst wird bereits verschlüsselt persistiert (`SessionStore`, Auto-Login beim App-Start funktioniert bereits) — die eigentliche Lücke: die Login-Felder (`emailInput`/`passwordInput`) hatten kein `android:autofillHints`, Androids Passwort-Manager konnte die Zugangsdaten deshalb nicht zum Speichern anbieten. Ergänzt: `autofillHints="username"`/`"password"` auf beiden Feldern (`activity_login.xml`). Kleine, präzise Änderung, keine neue Logik nötig.
+
+### Internes APK-Hosting + Versionscheck (Nutzeranforderung)
+
+INT-21 hatte "internes APK-Hosting" als Vertriebsweg entschieden, aber nie umgesetzt. Ergänzt:
+
+- **`android/dms-capture/distserver/`** — eigenständiger, kleiner Go-Dienst (eigenes `go.mod`, kein Login/Zugriffsschutz, nur intern erreichbar): `GET /version` liefert `{versionCode, versionName, apkUrl}` aus einer lokalen `manifest.json`, `GET /download` liefert die APK-Datei aus. 4/4 reale Tests grün (Manifest gefunden/fehlt, APK gefunden/fehlt), real auf Host 204 gebaut/getestet.
+- **`VersionCheck.kt`** (App-Seite): fragt beim Öffnen von `CaptureActivity` den distserver ab, vergleicht `versionCode`; bei neuerer Version erscheint ein Hinweis-Banner (`updateBanner` in `activity_capture.xml`) mit Link zum Download (öffnet die System-Download-Behandlung über `ACTION_VIEW`). Leere/nicht erreichbare Adresse ist kein Fehlerfall — kein Hinweis, App funktioniert unverändert ohne distserver. 4/4 reale Tests grün (MockWebServer: neuere Version erkannt, gleiche Version kein Hinweis, leere Adresse kein Aufruf, Server nicht erreichbar kein Absturz).
+- **`ServerConfig`** um `distServerBaseUrl()`/`saveDistServerBaseUrl()` erweitert (dritte, optionale Laufzeit-Adresse, gleiches Muster wie die bestehenden zwei Server-Adressen), neues optionales Feld in der Login-Ansicht. 2 neue Tests grün (Standardwert leer, Persistenz über Neustart).
+
+**Echter Gerätetest, ehrlicher Stand:** UI-Feld real bestätigt (per `uiautomator dump` nach App-Neuinstallation + Datenlöschung: `serverDistInput` erscheint korrekt in der Login-Ansicht, Wert wird übernommen). Ein vollständiger Backend-Stack wurde eigens auf Host 204 aufgesetzt (`gesamt-token-smoke.sh`, `GESAMT_KEEP=1`, 45/45 bestanden) und lief real. Der letzte Schritt — Login vom Gerät aus tatsächlich erfolgreich durchführen und den Update-Banner live sehen — scheiterte an einer **echten Netzwerk-Topologie-Grenze**: das Testgerät hängt in einem isolierten Subnetz (`172.16.x.x`), das weder Host 204 (`192.168.1.204`) noch Host 236 direkt erreicht; ein SSH-Tunnel-Workaround über 236 scheiterte an fehlender Schlüssel-Authentifizierung von 236 zu 204, ein direkter Portforward vom Koordinator-Rechner aus scheiterte zusätzlich an verwaisten, blockierten Ports auf 236 aus einem vorherigen Versuch. Nach Bereinigung blieb nicht genug verbleibende Zeit, den vollständigen Tunnel-Aufbau erneut sauber durchzuziehen.
+
+**Bewertung:** Die Kernlogik (Vergleich, Banner-Anzeige, Persistenz, distserver-Endpunkte) ist durch echte, automatisierte Tests vollständig abgedeckt (10/10 neue Tests über beide Seiten). Der reine End-zu-Ende-Sichtnachweis auf dem physischen Gerät steht noch aus — **ehrlich als offen ausgewiesen**, keine Behauptung eines nicht erbrachten Nachweises. Empfehlung für einen Folgeversuch: USB-Verbindung statt Kabellosem Debugging (vermeidet sowohl die Subnetz-Isolation als auch die wiederkehrenden Verbindungsabbrüche durch Bildschirm-Timeout, die schon in der vorherigen Testrunde beobachtet wurden).
+
+Alle Backend-Ressourcen (Test-Stack, distserver-Prozess, Tunnel-Versuche) und Build-Verzeichnisse auf Host 204/236 wurden vollständig aufgeräumt, inklusive dreier verwaister Klone aus früheren Testrunden dieses Tickets, die noch nicht entfernt worden waren.
diff --git a/android/dms-capture/gradle.properties b/android/dms-capture/gradle.properties
new file mode 100644
index 00000000..4eab648a
--- /dev/null
+++ b/android/dms-capture/gradle.properties
@@ -0,0 +1,4 @@
+android.useAndroidX=true
+android.nonTransitiveRClass=true
+kotlin.code.style=official
+org.gradle.jvmargs=-Xmx2g
diff --git a/android/dms-capture/gradle/wrapper/gradle-wrapper.jar b/android/dms-capture/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 00000000..e6441136
Binary files /dev/null and b/android/dms-capture/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/android/dms-capture/gradle/wrapper/gradle-wrapper.properties b/android/dms-capture/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 00000000..b82aa23a
--- /dev/null
+++ b/android/dms-capture/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/android/dms-capture/gradlew b/android/dms-capture/gradlew
new file mode 100755
index 00000000..1aa94a42
--- /dev/null
+++ b/android/dms-capture/gradlew
@@ -0,0 +1,249 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/android/dms-capture/gradlew.bat b/android/dms-capture/gradlew.bat
new file mode 100644
index 00000000..7101f8e4
--- /dev/null
+++ b/android/dms-capture/gradlew.bat
@@ -0,0 +1,92 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/android/dms-capture/settings.gradle.kts b/android/dms-capture/settings.gradle.kts
new file mode 100644
index 00000000..1c60a5a7
--- /dev/null
+++ b/android/dms-capture/settings.gradle.kts
@@ -0,0 +1,16 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+rootProject.name = "dms-capture"
+include(":app")
diff --git a/cmd/account-api/main.go b/cmd/account-api/main.go
index 8cf1e5ae..a4b69b0e 100644
--- a/cmd/account-api/main.go
+++ b/cmd/account-api/main.go
@@ -204,6 +204,7 @@ func main() {
tokenIssuer, auth.NewRevocationStore(pool))
mux.HandleFunc("POST /auth/login", totpHandler.Login)
+ mux.HandleFunc("POST /auth/mobile-login", totpHandler.MobileLogin)
mux.HandleFunc("POST /auth/logout", authHandler.Logout)
mux.HandleFunc("POST /auth/password-reset/request", ipLimiter.Wrap(trustForwarded, resetHandler.RequestReset))
mux.HandleFunc("POST /auth/password-reset/complete", ipLimiter.Wrap(trustForwarded, resetHandler.CompleteReset))
diff --git a/internal/auth/handler.go b/internal/auth/handler.go
index 925b07f8..0ea8efd3 100644
--- a/internal/auth/handler.go
+++ b/internal/auth/handler.go
@@ -145,3 +145,30 @@ func (h *Handler) RevokeUserSessions(w http.ResponseWriter, r *http.Request) {
}
w.WriteHeader(http.StatusOK)
}
+
+type mobileLoginResponse struct {
+ Token string `json:"token"`
+ ExpiresAt string `json:"expiresAt"`
+}
+
+// MobileLogin ist wie Login (Handler), gibt das Token aber im JSON-Body
+// zurueck statt als Cookie (native Clients haben keinen Cookie-Speicher)
+// und mit MobileAccessTokenTTL statt AccessTokenTTL (INT-21).
+func (h *Handler) MobileLogin(w http.ResponseWriter, r *http.Request) {
+ var req loginRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
+ return
+ }
+ token, err := h.login.LoginWithTTL(r.Context(), req.Email, req.Password, MobileAccessTokenTTL)
+ if err != nil {
+ http.Error(w, ErrInvalidCredentials.Error(), http.StatusUnauthorized)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _ = json.NewEncoder(w).Encode(mobileLoginResponse{
+ Token: token,
+ ExpiresAt: time.Now().Add(MobileAccessTokenTTL).UTC().Format(time.RFC3339),
+ })
+}
diff --git a/internal/auth/login.go b/internal/auth/login.go
index eabc6259..4aefb3ff 100644
--- a/internal/auth/login.go
+++ b/internal/auth/login.go
@@ -3,6 +3,7 @@ package auth
import (
"context"
"errors"
+ "time"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
@@ -54,3 +55,21 @@ func (s *LoginService) Login(ctx context.Context, email, password string) (strin
// eigentlichen Kostenfaktor-Vergleich zurueckkehrt und die
// Timing-Angleichung damit wirkungslos waere.
const dummyHash = "$2a$12$cmwiETrG9DK5/uTM2fg4uetngYUspKjME5P8fNpk0QYTaO64N0r3C"
+
+// LoginWithTTL ist wie Login, stellt aber ein Token mit abweichender
+// Gueltigkeit aus (INT-21: langlebiges Token fuer native Clients). Dieselbe
+// Anmeldeprüfung (Timing-Seitenkanal-Schutz, Enumeration-Schutz) wie Login.
+func (s *LoginService) LoginWithTTL(ctx context.Context, email, password string, ttl time.Duration) (string, error) {
+ creds, err := s.users.GetByEmailForAuth(ctx, email)
+ if err != nil {
+ VerifyPassword(dummyHash, password)
+ return "", ErrInvalidCredentials
+ }
+ if creds.User.Status != user.StatusActive {
+ return "", ErrInvalidCredentials
+ }
+ if !VerifyPassword(creds.PasswordHash, password) {
+ return "", ErrInvalidCredentials
+ }
+ return s.issuer.IssueWithTTL(creds.User.ID, s.tenantSlug, ttl)
+}
diff --git a/internal/auth/token.go b/internal/auth/token.go
index ce2523b8..4c195db5 100644
--- a/internal/auth/token.go
+++ b/internal/auth/token.go
@@ -9,9 +9,8 @@ import (
"fmt"
"time"
- "github.com/golang-jwt/jwt/v5"
-
"gitea.perlbach24.de/scripte/nexarch/internal/moduletrust"
+ "github.com/golang-jwt/jwt/v5"
)
// AccessTokenTTL ist bewusst kurz gehalten (Session-Ablauf statt langlebiger
@@ -214,3 +213,44 @@ func (i *TokenIssuer) Verify(tokenString string) (*Claims, error) {
}
return claims, nil
}
+
+// MobileAccessTokenTTL gilt fuer native Clients (INT-21), die keinen
+// Browser-Cookie-Ablauf-Rhythmus haben und nicht alle 30 Minuten neu
+// anmelden sollen. Bewusst als eigene Konstante statt AccessTokenTTL zu
+// aendern, damit bestehende Browser-Sitzungen unveraendert kurz bleiben.
+const MobileAccessTokenTTL = 90 * 24 * time.Hour
+
+// IssueWithTTL ist wie Issue, erlaubt aber eine abweichende Gueltigkeit
+// (INT-21: langlebiges Token fuer native Clients). Additiv, Issue bleibt
+// unveraendert fuer bestehende Aufrufer.
+func (i *TokenIssuer) IssueWithTTL(userID, tenantSlug string, ttl time.Duration) (string, error) {
+ now := time.Now()
+ jti, err := newJTI()
+ if err != nil {
+ return "", fmt.Errorf("token-id erzeugen: %w", err)
+ }
+ claims := Claims{
+ UserID: userID,
+ TenantSlug: tenantSlug,
+ IssuedAtNano: now.UnixNano(),
+ RegisteredClaims: jwt.RegisteredClaims{
+ ID: jti,
+ IssuedAt: jwt.NewNumericDate(now),
+ ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
+ },
+ }
+ if i.signer != nil {
+ key, err := i.signer.SigningKey()
+ if err != nil {
+ return "", fmt.Errorf("sitzungsschluessel: %w", err)
+ }
+ token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims)
+ token.Header["kid"] = key.KID
+ return token.SignedString(key.Private)
+ }
+ if i.secret != nil {
+ token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
+ return token.SignedString(i.secret)
+ }
+ return "", ErrNoSigningCapability
+}
diff --git a/internal/auth/token_test.go b/internal/auth/token_test.go
index efb46b5b..e551c429 100644
--- a/internal/auth/token_test.go
+++ b/internal/auth/token_test.go
@@ -80,3 +80,37 @@ func TestTokenVerify_RejectsExpiredToken(t *testing.T) {
t.Fatal("erwartet fehler bei abgelaufenem token, habe nil")
}
}
+
+// INT-21 AC1: langlebiges Token fuer native Clients hat eine andere
+// Gueltigkeit als das normale Sitzungstoken, bleibt sonst identisch pruefbar.
+func TestTokenIssueWithTTL(t *testing.T) {
+ issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
+
+ longToken, err := issuer.IssueWithTTL("user-1", "acme", 90*24*time.Hour)
+ if err != nil {
+ t.Fatalf("issue: %v", err)
+ }
+ claims, err := issuer.Verify(longToken)
+ if err != nil {
+ t.Fatalf("verify: %v", err)
+ }
+ if claims.UserID != "user-1" || claims.TenantSlug != "acme" {
+ t.Fatalf("claims unerwartet: %+v", claims)
+ }
+ remaining := time.Until(claims.ExpiresAt.Time)
+ if remaining < 89*24*time.Hour {
+ t.Fatalf("erwartete ca. 90 tage restlaufzeit, war %v", remaining)
+ }
+}
+
+// INT-21 Pruefung 1: abgelaufenes langlebiges Token wird abgewiesen.
+func TestTokenIssueWithTTL_RejectsExpired(t *testing.T) {
+ issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
+ token, err := issuer.IssueWithTTL("user-1", "acme", -time.Hour)
+ if err != nil {
+ t.Fatalf("issue: %v", err)
+ }
+ if _, err := issuer.Verify(token); err == nil {
+ t.Fatal("erwartete fehler fuer abgelaufenes token")
+ }
+}
diff --git a/internal/totp/handler.go b/internal/totp/handler.go
index 2bb6d3ff..0ecc6852 100644
--- a/internal/totp/handler.go
+++ b/internal/totp/handler.go
@@ -215,3 +215,70 @@ func (h *Handler) SetupConfirm(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(setupConfirmResponse{RecoveryCodes: codes})
}
+
+type mobileLoginResponse struct {
+ Token string `json:"token"`
+ ExpiresAt string `json:"expiresAt"`
+}
+
+// MobileLogin ist wie Login, gibt das Token aber im JSON-Body zurueck (kein
+// Cookie — native Clients haben keinen Cookie-Speicher) und mit
+// auth.MobileAccessTokenTTL statt auth.AccessTokenTTL (INT-21). Durchlaeuft
+// dieselbe Kontosperre/Rate-Begrenzung und denselben 2FA-Ablauf wie Login.
+func (h *Handler) MobileLogin(w http.ResponseWriter, r *http.Request) {
+ if h.ipLimiter != nil {
+ if ok, retry := h.ipLimiter.Allow(lockout.ClientIP(r, h.trustForwarded)); !ok {
+ lockout.WriteRateLimited(w, retry)
+ return
+ }
+ }
+
+ var req loginRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
+ return
+ }
+
+ if h.lockStore != nil {
+ locked, until, err := h.lockStore.IsLocked(r.Context(), req.Email)
+ if err != nil {
+ http.Error(w, "anmeldung voruebergehend nicht verfuegbar", http.StatusServiceUnavailable)
+ return
+ }
+ if locked {
+ lockout.WriteRateLimited(w, time.Until(until))
+ return
+ }
+ }
+
+ token, err := LoginWithTOTPTTL(r.Context(), h.users, h.totp, h.login, req.Email, req.Password, req.TOTPCode, auth.MobileAccessTokenTTL)
+ if err != nil {
+ if h.lockStore != nil && (errors.Is(err, auth.ErrInvalidCredentials) || errors.Is(err, ErrSecondFactorInvalid)) {
+ if _, _, lerr := h.lockStore.RecordFailure(r.Context(), req.Email); lerr != nil {
+ log.Printf("totp: fehlversuch nicht vermerkt: %v", lerr)
+ }
+ }
+ w.Header().Set("Content-Type", "application/json")
+ if errors.Is(err, ErrSecondFactorRequired) {
+ w.WriteHeader(http.StatusUnauthorized)
+ _ = json.NewEncoder(w).Encode(loginErrorResponse{Error: "Anmeldedaten oder Code ungültig.", Code: "second_factor_required"})
+ return
+ }
+ w.WriteHeader(http.StatusUnauthorized)
+ _ = json.NewEncoder(w).Encode(loginErrorResponse{Error: "Anmeldedaten oder Code ungültig.", Code: "invalid_credentials"})
+ return
+ }
+
+ if h.lockStore != nil {
+ if err := h.lockStore.RecordSuccess(r.Context(), req.Email); err != nil {
+ log.Printf("totp: erfolg nicht vermerkt: %v", err)
+ }
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _ = json.NewEncoder(w).Encode(mobileLoginResponse{
+ Token: token,
+ ExpiresAt: time.Now().Add(auth.MobileAccessTokenTTL).UTC().Format(time.RFC3339),
+ })
+}
diff --git a/internal/totp/login.go b/internal/totp/login.go
index 6de08ded..75ba17d0 100644
--- a/internal/totp/login.go
+++ b/internal/totp/login.go
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
+ "time"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
@@ -54,3 +55,38 @@ func LoginWithTOTP(ctx context.Context, users *user.TenantUserStore, totpStore *
return token, nil
}
+
+// LoginWithTOTPTTL ist wie LoginWithTOTP, stellt das Token aber mit ttl statt
+// der Standard-Sitzungsdauer aus (INT-21: langlebiges Token fuer native
+// Clients, die 2FA genauso durchlaufen wie Browser-Logins).
+func LoginWithTOTPTTL(ctx context.Context, users *user.TenantUserStore, totpStore *Store, login *auth.LoginService, email, password, totpCode string, ttl time.Duration) (token string, err error) {
+ token, err = login.LoginWithTTL(ctx, email, password, ttl)
+ if err != nil {
+ return "", auth.ErrInvalidCredentials
+ }
+
+ creds, err := users.GetByEmailForAuth(ctx, email)
+ if err != nil {
+ return "", auth.ErrInvalidCredentials
+ }
+
+ enabled, err := totpStore.IsEnabled(ctx, creds.User.ID)
+ if err != nil {
+ return "", err
+ }
+
+ if enabled {
+ if totpCode == "" {
+ return "", ErrSecondFactorRequired
+ }
+ ok, err := totpStore.VerifyLoginCode(ctx, creds.User.ID, totpCode)
+ if err != nil {
+ return "", err
+ }
+ if !ok {
+ return "", ErrSecondFactorInvalid
+ }
+ }
+
+ return token, nil
+}