add gitlab ci

This commit is contained in:
m
2026-09-16 14:47:10 +02:00
parent 906d48702c
commit cd4f0179fc
32 changed files with 1462 additions and 40 deletions
+30 -2
View File
@@ -8,6 +8,21 @@ plugins {
alias(libs.plugins.hilt)
}
// Source unique de vérité : VERSION à la racine du dépôt (ex. 0.1.0).
val vaultVersion: String = rootProject.file("../VERSION").readText().trim()
val vaultVersionCode: Int = run {
val parts = vaultVersion.split('.').map { it.toInt() }
check(parts.size == 3) { "VERSION doit être major.minor.patch, reçu: $vaultVersion" }
parts[0] * 1_000_000 + parts[1] * 1_000 + parts[2]
}
// Signature release : signing.properties (gitignoré) en local, variables
// d'env (SIGNING_*) en CI. Jamais de keystore commité.
val signingProps = Properties().apply {
val f = file("signing.properties")
if (f.exists()) f.inputStream().use { load(it) }
}
android {
namespace = "com.vaultdrop.mobile"
compileSdk = 35
@@ -19,8 +34,8 @@ android {
// OpenCV/CameraX sont en 4K (warning de compatibilité sur devices
// 16K-pages). On reste en 34 tant qu'elles ne sont pas mises à jour.
targetSdk = 34
versionCode = 1
versionName = "0.1.0"
versionCode = vaultVersionCode
versionName = vaultVersion
// OpenCV natif : on conserve uniquement les ABIs ciblés
// (arm64 = devices, x86_64 = émulateur) pour garder l'APK raisonnable.
@@ -29,6 +44,18 @@ android {
}
}
signingConfigs {
create("release") {
val storePath = signingProps.getProperty("storeFile") ?: System.getenv("SIGNING_STORE_FILE")
if (storePath != null) {
storeFile = file(storePath)
storePassword = signingProps.getProperty("storePassword") ?: System.getenv("SIGNING_STORE_PASSWORD")
keyAlias = signingProps.getProperty("keyAlias") ?: System.getenv("SIGNING_KEY_ALIAS")
keyPassword = signingProps.getProperty("keyPassword") ?: System.getenv("SIGNING_KEY_PASSWORD")
}
}
}
buildTypes {
release {
isMinifyEnabled = false
@@ -36,6 +63,7 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
signingConfig = signingConfigs.getByName("release")
}
}
compileOptions {
@@ -0,0 +1,14 @@
# Signature release Android — copier vers signing.properties (jamais commité)
# Alternative CI : mêmes valeurs via les variables d'environnement SIGNING_*.
#
# 1. Générer un keystore (ne jamais le committer, garder en lieu sûr) :
# keytool -genkey -v -keystore release.keystore -alias vaultdrop \
# -keyalg RSA -keysize 2048 -validity 10000
# 2. Renseigner les valeurs ci-dessous.
#
# storeFile est relatif au répertoire du module app/ (mobile-kotlin/app).
storeFile=release.keystore
storePassword=CHANGE_ME
keyAlias=vaultdrop
keyPassword=CHANGE_ME
@@ -6,6 +6,7 @@ import com.vaultdrop.mobile.data.remote.dto.ApiEnvelope
import com.vaultdrop.mobile.data.remote.dto.ApiErrorEnvelope
import com.vaultdrop.mobile.data.remote.dto.DeviceRegistrationDto
import com.vaultdrop.mobile.data.remote.dto.FileDto
import com.vaultdrop.mobile.data.remote.dto.FileOcrDto
import com.vaultdrop.mobile.data.remote.dto.FolderDto
import com.vaultdrop.mobile.data.remote.dto.LoginRequestDto
import com.vaultdrop.mobile.data.remote.dto.LoginResponseDto
@@ -105,6 +106,10 @@ class ApiClient @Inject constructor(
suspend fun getOcrJob(jobId: String): OcrJobDto =
unwrap({ apiService.getOcrJob(jobId) })
/** Extrait OCR server-side d'un fichier visible (viewer+, own ou shared). */
suspend fun getFileOcr(fileId: String): FileOcrDto =
unwrap({ apiService.getFileOcr(fileId) })
private suspend fun <T> unwrap(
call: suspend () -> Response<ApiEnvelope<T>>,
skipUnauthorizedHandling: Boolean = false,
@@ -3,6 +3,7 @@ package com.vaultdrop.mobile.data.remote
import com.vaultdrop.mobile.data.remote.dto.ApiEnvelope
import com.vaultdrop.mobile.data.remote.dto.DeviceRegistrationDto
import com.vaultdrop.mobile.data.remote.dto.FileDto
import com.vaultdrop.mobile.data.remote.dto.FileOcrDto
import com.vaultdrop.mobile.data.remote.dto.FolderDto
import com.vaultdrop.mobile.data.remote.dto.LoginRequestDto
import com.vaultdrop.mobile.data.remote.dto.LoginResponseDto
@@ -96,4 +97,14 @@ interface ApiService {
suspend fun getOcrJob(
@Path("id") jobId: String,
): Response<ApiEnvelope<OcrJobDto>>
/**
* Extrait OCR déjà calculé côté serveur pour un fichier visible (viewer+,
* own ou shared). Permet à un second device de récupérer le texte sans
* re-téléverser les octets (docs/api-v1.md §5).
*/
@GET("files/{id}/ocr")
suspend fun getFileOcr(
@Path("id") fileId: String,
): Response<ApiEnvelope<FileOcrDto>>
}
@@ -152,4 +152,14 @@ data class OcrJobDto(
/** Requête `POST /ocr/jobs` — `{ "fileId": "…32-hex" }` (docs/api-v1.md §5). */
data class OcrSubmitDto(
@Json(name = "fileId") val fileId: String,
)
/**
* Réponse `GET /files/{id}/ocr` — extrait OCR server-side du fichier,
* consultable par le user et les grantees (viewer+). `text` "" tant que
* l'OCR n'a pas encore atterri sur la ressource (docs/api-v1.md §5).
*/
data class FileOcrDto(
@Json(name = "text") val text: String,
@Json(name = "updatedAt") val updatedAt: String? = null,
)
@@ -26,6 +26,8 @@ import javax.inject.Singleton
*
* Séquence pour un fichier SAF `processed` + `local-cloud` (resource_id déjà
* connu du serveur via l'outbox `create_resource`) :
* 0. [syncFromServer] — si un autre device a déjà OCRisé le fichier, le texte
* est rapatrié via `GET /files/:id/ocr` (viewer+) **sans** upload ni job ;
* 1. [ensurePhysical] — upload multipart **ciblé** (`resourceId`) des octets
* locaux : pipeline séparé, hors outbox, qui écrit
* `UPLOAD_DIR/<user>/<resource_id>.<ext>` — sans lequel le job OCR
@@ -75,6 +77,20 @@ class OcrRepository @Inject constructor(
fileDao.updateOcrText(resourceId, text?.takeIf { it.isNotBlank() }, System.currentTimeMillis())
}
/**
* Rapatrie l'extrait OCR déjà calculé côté serveur pour un fichier visible
* (viewer+, own ou shared). Renvoie le texte persisté en Room, ou null si le
* serveur n'a pas encore d'extrait — un second device récupère ainsi le
* résultat d'un OCR fait par un autre device, **sans re-téléverser les
* octets ni re-soumettre un job** (docs/api-v1.md §5, GET /files/:id/ocr).
*/
suspend fun syncFromServer(file: FileEntity): String? {
val dto = apiClient.getFileOcr(file.resourceId)
if (dto.text.isBlank()) return null
saveResult(file.resourceId, dto.text)
return dto.text
}
/**
* OCR auto d'un fichier (un seul à la fois) : marqueur « en cours », upload
* multipart ciblé, soumission du job serveur, poll jusqu'au terminal puis
@@ -86,6 +102,12 @@ class OcrRepository @Inject constructor(
val now = System.currentTimeMillis()
fileDao.markOcrQueued(file.resourceId, now, now)
return try {
// 0. Un autre device a peut-être déjà OCRisé le fichier : si le
// serveur a le texte, on le rapatrie sans upload ni job.
if (syncFromServer(file) != null) {
fileDao.clearOcrQueued(file.resourceId, System.currentTimeMillis())
return OcrAutoOutcome.Done
}
ensurePhysical(file)
val jobId = submit(file.resourceId)
val final = poll(jobId)
@@ -77,6 +77,21 @@ class OcrViewModel @Inject constructor(
_uiState.value = OcrUiState(resourceId = file.resourceId, uploading = true)
activeJob = viewModelScope.launch {
try {
// Un autre device a peut-être déjà OCRisé ce document : le
// serveur a le texte (GET /files/:id/ocr) → on l'affiche sans
// re-téléverser les octets ni re-soumettre un job (docs §5).
val synced = ocrRepository.syncFromServer(file)
if (synced != null) {
_uiState.update {
it.copy(
resourceId = file.resourceId,
uploading = false,
status = OcrJobStatus.DONE,
text = synced,
)
}
return@launch
}
ocrRepository.ensurePhysical(file)
val jobId = ocrRepository.submit(file.resourceId)
val final = ocrRepository.poll(jobId) { job ->
@@ -17,6 +17,7 @@ import com.vaultdrop.mobile.data.remote.ApiService
import com.vaultdrop.mobile.data.remote.dto.ApiEnvelope
import com.vaultdrop.mobile.data.remote.dto.DeviceRegistrationDto
import com.vaultdrop.mobile.data.remote.dto.FileDto
import com.vaultdrop.mobile.data.remote.dto.FileOcrDto
import com.vaultdrop.mobile.data.remote.dto.FolderDto
import com.vaultdrop.mobile.data.remote.dto.LoginRequestDto
import com.vaultdrop.mobile.data.remote.dto.LoginResponseDto
@@ -224,4 +225,8 @@ private class FolderMergeApiService : ApiService {
override suspend fun getOcrJob(
jobId: String,
): Response<ApiEnvelope<OcrJobDto>> = Response.success(ApiEnvelope())
override suspend fun getFileOcr(
fileId: String,
): Response<ApiEnvelope<FileOcrDto>> = Response.success(ApiEnvelope(data = FileOcrDto(text = "")))
}
@@ -18,6 +18,7 @@ import com.vaultdrop.mobile.data.remote.ApiService
import com.vaultdrop.mobile.data.remote.dto.ApiEnvelope
import com.vaultdrop.mobile.data.remote.dto.DeviceRegistrationDto
import com.vaultdrop.mobile.data.remote.dto.FileDto
import com.vaultdrop.mobile.data.remote.dto.FileOcrDto
import com.vaultdrop.mobile.data.remote.dto.FolderDto
import com.vaultdrop.mobile.data.remote.dto.LoginRequestDto
import com.vaultdrop.mobile.data.remote.dto.LoginResponseDto
@@ -311,4 +312,8 @@ private class MoveProtectionApiService : ApiService {
override suspend fun getOcrJob(
jobId: String,
): Response<ApiEnvelope<OcrJobDto>> = Response.success(ApiEnvelope())
override suspend fun getFileOcr(
fileId: String,
): Response<ApiEnvelope<FileOcrDto>> = Response.success(ApiEnvelope(data = FileOcrDto(text = "")))
}
@@ -15,6 +15,7 @@ import com.vaultdrop.mobile.data.remote.ApiService
import com.vaultdrop.mobile.data.remote.dto.ApiEnvelope
import com.vaultdrop.mobile.data.remote.dto.DeviceRegistrationDto
import com.vaultdrop.mobile.data.remote.dto.FileDto
import com.vaultdrop.mobile.data.remote.dto.FileOcrDto
import com.vaultdrop.mobile.data.remote.dto.FolderDto
import com.vaultdrop.mobile.data.remote.dto.LoginRequestDto
import com.vaultdrop.mobile.data.remote.dto.LoginResponseDto
@@ -220,6 +221,43 @@ class OcrRepositoryTest {
assertNull(stored?.ocrQueuedAt)
assertNull(stored?.ocrText)
}
@Test
fun `syncFromServer_persiste_le_texte_deja_calcule_coté_serveur`() = runTest {
val file = insertFile("content://ocr/synced.txt")
apiService.fileOcr = FileOcrDto(text = "texte d'un autre device", updatedAt = "2026-01-01T00:00:00Z")
val text = repository.syncFromServer(file)
assertEquals("texte d'un autre device", text)
assertEquals("texte d'un autre device", fileDao.getByResourceId(file.resourceId)?.ocrText)
assertEquals(1, apiService.getFileOcrCalls)
}
@Test
fun `syncFromServer_retourne_null_quand_le_serveur_na_pas_d_extrait`() = runTest {
val file = insertFile("content://ocr/no-ocr.txt")
apiService.fileOcr = FileOcrDto(text = "")
val text = repository.syncFromServer(file)
assertNull(text)
assertNull(fileDao.getByResourceId(file.resourceId)?.ocrText)
}
@Test
fun `processAuto_reutilise_le_texte_serveur_sans_upload_ni_job`() = runTest {
val file = insertFile("content://ocr/reuse.txt")
apiService.fileOcr = FileOcrDto(text = "déjà extrait ailleurs")
apiService.jobSequence = emptyList()
val outcome = repository.processAuto(file)
assertTrue(outcome is OcrAutoOutcome.Done)
assertTrue(apiService.uploadedFiles.isEmpty())
assertEquals("déjà extrait ailleurs", fileDao.getByResourceId(file.resourceId)?.ocrText)
assertNull(fileDao.getByResourceId(file.resourceId)?.ocrQueuedAt)
}
}
/**
@@ -230,6 +268,8 @@ private class OcrApiServiceFake : ApiService {
var uploadedFiles: MutableList<Pair<String?, RequestBody?>> = mutableListOf()
var uploadedBytes: String? = null
var jobSequence: List<OcrJobDto> = emptyList()
var fileOcr: FileOcrDto = FileOcrDto(text = "")
var getFileOcrCalls = 0
private var getCalls = 0
override suspend fun listFolders(): Response<ApiEnvelope<List<FolderDto>>> =
@@ -289,6 +329,13 @@ private class OcrApiServiceFake : ApiService {
return Response.success(ApiEnvelope(data = job))
}
override suspend fun getFileOcr(
fileId: String,
): Response<ApiEnvelope<FileOcrDto>> {
getFileOcrCalls++
return Response.success(ApiEnvelope(data = fileOcr))
}
private fun readBody(body: RequestBody): String {
val buffer = okio.Buffer()
body.writeTo(buffer)
@@ -17,6 +17,7 @@ import com.vaultdrop.mobile.data.remote.ApiService
import com.vaultdrop.mobile.data.remote.dto.ApiEnvelope
import com.vaultdrop.mobile.data.remote.dto.DeviceRegistrationDto
import com.vaultdrop.mobile.data.remote.dto.FileDto
import com.vaultdrop.mobile.data.remote.dto.FileOcrDto
import com.vaultdrop.mobile.data.remote.dto.FolderDto
import com.vaultdrop.mobile.data.remote.dto.LoginRequestDto
import com.vaultdrop.mobile.data.remote.dto.LoginResponseDto
@@ -268,4 +269,8 @@ private class FakeApiService : ApiService {
override suspend fun getOcrJob(
jobId: String,
): Response<ApiEnvelope<OcrJobDto>> = Response.success(ApiEnvelope())
override suspend fun getFileOcr(
fileId: String,
): Response<ApiEnvelope<FileOcrDto>> = Response.success(ApiEnvelope(data = FileOcrDto(text = "")))
}