feat(android): sync device albums with a server album
Add phone album sync to the Android client: MediaStore source (device albums), a Room index with upload provenance, on-device previews, and a mixed server + device album list where a linked pair collapses into a single device card with a link indicator and mode label. Three modes over one pipeline: A (off), B (upload copies, originals stay local), C (upload originals and offload them locally to previews). The sync engine tracks device changes (add/remove/rename/move/content), matches assets to server photos by sha256 content hash, deletes only this client's uploads, and reflects server changes without touching local originals. Offload is automatic; the zoomed viewer pulls the server original. Backend: expose the original content checksum in photo data (Postgres and SQLite), additive with no migration. Also anchor the `.gitignore` `data/` rule to `/data/` so the Android `com.galo.android.data` package is tracked, and add the halo launcher icon.
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"galo/internal/auth"
|
||||
"galo/internal/models"
|
||||
"galo/internal/photos"
|
||||
)
|
||||
|
||||
// The original object's content checksum is exposed on photo data so clients can
|
||||
// match a local file against a server photo and avoid re-uploading it.
|
||||
func TestPhotoResponsesExposeOriginalChecksum(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, token, uid, repos := newAvatarServer(t)
|
||||
|
||||
album := &models.Album{ID: auth.NewID(), OwnerID: uid, Title: "A"}
|
||||
if err := repos.Albums.Create(ctx, album); err != nil {
|
||||
t.Fatalf("create album: %v", err)
|
||||
}
|
||||
if err := repos.Members.Add(ctx, album.ID, uid, "editor", nil); err != nil {
|
||||
t.Fatalf("add owner member: %v", err)
|
||||
}
|
||||
|
||||
obj := &photos.Object{
|
||||
ID: auth.NewID(),
|
||||
StorageKey: "objects/" + auth.NewID(),
|
||||
MimeType: "image/jpeg",
|
||||
Checksum: "sha256-photo-a",
|
||||
State: photos.ObjectPending,
|
||||
}
|
||||
p := &models.Photo{ID: auth.NewID(), AlbumID: album.ID, UploaderID: uid, OriginalFilename: "c.jpg"}
|
||||
if err := repos.Photos.CreateWithOriginal(ctx, p, obj); err != nil {
|
||||
t.Fatalf("create photo: %v", err)
|
||||
}
|
||||
if err := repos.Photos.ActivateObject(ctx, obj.ID); err != nil {
|
||||
t.Fatalf("activate photo object: %v", err)
|
||||
}
|
||||
|
||||
// Single photo.
|
||||
rec := doAuthed(r, http.MethodGet, "/api/photos/"+p.ID, nil, "", token)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("get photo = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var one struct {
|
||||
Photo models.Photo `json:"photo"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &one); err != nil {
|
||||
t.Fatalf("decode photo: %v", err)
|
||||
}
|
||||
if one.Photo.OriginalChecksum != "sha256-photo-a" {
|
||||
t.Fatalf("photo original_checksum = %q, want sha256-photo-a", one.Photo.OriginalChecksum)
|
||||
}
|
||||
|
||||
// Album listing.
|
||||
rec = doAuthed(r, http.MethodGet, "/api/albums/"+album.ID+"/photos", nil, "", token)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("list photos = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var list struct {
|
||||
Photos []models.Photo `json:"photos"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil {
|
||||
t.Fatalf("decode list: %v", err)
|
||||
}
|
||||
if len(list.Photos) != 1 || list.Photos[0].OriginalChecksum != "sha256-photo-a" {
|
||||
t.Fatalf("list original_checksum = %+v, want one photo with sha256-photo-a", list.Photos)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user