INT-21: native android erfassungs app #206
No files matched your search
@@ -0,0 +1,10 @@
|
||||
*.iml
|
||||
.gradle/
|
||||
/local.properties
|
||||
/.idea/
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
*.apk
|
||||
@@ -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,
|
||||
)
|
||||
+84
@@ -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)
|
||||
+45
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+87
@@ -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())
|
||||
}
|
||||
}
|
||||
+53
@@ -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))
|
||||
}
|
||||
}
|
||||
+109
@@ -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)
|
||||
}
|
||||
}
|
||||
+61
@@ -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)
|
||||
}
|
||||
}
|
||||
+78
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module gitea.perlbach24.de/scripte/nexarch/android/dms-capture/distserver
|
||||
|
||||
go 1.25
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
+249
@@ -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" "$@"
|
||||
Vendored
+92
@@ -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
|
||||
@@ -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")
|
||||
@@ -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))
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user