add ocr pipeline
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
// 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/<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 {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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/<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 ;
|
||||
* 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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
+9
-1
@@ -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<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.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<ResourcePermissionDto> =
|
||||
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(
|
||||
call: suspend () -> Response<ApiEnvelope<T>>,
|
||||
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.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<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 = "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,
|
||||
)
|
||||
+103
@@ -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()
|
||||
}
|
||||
}
|
||||
+182
-4
@@ -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. */
|
||||
|
||||
@@ -84,6 +84,19 @@
|
||||
<string name="document_cannot_read">Impossible de lire ce document.</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 -->
|
||||
<string name="nav_files">Fichiers</string>
|
||||
<string name="nav_search">Recherche</string>
|
||||
|
||||
+18
@@ -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<ApiEnvelope<List<ResourcePermissionDto>>> =
|
||||
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())
|
||||
}
|
||||
+18
@@ -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<ApiEnvelope<List<ResourcePermissionDto>>> =
|
||||
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())
|
||||
}
|
||||
+215
@@ -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()
|
||||
}
|
||||
}
|
||||
+18
@@ -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<ApiEnvelope<List<ResourcePermissionDto>>> =
|
||||
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())
|
||||
}
|
||||
Reference in New Issue
Block a user