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)
|
||||
}
|
||||
}
|
||||
@@ -45,19 +45,19 @@ func (u *User) Self() SelfUser {
|
||||
}
|
||||
|
||||
type Album struct {
|
||||
ID string `json:"id"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
CoverPhotoID *string `json:"cover_photo_id,omitempty"`
|
||||
CoverThumbKey string `json:"-"`
|
||||
CoverThumbURL string `json:"cover_thumb_url,omitempty"`
|
||||
CoverMediumKey string `json:"-"`
|
||||
CoverMediumURL string `json:"cover_medium_url,omitempty"`
|
||||
PhotosCount int `json:"photos_count"`
|
||||
MembersCount int `json:"members_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
CoverPhotoID *string `json:"cover_photo_id,omitempty"`
|
||||
CoverThumbKey string `json:"-"`
|
||||
CoverThumbURL string `json:"cover_thumb_url,omitempty"`
|
||||
CoverMediumKey string `json:"-"`
|
||||
CoverMediumURL string `json:"cover_medium_url,omitempty"`
|
||||
PhotosCount int `json:"photos_count"`
|
||||
MembersCount int `json:"members_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type AlbumMember struct {
|
||||
@@ -82,40 +82,41 @@ type Invite struct {
|
||||
}
|
||||
|
||||
type Photo struct {
|
||||
ID string `json:"id"`
|
||||
AlbumID string `json:"album_id"`
|
||||
UploaderID string `json:"uploader_id"`
|
||||
StorageKey string `json:"-"`
|
||||
ThumbKey string `json:"-"`
|
||||
MediumKey string `json:"-"`
|
||||
PreviewKey string `json:"-"`
|
||||
PlaybackKey string `json:"-"`
|
||||
OriginalFilename string `json:"original_filename"`
|
||||
Size int64 `json:"size"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Kind string `json:"kind"`
|
||||
DurationMS int64 `json:"duration_ms,omitempty"`
|
||||
MimeType string `json:"mime_type"`
|
||||
TakenAt *time.Time `json:"taken_at,omitempty"`
|
||||
Latitude *float64 `json:"latitude,omitempty"`
|
||||
Longitude *float64 `json:"longitude,omitempty"`
|
||||
ID string `json:"id"`
|
||||
AlbumID string `json:"album_id"`
|
||||
UploaderID string `json:"uploader_id"`
|
||||
StorageKey string `json:"-"`
|
||||
ThumbKey string `json:"-"`
|
||||
MediumKey string `json:"-"`
|
||||
PreviewKey string `json:"-"`
|
||||
PlaybackKey string `json:"-"`
|
||||
OriginalFilename string `json:"original_filename"`
|
||||
Size int64 `json:"size"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Kind string `json:"kind"`
|
||||
DurationMS int64 `json:"duration_ms,omitempty"`
|
||||
MimeType string `json:"mime_type"`
|
||||
OriginalChecksum string `json:"original_checksum,omitempty"`
|
||||
TakenAt *time.Time `json:"taken_at,omitempty"`
|
||||
Latitude *float64 `json:"latitude,omitempty"`
|
||||
Longitude *float64 `json:"longitude,omitempty"`
|
||||
Caption string `json:"caption"`
|
||||
EXIF map[string]any `json:"exif,omitempty"`
|
||||
Tags []Tag `json:"tags,omitempty"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
AlbumTitle string `json:"album_title,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Uploader PublicUser `json:"uploader"`
|
||||
LikesCount int `json:"likes_count"`
|
||||
CommentsCount int `json:"comments_count"`
|
||||
LikedByMe bool `json:"liked_by_me"`
|
||||
ThumbURL string `json:"thumb_url,omitempty"`
|
||||
MediumURL string `json:"medium_url,omitempty"`
|
||||
PreviewURL string `json:"preview_url,omitempty"`
|
||||
PlaybackURL string `json:"playback_url,omitempty"`
|
||||
OriginalURL string `json:"original_url,omitempty"`
|
||||
DownloadURL string `json:"download_url,omitempty"`
|
||||
Uploader PublicUser `json:"uploader"`
|
||||
LikesCount int `json:"likes_count"`
|
||||
CommentsCount int `json:"comments_count"`
|
||||
LikedByMe bool `json:"liked_by_me"`
|
||||
ThumbURL string `json:"thumb_url,omitempty"`
|
||||
MediumURL string `json:"medium_url,omitempty"`
|
||||
PreviewURL string `json:"preview_url,omitempty"`
|
||||
PlaybackURL string `json:"playback_url,omitempty"`
|
||||
OriginalURL string `json:"original_url,omitempty"`
|
||||
DownloadURL string `json:"download_url,omitempty"`
|
||||
}
|
||||
|
||||
type Tag struct {
|
||||
|
||||
@@ -25,7 +25,7 @@ const sqlitePhotoColumns = `p.id, COALESCE(p.album_id, ''), p.uploader_id,
|
||||
COALESCE(o_m.storage_key, ''),
|
||||
COALESCE(o_p.storage_key, ''),
|
||||
COALESCE(o_pb.storage_key, ''),
|
||||
p.original_filename, COALESCE(o_o.size, 0), COALESCE(o_o.width, 0), COALESCE(o_o.height, 0), COALESCE(o_o.mime_type, ''),
|
||||
p.original_filename, COALESCE(o_o.size, 0), COALESCE(o_o.width, 0), COALESCE(o_o.height, 0), COALESCE(o_o.mime_type, ''), COALESCE(o_o.checksum, ''),
|
||||
p.kind, COALESCE(p.duration_ms, 0),
|
||||
p.taken_at, p.latitude, p.longitude, COALESCE(p.caption, ''), p.created_at,
|
||||
u.id, u.name, COALESCE(u.avatar_url, ''),
|
||||
@@ -58,7 +58,7 @@ func scanSQLitePhoto(scan func(...any) error) (*models.Photo, error) {
|
||||
var liked int
|
||||
if err := scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
||||
&p.ThumbKey, &p.MediumKey, &p.PreviewKey, &p.PlaybackKey,
|
||||
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
|
||||
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType, &p.OriginalChecksum,
|
||||
&p.Kind, &p.DurationMS,
|
||||
&takenAt, &lat, &long, &p.Caption, &createdAt,
|
||||
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
||||
@@ -283,7 +283,7 @@ func (s *SQLiteStore) ListTrash(ctx context.Context, userID string) ([]models.Ph
|
||||
var liked int
|
||||
if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
||||
&p.ThumbKey, &p.MediumKey, &p.PreviewKey, &p.PlaybackKey,
|
||||
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
|
||||
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType, &p.OriginalChecksum,
|
||||
&p.Kind, &p.DurationMS,
|
||||
&takenAt, &lat, &long, &p.Caption, &createdAt,
|
||||
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
||||
|
||||
@@ -32,7 +32,7 @@ const photoColumns = `p.id, COALESCE(p.album_id, ''), p.uploader_id,
|
||||
COALESCE(o_m.storage_key, ''),
|
||||
COALESCE(o_p.storage_key, ''),
|
||||
COALESCE(o_pb.storage_key, ''),
|
||||
p.original_filename, COALESCE(o_o.size, 0), COALESCE(o_o.width, 0), COALESCE(o_o.height, 0), COALESCE(o_o.mime_type, ''),
|
||||
p.original_filename, COALESCE(o_o.size, 0), COALESCE(o_o.width, 0), COALESCE(o_o.height, 0), COALESCE(o_o.mime_type, ''), COALESCE(o_o.checksum, ''),
|
||||
p.kind, COALESCE(p.duration_ms, 0),
|
||||
p.taken_at, p.latitude, p.longitude, COALESCE(p.caption, ''), p.created_at,
|
||||
u.id, u.name, COALESCE(u.avatar_url, ''),
|
||||
@@ -61,7 +61,7 @@ func scanPhoto(row pgx.Row) (*models.Photo, error) {
|
||||
var exifText, tagsText string
|
||||
err := row.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
||||
&p.ThumbKey, &p.MediumKey, &p.PreviewKey, &p.PlaybackKey,
|
||||
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
|
||||
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType, &p.OriginalChecksum,
|
||||
&p.Kind, &p.DurationMS,
|
||||
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
|
||||
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
||||
@@ -197,7 +197,7 @@ func (s *Store) List(ctx context.Context, in ListParams) ([]models.Photo, error)
|
||||
var exifText, tagsText string
|
||||
if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
||||
&p.ThumbKey, &p.MediumKey, &p.PreviewKey, &p.PlaybackKey,
|
||||
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
|
||||
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType, &p.OriginalChecksum,
|
||||
&p.Kind, &p.DurationMS,
|
||||
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
|
||||
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
||||
@@ -312,7 +312,7 @@ func (s *Store) ListTrash(ctx context.Context, userID string) ([]models.Photo, e
|
||||
var exifText, tagsText string
|
||||
if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
||||
&p.ThumbKey, &p.MediumKey, &p.PreviewKey, &p.PlaybackKey,
|
||||
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
|
||||
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType, &p.OriginalChecksum,
|
||||
&p.Kind, &p.DurationMS,
|
||||
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
|
||||
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
||||
|
||||
@@ -494,6 +494,43 @@ func testPhotos(t *testing.T, ctx context.Context, r Repos) {
|
||||
t.Fatalf("album should exist")
|
||||
}
|
||||
|
||||
// The original object's content checksum is exposed so clients can match a
|
||||
// local file against a server photo. Identical content yields identical
|
||||
// checksums.
|
||||
csObjA := &photos.Object{ID: newID(), StorageKey: "objects/" + newID(), MimeType: "image/jpeg", Checksum: "sha256-same", State: photos.ObjectPending}
|
||||
csA := &models.Photo{ID: newID(), AlbumID: a.ID, UploaderID: owner.ID, OriginalFilename: "checksum-a.jpg"}
|
||||
if err := r.Photos.CreateWithOriginal(ctx, csA, csObjA); err != nil {
|
||||
t.Fatalf("create checksum photo: %v", err)
|
||||
}
|
||||
if err := r.Photos.ActivateObject(ctx, csObjA.ID); err != nil {
|
||||
t.Fatalf("activate checksum object: %v", err)
|
||||
}
|
||||
csObjB := &photos.Object{ID: newID(), StorageKey: "objects/" + newID(), MimeType: "image/jpeg", Checksum: "sha256-same", State: photos.ObjectPending}
|
||||
csB := &models.Photo{ID: newID(), AlbumID: a.ID, UploaderID: owner.ID, OriginalFilename: "checksum-b.jpg"}
|
||||
if err := r.Photos.CreateWithOriginal(ctx, csB, csObjB); err != nil {
|
||||
t.Fatalf("create checksum photo 2: %v", err)
|
||||
}
|
||||
if err := r.Photos.ActivateObject(ctx, csObjB.ID); err != nil {
|
||||
t.Fatalf("activate checksum object 2: %v", err)
|
||||
}
|
||||
checked, err := r.Photos.GetByID(ctx, csA.ID, "")
|
||||
if err != nil || checked.OriginalChecksum != "sha256-same" {
|
||||
t.Fatalf("original checksum via get = %q err=%v", checked.OriginalChecksum, err)
|
||||
}
|
||||
byList, err := r.Photos.List(ctx, photos.ListParams{AlbumID: a.ID, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("list with checksums: %v", err)
|
||||
}
|
||||
same := 0
|
||||
for _, ph := range byList {
|
||||
if ph.OriginalChecksum == "sha256-same" {
|
||||
same++
|
||||
}
|
||||
}
|
||||
if same != 2 {
|
||||
t.Fatalf("photos with identical content checksum in list = %d, want 2", same)
|
||||
}
|
||||
|
||||
if err := r.Photos.HardDelete(ctx, p.ID); err != nil {
|
||||
t.Fatalf("hard delete: %v", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user