INT-21: native android erfassungs app #206

Merged
patrick merged 8 commits from feature/int-21-native-android-erfassungs-app into main 2026-09-26 23:31:46 +02:00
51 changed files with 2554 additions and 2 deletions

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
*.iml
.gradle/
/local.properties
/.idea/
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
*.apk
+78
View File
@@ -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")
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application android:networkSecurityConfig="@xml/network_security_config" />
</manifest>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- INT-21: Debug-Build spricht die Testumgebung ueber Klartext-HTTP auf
localhost/adb-reverse oder einem Test-Host im lokalen Netz an (Backend
hat kein TLS in der Testumgebung). Gilt NICHT fuer den Release-Build
(kein Manifest-Verweis dort). -->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">127.0.0.1</domain>
<domain includeSubdomains="false">localhost</domain>
<domain includeSubdomains="false">192.168.1.204</domain>
</domain-config>
</network-security-config>
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-feature android:name="android.hardware.camera.any" android:required="true" />
<application
android:allowBackup="false"
android:label="NEXARCH Erfassung"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:theme="@style/Theme.AppCompat.DayNight.NoActionBar">
<activity
android:name=".ui.LoginActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity android:name=".ui.CaptureActivity" android:exported="false" />
<activity android:name=".ui.QueueActivity" android:exported="false" />
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
tools:node="merge"
xmlns:tools="http://schemas.android.com/tools" />
</application>
</manifest>
@@ -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())
}
}
}
@@ -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()
}
}
@@ -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()
}
}
@@ -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)
}
}
@@ -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<CaptureEntity>
@Query("SELECT * FROM captures ORDER BY createdAtMillis DESC")
fun all(): List<CaptureEntity>
@Query("SELECT * FROM captures WHERE id = :id")
fun byId(id: Long): CaptureEntity?
}
@@ -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 }
}
}
}
@@ -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,
)
@@ -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)
@@ -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<UploadRetryWorker>(15, TimeUnit.MINUTES)
.setConstraints(constraints)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
WORK_NAME, androidx.work.ExistingPeriodicWorkPolicy.KEEP, request,
)
}
}
}
@@ -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<android.widget.Button>(R.id.captureButton).setOnClickListener { capture() }
findViewById<android.widget.Button>(R.id.submitButton).setOnClickListener { finishCollection() }
findViewById<android.widget.Button>(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<android.widget.TextView>(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<androidx.camera.view.PreviewView>(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<android.widget.TextView>(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<UploadRetryWorker>()
.setConstraints(androidx.work.Constraints.Builder().setRequiredNetworkType(androidx.work.NetworkType.CONNECTED).build())
.build(),
)
startActivity(Intent(this, QueueActivity::class.java))
}
}
@@ -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<android.widget.EditText>(R.id.serverAccountInput)
val serverDmsInput = findViewById<android.widget.EditText>(R.id.serverDmsInput)
val serverDistInput = findViewById<android.widget.EditText>(R.id.serverDistInput)
serverAccountInput.setText(serverConfig.accountApiBaseUrl())
serverDmsInput.setText(serverConfig.dmsAppBaseUrl())
serverDistInput.setText(serverConfig.distServerBaseUrl())
val emailInput = findViewById<android.widget.EditText>(R.id.emailInput)
val passwordInput = findViewById<android.widget.EditText>(R.id.passwordInput)
val loginButton = findViewById<android.widget.Button>(R.id.loginButton)
val errorText = findViewById<android.widget.TextView>(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
}
}
}
}
}
}
@@ -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<android.widget.ListView>(R.id.queueList).adapter =
ArrayAdapter(this, android.R.layout.simple_list_item_1, lines)
}
}
}
}
@@ -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
}
}
}
@@ -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)
}
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/ic_launcher_background" />
</shape>
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FFFFFF"
android:pathData="M34,24 L74,24 L74,84 L34,84 Z"
android:strokeColor="#FFFFFF"
android:strokeWidth="2" />
<path
android:fillColor="#1B4F72"
android:pathData="M40,32 L68,32 L68,38 L40,38 Z" />
<path
android:fillColor="#1B4F72"
android:pathData="M40,44 L68,44 L68,50 L40,50 Z" />
<path
android:fillColor="#1B4F72"
android:pathData="M40,56 L60,56 L60,62 L40,62 Z" />
<path
android:fillColor="#F1C40F"
android:pathData="M46,66 m-14,0 a14,14 0 1,0 28,0 a14,14 0 1,0 -28,0" />
<path
android:fillColor="#1B4F72"
android:pathData="M46,66 m-9,0 a9,9 0 1,0 18,0 a9,9 0 1,0 -18,0" />
</vector>
@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.camera.view.PreviewView
android:id="@+id/previewView"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@id/controls"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<LinearLayout
android:id="@+id/controls"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="16dp"
app:layout_constraintBottom_toBottomOf="parent">
<Button
android:id="@+id/captureButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Aufnehmen" />
<Button
android:id="@+id/retakeButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Erneut aufnehmen"
android:visibility="gone" />
<Button
android:id="@+id/submitButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Absenden" />
<Button
android:id="@+id/queueButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Warteschlange" />
</LinearLayout>
<TextView
android:id="@+id/pageCountText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0 Seiten gesammelt"
android:textColor="#FFFFFF"
android:background="#88000000"
android:padding="8dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<TextView
android:id="@+id/updateBanner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Neue Version verfügbar. Zum Herunterladen antippen."
android:textColor="#FFFFFF"
android:background="#CC1B4F72"
android:padding="12dp"
android:gravity="center"
android:visibility="gone"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="24dp"
android:gravity="center">
<EditText
android:id="@+id/serverAccountInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Server-Adresse (Anmeldedienst)"
android:inputType="textUri" />
<EditText
android:id="@+id/serverDmsInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Server-Adresse (Dokumentendienst)"
android:inputType="textUri" />
<EditText
android:id="@+id/serverDistInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Update-Server-Adresse (optional)"
android:inputType="textUri" />
<EditText
android:id="@+id/emailInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="E-Mail"
android:inputType="textEmailAddress"
android:autofillHints="username" />
<EditText
android:id="@+id/passwordInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Passwort"
android:inputType="textPassword"
android:autofillHints="password" />
<Button
android:id="@+id/loginButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Anmelden" />
<TextView
android:id="@+id/errorText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="#B00020"
android:visibility="gone" />
</LinearLayout>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<ListView
android:id="@+id/queueList"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
</LinearLayout>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#1B4F72</color>
</resources>
@@ -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"))
}
}
@@ -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<Context>()
@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())
}
}
@@ -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))
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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)
}
}
+5
View File
@@ -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
}
+3
View File
@@ -0,0 +1,3 @@
module gitea.perlbach24.de/scripte/nexarch/android/dms-capture/distserver
go 1.25
+83
View File
@@ -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())
}
+126
View File
@@ -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)
}
}
@@ -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=<Sitzungstoken>` (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.
+4
View File
@@ -0,0 +1,4 @@
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official
org.gradle.jvmargs=-Xmx2g
Binary file not shown.
@@ -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
Vendored Executable
+249
View File
@@ -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" "$@"
+92
View File
@@ -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
+16
View File
@@ -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")
+1
View File
@@ -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))
+27
View File
@@ -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),
})
}
+19
View File
@@ -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)
}
+42 -2
View File
@@ -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
}
+34
View File
@@ -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")
}
}
+67
View File
@@ -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),
})
}
+36
View File
@@ -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
}