add ocr pipeline

This commit is contained in:
m
2026-09-15 23:52:19 +02:00
parent 102cc425b9
commit 050434325a
21 changed files with 1028 additions and 17 deletions
+8 -1
View File
@@ -85,6 +85,13 @@ func FilesUpload(c *gin.Context) {
} }
userID := c.GetString(UserIDKey) userID := c.GetString(UserIDKey)
folderID := c.PostForm("folderId") 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) c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, Store.MaxFileSize+1)
file, err := c.FormFile("file") file, err := c.FormFile("file")
@@ -102,7 +109,7 @@ func FilesUpload(c *gin.Context) {
return return
} }
dto, err := Store.Upload(userID, file, folderID) dto, err := Store.Upload(userID, file, folderID, resourceID)
if err != nil { if err != nil {
writeError(c, err) writeError(c, err)
return return
+84
View File
@@ -183,6 +183,29 @@ func uploadMultipart(t *testing.T, r *gin.Engine, token, folderID, filename stri
return rec 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) { func TestFilesFlow(t *testing.T) {
r, _, repo := setup(t) r, _, repo := setup(t)
deviceA := repository.NewID() deviceA := repository.NewID()
@@ -385,6 +408,67 @@ func TestUploadNameConflict(t *testing.T) {
expectError(t, rec, http.StatusConflict, "NAME_CONFLICT", "upload-dupe") 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) { func TestFoldersListAndScoping(t *testing.T) {
r, _, repo := setup(t) r, _, repo := setup(t)
deviceA := repository.NewID() deviceA := repository.NewID()
+23
View File
@@ -433,6 +433,29 @@ func (r *Resources) UpdateNameByID(resourceID, name string) error {
return nil 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 // SyncDelete soft-deletes a resource; absence is NOT an error (idempotent
// terminal state for the outbox). // terminal state for the outbox).
func (r *Resources) SyncDelete(ownerID, resourceID string) error { func (r *Resources) SyncDelete(ownerID, resourceID string) error {
+26 -5
View File
@@ -104,30 +104,51 @@ func (s *Resources) SearchFiles(ownerID, q string, page, pageSize int) ([]FileDT
// Upload persists the multipart-sourced file under UploadDir/<user> and // Upload persists the multipart-sourced file under UploadDir/<user> and
// records its metadata, returning the FileDTO. The physical file is removed // records its metadata, returning the FileDTO. The physical file is removed
// if metadata persistence fails (e.g. name conflict). // 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/<user>/<resourceID>.<ext> 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 { if file.Size > s.MaxFileSize {
return FileDTO{}, ErrFileTooLarge 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), ".") extension := strings.TrimPrefix(filepath.Ext(file.Filename), ".")
destDir := filepath.Join(s.UploadDir, ownerID) destDir := filepath.Join(s.UploadDir, ownerID)
if err := os.MkdirAll(destDir, 0o755); err != nil { if err := os.MkdirAll(destDir, 0o755); err != nil {
return FileDTO{}, fmt.Errorf("create upload dir: %w", err) 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 { if err := copyMultipart(file, destPath); err != nil {
return FileDTO{}, err return FileDTO{}, err
} }
mimeType := file.Header.Get("Content-Type") 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) _ = os.Remove(destPath)
return FileDTO{}, err return FileDTO{}, err
} }
row, err := s.Repo.GetFile(ownerID, id) row, err := s.Repo.GetFile(ownerID, resourceID)
if err != nil { if err != nil {
return FileDTO{}, err return FileDTO{}, err
} }
+74 -3
View File
@@ -42,7 +42,7 @@ func TestUploadPersistsPhysicalFile(t *testing.T) {
s := newServiceStore(t) s := newServiceStore(t)
userID := mustCreateUser(t, s.Repository, "upload-happy") 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 { if err != nil {
t.Fatalf("upload: %v", err) t.Fatalf("upload: %v", err)
} }
@@ -70,7 +70,7 @@ func TestUploadRemovesPhysicalFileOnNameConflict(t *testing.T) {
} }
target := multipartFileHeader(t, "dupe.txt", []byte("hello")) target := multipartFileHeader(t, "dupe.txt", []byte("hello"))
first, err := s.Upload(userID, target, folderID) first, err := s.Upload(userID, target, folderID, "")
if err != nil { if err != nil {
t.Fatalf("premier upload: %v", err) 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. // 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) 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)) 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)
}
}
+2 -1
View File
@@ -68,7 +68,8 @@ type OcrJob = { id: string; status: OcrJobStatus; text?: string | null; error?:
## 4. Upload ## 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", … } }`. - Limite : `MAX_FILE_SIZE_MB` (défaut 50). Dépassement → 413 `{ "error": { "code": "FILE_TOO_LARGE", … } }`.
- Le fichier physique est stocké sous `UPLOAD_DIR/<user_id>/<resource_id>.<ext>` ; 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é. - Le fichier physique est stocké sous `UPLOAD_DIR/<user_id>/<resource_id>.<ext>` ; 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é.
@@ -19,14 +19,15 @@ import com.vaultdrop.mobile.data.local.entity.UserPreferenceEntity
* v1: folders ; v2: user_preferences ; v3: files ; v4: category sur files ; * v1: folders ; v2: user_preferences ; v3: files ; v4: category sur files ;
* v5: created_in_app sur folders ; v6: processed sur files (mode review) ; * v5: created_in_app sur folders ; v6: processed sur files (mode review) ;
* v7: pending_operations (outbox) ; v8: scan_sessions + scan_pages (scanner) ; * 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( @Database(
entities = [ entities = [
FolderEntity::class, UserPreferenceEntity::class, FileEntity::class, FolderEntity::class, UserPreferenceEntity::class, FileEntity::class,
PendingOperationEntity::class, ScanSessionEntity::class, ScanPageEntity::class, PendingOperationEntity::class, ScanSessionEntity::class, ScanPageEntity::class,
], ],
version = 9, version = 10,
exportSchema = false, exportSchema = false,
) )
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
@@ -122,4 +122,8 @@ interface FileDao {
WHERE "exists" = 1 AND processed = 0 AND uri IS NOT NULL WHERE "exists" = 1 AND processed = 0 AND uri IS NOT NULL
""") """)
suspend fun markAllProcessed(updatedAt: Long) 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)
} }
@@ -55,6 +55,8 @@ data class FileEntity(
val content: String? = null, val content: String? = null,
@ColumnInfo(name = "processed") @ColumnInfo(name = "processed")
val processed: Boolean = false, val processed: Boolean = false,
@ColumnInfo(name = "ocr_text")
val ocrText: String? = null,
@ColumnInfo(name = "added_at") @ColumnInfo(name = "added_at")
val addedAt: Long, val addedAt: Long,
@ColumnInfo(name = "updated_at") @ColumnInfo(name = "updated_at")
@@ -24,6 +24,8 @@ import androidx.sqlite.db.SupportSQLiteDatabase
* multi-pages persistée pour survivre au process death avant export SAF. * 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 * 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. * 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 { 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<Migration> = arrayOf( val ALL: Array<Migration> = 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,
) )
} }
@@ -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.FolderDto
import com.vaultdrop.mobile.data.remote.dto.LoginRequestDto import com.vaultdrop.mobile.data.remote.dto.LoginRequestDto
import com.vaultdrop.mobile.data.remote.dto.LoginResponseDto 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.ResolvedUserDto
import com.vaultdrop.mobile.data.remote.dto.ResourcePermissionDto import com.vaultdrop.mobile.data.remote.dto.ResourcePermissionDto
import com.vaultdrop.mobile.data.remote.dto.SyncOpDto import com.vaultdrop.mobile.data.remote.dto.SyncOpDto
import com.vaultdrop.mobile.data.remote.dto.SyncOpsRequest import com.vaultdrop.mobile.data.remote.dto.SyncOpsRequest
import com.vaultdrop.mobile.data.remote.dto.SyncOpsResult 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 okio.IOException
import retrofit2.Response import retrofit2.Response
import timber.log.Timber import timber.log.Timber
@@ -76,6 +82,29 @@ class ApiClient @Inject constructor(
suspend fun syncPermissions(after: Long? = null): List<ResourcePermissionDto> = suspend fun syncPermissions(after: Long? = null): List<ResourcePermissionDto> =
unwrap({ apiService.syncPermissions(after) }) 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 <T> unwrap( private suspend fun <T> unwrap(
call: suspend () -> Response<ApiEnvelope<T>>, call: suspend () -> Response<ApiEnvelope<T>>,
skipUnauthorizedHandling: Boolean = false, skipUnauthorizedHandling: Boolean = false,
@@ -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.FolderDto
import com.vaultdrop.mobile.data.remote.dto.LoginRequestDto import com.vaultdrop.mobile.data.remote.dto.LoginRequestDto
import com.vaultdrop.mobile.data.remote.dto.LoginResponseDto 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.ResolvedUserDto
import com.vaultdrop.mobile.data.remote.dto.ResourcePermissionDto import com.vaultdrop.mobile.data.remote.dto.ResourcePermissionDto
import com.vaultdrop.mobile.data.remote.dto.SyncOpsRequest import com.vaultdrop.mobile.data.remote.dto.SyncOpsRequest
import com.vaultdrop.mobile.data.remote.dto.SyncOpsResult import com.vaultdrop.mobile.data.remote.dto.SyncOpsResult
import okhttp3.MultipartBody
import okhttp3.RequestBody
import retrofit2.Response import retrofit2.Response
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Multipart
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query import retrofit2.http.Query
/** /**
@@ -63,4 +70,30 @@ interface ApiService {
suspend fun syncPermissions( suspend fun syncPermissions(
@Query("after") after: Long? = null, @Query("after") after: Long? = null,
): Response<ApiEnvelope<List<ResourcePermissionDto>>> ): Response<ApiEnvelope<List<ResourcePermissionDto>>>
/**
* 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<ApiEnvelope<FileDto>>
/** Soumet un job OCR serveur — `{ "fileId": "…" }` (docs/api-v1.md §5). */
@POST("ocr/jobs")
suspend fun submitOcr(
@Body body: OcrSubmitDto,
): Response<ApiEnvelope<OcrJobDto>>
/** 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<ApiEnvelope<OcrJobDto>>
} }
@@ -130,3 +130,26 @@ data class ResourcePermissionDto(
@Json(name = "name") val name: String = "", @Json(name = "name") val name: String = "",
@Json(name = "parentId") val parentId: String? = null, @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,
)
@@ -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/<user>/<resource_id>.<ext>` — 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())
}
}
}
@@ -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<OcrUiState> = _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()
}
}
@@ -16,16 +16,22 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape 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.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete 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.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -38,6 +44,7 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
@@ -71,8 +78,11 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vaultdrop.mobile.R import com.vaultdrop.mobile.R
import com.vaultdrop.mobile.data.local.entity.FileEntity 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.domain.FileCategory
import com.vaultdrop.mobile.features.connection.ConnectionStatusViewModel 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.saf.FileDeleter
import com.vaultdrop.mobile.features.sync.SyncViewModel import com.vaultdrop.mobile.features.sync.SyncViewModel
import com.vaultdrop.mobile.ui.components.DeleteConfirmDialog import com.vaultdrop.mobile.ui.components.DeleteConfirmDialog
@@ -107,10 +117,12 @@ fun DocumentViewerScreen(
onBack: () -> Unit, onBack: () -> Unit,
connectionStatusViewModel: ConnectionStatusViewModel, connectionStatusViewModel: ConnectionStatusViewModel,
syncViewModel: SyncViewModel, syncViewModel: SyncViewModel,
ocrViewModel: OcrViewModel = hiltViewModel(),
viewModel: DocumentViewerViewModel = hiltViewModel(), viewModel: DocumentViewerViewModel = hiltViewModel(),
) { ) {
val documents by viewModel.documents.collectAsStateWithLifecycle() val documents by viewModel.documents.collectAsStateWithLifecycle()
val viewerState by viewModel.uiState.collectAsStateWithLifecycle() val viewerState by viewModel.uiState.collectAsStateWithLifecycle()
val ocrState by ocrViewModel.uiState.collectAsStateWithLifecycle()
val connectionStatus by connectionStatusViewModel.status.collectAsStateWithLifecycle() val connectionStatus by connectionStatusViewModel.status.collectAsStateWithLifecycle()
val pagerState = rememberPagerState(pageCount = { documents.size }) val pagerState = rememberPagerState(pageCount = { documents.size })
@@ -131,6 +143,18 @@ fun DocumentViewerScreen(
val currentFile = documents.getOrNull(pagerState.currentPage) 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 context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -255,6 +279,7 @@ fun DocumentViewerScreen(
// ou supprimer un document en attente de review, ou supprimer // ou supprimer un document en attente de review, ou supprimer
// (local / cloud / les deux) un document déjà traité. // (local / cloud / les deux) un document déjà traité.
if (current != null) { if (current != null) {
val hasOcr = current.ocrText != null
DocumentActionBar( DocumentActionBar(
file = current, file = current,
busy = viewerState.busy, busy = viewerState.busy,
@@ -263,6 +288,14 @@ fun DocumentViewerScreen(
pendingDeleteMode = mode pendingDeleteMode = mode
showDeleteConfirm = true showDeleteConfirm = true
}, },
onOcr = if (ocrCanRun || hasOcr) {
{
if (!hasOcr) ocrViewModel.run(current)
showOcrDialog = true
}
} else {
null
},
modifier = Modifier.fillMaxWidth(), 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, busy: Boolean,
onKeep: () -> Unit, onKeep: () -> Unit,
onDeleteModeSelected: (FileDeleter.DeleteMode) -> Unit, onDeleteModeSelected: (FileDeleter.DeleteMode) -> Unit,
onOcr: (() -> Unit)? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val pendingReview = !file.processed && file.uri != null val pendingReview = !file.processed && file.uri != null
@@ -569,6 +613,17 @@ private fun DocumentActionBar(
) )
} }
} else { } else {
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( DocumentDeleteMenu(
file = file, file = file,
enabled = !busy, enabled = !busy,
@@ -577,6 +632,120 @@ private fun DocumentActionBar(
} }
} }
} }
}
}
/** 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. */ /** Bouton rond d'action (garder / supprimer), style mode review mais compact. */
@@ -729,6 +898,15 @@ private fun fileTypeLabel(file: FileEntity): String =
private fun FileEntity.canFocus(): Boolean = private fun FileEntity.canFocus(): Boolean =
uri != null && categoryValue() in FOCUS_CATEGORIES 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) 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. */ /** Résolution de rendu PDF en fonction du zoom : re-rendu par paliers pour rester net. */
@@ -84,6 +84,19 @@
<string name="document_cannot_read">Impossible de lire ce document.</string> <string name="document_cannot_read">Impossible de lire ce document.</string>
<string name="document_page_unreadable">Page illisible</string> <string name="document_page_unreadable">Page illisible</string>
<!-- OCR serveur -->
<string name="ocr_action">OCR</string>
<string name="ocr_open">Voir l\'extrait OCR</string>
<string name="ocr_dialog_title">Extrait OCR</string>
<string name="ocr_sending">Envoi du fichier au serveur…</string>
<string name="ocr_queued">En file d\'attente…</string>
<string name="ocr_processing">Analyse en cours…</string>
<string name="ocr_empty">Aucun texte extrait.</string>
<string name="ocr_error_generic">Impossible d\'extraire le texte.</string>
<string name="ocr_requires_login">Connecte-toi pour utiliser l\'OCR.</string>
<string name="ocr_close">Fermer</string>
<string name="ocr_retry">Relancer</string>
<!-- Navigation --> <!-- Navigation -->
<string name="nav_files">Fichiers</string> <string name="nav_files">Fichiers</string>
<string name="nav_search">Recherche</string> <string name="nav_search">Recherche</string>
@@ -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.FolderDto
import com.vaultdrop.mobile.data.remote.dto.LoginRequestDto import com.vaultdrop.mobile.data.remote.dto.LoginRequestDto
import com.vaultdrop.mobile.data.remote.dto.LoginResponseDto 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.ResolvedUserDto
import com.vaultdrop.mobile.data.remote.dto.ResourcePermissionDto import com.vaultdrop.mobile.data.remote.dto.ResourcePermissionDto
import com.vaultdrop.mobile.data.remote.dto.SyncOpsRequest 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.DeviceIdentity
import com.vaultdrop.mobile.domain.GenerateId import com.vaultdrop.mobile.domain.GenerateId
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import okhttp3.MultipartBody
import okhttp3.RequestBody
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
@@ -206,4 +210,18 @@ private class FolderMergeApiService : ApiService {
after: Long?, after: Long?,
): Response<ApiEnvelope<List<ResourcePermissionDto>>> = ): Response<ApiEnvelope<List<ResourcePermissionDto>>> =
Response.success(ApiEnvelope(data = emptyList())) Response.success(ApiEnvelope(data = emptyList()))
override suspend fun uploadFile(
file: MultipartBody.Part,
resourceId: RequestBody?,
folderId: RequestBody?,
): Response<ApiEnvelope<FileDto>> = Response.success(ApiEnvelope())
override suspend fun submitOcr(
body: OcrSubmitDto,
): Response<ApiEnvelope<OcrJobDto>> = Response.success(ApiEnvelope())
override suspend fun getOcrJob(
jobId: String,
): Response<ApiEnvelope<OcrJobDto>> = Response.success(ApiEnvelope())
} }
@@ -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.FolderDto
import com.vaultdrop.mobile.data.remote.dto.LoginRequestDto import com.vaultdrop.mobile.data.remote.dto.LoginRequestDto
import com.vaultdrop.mobile.data.remote.dto.LoginResponseDto 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.ResolvedUserDto
import com.vaultdrop.mobile.data.remote.dto.ResourcePermissionDto import com.vaultdrop.mobile.data.remote.dto.ResourcePermissionDto
import com.vaultdrop.mobile.data.remote.dto.SyncOpsRequest import com.vaultdrop.mobile.data.remote.dto.SyncOpsRequest
import com.vaultdrop.mobile.data.remote.dto.SyncOpsResult import com.vaultdrop.mobile.data.remote.dto.SyncOpsResult
import com.vaultdrop.mobile.domain.GenerateId import com.vaultdrop.mobile.domain.GenerateId
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import okhttp3.MultipartBody
import okhttp3.RequestBody
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
@@ -293,4 +297,18 @@ private class MoveProtectionApiService : ApiService {
after: Long?, after: Long?,
): Response<ApiEnvelope<List<ResourcePermissionDto>>> = ): Response<ApiEnvelope<List<ResourcePermissionDto>>> =
Response.success(ApiEnvelope(data = emptyList())) Response.success(ApiEnvelope(data = emptyList()))
override suspend fun uploadFile(
file: MultipartBody.Part,
resourceId: RequestBody?,
folderId: RequestBody?,
): Response<ApiEnvelope<FileDto>> = Response.success(ApiEnvelope())
override suspend fun submitOcr(
body: OcrSubmitDto,
): Response<ApiEnvelope<OcrJobDto>> = Response.success(ApiEnvelope())
override suspend fun getOcrJob(
jobId: String,
): Response<ApiEnvelope<OcrJobDto>> = Response.success(ApiEnvelope())
} }
@@ -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<Context>()
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<String> = 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<Pair<String?, RequestBody?>> = mutableListOf()
var uploadedBytes: String? = null
var jobSequence: List<OcrJobDto> = emptyList()
private var getCalls = 0
override suspend fun listFolders(): Response<ApiEnvelope<List<FolderDto>>> =
Response.success(ApiEnvelope(data = emptyList()))
override suspend fun listFiles(
folderId: String?,
page: Int?,
pageSize: Int?,
): Response<ApiEnvelope<List<FileDto>>> = Response.success(ApiEnvelope(data = emptyList()))
override suspend fun registerDevice(
body: DeviceRegistrationDto,
): Response<ApiEnvelope<DeviceRegistrationDto>> =
Response.success(ApiEnvelope(data = body))
override suspend fun login(
body: LoginRequestDto,
): Response<ApiEnvelope<LoginResponseDto>> = Response.success(ApiEnvelope())
override suspend fun syncOps(
body: SyncOpsRequest,
): Response<ApiEnvelope<SyncOpsResult>> =
Response.success(ApiEnvelope(data = SyncOpsResult(applied = 0)))
override suspend fun resolveUser(
username: String,
): Response<ApiEnvelope<ResolvedUserDto>> = Response.success(ApiEnvelope())
override suspend fun syncPermissions(
after: Long?,
): Response<ApiEnvelope<List<ResourcePermissionDto>>> =
Response.success(ApiEnvelope(data = emptyList()))
override suspend fun uploadFile(
file: MultipartBody.Part,
resourceId: RequestBody?,
folderId: RequestBody?,
): Response<ApiEnvelope<FileDto>> {
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<ApiEnvelope<OcrJobDto>> =
Response.success(ApiEnvelope(data = OcrJobDto(id = "job-abc", status = OcrJobStatus.QUEUED)))
override suspend fun getOcrJob(
jobId: String,
): Response<ApiEnvelope<OcrJobDto>> {
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()
}
}
@@ -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.FolderDto
import com.vaultdrop.mobile.data.remote.dto.LoginRequestDto import com.vaultdrop.mobile.data.remote.dto.LoginRequestDto
import com.vaultdrop.mobile.data.remote.dto.LoginResponseDto 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.ResolvedUserDto
import com.vaultdrop.mobile.data.remote.dto.ResourcePermissionDto import com.vaultdrop.mobile.data.remote.dto.ResourcePermissionDto
import com.vaultdrop.mobile.data.remote.dto.SyncOpsRequest 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.DeviceIdentity
import com.vaultdrop.mobile.domain.GenerateId import com.vaultdrop.mobile.domain.GenerateId
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import okhttp3.MultipartBody
import okhttp3.RequestBody
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
@@ -250,4 +254,18 @@ private class FakeApiService : ApiService {
after: Long?, after: Long?,
): Response<ApiEnvelope<List<ResourcePermissionDto>>> = ): Response<ApiEnvelope<List<ResourcePermissionDto>>> =
Response.success(ApiEnvelope(data = permissions)) Response.success(ApiEnvelope(data = permissions))
override suspend fun uploadFile(
file: MultipartBody.Part,
resourceId: RequestBody?,
folderId: RequestBody?,
): Response<ApiEnvelope<FileDto>> = Response.success(ApiEnvelope())
override suspend fun submitOcr(
body: OcrSubmitDto,
): Response<ApiEnvelope<OcrJobDto>> = Response.success(ApiEnvelope())
override suspend fun getOcrJob(
jobId: String,
): Response<ApiEnvelope<OcrJobDto>> = Response.success(ApiEnvelope())
} }