From 4baaf341da86acf2bfc622f62551a0ba14d970f7 Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Sat, 16 Aug 2025 16:50:53 +1000 Subject: [PATCH] Add spotlight integration --- mobile/Heygo.xcodeproj/project.pbxproj | 4 + .../UserInterfaceState.xcuserstate | Bin 37485 -> 42031 bytes mobile/Heygo/ContentView.swift | 35 ++- mobile/Heygo/HeygoApp.swift | 15 + .../Heygo/Utils/SpotlightSearchManager.swift | 291 ++++++++++++++++++ mobile/Heygo/ViewModels/LinkViewModel.swift | 35 +++ mobile/Heygo/Views/SpotlightDebugView.swift | 135 ++++++++ mobile/IMPLEMENTATION_SUMMARY.md | 141 +++++++++ mobile/SPOTLIGHT_SEARCH_README.md | 76 +++++ mobile/TESTING_GUIDE.md | 116 +++++++ 10 files changed, 847 insertions(+), 1 deletion(-) create mode 100644 mobile/Heygo/Utils/SpotlightSearchManager.swift create mode 100644 mobile/Heygo/Views/SpotlightDebugView.swift create mode 100644 mobile/IMPLEMENTATION_SUMMARY.md create mode 100644 mobile/SPOTLIGHT_SEARCH_README.md create mode 100644 mobile/TESTING_GUIDE.md diff --git a/mobile/Heygo.xcodeproj/project.pbxproj b/mobile/Heygo.xcodeproj/project.pbxproj index fea1327..bfd82b9 100644 --- a/mobile/Heygo.xcodeproj/project.pbxproj +++ b/mobile/Heygo.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 35CECEB72E503305000D0A0A /* SpotlightSearchManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35CECEB62E503305000D0A0A /* SpotlightSearchManager.swift */; }; B001 /* HeygoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F001 /* HeygoApp.swift */; }; B002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F002 /* ContentView.swift */; }; B003 /* LinkListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F003 /* LinkListView.swift */; }; @@ -30,6 +31,7 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 35CECEB62E503305000D0A0A /* SpotlightSearchManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotlightSearchManager.swift; sourceTree = ""; }; F001 /* HeygoApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeygoApp.swift; sourceTree = ""; }; F002 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; F003 /* LinkListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkListView.swift; sourceTree = ""; }; @@ -121,6 +123,7 @@ GUTIL /* Utils */ = { isa = PBXGroup; children = ( + 35CECEB62E503305000D0A0A /* SpotlightSearchManager.swift */, F009 /* TagManager.swift */, F00A /* URLTemplateProcessor.swift */, F00B /* MarkdownRenderer.swift */, @@ -216,6 +219,7 @@ B001 /* HeygoApp.swift in Sources */, B002 /* ContentView.swift in Sources */, B003 /* LinkListView.swift in Sources */, + 35CECEB72E503305000D0A0A /* SpotlightSearchManager.swift in Sources */, B004 /* LinkDetailView.swift in Sources */, B005 /* LinkFormView.swift in Sources */, B006 /* AnalyticsView.swift in Sources */, diff --git a/mobile/Heygo.xcodeproj/project.xcworkspace/xcuserdata/junv.xcuserdatad/UserInterfaceState.xcuserstate b/mobile/Heygo.xcodeproj/project.xcworkspace/xcuserdata/junv.xcuserdatad/UserInterfaceState.xcuserstate index f5a375a722f34d53a66636f35a4a6fbef94bd8ba..201205cb1b582965de697a220116f69580f0d5f5 100644 GIT binary patch delta 18670 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 delta 15749 zcmb`ubzoE18#kVNuHU-RmU>gSq@j{ZD%9OW+t8M@skMI?}Pi`zPKMAjmO}zcpM&&C*X-n zJPFUjrFcI)8_&TD@Bz3SFT_jmQoI(g!|U+|yaiX{WAI7%G<-Tf3!jTG#M|)>d>OtR zUxojKZ^QTEhw)?hZ}@5aJboE}gTKY!;qUPe_@DSk{1g5e|AKcCJc1y!2yH@#Fd=#o zri2Y)OV|s+XvVa^wD#&88oU9;g$Xc?IY$6AdgUMm!aPm8H5;>WiLQW-Tk&DPS zvYqT8mys*TmE<~d7rC3lXV@;s@$Kwcy-k=M!FvNEgxNbOqf&H`0^n$@CO@Dm{&!POIq|^h|mdJ)53K zE8FQ6^h$aay_w!ZZ>6`ZVk zh%shNm|l!2W5$>>y%`I}hH+xN7y%<>#7qDa$V4(xOf(b2BrqvVDwECRFpW$T)6BFm z1DQe0U}gx@$_!H2C{wG2v)}SXY<&6wt!U*VC8HfTf{2ZVz!*EW9!)lb_m|%B|yNBJ&{><)U_p=AsgX|&pFnfeO&Yos3vsc)w>>uo1_Bs24 zeaXIJ-?1M#El!)$;dD7YPMp2EoaBMa&DZE^X5cK&WH2og1B%lic8^Axil`F z%jR;pJZ=D2!j*CrTpd@>HF4i@leo#;6mBXvjhoJ?xf$F{ZWcG2`<|P_&E@8C^SK4w zB5oD8np?xI<$mJUaqGDa+%|4McYr&{9pVmiC%IGHIqnkokbA^E=ALj*xo6zVEcG!u z1e>&hW7cAnmC)APx6g*XsF$Sv@0*_PQ18*p-*DLI2rwL1SXWiv*d!F5!2nK>RIlFZ zwq7Bmp2vrhIQ5-gL8P8#$?F?x2UC{_bo6#)o9QWrHPi0UGBhvHcZ-nM)YLZFmXy~h zY@L(j4V8-8fi)4eHBH4eP15q>fzGz&HMWgKwROeq7S`>SR_b~qBVc2zF1EDG$l>Ml zig?w$T3$16Aa5vdB5ww74sSkh9d83~EAMCCe%=w@aoz{sCtfGUUCn=0mL}CI zimMv+0lM$z-t$zvK88wox}y?axf&R{OQABtzsjVyiBOq728*?{j4gc%^bHfsYbq0w zB{b!|8V8n_G`0H$wEK%Skm&^m@xz*?cJ-4V1|!uCmiacmSRfXH#bL=KFayj4vsAySPOHwU&Z(B126Mn% zRDLLW^8$6Gvk$%qw5fk^w!zy$yLy2$Keqe+VoVAasLrSct4?KUU}^mNAlBG*qgiv~ zKQFCAm9GaIz(zIWQiX2?+ot#Hh3ej}p6X&r?gYEQZuL5sDC3{OK3SwRGrGL0I91+M z)&cf|18UCI2|ol5tNXZmVn@_+SAo`1RNFCZJ2(M;RnK!R(>@7KWhG~1r6;z7lWIM; z)R=SN7SCfPI1es>i{KKt46cBy;2O9NZh)JrJE}iacUAXP_f-#64^@v;k5x}pPgjE5 zJO`d5&k@`M_rU}35FI_c#G*3rU1ZQw9UaxkX#W~e$)th!)8`n|2Ree#14Ug-M8{)lqy*h9s+!!}ey;Hqc zedxeVaWmXp{lsI8(gr{!|ZYpj}(bpd&E_^**81-lS?l;zDrX@eC zKAz&uQFArYlf>eh z(xx(l!1esFu8MfPo~r!u5T3_UT#N_cfp`!ej7uO!K#YVK1u+_848+)_cqq>i56AoB z5qKoTIF&BMw4l1)>xfA*o~D5>1y6;THpFz=@pL>xoh%UO=HmT%z1naY#0=W-JoQ9@ zqjeFk;PqOxNF_AD{=&Zuuhdjfj#sFE6-45Vc$3;&Xo|AAX0SW{5pOEzlq<%Ww-G=_#gOP{2s(a5bFamABg!v%nxGz5EDZz0Ahg<3xZhiO8h~$ z#^6uzr}#7cIr9IPJO_wLR5~hMjsJ&3ED2)C5X;e=o zK~My(`9LfbVqx9iswMhU^!Z;KOXv~?hy;Wlp%1aX5Q}Ih3<)EMA+E)!+v?-82{Xb% z!x9gow}$Ugi0_1zhVRiT;s0_xd_YgF_Jk8sFM>}v5RMRwg;*TK;yVau!UZ>nSOUZn z)h|3CLkM_!gb=q=_m=Y+q7ORpK_^jiQ3N3-LXkKS0Yo4XLAkR{d=ttxr zr695)maS2Wp9(Dki2|aqOBM(@#7rS3`~T^2W?`)Ykr_!WQBG8-k4buCmFl|^FXcBX zK-3Vme|u~(#0LDsV~J*snOcZ}#2{iY#LznnAyx!2#s7`N=KZGz{G0GS=9)^(y|h*+X|rj2MPItb(s zRS>I&7`k7pP7IwUAbujY@jR9j>xlKl24W+ziP%hRA+|!S9%2m;YlK)6#F`=20fPZM65=%PVLNe#I190%5QD1No_1d#u4vrs zB5{eh46$Jl8;+>nK}_R064y~LH1trZd-V-d5_gG*|5b82&w+S?I;+xw*a(%7A2#Br zZ=)UYf_TaEKw(3m-xIZnx5WFuoA?1@V^o4J?IS*E8u^*{0kD@Yv@arEC67@`W0uTRue@SWN}!cl2X zy7D~M>g$ocNej}Fv?8rZ8`74vBkf5(=|DP?PNXyG0keU z#AZQkHpIS%7&7-$tn#M{)#|kPIm%P52{k&iFH&0b}UtU(AAKdlSGH=6gpK9p7{iC`% z34VKhVE6Tv>b3-b<8ON~wEMI4L^3J0T>MiD%as)Y2lVYre*iR5! z2eI|4TT93>QOB&v89#CAh$55)E^CbyE? z$n7L*@6Qn12eJJSJFZs7KkGvtKrxFv2(bgIC1=PZDECJaMgBq_C6AHEa~g}Qii?`c zYmuZJhS(v9p@PQ{mO+km@h{?-o?iVx3^n02#LhqrDb+cMorlpK?Y@K{-&4loP~|2Ht?!%?=7hT?(aCw^W}Y zc3WLv?x*hbu~F|xb*F_qJ<1!!+$X6Hv=2J=MaO1oZoS1+P*>Ea0yR)HY;e8L1EnP?sqf{Cshf_+3{Ry#;?G&U&KynM#IQfM8vSV7-^gXQ(!G(vD6Hv)nDIrPPnzNFkjwg@F1Wq+F=A)H=jB z>L&=8HflWt9P-D%<%-(Ur6Yh!?H2VQf3kkJ>^VQ~OG7!t_);1_BlW-P1qY>@THPj;tQF0~qn0i7zrJhmGsTb5s>J{~xdPBX1z!(A(2zo()kTrwA9D?2uSU_M2 zffWSSE2*!%q&`ufsV|z?Mq|jGfX!ET0(KC1Lg3XE(m+60mTg4qBQ$9P2yDN)6K#y# z$uJ|fO2=_Xo*3`RH8E%ZQo5IvY4LcOGi(!=QCv=V|~2qX}M zKoAN+7zE*62I~t!1O$-~M6IM@H~Z<)_)~hUW?V#1&=@Sb$6&FVaS_Pq9v6XpbXicK z-#eqWxuK{yqE?~)-EAKd*A)7DjSc5O5YuDB`Tw6IrLUSschE~UU@WGWKoAE(d^^32 zUJe05Hmyr|>DBZ)jrrElYiY#RLEA7lv1R`> z_Cxxy#@LS_$otmVFX-3Z#zy*#7*_BF@V7t0s=V% zg%A`$pnw2*Q3(X45R^es4nf5V1|`NkG{?qpjFyI$jIPGYl|5FjhG1|vCx`TiJY$J0 z${^-deYGfK+r>)8nQ=pnWLy|TrWy!p+ZlJp1A;mT8a1rUW4sxkE{K^v8i?yrCm7`G zh?Nch9^Gmk`f3;^hzUi;W`Y?BgWR5LcOSrmx1*10fjH#Z|s~SE{qRwnoIB zVq$rEOdPVXR|%hC646N#I>{*!Su$x%W*3wUa$N*v>o={?7`leZWl9il7#Y)_$z$@F z0%id9k||`07zKmS8wSB}2oQl(5I{7$+r^ua5R8IgGz4Q-GNoVftfW3O)m@0zX%HRT zgXqLAp_|Bz%w}c_vz6J#Y=_``2#}=AgO3(EhHvvhm|pT=2=ncK`$#5?8=^9OU6xyRgR9xxA?N6cg92?R?ZSPH>1 z2#|LD2*C;nRzk1}g4GbLS;0K}>L;(5*UTHlyLTEdS=-|!>mb+#!EQ~Y*wgJJBMs3M zQyWV`@Y7cpVc9O;vHGkLY9wpG8bYuhf(`AgF^hsRQo`*Tx#Y9GSrjfiSPRyYwSr(1 z1e+n)(!tuWwrIo%wnDJ2Ykr7z=IOC6h$qKt9B6lR?17Ho*0}W+up-S1geW@$J5YAU z_CeVh*ooXo9aQUV5yXacfyAPm6@j!DfyDOd^0A#7kQ8hrn}R@Mqu6LRhK*(8*myR9 zO=OeUWC->_upfd05FCWy5Cn%II0C^h5FCZz*a|ka8zeT9`mCW6o2>!qcn?UwBPwm5r-YdLJfq&StW~zkKE)e1m`-~5x6-!3WD<*PF(m$#9=2O04E{Lt6HUBj+rQINb2!3_v*LU0R$+YsF8(yc!rK(DagTzp$MgkHa{G+B*o|L+}BDKOy*N3&E!@x#B2IM#!;W6NS%T zGs4cUjF2PwGSSsHQ{oD~u)&JyChZ^VkT=N!8{gmch%2oC;%MzXxRIHRedyrwiOws>%9?Ma><*9YP#-9a<0oG<5x7PFF@ ztE$>LU$mH|nV{gr$jCec6+lx(-IFO?Fo#x`x~{cxp%AUHbx+fAebL-TSIOAZ9$$+_ zb7;ic!NqW~TpYx;A+7^)-3~5+OXQLut_N`=i1$)2QcYBH8C({smCJ;)#Kp$u*T%7xU3tG&XDG&U}a)b~ikqD?km8ls6T(bNReQJq?v}MVcxL zA#U8pDIi+z>#35bhwaA>aOJA01^PikAKz&83K(L@RdUsu2dW@$+Q!vD+)Vu^3|Dr& zzk%!D{od)GY3RK@uW07bXm||Bs@0DEBLerfoQg2SGeoB`D(AIs%^UDeg3~;u-F&#)=AglY9d< zMxRjPl5Pv0=Pu+WXCyT@$qTEBks;+B+O!rJnx2HYtwzSbs!~S=XrEB#Kc$sKVhJq*O4d&UR?NZwrJOq1MNz?qdiMPv_q+eZl%9Ndy=N2{YW#=UZn5ox%7NAtXWI1 zqc_l-&}O7<^bWKcX%GF3q0vU8evFD~XLh5VKbM%x%ty4#$AYy&0o)Gl@^NIHSy$Gb z^<=})?w&{#WMff)RVK2@Y$}`1X0oMdN6%!ojopNH?Ob3Vu^%{!v*K*fJ{&&h$T_3k zI7k^d0osq#hl@unOhy}V(z#4dinik9a{ak{v>B%mZMxaZUDLv~OtoCKQnYHcrfIF! zI;nL{>w(rIttVQ~v|ebv()yy!(+1jvHl@udwK;7|?P%>9?P1z8wA-{7YcJLQQG2EK zYVAGR$F)yr|Em4F_C@VG+D~+F9Ztto$5zKx$4e(hCsU`NPL580oqU}EI@LNgI<-3W zI>U9w>WtT!s541tiq7{sb9LtHEYw+~)2_2k=Y-B(T})S3S5H@8*HG6}H&9oi8>-t^ zH&Ry_tt;29&>gEgUU#Bim|mP-gWh1hc70mkSl>q9PM@#usPC-rs_&!kr!UqI)DPAV z(GSxv)`$AD^>^u?(SM@!ywC` zpFxg+%plL8$zYs8o54=gY4F@o%h1@++pv$Jui-<(cSf9%wvn!p zzLBAku~9E0Go#){mPP_2Z=*g&zDE8=0Y*Va5~EO~aH9yLYNKgJ8;!0Qbs9SuCm7cl zFE!p^ywiBM@m}M7#s`c~82@H`()hITS>yA@PfdV{g^8a@nn}Ki+@#2)#H7rm!eq3` zWE15|lhr0`O@mBhO|wkGym4=|55Pcct3&oIw2?`J+hX*dxzT5q=AWBs%Be(QtQhpm6N zK4pEz`keIz>r2)zY)Bhxn*f_k8@Y|bro^V)rqZU`W}MA5n>98+*{ruMwXL%qW;?=m zlD>;~8s+9~Wx?1tMdvRiF;!0wRU5xb*y$L)T#``zx8-5I-cb{Fg}*}brPW%tJJ zo!tk!k9ME!dG@$HWzX7c+w0nU+vnTY*|*v+wBKxh#r`GVgzvx?^P~7V{3?D8zm7kc zKbk**KZ~zy<9G14@_*(Z;2+{2;h*K-;@{!l<=^K&&p^ zY#e+YA{#){gpTil4s}8pu?l|0Yc;Lu6 zIyuHW_IDiOIN5Qo<5tJ*jyoN9JMMMd?|8`Zh~rVm+e*i`PNb8Slh7&ADcLF2Dcvd4 zN$QmCG{H&jG~a2V(;}yKr^QZ7oi;e_b~@m6$mxjFQKy?u51m|h(p60A}p6Ptm`L6RP7tF=f#n#2$#nVOLQs7eN z(&Wo$EDrngv%+H&#s)Sp{up4t*gDOgKL#*vumrX%5|3OA=lHc4_zO-K6QQW z`qK5a>s#0Nu7A3Ia{c1Qb2D`_ce8M_ab8~QWc5`#{a1*$ByY+EPb*pt#yKQp2 z=+^1(=AP_c?f#wnkM8T;H@RK zT|NCg<2(~QGd<;=C7xxTm7aB;4W7-O13gE2PWPPYxzKZ^=T^@hp1VDN_B`Nu*z>68 zdCyCpS3IwK-ttub;d$Tlp=YO;wO5c=o>!~aY_COLtG(8HZSvacwaaU-*M6^qUdO#I zdR_OrH7t60$;Vp{`P>FEkVy3$27sLRX=OP#_cu zeT3n{NMW=vR+uNO5LO9mgmuDULZuK2M+&D3rweBYX9+umONGmYD}>L5pM{;?m^a}a z@15Z-_0I8@c`Lk?-Xpw6doT6=(R-!$8t--98@;!9Z};Bmeb)Pe_hs*^-q*cvdf)bb z?ES`D`QH1Z_ZJZ+!bSQbBaw;7ROBFX61j-nME;@xQIJR?iWen{l0~VaJkbDAp-3UB z7mX856ipUQ6U`9K7R?nc5G@jQh?a_e6zvu57abHG7X2bRF8WP$N_19qUUW%xMRZN{ zu}|+lA$hq(|DxWnz$9$gpeDvk{0$qOzA?V> zzDd3*zG=Qgd{_CN@IB{y+xM;Sdq2!i*U!$6@8{tc;1}%|=a=Z0;+O81<(KVO>{sTe ztnjP$tMhC0Yw;W8H_306-!FcD_~ZVL{vv;W|3H6Ri zLtuK~h`_ah*MiuffS}5txk1Z z&kJ4{+!nkzcvKB2j308AN(lzN$@)fPog6+ zmY7O_P>A(CMdm1Lx3jAXpzJINHubjeJ~_mX*%g_1VO zV#z_t5y>&huac9J(~`523zAEctCAa%+mgGI2a?BN*$F?2$xI&^vH%FxxJKZR}x-5k0tbZ6+E(0!o?Ll1|Z2|X8jA@p*X zRhV0tN0?Wbci4om*7V^-@bjL`VQ?ozVF1ollo4L(2uZEMz}<{M|eeeNBBheMTAF0M#MzKMJ#M`6%Z916&e*Al@OI2l@^s5C5;*rrQ8&C zCt5e!B|0QJCOSSkDLN&3Ky*cPb#z^HV{}XO>gdhU`=gIUAB+Aq`gHWU=nK)8qHjju zkNy%v$2iA$#U#flV(McWVj5$bV@AY`jhPTLDQ0@i%$Vb&idWt%&_D_NUl$v46&y z$BE;z;_~C!AL82Mmc%WKI~sQ{?seS9 zxG(WoJQ1%IuN$u)ZxnA59}`~|KP`T4{IdA%@jK%W#-ED+BmQ3e>-Z1xALG9y;0aWM zl1;_#}iUL?^^0BqpRKWG3`W$Vn(mXik`rFezbbf;wSV!kmQp z2|pyXCoD-=p0F!nZ^FKW0||!`en~i<@LR&kgfj`}5-udXNi9FlF4N4WW8jAWaH#s$)U*ulBXxnOJ1J5BY9Wyq2$xa zca!fYKTLj?{3*FJ1*Fg^T#8PLUW#>!dx}?zcZyGne@Z|~bV_VWd`eQvh?FTQ(^F=q zD8Emcm$D#bTgtwagDFQ+j;35sd71Jlr85;v1*v2zlWLM`nrfRGkQ$bnlUkcPG<9O? zys9rmXwy7 zmXRh+%Sr2>R*+VhR-9IrR+%;;ZFJh$wDD;Zm1&dHrlrkDo0ax`+Pt&{X+NaxOuL-c znQoUJm0p=XD}8(V#q{?XCK=Wlb{P&CP8os>aYkT9a7IW*SVrHBl#I-bei^wL1sR1I z#Tlg;qcWCdY|Pk}u_I%5#{P^$8NXy4%ea(rHREB%i;UM9?=t?(#4_Oo~hOrB+f~DPQU&b(OkH#nLEgyfjIg zD$SB+OJ&kLX_a)abgEP>oh6+koiF`C+OCu?kuI06l&+DklOB;Clb(?NCOs)VBRwy@ zB)uxVF1;naBfZ-X?`PjHx?fqp@A|Fpcdp-?Y~yV2?7(bEc35`b?1b#h?0(s~*?HLm zvJ10ovzxL9W)I0$W{=1oojo>tMfTzBGufB3uVvrNzLWhZ`)T&`>{r=ua-4HwbLw-3 z&7y(z(XDrn$Xyt#WO1`MFNH zeRBPB#koPbA-UnX5xG&hxw(ULXXS3pJ(v43w^K&QXc;HdlNriPWTr9)nYYYG<}VAB zNo1k2L|Kk3UnZ9+WaY9dS*@&Irjkuk%Gza1WXokMWou;XWE*8$WZPxCWP4?2Wfx?Z zWLIR@WH)7ZWOrrvWshV}WY79*_jm7~(!aj{%>Fz2U+@1V&nho4FCs5GFD@@3FEcMM zuOLsJSCm(rSDM$9H#l!-o-%J#-q^eedEe#zn0GwyV&1j98+o_$?&m$qdz$w=uQOi> z^7Zn2<(ucD{{)fml<$)7p6`_}%umZ7lD{f{Q~tjEv-#)qZ{$BI;1z%Ztpc+Gs{-2s zet}DYdx2MhupqP`v7o4+q@cW@s-U)@p`f{7P(f?K@B&ycs$gEh!h%Hw?FEYqmKCfh zSY5ETU|qq+g3Se63(gk29$+>ga6tZmQOW_!2OJ)7SB}fI<$7{Mxv|_z?j(1Wd&mWH zk=#e_Cl|}36bF@x!-`)NcZj; zc$N5<1e64oNJ>IW!b_4%GD@T+IVJfe@)AW!Ny+$u>lHW^C zm7FR0Tq-C{EzKz{DIHweS~{w9TIu4_rKKB6x0dcG-Br4;^kC_c(xaspOO;nj|0umz z`nvQ(>8H}pGNOzw;`?JB)0!zz<1vnq=!>nh(WaaCYda8*c^GQ29XDyAyF zDzPf1Dy=G`s;+8E)rP9GRUfKtsw1ikt6}xR>Sfg{s#jO9t=?MwbM^k}gVl$tf2lrJ zeWChl^^NM=)%U9(RX?qMUZYbZtdZ38t% z_?qu(rqoQUSzmLxR;RXCEx*=JSu3s$uT8F%*P{Q}RXeO!RXehFOzrsE?`o&i&aeHU zwykz??Xub*Ygg5-soh_DtG2VwsxF{TR##S6T~}AvSU0GywQhKws%}!<+`0vIi|RV+ zmewt=+f=u&?r`1Fx)XJ$>dw|(sJm46pzdwmhq_O7o%Nues5h)PtGBARt#_?g3hRCA zL+ZopBkH5-)9bV9`_<>x=hYX~m(^F+SJ&6p53L_rKc;?s{de{1`dRgJ>gU%ltlv}r zuEDm!vq9L>%7Ha%{7)yy{Q zH(NA2G&?psH~Tb8n!}nSnxmWJn$w%JnzNf_&H2p(ng=(}XkOcVtNDKOljdj5@0*pK zEue*LVOqEr(-!L%yB3EQ=N7LP?-rjH|CWH3!j`EmJ6m?Q9BVn%a<=6{%jK3^Eq}D! zZ+X=6q~&$X=YeFaajR*oZL3$SZ)z_j%hI$V58yY&auV&`hlE(v}dkUF*)&1M^J+$=y0Kr2<$^ZZW diff --git a/mobile/Heygo/ContentView.swift b/mobile/Heygo/ContentView.swift index 5ac4572..8d8f7bf 100644 --- a/mobile/Heygo/ContentView.swift +++ b/mobile/Heygo/ContentView.swift @@ -2,7 +2,7 @@ import SwiftUI struct ContentView: View { @Environment(\.managedObjectContext) private var viewContext - @StateObject private var linkViewModel = LinkViewModel() + @EnvironmentObject var linkViewModel: LinkViewModel var body: some View { TabView { @@ -23,12 +23,45 @@ struct ContentView: View { Image(systemName: "chart.bar") Text("analytics") } + + #if DEBUG + SpotlightDebugView() + .tabItem { + Image(systemName: "gear") + Text("spotlight") + } + #endif } .environmentObject(linkViewModel) + .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("ShowLinkContent"))) { notification in + // Handle showing link content when opened from Spotlight + if let link = notification.userInfo?["link"] as? Link { + handleShowLinkContent(link: link) + } + } + .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("ShowTemplateParameterInput"))) { notification in + // Handle showing template parameter input when opened from Spotlight + if let link = notification.userInfo?["link"] as? Link { + handleShowTemplateParameterInput(link: link) + } + } + } + + private func handleShowLinkContent(link: Link) { + // For custom/template links, you might want to show a modal or navigate to a detail view + // For now, this is a placeholder - you can implement the UI as needed + print("Showing content for link: \(link.alias)") + } + + private func handleShowTemplateParameterInput(link: Link) { + // For template links with parameters, show parameter input interface + // For now, this is a placeholder - you can implement the UI as needed + print("Showing template parameter input for link: \(link.alias)") } } #Preview { ContentView() .environment(\.managedObjectContext, PersistenceController.preview.container.viewContext) + .environmentObject(LinkViewModel()) } diff --git a/mobile/Heygo/HeygoApp.swift b/mobile/Heygo/HeygoApp.swift index 97add31..22849df 100644 --- a/mobile/Heygo/HeygoApp.swift +++ b/mobile/Heygo/HeygoApp.swift @@ -1,13 +1,28 @@ import SwiftUI +import CoreSpotlight @main struct HeygoApp: App { let persistenceController = PersistenceController.shared + @StateObject private var linkViewModel = LinkViewModel() var body: some Scene { WindowGroup { ContentView() .environment(\.managedObjectContext, persistenceController.container.viewContext) + .environmentObject(linkViewModel) + .onContinueUserActivity(CSSearchableItemActionType) { userActivity in + handleSpotlightSearch(userActivity: userActivity) + } } } + + private func handleSpotlightSearch(userActivity: NSUserActivity) { + guard let alias = SpotlightSearchManager.handleSpotlightSearchSelection(userActivity: userActivity) else { + return + } + + // Open the link from Spotlight search + SpotlightSearchManager.openLinkFromSpotlight(alias: alias, linkViewModel: linkViewModel) + } } diff --git a/mobile/Heygo/Utils/SpotlightSearchManager.swift b/mobile/Heygo/Utils/SpotlightSearchManager.swift new file mode 100644 index 0000000..22829b8 --- /dev/null +++ b/mobile/Heygo/Utils/SpotlightSearchManager.swift @@ -0,0 +1,291 @@ +import Foundation +import CoreSpotlight +import CoreData +import UIKit +import UniformTypeIdentifiers + +class SpotlightSearchManager { + static let shared = SpotlightSearchManager() + + private var lastFullIndexTime: Date? + private let minimumIndexInterval: TimeInterval = 300 // 5 minutes between full re-indexes + + private init() {} + + // MARK: - Permission and Setup + + /// Check if Spotlight indexing is available + func isSpotlightAvailable() -> Bool { + return CSSearchableIndex.isIndexingAvailable() + } + + /// Initialize Spotlight indexing with proper permissions + func initializeSpotlightIndexing() { + guard isSpotlightAvailable() else { + print("Spotlight indexing is not available on this device") + return + } + + // Clear any existing index first, then reindex + clearAllIndexedLinks() + + // Index all links after a short delay to ensure cleanup is complete + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + self.indexAllLinks() + } + } + + // MARK: - Indexing + + /// Index all links in Spotlight search + func indexAllLinks() { + // Rate limit full indexing to avoid overwhelming the system + if let lastIndex = lastFullIndexTime, + Date().timeIntervalSince(lastIndex) < minimumIndexInterval { + print("Skipping full index - too soon since last index") + return + } + + let context = PersistenceController.shared.container.viewContext + let request: NSFetchRequest = Link.fetchRequest() + + do { + let links = try context.fetch(request) + let searchableItems = links.compactMap { createSearchableItem(for: $0) } + + CSSearchableIndex.default().indexSearchableItems(searchableItems) { error in + if let error = error { + print("Failed to index links: \(error.localizedDescription)") + } else { + print("Successfully indexed \(searchableItems.count) links") + self.lastFullIndexTime = Date() + } + } + } catch { + print("Failed to fetch links for indexing: \(error.localizedDescription)") + } + } + + /// Index a single link + func indexLink(_ link: Link) { + guard let searchableItem = createSearchableItem(for: link) else { return } + + CSSearchableIndex.default().indexSearchableItems([searchableItem]) { error in + if let error = error { + print("Failed to index link \(link.alias): \(error.localizedDescription)") + } else { + print("Successfully indexed link: \(link.alias)") + } + } + } + + /// Remove a link from Spotlight index + func removeLink(withAlias alias: String) { + CSSearchableIndex.default().deleteSearchableItems(withIdentifiers: [alias]) { error in + if let error = error { + print("Failed to remove link \(alias) from index: \(error.localizedDescription)") + } else { + print("Successfully removed link \(alias) from index") + } + } + } + + /// Clear all indexed links + func clearAllIndexedLinks() { + CSSearchableIndex.default().deleteAllSearchableItems { error in + if let error = error { + print("Failed to clear all indexed links: \(error.localizedDescription)") + } else { + print("Successfully cleared all indexed links") + } + } + } + + // MARK: - Debug and Testing + + /// Get status of indexed items (for debugging) + func getIndexedItemsStatus(completion: @escaping (Int) -> Void) { + var query = CSSearchQuery(queryString: "contentType == 'public.url'", attributes: ["kMDItemTitle"]) + query = CSSearchQuery(queryString: "*", attributes: nil) + + var itemCount = 0 + + query.foundItemsHandler = { items in + itemCount += items.count + } + + query.completionHandler = { error in + DispatchQueue.main.async { + if let error = error { + print("Error querying indexed items: \(error.localizedDescription)") + completion(0) + } else { + completion(itemCount) + } + } + } + + query.start() + } + + // MARK: - Private Methods + + private func createSearchableItem(for link: Link) -> CSSearchableItem? { + let attributeSet = CSSearchableItemAttributeSet(contentType: UTType.url) + + // Set basic attributes + attributeSet.title = "Heygo: \(link.alias)" + + // Add URL if it's a link type + if link.linkTypeEnum == .link, let urlString = link.originalURL, let url = URL(string: urlString) { + attributeSet.contentURL = url + // Add URL as part of the content description for search + if let description = link.linkDescription, !description.isEmpty { + attributeSet.contentDescription = "\(description) - \(urlString)" + } else { + attributeSet.contentDescription = urlString + } + } else { + // For non-link types, just use the description + attributeSet.contentDescription = link.linkDescription ?? "" + } + + // Add text content for custom/template types + if let text = link.text, !text.isEmpty { + attributeSet.textContent = text + } + + // Add comprehensive keywords for better search + var keywords = [String]() + + // Add the alias as primary keyword + keywords.append(link.alias) + + // Add individual characters of alias for prefix matching + let aliasChars = Array(link.alias.lowercased()) + for i in 1...aliasChars.count { + let prefix = String(aliasChars[0.. String? { + guard userActivity.activityType == CSSearchableItemActionType, + let uniqueIdentifier = userActivity.userInfo?[CSSearchableItemActivityIdentifier] as? String else { + return nil + } + + return uniqueIdentifier // This is the link alias + } + + /// Open link and record click + static func openLinkFromSpotlight(alias: String, linkViewModel: LinkViewModel) { + // Find the link by alias + let context = PersistenceController.shared.container.viewContext + let request: NSFetchRequest = Link.fetchRequest() + request.predicate = NSPredicate(format: "alias == %@", alias) + request.fetchLimit = 1 + + do { + let links = try context.fetch(request) + guard let link = links.first else { + print("Link with alias '\(alias)' not found") + return + } + + // Record the click + linkViewModel.recordClick(for: link) + + // Handle different link types + switch link.linkTypeEnum { + case .link: + if let urlString = link.originalURL, let url = URL(string: urlString) { + // Open URL in default browser + if UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url, options: [:]) { success in + if !success { + print("Failed to open URL: \(url)") + } + } + } + } + case .template: + // For template links, check if they have parameters + if let urlString = link.originalURL, link.hasParametersInURL() { + // If template has parameters, show the app to handle parameter input + NotificationCenter.default.post( + name: NSNotification.Name("ShowTemplateParameterInput"), + object: nil, + userInfo: ["link": link] + ) + } else if let urlString = link.originalURL, let url = URL(string: urlString) { + // If no parameters, treat as regular link + if UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url, options: [:]) { success in + if !success { + print("Failed to open template URL: \(url)") + } + } + } + } + case .custom: + // For custom types, show the content in the app + NotificationCenter.default.post( + name: NSNotification.Name("ShowLinkContent"), + object: nil, + userInfo: ["link": link] + ) + } + + } catch { + print("Failed to fetch link: \(error.localizedDescription)") + } + } +} diff --git a/mobile/Heygo/ViewModels/LinkViewModel.swift b/mobile/Heygo/ViewModels/LinkViewModel.swift index a6e5f14..88083be 100644 --- a/mobile/Heygo/ViewModels/LinkViewModel.swift +++ b/mobile/Heygo/ViewModels/LinkViewModel.swift @@ -1,6 +1,7 @@ import Foundation import CoreData import Combine +import CoreSpotlight class LinkViewModel: ObservableObject { @Published var links: [Link] = [] @@ -51,6 +52,26 @@ class LinkViewModel: ObservableObject { init() { fetchLinks() + initializeSpotlightSearch() + } + + private func initializeSpotlightSearch() { + // Initialize Spotlight search indexing when the app starts + DispatchQueue.global(qos: .background).async { + SpotlightSearchManager.shared.initializeSpotlightIndexing() + } + } + + /// Manually refresh Spotlight search index + func refreshSpotlightIndex() { + DispatchQueue.global(qos: .background).async { + SpotlightSearchManager.shared.initializeSpotlightIndexing() + } + } + + /// Check Spotlight search status for debugging + func checkSpotlightStatus(completion: @escaping (Int) -> Void) { + SpotlightSearchManager.shared.getIndexedItemsStatus(completion: completion) } func fetchLinks() { @@ -102,6 +123,10 @@ class LinkViewModel: ObservableObject { do { try context.save() + + // Index the new link in Spotlight search + SpotlightSearchManager.shared.indexLink(newLink) + fetchLinks() return true } catch { @@ -141,6 +166,10 @@ class LinkViewModel: ObservableObject { do { try context.save() + + // Re-index the updated link in Spotlight search + SpotlightSearchManager.shared.indexLink(link) + fetchLinks() return true } catch { @@ -151,10 +180,16 @@ class LinkViewModel: ObservableObject { func deleteLink(_ link: Link) { let context = persistenceController.container.viewContext + let aliasToRemove = link.alias + context.delete(link) do { try context.save() + + // Remove the link from Spotlight search index + SpotlightSearchManager.shared.removeLink(withAlias: aliasToRemove) + fetchLinks() } catch { errorMessage = error.localizedDescription diff --git a/mobile/Heygo/Views/SpotlightDebugView.swift b/mobile/Heygo/Views/SpotlightDebugView.swift new file mode 100644 index 0000000..fdf4261 --- /dev/null +++ b/mobile/Heygo/Views/SpotlightDebugView.swift @@ -0,0 +1,135 @@ +import SwiftUI +import CoreSpotlight + +struct SpotlightDebugView: View { + @EnvironmentObject var linkViewModel: LinkViewModel + @State private var indexedItemsCount: Int = 0 + @State private var isSpotlightAvailable: Bool = false + @State private var showingAlert = false + @State private var alertMessage = "" + + var body: some View { + NavigationView { + VStack(spacing: 20) { + Section { + Text("Spotlight Search Status") + .font(.title2) + .fontWeight(.bold) + + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Spotlight Available:") + Spacer() + Text(isSpotlightAvailable ? "✅ Yes" : "❌ No") + .foregroundColor(isSpotlightAvailable ? .green : .red) + } + + HStack { + Text("Indexed Items:") + Spacer() + Text("\(indexedItemsCount)") + .fontWeight(.semibold) + } + + HStack { + Text("Total Links:") + Spacer() + Text("\(linkViewModel.links.count)") + .fontWeight(.semibold) + } + } + .padding() + .background(Color(.systemGray6)) + .cornerRadius(10) + } + + Section { + VStack(spacing: 15) { + Button("Refresh Spotlight Index") { + refreshSpotlightIndex() + } + .foregroundColor(.blue) + + Button("Check Index Status") { + checkIndexStatus() + } + .foregroundColor(.blue) + + Button("Clear All Indexed Items") { + clearSpotlightIndex() + } + .foregroundColor(.red) + } + } + + Section { + VStack(alignment: .leading, spacing: 10) { + Text("Instructions:") + .font(.headline) + + Text("1. Tap 'Refresh Spotlight Index' to index your links") + Text("2. Go to iOS home screen") + Text("3. Pull down to open Spotlight search") + Text("4. Type 'heygo g' to test search") + Text("5. Your links should appear in results") + } + .padding() + .background(Color(.systemBlue).opacity(0.1)) + .cornerRadius(10) + } + + Spacer() + } + .padding() + .navigationTitle("Spotlight Debug") + .onAppear { + checkSpotlightAvailability() + checkIndexStatus() + } + .alert("Spotlight Debug", isPresented: $showingAlert) { + Button("OK") { } + } message: { + Text(alertMessage) + } + } + } + + private func checkSpotlightAvailability() { + isSpotlightAvailable = SpotlightSearchManager.shared.isSpotlightAvailable() + } + + private func checkIndexStatus() { + linkViewModel.checkSpotlightStatus { count in + DispatchQueue.main.async { + self.indexedItemsCount = count + } + } + } + + private func refreshSpotlightIndex() { + linkViewModel.refreshSpotlightIndex() + alertMessage = "Spotlight index refresh initiated. Check status in a few seconds." + showingAlert = true + + // Check status after a delay + DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { + checkIndexStatus() + } + } + + private func clearSpotlightIndex() { + SpotlightSearchManager.shared.clearAllIndexedLinks() + alertMessage = "All indexed items cleared. You can refresh the index to re-add them." + showingAlert = true + + // Update count after clearing + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + indexedItemsCount = 0 + } + } +} + +#Preview { + SpotlightDebugView() + .environmentObject(LinkViewModel()) +} diff --git a/mobile/IMPLEMENTATION_SUMMARY.md b/mobile/IMPLEMENTATION_SUMMARY.md new file mode 100644 index 0000000..9b8c9fb --- /dev/null +++ b/mobile/IMPLEMENTATION_SUMMARY.md @@ -0,0 +1,141 @@ +# iOS Spotlight Search Integration - Implementation Summary + +## Files Created + +### 1. `/Utils/SpotlightSearchManager.swift` +- **Purpose**: Core Spotlight search integration manager +- **Key Features**: + - Indexes links in iOS Spotlight search + - Handles search result selection + - Manages link opening and click tracking + - Rate limiting and error handling + - Debug utilities + +### 2. `/Views/SpotlightDebugView.swift` +- **Purpose**: Debug interface for testing Spotlight integration +- **Key Features**: + - Shows Spotlight availability status + - Displays indexed items count + - Manual index refresh controls + - Testing instructions + +### 3. `/mobile/SPOTLIGHT_SEARCH_README.md` +- **Purpose**: User documentation for Spotlight search feature +- **Contents**: Usage instructions, examples, troubleshooting + +### 4. `/mobile/TESTING_GUIDE.md` +- **Purpose**: Developer testing guide +- **Contents**: Step-by-step testing procedures, verification steps + +## Files Modified + +### 1. `/ViewModels/LinkViewModel.swift` +**Changes Added**: +- Import `CoreSpotlight` +- `initializeSpotlightSearch()` - Initialize indexing on app start +- `refreshSpotlightIndex()` - Manual index refresh +- `checkSpotlightStatus()` - Debug status checking +- Auto-indexing in `createLink()`, `updateLink()`, `deleteLink()` + +### 2. `/HeygoApp.swift` +**Changes Added**: +- Import `CoreSpotlight` +- `@StateObject` for shared `LinkViewModel` +- `.onContinueUserActivity()` handler for Spotlight search results +- `handleSpotlightSearch()` method + +### 3. `/ContentView.swift` +**Changes Added**: +- Use `@EnvironmentObject` instead of `@StateObject` for `LinkViewModel` +- Notification handlers for custom/template link content +- Debug tab (conditional for DEBUG builds) +- `handleShowLinkContent()` and `handleShowTemplateParameterInput()` methods + +## Key Functionality Implemented + +### Automatic Indexing +- **When**: Links created, updated, or deleted +- **What**: Searchable items with rich metadata +- **Where**: iOS Spotlight search index + +### Search Features +- **Prefix Matching**: "heygo g" finds "google", "github", etc. +- **Tag Search**: Find links by associated tags +- **Rich Results**: Shows link type icons and descriptions +- **Ranking**: Frequently clicked links rank higher + +### Link Opening +- **Regular Links**: Open directly in Safari browser +- **Custom Links**: Open app to show content (placeholder) +- **Template Links**: Open app for parameter input (placeholder) + +### Click Tracking +- **Automatic**: Every Spotlight-opened link records a click +- **Integrated**: Uses existing `recordClick()` method +- **Analytics**: Click data appears in app analytics + +### Error Handling +- **Rate Limiting**: Prevents excessive re-indexing +- **Availability Check**: Verifies Spotlight support +- **Graceful Failures**: Continues working if indexing fails + +## Technical Architecture + +### Data Flow +1. **Link CRUD** → Auto-trigger indexing +2. **iOS Spotlight** → User searches "heygo term" +3. **User Selection** → App handles via `onContinueUserActivity` +4. **Link Opening** → Safari or app, plus click tracking + +### Integration Points +- **Core Data**: Links automatically indexed on changes +- **Core Spotlight**: iOS system search integration +- **UIKit**: URL opening in external browser +- **SwiftUI**: Notification-based custom content handling + +### Performance Considerations +- **Background Indexing**: Heavy operations on background queue +- **Rate Limiting**: 5-minute minimum between full re-indexes +- **Incremental Updates**: Individual links indexed immediately +- **Memory Efficient**: Streams search results, doesn't load all at once + +## User Experience + +### Discovery +- Users type "heygo" in iOS search to see all links +- Prefix searching: "heygo g" shows links starting with 'g' +- Tag-based discovery: Search by tag names + +### Quick Access +- One-tap access to frequently used links +- No need to open the app for regular link access +- Integrates with iOS muscle memory (pull-down search) + +### Analytics Integration +- All Spotlight-accessed links count toward usage analytics +- Maintains existing click tracking and statistics +- Helps identify most valuable links + +## Future Enhancement Opportunities + +### 1. Custom Link Content Display +- Implement rich content viewer for custom links +- Markdown rendering in modal or dedicated view +- Potential deep linking to specific content sections + +### 2. Template Parameter Input +- Create parameter input interface for template links +- Pre-fill with common values or history +- Quick actions for frequently used parameters + +### 3. Advanced Search Features +- Date-based filtering in search results +- Search within link content for custom links +- Saved searches or search shortcuts + +### 4. Enhanced Metadata +- Website favicon extraction for link icons +- Preview generation for link content +- Rich snippets with link statistics + +This implementation provides a solid foundation for iOS Spotlight search integration while maintaining the existing app functionality and user experience. diff --git a/mobile/SPOTLIGHT_SEARCH_README.md b/mobile/SPOTLIGHT_SEARCH_README.md new file mode 100644 index 0000000..c857ab0 --- /dev/null +++ b/mobile/SPOTLIGHT_SEARCH_README.md @@ -0,0 +1,76 @@ +# iOS Spotlight Search Integration for Heygo + +## Overview + +The Heygo iOS app now supports system-wide Spotlight search integration. Users can search for their links directly from the iOS search screen without opening the app. + +## How It Works + +### Searching for Links +1. Pull down from the home screen or swipe right to access iOS Spotlight search +2. Type "heygo" followed by a space and then the beginning of your link alias + - Example: "heygo g" will show links that start with 'g' or contain 'g' + - Example: "heygo github" will show links with "github" in the alias, description, or tags + +### Opening Links +When you tap on a search result: +- **Regular Links**: Opens the URL directly in your default browser +- **Template Links**: + - If no parameters needed: Opens directly in browser + - If parameters needed: Opens the app to input parameters +- **Custom Links**: Opens the app to display the custom content + +### Click Tracking +Every time you open a link through Spotlight search, the app automatically: +- Increments the click counter for that link +- Records a click log entry with timestamp +- Updates the link's ranking for future searches + +## Technical Implementation + +### Files Modified/Created +1. **`Utils/SpotlightSearchManager.swift`** - Core Spotlight integration logic +2. **`ViewModels/LinkViewModel.swift`** - Added automatic indexing when links are created/updated/deleted +3. **`HeygoApp.swift`** - Added user activity handling for Spotlight search results +4. **`ContentView.swift`** - Added notification handling for custom/template links + +### Key Features +- **Automatic Indexing**: Links are automatically indexed when created or updated +- **Prefix Matching**: Search supports partial matches (typing "g" finds "github", "google", etc.) +- **Ranking**: More frequently clicked links rank higher in search results +- **Rich Metadata**: Search results show link type icons and descriptions +- **Clean Removal**: Deleted links are automatically removed from search index + +### Search Optimization +The search implementation includes: +- App identifier keywords ("heygo", "link") for filtering +- Alias prefix matching for quick access +- Tag-based searching +- Link type classification +- Click-based ranking + +## Usage Examples + +### Quick Link Access +- Search: "heygo g" → Shows all links starting with 'g' +- Search: "heygo gmail" → Shows gmail-related links +- Search: "heygo" → Shows all your links + +### Tag-Based Search +- Search: "heygo work" → Shows links tagged with "work" +- Search: "heygo social" → Shows links tagged with "social" + +## Troubleshooting + +### Links Not Appearing in Search +1. Make sure the app has been opened recently (triggers indexing) +2. Try searching with "heygo" prefix +3. Check if Spotlight indexing is enabled in iOS Settings → Siri & Search → Heygo + +### Search Results Not Opening Correctly +1. Ensure the app is installed and not deleted +2. Check if the link still exists in your collection +3. For template links, make sure parameters are properly configured + +## Privacy Note +Link indexing happens locally on your device. Your links are not sent to Apple's servers beyond what's necessary for local Spotlight functionality. diff --git a/mobile/TESTING_GUIDE.md b/mobile/TESTING_GUIDE.md new file mode 100644 index 0000000..69401cd --- /dev/null +++ b/mobile/TESTING_GUIDE.md @@ -0,0 +1,116 @@ +# Testing iOS Spotlight Search Integration + +## Quick Test Guide + +### 1. Build and Run the App +1. Build the Heygo iOS app in Xcode +2. Install it on a physical device or simulator (iOS 9.0+) +3. Open the app and create a few test links if you don't have any + +### 2. Test Basic Functionality +1. Create a test link with alias "google" pointing to "https://google.com" +2. Go to the debug tab (gear icon - only visible in debug builds) +3. Tap "Refresh Spotlight Index" +4. Wait for the success message + +### 3. Test Spotlight Search +1. Go to the iOS home screen +2. Pull down from the top to open Spotlight search +3. Type "heygo g" +4. You should see your "google" link appear in the search results +5. Tap on the result - it should open Google in Safari and increment the click counter + +### 4. Verify Click Tracking +1. After opening a link from Spotlight, return to the Heygo app +2. Go to the Analytics tab +3. Verify that the click count for the link has increased +4. Check that a new click log entry was created + +### 5. Test Different Link Types + +#### Regular Links +- Create a link with alias "github" → "https://github.com" +- Search "heygo github" in Spotlight +- Should open GitHub in Safari + +#### Custom Links +- Create a custom link with some markdown content +- Search for it in Spotlight +- Should open the app and show the content (placeholder for now) + +#### Template Links +- Create a template link with URL containing parameters like "https://google.com/search?q={query}" +- Search for it in Spotlight +- Should open the app for parameter input (placeholder for now) + +### 6. Advanced Testing + +#### Prefix Search +- Create links: "gmail", "github", "google" +- Search "heygo g" - should show all three +- Search "heygo git" - should show only github + +#### Tag-Based Search +- Create a link with tags "work", "social" +- Search "heygo work" - should find the tagged link + +#### Ranking Test +- Create two similar links +- Click one multiple times through the app +- Search for both - the frequently clicked one should appear higher + +## Debugging Issues + +### Links Not Appearing in Search +1. Check the debug tab - verify "Spotlight Available" shows ✅ +2. Verify "Indexed Items" count matches your link count +3. Try refreshing the index +4. Make sure you're using the "heygo" prefix in your search + +### Links Not Opening Correctly +1. Check that the link exists in the app +2. Verify the URL is properly formatted for regular links +3. Check the debug console for error messages + +### Performance Issues +1. The system rate-limits full re-indexing to every 5 minutes +2. Individual link updates are indexed immediately +3. Large link collections (100+) may take a few seconds to index + +## Technical Verification + +### Check Spotlight Index Status +1. Use the debug tab to see indexed item count +2. Compare with total link count in the app +3. Use "Clear All Indexed Items" to reset if needed + +### Verify Core Data Integration +1. Create a new link → should auto-index +2. Edit an existing link → should re-index +3. Delete a link → should remove from index + +### Test Error Handling +1. Try searching when network is off (should still work - local index) +2. Force-quit the app during indexing (should recover on next launch) +3. Delete the app and reinstall (should rebuild index) + +## Expected Behavior Summary + +| Action | Expected Result | +|--------|----------------| +| Create link | Automatically indexed in Spotlight | +| Edit link | Re-indexed with updated information | +| Delete link | Removed from Spotlight index | +| Search "heygo [term]" | Shows matching links | +| Tap search result | Opens link and records click | +| Regular link | Opens in Safari | +| Custom link | Opens app (placeholder) | +| Template link | Opens app for parameters (placeholder) | + +## Known Limitations + +1. Custom and template link handling shows placeholders - UI implementation needed +2. Template parameter input is not yet implemented - shows placeholder +3. Search results limited by iOS Spotlight constraints +4. Indexing happens on background thread - may have slight delay +5. Rate limiting prevents excessive re-indexing