From 6eb5dc7d8c67c0b1f8b935909b04b670317d86fb Mon Sep 17 00:00:00 2001 From: Fabrizio Demaria Date: Tue, 10 Dec 2024 16:52:10 +0100 Subject: [PATCH] refactor!: Context APIs changes and documentation/onboarding (#180) release-as: 1.2.0 --- .swiftlint.yml | 4 +- .../project.pbxproj | 8 + .../AppIcon.appiconset/ConfidenceLogo.png | Bin 0 -> 58639 bytes .../AppIcon.appiconset/Contents.json | 1 + .../ConfidenceDemoApp/ConfidenceDemoApp.swift | 111 ++++-- .../ConfidenceDemoApp/ContentView.swift | 160 +++++++-- .../ConfidenceDemoApp/LoginView.swift | 80 +++++ README.md | 45 ++- Sources/Confidence/Confidence.swift | 333 +++++++++++------- .../Confidence/ConfidenceEventSender.swift | 33 +- Sources/Confidence/DebugLogger.swift | 5 + Sources/Confidence/TaskManager.swift | 30 ++ .../ConfidenceFeatureProvider.swift | 26 +- .../ConfidenceContextTests.swift | 34 +- Tests/ConfidenceTests/ConfidenceTest.swift | 189 ++++++++-- .../Helpers/DebugLoggerFake.swift | 8 + Tests/ConfidenceTests/TaskManagerTests.swift | 92 +++++ api/Confidence_public_api.json | 54 ++- 18 files changed, 926 insertions(+), 287 deletions(-) create mode 100644 ConfidenceDemoApp/ConfidenceDemoApp/Assets.xcassets/AppIcon.appiconset/ConfidenceLogo.png create mode 100644 ConfidenceDemoApp/ConfidenceDemoApp/LoginView.swift create mode 100644 Sources/Confidence/TaskManager.swift create mode 100644 Tests/ConfidenceTests/TaskManagerTests.swift diff --git a/.swiftlint.yml b/.swiftlint.yml index 9f4087bd..1194ed81 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -4,7 +4,7 @@ excluded: - ${PWD}/DerivedData - ${PWD}/.build - ${PWD}/Tools/*/.build - - ${PWD}/Sources/ConfidenceProvider/FlagResolver/ + - ${PWD}/ConfidenceDemoApp disabled_rules: - discarded_notification_center_observer @@ -19,8 +19,8 @@ analyzer_rules: - unused_import opt_in_rules: - - array_init - attributes + - array_init - closure_end_indentation - closure_spacing - collection_alignment diff --git a/ConfidenceDemoApp/ConfidenceDemoApp.xcodeproj/project.pbxproj b/ConfidenceDemoApp/ConfidenceDemoApp.xcodeproj/project.pbxproj index cb0c65d5..c9d98176 100644 --- a/ConfidenceDemoApp/ConfidenceDemoApp.xcodeproj/project.pbxproj +++ b/ConfidenceDemoApp/ConfidenceDemoApp.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 733219BF2BE3C11100747AC2 /* ConfidenceOpenFeature in Frameworks */ = {isa = PBXBuildFile; productRef = 733219BE2BE3C11100747AC2 /* ConfidenceOpenFeature */; }; + 735EADF52CF9B64E007BC42C /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 735EADF42CF9B64E007BC42C /* LoginView.swift */; }; C770C99A2A739FBC00C2AC8C /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C770C9962A739FBC00C2AC8C /* Preview Assets.xcassets */; }; C770C99B2A739FBC00C2AC8C /* ConfidenceDemoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = C770C9972A739FBC00C2AC8C /* ConfidenceDemoApp.swift */; }; C770C99C2A739FBC00C2AC8C /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C770C9982A739FBC00C2AC8C /* ContentView.swift */; }; @@ -35,6 +36,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 735EADF42CF9B64E007BC42C /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; C770C9682A739FA000C2AC8C /* ConfidenceDemoApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ConfidenceDemoApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; C770C9782A739FA100C2AC8C /* ConfidenceDemoAppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ConfidenceDemoAppTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; C770C9822A739FA100C2AC8C /* ConfidenceDemoAppUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ConfidenceDemoAppUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -103,6 +105,7 @@ C770C9AA2A73A06000C2AC8C /* Info.plist */, C770C9992A739FBC00C2AC8C /* Assets.xcassets */, C770C9972A739FBC00C2AC8C /* ConfidenceDemoApp.swift */, + 735EADF42CF9B64E007BC42C /* LoginView.swift */, C770C9982A739FBC00C2AC8C /* ContentView.swift */, C770C9952A739FBC00C2AC8C /* Preview Content */, ); @@ -243,6 +246,8 @@ Base, ); mainGroup = C770C95F2A739FA000C2AC8C; + packageReferences = ( + ); productRefGroup = C770C9692A739FA000C2AC8C /* Products */; projectDirPath = ""; projectRoot = ""; @@ -285,6 +290,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 735EADF52CF9B64E007BC42C /* LoginView.swift in Sources */, C770C99C2A739FBC00C2AC8C /* ContentView.swift in Sources */, C770C99B2A739FBC00C2AC8C /* ConfidenceDemoApp.swift in Sources */, ); @@ -454,6 +460,7 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -485,6 +492,7 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/ConfidenceDemoApp/ConfidenceDemoApp/Assets.xcassets/AppIcon.appiconset/ConfidenceLogo.png b/ConfidenceDemoApp/ConfidenceDemoApp/Assets.xcassets/AppIcon.appiconset/ConfidenceLogo.png new file mode 100644 index 0000000000000000000000000000000000000000..163ed5ab09c3eaf009bab34821d517f0037f88c3 GIT binary patch literal 58639 zcmeFZXH*nf*9KZNpdca$3WyRUszgB~)2*n05(Jc-RDys636g0VM+_iIa#j&UqC|;} zG76G2BB4=oMsjGn?kVOSaNh6xe&4n39bGJn>Qi;n4$pq}KK=B%x)SXH)&md((W)q4 z(t;oga7h7C?E}BCYyuA<=%A{#qT+QGMMci*P7apV_bebt`Dvu?em(84Oi6}XCn=~d zVXjW7(T6c$u2Kp4Ept7&s*L)B;m*bD?_UQV$T7Z@_bQW9xU3A7|1D1-@b-KlC8dS2 z;I~N0lYYWm^=o5WW6fAtdZ|Tvyt&_NKjbNEdZy`u1X*3=$k*Hc+xm>*;rglSDf?W= zC|hw7rkv4EuU>s36MQ&JXy}`UG>dglL>Ry`aPH-6r^0C<%qvcH#(K(9diVe|$P-$> z2ytFL>7XF5@$OICligt#_Mgnr`g!oEz1GijN2@v=7@2Dxt)GR=T@_5D$o#I*jp%Rq zAAGC$xDCbs^tvGK8{O^X{{D=MPsSDRk=qR=xV%x2os~@t!ds`&S}$%KQ`9?p4SRC` zlhO7Qq()er4K5!UJGE~-FW{!UHWS0^pkrgR_g6bbd37GISMmsPG2FI;t{Vx?l<3~J zOylA)u?YE2HDVMaUj2uAf*gO8WOXgo2H7q8;};Wzzdohn$UCjdy#KVF8L3t5KD)@F z+wxB`FD+JoBa6S2pK*%&iomRg&-4s!=-RDY@~utoG{I^V|j!`0gfOx z*{IWFmk$m#c*Nd6`OfH82qn=Mz9py^qWtM8N9W zw)PC8FoHjLCo;kPiMBBtW)qXF#d};1C&l~+?u2{pgl_mjk5GtR@d}YHWIgYI*+J>N zGh47cy3Y5GR46rv*^!6h`4bPL{>0pd&i=u2sqizcBS$|C-D6#}UgG`pb7xs%wo)cG1En{U=14rQEj-uCgmQI$qJ8ZX-^ z?(IA0*310q{Jjsn#T2rBi64XSjOX!XvFGKz)+6usXVdxl^61(J>}T3$rjB`y^PR`4 zEUV=8E%-*&CDe`8lW+QXygKGl-&B`)Ch;_*mHF6vsfECkw}0`VYBdF|_^p_s2m(jf z^sK83>_&O3*n60XMCP~FR#jC+sJj1BiERBdTNT40pW%Do7CK(-tL{}qJrXqi{G*Tc%G1}n29YbFy=TPi#cTZkTQiT!Y|T{?bLq5 z>Ic^;efMo$kiUzn{b=n-W`ydgX8YhNQ|;VJKIpGOWJyNF5XFaV{e6p)fr0(R1l+z(=Mf;O!Q~=t5TNtQt5s%M%9E@zp!ju5o5?{l8x}_K&nHCB zfBDSy-dvFF#VzOex3i99{%Q06%6dYs*{q)ADjEOJg9(phtB;=WuN_8tvnnu~21F0v zbZ7Hr!26@Cy%usQSU-lEX)N$8P%%;+`(yGCu8XH{@&Dlbaisqm+aD#DxWZHl`O0c4 zV=gsvHFF7b+jGtFvU3+7<~Z!7!V|9WHEBfc8P6{jk5sPqeeLY+n(Y=^`zPh~pWnOq zfNy`;i?0k$CrcwL;=TXyjwMDApT1IgpX#o~b^2nxYW;!wLsz*+x!ycneERNkQJSar z@LT;(%1$vGp_L;m!Yf5_adEM6j|~dr^z5n(V!taC71+>qD!jGL?8|WO((XJu)zIaZ z!J&zpp<`nRu}z)p_E@ zB;~T!CfVj?&l0}N9ampue(g!f)PL!rycA%W%cN)5};=R5BE z#o5c>`-(K~qHmiWblw+z(oE2N#w_8s%LKh^maDsrz6`Gndb)6GdBSG8Y=Up1xi!00 zKC7?!&lnw5cJarh{-wzti5vI-XrpSTYJ2jA+eXQV{g=8!YEp8Na;~03?w~|v$fn>n zX8Ydu&CQj~wS$i2vJdY*oZJ^rtA5NSr1RLMgqCQ&`v@tlgjQ2gwuYrfKcJ7+@SxZ8 zpy&S2Q?DEkQfF~JF8+Kws353^mE-K3*sYlLm?vu8aVI1^zO-B1*OSf?>sHYT$#`k~ zB;%7yymjUzzqoCId%?wmO3SszjIn!5E=xsYO=I#*d`!bamWG{r{l%$z`9k7CJ%&l| zy{#!zi)b+mb{e?T@&+S;Y+CL9o9?9<=O%teb zt(d;uW7#8OKW8siVo)ONMzeNf?fIPKH<_7Jt75DAbpDU=wQ?bRA#_RhNghdC^6;Y5 zLjCIZ*)Hj3&xE`E!S_QdnkqI1`zrc;^b|4_41K=)sQGMEMOM*mH*WvoE4!WKbWd78 zK|X<*q)rlpACl%jB=t-8-yze(IAZQ&Y*QYXd7F6*ZF3f+1f|5KB$@RL>3^)fNOkho z$;^xP3qkHE6Ny~@PI(vgiLat7dEfFD^B(_r@FRcW%dd&`)?cqI6mJ68h;eMse9ORYC3tS7+wFmE0%Y7x+2%XWyR!KjRrEs83UO zQO`4wGwAXcYp}@QYW{;pWuMB=)BtlWr;CftBZE(Ij6G^I%Je(KoGR}8g zwK>+?{eaH_Y(4JAMVs4GbDb?jWnW&rdVJAkD8x7L)0JNje_p84@V!jhnxyWaYix}> z8QUh-VL#R$t3l&`z&Gwu3qzw#lAVm#$C>@kQqILqm&ekY?2@b$9NH>^H$E<9GF@jj z7g?`t&2V)65&!5Z&2i>qLUH(sHoA}K&Lh@GzCN=TTs5e$psu=9k?K16?alEhUm+%@ zWRd%4P77^RsOHTOL+sj?Pixvb{AzPNA@v%n!S*% z-LLPG(`i|H(Z7V@r@w|%_y-Suns1=0#gdJDG*;&pzQzIS{5D8F zVpg6{zS~D`c>VR*=_WUIRjKv8`axD!$nxN!BIFr;6m? z_o$4iC+Z=-oaCF=m0dLVYU^{rHTG!MzAr^=r88a|>ydr}>A8zJCLfsYXU}>PSK=kL z>3(|1yPbQK;+`~~?{dps$ljUzub2G&o=XfeDHrpNO0l78Uv59&@p_{68K;KKbjhoq7G$ogDHqnd{c> z7WecnS=)i7218Rgdsgb4+)h9LXU~DYb{_iu?ted_ z_rE^z``!P3Lc_@#EW|BjXbNZl)5Gug{?lGg1lj!GgyAnq+qnxwP2qr?$gbEb9Ej*+ z#z7DUQn_^DhCA8JfPUVI9v=?U!$-0oZ!)-rgc)u1P10ucfri3(C)T8#?$beP%WltPN(%47tsX@q@@v6jbz_e_eQ< z6vdK=;kEnJv{ys@6`n$~Z&|SCp(f^n`)$&y^I^EBw#rM|Z-$DPo z(*IG7f5qcpMf}TN{A)e_R*4M^etX`*YJa=hs<}uwZ{S{M750J%zk8Q8oA6Vp8wD7qo69JVIgas zyDfrirb$f~4B1#3kA6hMtd3?9)VppvuJ2$l9;k`(5_X*V_TFiEqRnZfE^Jd-#K43@ z!TZA;hfS`*RCZcU{d7UIOeQJ8$HP4NIfVN~ooa70m6KLw24R6{Q(uqDx2ws^eNTuO zGj+;E$uhgX;(YsovV6;?xO|(gclowGIg{rSu%wNZi)E$NbJK+B#xenq{t^d5;RTca z2Yq%$b5|=PHw9c1knvEB%uBeoM+q6$Pv_!IOng?l7H0}ts)Wum8#!K}r62L3c3CZ& z^E6D`#-#A0P>}p-<=zQAhNRfcZAdEE&L(VTJ9Vf@b_I^nVb^O`Ho~t7pJq03za>3i zu{3pubR*Af3Oh;~t~-efJ^)tn`M^NATeQ5_Y6SWIL!E(BM%U3Ro!X;sqD{{9l{h5N z{c67KLJ&?3$t}+*n|im{hu`iSjWV=9-DBjeMyq4uIr*k&PG+s&`I~|je9;~e(0iKb=CnMs=V8upE+%> z^hEPsi|W)QbEx=rr7}j{;d?NftWBtc4&zy+{Tc(2NuMZqJMuK-S09OZzcepwXU4C8 zwn-_$1|O)grb>8;@sFW45jLs#z!SPgTx#j5ww%h|*8b>U>9w|+HYcNahJ(M=nv5Hg zF_vRcN>ObIly{l9zOkH+Hm=&9b%@UOnYCT{wDB`gBuCML@kreXgX3hV0p_~5eyX(J zN}a9PXhW^ZLeM1l()p^bskeSap>#DptgTeaDh8%5Y2AY?PQmv}F~9wla&NPvO85DLZ5tCA_x4;L_&mou_iLhQshHs%ok&*fdd#24&n> zg15eAkhKd3sd-q4=^5+|II_9GcqhKI4lkWm7u4{NBf@l~VSgH-y#o@u~3iJ!+X zT*a^25YJ3358Qw!mW?7^RO;B_y@#ieF!yA9WZtxr-(!poia8x^a^F~u(Stg#KFMYfiGbdg+lnq&; zqNt&?x$htD-^s>1somkJ&cky%5#(_tA)lvBHdCKer%*5mwb=AdQWK{-6JYdq2YP!Y z6iY*-o$}%=s5z?47CKni7~OKwL;tS8(miLg#2(|yQ1vqBAIamYH#ZB=TLs^b$M^*R zOGP?B+#LJ!4QY1#`{*zS?4F2W z^echA@Eoz=JFJw>wAFxp+q)wZ)BACC}&mElanFTWA#dRYy2-h+O2f)~5>I)2_~K2s5n z7BMwBbVBMn2hulN(cQLJ!2Ecp5CK+DBnvfRg;JicI4kYne$97(@ ze)>o1PWg3`XK*-qW%sjj(oW}4ki``axOAwEYLG@W>g`GfbacrbJW*C*UtUtvPvljX zYIhvd*0nfX-D8Ta>^6>N=v*mMl~FxoO>u?K?DG3CO&NOJnsDpEr4}JlzGL@(^!i87 zHW35Tpqfv3bt=usPiaH@g2=wfH@*W zsOV#4aYp5h$(|zyd_CtR3Es}~9%&TdJ_{bG*Lnly09B`l)2+In(y?pFKMK!-VEhti zAj;C^x=ts4u59|uRc8N(DvF+K3Tve;U6*N`-zS?{>R#brU!~_1HNmOcst$?uoab1t z-aplulKPfrrN`YOA+g#W;sz}0xhd=0ZSjPrcRxg7LXZ%g;! zDzVD?UW@m<5x)76cX80w53Ap8ryp$_rGMN23R{fvk8Gk!6EZAT6`}t=d*d1#&XMQh(A6LtDNUX$t1aa<;IvDqSVqgy8rp;Xuxs%x7w)unC=fHsV4<| z_@U79qb#N8<{0k#q6Qe19pIH7Q`2-Z9@o+_O%P2E~Bz>*y)SMoCty^WP<%}IX9=CT!M2e7V8b%B)0n6?K#L$HAj@8p~VJ~ z+>Z^al**z7$K=nc9xmk*^+i@B8h7O2jg(mOXp_R@4i)d4Ut}K9hV_9-;&iOQuzu6O zF&&Vh3B~Hhe_s94Hqk8YJf~`U)gU}AO+i+n$(Ijf_6?;r5PB$B&^&HDi>-Zzqd2&q zktGDkihF-zBxbv1e&I*W=&L~Ccg$@~vxVniA89IjsW1#{pS~TwG_m^w3Q{p`4=rRV zb$QjEpA6xya3-?p3-Gw=<@iuQtjAg?#!N7f*<4d%80Kgl*7xxtws$4O2A`_)-G0M*BK#}TBGk{3D)bTu3a}#5dRs$Eo%B0CT0Fwi)dq=>74$rmmssaFEl7; zVNOVNd_-M&s0XFiVl4bh9G!EFtokvCBKaBW`g_lj;-w4K2RI;)X+KZfG?VED~ejeR0*YEuCd2uP&jc04$m zYCZR zb&$q>_=rnouV*BUZPp0}^qR?4?!S`n7l42rVtU4N`0wZcO6q6=Jqa@C{DnNaH^_en z{_l$aD=K^X05tDk<^2DtRf%Cj*phTpuM2*=$Y{^xdJ2g;3|5$oYm8B;h66jz6B$*= zuxmRl8G&9jIo7hTpsCpu~Z1+Hq%lA1hO~FQ6TVFw#yV%CTr{RT#wGG5ZFq(lf?7um$4%9KSJgb%@PiBZ4 zO4G046lDU9-sj1l-fe`tMP*J=J=PRIeGaq<&>VB>H3n_wqMk?Wd8!z9Tox($G(=4S zXe@`HOWfoXe85wigR4za5#uYs#%6Jq(wwd?vszoOJMuuNn|rA}}9U^S;(SG^pNLNx6Nv5UL2Pw_%2 zg|KJaLOd2~*HgSQBDhdB^z;zR73l`vy@^RPE6>A&m0r^x9qY_c%o3O5MU(?0J>-7; zF~{D<(z8TjSmE*qv+o{X6Xm1>@Ol`uVM(qO|7}~dF`io;>x5!E!=^iFC_ujT{fv4~ z4WikYQ2%h%y$QbHb6gnuIJp5&Zi5~R86pO&r~wSlzBGyes=tI5v!5dFFl1O_pPlh_ z7L)|bTo(-&y$jar0yDmGug@4Z+#PLg=)A-C8{N64rc6QxH-Na*_G(fm1OvI7yz3$I z_Zz{h(|H!gIzwS&Kw$+zQd=_x(O3BUg_gO&68_sli=diVq4=~kGO+J0M7ZimmbYpl=>8IO@}GNSortQA zci`nG^F!#f9uqG$D8=wHj|4drSV+eo4Klj}#f8zRN)QWpp*-jD=6s-;lY{U~!C_8O z1u({6y|UtaEe8*rkoPRS*H@f9CXx%leUK@TH=+e6n)2phVbbE4;|>vk*Xs5@M%J47GcuMMj4)>C(+iiqwJ|;d*iEzEGFRK)5(iVY zBnZQNO(ibi6ab8WYDHV^H7q!l=e0JM?=)IpMrEdc=RmdKZP|0U4j_k8w1N)5)khAE zph~w1nYmR4^H8YUaWY3I@p(>BW(2mroH?;Koqar^FJZuu3Q<#-J8$91j#gzoFaQv< z<1_dDy;gi0oPm%f1=xd;$iyRShGw_$1^jM%pHnqPR=>YfJLL_t$v%h}ZHNZcFm|9{ z6lIokwc3NKq8u=~^K-)`zde!g3WI{;&)c2Jtz0$OzT{hcn6s4)Y{*>1qli6J8gmyX zxLRV-5XDNNd}W!a;d1|~Hytoxo_cTd_u#deFlAhTFA6#?X`jGOCK_$+D2$>|2SJ9T z6ZibCodl7}5sE_`3P$JeeY+0GH)(koKB6!tL_yUU24;5Rf-LQxBvjFuH^bf8h7D6) znUv6`$+tzxFtB)x`u7<23?eWITdT7Iz&Z&}cV`PMjklES!>m11ezp$@a27=TX7L4VxT22dA-ZeC4qNpa6AJ}Fg+wQCkZYbP?%3|IE=6(oxa;>K37ld~Gb*p0=}otyv&4e`gLp7b+JR8@bP+t~UAEU@JRM?pkq->ZCVPn^_g?)86oFSotiVKLsE zD3OSQ5`~4Ia+@9So8R3`a51lD){&+*`o5N30Pz30`=vk@#Y;9+0~jlQ6cbqjqb2{| z+*>(tS&PXVq+?_YLl5bqfEny;EZsK6*6XQno;EvxK{wpY`E6t4Fsz&U#CgDMWZ!z^Y1glpak1l-V2z8eQ-~siA+j z^26{`#IcirNJoGi$4iB9(Gqz2(l2ES+zYTw2@!S2_qwN#IlW^H+pmd4ehy@gM$$m% zaJbm{Uvc2gz+buKs-`~q?ah<`9q6`i!QX9jBfBzndjhbly?`1!2!!S9DgF$e-Te~P z!ATNiwm{BisuiX^e!f#a01heAOGv1;2Cq$Eik93{LYBAHo!M}{-S@%_gi&8%@|)at zo5tgOIn2GdfLY)-k!0tSeqNL04gyCx7eMPIu6(dV0O6` zIQaI)auD|#Ait(~$>^JzI$eekyR_4#d06zBiLe(BP>I){B988<#A~LzI_qeeuA!Ds zSPyeqmx<6p_{ zsGFmK(V*(y2D+oHC+5Gse=WCI$G!3%;I;5Bm=X$DGY%d8iuj+I#TTA?BPkHh19`JW zz)(f6O!xFSzgZ((@v9yI>!)fGs{(rX&q#36jCI+h`~|}H8o{*Ke!!qb01~w#s$Vio z1?GW!)J62~mVj7hkn&!4G+u!@NIy8Hvf=RZezNNSP0V}>xZxp=Ov?3GJ zQIq@`7F&bclsJU+X*fH`0K21_gLty2#E3n~h>^f8lkk9F9Gy&076uRnvF2`lc>RtC zW+-Q5LnND;}2MsxCw-2shMTD4>&dYHh&HR2ty_L>T>USoYBW z3KWAd;(wwAw$U(gX|!}aDbrdOfQhy(LhS8udXyEAJI5>D3VX7VVu}K|<;>ckmxISh zm{L7L&gHY1F`k&vRUZwgUI1PuKzGdPw+C)=R>Jy3k&5j5&*isYu$d8w6nxrd zKg{_I-|ek8Jxft_oP=%ta0uGuIIK@4N`~aIwvh~shJHQuo*bYM2ywcJ)?%4rf|i=Cnpd+lwot)f5-tOROxpa z8XxRGwzsE2H_iHr?SN_`8Eu(mbRgMzZt^d2qWt_AGaV$FjO_Wi*V^S8=ksll8IF*h z`|!Fk8I7OmE6L5--=w>RfQ2TsViyd7Mf2ieWjSa=OS8UI zEH`EjY4~BHDTAO1r%7cb3G|mk$8{W0t^uLVHn3?Xr|IMtFdKMy4em~MK90#GCG(|~ zFiylG(DJ~I2fGj;KmfS6dk9WOZ?=W(n22VX=`5r`?hk1mVuo#GpjSP>#QOKP#@#FFx!Dk_Q&|ts)Hfz?-J7pW!h#D<|({ zUQ7xasNVMJYtp;ei{&lGb3@X;U(X)Dy}GziLl-T$9{u<^kWq0;RkE0j9};_OMa@`O zu@e?qCHoW;hxVgS0ol!wmhxPl_+#{v6UdO*k#R$ohTneeX;Gtpb#Bj=NKrZr>LxC; zZ6;$dX()i%9GRLYLV^D(1M$=g6aRLNzt>7s8Rwy0Ita==W;ftpAy}ii^7*QKutls8 z3(pf(Movd9U=Cg|c#!W6Y>wLe30xm3VA}#C?tBmkdcNb!hON2Ch$Dw5g8f)u$y%J` z-4iYKGog{&1=Xfwn`lIWw+OH=+hXz~DCC5+Q+taScykQ^zAW~=4PZ0>daRn_fO#AK z;5L41uDpTGtzQ#ye$HXle#lbI^O+s4cI)^h`?pvDy7jO!Il6`<30y0Cg2D2<7h%h! z6gaBsJgfl8Ej5(qA*L3QMt3rmJ;n(~#RG+QZAT00Ymqt#L^U6uU!R$y6J^~{z*JVB zMnE9L3&}lsX9>Zul41$TM@gV4r&(qOO+$LqLzpFf(dKKuYNmy3U}yLX`@ctpyFiut z3-2~RxQJladEd42QFt2<>ox;_%SvD82_XsCgm+*^($9?g>@C;WbxwKM!`uQsxTP@+ zk4Bs3IFm~3`p!Q-eIK~CP2jUyqr)TUcDpg5P8~-p5~C5PxLT0hbrW`*`KC#Z+DJqe zD_sD%@v_^4yE~3Ci=u2_N|uHZnWcq+w5KChYn3$Kxl#FlKT(4R+`%Fpyg=vr1Fvi1 z@#Z{9oDIM-2)A~I6Iw7C^$y;(wAECU?U~5|FphkGeV|B{-s5d}gdcr6w`$X702qtI zw`GsuZ~*BaEF7|k|NE&FLzLPuc4exos0Xe^lkO)1=o~cgI&9%kYEoSUU< z^WYB0WgS2ksDTt`VWU9So#!2I;ZG$pn^E2ihH}LGf0WEl7s~M z2+}Jd_0b!rd#ISfij=lCU z;ujHCw(gC{{I2j9@BwBpY+Ow~m`&c`yu+Z8?`B7G6rwv8O>q*u+^9orGUguvxOM{s z;dkKpzTcSHON%UI-S0JD$?HsN0q&ygJGMZ$IyULcI2fkmJFqnW z)H-#|p@|#0aFV=NJuq!Mz=+g<-4Jmdg_l~;__>~S^kroS;Q0SLV8OjSkg7>IH-lL9 zB=5zqi!YLisjEa0y}Xp>HMCR_6J{ytrOa0#y8nZ!T~_8?+APR6sVqCRvSaBK>Ev$! z?nu{R38?UU_E8-j5V>9gyRRZBVZ9qIP6S~(mhf1cDtL2cx-%^9CQve9uXG|2^HBDe! znv7t<)o4SaJn$4nZOJAuQZ$nQG|mFJ+0UV{_R$pu6vY@|8qIP(C+_CB&A31=q9qg- zT#oi2?ZCSs-2Qq%Mu~=Pgxy6^1n>i~t`Lj9vn$pe^vW&Ru_b`P%Cqzf(gwfJV@WNr zeiA77bOPgz26m;yTm~};upWYBD4;SUnRa0A{<1)#4{*ywBurl2gb~k-lnnNQ`oYT2 zroi9Id%Db6M?i>qe+G0T8!V8!@!`>PXq$=XnIvkVb_MM>vJvQW3 zcE=Fx0MFvPevqt}lYzq=tqbFJy;^-NvF~2=Pq60%MpTUn9h4CYr9le9dj_9o8Sz07smhOOI9B4fG$Bi@gGeOv66Jwgiu ze=q1p@NwJW0=ipd=PR5@8{<9r`1{1B#Gv z|L9urj02OKQW9|h_jg{5K2Y7f54 zmO$i)g+)3RG#p@U4f&g1TT8HY0LkH7-b)fk{Jbs7(jynaXB1ZtLkmKjqE~Q6F(W7v zP=##fW#Q{z^jOr@Qif6bt@nVb97PI~Yv$rTnw=A8_>^O>bUiyNU^5Wuc@L<#bK;Tl z==Du7U0&03W4=gk8j*h6{OC| z+*JCuB`RsB;LPYjuf=Ow95*0HXmAuX9TQ&@wscK>iUdJX_?Yd93pJnuE2(F>m`_dr z`Ycj%Jsr1$!dt_4={6nBwa-oG(a(Sk@Gox@O9ItOR<^m1Fk)hEm+c$XZsk6AWfwjieW_q z5zk%|)yVIlXI+buWiW3L>e{IyR${$%9`%~3?%WHCDj*Iz9QyqZUGHwX9;v>mYkc!K zQD1|f+Z-_@oC|Y6HFr`^mVm`#Eqy|&-mh@oON;?pN2Z~82wW4$OWw^&8dYQ;Vrp)vCu%$z09kLimajcIo|cWc(GM9QN4CvCd;%`T@V zi5?ezoEE6&PoOy`KRHRJRvCJ$rI$f;roKDtR(K)rbJMNwJ@p{6w?2Z=J@8g>(%nnZ zXRvc3Ow6Y?etA6?;2c5YvclC>D+86W4J*5+f{W39?*8P`SJIj)xAoZSQ&T}a4uBo7 z{3)S4?_q!jzBk@)tPBK^!<5tHo9ONz&jGtSkM=vkz`1Y;mlpOSKy&I*CtYtl{ZxH- zD(}aJ)L%6mB*)U9&jf(S*21t|RdSxL;yL|%9kDNALdT|(drB)U>_?+-k*Fb%@&f%m z0R36kc7arN6e!(G++ufyYP^sv*?0TW~K0R zNI8_ty8g(c)L((7#M`ux`K@AAx;$F#6lxRRwGCK^`|_es^BqE!&uCGPNhV=h&^LbG zt-GY|!H-AG-PLQn(MDQgXE+Hfa&a*CbUbnAduw(nSF=J?Y2DXH+MexBWD%OsFLFzZYn^i%m<2#=ye&C@O~ab`_(c`%w6kxTHwV|MbdTF(N#j_19!xae zyda@CtprVYPVu-KusOQ3Eq9|3njXUV?_^N*E77m7h%pF%#Cb30 z--9`I79Sc=r4wJdGi}Ci%T%6>)!zC@=bN{hFTs4%xcMQFK}SoJ^*M{fg^$@i-vTpA zWia*7vT8e)&1X4TJu$LyG|UxhzZ35Y*_Y22JIxk>b0m`!Uu8Z1jPPPzh%HGTx{1=! z?|d-)<+-Z~mT9G7fn<5+npl|O(D|jUm1VblJ>F*!j{ShD&_}JonOjxnPpdD{Am?#h zSw;XuWkgy6fK7tR5dcb!5DI!K0FolzQS@#6uGUQQqc>DGa^V$x_h84lS0HZa z+%ReDyjH6C5#g=LncyF2;^W0^2cOJT_J(t~ds8`XM|`Z@IsLZGDg!aPl$InRD_@rYWq3jfea3CT zRToJcX76vm9`{GKH5H#(n8^@+s~n%HSOKqxz#ohNkSQv0v-IUZso|r3N1MscqY)OG zJo)b0N{hRqLIL(&H2&C^WQjBRSFv+9!ZZb_QK@Ta#jS!o)3INYhTAipuwC!qY=6_1 zJQ7S+&wxm@F*x%zgA_N;=)~wNbj|5`JWTRD9JvC4FMHDHoP-cA#m1rjSmK`m?68z$ zSPhiYJrzsTC0?};VG&T`hTj?}J7wUh+Mz9DZRq)svC$oXZ<{*{;ESS8nXZ3gu1C(e zh=peyqZPH@m@@R#ID%=0(bgaM=@XUe#nF;WOX3$TM(ZP8PhkU=tbDP~f%R_U?Gso@ zHaq~|)&10fcysG+zBD9H4T|AwY&tR*b4gwJcOkjnqXN9(*b9B#=jG*WRJ=+2nhPHX z7sl*7r49MrDF>H#TLtH5=r|O_Bq%nOdqD`Z@ma)IbFOl2unf9O%8CEdeJ{p6=@|mS_He%FbWuSY<7tlTz>xI9kHVAO+v5wO>77@f8PUN!ol9;jsAl* zEmaiu?GqZgzMgAKb+>(})h$TAIo|EcjRcdyi3GX@g5^vQA#?4EjHU8=V&%YW#iT?Op^-m)}2;2Fzf(Nm3!t7De9g` zeTaYzUp7bV_yG}ffT<%e!l512EuUS4f9Crv#S^kF`1POU6tJ_pRP8aIsO#0A->NnD zF|qoBaO2`eY|94m7xO^V{8IV7T9`OpIp2?7@)0+coOeiyCyYwRFX1ck1;3;RGdD^- zd`s5A*LRPJd)WI%kKdc`!ux(hrwfLD(PX{4QEta>t@_4FVod=?oLc^MgW{m&uTkus zX$e!o)FggF0iRTFmPM#0RH}$Up>&O1Z_*x0<=;ri@SL8>@Dz5{ohloXl@;HrosZvG zYU~k5zvyY2Urd;-^o8SxJ0GfUi!Y7G=Ef5wTXt^WXd!qD)6QGARg5iJdrJ}`y<1ob zW3tf%iOOFf5cs^_{?>tY(`8B?8;w~AonPY7=!kpjV*wPz}hwOX_ ztNjmxVw2INHM`ootJ3humZZhx#sD~*7>;if42ar?Wm0MQX`PwQwApZCiUJjPvu&v= z?d;l|Qeq>aIjg+g(GC?0PJJx7Y)5(u*d~q$qcVo$Jl}ZOJNf1($FCL1YZz>Ij7V5o z=hX^Dd88^SRIXVEN#-t*&aDlknWvR~FaNjxT++w^^FyY*KU+^t( z6alcu$SE!J$mmXnx2RI(hHj(AvD?xekl1C;8asgWKgqC~dD{7H+QYyO_MogUy=ja3 z;u~N(9>)G{`1n>6VbLhvA*aR7dmwV(T~;Es-uV@sD`Ia-dRLF*&Q*gmf1I3#CzjZ3 zP=I+IUsD^FSKMSg5+iLhB&oF3l$j>yTQJ^}8?fjjr$7LQ7yPQ}f!_|eUcdOw7H3pl zsyFR=xZwoO>nXxTXIVH^Zm*j0&=YCnQjwX5x5a-r=9MIv( zJD6btfI~%iUmY4*m)#`7!HZ@jPN&l^e!GPLj3LmN97feC{IfBz1!gRxg3&%!{5R&e_+l&QFyXKTt z>0ZSJ(jN|_+ zTC!y&nglXXpa?hN?VQc(xO87^r||}{(K9QfAa0yE2*PEM5j(0pyFD#X+s5A0hHMB6 zZtSAFWq{S(wHj~XlCl0K=Vl)w<;pK~i^XO_AcV80YRuvee-(>P5007kd?k%t>VH5g z_8Dl6H~=2-U zUXcx-V4rS%tH^>D1+UHV;g$}f_eATmW0Hjgz^#iJQ5kbjXf% z{;M@BERIb+xxL}j(W*fXBS*DC*z1b0C|7-}h>H!j+~|3Pmzw_UtPtJ8qZ~>z%V?Z8 zo=_?^2nxgG-B>q}A}w&JY8r4Hks#sJy*BFR!E-8%xxb0KwjpYX7p#VGBcooO zmpW7b`bzrtH9rh9<)Yg_r;2r|B<(WMxFxGR&QqvGO%d&?J@u|ZxIF7T5gceW^+&0R ziqE%5tLwOp8mTDxM`t8!wWJ2C40Lu%BbB`9^tr2MoUTTL@nr(8Cc$l;h8wCcNFS`Q zs}i;K^KnYd4VU8(5wIuYhQ1i1ngOB2Phn{yD9QY1Z}h(V!k7?3dnVi#;#+9KgPhnJ zjkC=;4N^iD+ojesuFqMq*I3-qMM!7KH3}jeOuV}N2Pv^w0+-Q!^?v38e?3G;d zZkkT3x;oHS(L5j5lTo78GcpcN18*{(Kn}V$HGt!Dn#h@b^oydQH`N7DW;}(Psa$G#Rm>nsB=p(|A|24`lGr=n#b2BDQ!J# z;{r%XgyIDb-YQzg)3mxJ-IDWiI-8hOWL*BBYY3CnQ4^(P0@h%BoZsgq*fB+A)GXGOs`>-^x9-%3#G#Q=Aw{)UIe60MVr+|dnvom$zJvfepSQ$r zhi#qG2UD&nvQo3=&kqzjPKYF1`!-LP*m)589yHF;6*-Wy**8;9)2_xoUmY7ClV*&&&K{rtRwl4EYS<~g^~OzG-;Yk*?EWzwAo&Jz=Wq;Kt%*f_3Q zDdF4{N|aC2OyD#I(E0;N@6|sZdqD|#5?lP@_S>O}fH2Lh{z}SG0H;!; zkVMVS`ER=g><~G}TX~{dV&r(x)vE(tC#H=xq;BCGZ#?e2>5)1JzS$z^#J9X#iXKR| zq_PO9-UHCTirY@)tYG#g&FOd-luVqIm?`xyfo>N zyOq9l*3~aor*6h&L^-&{|{a78P(Jpb&Vz= zGy$b}R8%BNmnv0yJSZSlMMaRNQlu+YYKR^?C3FG=P?{)3iHLMO9F-cXK#-!+yHcg* zu8r^ajrZO$?mq{@PWDq)opUZQ&v?5SaO$dQ$tOoY!vrbwRp#tZ1!s;D=~LeybE4i zSZG#gjmu37d}$(3&+Ol9#u5=UIAmp~@_ErHtX7?Qp)|H!Tqr8J=GoFMr@_#vSK;Rj z1QG_fIe&TTN@Ux(98ri;v$hp5k7@}v6|`y1FLGp2jxEg@G!WR^By~4A%pr%vJ{hme zBK)4b)#CHIQia&+_yjK)K0*Y~83Dy?q3?j*dHh%X8IR71){ovYee#@d>D9{xp7DsR zgLG-Xmm%eI`qN2AV$IUhxWc7=ti;1zzU4cXW4uxlG-4&+4@?wN!16)sPV(L9)%>>468I-w6&3n(@@1xgE$* zIF8eC;bYU6y$V*0W5&_LBP~UjmUsAT5ZjVP201f*UD7P3gPMLuPkZzGo}Moo)G*sn zTI2RRI!|v^kbgmLu?;?l&Hgmr%ztzot4>um(2Ze7HJk9CJdVbABfUB#-w&&OgiW@g zk|VbJMUU@ywkTsX*=~ESO_|qNg8o9(7ceebxw&rTrTOD^*Yy4iwtPrdZB~xhuo8E2 z59>ew4S|+F&7BkdITCByt!t!fH?kF!tGg$wS^jvft{oc&7Sv{R%7*h}7PT|DOiZy!ZLvesl+e@Lqg9g@P2OgXnWkhR-lVI< zF;CpkWWHI%!tOV8HdZ?8{JFJ*OIMKm?aMq&&H6W))_XQ+Pq8anyVg6F1!X;qw( z32$ZdUgBSpm%GNhY>_f|CMEel#dOcm708W=GSHgwd2=!AF^cF+q!fe9ipGN0A>!G;^atXd4cM>bsHm16F}|T~78g3d8d|h_*|b)EIidVjPOl%*72e{Bz}2#Z9AfEL}MJIdMb>wsO9ce{>vNCTkYnU8hOsX zK5|!W-d(0zOzhzWwbLMkVo(RoH@Cc3^34pScDx3qKg<2=h6C|=2F7)SN0(z$$HN^P zUxej*$Fv(+2z^7)FfA9VnaKY}e>5rJk!#47n#HaIuBUCNI9>t6CrNtx!wNro8w&JY zPsgO5#1B%XNbyJCvUB&YA0-{3a=t=mE`|=NwpaP8N-?}E$=1c6dZs`+( zyVaS#?PJ|0tFsAiLmcI6-edTyt22=$bZ#FhP;%LP-|l9ir))WcjY9Kp;K*edg5j_} z&gFbm&W>nxGPg%+e`^`uBvX!KJ-Y{@p3dp?HEPStUbEp%!{c&aM>y;sxl`Bu=9(z& zC=21AD_=c}%$OFc>iv&tzRY1U3JqA5W;Gs&4|A{?uSk8iMOaHDSeUQ0{!M@%4y7b_ zrF+6gDgHDYBOm+;w!W(!)&ApYK^_7tA>V3=3%P{#+)=~~TGJU! zg1_;U#<=yD-4;2=m-Cl~G}RTRpGjFIKVyJ-lQ7=tvI3}l>LPJ-D6zb$Pjr565@=t| z2z_woy&CuhX-pZ;r09xFM#>tAUnb=f;c`9gxdlJF-6h7pO7wi=8Q*=KE|&*v%6**I zHfk6f3y8NUfAgiejuL>@vXi$Ed;@2w>ngj!n0i8qcyTeKr)GN@iF0)al&;TLAC?)4 z$38|np$LT$L44&ZI7Ea6Cl*(q?XKWqn=pLb#7b=7mSeV)Z#;XPeMuLmDahZ=_`Zkm zJ{WYNimDtfzRA{AQSD+PYJXvzgGbe4KST59>Y=M^M;sdUWnS1KDF#5N`lzC)Ta{_) zdPhm~cGccb_esATs_C+}cqLr7<(*QaIO2wl+<&uLMWz3i&X%@}Z?}k<8CE`f5;A7V zSLQ{9A6ksKcI0C6SU?1#$M>B2Q(FZWwI9a>m=dYM%Y!`u7w14Vljip?eiv=a6$2Lf|44S!tb0wx+0E5a;@>e=8QVh+ zP@-Rc?M!0Fq~`s6t*yPN?$ab)8VBk{+LEOMepd@VcUjkMd!#zgDKN`)V&pM6f3>xg znzfXJb_TCCTv?HRo?HqBIxsnfH~0>`$IE0Wq-)o`tJxftP1N2V)-L4O0@u5*3DVyP z#VRKz#`DUDGvh(A(50>&tB5-<96-6&2cG2v5laa!9b}rT)sC4rIx_jJ>YB^MN>BAl zN#At(vH`4)gTHT2ZH=%KX^ZS@o)a~)5tk>z?;IoDNyegSs9rsG?vr_Na?d`lB%+9& zf7O5V_8`VR>n(fHaE!2H+_r1-u2IcR2jr(yOUKq#_V?um1<#9-=1ty>U0;79S@#8yzktr{I7V&Jq4UNH zqHa~PDy8X^Xfp6sbH9d6T5dz-NgX0A%{Vo8tOxE*#aa8??}B>}m{ebE%8=L>WPogjVwpIAj#4u${YD-TlZPaOR)-Ak= z3d!GnyZMZfj2i$a3~@s>QZIb#y4q%!GP2Jn4)zWTCSN`kMAy-PyT=LAqY~a-5mO)5-v=Mn9Y5sbSf`c&L%+oRys9KKzE+Z( zyM*H#jb<8*E=2OlzEQAwEpRC1utpzS6 z>wtgoo*VBU*BIZVc~!9hcd5&n%Kt%MXbh@io?2UzrYeZ3rcV^j#S|=RFK{>zO$m-S zB|f~sKKBZ0@YzPdhmP4&po^#YBI1Sgt!tbkqYCTEfs#EsY7D{kwug;z3@|V$1#<`W zPwm!7S*D&Ca^>E7KO8B!+>M|JeUr@IraFIPmfpYyA_(KyyCg1*&OZHN)y%!*v25E4yWr$=<*`U{e2q~>xvgi6-8Wm7*UZt_kj$X#J zy^914cI7mrx29d2K>s*{DP0` zlqw0{{AAUFZlkcsT{*+{u3_Sx1=O>XMcY$F32IWMVwe1Vtxm?sV#oz?UyXqp#BV&g zDfWmQ7LRnif`o9W*D8VA3AK-yiyf6S_tNDgGaYM|uhx74Nj^d8lmqug1PBxck1bB~ zb8;5jUSi-@cBlagXGrF)?T4-;U=3H}=*H~V>HBM$b;4@Xpe8@_IJxGioELG% zC^eFx+q7Lr_`Szrgpn9Jnb@fR%9$!ub29MGdWl)$SJSbCt8{1eP8XzeI|dX`SNV>d zqXTt!Q^uu6)$}c#OdrKD2%AxiH~r6q6%yn}wFO@G{YoCssya;6e4!GZ@#VCeTg}hmV~Tn@@dSS2qfUrn6>6rh9CLHC1g&cPF#rt5#nsg5Vw zUG64|WyH6?ZFX!Z?~`J$Urxn^-SEO*$f5r}(G({?t6hr3_!Xz)%3Y@dZ*fml;H`I) zP@Bbhbd&`ziEae=OIM@>bC>CEVuoD(H`{nM zh&Y%%*GLoM;$tNqWgkg4J|~&^udgaz;7rr@K4q4}7o2HnJ#n|y7jqP<-Cm zQ97G1e!-9Xz;ir>owP2ghq0pLt^z zrVdkX9>#GzU7K3AyD>U@U-Wq7bX8AV&mV6>br)KH1#^59G|()vueM; z-AjS;eTCcQWM>9>IXbe2#*hO-F@fin>AqavVr~PoySH3l{5XFoK#uWXP z5ZHT}Z-yq?(SK1KjWHHNeLD_Vx4$sXSL{RFbhi+&-xv@#+(Vbm2_@kqljY5VRl@xw z)(0{qlTgR-MJEDKncJn(^2IA*ViTt2eEJMV{KVC|Ja zW&NA*^Lt^+o`w?x|Eo~6{R2E#T*K53Kd2s*1_Mpept*k<% zPhNwp{AXz|yh3SdD6&Zk<11f=?HO{pM8#HabMY7h2{>Y-8B$F80eKbcmu$SKa+osO z^o5-MwPQV@ch~4$icOdWDq`q)diNt_reku8384tK=Bs@lLAR>|?Ztf{7O&gkHPW33 z)~&`yEe7K6x}}SKZoCsf)?bl4i^3hs*g`7dc@-Vs8_qWsYnR3IVK~G{IJ_2>8a%H; zSX60p`VHm23rCuQK1k)-_)9|+sA)RGJgZP=wq_#7_BESwMn$+vzMJ2N=qvJyqw zf{2Pai}B8*`B@tG=uF0bhteav!8ygII>(^T8qWB9ioVM#B0w_(WTrb5B8I1bG0 zt}JSL#+AD_J_{0p5!YE9C$X{7%a<`wOgc!^f(UQv&Bc+&cteb{u+s9{WLji2BVpGm`KrI_H-z+Z62>*7+o^85k_6sOdwi{G*<_PXD3_$hnW)dc9W&2O zkaGBM5m2Gc5go=Gf~VHhKU#BSZ8vTVBhkYlj3^sPO-dnYij!A?Re9`W~tn`-t44h=8+Q5;-ieor=+V^hUapFN>8Pje1Y;%QQ26sS9-vyu(>Ci z6h?pE(dC|bu=FZ|8>6-|De;bie7@%XqsK-=+3iK~gOc<%#X~8f(^rR%&qsCL0MBVv zBQL1}GSi|Ewj3<^2fB3`gGVN2JqbY;Dk&}0!QY=$5+Wm5d}IA=#%EiTfUkB;+^a1 zMSy)NRe?s1dtRs8&A$1CSal4Ch#g<;{g~x)Y@ifL_mnOT5!L8!M(~1Cl;rSzH@TPI zf3q)8AXYlb$M|)e6^qANU{KDd$2DV>CL&j`ypPUq`F>#DAIHTg{-o+KjGz*USUFJq z?Hxk^LiDB)pg2j+KbzSaRv`E(MDZrsqZOcr?n605w7_;mH?W54N;5vz{NCRJVRc{S zth=XV1aVUaj5gLrg0!mZNE?|$Ssah$DM7HI4*T$Fv&vVC6jNIDuBS(a&UtPl0C(x2 z1p6(5BlbnUA}WK0A)5)cr`fxB zw)0t=;}~wqz6f)|nYcy)%yZAa`XCVs!$)7?sUxsTTUKVjMr2hf57=l52_#B=cn{pn z6a=$moFMA}Tij#wW_>uCEh{s9_1p?gpIZUK1eRtg)Tyv&E38RuEy7}!Va5pY*1x5P zZ`%5_pUGZKpXYB_`mX)_X>HzI34Tzv?Biv%bp8+%lpVrO2(jN+YqLmO$FRT4+E#;( zbF!()?C*3OUfIFO;zf<4$Rdtt&NfmNBcj-pC@YI@4h*X8Q@-<2@D9`{AMXX)Mg4W( z3&`^A*{<88UD^L>rj)Qzn$kZfTY)*G8I&Eb$2Mb-;dhb`t1qTl`lSyXL7AqVU-!El zKl8u}Mn%_oa=De~rFF@g1<8+^ndA~FKgOjkX5z11S#{~;FbflJB)eTT_qaW^r8wyN zFFDE(?4i}@tef6(($(z|Np%#Xg0MQ@xP;)rON>|#OFZrrecgkZOmaN#UfnU-I*ayp z#iPTjawwrhAANZ^oP_0~?MsV@{%u1fV~q|XWNGYO*(mnwj;ftdi%_o;46`r_a-1q+ z<3xI{_l=Wi$DdGz(P?_tQ+u_8vM8oy5G zQPVYGcx`8x2LIq|Vb%IcEWS;qG3J8SIWScAuGNlevnHlQ z<1f}_hiZYN-($~$uWq(WHg36r6-B^3^3cZRPP&APn8=J&d)Y6m?&?IH8|tH5P_|OIJ+W6*VJtll@lYL zF@s`|(3Jr4i;@{+iW+O&z;aSQl2M z3Ky~8en8Dnpb-v!i7aFP?>M@7k)~ecW_7UX@E1Riq94FVn~B78y%q@l{k;XL$1ZF) z4fSNBD;6!&08znUZbnTI_#2KARXOYhytL}gK@urvVQWE8yDwF>#QWXu`c!t!=vo^N z`zg1aH%>wYA!RyMxKz@3AbU~!JM;Wfpb&`*?R~Yg z%lhg&HwjyMuj2eE1$3a9k^J9wMQ|WjlG`}AA*@gnP%WNg3^6tcL#d0A{zj(hv!*hm z|J2%ZWWBLcr?RuyJIw}0xkpUUo3w1mfvG(8NKIQph(rhf02_Q$59-kg-$zzHuzwl| zUTn#pYizZ+JORgE9=k#b@4U4SRGu^DQYq2Y)yi?r(S4R`hYPtdvm9B&$#|;-bU!Fp zJDATxEElI#GWTX{VL?Avh!_J|U~$>EyBx{>M$9rd#-ZSUhzPn5Z-j%PC7rm1LipGo ze7(jVSq5RX!5nZphMwC*;aM@xc(K13&~Q^mh(+YTHxf!Kvo#WCMpnTS_3eM5fIb2n zF$UH4o5k7)v8b_SCl#1(*7W|1 zKZ{cqNXLn5`{YVJs?Ss%A@0U5T%%AvdvBzLZ#s3o84NWVuepbb76!9b{CI%hDYbov zJ9zQy#zMAXX1DPP)u|VmPueZaf)ctS7&d`93~25o;6rS47Srh`=Yz-9n`{pA+rJjh zEx171O}zh?9FyI(kSaAI^ZxpWzBy}xe!bTmhHIis+&2^38TrGn1V=d1*b%hQEBwJ` zdc8~v+HuJZypM5CXZPxCK0z^t)lAP}iZFL!p{ZuG$)Sf4YpPY&Wi52{Rx&nFlB9Ng zW0xkt6tZcE`-tU2OH0nUM>iaR;|Oc&blg<9&6IdBx|PNtZxg2Sa;G*|sV_!cvM^-@ z15jFD8M`64ab@yn&NcTZL1qc!AAdmGN#;XSDb(`S7(yXYCug_WN2dBe|v-`!W0{UYRij11Fx1 z{esT^w9DtpSAMfrsa}l|El4-;0s*-Bx2Fnitlr3<`X)j&+G=&9G^4Hnr<%T3Zr&F8 zlt0w|234Y{&{MMICc+^Im#Ibql+ro&h&?y=vk|x1Mz)gZYW$i_TP2=lqX`S!Ed}$R z{Qf)Ebga0nR}aQXj3K}Peuw;1yuLlXg>2(rop!XGvkIcWk_RE5%q*x_w=!AKwe!bH zp?d|STSeLwi21Ee(UAn@){d|_3v|P7q6D$;nKg6==vfMfTu1BW!^;23)sTBmfMh7T zT=I}`cX_^L#G`-ncqa<*A=-{QwQej)*+!~T*QWmL2s`YEn5gNoHd&(2LHUA{)7#oV ziF@MCzg5gyd}gKndL#Zu*-<$UV!A0MRJVf{Tc2ZDHVam5S~tjZ_N3LV56li@9b~68 z)joXv`=P&Rq2kbjhF{|hD{<{f2z=$?s21l7;00@stX@_kS1 z9`hXt!zZ9ewvv_gl;@g*RHHz{`+o$Fj3U|NCW|NavzTrJj$BDB_14{JLJu=BjiZZ( zsH#{b%%uADIgeJQqVQYRt)|zCsEN`49j5=BAD9pI&#PEiyh76+pTfzVCRXZsr_kK` z$R8fQJvCKacQh?ZgYN*a{A!a7hsySt-~?-Z-~L2uSILNVR{ilvsIlwE2TC|6-+^cN z!8(2=L+)>y^8u#bNC4SV@<}cREV?pp5TMPirV9Jt2&;!rZ$|x$_!h@1M%v9FvL5NJ zyzNqZGzgtdPTlr;E`z6+`-H2 z4btFQL5tH}s1?_Ch!~#X@Sl|4!FKv@S;1;m9|p%g0vqSwxcOm~>R%BKj7V_zE`i5q zvy`*EZG3YX5Eo@OaOSvS5vzOvQ{rJT;EVVy6kb`eWushXmj4sE9;84qs6sS`iKqUd z=Ny09p0E8X8*yeP5<)jOD&Ob$;c}0rK>WmRFVPB(`o@iut}fH=zovN8&;I>#JkEO) zI7?OusKanoJDbR&lU5WC;@SQB&~V)2uqU>^i4I?0{Qp0pCRgun!m zUu(E|&0{N3>A%q=%S15rc1ajAGimZC`;0?`I~SI7(ao{dx61Xb?k4r#ox6$JW}YpP zqvtV9-1TW9^!i0WKRWX^Hw_+9YEucDX*x!1M|8Kgc~NqEiE3Lig5~tnu0W*}nO^?& zEcIb9@U~*`gRSJ<>2#E;=xs%5SB656#GwSo`Ivr#=9uinLNpwEVPfBy{=hOsmZhrO zzlRN1q?ry}kZ7Fl1;_a*l=&p%1KWs>O7u!_ujA?rhz4;qv>0!eL}dP z-oL*;H0>9+q@7SWWCBRA;>l|{yT2;V#P{6QgWwl8KiNE?I++yYEg(K2L3^P<|Xl>1TKXT8{76AZ_hSMVx*4t(@&1&D~RF zQSX0K)QBH;b**~BLIOBi)9VV0T<@{?U(rC^3eT^l%*io#9Id|%?xH%MN3)q4?Fx|L zU0Jb}5YbJp`p+u12GhuARZ~|I&t(yJ&)7Cx-H=A=k_*xJvnj=|LqLXjpYu=ahVM9*zwvSqLtmOV(-7hSzW+( z(6zt+!K_l?6LS|u@fKQZt_{Z zH*x=s@tp;a()iF-w{vDwd(+F81?#u3eoJFF=;8w}^^aw^I4EYg!aibcS zx-r60xoS3h6NUPt1#ri(wR!z4>KicDTo?c4kmdB+2U5UgDIX9j;hYDA<&izcp%Q93 zpfi#5vZC1PHgr=R*v(iZJ3`NLzkYY))59O(>`PYx_c$~vT8n9|7s3vgwOrmB;1-VQ34B$9mF25{VMu;JA_M@$NrgFQ za8Y$ZivSk7Kh?L}q6O%*28SH=eS198N^=G_|(TG8S9+#ohH)+p+ zrlnbGaE6IXp1Lfi=H}_##q>z;$fJH~t|%OoA1q%_))u;9VkOTxt=R9wd$8W=bIy(D z$J1FeK&!Q%3N3~Q5@5lay<(@?A#QPYKP~n=&G+qcB#JL|$|S1x1#^cT?P8x#YA3Of zGJX`h?Q$e`d_*&a-_h)K|Bp5#m*L2Y@cOrF040CpgkGN<6sxSqXddrjE9#`xRnzT= zidmgEX&1jCIe$2&`_nQu{hb*Ie;DBAxB7W-rQ6Nomde|Q|JG3pFtVYBe+OiyOi*Gk zSHFm{-C>M#=13I*fD8#$s0&eC&jt_bO`bHw$O1*;PT9kBHXj+%o^~XKuz)~>9cAxt z4To}=x89@AGwAEr#<@P##iPxrm(;)H?|rwP4@~@;5IG&=`wsi0_5-V6eqfetIb7i!Jh<@ER< zs=k_;j*_DufYdA85Qh$P!zR(6A`?@<;9c6J`Qcy;+x{FtoF0Xt!luLUrdUVi8PlYxO+irb>l6wFsR;>TIsk$(DtvpbBOy<(pl7PJ8n_b_ zSQG!13!*>eiEe3TDlLrysA5+9)|!Lq5>(AIx%EG*Z4eJb8Z;W*nKySsNG;$c@o~BT z*{y)R%%j49L?+|=Wb#To5*nh{YaKty3N0`ZO~9k7jV7KX+Ol=&hUBz3bQS?NFm8Zh ztFruT63loirsM|Ys|zJ$g)V~Y?a}o)_`6{klsZ0JT%m!?t242S6_j_KcdL$&M1$^j zeNF0qM_c0Cf~7RUF3G4}OB5&4nHPuwKu1*uSrBQ{7oC%igm-|#03GPO=;gzC`{hft zDAR!|Vb!Vr2bbuj!U2Xqy&Agah9J#kJ8cK1vIUy(Hr3-!=rHkEX2*dN@-a*h$ zxP^YBSnqo(LlExNmN;t}NE2I6r{_T+uTDf95%w5q9oKo&RjAyQGToP<4FW1n`nD@l z5QGA^ACv?ku08uxUeA3xP7v7Pcqe!crQI;8)(``JMF;gwgpVPF|F$19`Fs3wm=fxo z`asCfybq5Qe|k3+O^zDM%Ggfm92o`li3fHn|I@((`TnevqAC|al@6K%7uko{eZfD*-9-H~jcc0u@S(u!2 z82DymVIvaQeU=%a;yFB3jO`7^v9i1euouwCsh58i{(5|{kM2_>tGD0<0bgR}m#ZPm zBAyGU!tnkWCGI5U2ocmk{Jl8#W@rz{r9An}_Ez|uUh9Hp=k_$4?b*;NBoiLq_rmwI za;t&RdeV(ZiPC{rQT}Rb6DTne#q)X(X$*>gq(1yvmxFb;^$i)0ag+ah3UH)7AMzQvntM!2D3J)c74`tTU+#z?~ZRrwWP<0*|MOBH( z+wY}b+iVvIQEOwy!}9BuJ@*vmAA&W~ZagKUdmO$cD2|=3SBM$AcROqUJt%~85Pg1A z&)%#`_Y(*i`j{^DyU2z6SZ7e@`yy@GJO2Wc5ERhhKt-NhpU5;!>l~ZZ*-|=y=NEa6 zf=%NHn-XDfzJwDDFKJI@@u@POvtkS`f<=FbpLzl*g;5Vr$>7Aoj1!Bj_d`s?x{RSt zA9h`WR4)0U35)j18mzy8RHzVYDb1!4YjCejd89MU1XatL$pMCf!hAv2K6wkxdiY%E zrRfMtde2yENnN^z1*R>h@+|;FaZUdKqP*Nl-Ra7xSZOSRc@J=y3I|#JJsxNCCs5LN~j-6vY z*)f>so=&v4R%SO!h2ZVKzw-DF#^59VNr&jJGYHKo!B{|80vP_YO$8{|TyYPp-`+-8RfNy=vUzX}xahgzSo4^*> zeEQCcnPu!@P?JApjbCz!Sxb_^gOUQW@bpEn_%DCCR_{rZ$l@5b)|6%yF7SDXa zUjWpKjU7*jg!P^oP&%hZ7nPfb1F&c^#mwGp{SA?Wju=;b&WswH8zW;siIZ5oS9V#v zx&F~7x8kX;kFf&R8aaUf2KglLhQ>rm$|fGnNorE=g0QXwkZ}1p$&N;Iz_H%*LGigl zc4_3xh}4g8LEv~QKA+|d(}53DhN zUs6Yu-{Wk5+}Mv00xoND4*h(f(u=({v;W?rm>eq+i;}(PGDV7xFK(!^PXLbi<)aMf-vEQa;YG*?7i=WyTJ0t)CP^{ z+PkogQU?n5i^};E5qU9hLa`GxvMq^PC^m;9u*FWeTz$loKuK65IIi#5B-bzryT7xl|MN!Y zcxXi}hgr~ru8IdU)Tp`7GX_xehz#?$Q>R_{BMclQ-B!wvQg9NQS#Ah`Jr=!;Gh?W@ z_gz#*!?nErl>V3t!9wDgMW6%-&+{M}RkgD9WkzZBOca4P-j*(4Rd#1Ei05 z{2b-%SUoQOW}R6GYY1|H2FLm}CVmhgdJ04^~e>ItHWeSZvzrS7+J*)->=h_3y< zEbTW@sp6pF189`j=j;(J20-+SlAPbt18Syqec+_%!lVuZl-;|#3kT4c8tx>^@;D)M z9imA%%CN@D82$^)^$fV(@44Z{;ppM99gnI(>{OkCK6hw-ElsJ4-2gdkKV6V?ZOafG z^MqR1n#$s>l58`T>hIkaQ{gfd0hU?m|^|)C*zzpxS)%s#PGe-AJ*egHF>As`l!lt$I{9 zS87%H{qB6-YA(2-SR^*_5jM;fJj4ZUH;{^>6Bt`fUPpaD+E4=l)G7kE-aL5wkBXuc zj0J5*=7aA>EnO=Q;X-9y^Hiw4U&E4xw|j&Ee!O=6)zZR0m5HTi+XtU#!(c2~c$Kx7 zCAB|ag^2ZPs_NHKkC-?dlB9c8?-xX)EZBXIYMa1dLIlspjvCn)28@Q|3im;~;R>l( z<2>9BzDubl50LE_J&y}QA+se~KzhCxsMcB((^69=Ev}88syW}5PZ5Wjb`L3`GHz=> zHqY=r_Sy-PCswSd4=RZ-DpqEsHvb|vAVxBO!AE)LDxAWUC{Sp=e(lwe8Z?PphEz_Q z4FH*>X<`tV*zc z3|0;JdA=~9;w$!b=LOP9qB$6d%8vhp{w$$4ECB88I?|C|UM`!p38`G8mm5TZs0wv9f&2QIy=1AO0~+h=a!; zZ8(N3M!0DP%ql`uM=6ZW{?9Bv3vne?^jOfs)J_E>fRQ`6K50s@^ssfAWt=Qx zavHEjHZ~z56|o>t*YCI)`tt*83Sb@ny5Fx;(TWbU_o{oqHR=}z?z{RHWqksIRfi?* zQ(=cg@=JAX^-(*MU#EWfFYK(T3bF5s$wTecHkx^)5q18?XSFrsK`;By^G0hRg!*#) zLg=I*+%U_G>Y>GL)1UIw49Fn*ZzxD#?oLeEC=2 zxmheCy2Uf=lyvx`a5EhGgU_}ns02Df4`TQu< z7a>GLHmZ+D|6$4ktcM)&z8i~Z^6%}u5ZxBZ& zZnSx&W<#B=QW%uSW9~x_AGtU-Y+aE{fKj@7b5fGAm=yr>82-PjuZUg_6jkkKH+A-Cb_D~-Mp^+? zT=>qfa6^iuAzGyFH**siRo{Vr(Rp_x)iVHU)7w~i*GWLPgqA7jPciw{ZqfrqFpKbJ za<(xxf2R4(WaHHs-4n`Iogpj-(zIs+V%s1ig*AhU^0SpTwS_IFATqmDG4>OUPg4N%YcoX0?YJP zikTM-U+_p+ts7V&YTljjo!xjVE=T2|NW7iYkhOe!lVo;Nfftd?REDvC?(Fu+SgkX~ zX*Ztc$3U@0gjm^%$T5$7%2P+#&l1$29w7bQ@IkEyn5P-hq+H0jU}x6$>EBazb);?( zAQpt?WHyiHe_#(%i;&?>%e64hD`}A1t$Lsm1`Q1Cf?Q?501+`)Mj%@OMSH#%iVVCw zOTi!89qGD60LRq28G zahS|g>&l)r%s|w_=`hVD;3Yq*NMvjkTaFn1Zd*vqA-eP;`XqGP(IVG?__`-qEIqgw z;^z<+K~((_7S%*gV8*tbSHLKq12 zZGJ;2(%Sk?)!@@5|H0QlR=RGj1qDfXN513d4mHp-B*Axz(f3odzXsAlq~*iG`f=nB0Qp1 zQ9DUC)xiO==S%u(JyazZldWh+sr=B^>b3%5Mu&hp_~*D1m#l6WUIi0Ca2oD#EiB8K zS$s1TxEA9?ej52p#O0_`s2!Nur%n<52DK*_ZSDux33O21C8Bz$oD@Jhw_i zx7HVU>GO5a{rV%}W%5VDi%|7C zge>Uv?k4^-SKQ5jE|gng)k+_9nEFfpV@V$tCpf9{!�!A4$85+&LCe5^Wnf$%yXd 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 S>#cI|&(ZeiH" + init() { + @AppStorage("appVersion") var appVersion = 0 + @AppStorage("loggedUser") var loggedUser: String? + appVersion += 1 // Simulate update of the app on every new run + var context = ["app_version": ConfidenceValue.init(integer: appVersion)] + if let user = loggedUser { + context["user_id"] = ConfidenceValue.init(string: user) + } -@main -struct ConfidenceDemoApp: App { - @StateObject private var lifecycleObserver = ConfidenceAppLifecycleProducer() + confidence = Confidence + .Builder(clientSecret: secret, loggerLevel: .TRACE) + .withContext(initialContext: context) + .build() + do { + // NOTE: here we are activating all the flag values from storage, regardless of how `context` looks now + try confidence.activate() + } catch { + flaggingState.state = .error(ExperimentationFlags.CustomError(message: error.localizedDescription)) + } + // flaggingState.color is set here at startup and remains immutable until a user logs out + let eval = confidence.getEvaluation( + key: "swift-demoapp.color", + defaultValue: "Gray") + flaggingState.color = ContentView.getColor( + color: eval.value) + flaggingState.reason = eval.reason + + self.appVersion = appVersion + self.loggedUser = loggedUser + updateConfidence() + } var body: some Scene { WindowGroup { - let secret = ProcessInfo.processInfo.environment["CLIENT_SECRET"] ?? "" - let confidence = Confidence.Builder(clientSecret: secret, loggerLevel: .TRACE) - .withContext(initialContext: [ - "targeting_key": ConfidenceValue(string: UUID.init().uuidString), - "user_id": .init(string: "user2") - ]) - .build() - - let status = Status() - - ContentView(confidence: confidence, status: status) - .task { - do { - confidence.track(producer: lifecycleObserver) - try await self.setup(confidence: confidence) - status.state = .ready - } catch { - status.state = .error(error) - print(error.localizedDescription) - } - } + if loggedUser == nil { + LoginView(confidence: confidence) + .environmentObject(flaggingState) + } else { + ContentView(confidence: confidence) + .environmentObject(flaggingState) + } + } + } + + private func updateConfidence() { + Task { + do { + flaggingState.state = .loading + try await Task.sleep(nanoseconds: 2 * 1_000_000_000) // simulating slow network + // The flags in storage are refreshed for the current `context`, and activated + // After this line, fresh (and potentially new) flags values can be accessed + try await confidence.fetchAndActivate() + flaggingState.state = .ready + } catch { + flaggingState.state = .error(ExperimentationFlags.CustomError(message: error.localizedDescription)) + } } } } -extension ConfidenceDemoApp { - func setup(confidence: Confidence) async throws { - try await confidence.fetchAndActivate() +class ExperimentationFlags: ObservableObject { + var color: Color = .red // This is set on applicaaton start, and reset on user logout + var reason: ResolveReason = .unknown + @Published var state: State = .notReady + + enum State: Equatable { + case unknown + case notReady + case loading + case ready + case error(CustomError?) + } + + public struct CustomError: Error, Equatable { + let message: String } } diff --git a/ConfidenceDemoApp/ConfidenceDemoApp/ContentView.swift b/ConfidenceDemoApp/ConfidenceDemoApp/ContentView.swift index cfa931f8..8ea0de8a 100644 --- a/ConfidenceDemoApp/ConfidenceDemoApp/ContentView.swift +++ b/ConfidenceDemoApp/ConfidenceDemoApp/ContentView.swift @@ -3,59 +3,145 @@ import Confidence import Combine struct ContentView: View { - @ObservedObject var status: Status - @StateObject var text = DisplayText() - @StateObject var color = FlagColor() + @EnvironmentObject + var flaggingState: ExperimentationFlags + @AppStorage("loggedUser") + private var loggedUser: String? + @State + private var isLoggingOut = false + @State + private var loggedOut = false private let confidence: Confidence - init(confidence: Confidence, status: Status) { + init(confidence: Confidence, color: Color? = nil) { self.confidence = confidence - self.status = status } var body: some View { - if case .ready = status.state { + NavigationStack { VStack { - Image(systemName: "flag") - .imageScale(.large) - .foregroundColor(color.color) - .padding(10) - Text(text.text) - Button("Get remote flag value") { - text.text = confidence.getValue(key: "swift-demoapp.color", defaultValue: "ERROR") - if text.text == "Green" { - color.color = .green - } else if text.text == "Yellow" { - color.color = .yellow - } else { - color.color = .red - } + if let user = loggedUser { + Text("Hello \(user)") + .font(.largeTitle) + .padding() + } + Spacer() + NavigationLink(destination: AboutPage(confidence: confidence)) { + Text("Navigate") + .font(.headline) + .foregroundColor(.white) + .padding(.horizontal, 20) + .padding(.vertical, 10) + .background(Color.blue) + .clipShape(Capsule()) } - Button("Flush 🚽") { - confidence.flush() + .padding() + Button(action: { + isLoggingOut = true + loggedUser = nil + flaggingState.state = .loading + flaggingState.color = .gray + Task { + await confidence.removeContextAndWait(key: "user_id") + flaggingState.state = .ready + } + loggedOut = true + }, label: { + Text("Logout") + .font(.headline) + .foregroundColor(.white) + .padding(.horizontal, 20) + .padding(.vertical, 10) + .background(Color.red) + .clipShape(Capsule()) + }) + .navigationDestination(isPresented: $loggedOut) { + LoginView(confidence: confidence) } + Spacer() } + Spacer() + HStack { + Text("[1]") + if flaggingState.state == .loading && !isLoggingOut { + Text("Loading the text color...") + .font(.body) + } else { + let eval = confidence.getEvaluation(key: "swift-demoapp.color", defaultValue: "Gray") + Text("This text only appears after a successful flag fetching") + .font(.body) + .foregroundStyle(ContentView.getColor(color: eval.value)) + Spacer() + Text("[\(eval.reason)]") + } + }.frame(maxWidth: .infinity, alignment: .leading) .padding() - } else if case .error(let error) = status.state { - VStack { - Text("Provider Error") - Text(error?.localizedDescription ?? "An unknow error has occured.") - .foregroundColor(.red) - } - } else { - VStack { - ProgressView() - } + HStack { + let eval = confidence.getEvaluation( + key: "swift-demoapp.color", + defaultValue: "Gray") + Text("[2]") + Text("This text color dynamically changes on each flags fetch") + .font(.body) + .foregroundStyle(ContentView.getColor( + color: eval.value)) + Spacer() + Text("[\(eval.reason)]") + }.frame(maxWidth: .infinity, alignment: .leading) + .padding() + + HStack { + Text("[3]") + Text("This text color is fixed from app start, doesn't react on flag fetches") + .font(.body) + .foregroundStyle(flaggingState.color) + Spacer() + Text("[\(flaggingState.reason)]") + }.frame(maxWidth: .infinity, alignment: .leading) + .padding() } } -} -class DisplayText: ObservableObject { - @Published var text = "Hello World!" + static func getColor(color: String) -> Color { + switch color { + case "Green": + return .green + case "Yellow": + return .yellow + case "Gray": + return .gray + default: + return .red + } + } } +struct AboutPage: View { + @State + private var textColor = Color.red + @State + private var reason = ResolveReason.unknown + private let confidence: Confidence + + init(confidence: Confidence) { + self.confidence = confidence + } -class FlagColor: ObservableObject { - @Published var color: Color = .black + var body: some View { + HStack { + Text("This text color is set on onAppear, doesn't wait for flag fetch") + .font(.body) + .foregroundStyle(textColor) + .padding() + .onAppear { + let eval = confidence.getEvaluation(key: "swift-demoapp.color", defaultValue: "Gray") + textColor = ContentView.getColor( + color: eval.value) + reason = eval.reason + } + Spacer() + Text("[\(reason)]") + } + } } diff --git a/ConfidenceDemoApp/ConfidenceDemoApp/LoginView.swift b/ConfidenceDemoApp/ConfidenceDemoApp/LoginView.swift new file mode 100644 index 00000000..07a69d92 --- /dev/null +++ b/ConfidenceDemoApp/ConfidenceDemoApp/LoginView.swift @@ -0,0 +1,80 @@ +import SwiftUI +import Confidence + +struct LoginView: View { + @EnvironmentObject + var flaggingState: ExperimentationFlags + @AppStorage("loggedUser") + private var loggedUser: String? + @State + private var loginCompleted = false + @State + private var flagsLoaded = false + @State + private var loggingIn = false + + private let confidence: Confidence + + init(confidence: Confidence) { + self.confidence = confidence + } + + var body: some View { + NavigationStack { + VStack { + Spacer() + ZStack { + Button(action: { + do { + try confidence.activate() + } catch { + flaggingState.state = .error( + ExperimentationFlags.CustomError(message: error.localizedDescription)) + } + + let eval = confidence.getEvaluation(key: "swift-demoapp.color", defaultValue: "Gray") + flaggingState.color = ContentView.getColor( + color: eval.value + ) + flaggingState.reason = eval.reason + + // Simulating a module that handles feature flagging state during login + Task { + flaggingState.state = .loading + try? await Task.sleep(nanoseconds: 5 * 1_000_000_000) // simulating network delay + // putContext adds the user_id field to the evaluation context and fetches values for it + await confidence.putContextAndWait(context: ["user_id": .init(string: "user1")]) + flaggingState.state = .ready + } + + // Simulating a module that handles the actual login mechanism for a user + Task { + loggingIn = true + try? await Task.sleep(nanoseconds: 1 * 1_000_000_000) // simulating network delay + loggedUser = "user1" + loggingIn = false + loginCompleted = true + } + }, label: { + Text("Login as user1") + .font(.headline) + .foregroundColor(.white) + .padding(.horizontal, 20) + .padding(.vertical, 10) + .background(Color.blue) + .clipShape(Capsule()) + }) + .navigationDestination(isPresented: $loginCompleted) { + ContentView(confidence: confidence) + } + + if loggingIn { + ProgressView() + .offset(y: 40) + } + } + Spacer() + } + } + } +} diff --git a/README.md b/README.md index e45e2e9c..a2cde3a6 100644 --- a/README.md +++ b/README.md @@ -50,38 +50,57 @@ If your app is using some of the features of Swift 6, we recommend setting the * ```swift import Confidence -let confidence = Confidence.Builder(clientSecret: "mysecret", loggerLevel: .NONE).build() +let confidence = Confidence + .Builder(clientSecret: "mysecret", loggerLevel: .NONE) + .withContext(context: ["user_id": ConfidenceValue(string: "user_1")]) + .build() await confidence.fetchAndActivate() ``` - The `clientSecret` for your application can be generated in the Confidence portal. - The `loggerLevel` sets the verbosity level for logging to console. This can be useful while testing your integration with the Confidence SDK. +- `withContext()` sets the initial context. The context is a key-value map used for sampling and for targeting, so it determines how flags are evaluated by the Confidence backend. _Note: the Confidence SDK has been intended to work as a single instance in your Application. Creating multiple instances in the same runtime could lead to unexpected behaviours._ ### Initialization strategy -`confidence.activateAndFetch()` is an async function that fetches the flags from the Confidence backend, -stores the result on disk, and make the same data ready for the Application to be consumed. +After creating the confidence instance, you can choose between different strategies to initialize the SDK: +- `await confidence.fetchAndActivate()`: async function that fetches the flags from the Confidence backend according to the current context, +stores the result in storage, and make the same data ready for the Application to be consumed. -The alternative option is to call `confidence.activate()`: this loads previously fetched flags data +- `confidence.activate()`: this loads fetched flags data from storage and makes that available for the Application to consume right away. -To avoid waiting on backend calls when the Application starts, the suggested approach is to call -`confidence.activate()` and then trigger a background refresh via `confidence.asyncFetch()` for future sessions. -### Setting the context -The context is a key-value map used for sampling and for targeting, when flag are evaluated by the Confidence backend. -It is also appended to the tracked events, making it a great way to create dimensions for metrics in Confidence. +If you wish to avoid waiting on backend calls when the Application starts, the suggested approach is to call +`confidence.activate()` and then call `confidence.asyncFetch()` to update the flag values in storage to be used on a future `activate()`. + +**Important:** `confidence.activate()` ignores the current context: even if the current context has changed since the last fetch, flag values from the last fetch will be exposed to the Application. + +### Managing the context +The context is set when instantiating the Confidence instance, but it can be updated at runtime: ```swift -confidence.putContext(context: ["key": ConfidenceValue(string: "value")]) +await confidence.putContext(context: ["key": ConfidenceValue(string: "value")]) +await confidence.putContext(key: "key", value: ConfidenceValue(string: "value")) +await confidence.removeContext(key: "key") ``` -Note that a `ConfidenceValue` is accepted a map values, which has a constructor for all the value types -supported by Confidence. +These functions are async functions, because the flag values are fetched from the backend for the new context, put in storage and then exposed to the Application. + +_Note: Changing the context could cause a change in the flag values._ + +_Note: When a context change is performed and the SDK is fetching the new values for it, the old values are still available for the Application to consume but marked with evaluation reason `STALE`._ + +When integrating the SDK in your Application, it's important to understand the implications of changing the context at runtime: +- You might want to keep the flag values unchanged within a certain session +- You might want to show a loading UI while re-fetching all flag values +- You might want the UI to dynamically adapt to underlying changes in flag values + +You can find examples on how to implement these different scenarios in the Demo Application project within this repo. -### Resolving feature flags +### Read flag values Once the Confidence instance is **activated**, you can access the flag values using the `getValue` method or the `getEvaluation` functions. Both functions use generics to return a type defined by the default value type. diff --git a/Sources/Confidence/Confidence.swift b/Sources/Confidence/Confidence.swift index 05127b70..c94cd461 100644 --- a/Sources/Confidence/Confidence.swift +++ b/Sources/Confidence/Confidence.swift @@ -3,25 +3,30 @@ import Foundation import Combine import os +// swiftlint:disable:next type_body_length public class Confidence: ConfidenceEventSender { + // User configurations private let clientSecret: String - private var region: ConfidenceRegion - private let parent: ConfidenceContextProvider? + private let region: ConfidenceRegion + private let debugLogger: DebugLogger? + + // Resources related to managing context and flags + private let parentContextProvider: ConfidenceContextProvider? + private let contextManager: ContextManager + private var cache = FlagResolution.EMPTY + + // Core components managing internal SDK functionality private let eventSenderEngine: EventSenderEngine - private let contextSubject = CurrentValueSubject([:]) - private var removedContextKeys: Set = Set() - private let contextSubjectQueue = DispatchQueue(label: "com.confidence.queue.contextsubject") - private let cacheQueue = DispatchQueue(label: "com.confidence.queue.cache") + private let storage: Storage private let flagApplier: FlagApplier - private var cache = FlagResolution.EMPTY - private var storage: Storage + + // Synchronization and task management resources private var cancellables = Set() - private var currentFetchTask: Task<(), Never>? - private let debugLogger: DebugLogger? + private let cacheQueue = DispatchQueue(label: "com.confidence.queue.cache") + private var taskManager = TaskManager() // Internal for testing internal let remoteFlagResolver: ConfidenceResolveClient - internal let contextReconciliatedChanges = PassthroughSubject() public static let sdkId: String = "SDK_ID_SWIFT_CONFIDENCE" @@ -41,35 +46,14 @@ public class Confidence: ConfidenceEventSender { self.clientSecret = clientSecret self.region = region self.storage = storage - self.contextSubject.value = context - self.parent = parent - self.storage = storage + self.contextManager = ContextManager(initialContext: context) + self.parentContextProvider = parent self.flagApplier = flagApplier self.remoteFlagResolver = remoteFlagResolver self.debugLogger = debugLogger if let visitorId { - putContext(context: ["visitor_id": ConfidenceValue.init(string: visitorId)]) - } - - contextChanges().sink { [weak self] context in - guard let self = self else { - return - } - self.currentFetchTask?.cancel() - self.currentFetchTask = Task { - do { - let context = self.getContext() - try await self.fetchAndActivate() - self.contextReconciliatedChanges.send(context.hash()) - } catch { - debugLogger?.logMessage( - message: "\(error)", - isWarning: true - ) - } - } + putContextLocal(context: ["visitor_id": ConfidenceValue.init(string: visitorId)]) } - .store(in: &cancellables) } /** @@ -83,7 +67,6 @@ public class Confidence: ConfidenceEventSender { } let savedFlags = try storage.load(defaultValue: FlagResolution.EMPTY) cache = savedFlags - debugLogger?.logFlags(action: "Activate", flag: "") } } @@ -94,14 +77,7 @@ public class Confidence: ConfidenceEventSender { Fetching is best-effort, so no error is propagated. Errors can still be thrown if something goes wrong access data on disk. */ public func fetchAndActivate() async throws { - do { - try await internalFetch() - } catch { - debugLogger?.logMessage( - message: "\(error)", - isWarning: true - ) - } + await asyncFetch() try activate() } @@ -109,20 +85,18 @@ public class Confidence: ConfidenceEventSender { Fetch latest flag evaluations and store them on disk. Note that "activate" must be called for this data to be made available in the app session. */ - public func asyncFetch() { - Task { - do { - try await internalFetch() - } catch { - debugLogger?.logMessage( - message: "\(error )", - isWarning: true - ) - } + public func asyncFetch() async { + do { + try await internalFetch() + } catch { + debugLogger?.logMessage( + message: "\(error )", + isWarning: true + ) } } - func internalFetch() async throws { + private func internalFetch() async throws { let context = getContext() let resolvedFlags = try await remoteFlagResolver.resolve(ctx: context) let resolution = FlagResolution( @@ -130,10 +104,16 @@ public class Confidence: ConfidenceEventSender { flags: resolvedFlags.resolvedValues, resolveToken: resolvedFlags.resolveToken ?? "" ) - debugLogger?.logFlags(action: "Fetch", flag: "") try storage.save(data: resolution) } + /** + Returns true if any flag is found in storage. + */ + public func isStorageEmpty() -> Bool { + return storage.isEmpty() + } + /** Get evaluation data for a specific flag. Evaluation data includes the variant's name and reason/error information. - Parameter key:expects dot-notation to retrieve a specific entry in the flag's value, e.g. "flagname.myentry" @@ -169,25 +149,139 @@ public class Confidence: ConfidenceEventSender { return getEvaluation(key: key, defaultValue: defaultValue).value } - func isStorageEmpty() -> Bool { - return storage.isEmpty() + public func getContext() -> ConfidenceStruct { + let parentContext = parentContextProvider?.getContext() ?? [:] + return contextManager.getContext(parentContext: parentContext) + } + + public func putContextAndWait(key: String, value: ConfidenceValue) async { + taskManager.currentTask = Task { + let newContext = contextManager.updateContext(withValues: [key: value], removedKeys: []) + do { + try await self.fetchAndActivate() + debugLogger?.logContext(action: "PutContext", context: newContext) + } catch { + debugLogger?.logMessage(message: "Error when putting context: \(error)", isWarning: true) + } + } + await awaitReconciliation() + } + + public func putContextAndWait(context: ConfidenceStruct, removedKeys: [String] = []) async { + taskManager.currentTask = Task { + let newContext = contextManager.updateContext(withValues: context, removedKeys: removedKeys) + do { + try await self.fetchAndActivate() + debugLogger?.logContext(action: "PutContext", context: newContext) + } catch { + debugLogger?.logMessage(message: "Error when putting context: \(error)", isWarning: true) + } + } + await awaitReconciliation() + } + + public func putContextAndWait(context: ConfidenceStruct) async { + taskManager.currentTask = Task { + let newContext = contextManager.updateContext(withValues: context, removedKeys: []) + do { + try await fetchAndActivate() + debugLogger?.logContext( + action: "PutContext", + context: newContext) + } catch { + debugLogger?.logMessage( + message: "Error when putting context: \(error)", + isWarning: true) + } + } + await awaitReconciliation() + } + + public func removeContextAndWait(key: String) async { + taskManager.currentTask = Task { + let newContext = contextManager.updateContext(withValues: [:], removedKeys: [key]) + do { + try await self.fetchAndActivate() + debugLogger?.logContext( + action: "RemoveContext", + context: newContext) + } catch { + debugLogger?.logMessage( + message: "Error when removing context key: \(error)", + isWarning: true) + } + } + await awaitReconciliation() } /** - Listen to changes in the context that is local to this Confidence instance. + Adds/override entry to local context data. Does not trigger fetchAndActivate after the context change. */ - public func contextChanges() -> AnyPublisher { - return contextSubject - .dropFirst() - .removeDuplicates() - .eraseToAnyPublisher() + public func putContextLocal(context: ConfidenceStruct, removeKeys removedKeys: [String] = []) { + let newContext = contextManager.updateContext(withValues: context, removedKeys: removedKeys) + debugLogger?.logContext( + action: "PutContextLocal", + context: newContext) } - public func track(eventName: String, data: ConfidenceStruct) throws { - try eventSenderEngine.emit( - eventName: eventName, - data: data, - context: getContext() + public func putContext(key: String, value: ConfidenceValue) { + taskManager.currentTask = Task { + await putContextAndWait(key: key, value: value) + } + } + + public func putContext(context: ConfidenceStruct) { + taskManager.currentTask = Task { + await putContextAndWait(context: context) + } + } + + public func putContext(context: ConfidenceStruct, removeKeys removedKeys: [String] = []) { + taskManager.currentTask = Task { + await putContextAndWait(context: context, removedKeys: removedKeys) + } + } + + public func removeContext(key: String) { + taskManager.currentTask = Task { + await removeContextAndWait(key: key) + } + } + + public func putContext(context: ConfidenceStruct, removedKeys: [String]) { + taskManager.currentTask = Task { + let newContext = contextManager.updateContext(withValues: context, removedKeys: removedKeys) + do { + try await self.fetchAndActivate() + debugLogger?.logContext( + action: "RemoveContext", + context: newContext) + } catch { + debugLogger?.logMessage( + message: "Error when putting context: \(error)", + isWarning: true) + } + } + } + + /** + Ensures all the already-started context changes prior to this function have been reconciliated + */ + public func awaitReconciliation() async { + await taskManager.awaitReconciliation() + } + + public func withContext(_ context: ConfidenceStruct) -> ConfidenceEventSender { + return Self.init( + clientSecret: clientSecret, + region: region, + eventSenderEngine: eventSenderEngine, + flagApplier: flagApplier, + remoteFlagResolver: remoteFlagResolver, + storage: storage, + context: context, + parent: self, + debugLogger: debugLogger ) } @@ -214,94 +308,67 @@ public class Confidence: ConfidenceEventSender { if let contextProducer = producer as? ConfidenceContextProducer { contextProducer.produceContexts() .sink { [weak self] context in - guard let self = self else { - return + Task { [weak self] in + guard let self = self else { return } + await self.putContextAndWait(context: context) } - self.putContext(context: context) } .store(in: &cancellables) } } + public func track(eventName: String, data: ConfidenceStruct) throws { + try eventSenderEngine.emit( + eventName: eventName, + data: data, + context: getContext() + ) + } + public func flush() { eventSenderEngine.flush() } +} - public func getContext() -> ConfidenceStruct { - let parentContext = parent?.getContext() ?? [:] - var reconciledCtx = parentContext.filter { - !removedContextKeys.contains($0.key) - } - self.contextSubject.value.forEach { entry in - reconciledCtx.updateValue(entry.value, forKey: entry.key) - } - return reconciledCtx - } +private class ContextManager { + private var context: ConfidenceStruct = [:] + private var removedContextKeys: Set = Set() + private let contextQueue = DispatchQueue(label: "com.confidence.queue.context") - public func putContext(key: String, value: ConfidenceValue) { - withLock { confidence in - var map = confidence.contextSubject.value - map[key] = value - confidence.contextSubject.value = map - confidence.debugLogger?.logContext(action: "PutContext", context: confidence.contextSubject.value) - } + public init(initialContext: ConfidenceStruct) { + context = initialContext } - public func putContext(context: ConfidenceStruct) { - withLock { confidence in - var map = confidence.contextSubject.value - for entry in context { - map.updateValue(entry.value, forKey: entry.key) + func updateContext(withValues: ConfidenceStruct, removedKeys: [String]) -> ConfidenceStruct { + contextQueue.sync { [weak self] in + guard let self = self else { + return [:] } - confidence.contextSubject.value = map - confidence.debugLogger?.logContext(action: "PutContext", context: confidence.contextSubject.value) - } - } - - public func putContext(context: ConfidenceStruct, removeKeys removedKeys: [String] = []) { - withLock { confidence in - var map = confidence.contextSubject.value + var map = self.context for removedKey in removedKeys { map.removeValue(forKey: removedKey) + removedContextKeys.insert(removedKey) } - for entry in context { + for entry in withValues { map.updateValue(entry.value, forKey: entry.key) } - confidence.contextSubject.value = map - confidence.debugLogger?.logContext(action: "PutContext", context: confidence.contextSubject.value) + self.context = map + return self.context } } - public func removeKey(key: String) { - withLock { confidence in - var map = confidence.contextSubject.value - map.removeValue(forKey: key) - confidence.contextSubject.value = map - confidence.removedContextKeys.insert(key) - confidence.debugLogger?.logContext(action: "RemoveContext", context: confidence.contextSubject.value) - } - } - - public func withContext(_ context: ConfidenceStruct) -> ConfidenceEventSender { - return Self.init( - clientSecret: clientSecret, - region: region, - eventSenderEngine: eventSenderEngine, - flagApplier: flagApplier, - remoteFlagResolver: remoteFlagResolver, - storage: storage, - context: context, - parent: self, - debugLogger: debugLogger - ) - } - - private func withLock(callback: @escaping (Confidence) -> Void) { - contextSubjectQueue.sync { [weak self] in + func getContext(parentContext: ConfidenceStruct) -> ConfidenceStruct { + contextQueue.sync { [weak self] in guard let self = self else { - return + return [:] + } + var reconciledCtx = parentContext.filter { + !self.removedContextKeys.contains($0.key) + } + context.forEach { entry in + reconciledCtx.updateValue(entry.value, forKey: entry.key) } - callback(self) + return reconciledCtx } } } diff --git a/Sources/Confidence/ConfidenceEventSender.swift b/Sources/Confidence/ConfidenceEventSender.swift index 18e7b829..5c4d5380 100644 --- a/Sources/Confidence/ConfidenceEventSender.swift +++ b/Sources/Confidence/ConfidenceEventSender.swift @@ -20,13 +20,44 @@ public protocol ConfidenceEventSender: ConfidenceContextProvider { func flush() /** Adds/override entry to local context data + Triggers fetchAndActivate after the context change + */ + func putContextAndWait(key: String, value: ConfidenceValue) async + /** + Adds/override entry to local context data + Triggers fetchAndActivate after the context change + */ + func putContextAndWait(context: ConfidenceStruct) async + /** + Removes entry from localcontext data + It hides entries with this key from parents' data (without modifying parents' data) + Triggers fetchAndActivate after the context change + */ + func removeContextAndWait(key: String) async + /** + Combination of putContext and removeContext + */ + func putContextAndWait(context: ConfidenceStruct, removedKeys: [String]) async + /** + Adds/override entry to local context data + Triggers fetchAndActivate after the context change */ func putContext(key: String, value: ConfidenceValue) /** + Adds/override entry to local context data + Triggers fetchAndActivate after the context change + */ + func putContext(context: ConfidenceStruct) + /** Removes entry from localcontext data It hides entries with this key from parents' data (without modifying parents' data) + Triggers fetchAndActivate after the context change + */ + func removeContext(key: String) + /** + Combination of putContext and removeContext */ - func removeKey(key: String) + func putContext(context: ConfidenceStruct, removedKeys: [String]) /** Creates a child event sender instance that maintains access to its parent's data */ diff --git a/Sources/Confidence/DebugLogger.swift b/Sources/Confidence/DebugLogger.swift index 5535c1b3..1f47dcd0 100644 --- a/Sources/Confidence/DebugLogger.swift +++ b/Sources/Confidence/DebugLogger.swift @@ -5,6 +5,7 @@ internal protocol DebugLogger { func logEvent(action: String, event: ConfidenceEvent?) func logMessage(message: String, isWarning: Bool) func logFlags(action: String, flag: String) + func logFlags(action: String, context: ConfidenceStruct) func logContext(action: String, context: ConfidenceStruct) func logResolveDebugURL(flagName: String, context: ConfidenceStruct) } @@ -62,6 +63,10 @@ internal class DebugLoggerImpl: DebugLogger { log(messageLevel: .TRACE, message: "[\(action)] \(flag)") } + func logFlags(action: String, context: ConfidenceStruct) { + log(messageLevel: .TRACE, message: "[\(action)] \(context)") + } + func logContext(action: String, context: ConfidenceStruct) { log(messageLevel: .TRACE, message: "[\(action)] \(context)") } diff --git a/Sources/Confidence/TaskManager.swift b/Sources/Confidence/TaskManager.swift new file mode 100644 index 00000000..13f4724e --- /dev/null +++ b/Sources/Confidence/TaskManager.swift @@ -0,0 +1,30 @@ +import Foundation + +internal class TaskManager { + public var currentTask: Task<(), Never>? { + didSet { + if let oldTask = oldValue { + oldTask.cancel() + } + } + } + public func awaitReconciliation() async { + while let task = self.currentTask { + // If current task is cancelled, return + if task.isCancelled { + return + } + // Wait for result of current task + await task.value + // If current task gets cancelled, check again if a new task was set + if task.isCancelled { + continue + } + // If current task finished successfully + // and the set task has not changed, we are done waiting + if self.currentTask == task { + return + } + } + } +} diff --git a/Sources/ConfidenceProvider/ConfidenceFeatureProvider.swift b/Sources/ConfidenceProvider/ConfidenceFeatureProvider.swift index 538518a3..cab13418 100644 --- a/Sources/ConfidenceProvider/ConfidenceFeatureProvider.swift +++ b/Sources/ConfidenceProvider/ConfidenceFeatureProvider.swift @@ -40,16 +40,15 @@ public class ConfidenceFeatureProvider: FeatureProvider { } public func initialize(initialContext: OpenFeature.EvaluationContext?) { - self.updateConfidenceContext(context: initialContext ?? MutableContext(attributes: [:])) - if self.initializationStrategy == .activateAndFetchAsync { - eventHandler.send(.ready) - } - + let context = ConfidenceTypeMapper.from(ctx: initialContext ?? MutableContext(attributes: [:])) + confidence.putContextLocal(context: context) do { if initializationStrategy == .activateAndFetchAsync { try confidence.activate() eventHandler.send(.ready) - confidence.asyncFetch() + Task { + await confidence.asyncFetch() + } } else { Task { do { @@ -77,16 +76,13 @@ public class ConfidenceFeatureProvider: FeatureProvider { oldContext: OpenFeature.EvaluationContext?, newContext: OpenFeature.EvaluationContext ) { - var removedKeys: [String] = [] - if let oldContext = oldContext { - removedKeys = Array(oldContext.asMap().filter { key, _ in !newContext.asMap().keys.contains(key) }.keys) - } - - self.updateConfidenceContext(context: newContext, removedKeys: removedKeys) - } + let removedKeys: [String] = oldContext.map { + Array($0.asMap().filter { key, _ in !newContext.asMap().keys.contains(key) }.keys) + } ?? [] - private func updateConfidenceContext(context: EvaluationContext, removedKeys: [String] = []) { - confidence.putContext(context: ConfidenceTypeMapper.from(ctx: context), removeKeys: removedKeys) + Task { + confidence.putContext(context: ConfidenceTypeMapper.from(ctx: newContext), removedKeys: removedKeys) + } } public func getBooleanEvaluation(key: String, defaultValue: Bool, context: EvaluationContext?) throws diff --git a/Tests/ConfidenceTests/ConfidenceContextTests.swift b/Tests/ConfidenceTests/ConfidenceContextTests.swift index 155c952d..3b4a6c45 100644 --- a/Tests/ConfidenceTests/ConfidenceContextTests.swift +++ b/Tests/ConfidenceTests/ConfidenceContextTests.swift @@ -30,7 +30,7 @@ final class ConfidenceContextTests: XCTestCase { XCTAssertEqual(confidenceChild.getContext(), expected) } - func testWithContextUpdateParent() { + func testWithContextUpdateParent() async { let client = RemoteConfidenceResolveClient( options: ConfidenceClientOptions( credentials: ConfidenceClientCredentials.clientSecret(secret: ""), timeoutIntervalForRequest: 10), @@ -51,7 +51,7 @@ final class ConfidenceContextTests: XCTestCase { let confidenceChild: ConfidenceEventSender = confidenceParent.withContext( ["k2": ConfidenceValue(string: "v2")] ) - confidenceParent.putContext( + await confidenceParent.putContextAndWait( key: "k3", value: ConfidenceValue(string: "v3")) let expected = [ @@ -62,7 +62,7 @@ final class ConfidenceContextTests: XCTestCase { XCTAssertEqual(confidenceChild.getContext(), expected) } - func testUpdateLocalContext() { + func testUpdateLocalContext() async { let client = RemoteConfidenceResolveClient( options: ConfidenceClientOptions( credentials: ConfidenceClientCredentials.clientSecret(secret: ""), timeoutIntervalForRequest: 10), @@ -80,7 +80,7 @@ final class ConfidenceContextTests: XCTestCase { parent: nil, debugLogger: nil ) - confidence.putContext( + await confidence.putContextAndWait( key: "k1", value: ConfidenceValue(string: "v3")) let expected = [ @@ -89,7 +89,7 @@ final class ConfidenceContextTests: XCTestCase { XCTAssertEqual(confidence.getContext(), expected) } - func testUpdateLocalContextWithoutOverride() { + func testUpdateLocalContextWithoutOverride() async { let client = RemoteConfidenceResolveClient( options: ConfidenceClientOptions( credentials: ConfidenceClientCredentials.clientSecret(secret: ""), timeoutIntervalForRequest: 10), @@ -110,7 +110,7 @@ final class ConfidenceContextTests: XCTestCase { let confidenceChild: ConfidenceEventSender = confidenceParent.withContext( ["k2": ConfidenceValue(string: "v2")] ) - confidenceChild.putContext( + await confidenceChild.putContextAndWait( key: "k2", value: ConfidenceValue(string: "v4")) let expected = [ @@ -120,7 +120,7 @@ final class ConfidenceContextTests: XCTestCase { XCTAssertEqual(confidenceChild.getContext(), expected) } - func testUpdateParentContextWithOverride() { + func testUpdateParentContextWithOverride() async { let client = RemoteConfidenceResolveClient( options: ConfidenceClientOptions( credentials: ConfidenceClientCredentials.clientSecret(secret: ""), timeoutIntervalForRequest: 10), @@ -141,7 +141,7 @@ final class ConfidenceContextTests: XCTestCase { let confidenceChild: ConfidenceEventSender = confidenceParent.withContext( ["k2": ConfidenceValue(string: "v2")] ) - confidenceParent.putContext( + await confidenceParent.putContextAndWait( key: "k2", value: ConfidenceValue(string: "v4")) let expected = [ @@ -151,7 +151,7 @@ final class ConfidenceContextTests: XCTestCase { XCTAssertEqual(confidenceChild.getContext(), expected) } - func testRemoveContextEntry() { + func testRemoveContextEntry() async { let client = RemoteConfidenceResolveClient( options: ConfidenceClientOptions( credentials: ConfidenceClientCredentials.clientSecret(secret: ""), timeoutIntervalForRequest: 10), @@ -169,14 +169,14 @@ final class ConfidenceContextTests: XCTestCase { parent: nil, debugLogger: nil ) - confidence.removeKey(key: "k2") + await confidence.removeContextAndWait(key: "k2") let expected = [ "k1": ConfidenceValue(string: "v1") ] XCTAssertEqual(confidence.getContext(), expected) } - func testRemoveContextEntryFromParent() { + func testRemoveContextEntryFromParent() async { let client = RemoteConfidenceResolveClient( options: ConfidenceClientOptions( credentials: ConfidenceClientCredentials.clientSecret(secret: ""), timeoutIntervalForRequest: 10), @@ -197,14 +197,14 @@ final class ConfidenceContextTests: XCTestCase { let confidenceChild: ConfidenceEventSender = confidenceParent.withContext( ["k2": ConfidenceValue(string: "v2")] ) - confidenceChild.removeKey(key: "k1") + await confidenceChild.removeContextAndWait(key: "k1") let expected = [ "k2": ConfidenceValue(string: "v2") ] XCTAssertEqual(confidenceChild.getContext(), expected) } - func testRemoveContextEntryFromParentAndChild() { + func testRemoveContextEntryFromParentAndChild() async { let client = RemoteConfidenceResolveClient( options: ConfidenceClientOptions( credentials: ConfidenceClientCredentials.clientSecret(secret: ""), timeoutIntervalForRequest: 10), @@ -228,14 +228,14 @@ final class ConfidenceContextTests: XCTestCase { "k1": ConfidenceValue(string: "v3"), ] ) - confidenceChild.removeKey(key: "k1") + await confidenceChild.removeContextAndWait(key: "k1") let expected = [ "k2": ConfidenceValue(string: "v2") ] XCTAssertEqual(confidenceChild.getContext(), expected) } - func testRemoveContextEntryFromParentAndChildThenUpdate() { + func testRemoveContextEntryFromParentAndChildThenUpdate() async { let client = RemoteConfidenceResolveClient( options: ConfidenceClientOptions( credentials: ConfidenceClientCredentials.clientSecret(secret: ""), timeoutIntervalForRequest: 10), @@ -259,8 +259,8 @@ final class ConfidenceContextTests: XCTestCase { "k1": ConfidenceValue(string: "v3"), ] ) - confidenceChild.removeKey(key: "k1") - confidenceChild.putContext(key: "k1", value: ConfidenceValue(string: "v4")) + await confidenceChild.removeContextAndWait(key: "k1") + await confidenceChild.putContextAndWait(key: "k1", value: ConfidenceValue(string: "v4")) let expected = [ "k2": ConfidenceValue(string: "v2"), "k1": ConfidenceValue(string: "v4"), diff --git a/Tests/ConfidenceTests/ConfidenceTest.swift b/Tests/ConfidenceTests/ConfidenceTest.swift index 51dc65d5..655d3143 100644 --- a/Tests/ConfidenceTests/ConfidenceTest.swift +++ b/Tests/ConfidenceTests/ConfidenceTest.swift @@ -10,7 +10,6 @@ import XCTest class ConfidenceTest: XCTestCase { private var flagApplier = FlagApplierMock() private let storage = StorageMock() - private var readyExpectation = XCTestExpectation(description: "Ready") override func setUp() { try? storage.clear() @@ -108,8 +107,8 @@ class ConfidenceTest: XCTestCase { XCTAssertEqual(2, client.resolveContexts.count) XCTAssertEqual(confidence.getContext(), client.resolveContexts[1]) } - // swiftlint:enable function_body_length + // swiftlint:enable function_body_length func testRefresh() async throws { class FakeClient: ConfidenceResolveClient { var resolveStats: Int = 0 @@ -142,14 +141,8 @@ class ConfidenceTest: XCTestCase { resolveReason: .match) ] - let expectation = expectation(description: "context is synced") - let cancellable = confidence.contextReconciliatedChanges.sink { _ in - expectation.fulfill() - } - confidence.putContext(context: ["targeting_key": .init(string: "user2")]) - await fulfillment(of: [expectation], timeout: 1) - cancellable.cancel() + await confidence.putContextAndWait(context: ["targeting_key": .init(string: "user2")]) let evaluation = confidence.getEvaluation( key: "flag.size", defaultValue: 0) @@ -376,8 +369,7 @@ class ConfidenceTest: XCTestCase { var resolvedValues: [ResolvedValue] = [] func resolve(ctx: ConfidenceStruct) async throws -> ResolvesResult { if self.resolveStats == 1 { - let expectation = expectation(description: "never fullfil") - await fulfillment(of: [expectation]) + throw ConfidenceError.internalError(message: "test") } self.resolveStats += 1 return .init(resolvedValues: resolvedValues, resolveToken: "token") @@ -400,7 +392,7 @@ class ConfidenceTest: XCTestCase { .build() try await confidence.fetchAndActivate() - confidence.putContext(context: ["hello": .init(string: "world")]) + await confidence.putContextAndWait(context: ["hello": .init(string: "world")]) let evaluation = confidence.getEvaluation( key: "flag.size", defaultValue: 0) @@ -457,6 +449,169 @@ class ConfidenceTest: XCTestCase { XCTAssertEqual(flagApplier.applyCallCount, 1) } + func testAwaitReconciliation() async throws { + class FakeClient: XCTestCase, ConfidenceResolveClient { + var resolveStats: Int = 0 + var resolvedValues: [ResolvedValue] = [] + func resolve(ctx: ConfidenceStruct) async throws -> ResolvesResult { + self.resolveStats += 1 + return .init(resolvedValues: resolvedValues, resolveToken: "token") + } + } + + let client = FakeClient() + client.resolvedValues = [ + ResolvedValue( + variant: "control", + value: .init(structure: ["size": .init(integer: 3)]), + flag: "flag", + resolveReason: .match) + ] + + let confidence = Confidence.Builder(clientSecret: "test") + .withContext(initialContext: ["targeting_key": .init(string: "user2")]) + .withFlagResolverClient(flagResolver: client) + .withFlagApplier(flagApplier: flagApplier) + .build() + confidence.putContext(context: ["hello": .init(string: "world")]) + await confidence.awaitReconciliation() + let evaluation = confidence.getEvaluation( + key: "flag.size", + defaultValue: 0) + + XCTAssertEqual(client.resolveStats, 1) + XCTAssertEqual(evaluation.value, 3) + XCTAssertNil(evaluation.errorCode) + XCTAssertNil(evaluation.errorMessage) + XCTAssertEqual(evaluation.reason, .match) + XCTAssertEqual(evaluation.variant, "control") + XCTAssertEqual(client.resolveStats, 1) + await fulfillment(of: [flagApplier.applyExpectation], timeout: 1) + XCTAssertEqual(flagApplier.applyCallCount, 1) + } + + func testAwaitReconciliationFailingTask() async throws { + class FakeClient: XCTestCase, ConfidenceResolveClient { + var resolveStats: Int = 0 + var resolvedValues: [ResolvedValue] = [] + + func resolve(ctx: ConfidenceStruct) async throws -> ResolvesResult { + self.resolveStats += 1 + if resolveStats == 1 { + // Delay to ensure the second putContext cancels this Task + try await Task.sleep(nanoseconds: 2_000_000_000) + XCTFail("This line shouldn't be reached as task is expected to be cancelled") + return .init(resolvedValues: [], resolveToken: "token") + } else { + if ctx["hello"] == .init(string: "world") { + return .init(resolvedValues: resolvedValues, resolveToken: "token") + } else { + return .init(resolvedValues: [], resolveToken: "token") + } + } + } + } + + let client = FakeClient() + client.resolvedValues = [ + ResolvedValue( + variant: "control", + value: .init(structure: ["size": .init(integer: 3)]), + flag: "flag", + resolveReason: .match) + ] + + let confidence = Confidence.Builder(clientSecret: "test") + .withContext(initialContext: ["targeting_key": .init(string: "user2")]) + .withFlagResolverClient(flagResolver: client) + .withFlagApplier(flagApplier: flagApplier) + .withStorage(storage: storage) + .build() + + confidence.putContext(context: ["hello": .init(string: "not-world")]) + try await Task.sleep(nanoseconds: 100_000_000) + Task { + confidence.putContext(context: ["hello": .init(string: "world")]) + } + try await Task.sleep(nanoseconds: 100_000_000) + await confidence.awaitReconciliation() + let evaluation = confidence.getEvaluation( + key: "flag.size", + defaultValue: 0 + ) + + XCTAssertEqual(client.resolveStats, 2) + XCTAssertEqual(evaluation.value, 3) + XCTAssertNil(evaluation.errorCode) + XCTAssertNil(evaluation.errorMessage) + XCTAssertEqual(evaluation.reason, .match) + XCTAssertEqual(evaluation.variant, "control") + await fulfillment(of: [flagApplier.applyExpectation], timeout: 1) + XCTAssertEqual(flagApplier.applyCallCount, 1) + } + + func testAwaitReconciliationFailingTaskAwait() async throws { + class FakeClient: XCTestCase, ConfidenceResolveClient { + var resolveStats: Int = 0 + var resolvedValues: [ResolvedValue] = [] + + func resolve(ctx: ConfidenceStruct) async throws -> ResolvesResult { + self.resolveStats += 1 + if resolveStats == 1 { + // Delay to ensure the second putContext cancels this Task + try await Task.sleep(nanoseconds: 2_000_000_000) + XCTFail("This line shouldn't be reached as task is expected to be cancelled") + return .init(resolvedValues: [], resolveToken: "token") + } else { + if ctx["hello"] == .init(string: "world") { + return .init(resolvedValues: resolvedValues, resolveToken: "token") + } else { + return .init(resolvedValues: [], resolveToken: "token") + } + } + } + } + + let client = FakeClient() + client.resolvedValues = [ + ResolvedValue( + variant: "control", + value: .init(structure: ["size": .init(integer: 3)]), + flag: "flag", + resolveReason: .match) + ] + + let confidence = Confidence.Builder(clientSecret: "test") + .withContext(initialContext: ["targeting_key": .init(string: "user2")]) + .withFlagResolverClient(flagResolver: client) + .withFlagApplier(flagApplier: flagApplier) + .withStorage(storage: storage) + .build() + + Task { + await confidence.putContextAndWait(context: ["hello": .init(string: "not-world")]) + } + try await Task.sleep(nanoseconds: 100_000_000) + Task { + await confidence.putContextAndWait(context: ["hello": .init(string: "world")]) + } + try await Task.sleep(nanoseconds: 100_000_000) + await confidence.awaitReconciliation() + let evaluation = confidence.getEvaluation( + key: "flag.size", + defaultValue: 0 + ) + + XCTAssertEqual(client.resolveStats, 2) + XCTAssertEqual(evaluation.value, 3) + XCTAssertNil(evaluation.errorCode) + XCTAssertNil(evaluation.errorMessage) + XCTAssertEqual(evaluation.reason, .match) + XCTAssertEqual(evaluation.variant, "control") + await fulfillment(of: [flagApplier.applyExpectation], timeout: 1) + XCTAssertEqual(flagApplier.applyCallCount, 1) + } + func testResolveBooleanFlag() async throws { class FakeClient: ConfidenceResolveClient { var resolveStats: Int = 0 @@ -697,15 +852,7 @@ class ConfidenceTest: XCTestCase { XCTAssertEqual(flagApplier.applyCallCount, 0) } - func testConcurrentActivate() async { - for _ in 1...100 { - Task { - await concurrentActivate() - } - } - } - - private func concurrentActivate() async { + func concurrentActivate() async { let confidence = Confidence.Builder(clientSecret: "test") .build() diff --git a/Tests/ConfidenceTests/Helpers/DebugLoggerFake.swift b/Tests/ConfidenceTests/Helpers/DebugLoggerFake.swift index 20d24a95..e2a53282 100644 --- a/Tests/ConfidenceTests/Helpers/DebugLoggerFake.swift +++ b/Tests/ConfidenceTests/Helpers/DebugLoggerFake.swift @@ -29,6 +29,14 @@ internal class DebugLoggerFake: DebugLogger { // no-op } + func logFlags(action: String, flag: String, resolveToken: String) { + // no-op + } + + func logFlags(action: String, context: ConfidenceStruct) { + // no-op + } + func getUploadBatchSuccessCount() -> Int { return uploadBatchSuccessCounter.get() } diff --git a/Tests/ConfidenceTests/TaskManagerTests.swift b/Tests/ConfidenceTests/TaskManagerTests.swift new file mode 100644 index 00000000..927c2aa6 --- /dev/null +++ b/Tests/ConfidenceTests/TaskManagerTests.swift @@ -0,0 +1,92 @@ +import Foundation +import XCTest +@testable import Confidence + +class TaskManagerTests: XCTestCase { + func testAwaitReconciliationCancelTask() async throws { + let signalManager = SignalManager() + let reconciliationExpectation = XCTestExpectation(description: "reconciliationExpectation") + let cancelTaskExpectation = XCTestExpectation(description: "cancelTaskExpectation") + let taskManager = TaskManager() + + let tenSeconds = Task { + do { + try await Task.sleep(nanoseconds: 10_000_000_000) + await signalManager.setSignal1(true) + } catch { + cancelTaskExpectation.fulfill() + } + } + taskManager.currentTask = tenSeconds + // Ensures the currentTask is set and has started + try await Task.sleep(nanoseconds: 100_000_000) + + Task { + await taskManager.awaitReconciliation() + reconciliationExpectation.fulfill() + } + tenSeconds.cancel() + await fulfillment(of: [cancelTaskExpectation, reconciliationExpectation], timeout: 1) + + let finalSignal1 = await signalManager.getSignal1() + + XCTAssertEqual(finalSignal1, false) + } + + func testOverrideTask() async throws { + let signalManager = SignalManager() + let cancelTaskExpectation = XCTestExpectation(description: "cancelTaskExpectation") + let secondTaskExpectation = XCTestExpectation(description: "secondTaskExpectation") + let taskManager = TaskManager() + + let tenSeconds1 = Task { + do { + try await Task.sleep(nanoseconds: 10_000_000_000) + await signalManager.setSignal1(true) + } catch { + cancelTaskExpectation.fulfill() + } + } + taskManager.currentTask = tenSeconds1 + // Ensures the currentTask is set and has started + try await Task.sleep(nanoseconds: 100_000_000) + + let tenSeconds2 = Task { + await signalManager.setSignal2(true) + secondTaskExpectation.fulfill() + } + taskManager.currentTask = tenSeconds2 + // Ensures the currentTask is set and has started + try await Task.sleep(nanoseconds: 100_000_000) + await taskManager.awaitReconciliation() + await fulfillment(of: [cancelTaskExpectation, secondTaskExpectation], timeout: 1) + + let finalSignal1 = await signalManager.getSignal1() + let finalSignal2 = await signalManager.getSignal2() + + XCTAssertEqual(finalSignal1, false) + XCTAssertEqual(finalSignal2, true) + } + + private actor SignalManager { + private var _signal1 = false + private var _signal2 = false + + // Functions to access and mutate `signal1` and `signal2` + func setSignal1(_ value: Bool) { + _signal1 = value + } + + func setSignal2(_ value: Bool) { + _signal2 = value + } + + func getSignal1() -> Bool { + return _signal1 + } + + func getSignal2() -> Bool { + return _signal2 + } + } +} diff --git a/api/Confidence_public_api.json b/api/Confidence_public_api.json index be9aaed5..141afc3e 100644 --- a/api/Confidence_public_api.json +++ b/api/Confidence_public_api.json @@ -12,7 +12,11 @@ }, { "name": "asyncFetch()", - "declaration": "public func asyncFetch()" + "declaration": "public func asyncFetch() async" + }, + { + "name": "isStorageEmpty()", + "declaration": "public func isStorageEmpty() -> Bool" }, { "name": "getEvaluation(key:defaultValue:)", @@ -23,24 +27,28 @@ "declaration": "public func getValue(key: String, defaultValue: T) -> T" }, { - "name": "contextChanges()", - "declaration": "public func contextChanges() -> AnyPublisher" + "name": "getContext()", + "declaration": "public func getContext() -> ConfidenceStruct" }, { - "name": "track(eventName:data:)", - "declaration": "public func track(eventName: String, data: ConfidenceStruct) throws" + "name": "putContextAndWait(key:value:)", + "declaration": "public func putContextAndWait(key: String, value: ConfidenceValue) async" }, { - "name": "track(producer:)", - "declaration": "public func track(producer: ConfidenceProducer)" + "name": "putContextAndWait(context:removedKeys:)", + "declaration": "public func putContextAndWait(context: ConfidenceStruct, removedKeys: [String] = []) async" }, { - "name": "flush()", - "declaration": "public func flush()" + "name": "putContextAndWait(context:)", + "declaration": "public func putContextAndWait(context: ConfidenceStruct) async" }, { - "name": "getContext()", - "declaration": "public func getContext() -> ConfidenceStruct" + "name": "removeContextAndWait(key:)", + "declaration": "public func removeContextAndWait(key: String) async" + }, + { + "name": "putContextLocal(context:removeKeys:)", + "declaration": "public func putContextLocal(context: ConfidenceStruct, removeKeys removedKeys: [String] = [])" }, { "name": "putContext(key:value:)", @@ -55,12 +63,32 @@ "declaration": "public func putContext(context: ConfidenceStruct, removeKeys removedKeys: [String] = [])" }, { - "name": "removeKey(key:)", - "declaration": "public func removeKey(key: String)" + "name": "removeContext(key:)", + "declaration": "public func removeContext(key: String)" + }, + { + "name": "putContext(context:removedKeys:)", + "declaration": "public func putContext(context: ConfidenceStruct, removedKeys: [String])" + }, + { + "name": "awaitReconciliation()", + "declaration": "public func awaitReconciliation() async" }, { "name": "withContext(_:)", "declaration": "public func withContext(_ context: ConfidenceStruct) -> ConfidenceEventSender" + }, + { + "name": "track(producer:)", + "declaration": "public func track(producer: ConfidenceProducer)" + }, + { + "name": "track(eventName:data:)", + "declaration": "public func track(eventName: String, data: ConfidenceStruct) throws" + }, + { + "name": "flush()", + "declaration": "public func flush()" } ] },