diff --git a/backend/handlers/files.go b/backend/handlers/files.go index c2dc034..96b8f7f 100644 --- a/backend/handlers/files.go +++ b/backend/handlers/files.go @@ -85,6 +85,13 @@ func FilesUpload(c *gin.Context) { } userID := c.GetString(UserIDKey) folderID := c.PostForm("folderId") + // resourceId optionnel : cible un fichier déjà connu (metadata-only de + // l'outbox) pour lui attacher ses octets physiques (ex. OCR serveur). + resourceID := c.PostForm("resourceId") + if resourceID != "" && !deviceIDPattern.MatchString(resourceID) { + api.Error(c, http.StatusNotFound, "NOT_FOUND", "file not found") + return + } c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, Store.MaxFileSize+1) file, err := c.FormFile("file") @@ -102,7 +109,7 @@ func FilesUpload(c *gin.Context) { return } - dto, err := Store.Upload(userID, file, folderID) + dto, err := Store.Upload(userID, file, folderID, resourceID) if err != nil { writeError(c, err) return diff --git a/backend/handlers/files_test.go b/backend/handlers/files_test.go index 588028a..c24bd2e 100644 --- a/backend/handlers/files_test.go +++ b/backend/handlers/files_test.go @@ -183,6 +183,29 @@ func uploadMultipart(t *testing.T, r *gin.Engine, token, folderID, filename stri return rec } +// uploadMultipartToResource uploads bytes targeted at an existing resource_id +// (metadata-only file from the outbox), le cas d'usage OCR. +func uploadMultipartToResource(t *testing.T, r *gin.Engine, token, resourceID, filename string, content []byte) *httptest.ResponseRecorder { + t.Helper() + var body bytes.Buffer + writer := multipart.NewWriter(&body) + part, err := writer.CreateFormFile("file", filename) + if err != nil { + t.Fatalf("create form file: %v", err) + } + if _, err := part.Write(content); err != nil { + t.Fatalf("write body: %v", err) + } + if err := writer.WriteField("resourceId", resourceID); err != nil { + t.Fatalf("write resourceId: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("close writer: %v", err) + } + rec, _ := doRequest(t, r, http.MethodPost, "/api/v1/files/upload", token, body.Bytes(), writer.FormDataContentType()) + return rec +} + func TestFilesFlow(t *testing.T) { r, _, repo := setup(t) deviceA := repository.NewID() @@ -385,6 +408,67 @@ func TestUploadNameConflict(t *testing.T) { expectError(t, rec, http.StatusConflict, "NAME_CONFLICT", "upload-dupe") } +func TestUploadTargetsExistingResource(t *testing.T) { + r, _, repo := setup(t) + device := repository.NewID() + token, user := registerAndLogin(t, r, repo, testUserUsername(device, "tr"), "upload-test-password", device) + + // Fiche metadata-only (même sémantique que l'outbox create_resource). + resourceID := repository.NewID() + if err := repo.Resources.InsertFile(user, resourceID, "scan.png", "", 0, nil, nil); err != nil { + t.Fatalf("insert metadata-only file: %v", err) + } + + rec := uploadMultipartToResource(t, r, token, resourceID, "scan.png", []byte("PNGDATA")) + env := expectOK(t, rec, "upload-target") + var uploaded fileDTO + if err := json.Unmarshal(env.Data, &uploaded); err != nil { + t.Fatalf("upload-target: unmarshal: %v", err) + } + if uploaded.ID != resourceID { + t.Errorf("id attendu %s, got %s", resourceID, uploaded.ID) + } + if uploaded.Size != 7 { + t.Errorf("taille = %d, attendu 7", uploaded.Size) + } + + // Toujours une seule ligne (pas de doublon). + rec, _ = doRequest(t, r, http.MethodGet, "/api/v1/files", token, nil, "") + env = expectOK(t, rec, "list-after-targeted-upload") + var files []fileDTO + if err := json.Unmarshal(env.Data, &files); err != nil { + t.Fatalf("list: unmarshal: %v", err) + } + if len(files) != 1 || files[0].ID != resourceID { + t.Errorf("aucun doublon attendu, got %+v", files) + } +} + +func TestUploadTargetForeignResourceNotFound(t *testing.T) { + r, _, repo := setup(t) + deviceA := repository.NewID() + deviceB := repository.NewID() + tokenA, _ := registerAndLogin(t, r, repo, testUserUsername(deviceA, "tf"), "upload-test-password", deviceA) + _, userB := registerAndLogin(t, r, repo, testUserUsername(deviceB, "tg"), "upload-test-password-b", deviceB) + + resourceID := repository.NewID() + if err := repo.Resources.InsertFile(userB, resourceID, "mine.png", "", 0, nil, nil); err != nil { + t.Fatalf("insert B file: %v", err) + } + + rec := uploadMultipartToResource(t, r, tokenA, resourceID, "mine.png", []byte("DATA")) + expectError(t, rec, http.StatusNotFound, "NOT_FOUND", "upload-foreign") +} + +func TestUploadTargetInvalidID(t *testing.T) { + r, _, repo := setup(t) + device := repository.NewID() + token, _ := registerAndLogin(t, r, repo, testUserUsername(device, "ti"), "upload-test-password", device) + + rec := uploadMultipartToResource(t, r, token, "NOT-HEX", "x.png", []byte("DATA")) + expectError(t, rec, http.StatusNotFound, "NOT_FOUND", "upload-invalid-id") +} + func TestFoldersListAndScoping(t *testing.T) { r, _, repo := setup(t) deviceA := repository.NewID() diff --git a/backend/repository/resources.go b/backend/repository/resources.go index dbd1ed2..dae503f 100644 --- a/backend/repository/resources.go +++ b/backend/repository/resources.go @@ -433,6 +433,29 @@ func (r *Resources) UpdateNameByID(resourceID, name string) error { return nil } +// UpdatePhysical refreshes an existing file row's physical metadata +// (size_bytes / mime_type / extension) after a targeted re-upload — used so a +// resource created metadata-only (outbox create_resource) receives its bytes +// for server-side OCR. Absent / not owned → ErrNotFound. Never renames or moves. +func (r *Resources) UpdatePhysical(ownerID, resourceID string, size int64, mimeType, extension *string) error { + result, err := r.DB.Exec( + `UPDATE resources SET size_bytes = $3, mime_type = $4, extension = $5, updated_at = NOW() + WHERE resource_id = $1 AND user_id = $2 AND type = 'file' AND deleted_at IS NULL`, + resourceID, ownerID, size, mimeType, extension, + ) + if err != nil { + return err + } + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return ErrNotFound + } + return nil +} + // SyncDelete soft-deletes a resource; absence is NOT an error (idempotent // terminal state for the outbox). func (r *Resources) SyncDelete(ownerID, resourceID string) error { diff --git a/backend/service/resources.go b/backend/service/resources.go index 60039ef..6dd6143 100644 --- a/backend/service/resources.go +++ b/backend/service/resources.go @@ -104,30 +104,51 @@ func (s *Resources) SearchFiles(ownerID, q string, page, pageSize int) ([]FileDT // Upload persists the multipart-sourced file under UploadDir/ and // records its metadata, returning the FileDTO. The physical file is removed // if metadata persistence fails (e.g. name conflict). -func (s *Resources) Upload(ownerID string, file *multipart.FileHeader, folderID string) (FileDTO, error) { +// +// When resourceID is non-empty the upload TARGETS an existing file owned by +// the user (metadata-only resource from the outbox, or a previously uploaded +// file): the bytes are written under UploadDir//. and +// only the physical metadata (size/mime/extension) is refreshed — never a +// rename, never a move, never a duplicate row. Unknown / foreign resource → +// repo.ErrNotFound. +func (s *Resources) Upload(ownerID string, file *multipart.FileHeader, folderID, resourceID string) (FileDTO, error) { if file.Size > s.MaxFileSize { return FileDTO{}, ErrFileTooLarge } - id := repository.NewID() + targetExisting := resourceID != "" + if targetExisting { + if _, err := s.Repo.GetFile(ownerID, resourceID); err != nil { + return FileDTO{}, err + } + } else { + resourceID = repository.NewID() + } + extension := strings.TrimPrefix(filepath.Ext(file.Filename), ".") destDir := filepath.Join(s.UploadDir, ownerID) if err := os.MkdirAll(destDir, 0o755); err != nil { return FileDTO{}, fmt.Errorf("create upload dir: %w", err) } - destPath := filepath.Join(destDir, id+"."+extension) + destPath := filepath.Join(destDir, resourceID+"."+extension) if err := copyMultipart(file, destPath); err != nil { return FileDTO{}, err } mimeType := file.Header.Get("Content-Type") - if err := s.Repo.InsertFile(ownerID, id, file.Filename, folderID, file.Size, &mimeType, &extension); err != nil { + var err error + if targetExisting { + err = s.Repo.UpdatePhysical(ownerID, resourceID, file.Size, &mimeType, &extension) + } else { + err = s.Repo.InsertFile(ownerID, resourceID, file.Filename, folderID, file.Size, &mimeType, &extension) + } + if err != nil { _ = os.Remove(destPath) return FileDTO{}, err } - row, err := s.Repo.GetFile(ownerID, id) + row, err := s.Repo.GetFile(ownerID, resourceID) if err != nil { return FileDTO{}, err } diff --git a/backend/service/resources_test.go b/backend/service/resources_test.go index a98e96a..bb15da3 100644 --- a/backend/service/resources_test.go +++ b/backend/service/resources_test.go @@ -42,7 +42,7 @@ func TestUploadPersistsPhysicalFile(t *testing.T) { s := newServiceStore(t) userID := mustCreateUser(t, s.Repository, "upload-happy") - dto, err := s.Upload(userID, multipartFileHeader(t, "docs.txt", []byte("hello")), "") + dto, err := s.Upload(userID, multipartFileHeader(t, "docs.txt", []byte("hello")), "", "") if err != nil { t.Fatalf("upload: %v", err) } @@ -70,7 +70,7 @@ func TestUploadRemovesPhysicalFileOnNameConflict(t *testing.T) { } target := multipartFileHeader(t, "dupe.txt", []byte("hello")) - first, err := s.Upload(userID, target, folderID) + first, err := s.Upload(userID, target, folderID, "") if err != nil { t.Fatalf("premier upload: %v", err) } @@ -79,7 +79,7 @@ func TestUploadRemovesPhysicalFileOnNameConflict(t *testing.T) { } // Même nom, même dossier → NAME_CONFLICT et pas de second fichier physique. - if _, err := s.Upload(userID, target, folderID); !errors.Is(err, repository.ErrNameConflict) { + if _, err := s.Upload(userID, target, folderID, ""); !errors.Is(err, repository.ErrNameConflict) { t.Fatalf("second upload : attendu ErrNameConflict, got %v", err) } @@ -91,3 +91,74 @@ func TestUploadRemovesPhysicalFileOnNameConflict(t *testing.T) { t.Errorf("fichier orphelin laissé après NAME_CONFLICT : %d fichiers", len(entries)) } } + +func TestUploadTargetsExistingResource(t *testing.T) { + s := newServiceStore(t) + userID := mustCreateUser(t, s.Repository, "upload-target") + + // Fiche metadata-only créée par l'outbox côté client. + resourceID := repository.NewID() + ext := "png" + if err := s.Repository.Resources.InsertFile(userID, resourceID, "scan.png", "", 0, nil, &ext); err != nil { + t.Fatalf("insert metadata-only file: %v", err) + } + + // Re-upload ciblé : mêmes octets, même resource_id, pas de doublon. + dto, err := s.Upload(userID, multipartFileHeader(t, "scan.png", []byte("PNGDATA")), "", resourceID) + if err != nil { + t.Fatalf("targeted upload: %v", err) + } + if dto.ID != resourceID { + t.Errorf("id attendu %s, got %s", resourceID, dto.ID) + } + if dto.Size != 7 { + t.Errorf("taille = %d, attendu 7 (rafraîchie)", dto.Size) + } + + path := filepath.Join(s.UploadDir, userID, resourceID+".png") + info, err := os.Stat(path) + if err != nil { + t.Fatalf("fichier physique absent: %v", err) + } + if info.Size() != 7 { + t.Errorf("taille physique = %d, attendu 7", info.Size()) + } + + // Une seule ligne serveur (pas de doublon créé par le re-upload). + rows, total, err := s.Repo.ListFiles(userID, "", 50, 0, "", "") + if err != nil { + t.Fatalf("list: %v", err) + } + if total != 1 || len(rows) != 1 || rows[0].ID != resourceID { + t.Errorf("liste après re-upload: total=%d rows=%+v", total, rows) + } +} + +func TestUploadTargetsOwnedResourceOnly(t *testing.T) { + s := newServiceStore(t) + userID := mustCreateUser(t, s.Repository, "upload-owner") + otherUser := mustCreateUser(t, s.Repository, "upload-owner-other") + + resourceID := repository.NewID() + if err := s.Repository.Resources.InsertFile(otherUser, resourceID, "mine.png", "", 0, nil, nil); err != nil { + t.Fatalf("insert other's file: %v", err) + } + + // Un resource_id d'un autre user → ErrNotFound, aucun octet écrit. + if _, err := s.Upload(userID, multipartFileHeader(t, "mine.png", []byte("DATA")), "", resourceID); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("upload cross-user : attendu ErrNotFound, got %v", err) + } + // Le dossier upload du user ne doit pas avoir été créé (échec avant écriture). + if _, err := os.Stat(filepath.Join(s.UploadDir, userID)); !os.IsNotExist(err) { + t.Errorf("dossier upload créé à tort ou erreur inattendue: %v", err) + } +} + +func TestUploadTargetUnknownIsNotFound(t *testing.T) { + s := newServiceStore(t) + userID := mustCreateUser(t, s.Repository, "upload-unknown") + + if _, err := s.Upload(userID, multipartFileHeader(t, "ghost.png", []byte("DATA")), "", repository.NewID()); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("upload inconnu : attendu ErrNotFound, got %v", err) + } +} diff --git a/docs/api-v1.md b/docs/api-v1.md index 867bff3..32e8b21 100644 --- a/docs/api-v1.md +++ b/docs/api-v1.md @@ -68,7 +68,8 @@ type OcrJob = { id: string; status: OcrJobStatus; text?: string | null; error?: ## 4. Upload -- Multipart : champ `file` + `folderId?` optionnel. **Le client ne fixe jamais `Content-Type`** (le boundary doit être généré par la plateforme). +- Multipart : champ `file` + `folderId?` optionnel + `resourceId?` optionnel. **Le client ne fixe jamais `Content-Type`** (le boundary doit être généré par la plateforme). +- `resourceId` optionnel : cible un **fichier déjà connu du user** (créé metadata-only via l'outbox `create_resource`, ou précédemment uploadé) pour lui **attacher ses octets physiques** — cas d'usage : le client local-first doit fournir le bytes d'un fichier SAF pour l'OCR serveur (`POST /ocr/jobs`). Le `resourceId` fourni doit exister et appartenir au user (sinon `NOT_FOUND`, même réponses/format que `GET /files/:id`) ; la ligne n'est **jamais re-créée ni renommée ni déplacée**, seule la métadonnée physique (`size`/`mimeType`/extension) est rafraîchie et `FileDto` renvoyé. `resourceId` absent → comportement historique (nouvelle ressource). - Limite : `MAX_FILE_SIZE_MB` (défaut 50). Dépassement → 413 `{ "error": { "code": "FILE_TOO_LARGE", … } }`. - Le fichier physique est stocké sous `UPLOAD_DIR//.` ; la métadonnée est persistée en base et renvoyée en `FileDto`. Si la persistance de la métadonnée échoue (ex. `NAME_CONFLICT`), le fichier physique est supprimé. diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/AppDatabase.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/AppDatabase.kt index fea0b9c..d1154aa 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/AppDatabase.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/AppDatabase.kt @@ -19,14 +19,15 @@ import com.vaultdrop.mobile.data.local.entity.UserPreferenceEntity * v1: folders ; v2: user_preferences ; v3: files ; v4: category sur files ; * v5: created_in_app sur folders ; v6: processed sur files (mode review) ; * v7: pending_operations (outbox) ; v8: scan_sessions + scan_pages (scanner) ; - * v9: content sur files (corps des notes créées dans l'app). + * v9: content sur files (corps des notes créées dans l'app) ; + * v10: ocr_text sur files (extrait OCR serveur persisté localement). */ @Database( entities = [ FolderEntity::class, UserPreferenceEntity::class, FileEntity::class, PendingOperationEntity::class, ScanSessionEntity::class, ScanPageEntity::class, ], - version = 9, + version = 10, exportSchema = false, ) abstract class AppDatabase : RoomDatabase() { diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FileDao.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FileDao.kt index 03960c0..5c25c47 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FileDao.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FileDao.kt @@ -122,4 +122,8 @@ interface FileDao { WHERE "exists" = 1 AND processed = 0 AND uri IS NOT NULL """) suspend fun markAllProcessed(updatedAt: Long) + + /** Persiste l'extrait OCR d'un fichier (résultat local du job serveur). */ + @Query("UPDATE files SET ocr_text = :text, updated_at = :updatedAt WHERE resource_id = :resourceId") + suspend fun updateOcrText(resourceId: String, text: String?, updatedAt: Long) } \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/entity/FileEntity.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/entity/FileEntity.kt index 9dd1987..f8817bf 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/entity/FileEntity.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/entity/FileEntity.kt @@ -55,6 +55,8 @@ data class FileEntity( val content: String? = null, @ColumnInfo(name = "processed") val processed: Boolean = false, + @ColumnInfo(name = "ocr_text") + val ocrText: String? = null, @ColumnInfo(name = "added_at") val addedAt: Long, @ColumnInfo(name = "updated_at") diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/migration/Migrations.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/migration/Migrations.kt index f7b6dcb..25171fc 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/migration/Migrations.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/migration/Migrations.kt @@ -24,6 +24,8 @@ import androidx.sqlite.db.SupportSQLiteDatabase * multi-pages persistée pour survivre au process death avant export SAF. * v9 : ajout colonne `content` sur `files` — corps d'une note créée dans * l'app (fichier cloud-only, uri = NULL). Null pour les fichiers importés. + * v10 : ajout colonne `ocr_text` sur `files` — extrait OCR serveur persisté + * localement (résultat d'un job `POST /ocr/jobs`, réaffichable sans relance). */ object Migrations { @@ -166,7 +168,13 @@ object Migrations { } } + private val MIGRATION_9_10 = object : Migration(9, 10) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `files` ADD COLUMN `ocr_text` TEXT") + } + } + val ALL: Array = arrayOf( - MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, + MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, ) } \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/ApiClient.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/ApiClient.kt index 217286a..58a1e48 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/ApiClient.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/ApiClient.kt @@ -9,11 +9,17 @@ import com.vaultdrop.mobile.data.remote.dto.FileDto import com.vaultdrop.mobile.data.remote.dto.FolderDto import com.vaultdrop.mobile.data.remote.dto.LoginRequestDto import com.vaultdrop.mobile.data.remote.dto.LoginResponseDto +import com.vaultdrop.mobile.data.remote.dto.OcrJobDto +import com.vaultdrop.mobile.data.remote.dto.OcrSubmitDto import com.vaultdrop.mobile.data.remote.dto.ResolvedUserDto import com.vaultdrop.mobile.data.remote.dto.ResourcePermissionDto import com.vaultdrop.mobile.data.remote.dto.SyncOpDto import com.vaultdrop.mobile.data.remote.dto.SyncOpsRequest import com.vaultdrop.mobile.data.remote.dto.SyncOpsResult +import okhttp3.MultipartBody +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody import okio.IOException import retrofit2.Response import timber.log.Timber @@ -76,6 +82,29 @@ class ApiClient @Inject constructor( suspend fun syncPermissions(after: Long? = null): List = unwrap({ apiService.syncPermissions(after) }) + /** + * Upload ciblé des octets d'un fichier vers son `resource_id` serveur + * (pipeline multipart séparé, hors outbox — cas d'usage OCR). La partie + * `file` est construite par l'appelant (flux SAF). + */ + suspend fun uploadFile( + file: MultipartBody.Part, + resourceId: String? = null, + folderId: String? = null, + ): FileDto { + val resourceIdBody = resourceId?.toRequestBody("text/plain".toMediaType()) + val folderIdBody = folderId?.toRequestBody("text/plain".toMediaType()) + return unwrap({ apiService.uploadFile(file, resourceIdBody, folderIdBody) }) + } + + /** Soumet un job OCR serveur pour un fichier possédé. */ + suspend fun submitOcr(fileId: String): OcrJobDto = + unwrap({ apiService.submitOcr(OcrSubmitDto(fileId)) }) + + /** Interroge un job OCR par son id (scopé au device du token). */ + suspend fun getOcrJob(jobId: String): OcrJobDto = + unwrap({ apiService.getOcrJob(jobId) }) + private suspend fun unwrap( call: suspend () -> Response>, skipUnauthorizedHandling: Boolean = false, diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/ApiService.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/ApiService.kt index 966e8fd..028492c 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/ApiService.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/ApiService.kt @@ -6,14 +6,21 @@ import com.vaultdrop.mobile.data.remote.dto.FileDto import com.vaultdrop.mobile.data.remote.dto.FolderDto import com.vaultdrop.mobile.data.remote.dto.LoginRequestDto import com.vaultdrop.mobile.data.remote.dto.LoginResponseDto +import com.vaultdrop.mobile.data.remote.dto.OcrJobDto +import com.vaultdrop.mobile.data.remote.dto.OcrSubmitDto import com.vaultdrop.mobile.data.remote.dto.ResolvedUserDto import com.vaultdrop.mobile.data.remote.dto.ResourcePermissionDto import com.vaultdrop.mobile.data.remote.dto.SyncOpsRequest import com.vaultdrop.mobile.data.remote.dto.SyncOpsResult +import okhttp3.MultipartBody +import okhttp3.RequestBody import retrofit2.Response import retrofit2.http.Body import retrofit2.http.GET +import retrofit2.http.Multipart import retrofit2.http.POST +import retrofit2.http.Part +import retrofit2.http.Path import retrofit2.http.Query /** @@ -63,4 +70,30 @@ interface ApiService { suspend fun syncPermissions( @Query("after") after: Long? = null, ): Response>> + + /** + * Upload ciblé (multipart) : attache les octets d'un fichier SAF à son + * `resource_id` serveur déjà connu (créé metadata-only par l'outbox) — + * pipeline séparé, hors outbox, prérequis du OCR serveur. `resourceId` + * absent → création d'une nouvelle ressource. + */ + @Multipart + @POST("files/upload") + suspend fun uploadFile( + @Part file: MultipartBody.Part, + @Part("resourceId") resourceId: RequestBody? = null, + @Part("folderId") folderId: RequestBody? = null, + ): Response> + + /** Soumet un job OCR serveur — `{ "fileId": "…" }` (docs/api-v1.md §5). */ + @POST("ocr/jobs") + suspend fun submitOcr( + @Body body: OcrSubmitDto, + ): Response> + + /** Interroge un job OCR — le client poll jusqu'à `done`/`failed` toutes les 3s. */ + @GET("ocr/jobs/{id}") + suspend fun getOcrJob( + @Path("id") jobId: String, + ): Response> } \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/dto/Dtos.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/dto/Dtos.kt index 3a39ad1..d0a672a 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/dto/Dtos.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/dto/Dtos.kt @@ -129,4 +129,27 @@ data class ResourcePermissionDto( @Json(name = "updatedAt") val updatedAt: Long, @Json(name = "name") val name: String = "", @Json(name = "parentId") val parentId: String? = null, +) + +/** Cycle d'un job OCR serveur (docs/api-v1.md §5). */ +object OcrJobStatus { + const val QUEUED = "queued" + const val PROCESSING = "processing" + const val DONE = "done" + const val FAILED = "failed" + + val TERMINAL = setOf(DONE, FAILED) +} + +/** Miroir de `OcrJob` (docs/api-v1.md §5) — `text`/`error` présents aux états terminaux. */ +data class OcrJobDto( + @Json(name = "id") val id: String, + @Json(name = "status") val status: String, + @Json(name = "text") val text: String? = null, + @Json(name = "error") val error: String? = null, +) + +/** Requête `POST /ocr/jobs` — `{ "fileId": "…32-hex" }` (docs/api-v1.md §5). */ +data class OcrSubmitDto( + @Json(name = "fileId") val fileId: String, ) \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/OcrRepository.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/OcrRepository.kt new file mode 100644 index 0000000..e7f55b0 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/OcrRepository.kt @@ -0,0 +1,103 @@ +package com.vaultdrop.mobile.data.repository + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import androidx.core.net.toUri +import com.vaultdrop.mobile.data.local.dao.FileDao +import com.vaultdrop.mobile.data.local.entity.FileEntity +import com.vaultdrop.mobile.data.remote.ApiClient +import com.vaultdrop.mobile.data.remote.dto.OcrJobDto +import com.vaultdrop.mobile.data.remote.dto.OcrJobStatus +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.delay +import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody +import okio.BufferedSink +import okio.source +import javax.inject.Inject +import javax.inject.Singleton + +/** + * OCR serveur (docs/api-v1.md §5) côté client. + * + * Séquence pour un fichier SAF `processed` + `local-cloud` (resource_id déjà + * connu du serveur via l'outbox `create_resource`) : + * 1. [ensurePhysical] — upload multipart **ciblé** (`resourceId`) des octets + * locaux : pipeline séparé, hors outbox, qui écrit + * `UPLOAD_DIR//.` — sans lequel le job OCR + * échouerait « file not readable » ; + * 2. [submit] — enfile le job (status `queued`) ; + * 3. [poll] — interroge toutes les 3 s jusqu'à `done`/`failed` ; + * 4. [saveResult] — persiste l'extrait en Room (v10, `ocr_text`) pour un + * réaffichage sans relance. + */ +@Singleton +class OcrRepository @Inject constructor( + private val apiClient: ApiClient, + private val fileDao: FileDao, + @ApplicationContext private val context: Context, +) { + + /** Envoie les octets SAF vers le resource_id serveur déjà connu. */ + suspend fun ensurePhysical(file: FileEntity) { + val uri = file.uri ?: return + val mediaType = file.mimeType?.toMediaTypeOrNull() + val body = SafFileRequestBody(context.contentResolver, uri, file.size, mediaType) + apiClient.uploadFile( + file = MultipartBody.Part.createFormData("file", file.name, body), + resourceId = file.resourceId, + ) + } + + /** Enfile le job OCR du fichier et renvoie son id. */ + suspend fun submit(fileId: String): String = + apiClient.submitOcr(fileId).id + + /** + * Poll toutes les 3 s (docs §5) jusqu'à l'état terminal. [onProgress] est + * appelé à chaque réponse du serveur (état courant exposé à l'UI). + */ + suspend fun poll(jobId: String, onProgress: (OcrJobDto) -> Unit = {}): OcrJobDto { + while (true) { + val job = apiClient.getOcrJob(jobId) + onProgress(job) + if (job.status in OcrJobStatus.TERMINAL) return job + delay(POLL_INTERVAL_MS) + } + } + + /** Persiste l'extrait OCR localement (null = texte absent → nettoyage). */ + suspend fun saveResult(resourceId: String, text: String?) { + fileDao.updateOcrText(resourceId, text?.takeIf { it.isNotBlank() }, System.currentTimeMillis()) + } + + companion object { + const val POLL_INTERVAL_MS = 3_000L + } +} + +/** + * `RequestBody` paresseux : le flux SAF est ouvert à l'écriture (multipart), + * jamais chargé en mémoire — les fichiers jusqu'à `MAX_FILE_SIZE_MB` restent + * gérables. + */ +private class SafFileRequestBody( + private val contentResolver: ContentResolver, + private val uri: String, + private val length: Long, + private val mediaType: MediaType?, +) : RequestBody() { + + override fun contentType(): MediaType? = mediaType + + override fun contentLength(): Long = length + + override fun writeTo(sink: BufferedSink) { + contentResolver.openInputStream(uri.toUri())?.use { stream -> + sink.writeAll(stream.source()) + } + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/ocr/OcrViewModel.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/ocr/OcrViewModel.kt new file mode 100644 index 0000000..12f92a1 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/ocr/OcrViewModel.kt @@ -0,0 +1,141 @@ +package com.vaultdrop.mobile.features.ocr + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vaultdrop.mobile.R +import com.vaultdrop.mobile.auth.TokenProvider +import com.vaultdrop.mobile.data.local.entity.FileEntity +import com.vaultdrop.mobile.data.remote.ApiException +import com.vaultdrop.mobile.data.remote.dto.OcrJobStatus +import com.vaultdrop.mobile.data.repository.OcrRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +/** + * OCR serveur d'un document du viewer : cela envoie les octets SAF ciblés + * (upload multipart hors outbox), enfile le job (`POST /ocr/jobs`), poll + * toutes les 3 s jusqu'à `done`/`failed` puis persiste l'extrait en Room. + * + * Single-flight par session (une seule run à la fois) ; le résultat est + * réaffichable sans relance via `files.ocr_text`. + */ +@HiltViewModel +class OcrViewModel @Inject constructor( + private val ocrRepository: OcrRepository, + private val tokenProvider: TokenProvider, + @ApplicationContext private val context: Context, +) : ViewModel() { + + data class OcrUiState( + /** resource du job/interaction courante — permet de ne pas afficher un état obsolète. */ + val resourceId: String? = null, + /** queued / processing / done / failed — null si état initial. */ + val status: String? = null, + /** Texte extrait (état done). */ + val text: String? = null, + /** Message d'erreur (état failed). */ + val error: String? = null, + /** Un upload multipart est en cours (avant que le job serveur n'existe). */ + val uploading: Boolean = false, + ) { + /** Une opération est en cours : spinner + pas de bouton Relancer. */ + val running: Boolean get() = uploading || status == null || + status == OcrJobStatus.QUEUED || status == OcrJobStatus.PROCESSING + } + + private val _uiState = MutableStateFlow(OcrUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var activeJob: Job? = null + + /** L'OCR nécessite un compte connecté (upload + token paseto). */ + val available: Boolean get() = tokenProvider.current != null + + /** Démarre (ou relance) l'extraction OCR du fichier. */ + fun run(file: FileEntity) { + if (!available) { + _uiState.update { + it.copy( + resourceId = file.resourceId, + status = OcrJobStatus.FAILED, + error = context.getString(R.string.ocr_requires_login), + ) + } + return + } + if (activeJob?.isActive == true) return + + _uiState.value = OcrUiState(resourceId = file.resourceId, uploading = true) + activeJob = viewModelScope.launch { + try { + ocrRepository.ensurePhysical(file) + val jobId = ocrRepository.submit(file.resourceId) + val final = ocrRepository.poll(jobId) { job -> + _uiState.update { + it.copy( + resourceId = file.resourceId, + uploading = false, + status = job.status, + ) + } + } + if (final.status == OcrJobStatus.DONE) { + ocrRepository.saveResult(file.resourceId, final.text) + _uiState.update { + it.copy( + resourceId = file.resourceId, + uploading = false, + status = OcrJobStatus.DONE, + text = final.text?.takeIf { it.isNotBlank() } + ?: context.getString(R.string.ocr_empty), + ) + } + } else { + _uiState.update { + it.copy( + resourceId = file.resourceId, + uploading = false, + status = OcrJobStatus.FAILED, + error = final.error + ?: context.getString(R.string.ocr_error_generic), + ) + } + } + } catch (e: ApiException) { + Timber.w(e, "ocr: job failed (%s)", e.code) + _uiState.update { + it.copy( + resourceId = file.resourceId, + uploading = false, + status = OcrJobStatus.FAILED, + error = context.getString(R.string.ocr_error_generic), + ) + } + } catch (e: Exception) { + Timber.w(e, "ocr: unexpected failure") + _uiState.update { + it.copy( + resourceId = file.resourceId, + uploading = false, + status = OcrJobStatus.FAILED, + error = context.getString(R.string.ocr_error_generic), + ) + } + } + } + } + + /** Réinitialise l'état — appeler au changement de document affiché. */ + fun reset() { + _uiState.value = OcrUiState() + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/DocumentViewerScreen.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/DocumentViewerScreen.kt index 30cc303..5c441ae 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/DocumentViewerScreen.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/DocumentViewerScreen.kt @@ -16,16 +16,22 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.DocumentScanner +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api @@ -38,6 +44,7 @@ import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -71,8 +78,11 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vaultdrop.mobile.R import com.vaultdrop.mobile.data.local.entity.FileEntity +import com.vaultdrop.mobile.data.local.entity.FileStatus +import com.vaultdrop.mobile.data.remote.dto.OcrJobStatus import com.vaultdrop.mobile.domain.FileCategory import com.vaultdrop.mobile.features.connection.ConnectionStatusViewModel +import com.vaultdrop.mobile.features.ocr.OcrViewModel import com.vaultdrop.mobile.features.saf.FileDeleter import com.vaultdrop.mobile.features.sync.SyncViewModel import com.vaultdrop.mobile.ui.components.DeleteConfirmDialog @@ -107,10 +117,12 @@ fun DocumentViewerScreen( onBack: () -> Unit, connectionStatusViewModel: ConnectionStatusViewModel, syncViewModel: SyncViewModel, + ocrViewModel: OcrViewModel = hiltViewModel(), viewModel: DocumentViewerViewModel = hiltViewModel(), ) { val documents by viewModel.documents.collectAsStateWithLifecycle() val viewerState by viewModel.uiState.collectAsStateWithLifecycle() + val ocrState by ocrViewModel.uiState.collectAsStateWithLifecycle() val connectionStatus by connectionStatusViewModel.status.collectAsStateWithLifecycle() val pagerState = rememberPagerState(pageCount = { documents.size }) @@ -131,6 +143,18 @@ fun DocumentViewerScreen( val currentFile = documents.getOrNull(pagerState.currentPage) + // OCR : réinitialise l'état (et referme la fenêtre) quand le document + // affiché change — une run en cours continue en arrière-plan (single-flight + // dans le OcrViewModel) et persiste son résultat. + var showOcrDialog by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(currentFile?.resourceId) { + ocrViewModel.reset() + showOcrDialog = false + } + // Le bouton n'apparaît que pour un document gardé, présent à la fois en + // local et connu du serveur (`local-cloud`) — et un compte connecté. + val ocrCanRun = currentFile != null && currentFile.canOcr() && ocrViewModel.available + val context = LocalContext.current val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() @@ -255,6 +279,7 @@ fun DocumentViewerScreen( // ou supprimer un document en attente de review, ou supprimer // (local / cloud / les deux) un document déjà traité. if (current != null) { + val hasOcr = current.ocrText != null DocumentActionBar( file = current, busy = viewerState.busy, @@ -263,6 +288,14 @@ fun DocumentViewerScreen( pendingDeleteMode = mode showDeleteConfirm = true }, + onOcr = if (ocrCanRun || hasOcr) { + { + if (!hasOcr) ocrViewModel.run(current) + showOcrDialog = true + } + } else { + null + }, modifier = Modifier.fillMaxWidth(), ) } @@ -286,6 +319,16 @@ fun DocumentViewerScreen( }, ) } + + if (showOcrDialog && currentFile != null && (ocrCanRun || currentFile.ocrText != null)) { + OcrResultDialog( + file = currentFile, + state = ocrState, + canRetry = ocrCanRun, + onRetry = { ocrViewModel.run(currentFile) }, + onDismiss = { showOcrDialog = false }, + ) + } } /** @@ -521,6 +564,7 @@ private fun DocumentActionBar( busy: Boolean, onKeep: () -> Unit, onDeleteModeSelected: (FileDeleter.DeleteMode) -> Unit, + onOcr: (() -> Unit)? = null, modifier: Modifier = Modifier, ) { val pendingReview = !file.processed && file.uri != null @@ -569,16 +613,141 @@ private fun DocumentActionBar( ) } } else { - DocumentDeleteMenu( - file = file, - enabled = !busy, - onModeSelected = onDeleteModeSelected, + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + if (onOcr != null) { + DocumentOcrButton( + label = if (file.ocrText != null) R.string.ocr_open else R.string.ocr_action, + enabled = !busy, + onClick = onOcr, + ) + } + DocumentDeleteMenu( + file = file, + enabled = !busy, + onModeSelected = onDeleteModeSelected, + ) + } + } + } + } +} + +/** Bouton « OCR » — même gabarit que le menu Supprimer (Surface 88×48). */ +@Composable +private fun DocumentOcrButton( + label: Int, + enabled: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val tint = if (enabled) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant + + Surface( + onClick = { if (enabled) onClick() }, + enabled = enabled, + shape = RoundedCornerShape(20.dp), + color = if (enabled) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.surfaceVariant, + modifier = modifier, + ) { + Box( + modifier = Modifier.size(width = 88.dp, height = 48.dp), + contentAlignment = Alignment.Center, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Filled.DocumentScanner, + contentDescription = null, + tint = tint, + modifier = Modifier.size(20.dp), + ) + Text( + text = stringResource(label), + color = tint, + fontSize = 11.sp, + maxLines = 1, + fontWeight = FontWeight.Bold, ) } } } } +/** + * Fenêtre OCR : progression (upload → queued → processing), texte extrait + * (sélectionnable) ou erreur. Le bouton « Relancer » est proposé dès que le + * job est terminal (done/failed) et que l'OCR reste possible. + */ +@Composable +private fun OcrResultDialog( + file: FileEntity, + state: OcrViewModel.OcrUiState, + canRetry: Boolean, + onRetry: () -> Unit, + onDismiss: () -> Unit, +) { + val active = state.resourceId == file.resourceId + val running = active && state.running + val failed = !running && active && state.status == OcrJobStatus.FAILED + val text = if (running || failed) null else (state.text ?: file.ocrText) + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.ocr_dialog_title)) }, + text = { + when { + running -> Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator(modifier = Modifier.size(40.dp)) + Spacer(Modifier.height(16.dp)) + Text( + text = stringResource(when (state.status) { + OcrJobStatus.QUEUED -> R.string.ocr_queued + OcrJobStatus.PROCESSING -> R.string.ocr_processing + else -> R.string.ocr_sending + }), + style = MaterialTheme.typography.bodyMedium, + ) + } + + failed -> Text( + text = state.error ?: stringResource(R.string.ocr_error_generic), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + + text != null -> SelectionContainer { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.verticalScroll(rememberScrollState()), + ) + } + + else -> Text( + text = stringResource(R.string.ocr_empty), + style = MaterialTheme.typography.bodyMedium, + ) + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.ocr_close)) + } + }, + dismissButton = { + if (canRetry && !running) { + TextButton(onClick = onRetry) { + Text(stringResource(R.string.ocr_retry)) + } + } + }, + ) +} + /** Bouton rond d'action (garder / supprimer), style mode review mais compact. */ @Composable private fun DocumentActionButton( @@ -729,6 +898,15 @@ private fun fileTypeLabel(file: FileEntity): String = private fun FileEntity.canFocus(): Boolean = uri != null && categoryValue() in FOCUS_CATEGORIES +/** + * Vrai si le fichier est prêt pour l'OCR serveur : copie physique locale ET + * déjà connue du serveur (`create_resource` appliqué → `local-cloud`). Un + * fichier encore `local` (ou non traité) n'a pas de resource_id côté serveur + * sur lequel attacher les octets. + */ +private fun FileEntity.canOcr(): Boolean = + uri != null && processed && syncStatus == FileStatus.LOCAL_CLOUD + private val FOCUS_CATEGORIES = setOf(FileCategory.PDF, FileCategory.IMAGE, FileCategory.TEXT) /** Résolution de rendu PDF en fonction du zoom : re-rendu par paliers pour rester net. */ diff --git a/mobile-kotlin/app/src/main/res/values/strings.xml b/mobile-kotlin/app/src/main/res/values/strings.xml index 7f107a4..34ef304 100644 --- a/mobile-kotlin/app/src/main/res/values/strings.xml +++ b/mobile-kotlin/app/src/main/res/values/strings.xml @@ -84,6 +84,19 @@ Impossible de lire ce document. Page illisible + + OCR + Voir l\'extrait OCR + Extrait OCR + Envoi du fichier au serveur… + En file d\'attente… + Analyse en cours… + Aucun texte extrait. + Impossible d\'extraire le texte. + Connecte-toi pour utiliser l\'OCR. + Fermer + Relancer + Fichiers Recherche diff --git a/mobile-kotlin/app/src/test/java/com/vaultdrop/mobile/data/repository/FolderMergeDataTest.kt b/mobile-kotlin/app/src/test/java/com/vaultdrop/mobile/data/repository/FolderMergeDataTest.kt index 4774525..3882302 100644 --- a/mobile-kotlin/app/src/test/java/com/vaultdrop/mobile/data/repository/FolderMergeDataTest.kt +++ b/mobile-kotlin/app/src/test/java/com/vaultdrop/mobile/data/repository/FolderMergeDataTest.kt @@ -20,6 +20,8 @@ import com.vaultdrop.mobile.data.remote.dto.FileDto import com.vaultdrop.mobile.data.remote.dto.FolderDto import com.vaultdrop.mobile.data.remote.dto.LoginRequestDto import com.vaultdrop.mobile.data.remote.dto.LoginResponseDto +import com.vaultdrop.mobile.data.remote.dto.OcrJobDto +import com.vaultdrop.mobile.data.remote.dto.OcrSubmitDto import com.vaultdrop.mobile.data.remote.dto.ResolvedUserDto import com.vaultdrop.mobile.data.remote.dto.ResourcePermissionDto import com.vaultdrop.mobile.data.remote.dto.SyncOpsRequest @@ -27,6 +29,8 @@ import com.vaultdrop.mobile.data.remote.dto.SyncOpsResult import com.vaultdrop.mobile.domain.DeviceIdentity import com.vaultdrop.mobile.domain.GenerateId import kotlinx.coroutines.test.runTest +import okhttp3.MultipartBody +import okhttp3.RequestBody import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -206,4 +210,18 @@ private class FolderMergeApiService : ApiService { after: Long?, ): Response>> = Response.success(ApiEnvelope(data = emptyList())) + + override suspend fun uploadFile( + file: MultipartBody.Part, + resourceId: RequestBody?, + folderId: RequestBody?, + ): Response> = Response.success(ApiEnvelope()) + + override suspend fun submitOcr( + body: OcrSubmitDto, + ): Response> = Response.success(ApiEnvelope()) + + override suspend fun getOcrJob( + jobId: String, + ): Response> = Response.success(ApiEnvelope()) } \ No newline at end of file diff --git a/mobile-kotlin/app/src/test/java/com/vaultdrop/mobile/data/repository/MoveProtectionTest.kt b/mobile-kotlin/app/src/test/java/com/vaultdrop/mobile/data/repository/MoveProtectionTest.kt index 2b109b8..1fd3858 100644 --- a/mobile-kotlin/app/src/test/java/com/vaultdrop/mobile/data/repository/MoveProtectionTest.kt +++ b/mobile-kotlin/app/src/test/java/com/vaultdrop/mobile/data/repository/MoveProtectionTest.kt @@ -21,12 +21,16 @@ import com.vaultdrop.mobile.data.remote.dto.FileDto import com.vaultdrop.mobile.data.remote.dto.FolderDto import com.vaultdrop.mobile.data.remote.dto.LoginRequestDto import com.vaultdrop.mobile.data.remote.dto.LoginResponseDto +import com.vaultdrop.mobile.data.remote.dto.OcrJobDto +import com.vaultdrop.mobile.data.remote.dto.OcrSubmitDto import com.vaultdrop.mobile.data.remote.dto.ResolvedUserDto import com.vaultdrop.mobile.data.remote.dto.ResourcePermissionDto import com.vaultdrop.mobile.data.remote.dto.SyncOpsRequest import com.vaultdrop.mobile.data.remote.dto.SyncOpsResult import com.vaultdrop.mobile.domain.GenerateId import kotlinx.coroutines.test.runTest +import okhttp3.MultipartBody +import okhttp3.RequestBody import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -293,4 +297,18 @@ private class MoveProtectionApiService : ApiService { after: Long?, ): Response>> = Response.success(ApiEnvelope(data = emptyList())) + + override suspend fun uploadFile( + file: MultipartBody.Part, + resourceId: RequestBody?, + folderId: RequestBody?, + ): Response> = Response.success(ApiEnvelope()) + + override suspend fun submitOcr( + body: OcrSubmitDto, + ): Response> = Response.success(ApiEnvelope()) + + override suspend fun getOcrJob( + jobId: String, + ): Response> = Response.success(ApiEnvelope()) } \ No newline at end of file diff --git a/mobile-kotlin/app/src/test/java/com/vaultdrop/mobile/data/repository/OcrRepositoryTest.kt b/mobile-kotlin/app/src/test/java/com/vaultdrop/mobile/data/repository/OcrRepositoryTest.kt new file mode 100644 index 0000000..bed5c01 --- /dev/null +++ b/mobile-kotlin/app/src/test/java/com/vaultdrop/mobile/data/repository/OcrRepositoryTest.kt @@ -0,0 +1,215 @@ +package com.vaultdrop.mobile.data.repository + +import android.content.Context +import androidx.core.net.toUri +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import com.vaultdrop.mobile.data.local.AppDatabase +import com.vaultdrop.mobile.data.local.dao.FileDao +import com.vaultdrop.mobile.data.local.entity.FileEntity +import com.vaultdrop.mobile.data.local.entity.FileStatus +import com.vaultdrop.mobile.data.remote.ApiClient +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.FolderDto +import com.vaultdrop.mobile.data.remote.dto.LoginRequestDto +import com.vaultdrop.mobile.data.remote.dto.LoginResponseDto +import com.vaultdrop.mobile.data.remote.dto.OcrJobDto +import com.vaultdrop.mobile.data.remote.dto.OcrJobStatus +import com.vaultdrop.mobile.data.remote.dto.OcrSubmitDto +import com.vaultdrop.mobile.data.remote.dto.ResolvedUserDto +import com.vaultdrop.mobile.data.remote.dto.ResourcePermissionDto +import com.vaultdrop.mobile.data.remote.dto.SyncOpsRequest +import com.vaultdrop.mobile.data.remote.dto.SyncOpsResult +import kotlinx.coroutines.test.runTest +import okhttp3.MultipartBody +import okhttp3.RequestBody +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import retrofit2.Response +import java.io.ByteArrayInputStream +import kotlinx.coroutines.runBlocking + +/** + * Pipeline OCR local : l'upload multipart ciblé envoie les octets du flux SAF + * (sans les garder en RAM), `submit`/`poll` drainent le job jusqu'à `done`, et + * `saveResult` persiste l'extrait sur la ligne du fichier. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class OcrRepositoryTest { + + private lateinit var context: Context + private lateinit var db: AppDatabase + private lateinit var fileDao: FileDao + private lateinit var apiService: OcrApiServiceFake + private lateinit var repository: OcrRepository + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java) + .allowMainThreadQueries() + .build() + fileDao = db.fileDao() + apiService = OcrApiServiceFake() + repository = OcrRepository(ApiClient(apiService, moshi()), fileDao, context) + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun `ensurePhysical_uptown_les_octets_saf_puis_flow_vers_une_ligne_persistée`() = runTest { + val uri = "content://ocr/sample.txt" + val sampleBytes = "bonjour monde".toByteArray() + val resolver = context.contentResolver + shadowOf(resolver).registerInputStream(uri.toUri(), ByteArrayInputStream(sampleBytes)) + + val file = insertFile(uri) + apiService.jobSequence = listOf( + OcrJobDto(id = "job-abc", status = OcrJobStatus.QUEUED), + OcrJobDto(id = "job-abc", status = OcrJobStatus.PROCESSING), + OcrJobDto(id = "job-abc", status = OcrJobStatus.DONE, text = "bonjour monde"), + ) + + repository.ensurePhysical(file) + assertEquals(1, apiService.uploadedFiles.size) + assertEquals(file.resourceId, apiService.uploadedFiles.single().first) + assertEquals("bonjour monde", apiService.uploadedBytes) + + val jobId = repository.submit(file.resourceId) + assertEquals("job-abc", jobId) + + var seen: List = emptyList() + val final = repository.poll(jobId) { seen += it.status } + assertEquals(OcrJobStatus.DONE, final.status) + assertEquals(listOf(OcrJobStatus.QUEUED, OcrJobStatus.PROCESSING, OcrJobStatus.DONE), seen) + + repository.saveResult(file.resourceId, final.text) + val stored = fileDao.getByResourceId(file.resourceId) + assertEquals("bonjour monde", stored?.ocrText) + } + + @Test + fun `poll_stop_sur_un_job_failed_et_remonte_lerreur`() = runTest { + apiService.jobSequence = listOf( + OcrJobDto(id = "job-err", status = OcrJobStatus.QUEUED), + OcrJobDto(id = "job-err", status = OcrJobStatus.FAILED, error = "boom"), + ) + + val final = repository.poll("job-err") { } + + assertEquals(OcrJobStatus.FAILED, final.status) + assertEquals("boom", final.error) + } + + private fun insertFile(uri: String): FileEntity { + val file = FileEntity( + resourceId = "aabbccddeeff11223344556677889900", + uri = uri, + name = "sample.txt", + folderResourceId = "f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0", + extension = "txt", + size = 13L, + mimeType = "text/plain", + exists = 1, + syncStatus = FileStatus.LOCAL_CLOUD, + processed = true, + addedAt = 1_700_000_000_000L, + updatedAt = 1_700_000_000_000L, + ) + runBlocking { fileDao.upsert(file) } + return file + } + + private fun moshi(): Moshi = + Moshi.Builder().add(KotlinJsonAdapterFactory()).build() +} + +/** + * Fake `ApiService` dédié OCR : enregistre les uploads multipart et joue une + * séquence pré-programmée de statuts de job (poll successif). + */ +private class OcrApiServiceFake : ApiService { + var uploadedFiles: MutableList> = mutableListOf() + var uploadedBytes: String? = null + var jobSequence: List = emptyList() + private var getCalls = 0 + + override suspend fun listFolders(): Response>> = + Response.success(ApiEnvelope(data = emptyList())) + + override suspend fun listFiles( + folderId: String?, + page: Int?, + pageSize: Int?, + ): Response>> = Response.success(ApiEnvelope(data = emptyList())) + + override suspend fun registerDevice( + body: DeviceRegistrationDto, + ): Response> = + Response.success(ApiEnvelope(data = body)) + + override suspend fun login( + body: LoginRequestDto, + ): Response> = Response.success(ApiEnvelope()) + + override suspend fun syncOps( + body: SyncOpsRequest, + ): Response> = + Response.success(ApiEnvelope(data = SyncOpsResult(applied = 0))) + + override suspend fun resolveUser( + username: String, + ): Response> = Response.success(ApiEnvelope()) + + override suspend fun syncPermissions( + after: Long?, + ): Response>> = + Response.success(ApiEnvelope(data = emptyList())) + + override suspend fun uploadFile( + file: MultipartBody.Part, + resourceId: RequestBody?, + folderId: RequestBody?, + ): Response> { + uploadedFiles += resourceId?.let { readBody(it) } to resourceId + val buffer = okio.Buffer() + file.body.writeTo(buffer) + uploadedBytes = buffer.readUtf8() + return Response.success(ApiEnvelope(data = FileDto(id = "aabbccddeeff11223344556677889900", name = "sample.txt", size = 13L))) + } + + override suspend fun submitOcr( + body: OcrSubmitDto, + ): Response> = + Response.success(ApiEnvelope(data = OcrJobDto(id = "job-abc", status = OcrJobStatus.QUEUED))) + + override suspend fun getOcrJob( + jobId: String, + ): Response> { + val job = jobSequence[getCalls.coerceAtMost(jobSequence.lastIndex)] + getCalls++ + return Response.success(ApiEnvelope(data = job)) + } + + private fun readBody(body: RequestBody): String { + val buffer = okio.Buffer() + body.writeTo(buffer) + return buffer.readUtf8() + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/test/java/com/vaultdrop/mobile/data/repository/ShareRepositoryTest.kt b/mobile-kotlin/app/src/test/java/com/vaultdrop/mobile/data/repository/ShareRepositoryTest.kt index 87b9694..7ae115a 100644 --- a/mobile-kotlin/app/src/test/java/com/vaultdrop/mobile/data/repository/ShareRepositoryTest.kt +++ b/mobile-kotlin/app/src/test/java/com/vaultdrop/mobile/data/repository/ShareRepositoryTest.kt @@ -20,6 +20,8 @@ import com.vaultdrop.mobile.data.remote.dto.FileDto import com.vaultdrop.mobile.data.remote.dto.FolderDto import com.vaultdrop.mobile.data.remote.dto.LoginRequestDto import com.vaultdrop.mobile.data.remote.dto.LoginResponseDto +import com.vaultdrop.mobile.data.remote.dto.OcrJobDto +import com.vaultdrop.mobile.data.remote.dto.OcrSubmitDto import com.vaultdrop.mobile.data.remote.dto.ResolvedUserDto import com.vaultdrop.mobile.data.remote.dto.ResourcePermissionDto import com.vaultdrop.mobile.data.remote.dto.SyncOpsRequest @@ -27,6 +29,8 @@ import com.vaultdrop.mobile.data.remote.dto.SyncOpsResult import com.vaultdrop.mobile.domain.DeviceIdentity import com.vaultdrop.mobile.domain.GenerateId import kotlinx.coroutines.test.runTest +import okhttp3.MultipartBody +import okhttp3.RequestBody import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -250,4 +254,18 @@ private class FakeApiService : ApiService { after: Long?, ): Response>> = Response.success(ApiEnvelope(data = permissions)) + + override suspend fun uploadFile( + file: MultipartBody.Part, + resourceId: RequestBody?, + folderId: RequestBody?, + ): Response> = Response.success(ApiEnvelope()) + + override suspend fun submitOcr( + body: OcrSubmitDto, + ): Response> = Response.success(ApiEnvelope()) + + override suspend fun getOcrJob( + jobId: String, + ): Response> = Response.success(ApiEnvelope()) } \ No newline at end of file