From 9a99897864850e77a75ce0ee0d45a0053ebff4fb Mon Sep 17 00:00:00 2001 From: chen08209 Date: Wed, 5 Aug 2026 14:54:07 +0800 Subject: [PATCH] Optimize Android TV launcher icon --- .../drawable/ic_launcher_foreground_tv.xml | 21 ++++ .../ic_launcher.xml | 5 + .../mipmap-television-hdpi/ic_launcher.webp | Bin 0 -> 1042 bytes .../mipmap-television-mdpi/ic_launcher.webp | Bin 0 -> 782 bytes .../mipmap-television-xhdpi/ic_launcher.webp | Bin 0 -> 1512 bytes .../mipmap-television-xxhdpi/ic_launcher.webp | Bin 0 -> 2166 bytes .../ic_launcher.webp | Bin 0 -> 2860 bytes test/android_tv_launcher_icon_test.dart | 102 ++++++++++++++++++ 8 files changed, 128 insertions(+) create mode 100644 android/app/src/main/res/drawable/ic_launcher_foreground_tv.xml create mode 100644 android/app/src/main/res/mipmap-television-anydpi-v26/ic_launcher.xml create mode 100644 android/app/src/main/res/mipmap-television-hdpi/ic_launcher.webp create mode 100644 android/app/src/main/res/mipmap-television-mdpi/ic_launcher.webp create mode 100644 android/app/src/main/res/mipmap-television-xhdpi/ic_launcher.webp create mode 100644 android/app/src/main/res/mipmap-television-xxhdpi/ic_launcher.webp create mode 100644 android/app/src/main/res/mipmap-television-xxxhdpi/ic_launcher.webp create mode 100644 test/android_tv_launcher_icon_test.dart diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground_tv.xml b/android/app/src/main/res/drawable/ic_launcher_foreground_tv.xml new file mode 100644 index 00000000..3cde6eb7 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_foreground_tv.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/android/app/src/main/res/mipmap-television-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-television-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..4aa629fb --- /dev/null +++ b/android/app/src/main/res/mipmap-television-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-television-hdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-television-hdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..d320a77e50a102b98d1b8859010dc86f4c390aeb GIT binary patch literal 1042 zcmV+t1nv7$Nk&Er1ONb6MM6+kP&iEf0{{RocfcJ0#|N=(Bu7$J-@S|M^N+?(OKLBGJ+rx2n0bO2n696fgtQ42r`5n1cE?vfe1Sg2r|MC>nhvfYv&jOK}K>s z7sn6rkACq;2)_sZDK5L$dQ?P%V+aT#f)IFlOnV|`aXZf(b35~JpE+nx#qBf^5z{=} zr#51SsVLtYY1cyXm zvOa=|fOC9sj!(c~5yC*SJ_d>5?fF?JKq6rxe0`6EiG@i(SRW?q2%p~{!#X4Z!Q|{C z>^|#|1lC882!Ztx0tDs=OeBIK#KJ@fScC|W1#O!lJAaOC+lr5Eo3YL57#rKRZQHhO z+g9Ia&|UR+Xf!$&lZ*zH;#Wtp8gLK_nGxuWnY zBASDRV3*#RXwMgbUU)0QvYj4!LG=h@7v$XYB*NMaHIw*`gn4Ic#PsU4ARNm9?|=%z zH3BWitgeJ>Lu;grV%rnGZM*^JnoSAkMm~VDZ!zKADaiydc1Y_?I2U>X#(Lqc3FrF3 zfO1F!!ntj%HDK(POE`DQvImT9lR6R3#Xf+sUU)0Qxk(K`Ik1v&ZWnJ17`x_5gs+tA z3>cflcOaZg{Q%{#=7e*zT7YwsMEG`0Faexn2wy4J6_EDq#HTV4(9R@0TSn;t-YqzE zO0flH%av=eaUksTJwVa&$b&DGzP+{$6?bNh!$5iOb1MJqk|iSc5}Vf1)}XlM4pn>G z3J++)qDzhwD6YErgQ`D^NHT+BUr@9;_MU3}z7!AVc3PTL2j$&QD8n;&Ky67?yEr3I z+;H;`WqA(~i0rD=HOCng*Pi=I*}lL8{7a<2qym3X)I0i?vVMsQfM-NXVqG^--tmaC zzl)5bFaw;;o>IP#cLCNX zDc5($Z2)V%S3G`PUJZC}rW|i?0?hZbd$tE~KgjCi2}?kJC!3#FRsj0upIO}B3L?C! z?d4$*w>67(QL>o&W#< literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-television-mdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-television-mdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..4a9b92fde05f7251fc209ec3b281d1ac1db42d7b GIT binary patch literal 782 zcmV+p1M&P)Nk&En0{{S5MM6+kP&iEa0ssInPrwrZ|K+%mBuT02T@I^w(U;HY6_jV_0o8gX<8q|myR10^u5!;cPR1YUL z!kz1LsloNJc(5Nr$$LI1apD%$qgpt*#Dy+DQ2Sh*;a1)UC#l-c%j10m^d5qkR85Vj z07XSW^%|pE|9??MHM`=%#MNs&zdry}RK3UZ^B0Y&ifXOVYfO!)5&(drdX2aMR0IMj z41p-%_PW|;K-RWxrro@;ZQHDECADJbYf`7So2E`}yA5juv-9KDS~GL)Z>}EYAfo>h zfJsO_3M*=BD@#3$k+Ftsm&KbQaY;Kl&NqSI%tn%IPT*ItmE;S#rK}>=DZHW=w2^Wi zC$DLBq}~w6D0egIoDR0hD@bSiTyTw@-$FV!W`n6maW(0zo8bkfnQKXB>w*|COD-bJt_4-3vwoHzn5M5Loo$N}z;$*T>8wlx+bN{8stA1j>oqjZ@dx8P zQf^%|Hl2`HDyC<>$-9;00?;gOQ>>?zB?q; zA8}M*l&mf62}a2eZpas7m}+c;CxcdfCnoYc16$(r28$(HlmKD9PJQ#|%`|D8@f{dTk_bCzJ+ zw%Rs({^Lo?%r!GJGqWU1k^q2(Bz4fXZQHhOoyN9p+q?hm{{$>VINbjSnlAvmaF;Po zsp8<|e-RH0t0Oa7e6^iRYjW&}I!w{-_)Nlf~>trw)yQqoq{;BbF;_(b(MjU(nh{w0Dx^*W5T1z>aW*q+tv+`d|P6yC0V`d_u|Y2%U`#b|2ms-MiAw zZTTqh+l%EHrTbk!2JkzED5q7WyJp`yusZ`S^i3rj(eL7!s9@I*KBaWC*%(Cve)}+` zbgFcdxa!Uhe#a5KrF3I>yZ1)0drS@W9ZL4LA|_t}yMAz+(#>M)0`NPS`VyrZ&wUX5 zPQoo@`Zgu|Zylu@!EYzt)k^lZw{8W)Rkf7vC0~H!269Sv{jUK49FHX3LS;wjZ{Rtr zO(pu)1bi_4>1{N^9s=97m6hUCm#+ig`OFj8viLJNo*t6gt`udv&Yab7wrVt2JzQmA z?YfoBiy;?#ygTb7IG+5Vl zEZpNw3~&1$hU3l~X_8#dx_oWunax32BQI_%V)7L@9(`rxs)w!ngxSJ*40Pc4;Qb5W zcv?Uz({)DqU$-U;w|orT7}ukzcui0{htohz*c3lgvL27W(Kx_twAN zn6zYLr^>@GEO0#V#Ep@vTC!=1sUyMhvN-9or5Y_+t2VUEjS)EBp1B;iRQEmrp4+Vy zmT2liy3bw!uCrF6G_Gkp(=LbsxSkwPE~XGAQLoUme+9mK?u3?Lj%sA&g$d4^&Nbd8 z%V)doB;dW-T;tU-wZ^86V1AD`N0;olrvzlMf21cFOYuP`E(GYu&lJri%H}(+Wj;h$ zYh*GNJz1+Zu*{Aygg7-I#Y$F;TwJ*2oA{tYj%`;86X{CH{zpDUS)pfl6+csIwNwRS z0)$y}WWLx@v_!4S&^jx^kmrCSZ%ate6>9m9g-HFdGF@keQX+1ubWOZWDQ7M6?1VcF z2N9lrrVbuSJC}?d3+DC(OYgRN^pdwAhEf@`;x_x|sqm6rbd3-3?ls-K5J_ZSggO)~ zi=Ec|6x}6{8yfx!EOuV$O=kE+B#l4}K40v%;+xEbElU{v`-;WxYk!H{Ac`BRzE2uh z+^?$;#o(cIvB_GWqrN0sCY(kVPd(Bkag~`tL#ZGZJi%hKy;^EP_=b8mw1PmDcPTRi z`f^D${p&R|Zq1QI&xXcGwfBknPbi65H}W16ok}K%@=XI5&b(1%F*n5DyW0+7PTDi8 zeMq|LTrxtOcc1F0;!CDSUFXXKe|M|uE5AvH)bMxUBgk}afCu(vt<&~;-$FG$#lCO+ O$M@{`EgOgX|3GCX?gLE# literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-television-xxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-television-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..f55d6e9589f9064b0a6982e81ec50febdf58ff71 GIT binary patch literal 2166 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 K@V|xsSE4D*t9}Fk literal 0 HcmV?d00001 diff --git a/test/android_tv_launcher_icon_test.dart b/test/android_tv_launcher_icon_test.dart new file mode 100644 index 00000000..957468ef --- /dev/null +++ b/test/android_tv_launcher_icon_test.dart @@ -0,0 +1,102 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter_test/flutter_test.dart'; + +String _androidAttribute(String source, String element, String attribute) { + final elementTag = RegExp('<$element\\b[^>]*>').firstMatch(source)!.group(0)!; + return RegExp( + 'android:$attribute="([^"]+)"', + ).firstMatch(elementTag)!.group(1)!; +} + +double _androidDoubleAttribute( + String source, + String element, + String attribute, +) { + return double.parse(_androidAttribute(source, element, attribute)); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('TV launcher icons meet density-specific minimum sizes', () async { + const expectedSizes = { + 'mdpi': 80, + 'hdpi': 120, + 'xhdpi': 160, + 'xxhdpi': 240, + 'xxxhdpi': 320, + }; + + for (final MapEntry(key: density, value: size) in expectedSizes.entries) { + final file = File( + 'android/app/src/main/res/' + 'mipmap-television-$density/ic_launcher.webp', + ); + expect(file.existsSync(), isTrue, reason: 'missing ${file.path}'); + + final codec = await ui.instantiateImageCodec(await file.readAsBytes()); + final frame = await codec.getNextFrame(); + expect( + (frame.image.width, frame.image.height), + (size, size), + reason: file.path, + ); + frame.image.dispose(); + codec.dispose(); + } + }); + + test('TV adaptive launcher icon stays centered in the safe zone', () { + final adaptiveIcon = File( + 'android/app/src/main/res/' + 'mipmap-television-anydpi-v26/ic_launcher.xml', + ).readAsStringSync(); + expect( + _androidAttribute(adaptiveIcon, 'foreground', 'drawable'), + '@drawable/ic_launcher_foreground_tv', + ); + expect( + _androidAttribute(adaptiveIcon, 'background', 'drawable'), + '@color/ic_launcher_background', + ); + + final vector = File( + 'android/app/src/main/res/drawable/' + 'ic_launcher_foreground_tv.xml', + ).readAsStringSync(); + final scaleX = _androidDoubleAttribute(vector, 'group', 'scaleX'); + final scaleY = _androidDoubleAttribute(vector, 'group', 'scaleY'); + final translateX = _androidDoubleAttribute(vector, 'group', 'translateX'); + final translateY = _androidDoubleAttribute(vector, 'group', 'translateY'); + final viewportWidth = _androidDoubleAttribute( + vector, + 'vector', + 'viewportWidth', + ); + final viewportHeight = _androidDoubleAttribute( + vector, + 'vector', + 'viewportHeight', + ); + + // Conservative bounds of the current logo, including the curved caps. + const logoBounds = ui.Rect.fromLTRB(54, 33, 179, 206.5); + final transformedBounds = ui.Rect.fromLTRB( + (logoBounds.left * scaleX + translateX) / viewportWidth * 108, + (logoBounds.top * scaleY + translateY) / viewportHeight * 108, + (logoBounds.right * scaleX + translateX) / viewportWidth * 108, + (logoBounds.bottom * scaleY + translateY) / viewportHeight * 108, + ); + const safeZone = ui.Rect.fromLTWH(18, 18, 72, 72); + + expect(transformedBounds.left, greaterThanOrEqualTo(safeZone.left)); + expect(transformedBounds.top, greaterThanOrEqualTo(safeZone.top)); + expect(transformedBounds.right, lessThanOrEqualTo(safeZone.right)); + expect(transformedBounds.bottom, lessThanOrEqualTo(safeZone.bottom)); + expect(transformedBounds.center.dx, closeTo(safeZone.center.dx, 0.05)); + expect(transformedBounds.center.dy, closeTo(safeZone.center.dy, 0.05)); + }); +}