diff --git a/android/dms-capture/.gitignore b/android/dms-capture/.gitignore new file mode 100644 index 00000000..79ca77d9 --- /dev/null +++ b/android/dms-capture/.gitignore @@ -0,0 +1,10 @@ +*.iml +.gradle/ +/local.properties +/.idea/ +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +*.apk diff --git a/android/dms-capture/app/build.gradle.kts b/android/dms-capture/app/build.gradle.kts new file mode 100644 index 00000000..8e252010 --- /dev/null +++ b/android/dms-capture/app/build.gradle.kts @@ -0,0 +1,78 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("com.google.devtools.ksp") +} + +android { + namespace = "de.nexarch.dmscapture" + compileSdk = 34 + + defaultConfig { + applicationId = "de.nexarch.dmscapture" + minSdk = 26 + targetSdk = 34 + versionCode = 1 + versionName = "1.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + // INT-21 AC3/AC5: Server-Basis-URL kommt aus einer Build-Variable, nie hartkodiert. + buildConfigField("String", "ACCOUNT_API_BASE_URL", "\"${project.findProperty("accountApiBaseUrl") ?: "http://127.0.0.1:8199"}\"") + buildConfigField("String", "DMS_APP_BASE_URL", "\"${project.findProperty("dmsAppBaseUrl") ?: "http://127.0.0.1:8700/api/dms/v1"}\"") + } + + buildTypes { + release { + isMinifyEnabled = false + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + buildFeatures { + buildConfig = true + viewBinding = true + } + testOptions { + unitTests.isReturnDefaultValues = true + unitTests.isIncludeAndroidResources = true + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.13.1") + implementation("androidx.appcompat:appcompat:1.7.0") + implementation("com.google.android.material:material:1.12.0") + implementation("androidx.constraintlayout:constraintlayout:2.1.4") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.4") + implementation("androidx.security:security-crypto:1.1.0-alpha06") + + // CameraX (INT-21 AC2) + implementation("androidx.camera:camera-core:1.3.4") + implementation("androidx.camera:camera-camera2:1.3.4") + implementation("androidx.camera:camera-lifecycle:1.3.4") + implementation("androidx.camera:camera-view:1.3.4") + + // Room (INT-21 AC4: Offline-Warteschlange) + implementation("androidx.room:room-runtime:2.6.1") + implementation("androidx.room:room-ktx:2.6.1") + ksp("androidx.room:room-compiler:2.6.1") + + // WorkManager (INT-21 AC4: automatische Nachreichung) + implementation("androidx.work:work-runtime-ktx:2.9.1") + + // HTTP-Client fuer die bestehende DOC-01-Upload-API (kein neuer Backend-Pfad) + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("org.json:json:20240303") + + testImplementation("junit:junit:4.13.2") + testImplementation("org.mockito:mockito-core:5.12.0") + testImplementation("org.mockito.kotlin:mockito-kotlin:5.4.0") + testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") + testImplementation("androidx.arch.core:core-testing:2.2.0") + testImplementation("org.robolectric:robolectric:4.13") + testImplementation("androidx.test:core:1.6.1") +} diff --git a/android/dms-capture/app/src/debug/AndroidManifest.xml b/android/dms-capture/app/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..68f484a4 --- /dev/null +++ b/android/dms-capture/app/src/debug/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/android/dms-capture/app/src/debug/res/xml/network_security_config.xml b/android/dms-capture/app/src/debug/res/xml/network_security_config.xml new file mode 100644 index 00000000..cf4ce559 --- /dev/null +++ b/android/dms-capture/app/src/debug/res/xml/network_security_config.xml @@ -0,0 +1,12 @@ + + + + + 127.0.0.1 + localhost + 192.168.1.204 + + diff --git a/android/dms-capture/app/src/main/AndroidManifest.xml b/android/dms-capture/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..fd9e1408 --- /dev/null +++ b/android/dms-capture/app/src/main/AndroidManifest.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/auth/AuthApi.kt b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/auth/AuthApi.kt new file mode 100644 index 00000000..a93d9379 --- /dev/null +++ b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/auth/AuthApi.kt @@ -0,0 +1,57 @@ +package de.nexarch.dmscapture.auth + +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject +import java.io.IOException + +class InvalidCredentialsException : RuntimeException("Anmeldedaten ungültig") +class ModuleTokenExpiredException : RuntimeException("Sitzungstoken abgelaufen oder widerrufen") + +data class SessionToken(val token: String, val expiresAt: String) +data class ModuleToken(val token: String, val expiresInSeconds: Int, val issuedAtMillis: Long) { + fun isExpiringWithin(marginSeconds: Int): Boolean { + val ageSeconds = (System.currentTimeMillis() - issuedAtMillis) / 1000 + return ageSeconds >= expiresInSeconds - marginSeconds + } +} + +/** + * Spricht account-api an: erst /auth/mobile-login (langlebiges Sitzungstoken, + * INT-21 AC1), dann /auth/module-token (kurzlebiges, audience-gebundenes + * Token je DOC-01-Aufruf) — beide Endpunkte existieren bereits serverseitig, + * kein neuer Backend-Pfad hier. + */ +class AuthApi( + private val baseUrl: String, + private val client: OkHttpClient = OkHttpClient(), +) { + private val jsonMedia = "application/json; charset=utf-8".toMediaType() + + fun login(email: String, password: String): SessionToken { + val body = JSONObject().put("email", email).put("password", password).toString().toRequestBody(jsonMedia) + val req = Request.Builder().url("$baseUrl/auth/mobile-login").post(body).build() + client.newCall(req).execute().use { resp -> + if (resp.code == 401) throw InvalidCredentialsException() + if (!resp.isSuccessful) throw IOException("mobile-login fehlgeschlagen: ${resp.code}") + val json = JSONObject(resp.body?.string().orEmpty()) + return SessionToken(json.getString("token"), json.getString("expiresAt")) + } + } + + fun exchangeModuleToken(sessionToken: String): ModuleToken { + val req = Request.Builder() + .url("$baseUrl/auth/module-token") + .addHeader("Cookie", "nexarch_session=$sessionToken") + .post("".toRequestBody(null)) + .build() + client.newCall(req).execute().use { resp -> + if (resp.code == 401) throw ModuleTokenExpiredException() + if (!resp.isSuccessful) throw IOException("module-token fehlgeschlagen: ${resp.code}") + val json = JSONObject(resp.body?.string().orEmpty()) + return ModuleToken(json.getString("token"), json.getInt("expires_in"), System.currentTimeMillis()) + } + } +} diff --git a/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/auth/ServerConfig.kt b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/auth/ServerConfig.kt new file mode 100644 index 00000000..f81bf398 --- /dev/null +++ b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/auth/ServerConfig.kt @@ -0,0 +1,36 @@ +package de.nexarch.dmscapture.auth + +import android.content.Context +import de.nexarch.dmscapture.BuildConfig + +private const val PREFS_NAME = "dms_capture_server_config" +private const val KEY_ACCOUNT_URL = "account_api_base_url" +private const val KEY_DMS_URL = "dms_app_base_url" +private const val KEY_DIST_URL = "dist_server_base_url" + +/** + * Laufzeit-Server-Adressen (Nutzer-Fund beim echten Geraetetest, INT-21): + * BuildConfig liefert nur den Erstwert, nicht die einzige Quelle -- sonst + * braeuchte jede Umgebungsaenderung einen Neu-Build. Persistiert unverschluesselt, + * da eine Server-URL kein Geheimnis ist (anders als SessionStore). + */ +class ServerConfig(context: Context) { + private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + fun accountApiBaseUrl(): String = prefs.getString(KEY_ACCOUNT_URL, null) ?: BuildConfig.ACCOUNT_API_BASE_URL + fun dmsAppBaseUrl(): String = prefs.getString(KEY_DMS_URL, null) ?: BuildConfig.DMS_APP_BASE_URL + + // Leer = Versionscheck deaktiviert (kein distserver konfiguriert), kein Fehlerzustand. + fun distServerBaseUrl(): String = prefs.getString(KEY_DIST_URL, null) ?: "" + + fun save(accountApiBaseUrl: String, dmsAppBaseUrl: String) { + prefs.edit() + .putString(KEY_ACCOUNT_URL, accountApiBaseUrl) + .putString(KEY_DMS_URL, dmsAppBaseUrl) + .apply() + } + + fun saveDistServerBaseUrl(url: String) { + prefs.edit().putString(KEY_DIST_URL, url).apply() + } +} diff --git a/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/auth/SessionStore.kt b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/auth/SessionStore.kt new file mode 100644 index 00000000..226e7f47 --- /dev/null +++ b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/auth/SessionStore.kt @@ -0,0 +1,37 @@ +package de.nexarch.dmscapture.auth + +import android.content.Context +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey + +private const val PREFS_NAME = "dms_capture_session" +private const val KEY_TOKEN = "session_token" +private const val KEY_EXPIRES_AT = "session_expires_at" + +/** + * Haelt das langlebige Sitzungstoken verschluesselt auf dem Geraet + * (Android Keystore ueber EncryptedSharedPreferences) — nie im Klartext, + * nie in Logs. + */ +class SessionStore(context: Context) { + private val masterKey = MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build() + private val prefs = EncryptedSharedPreferences.create( + context, PREFS_NAME, masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + + fun save(token: SessionToken) { + prefs.edit().putString(KEY_TOKEN, token.token).putString(KEY_EXPIRES_AT, token.expiresAt).apply() + } + + fun load(): SessionToken? { + val t = prefs.getString(KEY_TOKEN, null) ?: return null + val e = prefs.getString(KEY_EXPIRES_AT, null) ?: return null + return SessionToken(t, e) + } + + fun clear() { + prefs.edit().clear().apply() + } +} diff --git a/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/imaging/ImageProcessor.kt b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/imaging/ImageProcessor.kt new file mode 100644 index 00000000..bafa3d46 --- /dev/null +++ b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/imaging/ImageProcessor.kt @@ -0,0 +1,63 @@ +package de.nexarch.dmscapture.imaging + +import android.graphics.Bitmap + +/** + * INT-21: Kontrast-/Schaerfe-Korrektur einer Kamera-Aufnahme vor dem + * Einreihen, wie bei CamScanner-artigen Scan-Apps ueblich. Rein clientseitig, + * keine neue schwere Abhaengigkeit. Bewusst per-Pixel statt ueber + * Canvas/ColorMatrix umgesetzt: letzteres laeuft ueber die native + * Skia-Kompositierung, die in Testumgebungen (Robolectric) nicht simuliert + * wird und die Verarbeitung damit ungeprueft liesse. + */ +object ImageProcessor { + fun enhance(source: Bitmap, contrast: Float = 1.25f, brightness: Int = -12): Bitmap { + val w = source.width + val h = source.height + val pixels = IntArray(w * h) + source.getPixels(pixels, 0, w, 0, 0, w, h) + + val contrasted = IntArray(w * h) + for (i in pixels.indices) { + contrasted[i] = applyContrast(pixels[i], contrast, brightness) + } + + val out = contrasted.copyOf() + for (y in 1 until h - 1) { + for (x in 1 until w - 1) { + val center = contrasted[y * w + x] + val up = contrasted[(y - 1) * w + x] + val down = contrasted[(y + 1) * w + x] + val left = contrasted[y * w + (x - 1)] + val right = contrasted[y * w + (x + 1)] + out[y * w + x] = sharpenPixel(center, up, down, left, right) + } + } + + val result = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) + result.setPixels(out, 0, w, 0, 0, w, h) + return result + } + + private fun applyContrast(pixel: Int, contrast: Float, brightness: Int): Int { + val a = (pixel ushr 24) and 0xFF + fun channel(shift: Int): Int { + val c = (pixel shr shift) and 0xFF + return ((c * contrast) + brightness).toInt().coerceIn(0, 255) + } + return (a shl 24) or (channel(16) shl 16) or (channel(8) shl 8) or channel(0) + } + + // Unsharp-Kernel (Zentrum*5, vier Nachbarn*-1) — Rand bleibt unveraendert, + // um Randartefakte ohne Padding-Sonderfaelle zu vermeiden. + private fun sharpenPixel(center: Int, up: Int, down: Int, left: Int, right: Int): Int { + val a = (center ushr 24) and 0xFF + fun channel(shift: Int): Int { + val c = (center shr shift) and 0xFF + val neighborSum = ((up shr shift) and 0xFF) + ((down shr shift) and 0xFF) + + ((left shr shift) and 0xFF) + ((right shr shift) and 0xFF) + return (c * 5 - neighborSum).coerceIn(0, 255) + } + return (a shl 24) or (channel(16) shl 16) or (channel(8) shl 8) or channel(0) + } +} diff --git a/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/queue/CaptureDao.kt b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/queue/CaptureDao.kt new file mode 100644 index 00000000..ae5f32bd --- /dev/null +++ b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/queue/CaptureDao.kt @@ -0,0 +1,24 @@ +package de.nexarch.dmscapture.queue + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Update + +@Dao +interface CaptureDao { + @Insert + fun insert(capture: CaptureEntity): Long + + @Update + fun update(capture: CaptureEntity) + + @Query("SELECT * FROM captures WHERE status = :status ORDER BY createdAtMillis ASC") + fun byStatus(status: CaptureStatus): List + + @Query("SELECT * FROM captures ORDER BY createdAtMillis DESC") + fun all(): List + + @Query("SELECT * FROM captures WHERE id = :id") + fun byId(id: Long): CaptureEntity? +} diff --git a/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/queue/CaptureDatabase.kt b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/queue/CaptureDatabase.kt new file mode 100644 index 00000000..d361df94 --- /dev/null +++ b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/queue/CaptureDatabase.kt @@ -0,0 +1,34 @@ +package de.nexarch.dmscapture.queue + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.TypeConverter +import androidx.room.TypeConverters + +class Converters { + @TypeConverter + fun fromStatus(status: CaptureStatus): String = status.name + + @TypeConverter + fun toStatus(value: String): CaptureStatus = CaptureStatus.valueOf(value) +} + +@Database(entities = [CaptureEntity::class], version = 1) +@TypeConverters(Converters::class) +abstract class CaptureDatabase : RoomDatabase() { + abstract fun captureDao(): CaptureDao + + companion object { + @Volatile + private var instance: CaptureDatabase? = null + + fun get(context: Context): CaptureDatabase = + instance ?: synchronized(this) { + instance ?: Room.databaseBuilder( + context.applicationContext, CaptureDatabase::class.java, "dms-capture.db", + ).build().also { instance = it } + } + } +} diff --git a/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/queue/CaptureEntity.kt b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/queue/CaptureEntity.kt new file mode 100644 index 00000000..ff159e2e --- /dev/null +++ b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/queue/CaptureEntity.kt @@ -0,0 +1,25 @@ +package de.nexarch.dmscapture.queue + +import androidx.room.Entity +import androidx.room.PrimaryKey + +enum class CaptureStatus { + WARTET_AUF_UEBERTRAGUNG, + UEBERTRAGEN, + ABGELEHNT, +} + +/** + * Eine lokal zwischengespeicherte Aufnahme (INT-21 AC4: Offline-Warteschlange). + * filePath zeigt auf die im internen App-Speicher abgelegte Bilddatei. + */ +@Entity(tableName = "captures") +data class CaptureEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val filePath: String, + val mimeType: String, + val createdAtMillis: Long, + val status: CaptureStatus, + val rejectionReason: String? = null, + val documentId: String? = null, +) diff --git a/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/queue/UploadQueueProcessor.kt b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/queue/UploadQueueProcessor.kt new file mode 100644 index 00000000..5f4c9a37 --- /dev/null +++ b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/queue/UploadQueueProcessor.kt @@ -0,0 +1,84 @@ +package de.nexarch.dmscapture.queue + +import de.nexarch.dmscapture.auth.AuthApi +import de.nexarch.dmscapture.auth.ModuleToken +import de.nexarch.dmscapture.auth.SessionStore +import de.nexarch.dmscapture.upload.DocApiUploadClient +import de.nexarch.dmscapture.upload.UploadRejectedException +import java.io.File +import java.io.IOException + +// Modul-Tokens laufen serverseitig nach expiresInSeconds ab; unter dieser +// Marge wird vor jedem Verarbeitungslauf proaktiv erneuert statt erst nach +// einem 401 zu reagieren. +private const val REFRESH_MARGIN_SECONDS = 60 + +class NotLoggedInException : RuntimeException("keine gültige Anmeldung") + +/** + * Verarbeitet die Offline-Warteschlange (INT-21 AC4): fuer jede Aufnahme mit + * Status WARTET_AUF_UEBERTRAGUNG wird versucht, sie ueber die bestehende + * DOC-01-API hochzuladen. Netzwerkfehler lassen den Status unveraendert + * (spaeterer Versuch), eine echte Ablehnung (falscher Typ/zu gross) setzt + * ABGELEHNT mit einer fuer den Nutzer verstaendlichen Begruendung + * (Akzeptanzkriterium 5) statt nur einem Fehlercode. + */ +class UploadQueueProcessor( + private val dao: CaptureDao, + private val authApi: AuthApi, + private val sessionStore: SessionStore, + private val uploadClient: DocApiUploadClient, +) { + private var cachedModuleToken: ModuleToken? = null + + fun processAll(): ProcessSummary { + val pending = dao.byStatus(CaptureStatus.WARTET_AUF_UEBERTRAGUNG) + var succeeded = 0 + var rejected = 0 + var stillPending = 0 + for (capture in pending) { + when (processOne(capture)) { + Outcome.UPLOADED -> succeeded++ + Outcome.REJECTED -> rejected++ + Outcome.RETRY_LATER -> stillPending++ + } + } + return ProcessSummary(succeeded, rejected, stillPending) + } + + private enum class Outcome { UPLOADED, REJECTED, RETRY_LATER } + + private fun processOne(capture: CaptureEntity): Outcome { + val token = try { + moduleToken() + } catch (e: Exception) { + return Outcome.RETRY_LATER + } + val file = File(capture.filePath) + if (!file.exists()) { + dao.update(capture.copy(status = CaptureStatus.ABGELEHNT, rejectionReason = "Datei nicht mehr vorhanden")) + return Outcome.REJECTED + } + return try { + val result = uploadClient.upload(file, capture.mimeType, token.token) + dao.update(capture.copy(status = CaptureStatus.UEBERTRAGEN, documentId = result.documentId)) + Outcome.UPLOADED + } catch (e: UploadRejectedException) { + dao.update(capture.copy(status = CaptureStatus.ABGELEHNT, rejectionReason = e.serverMessage)) + Outcome.REJECTED + } catch (e: IOException) { + Outcome.RETRY_LATER + } + } + + private fun moduleToken(): ModuleToken { + val cached = cachedModuleToken + if (cached != null && !cached.isExpiringWithin(REFRESH_MARGIN_SECONDS)) return cached + val session = sessionStore.load() ?: throw NotLoggedInException() + val fresh = authApi.exchangeModuleToken(session.token) + cachedModuleToken = fresh + return fresh + } +} + +data class ProcessSummary(val succeeded: Int, val rejected: Int, val stillPending: Int) diff --git a/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/queue/UploadRetryWorker.kt b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/queue/UploadRetryWorker.kt new file mode 100644 index 00000000..c1eb4140 --- /dev/null +++ b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/queue/UploadRetryWorker.kt @@ -0,0 +1,45 @@ +package de.nexarch.dmscapture.queue + +import android.content.Context +import androidx.work.Constraints +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.Worker +import androidx.work.WorkerParameters +import de.nexarch.dmscapture.auth.AuthApi +import de.nexarch.dmscapture.auth.ServerConfig +import de.nexarch.dmscapture.auth.SessionStore +import de.nexarch.dmscapture.upload.DocApiUploadClient +import java.util.concurrent.TimeUnit + +private const val WORK_NAME = "dms-capture-upload-retry" + +/** + * Laeuft periodisch, sobald eine Netzwerkverbindung besteht (INT-21 AC4: + * "automatische Nachreichung ohne Nutzeraktion"). WorkManager uebernimmt + * selbst die Wiederholung bei Systemneustart/App-Kill. + */ +class UploadRetryWorker(context: Context, params: WorkerParameters) : Worker(context, params) { + override fun doWork(): Result { + val db = CaptureDatabase.get(applicationContext) + val serverConfig = ServerConfig(applicationContext) + val authApi = AuthApi(serverConfig.accountApiBaseUrl()) + val uploadClient = DocApiUploadClient(serverConfig.dmsAppBaseUrl()) + val processor = UploadQueueProcessor(db.captureDao(), authApi, SessionStore(applicationContext), uploadClient) + val summary = processor.processAll() + return if (summary.stillPending > 0) Result.retry() else Result.success() + } + + companion object { + fun schedule(context: Context) { + val constraints = Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build() + val request = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES) + .setConstraints(constraints) + .build() + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + WORK_NAME, androidx.work.ExistingPeriodicWorkPolicy.KEEP, request, + ) + } + } +} diff --git a/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/ui/CaptureActivity.kt b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/ui/CaptureActivity.kt new file mode 100644 index 00000000..c332326f --- /dev/null +++ b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/ui/CaptureActivity.kt @@ -0,0 +1,159 @@ +package de.nexarch.dmscapture.ui + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Bundle +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageCapture +import androidx.camera.core.ImageCaptureException +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import androidx.core.content.ContextCompat +import android.net.Uri +import de.nexarch.dmscapture.R +import de.nexarch.dmscapture.auth.ServerConfig +import de.nexarch.dmscapture.imaging.ImageProcessor +import de.nexarch.dmscapture.update.VersionCheck +import de.nexarch.dmscapture.queue.CaptureDatabase +import de.nexarch.dmscapture.queue.CaptureEntity +import de.nexarch.dmscapture.queue.CaptureStatus +import de.nexarch.dmscapture.queue.UploadRetryWorker +import java.io.File +import java.io.FileOutputStream +import java.util.concurrent.Executors +import java.text.SimpleDateFormat +import java.util.Locale + +/** + * INT-21 AC2: Kamera-Aufnahme mit Live-Vorschau, erneuter Aufnahme, + * Sammeln mehrerer Seiten vor dem Absenden. AC4: jede fertige Aufnahme + * landet zunaechst in der lokalen Warteschlange (Status + * WARTET_AUF_UEBERTRAGUNG), Netzwerkzugriff selbst passiert nur im + * Hintergrund-Worker — nie blockierend in dieser Activity. + */ +class CaptureActivity : AppCompatActivity() { + private lateinit var imageCapture: ImageCapture + private var collectedPages = 0 + private val ioExecutor = Executors.newSingleThreadExecutor() + + private val requestPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) startCamera() else finish() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_capture) + + if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { + startCamera() + } else { + requestPermission.launch(Manifest.permission.CAMERA) + } + + findViewById(R.id.captureButton).setOnClickListener { capture() } + findViewById(R.id.submitButton).setOnClickListener { finishCollection() } + findViewById(R.id.queueButton).setOnClickListener { + startActivity(Intent(this, QueueActivity::class.java)) + } + + checkForUpdate() + } + + private fun checkForUpdate() { + val distUrl = ServerConfig(this).distServerBaseUrl() + if (distUrl.isBlank()) return + ioExecutor.execute { + val currentCode = packageManager.getPackageInfo(packageName, 0).let { + if (android.os.Build.VERSION.SDK_INT >= 28) it.longVersionCode.toInt() else @Suppress("DEPRECATION") it.versionCode + } + val info = VersionCheck().checkForUpdate(distUrl, currentCode) + if (info != null) { + runOnUiThread { + val banner = findViewById(R.id.updateBanner) + banner.text = "Neue Version ${info.versionName} verfügbar. Zum Herunterladen antippen." + banner.visibility = android.view.View.VISIBLE + banner.setOnClickListener { + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(info.apkUrl))) + } + } + } + } + } + + private fun startCamera() { + val providerFuture = ProcessCameraProvider.getInstance(this) + providerFuture.addListener({ + val provider = providerFuture.get() + val preview = Preview.Builder().build().also { + it.setSurfaceProvider(findViewById(R.id.previewView).surfaceProvider) + } + imageCapture = ImageCapture.Builder().build() + provider.unbindAll() + provider.bindToLifecycle(this, CameraSelector.DEFAULT_BACK_CAMERA, preview, imageCapture) + }, ContextCompat.getMainExecutor(this)) + } + + private fun capture() { + val fileName = "capture-${SimpleDateFormat("yyyyMMdd-HHmmss-SSS", Locale.GERMANY).format(java.util.Date())}.jpg" + val file = File(filesDir, fileName) + val outputOptions = ImageCapture.OutputFileOptions.Builder(file).build() + imageCapture.takePicture(outputOptions, ContextCompat.getMainExecutor(this), object : ImageCapture.OnImageSavedCallback { + override fun onImageSaved(output: ImageCapture.OutputFileResults) { + ioExecutor.execute { + correctImage(file) + enqueueCapture(file) + runOnUiThread { + collectedPages++ + findViewById(R.id.pageCountText).text = "$collectedPages Seiten gesammelt" + } + } + } + + override fun onError(exception: ImageCaptureException) { + android.widget.Toast.makeText(this@CaptureActivity, "Aufnahme fehlgeschlagen: ${exception.message}", android.widget.Toast.LENGTH_LONG).show() + } + }) + } + + // INT-21: Kontrast-/Schaerfe-Korrektur vor dem Einreihen (ueberschreibt die + // Rohaufnahme). Schlaegt die Verarbeitung fehl, wird das Originalbild + // unveraendert weiterverwendet statt die Aufnahme zu verlieren. + private fun correctImage(file: File) { + try { + val original = BitmapFactory.decodeFile(file.absolutePath) ?: return + val corrected = ImageProcessor.enhance(original) + FileOutputStream(file).use { out -> + corrected.compress(Bitmap.CompressFormat.JPEG, 92, out) + } + } catch (_: Exception) { + // Rohaufnahme bleibt erhalten, kein Datenverlust bei Verarbeitungsfehler. + } + } + + private fun enqueueCapture(file: File) { + val dao = CaptureDatabase.get(this).captureDao() + dao.insert( + CaptureEntity( + filePath = file.absolutePath, + mimeType = "image/jpeg", + createdAtMillis = System.currentTimeMillis(), + status = CaptureStatus.WARTET_AUF_UEBERTRAGUNG, + ), + ) + } + + private fun finishCollection() { + UploadRetryWorker.schedule(this) + androidx.work.WorkManager.getInstance(this).enqueue( + androidx.work.OneTimeWorkRequestBuilder() + .setConstraints(androidx.work.Constraints.Builder().setRequiredNetworkType(androidx.work.NetworkType.CONNECTED).build()) + .build(), + ) + startActivity(Intent(this, QueueActivity::class.java)) + } +} diff --git a/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/ui/LoginActivity.kt b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/ui/LoginActivity.kt new file mode 100644 index 00000000..4bd8d609 --- /dev/null +++ b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/ui/LoginActivity.kt @@ -0,0 +1,79 @@ +package de.nexarch.dmscapture.ui + +import android.content.Intent +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import de.nexarch.dmscapture.R +import de.nexarch.dmscapture.auth.AuthApi +import de.nexarch.dmscapture.auth.InvalidCredentialsException +import de.nexarch.dmscapture.auth.ServerConfig +import de.nexarch.dmscapture.auth.SessionStore +import kotlin.concurrent.thread + +/** + * INT-21 AC1: Login mit Benutzername/Passwort, liefert bei Erfolg ein + * langlebiges Zugriffstoken, das ausschliesslich verschluesselt im + * SessionStore abgelegt wird. Server-Adressen kommen aus ServerConfig + * (Laufzeit-Einstellung, Nutzer-Fund beim echten Geraetetest), nicht mehr + * fest aus BuildConfig. + */ +class LoginActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_login) + + val sessionStore = SessionStore(this) + if (sessionStore.load() != null) { + startActivity(Intent(this, CaptureActivity::class.java)) + finish() + return + } + + val serverConfig = ServerConfig(this) + val serverAccountInput = findViewById(R.id.serverAccountInput) + val serverDmsInput = findViewById(R.id.serverDmsInput) + val serverDistInput = findViewById(R.id.serverDistInput) + serverAccountInput.setText(serverConfig.accountApiBaseUrl()) + serverDmsInput.setText(serverConfig.dmsAppBaseUrl()) + serverDistInput.setText(serverConfig.distServerBaseUrl()) + + val emailInput = findViewById(R.id.emailInput) + val passwordInput = findViewById(R.id.passwordInput) + val loginButton = findViewById(R.id.loginButton) + val errorText = findViewById(R.id.errorText) + + loginButton.setOnClickListener { + val accountUrl = serverAccountInput.text.toString().trimEnd('/') + val dmsUrl = serverDmsInput.text.toString().trimEnd('/') + serverConfig.save(accountUrl, dmsUrl) + serverConfig.saveDistServerBaseUrl(serverDistInput.text.toString().trimEnd('/')) + + val email = emailInput.text.toString() + val password = passwordInput.text.toString() + errorText.visibility = android.view.View.GONE + loginButton.isEnabled = false + thread { + try { + val token = AuthApi(accountUrl).login(email, password) + sessionStore.save(token) + runOnUiThread { + startActivity(Intent(this, CaptureActivity::class.java)) + finish() + } + } catch (e: InvalidCredentialsException) { + runOnUiThread { + errorText.text = "Anmeldedaten oder Konto ungültig." + errorText.visibility = android.view.View.VISIBLE + loginButton.isEnabled = true + } + } catch (e: Exception) { + runOnUiThread { + errorText.text = "Anmeldung derzeit nicht möglich: ${e.message}" + errorText.visibility = android.view.View.VISIBLE + loginButton.isEnabled = true + } + } + } + } + } +} diff --git a/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/ui/QueueActivity.kt b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/ui/QueueActivity.kt new file mode 100644 index 00000000..075aa628 --- /dev/null +++ b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/ui/QueueActivity.kt @@ -0,0 +1,48 @@ +package de.nexarch.dmscapture.ui + +import android.os.Bundle +import android.widget.ArrayAdapter +import androidx.appcompat.app.AppCompatActivity +import de.nexarch.dmscapture.R +import de.nexarch.dmscapture.queue.CaptureDatabase +import de.nexarch.dmscapture.queue.CaptureStatus +import java.util.concurrent.Executors + +/** + * INT-21 AC4: sichtbarer Status jeder Aufnahme ("wartet auf Übertragung", + * übertragen, abgelehnt mit Begründung) — keine stille Warteschlange. + */ +class QueueActivity : AppCompatActivity() { + private val ioExecutor = Executors.newSingleThreadExecutor() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_queue) + refresh() + } + + override fun onResume() { + super.onResume() + refresh() + } + + private fun refresh() { + // Room verbietet Datenbankzugriff auf dem Main-Thread (AC4-Absturz real + // auf einem Geraet reproduziert, hier behoben statt nur unit-getestet). + ioExecutor.execute { + val captures = CaptureDatabase.get(this).captureDao().all() + val lines = captures.map { c -> + val label = when (c.status) { + CaptureStatus.WARTET_AUF_UEBERTRAGUNG -> "Wartet auf Übertragung" + CaptureStatus.UEBERTRAGEN -> "Übertragen (Dokument ${c.documentId})" + CaptureStatus.ABGELEHNT -> "Abgelehnt: ${c.rejectionReason}" + } + "${c.filePath.substringAfterLast('/')} — $label" + } + runOnUiThread { + findViewById(R.id.queueList).adapter = + ArrayAdapter(this, android.R.layout.simple_list_item_1, lines) + } + } + } +} diff --git a/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/update/VersionCheck.kt b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/update/VersionCheck.kt new file mode 100644 index 00000000..d10c9c10 --- /dev/null +++ b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/update/VersionCheck.kt @@ -0,0 +1,32 @@ +package de.nexarch.dmscapture.update + +import okhttp3.OkHttpClient +import okhttp3.Request +import org.json.JSONObject + +data class UpdateInfo(val versionCode: Int, val versionName: String, val apkUrl: String) + +/** + * Fragt beim App-Start den distserver ab (INT-21, internes APK-Hosting) und + * vergleicht gegen den lokalen versionCode. Kein Auto-Update, nur ein + * sichtbarer Hinweis -- Netzwerkfehler/leere Adresse sind kein Fehlerfall, + * die App muss ohne Update-Server funktionieren. + */ +class VersionCheck(private val client: OkHttpClient = OkHttpClient()) { + + fun checkForUpdate(distServerBaseUrl: String, currentVersionCode: Int): UpdateInfo? { + if (distServerBaseUrl.isBlank()) return null + return try { + val req = Request.Builder().url("$distServerBaseUrl/version").get().build() + client.newCall(req).execute().use { resp -> + if (!resp.isSuccessful) return null + val json = JSONObject(resp.body?.string().orEmpty()) + val remoteCode = json.getInt("versionCode") + if (remoteCode <= currentVersionCode) return null + UpdateInfo(remoteCode, json.getString("versionName"), distServerBaseUrl + json.getString("apkUrl")) + } + } catch (e: Exception) { + null + } + } +} diff --git a/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/upload/DocApiUploadClient.kt b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/upload/DocApiUploadClient.kt new file mode 100644 index 00000000..763140c2 --- /dev/null +++ b/android/dms-capture/app/src/main/java/de/nexarch/dmscapture/upload/DocApiUploadClient.kt @@ -0,0 +1,87 @@ +package de.nexarch.dmscapture.upload + +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject +import java.io.File +import java.io.IOException + +class UploadRejectedException(val httpCode: Int, val serverMessage: String) : + RuntimeException("Upload abgelehnt ($httpCode): $serverMessage") + +data class UploadResult(val documentId: String, val revisionId: String) + +/** + * Ruft ausschliesslich die bestehende DOC-01-Upload-API auf + * (POST /uploads, PUT /uploads/{id}/chunk, POST /uploads/{id}/complete) — + * kein eigener Uploadpfad, dieselbe API wie INT-19 (Weboberflaeche). + */ +class DocApiUploadClient( + private val dmsBaseUrl: String, + private val client: OkHttpClient = OkHttpClient(), +) { + private val jsonMedia = "application/json; charset=utf-8".toMediaType() + + fun upload(file: File, mimeType: String, bearerToken: String): UploadResult { + val sessionId = start(file.name, mimeType, file.length(), bearerToken) + sendChunk(sessionId, file, bearerToken) + return complete(sessionId, bearerToken) + } + + private fun start(filename: String, mimeType: String, totalSize: Long, token: String): String { + val body = JSONObject() + .put("filename", filename) + .put("mimeType", mimeType) + .put("totalSize", totalSize) + .toString().toRequestBody(jsonMedia) + val req = Request.Builder() + .url("$dmsBaseUrl/uploads") + .addHeader("Authorization", "Bearer $token") + .post(body) + .build() + client.newCall(req).execute().use { resp -> + val json = parseOrThrow(resp) + return json.getString("sessionId") + } + } + + private fun sendChunk(sessionId: String, file: File, token: String) { + val bytes = file.readBytes() + val req = Request.Builder() + .url("$dmsBaseUrl/uploads/$sessionId/chunk?offset=0") + .addHeader("Authorization", "Bearer $token") + .put(bytes.toRequestBody("application/octet-stream".toMediaType())) + .build() + client.newCall(req).execute().use { resp -> + parseOrThrow(resp) + } + } + + private fun complete(sessionId: String, token: String): UploadResult { + val req = Request.Builder() + .url("$dmsBaseUrl/uploads/$sessionId/complete") + .addHeader("Authorization", "Bearer $token") + .post("".toRequestBody(null)) + .build() + client.newCall(req).execute().use { resp -> + val json = parseOrThrow(resp) + return UploadResult(json.getString("documentId"), json.optString("revisionId", "")) + } + } + + private fun parseOrThrow(resp: okhttp3.Response): JSONObject { + val text = resp.body?.string().orEmpty() + if (!resp.isSuccessful) { + val message = try { + JSONObject(text).optJSONObject("error")?.optString("message") ?: text + } catch (e: Exception) { + text + } + throw UploadRejectedException(resp.code, message) + } + if (text.isBlank()) throw IOException("leere Antwort vom Server") + return JSONObject(text) + } +} diff --git a/android/dms-capture/app/src/main/res/drawable/ic_launcher_background.xml b/android/dms-capture/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..5ecd5e20 --- /dev/null +++ b/android/dms-capture/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + + diff --git a/android/dms-capture/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/dms-capture/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 00000000..f71a2647 --- /dev/null +++ b/android/dms-capture/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,27 @@ + + + + + + + + + diff --git a/android/dms-capture/app/src/main/res/layout/activity_capture.xml b/android/dms-capture/app/src/main/res/layout/activity_capture.xml new file mode 100644 index 00000000..cf4539be --- /dev/null +++ b/android/dms-capture/app/src/main/res/layout/activity_capture.xml @@ -0,0 +1,78 @@ + + + + + + + +