diff --git a/internal/compat/ios/theme_colors.go b/internal/compat/ios/theme_colors.go new file mode 100644 index 00000000..4641be3b --- /dev/null +++ b/internal/compat/ios/theme_colors.go @@ -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)) +} diff --git a/internal/compat/ios/theme_colors_test.go b/internal/compat/ios/theme_colors_test.go new file mode 100644 index 00000000..f4d8c00d --- /dev/null +++ b/internal/compat/ios/theme_colors_test.go @@ -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") + } +} diff --git a/internal/rpc/account.go b/internal/rpc/account.go index d9cc0892..27c8d9c8 100644 --- a/internal/rpc/account.go +++ b/internal/rpc/account.go @@ -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) { hash := layerRequest. Hash - _ = hash - - if _, _, err := r.currentUserID(ctx); err != nil { - return nil, internalErr() - } - return tdesktop.ChatThemes(hash), nil + return r.onAccountGetChatThemes(ctx, hash) }) registerRPC[ diff --git a/internal/rpc/account_themes.go b/internal/rpc/account_themes.go index 59d384d9..be113522 100644 --- a/internal/rpc/account_themes.go +++ b/internal/rpc/account_themes.go @@ -6,6 +6,7 @@ import ( "github.com/iamxvbaba/td/tg" + ioscompat "telesrv/internal/compat/ios" "telesrv/internal/compat/tdesktop" "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 { return &tg.AccountThemesNotModified{}, 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 并返回。 // 它不创建主题实体——客户端随后用返回的 Document 调 createTheme/updateTheme。 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) } r.invalidateRPCProjectionForUser(userID) - return r.tgTheme(ctx, t, userID), nil + return projectThemeForClient(ctx, r.tgTheme(ctx, t, userID)), nil } // onAccountUpdateTheme 更新创建者自己的主题(部分字段)。 @@ -172,7 +205,7 @@ func (r *Router) onAccountUpdateTheme(ctx context.Context, req *tg.AccountUpdate return nil, themeErr(err) } r.invalidateRPCProjectionForUser(userID) - return r.tgTheme(ctx, t, userID), nil + return projectThemeForClient(ctx, r.tgTheme(ctx, t, userID)), nil } // onAccountSaveTheme 把主题加入/移出用户的已存列表。 @@ -261,5 +294,16 @@ func (r *Router) onAccountGetTheme(ctx context.Context, req *tg.AccountGetThemeR if !ok { 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] } diff --git a/internal/rpc/account_themes_rpc_test.go b/internal/rpc/account_themes_rpc_test.go index f4fed48d..c018f422 100644 --- a/internal/rpc/account_themes_rpc_test.go +++ b/internal/rpc/account_themes_rpc_test.go @@ -11,6 +11,7 @@ import ( "go.uber.org/zap/zaptest" themesapp "telesrv/internal/app/themes" + "telesrv/internal/compat/tdesktop" "telesrv/internal/domain" "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 非空可编码。 func TestAccountCreateThemeAccentSettingsEncode(t *testing.T) { ctx := WithUserID(context.Background(), 1000003) diff --git a/internal/rpc/convert_theme.go b/internal/rpc/convert_theme.go index e8fcf7ce..a12f3b2b 100644 --- a/internal/rpc/convert_theme.go +++ b/internal/rpc/convert_theme.go @@ -1,28 +1,55 @@ package rpc import ( + "bytes" "context" + "encoding/binary" + "fmt" + "hash/fnv" "sort" + "github.com/iamxvbaba/td/bin" "github.com/iamxvbaba/td/tg" "telesrv/internal/domain" ) -// themesListHash 计算一组主题的稳定哈希(服务端权威,客户端原样回传)。对 id 升序折叠, -// 与返回顺序无关;主题集合变化(用户新建/安装/卸载)即变,驱动客户端重取。 -func themesListHash(themes []tg.Theme) int64 { - ids := make([]int64, 0, len(themes)) - for _, t := range themes { - ids = append(ids, t.ID) +// themesListHash 计算一组主题完整 wire 内容的稳定哈希。编码后排序使返回顺序不影响 +// 哈希;标题、document、settings 或颜色等任一可见内容变化都会驱动客户端重取。 +func themesListHash(themes []tg.Theme) (int64, error) { + encoded := make([][]byte, 0, len(themes)) + for i := range themes { + 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] }) - var h uint64 = 0xcbf29ce484222325 // FNV-1a 64 offset basis - for _, id := range ids { - h ^= uint64(id) - h *= 0x100000001b3 + sort.Slice(encoded, func(i, j int) bool { + return bytes.Compare(encoded[i], encoded[j]) < 0 + }) + h := fnv.New64a() + 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。