fix: sync Android default theme installs
This commit is contained in:
parent
80c36a8ab4
commit
0d917ef87e
8 changed files with 263 additions and 7 deletions
|
|
@ -102,7 +102,14 @@ func (s *Service) Create(ctx context.Context, spec domain.ThemeSpec) (domain.The
|
|||
|
||||
func (s *Service) resolve(ctx context.Context, ref domain.ThemeRef) (domain.Theme, bool, error) {
|
||||
if ref.ID != 0 {
|
||||
return s.store.GetThemeByID(ctx, ref.ID)
|
||||
t, ok, err := s.store.GetThemeByID(ctx, ref.ID)
|
||||
if err != nil || !ok {
|
||||
return domain.Theme{}, ok, err
|
||||
}
|
||||
if t.AccessHash != ref.AccessHash {
|
||||
return domain.Theme{}, false, nil
|
||||
}
|
||||
return t, true, nil
|
||||
}
|
||||
if ref.Slug != "" {
|
||||
return s.store.GetThemeBySlug(ctx, ref.Slug)
|
||||
|
|
|
|||
|
|
@ -35,14 +35,14 @@ func TestServiceCreateAutoSlugAndCreatorGuard(t *testing.T) {
|
|||
}
|
||||
|
||||
// 非创建者不能改。
|
||||
if _, err := svc.Update(ctx, 2002, domain.ThemeRef{ID: a.ID}, domain.ThemeUpdate{Title: strptr("hacked")}); !errors.Is(err, domain.ErrThemeInvalid) {
|
||||
if _, err := svc.Update(ctx, 2002, domain.ThemeRef{ID: a.ID, AccessHash: a.AccessHash}, domain.ThemeUpdate{Title: strptr("hacked")}); !errors.Is(err, domain.ErrThemeInvalid) {
|
||||
t.Fatalf("non-creator update err = %v, want ErrThemeInvalid", err)
|
||||
}
|
||||
|
||||
// 创建者可改 title + document。
|
||||
newTitle := "A2"
|
||||
newDoc := int64(99)
|
||||
updated, err := svc.Update(ctx, owner, domain.ThemeRef{ID: a.ID}, domain.ThemeUpdate{Title: &newTitle, DocumentID: &newDoc})
|
||||
updated, err := svc.Update(ctx, owner, domain.ThemeRef{ID: a.ID, AccessHash: a.AccessHash}, domain.ThemeUpdate{Title: &newTitle, DocumentID: &newDoc})
|
||||
if err != nil {
|
||||
t.Fatalf("creator update: %v", err)
|
||||
}
|
||||
|
|
@ -51,7 +51,7 @@ func TestServiceCreateAutoSlugAndCreatorGuard(t *testing.T) {
|
|||
}
|
||||
|
||||
// install 计数 + 列表。
|
||||
if err := svc.Install(ctx, owner, domain.ThemeRef{ID: a.ID}, true); err != nil {
|
||||
if err := svc.Install(ctx, owner, domain.ThemeRef{ID: a.ID, AccessHash: a.AccessHash}, true); err != nil {
|
||||
t.Fatalf("install: %v", err)
|
||||
}
|
||||
got, ok, _ := svc.Get(ctx, domain.ThemeRef{Slug: a.Slug})
|
||||
|
|
@ -67,6 +67,20 @@ func TestServiceCreateAutoSlugAndCreatorGuard(t *testing.T) {
|
|||
if err := svc.Install(ctx, owner, domain.ThemeRef{Slug: "nope"}, false); !errors.Is(err, domain.ErrThemeInvalid) {
|
||||
t.Fatalf("install unknown err = %v, want ErrThemeInvalid", err)
|
||||
}
|
||||
|
||||
forged := domain.ThemeRef{ID: a.ID, AccessHash: a.AccessHash + 1}
|
||||
if _, ok, err := svc.Get(ctx, forged); err != nil || ok {
|
||||
t.Fatalf("get forged access hash = ok %v err %v, want false/nil", ok, err)
|
||||
}
|
||||
if err := svc.Save(ctx, owner, forged); !errors.Is(err, domain.ErrThemeInvalid) {
|
||||
t.Fatalf("save forged access hash err = %v, want ErrThemeInvalid", err)
|
||||
}
|
||||
if err := svc.Install(ctx, owner, forged, true); !errors.Is(err, domain.ErrThemeInvalid) {
|
||||
t.Fatalf("install forged access hash err = %v, want ErrThemeInvalid", err)
|
||||
}
|
||||
if _, err := svc.Update(ctx, owner, forged, domain.ThemeUpdate{Title: strptr("forged")}); !errors.Is(err, domain.ErrThemeNotFound) {
|
||||
t.Fatalf("update forged access hash err = %v, want ErrThemeNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func strptr(s string) *string { return &s }
|
||||
|
|
|
|||
|
|
@ -38,6 +38,18 @@ func LookupWallPaper(input tg.InputWallPaperClass) (tg.WallPaperClass, bool) {
|
|||
return DefaultWallPaper(wallpaper), true
|
||||
}
|
||||
}
|
||||
// account.getThemes/account.getChatThemes also advertise wallpapers nested
|
||||
// in ThemeSettings. The default getWallPapers export is a filtered list and
|
||||
// does not contain every nested entry, so these identities must remain part
|
||||
// of the same lookup boundary or Android can render a theme that it cannot
|
||||
// subsequently install.
|
||||
for _, theme := range catalog.ChatThemes {
|
||||
for _, settings := range theme.Settings {
|
||||
if inputWallPaperMatches(input, settings.Wallpaper) {
|
||||
return DefaultWallPaper(settings.Wallpaper), true
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -182,6 +182,31 @@ func DefaultThemeList() []tg.Theme {
|
|||
return themes
|
||||
}
|
||||
|
||||
// LookupDefaultTheme resolves an InputTheme against the exact identity emitted
|
||||
// by DefaultThemeList. ID references must carry the matching access hash; slug
|
||||
// references are public and match by their exact non-empty slug.
|
||||
//
|
||||
// These themes are an immutable server catalog rather than rows in the custom
|
||||
// cloud-theme store. RPCs which accept a Theme returned by account.getThemes
|
||||
// use this lookup before consulting that store.
|
||||
func LookupDefaultTheme(input tg.InputThemeClass) (tg.Theme, bool) {
|
||||
for _, theme := range DefaultThemeList() {
|
||||
switch in := input.(type) {
|
||||
case *tg.InputTheme:
|
||||
if in.ID == theme.ID && in.AccessHash == theme.AccessHash {
|
||||
return theme, true
|
||||
}
|
||||
case *tg.InputThemeSlug:
|
||||
if in.Slug != "" && in.Slug == theme.Slug {
|
||||
return theme, true
|
||||
}
|
||||
default:
|
||||
return tg.Theme{}, false
|
||||
}
|
||||
}
|
||||
return tg.Theme{}, false
|
||||
}
|
||||
|
||||
func UniqueGiftChatThemes(hash int64) tg.AccountChatThemesClass {
|
||||
if hash == uniqueGiftChatThemesHash {
|
||||
return &tg.AccountChatThemesNotModified{}
|
||||
|
|
|
|||
|
|
@ -216,6 +216,35 @@ func TestDefaultThemesAreDefaultFlaggedForPicker(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestLookupDefaultThemeValidatesIdentity(t *testing.T) {
|
||||
themes := DefaultThemeList()
|
||||
if len(themes) == 0 {
|
||||
t.Fatal("DefaultThemeList is empty")
|
||||
}
|
||||
for _, theme := range themes {
|
||||
byID, ok := LookupDefaultTheme(&tg.InputTheme{
|
||||
ID: theme.ID,
|
||||
AccessHash: theme.AccessHash,
|
||||
})
|
||||
if !ok || byID.ID != theme.ID {
|
||||
t.Fatalf("lookup id %d = id %d ok=%v, want exact theme", theme.ID, byID.ID, ok)
|
||||
}
|
||||
bySlug, ok := LookupDefaultTheme(&tg.InputThemeSlug{Slug: theme.Slug})
|
||||
if !ok || bySlug.ID != theme.ID {
|
||||
t.Fatalf("lookup slug %q = id %d ok=%v, want %d", theme.Slug, bySlug.ID, ok, theme.ID)
|
||||
}
|
||||
if _, ok := LookupDefaultTheme(&tg.InputTheme{
|
||||
ID: theme.ID,
|
||||
AccessHash: theme.AccessHash + 1,
|
||||
}); ok {
|
||||
t.Fatalf("lookup id %d accepted forged access hash", theme.ID)
|
||||
}
|
||||
}
|
||||
if _, ok := LookupDefaultTheme(&tg.InputThemeSlug{}); ok {
|
||||
t.Fatal("lookup accepted empty slug")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUniqueGiftChatThemesIsEmptyHashableStub(t *testing.T) {
|
||||
got, ok := UniqueGiftChatThemes(0).(*tg.AccountChatThemes)
|
||||
if !ok {
|
||||
|
|
@ -299,6 +328,50 @@ func TestLookupWallPaperByIDAndSlug(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestDefaultThemeWallpaperReferencesResolve(t *testing.T) {
|
||||
themes := DefaultThemeList()
|
||||
var checked int
|
||||
for _, theme := range themes {
|
||||
settings, ok := theme.GetSettings()
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for i, setting := range settings {
|
||||
wallpaperClass, ok := setting.GetWallpaper()
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
wallpaper, ok := wallpaperClass.(*tg.WallPaper)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
checked++
|
||||
byID, ok := LookupWallPaper(&tg.InputWallPaper{
|
||||
ID: wallpaper.ID,
|
||||
AccessHash: wallpaper.AccessHash,
|
||||
})
|
||||
if !ok {
|
||||
t.Fatalf("theme %d settings[%d] wallpaper id %d was not resolvable", theme.ID, i, wallpaper.ID)
|
||||
}
|
||||
gotByID, ok := byID.(*tg.WallPaper)
|
||||
if !ok || gotByID.ID != wallpaper.ID || gotByID.AccessHash != wallpaper.AccessHash {
|
||||
t.Fatalf("theme %d settings[%d] wallpaper id lookup = %#v", theme.ID, i, byID)
|
||||
}
|
||||
bySlug, ok := LookupWallPaper(&tg.InputWallPaperSlug{Slug: wallpaper.Slug})
|
||||
if !ok {
|
||||
t.Fatalf("theme %d settings[%d] wallpaper slug %q was not resolvable", theme.ID, i, wallpaper.Slug)
|
||||
}
|
||||
gotBySlug, ok := bySlug.(*tg.WallPaper)
|
||||
if !ok || gotBySlug.Slug != wallpaper.Slug {
|
||||
t.Fatalf("theme %d settings[%d] wallpaper slug lookup = %#v", theme.ID, i, bySlug)
|
||||
}
|
||||
}
|
||||
}
|
||||
if checked == 0 {
|
||||
t.Fatal("default themes exposed no file wallpapers")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarsRevenueStatsIsZeroBalanceCompatStub(t *testing.T) {
|
||||
got := StarsRevenueStats(false)
|
||||
if got == nil {
|
||||
|
|
|
|||
|
|
@ -220,6 +220,12 @@ func (r *Router) onAccountSaveTheme(ctx context.Context, req *tg.AccountSaveThem
|
|||
if userID == 0 {
|
||||
return false, authKeyUnregisteredErr()
|
||||
}
|
||||
if _, ok := tdesktop.LookupDefaultTheme(req.Theme); ok {
|
||||
// Default catalog themes are always present in account.getThemes. Saving
|
||||
// or unsaving one is therefore an idempotent signal and must not create a
|
||||
// synthetic custom-theme row or user install.
|
||||
return true, nil
|
||||
}
|
||||
if r.deps.Themes == nil {
|
||||
return false, notImplementedErr()
|
||||
}
|
||||
|
|
@ -252,14 +258,20 @@ func (r *Router) onAccountInstallTheme(ctx context.Context, req *tg.AccountInsta
|
|||
if userID == 0 {
|
||||
return false, authKeyUnregisteredErr()
|
||||
}
|
||||
if r.deps.Themes == nil {
|
||||
return false, notImplementedErr()
|
||||
}
|
||||
dark := req.GetDark()
|
||||
theme, ok := req.GetTheme()
|
||||
if !ok {
|
||||
return true, nil // 无 theme 引用:基础主题 no-op 安装
|
||||
}
|
||||
if _, ok := tdesktop.LookupDefaultTheme(theme); ok {
|
||||
// The immutable defaults were issued by account.getThemes but do not
|
||||
// live in the custom theme store. Applying one is a successful signal;
|
||||
// the Android client owns the active day/night choice locally.
|
||||
return true, nil
|
||||
}
|
||||
if r.deps.Themes == nil {
|
||||
return false, notImplementedErr()
|
||||
}
|
||||
ref, ok := themeRefFromInput(theme)
|
||||
if !ok {
|
||||
return false, themeInvalidErr()
|
||||
|
|
@ -280,6 +292,9 @@ func (r *Router) onAccountGetTheme(ctx context.Context, req *tg.AccountGetThemeR
|
|||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if t, ok := tdesktop.LookupDefaultTheme(req.Theme); ok {
|
||||
return projectThemeForClient(ctx, &t), nil
|
||||
}
|
||||
if r.deps.Themes == nil {
|
||||
return nil, notImplementedErr()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zaptest/observer"
|
||||
|
||||
"telesrv/internal/compat/tdesktop"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
|
|
@ -119,6 +120,26 @@ func TestLegacyThemeWireDispatch(t *testing.T) {
|
|||
t.Fatalf("installTheme legacy wire id = %#x err=%v, want boolTrue", id, err)
|
||||
}
|
||||
|
||||
// 同一 legacy overlay 必须接受 account.getThemes 下发的静态默认主题,
|
||||
// 且不能要求该引用存在于自定义主题 store。
|
||||
defaultTheme := tdesktop.DefaultThemeList()[0]
|
||||
var defaultIB bin.Buffer
|
||||
defaultIB.PutID(legacyInstallThemeID)
|
||||
defaultIB.PutInt32((1 << 0) | (1 << 1))
|
||||
defaultIB.PutString("android")
|
||||
(&tg.InputTheme{ID: defaultTheme.ID, AccessHash: defaultTheme.AccessHash}).Encode(&defaultIB)
|
||||
enc, err = r.Dispatch(ctx, authKeyID, sessionID, &defaultIB)
|
||||
if err != nil {
|
||||
t.Fatalf("installTheme legacy default dispatch: %v", err)
|
||||
}
|
||||
boolWire.Reset()
|
||||
if err := enc.Encode(&boolWire); err != nil {
|
||||
t.Fatalf("encode installTheme legacy default result: %v", err)
|
||||
}
|
||||
if id, err := boolWire.ID(); err != nil || id != tg.BoolTrueTypeID {
|
||||
t.Fatalf("installTheme legacy default wire id = %#x err=%v, want boolTrue", id, err)
|
||||
}
|
||||
|
||||
// 已声明 legacy 方法仍必须由静态 decoder 精确消费完整结构。
|
||||
var malformed bin.Buffer
|
||||
malformed.PutID(legacyCreateThemeID)
|
||||
|
|
|
|||
|
|
@ -126,6 +126,95 @@ func TestAccountCreateThemeFullFlow(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAccountDefaultThemeReferencesResolveWithoutCustomPersistence(t *testing.T) {
|
||||
const userID = 1000012
|
||||
ctx := WithClientInfo(
|
||||
WithUserID(context.Background(), userID),
|
||||
ClientInfo{Type: ClientTypeAndroid, AppVersion: "12.9.0 (69669)"},
|
||||
)
|
||||
r := newThemeRouter(t, &fakeFiles{})
|
||||
defaults := tdesktop.DefaultThemeList()
|
||||
if len(defaults) == 0 {
|
||||
t.Fatal("default theme catalog is empty")
|
||||
}
|
||||
|
||||
for i, theme := range defaults {
|
||||
t.Run(theme.Slug, func(t *testing.T) {
|
||||
input := &tg.InputTheme{ID: theme.ID, AccessHash: theme.AccessHash}
|
||||
install := &tg.AccountInstallThemeRequest{}
|
||||
install.SetDark(i%2 == 0)
|
||||
install.SetTheme(input)
|
||||
install.SetFormat("android")
|
||||
if ok, err := r.onAccountInstallTheme(ctx, install); err != nil || !ok {
|
||||
t.Fatalf("install default theme %d = %v/%v, want true/nil", theme.ID, ok, err)
|
||||
}
|
||||
|
||||
for _, unsave := range []bool{false, true} {
|
||||
if ok, err := r.onAccountSaveTheme(ctx, &tg.AccountSaveThemeRequest{
|
||||
Theme: input,
|
||||
Unsave: unsave,
|
||||
}); err != nil || !ok {
|
||||
t.Fatalf("save default theme %d unsave=%v = %v/%v, want true/nil", theme.ID, unsave, ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := r.onAccountGetTheme(ctx, &tg.AccountGetThemeRequest{
|
||||
Format: "android",
|
||||
Theme: &tg.InputThemeSlug{Slug: theme.Slug},
|
||||
})
|
||||
if err != nil || got.ID != theme.ID || got.AccessHash != theme.AccessHash {
|
||||
t.Fatalf("get default theme %d = %#v/%v", theme.ID, got, err)
|
||||
}
|
||||
|
||||
if ok, err := r.onAccountSaveTheme(ctx, &tg.AccountSaveThemeRequest{
|
||||
Theme: &tg.InputThemeSlug{Slug: theme.Slug},
|
||||
}); err != nil || !ok {
|
||||
t.Fatalf("save default theme slug %q = %v/%v, want true/nil", theme.Slug, ok, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Reproduce issue #29 through the canonical Layer 228 wire constructor,
|
||||
// not only by invoking the typed handler directly.
|
||||
canonical := &tg.AccountInstallThemeRequest{}
|
||||
canonical.SetDark(true)
|
||||
canonical.SetTheme(&tg.InputTheme{ID: defaults[0].ID, AccessHash: defaults[0].AccessHash})
|
||||
canonical.SetFormat("android")
|
||||
var body bin.Buffer
|
||||
if err := canonical.Encode(&body); err != nil {
|
||||
t.Fatalf("encode canonical installTheme: %v", err)
|
||||
}
|
||||
var authKeyID [8]byte
|
||||
authKeyID[0] = 2
|
||||
encoded, err := r.Dispatch(ctx, authKeyID, 1001, &body)
|
||||
if err != nil {
|
||||
t.Fatalf("canonical installTheme dispatch: %v", err)
|
||||
}
|
||||
var result bin.Buffer
|
||||
if err := encoded.Encode(&result); err != nil {
|
||||
t.Fatalf("encode canonical installTheme result: %v", err)
|
||||
}
|
||||
if id, err := result.ID(); err != nil || id != tg.BoolTrueTypeID {
|
||||
t.Fatalf("canonical installTheme result id = %#x err=%v, want boolTrue", id, err)
|
||||
}
|
||||
|
||||
installed, err := r.deps.Themes.ListInstalled(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("list custom installs after default signals: %v", err)
|
||||
}
|
||||
if len(installed) != 0 {
|
||||
t.Fatalf("default signals created %d custom installs, want 0", len(installed))
|
||||
}
|
||||
|
||||
forged := defaults[0]
|
||||
badInstall := &tg.AccountInstallThemeRequest{}
|
||||
badInstall.SetTheme(&tg.InputTheme{ID: forged.ID, AccessHash: forged.AccessHash + 1})
|
||||
badInstall.SetFormat("android")
|
||||
if _, err := r.onAccountInstallTheme(ctx, badInstall); !tgerr.Is(err, "THEME_INVALID") {
|
||||
t.Fatalf("install forged default reference err = %v, want THEME_INVALID", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccountGetThemesIncludesUserThemes 验证 getThemes 跨设备同步:返回内置默认主题
|
||||
// (is_default=true,emoji 预览条用)+ 当前用户创建的自定义主题(is_default=false,creator=true);
|
||||
// hash 稳定→NotModified,集合变化→重取。
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue