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:
iu
2026-10-13 17:10:47 +03:00
parent b7316f9e00
commit 38b5b03f59
102 changed files with 7199 additions and 153 deletions
+3 -3
View File
@@ -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,
+4 -4
View File
@@ -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,