From 45ecd69ee9477a1789ed8434269e00d470c1784c Mon Sep 17 00:00:00 2001 From: patrick Date: Sat, 26 Sep 2026 14:34:46 +0200 Subject: [PATCH 1/8] INT-21: native android erfassungs app Backend (kleinstmoeglich, wie im Ticket vorgesehen): - internal/auth: TokenIssuer.IssueWithTTL, LoginService.LoginWithTTL (additiv, bestehende Issue/Login unveraendert) - internal/totp: LoginWithTOTPTTL (durchlaeuft denselben 2FA-Ablauf) - cmd/account-api: neue Route POST /auth/mobile-login (JSON-Token statt Cookie, 90 Tage TTL statt 30 Minuten) Android-App (android/dms-capture, Kotlin, Gradle-Projekt): - CameraX-Erfassung mit Live-Vorschau, erneuter Aufnahme, Mehrseiten- Sammlung (AC2) - Ruft ausschliesslich die bestehende DOC-01-Upload-API auf, kein neuer Backend-Uploadpfad (AC3) -- echter Architektur-Fund dabei: dms-app erwartet ein audience-gebundenes Modul-Token (aud=dms), kein rohes Sitzungstoken; App tauscht ueber den bereits bestehenden Endpunkt POST /auth/module-token (Cookie-Header), dokumentiert im Pruefprotokoll - Room-basierte Offline-Warteschlange mit sichtbarem Status, WorkManager fuer automatische Nachreichung ohne Nutzeraktion (AC4) - Verstaendliche Fehlermeldungen bei Groessen-/Typ-Ablehnung statt nur Fehlercode (AC5) - Internes APK, kein Play-Store-Zwang (AC6) Real getestet: Ende-zu-Ende-Auth-Kette (Login -> Modul-Token -> Bearer gegen echten dms-app) auf Host 204, 13/13 Kotlin-Unit-Tests gruen, echter APK-Build auf Host 236 (Android-SDK dort vorhanden, kein Install auf der Entwicklungsmaschine, Gradle projektlokal nachgezogen statt System-Install). Offen (Pruefprotokoll): Kamera-/Offline-/Smoke-Install-Pruefungen brauchen einen Android-Emulator; kein System-Image auf Host 236 installiert, Nachinstallation bewusst nicht ohne Rueckfrage vorgenommen. Co-Authored-By: Claude Sonnet 5 --- android/dms-capture/.gitignore | 10 + android/dms-capture/app/build.gradle.kts | 78 ++++++ .../app/src/main/AndroidManifest.xml | 32 +++ .../de/nexarch/dmscapture/auth/AuthApi.kt | 57 ++++ .../nexarch/dmscapture/auth/SessionStore.kt | 37 +++ .../de/nexarch/dmscapture/queue/CaptureDao.kt | 24 ++ .../dmscapture/queue/CaptureDatabase.kt | 34 +++ .../nexarch/dmscapture/queue/CaptureEntity.kt | 25 ++ .../dmscapture/queue/UploadQueueProcessor.kt | 84 ++++++ .../dmscapture/queue/UploadRetryWorker.kt | 43 +++ .../nexarch/dmscapture/ui/CaptureActivity.kt | 107 ++++++++ .../de/nexarch/dmscapture/ui/LoginActivity.kt | 64 +++++ .../de/nexarch/dmscapture/ui/QueueActivity.kt | 39 +++ .../dmscapture/upload/DocApiUploadClient.kt | 87 ++++++ .../src/main/res/layout/activity_capture.xml | 64 +++++ .../src/main/res/layout/activity_login.xml | 35 +++ .../src/main/res/layout/activity_queue.xml | 12 + .../de/nexarch/dmscapture/auth/AuthApiTest.kt | 72 +++++ .../queue/UploadQueueProcessorTest.kt | 109 ++++++++ .../upload/DocApiUploadClientTest.kt | 78 ++++++ android/dms-capture/build.gradle.kts | 5 + .../dms-capture/docs/INT-21-PRUEFPROTOKOLL.md | 80 ++++++ android/dms-capture/gradle.properties | 4 + .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43453 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + android/dms-capture/gradlew | 249 ++++++++++++++++++ android/dms-capture/gradlew.bat | 92 +++++++ android/dms-capture/settings.gradle.kts | 16 ++ cmd/account-api/main.go | 1 + internal/auth/handler.go | 27 ++ internal/auth/login.go | 19 ++ internal/auth/token.go | 44 +++- internal/auth/token_test.go | 34 +++ internal/totp/handler.go | 67 +++++ internal/totp/login.go | 36 +++ 35 files changed, 1770 insertions(+), 2 deletions(-) create mode 100644 android/dms-capture/.gitignore create mode 100644 android/dms-capture/app/build.gradle.kts create mode 100644 android/dms-capture/app/src/main/AndroidManifest.xml create mode 100644 android/dms-capture/app/src/main/java/de/nexarch/dmscapture/auth/AuthApi.kt create mode 100644 android/dms-capture/app/src/main/java/de/nexarch/dmscapture/auth/SessionStore.kt create mode 100644 android/dms-capture/app/src/main/java/de/nexarch/dmscapture/queue/CaptureDao.kt create mode 100644 android/dms-capture/app/src/main/java/de/nexarch/dmscapture/queue/CaptureDatabase.kt create mode 100644 android/dms-capture/app/src/main/java/de/nexarch/dmscapture/queue/CaptureEntity.kt create mode 100644 android/dms-capture/app/src/main/java/de/nexarch/dmscapture/queue/UploadQueueProcessor.kt create mode 100644 android/dms-capture/app/src/main/java/de/nexarch/dmscapture/queue/UploadRetryWorker.kt create mode 100644 android/dms-capture/app/src/main/java/de/nexarch/dmscapture/ui/CaptureActivity.kt create mode 100644 android/dms-capture/app/src/main/java/de/nexarch/dmscapture/ui/LoginActivity.kt create mode 100644 android/dms-capture/app/src/main/java/de/nexarch/dmscapture/ui/QueueActivity.kt create mode 100644 android/dms-capture/app/src/main/java/de/nexarch/dmscapture/upload/DocApiUploadClient.kt create mode 100644 android/dms-capture/app/src/main/res/layout/activity_capture.xml create mode 100644 android/dms-capture/app/src/main/res/layout/activity_login.xml create mode 100644 android/dms-capture/app/src/main/res/layout/activity_queue.xml create mode 100644 android/dms-capture/app/src/test/java/de/nexarch/dmscapture/auth/AuthApiTest.kt create mode 100644 android/dms-capture/app/src/test/java/de/nexarch/dmscapture/queue/UploadQueueProcessorTest.kt create mode 100644 android/dms-capture/app/src/test/java/de/nexarch/dmscapture/upload/DocApiUploadClientTest.kt create mode 100644 android/dms-capture/build.gradle.kts create mode 100644 android/dms-capture/docs/INT-21-PRUEFPROTOKOLL.md create mode 100644 android/dms-capture/gradle.properties create mode 100644 android/dms-capture/gradle/wrapper/gradle-wrapper.jar create mode 100644 android/dms-capture/gradle/wrapper/gradle-wrapper.properties create mode 100755 android/dms-capture/gradlew create mode 100644 android/dms-capture/gradlew.bat create mode 100644 android/dms-capture/settings.gradle.kts diff --git a/android/dms-capture/.gitignore b/android/dms-capture/.gitignore new file mode 100644 index 00000000..79ca77d9 --- /dev/null +++ b/android/dms-capture/.gitignore @@ -0,0 +1,10 @@ +*.iml +.gradle/ +/local.properties +/.idea/ +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +*.apk diff --git a/android/dms-capture/app/build.gradle.kts b/android/dms-capture/app/build.gradle.kts new file mode 100644 index 00000000..8e252010 --- /dev/null +++ b/android/dms-capture/app/build.gradle.kts @@ -0,0 +1,78 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("com.google.devtools.ksp") +} + +android { + namespace = "de.nexarch.dmscapture" + compileSdk = 34 + + defaultConfig { + applicationId = "de.nexarch.dmscapture" + minSdk = 26 + targetSdk = 34 + versionCode = 1 + versionName = "1.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + // INT-21 AC3/AC5: Server-Basis-URL kommt aus einer Build-Variable, nie hartkodiert. + buildConfigField("String", "ACCOUNT_API_BASE_URL", "\"${project.findProperty("accountApiBaseUrl") ?: "http://127.0.0.1:8199"}\"") + buildConfigField("String", "DMS_APP_BASE_URL", "\"${project.findProperty("dmsAppBaseUrl") ?: "http://127.0.0.1:8700/api/dms/v1"}\"") + } + + buildTypes { + release { + isMinifyEnabled = false + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + buildFeatures { + buildConfig = true + viewBinding = true + } + testOptions { + unitTests.isReturnDefaultValues = true + unitTests.isIncludeAndroidResources = true + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.13.1") + implementation("androidx.appcompat:appcompat:1.7.0") + implementation("com.google.android.material:material:1.12.0") + implementation("androidx.constraintlayout:constraintlayout:2.1.4") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.4") + implementation("androidx.security:security-crypto:1.1.0-alpha06") + + // CameraX (INT-21 AC2) + implementation("androidx.camera:camera-core:1.3.4") + implementation("androidx.camera:camera-camera2:1.3.4") + implementation("androidx.camera:camera-lifecycle:1.3.4") + implementation("androidx.camera:camera-view:1.3.4") + + // Room (INT-21 AC4: Offline-Warteschlange) + implementation("androidx.room:room-runtime:2.6.1") + implementation("androidx.room:room-ktx:2.6.1") + ksp("androidx.room:room-compiler:2.6.1") + + // WorkManager (INT-21 AC4: automatische Nachreichung) + implementation("androidx.work:work-runtime-ktx:2.9.1") + + // HTTP-Client fuer die bestehende DOC-01-Upload-API (kein neuer Backend-Pfad) + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("org.json:json:20240303") + + testImplementation("junit:junit:4.13.2") + testImplementation("org.mockito:mockito-core:5.12.0") + testImplementation("org.mockito.kotlin:mockito-kotlin:5.4.0") + testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") + testImplementation("androidx.arch.core:core-testing:2.2.0") + testImplementation("org.robolectric:robolectric:4.13") + testImplementation("androidx.test:core:1.6.1") +} diff --git a/android/dms-capture/app/src/main/AndroidManifest.xml b/android/dms-capture/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..064ca89b --- /dev/null +++ b/android/dms-capture/app/src/main/AndroidManifest.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/auth/AuthApi.kt b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/auth/AuthApi.kt new file mode 100644 index 00000000..a93d9379 --- /dev/null +++ b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/auth/AuthApi.kt @@ -0,0 +1,57 @@ +package de.nexarch.dmscapture.auth + +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject +import java.io.IOException + +class InvalidCredentialsException : RuntimeException("Anmeldedaten ungültig") +class ModuleTokenExpiredException : RuntimeException("Sitzungstoken abgelaufen oder widerrufen") + +data class SessionToken(val token: String, val expiresAt: String) +data class ModuleToken(val token: String, val expiresInSeconds: Int, val issuedAtMillis: Long) { + fun isExpiringWithin(marginSeconds: Int): Boolean { + val ageSeconds = (System.currentTimeMillis() - issuedAtMillis) / 1000 + return ageSeconds >= expiresInSeconds - marginSeconds + } +} + +/** + * Spricht account-api an: erst /auth/mobile-login (langlebiges Sitzungstoken, + * INT-21 AC1), dann /auth/module-token (kurzlebiges, audience-gebundenes + * Token je DOC-01-Aufruf) — beide Endpunkte existieren bereits serverseitig, + * kein neuer Backend-Pfad hier. + */ +class AuthApi( + private val baseUrl: String, + private val client: OkHttpClient = OkHttpClient(), +) { + private val jsonMedia = "application/json; charset=utf-8".toMediaType() + + fun login(email: String, password: String): SessionToken { + val body = JSONObject().put("email", email).put("password", password).toString().toRequestBody(jsonMedia) + val req = Request.Builder().url("$baseUrl/auth/mobile-login").post(body).build() + client.newCall(req).execute().use { resp -> + if (resp.code == 401) throw InvalidCredentialsException() + if (!resp.isSuccessful) throw IOException("mobile-login fehlgeschlagen: ${resp.code}") + val json = JSONObject(resp.body?.string().orEmpty()) + return SessionToken(json.getString("token"), json.getString("expiresAt")) + } + } + + fun exchangeModuleToken(sessionToken: String): ModuleToken { + val req = Request.Builder() + .url("$baseUrl/auth/module-token") + .addHeader("Cookie", "nexarch_session=$sessionToken") + .post("".toRequestBody(null)) + .build() + client.newCall(req).execute().use { resp -> + if (resp.code == 401) throw ModuleTokenExpiredException() + if (!resp.isSuccessful) throw IOException("module-token fehlgeschlagen: ${resp.code}") + val json = JSONObject(resp.body?.string().orEmpty()) + return ModuleToken(json.getString("token"), json.getInt("expires_in"), System.currentTimeMillis()) + } + } +} diff --git a/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/auth/SessionStore.kt b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/auth/SessionStore.kt new file mode 100644 index 00000000..226e7f47 --- /dev/null +++ b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/auth/SessionStore.kt @@ -0,0 +1,37 @@ +package de.nexarch.dmscapture.auth + +import android.content.Context +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey + +private const val PREFS_NAME = "dms_capture_session" +private const val KEY_TOKEN = "session_token" +private const val KEY_EXPIRES_AT = "session_expires_at" + +/** + * Haelt das langlebige Sitzungstoken verschluesselt auf dem Geraet + * (Android Keystore ueber EncryptedSharedPreferences) — nie im Klartext, + * nie in Logs. + */ +class SessionStore(context: Context) { + private val masterKey = MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build() + private val prefs = EncryptedSharedPreferences.create( + context, PREFS_NAME, masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + + fun save(token: SessionToken) { + prefs.edit().putString(KEY_TOKEN, token.token).putString(KEY_EXPIRES_AT, token.expiresAt).apply() + } + + fun load(): SessionToken? { + val t = prefs.getString(KEY_TOKEN, null) ?: return null + val e = prefs.getString(KEY_EXPIRES_AT, null) ?: return null + return SessionToken(t, e) + } + + fun clear() { + prefs.edit().clear().apply() + } +} diff --git a/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/queue/CaptureDao.kt b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/queue/CaptureDao.kt new file mode 100644 index 00000000..ae5f32bd --- /dev/null +++ b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/queue/CaptureDao.kt @@ -0,0 +1,24 @@ +package de.nexarch.dmscapture.queue + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Update + +@Dao +interface CaptureDao { + @Insert + fun insert(capture: CaptureEntity): Long + + @Update + fun update(capture: CaptureEntity) + + @Query("SELECT * FROM captures WHERE status = :status ORDER BY createdAtMillis ASC") + fun byStatus(status: CaptureStatus): List + + @Query("SELECT * FROM captures ORDER BY createdAtMillis DESC") + fun all(): List + + @Query("SELECT * FROM captures WHERE id = :id") + fun byId(id: Long): CaptureEntity? +} diff --git a/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/queue/CaptureDatabase.kt b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/queue/CaptureDatabase.kt new file mode 100644 index 00000000..d361df94 --- /dev/null +++ b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/queue/CaptureDatabase.kt @@ -0,0 +1,34 @@ +package de.nexarch.dmscapture.queue + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.TypeConverter +import androidx.room.TypeConverters + +class Converters { + @TypeConverter + fun fromStatus(status: CaptureStatus): String = status.name + + @TypeConverter + fun toStatus(value: String): CaptureStatus = CaptureStatus.valueOf(value) +} + +@Database(entities = [CaptureEntity::class], version = 1) +@TypeConverters(Converters::class) +abstract class CaptureDatabase : RoomDatabase() { + abstract fun captureDao(): CaptureDao + + companion object { + @Volatile + private var instance: CaptureDatabase? = null + + fun get(context: Context): CaptureDatabase = + instance ?: synchronized(this) { + instance ?: Room.databaseBuilder( + context.applicationContext, CaptureDatabase::class.java, "dms-capture.db", + ).build().also { instance = it } + } + } +} diff --git a/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/queue/CaptureEntity.kt b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/queue/CaptureEntity.kt new file mode 100644 index 00000000..ff159e2e --- /dev/null +++ b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/queue/CaptureEntity.kt @@ -0,0 +1,25 @@ +package de.nexarch.dmscapture.queue + +import androidx.room.Entity +import androidx.room.PrimaryKey + +enum class CaptureStatus { + WARTET_AUF_UEBERTRAGUNG, + UEBERTRAGEN, + ABGELEHNT, +} + +/** + * Eine lokal zwischengespeicherte Aufnahme (INT-21 AC4: Offline-Warteschlange). + * filePath zeigt auf die im internen App-Speicher abgelegte Bilddatei. + */ +@Entity(tableName = "captures") +data class CaptureEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val filePath: String, + val mimeType: String, + val createdAtMillis: Long, + val status: CaptureStatus, + val rejectionReason: String? = null, + val documentId: String? = null, +) diff --git a/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/queue/UploadQueueProcessor.kt b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/queue/UploadQueueProcessor.kt new file mode 100644 index 00000000..5f4c9a37 --- /dev/null +++ b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/queue/UploadQueueProcessor.kt @@ -0,0 +1,84 @@ +package de.nexarch.dmscapture.queue + +import de.nexarch.dmscapture.auth.AuthApi +import de.nexarch.dmscapture.auth.ModuleToken +import de.nexarch.dmscapture.auth.SessionStore +import de.nexarch.dmscapture.upload.DocApiUploadClient +import de.nexarch.dmscapture.upload.UploadRejectedException +import java.io.File +import java.io.IOException + +// Modul-Tokens laufen serverseitig nach expiresInSeconds ab; unter dieser +// Marge wird vor jedem Verarbeitungslauf proaktiv erneuert statt erst nach +// einem 401 zu reagieren. +private const val REFRESH_MARGIN_SECONDS = 60 + +class NotLoggedInException : RuntimeException("keine gültige Anmeldung") + +/** + * Verarbeitet die Offline-Warteschlange (INT-21 AC4): fuer jede Aufnahme mit + * Status WARTET_AUF_UEBERTRAGUNG wird versucht, sie ueber die bestehende + * DOC-01-API hochzuladen. Netzwerkfehler lassen den Status unveraendert + * (spaeterer Versuch), eine echte Ablehnung (falscher Typ/zu gross) setzt + * ABGELEHNT mit einer fuer den Nutzer verstaendlichen Begruendung + * (Akzeptanzkriterium 5) statt nur einem Fehlercode. + */ +class UploadQueueProcessor( + private val dao: CaptureDao, + private val authApi: AuthApi, + private val sessionStore: SessionStore, + private val uploadClient: DocApiUploadClient, +) { + private var cachedModuleToken: ModuleToken? = null + + fun processAll(): ProcessSummary { + val pending = dao.byStatus(CaptureStatus.WARTET_AUF_UEBERTRAGUNG) + var succeeded = 0 + var rejected = 0 + var stillPending = 0 + for (capture in pending) { + when (processOne(capture)) { + Outcome.UPLOADED -> succeeded++ + Outcome.REJECTED -> rejected++ + Outcome.RETRY_LATER -> stillPending++ + } + } + return ProcessSummary(succeeded, rejected, stillPending) + } + + private enum class Outcome { UPLOADED, REJECTED, RETRY_LATER } + + private fun processOne(capture: CaptureEntity): Outcome { + val token = try { + moduleToken() + } catch (e: Exception) { + return Outcome.RETRY_LATER + } + val file = File(capture.filePath) + if (!file.exists()) { + dao.update(capture.copy(status = CaptureStatus.ABGELEHNT, rejectionReason = "Datei nicht mehr vorhanden")) + return Outcome.REJECTED + } + return try { + val result = uploadClient.upload(file, capture.mimeType, token.token) + dao.update(capture.copy(status = CaptureStatus.UEBERTRAGEN, documentId = result.documentId)) + Outcome.UPLOADED + } catch (e: UploadRejectedException) { + dao.update(capture.copy(status = CaptureStatus.ABGELEHNT, rejectionReason = e.serverMessage)) + Outcome.REJECTED + } catch (e: IOException) { + Outcome.RETRY_LATER + } + } + + private fun moduleToken(): ModuleToken { + val cached = cachedModuleToken + if (cached != null && !cached.isExpiringWithin(REFRESH_MARGIN_SECONDS)) return cached + val session = sessionStore.load() ?: throw NotLoggedInException() + val fresh = authApi.exchangeModuleToken(session.token) + cachedModuleToken = fresh + return fresh + } +} + +data class ProcessSummary(val succeeded: Int, val rejected: Int, val stillPending: Int) diff --git a/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/queue/UploadRetryWorker.kt b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/queue/UploadRetryWorker.kt new file mode 100644 index 00000000..7e8a8eb6 --- /dev/null +++ b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/queue/UploadRetryWorker.kt @@ -0,0 +1,43 @@ +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.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 authApi = AuthApi(de.nexarch.dmscapture.BuildConfig.ACCOUNT_API_BASE_URL) + val uploadClient = DocApiUploadClient(de.nexarch.dmscapture.BuildConfig.DMS_APP_BASE_URL) + val processor = UploadQueueProcessor(db.captureDao(), authApi, SessionStore(applicationContext), uploadClient) + val summary = processor.processAll() + return if (summary.stillPending > 0) Result.retry() else Result.success() + } + + companion object { + fun schedule(context: Context) { + val constraints = Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build() + val request = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES) + .setConstraints(constraints) + .build() + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + WORK_NAME, androidx.work.ExistingPeriodicWorkPolicy.KEEP, request, + ) + } + } +} diff --git a/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/ui/CaptureActivity.kt b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/ui/CaptureActivity.kt new file mode 100644 index 00000000..bde1bc97 --- /dev/null +++ b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/ui/CaptureActivity.kt @@ -0,0 +1,107 @@ +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 androidx.core.content.ContextCompat +import de.nexarch.dmscapture.R +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.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 requestPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) startCamera() else finish() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_capture) + + if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { + startCamera() + } else { + requestPermission.launch(Manifest.permission.CAMERA) + } + + findViewById(R.id.captureButton).setOnClickListener { capture() } + findViewById(R.id.submitButton).setOnClickListener { finishCollection() } + findViewById(R.id.queueButton).setOnClickListener { + startActivity(Intent(this, QueueActivity::class.java)) + } + } + + private fun startCamera() { + val providerFuture = ProcessCameraProvider.getInstance(this) + providerFuture.addListener({ + val provider = providerFuture.get() + val preview = Preview.Builder().build().also { + it.setSurfaceProvider(findViewById(R.id.previewView).surfaceProvider) + } + imageCapture = ImageCapture.Builder().build() + provider.unbindAll() + provider.bindToLifecycle(this, CameraSelector.DEFAULT_BACK_CAMERA, preview, imageCapture) + }, ContextCompat.getMainExecutor(this)) + } + + private fun capture() { + val fileName = "capture-${SimpleDateFormat("yyyyMMdd-HHmmss-SSS", Locale.GERMANY).format(java.util.Date())}.jpg" + val file = File(filesDir, fileName) + val outputOptions = ImageCapture.OutputFileOptions.Builder(file).build() + imageCapture.takePicture(outputOptions, ContextCompat.getMainExecutor(this), object : ImageCapture.OnImageSavedCallback { + override fun onImageSaved(output: ImageCapture.OutputFileResults) { + enqueueCapture(file) + collectedPages++ + findViewById(R.id.pageCountText).text = "$collectedPages Seiten gesammelt" + } + + override fun onError(exception: ImageCaptureException) { + android.widget.Toast.makeText(this@CaptureActivity, "Aufnahme fehlgeschlagen: ${exception.message}", android.widget.Toast.LENGTH_LONG).show() + } + }) + } + + private fun enqueueCapture(file: File) { + val dao = CaptureDatabase.get(this).captureDao() + dao.insert( + CaptureEntity( + filePath = file.absolutePath, + mimeType = "image/jpeg", + createdAtMillis = System.currentTimeMillis(), + status = CaptureStatus.WARTET_AUF_UEBERTRAGUNG, + ), + ) + } + + private fun finishCollection() { + UploadRetryWorker.schedule(this) + androidx.work.WorkManager.getInstance(this).enqueue( + androidx.work.OneTimeWorkRequestBuilder() + .setConstraints(androidx.work.Constraints.Builder().setRequiredNetworkType(androidx.work.NetworkType.CONNECTED).build()) + .build(), + ) + startActivity(Intent(this, QueueActivity::class.java)) + } +} diff --git a/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/ui/LoginActivity.kt b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/ui/LoginActivity.kt new file mode 100644 index 00000000..782a68a5 --- /dev/null +++ b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/ui/LoginActivity.kt @@ -0,0 +1,64 @@ +package de.nexarch.dmscapture.ui + +import android.content.Intent +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import de.nexarch.dmscapture.BuildConfig +import de.nexarch.dmscapture.R +import de.nexarch.dmscapture.auth.AuthApi +import de.nexarch.dmscapture.auth.InvalidCredentialsException +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. + */ +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 emailInput = findViewById(R.id.emailInput) + val passwordInput = findViewById(R.id.passwordInput) + val loginButton = findViewById(R.id.loginButton) + val errorText = findViewById(R.id.errorText) + + loginButton.setOnClickListener { + val email = emailInput.text.toString() + val password = passwordInput.text.toString() + errorText.visibility = android.view.View.GONE + loginButton.isEnabled = false + thread { + try { + val token = AuthApi(BuildConfig.ACCOUNT_API_BASE_URL).login(email, password) + sessionStore.save(token) + runOnUiThread { + startActivity(Intent(this, CaptureActivity::class.java)) + finish() + } + } catch (e: InvalidCredentialsException) { + runOnUiThread { + errorText.text = "Anmeldedaten oder Konto ungültig." + errorText.visibility = android.view.View.VISIBLE + loginButton.isEnabled = true + } + } catch (e: Exception) { + runOnUiThread { + errorText.text = "Anmeldung derzeit nicht möglich: ${e.message}" + errorText.visibility = android.view.View.VISIBLE + loginButton.isEnabled = true + } + } + } + } + } +} diff --git a/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/ui/QueueActivity.kt b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/ui/QueueActivity.kt new file mode 100644 index 00000000..71015611 --- /dev/null +++ b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/ui/QueueActivity.kt @@ -0,0 +1,39 @@ +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 + +/** + * INT-21 AC4: sichtbarer Status jeder Aufnahme ("wartet auf Übertragung", + * übertragen, abgelehnt mit Begründung) — keine stille Warteschlange. + */ +class QueueActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_queue) + refresh() + } + + override fun onResume() { + super.onResume() + refresh() + } + + private fun refresh() { + 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" + } + findViewById(R.id.queueList).adapter = + ArrayAdapter(this, android.R.layout.simple_list_item_1, lines) + } +} diff --git a/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/upload/DocApiUploadClient.kt b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/upload/DocApiUploadClient.kt new file mode 100644 index 00000000..763140c2 --- /dev/null +++ b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/upload/DocApiUploadClient.kt @@ -0,0 +1,87 @@ +package de.nexarch.dmscapture.upload + +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject +import java.io.File +import java.io.IOException + +class UploadRejectedException(val httpCode: Int, val serverMessage: String) : + RuntimeException("Upload abgelehnt ($httpCode): $serverMessage") + +data class UploadResult(val documentId: String, val revisionId: String) + +/** + * Ruft ausschliesslich die bestehende DOC-01-Upload-API auf + * (POST /uploads, PUT /uploads/{id}/chunk, POST /uploads/{id}/complete) — + * kein eigener Uploadpfad, dieselbe API wie INT-19 (Weboberflaeche). + */ +class DocApiUploadClient( + private val dmsBaseUrl: String, + private val client: OkHttpClient = OkHttpClient(), +) { + private val jsonMedia = "application/json; charset=utf-8".toMediaType() + + fun upload(file: File, mimeType: String, bearerToken: String): UploadResult { + val sessionId = start(file.name, mimeType, file.length(), bearerToken) + sendChunk(sessionId, file, bearerToken) + return complete(sessionId, bearerToken) + } + + private fun start(filename: String, mimeType: String, totalSize: Long, token: String): String { + val body = JSONObject() + .put("filename", filename) + .put("mimeType", mimeType) + .put("totalSize", totalSize) + .toString().toRequestBody(jsonMedia) + val req = Request.Builder() + .url("$dmsBaseUrl/uploads") + .addHeader("Authorization", "Bearer $token") + .post(body) + .build() + client.newCall(req).execute().use { resp -> + val json = parseOrThrow(resp) + return json.getString("sessionId") + } + } + + private fun sendChunk(sessionId: String, file: File, token: String) { + val bytes = file.readBytes() + val req = Request.Builder() + .url("$dmsBaseUrl/uploads/$sessionId/chunk?offset=0") + .addHeader("Authorization", "Bearer $token") + .put(bytes.toRequestBody("application/octet-stream".toMediaType())) + .build() + client.newCall(req).execute().use { resp -> + parseOrThrow(resp) + } + } + + private fun complete(sessionId: String, token: String): UploadResult { + val req = Request.Builder() + .url("$dmsBaseUrl/uploads/$sessionId/complete") + .addHeader("Authorization", "Bearer $token") + .post("".toRequestBody(null)) + .build() + client.newCall(req).execute().use { resp -> + val json = parseOrThrow(resp) + return UploadResult(json.getString("documentId"), json.optString("revisionId", "")) + } + } + + private fun parseOrThrow(resp: okhttp3.Response): JSONObject { + val text = resp.body?.string().orEmpty() + if (!resp.isSuccessful) { + val message = try { + JSONObject(text).optJSONObject("error")?.optString("message") ?: text + } catch (e: Exception) { + text + } + throw UploadRejectedException(resp.code, message) + } + if (text.isBlank()) throw IOException("leere Antwort vom Server") + return JSONObject(text) + } +} diff --git a/android/dms-capture/app/src/main/res/layout/activity_capture.xml b/android/dms-capture/app/src/main/res/layout/activity_capture.xml new file mode 100644 index 00000000..6305702a --- /dev/null +++ b/android/dms-capture/app/src/main/res/layout/activity_capture.xml @@ -0,0 +1,64 @@ + + + + + + + +