fix: sync preserve iOS theme accent visibility
This commit is contained in:
parent
209a0d279b
commit
a989e118d7
6 changed files with 408 additions and 22 deletions
46
internal/compat/ios/theme_colors.go
Normal file
46
internal/compat/ios/theme_colors.go
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
package ios
|
||||||
|
|
||||||
|
import "github.com/iamxvbaba/td/tg"
|
||||||
|
|
||||||
|
// ProjectThemes converts theme accent colors to the ARGB representation used by
|
||||||
|
// Telegram-iOS. Official chat-theme seed colors are RGB24 values, while iOS
|
||||||
|
// passes accent_color and outbox_accent_color to UIColor(argb:); a zero high
|
||||||
|
// byte would therefore make every accent-tinted control fully transparent.
|
||||||
|
//
|
||||||
|
// The projection is copy-on-write so the Android/TDesktop catalog remains
|
||||||
|
// byte-for-byte unchanged. message_colors and wallpaper colors intentionally
|
||||||
|
// stay RGB24, as required by the TL schema and all audited clients.
|
||||||
|
func ProjectThemes(themes []tg.Theme) []tg.Theme {
|
||||||
|
if len(themes) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]tg.Theme, len(themes))
|
||||||
|
for i := range themes {
|
||||||
|
out[i] = themes[i]
|
||||||
|
settings, ok := themes[i].GetSettings()
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
projected := make([]tg.ThemeSettings, len(settings))
|
||||||
|
for j := range settings {
|
||||||
|
projected[j] = settings[j]
|
||||||
|
projected[j].AccentColor = opaqueARGB(settings[j].AccentColor)
|
||||||
|
if color, ok := settings[j].GetOutboxAccentColor(); ok {
|
||||||
|
projected[j].SetOutboxAccentColor(opaqueARGB(color))
|
||||||
|
}
|
||||||
|
if colors, ok := settings[j].GetMessageColors(); ok {
|
||||||
|
projected[j].SetMessageColors(append([]int(nil), colors...))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out[i].SetSettings(projected)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func opaqueARGB(color int) int {
|
||||||
|
value := uint32(int32(color))
|
||||||
|
if value>>24 == 0 {
|
||||||
|
value |= 0xff000000
|
||||||
|
}
|
||||||
|
return int(int32(value))
|
||||||
|
}
|
||||||
75
internal/compat/ios/theme_colors_test.go
Normal file
75
internal/compat/ios/theme_colors_test.go
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
package ios
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/iamxvbaba/td/tg"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProjectThemesMakesOnlyIOSAccentColorsOpaque(t *testing.T) {
|
||||||
|
settings := tg.ThemeSettings{
|
||||||
|
BaseTheme: &tg.BaseThemeClassic{},
|
||||||
|
AccentColor: 0x29b071,
|
||||||
|
}
|
||||||
|
settings.SetOutboxAccentColor(0x4cb064)
|
||||||
|
settings.SetMessageColors([]int{0xd4f1ff, 0xb9e4ff})
|
||||||
|
wallpaper := &tg.WallPaperNoFile{}
|
||||||
|
settings.SetWallpaper(wallpaper)
|
||||||
|
theme := tg.Theme{ID: 1, Slug: "green", Title: "Green"}
|
||||||
|
theme.SetSettings([]tg.ThemeSettings{settings})
|
||||||
|
|
||||||
|
projected := ProjectThemes([]tg.Theme{theme})
|
||||||
|
if len(projected) != 1 {
|
||||||
|
t.Fatalf("ProjectThemes length = %d, want 1", len(projected))
|
||||||
|
}
|
||||||
|
got, ok := projected[0].GetSettings()
|
||||||
|
if !ok || len(got) != 1 {
|
||||||
|
t.Fatalf("projected settings = %#v ok=%v, want one setting", got, ok)
|
||||||
|
}
|
||||||
|
if color := uint32(int32(got[0].AccentColor)); color != 0xff29b071 {
|
||||||
|
t.Fatalf("accent color = %#08x, want opaque ARGB 0xff29b071", color)
|
||||||
|
}
|
||||||
|
if color, ok := got[0].GetOutboxAccentColor(); !ok || uint32(int32(color)) != 0xff4cb064 {
|
||||||
|
t.Fatalf("outbox accent = %#08x ok=%v, want opaque ARGB 0xff4cb064", uint32(int32(color)), ok)
|
||||||
|
}
|
||||||
|
colors, ok := got[0].GetMessageColors()
|
||||||
|
if !ok || len(colors) != 2 || colors[0] != 0xd4f1ff || colors[1] != 0xb9e4ff {
|
||||||
|
t.Fatalf("message colors = %#v ok=%v, want unchanged RGB24 values", colors, ok)
|
||||||
|
}
|
||||||
|
if got[0].Wallpaper != wallpaper {
|
||||||
|
t.Fatal("wallpaper changed during accent-only projection")
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceSettings, _ := theme.GetSettings()
|
||||||
|
if sourceSettings[0].AccentColor != 0x29b071 {
|
||||||
|
t.Fatalf("source accent mutated to %#x", sourceSettings[0].AccentColor)
|
||||||
|
}
|
||||||
|
sourceOutbox, _ := sourceSettings[0].GetOutboxAccentColor()
|
||||||
|
if sourceOutbox != 0x4cb064 {
|
||||||
|
t.Fatalf("source outbox accent mutated to %#x", sourceOutbox)
|
||||||
|
}
|
||||||
|
colors[0] = 1
|
||||||
|
sourceColors, _ := sourceSettings[0].GetMessageColors()
|
||||||
|
if sourceColors[0] != 0xd4f1ff {
|
||||||
|
t.Fatalf("source message colors mutated through projection: %#v", sourceColors)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectThemesPreservesExistingAlphaAndAbsentOutbox(t *testing.T) {
|
||||||
|
argb := uint32(0x80445566)
|
||||||
|
settings := tg.ThemeSettings{
|
||||||
|
BaseTheme: &tg.BaseThemeTinted{},
|
||||||
|
AccentColor: int(int32(argb)),
|
||||||
|
}
|
||||||
|
theme := tg.Theme{ID: 2}
|
||||||
|
theme.SetSettings([]tg.ThemeSettings{settings})
|
||||||
|
|
||||||
|
projected := ProjectThemes([]tg.Theme{theme})
|
||||||
|
got, _ := projected[0].GetSettings()
|
||||||
|
if color := uint32(int32(got[0].AccentColor)); color != argb {
|
||||||
|
t.Fatalf("existing ARGB color = %#08x, want %#08x", color, argb)
|
||||||
|
}
|
||||||
|
if _, ok := got[0].GetOutboxAccentColor(); ok {
|
||||||
|
t.Fatal("absent outbox accent became present")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -217,12 +217,7 @@ func (r *Router) registerAccount(d *tlprofile.Dispatcher) {
|
||||||
registerRPC[*tg.AccountGetChatThemesRequest](d, tlprofile.SemanticMethodAccountGetChatThemes, func(ctx context.Context, layerRequest *tg.AccountGetChatThemesRequest) (any, error) {
|
registerRPC[*tg.AccountGetChatThemesRequest](d, tlprofile.SemanticMethodAccountGetChatThemes, func(ctx context.Context, layerRequest *tg.AccountGetChatThemesRequest) (any, error) {
|
||||||
hash := layerRequest.
|
hash := layerRequest.
|
||||||
Hash
|
Hash
|
||||||
_ = hash
|
return r.onAccountGetChatThemes(ctx, hash)
|
||||||
|
|
||||||
if _, _, err := r.currentUserID(ctx); err != nil {
|
|
||||||
return nil, internalErr()
|
|
||||||
}
|
|
||||||
return tdesktop.ChatThemes(hash), nil
|
|
||||||
})
|
})
|
||||||
registerRPC[
|
registerRPC[
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
|
|
||||||
"github.com/iamxvbaba/td/tg"
|
"github.com/iamxvbaba/td/tg"
|
||||||
|
|
||||||
|
ioscompat "telesrv/internal/compat/ios"
|
||||||
"telesrv/internal/compat/tdesktop"
|
"telesrv/internal/compat/tdesktop"
|
||||||
"telesrv/internal/domain"
|
"telesrv/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
@ -45,13 +46,45 @@ func (r *Router) onAccountGetThemes(ctx context.Context, req *tg.AccountGetTheme
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
hash := themesListHash(themes)
|
if ClientTypeFrom(ctx) == ClientTypeIOS {
|
||||||
|
themes = ioscompat.ProjectThemes(themes)
|
||||||
|
}
|
||||||
|
hash, err := themesListHash(themes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, internalErr()
|
||||||
|
}
|
||||||
if req != nil && req.GetHash() == hash {
|
if req != nil && req.GetHash() == hash {
|
||||||
return &tg.AccountThemesNotModified{}, nil
|
return &tg.AccountThemesNotModified{}, nil
|
||||||
}
|
}
|
||||||
return &tg.AccountThemes{Hash: hash, Themes: themes}, nil
|
return &tg.AccountThemes{Hash: hash, Themes: themes}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// onAccountGetChatThemes applies the same iOS ARGB projection as account.getThemes.
|
||||||
|
// The projected content hash is intentionally distinct from the historical
|
||||||
|
// Android/TDesktop catalog hash, forcing clients with the transparent-color
|
||||||
|
// response cached to fetch the corrected payload once.
|
||||||
|
func (r *Router) onAccountGetChatThemes(ctx context.Context, hash int64) (tg.AccountThemesClass, error) {
|
||||||
|
if _, _, err := r.currentUserID(ctx); err != nil {
|
||||||
|
return nil, internalErr()
|
||||||
|
}
|
||||||
|
if ClientTypeFrom(ctx) != ClientTypeIOS {
|
||||||
|
return tdesktop.ChatThemes(hash), nil
|
||||||
|
}
|
||||||
|
base, ok := tdesktop.ChatThemes(0).(*tg.AccountThemes)
|
||||||
|
if !ok {
|
||||||
|
return nil, internalErr()
|
||||||
|
}
|
||||||
|
themes := ioscompat.ProjectThemes(base.Themes)
|
||||||
|
projectedHash, err := themesListHash(themes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, internalErr()
|
||||||
|
}
|
||||||
|
if hash == projectedHash {
|
||||||
|
return &tg.AccountThemesNotModified{}, nil
|
||||||
|
}
|
||||||
|
return &tg.AccountThemes{Hash: projectedHash, Themes: themes}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// onAccountUploadTheme 把客户端上传的 .attheme 文件落成可下载的 Document 并返回。
|
// onAccountUploadTheme 把客户端上传的 .attheme 文件落成可下载的 Document 并返回。
|
||||||
// 它不创建主题实体——客户端随后用返回的 Document 调 createTheme/updateTheme。
|
// 它不创建主题实体——客户端随后用返回的 Document 调 createTheme/updateTheme。
|
||||||
func (r *Router) onAccountUploadTheme(ctx context.Context, req *tg.AccountUploadThemeRequest) (tg.DocumentClass, error) {
|
func (r *Router) onAccountUploadTheme(ctx context.Context, req *tg.AccountUploadThemeRequest) (tg.DocumentClass, error) {
|
||||||
|
|
@ -128,7 +161,7 @@ func (r *Router) onAccountCreateTheme(ctx context.Context, req *tg.AccountCreate
|
||||||
return nil, themeErr(err)
|
return nil, themeErr(err)
|
||||||
}
|
}
|
||||||
r.invalidateRPCProjectionForUser(userID)
|
r.invalidateRPCProjectionForUser(userID)
|
||||||
return r.tgTheme(ctx, t, userID), nil
|
return projectThemeForClient(ctx, r.tgTheme(ctx, t, userID)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// onAccountUpdateTheme 更新创建者自己的主题(部分字段)。
|
// onAccountUpdateTheme 更新创建者自己的主题(部分字段)。
|
||||||
|
|
@ -172,7 +205,7 @@ func (r *Router) onAccountUpdateTheme(ctx context.Context, req *tg.AccountUpdate
|
||||||
return nil, themeErr(err)
|
return nil, themeErr(err)
|
||||||
}
|
}
|
||||||
r.invalidateRPCProjectionForUser(userID)
|
r.invalidateRPCProjectionForUser(userID)
|
||||||
return r.tgTheme(ctx, t, userID), nil
|
return projectThemeForClient(ctx, r.tgTheme(ctx, t, userID)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// onAccountSaveTheme 把主题加入/移出用户的已存列表。
|
// onAccountSaveTheme 把主题加入/移出用户的已存列表。
|
||||||
|
|
@ -261,5 +294,16 @@ func (r *Router) onAccountGetTheme(ctx context.Context, req *tg.AccountGetThemeR
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, themeInvalidErr()
|
return nil, themeInvalidErr()
|
||||||
}
|
}
|
||||||
return r.tgTheme(ctx, t, userID), nil
|
return projectThemeForClient(ctx, r.tgTheme(ctx, t, userID)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func projectThemeForClient(ctx context.Context, theme *tg.Theme) *tg.Theme {
|
||||||
|
if theme == nil || ClientTypeFrom(ctx) != ClientTypeIOS {
|
||||||
|
return theme
|
||||||
|
}
|
||||||
|
projected := ioscompat.ProjectThemes([]tg.Theme{*theme})
|
||||||
|
if len(projected) == 0 {
|
||||||
|
return theme
|
||||||
|
}
|
||||||
|
return &projected[0]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"go.uber.org/zap/zaptest"
|
"go.uber.org/zap/zaptest"
|
||||||
|
|
||||||
themesapp "telesrv/internal/app/themes"
|
themesapp "telesrv/internal/app/themes"
|
||||||
|
"telesrv/internal/compat/tdesktop"
|
||||||
"telesrv/internal/domain"
|
"telesrv/internal/domain"
|
||||||
"telesrv/internal/store/memory"
|
"telesrv/internal/store/memory"
|
||||||
)
|
)
|
||||||
|
|
@ -231,6 +232,204 @@ func TestAccountGetThemesTDesktopExcludesDocumentlessDefaults(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAccountGetThemesIOSProjectsOpaqueARGBAndRefreshesByContent(t *testing.T) {
|
||||||
|
const userID = 1000008
|
||||||
|
r := newThemeRouter(t, &fakeFiles{})
|
||||||
|
iosCtx := WithClientInfo(
|
||||||
|
WithUserID(context.Background(), userID),
|
||||||
|
ClientInfo{Type: ClientTypeIOS, AppVersion: "12.9.2 (10000)"},
|
||||||
|
)
|
||||||
|
|
||||||
|
first, err := r.onAccountGetThemes(iosCtx, &tg.AccountGetThemesRequest{Format: "ios"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getThemes ios err = %v", err)
|
||||||
|
}
|
||||||
|
themes, ok := first.(*tg.AccountThemes)
|
||||||
|
if !ok || len(themes.Themes) == 0 {
|
||||||
|
t.Fatalf("getThemes ios = %#v, want non-empty themes", first)
|
||||||
|
}
|
||||||
|
assertThemeAccentColorsOpaque(t, themes.Themes)
|
||||||
|
|
||||||
|
source := tdesktop.DefaultThemeList()
|
||||||
|
sourceSettings, _ := source[0].GetSettings()
|
||||||
|
projectedSettings, _ := themes.Themes[0].GetSettings()
|
||||||
|
if got, want := uint32(int32(projectedSettings[0].AccentColor))&0x00ffffff, uint32(sourceSettings[0].AccentColor); got != want {
|
||||||
|
t.Fatalf("projected RGB = %#06x, want source RGB %#06x", got, want)
|
||||||
|
}
|
||||||
|
if uint32(int32(sourceSettings[0].AccentColor))>>24 != 0 {
|
||||||
|
t.Fatalf("source Android/TDesktop accent unexpectedly changed to ARGB: %#08x", uint32(int32(sourceSettings[0].AccentColor)))
|
||||||
|
}
|
||||||
|
|
||||||
|
again, err := r.onAccountGetThemes(iosCtx, &tg.AccountGetThemesRequest{Format: "ios", Hash: themes.Hash})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getThemes ios matching hash err = %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := again.(*tg.AccountThemesNotModified); !ok {
|
||||||
|
t.Fatalf("getThemes ios matching hash = %T, want notModified", again)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountGetChatThemesIOSInvalidatesTransparentCatalogCache(t *testing.T) {
|
||||||
|
const userID = 1000009
|
||||||
|
r := newThemeRouter(t, &fakeFiles{})
|
||||||
|
base, ok := tdesktop.ChatThemes(0).(*tg.AccountThemes)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("base ChatThemes = %T, want *tg.AccountThemes", tdesktop.ChatThemes(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
iosCtx := WithClientInfo(
|
||||||
|
WithUserID(context.Background(), userID),
|
||||||
|
ClientInfo{Type: ClientTypeIOS, AppVersion: "12.9.2 (10000)"},
|
||||||
|
)
|
||||||
|
projected, err := r.onAccountGetChatThemes(iosCtx, base.Hash)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getChatThemes ios err = %v", err)
|
||||||
|
}
|
||||||
|
themes, ok := projected.(*tg.AccountThemes)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("getChatThemes ios with old hash = %T, want refreshed themes", projected)
|
||||||
|
}
|
||||||
|
if themes.Hash == base.Hash {
|
||||||
|
t.Fatalf("iOS projected hash = old catalog hash %d, want cache invalidation", themes.Hash)
|
||||||
|
}
|
||||||
|
assertThemeAccentColorsOpaque(t, themes.Themes)
|
||||||
|
|
||||||
|
again, err := r.onAccountGetChatThemes(iosCtx, themes.Hash)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getChatThemes ios matching hash err = %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := again.(*tg.AccountThemesNotModified); !ok {
|
||||||
|
t.Fatalf("getChatThemes ios matching hash = %T, want notModified", again)
|
||||||
|
}
|
||||||
|
|
||||||
|
androidCtx := WithClientInfo(WithUserID(context.Background(), userID), ClientInfo{Type: ClientTypeAndroid})
|
||||||
|
android, err := r.onAccountGetChatThemes(androidCtx, base.Hash)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getChatThemes android old hash err = %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := android.(*tg.AccountThemesNotModified); !ok {
|
||||||
|
t.Fatalf("getChatThemes android old hash = %T, want unchanged notModified", android)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestThemesListHashIncludesVisibleContentAndIgnoresOrder(t *testing.T) {
|
||||||
|
first := tg.Theme{ID: 1, AccessHash: 11, Slug: "one", Title: "One"}
|
||||||
|
first.SetSettings([]tg.ThemeSettings{{
|
||||||
|
BaseTheme: &tg.BaseThemeClassic{},
|
||||||
|
AccentColor: 0x29b071,
|
||||||
|
}})
|
||||||
|
second := tg.Theme{ID: 2, AccessHash: 22, Slug: "two", Title: "Two"}
|
||||||
|
|
||||||
|
hashA, err := themesListHash([]tg.Theme{first, second})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("themesListHash initial: %v", err)
|
||||||
|
}
|
||||||
|
hashReordered, err := themesListHash([]tg.Theme{second, first})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("themesListHash reordered: %v", err)
|
||||||
|
}
|
||||||
|
if hashA != hashReordered {
|
||||||
|
t.Fatalf("hash changed with order: %d != %d", hashA, hashReordered)
|
||||||
|
}
|
||||||
|
|
||||||
|
changed := first
|
||||||
|
changedSettings, _ := changed.GetSettings()
|
||||||
|
changedSettings = append([]tg.ThemeSettings(nil), changedSettings...)
|
||||||
|
changedSettings[0].AccentColor = 0x329ed7
|
||||||
|
changed.SetSettings(changedSettings)
|
||||||
|
hashChanged, err := themesListHash([]tg.Theme{changed, second})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("themesListHash changed: %v", err)
|
||||||
|
}
|
||||||
|
if hashChanged == hashA {
|
||||||
|
t.Fatalf("hash did not change after accent update: %d", hashA)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountSingleThemeResponsesUseIOSARGBProjection(t *testing.T) {
|
||||||
|
const userID = 1000011
|
||||||
|
r := newThemeRouter(t, &fakeFiles{})
|
||||||
|
androidCtx := WithClientInfo(WithUserID(context.Background(), userID), ClientInfo{Type: ClientTypeAndroid})
|
||||||
|
iosCtx := WithClientInfo(WithUserID(context.Background(), userID), ClientInfo{Type: ClientTypeIOS})
|
||||||
|
|
||||||
|
input := tg.InputThemeSettings{
|
||||||
|
BaseTheme: &tg.BaseThemeDay{},
|
||||||
|
AccentColor: 0x3997d3,
|
||||||
|
}
|
||||||
|
input.SetOutboxAccentColor(0x4cb064)
|
||||||
|
create := &tg.AccountCreateThemeRequest{Title: "Cross-platform"}
|
||||||
|
create.SetSettings([]tg.InputThemeSettings{input})
|
||||||
|
created, err := r.onAccountCreateTheme(androidCtx, create)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create Android theme: %v", err)
|
||||||
|
}
|
||||||
|
androidSettings, _ := created.GetSettings()
|
||||||
|
if color := uint32(int32(androidSettings[0].AccentColor)); color>>24 != 0 {
|
||||||
|
t.Fatalf("Android create response accent = %#08x, want unchanged RGB24", color)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := r.onAccountGetTheme(iosCtx, &tg.AccountGetThemeRequest{
|
||||||
|
Format: "ios",
|
||||||
|
Theme: &tg.InputTheme{ID: created.ID, AccessHash: created.AccessHash},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get iOS theme: %v", err)
|
||||||
|
}
|
||||||
|
assertThemeAccentColorsOpaque(t, []tg.Theme{*got})
|
||||||
|
iosSettings, _ := got.GetSettings()
|
||||||
|
if color := uint32(int32(iosSettings[0].AccentColor)); color != 0xff3997d3 {
|
||||||
|
t.Fatalf("iOS getTheme accent = %#08x, want 0xff3997d3", color)
|
||||||
|
}
|
||||||
|
if color, ok := iosSettings[0].GetOutboxAccentColor(); !ok || uint32(int32(color)) != 0xff4cb064 {
|
||||||
|
t.Fatalf("iOS getTheme outbox accent = %#08x ok=%v, want 0xff4cb064", uint32(int32(color)), ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
iosCreate := &tg.AccountCreateThemeRequest{Title: "Created on iOS"}
|
||||||
|
iosCreate.SetSettings([]tg.InputThemeSettings{input})
|
||||||
|
createdOnIOS, err := r.onAccountCreateTheme(iosCtx, iosCreate)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create iOS theme: %v", err)
|
||||||
|
}
|
||||||
|
assertThemeAccentColorsOpaque(t, []tg.Theme{*createdOnIOS})
|
||||||
|
|
||||||
|
updatedInput := tg.InputThemeSettings{
|
||||||
|
BaseTheme: &tg.BaseThemeTinted{},
|
||||||
|
AccentColor: 0x8660ad,
|
||||||
|
}
|
||||||
|
update := &tg.AccountUpdateThemeRequest{
|
||||||
|
Format: "ios",
|
||||||
|
Theme: &tg.InputTheme{ID: created.ID, AccessHash: created.AccessHash},
|
||||||
|
}
|
||||||
|
update.SetSettings([]tg.InputThemeSettings{updatedInput})
|
||||||
|
updated, err := r.onAccountUpdateTheme(iosCtx, update)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("update iOS theme: %v", err)
|
||||||
|
}
|
||||||
|
assertThemeAccentColorsOpaque(t, []tg.Theme{*updated})
|
||||||
|
updatedSettings, _ := updated.GetSettings()
|
||||||
|
if color := uint32(int32(updatedSettings[0].AccentColor)); color != 0xff8660ad {
|
||||||
|
t.Fatalf("iOS updateTheme accent = %#08x, want 0xff8660ad", color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertThemeAccentColorsOpaque(t *testing.T, themes []tg.Theme) {
|
||||||
|
t.Helper()
|
||||||
|
for _, theme := range themes {
|
||||||
|
settings, ok := theme.GetSettings()
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for i := range settings {
|
||||||
|
if color := uint32(int32(settings[i].AccentColor)); color>>24 == 0 {
|
||||||
|
t.Fatalf("theme %d settings[%d] accent remains transparent: %#08x", theme.ID, i, color)
|
||||||
|
}
|
||||||
|
if color, ok := settings[i].GetOutboxAccentColor(); ok && uint32(int32(color))>>24 == 0 {
|
||||||
|
t.Fatalf("theme %d settings[%d] outbox accent remains transparent: %#08x", theme.ID, i, uint32(int32(color)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestAccountCreateThemeAccentSettingsEncode 验证带 settings 的 accent 主题往返且 base_theme 非空可编码。
|
// TestAccountCreateThemeAccentSettingsEncode 验证带 settings 的 accent 主题往返且 base_theme 非空可编码。
|
||||||
func TestAccountCreateThemeAccentSettingsEncode(t *testing.T) {
|
func TestAccountCreateThemeAccentSettingsEncode(t *testing.T) {
|
||||||
ctx := WithUserID(context.Background(), 1000003)
|
ctx := WithUserID(context.Background(), 1000003)
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,55 @@
|
||||||
package rpc
|
package rpc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"hash/fnv"
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
|
"github.com/iamxvbaba/td/bin"
|
||||||
"github.com/iamxvbaba/td/tg"
|
"github.com/iamxvbaba/td/tg"
|
||||||
|
|
||||||
"telesrv/internal/domain"
|
"telesrv/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// themesListHash 计算一组主题的稳定哈希(服务端权威,客户端原样回传)。对 id 升序折叠,
|
// themesListHash 计算一组主题完整 wire 内容的稳定哈希。编码后排序使返回顺序不影响
|
||||||
// 与返回顺序无关;主题集合变化(用户新建/安装/卸载)即变,驱动客户端重取。
|
// 哈希;标题、document、settings 或颜色等任一可见内容变化都会驱动客户端重取。
|
||||||
func themesListHash(themes []tg.Theme) int64 {
|
func themesListHash(themes []tg.Theme) (int64, error) {
|
||||||
ids := make([]int64, 0, len(themes))
|
encoded := make([][]byte, 0, len(themes))
|
||||||
for _, t := range themes {
|
for i := range themes {
|
||||||
ids = append(ids, t.ID)
|
theme := themes[i]
|
||||||
|
if settings, ok := theme.GetSettings(); ok {
|
||||||
|
copied := append([]tg.ThemeSettings(nil), settings...)
|
||||||
|
for j := range copied {
|
||||||
|
if colors, ok := copied[j].GetMessageColors(); ok {
|
||||||
|
copied[j].SetMessageColors(append([]int(nil), colors...))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
theme.SetSettings(copied)
|
||||||
|
}
|
||||||
|
var b bin.Buffer
|
||||||
|
if err := theme.Encode(&b); err != nil {
|
||||||
|
return 0, fmt.Errorf("encode theme %d for hash: %w", theme.ID, err)
|
||||||
|
}
|
||||||
|
encoded = append(encoded, b.Copy())
|
||||||
}
|
}
|
||||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
sort.Slice(encoded, func(i, j int) bool {
|
||||||
var h uint64 = 0xcbf29ce484222325 // FNV-1a 64 offset basis
|
return bytes.Compare(encoded[i], encoded[j]) < 0
|
||||||
for _, id := range ids {
|
})
|
||||||
h ^= uint64(id)
|
h := fnv.New64a()
|
||||||
h *= 0x100000001b3
|
var size [8]byte
|
||||||
|
for _, body := range encoded {
|
||||||
|
binary.LittleEndian.PutUint64(size[:], uint64(len(body)))
|
||||||
|
_, _ = h.Write(size[:])
|
||||||
|
_, _ = h.Write(body)
|
||||||
}
|
}
|
||||||
return int64(h & 0x7fffffffffffffff)
|
value := int64(h.Sum64() & 0x7fffffffffffffff)
|
||||||
|
if value == 0 {
|
||||||
|
value = 1
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// themeRefFromInput 把 tg.InputThemeClass(inputTheme / inputThemeSlug)转成 domain.ThemeRef。
|
// themeRefFromInput 把 tg.InputThemeClass(inputTheme / inputThemeSlug)转成 domain.ThemeRef。
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue