From adc6133d63d27c888db30bb148f66bd20b442c87 Mon Sep 17 00:00:00 2001 From: Jonathan da Silva Santos Date: Fri, 3 Jul 2026 16:16:07 -0300 Subject: [PATCH 1/2] Add Gradle wrapper, fix NDK for Apple Silicon hosts, enable Kotlin jvm-default - Pin Gradle 8.13 via wrapper (AGP 8.11.1 requires >= 8.13; the repo ships no wrapper, so builds depend on whichever system Gradle is installed). - Bump ndkVersion 21.4.7075529 -> 27.2.12479018 in the braille modules: NDK r21 has no darwin-arm64 toolchain and fails on Apple Silicon hosts with "Unknown host CPU architecture: arm64". - Compile Kotlin with -Xjvm-default=all so Kotlin interface defaults are emitted as real Java default methods (required for Java implementers of interfaces that migrate to Kotlin). --- braille/brltty/build.gradle | 2 +- braille/translate/build.gradle | 2 +- gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 48462 bytes gradle/wrapper/gradle-wrapper.properties | 9 + gradlew | 248 +++++++++++++++++++++++ gradlew.bat | 82 ++++++++ shared.gradle | 10 + 7 files changed, 351 insertions(+), 2 deletions(-) create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat diff --git a/braille/brltty/build.gradle b/braille/brltty/build.gradle index e4a4865e2..04f298603 100644 --- a/braille/brltty/build.gradle +++ b/braille/brltty/build.gradle @@ -3,7 +3,7 @@ apply from: "../../shared.gradle" android { namespace "com.google.android.accessibility.braille.brltty" - ndkVersion "21.4.7075529" + ndkVersion "27.2.12479018" externalNativeBuild { ndkBuild { path file('src/phone/jni/Android.mk') diff --git a/braille/translate/build.gradle b/braille/translate/build.gradle index 0b32e6a8c..65e4026fe 100644 --- a/braille/translate/build.gradle +++ b/braille/translate/build.gradle @@ -3,7 +3,7 @@ apply from: "../../shared.gradle" android { namespace "com.google.android.accessibility.braille.translate" - ndkVersion "21.4.7075529" + ndkVersion "27.2.12479018" externalNativeBuild { ndkBuild { path file('src/phone/jni/Android.mk') diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..b1b8ef56b44f16b14dc800fa8103a6d89abb526f GIT binary patch literal 48462 zcma&NV{|3jwk;gnwr$(CRk3Z`Sy9Ed?Nn^ruGlsztklcC=e7I2x9>aqJFB(1eyu-q z%|3b`eLzVT6buar3JMAc2#EOW{C^)LAZQ?YaW!FjX$1*JIcZUG1yyl%HEd!6f#E+}*Jo*NafvM<-FbE0;-_L#rp}qdn%JEoAVNlEB#J^Oq`mU_#*ev4HLmc> zjXz_hFft^><#omb;Zer-%wm4hxo!wjuX3hBldg(^-RiOleKin`>KHfL3P*{k?(rji(#j2Cc0K509#>qu=-T&B!-5EBi(+ zIuTD-qfcAYgS@`Fb2^-p)4#o6A3z0&fp?~cV=CRsAeCmO4ZQ5kKgC%0el=Q&Rhd#k zaGmAbUW8uKC}-C0s~2);d{;mpsNBx9rn__66W{AhaSvJEK+c0b6ARO+l(CI7E|S5x zhaYP--@F<|99X&)9`q^2(^-Zu^Tzfm)v|gkTJHQ!G*zIg5hzoygeXZoYUEJ;iFkE# zq^r$*c|>Hmn3GapzcDYnjgSFiO^NFyTR5AH#mh%zRToMpEi(r)1$5)h455DuV}0al z!*psWuL@Ke-2gvftfMEGf9YEi^<{B@qru zINgo+YsE&LN?)1qItJoNhISp-fZ86`XR#*6xcvM~_7=JHUX;K9*=Gu5X~ zix|O2d=&C#u_w{=B$eCpJ4L*6i7={j+{Og~`Emz@&98}6s<-p^)`0fXE4cJBP{>)Ltb>JwcqI>yz z0-r-SEhC@p)XOoh|1|XgjFaREHfsu4dAGVz*k#m+V<4 zHqvlud6=;#QWHUoTR_a8Y8+heN?M%n1@0YLiaN@GuOPNd26tik7eKulTx?mM-R!1H znB6+H{^krFXg_b{y=QeCT~qR3T4}l+b!Oz9;~|3*6F<3?#|DYYW&1RtFE)ILZ!`85 zVmvrZkLTzf31unH7Cc5E0iFShqlBE9hgEnRJH1juII*vyp&xd!g`q}X_6WT6E$hhQ`Vdp9k^<)VS?lj!cTh z7FQcQAVA@jL^cXod8cnhKG2TS9+;QU6Kq>}UOY3&TL9gXbl{Fv8@WsF=z7>X0To@$ zY@Oi1uc|MdJ$>Kn{@!g_e`-I&Tpwfg9cr>(iakDX1qciCG_1y!Di#4_)lE!bWJbrp z5aUonb6m-?tiQyR_`P#~SOu+tb_ev6JO>EbEhHK@KbeT0_FDo>dl9bMg)>xmCNB*g zG5NC8ABavuTEZVGW6jP*nAqRt3W?7Iigc-EE~zpNJXRAE z>`~RO9$892j&I1kV;9U)xT8^}IeV`n{}QDtj2o-RBt`DGZUOO;O*lFCb_vpyGh*;95PfeGu!dyrmZ9VJ3Z*upg z6R-3Lr%_55$Hw1^{+KWx0#z`T7O6sXo1h;m?B_ur`X2bFz-SzDrL zpk^@B<+I6imc@7vip za%1jMB7q@1j# zz{u?YojZMW{5j$@h=v4iu2mTu7IzI|)Sxn!74=*J>1a&?Xjt z2%JhSi#4huEcD9qdR9Lj4vwmfnL{%+vQ{f-KgYeqin(OPd8+(g*Uq#TLxQjD4 zLCL%ul(V&PAPlAx8D`@K8Rc`{GPecQ<)d=KWel0ejFeeXGQ6o7601B!!I@RY&eDriADD6wP6DcFKDLZ|lO#YwnrNCZ)zRJpdxX_nPZa4j#$j6v!h|6p!dH}MY6#B`@%6=) z-HigguDACKBULnon^FKzazF|Y1{t(U5rUGnEU|}djVsWT-F>@@mNx?_$kF51QF4C5 zStKR$^3(fw85(4HGs9{mUTtn1)3PwxTN?6}j;32&vJ^BiPHfndLkdU5sOemXKGyCZ z@<7j(k>DNeo~QXyJkFWk!7(y1SB%nA3{v~P2c8ooKa4auM!el!Q_=;lJ$c5ADqE+^ zX8*|A99v;jWPrm(8=h;2ZAj|(vVbx~wQ{N%v;eYLD_BB2LAEWCs@xauyBDl(_HIBvA(XJ7B1E;O zJYCJ8xFJh7f5sr;Y#Wp_`$4Z_H4e9bGiBp?Qu&2!@%Bl2dT5evfFO*^hLDiBu2%Jl z*WAlL5PaQ7skJa(qVysky}DQquZ8U?2@UyJ8zB#=U_E>MgE%XA$CtfL31m$rATJvC zs@!crc0=128PM=Zp zW_5Czv9))n_8Ru?{pxM2F8^r%*O41}RnONbSj*piG%`nyF>6ky=|;B&k8iot(J=kyoU3p<_zaAX(1ijzf*uXA zZ_5jeC{Lks+&QeFIlmzZi3+fsF4fNW^~kvC4Q*T-vrNP!x9xnen12lZQM=1_MdW76LKX(GuW`%T~dM^YX6+ras|Xy4Qhfcq=D+z-P-ea z`T;^gj3+grr3^hwqcNTJErl$z+k>{bYFm6QV%7Opth?9+>|Dn)O@`7F@=j-XSqGPW zjUAu%b3Er@;j1%RZxVDhI3sakg-gvTLOSV7;FV6ED=(5;UG??=WADZw^=$4AyFh#}VMe3afM^pF zFa}-nM8X=K?Jy02*o02@6k{ z%O!hBhjXlXKdhy3A{xGB<##e|j3^dFv~~%v2_H{t(mN7NVeS~51?D&Ozbxa`qwZ_4 z;C#Q#fL1sua%ggucgIEHZtcY=Ag&GgE|h7Q{77D!WUq`;SSGEE0pU;aoj<7-JCAvf zduN=(tx3Mb+EUXKoax|v;8b@#HJ&Q|!g4ryrl|R>WlAv?IH`bk)I24;eE4NIq@SLK31LD4+w~#3iN{=<`<1R!t^$@K5>U6%W=%8_ANuR5 zs(IDuI18ftirTDARnGmF%;iz+4{MlMihJw_l!0Y)NttXC_t+s)V<EY>=Xin*nGX79k6vQ?beRk zy_J>@YSC_gMIG$yjO-y&o>S6xtfT27aSs>e|`x(f2R1bM}*518~%x>1Yct=18b&Z>GiS*>VB$+i2876zL)1cT zN33g=g|>xWE2)dds5m2+8Vy)m-u@NHOlGYxxjam21r1;xWtT0TgqKZrl}*LSkqFt4 zNTI1=3o%C*!-i;iWnlca$stRdwITA1?#fD~5OIqIQAM18BwO_u>hqL&OAANiF|8rG z_IZ9mp?FA-{Gq9+Ky<#NgL1gWJixfO0ziP$4T4G>vsvqC-NQh+A64F4! z-(t<=AbPSG%`mTl6BJtH~3RmvPhQlE-EUkEoBIP(_WMN zK~Fe!siee{M*ns1hkp5(2}vX#%u+T!Abh=<_gEx_QW?h4V@B>uOCEetEe01tl)^`V z(=cOLmuOB;8&&m%_6pcyrt83UXkJ`f9I&0KxY09}RTTs!l^_7~8$tPA%Hm#&$k0;# zF;O0zCGo0IN)X~SyKDoY1DW{Ulce|V9w=ld;U`z$t$>8U!Gu8V?_LAJAudt3eI#*! z2i9~F=kP5m>!bmb%1e~b1!1gz01Py(Yw5gOsFN#o1a&d|=PpgN(#UVreY9^99I0iG zaYE@>(C^V7pnoB~#w$2C1_TIb1N5Je&iao?S2A*TF>@vpHg`31{uk<9{zf_}s&z%dL-Fo)C$yl$%pAdqU!HJgp zh_{m1imk{&{ScyeuziqZHu5cto0{S}^BlXu% z0~;>_yHGd#?Kt8ErxK)z6ojj5SacQobw)-8`c!$HOI*V6eyqou{1Upm%_p!BY^t(D zDtn(oQ!jff`ddGSD;P8Hes!v)OKW-*>mS&#i0ow87;h>(=Cu0>b4)|=EegbN5=Xkh z9Ge13=3z#sk+fT<)PuUUf_%Nx@l!P?t*mni^94p^Ax6b2SVL5U>9dHH!H4DL4}@?@ z?Gpq$C**OmWliYA{5s<|EZ@QI2{-K#brFxfA~AIqq&-WSALHWQ8}%mvaNFasrtnE{ zg=sB4-RF!?)nf{>Wo~kNFgYefoFHBcSr*;iF9B!R=5Np|jv>Uf+mcarG-XGy*kP{z zISVyoPcl_9cOg-@613Qx16OGF#sH&2NTHDa_}vyidmxS~pMfY#AeQvu?AXpWNzi7A z*6&7a7!C9HRU+N{>WYTh0GXoBnXw{lQby^XShgDOw@e8TP}9Y*oFV4MVF#@Ds2A+A zXBEt3a@-IIl)TOcXx;0P;|ihR%Tq@DXeG5p-O{!T7Sg$s1 z8OA4iOx-!>6eK^x{jU-0SvByimK|nZik5zKIvvWVGE)4=x^&5Nx%Qgje!k3VoizaB zip#?$u(R8u{wUFC>tVR8oA%7fs?xEu(gYn>y6BB%vwPR9&RoZE%%RK! zl#Qnkl^+Y*Y4L{Xk(YX&aGj|zSpqO_;C3CTepA!L#4EXO|(eA`Fi+2EQ3!C zo^SpVP?{chQ3uaxu7y>w213e22cdA#l-M2kStPE%sq6vE4M*?3At!S7tIp(tQg(Ml zECjeJw8)*#LYYk_+Txv3rxsH9jJZBRrHp29yJ(^;_PEdn%#U1q`r89}38;XeF{ee& zsZEsUbJ{LtwOjU{vjL(Wvs2!Bx;#^Mzld&TjS@oo3kk=0P36MC-Ie6eHNN&{8b^s z0@jcbdejrrj!>r#Wu=3H1dgjeOI}NkhmE}K+UK&M>%7b!n&{0Zixk%^)6#@=V~IZN zxG>9kl&STQth}qScidfg58d2dF|v_U<@+V^eE@$4x;7oS3)MvWusA?9+%rN>aY#eA_6 zic@S(@e9$9tQM-&-7>X8~#n{5G}nuOu=dSyN+b~jA;_SExZ1H9Q1A}}Rz;XtXUIOP0~ zZzS|~T+%de-nGI$s?wxaJoe+99vmo%xm8o8SNEsAqAE)4LNvHc-1AX24C4k4u3vZmov^_VcxgGxapV(8)_K(^8= z2d{xCrmk(x&514Ly?e{Mf6}h3=oeP7+ZE{%B^c-kK8g0W{tYw3q%zty_Rd@1nbnyHMwabNp-sSyzpV4v>QsnKcQjF67%g~n&3t^1MesVxCzfJ5b=SOI#YfPP^^JGQw=9L1RCMFbrU{8O0LWOUdBK#j&{`tzXX zpe2_{+-8$a+o#%8MUlL4$yK`*--z&3{@Y?jP!m{g5nM+Ht=bD3o}Ok~sBQ_!^!->! z?NDVtyLXzmGYCEmjSCDK*q?Aq1;8fz9l9|z@~l{)R6GfKELc^(nV+TjjI^n0M+S0i z@YOu*Tk>|M6a0_n$(E;#^1Zgif<-CpYiMvyT+Y*9Z?&~IKSwsLa5Q#p_?FqK3lKIw zlp6Hk%lio6)yq>m-`QT2Nj-q!aX7~Hlm^Xh6FNbw z$#ri(Kk*GUHXORu@`aYQU@ zB~S-oIO^~abRPocemkm!W73dbb!j^_xgo_@#W#6p12>w^{){VfeX?U71Xyn9&E zHa1#*!4c;?r}jv7dMN`g#&R_S215)dccDOJr=uz%LIz@zia+LIFjRakROr?P zQ|Xw0Pa8o7&W=fw17`+SqepsQ-Os5v3ncD5|N?N(AHH&`>hLY+CLOluJ z_ErpaT49zK(UcdNmQ%iA-`jS`A_1c|$W86{d_T_T2V-HH3xUqpX0QJSH%i>1i>#vK z&y{;5)^pMB=u;&_DEWakQU>j&+opIrBf~2GUh{`kG{|Z&2Z}5dwG}>Y{W_uQHaR$_ zYH%}$c`CGC-FGCetRdQ@RZ2-%ucC_|R?mHzYEnqC%u9zRBH8wx7po`=EVPMpq+hL2 zTdjVhQn$)++17^cn;<3=bxJy0Z$U;i3AqJMPJO&SuieU&0eVX?eLEEI7Av@#PV_ZQ zsa>I>B5HE996O$z6HyJfhEt^aC><@AnzeN`xs@lv>^pPFtcodrcGyqPSB?#C`Piu0 zh5=hAW|OtT9hs*G?7}@*mG_f7ae@-Nz4{qvne66kco^uD$(JbCo2ttqUm-SMy@kx% z!eDt?5>w5)M!E#C!b#Iu9GqyhUs|QoYWHtR{4espRS-LUt=viY2iygF=-j3kcU#uF z{ka2=zsOuLR}s;&PbbrB`zty&NfZpV*Y;~i*W$EH0JOGS&FMS%VK@)f*%OOrcU3P9 zq4zjhMpx}oc`PWtP!o5Bdlp=(A***TZwVwuZbuB1Pibv5uiHvW{PsE-k5IfCgUz~l z0nMeZU0R>(ajoQ0G%Il)z0BgRR*bsdz5NcqJ<)niF6|PUO0i}<4)q>6wx4K(5>Y_I z4$WMkbCOQFs(krBnl zx85i0*7%Zm(&nKNP?AQ}d~6@?D9dO%@}ouN2paSR;zyUqJuw)1SRy=g%o;g(BD|Bh ztnKV(4fcBgDJ~M@%}n-6ow3xOhnC>C^d?PbS(9=TnO)k5p+W;pu2F4eiG7ts zJVL4M(NiZPQDy*9`H>-P0GWY#=UTnh8feiNF}hCs`8^ZDKy;XIL^9K4Ps&y^#DQSE z-?J z@YOQ9NQi>ZP>^ix5K`R07kWj?`R(B?E*OyR1$Vd;8p%2Y2zEYt4CJM~gVX%MO(E1B zzXhsHn~R1ifq9~dtzuH!*3&W;r`D(Sjrc)m#EI%`Car;CMWcU0c+0r?O!)HpjEvyP zb^;pO-Bn6e-+>dS^o{q&8yEH9v}vuXX`W;NPRlwJdX|59`z?~z{pFE!^u{3k{KkJ55^ zD;F0ldy9W*`d5YP|0(E6|K%}9|D^SIq>wO)4^cJ+yCa&xl*3}hpvcQ1eP_k;@>tz= zOZnw)#fxHc81jPcTM#)jgy|0?n0(jd3IPu-lJ&Tm`#F1)o$GTwYp@dlqy-qiHFCHS zKgikMUx|%x=_%B)>n_y^+HvD2=nP`}-G_0A7)I$yc4`tXS-On8qOkNp>Q^$|Ew%Jm zYx34*(*Z3SF}xw$CA?nG9O3ZH7l)@Dp4EyH>8eXDb}AFz)k*T53iA~gRu&e15u@|% z9Rw?69nQOeJhv^^unjd-VGFwbDzf9K{i(U{xxHyM@-aI+0qP{TU0G~w+Fs>taL#Ik z4+92(Z7n%+okd478;__0GkE`&(C`k8h@?UNnM=F%A~2|TKo)q9F<5`s)KwxJRw~k; z4giS~|8AIVG;rde6I^W6m9fliR^7YT*>&x7wv^?xu(5p45n{|2F>x%?9Jq+~Tqo9# zChbeGm@9!(s;uIKae_4h@`~yIj`Tqct+-M>d>~2PCiQ?UmFUioyy&~h_DTBQ--W|q zqA^UaJMTz4tEggQ*_cQ_LA7j7bLyz8#cpGggy;YBVk!%oSdufoh5-FYAQ)v=d$Bi`G$^~ zm!O;En#M9uCykPzLZ5SHa%?hDHP5P;T4HN0L6J*r9DAvC1WWPOrd{*obfr3yJ?Kl3 z^_6dnXRoi4<$Tr!=4mhHg6ig~BatHR zv%ZMJr-`8w_JyFEzUSQdp0HT>|9QQG?IXj$7Rbx4E)%HauDyY!tedHP ztIbq;D)ckd-eirAHOG7icBH23*ApHA@nG*Jdh}~G?L5C^Xw^+nLWG+>hRi&(fnpY5 z?^hj4si6I{m1u^%i_yk$tco}28X8|}g5*tAEZYF37$f(+xT%XvO^`i^Ig}%cydrwF zlpL!xdO->&@q|8MiJrAxt;z2CP*a+EvV`_2& z<1=p{zjhmmYVkpx#RV=#zuy&7^2Trn=H$nT{OBVF*0z|QH!NxBF%gbqT!BEx zKB!SsSUwSo1Zr?kMM%N)@hG=&m`vRQ6QK6=oIvnUI+|C)dGKM@jNwqG2Xi8;YCUHYRh? zbl@DN-za)+0F9kw>Yv=ioL)01uFp7@AVEB0AH-nmB%j$RC_totFy4BKd;OPCMUMBb zu3oUUK`|{AvkM+@KPZD4Tn$(VlQi&aWV*Uf@DO|FQjLOoVw&C@z~Um*h%Ka-C=n4H z@(Lf&MDJXNS{3Hs@J)11(zo9tGp>wS^b9{Q1WN=Ktn>ZieRZS?k`gb7P4n?cl^7^* zG5-oARAG#i<*z`J0ski%;QCLD-T$AbOHq<{KxIb4=QJRn@MGj=ns0WhZX+uX z=oTjz`o-VviMt1mB0W1vA*7oq1ENz{<*-EU)U;r*ODfV!G-?hdnzhM@rRZ=|qaFTN zX*t~$gc-)M7GS{#34R-n`B)eAPfebN46~61R?j^(Pg3TXR1PyQrO7Mf@xf<3VL0`4 zh(i?-SktJu8Oj?KIy4p@%5ZH;P&p5LB8 z^}7P)9h}vUP+1Hd3nNzNcbR`%1>dSZbWhiXe-CcB+s9e)_w<{bypZ(@cQT`P@ch=d zSOPhExgI31MVFPsClEXe>$~qYQ+d}7(!BE*9y%AjQ47BMDt=#>`1ie)|ES{pFFdHa zI)CK`f3x>)DtZnm!f5=e@g;3iK^jf!RU6hpjYu^V#q0uWLuJ-6={Ua3gDi9#*P7;- z`rm*5)n{2QE{UZ01PVy@_9(amogzzOwYcVgp2>LsJ(}hKbX_!ayZ7=U{!p{BHussVj(W z2z3$zu7h$KK<%}P0YBJ+)0unV*xD&6GusXqs=M=Cl&fP@Ttzfq?>H9TW#qDId+C7? zhD;;HOxDJR4dc_xI7-b6N6nZ@bUWueDk<_9Rju2I*o(i)M0&~%C^ zc)a<25M<^NrsjAccydV2HJu_-1W>b;xrB~Mi@c7FrW-94$-GnKXvF7( zA68!d!gkIo8(URS{(u{zRtrF}B$9@*)KH9POqOW-B$za4Sg-A&PM*on$>$o#L7pH~ z&YW8oJX3T!!@2r4Rr6ac0ZDbtB1b5yc$5}7oZSDvGF0FWTpZ#r7@GfM^MmC-p{9Qj z_JmmlTxO(^(NHqBc$ECU$jQp^;)%xnyr$qvNTd`R@j$8JppDCGQAHQ7?fja9McCUZ^;``VW$1+G#=<;K{_OfH- z_$fp~S3K`;jPNNZnkB@=DFQy3{6+Bq9nOf3~dr4q8zD_t{P4-^%<4kj!U z0aj`=#@G*w?!4fpM? z8Pwb15(Ka*TtDN-2aWK>*hh{R_C}*e*vSTkHdM(ETM!JrJ=1h?(_WL}2p#QXjrKZ_ z0k_yu^;~)#*r>sQP7d_4VBRvWJCzw#TxA{*hktwQI3ST{8{>3$KHJIgMGK6I!d}Q zinmfq&RLRxX8P)_@@vVr0gPu7*)uU<%xS{|Eg;*w1}2=C&?7B zSX?OLt-gZO+<4@tLeF+K0~*|xwMD__KxWgGfsUpj)KyeCM3J-f*uxe|xk;Dlqq%1< zL(PaY@U(>Z#k!C!B45JlmE^~wHSH;r1c^kWTG9_VT~1LN6$a6Yg@kNF?&b0hs+5Dw=0j zR(wcEYmdfgojx+Hzu89*C}4$I7^?^vYKhF(`>=MC)VeeFR}}?j#XeLnp8OhW9%9ND zt6utD8DHnQj5@YJv+$USdN{8apQir2)Z{8_s!BABmG2O#pz5lSh|gf#CI8X4I|U4g zhQwk=VEV+j+-KNxuIk96Bi%^(Sf9}A7o$zHJ5mV~)qP))QQY&^>9}z9z9)PWpw>8T z7#NWNEtnUoUl{DP5(lmy<3;tpLJ3hG|;CGB`3**uH0tf9>;7w;Aq9SRVg1FDpI5y~rY#B|eCNpAXD z9692@_%$t2^nu&4lU~(~_iVf|Cs|mXs-xKlY$-~FZB$!oDK#)JgHZCG)ySDURM=@(i zCpd{Er89|l&)(&5>L6LuWY3yC6)`jPz(Po8pY=AYIBnx3y2Qx6*sT42mpR$zwx!!< zHHCc~tbF^-bje?bo#~Q59Dmw_-VcliCn^FfI*EV)U1NkNA`6Cm=^%j`%M?1Zxa=1U zn#DPNc32&XHHfUfmPx*J+3_GA&g-_pd#wO=Q^5bdhzmm)>s@yO0q|>ROV(hkhJWf@ zqWjI#+9Wx%C+!kp&kxX|XPS5m9CBC&3r>}SwdFd#YF_W78A*CN6mFC)qzOjM);Z&v z#MjdXXMw63v*tbvY+$tDmuHNFunOlRM#qe|eV&|$98!xy{n)-=N?lrkr0_}U^sz|x zs0y);(2Dooa;(9zHzRi=I{GSVcv!6jl%ck@)>JODfR? z%aI)0HvbhzY9K7eYsntq#JvWzj$WCuoyGoPY7;LSPfZlFiWU)X?(-p}s4FXQcpIp00;%Jv;k0t@2vBu4i;rh-?{z}cHTLL9Rz zT8r(1Ws*H~EyH+adP$cGv|7HkeS9p6eOEI*`idH3twkEJ*72|ey4JgISglGV0Vo@qe#)f-=|g%l$S&Onwl@mmdn|sjXXYaQ4MlfzjiK1* zY&hWQyc9?G2}2s1fYnQ}LXpq{!&Kr97d?=a?_xXAU0SXrZE?T+=9os2*v9%Csph*M zW{}m4+PIRmHEI;<=c5$PMrfg#MTs);4Tb_0**o}*cimSWRcxo(;G&&NV+-?W7v*%4ACG#t5J zQP=$g-(mN*;B6s)d9JNkF0#Zz_WA>J;{=2a!IJsiqCV!YLjJ(wUJ`3b$>qcZ!HjDT z2xm;fMSbtJ|3o~tc!jJ+U8a)vX@NcxU8y#u!Puq%R~{sps0msRFO2!GM4}786S7* zxgNmf{q@|Sdnf6_he>gEGX7Hn)uih5nL&&t4`O{?V;;bdl1U~9RAnjNmt~1UPC3mh zrR8ZtHzz1(yOYSK$OjKf;InJ+7mH$WfqI^OG3dhA+S!YmIgRv>2H78?<6A=~%E{ug^P+^b*+f=j32&Nv&Ypq?DcH&Busg^AUDE|p; z8(tQxZs1+0gUX<5~Ah zT0cGckI5%nM~d`uaMJ$o%2bt^##I0UdaQ2>-bpsP4P1Vk8r7EOSr+a!D*Z4shiKFL z35Lvs^i;#;G{%ksUUo8(Nj2DY?u5->J8kqS_#{B`HqS(UkzR|K5&6XI_#FH4?$ znMXeTb$nmr1`|{n*#5H1T%vtU4-H)vrtAchme!ZG#@c+Hrf4uxx$;VU(Dr~N-ich4 zMKpdwot^bPY#kBILFgi?i3W_kV%vn2J+%R5x}TL8I?B~o#VXlmr?i=y`yJi-><;X* zPCDrsU51x;mkr+t18lPs=6)r^gEh2$saaA!qv_< zKQP13J}ptHaUjT_(*x+P}wfV-}57aU3rp#3AB&~e3%y}0ju#22u5@mUIT!GA{* zd%-e2DTmr#$(P6^$&N0oCgR)F9IPR~!Q!x6YI*7dx6LR6n8tj(#1~!0rofeMtT#g* zW%-p@V09>&o>iz0j66K^soJWg(o9#T(8Xx-P3?;J|t~nIDSGPq(?-B zOoNnc5HZhsW(m6!J+yj~kjmjV6GKvhO>%^v5`O2I@4B$Z!~DgelYWdC4P>YfmI$TR zq`atDEhIt5ua)PS;Yz1`FX@3Na6j^uBx_rNKTmgboWGwE6O5;iQiN6Q8>ZX%ApVJS zTEf6oj=@?7klS(JaijG|(gO@dTgxB3#H)4&?+@VWkTc)dl;qK|uv;WRI*cG2`6PiF z4+svy+Bfn&Fs57Jz6i!C(w$w@VWPAbRGak~oN>3vUg|Mmk0NpfURt0*DSJ_e*Gi8I zqshW4F}L&aS8x~4*#{4vOc`gKW99cx*L^69fgPj#?++q9LidItd}<@&#E{ZGz7g|c zFX$uKJ;Qv^NpN*e&EL;l@1br8j8oxO3e`g<911L_jr~Xb0)t$x$A~dFay9(}gt4&L zyb=1<`|)_7(!^xJ14xLBGKXO3`R^_;F01 zG70TiF<5(=pRsJYj!^XjLl_vFJOQPhN#Pkr#G0-m#xG>q)GAHjE4WFhe7Zi83;gte zdDv6+)qrgh3F0}$gPmtb9-Ff1m|xDD$6jX)Dcd5Ms-(@nKM_3)2+hfh6@Cs@-=%Z_ zIinf|ck6rN{EOadGmJ-rzvxZnAL)(mf108HL2v&m)%=a*?3CnX2ZfOQY?ha_11m@UzRqlkhrVbQ@0M(tSSTerx}IH@Dn2={w$iGqU#`v}PuV7I&A9JYNP%sqMn z1bTq*Ok{V>SlVH8H*4X-lO?VzaDQzAaLvc1tTL+To)YOuj^V8mQ?)K-FT(s_!ds-O zeb$rKRR-~g^+_aiGtH6kbJ)!K^ie;ipJ8e;>iy2}73i(1RY-~!(tk2zPj;pwB4k1a zVa~7lF^EE`UH=#eb**88zBH%!WkO0S?_Zu0KpRtXN+XMsAwfT56IZI}&cs+R5N~p3 zlQH7o$(zsQQBPIRmD)i>TfdcgCSKbVVD;VCmO3l1VNbV&rWc9o>Pk>ex!)Nap%NtP z&kKIFMm@k9-HeXj2$((SmG+a-dXvl7q(7n=8)cELHf!@Le+X)=++(}pKC*dcns?>G zVa*fV{2FDIJNaK_jq)WE9MvxiTm6sI%YUn|S=oP0Z`vE#GMZa`4V5byxmv0@8@Zb~ zyBOJuTAG>Im^uIL@!ZrWJy6xL{%n;pEwY87Y^xYSfmmgRcgcEDfz4TJ#{;n|g>8(> zv$(RLnp4oD1Mj>H@ar|0RCy}E{GwvuKOf1FS}O&z-Q)MmCVEK{p~b2xFj@lTn}#s4xg7h+r;n$TZDlT2AXAv z7R^$J?R|*xL^>7HI}e>7{HszA#Y_e8=~8*3zy_J$ejuhByeI0I!w-&%MW7Q-FGMKU z8qPm&IdU3w#^#`d%Vcn&q^w;EEr|w2F@ax^`R;a@p>l`U-T%~f&^`#zG}qdSV)A<0 z^*U=#=#o&gd{o+*s#j$xf+2y^t1Wj9_h}(DNi^aK#jI}z)v1rk-H)gocbgc`wB*?$ zfg~22r!^VEN+n>U8|3{Ebe#!9k|dF8lV*9c&9H~&g|$Ymc-2O^j9w$Q^I)ldd}5zv zQkBFDS2TxDn`p}-{-`br?tUCgyfr0Wbf3QeATbp=9sN|e90U^eVOu0~VT$1A5))@C zPcwzUn7bP^Gd~hLA@8EwiklMmlc^(;uPE%tLecC-iZ$_~jNJnZYn1A%r}=VE(-LG; znh6Q+b;zKz_N7)0SH7t~u#)e>Pr194w7xp;V&CpmJw5j6zBO%yB zjVf*iveYaWlrE~+p8YYym=-QmTd_F!`)ATishn6(oD}hTE2AqnVPF_os`ca^ET@@Z zoo~4YJASOBn<;8#(#3G>n1E)&@JA^3LV7mK^kaJ$((~ASWup3G(%#8O%xFX8XSiN~ zUF0&gDyT`FzIjtA`<-+9RXEKbwu%RtcrG!#-aoN0aj)i z(G|=#b_!z{o1}cIyw#n=j~Ac|NnR@<-CW$c%JFBFTi5JW0BX#4k2o2w{L0EglSN7E zFUcmFVF&U6NBA7!t`Lut>faDk>pW>Lz9BSzsqWvnI<+L#wg=zw+aeL6=70S773#Rq zG@fVM9=1ZibB`>L>hKz>rHG}`pX;dZD>I!_x~u>jsx3;0d$`Q%t7d<8^lkl8w0WZ3 z(HGiok6h^#G2EzIH}G*;!U8FW>@|C+wE+z{@e{wwWEkzUEiT0aDJo2JwZR{zcX$Bz ze2pzE&vKCc6@vE*GIv1LZ=qSg~HR)Jf|ljt#^m2hZF4z|32*7{hd|u`C7{C zjG>}`{SC3Dnc~5%D4yBa!V@}xSBtQ$ZWY^qs3)9jTuIXYMgPF5E0*&A0B(=JEntcVgC%ZO4UKHyuzuSblKNHWJ}OzVpeS z?8|{P8FtkJ=~%YMf1h*@o-YsZkLVQU!43cY~nWEmBt#&Ar%7WClZK8 zSe-!M)B8((tj^wSIm3?e5oe&mQs6BAE#Y7K*^boU^Z#aITL%-H zul5Gx*FKM}n~RnE*Ko3}nXrk8nTw0Ok-d?{|KMda<$n9cFHzkfb4wa&Dp0x>XjayP zg-KZ^Ayey*gb`NecHls@$a-2|Z!Xe^@P`uYYo`Q*jKzDQGPFf^GDQ5rd(-X3n)&f|bD>?`-DktKL<0hWK!cPS>L^@|VH6## zG*0#NtGfzpZpt+e{yL@K$|Lg*JfO%I+hp&kR;NxOJ+y2H49xZA7=^RKObPZi6 zL&R70!l_{PTFcxI#h+WsO^Y<`hE*z1vg9n7nG-6n0xBU8F8yDd}=?${Kl$qim3(S98@^W*vvSs{l zU}!oUIXap-i#nT`er(?avm4Q4-snuM&-cwu#-M{K8n;l1gP$ z3sw?`ls1z%eb%&mNBvLuEci8}-Q`|kUw6;F0-pHb?+A)+BLSn7_@my}6u%J=Ub~(* zU1n~wcfO|73IBZF;|Bhy$0FeO^>lmmZz?ZuZC8$p6<>B{Lsp-*mS05IVU00ergKWv z(LIsLS=?(>QLLQQ?bdTpyO?iiEL`;>(XJw^lA*7FCd|$g@c3VRy#tUf-Lfs*_HNs@ zZQC|>+qT`k+qP}nwz1o`ZNC1_y*J{2=fCentcZ%LwW?M`<;L&dcdwa@4GT@LCkltq=Xfy+OasOLT!lXrqy` zEW9YuDcfQtJ$oJ|Ln|b|q*_a|YPgCbBBfQ|5;-1(P3R`sK~3T`TtVV6yrtDbioJKI zPDV1BAaj#O~V^ll>$# zNC?nv_r5RiH^A2t<)qzcvns9Qd$_UU$`jN;KUSNqMCQiCFCi3A$*D#(v=FXCqz$SB zyC8vjHyJhMy$5kCi}FBy0NdSCJa6{q(|*9I^zwX1NHX*dHOIDB8bsI3_{(*-kkQV@ng|lWd*nWx!(xQ1stGMcRDjH=YUQvY2^uCZuO%-0Jw5az*F1nW_|h zR~z5DT4j&Z7527|#z9b}pmRW}p^|OrU(TWox^&Kn>YUn%%JlZJ^16vzy|O|GnZsf3 zSXEMjOhuYZlh*ikE0&zHt5va@6&GI{1&D+NPop@Tss&f!V4;}nqX@iOvdonoDa}J_ zE-u%qrrUpYVYSGU5NeXJr?#B#3dkObD8uk*U|u*zS;T2YgAk;_kdF0s4A6A*YGO4)#dKwYLQi+*i=C3N85d93 zAe#Lng7EX?@}-FPvIdp0y!`J@^1tg|IHwZ=C-i6LW7u!d>#==7<(?=6?caFCo;)AM zwwV6XHIU7}%D3 z75#&7SiVq=f6k4N*gy{?o~K9`+fsId8Co*62ksPHLm=SB>G)@44I(Fbs1stfE==|e z5WM)k7Hs~OwT#*$%<~0|BEb_6HV0F0=kYy;P zdAZbN(@{*9FL}4bSi-&#J^2;N`G{J?KFD@i^8BEXQq3$Q#~shvw_cx5r%ZlgHz2&Y z*cU<9UD1(G6qg=Yx{LRix``xh^Yi7@j|r7hm00t{(0ei78ZQbt`JV={$XlXvX91YH zxbI<;-YQG@9xrY>Ar~yWklR>hQ-X6TUxD-S!;~b9lu;Tu@f59S=euifnkTO2C*G;S z@TJZ5{$VG<^ThBbq_74=9q9r7DxC6VBngr@olJ}~W87-NEagn(;M*)7Oj2!(TG+}U zsLu!TV4B7DH{}gtanAHawLkpH5_$jk$0~;0`rM1Hjkl;4D-KsjXTl<*z|E`_8Nlb6 zroi&vNu(socja8wZ}9J>;D}esqgs4BR?_u7ZyELz2k%GQjtG%Vx+yeS&QI*AK1Q~e z;1-8)WjT?WqB>et(n%42u5UPI+!F^B7Hx#oW{i;??}{9#vpvk}lwvHPB$=-+pnIAL zGBd3sTO%TRGFw?`Nh>DzU#VeO7C?`w!-QT4ZgBE!WsS1clJ&i=m$ zHn^;?BNx^_wESMCsSKfxi542WFvUJUh%GpT-JP-b+D|wh`H$h4?*AT6uKyK)=>%&^oOXr5Al10+ld z9x<66pEk?hlV|$s!otJ~_Kz3DcB~XFzWq<@HMwvNFc2}VQuS$6g{U$+nN4G0`E zua0)-H1D8k;mm6E{(!pNomCz*qxv$pI3NvG>(+Q4AcJvK#K8 zb9SOKS@GC!pN|JW#<}*37GFj>D1wi~_)k#-N5izNy0%(q7hMm?oL_Ju8jMFGA9bKb zv$!gbC9lC0>Unx?+*3GF(6ZZH<(4j|5-Om02Y2z2IG_&xn+2Z`6;N1An(~^lQwwUQ zOiKj)?fuj7EGlb8nv@wDs4us&o=Bt%l*TAhB{h=R+Pddpm83-ms{V0T&ofYt=D7dS=Kr=V{~wzR|1=j_+3Fh+3mcp0J6k#Z&$+yVt*OJ$s$BYK zRx!5u|IH#%N;9@dV#r@$o(;Dy3GBon{2-)SK+R!>`0yL(nq~lFeelQy_)_BZt2i}m z8rSXb0|MpaMQpG<_IaUCD@=+=`KtLmC}H1)-vV;8Y!fw&`K2B6oou$QOj%XL`Ye$dX*5~GV? zjoCc8{4m*B_lFn=K@#mp@(*Vga>;sjA3Ds|(a_aGGbuFi)9-z>)&hY^h=PM>jvvAt z$Q7Zfbr%lPeu2OFHW3uNyavs`ezAXnB`OuCGx+U1e%!gwF?S3T3XLaG+BzOfiLB-f zLsTI!R2nT{#3)Z+EHpqiKXE$CK-~2S!*Tvgi)l{*o7SZiuHQf&N=jK$gt6|+nF)`Gm z!Txq?dNfctW^}=z-436nDud8w974=Iuf~cqED93ykXqf1w8FZK9fiO>iyHhGH6`Xa zy99CYP)x3@)FSqPdVt-Br1$H%x6;EwpuBzZ?#_D^RUI0KPMzf^_Q2rPhK)0jFB8Xm zlV*;2seylEHqM|s4!E5>k-zx$17R0R2*LcwM(ea^%K>Rf92id$mc6SChy+Lhh?+zh zvO6({dx7GOFjsuW1#TIks9C3Y1NS^K;IL#Bmt5WRAnNcc>QhlO{Vj2vmon)s*asQd z33&IEDekAAXHibwHHW4Kjin6FB;UgbL))#+*%fRgjq!Uy)J$xt^A4P* z=wpGU$DPMXW)DL%DW!nu39E+G5tKB@YM$r#?rOf~PwEaIWOZ?-rZteokPGZsqWYS4;B z|0LjjIbp)2Q9#;HApIi0rAAv&MKYgXU3KhsoOYe|YT)zr{({<}EXL67@nFgE$g8n) zlwsHK7H3m?1l)9j7MVEeKIFU&$Urel=||l_I+%2%vpEWGJ4%Ae=4~9emV-GN((dey zu%{X&7)-JZ@$2L0Yqtni7;-H%fWs%8= z=kT2S6oOA<-_q!hTShh=6tYB`my{cf^+Lx>yzS~3hAy^=8Fn4^M9*a;F$7-pPb`5WTTi>BH<(hQt<2d>L}bEO@qeR~R5CV6M#}U~hOs$t?sI z7o&N-naKA!$TJ z>&^XTo(>zGjv|b*XTI$ut5?7&&KtRH*Xif1`>gBEp7*Joo(B{{&6%EYr?;2euFLC6 zyxINGDCvA&Z9Ke6+p?I9Q!BMcUI`b0h}(?yqWH@VsM zQOR!?^5j*fLK3_B=$34i3+r{u7IgD)M~W2q7y3L-307k;BupXtBuqlRxD3=-rhwa9 z?bS^@iS*Hnd^;p2cOp}nC~VDSN?;3$3z!yI^$)`1W?UAhtCjjqn>M&ph0;8EaiL{z zu|C4KQm1Ko&6~iXk*x&^ph_a+*qDsevtmcT;T0k>1Tvc@2_|YU#phijBjGm~(FAS> zlUlF>J!lV+cX^mbgNt|q+%c)}o#I2L8tL)BII4PpHABevx1oqq4Fk=enLf)lPJppehzt;iO9UQ2qK{ycJZ}25$Em8#QCj@IGeY)Ih;t1C_j5#Indn9> z?q%Mr*&t<`FGYDnXUw!Q9F(&(vc=j2NyA|}`{O%(aBk4&ic|F*CyG^zcJTh7Jbkku znj-MdZ0aPz3?=kXncCW=-<;dP;J9T1y-C;{aJj^)J(P2N6H-0wO?ZvS=U!GHKVCK< z=aWv?u%5>H&8MwXa49`eLmGW<%;nt}*#2=)K*`axE(dLvH|fGa6F34#8tRY?cr_y0 ze3Ys0rp;JgADiP65s|!r+v;Bhhv}`Vm{n>M24Hc%zOJ&UhG2A;(vSJbsM4>fU{u2_ z-6VIhEcV`qxROML_k8tmxBr)-{ z0Nki4Ka!>@`U^UZ)eJ*+dVEKh%hU52puWKbEG44AD>zWsBPQobQCa)OTlz41wS`U5 zA(_e!#MIkQ_D?<^L@2G~TpSiQGc{2i*D?M}9=ed6<%52)rPN_&_Zz}kJyQ*xrss+n z+*}R)Uzw_8MN}8>Nin$jkrHrz;R3n*HT*JD&M9fIRS?wRHq#A#i(f4q5+z;_5Ij)k z55fi>(u^$A=GCiS!o_k6hWVWf;@9>(C^LB-^lw%JYn+7v`}UC04jw=#dbI?>PxGb< z^hYM;a|^$Xv8HwRyEFBlC0EGDeVFD zsI=F15ChE=aHP6tL~Ao9#WHh`H@ZcicgWiJi5Wg12JkaFg6%fLuw^#2^+FGSBYJC) zcLQaBfXhJJeIf<*h>U>kVP9*cRCfKc<$@qO~wd*)<>-)SK6P zJ@I^4#us1Hf$yt#&=?VaIkhDY^^W;!&OFd#L5S3wEK(42b#OVRSI3Yn=DLC>djb3m zOx*FMX7ymI4;B56>=L7Cv?Opmx_j#kUAIX{b-S2c8Z$v=gOMvo?-ij^Qg7+-IsiMdRFM)v7G{O9O zb{zD!lmDA*H)}70ZFQ4xTkLM$F*jknM@CK!9fA;1rEyA1T;kT|rRhl7MQ@3Z8K3<$ zthbXo^c6w1sy3usEhrD|+wtJ{DqW>!SzzMAYG&n5P_48!FI7^!mt^UsJ=Ii%VFz|f zC`{_0n8zVxPB%8P&U9wpG3=awF3lq(pY)ZY+X0iPX>u?nXvOVKqHlZ!kPr!p?==9sB_~DS`Wz) z-C{l?ZU7>v`xhem*b=STWhZXwe7a@WUN>CeYu(sj2^yMe+X__p(O0XKfx z%AXEQxVFsfTzy)ozm#eCQhr*;4iF$jVCn@40VgXeH%1E z29UQ3y$aVZ3TOp-E~*g`Gz^slv`Lf|RO$MFBa@P)tKRuI=cc?XxIqzmXgmw~OWv_3 z79M~sk*g{jtNxD4ShkFGO@d3`N{)-(L`+B$P3o{T)|L%BE`c71nj=koezdtBY4~a%t^5r3-m!3Kj%V`9dB?v%w?BxOI$&~!jUNWa z@o8Q~I6n%f3*aDLLYK<|4FU2X@*``7jnlDRq5+VebLwb4vJVL_1XDYFTUc;$dW3relP0}p?81NZ&{!uRJU{&9)O%uEL4Mkts~ z&T=;)Kjl_c^Tc3YX*8y9Lb`*cpyU^wFHkn{Z--k1SA~|n0bO2_YwyEVv91paW(>>D z5A?fn$`0!!94mEWTUFmE5+yocu&wZDj;aE3+jOFJ95*T%`pKWaqKNiaixt!T^#`@p zHlA$6Fj^5&7!Hb19 zHyE9zQWe<12XmH)8IDIOtwPeM zHRd&LKn-qMRQRtyy5LYzR9#*8JDBD2K-E^^INa=#S{XA+rW5XKtg>7Nn^Of&Vhir! z+P>KycTUF|e~Hw_vAX%ap<+u9o9)jcAVaw~|4zkmS zZa8>nl~i|D8zjQ^%<{;ZR6cbVD>%?nlBzUD&(9h}VOpBkVW!AuVW!MGuz;OfTWE_| z{yi!0mE#74$DH%4$iv357s-5PS(g3aXJUS?=I-+Jz4Y{Czu2{VMepL1!wV0l8b0k) zSH~&|HJ~YYm{WKY&gKO*WNzB=l|JE3C?T`VIh$Fi$wHFx68QWYRy%ziF%z4Zc<{>B zjkGSyv*i{+F*O@tKQ!EDM%7xw!z{Yx)~Woo$kr{Z7+t7ve;X$MoE{R-LVe22TZY;% zOIFYRqSw}4;Mcno^z?O*G8Q`&wbgNV%>E*DX{fnqK*lP#K0dvcU3endLW%GugLOH< z>Y{oG#ECe$UPvO#$t@?@GA5JFE*6oY@?+$jRxnx(BiZ8q{AuRkwymR+;{*D6-bh*) z-5@PC8lo`?K**Ec9*n$U>OJRjK0H$J@vnMoQZa4ti zMegzJ2oft=1Y+aEG$4JE9{t_I{tH*SwKVixk$IyL|hvQq*qu&_4C6X zp>36)v+qAXl|OfXL8koN-RrhNjjA36)N;pjmTkOO>jg}c>35j<2gH)fb7QYv#8VV2-AXJ1-O{Vpi$uIz3lMp3dl`?Wwpp>|6_$}|ROmbQ- z+O3VID2pdMNR%dc(_#%+-P-%bNIb5Irk&d>rOY(_mq8%P;dkWuH0mR4vhl=r?rV5g z%=n2Yz2%@f5#I6!(KxF>D%1-3IyJU|VW-!(l$}cWBQtobb>#9D+>HlD>@kp+qgiCj zU_Y+2nP+9m^gw~vIRygs?R~aXBZ*Vk8cFZj_&b8(pTaY{Y}cTT z*fRuKeL3=89rk16#2TNQ%KL}Ryx)%5M0MHy=A(uL9M*f_;^wBL-FO~J+@|(7I)GQF zGxu8y$fzRDE)xoI0MCR3S^FKd3Mzir$&35HZu)9V$~5*Kk^r{%vt!7ISD#%fswRS1 z7x8ugQ&u(usOPXbN5Z5URhEFc|NLc;g}f4JzVjlUxu&$T#yH-Omy4s=$~b=B<)v}= z;R7RHY}oe#TExRVjM2_)jF*Q3%G{)3ZZqgSTa^}wnjk_InITrx)tW> zN_A5pLZ9CogVv`5^1_9Jm_n4I&Od-1kC6YSPp-Oxyt0!D zIplg&zC_?4NKvoQui_?BUY3EYOP5n0W0#hYf21a%4Fg1xeEs;w-CE2d_X6pd9A`2e zuiIRY)}Lqe0J(eXdpq{`UG}5w@h=I2qwDlnybY&n3-F)3(mWK*z~Y1=sqQ352UCF4 zQlI=T^y5Lp>gG~>1T94`()}Z4=w<|*zIWTL=+#(!PT$k6nPOoI-RVk#s?iWB=$tTc z;v`#9_oLoCy7W1j8Mn^hfr?}kDKcERb3jxH4>hafqve(?N%m6{o48;*Aj`VQb5)Ul zHK-31_Fm*+OH8EXSzh8{$7fljqN=ahTv<75(Rp-SR$Zz#EMGFOcXfT5%J^HHx8x@r zP2)nIWHes~>%OVy%4>O3(0{X?N*ukyQv5>kKb>M|32-D&p%1(V8j7s?3w|Lp63nOV z937ts^a~AioVI92W$?353}~XMK~{A}5JkKH5b=n9Ciq@IDBAB;Z!IUAV+ciiDvH*j zMD^3Dk+a${QM5$azio{#f^OHOx>LnJ+5kbRm4^N`5ii4(4>XD|b?3s1jrWv1Z}MFy zT9v+!?Ds9SiLUpcRnr?JG+C=^SKkC=BwXt~F8Tyir)=)czcAl$Z)2R5pR!H;e=OVl z8*}D=$~ONscK(|=^G~^sSitaqkw<2U?vov$hY7)fa=I8~62|7IuK10w(qZq9BnSjK zt$S9yI^QU{77(-&cteiu27n8-8*tNC&-dMPS#upD2hi$Q=J$O0#Os?xwTN{WtSzZC zp0+5nsTrDO-C3RykP7Y)6z8U{uiQ@973Pg|STBrbPO4R4VU>jA3ZJD%OK)mD`u%Bq zjUA|-$B9L(11X}nY*naJ%@8ESe`WsFWU8vR= z2;2}9@)$?_zbc_riw26%Kg!e8Kd<=z-OEDxpIr0*^LqcyFQ+uzy_6rD_)MF*+Au)L zK+sV!gc8RX!}1A93BeHY86igj>{s@tCS@2Inb@Wg|3Ir$G(TxPHZ`*>y-_zsskEEv zlcqu`YL%;Yn6XuOyEIg6vQ;HLymz>grb&Gw(*q#A5?6USh=@|D2=%(`I*cmsk7f^9^}}P? z?OW5EW$5ivagZURMyiQ!)dSTd0?Cq6Pu{r&OKRfiuu+&nj(M|bhppFk4ze_}sSz1;);PvKNiaE=q^G|5w^Vy2SN zBs0Xts91C^d0dq<=JmXesd8D;1K5UvF9?WTYl6d%lJqXxN`Pj}5LxPgSRE$%)Se9Nn;^;MLmXCiH$)23AiNRlj3 zB5S`@U11=y{xj(rqgS3zSUD^dhUILAwb|IZt>UN#gv=Rm63ig{MK*6HQPQQC{?1ODO*flB7}Q(AO3hFI}(g&O+0tS_v* zssss=fjAF6c7M%h{bJFcbm>-<=R>Xa4X{qGb3|a97zk+R8pO+p(k2^QM<;%(sz0y~ zRB?%#!Lct8vXEtAzqvF2#xo$NsieLB9TCSs^E_?X{@2BD7<@uv#vvJzQhJD^v3!dT zl|$vIA|g+p5nMz|Au5{UAyp|$2kfI)S~hhN0%yOnr(#(o-&bKg$Y+VeF{*sx3Du~N znZWwrE{QHx{GA?2J*uLTQ+AKA)Nbt+N2AXvftlF`pev3SOJ$4`MSDf=HiGkA5i0UO zd~$T7PLbVXMt2^U57wmD5}@X1U>&QO#B&jZ0J18_+exP+Z@5Me9xd0Jbq&L^e7(>X zNNZ(5fx4(0i?cEE=!j+2!b@EfJXIo&j};GwfS*019h#N=Yt|*|0J4`!D5 zN_q7;3^d-)FNmK&7&H^rwGK+yh}q{Hpt?|PFC?Fm#mlG5xknmlrQ>IgB05c3KF~=a zh6K*nAvP~CiOXlXY$wlxYQ8_)WN;>NeiQS5Mb-&Nuox?GER-8$-`li(QhmzUy}Keq zW@+_RPM`C|bx|r{2{VLpv4kQKehI>QOprT%3zknCxVb_F`5u!3W#trOn>06Z6D*XH z=M)M2!jWK4RGLfuttE%E2P@F6hVZljI&jmjn43^ zPJ~{D)br75_H1XB8(ej-Emk3-$#Qk8x9>hEB<9vjxJQ=EG&)&*v=3TD&pvVnxeR-) z?Lb+YlOky39f%jYERz8;%h7@zQH?O%8>!r^nUZ(>IPqq+lbCHA8Ax24#IZ@dwzGe_ zNr{+ocSoD-L2*Xdg%@t^OiJbgq#@1W&4(>T_SLJKpM5HrJSQaRRfbG&uyI9+T~>My zyWR{C12~~%bhg$$vJk%xRx<*^v~v)B^3%hV33i~-tUvA5Sfb|5i=rmc9n>)2!GqKa z^P&<_F>DtK$|77CJ5xuKX-Q%!OtxP3n%EsDQrn82M%6F*?l55XtzSVcMPQG0ZuQjl zmq*Ic&aackwk$S6PqbQ!TT;VJDSX~x&h0RoXfrD8&a{@qUZfVn6$ilU9V(GVzCpk^ zP$Zf;Ui%dnVGK2;ueF6kZ zFhW{mY7j^Tftei%owFtP`AO&4M?tOT( z;Htw$hS6rDA9#f<0l{2DA~U)NOfScqg!^m^q#5Caibizsnh)JfGIIAiSiC=S%J|_X-AWeS|ich7A5v3!>zaS0qG@+}6 zF+61ADkXR}zFbZ1mX?PdOp=@C9DI^|;2Tz^0qedK3>_4z?WYMY85qL(rt=Zq14q`G zmX)L~hGa0K_F1zeK5O`YjYkt&x-#C=rX%}-v%xC}Z95zssU#Mk{YR8Je z@U4Wha=tl!xo6aPg=VsfWT-Uw*s!bATd!Jrcam6JES#?b>09?3j3HtW9zjdZo{@vm z;Qsw!K~TU*LK!uvRJbS;OkNH2Wt%Y^x3I4&v!zodO!!r6#`%hm7yl~tBXG|sE%(t= zztYj^vC$ivB^+7S$l7s@do8-L_omu&g;hi4Q7^#p%DB);DAqKLC_yf{M--fbVCW4Q zpLSAJpyR=Jw|FpZ7!OY9&`o&H;FE5C-006%H7z?V^+c?EUl19l4m+%pxM%W-d$e~- zt(|&Ex@CFK^ihfbnmM|@OUuO+x=YOaa6Up`MZSv=z+ zj&v;Xfs>|(JoZyyf*n#2H&qEvkEBqz1th01TIY?cy1siJEZd%upf04|88q_e^UcqIJI$qO^tX{0Q=;ytn*d0;d>W zpbMg2hvsXQ_P18QOkwPq?4dM+V|(uRBPZ<<$bpw08v0vS$9$VUpbm=Fv(IMqMe~ij zM>0rOq>iZMoC}d%y?jB;97(AMLyv&6Zzi(5LIvB?<#Ywf0)mZ_~Rdangdl z&@8jcCHuwoEo63_;{rqY2HFx=n@YZylX9a} zl&P9Yv{)Lgc|b3Q1o2l|SANshLidoYfmF5?I`bsF`E$9kGP};}K?$qva#L^~CH` z!TFGfb4WF(Bq_ENC#V_OREgx>tR!Qa(Jg2?b%7g;M5AE-&>&(JHfZkcmN2s4eJeN!nCrcl9Way`gTk=o|nGo|BD1pGHLvB0ih$H-WM^@K##RBrgEQ`4$CSNzg z8QjInTy|bpvXE2PqeM9*$mGvZ!Ps7Fn?$@*V_0OIlsGq$7xq#m0A&oC)8WX5OB{I{& z&m4D92ULj=J&5P>4A>lRn(KPS@|aiq-&TfHnOC`uYpkgbZ!za!sgrKX&HmC&DR$Qw znLUwmqe#(ab!;OBsne)NG--Cm>qV#<+25uf(vCyt?AGIMoJse#4t}n3bFn42(girok)X zsLlF0m3f3uPV@^VjN3J zs7vW$dREOUH=t;vnxK-_6qp*ejG&zM*m*>v9wu&xniWe@+eJ-67VZtoVET-b0X5{6 zr(c*Y=7z@KB`=B#zMR8)M_(&sn@t?LtNkyD`lrk0nJapT+`Ued`PVEyOY{v7f2Alh zxP{mY>C3kmqt~@Sx9=weAH3PUD&9e;-4Z?DM%u2JrA~7?nOo3Fg!@?ilHRb~Q9Vh0 zS~k)vttP$Xy9A>{?$-j{oKIM^!~^qOk9nFfO9U;uX<{Z}MGPU&T0}pPw4d7EHF*^c z(1Qo888T#p5hW(|Q-(yg#r6vVzhg0gpd>56bb9oH0wu}%3M)p2fxFLEy>QG4R_-h8 zU+Al?!eBv?3%sHzLA?4>j0E@%7$S|RYf_S$ylY+ z4n%*ot_mG#p83HvVERPUjJRH!Ay-9T%yQe2biJr+b%|?XeE(`??bZyWEqp{h5`F<$ z|26&q>X&o$0crC>TI-zNN~}*w7-kFnefLs z2fQs{{%-wM-9ryBgJ*Iuv&{5yuKy+Eoc^si>??Jju|gyAn_Uf`ajXB1%g`EBtwiQ1 zx^awk%lc*V?-yf2mx&<2oHk?3d{TaxpMu&Sc>d+t2h>+*DNg;iw%P+Pbq56MHt1{8 zuC!j;1YlpBL2hXi-rks7|L=db0Mz7?nWiEF08stMZRP$Sn6!kAqm#as74d%`|J5u1 zZ`hY{-1iNNl z1=2bj@r1^~3~TeQTAAId%fY2ha|!FRU6VMpiAkkk@VViqVwhBxz8SBI0v70InyyD6 z3Bn|Jj3nVomoatTh{xa7jx;yvi_UnW_#l*M<|9E)rOc4j#iVycL>cKHTtp3#k-nKL z+7?|mS#aSINetxl?nE8)%Zyk>!C1k`<{`huyPwZD2`YbK4!99|Okznl56^r1}88nU&cpyn*~f zRP2FGaX0@#FpvKuii!WfqnQ6~#DBA2l_uoxjK6W&?wmdns)%IKg2?m;9KE4d3H+J4 z{P-@21_oU4WQ76zv4`7rf2c8VBqkLlTWX8sn;VP7*r9$|Zvr<123Vyh&st-dNnOt) zxtL4AjW-w3bde9fPrdt&)f0toUJ2&UdD?Duy5Ap7dEF=0V82i93p+Kxkri{*^!QAa z`)V#?MO?Egc}EaN?0rV`N9>*U}noU~6E-WouZiR;Mgh z;i}OVBurvrDpRj7!i%ICbMj)VT&(w5JB7dEWs8$MSfbZaa1D^jw$rlh41JSI!*+g5 zc`HjldKt~dEdKiq-t`OW#SHiFi#h4kU3|pR`S;CF5SvpIp|Cl8#>|qEO zL6o_yj`uN0$wSqXQfj)_qWIKrnS3$j-u8y`GrF8k5xy*m3E_xC>4xG+3@28lsi2dl zG->G?bNPxG)$u+RlKOK*4722EnDvKFTfCP}MVn#i1AP7T_HVVXeMTs4JO zpT_!OPG@)cEQ+es9a7Q~8ZJxuwg`RN6PqI_ZGrR{=g#vc28nWQy+I8dcb5dFR^-u; z&&P%sTVJJ;F`R;9s*$hDbF31St>mkHWdp=P*}5fF!x?lQhPw$TMi}e=#xDm^PWJok zBklIX+F!cN8)z!@No~Er@9ywmEwj?-&7I}xh?Aw0SPtK(3EQ+5LHqwwu+}k1p;#vH zrvh`dw3QgL-4@kIQ!Av--?{@#~s8|+dQ;(;Mo#ndpY6spn{3TJBv8{Ee0%vgX2)N zCCV1=Y(p9TH+hpYR^mG9QF6nF>tHb9wDPpXRlL7F+QvVV*IK(W=+D|wiR-*I;elS7 zY`O=x^{a5b-2CDtug6c%+y!Jb>;Y$1|5k+KbP-$ndnLz+PK~0IJ6_kenCmP!NG!nT z0oX@l4sD#DBU$@kjnc{sh4baeOf!mqY{x0?+@X-P%tFTkGt+fK8Xnl}SW!g#bX7&^ z+2;eo?q}&im*rirs}E*eubvzp8ZZ##(eDL0O^$sfaX!0;rmj^d#vG<0v5$vbadqkM z;c@S>jXq)Rz%lvuo_XtEk0U!0-X%0LG%_Oo&y;sC!y!Vzbv!1e%gjo7+E(!P5CXQg zglw~&%zv|GAITU4^EUXYL*ba5L|+fG{n2f#<$P`;XXQzw!rFG>1xIQtjYXPCx$0Tg z_y1H9*k8*NMu;cG(T9I5k|_z+!6-KvLctWLG?awCF`Wto6>5{_B*kX_J!#TlRfW|Q zTxT2;H#0}=YR;55U1N;$dTp5H%;k}GCmbbyfA00QK5!SnK;wWT_=y7G3YX(F_2ej zekKG-;-FFYlnsInfBS-ue-l(=JyzlnCV;dv+bFa!pd>$1xZyr37BgGGzr|0+^O~0j z15^}t&e-E6dU|#)QNVmuka5beLq1^$=n5hx6Mg@fLV!rjf(f07zjUyE!{MRr^$O81 z9c&-SdtEZ{pn(T}h6ZnUS7wPMBn?d!5HMe!BHRBbb05=@24O?2h_`+1 zSkky=Y6p<;hK&MFs_UV3Pi4-ZFlQ5qOdAaJ4>=1O04Q<~*!bCF?FPS~o{er4?b z@BAktYAQF=_~SF#TF%vAsN~HdgBetV+7Sn}tl<@KS7SOg0f&fC(;da%oL1YWSL+*m zGM#5P_te#*^#`lcd2E#Bzrd<*Ozyihcs6GM{UIN@;iOnS-MRs~qr?3IfIIow<-ibm z1axfeXk3WdOtrvL9~RrkL@RPE27Wm{vO5xg=Y{Si6xRMyB}nHWVL(7VUs(tiyCf+=eFX z^v*e{k1Tj6MkZdZ0LiaYY^zFpCUo+Dxx=bBlNeU*IS#VeeOAzI)Vt^$zh$j^EZMHM z**h+Kz~xZ6N@mz-#ETTbxO`K|Nr-N;@=2jQ#7ZgkFx(W;GWygjB|Jx@jU+qS`t!IrL_@Mh#X_TZx%@ z^4p_*L+-*ol_Bw(5gpCY^}j0qLkVl4eKqJivQEuSwK~_wQU=a?(Pr}B&EB% zySux)K|s1&x?55}O1is2>5>k~O$h(?yyyFj*W>Z~9|mI&_Fz2Mnsd!nbFSyU4NmP* zk_r34gxePNOJ$h6cykvyCw$qW0>}3|r&9U*AFcQWu@^Z90;YM#zVCO^+rx zNH@pXoqevqr|SqP@$wvXr8J@&d_JP>=uXmMSW8G@sN0shx}NXhJ^U;k3^P3*Y9*{X zT_){Q>`WUL%w79gi?=u4Dq=QB^rnC>Qexc!1mCKET58qi_4>ylhJterN@VVP&{9R} zf`VGjgzL=<92XlYXsi4V{!C1%tpasaKFas6LJV)K-=vfm;P_v(pq!FX4Y?&YsVKhO zR%%faHzRDbQ!M3E;64T2WnRzcuczPxKYjJ4E?oK+r6|}!&xa}zY4)CB2A?|sZ9Z0a z|7}5bo3I!eu5axh5J}j*49lzaa_Zc8rw3g>pdb(cSDK@($H8DyJ~4-_*`cwZ$s? ze5h6-?o%Yb`5-tXa|0?FF6Y2tk6?PhbB~VSfa6cTW01)6;9^4dE+jka44m<(+qOx| zS7+%A4{cV1vYAlL_6DE@7TAVxXLfPEJy)0APHnPc=nL6sYxCkc(#=FY#J=VU)@bgA z0_~_L;7&Dz1PtGWxfn&<4}Ma94p>_udw=f*7k4kv58VQ0lC!J^kehlmGtWV4Mi6UiYHz1L*lE`k@;g5_yK$-= zZtu<-NFGqxlm4JpB#T7g%Ex-iNmQO!&y7g$cHfwbO|=&7md}4l4Mn9|n24rEQ^>Ux zYO+gTedMAD(2~_1Q6k*FOpy38A*yn7gLcbXj?+s+U;2tl$BG4xn$@hHmfNzSfuA*V zDR8OI{FbT?yi6r34Q}@hSTAGKo2ggB19-#DmV2x|Zadz2|rHCQV8f=qYq3S-XQKr)V!L{fbjC(JB{i1oZ ziF#JsGKmxT>@0|5a3}*}b2#dWUIr!i`8n>4;r7E*)&qvB!SvEbZkC%_T$i>HF_iTK znSw(apn9nYdcK)KaXd!E__$?es}T}>(H*ztldjGo3~FxJOQHIwDEbA;V7L2u0y+iR zI z`Ta|+1SVzj1fro-ACvhOxw!`lkeVnt+5zUv+2Q>l6W3DEHS!?GkLeUc=jF=*DYi;4 zgAmXvqwtL98S&@oBP*(OL2;6Q!{jJ!x!SIzc(UKP=n25KVnzea3MJKb=3u8Cm>iLlc zo>?@$-95+WQf~)EAZt_5R=Kx&-+eesXf5(h%iWVsgV-k<5sR4Bt?SzA!_Si!Vs17{ z{6tvfF)5Sptk|88Zta~Yi^wNgFB3D>72<4rA$j}O^elvaJgTjo4ShF~YmiNpHeGbr zyKXGp)-!&Ibd!z^zbI+4QbF?)fGbwcwDyLFza9Z}=ghoEC1>_-5DRf*_-4`0`D_3% z-j$9^NUELnMfu|?&hgFGHu3n@;Oi!chfyGFC1tj zysM2L<;pVB&eZILeivP-DG6^E!_0P@Pv$*0)yMcNP8S ztipdgy#t~iDVyOeruzZb?;xzt0NZ53utk9^3ZvN}(iFQco`XI5+!2~Bt*g7s$UI9V zqTk}E=N|5KTZK~u!6+3ngR++0rc2UcL~b2^1ySOpH^5EkBa;19dk^IoLT_D(^eYV? zh)u!~KjQmm97L8GO!T6q$6zM-+4)P@I(QCal||#8B$YWzh+EnD6~{;lGD;KM(2Z~x zbfm^>#(c>3<`9QS(Mb$0_NoT37Om8`p*ft5u4+)-eY&scXqIdG8ph(=r%k3w~PVLOXd zvY%SJgzTUS)}20bSmIE#Ku2ArE#^+hFkz~5s)Jq}y~;DcyBxahE*PlD`+}A(u^rn<&8zczVDn%^A5dk-Vy_mr0qL*uM z+kH(G>dhnCDc>o`r?(AIs+^*rfe)ECTkV3CYD3Q#19fXQhe<>BD4P`WFJ{4fglrGp zMC#o(hLNzR_6BG%EOWFS0kBYlhLR^aX`ly0}L;y&ATq9Kgir+g(JSTR7eC^Kd70rtk@Qwh@u3M8?jc zvgkQ+ER2q@6iY?Es?2yUOPXy52HHmmw09OlCy8i1JSX$cFQ?Kz?WxLaD*;xXXdOZ= zBkjariS2=U=4{ztOD4WdLby%7@-N=%81G7r_onmAC}*~wh&dH`ElcXAaT1YCg!*3c zydPyIQxoLY1}B)t!AYV-sVm|=v@yqXQI~?W4Le?d1`+uZEGOQ|ee*VGf zrT|&74wW?}lFB{`V02N9RseY6=RHwR+vczuOFPU6KW$IutXl`cwNkIGa12qG zrJ%bP3TNk7J?}yS3x6XEWxoN1EKl;n-Jr)OR82@8A-lLcqJ0m!DhivFnJu)P!CIZozRj3Dupfu>UuxP6njtRWN0x(t)#GPjJ(W*QX;@KZebajIc;dm zCW~hL0jRsrD=aVq-P|3Oy{?-lW2lzd!ihrjVFr)oLbOS5oQOiE*S-!;?Lbx&bB@wB zIBCNkoH#5Y8I#5PlHx>EpLUEIfBnTV;pU3R%nfkZ z!YFhE-!>M@7lKEDX})s?nHWmd;*DDNM6GEm7PaY{ePtQ7vU*E6^Yo7t_xmKXg?pIw zLetbL($kGYR?TwDFJ{6?y@??DP->A;k*WI-u5h`r_Fj=a1?c8CaYv_fx+w3Y&sz)# z5l!Eerg8T>?FtY$ym)%@xf}a@V)bx@rCghzp-=;#(K|s@NOO*IZA)NzB23n8Oyp`N z6Y_)!pjq5GpOl;|9mspLVAjuk4Swf>dB>Z+oWGfksTiJHt6LL8{)`TN&}5mlo&S@f zn?k$j;4E88b8ms}U06xznINvR%znonws$*X0nXu~KR;D&0=; zq1MxLBj~1VFmZ3_rpJ&0B|edG0LL4z$TA%JtOE-~IHfCXompV+wy z8-&6rt-RaR;6BG2HZ5IoYkQ!W1K80!*5H1C5|T&@US7!VmLWU9nG%2IR0sf%g(q;p zir%R2#OCiM-FRbfu?u|_l)-Q7I{}F_K#B)nXF9wXSLm-9xO`&}clEL58GaMK6`1Uo zQKob~3zs=o{h-kD;27bhfCkdw{8=X?mD$rB(iIfJLV2z}Inma$btemM>{3VY_dH`c zRmH*W_;0{4Bi*0y!=kq3gCg}!KzsqQv(?<&2%Y|52_E_JZZE7axCF6;pWKz-h9;(1 zFEg|lBDp{TkLtU9pc8X{8!)$h;lT}wYiX`cFvH{sCC$IJ1nrkGsX1R-c54t zLc9jBHVaK(PZqQAK)*w|rQxaCi@4yDsR;BKp_0+QMY4^V@oQdty=y?g5jigp7$EqZ zjDUR~x@7qfAlguTFi<0JZx{E(?05$3ZrE!(`+7JwC(6-O)0zPfL-;9#k~GMZLtGy?nM#)>2+T`kNj ze-Cd%!Vd{3rx0cOIo+1L-plN7F!@)*0?vWum?{xsvwILKF<=UycOWzqNrt^1DAHo{ z&>l4+Ab^}}aY{#leq4;cq6#<-V$Ho7UKVZ81@Wh+CFOY)SxBEZUOMd5^n&4mJBI5y zhiL&%RP$EK=dU%dsx>v_%dKWSAnH{~OU>To6_twC8@+RTFwOV zjN#5sZh{G`WWFrn$+vV8xa_EdxGegTh$iG5fdf8|IkR2eF_u{^F!2%tv7EYty{ytY zfTzxF4)ngPoP_WTG|Fer08u&Q$%>o}_7yWw_VUke{^I-nDIPLL`#{~ep5)0hW*8ez z$=vvIc7ys0bTt^Z4cC$pSAr8jP+)*}S0n5;J4~41b{%cIM*fv_$1_a{7~CzEGF*%a zmo!~DyV(mH=a!>N6aTXY|l>8fd_G+w#(nF|q5jcLBA z13?#dl>PPCA}RNzqD6oVO(@OKym{I-Pa5JmLRwqW$FBiUBnL+P2)@~J(ec|s_sm!R2@$OKicGYN*2GqU(J&T z{Lqn)*=vxuAX1Gv0Dk!C`pCTtlDrGq_gKcHI?^jian>rS^UL?G0{-ilaNK#DTyw56 z{Mo5FbQ?Hew~5Kllovle5o!-n7?EA%~9 z%jQnBip8H@%a9KGo;gZW59-6s%P>_Y62@fk&z9tt_3vec<8wZNl}y-DPVJOG|Iin_ z626Fx(_8z21@R?Y6h3=m$wyZ(m0~u^gGm$C_>_E9bIWd}w}}Fi6`vO0&SEgSdVWB! z70oGSTwI5)%Dq)n3w0Upp_=|g;_;3OZw=}>WJUsdX*M=A4EsAwYD>0ZPrKc^Y`%(P zR4QJgyJNu4aNup&3279U6_ zdbsfLmw#jb+-(ai0SJf=$M4ESh--^XS307Zgwt`pJ8{}aNm%u@LRcdGx zw~H)F7#NIpX{7#kW5V(1H5 zz5AdL#5;!Xs~elu2h{fX{pR6_V=3+&^ruJ{iTx$`s^O_)RYD@?{ol+}(o43PDCFcy z>6@z&ig(9lnQ&Je#^YG*qG0nV5izc-nDi1Oya!vptC5L&xq!LbWas62!Jk9@Hgg$u zcf|NzytpAfC_?Eo)ZG&ywyD+)KyrtAk@F|5=o#Mda4t2W8yW1la)U@5zE9jn2t8L( zX81%5B2%>F4iIQQ*!=|^;t?PSN?@8gFwrSJ@S3$#y8xt&xUbuD-u=7}9#eLWR72-qTT@xu+BTcA6}iClYMq3D|3PS&w~_olnHK zbbUG}X3XIIUV2VpcbYSqR^lWK`E;G4pb|N_JYdhO-P9g;3Pq zx#XGZHE!5Xc?m~}&3$AbIXJZLI=xQV><&VT5CXbQ&*Kz10ue(bo$2A61QOcN*>`p;EOKRNXLPtn*{8w3F-Cleb(>;Dq;Q;C(4 zd?J7xq=(1C&}V+H(IjuWE!QWIPhSF^7YZk!fUfOIo+QzqwU^5k7P>3Y8U%-;?GA!O zHYcntF5ohIP^By2K2uO|W-gA~czK@O*61M(U{K*rXX`j+=FR!L5*bC z8%ZNoC}V;XL!Kpb>sP)JkSj_sf;rwMx2$<+g%bK77T7~8tSw-VD@GV=JA)2g5Hs@& zN(X^2sMAj;J;5fpbBvQ$s%Wr@mKo`t|+60qbQv%_fRc(1N8*2fDS zc~Y)?i3pyo`Y`?2GK=TmHMB1Sk?@)-KhzR}Oj=qWo(Ut-uUx}_lC%xNatZzBfmEBJ zSB2ILfPtS-VxP5RivoeD?|F1}MKFC}S2DXwe+>&i*)@^(pNc<0Ylm@t;ENoizkQkG z#jnpbKyf#qNVcsT*VPwT{GWW9AfDFmg(z^eN2;&JR3~wRYIg?8~`b z6w+Q}ETeZ#j>1Z?z5425VK$AnXI=J;)o?YW1AC@*n=7rc0xy8rmLo~Jcb!bgn3ceG zv1@S2g~rpP*}ia;hD~CRV%Kn2XA_Ux$o_4-22CZ*sM5r!eGy6Peeyw==5WHgAUBr! zfvRYibkq^Pj~pB0`BIi)Xx#xu3H)+%OM`sS+HY@3+2tFUh{#~*CgyA#2A6>lqfn z6S5O{6{Wk3D3`MS+HG^VfwulGBaN;h`#huNIg<4%zjQE;0edb^GBt_26eM9Eg~2<= z%x&8wNd;sz2J(b`T`Vn+b%GZu!pg_&@u44I_b|jc_M^Ast*GX% z~cER`C{E`DzN*%y4r>@ti4A$Le2~6EEK|BE&%nFopIQQ zN!-D9pX<=ija}?3M}Wur)SnR4!Q^=N{TZI>K-5OX+PuZ@ecEdP)O|3 z;Z49IgbEtgSJg(*(Aa^$Aoi=5ZV6^_E4HzP)mn?bbRzqSk-Q@}P! zU^@l7uS{R0FQ1#*uh%#!jP+VDBI7|deK+xz-o;cMwsFQa_N6oU`m|HL^uTLD=QXI? zqFiDND9*>fT!W9Zuh{5;R})jH-(6Au;dQ~kD`bIM)20??E{+DjC_(m7K9a=~L+3%m zmtNX7LSUw(wb78YdD4gQYKDwb0w6BK=Xyc%RRPAvWSvJs>w0h2R385!%w)PxhWr&M01bMie zx>a1ez2u_4;Q$qR#^a%(z`bD;W}PcbW;gZp$;XJ(jj16;20aY3xp5(V_)^EWM`}Gr zK#ADYB0DVWY&9JP_oH)FDL~K(Y0HNT%jo5+7MAC6`q*B*BqP)IfOA zSs1}p4ht#5?g87B?XYTl`HxLvWh($kg4e|Fz2Zvohr;hXR?n)(=s&V%ugp%$J_YTVFooJk<#&j9b704}aM+b!QM* zY2B{6NUDF@2GpzM?B-{6Ghg#rk|qw*Qr=FO%CA^HN`cxwni?*?^I8;o%^2I|#b!@H z!~kFZVrVLm*xR}zG$0!nJB)j{!+gufR3EieNl0$mvb9e%%PXc-huMH^XTw*p?1 zYyBDhW(uaF%N2hMyCTWakzvUi@hY_+R8p{u`b*vcrP^U z_*g|+yWK|d2olI`sQ^ThBwo*25*7;P@yH3tB(f9HU$-isz0RnuWHIEzUyNIb?n@Re zv$Du(b|ul3b3Fq0U>?6%DxBrqHZ@M!(Q9Sr<$XXSD&RZR=lmi8#WaVOpR03FJ!gJX7}xq)vi!L65L~h`COI7w7PQN!xMG^TmKZsOTAK%u z#7EYSymBa>Y&`4@Ffm&lxog|JGhG>BPx$u;Ig zhanra)@5TBV{@8(le)od=MZScTHK2=8cikHIuNW>^0PQLiQ-@U95r?P0sc?spnX8XB-Fwp8ZN9nk*gQNY==j2)0kCP> zDS3wH9LV%ani_3bU2|xy#zAU$rwL<`uAe~6y>{(&G8kQVUiZh>m`rur~bZ0XVL~QQ(q<_ClM)5o8+`+95hA?X0lOj&2f6?i%}xEm~y3R zZA1w3h^*;MJ*GFdRrP9o(a}EeSy$0MRB1H>ND#EI?o(ILX|D1yXsML7Jz;PiQelZ+ zp!i9t0BZQ}Y0c!zH|4A21GdDR7i)Cpg{XY}^=@lm1vWb9>y^p4F^Fj{5|XH~U(`1y zf0U&kUb4c0uQ(#`!MNRwE;%*DP}`saRhM}Q@8)WSInEKkDq_N)ih@A^4cDIuzpTR1 zg1^TRqQx;vVRq~}7XnA(a3&`_p-X}Rp+M!R82&a9yRuU2)qbcH!*(OuBG-ZxL$7^3 zk&b$I^~5I@OdQRRR`nvwa|Z8Ax*#R#RSH|9#$u7?>1oDhG*RHFDlwSr4bi&61QLwz zDLzl|vh{cbR+{+2Riced&uLkYy9`dK_ScE8u`N&ueqg2cUruA%=)P)#35CF58vwV> zIFPBlmMmvWShXzwjAC;X9Q9dnE`&F@@U8Utn=nx1ySEfLX(0((;LiiMhO*{o z332vyIVs;A+_1A?y(oW|?Fl2oUa(^_iON_+oYqiYgd}-iq2eyFl8e*2C7b|Q$7#)w zm1s2=sH^Fdv2u>d+BWU{?4KqFr-5CP>KbEH1xpYDVVij6M-c8AG=ym^@?d!I(P`9u z(W@77VDq{wy0<#R`)C@Tr;x*YPD61$^u=U&KnFrtLk+}c7XYQ}!}&%5t49-o8#I6j z8$BWc@|_PmISg)MZFq}`=(Tu&Y0*gn=!zUT%R6}HnzGC1I3zr#o#GHqMQG@>OzQj7okNAF z(psjhjkl6sE-6TI^GhnVg0K&Qnd~;28l$D{!$=pSZL9m)_hz5f__8{k;McQxsl7yL zoV4+ZL@DetHhsB+u&|Sr*#=j%+t!eitu!F$RMK>tLL_&GeKR_!oe^eQ=FnS3U9fs4 zI?FrCXlH>RT``+eW}G!(+Yec7JR&Y?WJi( zmoa%r*|6?kWI2MyMWFR&UR94W?=gsTJxJ}_*g_YkdUWL!owBrj-lX=Hx;)8+BIbFr zftcCqOWQ7{96mH7cGBrD==xgg7+$j^gyKT_a)O9QZ?{T>TX!jrkd>J#Cm|;2;tO2| z=43{SY5NJhTQKQ*&oeNy$u#WO!de&b$r+usOzH|f+vA&o_9PCcYXVad((7s>b=O!Z zxvTY)LL%1i&SDV@+C7(o`!I)3_ln}{m?q?=Y~@fKh>zj!lY5>N_O3$Ml2U5KPx+(7 zN0LYrf4JaN?NRvbXSVht{+PCc8`(XyfG??_f2D8e;jKH>`WI|T!;WbjqP9zrm*ZR7KW`bM%aMZ4>;lijsSslVlc+pT}&WfxFuQSMv0}uM1%mqJA$7GWa z6pIIode$f6LrBHlm1tMmunGE`=P4W`HIGYvT#t8kYINF0AA{{c=jGrCMA7YO`<&7m znPRW=3T+R(iyAEZD5LAgt+0a^)JQ95Y} zArV<65fxQBr;(Bl?f2HlYs0 ziGdJ%;O|#epl^W;RG_kRG@~>7OHhi=$l8MLJ1b@ZM>7{2pdviba?Qm47dPlXx4gn5 zAS((u#k2^#&-gl#^es}6f5-WyC+g41pS(8g(F7)M06uYiwe0*BfoQ)={+9!*<1+zM zpe4zFKtG#={Y zWh|VWfPQ@cp#n$BpCHi$Fq3A1NJ*f0`j5@bc=iX#zgcbujwXNJ%$8|Sv|Ql8_W^R* zf9Tq6-~sy2ga7Yw^MCDC&}KYbVj#*CIDmc}rk9j|j8g*IG1;2^%l?~tkPAUa! zoPSK-#rj{#|LUpVSk(V~Fn@15{M8crTjX;6d-DGbxPRIH@BK7?9A#=eKOijruWrUa zH|Ben#;-;|-(p?xH>CfwTj$T*@7>LQyk=br|G@pFquD<@LjKJ8-uCLNSK7B=k^Fbg zA3CS~4E^4B>8qpGw|FJ}1N48^U;fBn>u1XM)-XTrI(OM$QvTNt=KtpC^fUK+i;S=aQLge^)V~~G-zzMBoxuDS=O(|*`v;1gKX3c@GJ`*k za60qfF#ev4`Df+EpE=)Gb$=Bt{1(v`f5!Qj&icO6_{Yu)@%|;?4@$*8G>EADkeqE{m7WL`BO#91q`=2-V`_;N1uP(+}zs&l(<<*~)e?RN~b;0jj z5a;|l`5!F*{S5hjw(!SY+EDOI$ls&#chmVlGroU@`a19UEsRQj$M}a?NO>s;-~$;5 R2np~f1o-$>Q}y+){|A@R9n$~+ literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..e3aae5528 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 000000000..249efbb03 --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 000000000..8508ef684 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/shared.gradle b/shared.gradle index c783e539d..4d499f22f 100644 --- a/shared.gradle +++ b/shared.gradle @@ -43,6 +43,16 @@ android { } } +// Kotlin interface defaults become real Java default methods, so Java +// implementers keep inheriting them (e.g. GestureConfigProvider), matching the +// original Java. (Dynamic access because script plugins cannot reference the +// Kotlin plugin classes by name.) +tasks.configureEach { task -> + if (task.name.startsWith("compile") && task.name.contains("Kotlin")) { + task.kotlinOptions.freeCompilerArgs += "-Xjvm-default=all" + } +} + dependencies { // Google common From ff013a741d5303cbd7cc4d3105b6bc6a389e47ac Mon Sep 17 00:00:00 2001 From: Jonathan da Silva Santos Date: Fri, 3 Jul 2026 16:16:07 -0300 Subject: [PATCH 2/2] Convert utils gestures/, traversal/ and core node utilities to Kotlin Mechanical, behavior-preserving Java -> Kotlin conversion of 41 files: the full gestures/ package (GestureMatcher base, GestureManifold, factory and all 18 matchers), the full traversal/ package (ordered + directional strategies, WorkingTree, utils), and the core node utilities (AccessibilityNodeInfoUtils, Role, FocusFinder, WebInterfaceUtils, AccessibilityNodeInfoRef, ScrollableNodeInfo). Every file was converted under a characterization-test harness: 123 new JUnit/Robolectric tests were written against the original Java sources first, then re-run unchanged against the converted Kotlin, pinning the observable behavior (state machines, timeouts, slops, traversal order, focus rules). All public signatures are preserved for Java callers via @JvmStatic/@JvmField/const; package-private members map to internal; @AutoValue classes are hand-written value classes with identical APIs. No behavior change intended. Full APK (phone + wear) builds; the whole unit-test suite passes. --- utils/build.gradle | 9 + .../utils/AccessibilityNodeInfoRef.java | 310 -- .../utils/AccessibilityNodeInfoRef.kt | 280 ++ .../utils/AccessibilityNodeInfoUtils.java | 3479 ---------------- .../utils/AccessibilityNodeInfoUtils.kt | 3671 +++++++++++++++++ .../accessibility/utils/FocusFinder.java | 119 - .../accessibility/utils/FocusFinder.kt | 116 + .../utils/{Role.java => Role.kt} | 518 +-- .../utils/ScrollableNodeInfo.java | 243 -- .../accessibility/utils/ScrollableNodeInfo.kt | 243 ++ .../utils/WebInterfaceUtils.java | 425 -- .../accessibility/utils/WebInterfaceUtils.kt | 420 ++ .../utils/gestures/GestureAnalyticsEvent.java | 50 - .../utils/gestures/GestureAnalyticsEvent.kt | 48 + ...iguration.java => GestureConfiguration.kt} | 12 +- .../utils/gestures/GestureManifold.java | 333 -- .../utils/gestures/GestureManifold.kt | 315 ++ .../utils/gestures/GestureMatcher.java | 573 --- .../utils/gestures/GestureMatcher.kt | 490 +++ .../utils/gestures/GestureMatcherFactory.java | 345 -- .../utils/gestures/GestureMatcherFactory.kt | 345 ++ .../utils/gestures/GestureUtils.java | 131 - .../utils/gestures/GestureUtils.kt | 152 + .../utils/gestures/MultiFingerMultiTap.java | 324 -- .../utils/gestures/MultiFingerMultiTap.kt | 309 ++ ...old.java => MultiFingerMultiTapAndHold.kt} | 62 +- .../utils/gestures/MultiFingerSwipe.java | 476 --- .../utils/gestures/MultiFingerSwipe.kt | 470 +++ .../utils/gestures/MultiTap.java | 256 -- .../accessibility/utils/gestures/MultiTap.kt | 239 ++ .../utils/gestures/MultiTapAndHold.java | 88 - .../utils/gestures/MultiTapAndHold.kt | 86 + .../utils/gestures/SecondFingerMultiTap.java | 188 - .../utils/gestures/SecondFingerMultiTap.kt | 169 + .../utils/gestures/SecondFingerTap.java | 193 - .../utils/gestures/SecondFingerTap.kt | 181 + ...AndHold.java => SecondFingerTapAndHold.kt} | 70 +- .../accessibility/utils/gestures/Swipe.java | 423 -- .../accessibility/utils/gestures/Swipe.kt | 423 ++ .../utils/gestures/TapToTouchExplore.java | 148 - .../utils/gestures/TapToTouchExplore.kt | 142 + .../utils/gestures/TapUpToTouchExplore.java | 139 - .../utils/gestures/TapUpToTouchExplore.kt | 134 + .../TwoFingerSecondFingerMultiTap.java | 305 -- .../gestures/TwoFingerSecondFingerMultiTap.kt | 293 ++ ....java => TwoFingerSingleTapAndLongHold.kt} | 53 +- .../DirectionalTraversalStrategy.java | 582 --- .../traversal/DirectionalTraversalStrategy.kt | 579 +++ .../utils/traversal/GridTraversalManager.java | 118 - .../utils/traversal/GridTraversalManager.kt | 127 + .../traversal/NodeCachedBoundsCalculator.java | 153 - .../traversal/NodeCachedBoundsCalculator.kt | 148 + ...odeFocusFinder.java => NodeFocusFinder.kt} | 47 +- .../traversal/OrderedTraversalController.java | 374 -- .../traversal/OrderedTraversalController.kt | 320 ++ .../traversal/OrderedTraversalStrategy.java | 104 - .../traversal/OrderedTraversalStrategy.kt | 91 + .../OrderedTraversalStrategyConfig.java | 49 - .../OrderedTraversalStrategyConfig.kt | 95 + .../traversal/ReorderedChildrenIterator.java | 276 -- .../traversal/ReorderedChildrenIterator.kt | 275 ++ .../traversal/SimpleTraversalStrategy.java | 70 - .../traversal/SimpleTraversalStrategy.kt | 71 + ...lUtils.java => SpannableTraversalUtils.kt} | 157 +- ...rsalStrategy.java => TraversalStrategy.kt} | 115 +- .../traversal/TraversalStrategyUtils.java | 456 -- .../utils/traversal/TraversalStrategyUtils.kt | 466 +++ .../utils/traversal/WorkingTree.java | 201 - .../utils/traversal/WorkingTree.kt | 193 + ...bilityNodeInfoUtilsCharacterizationTest.kt | 305 ++ .../utils/CoreUtilsCharacterizationTest.kt | 189 + .../WebAndScrollUtilsCharacterizationTest.kt | 203 + ...stureAnalyticsEventCharacterizationTest.kt | 52 + ...estureConfigurationCharacterizationTest.kt | 39 + .../GestureManifoldCharacterizationTest.kt | 189 + .../GestureMatcherBaseCharacterizationTest.kt | 133 + .../GestureUtilsCharacterizationTest.kt | 128 + ...MultiFingerGesturesCharacterizationTest.kt | 240 ++ .../MultiTapAndHoldCharacterizationTest.kt | 103 + .../gestures/MultiTapCharacterizationTest.kt | 143 + ...econdFingerGesturesCharacterizationTest.kt | 172 + .../gestures/SwipeCharacterizationTest.kt | 160 + ...ouchExploreMatchersCharacterizationTest.kt | 114 + ...ingleTapAndLongHoldCharacterizationTest.kt | 122 + ...irectionalTraversalCharacterizationTest.kt | 151 + .../OrderedTraversalCharacterizationTest.kt | 126 + .../TraversalBasicsCharacterizationTest.kt | 104 + ...versalStrategyUtilsCharacterizationTest.kt | 228 + .../WorkingTreeCharacterizationTest.kt | 102 + 89 files changed, 14420 insertions(+), 11448 deletions(-) delete mode 100644 utils/src/main/java/com/google/android/accessibility/utils/AccessibilityNodeInfoRef.java create mode 100644 utils/src/main/java/com/google/android/accessibility/utils/AccessibilityNodeInfoRef.kt delete mode 100644 utils/src/main/java/com/google/android/accessibility/utils/AccessibilityNodeInfoUtils.java create mode 100644 utils/src/main/java/com/google/android/accessibility/utils/AccessibilityNodeInfoUtils.kt delete mode 100644 utils/src/main/java/com/google/android/accessibility/utils/FocusFinder.java create mode 100644 utils/src/main/java/com/google/android/accessibility/utils/FocusFinder.kt rename utils/src/main/java/com/google/android/accessibility/utils/{Role.java => Role.kt} (75%) delete mode 100644 utils/src/main/java/com/google/android/accessibility/utils/ScrollableNodeInfo.java create mode 100644 utils/src/main/java/com/google/android/accessibility/utils/ScrollableNodeInfo.kt delete mode 100644 utils/src/main/java/com/google/android/accessibility/utils/WebInterfaceUtils.java create mode 100644 utils/src/main/java/com/google/android/accessibility/utils/WebInterfaceUtils.kt delete mode 100644 utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureAnalyticsEvent.java create mode 100644 utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureAnalyticsEvent.kt rename utils/src/main/java/com/google/android/accessibility/utils/gestures/{GestureConfiguration.java => GestureConfiguration.kt} (75%) delete mode 100644 utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureManifold.java create mode 100644 utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureManifold.kt delete mode 100644 utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureMatcher.java create mode 100644 utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureMatcher.kt delete mode 100644 utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureMatcherFactory.java create mode 100644 utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureMatcherFactory.kt delete mode 100644 utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureUtils.java create mode 100644 utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureUtils.kt delete mode 100644 utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiFingerMultiTap.java create mode 100644 utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiFingerMultiTap.kt rename utils/src/main/java/com/google/android/accessibility/utils/gestures/{MultiFingerMultiTapAndHold.java => MultiFingerMultiTapAndHold.kt} (51%) delete mode 100644 utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiFingerSwipe.java create mode 100644 utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiFingerSwipe.kt delete mode 100644 utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiTap.java create mode 100644 utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiTap.kt delete mode 100644 utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiTapAndHold.java create mode 100644 utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiTapAndHold.kt delete mode 100644 utils/src/main/java/com/google/android/accessibility/utils/gestures/SecondFingerMultiTap.java create mode 100644 utils/src/main/java/com/google/android/accessibility/utils/gestures/SecondFingerMultiTap.kt delete mode 100644 utils/src/main/java/com/google/android/accessibility/utils/gestures/SecondFingerTap.java create mode 100644 utils/src/main/java/com/google/android/accessibility/utils/gestures/SecondFingerTap.kt rename utils/src/main/java/com/google/android/accessibility/utils/gestures/{SecondFingerTapAndHold.java => SecondFingerTapAndHold.kt} (51%) delete mode 100644 utils/src/main/java/com/google/android/accessibility/utils/gestures/Swipe.java create mode 100644 utils/src/main/java/com/google/android/accessibility/utils/gestures/Swipe.kt delete mode 100644 utils/src/main/java/com/google/android/accessibility/utils/gestures/TapToTouchExplore.java create mode 100644 utils/src/main/java/com/google/android/accessibility/utils/gestures/TapToTouchExplore.kt delete mode 100644 utils/src/main/java/com/google/android/accessibility/utils/gestures/TapUpToTouchExplore.java create mode 100644 utils/src/main/java/com/google/android/accessibility/utils/gestures/TapUpToTouchExplore.kt delete mode 100644 utils/src/main/java/com/google/android/accessibility/utils/gestures/TwoFingerSecondFingerMultiTap.java create mode 100644 utils/src/main/java/com/google/android/accessibility/utils/gestures/TwoFingerSecondFingerMultiTap.kt rename utils/src/main/java/com/google/android/accessibility/utils/gestures/{TwoFingerSingleTapAndLongHold.java => TwoFingerSingleTapAndLongHold.kt} (55%) delete mode 100644 utils/src/main/java/com/google/android/accessibility/utils/traversal/DirectionalTraversalStrategy.java create mode 100644 utils/src/main/java/com/google/android/accessibility/utils/traversal/DirectionalTraversalStrategy.kt delete mode 100644 utils/src/main/java/com/google/android/accessibility/utils/traversal/GridTraversalManager.java create mode 100644 utils/src/main/java/com/google/android/accessibility/utils/traversal/GridTraversalManager.kt delete mode 100644 utils/src/main/java/com/google/android/accessibility/utils/traversal/NodeCachedBoundsCalculator.java create mode 100644 utils/src/main/java/com/google/android/accessibility/utils/traversal/NodeCachedBoundsCalculator.kt rename utils/src/main/java/com/google/android/accessibility/utils/traversal/{NodeFocusFinder.java => NodeFocusFinder.kt} (58%) delete mode 100644 utils/src/main/java/com/google/android/accessibility/utils/traversal/OrderedTraversalController.java create mode 100644 utils/src/main/java/com/google/android/accessibility/utils/traversal/OrderedTraversalController.kt delete mode 100644 utils/src/main/java/com/google/android/accessibility/utils/traversal/OrderedTraversalStrategy.java create mode 100644 utils/src/main/java/com/google/android/accessibility/utils/traversal/OrderedTraversalStrategy.kt delete mode 100644 utils/src/main/java/com/google/android/accessibility/utils/traversal/OrderedTraversalStrategyConfig.java create mode 100644 utils/src/main/java/com/google/android/accessibility/utils/traversal/OrderedTraversalStrategyConfig.kt delete mode 100644 utils/src/main/java/com/google/android/accessibility/utils/traversal/ReorderedChildrenIterator.java create mode 100644 utils/src/main/java/com/google/android/accessibility/utils/traversal/ReorderedChildrenIterator.kt delete mode 100644 utils/src/main/java/com/google/android/accessibility/utils/traversal/SimpleTraversalStrategy.java create mode 100644 utils/src/main/java/com/google/android/accessibility/utils/traversal/SimpleTraversalStrategy.kt rename utils/src/main/java/com/google/android/accessibility/utils/traversal/{SpannableTraversalUtils.java => SpannableTraversalUtils.kt} (51%) rename utils/src/main/java/com/google/android/accessibility/utils/traversal/{TraversalStrategy.java => TraversalStrategy.kt} (50%) delete mode 100644 utils/src/main/java/com/google/android/accessibility/utils/traversal/TraversalStrategyUtils.java create mode 100644 utils/src/main/java/com/google/android/accessibility/utils/traversal/TraversalStrategyUtils.kt delete mode 100644 utils/src/main/java/com/google/android/accessibility/utils/traversal/WorkingTree.java create mode 100644 utils/src/main/java/com/google/android/accessibility/utils/traversal/WorkingTree.kt create mode 100644 utils/src/test/java/com/google/android/accessibility/utils/AccessibilityNodeInfoUtilsCharacterizationTest.kt create mode 100644 utils/src/test/java/com/google/android/accessibility/utils/CoreUtilsCharacterizationTest.kt create mode 100644 utils/src/test/java/com/google/android/accessibility/utils/WebAndScrollUtilsCharacterizationTest.kt create mode 100644 utils/src/test/java/com/google/android/accessibility/utils/gestures/GestureAnalyticsEventCharacterizationTest.kt create mode 100644 utils/src/test/java/com/google/android/accessibility/utils/gestures/GestureConfigurationCharacterizationTest.kt create mode 100644 utils/src/test/java/com/google/android/accessibility/utils/gestures/GestureManifoldCharacterizationTest.kt create mode 100644 utils/src/test/java/com/google/android/accessibility/utils/gestures/GestureMatcherBaseCharacterizationTest.kt create mode 100644 utils/src/test/java/com/google/android/accessibility/utils/gestures/GestureUtilsCharacterizationTest.kt create mode 100644 utils/src/test/java/com/google/android/accessibility/utils/gestures/MultiFingerGesturesCharacterizationTest.kt create mode 100644 utils/src/test/java/com/google/android/accessibility/utils/gestures/MultiTapAndHoldCharacterizationTest.kt create mode 100644 utils/src/test/java/com/google/android/accessibility/utils/gestures/MultiTapCharacterizationTest.kt create mode 100644 utils/src/test/java/com/google/android/accessibility/utils/gestures/SecondFingerGesturesCharacterizationTest.kt create mode 100644 utils/src/test/java/com/google/android/accessibility/utils/gestures/SwipeCharacterizationTest.kt create mode 100644 utils/src/test/java/com/google/android/accessibility/utils/gestures/TouchExploreMatchersCharacterizationTest.kt create mode 100644 utils/src/test/java/com/google/android/accessibility/utils/gestures/TwoFingerSingleTapAndLongHoldCharacterizationTest.kt create mode 100644 utils/src/test/java/com/google/android/accessibility/utils/traversal/DirectionalTraversalCharacterizationTest.kt create mode 100644 utils/src/test/java/com/google/android/accessibility/utils/traversal/OrderedTraversalCharacterizationTest.kt create mode 100644 utils/src/test/java/com/google/android/accessibility/utils/traversal/TraversalBasicsCharacterizationTest.kt create mode 100644 utils/src/test/java/com/google/android/accessibility/utils/traversal/TraversalStrategyUtilsCharacterizationTest.kt create mode 100644 utils/src/test/java/com/google/android/accessibility/utils/traversal/WorkingTreeCharacterizationTest.kt diff --git a/utils/build.gradle b/utils/build.gradle index db64c88d0..657c01bce 100644 --- a/utils/build.gradle +++ b/utils/build.gradle @@ -6,6 +6,10 @@ apply from: "../shared.gradle" dependencies { implementation project(':proguard') + // Characterization tests: written against the original Java, then run + // unchanged against the converted Kotlin. + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.robolectric:robolectric:4.14.1' } android { @@ -14,4 +18,9 @@ android { buildConfigField("String", "TALKBACK_APPLICATION_ID", '"' + talkbackApplicationId + '"') buildConfigField("String", "IS_SYSTEM_PRELOAD", "\"False\"") } + testOptions { + unitTests { + includeAndroidResources = true + } + } } diff --git a/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityNodeInfoRef.java b/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityNodeInfoRef.java deleted file mode 100644 index 04ce4d617..000000000 --- a/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityNodeInfoRef.java +++ /dev/null @@ -1,310 +0,0 @@ -/* - * Copyright (C) 2012 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not - * use this file except in compliance with the License. You may obtain a copy of - * the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations under - * the License. - */ - -package com.google.android.accessibility.utils; - -import androidx.annotation.Nullable; -import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; -import com.google.android.accessibility.utils.traversal.ReorderedChildrenIterator; -import com.google.errorprone.annotations.CanIgnoreReturnValue; -import java.util.HashSet; -import java.util.Iterator; -import java.util.Set; - -/** - * A class that simplifies traversal of node trees. - * - *

This class keeps track of an {@link AccessibilityNodeInfoCompat} object and can traverse to - * other nodes in the tree, or be reset to other nodes. The node can be owned. - * - *

Any node can be assigned to objects of this class, including nodes that are not visible to the - * user. The traversal methods, however, will only traverse to visible nodes. - * - * @see AccessibilityNodeInfoUtils#isVisible(AccessibilityNodeInfoCompat) - */ -public class AccessibilityNodeInfoRef { - private AccessibilityNodeInfoCompat mNode; - - /** Returns the current node. */ - public AccessibilityNodeInfoCompat get() { - return mNode; - } - - /** Clears this object. */ - public void clear() { - reset((AccessibilityNodeInfoCompat) null); - } - - /** Resets this object to contain a new node, taking ownership of the new node. */ - public void reset(AccessibilityNodeInfoCompat newNode) { - mNode = newNode; - } - - /** - * Resets this object with the node held by {@code newNode}. if {@code newNode} was owning the - * node, ownership is transfered to this object. - */ - public void reset(AccessibilityNodeInfoRef newNode) { - reset(newNode.get()); - } - - /** Creates a new instance of this class. */ - public static AccessibilityNodeInfoRef obtain(AccessibilityNodeInfoCompat node) { - return new AccessibilityNodeInfoRef(node); - } - - /** Creates a new instance of this class without assuming ownership of {@code node}. */ - @Nullable - public static AccessibilityNodeInfoRef unOwned(AccessibilityNodeInfoCompat node) { - return node != null ? new AccessibilityNodeInfoRef(node) : null; - } - - /** Creates a new instance of this class taking ownership of {@code node}. */ - @Nullable - public static AccessibilityNodeInfoRef owned(AccessibilityNodeInfoCompat node) { - return node != null ? new AccessibilityNodeInfoRef(node) : null; - } - - /** - * Creates an {@link AccessibilityNodeInfoRef} with a refreshed copy of {@code node}, taking - * ownership of the copy. If {@code node} is {@code null}, {@code null} is returned. - */ - public static AccessibilityNodeInfoRef refreshed(AccessibilityNodeInfoCompat node) { - return owned(AccessibilityNodeInfoUtils.refreshNode(node)); - } - - /** - * Makes sure that this object owns its own copy of the node it holds by creating a new copy of - * the node if not already owned or doing nothing otherwise. - */ - @CanIgnoreReturnValue - public AccessibilityNodeInfoRef makeOwned() { - reset(mNode); - return this; - } - - public AccessibilityNodeInfoRef() {} - - private AccessibilityNodeInfoRef(AccessibilityNodeInfoCompat node) { - mNode = node; - } - - public static boolean isNull(AccessibilityNodeInfoRef ref) { - return ref == null || ref.get() == null; - } - - /** - * Releases the ownership of the underlying node if it was owned, returning the underlying node. - * This is typically chained with {@link #makeOwned} to have a copy that can be put in another - * container or {@link AccessibilityNodeInfoRef}. After this call, this object still refers to the - * underlying node so that any of the traversal methods can be used afterwards. - */ - public AccessibilityNodeInfoCompat release() { - return mNode; - } - - /** Traverses to the last child of this node, returning {@code true} on success. */ - boolean lastChild() { - if (mNode == null || mNode.getChildCount() < 1) { - return false; - } - - ReorderedChildrenIterator iterator = ReorderedChildrenIterator.createDescendingIterator(mNode); - while (iterator.hasNext()) { - AccessibilityNodeInfoCompat newNode = iterator.next(); - if (newNode == null) { - return false; - } - - if (AccessibilityNodeInfoUtils.isVisible(newNode)) { - reset(newNode); - return true; - } - } - return false; - } - - /** - * Traverses to the previous sibling of this node within its parent, returning {@code true} on - * success. - */ - public boolean previousSibling() { - if (mNode == null) { - return false; - } - AccessibilityNodeInfoCompat parent = mNode.getParent(); - if (parent == null) { - return false; - } - ReorderedChildrenIterator iterator = ReorderedChildrenIterator.createDescendingIterator(parent); - if (!moveIteratorAfterNode(iterator, mNode)) { - return false; - } - - while (iterator.hasNext()) { - AccessibilityNodeInfoCompat newNode = iterator.next(); - if (newNode == null) { - return false; - } - if (AccessibilityNodeInfoUtils.isVisible(newNode)) { - reset(newNode); - return true; - } - } - return false; - } - - /** Traverses to the first child of this node if any, returning {@code true} on success. */ - boolean firstChild() { - if (mNode == null) { - return false; - } - - ReorderedChildrenIterator iterator = ReorderedChildrenIterator.createAscendingIterator(mNode); - while (iterator.hasNext()) { - AccessibilityNodeInfoCompat newNode = iterator.next(); - if (newNode == null) { - return false; - } - if (AccessibilityNodeInfoUtils.isVisible(newNode)) { - reset(newNode); - return true; - } - } - return false; - } - - /** - * Traverses to the next sibling of this node within its parent, returning {@code true} on - * success. - */ - public boolean nextSibling() { - if (mNode == null) { - return false; - } - AccessibilityNodeInfoCompat parent = mNode.getParent(); - if (parent == null) { - return false; - } - ReorderedChildrenIterator iterator = ReorderedChildrenIterator.createAscendingIterator(parent); - if (!moveIteratorAfterNode(iterator, mNode)) { - return false; - } - - while (iterator.hasNext()) { - AccessibilityNodeInfoCompat newNode = iterator.next(); - if (newNode == null) { - return false; - } - if (AccessibilityNodeInfoUtils.isVisible(newNode)) { - reset(newNode); - return true; - } - } - return false; - } - - @SuppressWarnings("BooleanMethodIsAlwaysInverted") - private boolean moveIteratorAfterNode( - Iterator iterator, AccessibilityNodeInfoCompat node) { - if (node == null) { - return false; - } - while (iterator.hasNext()) { - AccessibilityNodeInfoCompat nextNode = iterator.next(); - if (node.equals(nextNode)) { - return true; - } - } - - return false; - } - - /** - * Traverses to the parent of this node, returning {@code true} on success. On failure, returns - * {@code false} and does not move. - */ - public boolean parent() { - if (mNode == null) { - return false; - } - Set visitedNodes = new HashSet<>(); - visitedNodes.add(mNode); - AccessibilityNodeInfoCompat parentNode = mNode.getParent(); - while (parentNode != null) { - if (visitedNodes.contains(parentNode)) { - return false; - } - - if (AccessibilityNodeInfoUtils.isVisible(parentNode)) { - reset(parentNode); - return true; - } - visitedNodes.add(parentNode); - parentNode = parentNode.getParent(); - } - return false; - } - - /** Traverses to the next node in depth-first order, returning {@code true} on success. */ - public boolean nextInOrder() { - if (mNode == null) { - return false; - } - if (firstChild()) { - return true; - } - if (nextSibling()) { - return true; - } - AccessibilityNodeInfoRef tmp = unOwned(mNode); - while (tmp.parent()) { - if (tmp.nextSibling()) { - reset(tmp); - return true; - } - } - tmp.clear(); - return false; - } - - /** Traverses to the previous node in depth-first order, returning {@code true} on success. */ - public boolean previousInOrder() { - if (mNode == null) { - return false; - } - if (previousSibling()) { - lastDescendant(); - return true; - } - return parent(); - } - - /** Traverses to the last descendant of this node, returning {@code true} on success. */ - public boolean lastDescendant() { - if (!lastChild()) { - return false; - } - Set visitedNodes = new HashSet<>(); - while (lastChild()) { - if (visitedNodes.contains(mNode)) { - return false; - } - visitedNodes.add(mNode); - } - return true; - } -} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityNodeInfoRef.kt b/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityNodeInfoRef.kt new file mode 100644 index 000000000..5b9c2548c --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityNodeInfoRef.kt @@ -0,0 +1,280 @@ +/* + * Copyright (C) The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Ported from Java to Kotlin. + */ + +package com.google.android.accessibility.utils + +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import com.google.android.accessibility.utils.traversal.ReorderedChildrenIterator +import com.google.errorprone.annotations.CanIgnoreReturnValue + +/** + * A class that simplifies traversal of node trees. + * + *

This class keeps track of an {@link AccessibilityNodeInfoCompat} object and can traverse to + * other nodes in the tree, or be reset to other nodes. The node can be owned. + * + *

Any node can be assigned to objects of this class, including nodes that are not visible to the + * user. The traversal methods, however, will only traverse to visible nodes. + * + * @see AccessibilityNodeInfoUtils#isVisible(AccessibilityNodeInfoCompat) + */ +class AccessibilityNodeInfoRef { + private var mNode: AccessibilityNodeInfoCompat? = null + + /** Returns the current node. */ + fun get(): AccessibilityNodeInfoCompat? = mNode + + /** Clears this object. */ + fun clear() { + reset(null as AccessibilityNodeInfoCompat?) + } + + /** Resets this object to contain a new node, taking ownership of the new node. */ + fun reset(newNode: AccessibilityNodeInfoCompat?) { + mNode = newNode + } + + /** + * Resets this object with the node held by {@code newNode}. if {@code newNode} was owning the + * node, ownership is transfered to this object. + */ + fun reset(newNode: AccessibilityNodeInfoRef) { + reset(newNode.get()) + } + + /** + * Makes sure that this object owns its own copy of the node it holds by creating a new copy of + * the node if not already owned or doing nothing otherwise. + */ + @CanIgnoreReturnValue + fun makeOwned(): AccessibilityNodeInfoRef { + reset(mNode) + return this + } + + constructor() + + private constructor(node: AccessibilityNodeInfoCompat?) { + mNode = node + } + + /** + * Releases the ownership of the underlying node if it was owned, returning the underlying node. + * This is typically chained with {@link #makeOwned} to have a copy that can be put in another + * container or {@link AccessibilityNodeInfoRef}. After this call, this object still refers to the + * underlying node so that any of the traversal methods can be used afterwards. + */ + fun release(): AccessibilityNodeInfoCompat? = mNode + + /** Traverses to the last child of this node, returning {@code true} on success. */ + internal fun lastChild(): Boolean { + val node = mNode + if (node == null || node.childCount < 1) { + return false + } + + val iterator = ReorderedChildrenIterator.createDescendingIterator(node) + while (iterator.hasNext()) { + val newNode = iterator.next() ?: return false + + if (AccessibilityNodeInfoUtils.isVisible(newNode)) { + reset(newNode) + return true + } + } + return false + } + + /** + * Traverses to the previous sibling of this node within its parent, returning {@code true} on + * success. + */ + fun previousSibling(): Boolean { + val node = mNode ?: return false + val parent = node.parent ?: return false + val iterator = ReorderedChildrenIterator.createDescendingIterator(parent) + if (!moveIteratorAfterNode(iterator, node)) { + return false + } + + while (iterator.hasNext()) { + val newNode = iterator.next() ?: return false + if (AccessibilityNodeInfoUtils.isVisible(newNode)) { + reset(newNode) + return true + } + } + return false + } + + /** Traverses to the first child of this node if any, returning {@code true} on success. */ + internal fun firstChild(): Boolean { + val node = mNode ?: return false + + val iterator = ReorderedChildrenIterator.createAscendingIterator(node) + while (iterator.hasNext()) { + val newNode = iterator.next() ?: return false + if (AccessibilityNodeInfoUtils.isVisible(newNode)) { + reset(newNode) + return true + } + } + return false + } + + /** + * Traverses to the next sibling of this node within its parent, returning {@code true} on + * success. + */ + fun nextSibling(): Boolean { + val node = mNode ?: return false + val parent = node.parent ?: return false + val iterator = ReorderedChildrenIterator.createAscendingIterator(parent) + if (!moveIteratorAfterNode(iterator, node)) { + return false + } + + while (iterator.hasNext()) { + val newNode = iterator.next() ?: return false + if (AccessibilityNodeInfoUtils.isVisible(newNode)) { + reset(newNode) + return true + } + } + return false + } + + private fun moveIteratorAfterNode( + iterator: Iterator, + node: AccessibilityNodeInfoCompat?, + ): Boolean { + if (node == null) { + return false + } + while (iterator.hasNext()) { + val nextNode = iterator.next() + if (node == nextNode) { + return true + } + } + + return false + } + + /** + * Traverses to the parent of this node, returning {@code true} on success. On failure, returns + * {@code false} and does not move. + */ + fun parent(): Boolean { + val node = mNode ?: return false + val visitedNodes = HashSet() + visitedNodes.add(node) + var parentNode = node.parent + while (parentNode != null) { + if (visitedNodes.contains(parentNode)) { + return false + } + + if (AccessibilityNodeInfoUtils.isVisible(parentNode)) { + reset(parentNode) + return true + } + visitedNodes.add(parentNode) + parentNode = parentNode.parent + } + return false + } + + /** Traverses to the next node in depth-first order, returning {@code true} on success. */ + fun nextInOrder(): Boolean { + if (mNode == null) { + return false + } + if (firstChild()) { + return true + } + if (nextSibling()) { + return true + } + val tmp = unOwned(mNode) ?: return false + while (tmp.parent()) { + if (tmp.nextSibling()) { + reset(tmp) + return true + } + } + tmp.clear() + return false + } + + /** Traverses to the previous node in depth-first order, returning {@code true} on success. */ + fun previousInOrder(): Boolean { + if (mNode == null) { + return false + } + if (previousSibling()) { + lastDescendant() + return true + } + return parent() + } + + /** Traverses to the last descendant of this node, returning {@code true} on success. */ + fun lastDescendant(): Boolean { + if (!lastChild()) { + return false + } + val visitedNodes = HashSet() + while (lastChild()) { + val node = mNode ?: return false + if (visitedNodes.contains(node)) { + return false + } + visitedNodes.add(node) + } + return true + } + + companion object { + /** Creates a new instance of this class. */ + @JvmStatic + fun obtain(node: AccessibilityNodeInfoCompat?): AccessibilityNodeInfoRef = + AccessibilityNodeInfoRef(node) + + /** Creates a new instance of this class without assuming ownership of {@code node}. */ + @JvmStatic + fun unOwned(node: AccessibilityNodeInfoCompat?): AccessibilityNodeInfoRef? = + if (node != null) AccessibilityNodeInfoRef(node) else null + + /** Creates a new instance of this class taking ownership of {@code node}. */ + @JvmStatic + fun owned(node: AccessibilityNodeInfoCompat?): AccessibilityNodeInfoRef? = + if (node != null) AccessibilityNodeInfoRef(node) else null + + /** + * Creates an {@link AccessibilityNodeInfoRef} with a refreshed copy of {@code node}, taking + * ownership of the copy. If {@code node} is {@code null}, {@code null} is returned. + */ + @JvmStatic + fun refreshed(node: AccessibilityNodeInfoCompat?): AccessibilityNodeInfoRef? = + owned(AccessibilityNodeInfoUtils.refreshNode(node)) + + @JvmStatic + fun isNull(ref: AccessibilityNodeInfoRef?): Boolean = ref == null || ref.get() == null + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityNodeInfoUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityNodeInfoUtils.java deleted file mode 100644 index 22537909c..000000000 --- a/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityNodeInfoUtils.java +++ /dev/null @@ -1,3479 +0,0 @@ -/* - * Copyright (C) 2012 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.android.accessibility.utils; - -import static android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS; -import static com.google.android.accessibility.utils.AccessibilityWindowInfoUtils.WINDOW_ID_NONE; -import static com.google.android.accessibility.utils.AccessibilityWindowInfoUtils.WINDOW_TYPE_NONE; -import static com.google.android.accessibility.utils.DiagnosticOverlayUtils.FOCUS_FAIL_FAIL_ALL_FOCUS_TESTS; -import static com.google.android.accessibility.utils.DiagnosticOverlayUtils.FOCUS_FAIL_NOT_SPEAKABLE; -import static com.google.android.accessibility.utils.DiagnosticOverlayUtils.FOCUS_FAIL_NOT_VISIBLE; -import static com.google.android.accessibility.utils.DiagnosticOverlayUtils.FOCUS_FAIL_SAME_WINDOW_BOUNDS_CHILDREN; -import static com.google.android.accessibility.utils.DiagnosticOverlayUtils.NONE; -import static com.google.android.accessibility.utils.Role.ROLE_GRID; -import static com.google.android.accessibility.utils.Role.ROLE_HORIZONTAL_SCROLL_VIEW; -import static com.google.android.accessibility.utils.Role.ROLE_LIST; -import static com.google.android.accessibility.utils.Role.ROLE_PAGER; -import static com.google.android.accessibility.utils.Role.ROLE_SCROLL_VIEW; -import static com.google.android.accessibility.utils.Role.ROLE_WEB_VIEW; - -import android.content.Context; -import android.graphics.Point; -import android.graphics.Rect; -import android.graphics.RectF; -import android.os.Bundle; -import android.os.LocaleList; -import android.os.Parcelable; -import android.text.Spannable; -import android.text.SpannableString; -import android.text.TextUtils; -import android.text.style.ClickableSpan; -import android.text.style.SuggestionSpan; -import android.text.style.URLSpan; -import android.util.Pair; -import android.view.accessibility.AccessibilityNodeInfo; -import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction; -import android.view.accessibility.AccessibilityNodeInfo.CollectionItemInfo; -import android.view.accessibility.AccessibilityWindowInfo; -import android.widget.GridView; -import android.widget.ListView; -import androidx.annotation.VisibleForTesting; -import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; -import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat; -import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.CollectionInfoCompat; -import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.CollectionItemInfoCompat; -import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.RangeInfoCompat; -import androidx.core.view.accessibility.AccessibilityWindowInfoCompat; -import com.google.android.accessibility.utils.DiagnosticOverlayUtils.DiagnosticType; -import com.google.android.accessibility.utils.Role.RoleName; -import com.google.android.accessibility.utils.SpannableUtils.SpannableWithOffset; -import com.google.android.accessibility.utils.compat.CompatUtils; -import com.google.android.accessibility.utils.traversal.SpannableTraversalUtils; -import com.google.android.libraries.accessibility.utils.log.LogUtils; -import com.google.android.libraries.accessibility.utils.url.SpannableUrl; -import com.google.auto.value.AutoValue; -import com.google.common.base.Function; -import com.google.common.base.Strings; -import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableSet; -import com.google.errorprone.annotations.FormatMethod; -import com.google.errorprone.annotations.FormatString; -import java.util.ArrayDeque; -import java.util.ArrayList; -import java.util.Collection; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Set; -import java.util.regex.Pattern; -import org.checkerframework.checker.nullness.qual.NonNull; -import org.checkerframework.checker.nullness.qual.Nullable; -import org.checkerframework.checker.nullness.qual.PolyNull; - -/** Provides a series of utilities for interacting with AccessibilityNodeInfo objects. */ -public class AccessibilityNodeInfoUtils { - - /** Internal AccessibilityNodeInfoCompat extras bundle key constants. */ - // The minimum amount of pixels that must be visible for a view to be surfaced to the user as - // visible (i.e. for this node to be added to the tree). - public static final int MIN_VISIBLE_PIXELS = 15; - - private static final String CLASS_LISTVIEW = ListView.class.getName(); - private static final String CLASS_GRIDVIEW = GridView.class.getName(); - - private static final HashMap actionIdToName = initActionIds(); - - /** Returns text from an accessibility-node, including spans. */ - public static @Nullable CharSequence getText(@Nullable AccessibilityNodeInfoCompat node) { - return (node == null) ? null : node.getText(); - } - - @FormatMethod - private static void logError(String functionName, @FormatString String format, Object... args) { - LogUtils.e(TAG, functionName + "() " + String.format(format, args)); - } - - ////////////////////////////////////////////////////////////////////////////////////////// - // Constants - - private static final String TAG = "AccessibilityNodeInfoUtils"; - - /** - * Class for Samsung's TouchWiz implementation of AdapterView. May be {@code null} on non-Samsung - * devices. - */ - private static final Class CLASS_TOUCHWIZ_TWADAPTERVIEW = - CompatUtils.getClass("com.sec.android.touchwiz.widget.TwAdapterView"); - - /** Key to get accessibility web hints from the web */ - private static final String HINT_TEXT_KEY = "AccessibilityNodeInfo.hint"; - - private static final Pattern RESOURCE_NAME_SPLIT_PATTERN = Pattern.compile(":id/"); - - /** Class used to find clickable-spans in text. */ - public static final Class BASE_CLICKABLE_SPAN = ClickableSpan.class; - - private static final String VIEW_ID_RESOURCE_NAME_PIN_ENTRY = "com.android.systemui:id/pinEntry"; - - @VisibleForTesting - static final String VIEW_ID_WEAR_UNREAD_NOTIFICATION_DOT = - "com.google.android.wearable.sysui:id/unread_dot"; - - @VisibleForTesting static final int THRESHOLD_HEIGHT_DP_FOR_SMALL_NODE = 32; - - /** Key to get the chrome role from the node */ - private static final String EXTRAS_KEY_CHROME_ROLE = "AccessibilityNodeInfo.chromeRole"; - - /** - * Chrome role for link. The role string should come from `ToString(ax::mojom::Role role)` at - * ui/accessibility/ax_enum_util.cc in the Chromium repo. - * https://source.chromium.org/chromium/chromium/src/+/main:ui/accessibility/ax_enum_util.cc?q=%22ToString(ax::mojom::Role%20role)%22%20f:ui%2Faccessibility%2Fax_enum_util.cc - */ - private static final String CHROME_ROLE_LINK = "link"; - - /** - * A wrapper over AccessibilityNodeInfoCompat constructor, so that we can add any desired error - * checking and memory management. - * - * @param nodeInfo The AccessibilityNodeInfo which will be wrapped. - * @return Encapsulating AccessibilityNodeInfoCompat, or null if input is null. - */ - public static @PolyNull AccessibilityNodeInfoCompat toCompat( - @PolyNull AccessibilityNodeInfo nodeInfo) { - if (nodeInfo == null) { - return null; - } - return AccessibilityNodeInfoCompat.wrap(nodeInfo); - } - - private static final int SYSTEM_ACTION_MAX = 0x01FFFFFF; - - public static final int WINDOW_TYPE_PICTURE_IN_PICTURE = 1000; - - /** - * Filter for scrollable items. One of the following must be true: - * - *

    - *
  • {@link AccessibilityNodeInfoCompat#isScrollable()} returns {@code true} - *
  • {@link AccessibilityNodeInfoCompat#getActions()} supports {@link - * AccessibilityNodeInfoCompat#ACTION_SCROLL_FORWARD} - *
  • {@link AccessibilityNodeInfoCompat#getActions()} supports {@link - * AccessibilityNodeInfoCompat#ACTION_SCROLL_BACKWARD} - *
- */ - public static final Filter FILTER_SCROLLABLE = - new Filter() { - @Override - public boolean accept(AccessibilityNodeInfoCompat node) { - return isScrollable(node); - } - }; - - /** Filter for items that could be scrolled forward. */ - public static final Filter FILTER_COULD_SCROLL_FORWARD = - new Filter() { - @Override - public boolean accept(AccessibilityNodeInfoCompat node) { - return node != null - && supportsAction(node, AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD); - } - }; - - /** Filter for items that could be scrolled backward. */ - public static final Filter FILTER_COULD_SCROLL_BACKWARD = - new Filter() { - @Override - public boolean accept(AccessibilityNodeInfoCompat node) { - return node != null - && supportsAction(node, AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD); - } - }; - - /** - * Filter for items that should receive accessibility focus. Equivalent to calling {@link - * #shouldFocusNode(AccessibilityNodeInfoCompat)}. - * - *

Note: Use {@link #FILTER_SHOULD_FOCUS_EXCEPT_WEB_VIEW} has a filter for - * {@link AccessibilityEvent#TYPE_VIEW_HOVER_ENTER} events. - */ - public static final Filter FILTER_SHOULD_FOCUS = - new Filter() { - @Override - public boolean accept(AccessibilityNodeInfoCompat node) { - return node != null && shouldFocusNode(node); - } - }; - - /** - * Filter for items that should receive accessibility focus from {@link - * AccessibilityEvent#TYPE_VIEW_HOVER_ENTER} events. WebView container node should not be focus - * for hover enter actions. - */ - public static final Filter FILTER_SHOULD_FOCUS_EXCEPT_WEB_VIEW = - FILTER_SHOULD_FOCUS.and( - new Filter() { - @Override - public boolean accept(AccessibilityNodeInfoCompat node) { - return Role.getRole(node) != Role.ROLE_WEB_VIEW; - } - }); - - /** Filter for heading items in collections. */ - public static final Filter FILTER_HEADING = - new Filter() { - @Override - public boolean accept(AccessibilityNodeInfoCompat node) { - return (node != null) && isHeading(node); - } - }; - - private static final ImmutableSet CONTAINER_ROLES = - ImmutableSet.of( - ROLE_LIST, - ROLE_GRID, - ROLE_PAGER, - ROLE_SCROLL_VIEW, - ROLE_HORIZONTAL_SCROLL_VIEW, - ROLE_WEB_VIEW); - - /** Filter for container. */ - public static final Filter FILTER_CONTAINER = - new Filter() { - @Override - public boolean accept(AccessibilityNodeInfoCompat node) { - return node != null - && (CONTAINER_ROLES.contains(Role.getRole(node)) - || !TextUtils.isEmpty(node.getContainerTitle())); - } - }; - - /** - * Filter for focusable containers with a descendant that is an unfocusable heading. This filter - * aids navigation by headings granularity when the node that is semantically a heading isn't - * focusable (for instance, because its text is combined with the text of other nodes to create - * speakable text for a container in a list context). - */ - public static final Filter - FILTER_CONTAINER_WITH_UNFOCUSABLE_HEADING = - new Filter() { - @Override - public boolean accept(AccessibilityNodeInfoCompat node) { - return searchFromBfs( - node, - FILTER_HEADING.and( - new Filter() { - @Override - public boolean accept(AccessibilityNodeInfoCompat childNode) { - return childNode.getChildCount() == 0 && !shouldFocusNode(childNode); - } - })) - != null; - } - }; - - /** Filter for scrollable grids. */ - public static final Filter FILTER_SCROLLABLE_GRID = - FILTER_SCROLLABLE.and(Filter.node((n) -> Role.getRole(n) == Role.ROLE_GRID)); - - /** Filter for table. */ - private static final Filter FILTER_TABLE = - Filter.node((node) -> isTableRoot(node)); - - /** Filter for table cell. */ - public static final Filter FILTER_TABLE_CELL = - Filter.node((node) -> isTableCell(node)); - - /** Filter for table cell and check if it is in a table. */ - public static final Filter FILTER_TABLE_CELL_UNDER_TABLE = - Filter.node((node) -> isTableCellUnderTable(node)); - - /** Filter the node matched the voice dictation definition. */ - public static final Filter FILTER_VOICE_DICTATION = - Filter.node(AccessibilityNodeInfoUtils::isVoiceDictationNode); - - /** - * Filter that also checks for {@param node}'s non-focusable but visible children. Sometimes, a - * node that passes the filter can be embedded in a parent and might be not focusable by itself. - * In those cases it is important to focus the parent. Example would be for "Control" granularity, - * if a switch is not focusable but is embedded into a focusable parent, its parent should be - * focused. - */ - public static Filter getFilterIncludingChildren( - final Filter filter) { - return new Filter() { - @Override - public boolean accept(AccessibilityNodeInfoCompat node) { - if (node == null) { - return false; - } - // If the node does not pass the filter, check its non focusable, visible children. - if (!filter.accept(node)) { - return hasMatchingDescendant(node, filter.and(FILTER_NON_FOCUSABLE_VISIBLE_NODE)); - } - return true; - } - }; - } - - // TODO: Provides an overall experience of focusing on small nodes on both watch and - // phone devices. - /** Filters out nodes which are small and located on the top and bottom borders. */ - public static Filter getFilterExcludingSmallTopAndBottomBorderNode( - final Context context) { - // For a watch device, we don't want to put focus on the small border nodes. These nodes - // could be located at the middle of AdapterView and they could be distorted to fit in a - // round screen when they are near top or bottom borders. - final Point screenPxSize = DisplayUtils.getScreenPixelSizeWithoutWindowDecor(context); - return new Filter() { - @Override - public boolean accept(AccessibilityNodeInfoCompat node) { - return !AccessibilityNodeInfoUtils.isSmallNodeInHeight(context, node) - || !AccessibilityNodeInfoUtils.isTopOrBottomBorderNode(screenPxSize, node); - } - }; - } - - /** Filter to identify nodes which are not focusable but visible. */ - public static final Filter FILTER_NON_FOCUSABLE_VISIBLE_NODE = - new Filter() { - @Override - public boolean accept(AccessibilityNodeInfoCompat node) { - return isVisible(node) && !isAccessibilityFocusable(node); - } - }; - - /** Filter to identify nodes which are not focusable and not visible but has text. */ - public static final Filter - FILTER_NON_FOCUSABLE_NON_VISIBLE_HAS_TEXT_NODE = - new Filter() { - @Override - public boolean accept(AccessibilityNodeInfoCompat node) { - return !isVisible(node) - && !isAccessibilityFocusable(node) - && !TextUtils.isEmpty(AccessibilityNodeInfoUtils.getNodeText(node)); - } - }; - - /** Filter for controllable elements. */ - public static final Filter FILTER_CONTROL = - new Filter() { - @Override - public boolean accept(AccessibilityNodeInfoCompat node) { - if (node == null) { - return false; - } - @RoleName int role = Role.getRole(node); - return (role == Role.ROLE_BUTTON) - || (role == Role.ROLE_IMAGE_BUTTON) - || (role == Role.ROLE_EDIT_TEXT) - || (role == Role.ROLE_CHECK_BOX) - || (role == Role.ROLE_RADIO_BUTTON) - || (role == Role.ROLE_TOGGLE_BUTTON) - || (role == Role.ROLE_SWITCH) - || (role == Role.ROLE_DROP_DOWN_LIST) - || (role == Role.ROLE_SEEK_CONTROL) - || (role == Role.ROLE_FLOATING_ACTION_BUTTON) - || (role == Role.ROLE_VOICE_DICTATION_BUTTON) - // The clickable view in a collection may not be a control, such as each setting item - // in the Settings page. - || (!nodeIsListOrGridItem(node) && (isClickable(node) || isLongClickable(node))); - } - }; - - /** Filter for Spannables with links. */ - public static final Filter FILTER_LINK = - new Filter() { - @Override - public boolean accept(AccessibilityNodeInfoCompat node) { - return SpannableTraversalUtils.hasTargetClickableSpanInNodeTree( - node, BASE_CLICKABLE_SPAN); - } - }; - - public static final Filter FILTER_CLICKABLE = - new Filter() { - @Override - public boolean accept(AccessibilityNodeInfoCompat node) { - return AccessibilityNodeInfoUtils.isClickable(node); - } - }; - - public static Filter getFilterIllegalTitleNodeAncestor( - Context context) { - return new Filter() { - @Override - public boolean accept(AccessibilityNodeInfoCompat node) { - if (isClickable(node) || isLongClickable(node)) { - return true; - } - - if (FeatureSupport.isWatch(context)) { - // A window title node can be a descendant of AdapterView in a watch device since the - // title node may be the first node in a AdapterView. - return false; - } else { - @RoleName int role = Role.getRole(node); - // A window title node should not be a descendant of AdapterView. - return (role == Role.ROLE_LIST) || (role == Role.ROLE_GRID); - } - } - }; - } - - /** - * Filter that defines which types of views should be auto-scrolled. Generally speaking, only - * accepts views that are capable of showing partially-visible data. - * - *

Accepts the following classes (and sub-classes thereof): - * - *

    - *
  • {@link androidx.recyclerview.widget.RecyclerView} (Should be classified as a List or Grid.) - *
  • {@link android.widget.AbsListView} (including both ListView and GridView) - *
  • {@link android.widget.AbsSpinner} - *
  • {@link android.widget.ScrollView} - *
  • {@link android.widget.HorizontalScrollView} - *
  • {@code com.sec.android.touchwiz.widget.TwAbsListView} - *
- * - *

Specifically excludes {@link android.widget.AdapterViewAnimator} and sub-classes, since they - * represent overlapping views. Also excludes {@link androidx.viewpager.widget.ViewPager} since it - * exclusively represents off-screen views. - */ - public static final Filter FILTER_AUTO_SCROLL = - new Filter() { - @Override - public boolean accept(AccessibilityNodeInfoCompat node) { - if (!isScrollable(node) || !isVisible(node)) { - return false; - } - @Role.RoleName int role = Role.getRole(node); - // TODO: Check if we should include ROLE_ADAPTER_VIEW as a target Role. - return role == Role.ROLE_DROP_DOWN_LIST - || role == Role.ROLE_LIST - || role == Role.ROLE_GRID - || role == Role.ROLE_SCROLL_VIEW - || role == Role.ROLE_HORIZONTAL_SCROLL_VIEW - || AccessibilityNodeInfoUtils.nodeMatchesAnyClassByType( - node, CLASS_TOUCHWIZ_TWADAPTERVIEW); - } - }; - - public static final Filter FILTER_COLLECTION = - Filter.node( - (node) -> { - int role = Role.getRole(node); - return (role == Role.ROLE_LIST) - || (role == Role.ROLE_GRID) - || (role == Role.ROLE_PAGER) - || (node != null && node.getCollectionInfo() != null); - }); - - public static final Filter FILTER_COLLECTION_ITEM = - Filter.node((node) -> node != null && node.getCollectionItemInfo() != null); - - private AccessibilityNodeInfoUtils() { - // This class is not instantiable. - } - - /** - * Gets the text of a node by returning the content description (if available) or by - * returning the text. - * - * @param node The node. - * @return The node text. - */ - public static @Nullable CharSequence getNodeText(@Nullable AccessibilityNodeInfoCompat node) { - if (node == null) { - return null; - } - - // Prefer content description over text. - // TODO: Why are we checking the trimmed length? - final CharSequence contentDescription = node.getContentDescription(); - if (!TextUtils.isEmpty(contentDescription) - && (TextUtils.getTrimmedLength(contentDescription) > 0)) { - return contentDescription; - } - - final @Nullable CharSequence text = AccessibilityNodeInfoUtils.getText(node); - if (!TextUtils.isEmpty(text) && (TextUtils.getTrimmedLength(text) > 0)) { - return text; - } - - return null; - } - - /** - * Gets the state description of a node. - * - * @param node The node. - * @return The node state description. - */ - public static @Nullable CharSequence getState(@Nullable AccessibilityNodeInfoCompat node) { - if (node == null) { - return null; - } - - final CharSequence state = node.getStateDescription(); - if (!TextUtils.isEmpty(state) && (TextUtils.getTrimmedLength(state) > 0)) { - return state; - } - - return null; - } - - /** - * Gets the Selected text of a node by returning the selected text. - * - * @param node The node. - * @return The selected node text. - */ - public static @Nullable CharSequence getSelectedNodeText( - @Nullable AccessibilityNodeInfoCompat node) { - if (node == null) { - return null; - } - - CharSequence selectedText = - subsequenceSafe( - AccessibilityNodeInfoUtils.getText(node), - node.getTextSelectionStart(), - node.getTextSelectionEnd()); - if (!TextUtils.isEmpty(selectedText) && (TextUtils.getTrimmedLength(selectedText) > 0)) { - return selectedText; - } - - return null; - } - - /** Returns a sub-string or empty-string, without crashing on invalid subsequence range. */ - public static CharSequence subsequenceSafe( - @Nullable CharSequence text, int startIndex, int endIndex) { - if (text == null) { - return ""; - } - // Swap start and end. - if (endIndex < startIndex) { - int newStartIndex = endIndex; - endIndex = startIndex; - startIndex = newStartIndex; - } - // Enforce string bounds. - if (startIndex < 0) { - startIndex = 0; - } else if (startIndex > text.length()) { - startIndex = text.length(); - } - if (endIndex < 0) { - endIndex = 0; - } else if (endIndex > text.length()) { - endIndex = text.length(); - } - - return text.subSequence(startIndex, endIndex); - } - - /** - * Gets the text selection indexes safe by adjusting the checking the selection bounds. - * - * @param node The node - * @return the selection indexes - */ - public static Pair getSelectionIndexesSafe( - @NonNull AccessibilityNodeInfoCompat node) { - int selectionStart = node.getTextSelectionStart(); - int selectionEnd = node.getTextSelectionEnd(); - if (selectionStart < 0) { - selectionStart = 0; - } - if (selectionEnd < 0) { - selectionEnd = selectionStart; - } - if (selectionEnd < selectionStart) { - // Swap start and end to make sure they are in order. - int newStart = selectionEnd; - selectionEnd = selectionStart; - selectionStart = newStart; - } - return Pair.create(selectionStart, selectionEnd); - } - - /** - * Gets the textual representation of the view ID that can be used when no custom label is - * available. For better readability/listenability, the "_" characters are replaced with spaces. - * - * @param node The node - * @return Readable text of the view Id - */ - public static @Nullable String getViewIdText(AccessibilityNodeInfoCompat node) { - if (node == null) { - return null; - } - - String resourceName = node.getViewIdResourceName(); - if (resourceName == null) { - return null; - } - - String[] parsedResourceName = RESOURCE_NAME_SPLIT_PATTERN.split(resourceName, 2); - if (parsedResourceName.length != 2 - || TextUtils.isEmpty(parsedResourceName[0]) - || TextUtils.isEmpty(parsedResourceName[1])) { - return null; - } - - return parsedResourceName[1].replace('_', ' '); // readable View ID text - } - - public static boolean isPage(@Nullable AccessibilityNodeInfoCompat node) { - @Nullable AccessibilityNodeInfoCompat parent = (node == null) ? null : node.getParent(); - return (parent != null) && (Role.getRole(parent) == Role.ROLE_PAGER); - } - - public static @Nullable CharSequence getSelectedPageTitle(AccessibilityNodeInfoCompat viewPager) { - if ((viewPager == null) || (Role.getRole(viewPager) != Role.ROLE_PAGER)) { - return null; - } - - int numChildren = viewPager.getChildCount(); // Not the number of pages! - CharSequence title = null; - for (int i = 0; i < numChildren; ++i) { - AccessibilityNodeInfoCompat child = viewPager.getChild(i); - if (child != null && child.isVisibleToUser()) { - if (title == null) { - // Try to roughly match RulePagerPage, which uses getNodeText - // (but completely matching all the time is not critical). - title = getNodeText(child); - } else { - // Multiple visible children, abort. - return null; - } - } - } - - return title; - } - - public static List getCustomActions(AccessibilityNodeInfoCompat node) { - List customActions = new ArrayList<>(); - for (AccessibilityActionCompat action : node.getActionList()) { - if (isCustomAction(action)) { - // We don't use custom actions that doesn't have a label - if (!TextUtils.isEmpty(action.getLabel())) { - customActions.add(action); - } - } - } - - return customActions; - } - - public static boolean isCustomAction(AccessibilityActionCompat action) { - return action.getId() > SYSTEM_ACTION_MAX; - } - - /** Returns the root node of the tree containing {@code node}. */ - public static @Nullable AccessibilityNodeInfoCompat getRoot(AccessibilityNodeInfoCompat node) { - if (node == null) { - return null; - } - - AccessibilityWindowInfoCompat window = getWindow(node); - if (window != null) { - return AccessibilityWindowInfoUtils.getRoot(window); - } - - Set visitedNodes = new HashSet<>(); - AccessibilityNodeInfoCompat current = null; - AccessibilityNodeInfoCompat parent = node; - - do { - if (current != null) { - if (visitedNodes.contains(current)) { - return null; - } - visitedNodes.add(current); - } - - current = parent; - parent = current.getParent(); - } while (parent != null); - - return current; - } - - /** - * Returns the node of the tree at {@code targetDepth} from the root of the tree containing {@code - * nodeCompat} with the root node considered as depth 0. This returns the last node available if - * the target depth is greater than the number of ancestors. - */ - public static @Nullable AccessibilityNodeInfoCompat getNthAncestorFromRoot( - AccessibilityNodeInfoCompat nodeCompat, int targetDepth) { - if (nodeCompat == null || targetDepth <= 0) { - return null; - } - - ArrayList visitedNodes = new ArrayList<>(); - AccessibilityNodeInfoCompat current = nodeCompat; - - do { - if (visitedNodes.contains(current)) { - break; - } - - visitedNodes.add(current); - current = current.getParent(); - } while (current != null); - - if (targetDepth >= visitedNodes.size()) { - targetDepth = visitedNodes.size() - 1; - } - - int nodeIndex = visitedNodes.size() - 1 - targetDepth; - return visitedNodes.get(nodeIndex); - } - - /** Returns the type of the window containing {@code nodeCompat}. */ - public static int getWindowType(AccessibilityNodeInfoCompat nodeCompat) { - if (nodeCompat == null) { - return WINDOW_TYPE_NONE; - } - - AccessibilityWindowInfoCompat windowInfoCompat = getWindow(nodeCompat); - if (windowInfoCompat == null) { - return WINDOW_TYPE_NONE; - } - - if (isPictureInPicture(nodeCompat)) { - return WINDOW_TYPE_PICTURE_IN_PICTURE; - } - - return windowInfoCompat.getType(); - } - - /** Wrapper for AccessibilityNodeInfoCompat.getWindow() that handles SecurityException. */ - public static @Nullable AccessibilityWindowInfoCompat getWindow( - AccessibilityNodeInfoCompat node) { - // This implementation is redundant with getWindow(AccessibilityNodeInfo) because there are no - // un/wrap() functions for AccessibilityWindowInfoCompat. - - if (node == null) { - return null; - } - - try { - return node.getWindow(); - } catch (SecurityException e) { - LogUtils.e(TAG, "SecurityException in AccessibilityWindowInfoCompat.getWindow()"); - return null; - } - } - - public static @Nullable AccessibilityWindowInfo getWindow(AccessibilityNodeInfo node) { - if (node == null) { - return null; - } - - try { - return node.getWindow(); - } catch (SecurityException e) { - LogUtils.e(TAG, "SecurityException in AccessibilityWindowInfo.getWindow()"); - return null; - } - } - - /** - * Returns whether a node can receive focus from focus traversal or touch exploration. One of the - * following must be true: - * - *

    - *
  • The node is actionable (see {@link #isFocusableOrClickable(AccessibilityNodeInfoCompat)}) - *
  • The node is a top-level list item (see {@link - * #isTopLevelScrollItem(AccessibilityNodeInfoCompat)} and is a speaking node - *
- * - * @param node The node to check. - * @return {@code true} of the node is accessibility focusable. - */ - public static boolean isAccessibilityFocusable(AccessibilityNodeInfoCompat node) { - return isFocusableOrClickable(node) - || (isTopLevelScrollItem(node) && isSpeakingNode(node, null, new HashSet<>())); - } - - /** - * Returns whether a node should receive accessibility focus from navigation. This method should - * never be called recursively, since it traverses up the parent hierarchy on every call. - * - * @see #findFocusFromHover(AccessibilityNodeInfoCompat) for touch exploration - * @see - * com.google.android.accessibility.talkback.focusmanagement.NavigationTarget#createNodeFilter(int, - * Map) for linear navigation - */ - public static boolean shouldFocusNode(AccessibilityNodeInfoCompat node) { - return shouldFocusNode(node, null, true); - } - - public static boolean shouldFocusNode( - final AccessibilityNodeInfoCompat node, - final Map speakingNodesCache) { - return shouldFocusNode(node, speakingNodesCache, true); - } - - public static boolean shouldFocusNode( - final AccessibilityNodeInfoCompat node, - final Map speakingNodesCache, - boolean checkChildren) { - if (node == null) { - LogUtils.v(TAG, "Don't focus, node=null"); - return false; - } - // Inside views that support web navigation, we delegate focus to the view itself and - // assume that it navigates to and focuses the correct elements. - if (WebInterfaceUtils.supportsWebActions(node)) { - // In history, we loosen the "visibility" check for web element: A web node can be focused - // even if it's not visibleToUser(). However we should hold the baseline that if the WebView - // container is not visible, we should not focus on its descendants. - AccessibilityNodeInfoCompat webViewContainer = - WebInterfaceUtils.ascendToWebViewContainer(node); - return webViewContainer != null && webViewContainer.isVisibleToUser(); - } - - if (!isVisible(node)) { - logShouldFocusNode( - checkChildren, FOCUS_FAIL_NOT_VISIBLE, "Don't focus, is not visible: ", node); - return false; - } - - if (isPictureInPicture(node)) { - // For picture-in-picture, allow focusing the root node, and any app controls inside the - // pic-in-pic window. - return true; - } else { - // Reject all non-leaf nodes that are neither actionable nor focusable, and have the same - // bounds as the window. - if (areBoundsIdenticalToWindow(node) - && node.getChildCount() > 0 - && !isFocusableOrClickable(node)) { - logShouldFocusNode( - checkChildren, - FOCUS_FAIL_SAME_WINDOW_BOUNDS_CHILDREN, - "Don't focus, bounds are same as window root node bounds, node has children and" - + " is neither actionable nor focusable: ", - node); - return false; - } - } - - HashSet visitedNodes = new HashSet<>(); - // This checks if a node is clickable, focusable, screen reader focusable, or a direct - // spekaing child of a scrollable container. - boolean accessibilityFocusable = - isFocusableOrClickable(node) - || (isTopLevelScrollItem(node) && isSpeakingNode(node, null, visitedNodes)); - - if (!checkChildren) { - // End of the line. Don't check children and don't allow any recursion. - // checkChildren is only false in the shouldFocusNode call below. This is to avoid - // repetitive checks down the tree when looking up at the ancestors. - LogUtils.d( - TAG, "checkChildren=false and isAccessibilityFocusable=%s", accessibilityFocusable); - return accessibilityFocusable; - } - - // A node that is deemed accessibility focusable shouldn't actually get focus if it has - // nothing to speak. For example, a view may be focusable, but if it has no text and all of - // its children are clickable, focus should go on each child individually and not on this - // view. - // Note: This is redundant for nodes that pass isSpeakingNode above - // Note: A special case exists for unlabeled buttons which otherwise wouldn't get focus. - if (accessibilityFocusable) { - visitedNodes.clear(); - // For TalkBack labeling feature, but this may still result in focusing non-speaking nodes. - // We should try to narrow down the check to close to TalkBackLabelManager#needsLabel. - if (node.getChildCount() == 0) { - logShouldFocusNode( - checkChildren, NONE, "Focus, is focusable and cannot keep search children: ", node); - return true; - } else if (isSpeakingNode(node, speakingNodesCache, visitedNodes)) { - logShouldFocusNode( - checkChildren, NONE, "Focus, is focusable and has something to speak: ", node); - return true; - } else { - logShouldFocusNode( - checkChildren, - FOCUS_FAIL_NOT_SPEAKABLE, - "Don't focus, is focusable but has nothing to speak: ", - node); - return false; - } - } - - // At this point, the node is an unfocusable target. - // If it has no focusable ancestors, but it still has text, then it should receive focus and be - // read aloud. - Filter filter = - new Filter() { - @Override - public boolean accept(AccessibilityNodeInfoCompat node) { - return shouldFocusNode(node, speakingNodesCache, false); - } - }; - - if (!hasMatchingAncestor(node, filter) && (hasText(node) || hasStateDescription(node))) { - logShouldFocusNode(checkChildren, NONE, "Focus, has text and no focusable ancestors: ", node); - return true; - } - - logShouldFocusNode( - checkChildren, - FOCUS_FAIL_FAIL_ALL_FOCUS_TESTS, - "Don't focus, failed all focusability tests: ", - node); - return false; - } - - private static void logShouldFocusNode( - boolean checkChildren, - @DiagnosticType @Nullable Integer diagnosticType, - String message, - AccessibilityNodeInfoCompat node) { - // When shouldFocusNode calls itself, the logs get inundated by unnecessary info about the - // ancestors. So only log when checkChildren is true. - if (checkChildren) { - if (diagnosticType != NONE) { - DiagnosticOverlayUtils.appendLog(diagnosticType, node); - } - // Show debug logs for #shouldFocusNode. Verbose logs will show for #isSpeakingNode - LogUtils.v(TAG, "%s %s", message, node); - } - } - - public static boolean isPictureInPicture(@NonNull AccessibilityNodeInfoCompat node) { - return isPictureInPicture(node.unwrap()); - } - - public static boolean isPictureInPicture(@Nullable AccessibilityNodeInfo node) { - return node != null && AccessibilityWindowInfoUtils.isPictureInPicture(getWindow(node)); - } - - /** - * Returns the node that should receive focus from hover by starting from the touched node and - * calling {@link #shouldFocusNode} at each level of the view hierarchy and exclude WebView - * container node. - */ - public static AccessibilityNodeInfoCompat findFocusFromHover( - @Nullable AccessibilityNodeInfoCompat touched) { - return AccessibilityNodeInfoUtils.getSelfOrMatchingAncestor( - touched, FILTER_SHOULD_FOCUS_EXCEPT_WEB_VIEW); - } - - /** - * Returns whether a node can be spoken. - * - *

A node should be spoken if it has text, is checkable, or has children that should be spoken - * but can't be focused themselves. This method can call itself recursively through {@link - * #hasNonActionableSpeakingChildren}. - * - *

Note: This is called in the context of looking for a a11y focusable node through {@link - * #shouldFocusNode} and {@link #isAccessibilityFocusable} - * - * @param node the node to check - * @param speakingNodesCache the cache that holds the speaking results for visited nodes - * @param visitedNodes the set of nodes that have already been visited - * @return {@code true} if the node can be spoken - */ - private static boolean isSpeakingNode( - @NonNull AccessibilityNodeInfoCompat node, - @Nullable Map speakingNodesCache, - @NonNull Set visitedNodes) { - if (speakingNodesCache != null && speakingNodesCache.containsKey(node)) { - return speakingNodesCache.get(node); - } - - boolean result = false; - if (hasText(node)) { - LogUtils.v(TAG, "Speaking, has text"); - result = true; - } else if (hasStateDescription(node)) { - LogUtils.v(TAG, "Speaking, has state description"); - result = true; - } else if (node.isCheckable()) { // Special case for check boxes. - LogUtils.v(TAG, "Speaking, is checkable"); - result = true; - } else if (hasNonActionableSpeakingChildren(node, speakingNodesCache, visitedNodes)) { - // Special case for containers with non-focusable content. In this case, the container should - // speak its non-focusable yet speakable content. - LogUtils.v(TAG, "Speaking, has non-actionable speaking children"); - result = true; - } - - if (speakingNodesCache != null) { - speakingNodesCache.put(node, result); - } - - return result; - } - - /** - * Returns whether a node has children that are not actionable/focusable but should be spoken. - * - *

This is done by ignoring any children nodes that are actionable/focusable, and checking the - * remaining for speaking ability. Also considers offscreen/invisible children which are - * non-actionable but which have speakable text. - * - * @param node the node to check - * @param speakingNodesCache the cache that holds the speaking results for visited nodes - * @param visitedNodes the set of nodes that have already been visited. - * @return {@code true} if the node has children that are speaking - */ - private static boolean hasNonActionableSpeakingChildren( - @NonNull AccessibilityNodeInfoCompat node, - @Nullable Map speakingNodesCache, - @NonNull Set visitedNodes) { - final int childCount = node.getChildCount(); - - AccessibilityNodeInfoCompat child; - - for (int i = 0; i < childCount; i++) { - child = node.getChild(i); - - if (child == null) { - LogUtils.v(TAG, "Child %d is null, skipping it", i); - continue; - } - - if (!visitedNodes.add(child)) { - return false; - } - - // Ignore invisible nodes. - if (!isVisible(child)) { - LogUtils.v(TAG, "Child %d, %s is invisible, skipping it", i, printId(node)); - continue; - } - - // Ignore focusable nodes - if (isFocusableOrClickable(child)) { - LogUtils.v(TAG, "Child %d, %s is focusable or clickable, skipping it", i, printId(node)); - continue; - } - - // Ignore top level scroll items that 1) are speaking and 2) have non-clickable parents. This - // means that a scrollable container that is clickable should get focus before its children. - if ((isTopLevelScrollItem(child) && isSpeakingNode(child, speakingNodesCache, visitedNodes)) - && !(isClickable(node) || isLongClickable(node))) { - - LogUtils.v(TAG, "Child %d, %s is a top level scroll item, skipping it", i, printId(node)); - continue; - } - - // Recursively check non-focusable child nodes. - if (isSpeakingNode(child, speakingNodesCache, visitedNodes)) { - LogUtils.v(TAG, "Does have actionable speaking children (child %d, %s)", i, printId(node)); - return true; - } - } - - LogUtils.v(TAG, "Does not have non-actionable speaking children. Examining invisible children"); - return hasInvisibleNonActionableSpeakingChildren(node, childCount); - } - - private static boolean hasInvisibleNonActionableSpeakingChildren( - AccessibilityNodeInfoCompat node, int childCount) { - // We don't want the presence of invisible children to lead to focus being set on a scrollable - // parent that is capable of showing partially-visible data. - if (FILTER_AUTO_SCROLL.accept(node)) { - return false; - } - - // We look at invisible children and return true if an invisible child is non-actionable and - // has associated text. Without this check, a parent would be considered unfocusable, and this - // would cause ACTION_SHOW_ON_SCREEN to fail when the non-actionable/speakable child nodes of - // a container are offscreen. - AccessibilityNodeInfoCompat child; - for (int i = 0; i < childCount; i++) { - child = node.getChild(i); - - if (child == null) { - LogUtils.v(TAG, "Child %d is null, skipping it", i); - continue; - } - - if (!child.isVisibleToUser() - && hasText(child) - && !(child.isScreenReaderFocusable() || isActionableForAccessibility(child))) { - LogUtils.v( - TAG, "Non-actionable invisible node with text found (child %d, %s)", i, printId(node)); - return true; - } - } - return false; - } - - public static int countVisibleChildren(@Nullable AccessibilityNodeInfoCompat node) { - if (node == null) { - return 0; - } - int childCount = node.getChildCount(); - int childVisibleCount = 0; - for (int i = 0; i < childCount; ++i) { - AccessibilityNodeInfoCompat child = node.getChild(i); - if (child != null && child.isVisibleToUser()) { - ++childVisibleCount; - } - } - return childVisibleCount; - } - - /** - * Returns whether a node is actionable. That is, the node supports one of the following actions: - * - *

    - *
  • {@link AccessibilityNodeInfoCompat#isClickable()} - *
  • {@link AccessibilityNodeInfoCompat#isFocusable()} - *
  • {@link AccessibilityNodeInfoCompat#isLongClickable()} - *
- * - * This parities the system method View#isActionableForAccessibility(), which was added in - * JellyBean. - * - * @param node The node to examine. - * @return {@code true} if node is actionable. - */ - public static boolean isActionableForAccessibility(@Nullable AccessibilityNodeInfoCompat node) { - if (node == null) { - return false; - } - - // Nodes that are clickable are always actionable. - if (isClickable(node) || isLongClickable(node)) { - return true; - } - - if (node.isFocusable()) { - return true; - } - - if (WebInterfaceUtils.hasNativeWebContent(node)) { - return supportsAnyAction(node, AccessibilityNodeInfoCompat.ACTION_FOCUS); - } - - return supportsAnyAction( - node, - AccessibilityNodeInfoCompat.ACTION_FOCUS, - AccessibilityNodeInfoCompat.ACTION_NEXT_HTML_ELEMENT, - AccessibilityNodeInfoCompat.ACTION_PREVIOUS_HTML_ELEMENT); - } - - public static boolean isSelfOrAncestorFocused(@Nullable AccessibilityNodeInfoCompat node) { - return node != null - && (node.isAccessibilityFocused() - || hasMatchingAncestor( - node, - new Filter() { - @Override - public boolean accept(AccessibilityNodeInfoCompat node) { - return (node != null) && node.isAccessibilityFocused(); - } - })); - } - - /** Returns whether {@code node} is editable or has an ancestor that is editable. */ - public static boolean isSelfOrAncestorEditable(@Nullable AccessibilityNodeInfoCompat node) { - return getSelfOrMatchingAncestor(node, Filter.node((n) -> n.isEditable())) != null; - } - - public static boolean isSelfOrAncestorRoleEditText(@Nullable AccessibilityNodeInfoCompat node) { - return isSelfOrAncestorWithRole(node, Role.ROLE_EDIT_TEXT); - } - - public static boolean isSelfOrAncestorRoleWebView(@Nullable AccessibilityNodeInfoCompat node) { - return isSelfOrAncestorWithRole(node, Role.ROLE_WEB_VIEW); - } - - private static boolean isSelfOrAncestorWithRole( - @Nullable AccessibilityNodeInfoCompat node, int role) { - return getSelfOrMatchingAncestor(node, Filter.node((n) -> Role.getRole(n) == role)) != null; - } - - /** Returns whether {@code node} or its ancestor has the given {@code chromeRole}. */ - public static boolean isSelfOrAncestorWithChromeRole( - @Nullable AccessibilityNodeInfoCompat node, String chromeRole) { - return getSelfOrMatchingAncestor( - node, Filter.node((n) -> TextUtils.equals(getChromeRole(n), chromeRole))) - != null; - } - - /** Returns whether {@code node} has the chrome role "link". */ - public static boolean isChromeRoleLink(@Nullable AccessibilityNodeInfoCompat node) { - return node != null && TextUtils.equals(getChromeRole(node), CHROME_ROLE_LINK); - } - - private static CharSequence getChromeRole(@Nullable AccessibilityNodeInfoCompat node) { - if (node == null) { - return ""; - } - AccessibilityNodeInfo info = node.unwrap(); - if (info == null) { - return ""; - } - return info.getExtras().getCharSequence(EXTRAS_KEY_CHROME_ROLE); - } - - /** - * Returns whether {@code node} is interactable with arrow keys. That is, the node supports at - * least one of the following: - * - *
    - *
  • {@link Role.ROLE_SEEK_CONTROL} - *
- * - * @return {@code true} if node is self interactable with arrow keys. - */ - public static boolean isInteractableWithArrowKeys(@Nullable AccessibilityNodeInfoCompat node) { - return Role.getRole(node) == Role.ROLE_SEEK_CONTROL; - } - - /** - * Returns whether a node is clickable. That is, the node supports at least one of the following: - * - *
    - *
  • {@link AccessibilityNodeInfoCompat#isClickable()} - *
  • {@link AccessibilityNodeInfoCompat#ACTION_CLICK} - *
- * - * @param node The node to examine. - * @return {@code true} if node is clickable. - */ - public static boolean isClickable(@Nullable AccessibilityNodeInfoCompat node) { - return node != null - && (node.isClickable() - || supportsAnyAction(node, AccessibilityNodeInfoCompat.ACTION_CLICK)); - } - - /** - * Returns whether a node is long clickable. That is, the node supports at least one of the - * following: - * - *
    - *
  • {@link AccessibilityNodeInfoCompat#isLongClickable()} - *
  • {@link AccessibilityNodeInfoCompat#ACTION_LONG_CLICK} - *
- * - * @param node The node to examine. - * @return {@code true} if node is long clickable. - */ - public static boolean isLongClickable(@Nullable AccessibilityNodeInfoCompat node) { - return node != null - && (node.isLongClickable() - || supportsAnyAction(node, AccessibilityNodeInfoCompat.ACTION_LONG_CLICK)); - } - - /** - * Returns whether the node is focusable. That is, the node supports at least one of the - * following: - * - *
    - *
  • {@link AccessibilityNodeInfoCompat#isFocusable()} - *
  • {@link AccessibilityNodeInfoCompat#ACTION_FOCUS} - *
- */ - public static boolean isFocusable(@Nullable AccessibilityNodeInfoCompat node) { - return node != null - && (node.isFocusable() - || supportsAnyAction(node, AccessibilityNodeInfoCompat.ACTION_FOCUS)); - } - - /** - * Returns whether a node is expandable. That is, the node supports the following action: - * - *
    - *
  • {@link AccessibilityNodeInfoCompat#ACTION_EXPAND} - *
- * - * @param node The node to examine. - * @return {@code true} if node is expandable. - */ - public static boolean isExpandable(@Nullable AccessibilityNodeInfoCompat node) { - return supportsAnyAction(node, AccessibilityNodeInfoCompat.ACTION_EXPAND); - } - - /** - * Returns whether a node is collapsible. That is, the node supports the following action: - * - *
    - *
  • {@link AccessibilityNodeInfoCompat#ACTION_COLLAPSE} - *
- * - * @param node The node to examine. - * @return {@code true} if node is collapsible. - */ - public static boolean isCollapsible(@Nullable AccessibilityNodeInfoCompat node) { - return supportsAnyAction(node, AccessibilityNodeInfoCompat.ACTION_COLLAPSE); - } - - /** - * Returns whether a node can be dismissed by the user. the node supports the following action: - * - *
    - *
  • {@link AccessibilityNodeInfoCompat#ACTION_DISMISS} - *
- * - * @param node The node to examine. - * @return {@code true} if node is dismissible. - */ - public static boolean isDismissible(@Nullable AccessibilityNodeInfoCompat node) { - return supportsAnyAction(node, AccessibilityNodeInfoCompat.ACTION_DISMISS); - } - - /** Returns {@code true} if the node is on keyboard. */ - public static boolean isKeyboard(@Nullable AccessibilityNodeInfo source) { - return isKeyboard(AccessibilityNodeInfoUtils.toCompat(source)); - } - - /** Returns {@code true} if the node is on keyboard. */ - public static boolean isKeyboard(@Nullable AccessibilityNodeInfoCompat source) { - if (source == null) { - return false; - } - AccessibilityWindowInfoCompat window = getWindow(source); - if (window == null) { - return false; - } - return AccessibilityWindowInfoUtils.isImeWindow(window); - } - - /** - * Check whether a given node has a matching ancestor given a filter. - * - * @param node The node to examine. - * @param filter The filter to match the nodes against. - * @return {@code true} if one of the node's ancestors is matching the filter. - */ - public static boolean hasMatchingAncestor( - @Nullable AccessibilityNodeInfoCompat node, - @NonNull Filter filter) { - return (node != null) && (getMatchingAncestor(node, filter) != null); - } - - // TODO: Discuss with framework owner to make unread notification context available - // to the app side. - /** - * Checks whether the node is the unread notification dot on the wearable sysUI. - * - * @param node the node to check - * @return {@code true} if the node is the unread notification dot on the wearable sysUI. - */ - public static boolean isWearUnreadNotificationDot(@Nullable AccessibilityNodeInfoCompat node) { - return (node != null) - && TextUtils.equals(node.getViewIdResourceName(), VIEW_ID_WEAR_UNREAD_NOTIFICATION_DOT); - } - - /** Returns whether the node is the Pin edit field at unlock screen. */ - public static boolean isPinEntry(@Nullable AccessibilityNodeInfo node) { - return isPinEntry(AccessibilityNodeInfoUtils.toCompat(node)); - } - - public static boolean isPinEntry(@Nullable AccessibilityNodeInfoCompat node) { - return (node != null) - && TextUtils.equals(node.getViewIdResourceName(), VIEW_ID_RESOURCE_NAME_PIN_ENTRY); - } - - /** - * Check whether a given node or any of its ancestors matches the given filter. - * - * @param node The node to examine. - * @param filter The filter to match the nodes against. - * @return {@code true} if the node or one of its ancestors matches the filter. - */ - public static boolean isOrHasMatchingAncestor( - @Nullable AccessibilityNodeInfoCompat node, - @NonNull Filter filter) { - return (node != null) && (getSelfOrMatchingAncestor(node, filter) != null); - } - - /** Check whether a given node has any descendant matching a given filter. */ - public static boolean hasMatchingDescendant( - @Nullable AccessibilityNodeInfoCompat node, - @NonNull Filter filter) { - return (node != null) && (getMatchingDescendant(node, filter) != null); - } - - /** Checks whether a given node or any of its descendants matches the given filter. */ - public static boolean isOrHasMatchingDescendant( - @Nullable AccessibilityNodeInfoCompat node, - @NonNull Filter filter) { - return (node != null) && (getSelfOrMatchingDescendant(node, filter) != null); - } - - /** Returns depth of node in node-tree, where root has depth=0. */ - public static int findDepth(@Nullable AccessibilityNodeInfoCompat node) { - if (node == null) { - return -1; - } - NodeCounter counter = new NodeCounter(); - processSelfAndAncestors(node, counter); - return counter.count - 1; - } - - private static class NodeCounter extends Filter { - public int count = 0; - - @Override - public boolean accept(AccessibilityNodeInfoCompat node) { - ++count; - return false; - } - } - - /** Applies filter to ancestor nodes. */ - public static void processSelfAndAncestors( - @Nullable AccessibilityNodeInfoCompat node, - @NonNull Filter filter) { - if (node != null) { - isOrHasMatchingAncestor(node, filter); - } - } - - /** - * Returns the {@code node} if it matches the {@code filter}, or the first matching ancestor. - * Returns {@code null} if no nodes match. - */ - public static @Nullable AccessibilityNodeInfoCompat getSelfOrMatchingAncestor( - @Nullable AccessibilityNodeInfoCompat node, - @NonNull Filter filter) { - if (node == null) { - return null; - } - if (filter.accept(node)) { - return node; - } - - return getMatchingAncestor(node, filter); - } - - /** - * Returns the {@code node} if it matches the {@code filter}, or the first matching ancestor, - * ending the ancestor search once it reaches {@code end}. The search is inclusive of {@code node} - * but exclusive of {@code end}. If {@code node} equals {@code end}, then {@code node} is an - * eligible match. Returns {@code null} if no nodes match. - */ - public static @Nullable AccessibilityNodeInfoCompat getSelfOrMatchingAncestor( - @Nullable AccessibilityNodeInfoCompat node, - @Nullable AccessibilityNodeInfoCompat end, - @NonNull Filter filter) { - if (node == null) { - return null; - } - if (filter.accept(node)) { - return node; - } - return getMatchingAncestor(node, end, filter); - } - - /** - * Returns the {@code node} if it matches the {@code filter}, or the first matching descendant. - * Returns {@code null} if no nodes match. - */ - public static @Nullable AccessibilityNodeInfoCompat getSelfOrMatchingDescendant( - @Nullable AccessibilityNodeInfoCompat node, - @NonNull Filter filter) { - if (node == null) { - return null; - } - if (filter.accept(node)) { - return node; - } - return getMatchingDescendant(node, filter); - } - - /** Processes subtree of root by {@code filter}. */ - public static void processSubtree( - @Nullable AccessibilityNodeInfoCompat root, - @NonNull Filter filter) { - - AccessibilityNodeInfoUtils.getSelfOrMatchingDescendant( - root, - Filter.node( - (node) -> { - filter.accept(node); - return false; // Force search to traverse whole subtree. - })); - } - - /** - * Determines whether the two nodes are in the same branch; that is, they are equal or one is the - * ancestor of the other. - */ - public static boolean areInSameBranch( - final @Nullable AccessibilityNodeInfoCompat node1, - final @Nullable AccessibilityNodeInfoCompat node2) { - if (node1 != null && node2 != null) { - // Same node? - if (node1.equals(node2)) { - return true; - } - - // Is node1 an ancestor of node2? - Filter matchNode1 = - new Filter() { - @Override - public boolean accept(AccessibilityNodeInfoCompat node) { - return node != null && node.equals(node1); - } - }; - if (AccessibilityNodeInfoUtils.hasMatchingAncestor(node2, matchNode1)) { - return true; - } - - // Is node2 an ancestor of node1? - Filter matchNode2 = - new Filter() { - @Override - public boolean accept(AccessibilityNodeInfoCompat node) { - return node != null && node.equals(node2); - } - }; - if (AccessibilityNodeInfoUtils.hasMatchingAncestor(node1, matchNode2)) { - return true; - } - } - - return false; - } - - /** - * Returns the first ancestor of {@code node} that matches the {@code filter}. Returns {@code - * null} if no nodes match. - */ - public static @Nullable AccessibilityNodeInfoCompat getMatchingAncestor( - @Nullable AccessibilityNodeInfoCompat node, - @NonNull Filter filter) { - return getMatchingAncestor(node, null, filter); - } - - /** - * Returns the first ancestor of {@code node} that matches the {@code filter}, terminating the - * search once it reaches {@code end}. The search is exclusive of both {@code node} and {@code - * end}. Returns {@code null} if no nodes match. - */ - private static @Nullable AccessibilityNodeInfoCompat getMatchingAncestor( - @Nullable AccessibilityNodeInfoCompat node, - @Nullable AccessibilityNodeInfoCompat end, - @NonNull Filter filter) { - if (node == null) { - return null; - } - - final HashSet ancestors = new HashSet<>(); - - ancestors.add(node); - node = node.getParent(); - - while (node != null) { - if (!ancestors.add(node)) { - // Already seen this node, so abort! - return null; - } - - if (end != null && node.equals(end)) { - // Reached the end node, so abort! - return null; - } - - if (filter.accept(node)) { - return node; - } - - node = node.getParent(); - } - - return null; - } - - /** - * Returns the number of ancestors matching the given filter. Does not include the current node in - * the count, even if it matches the filter. If there is a cycle in the ancestor hierarchy, then - * this method will return 0. - */ - public static int countMatchingAncestors( - AccessibilityNodeInfoCompat node, Filter filter) { - if (node == null) { - return 0; - } - - final HashSet ancestors = new HashSet<>(); - int matchingAncestors = 0; - - ancestors.add(node); - node = node.getParent(); - - while (node != null) { - if (!ancestors.add(node)) { - // Already seen this node, so abort! - return 0; - } - - if (filter.accept(node)) { - matchingAncestors++; - } - - node = node.getParent(); - } - - return matchingAncestors; - } - - private static @Nullable AccessibilityNodeInfoCompat getMatchingDescendant( - AccessibilityNodeInfoCompat node, - Filter filter, - Filter endFilter, - HashSet visitedNodes) { - if (node == null) { - return null; - } - - if (visitedNodes.contains(node)) { - return null; - } else { - visitedNodes.add(node); - } - - int childCount = node.getChildCount(); - for (int i = 0; i < childCount; ++i) { - AccessibilityNodeInfoCompat child = node.getChild(i); - - if (child == null) { - continue; - } - - if (filter.accept(child)) { - return child; // child was already obtained by node.getChild(). - } - - if (endFilter != null && endFilter.accept(child)) { - continue; - } - - AccessibilityNodeInfoCompat childMatch = - getMatchingDescendant(child, filter, endFilter, visitedNodes); - if (childMatch != null) { - return childMatch; - } - } - - return null; - } - - /** - * Returns the first child (by depth-first search) of {@code node} that matches the {@code - * filter}, and skips the nodes that match the {@code endFilter}. Returns {@code null} if no nodes - * match. - */ - public static @Nullable AccessibilityNodeInfoCompat getMatchingDescendant( - AccessibilityNodeInfoCompat node, - Filter filter, - Filter endFilter) { - return getMatchingDescendant(node, filter, endFilter, new HashSet<>()); - } - - /** - * Returns the first child (by depth-first search) of {@code node} that matches the {@code - * filter}. Returns {@code null} if no nodes match. - */ - public static @Nullable AccessibilityNodeInfoCompat getMatchingDescendant( - AccessibilityNodeInfoCompat node, Filter filter) { - return getMatchingDescendant(node, filter, /* endFilter= */ null, new HashSet<>()); - } - - /** Returns all descendants that match filter but skips the nested. */ - public static @Nullable List getMatchingDescendantsNotNested( - @Nullable AccessibilityNodeInfoCompat node, Filter filter) { - if (node == null) { - return null; - } - List matches = new ArrayList<>(); - getMatchingDescendants(node, filter, /* matchChild= */ false, new HashSet<>(), matches); - return matches; - } - - /** Returns all descendants that match filter. */ - public static @Nullable List getMatchingDescendantsOrRoot( - @Nullable AccessibilityNodeInfoCompat node, Filter filter) { - if (node == null) { - return null; - } - List matches = new ArrayList<>(); - getMatchingDescendants(node, filter, /* matchChild= */ true, new HashSet<>(), matches); - return matches; - } - - /** - * Returns all descendants that match filter, until the stopNode is found. At that point, the - * search will stop. Note that the stopNode is included in the results, if it matches the filter. - */ - public static @Nullable List getMatchingDescendantsOrRootUntilNode( - @Nullable AccessibilityNodeInfoCompat node, - Filter filter, - @Nullable AccessibilityNodeInfoCompat stopNode) { - if (node == null) { - return null; - } - List matches = new ArrayList<>(); - getMatchingDescendantsCore( - node, - filter, - /* matchChild= */ true, - new HashSet<>(), - matches, - /* stopNode= */ stopNode, - /* searchControlFlag= */ new SearchControlFlag()); - return matches; - } - - /** - * Collects all descendants that match filter, into matches. - * - * @param node The root node to start searching. - * @param filter The filter to match the nodes against. - * @param matchChild Flag that allows match with the childs of the matched nodes. - * @param visitedNodes The set of nodes already visited, for protection against loops. This will - * be modified. - * @param matches The list of nodes matching filter. This will be appended to. - */ - private static void getMatchingDescendants( - @Nullable AccessibilityNodeInfoCompat node, - Filter filter, - boolean matchChild, - Set visitedNodes, - List matches) { - getMatchingDescendantsCore( - node, - filter, - matchChild, - visitedNodes, - matches, - /* stopNode= */ null, - /* searchControlFlag= */ null); - } - - /** - * A flag to indicate whether the stop node has been found. Using a class instead of a boolean - * flag allows {@link #getMatchingDescendantsCore} to modify the flag and have the updated value - * reflected in other branches of the recursive search. - */ - private static class SearchControlFlag { - public boolean stopNodeHasBeenFound = false; - } - - /** - * Collects all descendants that match filter, into matches. - * - * @param node The root node to start searching. - * @param filter The filter to match the nodes against. - * @param matchChild Flag that allows match with the childs of the matched nodes. - * @param visitedNodes The set of nodes already visited, for protection against loops. This will - * be modified. - * @param matches The list of nodes matching filter. This will be appended to. - * @param stopNode The node to stop searching at. Note that this node is included in the matches, - * if it matches the filter. - * @param searchControlFlag A flag to indicate whether the stop node has been found. See {@link - * SearchControlFlag} for details. - */ - private static void getMatchingDescendantsCore( - @Nullable AccessibilityNodeInfoCompat node, - Filter filter, - boolean matchChild, - Set visitedNodes, - List matches, - @Nullable AccessibilityNodeInfoCompat stopNode, - @Nullable SearchControlFlag searchControlFlag) { - - if (node == null) { - return; - } - - // Update visited nodes. - if (visitedNodes.contains(node)) { - return; - } else { - visitedNodes.add(node); - } - - // Stop searching if the stop node has been found. - if (searchControlFlag != null && searchControlFlag.stopNodeHasBeenFound) { - return; - } - - // If node matches filter... collect node. - if (filter.accept(node)) { - matches.add(node); - } - - // If the stop node has been found, future searches can be skipped, even if the stopNode does - // not match the filter. - if (searchControlFlag != null && node.equals(stopNode)) { - searchControlFlag.stopNodeHasBeenFound = true; - } - - // For each child of node... - if (!matches.contains(node) || matchChild) { - int childCount = node.getChildCount(); - for (int i = 0; i < childCount; ++i) { - AccessibilityNodeInfoCompat child = node.getChild(i); - if (child == null) { - continue; - } - getMatchingDescendantsCore( - child, filter, matchChild, visitedNodes, matches, stopNode, searchControlFlag); - } - } - } - - /** - * Check whether a given node is scrollable. - * - * @param node The node to examine. - * @return {@code true} if the node is scrollable. - */ - public static boolean isScrollable(AccessibilityNodeInfoCompat node) { - // In some cases node#isScrollable lies. (Notably, some nodes that correspond to WebViews claim - // to be scrollable, but do not support any scroll actions. This seems to stem from a bug in the - // translation from the DOM to the AccessibilityNodeInfo.) To avoid labeling views that don't - // support scrolling (e.g. REFERTO), check for the explicit presence of - // AccessibilityActions. - return supportsAnyAction( - node, - AccessibilityActionCompat.ACTION_SCROLL_FORWARD, - AccessibilityActionCompat.ACTION_SCROLL_BACKWARD, - AccessibilityActionCompat.ACTION_SCROLL_DOWN, - AccessibilityActionCompat.ACTION_SCROLL_UP, - AccessibilityActionCompat.ACTION_SCROLL_RIGHT, - AccessibilityActionCompat.ACTION_SCROLL_LEFT); - } - - /** - * Returns whether the specified node has text. For the purposes of this check, any node with a - * CollectionInfo is considered to not have text since its text and content description are used - * only for collection transitions. - * - * @param node The node to check. - * @return {@code true} if the node has text. - */ - private static boolean hasText(@Nullable AccessibilityNodeInfoCompat node) { - return node != null - && node.getCollectionInfo() == null - && (!TextUtils.isEmpty(AccessibilityNodeInfoUtils.getText(node)) - || !TextUtils.isEmpty(node.getContentDescription()) - || !TextUtils.isEmpty(node.getHintText())); - } - - /** - * Returns whether the specified node has state description. - * - * @param node The node to check. - * @return {@code true} if the node has state description. - */ - private static boolean hasStateDescription(@Nullable AccessibilityNodeInfoCompat node) { - return node != null - && (!TextUtils.isEmpty(node.getStateDescription()) - || node.isCheckable() - || hasValidRangeInfo(node)); - } - - /** - * Returns if a node is focusable or clickable. - * - *

This is used in {@link #shouldFocusNode} and {@link #isAccessibilityFocusable} - * - * @param node the node to check - * @return {@code true} if the node is focusable or clickable - */ - private static boolean isFocusableOrClickable(AccessibilityNodeInfoCompat node) { - return (node != null) - && isVisible(node) - && (node.isScreenReaderFocusable() || isActionableForAccessibility(node)); - } - - /** - * Determines whether a node is a top-level item in a scrollable container. - * - * @param node The node to test. - * @return {@code true} if {@code node} is a top-level item in a scrollable container. - */ - public static boolean isTopLevelScrollItem(AccessibilityNodeInfoCompat node) { - if (node == null) { - return false; - } - - if (!isVisible(node)) { - return false; - } - - AccessibilityNodeInfoCompat parent = node.getParent(); - return isScrollItem(parent); - } - - private static boolean isScrollItem(AccessibilityNodeInfoCompat node) { - if (node == null) { - // Not a child node of anything. - return false; - } - - // Drop down lists (spinners) are not included to retain the old behavior of focusing on - // the spinner itself rather than on the single visible item. - // A spinner being scrollable is disingenuous since the scrollable list inside isn't exposed - // without interaction. - if (Role.getRole(node) == Role.ROLE_DROP_DOWN_LIST) { - return false; - } - - // A node with a scrollable parent is a top level scroll item. - if (isScrollable(node)) { - return true; - } - - @Role.RoleName int parentRole = Role.getRole(node); - // Note that ROLE_DROP_DOWN_LIST(Spinner) is not accepted. - // RecyclerView is classified as a list or grid based on its CollectionInfo. - // These parents may not be scrollable in some cases, like if the list is too short to be - // scrolled, but their children should still be considered top level scroll items. - return parentRole == Role.ROLE_LIST - || parentRole == Role.ROLE_GRID - || parentRole == Role.ROLE_SCROLL_VIEW - || parentRole == Role.ROLE_HORIZONTAL_SCROLL_VIEW - || nodeMatchesAnyClassByType(node, CLASS_TOUCHWIZ_TWADAPTERVIEW); - } - - public static boolean hasAncestor( - AccessibilityNodeInfoCompat node, final AccessibilityNodeInfoCompat targetAncestor) { - if (node == null || targetAncestor == null) { - return false; - } - - Filter filter = - new Filter() { - @Override - public boolean accept(AccessibilityNodeInfoCompat node) { - return targetAncestor.equals(node); - } - }; - - return (getMatchingAncestor(node, filter) != null); - } - - public static boolean hasDescendant( - @Nullable AccessibilityNodeInfoCompat node, - @Nullable AccessibilityNodeInfoCompat targetDescendant) { - if (node == null || targetDescendant == null) { - return false; - } - - Filter filter = - new Filter() { - @Override - public boolean accept(AccessibilityNodeInfoCompat node) { - return targetDescendant.equals(node); - } - }; - - return (getMatchingDescendant(node, filter) != null); - } - - /** - * Determines if the generating class of an {@link AccessibilityNodeInfoCompat} matches a given - * {@link Class} by type. - * - * @param node A sealed {@link AccessibilityNodeInfoCompat} dispatched by the accessibility - * framework. - * @param referenceClass A {@link Class} to match by type or inherited type. - * @return {@code true} if the {@link AccessibilityNodeInfoCompat} object matches the {@link - * Class} by type or inherited type, {@code false} otherwise. - */ - public static boolean nodeMatchesClassByType( - AccessibilityNodeInfoCompat node, Class referenceClass) { - if ((node == null) || (referenceClass == null)) { - return false; - } - - // Attempt to take a shortcut. - final CharSequence nodeClassName = node.getClassName(); - if (TextUtils.equals(nodeClassName, referenceClass.getName())) { - return true; - } - - return ClassLoadingCache.checkInstanceOf(nodeClassName, referenceClass); - } - - /** - * Determines if the generating class of an {@link AccessibilityNodeInfoCompat} matches any of the - * given {@link Class}es by type. - * - * @param node A sealed {@link AccessibilityNodeInfoCompat} dispatched by the accessibility - * framework. - * @return {@code true} if the {@link AccessibilityNodeInfoCompat} object matches the {@link - * Class} by type or inherited type, {@code false} otherwise. - * @param referenceClasses A variable-length list of {@link Class} objects to match by type or - * inherited type. - */ - public static boolean nodeMatchesAnyClassByType( - AccessibilityNodeInfoCompat node, Class... referenceClasses) { - if (node == null) { - return false; - } - - for (Class referenceClass : referenceClasses) { - if (ClassLoadingCache.checkInstanceOf(node.getClassName(), referenceClass)) { - return true; - } - } - - return false; - } - - /** - * Recycles the given nodes. - * - * @param nodes The nodes to recycle. - */ - public static void recycleNodes(Collection nodes) { - nodes.clear(); - } - - /** - * Recycles the given nodes. - * - * @param nodes The nodes to recycle. - */ - public static void recycleNodes(@Nullable AccessibilityNodeInfo... nodes) {} - - /** - * Recycles the given nodes. - * - * @param nodes The nodes to recycle. - */ - public static void recycleNodes(@Nullable AccessibilityNodeInfoCompat... nodes) {} - - /** - * Returns {@code true} if the node supports at least one of the specified actions. This method - * supports actions introduced in API level 21 and later. However, it does not support bitmasks. - * - * @param node The node to check - * @param actions The actions to check - * @return {@code true} if at least one action is supported - */ - // TODO: Use A11yActionCompat once AccessibilityActionCompat#equals is overridden - public static boolean supportsAnyAction( - AccessibilityNodeInfoCompat node, AccessibilityActionCompat... actions) { - if (node == null) { - return false; - } - // Unwrap the node and compare AccessibilityActions because AccessibilityActions, unlike - // AccessibilityActionCompats, are static (so checks for equality work correctly). - final List supportedActions = node.getActionList(); - - for (AccessibilityActionCompat action : actions) { - if (supportedActions.contains(action)) { - return true; - } - } - - return false; - } - - /** - * Returns {@code true} if the node supports at least one of the specified actions. To check - * whether a node supports multiple actions, combine them using the {@code |} (logical OR) - * operator. - * - *

Note: this method will check against the getActions() method of AccessibilityNodeInfo, which - * will not contain information for actions introduced in API level 21 or later. - * - * @param node The node to check. - * @param actions The actions to check. - * @return {@code true} if at least one action is supported. - */ - // TODO: Remove this method once AccessibilityActionCompat#equals is overridden - public static boolean supportsAnyAction( - @Nullable AccessibilityNodeInfoCompat node, int... actions) { - if (node != null) { - final int supportedActions = node.getActions(); - - for (int action : actions) { - if ((supportedActions & action) == action) { - return true; - } - } - } - - return false; - } - - /** - * Returns {@code true} if the node supports the specified action. This method supports actions - * introduced in API level 21 and later. However, it does not support bitmasks. - */ - public static boolean supportsAction(@NonNull AccessibilityNodeInfoCompat node, int action) { - // New actions in >= API 21 won't appear in getActions() but in getActionList(). - // On Lollipop+ devices, pre-API 21 actions will also appear in getActionList(). - List actions = node.getActionList(); - int size = actions.size(); - for (int i = 0; i < size; ++i) { - AccessibilityActionCompat actionCompat = actions.get(i); - if (actionCompat.getId() == action) { - return true; - } - } - return false; - } - - /** - * Returns the action label on the node by given action ID, or an empty text if the node doesn't - * support the action. - */ - public static @Nullable CharSequence getActionLabelById( - @NonNull AccessibilityNodeInfoCompat node, int action) { - List actions = node.getActionList(); - int size = actions.size(); - for (int i = 0; i < size; ++i) { - AccessibilityActionCompat actionCompat = actions.get(i); - if (actionCompat.getId() == action) { - return actionCompat.getLabel(); - } - } - return ""; - } - - /** - * Returns the result of applying a filter using breadth-first traversal. - * - * @param node The root node to traverse from. - * @param filter The filter to satisfy. - * @return The first node reached via BFS traversal that satisfies the filter. - */ - public static AccessibilityNodeInfoCompat searchFromBfs( - AccessibilityNodeInfoCompat node, Filter filter) { - return searchFromBfs(node, filter, /* filterToSkip= */ null); - } - - /** - * Returns the result of applying a filter using breadth-first traversal. It allows skip nodes to - * speed up the BFS traversal. - * - * @param node The root node to traverse from. - * @param filter The filter to satisfy. - * @param filterToSkip The filter for skipping nodes, all childs under the node will be skipped. - * @return The first node reached via BFS traversal that satisfies the filter. - */ - public static @Nullable AccessibilityNodeInfoCompat searchFromBfs( - AccessibilityNodeInfoCompat node, - Filter filter, - @Nullable Filter filterToSkip) { - if (node == null) { - return null; - } - - final ArrayDeque queue = new ArrayDeque<>(); - Set visitedNodes = new HashSet<>(); - - queue.add(node); - - while (!queue.isEmpty()) { - final AccessibilityNodeInfoCompat item = queue.removeFirst(); - visitedNodes.add(item); - - if (filterToSkip != null && filterToSkip.accept(item)) { - continue; - } - - if (filter.accept(item)) { - return item; - } - - final int childCount = item.getChildCount(); - - for (int i = 0; i < childCount; i++) { - final AccessibilityNodeInfoCompat child = item.getChild(i); - - if (child != null && !visitedNodes.contains(child)) { - queue.addLast(child); - } - } - } - return null; - } - - /** Safely obtains a copy of node. */ - @Deprecated - public static @Nullable AccessibilityNodeInfoCompat obtain(AccessibilityNodeInfoCompat node) { - return (node == null) ? null : AccessibilityNodeInfoCompat.obtain(node); - } - - /** - * Returns a fresh copy of {@code node} with properties that are less likely to be stale. Returns - * {@code null} if the node can't be found anymore. - */ - public static @Nullable AccessibilityNodeInfoCompat refreshNode( - AccessibilityNodeInfoCompat node) { - return ((node == null) || !node.refresh()) ? null : node; - } - - /** - * Gets the location of specific range of node text. It returns null if the node doesn't support - * text location data or the index is incorrect. - * - * @param node The node being queried. - * @param fromCharIndex start index of the queried text range. - * @param toCharIndex end index of the queried text range. - */ - public static @Nullable List getTextLocations( - AccessibilityNodeInfoCompat node, int fromCharIndex, int toCharIndex) { - return getTextLocations( - node, AccessibilityNodeInfoUtils.getText(node), fromCharIndex, toCharIndex); - } - - /** - * Gets the location of specific range of node {@code text}. It returns null if the node doesn't - * support text location data or the index is incorrect. - * - * @param node The node being queried. - * @param text The node's text. This is typically the text, but can also be the content - * description if the node was not properly created. If the content description is used, its - * text location will only be returned if it's visible on the screen. - * @param fromCharIndex start index of the queried text range. - * @param toCharIndex end index of the queried text range. - */ - public static @Nullable List getTextLocations( - AccessibilityNodeInfoCompat node, CharSequence text, int fromCharIndex, int toCharIndex) { - return getTextLocations(node, text, fromCharIndex, toCharIndex, true); - } - - /** - * Gets the location of specific range of node {@code text}. It returns null if the node doesn't - * support text location data or the index is incorrect. - * - * @param node The node being queried. - * @param text The node's text. This is typically the text, but can also be the content - * description if the node was not properly created. If the content description is used, its - * text location will only be returned if it's visible on the screen. - * @param fromCharIndex start index of the queried text range. - * @param toCharIndex end index of the queried text range. - * @param useWindowBound experimental feature that should more accurately determine word position. - */ - public static @Nullable List getTextLocations( - AccessibilityNodeInfoCompat node, - CharSequence text, - int fromCharIndex, - int toCharIndex, - boolean useWindowBound) { - if (node == null) { - return null; - } - - if (fromCharIndex < 0 - || TextUtils.isEmpty(text) - || !PrimitiveUtils.isInInterval(toCharIndex, fromCharIndex, text.length(), true)) { - return null; - } - AccessibilityNodeInfo info = node.unwrap(); - if (info == null) { - return null; - } - // Prefer character bounds in window, but fall back to character bounds in screen if not - // available. - String key = AccessibilityNodeInfo.EXTRA_DATA_TEXT_CHARACTER_LOCATION_KEY; - boolean isBoundsInWindow = false; - if (useWindowBound - && BuildVersionUtils.isAtLeastBaklava() - && info.getAvailableExtraData() - .contains( - AccessibilityNodeInfoCompat.EXTRA_DATA_TEXT_CHARACTER_LOCATION_IN_WINDOW_KEY)) { - key = AccessibilityNodeInfoCompat.EXTRA_DATA_TEXT_CHARACTER_LOCATION_IN_WINDOW_KEY; - isBoundsInWindow = true; - } - Bundle args = new Bundle(); - args.putInt( - AccessibilityNodeInfo.EXTRA_DATA_TEXT_CHARACTER_LOCATION_ARG_START_INDEX, fromCharIndex); - args.putInt( - AccessibilityNodeInfo.EXTRA_DATA_TEXT_CHARACTER_LOCATION_ARG_LENGTH, - toCharIndex - fromCharIndex); - if (!info.refreshWithExtraData(key, args)) { - return null; - } - - Bundle extras = info.getExtras(); - Parcelable[] data = extras.getParcelableArray(key); - if (data == null) { - return null; - } - - Rect windowBounds = new Rect(); - if (isBoundsInWindow) { - AccessibilityWindowInfo windowInfo = info.getWindow(); - windowInfo.getBoundsInScreen(windowBounds); - } - List result = new ArrayList<>(data.length); - for (Parcelable item : data) { - if (item == null) { - continue; - } - RectF rectF = (RectF) item; - if (isBoundsInWindow) { - rectF.offset(windowBounds.left, windowBounds.top); - } - result.add( - new Rect((int) rectF.left, (int) rectF.top, (int) rectF.right, (int) rectF.bottom)); - } - return result; - } - - /** Returns true if the node supports text location data. */ - public static boolean supportsTextLocation(AccessibilityNodeInfoCompat node) { - if (node == null) { - return false; - } - AccessibilityNodeInfo info = node.unwrap(); - if (info == null) { - return false; - } - List extraData = info.getAvailableExtraData(); - return extraData != null - && (extraData.contains(AccessibilityNodeInfo.EXTRA_DATA_TEXT_CHARACTER_LOCATION_KEY) - || (BuildVersionUtils.isAtLeastBaklava() - && extraData.contains( - AccessibilityNodeInfoCompat.EXTRA_DATA_TEXT_CHARACTER_LOCATION_IN_WINDOW_KEY))); - } - - /** Helper method that returns {@code true} if the specified node is visible to the user */ - public static boolean isVisible(AccessibilityNodeInfoCompat node) { - // We need to move focus to invisible node in WebView to scroll it but we don't want to - // move focus if WebView itself is invisible. - return node != null - && (node.isVisibleToUser() - || (WebInterfaceUtils.isWebContainer(node) - && Role.getRole(node) != Role.ROLE_WEB_VIEW)); - } - - /** - * Checks whether the node's height is smaller than the threshold - * - * @param context the context - * @param node the node to check - * @return {@code true} if the node's height is smaller than the dp threshold. - */ - public static boolean isSmallNodeInHeight(Context context, AccessibilityNodeInfoCompat node) { - final Rect nodeRect = new Rect(); - node.getBoundsInScreen(nodeRect); - - return nodeRect.height() < DisplayUtils.dpToPx(context, THRESHOLD_HEIGHT_DP_FOR_SMALL_NODE); - } - - /** - * Checks whether the node is a top or bottom border node or not. Horizontal scrolling with a - * check of left or right border isn't yet supported in this method. - * - * @param screenPxSize the pixel size of a screen - * @param node the node to check - * @return {@code true} if the node is at top or bottom border. - */ - public static boolean isTopOrBottomBorderNode( - Point screenPxSize, AccessibilityNodeInfoCompat node) { - - final Rect nodeRect = new Rect(); - node.getBoundsInScreen(nodeRect); - - // check the screen's border - if (isTopOrBottomBorderNode(nodeRect, screenPxSize)) { - return true; - } - - // check the scrollable container's border - final Rect parentRect = new Rect(); - Filter filter = - new Filter() { - @Override - public boolean accept(AccessibilityNodeInfoCompat parent) { - if (isScrollItem(parent)) { - parent.getBoundsInScreen(parentRect); - return parentRect.top == nodeRect.top || parentRect.bottom == nodeRect.bottom; - } - return false; - } - }; - - return hasMatchingAncestor(node, filter); - } - - private static boolean isTopOrBottomBorderNode(Rect nodeRect, Point screenPxSize) { - return nodeRect.top <= 0 || nodeRect.bottom >= screenPxSize.y; - } - - /** Determines whether the specified node has bounds identical to the bounds of its window. */ - private static boolean areBoundsIdenticalToWindow(AccessibilityNodeInfoCompat node) { - if (node == null) { - return false; - } - - AccessibilityWindowInfoCompat window = getWindow(node); - if (window == null) { - return false; - } - - Rect windowBounds = new Rect(); - window.getBoundsInScreen(windowBounds); - - Rect nodeBounds = new Rect(); - node.getBoundsInScreen(nodeBounds); - - return windowBounds.equals(nodeBounds); - } - - /** - * Analyses if the edit text has no text. - * - *

If there is a text field with hint text and no text, {@link - * AccessibilityNodeInfoUtils#getText()} returns hint text. Hence this method checks for {@link - * AccessibilityNodeInfo#ACTION_SET_SELECTION} to disregard the hint text. - */ - public static boolean isEmptyEditTextRegardlessOfHint( - @Nullable AccessibilityNodeInfoCompat node) { - if (node == null || !node.isEditable()) { - return false; - } - - if (TextUtils.isEmpty(AccessibilityNodeInfoUtils.getText(node))) { - return true; - } - return !supportsAction(node, AccessibilityNodeInfo.ACTION_SET_SELECTION); - } - - /** * Checks if node represents non-editable selectable text. */ - public static boolean isNonEditableSelectableText(AccessibilityNodeInfoCompat node) { - if (node != null && FeatureSupport.supportsIsTextSelectable()) { - return !node.isEditable() && node.unwrap().isTextSelectable(); - } - return false; - } - - /** * Checks if node represents selectable text. Editable text is selectable. */ - public static boolean isTextSelectable(AccessibilityNodeInfoCompat node) { - if (node == null) { - return false; - } - boolean isEditable = Role.getRole(node) == Role.ROLE_EDIT_TEXT || node.isEditable(); - boolean isNonEditableSelectableText = - AccessibilityNodeInfoUtils.isNonEditableSelectableText(node); - return isEditable || isNonEditableSelectableText; - } - - /** - * Gets a list of URLs contained within an {@link AccessibilityNodeInfoCompat}. - * - * @param node The node that will be searched for links - * @return A list of {@link SpannableUrl}s from the URLs found within the Node - */ - public static List getNodeUrls(AccessibilityNodeInfoCompat node) { - return getNodeClickableElements( - node, URLSpan.class, input -> SpannableUrl.create(input.first, (URLSpan) input.second)); - } - - /** - * Gets a list of ClickableSpans paired with the String they span within a node's text. - * - * @param node The node that will be searched for spans - * @return A list of Clickable elements found within the Node. - */ - public static List getNodeClickableStrings(AccessibilityNodeInfoCompat node) { - return getNodeClickableElements( - node, ClickableSpan.class, input -> ClickableString.create(input.first, input.second)); - } - - /** - * Gets a list of the clickable elements within a node. - * - * @param node the node to get the clickable elements from - * @param clickableType the type of clickable span that we look for within the node - * @param clickableElementFn a function taking the visual string representation and the clickable - * portion of the clickable element to produces the desired format that will be displayable to - * the user - * @param the displayable format representation of the clickable element - * @return a list of clickable elements, empty if there is none - */ - private static List getNodeClickableElements( - AccessibilityNodeInfoCompat node, - Class clickableType, - Function, E> clickableElementFn) { - List spannableStrings = new ArrayList<>(); - SpannableTraversalUtils.getSpannableStringsWithTargetClickableSpanInNodeTree( - node, clickableType, spannableStrings); - - List clickables = new ArrayList<>(1); - for (SpannableWithOffset spannableOffset : spannableStrings) { - if (spannableOffset == null || spannableOffset.spannableString == null) { - continue; - } - SpannableString spannable = spannableOffset.spannableString; - for (ClickableSpan span : spannable.getSpans(0, spannable.length(), clickableType)) { - // Child classes may not use #getUrl, so just check that the class is a URLSpan, instead of - // a child class with "instanceof". - if ((span.getClass() == URLSpan.class) - && Strings.isNullOrEmpty(((URLSpan) span).getURL())) { - continue; - } - int start = spannable.getSpanStart(span); - int end = spannable.getSpanEnd(span); - if (end > start) { - char[] chars = new char[end - start]; - spannable.getChars(start, end, chars, 0); - clickables.add(clickableElementFn.apply(Pair.create(new String(chars), span))); - } - } - } - return clickables; - } - - public static int getMovementGranularity(AccessibilityNodeInfoCompat node) { - // Some nodes in Webview have movement granularities even its content description/text is - // empty. - if (WebInterfaceUtils.supportsWebActions(node) - && TextUtils.isEmpty(node.getContentDescription()) - && TextUtils.isEmpty(AccessibilityNodeInfoUtils.getText(node))) { - return 0; - } - - return node.getMovementGranularities(); - } - - public static CharSequence getHintText(AccessibilityNodeInfoCompat node) { - CharSequence hintText = node.getHintText(); - if (TextUtils.isEmpty(hintText)) { - Bundle bundle = node.getExtras(); - if (bundle != null) { - // Hint text for WebView. - hintText = bundle.getCharSequence(HINT_TEXT_KEY); - } - } - - return hintText; - } - - /** - * To setup a hashmap for AccessibilityAction id and the display string. We only build into the - * hash map with identifiers which are supported in the running platform. - */ - private static HashMap initActionIds() { - HashMap actionIdHashMap = new HashMap<>(); - - actionIdHashMap.put(AccessibilityAction.ACTION_SHOW_ON_SCREEN.getId(), "ACTION_SHOW_ON_SCREEN"); - actionIdHashMap.put( - AccessibilityAction.ACTION_SCROLL_TO_POSITION.getId(), "ACTION_SCROLL_TO_POSITION"); - actionIdHashMap.put(AccessibilityAction.ACTION_SCROLL_UP.getId(), "ACTION_SCROLL_UP"); - actionIdHashMap.put(AccessibilityAction.ACTION_SCROLL_LEFT.getId(), "ACTION_SCROLL_LEFT"); - actionIdHashMap.put(AccessibilityAction.ACTION_SCROLL_DOWN.getId(), "ACTION_SCROLL_DOWN"); - actionIdHashMap.put(AccessibilityAction.ACTION_SCROLL_RIGHT.getId(), "ACTION_SCROLL_RIGHT"); - actionIdHashMap.put(AccessibilityAction.ACTION_CONTEXT_CLICK.getId(), "ACTION_CONTEXT_CLICK"); - actionIdHashMap.put(AccessibilityAction.ACTION_SET_PROGRESS.getId(), "ACTION_SET_PROGRESS"); - actionIdHashMap.put(AccessibilityAction.ACTION_MOVE_WINDOW.getId(), "ACTION_MOVE_WINDOW"); - - if (BuildVersionUtils.isAtLeastP()) { - actionIdHashMap.put(AccessibilityAction.ACTION_SHOW_TOOLTIP.getId(), "ACTION_SHOW_TOOLTIP"); - actionIdHashMap.put(AccessibilityAction.ACTION_HIDE_TOOLTIP.getId(), "ACTION_HIDE_TOOLTIP"); - } - if (BuildVersionUtils.isAtLeastQ()) { - actionIdHashMap.put(AccessibilityAction.ACTION_PAGE_RIGHT.getId(), "ACTION_PAGE_RIGHT"); - actionIdHashMap.put(AccessibilityAction.ACTION_PAGE_LEFT.getId(), "ACTION_PAGE_LEFT"); - actionIdHashMap.put(AccessibilityAction.ACTION_PAGE_DOWN.getId(), "ACTION_PAGE_DOWN"); - actionIdHashMap.put(AccessibilityAction.ACTION_PAGE_UP.getId(), "ACTION_PAGE_UP"); - } - if (BuildVersionUtils.isAtLeastR()) { - actionIdHashMap.put( - AccessibilityAction.ACTION_PRESS_AND_HOLD.getId(), "ACTION_PRESS_AND_HOLD"); - actionIdHashMap.put(AccessibilityAction.ACTION_IME_ENTER.getId(), "ACTION_IME_ENTER"); - } - return actionIdHashMap; - } - - //////////////////////////////////////////////////////////////////////////////////////////////// - // Methods for displaying node data - - public static String actionToString(int action) { - switch (action) { - case AccessibilityNodeInfoCompat.ACTION_ACCESSIBILITY_FOCUS -> { - return "ACTION_ACCESSIBILITY_FOCUS"; - } - case AccessibilityNodeInfoCompat.ACTION_CLEAR_ACCESSIBILITY_FOCUS -> { - return "ACTION_CLEAR_ACCESSIBILITY_FOCUS"; - } - case AccessibilityNodeInfoCompat.ACTION_CLEAR_FOCUS -> { - return "ACTION_CLEAR_FOCUS"; - } - case AccessibilityNodeInfoCompat.ACTION_CLEAR_SELECTION -> { - return "ACTION_CLEAR_SELECTION"; - } - case AccessibilityNodeInfoCompat.ACTION_CLICK -> { - return "ACTION_CLICK"; - } - case AccessibilityNodeInfoCompat.ACTION_COLLAPSE -> { - return "ACTION_COLLAPSE"; - } - case AccessibilityNodeInfoCompat.ACTION_COPY -> { - return "ACTION_COPY"; - } - case AccessibilityNodeInfoCompat.ACTION_CUT -> { - return "ACTION_CUT"; - } - case AccessibilityNodeInfoCompat.ACTION_DISMISS -> { - return "ACTION_DISMISS"; - } - case AccessibilityNodeInfoCompat.ACTION_EXPAND -> { - return "ACTION_EXPAND"; - } - case AccessibilityNodeInfoCompat.ACTION_FOCUS -> { - return "ACTION_FOCUS"; - } - case AccessibilityNodeInfoCompat.ACTION_LONG_CLICK -> { - return "ACTION_LONG_CLICK"; - } - case AccessibilityNodeInfoCompat.ACTION_NEXT_AT_MOVEMENT_GRANULARITY -> { - return "ACTION_NEXT_AT_MOVEMENT_GRANULARITY"; - } - case AccessibilityNodeInfoCompat.ACTION_NEXT_HTML_ELEMENT -> { - return "ACTION_NEXT_HTML_ELEMENT"; - } - case AccessibilityNodeInfoCompat.ACTION_PASTE -> { - return "ACTION_PASTE"; - } - case AccessibilityNodeInfoCompat.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY -> { - return "ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY"; - } - case AccessibilityNodeInfoCompat.ACTION_PREVIOUS_HTML_ELEMENT -> { - return "ACTION_PREVIOUS_HTML_ELEMENT"; - } - case AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD -> { - return "ACTION_SCROLL_BACKWARD"; - } - case AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD -> { - return "ACTION_SCROLL_FORWARD"; - } - case AccessibilityNodeInfoCompat.ACTION_SELECT -> { - return "ACTION_SELECT"; - } - case AccessibilityNodeInfoCompat.ACTION_SET_SELECTION -> { - return "ACTION_SET_SELECTION"; - } - case AccessibilityNodeInfoCompat.ACTION_SET_TEXT -> { - return "ACTION_SET_TEXT"; - } - default -> {} - } - @Nullable String actionName = actionIdToName.get(action); - return actionName == null ? "(unhandled action:" + action + ")" : actionName; - } - - public static String toStringShort(@Nullable AccessibilityNodeInfo node) { - return toStringShort(toCompat(node)); - } - - public static String toStringShort(@Nullable AccessibilityNodeInfoCompat node) { - if (node == null) { - return "null"; - } - return StringBuilderUtils.joinFields( - "AccessibilityNodeInfoCompat", - StringBuilderUtils.optionalInt("id", node.hashCode(), -1), - StringBuilderUtils.optionalText("class", node.getClassName()), - StringBuilderUtils.optionalText("package", node.getPackageName()), - // TODO: Uses hash value in production build - StringBuilderUtils.optionalText( - "text", - (AccessibilityNodeInfoUtils.getText(node) == null) - ? null - : FeatureSupport.logcatIncludePsi() - // Logs for DEBUG build or user had opt-in - ? AccessibilityNodeInfoUtils.getText(node) - : "***"), - StringBuilderUtils.optionalText("state", node.getStateDescription()), - StringBuilderUtils.optionalText("content", node.getContentDescription()), - StringBuilderUtils.optionalText("viewIdResName", node.getViewIdResourceName()), - StringBuilderUtils.optionalText("hint", node.getHintText()), - StringBuilderUtils.optionalTag("enabled", node.isEnabled()), - StringBuilderUtils.optionalTag("checkable", node.isCheckable()), - StringBuilderUtils.optionalTag("checked", node.isChecked()), - StringBuilderUtils.optionalTag("accessibilityFocused", node.isAccessibilityFocused()), - StringBuilderUtils.optionalTag("focusable", isFocusable(node)), - StringBuilderUtils.optionalTag("screenReaderFocusable", node.isScreenReaderFocusable()), - StringBuilderUtils.optionalTag("focused", node.isFocused()), - StringBuilderUtils.optionalTag("selected", node.isSelected()), - StringBuilderUtils.optionalTag("clickable", isClickable(node)), - StringBuilderUtils.optionalTag("longClickable", isLongClickable(node)), - StringBuilderUtils.optionalTag("password", node.isPassword()), - StringBuilderUtils.optionalTag("textEntryKey", node.isTextEntryKey()), - StringBuilderUtils.optionalTag("scrollable", isScrollable(node)), - StringBuilderUtils.optionalTag( - "heading", FeatureSupport.isHeadingWorks() && node.isHeading()), - StringBuilderUtils.optionalTag("collapsible", isCollapsible(node)), - StringBuilderUtils.optionalTag("expandable", isExpandable(node)), - StringBuilderUtils.optionalTag("dismissable", isDismissible(node)), - StringBuilderUtils.optionalTag("pinEntry", isPinEntry(node)), - StringBuilderUtils.optionalTag("visible", node.isVisibleToUser())); - } - - /** Copied from AccessibilityNodeInfo.java */ - public static @Nullable String getMovementGranularitySymbolicName(int granularity) { - if (granularity == 0) { - return null; - } - return switch (granularity) { - case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER -> "MOVEMENT_GRANULARITY_CHARACTER"; - case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD -> "MOVEMENT_GRANULARITY_WORD"; - case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE -> "MOVEMENT_GRANULARITY_LINE"; - case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PARAGRAPH -> "MOVEMENT_GRANULARITY_PARAGRAPH"; - case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PAGE -> "MOVEMENT_GRANULARITY_PAGE"; - default -> Integer.toHexString(granularity); - }; - } - - /** - * Given a double value, get the int percentage (0 to 100, both inclusive). Only return 0 or 100 - * when percentage is exactly 0 or 100 percent. - */ - public static int roundForProgressPercent(double percent) { - if (percent < 0.0f) { - return 0; - } else if (percent > 0.0f && percent < 1.0f) { - return 1; - } else if (percent > 99.0f && percent < 100.0f) { - return 99; - } else if (percent > 100.0f) { - return 100; - } - return (int) Math.round(percent); - } - - //////////////////////////////////////////////////////////////////////////////////////////////// - // Methods for node properties - - /** - * Returns {@code true} if the height and width of the {@link AccessibilityNodeInfoCompat}'s - * visible bounds on the screen are greater than a specified number of minimum pixels. This can be - * used to prune tiny elements or elements off the screen. - * - *

{@link AccessibilityNodeInfo#isVisibleToUser()} sometimes returns {@code true} for {@link - * android.webkit.WebView} items off the screen, so this method allows us to better ignore WebView - * content off the screen. - * - * @param node The node that will be checked for a minimum number of pixels on the screen - * @return {@code true} if the node has at least the number of minimum visible pixels in both - * width and height on the screen - */ - public static boolean hasMinimumPixelsVisibleOnScreen(AccessibilityNodeInfoCompat node) { - Rect visibleBounds = new Rect(); - node.getBoundsInScreen(visibleBounds); - return ((Math.abs(visibleBounds.height()) >= MIN_VISIBLE_PIXELS) - && (Math.abs(visibleBounds.width()) >= MIN_VISIBLE_PIXELS)); - } - - /** - * Returns the progress percentage from the node. The value will be in the range [0, 100]. - * - * @param node The node from which to obtain the progress percentage. - * @return The progress percentage. - */ - public static float getProgressPercent(@Nullable AccessibilityNodeInfoCompat node) { - if (node == null) { - return 0.0f; - } - - final @Nullable RangeInfoCompat rangeInfo = node.getRangeInfo(); - if (rangeInfo == null) { - return 0.0f; - } - - final float maxProgress = rangeInfo.getMax(); - final float minProgress = rangeInfo.getMin(); - final float currentProgress = rangeInfo.getCurrent(); - final float diffProgress = maxProgress - minProgress; - if (diffProgress <= 0.0f) { - logError("getProgressPercent", "Range is invalid. [%f, %f]", minProgress, maxProgress); - return 0.0f; - } - - if (currentProgress < minProgress) { - logError( - "getProgressPercent", - "Current percent is out of range. Current: %f Range: [%f, %f]", - currentProgress, - minProgress, - maxProgress); - return 0.0f; - } - - if (currentProgress > maxProgress) { - logError( - "getProgressPercent", - "Current percent is out of range. Current: %f Range: [%f, %f]", - currentProgress, - minProgress, - maxProgress); - return 100.0f; - } - - final float percent = (currentProgress - minProgress) / diffProgress; - return (100.0f * Math.max(0.0f, Math.min(1.0f, percent))); - } - - /** - * Returns whether the node has valid RangeInfo. - * - * @param node The node to check. - * @return Whether the node has valid RangeInfo. - */ - public static boolean hasValidRangeInfo(@Nullable AccessibilityNodeInfoCompat node) { - if (node == null) { - return false; - } - - final @Nullable RangeInfoCompat rangeInfo = node.getRangeInfo(); - if (rangeInfo == null) { - return false; - } - - final float maxProgress = rangeInfo.getMax(); - final float minProgress = rangeInfo.getMin(); - final float currentProgress = rangeInfo.getCurrent(); - final float diffProgress = maxProgress - minProgress; - return (diffProgress > 0.0f) - && (currentProgress >= minProgress) - && (currentProgress <= maxProgress); - } - - /** Checks whether the given node is still in the window. */ - public static boolean isInWindow( - AccessibilityNodeInfoCompat checkingNode, - @Nullable AccessibilityWindowInfoCompat windowInfoCompat) { - if (windowInfoCompat == null) { - return false; - } - int windowId = checkingNode.getWindowId(); - if (windowId != WINDOW_ID_NONE && windowId != windowInfoCompat.getId()) { - return false; - } - return hasDescendant(windowInfoCompat.getRoot(), checkingNode); - } - - /** Checks whether the given node is still in the window. */ - public static boolean isInWindow( - AccessibilityNodeInfoCompat checkingNode, @Nullable AccessibilityWindowInfo windowInfo) { - if (windowInfo == null) { - return false; - } - int windowId = checkingNode.getWindowId(); - if (windowId != WINDOW_ID_NONE && windowId != windowInfo.getId()) { - return false; - } - return hasDescendant(toCompat(windowInfo.getRoot()), checkingNode); - } - - /** - * Checks whether the given node is a header. - * - *

On M devices, the return value is always false if the node is an item in ListView or - * GridView but not in WebView. - */ - // TODO On pre-N devices, the framework ListView/GridView will mark non-headers - // as headers. The workaround should be removed when TalkBack doesn't support android M. - public static boolean isHeading(AccessibilityNodeInfoCompat node) { - if (!FeatureSupport.isHeadingWorks()) { - AccessibilityNodeInfoCompat collectionRoot = getCollectionRoot(node); - if (nodeIsListOrGrid(collectionRoot) && !WebInterfaceUtils.isWebContainer(collectionRoot)) { - return false; - } - } - return node.isHeading(); - } - - /** - * Returns the collection root for the given node. As it searches for the collection root, if - * there are more than one collection item along the way upwards, this function will return null - * as the a11y tree is formatted incorrectly. - * - *

For nested collection items, a collection node must always exist between an ancestor and a - * descendant collection item. If this function is called on a descendant item that is directly - * nested under an ancestor item (without an intermediary collection node), it will return null. - * See b/409569562#4. - * - * @param node The node to search for the collection root. - * @return The collection root, or {@code null} if no collection root is found. - */ - public static @Nullable AccessibilityNodeInfoCompat getCollectionRoot( - @Nullable AccessibilityNodeInfoCompat node) { - if (node == null) { - return null; - } - - Filter filter = FILTER_COLLECTION.or(FILTER_COLLECTION_ITEM); - - AccessibilityNodeInfoCompat collectionRoot = getSelfOrMatchingAncestor(node, filter); - if (collectionRoot == null || FILTER_COLLECTION.accept(collectionRoot)) { - return collectionRoot; - } - - collectionRoot = getMatchingAncestor(collectionRoot, filter); - if (collectionRoot == null || FILTER_COLLECTION.accept(collectionRoot)) { - return collectionRoot; - } - - return null; - } - - /** - * Returns the collection root for the given node, excluding the node itself from the search. - * - * @param node The node to search for the collection root. - * @return The collection root, or {@code null} if no collection root is found. - */ - public static @Nullable AccessibilityNodeInfoCompat getCollectionRootExcludeSelf( - @Nullable AccessibilityNodeInfoCompat node) { - if (node == null) { - return null; - } - - if (FILTER_COLLECTION.accept(node)) { - return getCollectionRoot(node.getParent()); - } - - return getCollectionRoot(node); - } - - /** Returns a table root containing the given node. */ - public static @Nullable AccessibilityNodeInfoCompat getTableRoot( - @Nullable AccessibilityNodeInfoCompat descendant) { - return AccessibilityNodeInfoUtils.getSelfOrMatchingAncestor(descendant, FILTER_TABLE); - } - - /** Returns whether the given node is a table root. */ - private static boolean isTableRoot(AccessibilityNodeInfoCompat node) { - CollectionInfoCompat collectionInfo = node.getCollectionInfo(); - return collectionInfo != null - && collectionInfo.getRowCount() > 1 - && collectionInfo.getColumnCount() > 1; - } - - /** Returns a table cell under table containing the given node. */ - public static @Nullable AccessibilityNodeInfoCompat getTableCellUnderTable( - @Nullable AccessibilityNodeInfoCompat descendant) { - return AccessibilityNodeInfoUtils.getSelfOrMatchingAncestor( - descendant, FILTER_TABLE_CELL_UNDER_TABLE); - } - - /** Returns a node that mapped to the voice dictation clickable view. */ - public static @Nullable AccessibilityNodeInfoCompat getVoiceDictationNode( - @Nullable AccessibilityNodeInfoCompat descendant) { - return AccessibilityNodeInfoUtils.getSelfOrMatchingDescendant( - descendant, FILTER_VOICE_DICTATION); - } - - private static boolean isVoiceDictationNode(AccessibilityNodeInfoCompat node) { - return Role.getRole(node) == Role.ROLE_VOICE_DICTATION_BUTTON; - } - - /** Returns whether the given node is a table cell. */ - private static boolean isTableCell(AccessibilityNodeInfoCompat node) { - CollectionItemInfoCompat collectionItemInfo = node.getCollectionItemInfo(); - return collectionItemInfo != null - && collectionItemInfo.getRowIndex() >= 0 - && collectionItemInfo.getColumnIndex() >= 0; - } - - /** Returns whether the given node is a table cell in a table. */ - private static boolean isTableCellUnderTable(AccessibilityNodeInfoCompat node) { - CollectionItemInfoCompat collectionItemInfo = node.getCollectionItemInfo(); - return collectionItemInfo != null - && collectionItemInfo.getRowIndex() >= 0 - && collectionItemInfo.getColumnIndex() >= 0 - && getTableRoot(node) != null; - } - - /** Checks if given node is ListView or GirdView. */ - public static boolean nodeIsListOrGrid(@Nullable AccessibilityNodeInfoCompat node) { - return nodeMatchesAnyClassName(node, CLASS_LISTVIEW, CLASS_GRIDVIEW); - } - - /** Returns {@code true} if the parent of the {@code node} is a collection. */ - public static boolean nodeIsListOrGridItem(@Nullable AccessibilityNodeInfoCompat node) { - if (node == null) { - return false; - } - - @Nullable AccessibilityNodeInfoCompat parent = node.getParent(); - if (parent == null) { - return false; - } - - @RoleName int role = Role.getRole(parent); - return role == Role.ROLE_LIST || role == Role.ROLE_GRID; - } - - /** Returns true if the {@code node} is in a collection. */ - public static boolean isInCollection(AccessibilityNodeInfoCompat node) { - return AccessibilityNodeInfoUtils.hasMatchingAncestor( - node, - new Filter() { - @Override - public boolean accept(AccessibilityNodeInfoCompat ancestor) { - @RoleName int role = Role.getRole(ancestor); - return role == Role.ROLE_LIST - || role == Role.ROLE_GRID - || (ancestor != null && ancestor.getCollectionInfo() != null); - } - }); - } - - public static @Nullable String getGridRowTitle(AccessibilityNodeInfoCompat node) { - if (FeatureSupport.supportGridTitle() && node.unwrap() != null) { - CollectionItemInfo itemInfo = node.unwrap().getCollectionItemInfo(); - if (itemInfo != null) { - return itemInfo.getRowTitle(); - } - } - return null; - } - - public static @Nullable String getGridColumnTitle(AccessibilityNodeInfoCompat node) { - if (FeatureSupport.supportGridTitle() && node.unwrap() != null) { - CollectionItemInfo itemInfo = node.unwrap().getCollectionItemInfo(); - if (itemInfo != null) { - return itemInfo.getColumnTitle(); - } - } - return null; - } - - /** - * Returns true if the {@link - * androidx.core.view.accessibility.AccessibilityNodeInfoCompat.CollectionInfoCompat} associated - * with {@code node} is not null and reflects the presence of at least 1 row and 1 column. - */ - public static boolean hasUsableCollectionInfo(AccessibilityNodeInfoCompat node) { - return node != null - && node.getCollectionInfo() != null - && node.getCollectionInfo().getRowCount() >= 1 - && node.getCollectionInfo().getColumnCount() >= 1; - } - - /** - * Returns true if the {@link - * androidx.core.view.accessibility.AccessibilityNodeInfoCompat.CollectionItemInfoCompat} - * associated with {@code node} is not null and contains legal collection row and column indices. - */ - public static boolean hasUsableCollectionItemInfo(AccessibilityNodeInfoCompat node) { - return node != null - && node.getCollectionItemInfo() != null - && node.getCollectionItemInfo().getRowIndex() >= 0 - && node.getCollectionItemInfo().getColumnIndex() >= 0; - } - - /** - * Returns true if the {@link - * androidx.core.view.accessibility.AccessibilityNodeInfoCompat.CollectionItemInfoCompat} - * associated with {@code node} is not null, and it contains legal collection row and column - * indices, which fall within the row and column bounds of {@code parent}. - */ - public static boolean hasUsableCollectionItemInfo( - AccessibilityNodeInfoCompat item, AccessibilityNodeInfoCompat collection) { - return hasUsableCollectionItemInfo(item) - && hasUsableCollectionInfo(collection) - && item.getCollectionItemInfo().getRowIndex() < collection.getCollectionInfo().getRowCount() - && item.getCollectionItemInfo().getColumnIndex() - < collection.getCollectionInfo().getColumnCount(); - } - - /** - * Returns the {@link Rect} of the node bounds in screen coordinates, and returns an empty Rect if - * the given node is null. - */ - public static Rect getNodeBoundsInScreen(@Nullable AccessibilityNodeInfoCompat node) { - Rect nodeBounds = new Rect(); - if (node != null) { - node.getBoundsInScreen(nodeBounds); - } - return nodeBounds; - } - - /** - * Returns a list of {@link SpellingSuggestion} for all {@link SuggestionSpan}s at the cursor - * position in the given {@link AccessibilityNodeInfoCompat} if the input method for the node is - * able to display spelling suggestions. - * - * @param node The node to check - */ - public static ImmutableList getSpellingSuggestions( - Context context, AccessibilityNodeInfoCompat node) { - return getSpellingSuggestions(context, node, /* activeSpellCheck= */ true); - } - - /** - * Returns a list of {@link SpellingSuggestion} for all {@link SuggestionSpan}s at the cursor - * position in the given {@link AccessibilityNodeInfoCompat}. - * - * @param node the node to check - * @param cursorPosition index of the cursor position - */ - public static ImmutableList getSpellingSuggestions( - Context context, AccessibilityNodeInfoCompat node, int cursorPosition) { - return getSpellingSuggestions(context, node, cursorPosition, /* activeSpellCheck= */ true); - } - - /** - * Returns a list of {@link SpellingSuggestion} for all {@link SuggestionSpan}s at the cursor - * position in the given {@link AccessibilityNodeInfoCompat} if the input method for the node is - * able to display spelling suggestions. - * - * @param node The node to check - * @param activeSpellCheck Perform in service spell check or not - */ - public static ImmutableList getSpellingSuggestions( - Context context, AccessibilityNodeInfoCompat node, boolean activeSpellCheck) { - if (node == null || hasNoSuggestionsNeed(node.getInputType())) { - return ImmutableList.of(); - } - - int start = node.getTextSelectionStart(); - int end = node.getTextSelectionEnd(); - - if (start != end) { - LogUtils.v(TAG, "Spelling suggestion does not work when text is selected."); - return ImmutableList.of(); - } - - return getSpellingSuggestions(context, node, end, activeSpellCheck); - } - - /** - * Returns a list of {@link SpellingSuggestion} for all {@link SuggestionSpan}s at the cursor - * position in the given {@link AccessibilityNodeInfoCompat}. - * - * @param node the node to check - * @param cursorPosition index of the cursor position - */ - // common_typos_disable - public static ImmutableList getSpellingSuggestions( - Context context, - AccessibilityNodeInfoCompat node, - int cursorPosition, - boolean activeSpellCheck) { - @Nullable CharSequence text = - activeSpellCheck ? SpellChecker.getTextWithSuggestionSpans(context, node) : node.getText(); - List spellingSuggestions = new ArrayList<>(); - if (TextUtils.isEmpty(text) || !(text instanceof Spannable spannedText)) { - LogUtils.v(TAG, "getSpellingSuggestions() text is null or not a Spannable"); - return ImmutableList.of(); - } - - // Returns the suggestion if just a space or punctuation is between the typo and the cursor. - // For example: helllo,| - if (cursorPosition > 0) { - if (cursorPosition < text.length()) { - // Do not return the suggestion if a word is after the cursor. For example: helllo |world - if (!Character.isLetterOrDigit(text.charAt(cursorPosition - 1)) - && !Character.isLetterOrDigit(text.charAt(cursorPosition))) { - cursorPosition--; - } - } else if (cursorPosition == text.length()) { - // It is unnecessary to check the character after the cursor because the cursor is at the - // end of the line. For example: helllo | - if (!Character.isLetterOrDigit(text.charAt(cursorPosition - 1))) { - cursorPosition--; - } - } - } - - SuggestionSpan[] spans = spannedText.getSpans(0, text.length(), SuggestionSpan.class); - StringBuilder logMessage = - new StringBuilder( - String.format( - Locale.ENGLISH, - "cursor=[%d] suggestion_spans text=[%s] spans=[%d]", - cursorPosition, - text, - spans.length)); - // TODO: Uses stream to simplify it. - for (SuggestionSpan span : spans) { - int start = spannedText.getSpanStart(span); - int end = spannedText.getSpanEnd(span); - if (start <= cursorPosition && end >= cursorPosition) { - SpellingSuggestion spellingSuggestion = - SpellingSuggestion.create(start, end, text.subSequence(start, end), span); - // Ignore the span which has no suggestion to avoid announcing suggestions available but - // there is no suggestion that can be chosen. - if (span.getSuggestions().length > 0) { - spellingSuggestions.add(spellingSuggestion); - } else { - LogUtils.v(TAG, "%s no suggestion", text.subSequence(start, end)); - } - - logMessage.append("\n"); - logMessage.append(spellingSuggestion); - } - } - - LogUtils.v(TAG, logMessage.toString()); - return ImmutableList.copyOf(spellingSuggestions); - } - - /** - * Returns the total number of typos which are in the edit field. - * - * @return 0, there is no typo or the input method for the node won't display spelling - * suggestions. - */ - public static int getTypoCount(Context context, AccessibilityNodeInfoCompat node) { - return getSuggestionSpans(context, node).size(); - } - - /** - * Returns {@code true} if the given {@link AccessibilityNodeInfoCompat} text includes misspelled - * words which have spelling suggestions and the input method for the node is able to display - * spelling suggestions. - */ - public static boolean hasSpellingSuggestionsForTypos( - Context context, AccessibilityNodeInfoCompat node) { - ImmutableList spans = getSuggestionSpans(context, node); - for (SuggestionSpan span : spans) { - if (span.getSuggestions().length > 0) { - return true; - } - } - return false; - } - - /** - * Returns {@link Locale} if the given {@link AccessibilityNodeInfoCompat} supports App Locale. - */ - public static @Nullable Locale getLocalesByNode(AccessibilityNodeInfoCompat node) { - if (node == null || !FeatureSupport.supportAccessibilityAppLocale()) { - return null; - } - AccessibilityWindowInfoCompat windowInfoCompat = node.getWindow(); - if (windowInfoCompat == null) { - return null; - } - AccessibilityWindowInfo windowInfo = windowInfoCompat.unwrap(); - if (windowInfo == null) { - return null; - } - LocaleList localeList = windowInfo.getLocales(); - Locale defaultLocal = Locale.getDefault(); - - int count = (localeList == null) ? 0 : localeList.size(); - if (count == 0 || defaultLocal.equals(localeList.get(0))) { - // AccessibilityWindowInfo#getLocales may return the system default locale. When the 1st entry - // matches the default locale, we don't insert the locale which will invalidate the locale - // embedded within the content. - return null; - } - return localeList.get(0); - } - - /** Returns whether the node has requested initial accessibility focus. */ - public static boolean hasRequestInitialAccessibilityFocus(AccessibilityNodeInfoCompat node) { - if (node == null) { - return false; - } - - boolean hasRequestInitialAccessibilityFocus = node.hasRequestInitialAccessibilityFocus(); - - // In the early version of AndroidX, the property was retrieved from the AccessibilityNodeInfo. - // See b/279108748 for details. - if (!hasRequestInitialAccessibilityFocus - && FeatureSupport.supportRequestInitialAccessibilityFocusNative()) { - AccessibilityNodeInfo unwrap = node.unwrap(); - if (unwrap != null) { - hasRequestInitialAccessibilityFocus = unwrap.hasRequestInitialAccessibilityFocus(); - } - } - - return hasRequestInitialAccessibilityFocus; - } - - /** - * Returns the rate update limitation (in milli-second) if the given {@link - * AccessibilityNodeInfoCompat} supports it. - */ - public static long getMinDurationBetweenContentChangesMillis(AccessibilityNodeInfoCompat node) { - if (node == null) { - LogUtils.w(TAG, "Failed to getMinDurationBetweenContentChangesMillis/node is null"); - return 0L; - } - return node.getMinDurationBetweenContentChangesMillis(); - } - - /** - * Returns a list of {@link SuggestionSpan} in the given {@link AccessibilityNodeInfoCompat} text - * or an empty list if the input method for the node won't display spelling suggestions. - */ - private static ImmutableList getSuggestionSpans( - Context context, AccessibilityNodeInfoCompat node) { - if (node == null) { - return ImmutableList.of(); - } - return getSuggestionSpans(context, node.getText(), node.getInputType()); - } - - /** - * Returns a list of {@link SuggestionSpan} in the given text or an empty list if the input type - * is no suggestion. - */ - public static ImmutableList getSuggestionSpans( - Context context, @Nullable CharSequence text, int inputType) { - if (TextUtils.isEmpty(text) || hasNoSuggestionsNeed(inputType)) { - return ImmutableList.of(); - } - - @Nullable CharSequence textWithSuggestionSpans = - SpellChecker.getTextWithSuggestionSpans(context, text); - if (TextUtils.isEmpty(textWithSuggestionSpans) - || !(textWithSuggestionSpans instanceof Spannable spannedText)) { - return ImmutableList.of(); - } - - SuggestionSpan[] spans = - spannedText.getSpans(0, textWithSuggestionSpans.length(), SuggestionSpan.class); - if (spans.length == 0) { - return ImmutableList.of(); - } - - return ImmutableList.copyOf(spans); - } - - /** - * Returns {@code true}, if the input method for the {@code node} won't display spelling - * suggestions. - */ - private static boolean hasNoSuggestionsNeed(int input) { - return input == TYPE_TEXT_FLAG_NO_SUGGESTIONS; - } - - private static boolean nodeMatchesAnyClassName( - @Nullable AccessibilityNodeInfoCompat node, CharSequence... classNames) { - if (node == null || node.getClassName() == null || classNames == null) { - return false; - } - - for (CharSequence name : classNames) { - if (TextUtils.equals(node.getClassName(), name)) { - return true; - } - } - - return false; - } - - /** - * Splits a fully-qualified resource identifier name into its package and ID name. For example, - * "com.android.deskclock:id/analog_appwidget" which provides by {@link - * AccessibilityNodeInfoCompat#getViewIdResourceName()} - */ - @AutoValue - public abstract static class ViewResourceName { - public abstract String packageName(); - - public abstract String viewIdName(); - - /** Creates a ViewResourceName instance by {@link AccessibilityNodeInfoCompat}. */ - public static @Nullable ViewResourceName create(AccessibilityNodeInfoCompat node) { - String resourceName = node.getViewIdResourceName(); - if (TextUtils.isEmpty(resourceName)) { - return null; - } - - final String[] splitId = RESOURCE_NAME_SPLIT_PATTERN.split(resourceName, 2); - if (splitId.length != 2 || TextUtils.isEmpty(splitId[0]) || TextUtils.isEmpty(splitId[1])) { - // Invalid view resource name. - LogUtils.w(TAG, "Failed to parse resource: %s", resourceName); - return null; - } - - return new AutoValue_AccessibilityNodeInfoUtils_ViewResourceName(splitId[0], splitId[1]); - } - - @Override - public final String toString() { - return "ViewResourceName= " - + StringBuilderUtils.joinFields( - StringBuilderUtils.optionalText("packageName", packageName()), - StringBuilderUtils.optionalText("viewIdName", viewIdName())); - } - } - - /** - * Represents a {@link ClickableSpan} and the string it spans to reduce the effort of downstream - * consumers; getting the spanned string is non-trivial. - */ - @AutoValue - public abstract static class ClickableString { - public static ClickableString create(String string, ClickableSpan clickableSpan) { - return new AutoValue_AccessibilityNodeInfoUtils_ClickableString(string, clickableSpan); - } - - public abstract String string(); - - public abstract ClickableSpan clickableSpan(); - - // ClickableSpan.onClick is actually fine with a null param. - public void onClick() { - clickableSpan().onClick(null); - } - } - - /** A wrapper of {@link SuggestionSpan}. */ - @AutoValue - public abstract static class SpellingSuggestion { - public abstract int start(); - - public abstract int end(); - - public abstract CharSequence misspelledWord(); - - public abstract SuggestionSpan suggestionSpan(); - - public static SpellingSuggestion create( - int start, int end, CharSequence misspelledWord, SuggestionSpan suggestionSpan) { - return new AutoValue_AccessibilityNodeInfoUtils_SpellingSuggestion( - start, end, misspelledWord, suggestionSpan); - } - - @Override - public final @NonNull String toString() { - StringBuilder suggestionsString = - new StringBuilder() - .append( - String.format(Locale.ENGLISH, "[%d-%d][%s]", start(), end(), misspelledWord())); - for (String suggestion : suggestionSpan().getSuggestions()) { - suggestionsString.append(String.format(Locale.ENGLISH, "[suggestion=%s]", suggestion)); - } - - return suggestionsString.toString(); - } - } - - private static String printId(AccessibilityNodeInfoCompat node) { - return String.format("Node(id=%s class=%s)", node.hashCode(), node.getClassName()); - } -} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityNodeInfoUtils.kt b/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityNodeInfoUtils.kt new file mode 100644 index 000000000..993759011 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityNodeInfoUtils.kt @@ -0,0 +1,3671 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Ported from Java to Kotlin. + */ + +package com.google.android.accessibility.utils + +import android.content.Context +import android.graphics.Point +import android.graphics.Rect +import android.graphics.RectF +import android.os.Bundle +import android.os.LocaleList +import android.os.Parcelable +import android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS +import android.text.Spannable +import android.text.SpannableString +import android.text.TextUtils +import android.text.style.ClickableSpan +import android.text.style.SuggestionSpan +import android.text.style.URLSpan +import android.util.Pair +import android.view.accessibility.AccessibilityNodeInfo +import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction +import android.view.accessibility.AccessibilityNodeInfo.CollectionItemInfo +import android.view.accessibility.AccessibilityWindowInfo +import android.widget.GridView +import android.widget.ListView +import androidx.annotation.VisibleForTesting +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.CollectionInfoCompat +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.CollectionItemInfoCompat +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.RangeInfoCompat +import androidx.core.view.accessibility.AccessibilityWindowInfoCompat +import com.google.android.accessibility.utils.AccessibilityWindowInfoUtils.WINDOW_ID_NONE +import com.google.android.accessibility.utils.AccessibilityWindowInfoUtils.WINDOW_TYPE_NONE +import com.google.android.accessibility.utils.DiagnosticOverlayUtils.FOCUS_FAIL_FAIL_ALL_FOCUS_TESTS +import com.google.android.accessibility.utils.DiagnosticOverlayUtils.FOCUS_FAIL_NOT_SPEAKABLE +import com.google.android.accessibility.utils.DiagnosticOverlayUtils.FOCUS_FAIL_NOT_VISIBLE +import com.google.android.accessibility.utils.DiagnosticOverlayUtils.FOCUS_FAIL_SAME_WINDOW_BOUNDS_CHILDREN +import com.google.android.accessibility.utils.DiagnosticOverlayUtils.NONE +import com.google.android.accessibility.utils.DiagnosticOverlayUtils.DiagnosticType +import com.google.android.accessibility.utils.Role.ROLE_GRID +import com.google.android.accessibility.utils.Role.ROLE_HORIZONTAL_SCROLL_VIEW +import com.google.android.accessibility.utils.Role.ROLE_LIST +import com.google.android.accessibility.utils.Role.ROLE_PAGER +import com.google.android.accessibility.utils.Role.ROLE_SCROLL_VIEW +import com.google.android.accessibility.utils.Role.ROLE_WEB_VIEW +import com.google.android.accessibility.utils.Role.RoleName +import com.google.android.accessibility.utils.SpannableUtils.SpannableWithOffset +import com.google.android.accessibility.utils.compat.CompatUtils +import com.google.android.accessibility.utils.traversal.SpannableTraversalUtils +import com.google.android.libraries.accessibility.utils.log.LogUtils +import com.google.android.libraries.accessibility.utils.url.SpannableUrl +import com.google.common.base.Function +import com.google.common.base.Strings +import com.google.common.collect.ImmutableList +import com.google.common.collect.ImmutableSet +import com.google.errorprone.annotations.FormatMethod +import com.google.errorprone.annotations.FormatString +import java.util.ArrayDeque +import java.util.Locale +import java.util.regex.Pattern + +/** Provides a series of utilities for interacting with AccessibilityNodeInfo objects. */ +object AccessibilityNodeInfoUtils { + + /** Internal AccessibilityNodeInfoCompat extras bundle key constants. */ + // The minimum amount of pixels that must be visible for a view to be surfaced to the user as + // visible (i.e. for this node to be added to the tree). + const val MIN_VISIBLE_PIXELS = 15 + + private val CLASS_LISTVIEW: String = ListView::class.java.name + private val CLASS_GRIDVIEW: String = GridView::class.java.name + + private val actionIdToName: HashMap = initActionIds() + + /** Returns text from an accessibility-node, including spans. */ + @JvmStatic + fun getText(node: AccessibilityNodeInfoCompat?): CharSequence? { + return if (node == null) null else node.text + } + + @FormatMethod + private fun logError(functionName: String, @FormatString format: String, vararg args: Any?) { + LogUtils.e(TAG, functionName + "() " + String.format(format, *args)) + } + + ////////////////////////////////////////////////////////////////////////////////////////// + // Constants + + private const val TAG = "AccessibilityNodeInfoUtils" + + /** + * Class for Samsung's TouchWiz implementation of AdapterView. May be {@code null} on non-Samsung + * devices. + */ + private val CLASS_TOUCHWIZ_TWADAPTERVIEW: Class<*>? = + CompatUtils.getClass("com.sec.android.touchwiz.widget.TwAdapterView") + + /** Key to get accessibility web hints from the web */ + private const val HINT_TEXT_KEY = "AccessibilityNodeInfo.hint" + + private val RESOURCE_NAME_SPLIT_PATTERN: Pattern = Pattern.compile(":id/") + + /** Class used to find clickable-spans in text. */ + @JvmField val BASE_CLICKABLE_SPAN: Class = ClickableSpan::class.java + + private const val VIEW_ID_RESOURCE_NAME_PIN_ENTRY = "com.android.systemui:id/pinEntry" + + @VisibleForTesting + internal const val VIEW_ID_WEAR_UNREAD_NOTIFICATION_DOT = + "com.google.android.wearable.sysui:id/unread_dot" + + @VisibleForTesting internal const val THRESHOLD_HEIGHT_DP_FOR_SMALL_NODE = 32 + + /** Key to get the chrome role from the node */ + private const val EXTRAS_KEY_CHROME_ROLE = "AccessibilityNodeInfo.chromeRole" + + /** + * Chrome role for link. The role string should come from `ToString(ax::mojom::Role role)` at + * ui/accessibility/ax_enum_util.cc in the Chromium repo. + * https://source.chromium.org/chromium/chromium/src/+/main:ui/accessibility/ax_enum_util.cc?q=%22ToString(ax::mojom::Role%20role)%22%20f:ui%2Faccessibility%2Fax_enum_util.cc + */ + private const val CHROME_ROLE_LINK = "link" + + /** + * A wrapper over AccessibilityNodeInfoCompat constructor, so that we can add any desired error + * checking and memory management. + * + * @param nodeInfo The AccessibilityNodeInfo which will be wrapped. + * @return Encapsulating AccessibilityNodeInfoCompat, or null if input is null. + */ + @JvmStatic + fun toCompat(nodeInfo: AccessibilityNodeInfo?): AccessibilityNodeInfoCompat? { + if (nodeInfo == null) { + return null + } + return AccessibilityNodeInfoCompat.wrap(nodeInfo) + } + + private const val SYSTEM_ACTION_MAX = 0x01FFFFFF + + const val WINDOW_TYPE_PICTURE_IN_PICTURE = 1000 + + /** + * Filter for scrollable items. One of the following must be true: + * + *

    + *
  • {@link AccessibilityNodeInfoCompat#isScrollable()} returns {@code true} + *
  • {@link AccessibilityNodeInfoCompat#getActions()} supports {@link + * AccessibilityNodeInfoCompat#ACTION_SCROLL_FORWARD} + *
  • {@link AccessibilityNodeInfoCompat#getActions()} supports {@link + * AccessibilityNodeInfoCompat#ACTION_SCROLL_BACKWARD} + *
+ */ + @JvmField + val FILTER_SCROLLABLE: Filter = + object : Filter() { + override fun accept(node: AccessibilityNodeInfoCompat?): Boolean { + return isScrollable(node) + } + } + + /** Filter for items that could be scrolled forward. */ + @JvmField + val FILTER_COULD_SCROLL_FORWARD: Filter = + object : Filter() { + override fun accept(node: AccessibilityNodeInfoCompat?): Boolean { + return node != null && + supportsAction(node, AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD) + } + } + + /** Filter for items that could be scrolled backward. */ + @JvmField + val FILTER_COULD_SCROLL_BACKWARD: Filter = + object : Filter() { + override fun accept(node: AccessibilityNodeInfoCompat?): Boolean { + return node != null && + supportsAction(node, AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD) + } + } + + /** + * Filter for items that should receive accessibility focus. Equivalent to calling {@link + * #shouldFocusNode(AccessibilityNodeInfoCompat)}. + * + *

Note: Use {@link #FILTER_SHOULD_FOCUS_EXCEPT_WEB_VIEW} has a filter for + * {@link AccessibilityEvent#TYPE_VIEW_HOVER_ENTER} events. + */ + @JvmField + val FILTER_SHOULD_FOCUS: Filter = + object : Filter() { + override fun accept(node: AccessibilityNodeInfoCompat?): Boolean { + return node != null && shouldFocusNode(node) + } + } + + /** + * Filter for items that should receive accessibility focus from {@link + * AccessibilityEvent#TYPE_VIEW_HOVER_ENTER} events. WebView container node should not be focus + * for hover enter actions. + */ + @JvmField + val FILTER_SHOULD_FOCUS_EXCEPT_WEB_VIEW: Filter = + FILTER_SHOULD_FOCUS.and( + object : Filter() { + override fun accept(node: AccessibilityNodeInfoCompat?): Boolean { + return Role.getRole(node) != Role.ROLE_WEB_VIEW + } + }) + + /** Filter for heading items in collections. */ + @JvmField + val FILTER_HEADING: Filter = + object : Filter() { + override fun accept(node: AccessibilityNodeInfoCompat?): Boolean { + return (node != null) && isHeading(node) + } + } + + private val CONTAINER_ROLES: ImmutableSet = + ImmutableSet.of( + ROLE_LIST, + ROLE_GRID, + ROLE_PAGER, + ROLE_SCROLL_VIEW, + ROLE_HORIZONTAL_SCROLL_VIEW, + ROLE_WEB_VIEW) + + /** Filter for container. */ + @JvmField + val FILTER_CONTAINER: Filter = + object : Filter() { + override fun accept(node: AccessibilityNodeInfoCompat?): Boolean { + return node != null && + (CONTAINER_ROLES.contains(Role.getRole(node)) || + !TextUtils.isEmpty(node.containerTitle)) + } + } + + /** + * Filter for focusable containers with a descendant that is an unfocusable heading. This filter + * aids navigation by headings granularity when the node that is semantically a heading isn't + * focusable (for instance, because its text is combined with the text of other nodes to create + * speakable text for a container in a list context). + */ + @JvmField + val FILTER_CONTAINER_WITH_UNFOCUSABLE_HEADING: Filter = + object : Filter() { + override fun accept(node: AccessibilityNodeInfoCompat?): Boolean { + return searchFromBfs( + node, + FILTER_HEADING.and( + object : Filter() { + override fun accept(childNode: AccessibilityNodeInfoCompat): Boolean { + return childNode.childCount == 0 && !shouldFocusNode(childNode) + } + })) != null + } + } + + /** Filter for scrollable grids. */ + @JvmField + val FILTER_SCROLLABLE_GRID: Filter = + FILTER_SCROLLABLE.and(Filter.node { n -> Role.getRole(n) == Role.ROLE_GRID }) + + /** Filter for table. */ + private val FILTER_TABLE: Filter = + Filter.node { node -> isTableRoot(node) } + + /** Filter for table cell. */ + @JvmField + val FILTER_TABLE_CELL: Filter = + Filter.node { node -> isTableCell(node) } + + /** Filter for table cell and check if it is in a table. */ + @JvmField + val FILTER_TABLE_CELL_UNDER_TABLE: Filter = + Filter.node { node -> isTableCellUnderTable(node) } + + /** Filter the node matched the voice dictation definition. */ + @JvmField + val FILTER_VOICE_DICTATION: Filter = + Filter.node { node -> isVoiceDictationNode(node) } + + /** + * Filter that also checks for {@param node}'s non-focusable but visible children. Sometimes, a + * node that passes the filter can be embedded in a parent and might be not focusable by itself. + * In those cases it is important to focus the parent. Example would be for "Control" granularity, + * if a switch is not focusable but is embedded into a focusable parent, its parent should be + * focused. + */ + @JvmStatic + fun getFilterIncludingChildren( + filter: Filter + ): Filter { + return object : Filter() { + override fun accept(node: AccessibilityNodeInfoCompat?): Boolean { + if (node == null) { + return false + } + // If the node does not pass the filter, check its non focusable, visible children. + if (!filter.accept(node)) { + return hasMatchingDescendant(node, filter.and(FILTER_NON_FOCUSABLE_VISIBLE_NODE)) + } + return true + } + } + } + + // TODO: Provides an overall experience of focusing on small nodes on both watch and + // phone devices. + /** Filters out nodes which are small and located on the top and bottom borders. */ + @JvmStatic + fun getFilterExcludingSmallTopAndBottomBorderNode( + context: Context + ): Filter { + // For a watch device, we don't want to put focus on the small border nodes. These nodes + // could be located at the middle of AdapterView and they could be distorted to fit in a + // round screen when they are near top or bottom borders. + val screenPxSize = DisplayUtils.getScreenPixelSizeWithoutWindowDecor(context) + return object : Filter() { + override fun accept(node: AccessibilityNodeInfoCompat): Boolean { + return !AccessibilityNodeInfoUtils.isSmallNodeInHeight(context, node) || + !AccessibilityNodeInfoUtils.isTopOrBottomBorderNode(screenPxSize, node) + } + } + } + + /** Filter to identify nodes which are not focusable but visible. */ + @JvmField + val FILTER_NON_FOCUSABLE_VISIBLE_NODE: Filter = + object : Filter() { + override fun accept(node: AccessibilityNodeInfoCompat?): Boolean { + return isVisible(node) && !isAccessibilityFocusable(node) + } + } + + /** Filter to identify nodes which are not focusable and not visible but has text. */ + @JvmField + val FILTER_NON_FOCUSABLE_NON_VISIBLE_HAS_TEXT_NODE: Filter = + object : Filter() { + override fun accept(node: AccessibilityNodeInfoCompat?): Boolean { + return !isVisible(node) && + !isAccessibilityFocusable(node) && + !TextUtils.isEmpty(AccessibilityNodeInfoUtils.getNodeText(node)) + } + } + + /** Filter for controllable elements. */ + @JvmField + val FILTER_CONTROL: Filter = + object : Filter() { + override fun accept(node: AccessibilityNodeInfoCompat?): Boolean { + if (node == null) { + return false + } + @RoleName val role = Role.getRole(node) + return (role == Role.ROLE_BUTTON) || + (role == Role.ROLE_IMAGE_BUTTON) || + (role == Role.ROLE_EDIT_TEXT) || + (role == Role.ROLE_CHECK_BOX) || + (role == Role.ROLE_RADIO_BUTTON) || + (role == Role.ROLE_TOGGLE_BUTTON) || + (role == Role.ROLE_SWITCH) || + (role == Role.ROLE_DROP_DOWN_LIST) || + (role == Role.ROLE_SEEK_CONTROL) || + (role == Role.ROLE_FLOATING_ACTION_BUTTON) || + (role == Role.ROLE_VOICE_DICTATION_BUTTON) || + // The clickable view in a collection may not be a control, such as each setting item + // in the Settings page. + (!nodeIsListOrGridItem(node) && (isClickable(node) || isLongClickable(node))) + } + } + + /** Filter for Spannables with links. */ + @JvmField + val FILTER_LINK: Filter = + object : Filter() { + override fun accept(node: AccessibilityNodeInfoCompat?): Boolean { + return SpannableTraversalUtils.hasTargetClickableSpanInNodeTree( + node, BASE_CLICKABLE_SPAN) + } + } + + @JvmField + val FILTER_CLICKABLE: Filter = + object : Filter() { + override fun accept(node: AccessibilityNodeInfoCompat?): Boolean { + return AccessibilityNodeInfoUtils.isClickable(node) + } + } + + @JvmStatic + fun getFilterIllegalTitleNodeAncestor( + context: Context + ): Filter { + return object : Filter() { + override fun accept(node: AccessibilityNodeInfoCompat?): Boolean { + if (isClickable(node) || isLongClickable(node)) { + return true + } + + if (FeatureSupport.isWatch(context)) { + // A window title node can be a descendant of AdapterView in a watch device since the + // title node may be the first node in a AdapterView. + return false + } else { + @RoleName val role = Role.getRole(node) + // A window title node should not be a descendant of AdapterView. + return (role == Role.ROLE_LIST) || (role == Role.ROLE_GRID) + } + } + } + } + + /** + * Filter that defines which types of views should be auto-scrolled. Generally speaking, only + * accepts views that are capable of showing partially-visible data. + * + *

Accepts the following classes (and sub-classes thereof): + * + *

    + *
  • {@link androidx.recyclerview.widget.RecyclerView} (Should be classified as a List or Grid.) + *
  • {@link android.widget.AbsListView} (including both ListView and GridView) + *
  • {@link android.widget.AbsSpinner} + *
  • {@link android.widget.ScrollView} + *
  • {@link android.widget.HorizontalScrollView} + *
  • {@code com.sec.android.touchwiz.widget.TwAbsListView} + *
+ * + *

Specifically excludes {@link android.widget.AdapterViewAnimator} and sub-classes, since they + * represent overlapping views. Also excludes {@link androidx.viewpager.widget.ViewPager} since it + * exclusively represents off-screen views. + */ + @JvmField + val FILTER_AUTO_SCROLL: Filter = + object : Filter() { + override fun accept(node: AccessibilityNodeInfoCompat?): Boolean { + if (!isScrollable(node) || !isVisible(node)) { + return false + } + @Role.RoleName val role = Role.getRole(node) + // TODO: Check if we should include ROLE_ADAPTER_VIEW as a target Role. + return role == Role.ROLE_DROP_DOWN_LIST || + role == Role.ROLE_LIST || + role == Role.ROLE_GRID || + role == Role.ROLE_SCROLL_VIEW || + role == Role.ROLE_HORIZONTAL_SCROLL_VIEW || + AccessibilityNodeInfoUtils.nodeMatchesAnyClassByType( + node, CLASS_TOUCHWIZ_TWADAPTERVIEW) + } + } + + @JvmField + val FILTER_COLLECTION: Filter = + Filter.node { node -> + val role = Role.getRole(node) + (role == Role.ROLE_LIST) || + (role == Role.ROLE_GRID) || + (role == Role.ROLE_PAGER) || + (node != null && node.collectionInfo != null) + } + + @JvmField + val FILTER_COLLECTION_ITEM: Filter = + Filter.node { node -> node != null && node.collectionItemInfo != null } + + // This class is not instantiable. + + /** + * Gets the text of a node by returning the content description (if available) or by + * returning the text. + * + * @param node The node. + * @return The node text. + */ + @JvmStatic + fun getNodeText(node: AccessibilityNodeInfoCompat?): CharSequence? { + if (node == null) { + return null + } + + // Prefer content description over text. + // TODO: Why are we checking the trimmed length? + val contentDescription = node.contentDescription + if (!TextUtils.isEmpty(contentDescription) && + (TextUtils.getTrimmedLength(contentDescription) > 0)) { + return contentDescription + } + + val text = AccessibilityNodeInfoUtils.getText(node) + if (!TextUtils.isEmpty(text) && (TextUtils.getTrimmedLength(text) > 0)) { + return text + } + + return null + } + + /** + * Gets the state description of a node. + * + * @param node The node. + * @return The node state description. + */ + @JvmStatic + fun getState(node: AccessibilityNodeInfoCompat?): CharSequence? { + if (node == null) { + return null + } + + val state = node.stateDescription + if (!TextUtils.isEmpty(state) && (TextUtils.getTrimmedLength(state) > 0)) { + return state + } + + return null + } + + /** + * Gets the Selected text of a node by returning the selected text. + * + * @param node The node. + * @return The selected node text. + */ + @JvmStatic + fun getSelectedNodeText(node: AccessibilityNodeInfoCompat?): CharSequence? { + if (node == null) { + return null + } + + val selectedText = + subsequenceSafe( + AccessibilityNodeInfoUtils.getText(node), + node.textSelectionStart, + node.textSelectionEnd) + if (!TextUtils.isEmpty(selectedText) && (TextUtils.getTrimmedLength(selectedText) > 0)) { + return selectedText + } + + return null + } + + /** Returns a sub-string or empty-string, without crashing on invalid subsequence range. */ + @JvmStatic + fun subsequenceSafe(text: CharSequence?, startIndex: Int, endIndex: Int): CharSequence { + if (text == null) { + return "" + } + var startIndexVar = startIndex + var endIndexVar = endIndex + // Swap start and end. + if (endIndexVar < startIndexVar) { + val newStartIndex = endIndexVar + endIndexVar = startIndexVar + startIndexVar = newStartIndex + } + // Enforce string bounds. + if (startIndexVar < 0) { + startIndexVar = 0 + } else if (startIndexVar > text.length) { + startIndexVar = text.length + } + if (endIndexVar < 0) { + endIndexVar = 0 + } else if (endIndexVar > text.length) { + endIndexVar = text.length + } + + return text.subSequence(startIndexVar, endIndexVar) + } + + /** + * Gets the text selection indexes safe by adjusting the checking the selection bounds. + * + * @param node The node + * @return the selection indexes + */ + @JvmStatic + fun getSelectionIndexesSafe(node: AccessibilityNodeInfoCompat): Pair { + var selectionStart = node.textSelectionStart + var selectionEnd = node.textSelectionEnd + if (selectionStart < 0) { + selectionStart = 0 + } + if (selectionEnd < 0) { + selectionEnd = selectionStart + } + if (selectionEnd < selectionStart) { + // Swap start and end to make sure they are in order. + val newStart = selectionEnd + selectionEnd = selectionStart + selectionStart = newStart + } + return Pair.create(selectionStart, selectionEnd) + } + + /** + * Gets the textual representation of the view ID that can be used when no custom label is + * available. For better readability/listenability, the "_" characters are replaced with spaces. + * + * @param node The node + * @return Readable text of the view Id + */ + @JvmStatic + fun getViewIdText(node: AccessibilityNodeInfoCompat?): String? { + if (node == null) { + return null + } + + val resourceName = node.viewIdResourceName ?: return null + + val parsedResourceName = RESOURCE_NAME_SPLIT_PATTERN.split(resourceName, 2) + if (parsedResourceName.size != 2 || + TextUtils.isEmpty(parsedResourceName[0]) || + TextUtils.isEmpty(parsedResourceName[1])) { + return null + } + + return parsedResourceName[1].replace('_', ' ') // readable View ID text + } + + @JvmStatic + fun isPage(node: AccessibilityNodeInfoCompat?): Boolean { + val parent = if (node == null) null else node.parent + return (parent != null) && (Role.getRole(parent) == Role.ROLE_PAGER) + } + + @JvmStatic + fun getSelectedPageTitle(viewPager: AccessibilityNodeInfoCompat?): CharSequence? { + if ((viewPager == null) || (Role.getRole(viewPager) != Role.ROLE_PAGER)) { + return null + } + + val numChildren = viewPager.childCount // Not the number of pages! + var title: CharSequence? = null + for (i in 0 until numChildren) { + val child = viewPager.getChild(i) + if (child != null && child.isVisibleToUser) { + if (title == null) { + // Try to roughly match RulePagerPage, which uses getNodeText + // (but completely matching all the time is not critical). + title = getNodeText(child) + } else { + // Multiple visible children, abort. + return null + } + } + } + + return title + } + + @JvmStatic + fun getCustomActions(node: AccessibilityNodeInfoCompat): List { + val customActions = ArrayList() + for (action in node.actionList) { + if (isCustomAction(action)) { + // We don't use custom actions that doesn't have a label + if (!TextUtils.isEmpty(action.label)) { + customActions.add(action) + } + } + } + + return customActions + } + + @JvmStatic + fun isCustomAction(action: AccessibilityActionCompat): Boolean { + return action.id > SYSTEM_ACTION_MAX + } + + /** Returns the root node of the tree containing {@code node}. */ + @JvmStatic + fun getRoot(node: AccessibilityNodeInfoCompat?): AccessibilityNodeInfoCompat? { + if (node == null) { + return null + } + + val window = getWindow(node) + if (window != null) { + return AccessibilityWindowInfoUtils.getRoot(window) + } + + val visitedNodes = HashSet() + var current: AccessibilityNodeInfoCompat? = null + var parent: AccessibilityNodeInfoCompat? = node + + do { + if (current != null) { + if (visitedNodes.contains(current)) { + return null + } + visitedNodes.add(current) + } + + current = parent + parent = current!!.parent + } while (parent != null) + + return current + } + + /** + * Returns the node of the tree at {@code targetDepth} from the root of the tree containing {@code + * nodeCompat} with the root node considered as depth 0. This returns the last node available if + * the target depth is greater than the number of ancestors. + */ + @JvmStatic + fun getNthAncestorFromRoot( + nodeCompat: AccessibilityNodeInfoCompat?, targetDepth: Int + ): AccessibilityNodeInfoCompat? { + if (nodeCompat == null || targetDepth <= 0) { + return null + } + + var targetDepthVar = targetDepth + val visitedNodes = ArrayList() + var current: AccessibilityNodeInfoCompat? = nodeCompat + + do { + if (visitedNodes.contains(current)) { + break + } + + visitedNodes.add(current!!) + current = current.parent + } while (current != null) + + if (targetDepthVar >= visitedNodes.size) { + targetDepthVar = visitedNodes.size - 1 + } + + val nodeIndex = visitedNodes.size - 1 - targetDepthVar + return visitedNodes[nodeIndex] + } + + /** Returns the type of the window containing {@code nodeCompat}. */ + @JvmStatic + fun getWindowType(nodeCompat: AccessibilityNodeInfoCompat?): Int { + if (nodeCompat == null) { + return WINDOW_TYPE_NONE + } + + val windowInfoCompat = getWindow(nodeCompat) ?: return WINDOW_TYPE_NONE + + if (isPictureInPicture(nodeCompat)) { + return WINDOW_TYPE_PICTURE_IN_PICTURE + } + + return windowInfoCompat.type + } + + /** Wrapper for AccessibilityNodeInfoCompat.getWindow() that handles SecurityException. */ + @JvmStatic + fun getWindow(node: AccessibilityNodeInfoCompat?): AccessibilityWindowInfoCompat? { + // This implementation is redundant with getWindow(AccessibilityNodeInfo) because there are no + // un/wrap() functions for AccessibilityWindowInfoCompat. + + if (node == null) { + return null + } + + try { + return node.window + } catch (e: SecurityException) { + LogUtils.e(TAG, "SecurityException in AccessibilityWindowInfoCompat.getWindow()") + return null + } + } + + @JvmStatic + fun getWindow(node: AccessibilityNodeInfo?): AccessibilityWindowInfo? { + if (node == null) { + return null + } + + try { + return node.window + } catch (e: SecurityException) { + LogUtils.e(TAG, "SecurityException in AccessibilityWindowInfo.getWindow()") + return null + } + } + + /** + * Returns whether a node can receive focus from focus traversal or touch exploration. One of the + * following must be true: + * + *

    + *
  • The node is actionable (see {@link #isFocusableOrClickable(AccessibilityNodeInfoCompat)}) + *
  • The node is a top-level list item (see {@link + * #isTopLevelScrollItem(AccessibilityNodeInfoCompat)} and is a speaking node + *
+ * + * @param node The node to check. + * @return {@code true} of the node is accessibility focusable. + */ + @JvmStatic + fun isAccessibilityFocusable(node: AccessibilityNodeInfoCompat?): Boolean { + return isFocusableOrClickable(node) || + (isTopLevelScrollItem(node) && isSpeakingNode(node!!, null, HashSet())) + } + + /** + * Returns whether a node should receive accessibility focus from navigation. This method should + * never be called recursively, since it traverses up the parent hierarchy on every call. + * + * @see #findFocusFromHover(AccessibilityNodeInfoCompat) for touch exploration + * @see + * com.google.android.accessibility.talkback.focusmanagement.NavigationTarget#createNodeFilter(int, + * Map) for linear navigation + */ + @JvmStatic + fun shouldFocusNode(node: AccessibilityNodeInfoCompat?): Boolean { + return shouldFocusNode(node, null, true) + } + + @JvmStatic + fun shouldFocusNode( + node: AccessibilityNodeInfoCompat?, + speakingNodesCache: MutableMap? + ): Boolean { + return shouldFocusNode(node, speakingNodesCache, true) + } + + @JvmStatic + fun shouldFocusNode( + node: AccessibilityNodeInfoCompat?, + speakingNodesCache: MutableMap?, + checkChildren: Boolean + ): Boolean { + if (node == null) { + LogUtils.v(TAG, "Don't focus, node=null") + return false + } + // Inside views that support web navigation, we delegate focus to the view itself and + // assume that it navigates to and focuses the correct elements. + if (WebInterfaceUtils.supportsWebActions(node)) { + // In history, we loosen the "visibility" check for web element: A web node can be focused + // even if it's not visibleToUser(). However we should hold the baseline that if the WebView + // container is not visible, we should not focus on its descendants. + val webViewContainer = WebInterfaceUtils.ascendToWebViewContainer(node) + return webViewContainer != null && webViewContainer.isVisibleToUser + } + + if (!isVisible(node)) { + logShouldFocusNode( + checkChildren, FOCUS_FAIL_NOT_VISIBLE, "Don't focus, is not visible: ", node) + return false + } + + if (isPictureInPicture(node)) { + // For picture-in-picture, allow focusing the root node, and any app controls inside the + // pic-in-pic window. + return true + } else { + // Reject all non-leaf nodes that are neither actionable nor focusable, and have the same + // bounds as the window. + if (areBoundsIdenticalToWindow(node) && + node.childCount > 0 && + !isFocusableOrClickable(node)) { + logShouldFocusNode( + checkChildren, + FOCUS_FAIL_SAME_WINDOW_BOUNDS_CHILDREN, + "Don't focus, bounds are same as window root node bounds, node has children and" + + " is neither actionable nor focusable: ", + node) + return false + } + } + + val visitedNodes = HashSet() + // This checks if a node is clickable, focusable, screen reader focusable, or a direct + // spekaing child of a scrollable container. + val accessibilityFocusable = + isFocusableOrClickable(node) || + (isTopLevelScrollItem(node) && isSpeakingNode(node, null, visitedNodes)) + + if (!checkChildren) { + // End of the line. Don't check children and don't allow any recursion. + // checkChildren is only false in the shouldFocusNode call below. This is to avoid + // repetitive checks down the tree when looking up at the ancestors. + LogUtils.d( + TAG, "checkChildren=false and isAccessibilityFocusable=%s", accessibilityFocusable) + return accessibilityFocusable + } + + // A node that is deemed accessibility focusable shouldn't actually get focus if it has + // nothing to speak. For example, a view may be focusable, but if it has no text and all of + // its children are clickable, focus should go on each child individually and not on this + // view. + // Note: This is redundant for nodes that pass isSpeakingNode above + // Note: A special case exists for unlabeled buttons which otherwise wouldn't get focus. + if (accessibilityFocusable) { + visitedNodes.clear() + // For TalkBack labeling feature, but this may still result in focusing non-speaking nodes. + // We should try to narrow down the check to close to TalkBackLabelManager#needsLabel. + if (node.childCount == 0) { + logShouldFocusNode( + checkChildren, NONE, "Focus, is focusable and cannot keep search children: ", node) + return true + } else if (isSpeakingNode(node, speakingNodesCache, visitedNodes)) { + logShouldFocusNode( + checkChildren, NONE, "Focus, is focusable and has something to speak: ", node) + return true + } else { + logShouldFocusNode( + checkChildren, + FOCUS_FAIL_NOT_SPEAKABLE, + "Don't focus, is focusable but has nothing to speak: ", + node) + return false + } + } + + // At this point, the node is an unfocusable target. + // If it has no focusable ancestors, but it still has text, then it should receive focus and be + // read aloud. + val filter = + object : Filter() { + override fun accept(node: AccessibilityNodeInfoCompat?): Boolean { + return shouldFocusNode(node, speakingNodesCache, false) + } + } + + if (!hasMatchingAncestor(node, filter) && (hasText(node) || hasStateDescription(node))) { + logShouldFocusNode(checkChildren, NONE, "Focus, has text and no focusable ancestors: ", node) + return true + } + + logShouldFocusNode( + checkChildren, + FOCUS_FAIL_FAIL_ALL_FOCUS_TESTS, + "Don't focus, failed all focusability tests: ", + node) + return false + } + + private fun logShouldFocusNode( + checkChildren: Boolean, + @DiagnosticType diagnosticType: Int?, + message: String, + node: AccessibilityNodeInfoCompat + ) { + // When shouldFocusNode calls itself, the logs get inundated by unnecessary info about the + // ancestors. So only log when checkChildren is true. + if (checkChildren) { + if (diagnosticType != NONE) { + DiagnosticOverlayUtils.appendLog(diagnosticType, node) + } + // Show debug logs for #shouldFocusNode. Verbose logs will show for #isSpeakingNode + LogUtils.v(TAG, "%s %s", message, node) + } + } + + @JvmStatic + fun isPictureInPicture(node: AccessibilityNodeInfoCompat): Boolean { + return isPictureInPicture(node.unwrap()) + } + + @JvmStatic + fun isPictureInPicture(node: AccessibilityNodeInfo?): Boolean { + return node != null && AccessibilityWindowInfoUtils.isPictureInPicture(getWindow(node)) + } + + /** + * Returns the node that should receive focus from hover by starting from the touched node and + * calling {@link #shouldFocusNode} at each level of the view hierarchy and exclude WebView + * container node. + */ + @JvmStatic + fun findFocusFromHover( + touched: AccessibilityNodeInfoCompat? + ): AccessibilityNodeInfoCompat? { + return AccessibilityNodeInfoUtils.getSelfOrMatchingAncestor( + touched, FILTER_SHOULD_FOCUS_EXCEPT_WEB_VIEW) + } + + /** + * Returns whether a node can be spoken. + * + *

A node should be spoken if it has text, is checkable, or has children that should be spoken + * but can't be focused themselves. This method can call itself recursively through {@link + * #hasNonActionableSpeakingChildren}. + * + *

Note: This is called in the context of looking for a a11y focusable node through {@link + * #shouldFocusNode} and {@link #isAccessibilityFocusable} + * + * @param node the node to check + * @param speakingNodesCache the cache that holds the speaking results for visited nodes + * @param visitedNodes the set of nodes that have already been visited + * @return {@code true} if the node can be spoken + */ + private fun isSpeakingNode( + node: AccessibilityNodeInfoCompat, + speakingNodesCache: MutableMap?, + visitedNodes: MutableSet + ): Boolean { + if (speakingNodesCache != null && speakingNodesCache.containsKey(node)) { + return speakingNodesCache[node]!! + } + + var result = false + if (hasText(node)) { + LogUtils.v(TAG, "Speaking, has text") + result = true + } else if (hasStateDescription(node)) { + LogUtils.v(TAG, "Speaking, has state description") + result = true + } else if (node.isCheckable) { // Special case for check boxes. + LogUtils.v(TAG, "Speaking, is checkable") + result = true + } else if (hasNonActionableSpeakingChildren(node, speakingNodesCache, visitedNodes)) { + // Special case for containers with non-focusable content. In this case, the container should + // speak its non-focusable yet speakable content. + LogUtils.v(TAG, "Speaking, has non-actionable speaking children") + result = true + } + + if (speakingNodesCache != null) { + speakingNodesCache[node] = result + } + + return result + } + + /** + * Returns whether a node has children that are not actionable/focusable but should be spoken. + * + *

This is done by ignoring any children nodes that are actionable/focusable, and checking the + * remaining for speaking ability. Also considers offscreen/invisible children which are + * non-actionable but which have speakable text. + * + * @param node the node to check + * @param speakingNodesCache the cache that holds the speaking results for visited nodes + * @param visitedNodes the set of nodes that have already been visited. + * @return {@code true} if the node has children that are speaking + */ + private fun hasNonActionableSpeakingChildren( + node: AccessibilityNodeInfoCompat, + speakingNodesCache: MutableMap?, + visitedNodes: MutableSet + ): Boolean { + val childCount = node.childCount + + for (i in 0 until childCount) { + val child = node.getChild(i) + + if (child == null) { + LogUtils.v(TAG, "Child %d is null, skipping it", i) + continue + } + + if (!visitedNodes.add(child)) { + return false + } + + // Ignore invisible nodes. + if (!isVisible(child)) { + LogUtils.v(TAG, "Child %d, %s is invisible, skipping it", i, printId(node)) + continue + } + + // Ignore focusable nodes + if (isFocusableOrClickable(child)) { + LogUtils.v(TAG, "Child %d, %s is focusable or clickable, skipping it", i, printId(node)) + continue + } + + // Ignore top level scroll items that 1) are speaking and 2) have non-clickable parents. This + // means that a scrollable container that is clickable should get focus before its children. + if ((isTopLevelScrollItem(child) && isSpeakingNode(child, speakingNodesCache, visitedNodes)) && + !(isClickable(node) || isLongClickable(node))) { + + LogUtils.v(TAG, "Child %d, %s is a top level scroll item, skipping it", i, printId(node)) + continue + } + + // Recursively check non-focusable child nodes. + if (isSpeakingNode(child, speakingNodesCache, visitedNodes)) { + LogUtils.v(TAG, "Does have actionable speaking children (child %d, %s)", i, printId(node)) + return true + } + } + + LogUtils.v(TAG, "Does not have non-actionable speaking children. Examining invisible children") + return hasInvisibleNonActionableSpeakingChildren(node, childCount) + } + + private fun hasInvisibleNonActionableSpeakingChildren( + node: AccessibilityNodeInfoCompat, childCount: Int + ): Boolean { + // We don't want the presence of invisible children to lead to focus being set on a scrollable + // parent that is capable of showing partially-visible data. + if (FILTER_AUTO_SCROLL.accept(node)) { + return false + } + + // We look at invisible children and return true if an invisible child is non-actionable and + // has associated text. Without this check, a parent would be considered unfocusable, and this + // would cause ACTION_SHOW_ON_SCREEN to fail when the non-actionable/speakable child nodes of + // a container are offscreen. + for (i in 0 until childCount) { + val child = node.getChild(i) + + if (child == null) { + LogUtils.v(TAG, "Child %d is null, skipping it", i) + continue + } + + if (!child.isVisibleToUser && + hasText(child) && + !(child.isScreenReaderFocusable || isActionableForAccessibility(child))) { + LogUtils.v( + TAG, "Non-actionable invisible node with text found (child %d, %s)", i, printId(node)) + return true + } + } + return false + } + + @JvmStatic + fun countVisibleChildren(node: AccessibilityNodeInfoCompat?): Int { + if (node == null) { + return 0 + } + val childCount = node.childCount + var childVisibleCount = 0 + for (i in 0 until childCount) { + val child = node.getChild(i) + if (child != null && child.isVisibleToUser) { + ++childVisibleCount + } + } + return childVisibleCount + } + + /** + * Returns whether a node is actionable. That is, the node supports one of the following actions: + * + *

    + *
  • {@link AccessibilityNodeInfoCompat#isClickable()} + *
  • {@link AccessibilityNodeInfoCompat#isFocusable()} + *
  • {@link AccessibilityNodeInfoCompat#isLongClickable()} + *
+ * + * This parities the system method View#isActionableForAccessibility(), which was added in + * JellyBean. + * + * @param node The node to examine. + * @return {@code true} if node is actionable. + */ + @JvmStatic + fun isActionableForAccessibility(node: AccessibilityNodeInfoCompat?): Boolean { + if (node == null) { + return false + } + + // Nodes that are clickable are always actionable. + if (isClickable(node) || isLongClickable(node)) { + return true + } + + if (node.isFocusable) { + return true + } + + if (WebInterfaceUtils.hasNativeWebContent(node)) { + return supportsAnyAction(node, AccessibilityNodeInfoCompat.ACTION_FOCUS) + } + + return supportsAnyAction( + node, + AccessibilityNodeInfoCompat.ACTION_FOCUS, + AccessibilityNodeInfoCompat.ACTION_NEXT_HTML_ELEMENT, + AccessibilityNodeInfoCompat.ACTION_PREVIOUS_HTML_ELEMENT) + } + + @JvmStatic + fun isSelfOrAncestorFocused(node: AccessibilityNodeInfoCompat?): Boolean { + return node != null && + (node.isAccessibilityFocused || + hasMatchingAncestor( + node, + object : Filter() { + override fun accept(node: AccessibilityNodeInfoCompat?): Boolean { + return (node != null) && node.isAccessibilityFocused + } + })) + } + + /** Returns whether {@code node} is editable or has an ancestor that is editable. */ + @JvmStatic + fun isSelfOrAncestorEditable(node: AccessibilityNodeInfoCompat?): Boolean { + return getSelfOrMatchingAncestor(node, Filter.node { n -> n.isEditable }) != null + } + + @JvmStatic + fun isSelfOrAncestorRoleEditText(node: AccessibilityNodeInfoCompat?): Boolean { + return isSelfOrAncestorWithRole(node, Role.ROLE_EDIT_TEXT) + } + + @JvmStatic + fun isSelfOrAncestorRoleWebView(node: AccessibilityNodeInfoCompat?): Boolean { + return isSelfOrAncestorWithRole(node, Role.ROLE_WEB_VIEW) + } + + private fun isSelfOrAncestorWithRole(node: AccessibilityNodeInfoCompat?, role: Int): Boolean { + return getSelfOrMatchingAncestor(node, Filter.node { n -> Role.getRole(n) == role }) != null + } + + /** Returns whether {@code node} or its ancestor has the given {@code chromeRole}. */ + @JvmStatic + fun isSelfOrAncestorWithChromeRole( + node: AccessibilityNodeInfoCompat?, chromeRole: String + ): Boolean { + return getSelfOrMatchingAncestor( + node, Filter.node { n -> TextUtils.equals(getChromeRole(n), chromeRole) }) != null + } + + /** Returns whether {@code node} has the chrome role "link". */ + @JvmStatic + fun isChromeRoleLink(node: AccessibilityNodeInfoCompat?): Boolean { + return node != null && TextUtils.equals(getChromeRole(node), CHROME_ROLE_LINK) + } + + private fun getChromeRole(node: AccessibilityNodeInfoCompat?): CharSequence? { + if (node == null) { + return "" + } + val info = node.unwrap() ?: return "" + return info.extras.getCharSequence(EXTRAS_KEY_CHROME_ROLE) + } + + /** + * Returns whether {@code node} is interactable with arrow keys. That is, the node supports at + * least one of the following: + * + *
    + *
  • {@link Role.ROLE_SEEK_CONTROL} + *
+ * + * @return {@code true} if node is self interactable with arrow keys. + */ + @JvmStatic + fun isInteractableWithArrowKeys(node: AccessibilityNodeInfoCompat?): Boolean { + return Role.getRole(node) == Role.ROLE_SEEK_CONTROL + } + + /** + * Returns whether a node is clickable. That is, the node supports at least one of the following: + * + *
    + *
  • {@link AccessibilityNodeInfoCompat#isClickable()} + *
  • {@link AccessibilityNodeInfoCompat#ACTION_CLICK} + *
+ * + * @param node The node to examine. + * @return {@code true} if node is clickable. + */ + @JvmStatic + fun isClickable(node: AccessibilityNodeInfoCompat?): Boolean { + return node != null && + (node.isClickable || + supportsAnyAction(node, AccessibilityNodeInfoCompat.ACTION_CLICK)) + } + + /** + * Returns whether a node is long clickable. That is, the node supports at least one of the + * following: + * + *
    + *
  • {@link AccessibilityNodeInfoCompat#isLongClickable()} + *
  • {@link AccessibilityNodeInfoCompat#ACTION_LONG_CLICK} + *
+ * + * @param node The node to examine. + * @return {@code true} if node is long clickable. + */ + @JvmStatic + fun isLongClickable(node: AccessibilityNodeInfoCompat?): Boolean { + return node != null && + (node.isLongClickable || + supportsAnyAction(node, AccessibilityNodeInfoCompat.ACTION_LONG_CLICK)) + } + + /** + * Returns whether the node is focusable. That is, the node supports at least one of the + * following: + * + *
    + *
  • {@link AccessibilityNodeInfoCompat#isFocusable()} + *
  • {@link AccessibilityNodeInfoCompat#ACTION_FOCUS} + *
+ */ + @JvmStatic + fun isFocusable(node: AccessibilityNodeInfoCompat?): Boolean { + return node != null && + (node.isFocusable || + supportsAnyAction(node, AccessibilityNodeInfoCompat.ACTION_FOCUS)) + } + + /** + * Returns whether a node is expandable. That is, the node supports the following action: + * + *
    + *
  • {@link AccessibilityNodeInfoCompat#ACTION_EXPAND} + *
+ * + * @param node The node to examine. + * @return {@code true} if node is expandable. + */ + @JvmStatic + fun isExpandable(node: AccessibilityNodeInfoCompat?): Boolean { + return supportsAnyAction(node, AccessibilityNodeInfoCompat.ACTION_EXPAND) + } + + /** + * Returns whether a node is collapsible. That is, the node supports the following action: + * + *
    + *
  • {@link AccessibilityNodeInfoCompat#ACTION_COLLAPSE} + *
+ * + * @param node The node to examine. + * @return {@code true} if node is collapsible. + */ + @JvmStatic + fun isCollapsible(node: AccessibilityNodeInfoCompat?): Boolean { + return supportsAnyAction(node, AccessibilityNodeInfoCompat.ACTION_COLLAPSE) + } + + /** + * Returns whether a node can be dismissed by the user. the node supports the following action: + * + *
    + *
  • {@link AccessibilityNodeInfoCompat#ACTION_DISMISS} + *
+ * + * @param node The node to examine. + * @return {@code true} if node is dismissible. + */ + @JvmStatic + fun isDismissible(node: AccessibilityNodeInfoCompat?): Boolean { + return supportsAnyAction(node, AccessibilityNodeInfoCompat.ACTION_DISMISS) + } + + /** Returns {@code true} if the node is on keyboard. */ + @JvmStatic + fun isKeyboard(source: AccessibilityNodeInfo?): Boolean { + return isKeyboard(AccessibilityNodeInfoUtils.toCompat(source)) + } + + /** Returns {@code true} if the node is on keyboard. */ + @JvmStatic + fun isKeyboard(source: AccessibilityNodeInfoCompat?): Boolean { + if (source == null) { + return false + } + val window = getWindow(source) ?: return false + return AccessibilityWindowInfoUtils.isImeWindow(window) + } + + /** + * Check whether a given node has a matching ancestor given a filter. + * + * @param node The node to examine. + * @param filter The filter to match the nodes against. + * @return {@code true} if one of the node's ancestors is matching the filter. + */ + @JvmStatic + fun hasMatchingAncestor( + node: AccessibilityNodeInfoCompat?, + filter: Filter + ): Boolean { + return (node != null) && (getMatchingAncestor(node, filter) != null) + } + + // TODO: Discuss with framework owner to make unread notification context available + // to the app side. + /** + * Checks whether the node is the unread notification dot on the wearable sysUI. + * + * @param node the node to check + * @return {@code true} if the node is the unread notification dot on the wearable sysUI. + */ + @JvmStatic + fun isWearUnreadNotificationDot(node: AccessibilityNodeInfoCompat?): Boolean { + return (node != null) && + TextUtils.equals(node.viewIdResourceName, VIEW_ID_WEAR_UNREAD_NOTIFICATION_DOT) + } + + /** Returns whether the node is the Pin edit field at unlock screen. */ + @JvmStatic + fun isPinEntry(node: AccessibilityNodeInfo?): Boolean { + return isPinEntry(AccessibilityNodeInfoUtils.toCompat(node)) + } + + @JvmStatic + fun isPinEntry(node: AccessibilityNodeInfoCompat?): Boolean { + return (node != null) && + TextUtils.equals(node.viewIdResourceName, VIEW_ID_RESOURCE_NAME_PIN_ENTRY) + } + + /** + * Check whether a given node or any of its ancestors matches the given filter. + * + * @param node The node to examine. + * @param filter The filter to match the nodes against. + * @return {@code true} if the node or one of its ancestors matches the filter. + */ + @JvmStatic + fun isOrHasMatchingAncestor( + node: AccessibilityNodeInfoCompat?, + filter: Filter + ): Boolean { + return (node != null) && (getSelfOrMatchingAncestor(node, filter) != null) + } + + /** Check whether a given node has any descendant matching a given filter. */ + @JvmStatic + fun hasMatchingDescendant( + node: AccessibilityNodeInfoCompat?, + filter: Filter + ): Boolean { + return (node != null) && (getMatchingDescendant(node, filter) != null) + } + + /** Checks whether a given node or any of its descendants matches the given filter. */ + @JvmStatic + fun isOrHasMatchingDescendant( + node: AccessibilityNodeInfoCompat?, + filter: Filter + ): Boolean { + return (node != null) && (getSelfOrMatchingDescendant(node, filter) != null) + } + + /** Returns depth of node in node-tree, where root has depth=0. */ + @JvmStatic + fun findDepth(node: AccessibilityNodeInfoCompat?): Int { + if (node == null) { + return -1 + } + val counter = NodeCounter() + processSelfAndAncestors(node, counter) + return counter.count - 1 + } + + private class NodeCounter : Filter() { + var count = 0 + + override fun accept(node: AccessibilityNodeInfoCompat?): Boolean { + ++count + return false + } + } + + /** Applies filter to ancestor nodes. */ + @JvmStatic + fun processSelfAndAncestors( + node: AccessibilityNodeInfoCompat?, + filter: Filter + ) { + if (node != null) { + isOrHasMatchingAncestor(node, filter) + } + } + + /** + * Returns the {@code node} if it matches the {@code filter}, or the first matching ancestor. + * Returns {@code null} if no nodes match. + */ + @JvmStatic + fun getSelfOrMatchingAncestor( + node: AccessibilityNodeInfoCompat?, + filter: Filter + ): AccessibilityNodeInfoCompat? { + if (node == null) { + return null + } + if (filter.accept(node)) { + return node + } + + return getMatchingAncestor(node, filter) + } + + /** + * Returns the {@code node} if it matches the {@code filter}, or the first matching ancestor, + * ending the ancestor search once it reaches {@code end}. The search is inclusive of {@code node} + * but exclusive of {@code end}. If {@code node} equals {@code end}, then {@code node} is an + * eligible match. Returns {@code null} if no nodes match. + */ + @JvmStatic + fun getSelfOrMatchingAncestor( + node: AccessibilityNodeInfoCompat?, + end: AccessibilityNodeInfoCompat?, + filter: Filter + ): AccessibilityNodeInfoCompat? { + if (node == null) { + return null + } + if (filter.accept(node)) { + return node + } + return getMatchingAncestor(node, end, filter) + } + + /** + * Returns the {@code node} if it matches the {@code filter}, or the first matching descendant. + * Returns {@code null} if no nodes match. + */ + @JvmStatic + fun getSelfOrMatchingDescendant( + node: AccessibilityNodeInfoCompat?, + filter: Filter + ): AccessibilityNodeInfoCompat? { + if (node == null) { + return null + } + if (filter.accept(node)) { + return node + } + return getMatchingDescendant(node, filter) + } + + /** Processes subtree of root by {@code filter}. */ + @JvmStatic + fun processSubtree( + root: AccessibilityNodeInfoCompat?, + filter: Filter + ) { + + AccessibilityNodeInfoUtils.getSelfOrMatchingDescendant( + root, + Filter.node { node -> + filter.accept(node) + false // Force search to traverse whole subtree. + }) + } + + /** + * Determines whether the two nodes are in the same branch; that is, they are equal or one is the + * ancestor of the other. + */ + @JvmStatic + fun areInSameBranch( + node1: AccessibilityNodeInfoCompat?, + node2: AccessibilityNodeInfoCompat? + ): Boolean { + if (node1 != null && node2 != null) { + // Same node? + if (node1 == node2) { + return true + } + + // Is node1 an ancestor of node2? + val matchNode1 = + object : Filter() { + override fun accept(node: AccessibilityNodeInfoCompat?): Boolean { + return node != null && node == node1 + } + } + if (AccessibilityNodeInfoUtils.hasMatchingAncestor(node2, matchNode1)) { + return true + } + + // Is node2 an ancestor of node1? + val matchNode2 = + object : Filter() { + override fun accept(node: AccessibilityNodeInfoCompat?): Boolean { + return node != null && node == node2 + } + } + if (AccessibilityNodeInfoUtils.hasMatchingAncestor(node1, matchNode2)) { + return true + } + } + + return false + } + + /** + * Returns the first ancestor of {@code node} that matches the {@code filter}. Returns {@code + * null} if no nodes match. + */ + @JvmStatic + fun getMatchingAncestor( + node: AccessibilityNodeInfoCompat?, + filter: Filter + ): AccessibilityNodeInfoCompat? { + return getMatchingAncestor(node, null, filter) + } + + /** + * Returns the first ancestor of {@code node} that matches the {@code filter}, terminating the + * search once it reaches {@code end}. The search is exclusive of both {@code node} and {@code + * end}. Returns {@code null} if no nodes match. + */ + private fun getMatchingAncestor( + node: AccessibilityNodeInfoCompat?, + end: AccessibilityNodeInfoCompat?, + filter: Filter + ): AccessibilityNodeInfoCompat? { + if (node == null) { + return null + } + + val ancestors = HashSet() + + ancestors.add(node) + var current: AccessibilityNodeInfoCompat? = node.parent + + while (current != null) { + if (!ancestors.add(current)) { + // Already seen this node, so abort! + return null + } + + if (end != null && current == end) { + // Reached the end node, so abort! + return null + } + + if (filter.accept(current)) { + return current + } + + current = current.parent + } + + return null + } + + /** + * Returns the number of ancestors matching the given filter. Does not include the current node in + * the count, even if it matches the filter. If there is a cycle in the ancestor hierarchy, then + * this method will return 0. + */ + @JvmStatic + fun countMatchingAncestors( + node: AccessibilityNodeInfoCompat?, filter: Filter + ): Int { + if (node == null) { + return 0 + } + + val ancestors = HashSet() + var matchingAncestors = 0 + + ancestors.add(node) + var current: AccessibilityNodeInfoCompat? = node.parent + + while (current != null) { + if (!ancestors.add(current)) { + // Already seen this node, so abort! + return 0 + } + + if (filter.accept(current)) { + matchingAncestors++ + } + + current = current.parent + } + + return matchingAncestors + } + + private fun getMatchingDescendant( + node: AccessibilityNodeInfoCompat?, + filter: Filter, + endFilter: Filter?, + visitedNodes: HashSet + ): AccessibilityNodeInfoCompat? { + if (node == null) { + return null + } + + if (visitedNodes.contains(node)) { + return null + } else { + visitedNodes.add(node) + } + + val childCount = node.childCount + for (i in 0 until childCount) { + val child = node.getChild(i) ?: continue + + if (filter.accept(child)) { + return child // child was already obtained by node.getChild(). + } + + if (endFilter != null && endFilter.accept(child)) { + continue + } + + val childMatch = getMatchingDescendant(child, filter, endFilter, visitedNodes) + if (childMatch != null) { + return childMatch + } + } + + return null + } + + /** + * Returns the first child (by depth-first search) of {@code node} that matches the {@code + * filter}, and skips the nodes that match the {@code endFilter}. Returns {@code null} if no nodes + * match. + */ + @JvmStatic + fun getMatchingDescendant( + node: AccessibilityNodeInfoCompat?, + filter: Filter, + endFilter: Filter? + ): AccessibilityNodeInfoCompat? { + return getMatchingDescendant(node, filter, endFilter, HashSet()) + } + + /** + * Returns the first child (by depth-first search) of {@code node} that matches the {@code + * filter}. Returns {@code null} if no nodes match. + */ + @JvmStatic + fun getMatchingDescendant( + node: AccessibilityNodeInfoCompat?, filter: Filter + ): AccessibilityNodeInfoCompat? { + return getMatchingDescendant(node, filter, /* endFilter= */ null, HashSet()) + } + + /** Returns all descendants that match filter but skips the nested. */ + @JvmStatic + fun getMatchingDescendantsNotNested( + node: AccessibilityNodeInfoCompat?, filter: Filter + ): List? { + if (node == null) { + return null + } + val matches = ArrayList() + getMatchingDescendants(node, filter, /* matchChild= */ false, HashSet(), matches) + return matches + } + + /** Returns all descendants that match filter. */ + @JvmStatic + fun getMatchingDescendantsOrRoot( + node: AccessibilityNodeInfoCompat?, filter: Filter + ): List? { + if (node == null) { + return null + } + val matches = ArrayList() + getMatchingDescendants(node, filter, /* matchChild= */ true, HashSet(), matches) + return matches + } + + /** + * Returns all descendants that match filter, until the stopNode is found. At that point, the + * search will stop. Note that the stopNode is included in the results, if it matches the filter. + */ + @JvmStatic + fun getMatchingDescendantsOrRootUntilNode( + node: AccessibilityNodeInfoCompat?, + filter: Filter, + stopNode: AccessibilityNodeInfoCompat? + ): List? { + if (node == null) { + return null + } + val matches = ArrayList() + getMatchingDescendantsCore( + node, + filter, + /* matchChild= */ true, + HashSet(), + matches, + /* stopNode= */ stopNode, + /* searchControlFlag= */ SearchControlFlag()) + return matches + } + + /** + * Collects all descendants that match filter, into matches. + * + * @param node The root node to start searching. + * @param filter The filter to match the nodes against. + * @param matchChild Flag that allows match with the childs of the matched nodes. + * @param visitedNodes The set of nodes already visited, for protection against loops. This will + * be modified. + * @param matches The list of nodes matching filter. This will be appended to. + */ + private fun getMatchingDescendants( + node: AccessibilityNodeInfoCompat?, + filter: Filter, + matchChild: Boolean, + visitedNodes: MutableSet, + matches: MutableList + ) { + getMatchingDescendantsCore( + node, + filter, + matchChild, + visitedNodes, + matches, + /* stopNode= */ null, + /* searchControlFlag= */ null) + } + + /** + * A flag to indicate whether the stop node has been found. Using a class instead of a boolean + * flag allows {@link #getMatchingDescendantsCore} to modify the flag and have the updated value + * reflected in other branches of the recursive search. + */ + private class SearchControlFlag { + var stopNodeHasBeenFound = false + } + + /** + * Collects all descendants that match filter, into matches. + * + * @param node The root node to start searching. + * @param filter The filter to match the nodes against. + * @param matchChild Flag that allows match with the childs of the matched nodes. + * @param visitedNodes The set of nodes already visited, for protection against loops. This will + * be modified. + * @param matches The list of nodes matching filter. This will be appended to. + * @param stopNode The node to stop searching at. Note that this node is included in the matches, + * if it matches the filter. + * @param searchControlFlag A flag to indicate whether the stop node has been found. See {@link + * SearchControlFlag} for details. + */ + private fun getMatchingDescendantsCore( + node: AccessibilityNodeInfoCompat?, + filter: Filter, + matchChild: Boolean, + visitedNodes: MutableSet, + matches: MutableList, + stopNode: AccessibilityNodeInfoCompat?, + searchControlFlag: SearchControlFlag? + ) { + + if (node == null) { + return + } + + // Update visited nodes. + if (visitedNodes.contains(node)) { + return + } else { + visitedNodes.add(node) + } + + // Stop searching if the stop node has been found. + if (searchControlFlag != null && searchControlFlag.stopNodeHasBeenFound) { + return + } + + // If node matches filter... collect node. + if (filter.accept(node)) { + matches.add(node) + } + + // If the stop node has been found, future searches can be skipped, even if the stopNode does + // not match the filter. + if (searchControlFlag != null && node == stopNode) { + searchControlFlag.stopNodeHasBeenFound = true + } + + // For each child of node... + if (!matches.contains(node) || matchChild) { + val childCount = node.childCount + for (i in 0 until childCount) { + val child = node.getChild(i) ?: continue + getMatchingDescendantsCore( + child, filter, matchChild, visitedNodes, matches, stopNode, searchControlFlag) + } + } + } + + /** + * Check whether a given node is scrollable. + * + * @param node The node to examine. + * @return {@code true} if the node is scrollable. + */ + @JvmStatic + fun isScrollable(node: AccessibilityNodeInfoCompat?): Boolean { + // In some cases node#isScrollable lies. (Notably, some nodes that correspond to WebViews claim + // to be scrollable, but do not support any scroll actions. This seems to stem from a bug in the + // translation from the DOM to the AccessibilityNodeInfo.) To avoid labeling views that don't + // support scrolling (e.g. REFERTO), check for the explicit presence of + // AccessibilityActions. + return supportsAnyAction( + node, + AccessibilityActionCompat.ACTION_SCROLL_FORWARD, + AccessibilityActionCompat.ACTION_SCROLL_BACKWARD, + AccessibilityActionCompat.ACTION_SCROLL_DOWN, + AccessibilityActionCompat.ACTION_SCROLL_UP, + AccessibilityActionCompat.ACTION_SCROLL_RIGHT, + AccessibilityActionCompat.ACTION_SCROLL_LEFT) + } + + /** + * Returns whether the specified node has text. For the purposes of this check, any node with a + * CollectionInfo is considered to not have text since its text and content description are used + * only for collection transitions. + * + * @param node The node to check. + * @return {@code true} if the node has text. + */ + private fun hasText(node: AccessibilityNodeInfoCompat?): Boolean { + return node != null && + node.collectionInfo == null && + (!TextUtils.isEmpty(AccessibilityNodeInfoUtils.getText(node)) || + !TextUtils.isEmpty(node.contentDescription) || + !TextUtils.isEmpty(node.hintText)) + } + + /** + * Returns whether the specified node has state description. + * + * @param node The node to check. + * @return {@code true} if the node has state description. + */ + private fun hasStateDescription(node: AccessibilityNodeInfoCompat?): Boolean { + return node != null && + (!TextUtils.isEmpty(node.stateDescription) || + node.isCheckable || + hasValidRangeInfo(node)) + } + + /** + * Returns if a node is focusable or clickable. + * + *

This is used in {@link #shouldFocusNode} and {@link #isAccessibilityFocusable} + * + * @param node the node to check + * @return {@code true} if the node is focusable or clickable + */ + private fun isFocusableOrClickable(node: AccessibilityNodeInfoCompat?): Boolean { + return (node != null) && + isVisible(node) && + (node.isScreenReaderFocusable || isActionableForAccessibility(node)) + } + + /** + * Determines whether a node is a top-level item in a scrollable container. + * + * @param node The node to test. + * @return {@code true} if {@code node} is a top-level item in a scrollable container. + */ + @JvmStatic + fun isTopLevelScrollItem(node: AccessibilityNodeInfoCompat?): Boolean { + if (node == null) { + return false + } + + if (!isVisible(node)) { + return false + } + + val parent = node.parent + return isScrollItem(parent) + } + + private fun isScrollItem(node: AccessibilityNodeInfoCompat?): Boolean { + if (node == null) { + // Not a child node of anything. + return false + } + + // Drop down lists (spinners) are not included to retain the old behavior of focusing on + // the spinner itself rather than on the single visible item. + // A spinner being scrollable is disingenuous since the scrollable list inside isn't exposed + // without interaction. + if (Role.getRole(node) == Role.ROLE_DROP_DOWN_LIST) { + return false + } + + // A node with a scrollable parent is a top level scroll item. + if (isScrollable(node)) { + return true + } + + @Role.RoleName val parentRole = Role.getRole(node) + // Note that ROLE_DROP_DOWN_LIST(Spinner) is not accepted. + // RecyclerView is classified as a list or grid based on its CollectionInfo. + // These parents may not be scrollable in some cases, like if the list is too short to be + // scrolled, but their children should still be considered top level scroll items. + return parentRole == Role.ROLE_LIST || + parentRole == Role.ROLE_GRID || + parentRole == Role.ROLE_SCROLL_VIEW || + parentRole == Role.ROLE_HORIZONTAL_SCROLL_VIEW || + nodeMatchesAnyClassByType(node, CLASS_TOUCHWIZ_TWADAPTERVIEW) + } + + @JvmStatic + fun hasAncestor( + node: AccessibilityNodeInfoCompat?, targetAncestor: AccessibilityNodeInfoCompat? + ): Boolean { + if (node == null || targetAncestor == null) { + return false + } + + val filter = + object : Filter() { + override fun accept(node: AccessibilityNodeInfoCompat?): Boolean { + return targetAncestor == node + } + } + + return (getMatchingAncestor(node, filter) != null) + } + + @JvmStatic + fun hasDescendant( + node: AccessibilityNodeInfoCompat?, + targetDescendant: AccessibilityNodeInfoCompat? + ): Boolean { + if (node == null || targetDescendant == null) { + return false + } + + val filter = + object : Filter() { + override fun accept(node: AccessibilityNodeInfoCompat?): Boolean { + return targetDescendant == node + } + } + + return (getMatchingDescendant(node, filter) != null) + } + + /** + * Determines if the generating class of an {@link AccessibilityNodeInfoCompat} matches a given + * {@link Class} by type. + * + * @param node A sealed {@link AccessibilityNodeInfoCompat} dispatched by the accessibility + * framework. + * @param referenceClass A {@link Class} to match by type or inherited type. + * @return {@code true} if the {@link AccessibilityNodeInfoCompat} object matches the {@link + * Class} by type or inherited type, {@code false} otherwise. + */ + @JvmStatic + fun nodeMatchesClassByType( + node: AccessibilityNodeInfoCompat?, referenceClass: Class<*>? + ): Boolean { + if ((node == null) || (referenceClass == null)) { + return false + } + + // Attempt to take a shortcut. + val nodeClassName = node.className + if (TextUtils.equals(nodeClassName, referenceClass.name)) { + return true + } + + return ClassLoadingCache.checkInstanceOf(nodeClassName, referenceClass) + } + + /** + * Determines if the generating class of an {@link AccessibilityNodeInfoCompat} matches any of the + * given {@link Class}es by type. + * + * @param node A sealed {@link AccessibilityNodeInfoCompat} dispatched by the accessibility + * framework. + * @return {@code true} if the {@link AccessibilityNodeInfoCompat} object matches the {@link + * Class} by type or inherited type, {@code false} otherwise. + * @param referenceClasses A variable-length list of {@link Class} objects to match by type or + * inherited type. + */ + @JvmStatic + fun nodeMatchesAnyClassByType( + node: AccessibilityNodeInfoCompat?, vararg referenceClasses: Class<*>? + ): Boolean { + if (node == null) { + return false + } + + for (referenceClass in referenceClasses) { + if (ClassLoadingCache.checkInstanceOf(node.className, referenceClass)) { + return true + } + } + + return false + } + + /** + * Recycles the given nodes. + * + * @param nodes The nodes to recycle. + */ + @JvmStatic + fun recycleNodes(nodes: MutableCollection) { + nodes.clear() + } + + /** + * Recycles the given nodes. + * + * @param nodes The nodes to recycle. + */ + @JvmStatic + fun recycleNodes(vararg nodes: AccessibilityNodeInfo?) {} + + /** + * Recycles the given nodes. + * + * @param nodes The nodes to recycle. + */ + @JvmStatic + fun recycleNodes(vararg nodes: AccessibilityNodeInfoCompat?) {} + + /** + * Returns {@code true} if the node supports at least one of the specified actions. This method + * supports actions introduced in API level 21 and later. However, it does not support bitmasks. + * + * @param node The node to check + * @param actions The actions to check + * @return {@code true} if at least one action is supported + */ + // TODO: Use A11yActionCompat once AccessibilityActionCompat#equals is overridden + @JvmStatic + fun supportsAnyAction( + node: AccessibilityNodeInfoCompat?, vararg actions: AccessibilityActionCompat + ): Boolean { + if (node == null) { + return false + } + // Unwrap the node and compare AccessibilityActions because AccessibilityActions, unlike + // AccessibilityActionCompats, are static (so checks for equality work correctly). + val supportedActions = node.actionList + + for (action in actions) { + if (supportedActions.contains(action)) { + return true + } + } + + return false + } + + /** + * Returns {@code true} if the node supports at least one of the specified actions. To check + * whether a node supports multiple actions, combine them using the {@code |} (logical OR) + * operator. + * + *

Note: this method will check against the getActions() method of AccessibilityNodeInfo, which + * will not contain information for actions introduced in API level 21 or later. + * + * @param node The node to check. + * @param actions The actions to check. + * @return {@code true} if at least one action is supported. + */ + // TODO: Remove this method once AccessibilityActionCompat#equals is overridden + @JvmStatic + fun supportsAnyAction(node: AccessibilityNodeInfoCompat?, vararg actions: Int): Boolean { + if (node != null) { + val supportedActions = node.actions + + for (action in actions) { + if ((supportedActions and action) == action) { + return true + } + } + } + + return false + } + + /** + * Returns {@code true} if the node supports the specified action. This method supports actions + * introduced in API level 21 and later. However, it does not support bitmasks. + */ + @JvmStatic + fun supportsAction(node: AccessibilityNodeInfoCompat, action: Int): Boolean { + // New actions in >= API 21 won't appear in getActions() but in getActionList(). + // On Lollipop+ devices, pre-API 21 actions will also appear in getActionList(). + val actions = node.actionList + val size = actions.size + for (i in 0 until size) { + val actionCompat = actions[i] + if (actionCompat.id == action) { + return true + } + } + return false + } + + /** + * Returns the action label on the node by given action ID, or an empty text if the node doesn't + * support the action. + */ + @JvmStatic + fun getActionLabelById(node: AccessibilityNodeInfoCompat, action: Int): CharSequence? { + val actions = node.actionList + val size = actions.size + for (i in 0 until size) { + val actionCompat = actions[i] + if (actionCompat.id == action) { + return actionCompat.label + } + } + return "" + } + + /** + * Returns the result of applying a filter using breadth-first traversal. + * + * @param node The root node to traverse from. + * @param filter The filter to satisfy. + * @return The first node reached via BFS traversal that satisfies the filter. + */ + @JvmStatic + fun searchFromBfs( + node: AccessibilityNodeInfoCompat?, filter: Filter + ): AccessibilityNodeInfoCompat? { + return searchFromBfs(node, filter, /* filterToSkip= */ null) + } + + /** + * Returns the result of applying a filter using breadth-first traversal. It allows skip nodes to + * speed up the BFS traversal. + * + * @param node The root node to traverse from. + * @param filter The filter to satisfy. + * @param filterToSkip The filter for skipping nodes, all childs under the node will be skipped. + * @return The first node reached via BFS traversal that satisfies the filter. + */ + @JvmStatic + fun searchFromBfs( + node: AccessibilityNodeInfoCompat?, + filter: Filter, + filterToSkip: Filter? + ): AccessibilityNodeInfoCompat? { + if (node == null) { + return null + } + + val queue = ArrayDeque() + val visitedNodes = HashSet() + + queue.add(node) + + while (!queue.isEmpty()) { + val item = queue.removeFirst() + visitedNodes.add(item) + + if (filterToSkip != null && filterToSkip.accept(item)) { + continue + } + + if (filter.accept(item)) { + return item + } + + val childCount = item.childCount + + for (i in 0 until childCount) { + val child = item.getChild(i) + + if (child != null && !visitedNodes.contains(child)) { + queue.addLast(child) + } + } + } + return null + } + + /** Safely obtains a copy of node. */ + @Deprecated("Deprecated in Java") + @JvmStatic + fun obtain(node: AccessibilityNodeInfoCompat?): AccessibilityNodeInfoCompat? { + return if (node == null) null else AccessibilityNodeInfoCompat.obtain(node) + } + + /** + * Returns a fresh copy of {@code node} with properties that are less likely to be stale. Returns + * {@code null} if the node can't be found anymore. + */ + @JvmStatic + fun refreshNode(node: AccessibilityNodeInfoCompat?): AccessibilityNodeInfoCompat? { + return if ((node == null) || !node.refresh()) null else node + } + + /** + * Gets the location of specific range of node text. It returns null if the node doesn't support + * text location data or the index is incorrect. + * + * @param node The node being queried. + * @param fromCharIndex start index of the queried text range. + * @param toCharIndex end index of the queried text range. + */ + @JvmStatic + fun getTextLocations( + node: AccessibilityNodeInfoCompat?, fromCharIndex: Int, toCharIndex: Int + ): List? { + return getTextLocations( + node, AccessibilityNodeInfoUtils.getText(node), fromCharIndex, toCharIndex) + } + + /** + * Gets the location of specific range of node {@code text}. It returns null if the node doesn't + * support text location data or the index is incorrect. + * + * @param node The node being queried. + * @param text The node's text. This is typically the text, but can also be the content + * description if the node was not properly created. If the content description is used, its + * text location will only be returned if it's visible on the screen. + * @param fromCharIndex start index of the queried text range. + * @param toCharIndex end index of the queried text range. + */ + @JvmStatic + fun getTextLocations( + node: AccessibilityNodeInfoCompat?, + text: CharSequence?, + fromCharIndex: Int, + toCharIndex: Int + ): List? { + return getTextLocations(node, text, fromCharIndex, toCharIndex, true) + } + + /** + * Gets the location of specific range of node {@code text}. It returns null if the node doesn't + * support text location data or the index is incorrect. + * + * @param node The node being queried. + * @param text The node's text. This is typically the text, but can also be the content + * description if the node was not properly created. If the content description is used, its + * text location will only be returned if it's visible on the screen. + * @param fromCharIndex start index of the queried text range. + * @param toCharIndex end index of the queried text range. + * @param useWindowBound experimental feature that should more accurately determine word position. + */ + @JvmStatic + fun getTextLocations( + node: AccessibilityNodeInfoCompat?, + text: CharSequence?, + fromCharIndex: Int, + toCharIndex: Int, + useWindowBound: Boolean + ): List? { + if (node == null) { + return null + } + + if (fromCharIndex < 0 || + TextUtils.isEmpty(text) || + !PrimitiveUtils.isInInterval(toCharIndex, fromCharIndex, text!!.length, true)) { + return null + } + val info = node.unwrap() ?: return null + // Prefer character bounds in window, but fall back to character bounds in screen if not + // available. + var key = AccessibilityNodeInfo.EXTRA_DATA_TEXT_CHARACTER_LOCATION_KEY + var isBoundsInWindow = false + if (useWindowBound && + BuildVersionUtils.isAtLeastBaklava() && + info.availableExtraData + .contains( + AccessibilityNodeInfoCompat.EXTRA_DATA_TEXT_CHARACTER_LOCATION_IN_WINDOW_KEY)) { + key = AccessibilityNodeInfoCompat.EXTRA_DATA_TEXT_CHARACTER_LOCATION_IN_WINDOW_KEY + isBoundsInWindow = true + } + val args = Bundle() + args.putInt( + AccessibilityNodeInfo.EXTRA_DATA_TEXT_CHARACTER_LOCATION_ARG_START_INDEX, fromCharIndex) + args.putInt( + AccessibilityNodeInfo.EXTRA_DATA_TEXT_CHARACTER_LOCATION_ARG_LENGTH, + toCharIndex - fromCharIndex) + if (!info.refreshWithExtraData(key, args)) { + return null + } + + val extras = info.extras + val data: Array? = extras.getParcelableArray(key) + if (data == null) { + return null + } + + val windowBounds = Rect() + if (isBoundsInWindow) { + val windowInfo = info.window + windowInfo.getBoundsInScreen(windowBounds) + } + val result = ArrayList(data.size) + for (item in data) { + if (item == null) { + continue + } + val rectF = item as RectF + if (isBoundsInWindow) { + rectF.offset(windowBounds.left.toFloat(), windowBounds.top.toFloat()) + } + result.add( + Rect(rectF.left.toInt(), rectF.top.toInt(), rectF.right.toInt(), rectF.bottom.toInt())) + } + return result + } + + /** Returns true if the node supports text location data. */ + @JvmStatic + fun supportsTextLocation(node: AccessibilityNodeInfoCompat?): Boolean { + if (node == null) { + return false + } + val info = node.unwrap() ?: return false + val extraData = info.availableExtraData + return extraData != null && + (extraData.contains(AccessibilityNodeInfo.EXTRA_DATA_TEXT_CHARACTER_LOCATION_KEY) || + (BuildVersionUtils.isAtLeastBaklava() && + extraData.contains( + AccessibilityNodeInfoCompat.EXTRA_DATA_TEXT_CHARACTER_LOCATION_IN_WINDOW_KEY))) + } + + /** Helper method that returns {@code true} if the specified node is visible to the user */ + @JvmStatic + fun isVisible(node: AccessibilityNodeInfoCompat?): Boolean { + // We need to move focus to invisible node in WebView to scroll it but we don't want to + // move focus if WebView itself is invisible. + return node != null && + (node.isVisibleToUser || + (WebInterfaceUtils.isWebContainer(node) && + Role.getRole(node) != Role.ROLE_WEB_VIEW)) + } + + /** + * Checks whether the node's height is smaller than the threshold + * + * @param context the context + * @param node the node to check + * @return {@code true} if the node's height is smaller than the dp threshold. + */ + @JvmStatic + fun isSmallNodeInHeight(context: Context, node: AccessibilityNodeInfoCompat): Boolean { + val nodeRect = Rect() + node.getBoundsInScreen(nodeRect) + + return nodeRect.height() < DisplayUtils.dpToPx(context, THRESHOLD_HEIGHT_DP_FOR_SMALL_NODE) + } + + /** + * Checks whether the node is a top or bottom border node or not. Horizontal scrolling with a + * check of left or right border isn't yet supported in this method. + * + * @param screenPxSize the pixel size of a screen + * @param node the node to check + * @return {@code true} if the node is at top or bottom border. + */ + @JvmStatic + fun isTopOrBottomBorderNode( + screenPxSize: Point, node: AccessibilityNodeInfoCompat + ): Boolean { + + val nodeRect = Rect() + node.getBoundsInScreen(nodeRect) + + // check the screen's border + if (isTopOrBottomBorderNode(nodeRect, screenPxSize)) { + return true + } + + // check the scrollable container's border + val parentRect = Rect() + val filter = + object : Filter() { + override fun accept(parent: AccessibilityNodeInfoCompat?): Boolean { + if (isScrollItem(parent)) { + parent!!.getBoundsInScreen(parentRect) + return parentRect.top == nodeRect.top || parentRect.bottom == nodeRect.bottom + } + return false + } + } + + return hasMatchingAncestor(node, filter) + } + + private fun isTopOrBottomBorderNode(nodeRect: Rect, screenPxSize: Point): Boolean { + return nodeRect.top <= 0 || nodeRect.bottom >= screenPxSize.y + } + + /** Determines whether the specified node has bounds identical to the bounds of its window. */ + private fun areBoundsIdenticalToWindow(node: AccessibilityNodeInfoCompat?): Boolean { + if (node == null) { + return false + } + + val window = getWindow(node) ?: return false + + val windowBounds = Rect() + window.getBoundsInScreen(windowBounds) + + val nodeBounds = Rect() + node.getBoundsInScreen(nodeBounds) + + return windowBounds == nodeBounds + } + + /** + * Analyses if the edit text has no text. + * + *

If there is a text field with hint text and no text, {@link + * AccessibilityNodeInfoUtils#getText()} returns hint text. Hence this method checks for {@link + * AccessibilityNodeInfo#ACTION_SET_SELECTION} to disregard the hint text. + */ + @JvmStatic + fun isEmptyEditTextRegardlessOfHint(node: AccessibilityNodeInfoCompat?): Boolean { + if (node == null || !node.isEditable) { + return false + } + + if (TextUtils.isEmpty(AccessibilityNodeInfoUtils.getText(node))) { + return true + } + return !supportsAction(node, AccessibilityNodeInfo.ACTION_SET_SELECTION) + } + + /** * Checks if node represents non-editable selectable text. */ + @JvmStatic + fun isNonEditableSelectableText(node: AccessibilityNodeInfoCompat?): Boolean { + if (node != null && FeatureSupport.supportsIsTextSelectable()) { + return !node.isEditable && node.unwrap().isTextSelectable + } + return false + } + + /** * Checks if node represents selectable text. Editable text is selectable. */ + @JvmStatic + fun isTextSelectable(node: AccessibilityNodeInfoCompat?): Boolean { + if (node == null) { + return false + } + val isEditable = Role.getRole(node) == Role.ROLE_EDIT_TEXT || node.isEditable + val isNonEditableSelectableText = + AccessibilityNodeInfoUtils.isNonEditableSelectableText(node) + return isEditable || isNonEditableSelectableText + } + + /** + * Gets a list of URLs contained within an {@link AccessibilityNodeInfoCompat}. + * + * @param node The node that will be searched for links + * @return A list of {@link SpannableUrl}s from the URLs found within the Node + */ + @JvmStatic + fun getNodeUrls(node: AccessibilityNodeInfoCompat): List { + return getNodeClickableElements( + node, + URLSpan::class.java, + Function { input -> SpannableUrl.create(input!!.first, input.second as URLSpan) }) + } + + /** + * Gets a list of ClickableSpans paired with the String they span within a node's text. + * + * @param node The node that will be searched for spans + * @return A list of Clickable elements found within the Node. + */ + @JvmStatic + fun getNodeClickableStrings(node: AccessibilityNodeInfoCompat): List { + return getNodeClickableElements( + node, + ClickableSpan::class.java, + Function { input -> ClickableString.create(input!!.first, input.second) }) + } + + /** + * Gets a list of the clickable elements within a node. + * + * @param node the node to get the clickable elements from + * @param clickableType the type of clickable span that we look for within the node + * @param clickableElementFn a function taking the visual string representation and the clickable + * portion of the clickable element to produces the desired format that will be displayable to + * the user + * @param the displayable format representation of the clickable element + * @return a list of clickable elements, empty if there is none + */ + private fun getNodeClickableElements( + node: AccessibilityNodeInfoCompat, + clickableType: Class, + clickableElementFn: Function, E> + ): List { + val spannableStrings = ArrayList() + SpannableTraversalUtils.getSpannableStringsWithTargetClickableSpanInNodeTree( + node, clickableType, spannableStrings) + + val clickables = ArrayList(1) + for (spannableOffset in spannableStrings) { + if (spannableOffset == null || spannableOffset.spannableString == null) { + continue + } + val spannable: SpannableString = spannableOffset.spannableString + for (span in spannable.getSpans(0, spannable.length, clickableType)) { + // Child classes may not use #getUrl, so just check that the class is a URLSpan, instead of + // a child class with "instanceof". + if ((span.javaClass == URLSpan::class.java) && + Strings.isNullOrEmpty((span as URLSpan).url)) { + continue + } + val start = spannable.getSpanStart(span) + val end = spannable.getSpanEnd(span) + if (end > start) { + val chars = CharArray(end - start) + spannable.getChars(start, end, chars, 0) + clickables.add(clickableElementFn.apply(Pair.create(String(chars), span))) + } + } + } + return clickables + } + + @JvmStatic + fun getMovementGranularity(node: AccessibilityNodeInfoCompat): Int { + // Some nodes in Webview have movement granularities even its content description/text is + // empty. + if (WebInterfaceUtils.supportsWebActions(node) && + TextUtils.isEmpty(node.contentDescription) && + TextUtils.isEmpty(AccessibilityNodeInfoUtils.getText(node))) { + return 0 + } + + return node.movementGranularities + } + + @JvmStatic + fun getHintText(node: AccessibilityNodeInfoCompat): CharSequence? { + var hintText = node.hintText + if (TextUtils.isEmpty(hintText)) { + val bundle = node.extras + if (bundle != null) { + // Hint text for WebView. + hintText = bundle.getCharSequence(HINT_TEXT_KEY) + } + } + + return hintText + } + + /** + * To setup a hashmap for AccessibilityAction id and the display string. We only build into the + * hash map with identifiers which are supported in the running platform. + */ + private fun initActionIds(): HashMap { + val actionIdHashMap = HashMap() + + actionIdHashMap.put(AccessibilityAction.ACTION_SHOW_ON_SCREEN.id, "ACTION_SHOW_ON_SCREEN") + actionIdHashMap.put( + AccessibilityAction.ACTION_SCROLL_TO_POSITION.id, "ACTION_SCROLL_TO_POSITION") + actionIdHashMap.put(AccessibilityAction.ACTION_SCROLL_UP.id, "ACTION_SCROLL_UP") + actionIdHashMap.put(AccessibilityAction.ACTION_SCROLL_LEFT.id, "ACTION_SCROLL_LEFT") + actionIdHashMap.put(AccessibilityAction.ACTION_SCROLL_DOWN.id, "ACTION_SCROLL_DOWN") + actionIdHashMap.put(AccessibilityAction.ACTION_SCROLL_RIGHT.id, "ACTION_SCROLL_RIGHT") + actionIdHashMap.put(AccessibilityAction.ACTION_CONTEXT_CLICK.id, "ACTION_CONTEXT_CLICK") + actionIdHashMap.put(AccessibilityAction.ACTION_SET_PROGRESS.id, "ACTION_SET_PROGRESS") + actionIdHashMap.put(AccessibilityAction.ACTION_MOVE_WINDOW.id, "ACTION_MOVE_WINDOW") + + if (BuildVersionUtils.isAtLeastP()) { + actionIdHashMap.put(AccessibilityAction.ACTION_SHOW_TOOLTIP.id, "ACTION_SHOW_TOOLTIP") + actionIdHashMap.put(AccessibilityAction.ACTION_HIDE_TOOLTIP.id, "ACTION_HIDE_TOOLTIP") + } + if (BuildVersionUtils.isAtLeastQ()) { + actionIdHashMap.put(AccessibilityAction.ACTION_PAGE_RIGHT.id, "ACTION_PAGE_RIGHT") + actionIdHashMap.put(AccessibilityAction.ACTION_PAGE_LEFT.id, "ACTION_PAGE_LEFT") + actionIdHashMap.put(AccessibilityAction.ACTION_PAGE_DOWN.id, "ACTION_PAGE_DOWN") + actionIdHashMap.put(AccessibilityAction.ACTION_PAGE_UP.id, "ACTION_PAGE_UP") + } + if (BuildVersionUtils.isAtLeastR()) { + actionIdHashMap.put( + AccessibilityAction.ACTION_PRESS_AND_HOLD.id, "ACTION_PRESS_AND_HOLD") + actionIdHashMap.put(AccessibilityAction.ACTION_IME_ENTER.id, "ACTION_IME_ENTER") + } + return actionIdHashMap + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + // Methods for displaying node data + + @JvmStatic + fun actionToString(action: Int): String { + when (action) { + AccessibilityNodeInfoCompat.ACTION_ACCESSIBILITY_FOCUS -> { + return "ACTION_ACCESSIBILITY_FOCUS" + } + AccessibilityNodeInfoCompat.ACTION_CLEAR_ACCESSIBILITY_FOCUS -> { + return "ACTION_CLEAR_ACCESSIBILITY_FOCUS" + } + AccessibilityNodeInfoCompat.ACTION_CLEAR_FOCUS -> { + return "ACTION_CLEAR_FOCUS" + } + AccessibilityNodeInfoCompat.ACTION_CLEAR_SELECTION -> { + return "ACTION_CLEAR_SELECTION" + } + AccessibilityNodeInfoCompat.ACTION_CLICK -> { + return "ACTION_CLICK" + } + AccessibilityNodeInfoCompat.ACTION_COLLAPSE -> { + return "ACTION_COLLAPSE" + } + AccessibilityNodeInfoCompat.ACTION_COPY -> { + return "ACTION_COPY" + } + AccessibilityNodeInfoCompat.ACTION_CUT -> { + return "ACTION_CUT" + } + AccessibilityNodeInfoCompat.ACTION_DISMISS -> { + return "ACTION_DISMISS" + } + AccessibilityNodeInfoCompat.ACTION_EXPAND -> { + return "ACTION_EXPAND" + } + AccessibilityNodeInfoCompat.ACTION_FOCUS -> { + return "ACTION_FOCUS" + } + AccessibilityNodeInfoCompat.ACTION_LONG_CLICK -> { + return "ACTION_LONG_CLICK" + } + AccessibilityNodeInfoCompat.ACTION_NEXT_AT_MOVEMENT_GRANULARITY -> { + return "ACTION_NEXT_AT_MOVEMENT_GRANULARITY" + } + AccessibilityNodeInfoCompat.ACTION_NEXT_HTML_ELEMENT -> { + return "ACTION_NEXT_HTML_ELEMENT" + } + AccessibilityNodeInfoCompat.ACTION_PASTE -> { + return "ACTION_PASTE" + } + AccessibilityNodeInfoCompat.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY -> { + return "ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY" + } + AccessibilityNodeInfoCompat.ACTION_PREVIOUS_HTML_ELEMENT -> { + return "ACTION_PREVIOUS_HTML_ELEMENT" + } + AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD -> { + return "ACTION_SCROLL_BACKWARD" + } + AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD -> { + return "ACTION_SCROLL_FORWARD" + } + AccessibilityNodeInfoCompat.ACTION_SELECT -> { + return "ACTION_SELECT" + } + AccessibilityNodeInfoCompat.ACTION_SET_SELECTION -> { + return "ACTION_SET_SELECTION" + } + AccessibilityNodeInfoCompat.ACTION_SET_TEXT -> { + return "ACTION_SET_TEXT" + } + else -> {} + } + val actionName = actionIdToName[action] + return if (actionName == null) "(unhandled action:$action)" else actionName + } + + @JvmStatic + fun toStringShort(node: AccessibilityNodeInfo?): String { + return toStringShort(toCompat(node)) + } + + @JvmStatic + fun toStringShort(node: AccessibilityNodeInfoCompat?): String { + if (node == null) { + return "null" + } + return StringBuilderUtils.joinFields( + "AccessibilityNodeInfoCompat", + StringBuilderUtils.optionalInt("id", node.hashCode(), -1), + StringBuilderUtils.optionalText("class", node.className), + StringBuilderUtils.optionalText("package", node.packageName), + // TODO: Uses hash value in production build + StringBuilderUtils.optionalText( + "text", + if (AccessibilityNodeInfoUtils.getText(node) == null) + null + else if (FeatureSupport.logcatIncludePsi()) + // Logs for DEBUG build or user had opt-in + AccessibilityNodeInfoUtils.getText(node) + else "***"), + StringBuilderUtils.optionalText("state", node.stateDescription), + StringBuilderUtils.optionalText("content", node.contentDescription), + StringBuilderUtils.optionalText("viewIdResName", node.viewIdResourceName), + StringBuilderUtils.optionalText("hint", node.hintText), + StringBuilderUtils.optionalTag("enabled", node.isEnabled), + StringBuilderUtils.optionalTag("checkable", node.isCheckable), + StringBuilderUtils.optionalTag("checked", node.isChecked), + StringBuilderUtils.optionalTag("accessibilityFocused", node.isAccessibilityFocused), + StringBuilderUtils.optionalTag("focusable", isFocusable(node)), + StringBuilderUtils.optionalTag("screenReaderFocusable", node.isScreenReaderFocusable), + StringBuilderUtils.optionalTag("focused", node.isFocused), + StringBuilderUtils.optionalTag("selected", node.isSelected), + StringBuilderUtils.optionalTag("clickable", isClickable(node)), + StringBuilderUtils.optionalTag("longClickable", isLongClickable(node)), + StringBuilderUtils.optionalTag("password", node.isPassword), + StringBuilderUtils.optionalTag("textEntryKey", node.isTextEntryKey), + StringBuilderUtils.optionalTag("scrollable", isScrollable(node)), + StringBuilderUtils.optionalTag( + "heading", FeatureSupport.isHeadingWorks() && node.isHeading), + StringBuilderUtils.optionalTag("collapsible", isCollapsible(node)), + StringBuilderUtils.optionalTag("expandable", isExpandable(node)), + StringBuilderUtils.optionalTag("dismissable", isDismissible(node)), + StringBuilderUtils.optionalTag("pinEntry", isPinEntry(node)), + StringBuilderUtils.optionalTag("visible", node.isVisibleToUser)) + } + + /** Copied from AccessibilityNodeInfo.java */ + @JvmStatic + fun getMovementGranularitySymbolicName(granularity: Int): String? { + if (granularity == 0) { + return null + } + return when (granularity) { + AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER -> "MOVEMENT_GRANULARITY_CHARACTER" + AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD -> "MOVEMENT_GRANULARITY_WORD" + AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE -> "MOVEMENT_GRANULARITY_LINE" + AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PARAGRAPH -> "MOVEMENT_GRANULARITY_PARAGRAPH" + AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PAGE -> "MOVEMENT_GRANULARITY_PAGE" + else -> Integer.toHexString(granularity) + } + } + + /** + * Given a double value, get the int percentage (0 to 100, both inclusive). Only return 0 or 100 + * when percentage is exactly 0 or 100 percent. + */ + @JvmStatic + fun roundForProgressPercent(percent: Double): Int { + if (percent < 0.0f) { + return 0 + } else if (percent > 0.0f && percent < 1.0f) { + return 1 + } else if (percent > 99.0f && percent < 100.0f) { + return 99 + } else if (percent > 100.0f) { + return 100 + } + return Math.round(percent).toInt() + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + // Methods for node properties + + /** + * Returns {@code true} if the height and width of the {@link AccessibilityNodeInfoCompat}'s + * visible bounds on the screen are greater than a specified number of minimum pixels. This can be + * used to prune tiny elements or elements off the screen. + * + *

{@link AccessibilityNodeInfo#isVisibleToUser()} sometimes returns {@code true} for {@link + * android.webkit.WebView} items off the screen, so this method allows us to better ignore WebView + * content off the screen. + * + * @param node The node that will be checked for a minimum number of pixels on the screen + * @return {@code true} if the node has at least the number of minimum visible pixels in both + * width and height on the screen + */ + @JvmStatic + fun hasMinimumPixelsVisibleOnScreen(node: AccessibilityNodeInfoCompat): Boolean { + val visibleBounds = Rect() + node.getBoundsInScreen(visibleBounds) + return ((Math.abs(visibleBounds.height()) >= MIN_VISIBLE_PIXELS) && + (Math.abs(visibleBounds.width()) >= MIN_VISIBLE_PIXELS)) + } + + /** + * Returns the progress percentage from the node. The value will be in the range [0, 100]. + * + * @param node The node from which to obtain the progress percentage. + * @return The progress percentage. + */ + @JvmStatic + fun getProgressPercent(node: AccessibilityNodeInfoCompat?): Float { + if (node == null) { + return 0.0f + } + + val rangeInfo = node.rangeInfo ?: return 0.0f + + val maxProgress = rangeInfo.max + val minProgress = rangeInfo.min + val currentProgress = rangeInfo.current + val diffProgress = maxProgress - minProgress + if (diffProgress <= 0.0f) { + logError("getProgressPercent", "Range is invalid. [%f, %f]", minProgress, maxProgress) + return 0.0f + } + + if (currentProgress < minProgress) { + logError( + "getProgressPercent", + "Current percent is out of range. Current: %f Range: [%f, %f]", + currentProgress, + minProgress, + maxProgress) + return 0.0f + } + + if (currentProgress > maxProgress) { + logError( + "getProgressPercent", + "Current percent is out of range. Current: %f Range: [%f, %f]", + currentProgress, + minProgress, + maxProgress) + return 100.0f + } + + val percent = (currentProgress - minProgress) / diffProgress + return (100.0f * Math.max(0.0f, Math.min(1.0f, percent))) + } + + /** + * Returns whether the node has valid RangeInfo. + * + * @param node The node to check. + * @return Whether the node has valid RangeInfo. + */ + @JvmStatic + fun hasValidRangeInfo(node: AccessibilityNodeInfoCompat?): Boolean { + if (node == null) { + return false + } + + val rangeInfo = node.rangeInfo ?: return false + + val maxProgress = rangeInfo.max + val minProgress = rangeInfo.min + val currentProgress = rangeInfo.current + val diffProgress = maxProgress - minProgress + return (diffProgress > 0.0f) && + (currentProgress >= minProgress) && + (currentProgress <= maxProgress) + } + + /** Checks whether the given node is still in the window. */ + @JvmStatic + fun isInWindow( + checkingNode: AccessibilityNodeInfoCompat, + windowInfoCompat: AccessibilityWindowInfoCompat? + ): Boolean { + if (windowInfoCompat == null) { + return false + } + val windowId = checkingNode.windowId + if (windowId != WINDOW_ID_NONE && windowId != windowInfoCompat.id) { + return false + } + return hasDescendant(windowInfoCompat.root, checkingNode) + } + + /** Checks whether the given node is still in the window. */ + @JvmStatic + fun isInWindow( + checkingNode: AccessibilityNodeInfoCompat, windowInfo: AccessibilityWindowInfo? + ): Boolean { + if (windowInfo == null) { + return false + } + val windowId = checkingNode.windowId + if (windowId != WINDOW_ID_NONE && windowId != windowInfo.id) { + return false + } + return hasDescendant(toCompat(windowInfo.root), checkingNode) + } + + /** + * Checks whether the given node is a header. + * + *

On M devices, the return value is always false if the node is an item in ListView or + * GridView but not in WebView. + */ + // TODO On pre-N devices, the framework ListView/GridView will mark non-headers + // as headers. The workaround should be removed when TalkBack doesn't support android M. + @JvmStatic + fun isHeading(node: AccessibilityNodeInfoCompat): Boolean { + if (!FeatureSupport.isHeadingWorks()) { + val collectionRoot = getCollectionRoot(node) + if (nodeIsListOrGrid(collectionRoot) && !WebInterfaceUtils.isWebContainer(collectionRoot)) { + return false + } + } + return node.isHeading + } + + /** + * Returns the collection root for the given node. As it searches for the collection root, if + * there are more than one collection item along the way upwards, this function will return null + * as the a11y tree is formatted incorrectly. + * + *

For nested collection items, a collection node must always exist between an ancestor and a + * descendant collection item. If this function is called on a descendant item that is directly + * nested under an ancestor item (without an intermediary collection node), it will return null. + * See b/409569562#4. + * + * @param node The node to search for the collection root. + * @return The collection root, or {@code null} if no collection root is found. + */ + @JvmStatic + fun getCollectionRoot(node: AccessibilityNodeInfoCompat?): AccessibilityNodeInfoCompat? { + if (node == null) { + return null + } + + val filter = FILTER_COLLECTION.or(FILTER_COLLECTION_ITEM) + + var collectionRoot = getSelfOrMatchingAncestor(node, filter) + if (collectionRoot == null || FILTER_COLLECTION.accept(collectionRoot)) { + return collectionRoot + } + + collectionRoot = getMatchingAncestor(collectionRoot, filter) + if (collectionRoot == null || FILTER_COLLECTION.accept(collectionRoot)) { + return collectionRoot + } + + return null + } + + /** + * Returns the collection root for the given node, excluding the node itself from the search. + * + * @param node The node to search for the collection root. + * @return The collection root, or {@code null} if no collection root is found. + */ + @JvmStatic + fun getCollectionRootExcludeSelf( + node: AccessibilityNodeInfoCompat? + ): AccessibilityNodeInfoCompat? { + if (node == null) { + return null + } + + if (FILTER_COLLECTION.accept(node)) { + return getCollectionRoot(node.parent) + } + + return getCollectionRoot(node) + } + + /** Returns a table root containing the given node. */ + @JvmStatic + fun getTableRoot(descendant: AccessibilityNodeInfoCompat?): AccessibilityNodeInfoCompat? { + return AccessibilityNodeInfoUtils.getSelfOrMatchingAncestor(descendant, FILTER_TABLE) + } + + /** Returns whether the given node is a table root. */ + private fun isTableRoot(node: AccessibilityNodeInfoCompat): Boolean { + val collectionInfo: CollectionInfoCompat? = node.collectionInfo + return collectionInfo != null && + collectionInfo.rowCount > 1 && + collectionInfo.columnCount > 1 + } + + /** Returns a table cell under table containing the given node. */ + @JvmStatic + fun getTableCellUnderTable( + descendant: AccessibilityNodeInfoCompat? + ): AccessibilityNodeInfoCompat? { + return AccessibilityNodeInfoUtils.getSelfOrMatchingAncestor( + descendant, FILTER_TABLE_CELL_UNDER_TABLE) + } + + /** Returns a node that mapped to the voice dictation clickable view. */ + @JvmStatic + fun getVoiceDictationNode( + descendant: AccessibilityNodeInfoCompat? + ): AccessibilityNodeInfoCompat? { + return AccessibilityNodeInfoUtils.getSelfOrMatchingDescendant( + descendant, FILTER_VOICE_DICTATION) + } + + private fun isVoiceDictationNode(node: AccessibilityNodeInfoCompat?): Boolean { + return Role.getRole(node) == Role.ROLE_VOICE_DICTATION_BUTTON + } + + /** Returns whether the given node is a table cell. */ + private fun isTableCell(node: AccessibilityNodeInfoCompat): Boolean { + val collectionItemInfo: CollectionItemInfoCompat? = node.collectionItemInfo + return collectionItemInfo != null && + collectionItemInfo.rowIndex >= 0 && + collectionItemInfo.columnIndex >= 0 + } + + /** Returns whether the given node is a table cell in a table. */ + private fun isTableCellUnderTable(node: AccessibilityNodeInfoCompat): Boolean { + val collectionItemInfo: CollectionItemInfoCompat? = node.collectionItemInfo + return collectionItemInfo != null && + collectionItemInfo.rowIndex >= 0 && + collectionItemInfo.columnIndex >= 0 && + getTableRoot(node) != null + } + + /** Checks if given node is ListView or GirdView. */ + @JvmStatic + fun nodeIsListOrGrid(node: AccessibilityNodeInfoCompat?): Boolean { + return nodeMatchesAnyClassName(node, CLASS_LISTVIEW, CLASS_GRIDVIEW) + } + + /** Returns {@code true} if the parent of the {@code node} is a collection. */ + @JvmStatic + fun nodeIsListOrGridItem(node: AccessibilityNodeInfoCompat?): Boolean { + if (node == null) { + return false + } + + val parent = node.parent ?: return false + + @RoleName val role = Role.getRole(parent) + return role == Role.ROLE_LIST || role == Role.ROLE_GRID + } + + /** Returns true if the {@code node} is in a collection. */ + @JvmStatic + fun isInCollection(node: AccessibilityNodeInfoCompat?): Boolean { + return AccessibilityNodeInfoUtils.hasMatchingAncestor( + node, + object : Filter() { + override fun accept(ancestor: AccessibilityNodeInfoCompat?): Boolean { + @RoleName val role = Role.getRole(ancestor) + return role == Role.ROLE_LIST || + role == Role.ROLE_GRID || + (ancestor != null && ancestor.collectionInfo != null) + } + }) + } + + @JvmStatic + fun getGridRowTitle(node: AccessibilityNodeInfoCompat): String? { + if (FeatureSupport.supportGridTitle() && node.unwrap() != null) { + val itemInfo: CollectionItemInfo? = node.unwrap().collectionItemInfo + if (itemInfo != null) { + return itemInfo.rowTitle + } + } + return null + } + + @JvmStatic + fun getGridColumnTitle(node: AccessibilityNodeInfoCompat): String? { + if (FeatureSupport.supportGridTitle() && node.unwrap() != null) { + val itemInfo: CollectionItemInfo? = node.unwrap().collectionItemInfo + if (itemInfo != null) { + return itemInfo.columnTitle + } + } + return null + } + + /** + * Returns true if the {@link + * androidx.core.view.accessibility.AccessibilityNodeInfoCompat.CollectionInfoCompat} associated + * with {@code node} is not null and reflects the presence of at least 1 row and 1 column. + */ + @JvmStatic + fun hasUsableCollectionInfo(node: AccessibilityNodeInfoCompat?): Boolean { + return node != null && + node.collectionInfo != null && + node.collectionInfo.rowCount >= 1 && + node.collectionInfo.columnCount >= 1 + } + + /** + * Returns true if the {@link + * androidx.core.view.accessibility.AccessibilityNodeInfoCompat.CollectionItemInfoCompat} + * associated with {@code node} is not null and contains legal collection row and column indices. + */ + @JvmStatic + fun hasUsableCollectionItemInfo(node: AccessibilityNodeInfoCompat?): Boolean { + return node != null && + node.collectionItemInfo != null && + node.collectionItemInfo.rowIndex >= 0 && + node.collectionItemInfo.columnIndex >= 0 + } + + /** + * Returns true if the {@link + * androidx.core.view.accessibility.AccessibilityNodeInfoCompat.CollectionItemInfoCompat} + * associated with {@code node} is not null, and it contains legal collection row and column + * indices, which fall within the row and column bounds of {@code parent}. + */ + @JvmStatic + fun hasUsableCollectionItemInfo( + item: AccessibilityNodeInfoCompat?, collection: AccessibilityNodeInfoCompat? + ): Boolean { + return hasUsableCollectionItemInfo(item) && + hasUsableCollectionInfo(collection) && + item!!.collectionItemInfo.rowIndex < collection!!.collectionInfo.rowCount && + item.collectionItemInfo.columnIndex < collection.collectionInfo.columnCount + } + + /** + * Returns the {@link Rect} of the node bounds in screen coordinates, and returns an empty Rect if + * the given node is null. + */ + @JvmStatic + fun getNodeBoundsInScreen(node: AccessibilityNodeInfoCompat?): Rect { + val nodeBounds = Rect() + if (node != null) { + node.getBoundsInScreen(nodeBounds) + } + return nodeBounds + } + + /** + * Returns a list of {@link SpellingSuggestion} for all {@link SuggestionSpan}s at the cursor + * position in the given {@link AccessibilityNodeInfoCompat} if the input method for the node is + * able to display spelling suggestions. + * + * @param node The node to check + */ + @JvmStatic + fun getSpellingSuggestions( + context: Context, node: AccessibilityNodeInfoCompat? + ): ImmutableList { + return getSpellingSuggestions(context, node, /* activeSpellCheck= */ true) + } + + /** + * Returns a list of {@link SpellingSuggestion} for all {@link SuggestionSpan}s at the cursor + * position in the given {@link AccessibilityNodeInfoCompat}. + * + * @param node the node to check + * @param cursorPosition index of the cursor position + */ + @JvmStatic + fun getSpellingSuggestions( + context: Context, node: AccessibilityNodeInfoCompat, cursorPosition: Int + ): ImmutableList { + return getSpellingSuggestions(context, node, cursorPosition, /* activeSpellCheck= */ true) + } + + /** + * Returns a list of {@link SpellingSuggestion} for all {@link SuggestionSpan}s at the cursor + * position in the given {@link AccessibilityNodeInfoCompat} if the input method for the node is + * able to display spelling suggestions. + * + * @param node The node to check + * @param activeSpellCheck Perform in service spell check or not + */ + @JvmStatic + fun getSpellingSuggestions( + context: Context, node: AccessibilityNodeInfoCompat?, activeSpellCheck: Boolean + ): ImmutableList { + if (node == null || hasNoSuggestionsNeed(node.inputType)) { + return ImmutableList.of() + } + + val start = node.textSelectionStart + val end = node.textSelectionEnd + + if (start != end) { + LogUtils.v(TAG, "Spelling suggestion does not work when text is selected.") + return ImmutableList.of() + } + + return getSpellingSuggestions(context, node, end, activeSpellCheck) + } + + /** + * Returns a list of {@link SpellingSuggestion} for all {@link SuggestionSpan}s at the cursor + * position in the given {@link AccessibilityNodeInfoCompat}. + * + * @param node the node to check + * @param cursorPosition index of the cursor position + */ + // common_typos_disable + @JvmStatic + fun getSpellingSuggestions( + context: Context, + node: AccessibilityNodeInfoCompat, + cursorPosition: Int, + activeSpellCheck: Boolean + ): ImmutableList { + var cursorPositionVar = cursorPosition + val text: CharSequence? = + if (activeSpellCheck) SpellChecker.getTextWithSuggestionSpans(context, node) else node.text + val spellingSuggestions = ArrayList() + if (TextUtils.isEmpty(text) || text !is Spannable) { + LogUtils.v(TAG, "getSpellingSuggestions() text is null or not a Spannable") + return ImmutableList.of() + } + val spannedText: Spannable = text + + // Returns the suggestion if just a space or punctuation is between the typo and the cursor. + // For example: helllo,| + if (cursorPositionVar > 0) { + if (cursorPositionVar < text.length) { + // Do not return the suggestion if a word is after the cursor. For example: helllo |world + if (!Character.isLetterOrDigit(text[cursorPositionVar - 1]) && + !Character.isLetterOrDigit(text[cursorPositionVar])) { + cursorPositionVar-- + } + } else if (cursorPositionVar == text.length) { + // It is unnecessary to check the character after the cursor because the cursor is at the + // end of the line. For example: helllo | + if (!Character.isLetterOrDigit(text[cursorPositionVar - 1])) { + cursorPositionVar-- + } + } + } + + val spans = spannedText.getSpans(0, text.length, SuggestionSpan::class.java) + val logMessage = + StringBuilder( + String.format( + Locale.ENGLISH, + "cursor=[%d] suggestion_spans text=[%s] spans=[%d]", + cursorPositionVar, + text, + spans.size)) + // TODO: Uses stream to simplify it. + for (span in spans) { + val start = spannedText.getSpanStart(span) + val end = spannedText.getSpanEnd(span) + if (start <= cursorPositionVar && end >= cursorPositionVar) { + val spellingSuggestion = + SpellingSuggestion.create(start, end, text.subSequence(start, end), span) + // Ignore the span which has no suggestion to avoid announcing suggestions available but + // there is no suggestion that can be chosen. + if (span.suggestions.size > 0) { + spellingSuggestions.add(spellingSuggestion) + } else { + LogUtils.v(TAG, "%s no suggestion", text.subSequence(start, end)) + } + + logMessage.append("\n") + logMessage.append(spellingSuggestion) + } + } + + LogUtils.v(TAG, logMessage.toString()) + return ImmutableList.copyOf(spellingSuggestions) + } + + /** + * Returns the total number of typos which are in the edit field. + * + * @return 0, there is no typo or the input method for the node won't display spelling + * suggestions. + */ + @JvmStatic + fun getTypoCount(context: Context, node: AccessibilityNodeInfoCompat?): Int { + return getSuggestionSpans(context, node).size + } + + /** + * Returns {@code true} if the given {@link AccessibilityNodeInfoCompat} text includes misspelled + * words which have spelling suggestions and the input method for the node is able to display + * spelling suggestions. + */ + @JvmStatic + fun hasSpellingSuggestionsForTypos( + context: Context, node: AccessibilityNodeInfoCompat? + ): Boolean { + val spans = getSuggestionSpans(context, node) + for (span in spans) { + if (span.suggestions.size > 0) { + return true + } + } + return false + } + + /** + * Returns {@link Locale} if the given {@link AccessibilityNodeInfoCompat} supports App Locale. + */ + @JvmStatic + fun getLocalesByNode(node: AccessibilityNodeInfoCompat?): Locale? { + if (node == null || !FeatureSupport.supportAccessibilityAppLocale()) { + return null + } + val windowInfoCompat = node.window ?: return null + val windowInfo = windowInfoCompat.unwrap() ?: return null + val localeList: LocaleList? = windowInfo.locales + val defaultLocal = Locale.getDefault() + + val count = if (localeList == null) 0 else localeList.size() + if (count == 0 || defaultLocal == localeList!![0]) { + // AccessibilityWindowInfo#getLocales may return the system default locale. When the 1st entry + // matches the default locale, we don't insert the locale which will invalidate the locale + // embedded within the content. + return null + } + return localeList[0] + } + + /** Returns whether the node has requested initial accessibility focus. */ + @JvmStatic + fun hasRequestInitialAccessibilityFocus(node: AccessibilityNodeInfoCompat?): Boolean { + if (node == null) { + return false + } + + var hasRequestInitialAccessibilityFocus = node.hasRequestInitialAccessibilityFocus() + + // In the early version of AndroidX, the property was retrieved from the AccessibilityNodeInfo. + // See b/279108748 for details. + if (!hasRequestInitialAccessibilityFocus && + FeatureSupport.supportRequestInitialAccessibilityFocusNative()) { + val unwrap = node.unwrap() + if (unwrap != null) { + hasRequestInitialAccessibilityFocus = unwrap.hasRequestInitialAccessibilityFocus() + } + } + + return hasRequestInitialAccessibilityFocus + } + + /** + * Returns the rate update limitation (in milli-second) if the given {@link + * AccessibilityNodeInfoCompat} supports it. + */ + @JvmStatic + fun getMinDurationBetweenContentChangesMillis(node: AccessibilityNodeInfoCompat?): Long { + if (node == null) { + LogUtils.w(TAG, "Failed to getMinDurationBetweenContentChangesMillis/node is null") + return 0L + } + return node.minDurationBetweenContentChangesMillis + } + + /** + * Returns a list of {@link SuggestionSpan} in the given {@link AccessibilityNodeInfoCompat} text + * or an empty list if the input method for the node won't display spelling suggestions. + */ + private fun getSuggestionSpans( + context: Context, node: AccessibilityNodeInfoCompat? + ): ImmutableList { + if (node == null) { + return ImmutableList.of() + } + return getSuggestionSpans(context, node.text, node.inputType) + } + + /** + * Returns a list of {@link SuggestionSpan} in the given text or an empty list if the input type + * is no suggestion. + */ + @JvmStatic + fun getSuggestionSpans( + context: Context, text: CharSequence?, inputType: Int + ): ImmutableList { + if (TextUtils.isEmpty(text) || hasNoSuggestionsNeed(inputType)) { + return ImmutableList.of() + } + + val textWithSuggestionSpans: CharSequence? = + SpellChecker.getTextWithSuggestionSpans(context, text) + if (TextUtils.isEmpty(textWithSuggestionSpans) || + textWithSuggestionSpans !is Spannable) { + return ImmutableList.of() + } + val spannedText: Spannable = textWithSuggestionSpans + + val spans = + spannedText.getSpans(0, textWithSuggestionSpans.length, SuggestionSpan::class.java) + if (spans.size == 0) { + return ImmutableList.of() + } + + return ImmutableList.copyOf(spans) + } + + /** + * Returns {@code true}, if the input method for the {@code node} won't display spelling + * suggestions. + */ + private fun hasNoSuggestionsNeed(input: Int): Boolean { + return input == TYPE_TEXT_FLAG_NO_SUGGESTIONS + } + + private fun nodeMatchesAnyClassName( + node: AccessibilityNodeInfoCompat?, vararg classNames: CharSequence? + ): Boolean { + if (node == null || node.className == null) { + return false + } + + for (name in classNames) { + if (TextUtils.equals(node.className, name)) { + return true + } + } + + return false + } + + /** + * Splits a fully-qualified resource identifier name into its package and ID name. For example, + * "com.android.deskclock:id/analog_appwidget" which provides by {@link + * AccessibilityNodeInfoCompat#getViewIdResourceName()} + */ + class ViewResourceName private constructor( + private val packageName: String, + private val viewIdName: String, + ) { + fun packageName(): String = packageName + + fun viewIdName(): String = viewIdName + + override fun equals(other: Any?): Boolean { + if (other === this) return true + if (other !is ViewResourceName) return false + return packageName == other.packageName && viewIdName == other.viewIdName + } + + override fun hashCode(): Int { + var h = 1 + h *= 1000003 + h = h xor packageName.hashCode() + h *= 1000003 + h = h xor viewIdName.hashCode() + return h + } + + override fun toString(): String { + return "ViewResourceName= " + + StringBuilderUtils.joinFields( + StringBuilderUtils.optionalText("packageName", packageName), + StringBuilderUtils.optionalText("viewIdName", viewIdName)) + } + + companion object { + /** Creates a ViewResourceName instance by {@link AccessibilityNodeInfoCompat}. */ + @JvmStatic + fun create(node: AccessibilityNodeInfoCompat): ViewResourceName? { + val resourceName = node.viewIdResourceName + if (TextUtils.isEmpty(resourceName)) { + return null + } + + val splitId = RESOURCE_NAME_SPLIT_PATTERN.split(resourceName, 2) + if (splitId.size != 2 || TextUtils.isEmpty(splitId[0]) || TextUtils.isEmpty(splitId[1])) { + // Invalid view resource name. + LogUtils.w(TAG, "Failed to parse resource: %s", resourceName) + return null + } + + return ViewResourceName(splitId[0], splitId[1]) + } + } + } + + /** + * Represents a {@link ClickableSpan} and the string it spans to reduce the effort of downstream + * consumers; getting the spanned string is non-trivial. + */ + class ClickableString private constructor( + private val string: String, + private val clickableSpan: ClickableSpan, + ) { + fun string(): String = string + + fun clickableSpan(): ClickableSpan = clickableSpan + + // ClickableSpan.onClick is actually fine with a null param. + fun onClick() { + // The platform annotates ClickableSpan.onClick(View) as @NonNull, but the original Java + // code intentionally passes null; erase the nullability with an unchecked generic cast. + clickableSpan().onClick(uncheckedNullView()) + } + + @Suppress("UNCHECKED_CAST") + private fun uncheckedNullView(): T = null as T + + override fun equals(other: Any?): Boolean { + if (other === this) return true + if (other !is ClickableString) return false + return string == other.string && clickableSpan == other.clickableSpan + } + + override fun hashCode(): Int { + var h = 1 + h *= 1000003 + h = h xor string.hashCode() + h *= 1000003 + h = h xor clickableSpan.hashCode() + return h + } + + override fun toString(): String = + "ClickableString{" + + "string=$string, " + + "clickableSpan=$clickableSpan}" + + companion object { + @JvmStatic + fun create(string: String, clickableSpan: ClickableSpan): ClickableString = + ClickableString(string, clickableSpan) + } + } + + /** A wrapper of {@link SuggestionSpan}. */ + class SpellingSuggestion private constructor( + private val start: Int, + private val end: Int, + private val misspelledWord: CharSequence, + private val suggestionSpan: SuggestionSpan, + ) { + fun start(): Int = start + + fun end(): Int = end + + fun misspelledWord(): CharSequence = misspelledWord + + fun suggestionSpan(): SuggestionSpan = suggestionSpan + + override fun equals(other: Any?): Boolean { + if (other === this) return true + if (other !is SpellingSuggestion) return false + return start == other.start && + end == other.end && + misspelledWord == other.misspelledWord && + suggestionSpan == other.suggestionSpan + } + + override fun hashCode(): Int { + var h = 1 + h *= 1000003 + h = h xor start + h *= 1000003 + h = h xor end + h *= 1000003 + h = h xor misspelledWord.hashCode() + h *= 1000003 + h = h xor suggestionSpan.hashCode() + return h + } + + override fun toString(): String { + val suggestionsString = + StringBuilder() + .append( + String.format(Locale.ENGLISH, "[%d-%d][%s]", start(), end(), misspelledWord())) + for (suggestion in suggestionSpan().suggestions) { + suggestionsString.append(String.format(Locale.ENGLISH, "[suggestion=%s]", suggestion)) + } + + return suggestionsString.toString() + } + + companion object { + @JvmStatic + fun create( + start: Int, end: Int, misspelledWord: CharSequence, suggestionSpan: SuggestionSpan + ): SpellingSuggestion = + SpellingSuggestion(start, end, misspelledWord, suggestionSpan) + } + } + + private fun printId(node: AccessibilityNodeInfoCompat): String { + return String.format("Node(id=%s class=%s)", node.hashCode(), node.className) + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/FocusFinder.java b/utils/src/main/java/com/google/android/accessibility/utils/FocusFinder.java deleted file mode 100644 index 849b7aa9f..000000000 --- a/utils/src/main/java/com/google/android/accessibility/utils/FocusFinder.java +++ /dev/null @@ -1,119 +0,0 @@ -/* - * Copyright (C) 2014 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not - * use this file except in compliance with the License. You may obtain a copy of - * the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations under - * the License. - */ - -package com.google.android.accessibility.utils; - -import static android.view.accessibility.AccessibilityNodeInfo.FOCUS_ACCESSIBILITY; -import static android.view.accessibility.AccessibilityNodeInfo.FOCUS_INPUT; - -import android.accessibilityservice.AccessibilityService; -import android.view.accessibility.AccessibilityNodeInfo; -import androidx.annotation.IntDef; -import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; -import com.google.android.libraries.accessibility.utils.log.LogUtils; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import org.checkerframework.checker.nullness.qual.Nullable; - -/** - * Functions to find focus. - * - *

NOTE: To give a consistent behaviour, this code should be kept in sync with the relevant - * subset of code in the {@code CursorController} class in TalkBack. - */ -public class FocusFinder { - - private static final String TAG = "FocusFinder"; - private final AccessibilityService service; - - /** Screen focus types in accessibility. */ - @IntDef({FOCUS_INPUT, FOCUS_ACCESSIBILITY}) - @Retention(RetentionPolicy.SOURCE) - public @interface FocusType {} - - public FocusFinder(AccessibilityService service) { - this.service = service; - } - - /** Returns the view that has accessibility-focus. */ - public @Nullable AccessibilityNodeInfoCompat findAccessibilityFocus() { - return getAccessibilityFocusNode(service, false); - } - - /** Finds the view that has the specified focus type. The type is defined in {@link FocusType}. */ - public @Nullable AccessibilityNodeInfoCompat findFocusCompat(@FocusType int focusType) { - switch (focusType) { - case FOCUS_ACCESSIBILITY -> { - return FocusFinder.getAccessibilityFocusNode(service, false); - } - case FOCUS_INPUT -> { - return AccessibilityNodeInfoUtils.toCompat( - service.findFocus(AccessibilityNodeInfo.FOCUS_INPUT)); - } - default -> {} - } - - return null; - } - - /** - * Returns the accessibility focus by calling {@link AccessibilityService#findFocus(int)}. If no - * focus is found, it allows to return the root node of the active window. - * - * @param fallbackOnRoot true for returning the root node if no focus is found. - */ - public static @Nullable AccessibilityNodeInfoCompat getAccessibilityFocusNode( - AccessibilityService service, boolean fallbackOnRoot) { - AccessibilityNodeInfo ret = null; - AccessibilityNodeInfo focused = service.findFocus(AccessibilityNodeInfo.FOCUS_ACCESSIBILITY); - if (focused == null) { - // Find the focused node from the root of the active window, as a alternative method if - // couldn't find the focused node by AccessibilityService. - AccessibilityNodeInfo root = service.getRootInActiveWindow(); - if (root != null) { - focused = root.findFocus(AccessibilityNodeInfo.FOCUS_ACCESSIBILITY); - } - } - - if (focused != null) { - // If the focused node is from WebView, we still need to return it even though it's not - // visible to user. REFERTO for details. - if (focused.isVisibleToUser() - || WebInterfaceUtils.isWebContainer(AccessibilityNodeInfoUtils.toCompat(focused))) { - ret = focused; - focused = null; - } - } - - if (ret == null && fallbackOnRoot) { - ret = service.getRootInActiveWindow(); - if (ret == null) { - LogUtils.e(TAG, "No current window root"); - } - } - - if (ret != null) { - // When AccessibilityNodeProvider is used, the returned node may be stale. - boolean exist = ret.refresh(); - if (!exist) { - return null; - } - return AccessibilityNodeInfoUtils.toCompat(ret); - } - - return null; - } -} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/FocusFinder.kt b/utils/src/main/java/com/google/android/accessibility/utils/FocusFinder.kt new file mode 100644 index 000000000..f63317aa1 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/FocusFinder.kt @@ -0,0 +1,116 @@ +/* + * Copyright (C) The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Ported from Java to Kotlin. + */ + +package com.google.android.accessibility.utils + +import android.accessibilityservice.AccessibilityService +import android.view.accessibility.AccessibilityNodeInfo +import android.view.accessibility.AccessibilityNodeInfo.FOCUS_ACCESSIBILITY +import android.view.accessibility.AccessibilityNodeInfo.FOCUS_INPUT +import androidx.annotation.IntDef +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import com.google.android.libraries.accessibility.utils.log.LogUtils + +/** + * Functions to find focus. + * + *

NOTE: To give a consistent behaviour, this code should be kept in sync with the relevant + * subset of code in the {@code CursorController} class in TalkBack. + */ +class FocusFinder(private val service: AccessibilityService) { + + /** Screen focus types in accessibility. */ + @IntDef(FOCUS_INPUT, FOCUS_ACCESSIBILITY) + @Retention(AnnotationRetention.SOURCE) + annotation class FocusType + + /** Returns the view that has accessibility-focus. */ + fun findAccessibilityFocus(): AccessibilityNodeInfoCompat? = + getAccessibilityFocusNode(service, false) + + /** Finds the view that has the specified focus type. The type is defined in {@link FocusType}. */ + fun findFocusCompat(@FocusType focusType: Int): AccessibilityNodeInfoCompat? { + when (focusType) { + FOCUS_ACCESSIBILITY -> { + return getAccessibilityFocusNode(service, false) + } + FOCUS_INPUT -> { + return AccessibilityNodeInfoUtils.toCompat( + service.findFocus(AccessibilityNodeInfo.FOCUS_INPUT)) + } + else -> {} + } + + return null + } + + companion object { + private const val TAG = "FocusFinder" + + /** + * Returns the accessibility focus by calling {@link AccessibilityService#findFocus(int)}. If no + * focus is found, it allows to return the root node of the active window. + * + * @param fallbackOnRoot true for returning the root node if no focus is found. + */ + @JvmStatic + fun getAccessibilityFocusNode( + service: AccessibilityService, + fallbackOnRoot: Boolean, + ): AccessibilityNodeInfoCompat? { + var ret: AccessibilityNodeInfo? = null + var focused = service.findFocus(AccessibilityNodeInfo.FOCUS_ACCESSIBILITY) + if (focused == null) { + // Find the focused node from the root of the active window, as a alternative method if + // couldn't find the focused node by AccessibilityService. + val root = service.rootInActiveWindow + if (root != null) { + focused = root.findFocus(AccessibilityNodeInfo.FOCUS_ACCESSIBILITY) + } + } + + if (focused != null) { + // If the focused node is from WebView, we still need to return it even though it's not + // visible to user. REFERTO for details. + if (focused.isVisibleToUser || + WebInterfaceUtils.isWebContainer(AccessibilityNodeInfoUtils.toCompat(focused)) + ) { + ret = focused + } + } + + if (ret == null && fallbackOnRoot) { + ret = service.rootInActiveWindow + if (ret == null) { + LogUtils.e(TAG, "No current window root") + } + } + + if (ret != null) { + // When AccessibilityNodeProvider is used, the returned node may be stale. + val exist = ret.refresh() + if (!exist) { + return null + } + return AccessibilityNodeInfoUtils.toCompat(ret) + } + + return null + } + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/Role.java b/utils/src/main/java/com/google/android/accessibility/utils/Role.kt similarity index 75% rename from utils/src/main/java/com/google/android/accessibility/utils/Role.java rename to utils/src/main/java/com/google/android/accessibility/utils/Role.kt index 0cdbc8bdd..1171b4e57 100644 --- a/utils/src/main/java/com/google/android/accessibility/utils/Role.java +++ b/utils/src/main/java/com/google/android/accessibility/utils/Role.kt @@ -1,36 +1,34 @@ /* - * Copyright (C) 2017 The Android Open Source Project + * Copyright (C) The Android Open Source Project * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not - * use this file except in compliance with the License. You may obtain a copy of - * the License at + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations under - * the License. + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Ported from Java to Kotlin. */ -package com.google.android.accessibility.utils; - -import android.view.accessibility.AccessibilityEvent; -import android.view.accessibility.AccessibilityNodeInfo; -import android.widget.ProgressBar; -import android.widget.SeekBar; -import androidx.annotation.IntDef; -import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; -import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat; -import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.CollectionInfoCompat; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import org.checkerframework.checker.nullness.qual.Nullable; -/** Utility methods for managing AccessibilityNodeInfo Roles. */ -public class Role { +package com.google.android.accessibility.utils - /** +import android.view.accessibility.AccessibilityEvent +import android.view.accessibility.AccessibilityNodeInfo +import android.widget.ProgressBar +import android.widget.SeekBar +import androidx.annotation.IntDef +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat + +/** Utility methods for managing AccessibilityNodeInfo Roles. */ +object Role { + /** * Ids of user-interface element roles, which are flexibly mapped from specific UI classes. This * mapping allows us to abstract similar UI elements to the same role, and to isolate UI element * interpretation logic. @@ -140,7 +138,7 @@ public class Role { * * */ - @IntDef({ + @IntDef( ROLE_NONE, ROLE_BUTTON, ROLE_CHECK_BOX, @@ -185,14 +183,15 @@ public class Role { ROLE_NAVIGATION, ROLE_SEARCH, ROLE_VOICE_DICTATION_BUTTON, - }) - @Retention(RetentionPolicy.SOURCE) - public @interface RoleName {} + ) + @Retention(AnnotationRetention.SOURCE) + annotation class RoleName + // Please keep the constants in this list sorted by constant index order, and not by // alphabetical order. If you add a new constant, it must also be added to the RoleName // annotation interface. - public static final int ROLE_NONE = 0; + const val ROLE_NONE = 0 /** * Role for an {@link AccessibilityNodeInfo} node which corresponds to a {@link @@ -245,7 +244,7 @@ public class Role { * it. * */ - public static final int ROLE_BUTTON = 1; + const val ROLE_BUTTON = 1 /** * Role for an {@link AccessibilityNodeInfo} node which corresponds to a {@link @@ -257,10 +256,10 @@ public class Role { *

  • node MUST be checkable * */ - public static final int ROLE_CHECK_BOX = 2; + const val ROLE_CHECK_BOX = 2 - public static final int ROLE_DROP_DOWN_LIST = 3; - public static final int ROLE_EDIT_TEXT = 4; + const val ROLE_DROP_DOWN_LIST = 3 + const val ROLE_EDIT_TEXT = 4 /** * Role for an {@link AccessibilityNodeInfo} node which corresponds to a {@link @@ -274,10 +273,10 @@ public class Role { * MUST return values >=1 for non-empty grids. * */ - public static final int ROLE_GRID = 5; + const val ROLE_GRID = 5 - public static final int ROLE_IMAGE = 6; - public static final int ROLE_IMAGE_BUTTON = 7; + const val ROLE_IMAGE = 6 + const val ROLE_IMAGE_BUTTON = 7 /** * Role for an {@link AccessibilityNodeInfo} node which corresponds to a {@link @@ -292,7 +291,7 @@ public class Role { * (in a row) {@link CollectionInfo#getRowCount()} MUST return 1. * */ - public static final int ROLE_LIST = 8; + const val ROLE_LIST = 8 /** * Role for an {@link AccessibilityNodeInfo} node which corresponds to a {@link @@ -311,7 +310,7 @@ public class Role { * */ // TODO: add ROLE_RADIO_GROUP. - public static final int ROLE_RADIO_BUTTON = 9; + const val ROLE_RADIO_BUTTON = 9 /** * Role of an {@link AccessibilityNodeInfo} node corresponding to a {@link @@ -351,7 +350,7 @@ public class Role { * * */ - public static final int ROLE_SEEK_CONTROL = 10; + const val ROLE_SEEK_CONTROL = 10 /** * Role for an {@link AccessibilityNodeInfo} node which corresponds to a {@link @@ -363,14 +362,14 @@ public class Role { *
  • node MUST be checkable * */ - public static final int ROLE_SWITCH = 11; + const val ROLE_SWITCH = 11 - public static final int ROLE_TAB_BAR = 12; - public static final int ROLE_TOGGLE_BUTTON = 13; - public static final int ROLE_VIEW_GROUP = 14; - public static final int ROLE_WEB_VIEW = 15; - public static final int ROLE_PAGER = 16; - public static final int ROLE_CHECKED_TEXT_VIEW = 17; + const val ROLE_TAB_BAR = 12 + const val ROLE_TOGGLE_BUTTON = 13 + const val ROLE_VIEW_GROUP = 14 + const val ROLE_WEB_VIEW = 15 + const val ROLE_PAGER = 16 + const val ROLE_CHECKED_TEXT_VIEW = 17 // TODO: define specific expectations for indeterminate ProgressBar. /** @@ -389,24 +388,24 @@ public class Role { *
  • Generally, node SHOULD NOT be actionable. * */ - public static final int ROLE_PROGRESS_BAR = 18; - - public static final int ROLE_ACTION_BAR_TAB = 19; - public static final int ROLE_DRAWER_LAYOUT = 20; - public static final int ROLE_SLIDING_DRAWER = 21; - public static final int ROLE_ICON_MENU = 22; - public static final int ROLE_TOAST = 23; - public static final int ROLE_ALERT_DIALOG = 24; - public static final int ROLE_DATE_PICKER_DIALOG = 25; - public static final int ROLE_TIME_PICKER_DIALOG = 26; - public static final int ROLE_DATE_PICKER = 27; - public static final int ROLE_TIME_PICKER = 28; - public static final int ROLE_NUMBER_PICKER = 29; - public static final int ROLE_SCROLL_VIEW = 30; - public static final int ROLE_HORIZONTAL_SCROLL_VIEW = 31; - public static final int ROLE_KEYBOARD_KEY = 32; - public static final int ROLE_TALKBACK_EDIT_TEXT_OVERLAY = 33; - public static final int ROLE_TEXT_ENTRY_KEY = 34; + const val ROLE_PROGRESS_BAR = 18 + + const val ROLE_ACTION_BAR_TAB = 19 + const val ROLE_DRAWER_LAYOUT = 20 + const val ROLE_SLIDING_DRAWER = 21 + const val ROLE_ICON_MENU = 22 + const val ROLE_TOAST = 23 + const val ROLE_ALERT_DIALOG = 24 + const val ROLE_DATE_PICKER_DIALOG = 25 + const val ROLE_TIME_PICKER_DIALOG = 26 + const val ROLE_DATE_PICKER = 27 + const val ROLE_TIME_PICKER = 28 + const val ROLE_NUMBER_PICKER = 29 + const val ROLE_SCROLL_VIEW = 30 + const val ROLE_HORIZONTAL_SCROLL_VIEW = 31 + const val ROLE_KEYBOARD_KEY = 32 + const val ROLE_TALKBACK_EDIT_TEXT_OVERLAY = 33 + const val ROLE_TEXT_ENTRY_KEY = 34 // TODO: add expectations of CollectionItemInfo (here and for ROLE_GRID) // TODO: Talkback should announce the correct collection size for staggered grids. @@ -427,9 +426,9 @@ public class Role { * CollectionInfo#getColumnCount()} MUST return -1. * */ - public static final int ROLE_STAGGERED_GRID = 35; + const val ROLE_STAGGERED_GRID = 35 - public static final int ROLE_FLOATING_ACTION_BUTTON = 36; + const val ROLE_FLOATING_ACTION_BUTTON = 36 /** * Role for an {@link AccessibilityNodeInfo} node which contains content that the user @@ -452,7 +451,7 @@ public class Role { * {@link AccessibilityWindowInfo#getTitle()} must return the node title. * */ - public static final int ROLE_NON_MODAL_ALERT = 37; + const val ROLE_NON_MODAL_ALERT = 37 /** * Role for an {@link AccessibilityNodeInfo} node which aligns with a */ - public static final int ROLE_SNACKBAR = 38; + const val ROLE_SNACKBAR = 38 /** * Role for an {@link AccessibilityNodeInfo} node which corresponds to a {@code @@ -493,13 +492,13 @@ public class Role { * should also be conveyed to users of screen readers. * */ - public static final int ROLE_AUDIO_CAPTION = 39; + const val ROLE_AUDIO_CAPTION = 39 /** * Role for an {@link AccessibilityNodeInfo} node which corresponds to a {@link * android.app.Dialog} or equivalent. */ - public static final int ROLE_DIALOG = 40; + const val ROLE_DIALOG = 40 /** * Role for an {@link AccessibilityNodeInfo} node, which corresponds to a landmark that @@ -536,7 +535,7 @@ public class Role { * } * */ - public static final int ROLE_NAVIGATION = 41; + const val ROLE_NAVIGATION = 41 /** * Role for an {@link AccessibilityNodeInfo} node, which corresponds to a landmark that @@ -572,7 +571,7 @@ public class Role { * } * */ - public static final int ROLE_SEARCH = 42; + const val ROLE_SEARCH = 42 /** * Role for an {@link AccessibilityNodeInfo} node, which corresponds to a voice dictation @@ -584,59 +583,59 @@ public class Role { *
  • node MUST on the input method editor(IME) window. * */ - public static final int ROLE_VOICE_DICTATION_BUTTON = 43; + const val ROLE_VOICE_DICTATION_BUTTON = 43 /** Used to identify the voice dictation node. */ - public static final String VOICE_DICTATION_CLASSNAME = - "android.speech.SpeechRecognizer.VoiceDictationButton"; + const val VOICE_DICTATION_CLASSNAME = + "android.speech.SpeechRecognizer.VoiceDictationButton" /** Used to identify and ignore a11y overlay windows created by Talkback. */ - public static final String TALKBACK_EDIT_TEXT_OVERLAY_CLASSNAME = "TalkbackEditTextOverlay"; + const val TALKBACK_EDIT_TEXT_OVERLAY_CLASSNAME = "TalkbackEditTextOverlay" /** Used to identify lists. */ - public static final String TALKBACK_LIST_CLASSNAME = "android.widget.listview"; + const val TALKBACK_LIST_CLASSNAME = "android.widget.listview" /** Used to identify grids. */ - public static final String TALKBACK_GRID_CLASSNAME = "android.widget.gridview"; + const val TALKBACK_GRID_CLASSNAME = "android.widget.gridview" /** Used to identify staggered grids. */ - public static final String TALKBACK_STAGGERED_GRID_CLASSNAME = - "androidx.recyclerview.widget.StaggeredGridLayoutManager"; + const val TALKBACK_STAGGERED_GRID_CLASSNAME = + "androidx.recyclerview.widget.StaggeredGridLayoutManager" /** Used to identify floating action buttons. */ - public static final String TALKBACK_FLOATING_ACTION_BUTTON_CLASSNAME = - "com.google.android.material.floatingactionbutton.FloatingActionButton"; + const val TALKBACK_FLOATING_ACTION_BUTTON_CLASSNAME = + "com.google.android.material.floatingactionbutton.FloatingActionButton" /** Used to identify non-modal alerts */ - public static final String NON_MODAL_ALERT_CLASSNAME = - "com.google.android.material.snackbar.BaseTransientBottomBar"; + const val NON_MODAL_ALERT_CLASSNAME = + "com.google.android.material.snackbar.BaseTransientBottomBar" /** Used to identify Material Snackbars. */ - public static final String SNACKBAR_CLASSNAME = "com.google.android.material.snackbar.SnackBar"; + const val SNACKBAR_CLASSNAME = "com.google.android.material.snackbar.SnackBar" /** Used to identify nodes that represent audio captions. */ - public static final String AUDIO_CAPTION_CLASSNAME = "androidx.media3.ui.SubtitleView"; + const val AUDIO_CAPTION_CLASSNAME = "androidx.media3.ui.SubtitleView" /** Used to identify dialogs. */ - public static final String DIALOG_CLASSNAME = "android.app.Dialog"; + const val DIALOG_CLASSNAME = "android.app.Dialog" /** Used to identify alert dialogs. */ - public static final String ALERT_DIALOG_CLASSNAME = "android.app.AlertDialog"; + const val ALERT_DIALOG_CLASSNAME = "android.app.AlertDialog" /** Used to identify Material NavigationView. */ - public static final String MATERIAL_NAVIGATION_VIEW_CLASSNAME = - "com.google.android.material.navigation.NavigationView"; + const val MATERIAL_NAVIGATION_VIEW_CLASSNAME = + "com.google.android.material.navigation.NavigationView" /** Used to identify Material NavigationBarView. */ - public static final String MATERIAL_NAVIGATION_BAR_CLASSNAME = - "com.google.android.material.navigation.NavigationBarView"; + const val MATERIAL_NAVIGATION_BAR_CLASSNAME = + "com.google.android.material.navigation.NavigationBarView" /** Used to identify Material NavigationRailView. */ - public static final String MATERIAL_NAVIGATION_RAIL_CLASSNAME = - "com.google.android.material.navigationrail.NavigationRailView"; + const val MATERIAL_NAVIGATION_RAIL_CLASSNAME = + "com.google.android.material.navigationrail.NavigationRailView" /** Used to identify a search view. */ - public static final String SEARCH_CLASSNAME = "android.widget.SearchView"; + const val SEARCH_CLASSNAME = "android.widget.SearchView" /** * Gets the source {@link Role} from the {@link AccessibilityEvent}. @@ -645,45 +644,46 @@ public class Role { * #ROLE_NONE}, fallback to check {@link AccessibilityNodeInfoCompat#getClassName()} of the source * node. */ + @JvmStatic @RoleName - public static int getSourceRole(AccessibilityEvent event) { + fun getSourceRole(event: AccessibilityEvent?): Int { if (event == null) { - return ROLE_NONE; + return ROLE_NONE } // Try to get role from event's class name. - @RoleName int role = sourceClassNameToRole(event); + @RoleName val role = sourceClassNameToRole(event) if (role != ROLE_NONE) { - return role; + return role } // Extract event's source node, and map source node class to role. - return getRole(event.getSource()); + return getRole(event.source) } /** Find role from source event's class name string. */ @RoleName - private static int sourceClassNameToRole(AccessibilityEvent event) { + private fun sourceClassNameToRole(event: AccessibilityEvent?): Int { if (event == null) { - return ROLE_NONE; + return ROLE_NONE } // Event TYPE_NOTIFICATION_STATE_CHANGED always has null source node. - CharSequence eventClassName = event.getClassName(); + val eventClassName: CharSequence? = event.className - // When comparing event.getClassName() to class name of standard widgets, we should take care of + // When comparing event.className to class name of standard widgets, we should take care of // the order of the "if" statements: check subclasses before checking superclasses. // Toast.TN is a private class, thus we have to hard code the class name. // "$TN" is only in the class-name before android-R. - if (ClassLoadingCache.checkInstanceOf(eventClassName, "android.widget.Toast$TN") + if (ClassLoadingCache.checkInstanceOf(eventClassName, "android.widget.Toast\$TN") || ClassLoadingCache.checkInstanceOf(eventClassName, "android.widget.Toast")) { - return ROLE_TOAST; + return ROLE_TOAST } // Some events have different value for getClassName() and getSource().getClass() - if (ClassLoadingCache.checkInstanceOf(eventClassName, android.app.ActionBar.Tab.class)) { - return ROLE_ACTION_BAR_TAB; + if (ClassLoadingCache.checkInstanceOf(eventClassName, android.app.ActionBar.Tab::class.java)) { + return ROLE_ACTION_BAR_TAB } // ////////////////////////////////////////////////////////////////////////////////////////// @@ -691,68 +691,68 @@ private static int sourceClassNameToRole(AccessibilityEvent event) { // Inheritance: View->ViewGroup->DrawerLayout if (ClassLoadingCache.checkInstanceOf( - eventClassName, androidx.drawerlayout.widget.DrawerLayout.class) + eventClassName, androidx.drawerlayout.widget.DrawerLayout::class.java) || ClassLoadingCache.checkInstanceOf(eventClassName, "androidx.core.widget.DrawerLayout")) { - return ROLE_DRAWER_LAYOUT; + return ROLE_DRAWER_LAYOUT } // Inheritance: View->ViewGroup->SlidingDrawer - if (ClassLoadingCache.checkInstanceOf(eventClassName, android.widget.SlidingDrawer.class)) { - return ROLE_SLIDING_DRAWER; + if (ClassLoadingCache.checkInstanceOf(eventClassName, android.widget.SlidingDrawer::class.java)) { + return ROLE_SLIDING_DRAWER } // Inheritance: View->ViewGroup->IconMenuView // IconMenuView is a hidden class, thus we have to hard code the class name. if (ClassLoadingCache.checkInstanceOf( eventClassName, "com.android.internal.view.menu.IconMenuView")) { - return ROLE_ICON_MENU; + return ROLE_ICON_MENU } // Inheritance: View->ViewGroup->FrameLayout->DatePicker - if (ClassLoadingCache.checkInstanceOf(eventClassName, android.widget.DatePicker.class)) { - return ROLE_DATE_PICKER; + if (ClassLoadingCache.checkInstanceOf(eventClassName, android.widget.DatePicker::class.java)) { + return ROLE_DATE_PICKER } // Inheritance: View->ViewGroup->FrameLayout->TimePicker - if (ClassLoadingCache.checkInstanceOf(eventClassName, android.widget.TimePicker.class)) { - return ROLE_TIME_PICKER; + if (ClassLoadingCache.checkInstanceOf(eventClassName, android.widget.TimePicker::class.java)) { + return ROLE_TIME_PICKER } // Inheritance: View->ViewGroup->LinearLayout->NumberPicker - if (ClassLoadingCache.checkInstanceOf(eventClassName, android.widget.NumberPicker.class)) { - return ROLE_NUMBER_PICKER; + if (ClassLoadingCache.checkInstanceOf(eventClassName, android.widget.NumberPicker::class.java)) { + return ROLE_NUMBER_PICKER } // Inheritance: View->ViewGroup->LinearLayout->SearchView if (ClassLoadingCache.checkInstanceOf(eventClassName, SEARCH_CLASSNAME)) { - return ROLE_SEARCH; + return ROLE_SEARCH } // ////////////////////////////////////////////////////////////////////////////////////////// // Subclasses of Dialog. // Inheritance: Dialog->AlertDialog->DatePickerDialog - if (ClassLoadingCache.checkInstanceOf(eventClassName, android.app.DatePickerDialog.class)) { - return ROLE_DATE_PICKER_DIALOG; + if (ClassLoadingCache.checkInstanceOf(eventClassName, android.app.DatePickerDialog::class.java)) { + return ROLE_DATE_PICKER_DIALOG } // Inheritance: Dialog->AlertDialog->TimePickerDialog - if (ClassLoadingCache.checkInstanceOf(eventClassName, android.app.TimePickerDialog.class)) { - return ROLE_TIME_PICKER_DIALOG; + if (ClassLoadingCache.checkInstanceOf(eventClassName, android.app.TimePickerDialog::class.java)) { + return ROLE_TIME_PICKER_DIALOG } // Inheritance: Dialog->AlertDialog if (ClassLoadingCache.checkInstanceOf(eventClassName, ALERT_DIALOG_CLASSNAME) || ClassLoadingCache.checkInstanceOf( eventClassName, "androidx.appcompat.app.AlertDialog")) { - return ROLE_ALERT_DIALOG; + return ROLE_ALERT_DIALOG } if (ClassLoadingCache.checkInstanceOf(eventClassName, DIALOG_CLASSNAME)) { - return ROLE_DIALOG; + return ROLE_DIALOG } if (ClassLoadingCache.checkInstanceOf(eventClassName, AUDIO_CAPTION_CLASSNAME)) { - return ROLE_AUDIO_CAPTION; + return ROLE_AUDIO_CAPTION } // Inheritance: View->ViewGroup->FrameLayout->NavigationView @@ -761,220 +761,221 @@ private static int sourceClassNameToRole(AccessibilityEvent event) { || ClassLoadingCache.checkInstanceOf(eventClassName, MATERIAL_NAVIGATION_RAIL_CLASSNAME) // Inheritance: View->ViewGroup->FrameLayout->NavigationBarView || ClassLoadingCache.checkInstanceOf(eventClassName, MATERIAL_NAVIGATION_BAR_CLASSNAME)) { - return ROLE_NAVIGATION; + return ROLE_NAVIGATION } - return ROLE_NONE; + return ROLE_NONE } /** Gets {@link Role} for {@link AccessibilityNodeInfoCompat}. */ @RoleName - public static int getRole(@Nullable AccessibilityNodeInfoCompat node) { + @JvmStatic + fun getRole(node: AccessibilityNodeInfoCompat?): Int { if (node == null) { - return ROLE_NONE; + return ROLE_NONE } // We check Text entry key from property instead of class, so it needs to be in the beginning. - if (node.isTextEntryKey()) { - return ROLE_TEXT_ENTRY_KEY; + if (node.isTextEntryKey) { + return ROLE_TEXT_ENTRY_KEY } - CharSequence className = node.getClassName(); + val className: CharSequence? = node.className - // When comparing node.getClassName() to class name of standard widgets, we should take care of + // When comparing node.className to class name of standard widgets, we should take care of // the order of the "if" statements: check subclasses before checking superclasses. // e.g. RadioButton is a subclass of Button, we should check Role RadioButton first and fall // down to check Role Button. // Identifies a11y overlay added by Talkback on edit texts. if (ClassLoadingCache.checkInstanceOf(className, TALKBACK_EDIT_TEXT_OVERLAY_CLASSNAME)) { - return ROLE_TALKBACK_EDIT_TEXT_OVERLAY; + return ROLE_TALKBACK_EDIT_TEXT_OVERLAY } // Inheritance: View->ImageView->ImageButton->FloatingActionButton if (ClassLoadingCache.checkInstanceOf(className, TALKBACK_FLOATING_ACTION_BUTTON_CLASSNAME)) { - return ROLE_FLOATING_ACTION_BUTTON; + return ROLE_FLOATING_ACTION_BUTTON } if (className != null && className.toString().equals(VOICE_DICTATION_CLASSNAME)) { - return ROLE_VOICE_DICTATION_BUTTON; + return ROLE_VOICE_DICTATION_BUTTON } // Inheritance: View->ImageView - if (ClassLoadingCache.checkInstanceOf(className, android.widget.ImageView.class)) { - return node.isClickable() ? ROLE_IMAGE_BUTTON : ROLE_IMAGE; + if (ClassLoadingCache.checkInstanceOf(className, android.widget.ImageView::class.java)) { + return if (node.isClickable) ROLE_IMAGE_BUTTON else ROLE_IMAGE } if (className != null && className.toString().equals("android.widget.Image")) { // "android.widget.Image" is used in some WebView to play as an image. - return node.isClickable() ? ROLE_IMAGE_BUTTON : ROLE_IMAGE; + return if (node.isClickable) ROLE_IMAGE_BUTTON else ROLE_IMAGE } // ////////////////////////////////////////////////////////////////////////////////////////// // Subclasses of TextView. // Inheritance: View->TextView->Button->CompoundButton->Switch - if (ClassLoadingCache.checkInstanceOf(className, android.widget.Switch.class)) { - return ROLE_SWITCH; + if (ClassLoadingCache.checkInstanceOf(className, android.widget.Switch::class.java)) { + return ROLE_SWITCH } // Inheritance: View->TextView->Button->CompoundButton->ToggleButton - if (ClassLoadingCache.checkInstanceOf(className, android.widget.ToggleButton.class)) { - return ROLE_TOGGLE_BUTTON; + if (ClassLoadingCache.checkInstanceOf(className, android.widget.ToggleButton::class.java)) { + return ROLE_TOGGLE_BUTTON } // Inheritance: View->TextView->Button->CompoundButton->RadioButton - if (ClassLoadingCache.checkInstanceOf(className, android.widget.RadioButton.class)) { - return ROLE_RADIO_BUTTON; + if (ClassLoadingCache.checkInstanceOf(className, android.widget.RadioButton::class.java)) { + return ROLE_RADIO_BUTTON } // Inheritance: View->TextView->Button->CompoundButton - if (ClassLoadingCache.checkInstanceOf(className, android.widget.CompoundButton.class)) { - return ROLE_CHECK_BOX; + if (ClassLoadingCache.checkInstanceOf(className, android.widget.CompoundButton::class.java)) { + return ROLE_CHECK_BOX } // Inheritance: View->TextView->Button - if (ClassLoadingCache.checkInstanceOf(className, android.widget.Button.class)) { - return ROLE_BUTTON; + if (ClassLoadingCache.checkInstanceOf(className, android.widget.Button::class.java)) { + return ROLE_BUTTON } // Inheritance: View->TextView->CheckedTextView - if (ClassLoadingCache.checkInstanceOf(className, android.widget.CheckedTextView.class)) { - return ROLE_CHECKED_TEXT_VIEW; + if (ClassLoadingCache.checkInstanceOf(className, android.widget.CheckedTextView::class.java)) { + return ROLE_CHECKED_TEXT_VIEW } // Inheritance: View->TextView->EditText - if (ClassLoadingCache.checkInstanceOf(className, android.widget.EditText.class)) { - return ROLE_EDIT_TEXT; + if (ClassLoadingCache.checkInstanceOf(className, android.widget.EditText::class.java)) { + return ROLE_EDIT_TEXT } // ////////////////////////////////////////////////////////////////////////////////////////// // Subclasses of ProgressBar. // Inheritance: View->ProgressBar->AbsSeekBar->SeekBar - if (ClassLoadingCache.checkInstanceOf(className, SeekBar.class) + if (ClassLoadingCache.checkInstanceOf(className, SeekBar::class.java) || (AccessibilityNodeInfoUtils.hasValidRangeInfo(node) && AccessibilityNodeInfoUtils.supportsAction( node, android.R.id.accessibilityActionSetProgress))) { - return ROLE_SEEK_CONTROL; + return ROLE_SEEK_CONTROL } // Inheritance: View->ProgressBar - if (ClassLoadingCache.checkInstanceOf(className, ProgressBar.class) + if (ClassLoadingCache.checkInstanceOf(className, ProgressBar::class.java) || (AccessibilityNodeInfoUtils.hasValidRangeInfo(node) && !AccessibilityNodeInfoUtils.supportsAction( node, android.R.id.accessibilityActionSetProgress))) { // ProgressBar check must come after SeekBar, because SeekBar specializes ProgressBar. - return ROLE_PROGRESS_BAR; + return ROLE_PROGRESS_BAR } if (ClassLoadingCache.checkInstanceOf( - className, android.inputmethodservice.Keyboard.Key.class)) { - return ROLE_KEYBOARD_KEY; + className, android.inputmethodservice.Keyboard.Key::class.java)) { + return ROLE_KEYBOARD_KEY } // ////////////////////////////////////////////////////////////////////////////////////////// // Subclasses of ViewGroup. // Inheritance: View->ViewGroup->AbsoluteLayout->WebView - if (ClassLoadingCache.checkInstanceOf(className, android.webkit.WebView.class)) { - return ROLE_WEB_VIEW; + if (ClassLoadingCache.checkInstanceOf(className, android.webkit.WebView::class.java)) { + return ROLE_WEB_VIEW } // Inheritance: View->ViewGroup->LinearLayout->TabWidget - if (ClassLoadingCache.checkInstanceOf(className, android.widget.TabWidget.class)) { - return ROLE_TAB_BAR; + if (ClassLoadingCache.checkInstanceOf(className, android.widget.TabWidget::class.java)) { + return ROLE_TAB_BAR } // Inheritance: View->ViewGroup->FrameLayout->HorizontalScrollView // If there is a CollectionInfo, fall into a ROLE_LIST/ROLE_GRID - if (ClassLoadingCache.checkInstanceOf(className, android.widget.HorizontalScrollView.class) - && node.getCollectionInfo() == null) { - return ROLE_HORIZONTAL_SCROLL_VIEW; + if (ClassLoadingCache.checkInstanceOf(className, android.widget.HorizontalScrollView::class.java) + && node.collectionInfo == null) { + return ROLE_HORIZONTAL_SCROLL_VIEW } // Inheritance: View->ViewGroup->FrameLayout->ScrollView - if (ClassLoadingCache.checkInstanceOf(className, android.widget.ScrollView.class)) { - return ROLE_SCROLL_VIEW; + if (ClassLoadingCache.checkInstanceOf(className, android.widget.ScrollView::class.java)) { + return ROLE_SCROLL_VIEW } // Inheritance: View->ViewGroup->ViewPager - if (ClassLoadingCache.checkInstanceOf(className, androidx.viewpager.widget.ViewPager.class) + if (ClassLoadingCache.checkInstanceOf(className, androidx.viewpager.widget.ViewPager::class.java) || ClassLoadingCache.checkInstanceOf(className, "android.support.v4.view.ViewPager") || ClassLoadingCache.checkInstanceOf(className, "androidx.core.view.ViewPager") || ClassLoadingCache.checkInstanceOf(className, "com.android.internal.widget.ViewPager")) { - return ROLE_PAGER; + return ROLE_PAGER } // Inheritance: View->ViewGroup->LinearLayout->NumberPicker - if (ClassLoadingCache.checkInstanceOf(className, android.widget.NumberPicker.class)) { - return ROLE_NUMBER_PICKER; + if (ClassLoadingCache.checkInstanceOf(className, android.widget.NumberPicker::class.java)) { + return ROLE_NUMBER_PICKER } // Inheritance: View->ViewGroup->AdapterView->AbsSpinner->Spinner - if (ClassLoadingCache.checkInstanceOf(className, android.widget.Spinner.class)) { - return ROLE_DROP_DOWN_LIST; + if (ClassLoadingCache.checkInstanceOf(className, android.widget.Spinner::class.java)) { + return ROLE_DROP_DOWN_LIST } // Inheritance: View->ViewGroup->AdapterView->AbsListView->GridView - if (ClassLoadingCache.checkInstanceOf(className, android.widget.GridView.class)) { - return ROLE_GRID; + if (ClassLoadingCache.checkInstanceOf(className, android.widget.GridView::class.java)) { + return ROLE_GRID } // Inheritance: View->ViewGroup->AdapterView->AbsListView - if (ClassLoadingCache.checkInstanceOf(className, android.widget.AbsListView.class)) { - return ROLE_LIST; + if (ClassLoadingCache.checkInstanceOf(className, android.widget.AbsListView::class.java)) { + return ROLE_LIST } // Inheritance: View->ViewGroup->ViewPager2 if (AccessibilityNodeInfoUtils.supportsAction( - node, AccessibilityActionCompat.ACTION_PAGE_UP.getId()) + node, AccessibilityActionCompat.ACTION_PAGE_UP.id) || AccessibilityNodeInfoUtils.supportsAction( - node, AccessibilityActionCompat.ACTION_PAGE_DOWN.getId()) + node, AccessibilityActionCompat.ACTION_PAGE_DOWN.id) || AccessibilityNodeInfoUtils.supportsAction( - node, AccessibilityActionCompat.ACTION_PAGE_LEFT.getId()) + node, AccessibilityActionCompat.ACTION_PAGE_LEFT.id) || AccessibilityNodeInfoUtils.supportsAction( - node, AccessibilityActionCompat.ACTION_PAGE_RIGHT.getId())) { - return ROLE_PAGER; + node, AccessibilityActionCompat.ACTION_PAGE_RIGHT.id)) { + return ROLE_PAGER } - CollectionInfoCompat collection = node.getCollectionInfo(); + val collection = node.collectionInfo if (collection != null) { // Grids, staggered grids, and lists can be empty or have only a single row or a single // column. For these collections, we rely on the classname being set explicitly when // assigning a role. if (ClassLoadingCache.checkInstanceOf(className, TALKBACK_LIST_CLASSNAME)) { - return ROLE_LIST; + return ROLE_LIST } if (ClassLoadingCache.checkInstanceOf(className, TALKBACK_GRID_CLASSNAME)) { - return ROLE_GRID; + return ROLE_GRID } // Staggered grids don't always map neatly to row and column semantics (vertical staggered // grids only have clear columns and horizontal staggered grids only have clear rows). We // distinguish staggered grids from regular grids, which have both well defined rows and // columns. if (ClassLoadingCache.checkInstanceOf(className, TALKBACK_STAGGERED_GRID_CLASSNAME)) { - return ROLE_STAGGERED_GRID; + return ROLE_STAGGERED_GRID } // Any collection that does not explicitly set a classname has its role assigned based on the // collection count. - if (collection.getRowCount() > 1 && collection.getColumnCount() > 1) { - return ROLE_GRID; + if (collection.rowCount > 1 && collection.columnCount > 1) { + return ROLE_GRID } else { - return ROLE_LIST; + return ROLE_LIST } } if (ClassLoadingCache.checkInstanceOf(className, NON_MODAL_ALERT_CLASSNAME)) { - return ROLE_NON_MODAL_ALERT; + return ROLE_NON_MODAL_ALERT } if (ClassLoadingCache.checkInstanceOf(className, SNACKBAR_CLASSNAME)) { - return ROLE_SNACKBAR; + return ROLE_SNACKBAR } if (ClassLoadingCache.checkInstanceOf(className, AUDIO_CAPTION_CLASSNAME)) { - return ROLE_AUDIO_CAPTION; + return ROLE_AUDIO_CAPTION } // Inheritance: View->ViewGroup->FrameLayout->NavigationView @@ -983,16 +984,16 @@ public static int getRole(@Nullable AccessibilityNodeInfoCompat node) { || ClassLoadingCache.checkInstanceOf(className, MATERIAL_NAVIGATION_RAIL_CLASSNAME) // Inheritance: View->ViewGroup->FrameLayout->NavigationBarView || ClassLoadingCache.checkInstanceOf(className, MATERIAL_NAVIGATION_BAR_CLASSNAME)) { - return ROLE_NAVIGATION; + return ROLE_NAVIGATION } // Inheritance: View->ViewGroup->LinearLayout->SearchView if (ClassLoadingCache.checkInstanceOf(className, SEARCH_CLASSNAME)) { - return ROLE_SEARCH; + return ROLE_SEARCH } - if (ClassLoadingCache.checkInstanceOf(className, android.view.ViewGroup.class)) { - return ROLE_VIEW_GROUP; + if (ClassLoadingCache.checkInstanceOf(className, android.view.ViewGroup::class.java)) { + return ROLE_VIEW_GROUP } // ////////////////////////////////////////////////////////////////////////////////////////// @@ -1000,14 +1001,14 @@ public static int getRole(@Nullable AccessibilityNodeInfoCompat node) { // Inheritance: Dialog->AlertDialog if (ClassLoadingCache.checkInstanceOf(className, ALERT_DIALOG_CLASSNAME)) { - return ROLE_ALERT_DIALOG; + return ROLE_ALERT_DIALOG } if (ClassLoadingCache.checkInstanceOf(className, DIALOG_CLASSNAME)) { - return ROLE_DIALOG; + return ROLE_DIALOG } - return ROLE_NONE; + return ROLE_NONE } /** @@ -1015,74 +1016,77 @@ public static int getRole(@Nullable AccessibilityNodeInfoCompat node) { * #getRole(AccessibilityNodeInfoCompat)} */ @RoleName - public static int getRole(@Nullable AccessibilityNodeInfo node) { + @JvmStatic + fun getRole(node: AccessibilityNodeInfo?): Int { if (node == null) { - return Role.ROLE_NONE; + return Role.ROLE_NONE } - AccessibilityNodeInfoCompat nodeCompat = AccessibilityNodeInfoUtils.toCompat(node); - return getRole(nodeCompat); + val nodeCompat = AccessibilityNodeInfoUtils.toCompat(node) + return getRole(nodeCompat) } /** For use in logging. */ - public static String roleToString(@RoleName int role) { - return switch (role) { - case ROLE_NONE -> "ROLE_NONE"; - case ROLE_BUTTON -> "ROLE_BUTTON"; - case ROLE_CHECK_BOX -> "ROLE_CHECK_BOX"; - case ROLE_DROP_DOWN_LIST -> "ROLE_DROP_DOWN_LIST"; - case ROLE_EDIT_TEXT -> "ROLE_EDIT_TEXT"; - case ROLE_GRID -> "ROLE_GRID"; - case ROLE_IMAGE -> "ROLE_IMAGE"; - case ROLE_IMAGE_BUTTON -> "ROLE_IMAGE_BUTTON"; - case ROLE_LIST -> "ROLE_LIST"; - case ROLE_RADIO_BUTTON -> "ROLE_RADIO_BUTTON"; - case ROLE_SEEK_CONTROL -> "ROLE_SEEK_CONTROL"; - case ROLE_SWITCH -> "ROLE_SWITCH"; - case ROLE_TAB_BAR -> "ROLE_TAB_BAR"; - case ROLE_TOGGLE_BUTTON -> "ROLE_TOGGLE_BUTTON"; - case ROLE_VIEW_GROUP -> "ROLE_VIEW_GROUP"; - case ROLE_WEB_VIEW -> "ROLE_WEB_VIEW"; - case ROLE_PAGER -> "ROLE_PAGER"; - case ROLE_CHECKED_TEXT_VIEW -> "ROLE_CHECKED_TEXT_VIEW"; - case ROLE_PROGRESS_BAR -> "ROLE_PROGRESS_BAR"; - case ROLE_ACTION_BAR_TAB -> "ROLE_ACTION_BAR_TAB"; - case ROLE_DRAWER_LAYOUT -> "ROLE_DRAWER_LAYOUT"; - case ROLE_SLIDING_DRAWER -> "ROLE_SLIDING_DRAWER"; - case ROLE_ICON_MENU -> "ROLE_ICON_MENU"; - case ROLE_TOAST -> "ROLE_TOAST"; - case ROLE_ALERT_DIALOG -> "ROLE_ALERT_DIALOG"; - case ROLE_DATE_PICKER_DIALOG -> "ROLE_DATE_PICKER_DIALOG"; - case ROLE_TIME_PICKER_DIALOG -> "ROLE_TIME_PICKER_DIALOG"; - case ROLE_DATE_PICKER -> "ROLE_DATE_PICKER"; - case ROLE_TIME_PICKER -> "ROLE_TIME_PICKER"; - case ROLE_NUMBER_PICKER -> "ROLE_NUMBER_PICKER"; - case ROLE_SCROLL_VIEW -> "ROLE_SCROLL_VIEW"; - case ROLE_HORIZONTAL_SCROLL_VIEW -> "ROLE_HORIZONTAL_SCROLL_VIEW"; - case ROLE_KEYBOARD_KEY -> "ROLE_KEYBOARD_KEY"; - case ROLE_TALKBACK_EDIT_TEXT_OVERLAY -> "ROLE_TALKBACK_EDIT_TEXT_OVERLAY"; - case ROLE_TEXT_ENTRY_KEY -> "ROLE_TEXT_ENTRY_KEY"; - case ROLE_STAGGERED_GRID -> "ROLE_STAGGERED_GRID"; - case ROLE_FLOATING_ACTION_BUTTON -> "ROLE_FLOATING_ACTION_BUTTON"; - case ROLE_NON_MODAL_ALERT -> "ROLE_NON_MODAL_ALERT"; - case ROLE_SNACKBAR -> "ROLE_SNACKBAR"; - case ROLE_AUDIO_CAPTION -> "ROLE_AUDIO_CAPTION"; - case ROLE_DIALOG -> "ROLE_DIALOG"; - case ROLE_NAVIGATION -> "ROLE_NAVIGATION"; - case ROLE_SEARCH -> "ROLE_SEARCH"; - case ROLE_VOICE_DICTATION_BUTTON -> "ROLE_VOICE_DICTATION_BUTTON"; - default -> "(unknown role " + role + ")"; - }; + @JvmStatic + fun roleToString(@RoleName role: Int): String { + return when (role) { + ROLE_NONE -> "ROLE_NONE" + ROLE_BUTTON -> "ROLE_BUTTON" + ROLE_CHECK_BOX -> "ROLE_CHECK_BOX" + ROLE_DROP_DOWN_LIST -> "ROLE_DROP_DOWN_LIST" + ROLE_EDIT_TEXT -> "ROLE_EDIT_TEXT" + ROLE_GRID -> "ROLE_GRID" + ROLE_IMAGE -> "ROLE_IMAGE" + ROLE_IMAGE_BUTTON -> "ROLE_IMAGE_BUTTON" + ROLE_LIST -> "ROLE_LIST" + ROLE_RADIO_BUTTON -> "ROLE_RADIO_BUTTON" + ROLE_SEEK_CONTROL -> "ROLE_SEEK_CONTROL" + ROLE_SWITCH -> "ROLE_SWITCH" + ROLE_TAB_BAR -> "ROLE_TAB_BAR" + ROLE_TOGGLE_BUTTON -> "ROLE_TOGGLE_BUTTON" + ROLE_VIEW_GROUP -> "ROLE_VIEW_GROUP" + ROLE_WEB_VIEW -> "ROLE_WEB_VIEW" + ROLE_PAGER -> "ROLE_PAGER" + ROLE_CHECKED_TEXT_VIEW -> "ROLE_CHECKED_TEXT_VIEW" + ROLE_PROGRESS_BAR -> "ROLE_PROGRESS_BAR" + ROLE_ACTION_BAR_TAB -> "ROLE_ACTION_BAR_TAB" + ROLE_DRAWER_LAYOUT -> "ROLE_DRAWER_LAYOUT" + ROLE_SLIDING_DRAWER -> "ROLE_SLIDING_DRAWER" + ROLE_ICON_MENU -> "ROLE_ICON_MENU" + ROLE_TOAST -> "ROLE_TOAST" + ROLE_ALERT_DIALOG -> "ROLE_ALERT_DIALOG" + ROLE_DATE_PICKER_DIALOG -> "ROLE_DATE_PICKER_DIALOG" + ROLE_TIME_PICKER_DIALOG -> "ROLE_TIME_PICKER_DIALOG" + ROLE_DATE_PICKER -> "ROLE_DATE_PICKER" + ROLE_TIME_PICKER -> "ROLE_TIME_PICKER" + ROLE_NUMBER_PICKER -> "ROLE_NUMBER_PICKER" + ROLE_SCROLL_VIEW -> "ROLE_SCROLL_VIEW" + ROLE_HORIZONTAL_SCROLL_VIEW -> "ROLE_HORIZONTAL_SCROLL_VIEW" + ROLE_KEYBOARD_KEY -> "ROLE_KEYBOARD_KEY" + ROLE_TALKBACK_EDIT_TEXT_OVERLAY -> "ROLE_TALKBACK_EDIT_TEXT_OVERLAY" + ROLE_TEXT_ENTRY_KEY -> "ROLE_TEXT_ENTRY_KEY" + ROLE_STAGGERED_GRID -> "ROLE_STAGGERED_GRID" + ROLE_FLOATING_ACTION_BUTTON -> "ROLE_FLOATING_ACTION_BUTTON" + ROLE_NON_MODAL_ALERT -> "ROLE_NON_MODAL_ALERT" + ROLE_SNACKBAR -> "ROLE_SNACKBAR" + ROLE_AUDIO_CAPTION -> "ROLE_AUDIO_CAPTION" + ROLE_DIALOG -> "ROLE_DIALOG" + ROLE_NAVIGATION -> "ROLE_NAVIGATION" + ROLE_SEARCH -> "ROLE_SEARCH" + ROLE_VOICE_DICTATION_BUTTON -> "ROLE_VOICE_DICTATION_BUTTON" + else -> "(unknown role " + role + ")" + } } /** * Whether the role is an adjustable role. Need to check the ancestor of the node to see if it is * a number picker. */ - public static boolean isAdjustableRole(@RoleName int role) { + @JvmStatic + fun isAdjustableRole(@RoleName role: Int): Boolean { return role == Role.ROLE_SEEK_CONTROL || role == Role.ROLE_DATE_PICKER || role == Role.ROLE_TIME_PICKER - || role == Role.ROLE_NUMBER_PICKER; + || role == Role.ROLE_NUMBER_PICKER } } diff --git a/utils/src/main/java/com/google/android/accessibility/utils/ScrollableNodeInfo.java b/utils/src/main/java/com/google/android/accessibility/utils/ScrollableNodeInfo.java deleted file mode 100644 index 59072bac0..000000000 --- a/utils/src/main/java/com/google/android/accessibility/utils/ScrollableNodeInfo.java +++ /dev/null @@ -1,243 +0,0 @@ -/* - * Copyright (C) 2022 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.android.accessibility.utils; - -import static androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_SCROLL_DOWN; -import static androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_SCROLL_LEFT; -import static androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_SCROLL_RIGHT; -import static androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_SCROLL_UP; -import static com.google.android.accessibility.utils.AccessibilityNodeInfoUtils.FILTER_AUTO_SCROLL; -import static com.google.android.accessibility.utils.traversal.TraversalStrategy.SEARCH_FOCUS_DOWN; -import static com.google.android.accessibility.utils.traversal.TraversalStrategy.SEARCH_FOCUS_FORWARD; -import static com.google.android.accessibility.utils.traversal.TraversalStrategy.SEARCH_FOCUS_LEFT; -import static com.google.android.accessibility.utils.traversal.TraversalStrategy.SEARCH_FOCUS_RIGHT; -import static com.google.android.accessibility.utils.traversal.TraversalStrategy.SEARCH_FOCUS_UP; -import static com.google.android.accessibility.utils.traversal.TraversalStrategy.getSymbolicName; - -import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; -import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat; -import com.google.android.accessibility.utils.traversal.DirectionalTraversalStrategy; -import com.google.android.accessibility.utils.traversal.OrderedTraversalStrategy; -import com.google.android.accessibility.utils.traversal.TraversalStrategy; -import com.google.android.accessibility.utils.traversal.TraversalStrategy.SearchDirection; -import com.google.android.accessibility.utils.traversal.TraversalStrategy.SearchDirectionOrUnknown; -import com.google.android.accessibility.utils.traversal.TraversalStrategyUtils; -import com.google.android.libraries.accessibility.utils.log.LogUtils; -import java.util.HashSet; -import java.util.Set; -import org.checkerframework.checker.nullness.qual.NonNull; -import org.checkerframework.checker.nullness.qual.Nullable; - -/** - * Wrapper around a scrollable {@link AccessibilityNodeInfoCompat}. - * - *

    Facilitates fallbacks for search directions not natively supported by widgets. - */ -public class ScrollableNodeInfo { - private final AccessibilityNodeInfoCompat node; - private final boolean rtl; - private boolean supportsUpDownScrolling; - private boolean supportsLeftRightScrolling; - - private static final String TAG = "ScrollableNodeInfo"; - - /** - * Creates a wrapper with the given scrollable {@code node}. - * - * @param node scrollable {@link AccessibilityNodeInfoCompat} - * @param rtl {@code true} if the ui locale is right-to-left. It is Required for mapping logical - * to spatial directions. - */ - public ScrollableNodeInfo(@NonNull AccessibilityNodeInfoCompat node, boolean rtl) { - this.node = node; - this.rtl = rtl; - initSupportedDirections(); - } - - /** Returns the single node which is wrapped by the instance. */ - public @NonNull AccessibilityNodeInfoCompat getNode() { - return node; - } - - /** - * Returns a supported {@code TraversalStrategy} for the specified {@code direction} if one - * exists, and empty otherwise. - * - * @param direction {@link SearchDirection} for the node. The actual returned strategy may use a - * different, fallback direction. - * @param focusFinder Reference to a {@link FocusFinder} that is needed to create a {@link - * DirectionalTraversalStrategy}. - */ - public @Nullable TraversalStrategy getSupportedTraversalStrategy( - @SearchDirection int direction, @NonNull FocusFinder focusFinder) { - Integer supportedDirection = getSupportedScrollDirection(direction); - if (supportedDirection == null) { - return null; - } - if (TraversalStrategyUtils.isLogicalDirection(supportedDirection)) { - return getLogicalTraversalStrategy(); - } - if (TraversalStrategyUtils.isSpatialDirection(supportedDirection)) { - return getSpatialTraversalStrategy(focusFinder); - } - return null; - } - - /** - * Returns a supported {@link SearchDirection} that is equivalent to the specified {@code - * searchDirection} if one exists, and {@code null} otherwise. - */ - public @Nullable Integer getSupportedScrollDirection(@SearchDirection int searchDirection) { - Integer nativeDirection = getDirectionIfNativelySupported(searchDirection); - if (nativeDirection != null) { - return nativeDirection; - } - // fallback - if (TraversalStrategyUtils.isLogicalDirection(searchDirection)) { - if (supportsUpDownScrolling && supportsLeftRightScrolling) { - // If two axes can be scrolled, we would not know which one to scroll. - return null; - } - if (supportsUpDownScrolling) { - return getDirectionIfNativelySupported( - searchDirection == SEARCH_FOCUS_FORWARD ? SEARCH_FOCUS_DOWN : SEARCH_FOCUS_UP); - } - if (supportsLeftRightScrolling) { - @SearchDirection int forward = rtl ? SEARCH_FOCUS_LEFT : SEARCH_FOCUS_RIGHT; - @SearchDirection int backward = rtl ? SEARCH_FOCUS_RIGHT : SEARCH_FOCUS_LEFT; - return getDirectionIfNativelySupported( - searchDirection == SEARCH_FOCUS_FORWARD ? forward : backward); - } - } - if (TraversalStrategyUtils.isSpatialDirection(searchDirection)) { - return getDirectionIfNativelySupported( - TraversalStrategyUtils.getLogicalDirection(searchDirection, rtl)); - } - - return null; - } - - private @Nullable Integer getDirectionIfNativelySupported(@SearchDirection int searchDirection) { - int desiredAction = - TraversalStrategyUtils.convertSearchDirectionToScrollAction(searchDirection); - for (AccessibilityActionCompat action : node.getActionList()) { - if (action.getId() == desiredAction) { - return searchDirection; - } - } - return null; - } - - private void initSupportedDirections() { - supportsUpDownScrolling = false; - supportsLeftRightScrolling = false; - for (AccessibilityActionCompat action : node.getActionList()) { - if (action.equals(ACTION_SCROLL_UP) || action.equals(ACTION_SCROLL_DOWN)) { - supportsUpDownScrolling = true; - } - if (action.equals(ACTION_SCROLL_LEFT) || action.equals(ACTION_SCROLL_RIGHT)) { - supportsLeftRightScrolling = true; - } - } - } - - private @NonNull TraversalStrategy getLogicalTraversalStrategy() { - return new OrderedTraversalStrategy(AccessibilityNodeInfoUtils.getRoot(node)); - } - - private @NonNull TraversalStrategy getSpatialTraversalStrategy(@NonNull FocusFinder focusFinder) { - return new DirectionalTraversalStrategy(AccessibilityNodeInfoUtils.getRoot(node), focusFinder); - } - - /** - * Returns a {@link ScrollableNodeInfo} for a node that is an ancestor of {@code pivot} and can be - * scrolled in the specified {@code direction} or an equivalent fallback direction if one exists, - * and {@code null} otherwise. - * - * @param direction The direction in which a scroll is requested. - * @param pivot The node for which an ancestor is searched. - * @param includeSelf Whether the {@code pivot} is allowed to be the ancestor. - * @param rtl Whether the window has RTL direction. This is required to map logical to spatial - * direction and vice-versa. - */ - public static @Nullable ScrollableNodeInfo findScrollableNodeForDirection( - @SearchDirectionOrUnknown int direction, - @NonNull AccessibilityNodeInfoCompat pivot, - boolean includeSelf, - boolean rtl) { - if (direction == TraversalStrategy.SEARCH_FOCUS_UNKNOWN) { - return null; - } - if (includeSelf) { - ScrollableNodeInfo match = findMatchingScrollable(direction, pivot, rtl); - if (match != null) { - return match; - } - } - if (pivot.getParent() != null) { - Set visited = new HashSet<>(); - return findScrollableNodeForDirectionRecursive(direction, pivot.getParent(), rtl, visited); - } - return null; - } - - private static @Nullable ScrollableNodeInfo findScrollableNodeForDirectionRecursive( - @SearchDirection int direction, - @NonNull AccessibilityNodeInfoCompat node, - boolean rtl, - Set visited) { - // If node already checked... quit. - if (node == null || visited.contains(node)) { - return null; - } else { - visited.add(node); - } - - ScrollableNodeInfo match = findMatchingScrollable(direction, node, rtl); - if (match != null) { - return match; - } - if (node.getParent() != null) { - return findScrollableNodeForDirectionRecursive(direction, node.getParent(), rtl, visited); - } - return null; - } - - private static @Nullable ScrollableNodeInfo findMatchingScrollable( - @SearchDirection int direction, @NonNull AccessibilityNodeInfoCompat node, boolean rtl) { - if (!FILTER_AUTO_SCROLL.accept(node)) { - return null; - } - - ScrollableNodeInfo scrollableNodeInfo = new ScrollableNodeInfo(node, rtl); - Integer supportedDirection = scrollableNodeInfo.getSupportedScrollDirection(direction); - if (supportedDirection != null) { - NodeActionFilter scrollableFilter = - new NodeActionFilter( - TraversalStrategyUtils.convertSearchDirectionToScrollAction(supportedDirection)); - if (scrollableFilter.accept(node)) { - return scrollableNodeInfo; - } - } else { - LogUtils.d( - TAG, - "findMatchingScrollable - supportedDirection is null, direction = %s", - getSymbolicName(direction)); - } - return null; - } -} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/ScrollableNodeInfo.kt b/utils/src/main/java/com/google/android/accessibility/utils/ScrollableNodeInfo.kt new file mode 100644 index 000000000..b3a1c0a11 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/ScrollableNodeInfo.kt @@ -0,0 +1,243 @@ +/* + * Copyright (C) The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Ported from Java to Kotlin. + */ + +package com.google.android.accessibility.utils + +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat +import com.google.android.accessibility.utils.traversal.DirectionalTraversalStrategy +import com.google.android.accessibility.utils.traversal.OrderedTraversalStrategy +import com.google.android.accessibility.utils.traversal.TraversalStrategy +import com.google.android.accessibility.utils.traversal.TraversalStrategyUtils +import com.google.android.libraries.accessibility.utils.log.LogUtils + +/** + * Wrapper around a scrollable {@link AccessibilityNodeInfoCompat}. + * + *

    Facilitates fallbacks for search directions not natively supported by widgets. + * + * @param node scrollable {@link AccessibilityNodeInfoCompat} + * @param rtl {@code true} if the ui locale is right-to-left. It is Required for mapping logical + * to spatial directions. + */ +class ScrollableNodeInfo( + /** The single node which is wrapped by the instance. */ + val node: AccessibilityNodeInfoCompat, + private val rtl: Boolean, +) { + private var supportsUpDownScrolling = false + private var supportsLeftRightScrolling = false + + init { + initSupportedDirections() + } + + /** + * Returns a supported {@code TraversalStrategy} for the specified {@code direction} if one + * exists, and empty otherwise. + * + * @param direction {@link SearchDirection} for the node. The actual returned strategy may use a + * different, fallback direction. + * @param focusFinder Reference to a {@link FocusFinder} that is needed to create a {@link + * DirectionalTraversalStrategy}. + */ + fun getSupportedTraversalStrategy( + @TraversalStrategy.SearchDirection direction: Int, + focusFinder: FocusFinder?, + ): TraversalStrategy? { + val supportedDirection = getSupportedScrollDirection(direction) ?: return null + if (TraversalStrategyUtils.isLogicalDirection(supportedDirection)) { + return getLogicalTraversalStrategy() + } + if (TraversalStrategyUtils.isSpatialDirection(supportedDirection)) { + return getSpatialTraversalStrategy(focusFinder) + } + return null + } + + /** + * Returns a supported {@link SearchDirection} that is equivalent to the specified {@code + * searchDirection} if one exists, and {@code null} otherwise. + */ + fun getSupportedScrollDirection( + @TraversalStrategy.SearchDirection searchDirection: Int, + ): Int? { + val nativeDirection = getDirectionIfNativelySupported(searchDirection) + if (nativeDirection != null) { + return nativeDirection + } + // fallback + if (TraversalStrategyUtils.isLogicalDirection(searchDirection)) { + if (supportsUpDownScrolling && supportsLeftRightScrolling) { + // If two axes can be scrolled, we would not know which one to scroll. + return null + } + if (supportsUpDownScrolling) { + return getDirectionIfNativelySupported( + if (searchDirection == TraversalStrategy.SEARCH_FOCUS_FORWARD) { + TraversalStrategy.SEARCH_FOCUS_DOWN + } else { + TraversalStrategy.SEARCH_FOCUS_UP + }, + ) + } + if (supportsLeftRightScrolling) { + @TraversalStrategy.SearchDirection + val forward = + if (rtl) TraversalStrategy.SEARCH_FOCUS_LEFT else TraversalStrategy.SEARCH_FOCUS_RIGHT + @TraversalStrategy.SearchDirection + val backward = + if (rtl) TraversalStrategy.SEARCH_FOCUS_RIGHT else TraversalStrategy.SEARCH_FOCUS_LEFT + return getDirectionIfNativelySupported( + if (searchDirection == TraversalStrategy.SEARCH_FOCUS_FORWARD) forward else backward, + ) + } + } + if (TraversalStrategyUtils.isSpatialDirection(searchDirection)) { + return getDirectionIfNativelySupported( + TraversalStrategyUtils.getLogicalDirection(searchDirection, rtl), + ) + } + + return null + } + + private fun getDirectionIfNativelySupported( + @TraversalStrategy.SearchDirection searchDirection: Int, + ): Int? { + val desiredAction = TraversalStrategyUtils.convertSearchDirectionToScrollAction(searchDirection) + for (action in node.actionList) { + if (action.id == desiredAction) { + return searchDirection + } + } + return null + } + + private fun initSupportedDirections() { + supportsUpDownScrolling = false + supportsLeftRightScrolling = false + for (action in node.actionList) { + if (action == AccessibilityActionCompat.ACTION_SCROLL_UP || + action == AccessibilityActionCompat.ACTION_SCROLL_DOWN + ) { + supportsUpDownScrolling = true + } + if (action == AccessibilityActionCompat.ACTION_SCROLL_LEFT || + action == AccessibilityActionCompat.ACTION_SCROLL_RIGHT + ) { + supportsLeftRightScrolling = true + } + } + } + + private fun getLogicalTraversalStrategy(): TraversalStrategy = + OrderedTraversalStrategy(AccessibilityNodeInfoUtils.getRoot(node)) + + private fun getSpatialTraversalStrategy(focusFinder: FocusFinder?): TraversalStrategy = + DirectionalTraversalStrategy(AccessibilityNodeInfoUtils.getRoot(node), focusFinder) + + companion object { + private const val TAG = "ScrollableNodeInfo" + + /** + * Returns a {@link ScrollableNodeInfo} for a node that is an ancestor of {@code pivot} and can + * be scrolled in the specified {@code direction} or an equivalent fallback direction if one + * exists, and {@code null} otherwise. + * + * @param direction The direction in which a scroll is requested. + * @param pivot The node for which an ancestor is searched. + * @param includeSelf Whether the {@code pivot} is allowed to be the ancestor. + * @param rtl Whether the window has RTL direction. This is required to map logical to spatial + * direction and vice-versa. + */ + @JvmStatic + fun findScrollableNodeForDirection( + @TraversalStrategy.SearchDirectionOrUnknown direction: Int, + pivot: AccessibilityNodeInfoCompat, + includeSelf: Boolean, + rtl: Boolean, + ): ScrollableNodeInfo? { + if (direction == TraversalStrategy.SEARCH_FOCUS_UNKNOWN) { + return null + } + if (includeSelf) { + val match = findMatchingScrollable(direction, pivot, rtl) + if (match != null) { + return match + } + } + if (pivot.parent != null) { + val visited = HashSet() + return findScrollableNodeForDirectionRecursive(direction, pivot.parent, rtl, visited) + } + return null + } + + private fun findScrollableNodeForDirectionRecursive( + @TraversalStrategy.SearchDirection direction: Int, + node: AccessibilityNodeInfoCompat?, + rtl: Boolean, + visited: MutableSet, + ): ScrollableNodeInfo? { + // If node already checked... quit. + if (node == null || visited.contains(node)) { + return null + } else { + visited.add(node) + } + + val match = findMatchingScrollable(direction, node, rtl) + if (match != null) { + return match + } + if (node.parent != null) { + return findScrollableNodeForDirectionRecursive(direction, node.parent, rtl, visited) + } + return null + } + + private fun findMatchingScrollable( + @TraversalStrategy.SearchDirection direction: Int, + node: AccessibilityNodeInfoCompat, + rtl: Boolean, + ): ScrollableNodeInfo? { + if (!AccessibilityNodeInfoUtils.FILTER_AUTO_SCROLL.accept(node)) { + return null + } + + val scrollableNodeInfo = ScrollableNodeInfo(node, rtl) + val supportedDirection = scrollableNodeInfo.getSupportedScrollDirection(direction) + if (supportedDirection != null) { + val scrollableFilter = + NodeActionFilter( + TraversalStrategyUtils.convertSearchDirectionToScrollAction(supportedDirection)) + if (scrollableFilter.accept(node)) { + return scrollableNodeInfo + } + } else { + LogUtils.d( + TAG, + "findMatchingScrollable - supportedDirection is null, direction = %s", + TraversalStrategy.getSymbolicName(direction), + ) + } + return null + } + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/WebInterfaceUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/WebInterfaceUtils.java deleted file mode 100644 index 656aec8e6..000000000 --- a/utils/src/main/java/com/google/android/accessibility/utils/WebInterfaceUtils.java +++ /dev/null @@ -1,425 +0,0 @@ -/* - * Copyright (C) 2012 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not - * use this file except in compliance with the License. You may obtain a copy of - * the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations under - * the License. - */ - -package com.google.android.accessibility.utils; - -import android.content.Context; -import android.os.Bundle; -import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; -import com.google.android.accessibility.utils.traversal.TraversalStrategy; -import com.google.android.accessibility.utils.traversal.TraversalStrategy.SearchDirectionOrUnknown; -import com.google.android.accessibility.utils.traversal.TraversalStrategyUtils; -import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableMap; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Map; -import org.checkerframework.checker.nullness.qual.Nullable; - -/** Utility class for sending commands to Chrome. */ -public class WebInterfaceUtils { - - private static final String KEY_WEB_IMAGE = "AccessibilityNodeInfo.hasImage"; - private static final String VALUE_HAS_WEB_IMAGE = "true"; - - private static final String ACTION_ARGUMENT_HTML_ELEMENT_STRING_VALUES = - "ACTION_ARGUMENT_HTML_ELEMENT_STRING_VALUES"; - - /** Direction constant for forward movement within a page. */ - public static final int DIRECTION_FORWARD = 1; - - /** Direction constant for backward movement within a page. */ - public static final int DIRECTION_BACKWARD = -1; - - /** - * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to - * instruct Chrome to move to the next or previous page section. - */ - public static final String HTML_ELEMENT_MOVE_BY_SECTION = "SECTION"; - - /** - * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to - * instruct Chrome to move to the next or previous page heading. - */ - public static final String HTML_ELEMENT_MOVE_BY_HEADING = "HEADING"; - - /** - * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to - * instruct Chrome to move to the next or previous page section. - */ - public static final String HTML_ELEMENT_MOVE_BY_LANDMARK = "LANDMARK"; - - /** - * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to - * instruct Chrome to move to the next or previous link. - */ - public static final String HTML_ELEMENT_MOVE_BY_LINK = "LINK"; - - /** - * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to - * instruct Chrome to move to the next or previous list. - */ - public static final String HTML_ELEMENT_MOVE_BY_LIST = "LIST"; - - /** - * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to - * instruct Chrome to move to the next or previous control. - */ - public static final String HTML_ELEMENT_MOVE_BY_CONTROL = "CONTROL"; - - /** - * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to - * instruct Chrome to move to the next or previous button. - */ - public static final String HTML_ELEMENT_MOVE_BY_BUTTON = "BUTTON"; - - /** - * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to - * instruct Chrome to move to the next or previous checkbox. - */ - public static final String HTML_ELEMENT_MOVE_BY_CHECKBOX = "CHECKBOX"; - - /** - * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to - * instruct Chrome to move to the next or previous radio. - */ - public static final String HTML_ELEMENT_MOVE_BY_RADIO = "RADIO"; - - /** - * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to - * instruct Chrome to move to the next or previous edit field. - */ - public static final String HTML_ELEMENT_MOVE_BY_EDIT_FIELD = "TEXT_FIELD"; - - /** - * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to - * instruct Chrome to move to the next or previous focusable item. - */ - public static final String HTML_ELEMENT_MOVE_BY_FOCUSABLE_ITEM = "FOCUSABLE"; - - /** - * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to - * instruct Chrome to move to the next or previous page heading 1. - */ - public static final String HTML_ELEMENT_MOVE_BY_HEADING_1 = "H1"; - - /** - * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to - * instruct Chrome to move to the next or previous page heading 2. - */ - public static final String HTML_ELEMENT_MOVE_BY_HEADING_2 = "H2"; - - /** - * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to - * instruct Chrome to move to the next or previous page heading 3. - */ - public static final String HTML_ELEMENT_MOVE_BY_HEADING_3 = "H3"; - - /** - * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to - * instruct Chrome to move to the next or previous page heading 4. - */ - public static final String HTML_ELEMENT_MOVE_BY_HEADING_4 = "H4"; - - /** - * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to - * instruct Chrome to move to the next or previous page heading 5. - */ - public static final String HTML_ELEMENT_MOVE_BY_HEADING_5 = "H5"; - - /** - * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to - * instruct Chrome to move to the next or previous page heading 6. - */ - public static final String HTML_ELEMENT_MOVE_BY_HEADING_6 = "H6"; - - /** - * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to - * instruct Chrome to move to the next or previous image. - */ - public static final String HTML_ELEMENT_MOVE_BY_GRAPHIC = "GRAPHIC"; - - /** - * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to - * instruct Chrome to move to the next or previous list item. - */ - public static final String HTML_ELEMENT_MOVE_BY_LIST_ITEM = "LIST_ITEM"; - - /** - * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to - * instruct Chrome to move to the next or previous table. - */ - public static final String HTML_ELEMENT_MOVE_BY_TABLE = "TABLE"; - - /** - * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to - * instruct Chrome to move to the next or previous combo box. - */ - public static final String HTML_ELEMENT_MOVE_BY_COMBOBOX = "COMBOBOX"; - - /** - * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to - * instruct Chrome to move to the next or previous visited link. - */ - public static final String HTML_ELEMENT_MOVE_BY_VISITED_LINK = "VISITED_LINK"; - - /** - * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to - * instruct Chrome to move to the next or previous unvisited link. - */ - public static final String HTML_ELEMENT_MOVE_BY_UNVISITED_LINK = "UNVISITED_LINK"; - - /** - * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to - * instruct Chrome to move to the next or previous column. - */ - public static final String HTML_ELEMENT_MOVE_BY_COLUMN = "COLUMN"; - - /** - * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to - * instruct Chrome to move to the next or previous row. - */ - public static final String HTML_ELEMENT_MOVE_BY_ROW = "ROW"; - - /** - * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to - * instruct Chrome to move to the next or previous column bounds. - */ - public static final String HTML_ELEMENT_MOVE_BY_COLUMN_BOUNDS = "COLUMN_BOUNDS"; - - /** - * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to - * instruct Chrome to move to the next or previous row bounds. - */ - public static final String HTML_ELEMENT_MOVE_BY_ROW_BOUNDS = "ROW_BOUNDS"; - - /** - * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to - * instruct Chrome to move to the next or previous table bounds. - */ - public static final String HTML_ELEMENT_MOVE_BY_TABLE_BOUNDS = "TABLE_BOUNDS"; - - private static final ImmutableMap> URL_BAR_IDS = - ImmutableMap.ofEntries( - Map.entry("com.android.chrome", ImmutableList.of("com.android.chrome:id/url_bar")), - Map.entry("com.chrome.beta", ImmutableList.of("com.chrome.beta:id/url_bar")), - Map.entry("com.chrome.dev", ImmutableList.of("com.chrome.dev:id/url_bar")), - Map.entry( - "org.mozilla.firefox", - ImmutableList.of( - "org.mozilla.firefox:id/url", - "org.mozilla.firefox:id/url_bar_title", - "org.mozilla.firefox:id/url_edit_text", - "org.mozilla.firefox:id/mozac_browser_toolbar_url_view", - "org.mozilla.firefox:id/mozac_browser_toolbar_edit_url_view")), - Map.entry( - "org.mozilla.firefox_beta", - ImmutableList.of( - "org.mozilla.firefox_beta:id/url", - "org.mozilla.firefox_beta:id/url_bar_title", - "org.mozilla.firefox_beta:id/url_edit_text", - "org.mozilla.firefox_beta:id/mozac_browser_toolbar_url_view", - "org.mozilla.firefox_beta:id/mozac_browser_toolbar_edit_url_view")), - Map.entry( - "com.sec.android.app.sbrowser", - ImmutableList.of("com.sec.android.app.sbrowser:id/location_bar_edit_text")), - Map.entry("com.android.browser", ImmutableList.of("com.android.browser:id/url")), - Map.entry("com.opera.android", ImmutableList.of("com.opera.android:id/url_field")), - Map.entry("com.opera.browser", ImmutableList.of("com.opera.browser:id/url_field")), - Map.entry( - "com.hsv.freeadblockerbrowser", ImmutableList.of("com.opera.browser:id/url_field")), - Map.entry("com.microsoft.emmx", ImmutableList.of("com.microsoft.emmx:id/url_bar"))); - - /** - * Filter for WebView container node. See {@link - * #ascendToWebViewContainer(AccessibilityNodeInfoCompat)}. - */ - private static final Filter FILTER_WEB_VIEW_CONTAINER = - new Filter() { - @Override - public boolean accept(AccessibilityNodeInfoCompat node) { - if (node == null) { - return false; - } - - return Role.getRole(node) == Role.ROLE_WEB_VIEW - && Role.getRole(node.getParent()) != Role.ROLE_WEB_VIEW; - } - }; - - /** Filter for WebView node. See {@link #ascendToWebView(AccessibilityNodeInfoCompat)}. */ - private static final Filter FILTER_WEB_VIEW = - new Filter() { - @Override - public boolean accept(AccessibilityNodeInfoCompat node) { - return node != null && Role.getRole(node) == Role.ROLE_WEB_VIEW; - } - }; - - public static int searchDirectionToWebNavigationDirection( - Context context, @SearchDirectionOrUnknown int searchDirection) { - if (searchDirection == TraversalStrategy.SEARCH_FOCUS_UNKNOWN) { - return 0; - } - @SearchDirectionOrUnknown - int logicalDirection = - TraversalStrategyUtils.getLogicalDirection( - searchDirection, WindowUtils.isScreenLayoutRTL(context)); - return logicalDirection == TraversalStrategy.SEARCH_FOCUS_FORWARD - ? WebInterfaceUtils.DIRECTION_FORWARD - : WebInterfaceUtils.DIRECTION_BACKWARD; - } - - /** - * Gets supported html elements, such as HEADING, LANDMARK, LINK and LIST, by - * AccessibilityNodeInfoCompat. - * - * @param node The node containing supported html elements - * @return supported html elements - */ - public static String @Nullable [] getSupportedHtmlElements( - @Nullable AccessibilityNodeInfoCompat node) { - SupportedHtmlNodeCollector supportedHtmlNodeCollector = new SupportedHtmlNodeCollector(); - AccessibilityNodeInfoUtils.isOrHasMatchingAncestor(node, supportedHtmlNodeCollector); - if ((supportedHtmlNodeCollector.getSupportedTypes() == null) - || supportedHtmlNodeCollector.getSupportedTypes().isEmpty()) { - return null; - } - return supportedHtmlNodeCollector.getSupportedTypes().toArray(new String[] {}); - } - - private static class SupportedHtmlNodeCollector extends Filter { - private final ArrayList supportedTypes = new ArrayList<>(); - - @Override - public boolean accept(AccessibilityNodeInfoCompat node) { - if (node == null) { - return false; - } - Bundle bundle = node.getExtras(); - CharSequence supportedHtmlElements = - bundle.getCharSequence(ACTION_ARGUMENT_HTML_ELEMENT_STRING_VALUES); - - if (supportedHtmlElements != null) { - Collections.addAll(supportedTypes, supportedHtmlElements.toString().split(",")); - return true; - } - return false; - } - - public ArrayList getSupportedTypes() { - return supportedTypes; - } - } - - /** - * Returns the WebView container node if the {@code node} is a web element. Note: - * A web content node tree is always constructed with a WebView root node, a second level WebView - * node, and all other nodes attached beneath the second level WebView node. When referring to the - * WebView container, we prefer the root node instead of the second level node, because attributes - * like isVisibleToUser() sometimes are not correctly exposed at second level WebView node. - */ - public static @Nullable AccessibilityNodeInfoCompat ascendToWebViewContainer( - AccessibilityNodeInfoCompat node) { - if (!WebInterfaceUtils.supportsWebActions(node)) { - return null; - } - return AccessibilityNodeInfoUtils.getSelfOrMatchingAncestor(node, FILTER_WEB_VIEW_CONTAINER); - } - - /** Returns the closest ancestor(inclusive) WebView node if the {@code node} is a web element. */ - public static @Nullable AccessibilityNodeInfoCompat ascendToWebView( - AccessibilityNodeInfoCompat node) { - if (!WebInterfaceUtils.supportsWebActions(node)) { - return null; - } - return AccessibilityNodeInfoUtils.getSelfOrMatchingAncestor(node, FILTER_WEB_VIEW); - } - - /** - * Determines whether or not the given node contains web content. - * - * @param node The node to evaluate - * @return {@code true} if the node contains web content, {@code false} otherwise - */ - public static boolean supportsWebActions(@Nullable AccessibilityNodeInfoCompat node) { - return AccessibilityNodeInfoUtils.supportsAnyAction( - node, - AccessibilityNodeInfoCompat.ACTION_NEXT_HTML_ELEMENT, - AccessibilityNodeInfoCompat.ACTION_PREVIOUS_HTML_ELEMENT); - } - - /** - * Determines whether or not the given node contains native web content (and not Chrome). - * - * @param node The node to evaluate - * @return {@code true} if the node contains native web content, {@code false} otherwise - */ - public static boolean hasNativeWebContent(@Nullable AccessibilityNodeInfoCompat node) { - return supportsWebActions(node); - } - - /** - * Returns whether the given node has navigable web content, either legacy (Chrome) or native web - * content. - * - * @param node The node to check for web content. - * @return Whether the given node has navigable web content. - */ - public static boolean hasNavigableWebContent(@Nullable AccessibilityNodeInfoCompat node) { - return supportsWebActions(node); - } - - /** Check if node is web container */ - public static boolean isWebContainer(@Nullable AccessibilityNodeInfoCompat node) { - if (node == null) { - return false; - } - return hasNativeWebContent(node) || isNodeFromFirefox(node); - } - - /** Returns {@code true} if the {@code node} or its descendant contains image. */ - public static boolean containsImage(AccessibilityNodeInfoCompat node) { - if (node == null) { - return false; - } - Bundle extras = node.getExtras(); - return (extras != null) && VALUE_HAS_WEB_IMAGE.equals(extras.getString(KEY_WEB_IMAGE)); - } - - public static @Nullable AccessibilityNodeInfoCompat findUrlBar(AccessibilityNodeInfoCompat root) { - return AccessibilityNodeInfoUtils.searchFromBfs( - root, - new Filter() { - @Override - public boolean accept(AccessibilityNodeInfoCompat node) { - return URL_BAR_IDS - .getOrDefault(node.getPackageName().toString(), ImmutableList.of()) - .contains(node.getViewIdResourceName()); - } - }); - } - - private static boolean isNodeFromFirefox(AccessibilityNodeInfoCompat node) { - if (node == null) { - return false; - } - - final String packageName = - node.getPackageName() != null ? node.getPackageName().toString() : ""; - return packageName.startsWith("org.mozilla."); - } -} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/WebInterfaceUtils.kt b/utils/src/main/java/com/google/android/accessibility/utils/WebInterfaceUtils.kt new file mode 100644 index 000000000..57dac8f3d --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/WebInterfaceUtils.kt @@ -0,0 +1,420 @@ +/* + * Copyright (C) The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Ported from Java to Kotlin. + */ + +package com.google.android.accessibility.utils + +import android.content.Context +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import com.google.android.accessibility.utils.traversal.TraversalStrategy +import com.google.android.accessibility.utils.traversal.TraversalStrategyUtils +import com.google.common.collect.ImmutableList +import com.google.common.collect.ImmutableMap +import java.util.Collections + +/** Utility class for sending commands to Chrome. */ +object WebInterfaceUtils { + + private const val KEY_WEB_IMAGE = "AccessibilityNodeInfo.hasImage" + private const val VALUE_HAS_WEB_IMAGE = "true" + + private const val ACTION_ARGUMENT_HTML_ELEMENT_STRING_VALUES = + "ACTION_ARGUMENT_HTML_ELEMENT_STRING_VALUES" + + /** Direction constant for forward movement within a page. */ + const val DIRECTION_FORWARD = 1 + + /** Direction constant for backward movement within a page. */ + const val DIRECTION_BACKWARD = -1 + + /** + * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to + * instruct Chrome to move to the next or previous page section. + */ + const val HTML_ELEMENT_MOVE_BY_SECTION = "SECTION" + + /** + * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to + * instruct Chrome to move to the next or previous page heading. + */ + const val HTML_ELEMENT_MOVE_BY_HEADING = "HEADING" + + /** + * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to + * instruct Chrome to move to the next or previous page section. + */ + const val HTML_ELEMENT_MOVE_BY_LANDMARK = "LANDMARK" + + /** + * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to + * instruct Chrome to move to the next or previous link. + */ + const val HTML_ELEMENT_MOVE_BY_LINK = "LINK" + + /** + * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to + * instruct Chrome to move to the next or previous list. + */ + const val HTML_ELEMENT_MOVE_BY_LIST = "LIST" + + /** + * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to + * instruct Chrome to move to the next or previous control. + */ + const val HTML_ELEMENT_MOVE_BY_CONTROL = "CONTROL" + + /** + * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to + * instruct Chrome to move to the next or previous button. + */ + const val HTML_ELEMENT_MOVE_BY_BUTTON = "BUTTON" + + /** + * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to + * instruct Chrome to move to the next or previous checkbox. + */ + const val HTML_ELEMENT_MOVE_BY_CHECKBOX = "CHECKBOX" + + /** + * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to + * instruct Chrome to move to the next or previous radio. + */ + const val HTML_ELEMENT_MOVE_BY_RADIO = "RADIO" + + /** + * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to + * instruct Chrome to move to the next or previous edit field. + */ + const val HTML_ELEMENT_MOVE_BY_EDIT_FIELD = "TEXT_FIELD" + + /** + * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to + * instruct Chrome to move to the next or previous focusable item. + */ + const val HTML_ELEMENT_MOVE_BY_FOCUSABLE_ITEM = "FOCUSABLE" + + /** + * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to + * instruct Chrome to move to the next or previous page heading 1. + */ + const val HTML_ELEMENT_MOVE_BY_HEADING_1 = "H1" + + /** + * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to + * instruct Chrome to move to the next or previous page heading 2. + */ + const val HTML_ELEMENT_MOVE_BY_HEADING_2 = "H2" + + /** + * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to + * instruct Chrome to move to the next or previous page heading 3. + */ + const val HTML_ELEMENT_MOVE_BY_HEADING_3 = "H3" + + /** + * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to + * instruct Chrome to move to the next or previous page heading 4. + */ + const val HTML_ELEMENT_MOVE_BY_HEADING_4 = "H4" + + /** + * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to + * instruct Chrome to move to the next or previous page heading 5. + */ + const val HTML_ELEMENT_MOVE_BY_HEADING_5 = "H5" + + /** + * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to + * instruct Chrome to move to the next or previous page heading 6. + */ + const val HTML_ELEMENT_MOVE_BY_HEADING_6 = "H6" + + /** + * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to + * instruct Chrome to move to the next or previous image. + */ + const val HTML_ELEMENT_MOVE_BY_GRAPHIC = "GRAPHIC" + + /** + * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to + * instruct Chrome to move to the next or previous list item. + */ + const val HTML_ELEMENT_MOVE_BY_LIST_ITEM = "LIST_ITEM" + + /** + * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to + * instruct Chrome to move to the next or previous table. + */ + const val HTML_ELEMENT_MOVE_BY_TABLE = "TABLE" + + /** + * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to + * instruct Chrome to move to the next or previous combo box. + */ + const val HTML_ELEMENT_MOVE_BY_COMBOBOX = "COMBOBOX" + + /** + * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to + * instruct Chrome to move to the next or previous visited link. + */ + const val HTML_ELEMENT_MOVE_BY_VISITED_LINK = "VISITED_LINK" + + /** + * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to + * instruct Chrome to move to the next or previous unvisited link. + */ + const val HTML_ELEMENT_MOVE_BY_UNVISITED_LINK = "UNVISITED_LINK" + + /** + * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to + * instruct Chrome to move to the next or previous column. + */ + const val HTML_ELEMENT_MOVE_BY_COLUMN = "COLUMN" + + /** + * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to + * instruct Chrome to move to the next or previous row. + */ + const val HTML_ELEMENT_MOVE_BY_ROW = "ROW" + + /** + * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to + * instruct Chrome to move to the next or previous column bounds. + */ + const val HTML_ELEMENT_MOVE_BY_COLUMN_BOUNDS = "COLUMN_BOUNDS" + + /** + * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to + * instruct Chrome to move to the next or previous row bounds. + */ + const val HTML_ELEMENT_MOVE_BY_ROW_BOUNDS = "ROW_BOUNDS" + + /** + * HTML element argument to use with {@link AccessibilityNodeInfoCompat#performAction()} to + * instruct Chrome to move to the next or previous table bounds. + */ + const val HTML_ELEMENT_MOVE_BY_TABLE_BOUNDS = "TABLE_BOUNDS" + + private val URL_BAR_IDS: ImmutableMap> = + ImmutableMap.ofEntries( + java.util.Map.entry("com.android.chrome", ImmutableList.of("com.android.chrome:id/url_bar")), + java.util.Map.entry("com.chrome.beta", ImmutableList.of("com.chrome.beta:id/url_bar")), + java.util.Map.entry("com.chrome.dev", ImmutableList.of("com.chrome.dev:id/url_bar")), + java.util.Map.entry( + "org.mozilla.firefox", + ImmutableList.of( + "org.mozilla.firefox:id/url", + "org.mozilla.firefox:id/url_bar_title", + "org.mozilla.firefox:id/url_edit_text", + "org.mozilla.firefox:id/mozac_browser_toolbar_url_view", + "org.mozilla.firefox:id/mozac_browser_toolbar_edit_url_view")), + java.util.Map.entry( + "org.mozilla.firefox_beta", + ImmutableList.of( + "org.mozilla.firefox_beta:id/url", + "org.mozilla.firefox_beta:id/url_bar_title", + "org.mozilla.firefox_beta:id/url_edit_text", + "org.mozilla.firefox_beta:id/mozac_browser_toolbar_url_view", + "org.mozilla.firefox_beta:id/mozac_browser_toolbar_edit_url_view")), + java.util.Map.entry( + "com.sec.android.app.sbrowser", + ImmutableList.of("com.sec.android.app.sbrowser:id/location_bar_edit_text")), + java.util.Map.entry("com.android.browser", ImmutableList.of("com.android.browser:id/url")), + java.util.Map.entry("com.opera.android", ImmutableList.of("com.opera.android:id/url_field")), + java.util.Map.entry("com.opera.browser", ImmutableList.of("com.opera.browser:id/url_field")), + java.util.Map.entry( + "com.hsv.freeadblockerbrowser", ImmutableList.of("com.opera.browser:id/url_field")), + java.util.Map.entry("com.microsoft.emmx", ImmutableList.of("com.microsoft.emmx:id/url_bar")), + ) + + /** + * Filter for WebView container node. See {@link + * #ascendToWebViewContainer(AccessibilityNodeInfoCompat)}. + */ + private val FILTER_WEB_VIEW_CONTAINER: Filter = + object : Filter() { + override fun accept(node: AccessibilityNodeInfoCompat?): Boolean { + if (node == null) { + return false + } + + return Role.getRole(node) == Role.ROLE_WEB_VIEW && + Role.getRole(node.parent) != Role.ROLE_WEB_VIEW + } + } + + /** Filter for WebView node. See {@link #ascendToWebView(AccessibilityNodeInfoCompat)}. */ + private val FILTER_WEB_VIEW: Filter = + object : Filter() { + override fun accept(node: AccessibilityNodeInfoCompat?): Boolean = + node != null && Role.getRole(node) == Role.ROLE_WEB_VIEW + } + + @JvmStatic + fun searchDirectionToWebNavigationDirection( + context: Context, + @TraversalStrategy.SearchDirectionOrUnknown searchDirection: Int, + ): Int { + if (searchDirection == TraversalStrategy.SEARCH_FOCUS_UNKNOWN) { + return 0 + } + @TraversalStrategy.SearchDirectionOrUnknown + val logicalDirection = + TraversalStrategyUtils.getLogicalDirection( + searchDirection, WindowUtils.isScreenLayoutRTL(context)) + return if (logicalDirection == TraversalStrategy.SEARCH_FOCUS_FORWARD) { + DIRECTION_FORWARD + } else { + DIRECTION_BACKWARD + } + } + + /** + * Gets supported html elements, such as HEADING, LANDMARK, LINK and LIST, by + * AccessibilityNodeInfoCompat. + * + * @param node The node containing supported html elements + * @return supported html elements + */ + @JvmStatic + fun getSupportedHtmlElements(node: AccessibilityNodeInfoCompat?): Array? { + val supportedHtmlNodeCollector = SupportedHtmlNodeCollector() + AccessibilityNodeInfoUtils.isOrHasMatchingAncestor(node, supportedHtmlNodeCollector) + if (supportedHtmlNodeCollector.supportedTypes.isEmpty()) { + return null + } + return supportedHtmlNodeCollector.supportedTypes.toTypedArray() + } + + private class SupportedHtmlNodeCollector : Filter() { + val supportedTypes = ArrayList() + + override fun accept(node: AccessibilityNodeInfoCompat?): Boolean { + if (node == null) { + return false + } + val bundle = node.extras + val supportedHtmlElements = bundle.getCharSequence(ACTION_ARGUMENT_HTML_ELEMENT_STRING_VALUES) + + if (supportedHtmlElements != null) { + Collections.addAll( + supportedTypes, + *supportedHtmlElements.toString().split(",").toTypedArray(), + ) + return true + } + return false + } + } + + /** + * Returns the WebView container node if the {@code node} is a web element. Note: + * A web content node tree is always constructed with a WebView root node, a second level WebView + * node, and all other nodes attached beneath the second level WebView node. When referring to the + * WebView container, we prefer the root node instead of the second level node, because attributes + * like isVisibleToUser() sometimes are not correctly exposed at second level WebView node. + */ + @JvmStatic + fun ascendToWebViewContainer(node: AccessibilityNodeInfoCompat?): AccessibilityNodeInfoCompat? { + if (!supportsWebActions(node)) { + return null + } + return AccessibilityNodeInfoUtils.getSelfOrMatchingAncestor(node, FILTER_WEB_VIEW_CONTAINER) + } + + /** Returns the closest ancestor(inclusive) WebView node if the {@code node} is a web element. */ + @JvmStatic + fun ascendToWebView(node: AccessibilityNodeInfoCompat?): AccessibilityNodeInfoCompat? { + if (!supportsWebActions(node)) { + return null + } + return AccessibilityNodeInfoUtils.getSelfOrMatchingAncestor(node, FILTER_WEB_VIEW) + } + + /** + * Determines whether or not the given node contains web content. + * + * @param node The node to evaluate + * @return {@code true} if the node contains web content, {@code false} otherwise + */ + @JvmStatic + fun supportsWebActions(node: AccessibilityNodeInfoCompat?): Boolean = + AccessibilityNodeInfoUtils.supportsAnyAction( + node, + AccessibilityNodeInfoCompat.ACTION_NEXT_HTML_ELEMENT, + AccessibilityNodeInfoCompat.ACTION_PREVIOUS_HTML_ELEMENT, + ) + + /** + * Determines whether or not the given node contains native web content (and not Chrome). + * + * @param node The node to evaluate + * @return {@code true} if the node contains native web content, {@code false} otherwise + */ + @JvmStatic + fun hasNativeWebContent(node: AccessibilityNodeInfoCompat?): Boolean = supportsWebActions(node) + + /** + * Returns whether the given node has navigable web content, either legacy (Chrome) or native web + * content. + * + * @param node The node to check for web content. + * @return Whether the given node has navigable web content. + */ + @JvmStatic + fun hasNavigableWebContent(node: AccessibilityNodeInfoCompat?): Boolean = supportsWebActions(node) + + /** Check if node is web container */ + @JvmStatic + fun isWebContainer(node: AccessibilityNodeInfoCompat?): Boolean { + if (node == null) { + return false + } + return hasNativeWebContent(node) || isNodeFromFirefox(node) + } + + /** Returns {@code true} if the {@code node} or its descendant contains image. */ + @JvmStatic + fun containsImage(node: AccessibilityNodeInfoCompat?): Boolean { + if (node == null) { + return false + } + val extras = node.extras + return extras != null && VALUE_HAS_WEB_IMAGE == extras.getString(KEY_WEB_IMAGE) + } + + @JvmStatic + fun findUrlBar(root: AccessibilityNodeInfoCompat?): AccessibilityNodeInfoCompat? = + AccessibilityNodeInfoUtils.searchFromBfs( + root, + object : Filter() { + override fun accept(node: AccessibilityNodeInfoCompat): Boolean = + URL_BAR_IDS + .getOrDefault(node.packageName.toString(), ImmutableList.of())!! + .contains(node.viewIdResourceName) + }, + ) + + private fun isNodeFromFirefox(node: AccessibilityNodeInfoCompat?): Boolean { + if (node == null) { + return false + } + + val packageName = if (node.packageName != null) node.packageName.toString() else "" + return packageName.startsWith("org.mozilla.") + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureAnalyticsEvent.java b/utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureAnalyticsEvent.java deleted file mode 100644 index 7cf5e8709..000000000 --- a/utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureAnalyticsEvent.java +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright (C) 2024 Google Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.android.accessibility.utils.gestures; - -/** - * A base class to define the common part of Gesture Analytics event. The realization of each - * gesture' analytics, which can extend this base with additional data. - */ -public class GestureAnalyticsEvent { - // The events which the detector will report. - public static final int EVENT_DOUBLE_TAP_SLOP_OVER_RANGE = 0; // Double-tap slop's over range. - public static final int EVENT_TAP_TO_TOUCH_EXPLORE = 1; // Tap's sped up entering Touch Explore. - - // Extra debug data for event EVENT_DOUBLE_TAP_SLOP_OVER_RANGE. - public static final int EXTRA_DEBUG_DOUBLE_TAP_SLOP_OVER_IN_10_PERCENT = 0; - public static final int EXTRA_DEBUG_DOUBLE_TAP_SLOP_OVER_IN_20_PERCENT = 1; - public static final int EXTRA_DEBUG_DOUBLE_TAP_SLOP_OVER_IN_50_PERCENT = 2; - public static final int EXTRA_DEBUG_DOUBLE_TAP_SLOP_OVER_IN_100_PERCENT = 3; - public static final int EXTRA_DEBUG_DOUBLE_TAP_SLOP_OVER_MORE_THAN_100_PERCENT = 4; - - // Extra debug data for event EVENT_TAP_TO_TOUCH_EXPLORE. - public static final int EXTRA_DEBUG_TAP_TO_TOUCH_EXPLORE_TOTAL_SAVED_TIME = 0; - public static final int EXTRA_DEBUG_TAP_TO_TOUCH_EXPLORE_HIT_COUNT = 1; - - public final int event; - public final int gestureId; - - /** - * @param event It's used to identify which extended event to report; which is listed above. - * @param gestureId The gesture state machine generating this event. - */ - GestureAnalyticsEvent(int event, int gestureId) { - this.event = event; - this.gestureId = gestureId; - } -} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureAnalyticsEvent.kt b/utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureAnalyticsEvent.kt new file mode 100644 index 000000000..9476ab5b9 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureAnalyticsEvent.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2024 Google Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Ported from Java to Kotlin. + */ + +package com.google.android.accessibility.utils.gestures + +/** + * A base class to define the common part of Gesture Analytics event. The realization of each + * gesture' analytics, which can extend this base with additional data. + * + * @param event It's used to identify which extended event to report; which is listed above. + * @param gestureId The gesture state machine generating this event. + */ +open class GestureAnalyticsEvent internal constructor( + @JvmField val event: Int, + @JvmField val gestureId: Int, +) { + companion object { + // The events which the detector will report. + const val EVENT_DOUBLE_TAP_SLOP_OVER_RANGE = 0 // Double-tap slop's over range. + const val EVENT_TAP_TO_TOUCH_EXPLORE = 1 // Tap's sped up entering Touch Explore. + + // Extra debug data for event EVENT_DOUBLE_TAP_SLOP_OVER_RANGE. + const val EXTRA_DEBUG_DOUBLE_TAP_SLOP_OVER_IN_10_PERCENT = 0 + const val EXTRA_DEBUG_DOUBLE_TAP_SLOP_OVER_IN_20_PERCENT = 1 + const val EXTRA_DEBUG_DOUBLE_TAP_SLOP_OVER_IN_50_PERCENT = 2 + const val EXTRA_DEBUG_DOUBLE_TAP_SLOP_OVER_IN_100_PERCENT = 3 + const val EXTRA_DEBUG_DOUBLE_TAP_SLOP_OVER_MORE_THAN_100_PERCENT = 4 + + // Extra debug data for event EVENT_TAP_TO_TOUCH_EXPLORE. + const val EXTRA_DEBUG_TAP_TO_TOUCH_EXPLORE_TOTAL_SAVED_TIME = 0 + const val EXTRA_DEBUG_TAP_TO_TOUCH_EXPLORE_HIT_COUNT = 1 + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureConfiguration.java b/utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureConfiguration.kt similarity index 75% rename from utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureConfiguration.java rename to utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureConfiguration.kt index 3931b3ec3..9a59cc182 100644 --- a/utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureConfiguration.java +++ b/utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureConfiguration.kt @@ -12,20 +12,20 @@ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. + * + * Ported from Java to Kotlin. */ -package com.google.android.accessibility.utils.gestures; +package com.google.android.accessibility.utils.gestures -import android.view.ViewConfiguration; +import android.view.ViewConfiguration /** This class contains timeout values that control the behavior of gesture detection. */ -public final class GestureConfiguration { +object GestureConfiguration { /** * The maximum number of milliseconds that can be between two taps for them to count as a * multi-tap gesture. */ - public static final int DOUBLE_TAP_TIMEOUT_MS = ViewConfiguration.getDoubleTapTimeout() - 50; - - private GestureConfiguration() {} + @JvmField val DOUBLE_TAP_TIMEOUT_MS: Int = ViewConfiguration.getDoubleTapTimeout() - 50 } diff --git a/utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureManifold.java b/utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureManifold.java deleted file mode 100644 index ac55cc45a..000000000 --- a/utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureManifold.java +++ /dev/null @@ -1,333 +0,0 @@ -/* - * Copyright (C) 2021s The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.android.accessibility.utils.gestures; - -import static android.accessibilityservice.AccessibilityService.GESTURE_2_FINGER_SWIPE_DOWN; -import static android.accessibilityservice.AccessibilityService.GESTURE_2_FINGER_SWIPE_LEFT; -import static android.accessibilityservice.AccessibilityService.GESTURE_2_FINGER_SWIPE_RIGHT; -import static android.accessibilityservice.AccessibilityService.GESTURE_2_FINGER_SWIPE_UP; -import static com.google.android.accessibility.utils.gestures.TwoFingerSecondFingerMultiTap.ROTATE_DIRECTION_BACKWARD; -import static com.google.android.accessibility.utils.gestures.TwoFingerSecondFingerMultiTap.ROTATE_DIRECTION_FORWARD; - -import android.accessibilityservice.AccessibilityGestureEvent; -import android.content.Context; -import android.os.Build; -import android.view.MotionEvent; -import androidx.annotation.RequiresApi; -import com.google.android.accessibility.utils.Performance.EventId; -import com.google.android.accessibility.utils.gestures.GestureMatcher.AnalyticsEventLogger; -import com.google.android.libraries.accessibility.utils.log.LogUtils; -import com.google.common.collect.ImmutableList; -import com.google.errorprone.annotations.CanIgnoreReturnValue; -import java.util.ArrayList; -import java.util.List; - -/** - * This class coordinates a series of individual gesture matchers to serve as a unified gesture - * detector. Gesture matchers are tied to a single gesture. It calls listener callback functions - * when a gesture starts or completes. - */ -@RequiresApi(Build.VERSION_CODES.TIRAMISU) -public class GestureManifold implements GestureMatcher.StateChangeListener { - public static final int GESTURE_FAKED_SPLIT_TYPING = -3; - public static final int GESTURE_TAP_HOLD_AND_2ND_FINGER_FORWARD_DOUBLE_TAP = -4; - public static final int GESTURE_TAP_HOLD_AND_2ND_FINGER_BACKWARD_DOUBLE_TAP = -5; - public static final int GESTURE_TOUCH_EXPLORE = -6; - public static final int GESTURE_TAP_UP_TOUCH_EXPLORE = -7; - public static final int GESTURE_FAKED_SPLIT_TYPING_AND_HOLD = -8; - - // Match the value of GESTURE_ID_2FINGER_1TAP_HOLD in TalkBack. - public static final int GESTURE_2_FINGER_SINGLE_TAP_AND_HOLD = 63; - - private static final String LOG_TAG = "GestureManifold"; - - private final List gestures = new ArrayList<>(); - private final int displayId; - // Listener to be notified of gesture start and end. - private final Listener listener; - // Whether multi-finger gestures are enabled. - boolean multiFingerGesturesEnabled; - // Whether the two-finger passthrough is enabled when multi-finger gestures are enabled. - private boolean twoFingerPassthroughEnabled; - // A list of all the multi-finger gestures, for easy adding and removal. - private final List multiFingerGestures = new ArrayList<>(); - // A list of two-finger swipes, for easy adding and removal when turning on or off two-finger - // passthrough. - private final List twoFingerSwipes = new ArrayList<>(); - private boolean logMotionEvent = false; - - /** Define the interface to get project base feature settings. */ - public interface GestureConfigProvider { - default float getDoubleTapSlopMultiplier() { - return (float) 1.0; - } - - default boolean getSpeedUpTouchExploreState() { - return false; - } - - default boolean invalidSwipeGestureEarlyDetection() { - return false; - } - - default boolean useMultipleGestureSet() { - return false; - } - - default boolean enableSplitTapAndHold() { - return false; - } - } - - public GestureManifold( - Context context, - Listener listener, - GestureConfigProvider configResolver, - AnalyticsEventLogger logger, - int displayId, - ImmutableList supportGestureList) { - this.listener = listener; - this.displayId = displayId; - multiFingerGesturesEnabled = false; - twoFingerPassthroughEnabled = false; - - // Set up gestures. - List gestureMatcherList = - GestureMatcherFactory.getGestureMatcherList( - context, supportGestureList, this, configResolver, logger); - - for (GestureMatcher gestureMatcher : gestureMatcherList) { - if (gestureMatcher != null) { - if ((gestureMatcher instanceof Swipe) - || (gestureMatcher instanceof MultiTap) - || (gestureMatcher instanceof MultiTapAndHold) - || (gestureMatcher instanceof SecondFingerTap)) { - gestures.add(gestureMatcher); - } else { - multiFingerGestures.add(gestureMatcher); - int gestureId = gestureMatcher.getGestureId(); - if ((gestureId == GESTURE_2_FINGER_SWIPE_DOWN) - || (gestureId == GESTURE_2_FINGER_SWIPE_LEFT) - || (gestureId == GESTURE_2_FINGER_SWIPE_RIGHT) - || (gestureId == GESTURE_2_FINGER_SWIPE_UP)) { - twoFingerSwipes.add(gestureMatcher); - } - } - } - } - if (configResolver.useMultipleGestureSet()) { - gestures.add( - new TwoFingerSecondFingerMultiTap( - context, - 2, - ROTATE_DIRECTION_FORWARD, - GESTURE_TAP_HOLD_AND_2ND_FINGER_FORWARD_DOUBLE_TAP, - this, - logger)); - gestures.add( - new TwoFingerSecondFingerMultiTap( - context, - 2, - ROTATE_DIRECTION_BACKWARD, - GESTURE_TAP_HOLD_AND_2ND_FINGER_BACKWARD_DOUBLE_TAP, - this, - logger)); - } - } - - public void onConfigurationChanged(Context context) { - for (GestureMatcher gestureDetector : gestures) { - gestureDetector.onConfigurationChanged(context); - } - } - - public void enableLogMotionEvent() { - logMotionEvent = true; - for (GestureMatcher gestureDetector : gestures) { - gestureDetector.enableLogMotionEvent(); - } - } - - /** - * Processes a motion event. - * - * @param event The event as received from the previous entry in the event stream. - * @return True if the event has been appropriately handled by the gesture manifold and related - * callback functions, false if it should be handled further by the calling function. - */ - @CanIgnoreReturnValue - public boolean onMotionEvent(EventId eventId, MotionEvent event) { - for (GestureMatcher matcher : gestures) { - if (matcher.getState() != GestureMatcher.STATE_GESTURE_CANCELED) { - if (logMotionEvent) { - LogUtils.v(LOG_TAG, matcher.toString()); - } - matcher.onMotionEvent(eventId, event); - if (logMotionEvent) { - LogUtils.v(LOG_TAG, matcher.toString()); - } - - if (matcher.getState() == GestureMatcher.STATE_GESTURE_COMPLETED) { - // Here we just return. The actual gesture dispatch is done in - // onStateChanged(). - // No need to process this event any further. - return true; - } - } - } - return false; - } - - public void clear() { - for (GestureMatcher matcher : gestures) { - matcher.clear(); - } - } - - /** - * Listener that receives notifications of the state of the gesture detector. Listener functions - * are called as a result of onMotionEvent(). The current MotionEvent in the context of these - * functions is the event passed into onMotionEvent. - */ - public interface Listener { - - /** - * Called when the system has decided the event stream is a potential gesture. - * - * @param gestureId the gesture which is start matching. - */ - void onGestureStarted(int gestureId); - - /** - * Called when an event stream is recognized as a gesture. - * - * @param gestureEvent Information about the gesture. - */ - void onGestureCompleted(AccessibilityGestureEvent gestureEvent); - - /** - * Called when the system has decided an event stream doesn't match any known gesture. - * - * @param gestureId the gesture which is fail to match. - */ - void onGestureCancelled(int gestureId); - - /** - * Called when the gesture is processing and should be avoided to be interrupted. It's mainly be - * used to extend the multi-tap timeout even the user sets the touch focus delay with a shorter - * time. - * - * @param gestureId the gesture which is fail to match. - */ - default void onGestureProcessing(int gestureId) {} - } - - @Override - public void onStateChanged(int gestureId, int state, MotionEvent event) { - if (state == GestureMatcher.STATE_GESTURE_STARTED) { - listener.onGestureStarted(gestureId); - } else if (state == GestureMatcher.STATE_GESTURE_COMPLETED) { - onGestureCompleted(gestureId, event); - } else if (state == GestureMatcher.STATE_GESTURE_CANCELED) { - listener.onGestureCancelled(gestureId); - } else if (state == GestureMatcher.STATE_GESTURE_PROCESSING) { - listener.onGestureProcessing(gestureId); - } - } - - /** - * Called when the gesture detector has successfully identified the gesture by a series of - * MotionEvent. - * - * @param gestureId the gesture which is fail to match. - * @param event the last MotionEvent to match the identified gesture. - */ - private void onGestureCompleted(int gestureId, MotionEvent event) { - // Note that gestures that complete immediately call clear() from onMotionEvent. - // Gestures that complete on a delay call clear() here. - ArrayList eventList = new ArrayList<>(); - eventList.add(event); - AccessibilityGestureEvent gestureEvent = - new AccessibilityGestureEvent(gestureId, displayId, eventList); - for (GestureMatcher matcher : gestures) { - if (gestureId == GESTURE_TAP_UP_TOUCH_EXPLORE - && matcher.bypassCancelByTapUpToTouchExplore()) { - // Skip to cancel the gesture which claims itself to bypass cancel for this event. - continue; - } else if (gestureId == GESTURE_FAKED_SPLIT_TYPING - && matcher.getGestureId() == GESTURE_FAKED_SPLIT_TYPING_AND_HOLD) { - matcher.restart(true); - continue; - } else if (gestureId == GESTURE_FAKED_SPLIT_TYPING_AND_HOLD - && matcher.getGestureId() == GESTURE_FAKED_SPLIT_TYPING) { - matcher.restart(true); - continue; - } - if (matcher.getGestureId() != gestureId) { - matcher.cancelGesture(event, false); - } - } - listener.onGestureCompleted(gestureEvent); - } - - public boolean isMultiFingerGesturesEnabled() { - return multiFingerGesturesEnabled; - } - - public void setMultiFingerGesturesEnabled(boolean mode) { - if (multiFingerGesturesEnabled != mode) { - multiFingerGesturesEnabled = mode; - if (mode) { - gestures.addAll(multiFingerGestures); - } else { - gestures.removeAll(multiFingerGestures); - } - } - } - - public boolean isTwoFingerPassthroughEnabled() { - return twoFingerPassthroughEnabled; - } - - public void setTwoFingerPassthroughEnabled(boolean mode) { - if (twoFingerPassthroughEnabled != mode) { - twoFingerPassthroughEnabled = mode; - if (!mode) { - multiFingerGestures.addAll(twoFingerSwipes); - if (multiFingerGesturesEnabled) { - gestures.addAll(twoFingerSwipes); - } - } else { - multiFingerGestures.removeAll(twoFingerSwipes); - gestures.removeAll(twoFingerSwipes); - } - } - } - - /** - * This class helps to collect data (saved time to enter Touch Explore), in addition to the - * fundamental Gesture analytic event. - */ - public static class TapToTouchExploreAnalyticsEvent extends GestureAnalyticsEvent { - public int savedTimeMs; - - public TapToTouchExploreAnalyticsEvent(int event, int gestureId, int savedTimeMs) { - super(event, gestureId); - this.savedTimeMs = savedTimeMs; - } - } -} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureManifold.kt b/utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureManifold.kt new file mode 100644 index 000000000..e25546580 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureManifold.kt @@ -0,0 +1,315 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Ported from Java to Kotlin. + */ + +package com.google.android.accessibility.utils.gestures + +import android.accessibilityservice.AccessibilityGestureEvent +import android.accessibilityservice.AccessibilityService.GESTURE_2_FINGER_SWIPE_DOWN +import android.accessibilityservice.AccessibilityService.GESTURE_2_FINGER_SWIPE_LEFT +import android.accessibilityservice.AccessibilityService.GESTURE_2_FINGER_SWIPE_RIGHT +import android.accessibilityservice.AccessibilityService.GESTURE_2_FINGER_SWIPE_UP +import android.content.Context +import android.os.Build +import android.view.MotionEvent +import androidx.annotation.RequiresApi +import com.google.android.accessibility.utils.Performance.EventId +import com.google.android.accessibility.utils.gestures.GestureMatcher.AnalyticsEventLogger +import com.google.android.libraries.accessibility.utils.log.LogUtils +import com.google.common.collect.ImmutableList +import com.google.errorprone.annotations.CanIgnoreReturnValue + +/** + * This class coordinates a series of individual gesture matchers to serve as a unified gesture + * detector. Gesture matchers are tied to a single gesture. It calls listener callback functions + * when a gesture starts or completes. + */ +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +class GestureManifold( + context: Context, + // Listener to be notified of gesture start and end. + private val listener: Listener, + configResolver: GestureConfigProvider, + logger: AnalyticsEventLogger, + private val displayId: Int, + supportGestureList: ImmutableList, +) : GestureMatcher.StateChangeListener { + + private val gestures: MutableList = ArrayList() + + // Whether multi-finger gestures are enabled. + @JvmField internal var multiFingerGesturesEnabled: Boolean = false + + // Whether the two-finger passthrough is enabled when multi-finger gestures are enabled. + private var twoFingerPassthroughEnabled: Boolean = false + + // A list of all the multi-finger gestures, for easy adding and removal. + private val multiFingerGestures: MutableList = ArrayList() + + // A list of two-finger swipes, for easy adding and removal when turning on or off two-finger + // passthrough. + private val twoFingerSwipes: MutableList = ArrayList() + private var logMotionEvent = false + + /** Define the interface to get project base feature settings. */ + interface GestureConfigProvider { + fun getDoubleTapSlopMultiplier(): Float = 1.0f + + fun getSpeedUpTouchExploreState(): Boolean = false + + fun invalidSwipeGestureEarlyDetection(): Boolean = false + + fun useMultipleGestureSet(): Boolean = false + + fun enableSplitTapAndHold(): Boolean = false + } + + init { + // Set up gestures. + val gestureMatcherList = + GestureMatcherFactory.getGestureMatcherList( + context, supportGestureList, this, configResolver, logger, + ) + + for (gestureMatcher in gestureMatcherList) { + if (gestureMatcher is Swipe || + gestureMatcher is MultiTap || + gestureMatcher is MultiTapAndHold || + gestureMatcher is SecondFingerTap + ) { + gestures.add(gestureMatcher) + } else { + multiFingerGestures.add(gestureMatcher) + val gestureId = gestureMatcher.gestureId + if (gestureId == GESTURE_2_FINGER_SWIPE_DOWN || + gestureId == GESTURE_2_FINGER_SWIPE_LEFT || + gestureId == GESTURE_2_FINGER_SWIPE_RIGHT || + gestureId == GESTURE_2_FINGER_SWIPE_UP + ) { + twoFingerSwipes.add(gestureMatcher) + } + } + } + if (configResolver.useMultipleGestureSet()) { + gestures.add( + TwoFingerSecondFingerMultiTap( + context, + 2, + TwoFingerSecondFingerMultiTap.ROTATE_DIRECTION_FORWARD, + GESTURE_TAP_HOLD_AND_2ND_FINGER_FORWARD_DOUBLE_TAP, + this, + logger, + ), + ) + gestures.add( + TwoFingerSecondFingerMultiTap( + context, + 2, + TwoFingerSecondFingerMultiTap.ROTATE_DIRECTION_BACKWARD, + GESTURE_TAP_HOLD_AND_2ND_FINGER_BACKWARD_DOUBLE_TAP, + this, + logger, + ), + ) + } + } + + fun onConfigurationChanged(context: Context) { + for (gestureDetector in gestures) { + gestureDetector.onConfigurationChanged(context) + } + } + + fun enableLogMotionEvent() { + logMotionEvent = true + for (gestureDetector in gestures) { + gestureDetector.enableLogMotionEvent() + } + } + + /** + * Processes a motion event. + * + * @param event The event as received from the previous entry in the event stream. + * @return True if the event has been appropriately handled by the gesture manifold and related + * callback functions, false if it should be handled further by the calling function. + */ + @CanIgnoreReturnValue + fun onMotionEvent(eventId: EventId?, event: MotionEvent): Boolean { + for (matcher in gestures) { + if (matcher.state != GestureMatcher.STATE_GESTURE_CANCELED) { + if (logMotionEvent) { + LogUtils.v(LOG_TAG, matcher.toString()) + } + matcher.onMotionEvent(eventId, event) + if (logMotionEvent) { + LogUtils.v(LOG_TAG, matcher.toString()) + } + + if (matcher.state == GestureMatcher.STATE_GESTURE_COMPLETED) { + // Here we just return. The actual gesture dispatch is done in + // onStateChanged(). + // No need to process this event any further. + return true + } + } + } + return false + } + + fun clear() { + for (matcher in gestures) { + matcher.clear() + } + } + + /** + * Listener that receives notifications of the state of the gesture detector. Listener functions + * are called as a result of onMotionEvent(). The current MotionEvent in the context of these + * functions is the event passed into onMotionEvent. + */ + interface Listener { + + /** + * Called when the system has decided the event stream is a potential gesture. + * + * @param gestureId the gesture which is start matching. + */ + fun onGestureStarted(gestureId: Int) + + /** + * Called when an event stream is recognized as a gesture. + * + * @param gestureEvent Information about the gesture. + */ + fun onGestureCompleted(gestureEvent: AccessibilityGestureEvent) + + /** + * Called when the system has decided an event stream doesn't match any known gesture. + * + * @param gestureId the gesture which is fail to match. + */ + fun onGestureCancelled(gestureId: Int) + + /** + * Called when the gesture is processing and should be avoided to be interrupted. It's mainly be + * used to extend the multi-tap timeout even the user sets the touch focus delay with a shorter + * time. + * + * @param gestureId the gesture which is fail to match. + */ + fun onGestureProcessing(gestureId: Int) {} + } + + override fun onStateChanged(gestureId: Int, state: Int, event: MotionEvent) { + if (state == GestureMatcher.STATE_GESTURE_STARTED) { + listener.onGestureStarted(gestureId) + } else if (state == GestureMatcher.STATE_GESTURE_COMPLETED) { + onGestureCompleted(gestureId, event) + } else if (state == GestureMatcher.STATE_GESTURE_CANCELED) { + listener.onGestureCancelled(gestureId) + } else if (state == GestureMatcher.STATE_GESTURE_PROCESSING) { + listener.onGestureProcessing(gestureId) + } + } + + /** + * Called when the gesture detector has successfully identified the gesture by a series of + * MotionEvent. + * + * @param gestureId the gesture which is fail to match. + * @param event the last MotionEvent to match the identified gesture. + */ + private fun onGestureCompleted(gestureId: Int, event: MotionEvent) { + // Note that gestures that complete immediately call clear() from onMotionEvent. + // Gestures that complete on a delay call clear() here. + val eventList = ArrayList() + eventList.add(event) + val gestureEvent = AccessibilityGestureEvent(gestureId, displayId, eventList) + for (matcher in gestures) { + if (gestureId == GESTURE_TAP_UP_TOUCH_EXPLORE && matcher.bypassCancelByTapUpToTouchExplore()) { + // Skip to cancel the gesture which claims itself to bypass cancel for this event. + continue + } else if (gestureId == GESTURE_FAKED_SPLIT_TYPING && + matcher.gestureId == GESTURE_FAKED_SPLIT_TYPING_AND_HOLD + ) { + matcher.restart(true) + continue + } else if (gestureId == GESTURE_FAKED_SPLIT_TYPING_AND_HOLD && + matcher.gestureId == GESTURE_FAKED_SPLIT_TYPING + ) { + matcher.restart(true) + continue + } + if (matcher.gestureId != gestureId) { + matcher.cancelGesture(event, false) + } + } + listener.onGestureCompleted(gestureEvent) + } + + fun isMultiFingerGesturesEnabled(): Boolean = multiFingerGesturesEnabled + + fun setMultiFingerGesturesEnabled(mode: Boolean) { + if (multiFingerGesturesEnabled != mode) { + multiFingerGesturesEnabled = mode + if (mode) { + gestures.addAll(multiFingerGestures) + } else { + gestures.removeAll(multiFingerGestures) + } + } + } + + fun isTwoFingerPassthroughEnabled(): Boolean = twoFingerPassthroughEnabled + + fun setTwoFingerPassthroughEnabled(mode: Boolean) { + if (twoFingerPassthroughEnabled != mode) { + twoFingerPassthroughEnabled = mode + if (!mode) { + multiFingerGestures.addAll(twoFingerSwipes) + if (multiFingerGesturesEnabled) { + gestures.addAll(twoFingerSwipes) + } + } else { + multiFingerGestures.removeAll(twoFingerSwipes) + gestures.removeAll(twoFingerSwipes) + } + } + } + + /** + * This class helps to collect data (saved time to enter Touch Explore), in addition to the + * fundamental Gesture analytic event. + */ + class TapToTouchExploreAnalyticsEvent(event: Int, gestureId: Int, @JvmField var savedTimeMs: Int) : + GestureAnalyticsEvent(event, gestureId) + + companion object { + const val GESTURE_FAKED_SPLIT_TYPING = -3 + const val GESTURE_TAP_HOLD_AND_2ND_FINGER_FORWARD_DOUBLE_TAP = -4 + const val GESTURE_TAP_HOLD_AND_2ND_FINGER_BACKWARD_DOUBLE_TAP = -5 + const val GESTURE_TOUCH_EXPLORE = -6 + const val GESTURE_TAP_UP_TOUCH_EXPLORE = -7 + const val GESTURE_FAKED_SPLIT_TYPING_AND_HOLD = -8 + + // Match the value of GESTURE_ID_2FINGER_1TAP_HOLD in TalkBack. + const val GESTURE_2_FINGER_SINGLE_TAP_AND_HOLD = 63 + + private const val LOG_TAG = "GestureManifold" + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureMatcher.java b/utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureMatcher.java deleted file mode 100644 index e6a32de08..000000000 --- a/utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureMatcher.java +++ /dev/null @@ -1,573 +0,0 @@ -/* - * Copyright (C) 2021 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.android.accessibility.utils.gestures; - -import static android.accessibilityservice.AccessibilityService.GESTURE_2_FINGER_DOUBLE_TAP; -import static android.accessibilityservice.AccessibilityService.GESTURE_2_FINGER_DOUBLE_TAP_AND_HOLD; -import static android.accessibilityservice.AccessibilityService.GESTURE_2_FINGER_SINGLE_TAP; -import static android.accessibilityservice.AccessibilityService.GESTURE_2_FINGER_SWIPE_DOWN; -import static android.accessibilityservice.AccessibilityService.GESTURE_2_FINGER_SWIPE_LEFT; -import static android.accessibilityservice.AccessibilityService.GESTURE_2_FINGER_SWIPE_RIGHT; -import static android.accessibilityservice.AccessibilityService.GESTURE_2_FINGER_SWIPE_UP; -import static android.accessibilityservice.AccessibilityService.GESTURE_2_FINGER_TRIPLE_TAP; -import static android.accessibilityservice.AccessibilityService.GESTURE_2_FINGER_TRIPLE_TAP_AND_HOLD; -import static android.accessibilityservice.AccessibilityService.GESTURE_3_FINGER_DOUBLE_TAP; -import static android.accessibilityservice.AccessibilityService.GESTURE_3_FINGER_DOUBLE_TAP_AND_HOLD; -import static android.accessibilityservice.AccessibilityService.GESTURE_3_FINGER_SINGLE_TAP; -import static android.accessibilityservice.AccessibilityService.GESTURE_3_FINGER_SINGLE_TAP_AND_HOLD; -import static android.accessibilityservice.AccessibilityService.GESTURE_3_FINGER_SWIPE_DOWN; -import static android.accessibilityservice.AccessibilityService.GESTURE_3_FINGER_SWIPE_LEFT; -import static android.accessibilityservice.AccessibilityService.GESTURE_3_FINGER_SWIPE_RIGHT; -import static android.accessibilityservice.AccessibilityService.GESTURE_3_FINGER_SWIPE_UP; -import static android.accessibilityservice.AccessibilityService.GESTURE_3_FINGER_TRIPLE_TAP; -import static android.accessibilityservice.AccessibilityService.GESTURE_3_FINGER_TRIPLE_TAP_AND_HOLD; -import static android.accessibilityservice.AccessibilityService.GESTURE_4_FINGER_DOUBLE_TAP; -import static android.accessibilityservice.AccessibilityService.GESTURE_4_FINGER_DOUBLE_TAP_AND_HOLD; -import static android.accessibilityservice.AccessibilityService.GESTURE_4_FINGER_SINGLE_TAP; -import static android.accessibilityservice.AccessibilityService.GESTURE_4_FINGER_SWIPE_DOWN; -import static android.accessibilityservice.AccessibilityService.GESTURE_4_FINGER_SWIPE_LEFT; -import static android.accessibilityservice.AccessibilityService.GESTURE_4_FINGER_SWIPE_RIGHT; -import static android.accessibilityservice.AccessibilityService.GESTURE_4_FINGER_SWIPE_UP; -import static android.accessibilityservice.AccessibilityService.GESTURE_4_FINGER_TRIPLE_TAP; -import static android.accessibilityservice.AccessibilityService.GESTURE_DOUBLE_TAP; -import static android.accessibilityservice.AccessibilityService.GESTURE_DOUBLE_TAP_AND_HOLD; -import static android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_DOWN; -import static android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_DOWN_AND_LEFT; -import static android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_DOWN_AND_RIGHT; -import static android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_DOWN_AND_UP; -import static android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_LEFT; -import static android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_LEFT_AND_DOWN; -import static android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_LEFT_AND_RIGHT; -import static android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_LEFT_AND_UP; -import static android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_RIGHT; -import static android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_RIGHT_AND_DOWN; -import static android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_RIGHT_AND_LEFT; -import static android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_RIGHT_AND_UP; -import static android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_UP; -import static android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_UP_AND_DOWN; -import static android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_UP_AND_LEFT; -import static android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_UP_AND_RIGHT; -import static android.util.Log.ERROR; -import static android.util.Log.VERBOSE; - -import android.content.Context; -import android.os.Build; -import android.os.Handler; -import android.view.MotionEvent; -import android.view.ViewConfiguration; -import androidx.annotation.IntDef; -import androidx.annotation.NonNull; -import androidx.annotation.RequiresApi; -import com.google.android.accessibility.utils.Performance; -import com.google.android.accessibility.utils.Performance.EventId; -import com.google.android.libraries.accessibility.utils.log.LogUtils; -import com.google.errorprone.annotations.CanIgnoreReturnValue; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import org.checkerframework.checker.nullness.qual.Nullable; - -/** - * This class describes a common base for gesture matchers. A gesture matcher checks a series of - * motion events against a single gesture. Coordinating the individual gesture matchers is done by - * the GestureManifold. To create a new Gesture, extend this class and override the onDown, onMove, - * onUp, etc methods as necessary. If you don't override a method your matcher will do nothing in - * response to that type of event. Finally, be sure to give your gesture a name by overriding - * getGestureName(). - * - * @hide - */ -@RequiresApi(Build.VERSION_CODES.TIRAMISU) -public abstract class GestureMatcher { - // Potential states for this individual gesture matcher. - /** - * In STATE_CLEAR, this matcher is accepting new motion events but has not formally signaled that - * there is enough data to judge that a gesture has started. - */ - public static final int STATE_CLEAR = 0; - - /** - * In STATE_GESTURE_STARTED, this matcher continues to accept motion events and it has signaled to - * the listener that what looks like the specified gesture has started. - */ - public static final int STATE_GESTURE_STARTED = 1; - - /** - * In STATE_GESTURE_COMPLETED, this matcher has successfully matched the specified gesture. and - * will not accept motion events until it is cleared. - */ - public static final int STATE_GESTURE_COMPLETED = 2; - - /** - * In STATE_GESTURE_CANCELED, this matcher will not accept new motion events because it is - * impossible that this set of motion events will match the specified gesture. - */ - public static final int STATE_GESTURE_CANCELED = 3; - - /** - * In STATE_GESTURE_PROCESSING, this matcher does nothing but informing the listener which can - * handle trivial thing such as extend the touch explore timer. - */ - public static final int STATE_GESTURE_PROCESSING = 4; - - @Retention(RetentionPolicy.SOURCE) - @IntDef({ - STATE_CLEAR, - STATE_GESTURE_STARTED, - STATE_GESTURE_COMPLETED, - STATE_GESTURE_CANCELED, - STATE_GESTURE_PROCESSING - }) - @interface State {} - - @State private int state = STATE_CLEAR; - - @IntDef({ - GESTURE_2_FINGER_SINGLE_TAP, - GESTURE_2_FINGER_DOUBLE_TAP, - GESTURE_2_FINGER_DOUBLE_TAP_AND_HOLD, - GESTURE_2_FINGER_TRIPLE_TAP, - GESTURE_2_FINGER_TRIPLE_TAP_AND_HOLD, - GESTURE_3_FINGER_SINGLE_TAP, - GESTURE_3_FINGER_SINGLE_TAP_AND_HOLD, - GESTURE_3_FINGER_DOUBLE_TAP, - GESTURE_3_FINGER_DOUBLE_TAP_AND_HOLD, - GESTURE_3_FINGER_TRIPLE_TAP, - GESTURE_3_FINGER_TRIPLE_TAP_AND_HOLD, - GESTURE_DOUBLE_TAP, - GESTURE_DOUBLE_TAP_AND_HOLD, - GESTURE_SWIPE_UP, - GESTURE_SWIPE_UP_AND_LEFT, - GESTURE_SWIPE_UP_AND_DOWN, - GESTURE_SWIPE_UP_AND_RIGHT, - GESTURE_SWIPE_DOWN, - GESTURE_SWIPE_DOWN_AND_LEFT, - GESTURE_SWIPE_DOWN_AND_UP, - GESTURE_SWIPE_DOWN_AND_RIGHT, - GESTURE_SWIPE_LEFT, - GESTURE_SWIPE_LEFT_AND_UP, - GESTURE_SWIPE_LEFT_AND_RIGHT, - GESTURE_SWIPE_LEFT_AND_DOWN, - GESTURE_SWIPE_RIGHT, - GESTURE_SWIPE_RIGHT_AND_UP, - GESTURE_SWIPE_RIGHT_AND_LEFT, - GESTURE_SWIPE_RIGHT_AND_DOWN, - GESTURE_2_FINGER_SWIPE_DOWN, - GESTURE_2_FINGER_SWIPE_LEFT, - GESTURE_2_FINGER_SWIPE_RIGHT, - GESTURE_2_FINGER_SWIPE_UP, - GESTURE_3_FINGER_SWIPE_DOWN, - GESTURE_3_FINGER_SWIPE_LEFT, - GESTURE_3_FINGER_SWIPE_RIGHT, - GESTURE_3_FINGER_SWIPE_UP, - GESTURE_4_FINGER_DOUBLE_TAP, - GESTURE_4_FINGER_DOUBLE_TAP_AND_HOLD, - GESTURE_4_FINGER_SINGLE_TAP, - GESTURE_4_FINGER_SWIPE_DOWN, - GESTURE_4_FINGER_SWIPE_LEFT, - GESTURE_4_FINGER_SWIPE_RIGHT, - GESTURE_4_FINGER_SWIPE_UP, - GESTURE_4_FINGER_TRIPLE_TAP - }) - @Retention(RetentionPolicy.SOURCE) - @interface GestureId {} - - // The id number of the gesture that gets passed to accessibility services. - @GestureId private final int gestureId; - // handler for asynchronous operations like timeouts - private final Handler handler; - - private StateChangeListener listener = null; - private final AnalyticsEventLogger logger; - - // Use this to transition to new states after a delay. - // e.g. cancel or complete after some timeout. - // Convenience functions for tapTimeout and doubleTapTimeout are already defined here. - protected final DelayedTransition delayedTransition; - - protected boolean logMotionEvent = false; - - protected GestureMatcher( - int gestureId, Handler handler, StateChangeListener listener, AnalyticsEventLogger logger) { - this.gestureId = gestureId; - this.handler = handler; - delayedTransition = new DelayedTransition(); - this.listener = listener; - this.logger = logger; - } - - public void onConfigurationChanged(Context context) {} - - public void enableLogMotionEvent() { - logMotionEvent = true; - } - - /** - * Resets all state information for this matcher. Subclasses that include their own state - * information should override this method to reset their own state information and call - * super.clear(). - */ - public void clear() { - state = STATE_CLEAR; - cancelPendingTransitions(); - } - - /** - * TalkBack maintains the touch-interaction & touch-explore state by itself. When the system - * detects a valid split-tap, user can keep his finger touched on screen and request the state to - * touch-explore so that the gesture detector can resume again. - * - * @param pending tells the Split-tap detector should wait extra events then back to its detecting - * state. - */ - public void restart(boolean pending) { - state = STATE_CLEAR; - cancelPendingTransitions(); - } - - public final int getState() { - return state; - } - - public void debugMotionEvent(String tag, String format, @Nullable Object... args) { - if (logMotionEvent) { - LogUtils.v(tag, format, args); - } - } - - /** - * Transitions to a new state and notifies any listeners. Note that any pending transitions are - * canceled. - */ - private void setState(@State int state, MotionEvent event) { - setState(state, event, true); - } - - /** - * Transitions to a new state and notifies any listeners. Note that any pending transitions are - * canceled. - * - * @param state the new state for the gesture detector. - * @param event the MotionEvent caused the state transition. - * @param notify should notify the upper listener or not about the state change. This can avoid - * the upper listeners receive call back more than once (especially for cancel event). - */ - private void setState(@State int state, MotionEvent event, boolean notify) { - if (state != STATE_GESTURE_PROCESSING) { - this.state = state; - cancelPendingTransitions(); - } - if (notify && listener != null) { - listener.onStateChanged(gestureId, state, event); - } - } - - /** Indicates that there is evidence to suggest that this gesture has started. */ - protected final void startGesture(MotionEvent event) { - setState(STATE_GESTURE_STARTED, event); - } - - /** Indicates this stream of motion events can no longer match this gesture. */ - public final void cancelGesture(MotionEvent event, boolean notify) { - setState(STATE_GESTURE_CANCELED, event, notify); - } - - public final void cancelGesture(MotionEvent event) { - setState(STATE_GESTURE_CANCELED, event); - } - - /** Indicates this gesture is completed. */ - protected final void completeGesture(EventId eventId, MotionEvent event) { - Performance.getInstance().onGestureLastMotionEventTime(eventId, event.getEventTime()); - setState(STATE_GESTURE_COMPLETED, event); - } - - /** Extend the touch explore timer window. */ - protected final void processGesture(EventId eventId, MotionEvent event) { - setState(STATE_GESTURE_PROCESSING, event); - } - - protected final void analyticsEvent(GestureAnalyticsEvent analyticsEvent) { - logger.logAnalyticsEvent(analyticsEvent); - } - - public final void setListener(@NonNull StateChangeListener listener) { - this.listener = listener; - } - - public int getGestureId() { - return gestureId; - } - - /** - * Returns true to indicate that the gesture would not be cancelled when the touch-exploring mode - * is still ongoing event gesture completed. For example, split-typing could keep alive when user - * is touch-exploring the screen. - */ - public boolean bypassCancelByTapUpToTouchExplore() { - return false; - } - - /** - * Process a motion event and attempt to match it to this gesture. - * - * @param event the event as passed in from the event stream. - * @return the state of this matcher. - */ - @CanIgnoreReturnValue - public final int onMotionEvent(EventId eventId, MotionEvent event) { - if (state == STATE_GESTURE_CANCELED || state == STATE_GESTURE_COMPLETED) { - return state; - } - switch (event.getActionMasked()) { - case MotionEvent.ACTION_DOWN -> onDown(eventId, event); - case MotionEvent.ACTION_POINTER_DOWN -> onPointerDown(eventId, event); - case MotionEvent.ACTION_MOVE -> onMove(eventId, event); - case MotionEvent.ACTION_POINTER_UP -> onPointerUp(eventId, event); - case MotionEvent.ACTION_UP -> onUp(eventId, event); - default -> - // Cancel because of invalid event. - setState(STATE_GESTURE_CANCELED, event); - } - return state; - } - - /** - * Matchers override this method to respond to ACTION_DOWN events. ACTION_DOWN events indicate the - * first finger has touched the screen. If not overridden the default response is to do nothing. - */ - protected void onDown(EventId eventId, MotionEvent event) {} - - /** - * Matchers override this method to respond to ACTION_POINTER_DOWN events. ACTION_POINTER_DOWN - * indicates that more than one finger has touched the screen. If not overridden the default - * response is to do nothing. - * - * @param event the event as passed in from the event stream. - */ - protected void onPointerDown(EventId eventId, MotionEvent event) {} - - /** - * Matchers override this method to respond to ACTION_MOVE events. ACTION_MOVE indicates that one - * or fingers has moved. If not overridden the default response is to do nothing. - * - * @param event the event as passed in from the event stream. - */ - protected void onMove(EventId eventId, MotionEvent event) {} - - /** - * Matchers override this method to respond to ACTION_POINTER_UP events. ACTION_POINTER_UP - * indicates that a finger has lifted from the screen but at least one finger continues to touch - * the screen. If not overridden the default response is to do nothing. - * - * @param event the event as passed in from the event stream. - */ - protected void onPointerUp(EventId eventId, MotionEvent event) {} - - /** - * Matchers override this method to respond to ACTION_UP events. ACTION_UP indicates that there - * are no more fingers touching the screen. If not overridden the default response is to do - * nothing. - * - * @param event the event as passed in from the event stream. - */ - protected void onUp(EventId eventId, MotionEvent event) {} - - /** Cancels this matcher after the tap timeout. Any pending state transitions are removed. */ - protected void cancelAfterTapTimeout(EventId eventId, MotionEvent event) { - cancelAfter(ViewConfiguration.getTapTimeout(), event); - } - - /** Cancels this matcher after the double tap timeout. Any pending cancelations are removed. */ - protected final void cancelAfterDoubleTapTimeout(MotionEvent event) { - cancelAfter(ViewConfiguration.getDoubleTapTimeout(), event); - } - - /** - * Cancels this matcher after the specified timeout. Any pending cancelations are removed. Used to - * prevent this matcher from accepting motion events until it is cleared. - */ - protected final void cancelAfter(long timeout, MotionEvent event) { - delayedTransition.cancel(); - delayedTransition.post(STATE_GESTURE_CANCELED, timeout, event); - } - - /** Cancels any delayed transitions between states scheduled for this matcher. */ - protected final void cancelPendingTransitions() { - delayedTransition.cancel(); - } - - /** - * Signals that this gesture has been completed after the tap timeout has expired. Used to ensure - * that there is no conflict with another gesture or for gestures that explicitly require a hold. - */ - protected final void completeAfterLongPressTimeout(EventId eventId, MotionEvent event) { - completeAfter(ViewConfiguration.getLongPressTimeout(), eventId, event); - } - - /** - * Signals that this gesture has been completed after the tap timeout has expired. Used to ensure - * that there is no conflict with another gesture or for gestures that explicitly require a hold. - */ - protected final void completeAfterTapTimeout(EventId eventId, MotionEvent event) { - completeAfter(ViewConfiguration.getTapTimeout(), eventId, event); - } - - /** - * Signals that this gesture has been completed after the specified timeout has expired. Used to - * ensure that there is no conflict with another gesture or for gestures that explicitly require a - * hold. - */ - protected final void completeAfter(long timeout, EventId eventId, MotionEvent event) { - delayedTransition.cancel(); - Performance.getInstance().onGestureLastMotionEventTime(eventId, event.getEventTime()); - delayedTransition.post(STATE_GESTURE_COMPLETED, timeout, event); - } - - /** - * Signals that this gesture has been completed after the double-tap timeout has expired. Used to - * ensure that there is no conflict with another gesture or for gestures that explicitly require a - * hold. - */ - protected final void completeAfterDoubleTapTimeout(EventId eventId, MotionEvent event) { - completeAfter(ViewConfiguration.getDoubleTapTimeout(), eventId, event); - } - - void gestureMotionEventLog(int logLevel, String format, @Nullable Object... args) { - if (logMotionEvent) { - switch (logLevel) { - case ERROR: - LogUtils.e(getGestureName(), format, args); - break; - case VERBOSE: - // fall-through - default: - LogUtils.v(getGestureName(), format, args); - break; - } - } - } - - static String getStateSymbolicName(@State int state) { - return switch (state) { - case STATE_CLEAR -> "STATE_CLEAR"; - case STATE_GESTURE_STARTED -> "STATE_GESTURE_STARTED"; - case STATE_GESTURE_COMPLETED -> "STATE_GESTURE_COMPLETED"; - case STATE_GESTURE_CANCELED -> "STATE_GESTURE_CANCELED"; - case STATE_GESTURE_PROCESSING -> "STATE_GESTURE_PROCESSING"; - default -> "Unknown state: " + state; - }; - } - - /** - * Returns a readable name for this matcher that can be displayed to the user and in system logs. - */ - protected abstract String getGestureName(); - - /** - * Returns a String representation of this matcher. Each matcher can override this method to add - * extra state information to the string representation. - */ - @Override - public String toString() { - return getGestureName() + ":" + getStateSymbolicName(state); - } - - /** This class allows matchers to transition between states on a delay. */ - protected final class DelayedTransition implements Runnable { - - private static final String LOG_TAG = "GestureMatcher.DelayedTransition"; - int targetState; - MotionEvent event; - - public void cancel() { - // Avoid meaningless debug messages. - synchronized (GestureMatcher.this) { - if (isPending()) { - LogUtils.v( - LOG_TAG, - "%s: canceling delayed transition to %s", - getGestureName(), - getStateSymbolicName(targetState)); - } - handler.removeCallbacks(this); - recycleEvent(); - } - } - - public void post(int state, long delay, MotionEvent event) { - synchronized (GestureMatcher.this) { - this.targetState = state; - // Just in case the cancel is not performed immediately before post. - recycleEvent(); - this.event = MotionEvent.obtain(event); - handler.postDelayed(this, delay); - LogUtils.v( - LOG_TAG, - "%s: posting delayed transition to %s", - getGestureName(), - getStateSymbolicName(targetState)); - } - } - - public boolean isPending() { - return handler.hasCallbacks(this); - } - - public void forceSendAndRemove() { - if (isPending()) { - run(); - cancel(); - } - } - - @Override - public void run() { - synchronized (GestureMatcher.this) { - if (event == null) { - return; - } - LogUtils.v( - LOG_TAG, - "%s: executing delayed transition to %s", - getGestureName(), - getStateSymbolicName(targetState)); - setState(targetState, event); - recycleEvent(); - } - } - - private void recycleEvent() { - if (event == null) { - return; - } - event.recycle(); - event = null; - } - } - - /** Interface to allow a class to listen for state changes in a specific gesture matcher */ - public interface StateChangeListener { - - void onStateChanged(int gestureId, int state, MotionEvent event); - } - - /** Interface to handle the analytics event for a gesture. */ - public interface AnalyticsEventLogger { - - void logAnalyticsEvent(GestureAnalyticsEvent analyticsEvent); - } -} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureMatcher.kt b/utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureMatcher.kt new file mode 100644 index 000000000..3817aaa7f --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureMatcher.kt @@ -0,0 +1,490 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Ported from Java to Kotlin. + */ + +package com.google.android.accessibility.utils.gestures + +import android.accessibilityservice.AccessibilityService +import android.content.Context +import android.os.Build +import android.os.Handler +import android.util.Log.ERROR +import android.util.Log.VERBOSE +import android.view.MotionEvent +import android.view.ViewConfiguration +import androidx.annotation.IntDef +import androidx.annotation.RequiresApi +import com.google.android.accessibility.utils.Performance +import com.google.android.accessibility.utils.Performance.EventId +import com.google.android.libraries.accessibility.utils.log.LogUtils +import com.google.errorprone.annotations.CanIgnoreReturnValue + +/** + * This class describes a common base for gesture matchers. A gesture matcher checks a series of + * motion events against a single gesture. Coordinating the individual gesture matchers is done by + * the GestureManifold. To create a new Gesture, extend this class and override the onDown, onMove, + * onUp, etc methods as necessary. If you don't override a method your matcher will do nothing in + * response to that type of event. Finally, be sure to give your gesture a name by overriding + * getGestureName(). + * + * @hide + */ +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +abstract class GestureMatcher protected constructor( + // The id number of the gesture that gets passed to accessibility services. + @get:JvmName("getGestureId") val gestureId: Int, + // handler for asynchronous operations like timeouts + private val handler: Handler, + private var listener: StateChangeListener?, + private val logger: AnalyticsEventLogger, +) { + + @Retention(AnnotationRetention.SOURCE) + @IntDef( + STATE_CLEAR, + STATE_GESTURE_STARTED, + STATE_GESTURE_COMPLETED, + STATE_GESTURE_CANCELED, + STATE_GESTURE_PROCESSING, + ) + internal annotation class State + + @Retention(AnnotationRetention.SOURCE) + @IntDef( + AccessibilityService.GESTURE_2_FINGER_SINGLE_TAP, + AccessibilityService.GESTURE_2_FINGER_DOUBLE_TAP, + AccessibilityService.GESTURE_2_FINGER_DOUBLE_TAP_AND_HOLD, + AccessibilityService.GESTURE_2_FINGER_TRIPLE_TAP, + AccessibilityService.GESTURE_2_FINGER_TRIPLE_TAP_AND_HOLD, + AccessibilityService.GESTURE_3_FINGER_SINGLE_TAP, + AccessibilityService.GESTURE_3_FINGER_SINGLE_TAP_AND_HOLD, + AccessibilityService.GESTURE_3_FINGER_DOUBLE_TAP, + AccessibilityService.GESTURE_3_FINGER_DOUBLE_TAP_AND_HOLD, + AccessibilityService.GESTURE_3_FINGER_TRIPLE_TAP, + AccessibilityService.GESTURE_3_FINGER_TRIPLE_TAP_AND_HOLD, + AccessibilityService.GESTURE_DOUBLE_TAP, + AccessibilityService.GESTURE_DOUBLE_TAP_AND_HOLD, + AccessibilityService.GESTURE_SWIPE_UP, + AccessibilityService.GESTURE_SWIPE_UP_AND_LEFT, + AccessibilityService.GESTURE_SWIPE_UP_AND_DOWN, + AccessibilityService.GESTURE_SWIPE_UP_AND_RIGHT, + AccessibilityService.GESTURE_SWIPE_DOWN, + AccessibilityService.GESTURE_SWIPE_DOWN_AND_LEFT, + AccessibilityService.GESTURE_SWIPE_DOWN_AND_UP, + AccessibilityService.GESTURE_SWIPE_DOWN_AND_RIGHT, + AccessibilityService.GESTURE_SWIPE_LEFT, + AccessibilityService.GESTURE_SWIPE_LEFT_AND_UP, + AccessibilityService.GESTURE_SWIPE_LEFT_AND_RIGHT, + AccessibilityService.GESTURE_SWIPE_LEFT_AND_DOWN, + AccessibilityService.GESTURE_SWIPE_RIGHT, + AccessibilityService.GESTURE_SWIPE_RIGHT_AND_UP, + AccessibilityService.GESTURE_SWIPE_RIGHT_AND_LEFT, + AccessibilityService.GESTURE_SWIPE_RIGHT_AND_DOWN, + AccessibilityService.GESTURE_2_FINGER_SWIPE_DOWN, + AccessibilityService.GESTURE_2_FINGER_SWIPE_LEFT, + AccessibilityService.GESTURE_2_FINGER_SWIPE_RIGHT, + AccessibilityService.GESTURE_2_FINGER_SWIPE_UP, + AccessibilityService.GESTURE_3_FINGER_SWIPE_DOWN, + AccessibilityService.GESTURE_3_FINGER_SWIPE_LEFT, + AccessibilityService.GESTURE_3_FINGER_SWIPE_RIGHT, + AccessibilityService.GESTURE_3_FINGER_SWIPE_UP, + AccessibilityService.GESTURE_4_FINGER_DOUBLE_TAP, + AccessibilityService.GESTURE_4_FINGER_DOUBLE_TAP_AND_HOLD, + AccessibilityService.GESTURE_4_FINGER_SINGLE_TAP, + AccessibilityService.GESTURE_4_FINGER_SWIPE_DOWN, + AccessibilityService.GESTURE_4_FINGER_SWIPE_LEFT, + AccessibilityService.GESTURE_4_FINGER_SWIPE_RIGHT, + AccessibilityService.GESTURE_4_FINGER_SWIPE_UP, + AccessibilityService.GESTURE_4_FINGER_TRIPLE_TAP, + ) + internal annotation class GestureId + + @State + var state: Int = STATE_CLEAR + private set + + // Use this to transition to new states after a delay. + // e.g. cancel or complete after some timeout. + // Convenience functions for tapTimeout and doubleTapTimeout are already defined here. + protected val delayedTransition: DelayedTransition = DelayedTransition() + + protected var logMotionEvent: Boolean = false + + open fun onConfigurationChanged(context: Context) {} + + fun enableLogMotionEvent() { + logMotionEvent = true + } + + /** + * Resets all state information for this matcher. Subclasses that include their own state + * information should override this method to reset their own state information and call + * super.clear(). + */ + open fun clear() { + state = STATE_CLEAR + cancelPendingTransitions() + } + + /** + * TalkBack maintains the touch-interaction & touch-explore state by itself. When the system + * detects a valid split-tap, user can keep his finger touched on screen and request the state to + * touch-explore so that the gesture detector can resume again. + * + * @param pending tells the Split-tap detector should wait extra events then back to its detecting + * state. + */ + open fun restart(pending: Boolean) { + state = STATE_CLEAR + cancelPendingTransitions() + } + + open fun debugMotionEvent(tag: String, format: String, vararg args: Any?) { + if (logMotionEvent) { + LogUtils.v(tag, format, *args) + } + } + + /** + * Transitions to a new state and notifies any listeners. Note that any pending transitions are + * canceled. + * + * @param state the new state for the gesture detector. + * @param event the MotionEvent caused the state transition. + * @param notify should notify the upper listener or not about the state change. This can avoid + * the upper listeners receive call back more than once (especially for cancel event). + */ + private fun setState(@State state: Int, event: MotionEvent, notify: Boolean = true) { + if (state != STATE_GESTURE_PROCESSING) { + this.state = state + cancelPendingTransitions() + } + if (notify) { + listener?.onStateChanged(gestureId, state, event) + } + } + + /** Indicates that there is evidence to suggest that this gesture has started. */ + protected fun startGesture(event: MotionEvent) { + setState(STATE_GESTURE_STARTED, event) + } + + /** Indicates this stream of motion events can no longer match this gesture. */ + fun cancelGesture(event: MotionEvent, notify: Boolean) { + setState(STATE_GESTURE_CANCELED, event, notify) + } + + fun cancelGesture(event: MotionEvent) { + setState(STATE_GESTURE_CANCELED, event) + } + + /** Indicates this gesture is completed. */ + protected fun completeGesture(eventId: EventId?, event: MotionEvent) { + Performance.getInstance().onGestureLastMotionEventTime(eventId, event.eventTime) + setState(STATE_GESTURE_COMPLETED, event) + } + + /** Extend the touch explore timer window. */ + protected fun processGesture(eventId: EventId?, event: MotionEvent) { + setState(STATE_GESTURE_PROCESSING, event) + } + + protected fun analyticsEvent(analyticsEvent: GestureAnalyticsEvent) { + logger.logAnalyticsEvent(analyticsEvent) + } + + fun setListener(listener: StateChangeListener) { + this.listener = listener + } + + /** + * Returns true to indicate that the gesture would not be cancelled when the touch-exploring mode + * is still ongoing event gesture completed. For example, split-typing could keep alive when user + * is touch-exploring the screen. + */ + open fun bypassCancelByTapUpToTouchExplore(): Boolean = false + + /** + * Process a motion event and attempt to match it to this gesture. + * + * @param event the event as passed in from the event stream. + * @return the state of this matcher. + */ + @CanIgnoreReturnValue + fun onMotionEvent(eventId: EventId?, event: MotionEvent): Int { + if (state == STATE_GESTURE_CANCELED || state == STATE_GESTURE_COMPLETED) { + return state + } + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> onDown(eventId, event) + MotionEvent.ACTION_POINTER_DOWN -> onPointerDown(eventId, event) + MotionEvent.ACTION_MOVE -> onMove(eventId, event) + MotionEvent.ACTION_POINTER_UP -> onPointerUp(eventId, event) + MotionEvent.ACTION_UP -> onUp(eventId, event) + else -> + // Cancel because of invalid event. + setState(STATE_GESTURE_CANCELED, event) + } + return state + } + + /** + * Matchers override this method to respond to ACTION_DOWN events. ACTION_DOWN events indicate the + * first finger has touched the screen. If not overridden the default response is to do nothing. + */ + protected open fun onDown(eventId: EventId?, event: MotionEvent) {} + + /** + * Matchers override this method to respond to ACTION_POINTER_DOWN events. ACTION_POINTER_DOWN + * indicates that more than one finger has touched the screen. If not overridden the default + * response is to do nothing. + * + * @param event the event as passed in from the event stream. + */ + protected open fun onPointerDown(eventId: EventId?, event: MotionEvent) {} + + /** + * Matchers override this method to respond to ACTION_MOVE events. ACTION_MOVE indicates that one + * or fingers has moved. If not overridden the default response is to do nothing. + * + * @param event the event as passed in from the event stream. + */ + protected open fun onMove(eventId: EventId?, event: MotionEvent) {} + + /** + * Matchers override this method to respond to ACTION_POINTER_UP events. ACTION_POINTER_UP + * indicates that a finger has lifted from the screen but at least one finger continues to touch + * the screen. If not overridden the default response is to do nothing. + * + * @param event the event as passed in from the event stream. + */ + protected open fun onPointerUp(eventId: EventId?, event: MotionEvent) {} + + /** + * Matchers override this method to respond to ACTION_UP events. ACTION_UP indicates that there + * are no more fingers touching the screen. If not overridden the default response is to do + * nothing. + * + * @param event the event as passed in from the event stream. + */ + protected open fun onUp(eventId: EventId?, event: MotionEvent) {} + + /** Cancels this matcher after the tap timeout. Any pending state transitions are removed. */ + protected open fun cancelAfterTapTimeout(eventId: EventId?, event: MotionEvent) { + cancelAfter(ViewConfiguration.getTapTimeout().toLong(), event) + } + + /** Cancels this matcher after the double tap timeout. Any pending cancelations are removed. */ + protected fun cancelAfterDoubleTapTimeout(event: MotionEvent) { + cancelAfter(ViewConfiguration.getDoubleTapTimeout().toLong(), event) + } + + /** + * Cancels this matcher after the specified timeout. Any pending cancelations are removed. Used to + * prevent this matcher from accepting motion events until it is cleared. + */ + protected fun cancelAfter(timeout: Long, event: MotionEvent) { + delayedTransition.cancel() + delayedTransition.post(STATE_GESTURE_CANCELED, timeout, event) + } + + /** Cancels any delayed transitions between states scheduled for this matcher. */ + protected fun cancelPendingTransitions() { + delayedTransition.cancel() + } + + /** + * Signals that this gesture has been completed after the tap timeout has expired. Used to ensure + * that there is no conflict with another gesture or for gestures that explicitly require a hold. + */ + protected fun completeAfterLongPressTimeout(eventId: EventId?, event: MotionEvent) { + completeAfter(ViewConfiguration.getLongPressTimeout().toLong(), eventId, event) + } + + /** + * Signals that this gesture has been completed after the tap timeout has expired. Used to ensure + * that there is no conflict with another gesture or for gestures that explicitly require a hold. + */ + protected fun completeAfterTapTimeout(eventId: EventId?, event: MotionEvent) { + completeAfter(ViewConfiguration.getTapTimeout().toLong(), eventId, event) + } + + /** + * Signals that this gesture has been completed after the specified timeout has expired. Used to + * ensure that there is no conflict with another gesture or for gestures that explicitly require a + * hold. + */ + protected fun completeAfter(timeout: Long, eventId: EventId?, event: MotionEvent) { + delayedTransition.cancel() + Performance.getInstance().onGestureLastMotionEventTime(eventId, event.eventTime) + delayedTransition.post(STATE_GESTURE_COMPLETED, timeout, event) + } + + /** + * Signals that this gesture has been completed after the double-tap timeout has expired. Used to + * ensure that there is no conflict with another gesture or for gestures that explicitly require a + * hold. + */ + protected fun completeAfterDoubleTapTimeout(eventId: EventId?, event: MotionEvent) { + completeAfter(ViewConfiguration.getDoubleTapTimeout().toLong(), eventId, event) + } + + internal fun gestureMotionEventLog(logLevel: Int, format: String, vararg args: Any?) { + if (logMotionEvent) { + when (logLevel) { + ERROR -> LogUtils.e(getGestureName(), format, *args) + VERBOSE -> LogUtils.v(getGestureName(), format, *args) + else -> LogUtils.v(getGestureName(), format, *args) + } + } + } + + /** + * Returns a readable name for this matcher that can be displayed to the user and in system logs. + */ + protected abstract fun getGestureName(): String + + /** + * Returns a String representation of this matcher. Each matcher can override this method to add + * extra state information to the string representation. + */ + override fun toString(): String = getGestureName() + ":" + getStateSymbolicName(state) + + /** This class allows matchers to transition between states on a delay. */ + protected inner class DelayedTransition : Runnable { + private var targetState = 0 + private var event: MotionEvent? = null + + fun cancel() { + // Avoid meaningless debug messages. + synchronized(this@GestureMatcher) { + if (isPending) { + LogUtils.v( + DELAYED_TRANSITION_LOG_TAG, + "%s: canceling delayed transition to %s", + getGestureName(), + getStateSymbolicName(targetState), + ) + } + handler.removeCallbacks(this) + recycleEvent() + } + } + + fun post(state: Int, delay: Long, event: MotionEvent) { + synchronized(this@GestureMatcher) { + this.targetState = state + // Just in case the cancel is not performed immediately before post. + recycleEvent() + this.event = MotionEvent.obtain(event) + handler.postDelayed(this, delay) + LogUtils.v( + DELAYED_TRANSITION_LOG_TAG, + "%s: posting delayed transition to %s", + getGestureName(), + getStateSymbolicName(targetState), + ) + } + } + + val isPending: Boolean + get() = handler.hasCallbacks(this) + + fun forceSendAndRemove() { + if (isPending) { + run() + cancel() + } + } + + override fun run() { + synchronized(this@GestureMatcher) { + val event = this.event ?: return + LogUtils.v( + DELAYED_TRANSITION_LOG_TAG, + "%s: executing delayed transition to %s", + getGestureName(), + getStateSymbolicName(targetState), + ) + setState(targetState, event) + recycleEvent() + } + } + + private fun recycleEvent() { + val event = this.event ?: return + event.recycle() + this.event = null + } + } + + /** Interface to allow a class to listen for state changes in a specific gesture matcher */ + fun interface StateChangeListener { + fun onStateChanged(gestureId: Int, state: Int, event: MotionEvent) + } + + /** Interface to handle the analytics event for a gesture. */ + fun interface AnalyticsEventLogger { + fun logAnalyticsEvent(analyticsEvent: GestureAnalyticsEvent) + } + + companion object { + // Potential states for this individual gesture matcher. + /** + * In STATE_CLEAR, this matcher is accepting new motion events but has not formally signaled + * that there is enough data to judge that a gesture has started. + */ + const val STATE_CLEAR = 0 + + /** + * In STATE_GESTURE_STARTED, this matcher continues to accept motion events and it has signaled + * to the listener that what looks like the specified gesture has started. + */ + const val STATE_GESTURE_STARTED = 1 + + /** + * In STATE_GESTURE_COMPLETED, this matcher has successfully matched the specified gesture. and + * will not accept motion events until it is cleared. + */ + const val STATE_GESTURE_COMPLETED = 2 + + /** + * In STATE_GESTURE_CANCELED, this matcher will not accept new motion events because it is + * impossible that this set of motion events will match the specified gesture. + */ + const val STATE_GESTURE_CANCELED = 3 + + /** + * In STATE_GESTURE_PROCESSING, this matcher does nothing but informing the listener which can + * handle trivial thing such as extend the touch explore timer. + */ + const val STATE_GESTURE_PROCESSING = 4 + + private const val DELAYED_TRANSITION_LOG_TAG = "GestureMatcher.DelayedTransition" + + @JvmStatic + internal fun getStateSymbolicName(@State state: Int): String = + when (state) { + STATE_CLEAR -> "STATE_CLEAR" + STATE_GESTURE_STARTED -> "STATE_GESTURE_STARTED" + STATE_GESTURE_COMPLETED -> "STATE_GESTURE_COMPLETED" + STATE_GESTURE_CANCELED -> "STATE_GESTURE_CANCELED" + STATE_GESTURE_PROCESSING -> "STATE_GESTURE_PROCESSING" + else -> "Unknown state: $state" + } + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureMatcherFactory.java b/utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureMatcherFactory.java deleted file mode 100644 index 0778dd21b..000000000 --- a/utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureMatcherFactory.java +++ /dev/null @@ -1,345 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.android.accessibility.utils.gestures; - -import static android.accessibilityservice.AccessibilityService.GESTURE_2_FINGER_DOUBLE_TAP; -import static android.accessibilityservice.AccessibilityService.GESTURE_2_FINGER_DOUBLE_TAP_AND_HOLD; -import static android.accessibilityservice.AccessibilityService.GESTURE_2_FINGER_SINGLE_TAP; -import static android.accessibilityservice.AccessibilityService.GESTURE_2_FINGER_SWIPE_DOWN; -import static android.accessibilityservice.AccessibilityService.GESTURE_2_FINGER_SWIPE_LEFT; -import static android.accessibilityservice.AccessibilityService.GESTURE_2_FINGER_SWIPE_RIGHT; -import static android.accessibilityservice.AccessibilityService.GESTURE_2_FINGER_SWIPE_UP; -import static android.accessibilityservice.AccessibilityService.GESTURE_2_FINGER_TRIPLE_TAP; -import static android.accessibilityservice.AccessibilityService.GESTURE_2_FINGER_TRIPLE_TAP_AND_HOLD; -import static android.accessibilityservice.AccessibilityService.GESTURE_3_FINGER_DOUBLE_TAP; -import static android.accessibilityservice.AccessibilityService.GESTURE_3_FINGER_DOUBLE_TAP_AND_HOLD; -import static android.accessibilityservice.AccessibilityService.GESTURE_3_FINGER_SINGLE_TAP; -import static android.accessibilityservice.AccessibilityService.GESTURE_3_FINGER_SINGLE_TAP_AND_HOLD; -import static android.accessibilityservice.AccessibilityService.GESTURE_3_FINGER_SWIPE_DOWN; -import static android.accessibilityservice.AccessibilityService.GESTURE_3_FINGER_SWIPE_LEFT; -import static android.accessibilityservice.AccessibilityService.GESTURE_3_FINGER_SWIPE_RIGHT; -import static android.accessibilityservice.AccessibilityService.GESTURE_3_FINGER_SWIPE_UP; -import static android.accessibilityservice.AccessibilityService.GESTURE_3_FINGER_TRIPLE_TAP; -import static android.accessibilityservice.AccessibilityService.GESTURE_3_FINGER_TRIPLE_TAP_AND_HOLD; -import static android.accessibilityservice.AccessibilityService.GESTURE_4_FINGER_DOUBLE_TAP; -import static android.accessibilityservice.AccessibilityService.GESTURE_4_FINGER_DOUBLE_TAP_AND_HOLD; -import static android.accessibilityservice.AccessibilityService.GESTURE_4_FINGER_SINGLE_TAP; -import static android.accessibilityservice.AccessibilityService.GESTURE_4_FINGER_SWIPE_DOWN; -import static android.accessibilityservice.AccessibilityService.GESTURE_4_FINGER_SWIPE_LEFT; -import static android.accessibilityservice.AccessibilityService.GESTURE_4_FINGER_SWIPE_RIGHT; -import static android.accessibilityservice.AccessibilityService.GESTURE_4_FINGER_SWIPE_UP; -import static android.accessibilityservice.AccessibilityService.GESTURE_4_FINGER_TRIPLE_TAP; -import static android.accessibilityservice.AccessibilityService.GESTURE_DOUBLE_TAP; -import static android.accessibilityservice.AccessibilityService.GESTURE_DOUBLE_TAP_AND_HOLD; -import static android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_DOWN; -import static android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_DOWN_AND_LEFT; -import static android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_DOWN_AND_RIGHT; -import static android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_DOWN_AND_UP; -import static android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_LEFT; -import static android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_LEFT_AND_DOWN; -import static android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_LEFT_AND_RIGHT; -import static android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_LEFT_AND_UP; -import static android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_RIGHT; -import static android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_RIGHT_AND_DOWN; -import static android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_RIGHT_AND_LEFT; -import static android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_RIGHT_AND_UP; -import static android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_UP; -import static android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_UP_AND_DOWN; -import static android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_UP_AND_LEFT; -import static android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_UP_AND_RIGHT; -import static com.google.android.accessibility.utils.gestures.GestureManifold.GESTURE_2_FINGER_SINGLE_TAP_AND_HOLD; -import static com.google.android.accessibility.utils.gestures.GestureManifold.GESTURE_TAP_UP_TOUCH_EXPLORE; -import static com.google.android.accessibility.utils.gestures.GestureManifold.GESTURE_TOUCH_EXPLORE; -import static com.google.android.accessibility.utils.gestures.Swipe.DOWN; -import static com.google.android.accessibility.utils.gestures.Swipe.LEFT; -import static com.google.android.accessibility.utils.gestures.Swipe.NONE; -import static com.google.android.accessibility.utils.gestures.Swipe.RIGHT; -import static com.google.android.accessibility.utils.gestures.Swipe.UP; - -import android.content.Context; -import android.os.Build; -import androidx.annotation.RequiresApi; -import com.google.android.accessibility.utils.gestures.GestureManifold.GestureConfigProvider; -import com.google.android.libraries.accessibility.utils.log.LogUtils; -import com.google.common.collect.ImmutableList; -import java.util.ArrayList; -import java.util.List; -import org.checkerframework.checker.nullness.qual.Nullable; - -/** - * This class generates the list of the {@link GestureMatcher} with the given support gesture list. - */ -@RequiresApi(Build.VERSION_CODES.TIRAMISU) -class GestureMatcherFactory { - - /** List of the gestures. */ - private enum GestureMatchConfig { - // Start with double tap. - MAPPER_GESTURE_DOUBLE_TAP(GESTURE_DOUBLE_TAP, 1, 2, NONE, NONE), - MAPPER_GESTURE_DOUBLE_TAP_AND_HOLD(GESTURE_DOUBLE_TAP_AND_HOLD, 1, 2, true, NONE, NONE), - // Second-finger tap. - MAPPER_GESTURE_FAKED_SPLIT_TYPING( - GestureManifold.GESTURE_FAKED_SPLIT_TYPING, 1, 1, true, NONE, NONE), - MAPPER_GESTURE_FAKED_SPLIT_TYPING_AND_HOLD( - GestureManifold.GESTURE_FAKED_SPLIT_TYPING_AND_HOLD, 1, 1, true, NONE, NONE), - // One-direction swipes. - MAPPER_GESTURE_SWIPE_RIGHT(GESTURE_SWIPE_RIGHT, 1, 0, RIGHT, NONE), - MAPPER_GESTURE_SWIPE_LEFT(GESTURE_SWIPE_LEFT, 1, 0, LEFT, NONE), - MAPPER_GESTURE_SWIPE_UP(GESTURE_SWIPE_UP, 1, 0, UP, NONE), - MAPPER_GESTURE_SWIPE_DOWN(GESTURE_SWIPE_DOWN, 1, 0, DOWN, NONE), - // Two-direction swipes. - MAPPER_GESTURE_SWIPE_LEFT_AND_RIGHT(GESTURE_SWIPE_LEFT_AND_RIGHT, 1, 0, LEFT, RIGHT), - MAPPER_GESTURE_SWIPE_LEFT_AND_UP(GESTURE_SWIPE_LEFT_AND_UP, 1, 0, LEFT, UP), - MAPPER_GESTURE_SWIPE_LEFT_AND_DOWN(GESTURE_SWIPE_LEFT_AND_DOWN, 1, 0, LEFT, DOWN), - MAPPER_GESTURE_SWIPE_RIGHT_AND_UP(GESTURE_SWIPE_RIGHT_AND_UP, 1, 0, RIGHT, UP), - MAPPER_GESTURE_SWIPE_RIGHT_AND_DOWN(GESTURE_SWIPE_RIGHT_AND_DOWN, 1, 0, RIGHT, DOWN), - MAPPER_GESTURE_SWIPE_RIGHT_AND_LEFT(GESTURE_SWIPE_RIGHT_AND_LEFT, 1, 0, RIGHT, LEFT), - MAPPER_GESTURE_SWIPE_DOWN_AND_UP(GESTURE_SWIPE_DOWN_AND_UP, 1, 0, DOWN, UP), - MAPPER_GESTURE_SWIPE_DOWN_AND_LEFT(GESTURE_SWIPE_DOWN_AND_LEFT, 1, 0, DOWN, LEFT), - MAPPER_GESTURE_SWIPE_DOWN_AND_RIGHT(GESTURE_SWIPE_DOWN_AND_RIGHT, 1, 0, DOWN, RIGHT), - MAPPER_GESTURE_SWIPE_UP_AND_DOWN(GESTURE_SWIPE_UP_AND_DOWN, 1, 0, UP, DOWN), - MAPPER_GESTURE_SWIPE_UP_AND_LEFT(GESTURE_SWIPE_UP_AND_LEFT, 1, 0, UP, LEFT), - MAPPER_GESTURE_SWIPE_UP_AND_RIGHT(GESTURE_SWIPE_UP_AND_RIGHT, 1, 0, UP, RIGHT), - // Set up multi-finger gestures to be enabled later. - // Two-finger taps. - MAPPER_GESTURE_2_FINGER_SINGLE_TAP(GESTURE_2_FINGER_SINGLE_TAP, 2, 1, NONE, NONE), - MAPPER_GESTURE_2_FINGER_SINGLE_TAP_AND_HOLD( - GESTURE_2_FINGER_SINGLE_TAP_AND_HOLD, 2, 1, true, NONE, NONE), - MAPPER_GESTURE_2_FINGER_DOUBLE_TAP(GESTURE_2_FINGER_DOUBLE_TAP, 2, 2, NONE, NONE), - MAPPER_GESTURE_2_FINGER_DOUBLE_TAP_AND_HOLD( - GESTURE_2_FINGER_DOUBLE_TAP_AND_HOLD, 2, 2, true, NONE, NONE), - MAPPER_GESTURE_2_FINGER_TRIPLE_TAP(GESTURE_2_FINGER_TRIPLE_TAP, 2, 3, NONE, NONE), - MAPPER_GESTURE_2_FINGER_TRIPLE_TAP_AND_HOLD( - GESTURE_2_FINGER_TRIPLE_TAP_AND_HOLD, 2, 3, true, NONE, NONE), - // Three-finger taps. - MAPPER_GESTURE_3_FINGER_SINGLE_TAP(GESTURE_3_FINGER_SINGLE_TAP, 3, 1, NONE, NONE), - MAPPER_GESTURE_3_FINGER_DOUBLE_TAP(GESTURE_3_FINGER_DOUBLE_TAP, 3, 2, NONE, NONE), - MAPPER_GESTURE_3_FINGER_SINGLE_TAP_AND_HOLD( - GESTURE_3_FINGER_SINGLE_TAP_AND_HOLD, 3, 1, true, NONE, NONE), - MAPPER_GESTURE_3_FINGER_DOUBLE_TAP_AND_HOLD( - GESTURE_3_FINGER_DOUBLE_TAP_AND_HOLD, 3, 2, true, NONE, NONE), - MAPPER_GESTURE_3_FINGER_TRIPLE_TAP(GESTURE_3_FINGER_TRIPLE_TAP, 3, 3, NONE, NONE), - MAPPER_GESTURE_3_FINGER_TRIPLE_TAP_AND_HOLD( - GESTURE_3_FINGER_TRIPLE_TAP_AND_HOLD, 3, 3, true, NONE, NONE), - // Four-finger taps. - MAPPER_GESTURE_4_FINGER_SINGLE_TAP(GESTURE_4_FINGER_SINGLE_TAP, 4, 1, NONE, NONE), - MAPPER_GESTURE_4_FINGER_DOUBLE_TAP(GESTURE_4_FINGER_DOUBLE_TAP, 4, 2, NONE, NONE), - MAPPER_GESTURE_4_FINGER_DOUBLE_TAP_AND_HOLD( - GESTURE_4_FINGER_DOUBLE_TAP_AND_HOLD, 4, 2, true, NONE, NONE), - MAPPER_GESTURE_4_FINGER_TRIPLE_TAP(GESTURE_4_FINGER_TRIPLE_TAP, 4, 3, NONE, NONE), - // Two-finger swipes. - MAPPER_GESTURE_2_FINGER_SWIPE_DOWN(GESTURE_2_FINGER_SWIPE_DOWN, 2, 0, DOWN, NONE), - MAPPER_GESTURE_2_FINGER_SWIPE_LEFT(GESTURE_2_FINGER_SWIPE_LEFT, 2, 0, LEFT, NONE), - MAPPER_GESTURE_2_FINGER_SWIPE_RIGHT(GESTURE_2_FINGER_SWIPE_RIGHT, 2, 0, RIGHT, NONE), - MAPPER_GESTURE_2_FINGER_SWIPE_UP(GESTURE_2_FINGER_SWIPE_UP, 2, 0, UP, NONE), - // Three-finger swipes. - MAPPER_GESTURE_3_FINGER_SWIPE_DOWN(GESTURE_3_FINGER_SWIPE_DOWN, 3, 0, DOWN, NONE), - MAPPER_GESTURE_3_FINGER_SWIPE_LEFT(GESTURE_3_FINGER_SWIPE_LEFT, 3, 0, LEFT, NONE), - MAPPER_GESTURE_3_FINGER_SWIPE_RIGHT(GESTURE_3_FINGER_SWIPE_RIGHT, 3, 0, RIGHT, NONE), - MAPPER_GESTURE_3_FINGER_SWIPE_UP(GESTURE_3_FINGER_SWIPE_UP, 3, 0, UP, NONE), - // Four-finger swipes. - MAPPER_GESTURE_4_FINGER_SWIPE_DOWN(GESTURE_4_FINGER_SWIPE_DOWN, 4, 0, DOWN, NONE), - MAPPER_GESTURE_4_FINGER_SWIPE_LEFT(GESTURE_4_FINGER_SWIPE_LEFT, 4, 0, LEFT, NONE), - MAPPER_GESTURE_4_FINGER_SWIPE_RIGHT(GESTURE_4_FINGER_SWIPE_RIGHT, 4, 0, RIGHT, NONE), - MAPPER_GESTURE_4_FINGER_SWIPE_UP(GESTURE_4_FINGER_SWIPE_UP, 4, 0, UP, NONE); - - GestureMatchConfig(int gestureId, int finger, int tap, int direction1, int direction2) { - this(gestureId, finger, tap, /* isHold= */ false, direction1, direction2); - } - - GestureMatchConfig( - int gestureId, int finger, int tap, boolean isHold, int direction1, int direction2) { - this.gestureId = gestureId; - this.finger = finger; - this.tap = tap; - this.isHold = isHold; - this.direction1 = direction1; - this.direction2 = direction2; - } - - final int gestureId; - final int finger; - final int tap; - final boolean isHold; - final int direction1; - final int direction2; - - @Override - public String toString() { - return "GestureMatchConfig{" - + "gestureId=" - + gestureId - + ", finger=" - + finger - + ", tap=" - + tap - + ", isHold=" - + isHold - + ", direction1=" - + direction1 - + ", direction2=" - + direction2 - + '}'; - } - } - - private GestureMatcherFactory() {} - - /** - * Gets the list of {@link GestureMatcher} by the support gesture list. - * - * @param supportGestureList the support gesture list - * @param listener the listener to set to the GestureMatcher - * @param logger the event logger to report the specific events - * @return the list of GestureMatcher - */ - static List getGestureMatcherList( - Context context, - ImmutableList supportGestureList, - GestureMatcher.StateChangeListener listener, - GestureConfigProvider configResolver, - GestureMatcher.AnalyticsEventLogger logger) { - List gestureMatchers = new ArrayList<>(); - LogUtils.v( - "GestureMatcherFactory", - "Speed up TouchExplore state: %b", - configResolver.getSpeedUpTouchExploreState()); - if (configResolver.getSpeedUpTouchExploreState()) { - gestureMatchers.add( - new TapToTouchExplore(context, GESTURE_TOUCH_EXPLORE, listener, configResolver, logger)); - gestureMatchers.add( - new TapUpToTouchExplore(context, GESTURE_TAP_UP_TOUCH_EXPLORE, listener, logger)); - } - for (GestureMatchConfig iterator : GestureMatchConfig.values()) { - if (supportGestureList.contains(iterator.name())) { - @Nullable GestureMatcher gestureMatcher = - createGestureMatcher(context, iterator, listener, configResolver, logger); - if (gestureMatcher != null) { - gestureMatchers.add(gestureMatcher); - } - } - } - return gestureMatchers; - } - - @Nullable - private static GestureMatcher createGestureMatcher( - Context context, - GestureMatchConfig gestureMatchConfig, - GestureMatcher.StateChangeListener listener, - GestureConfigProvider configResolver, - GestureMatcher.AnalyticsEventLogger logger) { - - // 1-finger - if (gestureMatchConfig.finger == 1) { - // swipe - if (gestureMatchConfig.tap == 0) { - if (gestureMatchConfig.direction2 == NONE) { - return new Swipe( - context, - gestureMatchConfig.direction1, - gestureMatchConfig.gestureId, - listener, - configResolver, - logger); - } else { - return new Swipe( - context, - gestureMatchConfig.direction1, - gestureMatchConfig.direction2, - gestureMatchConfig.gestureId, - listener, - configResolver, - logger); - } - } - // single-tap and hold - if ((gestureMatchConfig.tap == 1) && gestureMatchConfig.isHold) { - if (gestureMatchConfig.gestureId == GestureManifold.GESTURE_FAKED_SPLIT_TYPING) { - return new SecondFingerTap( - context, gestureMatchConfig.tap, gestureMatchConfig.gestureId, listener, logger); - } else if (configResolver.enableSplitTapAndHold() - && gestureMatchConfig.gestureId - == GestureManifold.GESTURE_FAKED_SPLIT_TYPING_AND_HOLD) { - return new SecondFingerTapAndHold( - context, gestureMatchConfig.tap, gestureMatchConfig.gestureId, listener, logger); - } else { - return null; - } - } - // double-taps - if (gestureMatchConfig.tap == 2) { - if (gestureMatchConfig.isHold) { - return new MultiTapAndHold( - context, - gestureMatchConfig.tap, - gestureMatchConfig.gestureId, - listener, - configResolver, - logger); - } else { - return new MultiTap( - context, - gestureMatchConfig.tap, - gestureMatchConfig.gestureId, - listener, - configResolver, - logger); - } - } - } else { - // multi-finger - // swipe - if (gestureMatchConfig.tap == 0) { - return new MultiFingerSwipe( - context, - gestureMatchConfig.finger, - gestureMatchConfig.direction1, - gestureMatchConfig.gestureId, - listener, - logger); - } - // multi-taps and hold - if (gestureMatchConfig.isHold) { - // Two-finger single tap and hold is special gesture for TalkBack mis-triggering recovery - // feature. - return gestureMatchConfig.finger == 2 && gestureMatchConfig.tap == 1 - ? new TwoFingerSingleTapAndLongHold( - context, gestureMatchConfig.gestureId, listener, logger) - : new MultiFingerMultiTapAndHold( - context, - gestureMatchConfig.finger, - gestureMatchConfig.tap, - gestureMatchConfig.gestureId, - listener, - logger); - } else { - // multi-taps without hold - return new MultiFingerMultiTap( - context, - gestureMatchConfig.finger, - gestureMatchConfig.tap, - gestureMatchConfig.gestureId, - listener, - logger); - } - } - throw new IllegalArgumentException( - String.format( - "IllegalArgumentException: GestureMatchConfig %s defines the wrong argument. %s", - gestureMatchConfig.name(), gestureMatchConfig)); - } -} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureMatcherFactory.kt b/utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureMatcherFactory.kt new file mode 100644 index 000000000..6b5f6e390 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureMatcherFactory.kt @@ -0,0 +1,345 @@ +/* + * Copyright (C) The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Ported from Java to Kotlin. + */ + +package com.google.android.accessibility.utils.gestures + +import android.accessibilityservice.AccessibilityService.GESTURE_2_FINGER_DOUBLE_TAP +import android.accessibilityservice.AccessibilityService.GESTURE_2_FINGER_DOUBLE_TAP_AND_HOLD +import android.accessibilityservice.AccessibilityService.GESTURE_2_FINGER_SINGLE_TAP +import android.accessibilityservice.AccessibilityService.GESTURE_2_FINGER_SWIPE_DOWN +import android.accessibilityservice.AccessibilityService.GESTURE_2_FINGER_SWIPE_LEFT +import android.accessibilityservice.AccessibilityService.GESTURE_2_FINGER_SWIPE_RIGHT +import android.accessibilityservice.AccessibilityService.GESTURE_2_FINGER_SWIPE_UP +import android.accessibilityservice.AccessibilityService.GESTURE_2_FINGER_TRIPLE_TAP +import android.accessibilityservice.AccessibilityService.GESTURE_2_FINGER_TRIPLE_TAP_AND_HOLD +import android.accessibilityservice.AccessibilityService.GESTURE_3_FINGER_DOUBLE_TAP +import android.accessibilityservice.AccessibilityService.GESTURE_3_FINGER_DOUBLE_TAP_AND_HOLD +import android.accessibilityservice.AccessibilityService.GESTURE_3_FINGER_SINGLE_TAP +import android.accessibilityservice.AccessibilityService.GESTURE_3_FINGER_SINGLE_TAP_AND_HOLD +import android.accessibilityservice.AccessibilityService.GESTURE_3_FINGER_SWIPE_DOWN +import android.accessibilityservice.AccessibilityService.GESTURE_3_FINGER_SWIPE_LEFT +import android.accessibilityservice.AccessibilityService.GESTURE_3_FINGER_SWIPE_RIGHT +import android.accessibilityservice.AccessibilityService.GESTURE_3_FINGER_SWIPE_UP +import android.accessibilityservice.AccessibilityService.GESTURE_3_FINGER_TRIPLE_TAP +import android.accessibilityservice.AccessibilityService.GESTURE_3_FINGER_TRIPLE_TAP_AND_HOLD +import android.accessibilityservice.AccessibilityService.GESTURE_4_FINGER_DOUBLE_TAP +import android.accessibilityservice.AccessibilityService.GESTURE_4_FINGER_DOUBLE_TAP_AND_HOLD +import android.accessibilityservice.AccessibilityService.GESTURE_4_FINGER_SINGLE_TAP +import android.accessibilityservice.AccessibilityService.GESTURE_4_FINGER_SWIPE_DOWN +import android.accessibilityservice.AccessibilityService.GESTURE_4_FINGER_SWIPE_LEFT +import android.accessibilityservice.AccessibilityService.GESTURE_4_FINGER_SWIPE_RIGHT +import android.accessibilityservice.AccessibilityService.GESTURE_4_FINGER_SWIPE_UP +import android.accessibilityservice.AccessibilityService.GESTURE_4_FINGER_TRIPLE_TAP +import android.accessibilityservice.AccessibilityService.GESTURE_DOUBLE_TAP +import android.accessibilityservice.AccessibilityService.GESTURE_DOUBLE_TAP_AND_HOLD +import android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_DOWN +import android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_DOWN_AND_LEFT +import android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_DOWN_AND_RIGHT +import android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_DOWN_AND_UP +import android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_LEFT +import android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_LEFT_AND_DOWN +import android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_LEFT_AND_RIGHT +import android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_LEFT_AND_UP +import android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_RIGHT +import android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_RIGHT_AND_DOWN +import android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_RIGHT_AND_LEFT +import android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_RIGHT_AND_UP +import android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_UP +import android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_UP_AND_DOWN +import android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_UP_AND_LEFT +import android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_UP_AND_RIGHT +import android.content.Context +import android.os.Build +import androidx.annotation.RequiresApi +import com.google.android.accessibility.utils.gestures.GestureManifold.GestureConfigProvider +import com.google.android.accessibility.utils.gestures.Swipe.Companion.DOWN +import com.google.android.accessibility.utils.gestures.Swipe.Companion.LEFT +import com.google.android.accessibility.utils.gestures.Swipe.Companion.NONE +import com.google.android.accessibility.utils.gestures.Swipe.Companion.RIGHT +import com.google.android.accessibility.utils.gestures.Swipe.Companion.UP +import com.google.android.libraries.accessibility.utils.log.LogUtils + +/** + * This class generates the list of the {@link GestureMatcher} with the given support gesture list. + */ +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +internal object GestureMatcherFactory { + + /** List of the gestures. */ + private enum class GestureMatchConfig( + @JvmField val gestureId: Int, + @JvmField val finger: Int, + @JvmField val tap: Int, + @JvmField val isHold: Boolean, + @JvmField val direction1: Int, + @JvmField val direction2: Int, + ) { + // Start with double tap. + MAPPER_GESTURE_DOUBLE_TAP(GESTURE_DOUBLE_TAP, 1, 2, NONE, NONE), + MAPPER_GESTURE_DOUBLE_TAP_AND_HOLD(GESTURE_DOUBLE_TAP_AND_HOLD, 1, 2, true, NONE, NONE), + // Second-finger tap. + MAPPER_GESTURE_FAKED_SPLIT_TYPING( + GestureManifold.GESTURE_FAKED_SPLIT_TYPING, 1, 1, true, NONE, NONE), + MAPPER_GESTURE_FAKED_SPLIT_TYPING_AND_HOLD( + GestureManifold.GESTURE_FAKED_SPLIT_TYPING_AND_HOLD, 1, 1, true, NONE, NONE), + // One-direction swipes. + MAPPER_GESTURE_SWIPE_RIGHT(GESTURE_SWIPE_RIGHT, 1, 0, RIGHT, NONE), + MAPPER_GESTURE_SWIPE_LEFT(GESTURE_SWIPE_LEFT, 1, 0, LEFT, NONE), + MAPPER_GESTURE_SWIPE_UP(GESTURE_SWIPE_UP, 1, 0, UP, NONE), + MAPPER_GESTURE_SWIPE_DOWN(GESTURE_SWIPE_DOWN, 1, 0, DOWN, NONE), + // Two-direction swipes. + MAPPER_GESTURE_SWIPE_LEFT_AND_RIGHT(GESTURE_SWIPE_LEFT_AND_RIGHT, 1, 0, LEFT, RIGHT), + MAPPER_GESTURE_SWIPE_LEFT_AND_UP(GESTURE_SWIPE_LEFT_AND_UP, 1, 0, LEFT, UP), + MAPPER_GESTURE_SWIPE_LEFT_AND_DOWN(GESTURE_SWIPE_LEFT_AND_DOWN, 1, 0, LEFT, DOWN), + MAPPER_GESTURE_SWIPE_RIGHT_AND_UP(GESTURE_SWIPE_RIGHT_AND_UP, 1, 0, RIGHT, UP), + MAPPER_GESTURE_SWIPE_RIGHT_AND_DOWN(GESTURE_SWIPE_RIGHT_AND_DOWN, 1, 0, RIGHT, DOWN), + MAPPER_GESTURE_SWIPE_RIGHT_AND_LEFT(GESTURE_SWIPE_RIGHT_AND_LEFT, 1, 0, RIGHT, LEFT), + MAPPER_GESTURE_SWIPE_DOWN_AND_UP(GESTURE_SWIPE_DOWN_AND_UP, 1, 0, DOWN, UP), + MAPPER_GESTURE_SWIPE_DOWN_AND_LEFT(GESTURE_SWIPE_DOWN_AND_LEFT, 1, 0, DOWN, LEFT), + MAPPER_GESTURE_SWIPE_DOWN_AND_RIGHT(GESTURE_SWIPE_DOWN_AND_RIGHT, 1, 0, DOWN, RIGHT), + MAPPER_GESTURE_SWIPE_UP_AND_DOWN(GESTURE_SWIPE_UP_AND_DOWN, 1, 0, UP, DOWN), + MAPPER_GESTURE_SWIPE_UP_AND_LEFT(GESTURE_SWIPE_UP_AND_LEFT, 1, 0, UP, LEFT), + MAPPER_GESTURE_SWIPE_UP_AND_RIGHT(GESTURE_SWIPE_UP_AND_RIGHT, 1, 0, UP, RIGHT), + // Set up multi-finger gestures to be enabled later. + // Two-finger taps. + MAPPER_GESTURE_2_FINGER_SINGLE_TAP(GESTURE_2_FINGER_SINGLE_TAP, 2, 1, NONE, NONE), + MAPPER_GESTURE_2_FINGER_SINGLE_TAP_AND_HOLD( + GestureManifold.GESTURE_2_FINGER_SINGLE_TAP_AND_HOLD, 2, 1, true, NONE, NONE), + MAPPER_GESTURE_2_FINGER_DOUBLE_TAP(GESTURE_2_FINGER_DOUBLE_TAP, 2, 2, NONE, NONE), + MAPPER_GESTURE_2_FINGER_DOUBLE_TAP_AND_HOLD( + GESTURE_2_FINGER_DOUBLE_TAP_AND_HOLD, 2, 2, true, NONE, NONE), + MAPPER_GESTURE_2_FINGER_TRIPLE_TAP(GESTURE_2_FINGER_TRIPLE_TAP, 2, 3, NONE, NONE), + MAPPER_GESTURE_2_FINGER_TRIPLE_TAP_AND_HOLD( + GESTURE_2_FINGER_TRIPLE_TAP_AND_HOLD, 2, 3, true, NONE, NONE), + // Three-finger taps. + MAPPER_GESTURE_3_FINGER_SINGLE_TAP(GESTURE_3_FINGER_SINGLE_TAP, 3, 1, NONE, NONE), + MAPPER_GESTURE_3_FINGER_DOUBLE_TAP(GESTURE_3_FINGER_DOUBLE_TAP, 3, 2, NONE, NONE), + MAPPER_GESTURE_3_FINGER_SINGLE_TAP_AND_HOLD( + GESTURE_3_FINGER_SINGLE_TAP_AND_HOLD, 3, 1, true, NONE, NONE), + MAPPER_GESTURE_3_FINGER_DOUBLE_TAP_AND_HOLD( + GESTURE_3_FINGER_DOUBLE_TAP_AND_HOLD, 3, 2, true, NONE, NONE), + MAPPER_GESTURE_3_FINGER_TRIPLE_TAP(GESTURE_3_FINGER_TRIPLE_TAP, 3, 3, NONE, NONE), + MAPPER_GESTURE_3_FINGER_TRIPLE_TAP_AND_HOLD( + GESTURE_3_FINGER_TRIPLE_TAP_AND_HOLD, 3, 3, true, NONE, NONE), + // Four-finger taps. + MAPPER_GESTURE_4_FINGER_SINGLE_TAP(GESTURE_4_FINGER_SINGLE_TAP, 4, 1, NONE, NONE), + MAPPER_GESTURE_4_FINGER_DOUBLE_TAP(GESTURE_4_FINGER_DOUBLE_TAP, 4, 2, NONE, NONE), + MAPPER_GESTURE_4_FINGER_DOUBLE_TAP_AND_HOLD( + GESTURE_4_FINGER_DOUBLE_TAP_AND_HOLD, 4, 2, true, NONE, NONE), + MAPPER_GESTURE_4_FINGER_TRIPLE_TAP(GESTURE_4_FINGER_TRIPLE_TAP, 4, 3, NONE, NONE), + // Two-finger swipes. + MAPPER_GESTURE_2_FINGER_SWIPE_DOWN(GESTURE_2_FINGER_SWIPE_DOWN, 2, 0, DOWN, NONE), + MAPPER_GESTURE_2_FINGER_SWIPE_LEFT(GESTURE_2_FINGER_SWIPE_LEFT, 2, 0, LEFT, NONE), + MAPPER_GESTURE_2_FINGER_SWIPE_RIGHT(GESTURE_2_FINGER_SWIPE_RIGHT, 2, 0, RIGHT, NONE), + MAPPER_GESTURE_2_FINGER_SWIPE_UP(GESTURE_2_FINGER_SWIPE_UP, 2, 0, UP, NONE), + // Three-finger swipes. + MAPPER_GESTURE_3_FINGER_SWIPE_DOWN(GESTURE_3_FINGER_SWIPE_DOWN, 3, 0, DOWN, NONE), + MAPPER_GESTURE_3_FINGER_SWIPE_LEFT(GESTURE_3_FINGER_SWIPE_LEFT, 3, 0, LEFT, NONE), + MAPPER_GESTURE_3_FINGER_SWIPE_RIGHT(GESTURE_3_FINGER_SWIPE_RIGHT, 3, 0, RIGHT, NONE), + MAPPER_GESTURE_3_FINGER_SWIPE_UP(GESTURE_3_FINGER_SWIPE_UP, 3, 0, UP, NONE), + // Four-finger swipes. + MAPPER_GESTURE_4_FINGER_SWIPE_DOWN(GESTURE_4_FINGER_SWIPE_DOWN, 4, 0, DOWN, NONE), + MAPPER_GESTURE_4_FINGER_SWIPE_LEFT(GESTURE_4_FINGER_SWIPE_LEFT, 4, 0, LEFT, NONE), + MAPPER_GESTURE_4_FINGER_SWIPE_RIGHT(GESTURE_4_FINGER_SWIPE_RIGHT, 4, 0, RIGHT, NONE), + MAPPER_GESTURE_4_FINGER_SWIPE_UP(GESTURE_4_FINGER_SWIPE_UP, 4, 0, UP, NONE); + + constructor( + gestureId: Int, + finger: Int, + tap: Int, + direction1: Int, + direction2: Int, + ) : this(gestureId, finger, tap, /* isHold= */ false, direction1, direction2) + + override fun toString(): String = + "GestureMatchConfig{" + + "gestureId=" + + gestureId + + ", finger=" + + finger + + ", tap=" + + tap + + ", isHold=" + + isHold + + ", direction1=" + + direction1 + + ", direction2=" + + direction2 + + '}' + } + + /** + * Gets the list of {@link GestureMatcher} by the support gesture list. + * + * @param supportGestureList the support gesture list + * @param listener the listener to set to the GestureMatcher + * @param logger the event logger to report the specific events + * @return the list of GestureMatcher + */ + @JvmStatic + fun getGestureMatcherList( + context: Context, + supportGestureList: List, + listener: GestureMatcher.StateChangeListener, + configResolver: GestureConfigProvider, + logger: GestureMatcher.AnalyticsEventLogger, + ): List { + val gestureMatchers = ArrayList() + LogUtils.v( + "GestureMatcherFactory", + "Speed up TouchExplore state: %b", + configResolver.getSpeedUpTouchExploreState(), + ) + if (configResolver.getSpeedUpTouchExploreState()) { + gestureMatchers.add( + TapToTouchExplore( + context, GestureManifold.GESTURE_TOUCH_EXPLORE, listener, configResolver, logger), + ) + gestureMatchers.add( + TapUpToTouchExplore(context, GestureManifold.GESTURE_TAP_UP_TOUCH_EXPLORE, listener, logger), + ) + } + for (iterator in GestureMatchConfig.entries) { + if (supportGestureList.contains(iterator.name)) { + val gestureMatcher = createGestureMatcher(context, iterator, listener, configResolver, logger) + if (gestureMatcher != null) { + gestureMatchers.add(gestureMatcher) + } + } + } + return gestureMatchers + } + + private fun createGestureMatcher( + context: Context, + gestureMatchConfig: GestureMatchConfig, + listener: GestureMatcher.StateChangeListener, + configResolver: GestureConfigProvider, + logger: GestureMatcher.AnalyticsEventLogger, + ): GestureMatcher? { + // 1-finger + if (gestureMatchConfig.finger == 1) { + // swipe + if (gestureMatchConfig.tap == 0) { + return if (gestureMatchConfig.direction2 == NONE) { + Swipe( + context, + gestureMatchConfig.direction1, + gestureMatchConfig.gestureId, + listener, + configResolver, + logger, + ) + } else { + Swipe( + context, + gestureMatchConfig.direction1, + gestureMatchConfig.direction2, + gestureMatchConfig.gestureId, + listener, + configResolver, + logger, + ) + } + } + // single-tap and hold + if (gestureMatchConfig.tap == 1 && gestureMatchConfig.isHold) { + return if (gestureMatchConfig.gestureId == GestureManifold.GESTURE_FAKED_SPLIT_TYPING) { + SecondFingerTap( + context, gestureMatchConfig.tap, gestureMatchConfig.gestureId, listener, logger) + } else if (configResolver.enableSplitTapAndHold() && + gestureMatchConfig.gestureId == GestureManifold.GESTURE_FAKED_SPLIT_TYPING_AND_HOLD + ) { + SecondFingerTapAndHold( + context, gestureMatchConfig.tap, gestureMatchConfig.gestureId, listener, logger) + } else { + null + } + } + // double-taps + if (gestureMatchConfig.tap == 2) { + return if (gestureMatchConfig.isHold) { + MultiTapAndHold( + context, + gestureMatchConfig.tap, + gestureMatchConfig.gestureId, + listener, + configResolver, + logger, + ) + } else { + MultiTap( + context, + gestureMatchConfig.tap, + gestureMatchConfig.gestureId, + listener, + configResolver, + logger, + ) + } + } + } else { + // multi-finger + // swipe + if (gestureMatchConfig.tap == 0) { + return MultiFingerSwipe( + context, + gestureMatchConfig.finger, + gestureMatchConfig.direction1, + gestureMatchConfig.gestureId, + listener, + logger, + ) + } + // multi-taps and hold + return if (gestureMatchConfig.isHold) { + // Two-finger single tap and hold is special gesture for TalkBack mis-triggering recovery + // feature. + if (gestureMatchConfig.finger == 2 && gestureMatchConfig.tap == 1) { + TwoFingerSingleTapAndLongHold(context, gestureMatchConfig.gestureId, listener, logger) + } else { + MultiFingerMultiTapAndHold( + context, + gestureMatchConfig.finger, + gestureMatchConfig.tap, + gestureMatchConfig.gestureId, + listener, + logger, + ) + } + } else { + // multi-taps without hold + MultiFingerMultiTap( + context, + gestureMatchConfig.finger, + gestureMatchConfig.tap, + gestureMatchConfig.gestureId, + listener, + logger, + ) + } + } + throw IllegalArgumentException( + String.format( + "IllegalArgumentException: GestureMatchConfig %s defines the wrong argument. %s", + gestureMatchConfig.name, + gestureMatchConfig, + ), + ) + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureUtils.java deleted file mode 100644 index 9c511743d..000000000 --- a/utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureUtils.java +++ /dev/null @@ -1,131 +0,0 @@ -package com.google.android.accessibility.utils.gestures; - -import android.graphics.PointF; -import android.view.MotionEvent; - -/** Some helper functions for gesture detection. */ -public final class GestureUtils { - - public static final int MM_PER_CM = 10; - public static final float CM_PER_INCH = 2.54f; - - private GestureUtils() { - /* cannot be instantiated */ - } - - public static boolean isMultiTap( - MotionEvent firstUp, MotionEvent secondUp, int multiTapTimeSlop, int multiTapDistanceSlop) { - if (firstUp == null || secondUp == null) { - return false; - } - return eventsWithinTimeAndDistanceSlop( - firstUp, secondUp, multiTapTimeSlop, multiTapDistanceSlop); - } - - private static boolean eventsWithinTimeAndDistanceSlop( - MotionEvent first, MotionEvent second, int timeout, int distance) { - if (isTimedOut(first, second, timeout)) { - return false; - } - final double deltaMove = distance(first, second); - if (deltaMove >= distance) { - return false; - } - return true; - } - - public static double distance(MotionEvent first, MotionEvent second) { - return dist(first.getX(), first.getY(), second.getX(), second.getY()); - } - - /** - * Returns the minimum distance between {@code pointerDown} and each pointer of {@link - * MotionEvent}. - * - * @param pointerDown The action pointer location of the {@link MotionEvent} with {@link - * MotionEvent#ACTION_DOWN} or {@link MotionEvent#ACTION_POINTER_DOWN} - * @param moveEvent The {@link MotionEvent} with {@link MotionEvent#ACTION_MOVE} - * @return the movement of the pointer. - */ - public static double distanceClosestPointerToPoint(PointF pointerDown, MotionEvent moveEvent) { - float movement = Float.MAX_VALUE; - for (int i = 0; i < moveEvent.getPointerCount(); i++) { - final float moveDelta = - dist(pointerDown.x, pointerDown.y, moveEvent.getX(i), moveEvent.getY(i)); - if (movement > moveDelta) { - movement = moveDelta; - } - } - return movement; - } - - public static boolean isTimedOut(MotionEvent firstUp, MotionEvent secondUp, int timeout) { - final long deltaTime = secondUp.getEventTime() - firstUp.getEventTime(); - return (deltaTime >= timeout); - } - - /** - * Determines whether a two pointer gesture is a dragging one. - * - * @return True if the gesture is a dragging one. - */ - public static boolean isDraggingGesture( - float firstPtrDownX, - float firstPtrDownY, - float secondPtrDownX, - float secondPtrDownY, - float firstPtrX, - float firstPtrY, - float secondPtrX, - float secondPtrY, - float maxDraggingAngleCos) { - - // Check if the pointers are moving in the same direction. - final float firstDeltaX = firstPtrX - firstPtrDownX; - final float firstDeltaY = firstPtrY - firstPtrDownY; - - if (firstDeltaX == 0 && firstDeltaY == 0) { - return true; - } - - final float firstMagnitude = (float) Math.hypot(firstDeltaX, firstDeltaY); - final float firstXNormalized = - (firstMagnitude > 0) ? firstDeltaX / firstMagnitude : firstDeltaX; - final float firstYNormalized = - (firstMagnitude > 0) ? firstDeltaY / firstMagnitude : firstDeltaY; - - final float secondDeltaX = secondPtrX - secondPtrDownX; - final float secondDeltaY = secondPtrY - secondPtrDownY; - - if (secondDeltaX == 0 && secondDeltaY == 0) { - return true; - } - - final float secondMagnitude = (float) Math.hypot(secondDeltaX, secondDeltaY); - final float secondXNormalized = - (secondMagnitude > 0) ? secondDeltaX / secondMagnitude : secondDeltaX; - final float secondYNormalized = - (secondMagnitude > 0) ? secondDeltaY / secondMagnitude : secondDeltaY; - - final float angleCos = - firstXNormalized * secondXNormalized + firstYNormalized * secondYNormalized; - - if (angleCos < maxDraggingAngleCos) { - return false; - } - - return true; - } - - /** Gets the index of the pointer that went up or down from a motion event. */ - public static int getActionIndex(MotionEvent event) { - return (event.getAction() & MotionEvent.ACTION_POINTER_INDEX_MASK) - >> MotionEvent.ACTION_POINTER_INDEX_SHIFT; - } - - public static float dist(float x1, float y1, float x2, float y2) { - final float x = (x2 - x1); - final float y = (y2 - y1); - return (float) Math.hypot(x, y); - } -} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureUtils.kt b/utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureUtils.kt new file mode 100644 index 000000000..15e8c53b0 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/gestures/GestureUtils.kt @@ -0,0 +1,152 @@ +/* + * Copyright (C) The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Ported from Java to Kotlin. + */ + +package com.google.android.accessibility.utils.gestures + +import android.graphics.PointF +import android.view.MotionEvent +import kotlin.math.hypot + +/** Some helper functions for gesture detection. */ +object GestureUtils { + + const val MM_PER_CM = 10 + const val CM_PER_INCH = 2.54f + + @JvmStatic + fun isMultiTap( + firstUp: MotionEvent?, + secondUp: MotionEvent?, + multiTapTimeSlop: Int, + multiTapDistanceSlop: Int, + ): Boolean { + if (firstUp == null || secondUp == null) { + return false + } + return eventsWithinTimeAndDistanceSlop(firstUp, secondUp, multiTapTimeSlop, multiTapDistanceSlop) + } + + private fun eventsWithinTimeAndDistanceSlop( + first: MotionEvent, + second: MotionEvent, + timeout: Int, + distance: Int, + ): Boolean { + if (isTimedOut(first, second, timeout)) { + return false + } + val deltaMove = distance(first, second) + if (deltaMove >= distance) { + return false + } + return true + } + + @JvmStatic + fun distance(first: MotionEvent, second: MotionEvent): Double = + dist(first.x, first.y, second.x, second.y).toDouble() + + /** + * Returns the minimum distance between {@code pointerDown} and each pointer of {@link + * MotionEvent}. + * + * @param pointerDown The action pointer location of the {@link MotionEvent} with {@link + * MotionEvent#ACTION_DOWN} or {@link MotionEvent#ACTION_POINTER_DOWN} + * @param moveEvent The {@link MotionEvent} with {@link MotionEvent#ACTION_MOVE} + * @return the movement of the pointer. + */ + @JvmStatic + fun distanceClosestPointerToPoint(pointerDown: PointF, moveEvent: MotionEvent): Double { + var movement = Float.MAX_VALUE + for (i in 0 until moveEvent.pointerCount) { + val moveDelta = dist(pointerDown.x, pointerDown.y, moveEvent.getX(i), moveEvent.getY(i)) + if (movement > moveDelta) { + movement = moveDelta + } + } + return movement.toDouble() + } + + @JvmStatic + fun isTimedOut(firstUp: MotionEvent, secondUp: MotionEvent, timeout: Int): Boolean { + val deltaTime = secondUp.eventTime - firstUp.eventTime + return deltaTime >= timeout + } + + /** + * Determines whether a two pointer gesture is a dragging one. + * + * @return True if the gesture is a dragging one. + */ + @JvmStatic + fun isDraggingGesture( + firstPtrDownX: Float, + firstPtrDownY: Float, + secondPtrDownX: Float, + secondPtrDownY: Float, + firstPtrX: Float, + firstPtrY: Float, + secondPtrX: Float, + secondPtrY: Float, + maxDraggingAngleCos: Float, + ): Boolean { + // Check if the pointers are moving in the same direction. + val firstDeltaX = firstPtrX - firstPtrDownX + val firstDeltaY = firstPtrY - firstPtrDownY + + if (firstDeltaX == 0f && firstDeltaY == 0f) { + return true + } + + val firstMagnitude = hypot(firstDeltaX, firstDeltaY) + val firstXNormalized = if (firstMagnitude > 0) firstDeltaX / firstMagnitude else firstDeltaX + val firstYNormalized = if (firstMagnitude > 0) firstDeltaY / firstMagnitude else firstDeltaY + + val secondDeltaX = secondPtrX - secondPtrDownX + val secondDeltaY = secondPtrY - secondPtrDownY + + if (secondDeltaX == 0f && secondDeltaY == 0f) { + return true + } + + val secondMagnitude = hypot(secondDeltaX, secondDeltaY) + val secondXNormalized = if (secondMagnitude > 0) secondDeltaX / secondMagnitude else secondDeltaX + val secondYNormalized = if (secondMagnitude > 0) secondDeltaY / secondMagnitude else secondDeltaY + + val angleCos = firstXNormalized * secondXNormalized + firstYNormalized * secondYNormalized + + if (angleCos < maxDraggingAngleCos) { + return false + } + + return true + } + + /** Gets the index of the pointer that went up or down from a motion event. */ + @JvmStatic + fun getActionIndex(event: MotionEvent): Int = + (event.action and MotionEvent.ACTION_POINTER_INDEX_MASK) shr + MotionEvent.ACTION_POINTER_INDEX_SHIFT + + @JvmStatic + fun dist(x1: Float, y1: Float, x2: Float, y2: Float): Float { + val x = x2 - x1 + val y = y2 - y1 + return hypot(x, y) + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiFingerMultiTap.java b/utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiFingerMultiTap.java deleted file mode 100644 index 0417105e0..000000000 --- a/utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiFingerMultiTap.java +++ /dev/null @@ -1,324 +0,0 @@ -/* - * Copyright (C) 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.android.accessibility.utils.gestures; - -import android.content.Context; -import android.graphics.PointF; -import android.os.Handler; -import android.view.MotionEvent; -import android.view.ViewConfiguration; -import androidx.annotation.Nullable; -import com.google.android.accessibility.utils.Performance.EventId; -import java.util.ArrayList; -import java.util.Arrays; - -/** - * This class matches multi-finger multi-tap gestures. The number of fingers and the number of taps - * for each instance is specified in the constructor. - */ -class MultiFingerMultiTap extends GestureMatcher { - - // The target number of taps. - final int mTargetTapCount; - // The target number of fingers. - final int targetFingerCount; - // The acceptable distance between two taps of a finger. - private int doubleTapSlop; - private int doubleTapTimeout; - private int tapTimeout; - // The acceptable distance the pointer can move and still count as a tap. - private int touchSlop; - // A tap counts when target number of fingers are down and up once. - protected int completedTapCount; - // A flag set to true when target number of fingers have touched down at once before. - // Used to indicate what next finger action should be. Down when false and lift when true. - protected boolean isTargetFingerCountReached = false; - // Store initial down points for slop checking and update when next down if is inside slop. - private PointF[] bases; - // The points in bases that already have slop checked when onDown or onPointerDown. - // It prevents excluded points matched multiple times by other pointers from next check. - private ArrayList excludedPointsForDownSlopChecked; - private long lastDownTime; - private long lastUpTime; - - /** - * @throws IllegalArgumentException if fingers is less than 2 - * or taps is not positive. - */ - MultiFingerMultiTap( - Context context, - int fingers, - int taps, - int gestureId, - GestureMatcher.StateChangeListener listener, - GestureMatcher.AnalyticsEventLogger logger) { - super(gestureId, new Handler(context.getMainLooper()), listener, logger); - mTargetTapCount = taps; - targetFingerCount = fingers; - doubleTapSlop = ViewConfiguration.get(context).getScaledDoubleTapSlop() * fingers; - doubleTapTimeout = GestureConfiguration.DOUBLE_TAP_TIMEOUT_MS; - tapTimeout = targetFingerCount * ViewConfiguration.getTapTimeout(); - touchSlop = ViewConfiguration.get(context).getScaledTouchSlop() * fingers; - - bases = new PointF[targetFingerCount]; - for (int i = 0; i < bases.length; i++) { - bases[i] = new PointF(); - } - excludedPointsForDownSlopChecked = new ArrayList<>(targetFingerCount); - clear(); - } - - @Override - public void clear() { - completedTapCount = 0; - isTargetFingerCountReached = false; - for (int i = 0; i < bases.length; i++) { - bases[i].set(Float.NaN, Float.NaN); - } - excludedPointsForDownSlopChecked.clear(); - lastDownTime = Long.MAX_VALUE; - lastUpTime = Long.MAX_VALUE; - super.clear(); - } - - @Override - protected void onDown(EventId eventId, MotionEvent event) { - // Before the matcher state transit to completed, - // Cancel when an additional down arrived after reaching the target number of taps. - if (completedTapCount == mTargetTapCount) { - cancelGesture(event); - return; - } - long timeDelta = event.getEventTime() - lastUpTime; - if (timeDelta > doubleTapTimeout) { - cancelGesture(event); - return; - } - lastDownTime = event.getEventTime(); - if (completedTapCount == 0) { - initBaseLocation(event); - return; - } - // As fingers go up and down, their pointer ids will not be the same. - // Therefore we require that a given finger be in slop range of any one - // of the fingers from the previous tap. - final PointF nearest = findNearestPoint(event, doubleTapSlop, true); - if (nearest != null) { - // Update pointer location to nearest one as a new base for next slop check. - final int index = event.getActionIndex(); - nearest.set(event.getX(index), event.getY(index)); - } else { - cancelGesture(event); - } - } - - @Override - protected void onUp(EventId eventId, MotionEvent event) { - // Because this is a multi-finger gesture, we must have received ACTION_POINTER_UP before this - // so we calculate timeDelta relative to lastUpTime. - long timeDelta = event.getEventTime() - lastUpTime; - if (timeDelta > tapTimeout) { - cancelGesture(event); - return; - } - lastUpTime = event.getEventTime(); - final PointF nearest = findNearestPoint(event, touchSlop, false); - if ((getState() == STATE_GESTURE_STARTED || getState() == STATE_CLEAR) && null != nearest) { - // Increase current tap count when the user have all fingers lifted - // within the tap timeout since the target number of fingers are down. - if (isTargetFingerCountReached) { - completedTapCount++; - isTargetFingerCountReached = false; - excludedPointsForDownSlopChecked.clear(); - } - - // Start gesture detection here to avoid the conflict to 2nd finger double tap - // that never actually started gesture detection. - if (completedTapCount == 1) { - startGesture(event); - } - if (completedTapCount == mTargetTapCount) { - // Done. - completeAfterDoubleTapTimeout(eventId, event); - } - } else { - // Either too many taps or nonsensical event stream. - cancelGesture(event); - } - } - - @Override - protected void onMove(EventId eventId, MotionEvent event) { - // Outside the touch slop - if (null == findNearestPoint(event, touchSlop, false)) { - cancelGesture(event); - } - } - - @Override - protected void onPointerDown(EventId eventId, MotionEvent event) { - // Reset timeout to ease the use for some people - // with certain impairments to get all their fingers down. - long timeDelta = event.getEventTime() - lastDownTime; - if (timeDelta > tapTimeout) { - cancelGesture(event); - return; - } - lastDownTime = event.getEventTime(); - final int currentFingerCount = event.getPointerCount(); - // Accept down only before target number of fingers are down - // or the finger count is not more than target. - if ((currentFingerCount > targetFingerCount) || isTargetFingerCountReached) { - isTargetFingerCountReached = false; - cancelGesture(event); - return; - } - - final PointF nearest; - if (completedTapCount == 0) { - nearest = initBaseLocation(event); - } else { - nearest = findNearestPoint(event, doubleTapSlop, true); - } - if ((getState() == STATE_GESTURE_STARTED || getState() == STATE_CLEAR) && nearest != null) { - // The user have all fingers down within the tap timeout since first finger down, - // setting the timeout for fingers to be lifted. - if (currentFingerCount == targetFingerCount) { - isTargetFingerCountReached = true; - } - // Update pointer location to nearest one as a new base for next slop check. - final int index = event.getActionIndex(); - nearest.set(event.getX(index), event.getY(index)); - } else { - cancelGesture(event); - } - } - - @Override - protected void onPointerUp(EventId eventId, MotionEvent event) { - // Accept up only after target number of fingers are down. - if (!isTargetFingerCountReached) { - cancelGesture(event); - return; - } - - if (getState() == STATE_GESTURE_STARTED || getState() == STATE_CLEAR) { - // Needs more fingers lifted within the tap timeout - // after reaching the target number of fingers are down. - // Calculate timeDelta relative to whichever baseline is most recent, lastUpTime or - // lastDownTime. - long timeDelta = event.getEventTime() - Math.max(lastDownTime, lastUpTime); - if (timeDelta > tapTimeout) { - cancelGesture(event); - return; - } - lastUpTime = event.getEventTime(); - } else { - cancelGesture(event); - } - } - - @Override - public String getGestureName() { - final StringBuilder builder = new StringBuilder(); - builder.append(targetFingerCount).append("-Finger "); - if (mTargetTapCount == 1) { - builder.append("Single"); - } else if (mTargetTapCount == 2) { - builder.append("Double"); - } else if (mTargetTapCount == 3) { - builder.append("Triple"); - } else if (mTargetTapCount > 3) { - builder.append(mTargetTapCount); - } - return builder.append(" Tap").toString(); - } - - private PointF initBaseLocation(MotionEvent event) { - final int index = event.getActionIndex(); - final int baseIndex = event.getPointerCount() - 1; - final PointF p = bases[baseIndex]; - if (Float.isNaN(p.x) && Float.isNaN(p.y)) { - p.set(event.getX(index), event.getY(index)); - } - return p; - } - - /** - * Find the nearest location to the given event in the bases. If no one found, it could be not - * inside {@code slop}, filtered or empty bases. When {@code filterMatched} is true, if the - * location of given event matches one of the points in {@link #mExcludedPointsForDownSlopChecked} - * it would be ignored. Otherwise, the location will be added to {@link - * #mExcludedPointsForDownSlopChecked}. - * - * @param event to find nearest point in bases. - * @param slop to check to the given location of the event. - * @param filterMatched true to exclude points already matched other pointers. - * @return the point in bases closed to the location of the given event. - */ - @Nullable - private PointF findNearestPoint(MotionEvent event, float slop, boolean filterMatched) { - float moveDelta = Float.MAX_VALUE; - PointF nearest = null; - for (int i = 0; i < bases.length; i++) { - final PointF p = bases[i]; - if (Float.isNaN(p.x) && Float.isNaN(p.y)) { - continue; - } - if (filterMatched && excludedPointsForDownSlopChecked.contains(p)) { - continue; - } - final int index = event.getActionIndex(); - final float dX = p.x - event.getX(index); - final float dY = p.y - event.getY(index); - if (dX == 0 && dY == 0) { - if (filterMatched) { - excludedPointsForDownSlopChecked.add(p); - } - return p; - } - final float delta = (float) Math.hypot(dX, dY); - if (moveDelta > delta) { - moveDelta = delta; - nearest = p; - } - } - if (moveDelta < slop) { - if (filterMatched) { - excludedPointsForDownSlopChecked.add(nearest); - } - return nearest; - } - return null; - } - - @Override - public String toString() { - final StringBuilder builder = new StringBuilder(super.toString()); - if (getState() != STATE_GESTURE_CANCELED) { - builder.append(", CompletedTapCount: "); - builder.append(completedTapCount); - builder.append(", IsTargetFingerCountReached: "); - builder.append(isTargetFingerCountReached); - builder.append(", Bases: "); - builder.append(Arrays.toString(bases)); - builder.append(", ExcludedPointsForDownSlopChecked: "); - builder.append(excludedPointsForDownSlopChecked.toString()); - } - return builder.toString(); - } -} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiFingerMultiTap.kt b/utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiFingerMultiTap.kt new file mode 100644 index 000000000..5127ff9d4 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiFingerMultiTap.kt @@ -0,0 +1,309 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Ported from Java to Kotlin. + */ + +package com.google.android.accessibility.utils.gestures + +import android.content.Context +import android.graphics.PointF +import android.os.Handler +import android.view.MotionEvent +import android.view.ViewConfiguration +import com.google.android.accessibility.utils.Performance.EventId +import java.util.Arrays +import kotlin.math.hypot + +/** + * This class matches multi-finger multi-tap gestures. The number of fingers and the number of taps + * for each instance is specified in the constructor. + * + * @throws IllegalArgumentException if `fingers` is less than 2 or `taps` is not positive. + */ +internal open class MultiFingerMultiTap( + context: Context, + fingers: Int, + taps: Int, + gestureId: Int, + listener: GestureMatcher.StateChangeListener, + logger: GestureMatcher.AnalyticsEventLogger, +) : GestureMatcher(gestureId, Handler(context.mainLooper), listener, logger) { + + // The target number of taps. + @JvmField internal val mTargetTapCount: Int = taps + + // The target number of fingers. + @JvmField internal val targetFingerCount: Int = fingers + + // The acceptable distance between two taps of a finger. + private val doubleTapSlop: Int = ViewConfiguration.get(context).scaledDoubleTapSlop * fingers + private val doubleTapTimeout: Int = GestureConfiguration.DOUBLE_TAP_TIMEOUT_MS + private val tapTimeout: Int = targetFingerCount * ViewConfiguration.getTapTimeout() + + // The acceptable distance the pointer can move and still count as a tap. + private val touchSlop: Int = ViewConfiguration.get(context).scaledTouchSlop * fingers + + // A tap counts when target number of fingers are down and up once. + @JvmField protected var completedTapCount: Int = 0 + + // A flag set to true when target number of fingers have touched down at once before. + // Used to indicate what next finger action should be. Down when false and lift when true. + @JvmField protected var isTargetFingerCountReached: Boolean = false + + // Store initial down points for slop checking and update when next down if is inside slop. + private val bases: Array = Array(targetFingerCount) { PointF() } + + // The points in bases that already have slop checked when onDown or onPointerDown. + // It prevents excluded points matched multiple times by other pointers from next check. + private val excludedPointsForDownSlopChecked: ArrayList = ArrayList(targetFingerCount) + private var lastDownTime: Long = Long.MAX_VALUE + private var lastUpTime: Long = Long.MAX_VALUE + + init { + clear() + } + + override fun clear() { + completedTapCount = 0 + isTargetFingerCountReached = false + for (i in bases.indices) { + bases[i].set(Float.NaN, Float.NaN) + } + excludedPointsForDownSlopChecked.clear() + lastDownTime = Long.MAX_VALUE + lastUpTime = Long.MAX_VALUE + super.clear() + } + + override fun onDown(eventId: EventId?, event: MotionEvent) { + // Before the matcher state transit to completed, + // Cancel when an additional down arrived after reaching the target number of taps. + if (completedTapCount == mTargetTapCount) { + cancelGesture(event) + return + } + val timeDelta = event.eventTime - lastUpTime + if (timeDelta > doubleTapTimeout) { + cancelGesture(event) + return + } + lastDownTime = event.eventTime + if (completedTapCount == 0) { + initBaseLocation(event) + return + } + // As fingers go up and down, their pointer ids will not be the same. + // Therefore we require that a given finger be in slop range of any one + // of the fingers from the previous tap. + val nearest = findNearestPoint(event, doubleTapSlop.toFloat(), true) + if (nearest != null) { + // Update pointer location to nearest one as a new base for next slop check. + val index = event.actionIndex + nearest.set(event.getX(index), event.getY(index)) + } else { + cancelGesture(event) + } + } + + override fun onUp(eventId: EventId?, event: MotionEvent) { + // Because this is a multi-finger gesture, we must have received ACTION_POINTER_UP before this + // so we calculate timeDelta relative to lastUpTime. + val timeDelta = event.eventTime - lastUpTime + if (timeDelta > tapTimeout) { + cancelGesture(event) + return + } + lastUpTime = event.eventTime + val nearest = findNearestPoint(event, touchSlop.toFloat(), false) + if ((state == STATE_GESTURE_STARTED || state == STATE_CLEAR) && nearest != null) { + // Increase current tap count when the user have all fingers lifted + // within the tap timeout since the target number of fingers are down. + if (isTargetFingerCountReached) { + completedTapCount++ + isTargetFingerCountReached = false + excludedPointsForDownSlopChecked.clear() + } + + // Start gesture detection here to avoid the conflict to 2nd finger double tap + // that never actually started gesture detection. + if (completedTapCount == 1) { + startGesture(event) + } + if (completedTapCount == mTargetTapCount) { + // Done. + completeAfterDoubleTapTimeout(eventId, event) + } + } else { + // Either too many taps or nonsensical event stream. + cancelGesture(event) + } + } + + override fun onMove(eventId: EventId?, event: MotionEvent) { + // Outside the touch slop + if (findNearestPoint(event, touchSlop.toFloat(), false) == null) { + cancelGesture(event) + } + } + + override fun onPointerDown(eventId: EventId?, event: MotionEvent) { + // Reset timeout to ease the use for some people + // with certain impairments to get all their fingers down. + val timeDelta = event.eventTime - lastDownTime + if (timeDelta > tapTimeout) { + cancelGesture(event) + return + } + lastDownTime = event.eventTime + val currentFingerCount = event.pointerCount + // Accept down only before target number of fingers are down + // or the finger count is not more than target. + if (currentFingerCount > targetFingerCount || isTargetFingerCountReached) { + isTargetFingerCountReached = false + cancelGesture(event) + return + } + + val nearest: PointF? = + if (completedTapCount == 0) { + initBaseLocation(event) + } else { + findNearestPoint(event, doubleTapSlop.toFloat(), true) + } + if ((state == STATE_GESTURE_STARTED || state == STATE_CLEAR) && nearest != null) { + // The user have all fingers down within the tap timeout since first finger down, + // setting the timeout for fingers to be lifted. + if (currentFingerCount == targetFingerCount) { + isTargetFingerCountReached = true + } + // Update pointer location to nearest one as a new base for next slop check. + val index = event.actionIndex + nearest.set(event.getX(index), event.getY(index)) + } else { + cancelGesture(event) + } + } + + override fun onPointerUp(eventId: EventId?, event: MotionEvent) { + // Accept up only after target number of fingers are down. + if (!isTargetFingerCountReached) { + cancelGesture(event) + return + } + + if (state == STATE_GESTURE_STARTED || state == STATE_CLEAR) { + // Needs more fingers lifted within the tap timeout + // after reaching the target number of fingers are down. + // Calculate timeDelta relative to whichever baseline is most recent, lastUpTime or + // lastDownTime. + val timeDelta = event.eventTime - maxOf(lastDownTime, lastUpTime) + if (timeDelta > tapTimeout) { + cancelGesture(event) + return + } + lastUpTime = event.eventTime + } else { + cancelGesture(event) + } + } + + override fun getGestureName(): String { + val builder = StringBuilder() + builder.append(targetFingerCount).append("-Finger ") + if (mTargetTapCount == 1) { + builder.append("Single") + } else if (mTargetTapCount == 2) { + builder.append("Double") + } else if (mTargetTapCount == 3) { + builder.append("Triple") + } else if (mTargetTapCount > 3) { + builder.append(mTargetTapCount) + } + return builder.append(" Tap").toString() + } + + private fun initBaseLocation(event: MotionEvent): PointF { + val index = event.actionIndex + val baseIndex = event.pointerCount - 1 + val p = bases[baseIndex] + if (p.x.isNaN() && p.y.isNaN()) { + p.set(event.getX(index), event.getY(index)) + } + return p + } + + /** + * Find the nearest location to the given event in the bases. If no one found, it could be not + * inside {@code slop}, filtered or empty bases. When {@code filterMatched} is true, if the + * location of given event matches one of the points in {@link #mExcludedPointsForDownSlopChecked} + * it would be ignored. Otherwise, the location will be added to {@link + * #mExcludedPointsForDownSlopChecked}. + * + * @param event to find nearest point in bases. + * @param slop to check to the given location of the event. + * @param filterMatched true to exclude points already matched other pointers. + * @return the point in bases closed to the location of the given event. + */ + private fun findNearestPoint(event: MotionEvent, slop: Float, filterMatched: Boolean): PointF? { + var moveDelta = Float.MAX_VALUE + var nearest: PointF? = null + for (i in bases.indices) { + val p = bases[i] + if (p.x.isNaN() && p.y.isNaN()) { + continue + } + if (filterMatched && excludedPointsForDownSlopChecked.contains(p)) { + continue + } + val index = event.actionIndex + val dX = p.x - event.getX(index) + val dY = p.y - event.getY(index) + if (dX == 0f && dY == 0f) { + if (filterMatched) { + excludedPointsForDownSlopChecked.add(p) + } + return p + } + val delta = hypot(dX, dY) + if (moveDelta > delta) { + moveDelta = delta + nearest = p + } + } + if (moveDelta < slop) { + if (filterMatched) { + excludedPointsForDownSlopChecked.add(nearest!!) + } + return nearest + } + return null + } + + override fun toString(): String { + val builder = StringBuilder(super.toString()) + if (state != STATE_GESTURE_CANCELED) { + builder.append(", CompletedTapCount: ") + builder.append(completedTapCount) + builder.append(", IsTargetFingerCountReached: ") + builder.append(isTargetFingerCountReached) + builder.append(", Bases: ") + builder.append(Arrays.toString(bases)) + builder.append(", ExcludedPointsForDownSlopChecked: ") + builder.append(excludedPointsForDownSlopChecked.toString()) + } + return builder.toString() + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiFingerMultiTapAndHold.java b/utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiFingerMultiTapAndHold.kt similarity index 51% rename from utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiFingerMultiTapAndHold.java rename to utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiFingerMultiTapAndHold.kt index b0bd414a0..67f06abcc 100644 --- a/utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiFingerMultiTapAndHold.java +++ b/utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiFingerMultiTapAndHold.kt @@ -12,62 +12,58 @@ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. + * + * Ported from Java to Kotlin. */ -package com.google.android.accessibility.utils.gestures; +package com.google.android.accessibility.utils.gestures -import android.content.Context; -import android.view.MotionEvent; -import com.google.android.accessibility.utils.Performance.EventId; +import android.content.Context +import android.view.MotionEvent +import com.google.android.accessibility.utils.Performance.EventId /** * This class matches gestures of the form multi-finger multi-tap and hold. The number of fingers * and taps for each instance is specified in the constructor. */ -class MultiFingerMultiTapAndHold extends MultiFingerMultiTap { - - MultiFingerMultiTapAndHold( - Context context, - int fingers, - int taps, - int gestureId, - GestureMatcher.StateChangeListener listener, - GestureMatcher.AnalyticsEventLogger logger) { - super(context, fingers, taps, gestureId, listener, logger); - } +internal class MultiFingerMultiTapAndHold( + context: Context, + fingers: Int, + taps: Int, + gestureId: Int, + listener: GestureMatcher.StateChangeListener, + logger: GestureMatcher.AnalyticsEventLogger, +) : MultiFingerMultiTap(context, fingers, taps, gestureId, listener, logger) { - @Override - protected void onPointerDown(EventId eventId, MotionEvent event) { - super.onPointerDown(eventId, event); + override fun onPointerDown(eventId: EventId?, event: MotionEvent) { + super.onPointerDown(eventId, event) if (isTargetFingerCountReached && completedTapCount + 1 == mTargetTapCount) { - completeAfterLongPressTimeout(eventId, event); + completeAfterLongPressTimeout(eventId, event) } } - @Override - protected void onUp(EventId eventId, MotionEvent event) { + override fun onUp(eventId: EventId?, event: MotionEvent) { if (completedTapCount + 1 == mTargetTapCount) { // Calling super.onUp would complete the multi-tap version of this. - cancelGesture(event); + cancelGesture(event) } else { - super.onUp(eventId, event); - cancelAfterDoubleTapTimeout(event); + super.onUp(eventId, event) + cancelAfterDoubleTapTimeout(event) } } - @Override - public String getGestureName() { - final StringBuilder builder = new StringBuilder(); - builder.append(targetFingerCount).append("-Finger "); + override fun getGestureName(): String { + val builder = StringBuilder() + builder.append(targetFingerCount).append("-Finger ") if (mTargetTapCount == 1) { - builder.append("Single"); + builder.append("Single") } else if (mTargetTapCount == 2) { - builder.append("Double"); + builder.append("Double") } else if (mTargetTapCount == 3) { - builder.append("Triple"); + builder.append("Triple") } else if (mTargetTapCount > 3) { - builder.append(mTargetTapCount); + builder.append(mTargetTapCount) } - return builder.append(" Tap and hold").toString(); + return builder.append(" Tap and hold").toString() } } diff --git a/utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiFingerSwipe.java b/utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiFingerSwipe.java deleted file mode 100644 index c514a14c4..000000000 --- a/utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiFingerSwipe.java +++ /dev/null @@ -1,476 +0,0 @@ -/* - * Copyright (C) 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.android.accessibility.utils.gestures; - -import static android.util.Log.VERBOSE; -import static android.view.MotionEvent.INVALID_POINTER_ID; - -import android.content.Context; -import android.graphics.PointF; -import android.os.Build; -import android.os.Handler; -import android.util.DisplayMetrics; -import android.util.Log; -import android.view.MotionEvent; -import android.view.ViewConfiguration; -import androidx.annotation.RequiresApi; -import com.google.android.accessibility.utils.Performance.EventId; -import com.google.android.accessibility.utils.R; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - -/** - * This class is responsible for matching one-finger swipe gestures. Each instance matches one swipe - * gesture. A swipe is specified as a series of one or more directions e.g. left, left and up, etc. - * At this time swipes with more than two directions are not supported. - */ -@RequiresApi(Build.VERSION_CODES.TIRAMISU) -class MultiFingerSwipe extends GestureMatcher { - - // Direction constants. - public static final int LEFT = 0; - public static final int RIGHT = 1; - public static final int UP = 2; - public static final int DOWN = 3; - public static final int UNCERTAIN = 4; - - // Buffer for storing points for gesture detection. - private final List> strokeBuffers; - - // The swipe direction for this matcher. - private int targetDirection; - private int[] pointerIds; - // The starting point of each finger's path in the gesture. - private PointF[] base; - // The most recent entry in each finger's gesture path. - private PointF[] previousGesturePoint; - private int targetFingerCount; - private int currentFingerCount; - // Whether the appropriate number of fingers have gone down at some point. This is reset only on - // clear. - private boolean targetFingerCountReached = false; - // Constants for sampling motion event points. - // We sample based on a minimum distance between points, primarily to improve accuracy by - // reducing noisy minor changes in direction. - private static final float MIN_CM_BETWEEN_SAMPLES = 0.25f; - private float minPixelsBetweenSamplesX; - private float minPixelsBetweenSamplesY; - // The minimum distance the finger must travel before we evaluate the initial direction of the - // swipe. - // Anything less is still considered a touch. - private int touchSlop; - private int tapTimeout; - // This is used to record the time of onDown/onPointerDown, so that we can check whether the all - // fingersʼ tap comply the TapTimeout spec. - private long lastDownTime; - - MultiFingerSwipe( - Context context, - int fingerCount, - int direction, - int gesture, - GestureMatcher.StateChangeListener listener, - GestureMatcher.AnalyticsEventLogger logger) { - super(gesture, new Handler(context.getMainLooper()), listener, logger); - targetFingerCount = fingerCount; - pointerIds = new int[targetFingerCount]; - base = new PointF[targetFingerCount]; - previousGesturePoint = new PointF[targetFingerCount]; - strokeBuffers = new ArrayList<>(); - for (int i = 0; i < targetFingerCount; ++i) { - strokeBuffers.add(new ArrayList()); - } - targetDirection = direction; - initializeViewConfigurationParameters(context); - clear(); - } - - @Override - public void onConfigurationChanged(Context context) { - initializeViewConfigurationParameters(context); - } - - private void initializeViewConfigurationParameters(Context context) { - DisplayMetrics displayMetrics = context.getResources().getDisplayMetrics(); - // Calculate gesture sampling interval. - final float pixelsPerCmX = displayMetrics.xdpi / GestureUtils.CM_PER_INCH; - final float pixelsPerCmY = displayMetrics.ydpi / GestureUtils.CM_PER_INCH; - minPixelsBetweenSamplesX = MIN_CM_BETWEEN_SAMPLES * pixelsPerCmX; - minPixelsBetweenSamplesY = MIN_CM_BETWEEN_SAMPLES * pixelsPerCmY; - tapTimeout = targetFingerCount * ViewConfiguration.getTapTimeout(); - touchSlop = - ViewConfiguration.get(context).getScaledTouchSlop() - * context.getResources().getInteger(R.integer.config_slop_default_multiplier); - } - - @Override - public void clear() { - targetFingerCountReached = false; - currentFingerCount = 0; - for (int i = 0; i < targetFingerCount; ++i) { - pointerIds[i] = INVALID_POINTER_ID; - if (base[i] == null) { - base[i] = new PointF(); - } - base[i].x = Float.NaN; - base[i].y = Float.NaN; - if (previousGesturePoint[i] == null) { - previousGesturePoint[i] = new PointF(); - } - previousGesturePoint[i].x = Float.NaN; - previousGesturePoint[i].y = Float.NaN; - strokeBuffers.get(i).clear(); - } - lastDownTime = Long.MAX_VALUE; - super.clear(); - } - - @Override - protected void onDown(EventId eventId, MotionEvent event) { - if (currentFingerCount > 0) { - cancelGesture(event); - return; - } - currentFingerCount = 1; - final int actionIndex = event.getActionIndex(); - final int pointerId = event.getPointerId(actionIndex); - int pointerIndex = event.getPointerCount() - 1; - if (pointerId < 0) { - // Nonsensical pointer id. - cancelGesture(event); - return; - } - if (pointerIds[pointerIndex] != INVALID_POINTER_ID) { - // Inconsistent event stream. - cancelGesture(event); - return; - } - lastDownTime = event.getEventTime(); - pointerIds[pointerIndex] = pointerId; - if (Float.isNaN(base[pointerIndex].x) && Float.isNaN(base[pointerIndex].y)) { - final float x = event.getX(actionIndex); - final float y = event.getY(actionIndex); - if (x < 0f || y < 0f) { - cancelGesture(event); - return; - } - base[pointerIndex].x = x; - base[pointerIndex].y = y; - previousGesturePoint[pointerIndex].x = x; - previousGesturePoint[pointerIndex].y = y; - } else { - // This event doesn't make sense in the middle of a gesture. - cancelGesture(event); - return; - } - } - - @Override - protected void onPointerDown(EventId eventId, MotionEvent event) { - if (event.getPointerCount() > targetFingerCount) { - cancelGesture(event); - return; - } - long timeDelta = event.getEventTime() - lastDownTime; - if (timeDelta > tapTimeout) { - cancelGesture(event); - return; - } - lastDownTime = event.getEventTime(); - currentFingerCount += 1; - if (currentFingerCount != event.getPointerCount()) { - cancelGesture(event); - return; - } - if (currentFingerCount == targetFingerCount) { - targetFingerCountReached = true; - } - final int actionIndex = event.getActionIndex(); - final int pointerId = event.getPointerId(actionIndex); - if (pointerId < 0) { - // Nonsensical pointer id. - cancelGesture(event); - return; - } - int pointerIndex = currentFingerCount - 1; - if (pointerIds[pointerIndex] != INVALID_POINTER_ID) { - // Inconsistent event stream. - cancelGesture(event); - return; - } - pointerIds[pointerIndex] = pointerId; - if (Float.isNaN(base[pointerIndex].x) && Float.isNaN(base[pointerIndex].y)) { - final float x = event.getX(actionIndex); - final float y = event.getY(actionIndex); - if (x < 0f || y < 0f) { - cancelGesture(event); - return; - } - base[pointerIndex].x = x; - base[pointerIndex].y = y; - previousGesturePoint[pointerIndex].x = x; - previousGesturePoint[pointerIndex].y = y; - } else { - cancelGesture(event); - return; - } - } - - @Override - protected void onPointerUp(EventId eventId, MotionEvent event) { - if (!targetFingerCountReached) { - cancelGesture(event); - return; - } - currentFingerCount -= 1; - final int actionIndex = event.getActionIndex(); - final int pointerId = event.getPointerId(actionIndex); - if (pointerId < 0) { - // Nonsensical pointer id. - cancelGesture(event); - return; - } - final int pointerIndex = Arrays.binarySearch(pointerIds, pointerId); - if (pointerIndex < 0) { - cancelGesture(event); - return; - } - final float x = event.getX(actionIndex); - final float y = event.getY(actionIndex); - if (x < 0f || y < 0f) { - cancelGesture(event); - return; - } - final float dX = Math.abs(x - previousGesturePoint[pointerIndex].x); - final float dY = Math.abs(y - previousGesturePoint[pointerIndex].y); - if (dX >= minPixelsBetweenSamplesX || dY >= minPixelsBetweenSamplesY) { - strokeBuffers.get(pointerIndex).add(new PointF(x, y)); - } - // We will evaluate all the paths on ACTION_UP. - } - - @Override - protected void onMove(EventId eventId, MotionEvent event) { - for (int pointerIndex = 0; pointerIndex < targetFingerCount; ++pointerIndex) { - if (pointerIds[pointerIndex] == INVALID_POINTER_ID) { - // Fingers have started to move before the required number of fingers are down. - // However, they can still move less than the touch slop and still be considered - // touching, not moving. - // So we just ignore fingers that haven't been assigned a pointer id and process - // those who have. - continue; - } - gestureMotionEventLog( - VERBOSE, getGestureName(), "Processing move on finger %d", pointerIndex); - int index = event.findPointerIndex(pointerIds[pointerIndex]); - if (index < 0) { - // This finger is not present in this event. It could have gone up just before this - // movement. - gestureMotionEventLog( - VERBOSE, "Finger %d not found in this event. skipping.", pointerIndex); - continue; - } - final float x = event.getX(index); - final float y = event.getY(index); - if (x < 0f || y < 0f) { - cancelGesture(event); - return; - } - final float dX = Math.abs(x - previousGesturePoint[pointerIndex].x); - final float dY = Math.abs(y - previousGesturePoint[pointerIndex].y); - final double moveDelta = - Math.hypot(Math.abs(x - base[pointerIndex].x), Math.abs(y - base[pointerIndex].y)); - gestureMotionEventLog(VERBOSE, "moveDelta%g", moveDelta); - if (getState() == STATE_CLEAR) { - if (moveDelta < (targetFingerCount * touchSlop)) { - // This still counts as a touch not a swipe. - continue; - } - // First, make sure we have the right number of fingers down. - if (currentFingerCount != targetFingerCount) { - cancelGesture(event); - return; - } - // Then, make sure the pointer is going in the right direction. - int direction = toDirection(x - base[pointerIndex].x, y - base[pointerIndex].y); - if (direction != targetDirection) { - cancelGesture(event); - return; - } - // This is confirmed to be some kind of swipe so start tracking points. - startGesture(event); - for (int i = 0; i < targetFingerCount; ++i) { - strokeBuffers.get(i).add(new PointF(base[i])); - } - } else if (getState() == STATE_GESTURE_STARTED) { - // Cancel if the finger starts to go the wrong way. - // Note that this only works because this matcher assumes one direction. - int direction = toDirection(x - base[pointerIndex].x, y - base[pointerIndex].y); - if (direction != UNCERTAIN && direction != targetDirection) { - cancelGesture(event); - return; - } - if (dX >= minPixelsBetweenSamplesX || dY >= minPixelsBetweenSamplesY) { - // Sample every 2.5 MM in order to guard against minor variations in path. - previousGesturePoint[pointerIndex].x = x; - previousGesturePoint[pointerIndex].y = y; - strokeBuffers.get(pointerIndex).add(new PointF(x, y)); - } - } - } - } - - @Override - protected void onUp(EventId eventId, MotionEvent event) { - switch (getState()) { - case STATE_GESTURE_STARTED -> {} - case STATE_CLEAR -> { - // For Swipe gestures, this is the very last motion event. When any of the swipe gesture - // detectors matches, the others will enter the clear state. We should not Cancel the - // detector again for the Up event, or it cannot detect new gesture immediately. - return; - } - default -> { - cancelGesture(event); - return; - } - } - currentFingerCount = 0; - final int actionIndex = event.getActionIndex(); - final int pointerId = event.getPointerId(actionIndex); - final int pointerIndex = Arrays.binarySearch(pointerIds, pointerId); - if (pointerIndex < 0) { - cancelGesture(event); - return; - } - final float x = event.getX(actionIndex); - final float y = event.getY(actionIndex); - if (x < 0f || y < 0f) { - cancelGesture(event); - return; - } - final float dX = Math.abs(x - previousGesturePoint[pointerIndex].x); - final float dY = Math.abs(y - previousGesturePoint[pointerIndex].y); - if (dX >= minPixelsBetweenSamplesX || dY >= minPixelsBetweenSamplesY) { - strokeBuffers.get(pointerIndex).add(new PointF(x, y)); - } - recognizeGesture(eventId, event); - } - - /** - * Looks at the sequence of motions in mStrokeBuffer, classifies the gesture, then transitions to - * the complete or cancel state depending on the result. - */ - private void recognizeGesture(EventId eventId, MotionEvent event) { - // Check the path of each finger against the specified direction. - // Note that we sample every 2.5 MMm, and the direction matching is extremely tolerant (each - // direction has a 90-degree arch of tolerance) meaning that minor perpendicular movements - // should not create false negatives. - for (int i = 0; i < targetFingerCount; ++i) { - gestureMotionEventLog(VERBOSE, "Recognizing finger: %d", i); - if (strokeBuffers.get(i).size() < 2) { - Log.d(getGestureName(), "Too few points."); - cancelGesture(event); - return; - } - List path = strokeBuffers.get(i); - - gestureMotionEventLog(VERBOSE, "path= %s", path.toString()); - // Classify line segments, and call Listener callbacks. - if (!recognizeGesturePath(path)) { - cancelGesture(event); - return; - } - } - // If we reach this point then all paths match. - completeGesture(eventId, event); - } - - /** - * Tests the path of a given finger against the direction specified in this matcher. - * - * @return True if the path matches the specified direction for this matcher, otherwise false. - */ - private boolean recognizeGesturePath(List path) { - for (int i = 0; i < path.size() - 1; ++i) { - PointF start = path.get(i); - PointF end = path.get(i + 1); - - float dX = end.x - start.x; - float dY = end.y - start.y; - int direction = toDirection(dX, dY); - if (direction != targetDirection) { - gestureMotionEventLog( - VERBOSE, - "Found direction %s when expecting %s", - directionToString(direction), - directionToString(this.targetDirection)); - return false; - } - } - gestureMotionEventLog(VERBOSE, "Completed."); - return true; - } - - private static int toDirection(float dX, float dY) { - if (dX == 0 && dY == 0) { - return UNCERTAIN; - } - if (Math.abs(dX) > Math.abs(dY)) { - // Horizontal - return (dX < 0) ? LEFT : RIGHT; - } else { - // Vertical - return (dY < 0) ? UP : DOWN; - } - } - - public static String directionToString(int direction) { - return switch (direction) { - case LEFT -> "left"; - case RIGHT -> "right"; - case UP -> "up"; - case DOWN -> "down"; - case UNCERTAIN -> "still"; - default -> "Unknown Direction"; - }; - } - - @Override - protected String getGestureName() { - StringBuilder builder = new StringBuilder(); - builder.append(targetFingerCount).append("-finger "); - builder.append("Swipe ").append(directionToString(targetDirection)); - return builder.toString(); - } - - @Override - public String toString() { - StringBuilder builder = new StringBuilder(super.toString()); - if (getState() != STATE_GESTURE_CANCELED) { - builder - .append(", mBase: ") - .append(Arrays.toString(base)) - .append(", mMinPixelsBetweenSamplesX:") - .append(minPixelsBetweenSamplesX) - .append(", mMinPixelsBetweenSamplesY:") - .append(minPixelsBetweenSamplesY); - } - return builder.toString(); - } -} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiFingerSwipe.kt b/utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiFingerSwipe.kt new file mode 100644 index 000000000..8cf308482 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiFingerSwipe.kt @@ -0,0 +1,470 @@ +/* + * Copyright (C) The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Ported from Java to Kotlin. + */ + +package com.google.android.accessibility.utils.gestures + +import android.content.Context +import android.graphics.PointF +import android.os.Build +import android.os.Handler +import android.util.Log +import android.util.Log.VERBOSE +import android.view.MotionEvent +import android.view.MotionEvent.INVALID_POINTER_ID +import android.view.ViewConfiguration +import androidx.annotation.RequiresApi +import com.google.android.accessibility.utils.Performance.EventId +import com.google.android.accessibility.utils.R +import java.util.Arrays +import kotlin.math.abs +import kotlin.math.hypot + +/** + * This class is responsible for matching one-finger swipe gestures. Each instance matches one swipe + * gesture. A swipe is specified as a series of one or more directions e.g. left, left and up, etc. + * At this time swipes with more than two directions are not supported. + */ +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +internal class MultiFingerSwipe( + context: Context, + fingerCount: Int, + direction: Int, + gesture: Int, + listener: GestureMatcher.StateChangeListener, + logger: GestureMatcher.AnalyticsEventLogger, +) : GestureMatcher(gesture, Handler(context.mainLooper), listener, logger) { + + // Buffer for storing points for gesture detection. + private val strokeBuffers: MutableList> + + // The swipe direction for this matcher. + private val targetDirection: Int = direction + private val pointerIds: IntArray + // The starting point of each finger's path in the gesture. + private val base: Array + // The most recent entry in each finger's gesture path. + private val previousGesturePoint: Array + private val targetFingerCount: Int = fingerCount + private var currentFingerCount: Int = 0 + // Whether the appropriate number of fingers have gone down at some point. This is reset only on + // clear. + private var targetFingerCountReached: Boolean = false + private var minPixelsBetweenSamplesX: Float = 0f + private var minPixelsBetweenSamplesY: Float = 0f + // The minimum distance the finger must travel before we evaluate the initial direction of the + // swipe. + // Anything less is still considered a touch. + private var touchSlop: Int = 0 + private var tapTimeout: Int = 0 + // This is used to record the time of onDown/onPointerDown, so that we can check whether the all + // fingersʼ tap comply the TapTimeout spec. + private var lastDownTime: Long = Long.MAX_VALUE + + init { + pointerIds = IntArray(targetFingerCount) + base = arrayOfNulls(targetFingerCount) + previousGesturePoint = arrayOfNulls(targetFingerCount) + strokeBuffers = ArrayList() + for (i in 0 until targetFingerCount) { + strokeBuffers.add(ArrayList()) + } + initializeViewConfigurationParameters(context) + clear() + } + + override fun onConfigurationChanged(context: Context) { + initializeViewConfigurationParameters(context) + } + + private fun initializeViewConfigurationParameters(context: Context) { + val displayMetrics = context.resources.displayMetrics + // Calculate gesture sampling interval. + val pixelsPerCmX = displayMetrics.xdpi / GestureUtils.CM_PER_INCH + val pixelsPerCmY = displayMetrics.ydpi / GestureUtils.CM_PER_INCH + minPixelsBetweenSamplesX = MIN_CM_BETWEEN_SAMPLES * pixelsPerCmX + minPixelsBetweenSamplesY = MIN_CM_BETWEEN_SAMPLES * pixelsPerCmY + tapTimeout = targetFingerCount * ViewConfiguration.getTapTimeout() + touchSlop = + ViewConfiguration.get(context).scaledTouchSlop * + context.resources.getInteger(R.integer.config_slop_default_multiplier) + } + + override fun clear() { + targetFingerCountReached = false + currentFingerCount = 0 + for (i in 0 until targetFingerCount) { + pointerIds[i] = INVALID_POINTER_ID + if (base[i] == null) { + base[i] = PointF() + } + base[i]!!.x = Float.NaN + base[i]!!.y = Float.NaN + if (previousGesturePoint[i] == null) { + previousGesturePoint[i] = PointF() + } + previousGesturePoint[i]!!.x = Float.NaN + previousGesturePoint[i]!!.y = Float.NaN + strokeBuffers[i].clear() + } + lastDownTime = Long.MAX_VALUE + super.clear() + } + + override fun onDown(eventId: EventId?, event: MotionEvent) { + if (currentFingerCount > 0) { + cancelGesture(event) + return + } + currentFingerCount = 1 + val actionIndex = event.actionIndex + val pointerId = event.getPointerId(actionIndex) + val pointerIndex = event.pointerCount - 1 + if (pointerId < 0) { + // Nonsensical pointer id. + cancelGesture(event) + return + } + if (pointerIds[pointerIndex] != INVALID_POINTER_ID) { + // Inconsistent event stream. + cancelGesture(event) + return + } + lastDownTime = event.eventTime + pointerIds[pointerIndex] = pointerId + if (base[pointerIndex]!!.x.isNaN() && base[pointerIndex]!!.y.isNaN()) { + val x = event.getX(actionIndex) + val y = event.getY(actionIndex) + if (x < 0f || y < 0f) { + cancelGesture(event) + return + } + base[pointerIndex]!!.x = x + base[pointerIndex]!!.y = y + previousGesturePoint[pointerIndex]!!.x = x + previousGesturePoint[pointerIndex]!!.y = y + } else { + // This event doesn't make sense in the middle of a gesture. + cancelGesture(event) + return + } + } + + override fun onPointerDown(eventId: EventId?, event: MotionEvent) { + if (event.pointerCount > targetFingerCount) { + cancelGesture(event) + return + } + val timeDelta = event.eventTime - lastDownTime + if (timeDelta > tapTimeout) { + cancelGesture(event) + return + } + lastDownTime = event.eventTime + currentFingerCount += 1 + if (currentFingerCount != event.pointerCount) { + cancelGesture(event) + return + } + if (currentFingerCount == targetFingerCount) { + targetFingerCountReached = true + } + val actionIndex = event.actionIndex + val pointerId = event.getPointerId(actionIndex) + if (pointerId < 0) { + // Nonsensical pointer id. + cancelGesture(event) + return + } + val pointerIndex = currentFingerCount - 1 + if (pointerIds[pointerIndex] != INVALID_POINTER_ID) { + // Inconsistent event stream. + cancelGesture(event) + return + } + pointerIds[pointerIndex] = pointerId + if (base[pointerIndex]!!.x.isNaN() && base[pointerIndex]!!.y.isNaN()) { + val x = event.getX(actionIndex) + val y = event.getY(actionIndex) + if (x < 0f || y < 0f) { + cancelGesture(event) + return + } + base[pointerIndex]!!.x = x + base[pointerIndex]!!.y = y + previousGesturePoint[pointerIndex]!!.x = x + previousGesturePoint[pointerIndex]!!.y = y + } else { + cancelGesture(event) + return + } + } + + override fun onPointerUp(eventId: EventId?, event: MotionEvent) { + if (!targetFingerCountReached) { + cancelGesture(event) + return + } + currentFingerCount -= 1 + val actionIndex = event.actionIndex + val pointerId = event.getPointerId(actionIndex) + if (pointerId < 0) { + // Nonsensical pointer id. + cancelGesture(event) + return + } + val pointerIndex = Arrays.binarySearch(pointerIds, pointerId) + if (pointerIndex < 0) { + cancelGesture(event) + return + } + val x = event.getX(actionIndex) + val y = event.getY(actionIndex) + if (x < 0f || y < 0f) { + cancelGesture(event) + return + } + val dX = abs(x - previousGesturePoint[pointerIndex]!!.x) + val dY = abs(y - previousGesturePoint[pointerIndex]!!.y) + if (dX >= minPixelsBetweenSamplesX || dY >= minPixelsBetweenSamplesY) { + strokeBuffers[pointerIndex].add(PointF(x, y)) + } + // We will evaluate all the paths on ACTION_UP. + } + + override fun onMove(eventId: EventId?, event: MotionEvent) { + for (pointerIndex in 0 until targetFingerCount) { + if (pointerIds[pointerIndex] == INVALID_POINTER_ID) { + // Fingers have started to move before the required number of fingers are down. + // However, they can still move less than the touch slop and still be considered + // touching, not moving. + // So we just ignore fingers that haven't been assigned a pointer id and process + // those who have. + continue + } + gestureMotionEventLog(VERBOSE, getGestureName(), "Processing move on finger %d", pointerIndex) + val index = event.findPointerIndex(pointerIds[pointerIndex]) + if (index < 0) { + // This finger is not present in this event. It could have gone up just before this + // movement. + gestureMotionEventLog(VERBOSE, "Finger %d not found in this event. skipping.", pointerIndex) + continue + } + val x = event.getX(index) + val y = event.getY(index) + if (x < 0f || y < 0f) { + cancelGesture(event) + return + } + val dX = abs(x - previousGesturePoint[pointerIndex]!!.x) + val dY = abs(y - previousGesturePoint[pointerIndex]!!.y) + val moveDelta = + hypot( + abs(x - base[pointerIndex]!!.x).toDouble(), + abs(y - base[pointerIndex]!!.y).toDouble(), + ) + gestureMotionEventLog(VERBOSE, "moveDelta%g", moveDelta) + if (state == STATE_CLEAR) { + if (moveDelta < targetFingerCount * touchSlop) { + // This still counts as a touch not a swipe. + continue + } + // First, make sure we have the right number of fingers down. + if (currentFingerCount != targetFingerCount) { + cancelGesture(event) + return + } + // Then, make sure the pointer is going in the right direction. + val direction = toDirection(x - base[pointerIndex]!!.x, y - base[pointerIndex]!!.y) + if (direction != targetDirection) { + cancelGesture(event) + return + } + // This is confirmed to be some kind of swipe so start tracking points. + startGesture(event) + for (i in 0 until targetFingerCount) { + strokeBuffers[i].add(PointF(base[i]!!.x, base[i]!!.y)) + } + } else if (state == STATE_GESTURE_STARTED) { + // Cancel if the finger starts to go the wrong way. + // Note that this only works because this matcher assumes one direction. + val direction = toDirection(x - base[pointerIndex]!!.x, y - base[pointerIndex]!!.y) + if (direction != UNCERTAIN && direction != targetDirection) { + cancelGesture(event) + return + } + if (dX >= minPixelsBetweenSamplesX || dY >= minPixelsBetweenSamplesY) { + // Sample every 2.5 MM in order to guard against minor variations in path. + previousGesturePoint[pointerIndex]!!.x = x + previousGesturePoint[pointerIndex]!!.y = y + strokeBuffers[pointerIndex].add(PointF(x, y)) + } + } + } + } + + override fun onUp(eventId: EventId?, event: MotionEvent) { + when (state) { + STATE_GESTURE_STARTED -> {} + STATE_CLEAR -> { + // For Swipe gestures, this is the very last motion event. When any of the swipe gesture + // detectors matches, the others will enter the clear state. We should not Cancel the + // detector again for the Up event, or it cannot detect new gesture immediately. + return + } + else -> { + cancelGesture(event) + return + } + } + currentFingerCount = 0 + val actionIndex = event.actionIndex + val pointerId = event.getPointerId(actionIndex) + val pointerIndex = Arrays.binarySearch(pointerIds, pointerId) + if (pointerIndex < 0) { + cancelGesture(event) + return + } + val x = event.getX(actionIndex) + val y = event.getY(actionIndex) + if (x < 0f || y < 0f) { + cancelGesture(event) + return + } + val dX = abs(x - previousGesturePoint[pointerIndex]!!.x) + val dY = abs(y - previousGesturePoint[pointerIndex]!!.y) + if (dX >= minPixelsBetweenSamplesX || dY >= minPixelsBetweenSamplesY) { + strokeBuffers[pointerIndex].add(PointF(x, y)) + } + recognizeGesture(eventId, event) + } + + /** + * Looks at the sequence of motions in mStrokeBuffer, classifies the gesture, then transitions to + * the complete or cancel state depending on the result. + */ + private fun recognizeGesture(eventId: EventId?, event: MotionEvent) { + // Check the path of each finger against the specified direction. + // Note that we sample every 2.5 MMm, and the direction matching is extremely tolerant (each + // direction has a 90-degree arch of tolerance) meaning that minor perpendicular movements + // should not create false negatives. + for (i in 0 until targetFingerCount) { + gestureMotionEventLog(VERBOSE, "Recognizing finger: %d", i) + if (strokeBuffers[i].size < 2) { + Log.d(getGestureName(), "Too few points.") + cancelGesture(event) + return + } + val path: List = strokeBuffers[i] + + gestureMotionEventLog(VERBOSE, "path= %s", path.toString()) + // Classify line segments, and call Listener callbacks. + if (!recognizeGesturePath(path)) { + cancelGesture(event) + return + } + } + // If we reach this point then all paths match. + completeGesture(eventId, event) + } + + /** + * Tests the path of a given finger against the direction specified in this matcher. + * + * @return True if the path matches the specified direction for this matcher, otherwise false. + */ + private fun recognizeGesturePath(path: List): Boolean { + for (i in 0 until path.size - 1) { + val start = path[i] + val end = path[i + 1] + + val dX = end.x - start.x + val dY = end.y - start.y + val direction = toDirection(dX, dY) + if (direction != targetDirection) { + gestureMotionEventLog( + VERBOSE, + "Found direction %s when expecting %s", + directionToString(direction), + directionToString(this.targetDirection), + ) + return false + } + } + gestureMotionEventLog(VERBOSE, "Completed.") + return true + } + + override fun getGestureName(): String { + val builder = StringBuilder() + builder.append(targetFingerCount).append("-finger ") + builder.append("Swipe ").append(directionToString(targetDirection)) + return builder.toString() + } + + override fun toString(): String { + val builder = StringBuilder(super.toString()) + if (state != STATE_GESTURE_CANCELED) { + builder + .append(", mBase: ") + .append(Arrays.toString(base)) + .append(", mMinPixelsBetweenSamplesX:") + .append(minPixelsBetweenSamplesX) + .append(", mMinPixelsBetweenSamplesY:") + .append(minPixelsBetweenSamplesY) + } + return builder.toString() + } + + companion object { + // Direction constants. + const val LEFT = 0 + const val RIGHT = 1 + const val UP = 2 + const val DOWN = 3 + const val UNCERTAIN = 4 + + // Constants for sampling motion event points. + // We sample based on a minimum distance between points, primarily to improve accuracy by + // reducing noisy minor changes in direction. + private const val MIN_CM_BETWEEN_SAMPLES = 0.25f + + private fun toDirection(dX: Float, dY: Float): Int { + if (dX == 0f && dY == 0f) { + return UNCERTAIN + } + return if (abs(dX) > abs(dY)) { + // Horizontal + if (dX < 0) LEFT else RIGHT + } else { + // Vertical + if (dY < 0) UP else DOWN + } + } + + @JvmStatic + fun directionToString(direction: Int): String = + when (direction) { + LEFT -> "left" + RIGHT -> "right" + UP -> "up" + DOWN -> "down" + UNCERTAIN -> "still" + else -> "Unknown Direction" + } + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiTap.java b/utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiTap.java deleted file mode 100644 index 077c56d1b..000000000 --- a/utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiTap.java +++ /dev/null @@ -1,256 +0,0 @@ -/* - * Copyright (C) 2019 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.android.accessibility.utils.gestures; - -import static com.google.android.accessibility.utils.gestures.GestureAnalyticsEvent.EVENT_DOUBLE_TAP_SLOP_OVER_RANGE; - -import android.content.Context; -import android.os.Build; -import android.os.Handler; -import android.view.MotionEvent; -import android.view.ViewConfiguration; -import androidx.annotation.RequiresApi; -import com.google.android.accessibility.utils.Performance.EventId; -import com.google.android.accessibility.utils.R; -import com.google.android.accessibility.utils.gestures.GestureManifold.GestureConfigProvider; -import com.google.android.libraries.accessibility.utils.log.LogUtils; -import com.google.errorprone.annotations.CanIgnoreReturnValue; - -/** - * This class matches multi-tap gestures. The number of taps for each instance is specified in the - * constructor. - * - * @hide - */ -@RequiresApi(Build.VERSION_CODES.TIRAMISU) -public class MultiTap extends GestureMatcher { - private static final String TAG = "MultiTap"; - final int targetTaps; - private final GestureConfigProvider configProvider; - // The acceptable distance between two taps - int doubleTapSlop; - // The acceptable distance the pointer can move and still count as a tap. - int touchSlop; - int tapTimeout; - int doubleTapTimeout; - int currentTaps; - float baseX; - float baseY; - long lastDownTime; - long lastUpTime; - - public MultiTap( - Context context, - int taps, - int gesture, - GestureMatcher.StateChangeListener listener, - GestureConfigProvider configProvider, - GestureMatcher.AnalyticsEventLogger logger) { - super(gesture, new Handler(context.getMainLooper()), listener, logger); - this.configProvider = configProvider; - targetTaps = taps; - initializeViewConfigurationParameters(context); - clear(); - } - - @Override - public void onConfigurationChanged(Context context) { - initializeViewConfigurationParameters(context); - } - - private void initializeViewConfigurationParameters(Context context) { - doubleTapSlop = - (int) - (ViewConfiguration.get(context).getScaledDoubleTapSlop() - * configProvider.getDoubleTapSlopMultiplier()); - LogUtils.v(TAG, "Double-Tap slop is: %d", doubleTapSlop); - touchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); - int deltaTapTimeout = context.getResources().getInteger(R.integer.config_tap_timeout_delta); - tapTimeout = ViewConfiguration.getTapTimeout() + deltaTapTimeout; - doubleTapTimeout = GestureConfiguration.DOUBLE_TAP_TIMEOUT_MS; - } - - @Override - public void clear() { - currentTaps = 0; - baseX = Float.NaN; - baseY = Float.NaN; - lastDownTime = Long.MAX_VALUE; - lastUpTime = Long.MAX_VALUE; - super.clear(); - } - - @Override - protected void onDown(EventId eventId, MotionEvent event) { - long time = event.getEventTime(); - long timeDelta = time - lastUpTime; - if (timeDelta > doubleTapTimeout) { - debugMotionEvent(TAG, "onDown/doubleTapTimeout's over. Gesture:%d", getGestureId()); - cancelGesture(event); - return; - } - lastDownTime = time; - if (Float.isNaN(baseX) && Float.isNaN(baseY)) { - baseX = event.getX(); - baseY = event.getY(); - } - if (!isInsideSlop(event, doubleTapSlop, /* isTouchSlop= */ false)) { - debugMotionEvent(TAG, "onDown/doubleTapSlop's over. Gesture:%d", getGestureId()); - cancelGesture(event); - return; - } - baseX = event.getX(); - baseY = event.getY(); - if (currentTaps + 1 == targetTaps) { - // Start gesture detecting on down of final tap. - // Note that if this instance is matching double tap, - // and the service is not requesting to handle double tap, GestureManifold will - // ignore this. - startGesture(event); - } - } - - @Override - protected void onUp(EventId eventId, MotionEvent event) { - if (!isValidUpEvent(event)) { - debugMotionEvent(TAG, "onUp/!isValidUpEvent. Gesture:%d", getGestureId()); - cancelGesture(event); - return; - } - if (getState() == STATE_GESTURE_STARTED || getState() == STATE_CLEAR) { - currentTaps++; - if (currentTaps == targetTaps) { - // Done. - completeGesture(eventId, event); - } else { - processGesture(eventId, event); - } - // Needs more taps. - } else { - // Either too many taps or nonsensical event stream. - cancelGesture(event); - debugMotionEvent(TAG, "onUp/Too many taps. Gesture:%d", getGestureId()); - } - } - - @Override - protected void onMove(EventId eventId, MotionEvent event) { - if (!isInsideSlop(event, touchSlop, /* isTouchSlop= */ true)) { - cancelGesture(event); - debugMotionEvent(TAG, "onMove/!isInsideSlop. Gesture:%d", getGestureId()); - } - } - - @Override - protected void onPointerDown(EventId eventId, MotionEvent event) { - cancelGesture(event); - debugMotionEvent(TAG, "onPointerDown. Gesture:%d", getGestureId()); - } - - @Override - protected void onPointerUp(EventId eventId, MotionEvent event) { - cancelGesture(event); - debugMotionEvent(TAG, "onPointerUp. Gesture:%d", getGestureId()); - } - - @Override - public String getGestureName() { - return switch (targetTaps) { - case 2 -> "Double Tap"; - case 3 -> "Triple Tap"; - default -> Integer.toString(targetTaps) + " Taps"; - }; - } - - /** - * This class helps to collect data (double-tap slop over), in addition to the fundamental Gesture - * analytic event. - */ - public static class MultiTapAnalyticsEvent extends GestureAnalyticsEvent { - public int doubleTapSlopOverRange; - - MultiTapAnalyticsEvent(int event, int gestureId) { - super(event, gestureId); - } - - @CanIgnoreReturnValue - MultiTapAnalyticsEvent setDoubleTapSlopOverRange(int doubleTapSlopOverRange) { - this.doubleTapSlopOverRange = doubleTapSlopOverRange; - return this; - } - } - - private void logExceededSlop(double deviation) { - int extraData; - - if (deviation < 0.1) { - extraData = MultiTapAnalyticsEvent.EXTRA_DEBUG_DOUBLE_TAP_SLOP_OVER_IN_10_PERCENT; - } else if (deviation < 0.2) { - extraData = MultiTapAnalyticsEvent.EXTRA_DEBUG_DOUBLE_TAP_SLOP_OVER_IN_20_PERCENT; - } else if (deviation < 0.5) { - extraData = MultiTapAnalyticsEvent.EXTRA_DEBUG_DOUBLE_TAP_SLOP_OVER_IN_50_PERCENT; - } else if (deviation < 1.0) { - extraData = MultiTapAnalyticsEvent.EXTRA_DEBUG_DOUBLE_TAP_SLOP_OVER_IN_100_PERCENT; - } else { - extraData = MultiTapAnalyticsEvent.EXTRA_DEBUG_DOUBLE_TAP_SLOP_OVER_MORE_THAN_100_PERCENT; - } - debugMotionEvent(TAG, "logExceededSlop. Gesture:%d, range:%d", getGestureId(), extraData); - MultiTapAnalyticsEvent event = - new MultiTapAnalyticsEvent(EVENT_DOUBLE_TAP_SLOP_OVER_RANGE, getGestureId()) - .setDoubleTapSlopOverRange(extraData); - analyticsEvent(event); - } - - private boolean isInsideSlop(MotionEvent event, int slop, boolean isTouchSlop) { - final float deltaX = baseX - event.getX(); - final float deltaY = baseY - event.getY(); - if (deltaX == 0 && deltaY == 0) { - return true; - } - final double moveDelta = Math.hypot(deltaX, deltaY); - if (!isTouchSlop && moveDelta > slop) { - logExceededSlop((moveDelta - slop) / slop); - } - return moveDelta <= slop; - } - - protected boolean isValidUpEvent(MotionEvent upEvent) { - long time = upEvent.getEventTime(); - long timeDelta = time - lastDownTime; - if (timeDelta > tapTimeout) { - debugMotionEvent(TAG, "isValidUpEvent/tapTimeout's over. Gesture:%d", getGestureId()); - return false; - } - lastUpTime = time; - if (!isInsideSlop(upEvent, touchSlop, /* isTouchSlop= */ true)) { - debugMotionEvent(TAG, "isValidUpEvent/!isInsideSlop. Gesture:%d", getGestureId()); - return false; - } - return true; - } - - @Override - public String toString() { - return super.toString() - + ", Taps:" - + currentTaps - + ", mBaseX: " - + Float.toString(baseX) - + ", mBaseY: " - + Float.toString(baseY); - } -} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiTap.kt b/utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiTap.kt new file mode 100644 index 000000000..ee0845c3c --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiTap.kt @@ -0,0 +1,239 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Ported from Java to Kotlin. + */ + +package com.google.android.accessibility.utils.gestures + +import android.content.Context +import android.os.Build +import android.os.Handler +import android.view.MotionEvent +import android.view.ViewConfiguration +import androidx.annotation.RequiresApi +import com.google.android.accessibility.utils.Performance.EventId +import com.google.android.accessibility.utils.R +import com.google.android.accessibility.utils.gestures.GestureManifold.GestureConfigProvider +import com.google.android.libraries.accessibility.utils.log.LogUtils +import com.google.errorprone.annotations.CanIgnoreReturnValue +import kotlin.math.hypot + +/** + * This class matches multi-tap gestures. The number of taps for each instance is specified in the + * constructor. + * + * @hide + */ +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +open class MultiTap( + context: Context, + taps: Int, + gesture: Int, + listener: GestureMatcher.StateChangeListener, + private val configProvider: GestureConfigProvider, + logger: GestureMatcher.AnalyticsEventLogger, +) : GestureMatcher(gesture, Handler(context.mainLooper), listener, logger) { + + @JvmField internal val targetTaps: Int = taps + + // The acceptable distance between two taps + @JvmField internal var doubleTapSlop: Int = 0 + + // The acceptable distance the pointer can move and still count as a tap. + @JvmField internal var touchSlop: Int = 0 + @JvmField internal var tapTimeout: Int = 0 + @JvmField internal var doubleTapTimeout: Int = 0 + @JvmField internal var currentTaps: Int = 0 + @JvmField internal var baseX: Float = Float.NaN + @JvmField internal var baseY: Float = Float.NaN + @JvmField internal var lastDownTime: Long = Long.MAX_VALUE + @JvmField internal var lastUpTime: Long = Long.MAX_VALUE + + init { + initializeViewConfigurationParameters(context) + clear() + } + + override fun onConfigurationChanged(context: Context) { + initializeViewConfigurationParameters(context) + } + + private fun initializeViewConfigurationParameters(context: Context) { + doubleTapSlop = + (ViewConfiguration.get(context).scaledDoubleTapSlop * + configProvider.getDoubleTapSlopMultiplier()) + .toInt() + LogUtils.v(TAG, "Double-Tap slop is: %d", doubleTapSlop) + touchSlop = ViewConfiguration.get(context).scaledTouchSlop + val deltaTapTimeout = context.resources.getInteger(R.integer.config_tap_timeout_delta) + tapTimeout = ViewConfiguration.getTapTimeout() + deltaTapTimeout + doubleTapTimeout = GestureConfiguration.DOUBLE_TAP_TIMEOUT_MS + } + + override fun clear() { + currentTaps = 0 + baseX = Float.NaN + baseY = Float.NaN + lastDownTime = Long.MAX_VALUE + lastUpTime = Long.MAX_VALUE + super.clear() + } + + override fun onDown(eventId: EventId?, event: MotionEvent) { + val time = event.eventTime + val timeDelta = time - lastUpTime + if (timeDelta > doubleTapTimeout) { + debugMotionEvent(TAG, "onDown/doubleTapTimeout's over. Gesture:%d", gestureId) + cancelGesture(event) + return + } + lastDownTime = time + if (baseX.isNaN() && baseY.isNaN()) { + baseX = event.x + baseY = event.y + } + if (!isInsideSlop(event, doubleTapSlop, /* isTouchSlop= */ false)) { + debugMotionEvent(TAG, "onDown/doubleTapSlop's over. Gesture:%d", gestureId) + cancelGesture(event) + return + } + baseX = event.x + baseY = event.y + if (currentTaps + 1 == targetTaps) { + // Start gesture detecting on down of final tap. + // Note that if this instance is matching double tap, + // and the service is not requesting to handle double tap, GestureManifold will + // ignore this. + startGesture(event) + } + } + + override fun onUp(eventId: EventId?, event: MotionEvent) { + if (!isValidUpEvent(event)) { + debugMotionEvent(TAG, "onUp/!isValidUpEvent. Gesture:%d", gestureId) + cancelGesture(event) + return + } + if (state == STATE_GESTURE_STARTED || state == STATE_CLEAR) { + currentTaps++ + if (currentTaps == targetTaps) { + // Done. + completeGesture(eventId, event) + } else { + processGesture(eventId, event) + } + // Needs more taps. + } else { + // Either too many taps or nonsensical event stream. + cancelGesture(event) + debugMotionEvent(TAG, "onUp/Too many taps. Gesture:%d", gestureId) + } + } + + override fun onMove(eventId: EventId?, event: MotionEvent) { + if (!isInsideSlop(event, touchSlop, /* isTouchSlop= */ true)) { + cancelGesture(event) + debugMotionEvent(TAG, "onMove/!isInsideSlop. Gesture:%d", gestureId) + } + } + + override fun onPointerDown(eventId: EventId?, event: MotionEvent) { + cancelGesture(event) + debugMotionEvent(TAG, "onPointerDown. Gesture:%d", gestureId) + } + + override fun onPointerUp(eventId: EventId?, event: MotionEvent) { + cancelGesture(event) + debugMotionEvent(TAG, "onPointerUp. Gesture:%d", gestureId) + } + + override fun getGestureName(): String = + when (targetTaps) { + 2 -> "Double Tap" + 3 -> "Triple Tap" + else -> "$targetTaps Taps" + } + + /** + * This class helps to collect data (double-tap slop over), in addition to the fundamental Gesture + * analytic event. + */ + class MultiTapAnalyticsEvent internal constructor(event: Int, gestureId: Int) : + GestureAnalyticsEvent(event, gestureId) { + @JvmField var doubleTapSlopOverRange: Int = 0 + + @CanIgnoreReturnValue + internal fun setDoubleTapSlopOverRange(doubleTapSlopOverRange: Int): MultiTapAnalyticsEvent { + this.doubleTapSlopOverRange = doubleTapSlopOverRange + return this + } + } + + private fun logExceededSlop(deviation: Double) { + val extraData: Int = + if (deviation < 0.1) { + GestureAnalyticsEvent.EXTRA_DEBUG_DOUBLE_TAP_SLOP_OVER_IN_10_PERCENT + } else if (deviation < 0.2) { + GestureAnalyticsEvent.EXTRA_DEBUG_DOUBLE_TAP_SLOP_OVER_IN_20_PERCENT + } else if (deviation < 0.5) { + GestureAnalyticsEvent.EXTRA_DEBUG_DOUBLE_TAP_SLOP_OVER_IN_50_PERCENT + } else if (deviation < 1.0) { + GestureAnalyticsEvent.EXTRA_DEBUG_DOUBLE_TAP_SLOP_OVER_IN_100_PERCENT + } else { + GestureAnalyticsEvent.EXTRA_DEBUG_DOUBLE_TAP_SLOP_OVER_MORE_THAN_100_PERCENT + } + debugMotionEvent(TAG, "logExceededSlop. Gesture:%d, range:%d", gestureId, extraData) + val event = + MultiTapAnalyticsEvent(GestureAnalyticsEvent.EVENT_DOUBLE_TAP_SLOP_OVER_RANGE, gestureId) + .setDoubleTapSlopOverRange(extraData) + analyticsEvent(event) + } + + private fun isInsideSlop(event: MotionEvent, slop: Int, isTouchSlop: Boolean): Boolean { + val deltaX = baseX - event.x + val deltaY = baseY - event.y + if (deltaX == 0f && deltaY == 0f) { + return true + } + val moveDelta = hypot(deltaX.toDouble(), deltaY.toDouble()) + if (!isTouchSlop && moveDelta > slop) { + logExceededSlop((moveDelta - slop) / slop) + } + return moveDelta <= slop + } + + protected fun isValidUpEvent(upEvent: MotionEvent): Boolean { + val time = upEvent.eventTime + val timeDelta = time - lastDownTime + if (timeDelta > tapTimeout) { + debugMotionEvent(TAG, "isValidUpEvent/tapTimeout's over. Gesture:%d", gestureId) + return false + } + lastUpTime = time + if (!isInsideSlop(upEvent, touchSlop, /* isTouchSlop= */ true)) { + debugMotionEvent(TAG, "isValidUpEvent/!isInsideSlop. Gesture:%d", gestureId) + return false + } + return true + } + + override fun toString(): String = + super.toString() + ", Taps:" + currentTaps + ", mBaseX: " + baseX + ", mBaseY: " + baseY + + private companion object { + const val TAG = "MultiTap" + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiTapAndHold.java b/utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiTapAndHold.java deleted file mode 100644 index 5e96dabbf..000000000 --- a/utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiTapAndHold.java +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Copyright (C) 2019 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.android.accessibility.utils.gestures; - -import android.content.Context; -import android.os.Build; -import android.view.MotionEvent; -import androidx.annotation.RequiresApi; -import com.google.android.accessibility.utils.Performance.EventId; -import com.google.android.accessibility.utils.gestures.GestureManifold.GestureConfigProvider; - -/** - * This class matches gestures of the form multi-tap and hold. The number of taps for each instance - * is specified in the constructor. - * - * @hide - */ -@RequiresApi(Build.VERSION_CODES.TIRAMISU) -public class MultiTapAndHold extends MultiTap { - private static final String TAG = "MultiTapAndHold"; - - public MultiTapAndHold( - Context context, - int taps, - int gesture, - GestureMatcher.StateChangeListener listener, - GestureConfigProvider configProvider, - GestureMatcher.AnalyticsEventLogger logger) { - super(context, taps, gesture, listener, configProvider, logger); - } - - @Override - protected void onDown(EventId eventId, MotionEvent event) { - super.onDown(eventId, event); - if (currentTaps + 1 == targetTaps && getState() != STATE_GESTURE_CANCELED) { - // We should check the detector state in advance because it may enter Cancel state in base - // class (MultiTap). - completeAfterLongPressTimeout(eventId, event); - } - } - - @Override - protected void onUp(EventId eventId, MotionEvent event) { - if (!isValidUpEvent(event)) { - debugMotionEvent(TAG, "onUp/!isValidUpEvent. Gesture:%d", getGestureId()); - cancelGesture(event); - return; - } - if (getState() == STATE_GESTURE_STARTED || getState() == STATE_CLEAR) { - currentTaps++; - if (currentTaps == targetTaps) { - debugMotionEvent(TAG, "onUp/Not a HOLD gesture. Gesture:%d", getGestureId()); - cancelGesture(event); - return; - } - // Needs more taps. - } else { - debugMotionEvent(TAG, "onUp/State mismatch. Gesture:%d", getGestureId()); - // Either too many taps or nonsensical event stream. - cancelGesture(event); - return; - } - cancelAfterDoubleTapTimeout(event); - } - - @Override - public String getGestureName() { - return switch (targetTaps) { - case 2 -> "Double Tap and Hold"; - case 3 -> "Triple Tap and Hold"; - default -> Integer.toString(targetTaps) + " Taps and Hold"; - }; - } -} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiTapAndHold.kt b/utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiTapAndHold.kt new file mode 100644 index 000000000..4ca130e2b --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/gestures/MultiTapAndHold.kt @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Ported from Java to Kotlin. + */ + +package com.google.android.accessibility.utils.gestures + +import android.content.Context +import android.os.Build +import android.view.MotionEvent +import androidx.annotation.RequiresApi +import com.google.android.accessibility.utils.Performance.EventId +import com.google.android.accessibility.utils.gestures.GestureManifold.GestureConfigProvider + +/** + * This class matches gestures of the form multi-tap and hold. The number of taps for each instance + * is specified in the constructor. + * + * @hide + */ +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +open class MultiTapAndHold( + context: Context, + taps: Int, + gesture: Int, + listener: GestureMatcher.StateChangeListener, + configProvider: GestureConfigProvider, + logger: GestureMatcher.AnalyticsEventLogger, +) : MultiTap(context, taps, gesture, listener, configProvider, logger) { + + override fun onDown(eventId: EventId?, event: MotionEvent) { + super.onDown(eventId, event) + if (currentTaps + 1 == targetTaps && state != STATE_GESTURE_CANCELED) { + // We should check the detector state in advance because it may enter Cancel state in base + // class (MultiTap). + completeAfterLongPressTimeout(eventId, event) + } + } + + override fun onUp(eventId: EventId?, event: MotionEvent) { + if (!isValidUpEvent(event)) { + debugMotionEvent(TAG, "onUp/!isValidUpEvent. Gesture:%d", gestureId) + cancelGesture(event) + return + } + if (state == STATE_GESTURE_STARTED || state == STATE_CLEAR) { + currentTaps++ + if (currentTaps == targetTaps) { + debugMotionEvent(TAG, "onUp/Not a HOLD gesture. Gesture:%d", gestureId) + cancelGesture(event) + return + } + // Needs more taps. + } else { + debugMotionEvent(TAG, "onUp/State mismatch. Gesture:%d", gestureId) + // Either too many taps or nonsensical event stream. + cancelGesture(event) + return + } + cancelAfterDoubleTapTimeout(event) + } + + override fun getGestureName(): String = + when (targetTaps) { + 2 -> "Double Tap and Hold" + 3 -> "Triple Tap and Hold" + else -> "$targetTaps Taps and Hold" + } + + private companion object { + const val TAG = "MultiTapAndHold" + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/gestures/SecondFingerMultiTap.java b/utils/src/main/java/com/google/android/accessibility/utils/gestures/SecondFingerMultiTap.java deleted file mode 100644 index 11f3c0aee..000000000 --- a/utils/src/main/java/com/google/android/accessibility/utils/gestures/SecondFingerMultiTap.java +++ /dev/null @@ -1,188 +0,0 @@ -/* - * Copyright (C) 2019 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.android.accessibility.utils.gestures; - -import static android.util.Log.ERROR; -import static android.util.Log.VERBOSE; -import static android.view.MotionEvent.INVALID_POINTER_ID; - -import android.content.Context; -import android.os.Handler; -import android.view.MotionEvent; -import android.view.ViewConfiguration; -import com.google.android.accessibility.utils.Performance.EventId; - -/** - * This class matches second-finger multi-tap gestures. A second-finger multi-tap gesture is where - * one finger is held down and a second finger executes the taps. The number of taps for each - * instance is specified in the constructor. - */ -class SecondFingerMultiTap extends GestureMatcher { - private final int targetTaps; - private int doubleTapSlop; - private int touchSlop; - private int tapTimeout; - private int doubleTapTimeout; - private int currentTaps; - private int secondFingerPointerId; - float baseX; - float baseY; - long lastDownTime; - long lastUpTime; - - SecondFingerMultiTap( - Context context, - int taps, - int gesture, - GestureMatcher.StateChangeListener listener, - GestureMatcher.AnalyticsEventLogger logger) { - super(gesture, new Handler(context.getMainLooper()), listener, logger); - targetTaps = taps; - doubleTapSlop = ViewConfiguration.get(context).getScaledDoubleTapSlop(); - - touchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); - tapTimeout = ViewConfiguration.getTapTimeout(); - doubleTapTimeout = GestureConfiguration.DOUBLE_TAP_TIMEOUT_MS; - clear(); - } - - @Override - public void clear() { - currentTaps = 0; - baseX = Float.NaN; - baseY = Float.NaN; - secondFingerPointerId = INVALID_POINTER_ID; - lastDownTime = Long.MAX_VALUE; - lastUpTime = Long.MAX_VALUE; - super.clear(); - } - - @Override - protected void onPointerDown(EventId eventId, MotionEvent event) { - if (event.getPointerCount() > 2) { - cancelGesture(event); - return; - } - // Second finger has gone down. - int index = event.getActionIndex(); - secondFingerPointerId = event.getPointerId(index); - long time = event.getEventTime(); - long timeDelta = time - lastUpTime; - if (timeDelta > doubleTapTimeout) { - cancelGesture(event); - return; - } - lastDownTime = time; - if (Float.isNaN(baseX) && Float.isNaN(baseY)) { - baseX = event.getX(index); - baseY = event.getY(index); - } - if (!isSecondFingerInsideSlop(event, doubleTapSlop)) { - cancelGesture(event); - } - baseX = event.getX(index); - baseY = event.getY(index); - } - - @Override - protected void onPointerUp(EventId eventId, MotionEvent event) { - if (event.getPointerCount() > 2) { - cancelGesture(event); - return; - } - long time = event.getEventTime(); - long timeDelta = time - lastDownTime; - if (timeDelta > tapTimeout) { - cancelGesture(event); - return; - } - lastUpTime = time; - if (!isSecondFingerInsideSlop(event, touchSlop)) { - cancelGesture(event); - } - if (getState() == STATE_GESTURE_STARTED || getState() == STATE_CLEAR) { - currentTaps++; - if (currentTaps == targetTaps) { - // Done. - completeGesture(eventId, event); - return; - } - } else { - // Nonsensical event stream. - cancelGesture(event); - } - } - - @Override - protected void onMove(EventId eventId, MotionEvent event) { - switch (event.getPointerCount()) { - case 1 -> { - // We don't need to track anything about one-finger movements. - } - case 2 -> { - if (!isSecondFingerInsideSlop(event, touchSlop)) { - cancelGesture(event); - } - } - default -> - // More than two fingers means we stop tracking. - cancelGesture(event); - } - } - - @Override - protected void onUp(EventId eventId, MotionEvent event) { - // Cancel early when possible, or it will take precedence over two-finger double tap. - cancelGesture(event); - } - - @Override - public String getGestureName() { - return switch (targetTaps) { - case 2 -> "Second Finger Double Tap"; - case 3 -> "Second Finger Triple Tap"; - default -> "Second Finger " + Integer.toString(targetTaps) + " Taps"; - }; - } - - private boolean isSecondFingerInsideSlop(MotionEvent event, int slop) { - int pointerIndex = event.findPointerIndex(secondFingerPointerId); - if (pointerIndex == -1) { - gestureMotionEventLog(ERROR, "Unable to find pointer."); - return false; - } - final float deltaX = baseX - event.getX(pointerIndex); - final float deltaY = baseY - event.getY(pointerIndex); - if (deltaX == 0 && deltaY == 0) { - return true; - } - final double moveDelta = Math.hypot(deltaX, deltaY); - gestureMotionEventLog(VERBOSE, "moveDelta: %g", moveDelta); - return moveDelta <= slop; - } - - @Override - public String toString() { - return super.toString() - + ", Taps:" - + currentTaps - + ", mBaseX: " - + Float.toString(baseX) - + ", mBaseY: " - + Float.toString(baseY); - } -} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/gestures/SecondFingerMultiTap.kt b/utils/src/main/java/com/google/android/accessibility/utils/gestures/SecondFingerMultiTap.kt new file mode 100644 index 000000000..fee3c50bf --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/gestures/SecondFingerMultiTap.kt @@ -0,0 +1,169 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Ported from Java to Kotlin. + */ + +package com.google.android.accessibility.utils.gestures + +import android.content.Context +import android.os.Handler +import android.util.Log.ERROR +import android.util.Log.VERBOSE +import android.view.MotionEvent +import android.view.MotionEvent.INVALID_POINTER_ID +import android.view.ViewConfiguration +import com.google.android.accessibility.utils.Performance.EventId +import kotlin.math.hypot + +/** + * This class matches second-finger multi-tap gestures. A second-finger multi-tap gesture is where + * one finger is held down and a second finger executes the taps. The number of taps for each + * instance is specified in the constructor. + */ +internal class SecondFingerMultiTap( + context: Context, + taps: Int, + gesture: Int, + listener: GestureMatcher.StateChangeListener, + logger: GestureMatcher.AnalyticsEventLogger, +) : GestureMatcher(gesture, Handler(context.mainLooper), listener, logger) { + private val targetTaps: Int = taps + private val doubleTapSlop: Int = ViewConfiguration.get(context).scaledDoubleTapSlop + private val touchSlop: Int = ViewConfiguration.get(context).scaledTouchSlop + private val tapTimeout: Int = ViewConfiguration.getTapTimeout() + private val doubleTapTimeout: Int = GestureConfiguration.DOUBLE_TAP_TIMEOUT_MS + private var currentTaps: Int = 0 + private var secondFingerPointerId: Int = INVALID_POINTER_ID + @JvmField internal var baseX: Float = Float.NaN + @JvmField internal var baseY: Float = Float.NaN + @JvmField internal var lastDownTime: Long = Long.MAX_VALUE + @JvmField internal var lastUpTime: Long = Long.MAX_VALUE + + init { + clear() + } + + override fun clear() { + currentTaps = 0 + baseX = Float.NaN + baseY = Float.NaN + secondFingerPointerId = INVALID_POINTER_ID + lastDownTime = Long.MAX_VALUE + lastUpTime = Long.MAX_VALUE + super.clear() + } + + override fun onPointerDown(eventId: EventId?, event: MotionEvent) { + if (event.pointerCount > 2) { + cancelGesture(event) + return + } + // Second finger has gone down. + val index = event.actionIndex + secondFingerPointerId = event.getPointerId(index) + val time = event.eventTime + val timeDelta = time - lastUpTime + if (timeDelta > doubleTapTimeout) { + cancelGesture(event) + return + } + lastDownTime = time + if (baseX.isNaN() && baseY.isNaN()) { + baseX = event.getX(index) + baseY = event.getY(index) + } + if (!isSecondFingerInsideSlop(event, doubleTapSlop)) { + cancelGesture(event) + } + baseX = event.getX(index) + baseY = event.getY(index) + } + + override fun onPointerUp(eventId: EventId?, event: MotionEvent) { + if (event.pointerCount > 2) { + cancelGesture(event) + return + } + val time = event.eventTime + val timeDelta = time - lastDownTime + if (timeDelta > tapTimeout) { + cancelGesture(event) + return + } + lastUpTime = time + if (!isSecondFingerInsideSlop(event, touchSlop)) { + cancelGesture(event) + } + if (state == STATE_GESTURE_STARTED || state == STATE_CLEAR) { + currentTaps++ + if (currentTaps == targetTaps) { + // Done. + completeGesture(eventId, event) + return + } + } else { + // Nonsensical event stream. + cancelGesture(event) + } + } + + override fun onMove(eventId: EventId?, event: MotionEvent) { + when (event.pointerCount) { + 1 -> { + // We don't need to track anything about one-finger movements. + } + 2 -> { + if (!isSecondFingerInsideSlop(event, touchSlop)) { + cancelGesture(event) + } + } + else -> + // More than two fingers means we stop tracking. + cancelGesture(event) + } + } + + override fun onUp(eventId: EventId?, event: MotionEvent) { + // Cancel early when possible, or it will take precedence over two-finger double tap. + cancelGesture(event) + } + + override fun getGestureName(): String = + when (targetTaps) { + 2 -> "Second Finger Double Tap" + 3 -> "Second Finger Triple Tap" + else -> "Second Finger $targetTaps Taps" + } + + private fun isSecondFingerInsideSlop(event: MotionEvent, slop: Int): Boolean { + val pointerIndex = event.findPointerIndex(secondFingerPointerId) + if (pointerIndex == -1) { + gestureMotionEventLog(ERROR, "Unable to find pointer.") + return false + } + val deltaX = baseX - event.getX(pointerIndex) + val deltaY = baseY - event.getY(pointerIndex) + if (deltaX == 0f && deltaY == 0f) { + return true + } + val moveDelta = hypot(deltaX.toDouble(), deltaY.toDouble()) + gestureMotionEventLog(VERBOSE, "moveDelta: %g", moveDelta) + return moveDelta <= slop + } + + override fun toString(): String = + super.toString() + ", Taps:" + currentTaps + ", mBaseX: " + baseX + ", mBaseY: " + baseY +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/gestures/SecondFingerTap.java b/utils/src/main/java/com/google/android/accessibility/utils/gestures/SecondFingerTap.java deleted file mode 100644 index d51a33d58..000000000 --- a/utils/src/main/java/com/google/android/accessibility/utils/gestures/SecondFingerTap.java +++ /dev/null @@ -1,193 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.android.accessibility.utils.gestures; - -import static android.util.Log.VERBOSE; - -import android.content.Context; -import android.graphics.PointF; -import android.os.Build; -import android.os.Handler; -import android.view.MotionEvent; -import android.view.ViewConfiguration; -import androidx.annotation.RequiresApi; -import com.google.android.accessibility.utils.Performance.EventId; - -/** - * This class matches second-finger multi-tap gestures. A second-finger multi-tap gesture is where - * one finger is held down and a second finger executes the taps. The number of taps for each - * instance is specified in the constructor. - */ -@RequiresApi(Build.VERSION_CODES.TIRAMISU) -class SecondFingerTap extends GestureMatcher { - private static final int TARGET_FINGER_COUNT = 2; - protected final int targetTaps; - private final int doubleTapTimeout; - protected int currentTaps; - private final int touchSlop; - private long firstDownTime; - // Store initial down points for slop checking and update when next down if is inside slop. - private final PointF[] bases; - protected boolean pendingRestart; - - SecondFingerTap( - Context context, - int taps, - int gesture, - GestureMatcher.StateChangeListener listener, - GestureMatcher.AnalyticsEventLogger logger) { - super(gesture, new Handler(context.getMainLooper()), listener, logger); - targetTaps = taps; - doubleTapTimeout = ViewConfiguration.getDoubleTapTimeout(); - touchSlop = ViewConfiguration.get(context).getScaledTouchSlop() * TARGET_FINGER_COUNT; - bases = new PointF[TARGET_FINGER_COUNT]; - for (int i = 0; i < TARGET_FINGER_COUNT; i++) { - bases[i] = new PointF(); - } - clear(); - } - - @Override - public void clear() { - currentTaps = 0; - firstDownTime = Long.MAX_VALUE; - pendingRestart = false; - super.clear(); - } - - // Instead of clear the detector, this method restore the state variables to detect the next tap - // event. - @Override - public void restart(boolean pending) { - super.restart(pending); - pendingRestart = pending; - currentTaps = 0; - } - - @Override - public boolean bypassCancelByTapUpToTouchExplore() { - return true; - } - - @Override - protected void onDown(EventId eventId, MotionEvent event) { - firstDownTime = event.getEventTime(); - } - - @Override - protected void onPointerDown(EventId eventId, MotionEvent event) { - long timeDelta = event.getEventTime() - firstDownTime; - if (timeDelta < doubleTapTimeout) { - cancelGesture(event); - return; - } - - if (event.getPointerCount() > TARGET_FINGER_COUNT) { - gestureMotionEventLog(VERBOSE, "onPointerDown/getPointerCount=%d", event.getPointerCount()); - cancelGesture(event); - return; - } - bases[0].x = event.getX(0); - bases[0].y = event.getY(0); - bases[1].x = event.getX(1); - bases[1].y = event.getY(1); - } - - @Override - protected void onPointerUp(EventId eventId, MotionEvent event) { - gestureMotionEventLog(VERBOSE, "onPointerUp"); - if (event.getPointerId(event.getActionIndex()) != 1) { - // Invalid finger of split-tap - gestureMotionEventLog(VERBOSE, "Invalid finger of split-tap"); - cancelGesture(event); - return; - } - if (event.getPointerCount() > TARGET_FINGER_COUNT) { - gestureMotionEventLog(VERBOSE, "onPointerUp/getPointerCount=%d", event.getPointerCount()); - cancelGesture(event); - return; - } - if (!validatePositions(event)) { - gestureMotionEventLog(VERBOSE, "onPointerUp/validatePositions=false"); - cancelGesture(event); - return; - } - if (pendingRestart) { - pendingRestart = false; - return; - } - if (getState() == STATE_GESTURE_STARTED || getState() == STATE_CLEAR) { - currentTaps++; - gestureMotionEventLog(VERBOSE, "onPointerUp/getState=%d", getState()); - if (currentTaps == targetTaps) { - gestureMotionEventLog(VERBOSE, "onPointerUp/currentTaps=%d", currentTaps); - // Done. - completeGesture(eventId, event); - restart(false); - startGesture(event); - } - } else { - gestureMotionEventLog(VERBOSE, "onPointerUp/currentTaps=%d", currentTaps); - // Nonsensical event stream. - cancelGesture(event); - } - } - - @Override - protected void onUp(EventId eventId, MotionEvent event) { - gestureMotionEventLog(VERBOSE, "onUp"); - // Cancel early when possible, or it will take precedence over two-finger double tap. - cancelGesture(event); - } - - /** - * Ensures the touched points when performing split-tap are not moving too far (within the - * touch-slop). - * - * @param event the latest MotionEvent received. - * @return {@code true} if successful, {@code false} otherwise. - */ - private boolean validatePositions(MotionEvent event) { - int eventCount = event.getPointerCount(); - if (eventCount != TARGET_FINGER_COUNT) { - return true; - } - for (int index = 0; index < eventCount; index++) { - PointF base = bases[index]; - final float dX = base.x - event.getX(index); - final float dY = base.y - event.getY(index); - final float delta = (float) Math.hypot(dX, dY); - if (delta > touchSlop) { - return false; - } - } - return true; - } - - @Override - public String getGestureName() { - return switch (targetTaps) { - case 1 -> "Second Finger Tap"; - default -> "Second Finger " + targetTaps + " Taps"; - }; - } - - @Override - public String toString() { - return super.toString() + ", Taps:" + currentTaps + ", Bases:" + bases[0] + "," + bases[1]; - } -} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/gestures/SecondFingerTap.kt b/utils/src/main/java/com/google/android/accessibility/utils/gestures/SecondFingerTap.kt new file mode 100644 index 000000000..294a02c39 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/gestures/SecondFingerTap.kt @@ -0,0 +1,181 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Ported from Java to Kotlin. + */ + +package com.google.android.accessibility.utils.gestures + +import android.content.Context +import android.graphics.PointF +import android.os.Build +import android.os.Handler +import android.util.Log.VERBOSE +import android.view.MotionEvent +import android.view.ViewConfiguration +import androidx.annotation.RequiresApi +import com.google.android.accessibility.utils.Performance.EventId +import kotlin.math.hypot + +/** + * This class matches second-finger multi-tap gestures. A second-finger multi-tap gesture is where + * one finger is held down and a second finger executes the taps. The number of taps for each + * instance is specified in the constructor. + */ +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +internal open class SecondFingerTap( + context: Context, + taps: Int, + gesture: Int, + listener: GestureMatcher.StateChangeListener, + logger: GestureMatcher.AnalyticsEventLogger, +) : GestureMatcher(gesture, Handler(context.mainLooper), listener, logger) { + + @JvmField protected val targetTaps: Int = taps + private val doubleTapTimeout: Int = ViewConfiguration.getDoubleTapTimeout() + @JvmField protected var currentTaps: Int = 0 + private val touchSlop: Int = + ViewConfiguration.get(context).scaledTouchSlop * TARGET_FINGER_COUNT + private var firstDownTime: Long = Long.MAX_VALUE + + // Store initial down points for slop checking and update when next down if is inside slop. + private val bases: Array = Array(TARGET_FINGER_COUNT) { PointF() } + @JvmField protected var pendingRestart: Boolean = false + + init { + clear() + } + + override fun clear() { + currentTaps = 0 + firstDownTime = Long.MAX_VALUE + pendingRestart = false + super.clear() + } + + // Instead of clear the detector, this method restore the state variables to detect the next tap + // event. + override fun restart(pending: Boolean) { + super.restart(pending) + pendingRestart = pending + currentTaps = 0 + } + + override fun bypassCancelByTapUpToTouchExplore(): Boolean = true + + override fun onDown(eventId: EventId?, event: MotionEvent) { + firstDownTime = event.eventTime + } + + override fun onPointerDown(eventId: EventId?, event: MotionEvent) { + val timeDelta = event.eventTime - firstDownTime + if (timeDelta < doubleTapTimeout) { + cancelGesture(event) + return + } + + if (event.pointerCount > TARGET_FINGER_COUNT) { + gestureMotionEventLog(VERBOSE, "onPointerDown/getPointerCount=%d", event.pointerCount) + cancelGesture(event) + return + } + bases[0].x = event.getX(0) + bases[0].y = event.getY(0) + bases[1].x = event.getX(1) + bases[1].y = event.getY(1) + } + + override fun onPointerUp(eventId: EventId?, event: MotionEvent) { + gestureMotionEventLog(VERBOSE, "onPointerUp") + if (event.getPointerId(event.actionIndex) != 1) { + // Invalid finger of split-tap + gestureMotionEventLog(VERBOSE, "Invalid finger of split-tap") + cancelGesture(event) + return + } + if (event.pointerCount > TARGET_FINGER_COUNT) { + gestureMotionEventLog(VERBOSE, "onPointerUp/getPointerCount=%d", event.pointerCount) + cancelGesture(event) + return + } + if (!validatePositions(event)) { + gestureMotionEventLog(VERBOSE, "onPointerUp/validatePositions=false") + cancelGesture(event) + return + } + if (pendingRestart) { + pendingRestart = false + return + } + if (state == STATE_GESTURE_STARTED || state == STATE_CLEAR) { + currentTaps++ + gestureMotionEventLog(VERBOSE, "onPointerUp/getState=%d", state) + if (currentTaps == targetTaps) { + gestureMotionEventLog(VERBOSE, "onPointerUp/currentTaps=%d", currentTaps) + // Done. + completeGesture(eventId, event) + restart(false) + startGesture(event) + } + } else { + gestureMotionEventLog(VERBOSE, "onPointerUp/currentTaps=%d", currentTaps) + // Nonsensical event stream. + cancelGesture(event) + } + } + + override fun onUp(eventId: EventId?, event: MotionEvent) { + gestureMotionEventLog(VERBOSE, "onUp") + // Cancel early when possible, or it will take precedence over two-finger double tap. + cancelGesture(event) + } + + /** + * Ensures the touched points when performing split-tap are not moving too far (within the + * touch-slop). + * + * @param event the latest MotionEvent received. + * @return {@code true} if successful, {@code false} otherwise. + */ + private fun validatePositions(event: MotionEvent): Boolean { + val eventCount = event.pointerCount + if (eventCount != TARGET_FINGER_COUNT) { + return true + } + for (index in 0 until eventCount) { + val base = bases[index] + val dX = base.x - event.getX(index) + val dY = base.y - event.getY(index) + val delta = hypot(dX, dY) + if (delta > touchSlop) { + return false + } + } + return true + } + + override fun getGestureName(): String = + when (targetTaps) { + 1 -> "Second Finger Tap" + else -> "Second Finger $targetTaps Taps" + } + + override fun toString(): String = + super.toString() + ", Taps:" + currentTaps + ", Bases:" + bases[0] + "," + bases[1] + + private companion object { + const val TARGET_FINGER_COUNT = 2 + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/gestures/SecondFingerTapAndHold.java b/utils/src/main/java/com/google/android/accessibility/utils/gestures/SecondFingerTapAndHold.kt similarity index 51% rename from utils/src/main/java/com/google/android/accessibility/utils/gestures/SecondFingerTapAndHold.java rename to utils/src/main/java/com/google/android/accessibility/utils/gestures/SecondFingerTapAndHold.kt index 6dae4f9a8..85e4574b1 100644 --- a/utils/src/main/java/com/google/android/accessibility/utils/gestures/SecondFingerTapAndHold.java +++ b/utils/src/main/java/com/google/android/accessibility/utils/gestures/SecondFingerTapAndHold.kt @@ -12,15 +12,17 @@ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. + * + * Ported from Java to Kotlin. */ -package com.google.android.accessibility.utils.gestures; +package com.google.android.accessibility.utils.gestures -import android.content.Context; -import android.os.Build; -import android.view.MotionEvent; -import androidx.annotation.RequiresApi; -import com.google.android.accessibility.utils.Performance.EventId; +import android.content.Context +import android.os.Build +import android.view.MotionEvent +import androidx.annotation.RequiresApi +import com.google.android.accessibility.utils.Performance.EventId /** * This class matches second-finger multi-tap-and-hold gestures. A second-finger multi-tap gesture @@ -28,54 +30,46 @@ * taps for each instance is specified in the constructor. */ @RequiresApi(Build.VERSION_CODES.TIRAMISU) -public class SecondFingerTapAndHold extends SecondFingerTap { - public SecondFingerTapAndHold( - Context context, - int taps, - int gesture, - GestureMatcher.StateChangeListener listener, - GestureMatcher.AnalyticsEventLogger logger) { - super(context, taps, gesture, listener, logger); - } +internal class SecondFingerTapAndHold( + context: Context, + taps: Int, + gesture: Int, + listener: GestureMatcher.StateChangeListener, + logger: GestureMatcher.AnalyticsEventLogger, +) : SecondFingerTap(context, taps, gesture, listener, logger) { - @Override - protected void onPointerDown(EventId eventId, MotionEvent event) { - super.onPointerDown(eventId, event); - if (getState() != STATE_GESTURE_CANCELED) { + override fun onPointerDown(eventId: EventId?, event: MotionEvent) { + super.onPointerDown(eventId, event) + if (state != STATE_GESTURE_CANCELED) { // We should check the detector state in advance because it may enter Cancel state in base // class (MultiTap). - completeAfterLongPressTimeout(eventId, event); + completeAfterLongPressTimeout(eventId, event) } } - @Override - protected void onPointerUp(EventId eventId, MotionEvent event) { + override fun onPointerUp(eventId: EventId?, event: MotionEvent) { if (pendingRestart) { - pendingRestart = false; - return; + pendingRestart = false + return } - if (getState() == STATE_GESTURE_STARTED || getState() == STATE_CLEAR) { - currentTaps++; + if (state == STATE_GESTURE_STARTED || state == STATE_CLEAR) { + currentTaps++ if (currentTaps == targetTaps) { - cancelGesture(event); - return; + cancelGesture(event) + return } // Needs more taps. } else { // Either too many taps or nonsensical event stream. - cancelGesture(event); - return; + cancelGesture(event) + return } - cancelAfterDoubleTapTimeout(event); + cancelAfterDoubleTapTimeout(event) } - @Override - protected void onUp(EventId eventId, MotionEvent event) { - cancelGesture(event); + override fun onUp(eventId: EventId?, event: MotionEvent) { + cancelGesture(event) } - @Override - public String getGestureName() { - return "Second Finger Tap and Hold"; - } + override fun getGestureName(): String = "Second Finger Tap and Hold" } diff --git a/utils/src/main/java/com/google/android/accessibility/utils/gestures/Swipe.java b/utils/src/main/java/com/google/android/accessibility/utils/gestures/Swipe.java deleted file mode 100644 index 60b6f852a..000000000 --- a/utils/src/main/java/com/google/android/accessibility/utils/gestures/Swipe.java +++ /dev/null @@ -1,423 +0,0 @@ -/* - * Copyright (C) 2021 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.android.accessibility.utils.gestures; - -import static android.util.Log.VERBOSE; -import static com.google.android.accessibility.utils.gestures.GestureUtils.MM_PER_CM; - -import android.content.Context; -import android.graphics.PointF; -import android.os.Build; -import android.os.Handler; -import android.util.DisplayMetrics; -import android.util.TypedValue; -import android.view.MotionEvent; -import android.view.ViewConfiguration; -import androidx.annotation.RequiresApi; -import com.google.android.accessibility.utils.Performance.EventId; -import com.google.android.accessibility.utils.R; -import com.google.android.accessibility.utils.gestures.GestureManifold.GestureConfigProvider; -import java.util.ArrayList; - -/** - * This class is responsible for matching one-finger swipe gestures. Each instance matches one swipe - * gesture. A swipe is specified as a series of one or more directions e.g. left, left and up, etc. - * At this time swipes with more than two directions are not supported. - */ -@RequiresApi(Build.VERSION_CODES.TIRAMISU) -class Swipe extends GestureMatcher { - - // Direction constants. - public static final int NONE = -1; - public static final int LEFT = 0; - public static final int RIGHT = 1; - public static final int UP = 2; - public static final int DOWN = 3; - // This is the calculated movement threshold used track if the user is still - // moving their finger. - private final float gestureDetectionThresholdPixels; - - // Buffer for storing points for gesture detection. - private final ArrayList strokeBuffer = new ArrayList<>(100); - - // Constants for sampling motion event points. - // We sample based on a minimum distance between points, primarily to improve accuracy by - // reducing noisy minor changes in direction. - private static final float MIN_CM_BETWEEN_SAMPLES = 0.25f; - private final int[] directions; - private float baseX; - private float baseY; - private long baseTime; - private float previousGestureX; - private float previousGestureY; - private final float minPixelsBetweenSamplesX; - private final float minPixelsBetweenSamplesY; - // Time threshold in millisecond to determine if an interaction is a gesture or not. - private final int maxStartThreshold; - // Time threshold in millisecond to determine if a gesture should be cancelled. - private final int maxContinueThreshold; - // The minimum distance the finger must travel before we evaluate the initial direction of the - // swipe. - // Anything less is still considered a touch. - private final int touchSlop; - - // Constants for separating gesture segments - private static final float ANGLE_THRESHOLD = 0.0f; - private final boolean invalidSwipeGestureEarlyDetection; - - Swipe( - Context context, - int direction, - int gesture, - GestureMatcher.StateChangeListener listener, - GestureConfigProvider configProvider, - GestureMatcher.AnalyticsEventLogger logger) { - this(context, new int[] {direction}, gesture, listener, configProvider, logger); - } - - Swipe( - Context context, - int direction1, - int direction2, - int gesture, - GestureMatcher.StateChangeListener listener, - GestureConfigProvider configProvider, - GestureMatcher.AnalyticsEventLogger logger) { - this(context, new int[] {direction1, direction2}, gesture, listener, configProvider, logger); - } - - private Swipe( - Context context, - int[] directions, - int gesture, - GestureMatcher.StateChangeListener listener, - GestureConfigProvider configProvider, - GestureMatcher.AnalyticsEventLogger logger) { - super(gesture, new Handler(context.getMainLooper()), listener, logger); - float gestureConfirmDistanceCm = - context.getResources().getFloat(R.dimen.config_gesture_confirm_distance_cm); - int maxTimeToStartSwipeMsPerCm = - context.getResources().getInteger(R.integer.config_max_time_to_start_swipe_ms_per_cm); - int maxTimeToContinueSwipeMsPerCm = - context.getResources().getInteger(R.integer.config_max_time_to_continue_swipe_ms_per_cm); - invalidSwipeGestureEarlyDetection = configProvider.invalidSwipeGestureEarlyDetection(); - maxStartThreshold = (int) (maxTimeToStartSwipeMsPerCm * gestureConfirmDistanceCm); - maxContinueThreshold = - // The tolerance of gesture time was actually greater than this value. Considering the - // swiping - // distance in large screen devices (such as Tablet) could be quite long, and the value - // doesn't affect the detection time, we increase the value. - invalidSwipeGestureEarlyDetection - ? ((int) ((maxTimeToContinueSwipeMsPerCm * gestureConfirmDistanceCm) * 3)) - : (int) (maxTimeToContinueSwipeMsPerCm * gestureConfirmDistanceCm); - - this.directions = directions; - DisplayMetrics displayMetrics = context.getResources().getDisplayMetrics(); - gestureDetectionThresholdPixels = - TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_MM, MM_PER_CM, displayMetrics) - * gestureConfirmDistanceCm; - // Calculate minimum gesture velocity - final float pixelsPerCmX = displayMetrics.xdpi / 2.54f; - final float pixelsPerCmY = displayMetrics.ydpi / 2.54f; - minPixelsBetweenSamplesX = MIN_CM_BETWEEN_SAMPLES * pixelsPerCmX; - minPixelsBetweenSamplesY = MIN_CM_BETWEEN_SAMPLES * pixelsPerCmY; - touchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); - clear(); - } - - @Override - public void clear() { - baseX = Float.NaN; - baseY = Float.NaN; - baseTime = 0; - previousGestureX = Float.NaN; - previousGestureY = Float.NaN; - strokeBuffer.clear(); - super.clear(); - } - - @Override - protected void onDown(EventId eventId, MotionEvent event) { - if (Float.isNaN(baseX) && Float.isNaN(baseY)) { - baseX = event.getX(); - baseY = event.getY(); - baseTime = event.getEventTime(); - previousGestureX = baseX; - previousGestureY = baseY; - } - // Otherwise do nothing because this event doesn't make sense in the middle of a gesture. - } - - @Override - protected void onMove(EventId eventId, MotionEvent event) { - final float x = event.getX(); - final float y = event.getY(); - final long time = event.getEventTime(); - final float dX = Math.abs(x - previousGestureX); - final float dY = Math.abs(y - previousGestureY); - final double moveDelta = Math.hypot(Math.abs(x - baseX), Math.abs(y - baseY)); - final long timeDelta = time - baseTime; - gestureMotionEventLog( - VERBOSE, - "moveDelta: %g, mGestureDetectionThreshold: %g", - moveDelta, - gestureDetectionThresholdPixels); - if (getState() == STATE_CLEAR) { - if (moveDelta < touchSlop) { - // This still counts as a touch not a swipe. - return; - } else if (strokeBuffer.isEmpty()) { - // First, make sure the pointer is going in the right direction. - int direction = toDirection(x - baseX, y - baseY); - if (direction != directions[0]) { - cancelGesture(event); - return; - } - // This is confirmed to be some kind of swipe so start tracking points. - strokeBuffer.add(new PointF(baseX, baseY)); - } - } - if (moveDelta > gestureDetectionThresholdPixels) { - if (invalidSwipeGestureEarlyDetection && timeDelta > maxContinueThreshold) { - cancelGesture(event); - return; - } - if (!invalidSwipeGestureEarlyDetection || (getState() == STATE_CLEAR)) { - // This is a gesture, not touch exploration. - baseX = x; - baseY = y; - baseTime = time; - startGesture(event); - } - } else if (getState() == STATE_CLEAR && timeDelta > maxStartThreshold) { - cancelGesture(event); - return; - } else if (getState() == STATE_GESTURE_STARTED && (timeDelta > maxContinueThreshold)) { - cancelGesture(event); - return; - } - - if (dX >= minPixelsBetweenSamplesX || dY >= minPixelsBetweenSamplesY) { - // At this point gesture detection has started and we are sampling points. - previousGestureX = x; - previousGestureY = y; - strokeBuffer.add(new PointF(x, y)); - } - } - - @Override - protected void onUp(EventId eventId, MotionEvent event) { - switch (getState()) { - case STATE_GESTURE_STARTED -> {} - case STATE_CLEAR -> { - // For Swipe gestures, this is the very last motion event. When any of the swipe gesture - // detectors matches, the others will enter the clear state. We should not Cancel the - // detector again for the Up event, or it cannot detect new gesture immediately. - // On the other hand, if we don't do clear(), the followed onDown event will credit the last - // stroke data, which caused miss-identified gesture. - clear(); - return; - } - default -> { - cancelGesture(event); - return; - } - } - - final float x = event.getX(); - final float y = event.getY(); - final float dX = Math.abs(x - previousGestureX); - final float dY = Math.abs(y - previousGestureY); - if (dX >= minPixelsBetweenSamplesX || dY >= minPixelsBetweenSamplesY) { - strokeBuffer.add(new PointF(x, y)); - } - recognizeGesture(eventId, event); - } - - @Override - protected void onPointerDown(EventId eventId, MotionEvent event) { - cancelGesture(event); - } - - @Override - protected void onPointerUp(EventId eventId, MotionEvent event) { - cancelGesture(event); - } - - /** - * Looks at the sequence of motions in mStrokeBuffer, classifies the gesture, then calls Listener - * callbacks for success or failure. - * - * @param event The raw motion event to pass to the listener callbacks. - */ - private void recognizeGesture(EventId eventId, MotionEvent event) { - if (strokeBuffer.size() < 2) { - cancelGesture(event); - return; - } - - // Look at mStrokeBuffer and extract 2 line segments, delimited by near-perpendicular - // direction change. - // Method: for each sampled motion event, check the angle of the most recent motion vector - // versus the preceding motion vector, and segment the line if the angle is about - // 90 degrees. - - ArrayList path = new ArrayList<>(); - PointF lastDelimiter = strokeBuffer.get(0); - path.add(lastDelimiter); - - float dX = 0; // Sum of unit vectors from last delimiter to each following point - float dY = 0; - int count = 0; // Number of points since last delimiter - float length = 0; // Vector length from delimiter to most recent point - - PointF next = null; - for (int i = 1; i < strokeBuffer.size(); ++i) { - next = strokeBuffer.get(i); - if (count > 0) { - // Average of unit vectors from delimiter to following points - float currentDX = dX / count; - float currentDY = dY / count; - - // newDelimiter is a possible new delimiter, based on a vector with length from - // the last delimiter to the previous point, but in the direction of the average - // unit vector from delimiter to previous points. - // Using the averaged vector has the effect of "squaring off the curve", - // creating a sharper angle between the last motion and the preceding motion from - // the delimiter. In turn, this sharper angle achieves the splitting threshold - // even in a gentle curve. - PointF newDelimiter = - new PointF(length * currentDX + lastDelimiter.x, length * currentDY + lastDelimiter.y); - - // Unit vector from newDelimiter to the most recent point - float nextDX = next.x - newDelimiter.x; - float nextDY = next.y - newDelimiter.y; - float nextLength = (float) Math.hypot(nextDX, nextDY); - nextDX = nextDX / nextLength; - nextDY = nextDY / nextLength; - - // Compare the initial motion direction to the most recent motion direction, - // and segment the line if direction has changed by about 90 degrees. - float dot = currentDX * nextDX + currentDY * nextDY; - if (dot < ANGLE_THRESHOLD) { - path.add(newDelimiter); - lastDelimiter = newDelimiter; - dX = 0; - dY = 0; - count = 0; - } - } - - // Vector from last delimiter to most recent point - float currentDX = next.x - lastDelimiter.x; - float currentDY = next.y - lastDelimiter.y; - length = (float) Math.hypot(currentDX, currentDY); - - // Increment sum of unit vectors from delimiter to each following point - count = count + 1; - dX = dX + currentDX / length; - dY = dY + currentDY / length; - } - - path.add(next); - gestureMotionEventLog(VERBOSE, "path = %s", path.toString()); - // Classify line segments, and call Listener callbacks. - recognizeGesturePath(eventId, event, path); - } - - /** - * Classifies a pair of line segments, by direction. Calls Listener callbacks for success or - * failure. - * - * @param event The raw motion event to pass to the listener's onGestureCanceled method. - * @param path A sequence of motion line segments derived from motion points in mStrokeBuffer. - */ - private void recognizeGesturePath(EventId eventId, MotionEvent event, ArrayList path) { - if (path.size() != directions.length + 1) { - cancelGesture(event); - return; - } - for (int i = 0; i < path.size() - 1; ++i) { - PointF start = path.get(i); - PointF end = path.get(i + 1); - - float dX = end.x - start.x; - float dY = end.y - start.y; - int direction = toDirection(dX, dY); - if (direction != directions[i]) { - gestureMotionEventLog( - VERBOSE, - "Found direction %s when expecting %s", - directionToString(direction), - directionToString(directions[i])); - cancelGesture(event); - return; - } - } - gestureMotionEventLog(VERBOSE, "Completed."); - completeGesture(eventId, event); - } - - private static int toDirection(float dX, float dY) { - if (Math.abs(dX) > Math.abs(dY)) { - // Horizontal - return (dX < 0) ? LEFT : RIGHT; - } else { - // Vertical - return (dY < 0) ? UP : DOWN; - } - } - - public static String directionToString(int direction) { - return switch (direction) { - case LEFT -> "left"; - case RIGHT -> "right"; - case UP -> "up"; - case DOWN -> "down"; - default -> "Unknown Direction"; - }; - } - - @Override - protected String getGestureName() { - StringBuilder builder = new StringBuilder(); - builder.append("Swipe ").append(directionToString(directions[0])); - for (int i = 1; i < directions.length; ++i) { - builder.append(" and ").append(directionToString(directions[i])); - } - return builder.toString(); - } - - @Override - public String toString() { - StringBuilder builder = new StringBuilder(super.toString()); - if (getState() != STATE_GESTURE_CANCELED) { - builder - .append(", mBaseX: ") - .append(baseX) - .append(", mBaseY: ") - .append(baseY) - .append(", mGestureDetectionThreshold:") - .append(gestureDetectionThresholdPixels) - .append(", mMinPixelsBetweenSamplesX:") - .append(minPixelsBetweenSamplesX) - .append(", mMinPixelsBetweenSamplesY:") - .append(minPixelsBetweenSamplesY); - } - return builder.toString(); - } -} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/gestures/Swipe.kt b/utils/src/main/java/com/google/android/accessibility/utils/gestures/Swipe.kt new file mode 100644 index 000000000..f8a4a4a69 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/gestures/Swipe.kt @@ -0,0 +1,423 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Ported from Java to Kotlin. + */ + +package com.google.android.accessibility.utils.gestures + +import android.content.Context +import android.graphics.PointF +import android.os.Build +import android.os.Handler +import android.util.Log.VERBOSE +import android.util.TypedValue +import android.view.MotionEvent +import android.view.ViewConfiguration +import androidx.annotation.RequiresApi +import com.google.android.accessibility.utils.Performance.EventId +import com.google.android.accessibility.utils.R +import com.google.android.accessibility.utils.gestures.GestureManifold.GestureConfigProvider +import com.google.android.accessibility.utils.gestures.GestureUtils.MM_PER_CM +import kotlin.math.abs +import kotlin.math.hypot + +/** + * This class is responsible for matching one-finger swipe gestures. Each instance matches one swipe + * gesture. A swipe is specified as a series of one or more directions e.g. left, left and up, etc. + * At this time swipes with more than two directions are not supported. + */ +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +internal class Swipe private constructor( + context: Context, + private val directions: IntArray, + gesture: Int, + listener: GestureMatcher.StateChangeListener, + configProvider: GestureConfigProvider, + logger: GestureMatcher.AnalyticsEventLogger, +) : GestureMatcher(gesture, Handler(context.mainLooper), listener, logger) { + + // This is the calculated movement threshold used track if the user is still + // moving their finger. + private val gestureDetectionThresholdPixels: Float + + // Buffer for storing points for gesture detection. + private val strokeBuffer = ArrayList(100) + + private var baseX = 0f + private var baseY = 0f + private var baseTime = 0L + private var previousGestureX = 0f + private var previousGestureY = 0f + private val minPixelsBetweenSamplesX: Float + private val minPixelsBetweenSamplesY: Float + + // Time threshold in millisecond to determine if an interaction is a gesture or not. + private val maxStartThreshold: Int + + // Time threshold in millisecond to determine if a gesture should be cancelled. + private val maxContinueThreshold: Int + + // The minimum distance the finger must travel before we evaluate the initial direction of the + // swipe. + // Anything less is still considered a touch. + private val touchSlop: Int + + private val invalidSwipeGestureEarlyDetection: Boolean + + constructor( + context: Context, + direction: Int, + gesture: Int, + listener: GestureMatcher.StateChangeListener, + configProvider: GestureConfigProvider, + logger: GestureMatcher.AnalyticsEventLogger, + ) : this(context, intArrayOf(direction), gesture, listener, configProvider, logger) + + constructor( + context: Context, + direction1: Int, + direction2: Int, + gesture: Int, + listener: GestureMatcher.StateChangeListener, + configProvider: GestureConfigProvider, + logger: GestureMatcher.AnalyticsEventLogger, + ) : this(context, intArrayOf(direction1, direction2), gesture, listener, configProvider, logger) + + init { + val gestureConfirmDistanceCm = + context.resources.getFloat(R.dimen.config_gesture_confirm_distance_cm) + val maxTimeToStartSwipeMsPerCm = + context.resources.getInteger(R.integer.config_max_time_to_start_swipe_ms_per_cm) + val maxTimeToContinueSwipeMsPerCm = + context.resources.getInteger(R.integer.config_max_time_to_continue_swipe_ms_per_cm) + invalidSwipeGestureEarlyDetection = configProvider.invalidSwipeGestureEarlyDetection() + maxStartThreshold = (maxTimeToStartSwipeMsPerCm * gestureConfirmDistanceCm).toInt() + maxContinueThreshold = + // The tolerance of gesture time was actually greater than this value. Considering the + // swiping + // distance in large screen devices (such as Tablet) could be quite long, and the value + // doesn't affect the detection time, we increase the value. + if (invalidSwipeGestureEarlyDetection) { + ((maxTimeToContinueSwipeMsPerCm * gestureConfirmDistanceCm) * 3).toInt() + } else { + (maxTimeToContinueSwipeMsPerCm * gestureConfirmDistanceCm).toInt() + } + + val displayMetrics = context.resources.displayMetrics + gestureDetectionThresholdPixels = + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_MM, MM_PER_CM.toFloat(), displayMetrics) * + gestureConfirmDistanceCm + // Calculate minimum gesture velocity + val pixelsPerCmX = displayMetrics.xdpi / 2.54f + val pixelsPerCmY = displayMetrics.ydpi / 2.54f + minPixelsBetweenSamplesX = MIN_CM_BETWEEN_SAMPLES * pixelsPerCmX + minPixelsBetweenSamplesY = MIN_CM_BETWEEN_SAMPLES * pixelsPerCmY + touchSlop = ViewConfiguration.get(context).scaledTouchSlop + clear() + } + + override fun clear() { + baseX = Float.NaN + baseY = Float.NaN + baseTime = 0 + previousGestureX = Float.NaN + previousGestureY = Float.NaN + strokeBuffer.clear() + super.clear() + } + + override fun onDown(eventId: EventId?, event: MotionEvent) { + if (baseX.isNaN() && baseY.isNaN()) { + baseX = event.x + baseY = event.y + baseTime = event.eventTime + previousGestureX = baseX + previousGestureY = baseY + } + // Otherwise do nothing because this event doesn't make sense in the middle of a gesture. + } + + override fun onMove(eventId: EventId?, event: MotionEvent) { + val x = event.x + val y = event.y + val time = event.eventTime + val dX = abs(x - previousGestureX) + val dY = abs(y - previousGestureY) + val moveDelta = hypot(abs(x - baseX).toDouble(), abs(y - baseY).toDouble()) + val timeDelta = time - baseTime + gestureMotionEventLog( + VERBOSE, + "moveDelta: %g, mGestureDetectionThreshold: %g", + moveDelta, + gestureDetectionThresholdPixels, + ) + if (state == STATE_CLEAR) { + if (moveDelta < touchSlop) { + // This still counts as a touch not a swipe. + return + } else if (strokeBuffer.isEmpty()) { + // First, make sure the pointer is going in the right direction. + val direction = toDirection(x - baseX, y - baseY) + if (direction != directions[0]) { + cancelGesture(event) + return + } + // This is confirmed to be some kind of swipe so start tracking points. + strokeBuffer.add(PointF(baseX, baseY)) + } + } + if (moveDelta > gestureDetectionThresholdPixels) { + if (invalidSwipeGestureEarlyDetection && timeDelta > maxContinueThreshold) { + cancelGesture(event) + return + } + if (!invalidSwipeGestureEarlyDetection || state == STATE_CLEAR) { + // This is a gesture, not touch exploration. + baseX = x + baseY = y + baseTime = time + startGesture(event) + } + } else if (state == STATE_CLEAR && timeDelta > maxStartThreshold) { + cancelGesture(event) + return + } else if (state == STATE_GESTURE_STARTED && timeDelta > maxContinueThreshold) { + cancelGesture(event) + return + } + + if (dX >= minPixelsBetweenSamplesX || dY >= minPixelsBetweenSamplesY) { + // At this point gesture detection has started and we are sampling points. + previousGestureX = x + previousGestureY = y + strokeBuffer.add(PointF(x, y)) + } + } + + override fun onUp(eventId: EventId?, event: MotionEvent) { + when (state) { + STATE_GESTURE_STARTED -> {} + STATE_CLEAR -> { + // For Swipe gestures, this is the very last motion event. When any of the swipe gesture + // detectors matches, the others will enter the clear state. We should not Cancel the + // detector again for the Up event, or it cannot detect new gesture immediately. + // On the other hand, if we don't do clear(), the followed onDown event will credit the last + // stroke data, which caused miss-identified gesture. + clear() + return + } + else -> { + cancelGesture(event) + return + } + } + + val x = event.x + val y = event.y + val dX = abs(x - previousGestureX) + val dY = abs(y - previousGestureY) + if (dX >= minPixelsBetweenSamplesX || dY >= minPixelsBetweenSamplesY) { + strokeBuffer.add(PointF(x, y)) + } + recognizeGesture(eventId, event) + } + + override fun onPointerDown(eventId: EventId?, event: MotionEvent) { + cancelGesture(event) + } + + override fun onPointerUp(eventId: EventId?, event: MotionEvent) { + cancelGesture(event) + } + + /** + * Looks at the sequence of motions in mStrokeBuffer, classifies the gesture, then calls Listener + * callbacks for success or failure. + * + * @param event The raw motion event to pass to the listener callbacks. + */ + private fun recognizeGesture(eventId: EventId?, event: MotionEvent) { + if (strokeBuffer.size < 2) { + cancelGesture(event) + return + } + + // Look at mStrokeBuffer and extract 2 line segments, delimited by near-perpendicular + // direction change. + // Method: for each sampled motion event, check the angle of the most recent motion vector + // versus the preceding motion vector, and segment the line if the angle is about + // 90 degrees. + + val path = ArrayList() + var lastDelimiter = strokeBuffer[0] + path.add(lastDelimiter) + + var dX = 0f // Sum of unit vectors from last delimiter to each following point + var dY = 0f + var count = 0 // Number of points since last delimiter + var length = 0f // Vector length from delimiter to most recent point + + var next: PointF? = null + for (i in 1 until strokeBuffer.size) { + next = strokeBuffer[i] + if (count > 0) { + // Average of unit vectors from delimiter to following points + val currentDX = dX / count + val currentDY = dY / count + + // newDelimiter is a possible new delimiter, based on a vector with length from + // the last delimiter to the previous point, but in the direction of the average + // unit vector from delimiter to previous points. + // Using the averaged vector has the effect of "squaring off the curve", + // creating a sharper angle between the last motion and the preceding motion from + // the delimiter. In turn, this sharper angle achieves the splitting threshold + // even in a gentle curve. + val newDelimiter = + PointF(length * currentDX + lastDelimiter.x, length * currentDY + lastDelimiter.y) + + // Unit vector from newDelimiter to the most recent point + var nextDX = next.x - newDelimiter.x + var nextDY = next.y - newDelimiter.y + val nextLength = hypot(nextDX, nextDY) + nextDX /= nextLength + nextDY /= nextLength + + // Compare the initial motion direction to the most recent motion direction, + // and segment the line if direction has changed by about 90 degrees. + val dot = currentDX * nextDX + currentDY * nextDY + if (dot < ANGLE_THRESHOLD) { + path.add(newDelimiter) + lastDelimiter = newDelimiter + dX = 0f + dY = 0f + count = 0 + } + } + + // Vector from last delimiter to most recent point + val currentDX = next.x - lastDelimiter.x + val currentDY = next.y - lastDelimiter.y + length = hypot(currentDX, currentDY) + + // Increment sum of unit vectors from delimiter to each following point + count += 1 + dX += currentDX / length + dY += currentDY / length + } + + path.add(next!!) + gestureMotionEventLog(VERBOSE, "path = %s", path.toString()) + // Classify line segments, and call Listener callbacks. + recognizeGesturePath(eventId, event, path) + } + + /** + * Classifies a pair of line segments, by direction. Calls Listener callbacks for success or + * failure. + * + * @param event The raw motion event to pass to the listener's onGestureCanceled method. + * @param path A sequence of motion line segments derived from motion points in mStrokeBuffer. + */ + private fun recognizeGesturePath(eventId: EventId?, event: MotionEvent, path: ArrayList) { + if (path.size != directions.size + 1) { + cancelGesture(event) + return + } + for (i in 0 until path.size - 1) { + val start = path[i] + val end = path[i + 1] + + val dX = end.x - start.x + val dY = end.y - start.y + val direction = toDirection(dX, dY) + if (direction != directions[i]) { + gestureMotionEventLog( + VERBOSE, + "Found direction %s when expecting %s", + directionToString(direction), + directionToString(directions[i]), + ) + cancelGesture(event) + return + } + } + gestureMotionEventLog(VERBOSE, "Completed.") + completeGesture(eventId, event) + } + + override fun getGestureName(): String { + val builder = StringBuilder() + builder.append("Swipe ").append(directionToString(directions[0])) + for (i in 1 until directions.size) { + builder.append(" and ").append(directionToString(directions[i])) + } + return builder.toString() + } + + override fun toString(): String { + val builder = StringBuilder(super.toString()) + if (state != STATE_GESTURE_CANCELED) { + builder + .append(", mBaseX: ") + .append(baseX) + .append(", mBaseY: ") + .append(baseY) + .append(", mGestureDetectionThreshold:") + .append(gestureDetectionThresholdPixels) + .append(", mMinPixelsBetweenSamplesX:") + .append(minPixelsBetweenSamplesX) + .append(", mMinPixelsBetweenSamplesY:") + .append(minPixelsBetweenSamplesY) + } + return builder.toString() + } + + companion object { + // Direction constants. + const val NONE = -1 + const val LEFT = 0 + const val RIGHT = 1 + const val UP = 2 + const val DOWN = 3 + + // Constants for sampling motion event points. + // We sample based on a minimum distance between points, primarily to improve accuracy by + // reducing noisy minor changes in direction. + private const val MIN_CM_BETWEEN_SAMPLES = 0.25f + + // Constants for separating gesture segments + private const val ANGLE_THRESHOLD = 0.0f + + private fun toDirection(dX: Float, dY: Float): Int = + if (abs(dX) > abs(dY)) { + // Horizontal + if (dX < 0) LEFT else RIGHT + } else { + // Vertical + if (dY < 0) UP else DOWN + } + + @JvmStatic + fun directionToString(direction: Int): String = + when (direction) { + LEFT -> "left" + RIGHT -> "right" + UP -> "up" + DOWN -> "down" + else -> "Unknown Direction" + } + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/gestures/TapToTouchExplore.java b/utils/src/main/java/com/google/android/accessibility/utils/gestures/TapToTouchExplore.java deleted file mode 100644 index 2513cb0bc..000000000 --- a/utils/src/main/java/com/google/android/accessibility/utils/gestures/TapToTouchExplore.java +++ /dev/null @@ -1,148 +0,0 @@ -/* - * Copyright (C) 2024 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not - * use this file except in compliance with the License. You may obtain a copy of - * the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations under - * the License. - */ - -package com.google.android.accessibility.utils.gestures; - -import android.content.Context; -import android.os.Build; -import android.os.Handler; -import android.view.MotionEvent; -import android.view.ViewConfiguration; -import androidx.annotation.RequiresApi; -import com.google.android.accessibility.utils.Performance.EventId; -import com.google.android.accessibility.utils.R; -import com.google.android.accessibility.utils.gestures.GestureManifold.GestureConfigProvider; - -/** - * This class is a pseudo gesture matcher which can early determine to enter the Touch Explore - * state. To help entering Touch Explore state earlier, it has to predict that all gesture detector - * would no longer possible to match with additional MotionEvent. For 1-finger case, when the delta - * time of action_down & action_up is over the TapTimeout value, we can determine all gesture - * detectors are fail. To report the faked gesture complete event would cancel the all detectors and - * inform TouchInteractionMonitor to directly transit to Touch Explore state. - */ -@RequiresApi(Build.VERSION_CODES.TIRAMISU) -public class TapToTouchExplore extends GestureMatcher { - private static final String TAG = "TapToTouchExplore"; - // The acceptable distance the pointer can move and still count as a tap. - int touchSlop; - int tapTimeout; - float baseX; - float baseY; - long lastDownTime; - - public TapToTouchExplore( - Context context, - int gesture, - GestureMatcher.StateChangeListener listener, - GestureConfigProvider configProvider, - GestureMatcher.AnalyticsEventLogger logger) { - super(gesture, new Handler(context.getMainLooper()), listener, logger); - initializeViewConfigurationParameters(context); - clear(); - } - - @Override - public void onConfigurationChanged(Context context) { - initializeViewConfigurationParameters(context); - } - - private void initializeViewConfigurationParameters(Context context) { - touchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); - int deltaTapTimeout = context.getResources().getInteger(R.integer.config_tap_timeout_delta); - tapTimeout = ViewConfiguration.getTapTimeout() + deltaTapTimeout; - } - - @Override - public void clear() { - baseX = Float.NaN; - baseY = Float.NaN; - lastDownTime = Long.MAX_VALUE; - super.clear(); - } - - @Override - protected void onDown(EventId eventId, MotionEvent event) { - baseX = event.getX(); - baseY = event.getY(); - lastDownTime = event.getEventTime(); - } - - @Override - protected void onUp(EventId eventId, MotionEvent event) { - if (isOutsideSlop(event, touchSlop, /* isTouchSlop= */ true)) { - debugMotionEvent(TAG, "onUp/isOutsideSlop. Gesture:%d", getGestureId()); - cancelGesture(event); - return; - } - if (isInvalidUpEvent(event)) { - debugMotionEvent(TAG, "onUp/isInvalidUpEvent. Gesture:%d", getGestureId()); - completeGesture(eventId, event); - return; - } - cancelGesture(event); - } - - @Override - protected void onMove(EventId eventId, MotionEvent event) { - if (isOutsideSlop(event, touchSlop, /* isTouchSlop= */ true)) { - cancelGesture(event); - debugMotionEvent(TAG, "onMove/isOutsideSlop. Gesture:%d", getGestureId()); - } - } - - @Override - protected void onPointerDown(EventId eventId, MotionEvent event) { - cancelGesture(event); - debugMotionEvent(TAG, "onPointerDown. Gesture:%d", getGestureId()); - } - - @Override - protected void onPointerUp(EventId eventId, MotionEvent event) { - cancelGesture(event); - debugMotionEvent(TAG, "onPointerUp. Gesture:%d", getGestureId()); - } - - @Override - public String getGestureName() { - return "Touch Explore"; - } - - protected boolean isOutsideSlop(MotionEvent event, int slop, boolean isTouchSlop) { - final float deltaX = baseX - event.getX(); - final float deltaY = baseY - event.getY(); - if (deltaX == 0 && deltaY == 0) { - return false; - } - final double moveDelta = Math.hypot(deltaX, deltaY); - return moveDelta > slop; - } - - protected boolean isInvalidUpEvent(MotionEvent upEvent) { - long time = upEvent.getEventTime(); - long timeDelta = time - lastDownTime; - if (timeDelta > tapTimeout) { - debugMotionEvent(TAG, "isInvalidUpEvent/tapTimeout's over. Gesture:%d", getGestureId()); - return true; - } - return false; - } - - @Override - public String toString() { - return super.toString() + "mBaseX: " + baseX + ", mBaseY: " + baseY; - } -} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/gestures/TapToTouchExplore.kt b/utils/src/main/java/com/google/android/accessibility/utils/gestures/TapToTouchExplore.kt new file mode 100644 index 000000000..f29fa53c1 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/gestures/TapToTouchExplore.kt @@ -0,0 +1,142 @@ +/* + * Copyright (C) 2024 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + * + * Ported from Java to Kotlin. + */ + +package com.google.android.accessibility.utils.gestures + +import android.content.Context +import android.os.Build +import android.os.Handler +import android.view.MotionEvent +import android.view.ViewConfiguration +import androidx.annotation.RequiresApi +import com.google.android.accessibility.utils.Performance.EventId +import com.google.android.accessibility.utils.R +import com.google.android.accessibility.utils.gestures.GestureManifold.GestureConfigProvider +import kotlin.math.hypot + +/** + * This class is a pseudo gesture matcher which can early determine to enter the Touch Explore + * state. To help entering Touch Explore state earlier, it has to predict that all gesture detector + * would no longer possible to match with additional MotionEvent. For 1-finger case, when the delta + * time of action_down & action_up is over the TapTimeout value, we can determine all gesture + * detectors are fail. To report the faked gesture complete event would cancel the all detectors and + * inform TouchInteractionMonitor to directly transit to Touch Explore state. + */ +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +class TapToTouchExplore( + context: Context, + gesture: Int, + listener: GestureMatcher.StateChangeListener, + configProvider: GestureConfigProvider, + logger: GestureMatcher.AnalyticsEventLogger, +) : GestureMatcher(gesture, Handler(context.mainLooper), listener, logger) { + + // The acceptable distance the pointer can move and still count as a tap. + @JvmField internal var touchSlop: Int = 0 + @JvmField internal var tapTimeout: Int = 0 + @JvmField internal var baseX: Float = Float.NaN + @JvmField internal var baseY: Float = Float.NaN + @JvmField internal var lastDownTime: Long = Long.MAX_VALUE + + init { + initializeViewConfigurationParameters(context) + clear() + } + + override fun onConfigurationChanged(context: Context) { + initializeViewConfigurationParameters(context) + } + + private fun initializeViewConfigurationParameters(context: Context) { + touchSlop = ViewConfiguration.get(context).scaledTouchSlop + val deltaTapTimeout = context.resources.getInteger(R.integer.config_tap_timeout_delta) + tapTimeout = ViewConfiguration.getTapTimeout() + deltaTapTimeout + } + + override fun clear() { + baseX = Float.NaN + baseY = Float.NaN + lastDownTime = Long.MAX_VALUE + super.clear() + } + + override fun onDown(eventId: EventId?, event: MotionEvent) { + baseX = event.x + baseY = event.y + lastDownTime = event.eventTime + } + + override fun onUp(eventId: EventId?, event: MotionEvent) { + if (isOutsideSlop(event, touchSlop, /* isTouchSlop= */ true)) { + debugMotionEvent(TAG, "onUp/isOutsideSlop. Gesture:%d", gestureId) + cancelGesture(event) + return + } + if (isInvalidUpEvent(event)) { + debugMotionEvent(TAG, "onUp/isInvalidUpEvent. Gesture:%d", gestureId) + completeGesture(eventId, event) + return + } + cancelGesture(event) + } + + override fun onMove(eventId: EventId?, event: MotionEvent) { + if (isOutsideSlop(event, touchSlop, /* isTouchSlop= */ true)) { + cancelGesture(event) + debugMotionEvent(TAG, "onMove/isOutsideSlop. Gesture:%d", gestureId) + } + } + + override fun onPointerDown(eventId: EventId?, event: MotionEvent) { + cancelGesture(event) + debugMotionEvent(TAG, "onPointerDown. Gesture:%d", gestureId) + } + + override fun onPointerUp(eventId: EventId?, event: MotionEvent) { + cancelGesture(event) + debugMotionEvent(TAG, "onPointerUp. Gesture:%d", gestureId) + } + + override fun getGestureName(): String = "Touch Explore" + + protected fun isOutsideSlop(event: MotionEvent, slop: Int, isTouchSlop: Boolean): Boolean { + val deltaX = baseX - event.x + val deltaY = baseY - event.y + if (deltaX == 0f && deltaY == 0f) { + return false + } + val moveDelta = hypot(deltaX.toDouble(), deltaY.toDouble()) + return moveDelta > slop + } + + protected fun isInvalidUpEvent(upEvent: MotionEvent): Boolean { + val time = upEvent.eventTime + val timeDelta = time - lastDownTime + if (timeDelta > tapTimeout) { + debugMotionEvent(TAG, "isInvalidUpEvent/tapTimeout's over. Gesture:%d", gestureId) + return true + } + return false + } + + override fun toString(): String = super.toString() + "mBaseX: " + baseX + ", mBaseY: " + baseY + + private companion object { + const val TAG = "TapToTouchExplore" + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/gestures/TapUpToTouchExplore.java b/utils/src/main/java/com/google/android/accessibility/utils/gestures/TapUpToTouchExplore.java deleted file mode 100644 index b74b632f8..000000000 --- a/utils/src/main/java/com/google/android/accessibility/utils/gestures/TapUpToTouchExplore.java +++ /dev/null @@ -1,139 +0,0 @@ -/* - * Copyright (C) 2024 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not - * use this file except in compliance with the License. You may obtain a copy of - * the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations under - * the License. - */ - -package com.google.android.accessibility.utils.gestures; - -import static android.util.Log.VERBOSE; -import static com.google.android.accessibility.utils.gestures.GestureUtils.MM_PER_CM; - -import android.content.Context; -import android.os.Build; -import android.os.Handler; -import android.util.DisplayMetrics; -import android.util.TypedValue; -import android.view.MotionEvent; -import androidx.annotation.RequiresApi; -import com.google.android.accessibility.utils.Performance.EventId; -import com.google.android.accessibility.utils.R; - -/** - * This class is a pseudo gesture matcher which can early determine to enter the Touch Explore - * state. To help entering Touch Explore state earlier, it has to predict that all gesture detector - * would no longer possible to match with additional MotionEvent. For 1-finger case, when the 1st - * action_down is held for more than the time the swipe gestures expect the finger should move, itʼs - * no conflict to directly enter the touch explore state. - */ -@RequiresApi(Build.VERSION_CODES.TIRAMISU) -class TapUpToTouchExplore extends GestureMatcher { - private static final String TAG = "TapUpToTouchExplore"; - private float baseX; - private float baseY; - private long firstDownTime; - // This is the calculated movement threshold used track if the user is still - // moving their finger. - private float gestureDetectionThresholdPixels; - // Time threshold in millisecond to determine if an interaction is a gesture or not. - private int maxStartThreshold; - - TapUpToTouchExplore( - Context context, - int gesture, - GestureMatcher.StateChangeListener listener, - GestureMatcher.AnalyticsEventLogger logger) { - super(gesture, new Handler(context.getMainLooper()), listener, logger); - initializeViewConfigurationParameters(context); - clear(); - } - - @Override - public void onConfigurationChanged(Context context) { - initializeViewConfigurationParameters(context); - } - - private void initializeViewConfigurationParameters(Context context) { - float gestureConfirmDistanceCm = - context.getResources().getFloat(R.dimen.config_gesture_confirm_distance_cm); - DisplayMetrics displayMetrics = context.getResources().getDisplayMetrics(); - gestureDetectionThresholdPixels = - TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_MM, MM_PER_CM, displayMetrics) - * gestureConfirmDistanceCm; - int maxTimeToStartSwipeMsPerCm = - context.getResources().getInteger(R.integer.config_max_time_to_start_swipe_ms_per_cm); - maxStartThreshold = (int) (maxTimeToStartSwipeMsPerCm * gestureConfirmDistanceCm); - } - - @Override - public void clear() { - baseX = Float.NaN; - baseY = Float.NaN; - firstDownTime = Long.MAX_VALUE; - super.clear(); - } - - @Override - protected void onDown(EventId eventId, MotionEvent event) { - baseX = event.getX(); - baseY = event.getY(); - firstDownTime = event.getEventTime(); - } - - @Override - protected void onMove(EventId eventId, MotionEvent event) { - if (event.getPointerCount() > 1) { - return; - } - final float x = event.getX(); - final float y = event.getY(); - final double moveDelta = Math.hypot(Math.abs(x - baseX), Math.abs(y - baseY)); - if (moveDelta > gestureDetectionThresholdPixels) { - // No need to monitor to Touch Explore - cancelGesture(event); - return; - } - final long timeDelta = event.getEventTime() - firstDownTime; - if (timeDelta > maxStartThreshold) { - debugMotionEvent(TAG, "onMove/timeDelta is over. Gesture:%d", getGestureId()); - completeGesture(eventId, event); - return; - } - } - - @Override - protected void onPointerDown(EventId eventId, MotionEvent event) { - cancelGesture(event); - } - - @Override - protected void onPointerUp(EventId eventId, MotionEvent event) { - cancelGesture(event); - } - - @Override - protected void onUp(EventId eventId, MotionEvent event) { - gestureMotionEventLog(VERBOSE, "onUp"); - cancelGesture(event); - } - - @Override - public String getGestureName() { - return "TapUpToTouchExplore"; - } - - @Override - public String toString() { - return super.toString() + "BaseX: " + baseX + ", BaseY: " + baseY; - } -} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/gestures/TapUpToTouchExplore.kt b/utils/src/main/java/com/google/android/accessibility/utils/gestures/TapUpToTouchExplore.kt new file mode 100644 index 000000000..208d1bafb --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/gestures/TapUpToTouchExplore.kt @@ -0,0 +1,134 @@ +/* + * Copyright (C) 2024 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + * + * Ported from Java to Kotlin. + */ + +package com.google.android.accessibility.utils.gestures + +import android.content.Context +import android.os.Build +import android.os.Handler +import android.util.Log.VERBOSE +import android.util.TypedValue +import android.view.MotionEvent +import androidx.annotation.RequiresApi +import com.google.android.accessibility.utils.Performance.EventId +import com.google.android.accessibility.utils.R +import com.google.android.accessibility.utils.gestures.GestureUtils.MM_PER_CM +import kotlin.math.abs +import kotlin.math.hypot + +/** + * This class is a pseudo gesture matcher which can early determine to enter the Touch Explore + * state. To help entering Touch Explore state earlier, it has to predict that all gesture detector + * would no longer possible to match with additional MotionEvent. For 1-finger case, when the 1st + * action_down is held for more than the time the swipe gestures expect the finger should move, itʼs + * no conflict to directly enter the touch explore state. + */ +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +internal class TapUpToTouchExplore( + context: Context, + gesture: Int, + listener: GestureMatcher.StateChangeListener, + logger: GestureMatcher.AnalyticsEventLogger, +) : GestureMatcher(gesture, Handler(context.mainLooper), listener, logger) { + + private var baseX: Float = Float.NaN + private var baseY: Float = Float.NaN + private var firstDownTime: Long = Long.MAX_VALUE + + // This is the calculated movement threshold used track if the user is still + // moving their finger. + private var gestureDetectionThresholdPixels: Float = 0f + + // Time threshold in millisecond to determine if an interaction is a gesture or not. + private var maxStartThreshold: Int = 0 + + init { + initializeViewConfigurationParameters(context) + clear() + } + + override fun onConfigurationChanged(context: Context) { + initializeViewConfigurationParameters(context) + } + + private fun initializeViewConfigurationParameters(context: Context) { + val gestureConfirmDistanceCm = + context.resources.getFloat(R.dimen.config_gesture_confirm_distance_cm) + val displayMetrics = context.resources.displayMetrics + gestureDetectionThresholdPixels = + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_MM, MM_PER_CM.toFloat(), displayMetrics) * + gestureConfirmDistanceCm + val maxTimeToStartSwipeMsPerCm = + context.resources.getInteger(R.integer.config_max_time_to_start_swipe_ms_per_cm) + maxStartThreshold = (maxTimeToStartSwipeMsPerCm * gestureConfirmDistanceCm).toInt() + } + + override fun clear() { + baseX = Float.NaN + baseY = Float.NaN + firstDownTime = Long.MAX_VALUE + super.clear() + } + + override fun onDown(eventId: EventId?, event: MotionEvent) { + baseX = event.x + baseY = event.y + firstDownTime = event.eventTime + } + + override fun onMove(eventId: EventId?, event: MotionEvent) { + if (event.pointerCount > 1) { + return + } + val x = event.x + val y = event.y + val moveDelta = hypot(abs(x - baseX).toDouble(), abs(y - baseY).toDouble()) + if (moveDelta > gestureDetectionThresholdPixels) { + // No need to monitor to Touch Explore + cancelGesture(event) + return + } + val timeDelta = event.eventTime - firstDownTime + if (timeDelta > maxStartThreshold) { + debugMotionEvent(TAG, "onMove/timeDelta is over. Gesture:%d", gestureId) + completeGesture(eventId, event) + return + } + } + + override fun onPointerDown(eventId: EventId?, event: MotionEvent) { + cancelGesture(event) + } + + override fun onPointerUp(eventId: EventId?, event: MotionEvent) { + cancelGesture(event) + } + + override fun onUp(eventId: EventId?, event: MotionEvent) { + gestureMotionEventLog(VERBOSE, "onUp") + cancelGesture(event) + } + + override fun getGestureName(): String = "TapUpToTouchExplore" + + override fun toString(): String = super.toString() + "BaseX: " + baseX + ", BaseY: " + baseY + + private companion object { + const val TAG = "TapUpToTouchExplore" + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/gestures/TwoFingerSecondFingerMultiTap.java b/utils/src/main/java/com/google/android/accessibility/utils/gestures/TwoFingerSecondFingerMultiTap.java deleted file mode 100644 index 262ff726d..000000000 --- a/utils/src/main/java/com/google/android/accessibility/utils/gestures/TwoFingerSecondFingerMultiTap.java +++ /dev/null @@ -1,305 +0,0 @@ -/* - * Copyright (C) 2023 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.android.accessibility.utils.gestures; - -import static android.util.Log.VERBOSE; -import static android.view.MotionEvent.INVALID_POINTER_ID; - -import android.content.Context; -import android.graphics.PointF; -import android.os.Handler; -import android.view.MotionEvent; -import android.view.ViewConfiguration; -import androidx.annotation.IntDef; -import com.google.android.accessibility.utils.Performance.EventId; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.util.Arrays; - -/** - * This class matches second-finger multi-tap gestures. The difference between this class and - * SecondFingerMultiTap is the two finger tap at the same time for this gesture. - */ -public class TwoFingerSecondFingerMultiTap extends GestureMatcher { - @Retention(RetentionPolicy.SOURCE) - @IntDef({ROTATE_DIRECTION_DONT_CARE, ROTATE_DIRECTION_FORWARD, ROTATE_DIRECTION_BACKWARD}) - @interface RotateDirection {} - - public static final int ROTATE_DIRECTION_DONT_CARE = 0; - public static final int ROTATE_DIRECTION_FORWARD = 1; - public static final int ROTATE_DIRECTION_BACKWARD = 2; - - // The target number of taps. - private final int targetTapCount; - // The target number of fingers. - private final int targetFingerCount; - private final int tapTimeout; - // The acceptable distance the pointer can move and still count as a tap. - private final int touchSlop; - // A tap counts when target number of fingers are down and up once. - private int completedTapCount; - // A flag set to true when target number of fingers have touched down at once before. - // Used to indicate what next finger action should be. Down when false and lift when true. - private boolean isTargetFingerCountReached = false; - // Store initial down points for slop checking and update when next down if is inside slop. - private final PointF[] bases; - private final int[] pointerIds; - private long lastDownTime; - private long lastUpTime; - @RotateDirection private final int rotateDirection; - private int tappingIndex; - - /** - * This class matches gestures of the form 2-finger tap. Then one of them keeps hold while the - * other finger multi-taps. The number of taps for each instance is specified in the constructor. - */ - TwoFingerSecondFingerMultiTap( - Context context, - int taps, - @RotateDirection int rotateDirection, - int gestureId, - GestureMatcher.StateChangeListener listener, - GestureMatcher.AnalyticsEventLogger logger) { - super(gestureId, new Handler(context.getMainLooper()), listener, logger); - this.rotateDirection = rotateDirection; - targetTapCount = taps; - targetFingerCount = 2; - pointerIds = new int[targetFingerCount]; - tapTimeout = targetFingerCount * ViewConfiguration.getTapTimeout(); - touchSlop = ViewConfiguration.get(context).getScaledTouchSlop() * targetFingerCount; - - bases = new PointF[targetFingerCount]; - for (int i = 0; i < bases.length; i++) { - bases[i] = new PointF(); - } - clear(); - } - - @Override - public void clear() { - completedTapCount = 0; - isTargetFingerCountReached = false; - for (int i = 0; i < targetFingerCount; ++i) { - pointerIds[i] = INVALID_POINTER_ID; - bases[i].x = Float.NaN; - bases[i].y = Float.NaN; - } - lastDownTime = Long.MAX_VALUE; - lastUpTime = Long.MAX_VALUE; - super.clear(); - } - - @Override - protected void onDown(EventId eventId, MotionEvent event) { - lastDownTime = event.getEventTime(); - if (pointerIds[0] != INVALID_POINTER_ID) { - // Inconsistent event stream. - cancelGesture(event); - return; - } - pointerIds[0] = 0; - if (Float.isNaN(bases[0].x) && Float.isNaN(bases[0].y)) { - final float x = event.getX(0); - final float y = event.getY(0); - if (x < 0f || y < 0f) { - gestureMotionEventLog(VERBOSE, "MotionEvent position's incorrect."); - cancelGesture(event); - return; - } - bases[0].x = x; - bases[0].y = y; - } else { - gestureMotionEventLog(VERBOSE, "MotionEvent comes out of sync."); - // This event doesn't make sense in the middle of a gesture. - cancelGesture(event); - return; - } - } - - @Override - protected void onUp(EventId eventId, MotionEvent event) { - // Because this is a multi-finger gesture, we must have received ACTION_POINTER_UP before this - // so we calculate timeDelta relative to lastUpTime. - if (completedTapCount != targetTapCount) { - gestureMotionEventLog(VERBOSE, "The expected tap count does not reach."); - cancelGesture(event); - return; - } - completeGesture(eventId, event); - } - - @Override - protected void onMove(EventId eventId, MotionEvent event) { - if (Float.isNaN(bases[0].x) && Float.isNaN(bases[0].y)) { - return; - } - - final int currentFingerCount = event.getPointerCount(); - for (int i = 0; i < currentFingerCount; i++) { - final float delta = - (float) - Math.hypot( - event.getX(i) - bases[event.getPointerId(i)].x, - event.getY(i) - bases[event.getPointerId(i)].y); - if (delta > ((completedTapCount + 1) * touchSlop)) { - // Outside the touch slop - gestureMotionEventLog(VERBOSE, "MotionEvent positions move Excessively."); - cancelGesture(event); - return; - } - } - if (currentFingerCount > targetFingerCount) { - gestureMotionEventLog(VERBOSE, "Too many fingers involved."); - cancelGesture(event); - return; - } - } - - @Override - protected void onPointerDown(EventId eventId, MotionEvent event) { - if (Float.isNaN(bases[0].x) && Float.isNaN(bases[0].y)) { - return; - } - long timeDelta = event.getEventTime() - lastUpTime; - if (timeDelta > tapTimeout) { - gestureMotionEventLog(VERBOSE, "The 2nd finger taps occur too slow."); - cancelGesture(event); - return; - } - lastDownTime = event.getEventTime(); - final int currentFingerCount = event.getPointerCount(); - // Accept down only before target number of fingers are down - // or the finger count is not more than target. - if (currentFingerCount > targetFingerCount) { - gestureMotionEventLog(VERBOSE, "Too many fingers involved."); - cancelGesture(event); - return; - } - completedTapCount++; - if ((completedTapCount > 1) && (event.getActionIndex() != tappingIndex)) { - gestureMotionEventLog(VERBOSE, "The tapping finger is not persistent."); - cancelGesture(event); - return; - } - if ((getState() == STATE_GESTURE_STARTED || getState() == STATE_CLEAR)) { - // The user have all fingers down within the tap timeout since first finger down, - // setting the timeout for fingers to be lifted. - if (currentFingerCount == targetFingerCount) { - isTargetFingerCountReached = true; - } - } else { - gestureMotionEventLog(VERBOSE, "MotionEvent state's out of sync."); - cancelGesture(event); - return; - } - if (completedTapCount == 1) { - // Update pointer location . - bases[1].x = event.getX(1); - bases[1].y = event.getY(1); - } - } - - @Override - protected void onPointerUp(EventId eventId, MotionEvent event) { - // Accept up only after target number of fingers are down. - if (Float.isNaN(bases[0].x) && Float.isNaN(bases[0].y)) { - return; - } - if (!isTargetFingerCountReached) { - cancelGesture(event); - return; - } - if (completedTapCount == 1) { - tappingIndex = event.getActionIndex(); - float deltaX = event.getX(tappingIndex) - bases[1 - tappingIndex].x; - switch (rotateDirection) { - case ROTATE_DIRECTION_FORWARD -> { - if (deltaX <= 0) { - gestureMotionEventLog(VERBOSE, "Rotating direction mismatches."); - cancelGesture(event); - return; - } - } - case ROTATE_DIRECTION_BACKWARD -> { - if (deltaX >= 0) { - gestureMotionEventLog(VERBOSE, "Rotating direction mismatches."); - cancelGesture(event); - return; - } - } - case ROTATE_DIRECTION_DONT_CARE -> {} - default -> {} - } - if (completedTapCount == (targetTapCount - 1)) { - startGesture(event); - } - } else if (tappingIndex != event.getActionIndex()) { - gestureMotionEventLog(VERBOSE, "The tapping finger is not persistent."); - cancelGesture(event); - return; - } - - if (getState() == STATE_GESTURE_STARTED || getState() == STATE_CLEAR) { - // Needs more fingers lifted within the tap timeout - // after reaching the target number of fingers are down. - // Calculate timeDelta relative to whichever baseline is most recent, lastUpTime or - // lastDownTime. - long timeDelta = event.getEventTime() - lastDownTime; - if (timeDelta > tapTimeout) { - gestureMotionEventLog(VERBOSE, "The tapping finger holds too long time."); - cancelGesture(event); - return; - } - } else { - gestureMotionEventLog(VERBOSE, "MotionEvent state's out of sync."); - cancelGesture(event); - return; - } - lastUpTime = event.getEventTime(); - if (completedTapCount == targetTapCount) { - completeAfterDoubleTapTimeout(eventId, event); - } - } - - @Override - public String getGestureName() { - final StringBuilder builder = new StringBuilder(); - builder.append("One").append("-Finger Tap-and-hold with 2nd finger"); - if (targetTapCount == 2) { - builder.append("Double"); - } else if (targetTapCount == 3) { - builder.append("Triple"); - } else if (targetTapCount > 3) { - builder.append(targetTapCount); - } - return builder.append(" Tap").toString(); - } - - @Override - public String toString() { - final StringBuilder builder = new StringBuilder(super.toString()); - if (getState() != STATE_GESTURE_CANCELED) { - builder.append(", CompletedTapCount: "); - builder.append(completedTapCount); - builder.append(", IsTargetFingerCountReached: "); - builder.append(isTargetFingerCountReached); - builder.append(", Bases: "); - builder.append(Arrays.toString(bases)); - } - return builder.toString(); - } -} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/gestures/TwoFingerSecondFingerMultiTap.kt b/utils/src/main/java/com/google/android/accessibility/utils/gestures/TwoFingerSecondFingerMultiTap.kt new file mode 100644 index 000000000..43f0d9e89 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/gestures/TwoFingerSecondFingerMultiTap.kt @@ -0,0 +1,293 @@ +/* + * Copyright (C) The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Ported from Java to Kotlin. + */ + +package com.google.android.accessibility.utils.gestures + +import android.content.Context +import android.graphics.PointF +import android.os.Handler +import android.util.Log.VERBOSE +import android.view.MotionEvent +import android.view.MotionEvent.INVALID_POINTER_ID +import android.view.ViewConfiguration +import androidx.annotation.IntDef +import com.google.android.accessibility.utils.Performance.EventId +import java.util.Arrays +import kotlin.math.hypot + +/** + * This class matches second-finger multi-tap gestures. The difference between this class and + * SecondFingerMultiTap is the two finger tap at the same time for this gesture. + * + * This class matches gestures of the form 2-finger tap. Then one of them keeps hold while the + * other finger multi-taps. The number of taps for each instance is specified in the constructor. + */ +class TwoFingerSecondFingerMultiTap internal constructor( + context: Context, + taps: Int, + @RotateDirection private val rotateDirection: Int, + gestureId: Int, + listener: GestureMatcher.StateChangeListener, + logger: GestureMatcher.AnalyticsEventLogger, +) : GestureMatcher(gestureId, Handler(context.mainLooper), listener, logger) { + + @Retention(AnnotationRetention.SOURCE) + @IntDef(ROTATE_DIRECTION_DONT_CARE, ROTATE_DIRECTION_FORWARD, ROTATE_DIRECTION_BACKWARD) + internal annotation class RotateDirection + + // The target number of taps. + private val targetTapCount: Int = taps + + // The target number of fingers. + private val targetFingerCount: Int = 2 + private val tapTimeout: Int = targetFingerCount * ViewConfiguration.getTapTimeout() + + // The acceptable distance the pointer can move and still count as a tap. + private val touchSlop: Int = + ViewConfiguration.get(context).scaledTouchSlop * targetFingerCount + + // A tap counts when target number of fingers are down and up once. + private var completedTapCount: Int = 0 + + // A flag set to true when target number of fingers have touched down at once before. + // Used to indicate what next finger action should be. Down when false and lift when true. + private var isTargetFingerCountReached: Boolean = false + + // Store initial down points for slop checking and update when next down if is inside slop. + private val bases: Array = Array(targetFingerCount) { PointF() } + private val pointerIds: IntArray = IntArray(targetFingerCount) + private var lastDownTime: Long = Long.MAX_VALUE + private var lastUpTime: Long = Long.MAX_VALUE + private var tappingIndex: Int = 0 + + init { + clear() + } + + override fun clear() { + completedTapCount = 0 + isTargetFingerCountReached = false + for (i in 0 until targetFingerCount) { + pointerIds[i] = INVALID_POINTER_ID + bases[i].x = Float.NaN + bases[i].y = Float.NaN + } + lastDownTime = Long.MAX_VALUE + lastUpTime = Long.MAX_VALUE + super.clear() + } + + override fun onDown(eventId: EventId?, event: MotionEvent) { + lastDownTime = event.eventTime + if (pointerIds[0] != INVALID_POINTER_ID) { + // Inconsistent event stream. + cancelGesture(event) + return + } + pointerIds[0] = 0 + if (bases[0].x.isNaN() && bases[0].y.isNaN()) { + val x = event.getX(0) + val y = event.getY(0) + if (x < 0f || y < 0f) { + gestureMotionEventLog(VERBOSE, "MotionEvent position's incorrect.") + cancelGesture(event) + return + } + bases[0].x = x + bases[0].y = y + } else { + gestureMotionEventLog(VERBOSE, "MotionEvent comes out of sync.") + // This event doesn't make sense in the middle of a gesture. + cancelGesture(event) + return + } + } + + override fun onUp(eventId: EventId?, event: MotionEvent) { + // Because this is a multi-finger gesture, we must have received ACTION_POINTER_UP before this + // so we calculate timeDelta relative to lastUpTime. + if (completedTapCount != targetTapCount) { + gestureMotionEventLog(VERBOSE, "The expected tap count does not reach.") + cancelGesture(event) + return + } + completeGesture(eventId, event) + } + + override fun onMove(eventId: EventId?, event: MotionEvent) { + if (bases[0].x.isNaN() && bases[0].y.isNaN()) { + return + } + + val currentFingerCount = event.pointerCount + for (i in 0 until currentFingerCount) { + val delta = + hypot( + event.getX(i) - bases[event.getPointerId(i)].x, + event.getY(i) - bases[event.getPointerId(i)].y, + ) + if (delta > (completedTapCount + 1) * touchSlop) { + // Outside the touch slop + gestureMotionEventLog(VERBOSE, "MotionEvent positions move Excessively.") + cancelGesture(event) + return + } + } + if (currentFingerCount > targetFingerCount) { + gestureMotionEventLog(VERBOSE, "Too many fingers involved.") + cancelGesture(event) + return + } + } + + override fun onPointerDown(eventId: EventId?, event: MotionEvent) { + if (bases[0].x.isNaN() && bases[0].y.isNaN()) { + return + } + val timeDelta = event.eventTime - lastUpTime + if (timeDelta > tapTimeout) { + gestureMotionEventLog(VERBOSE, "The 2nd finger taps occur too slow.") + cancelGesture(event) + return + } + lastDownTime = event.eventTime + val currentFingerCount = event.pointerCount + // Accept down only before target number of fingers are down + // or the finger count is not more than target. + if (currentFingerCount > targetFingerCount) { + gestureMotionEventLog(VERBOSE, "Too many fingers involved.") + cancelGesture(event) + return + } + completedTapCount++ + if (completedTapCount > 1 && event.actionIndex != tappingIndex) { + gestureMotionEventLog(VERBOSE, "The tapping finger is not persistent.") + cancelGesture(event) + return + } + if (state == STATE_GESTURE_STARTED || state == STATE_CLEAR) { + // The user have all fingers down within the tap timeout since first finger down, + // setting the timeout for fingers to be lifted. + if (currentFingerCount == targetFingerCount) { + isTargetFingerCountReached = true + } + } else { + gestureMotionEventLog(VERBOSE, "MotionEvent state's out of sync.") + cancelGesture(event) + return + } + if (completedTapCount == 1) { + // Update pointer location . + bases[1].x = event.getX(1) + bases[1].y = event.getY(1) + } + } + + override fun onPointerUp(eventId: EventId?, event: MotionEvent) { + // Accept up only after target number of fingers are down. + if (bases[0].x.isNaN() && bases[0].y.isNaN()) { + return + } + if (!isTargetFingerCountReached) { + cancelGesture(event) + return + } + if (completedTapCount == 1) { + tappingIndex = event.actionIndex + val deltaX = event.getX(tappingIndex) - bases[1 - tappingIndex].x + when (rotateDirection) { + ROTATE_DIRECTION_FORWARD -> { + if (deltaX <= 0) { + gestureMotionEventLog(VERBOSE, "Rotating direction mismatches.") + cancelGesture(event) + return + } + } + ROTATE_DIRECTION_BACKWARD -> { + if (deltaX >= 0) { + gestureMotionEventLog(VERBOSE, "Rotating direction mismatches.") + cancelGesture(event) + return + } + } + ROTATE_DIRECTION_DONT_CARE -> {} + else -> {} + } + if (completedTapCount == targetTapCount - 1) { + startGesture(event) + } + } else if (tappingIndex != event.actionIndex) { + gestureMotionEventLog(VERBOSE, "The tapping finger is not persistent.") + cancelGesture(event) + return + } + + if (state == STATE_GESTURE_STARTED || state == STATE_CLEAR) { + // Needs more fingers lifted within the tap timeout + // after reaching the target number of fingers are down. + // Calculate timeDelta relative to whichever baseline is most recent, lastUpTime or + // lastDownTime. + val timeDelta = event.eventTime - lastDownTime + if (timeDelta > tapTimeout) { + gestureMotionEventLog(VERBOSE, "The tapping finger holds too long time.") + cancelGesture(event) + return + } + } else { + gestureMotionEventLog(VERBOSE, "MotionEvent state's out of sync.") + cancelGesture(event) + return + } + lastUpTime = event.eventTime + if (completedTapCount == targetTapCount) { + completeAfterDoubleTapTimeout(eventId, event) + } + } + + override fun getGestureName(): String { + val builder = StringBuilder() + builder.append("One").append("-Finger Tap-and-hold with 2nd finger") + if (targetTapCount == 2) { + builder.append("Double") + } else if (targetTapCount == 3) { + builder.append("Triple") + } else if (targetTapCount > 3) { + builder.append(targetTapCount) + } + return builder.append(" Tap").toString() + } + + override fun toString(): String { + val builder = StringBuilder(super.toString()) + if (state != STATE_GESTURE_CANCELED) { + builder.append(", CompletedTapCount: ") + builder.append(completedTapCount) + builder.append(", IsTargetFingerCountReached: ") + builder.append(isTargetFingerCountReached) + builder.append(", Bases: ") + builder.append(Arrays.toString(bases)) + } + return builder.toString() + } + + companion object { + const val ROTATE_DIRECTION_DONT_CARE = 0 + const val ROTATE_DIRECTION_FORWARD = 1 + const val ROTATE_DIRECTION_BACKWARD = 2 + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/gestures/TwoFingerSingleTapAndLongHold.java b/utils/src/main/java/com/google/android/accessibility/utils/gestures/TwoFingerSingleTapAndLongHold.kt similarity index 55% rename from utils/src/main/java/com/google/android/accessibility/utils/gestures/TwoFingerSingleTapAndLongHold.java rename to utils/src/main/java/com/google/android/accessibility/utils/gestures/TwoFingerSingleTapAndLongHold.kt index e29f5908a..cfa93929e 100644 --- a/utils/src/main/java/com/google/android/accessibility/utils/gestures/TwoFingerSingleTapAndLongHold.java +++ b/utils/src/main/java/com/google/android/accessibility/utils/gestures/TwoFingerSingleTapAndLongHold.kt @@ -12,51 +12,50 @@ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. + * + * Ported from Java to Kotlin. */ -package com.google.android.accessibility.utils.gestures; +package com.google.android.accessibility.utils.gestures -import android.content.Context; -import android.os.Build; -import android.view.MotionEvent; -import androidx.annotation.RequiresApi; -import com.google.android.accessibility.utils.Performance.EventId; +import android.content.Context +import android.os.Build +import android.view.MotionEvent +import androidx.annotation.RequiresApi +import com.google.android.accessibility.utils.Performance.EventId /** * This class matches gesture of the form 2-finger 1-tap and long hold. It is only for TalkBack * mis-triggering recovery feature. Its hold complete timeout is longer than long press timeout. */ @RequiresApi(Build.VERSION_CODES.TIRAMISU) -class TwoFingerSingleTapAndLongHold extends MultiFingerMultiTap { - - private static final int LONG_HOLD_TIMEOUT_MS = 5000; - - TwoFingerSingleTapAndLongHold( - Context context, int gestureId, StateChangeListener listener, AnalyticsEventLogger logger) { - super(context, 2, 1, gestureId, listener, logger); - } - - @Override - protected void onPointerDown(EventId eventId, MotionEvent event) { - super.onPointerDown(eventId, event); +internal class TwoFingerSingleTapAndLongHold( + context: Context, + gestureId: Int, + listener: GestureMatcher.StateChangeListener, + logger: GestureMatcher.AnalyticsEventLogger, +) : MultiFingerMultiTap(context, 2, 1, gestureId, listener, logger) { + + override fun onPointerDown(eventId: EventId?, event: MotionEvent) { + super.onPointerDown(eventId, event) if (isTargetFingerCountReached && completedTapCount + 1 == mTargetTapCount) { - completeAfter(LONG_HOLD_TIMEOUT_MS, eventId, event); + completeAfter(LONG_HOLD_TIMEOUT_MS.toLong(), eventId, event) } } - @Override - protected void onUp(EventId eventId, MotionEvent event) { + override fun onUp(eventId: EventId?, event: MotionEvent) { if (completedTapCount + 1 == mTargetTapCount) { // Calling super.onUp would complete the multi-tap version of this. - cancelGesture(event); + cancelGesture(event) } else { - super.onUp(eventId, event); - cancelAfterDoubleTapTimeout(event); + super.onUp(eventId, event) + cancelAfterDoubleTapTimeout(event) } } - @Override - public String getGestureName() { - return "2-Finger Tap and long hold"; + override fun getGestureName(): String = "2-Finger Tap and long hold" + + private companion object { + const val LONG_HOLD_TIMEOUT_MS = 5000 } } diff --git a/utils/src/main/java/com/google/android/accessibility/utils/traversal/DirectionalTraversalStrategy.java b/utils/src/main/java/com/google/android/accessibility/utils/traversal/DirectionalTraversalStrategy.java deleted file mode 100644 index baec8dc90..000000000 --- a/utils/src/main/java/com/google/android/accessibility/utils/traversal/DirectionalTraversalStrategy.java +++ /dev/null @@ -1,582 +0,0 @@ -/* - * Copyright (C) 2015 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not - * use this file except in compliance with the License. You may obtain a copy of - * the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations under - * the License. - */ - -package com.google.android.accessibility.utils.traversal; - -import static android.view.accessibility.AccessibilityNodeInfo.FOCUS_ACCESSIBILITY; -import static android.view.accessibility.AccessibilityNodeInfo.FOCUS_INPUT; - -import android.graphics.Rect; -import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; -import com.google.android.accessibility.utils.AccessibilityNodeInfoUtils; -import com.google.android.accessibility.utils.Filter; -import com.google.android.accessibility.utils.FocusFinder; -import com.google.android.accessibility.utils.WebInterfaceUtils; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import org.checkerframework.checker.nullness.qual.Nullable; - -public class DirectionalTraversalStrategy implements TraversalStrategy { - - /** The root node within which to traverse. */ - private @Nullable AccessibilityNodeInfoCompat root; - - /** Instance for finding Accessibility/Input focus. */ - private final FocusFinder focusFinder; - - /** The bounds of the root node, padded slightly for intersection checks. */ - private final Rect rootRectPadded; - - /** A set of all visited nodes in root's hierarchy. */ - private final Set visitedNodes = new HashSet<>(); - - /** A list of only focusable nodes. */ - private final List focusableNodes = new ArrayList<>(); - - /** The set of focusable nodes that have focusable descendants. */ - private final Set containerNodes = new HashSet<>(); - - /** Cache of nodes that have speech for use by AccessibilityNodeInfoUtils. */ - private final Map speakingNodesCache = new HashMap<>(); - - public DirectionalTraversalStrategy( - @Nullable AccessibilityNodeInfoCompat root, FocusFinder focusFinder) { - this.root = root; - this.focusFinder = focusFinder; - - // The cached on-screen bounds of the root node. - Rect rootRect = new Rect(); - if (this.root != null) { - this.root.getBoundsInScreen(rootRect); - } - - int fudge = -(rootRect.width() / 20); // 5% fudge factor to catch objects near edge. - rootRectPadded = new Rect(rootRect); - rootRectPadded.inset(fudge, fudge); - - processNodes(this.root, /* forceRefresh= */ false); - } - - /** - * Goes through root and its descendant nodes, sorting out the focusable nodes and the container - * nodes for use in finding focus. Does not re-process visitedNodes. - * - * @return whether the root is focusable or has focusable children in its hierarchy - */ - private boolean processNodes(@Nullable AccessibilityNodeInfoCompat root, boolean forceRefresh) { - if (root == null || visitedNodes.contains(root)) { - return false; - } - - if (forceRefresh) { - root.refresh(); - } - - Rect currentRect = new Rect(); - root.getBoundsInScreen(currentRect); - - // Determine if the node is inside rootRect (within a fudge factor). If it is outside, we - // will optimize by skipping its entire hierarchy. - if (!Rect.intersects(currentRect, rootRectPadded)) { - return false; - } - - visitedNodes.add(root); - - // When we reach a node that supports web navigation, we traverse using the web navigation - // actions, so we should not add any of its descendants to the list of focusable nodes. - if (WebInterfaceUtils.hasNativeWebContent(root)) { - focusableNodes.add(root); - return true; - } else { - boolean isFocusable = AccessibilityNodeInfoUtils.shouldFocusNode(root, speakingNodesCache); - if (isFocusable) { - focusableNodes.add(root); - } - - boolean hasFocusableDescendants = false; - int childCount = root.getChildCount(); - for (int i = 0; i < childCount; ++i) { - AccessibilityNodeInfoCompat child = root.getChild(i); - if (child != null) { - hasFocusableDescendants |= processNodes(child, forceRefresh); - } - } - - if (hasFocusableDescendants) { - containerNodes.add(root); - } - - return isFocusable || hasFocusableDescendants; - } - } - - @Override - public @Nullable AccessibilityNodeInfoCompat findFocus( - AccessibilityNodeInfoCompat startNode, int direction) { - if (startNode == null) { - return null; - } else if (startNode.equals(root)) { - return getFirstOrderedFocus(); - } - - Rect focusedRect = new Rect(); - getAssumedRectInScreen(startNode, focusedRect); - - return findFocusFromRect(startNode, focusedRect, direction); - } - - /** - * Searches the best candidate to focus in the given direction. - * - * @param focused The node which is currently accessibility-focused. - * @param focusedRect The coordinates from which to start the search from. This may be different - * from the actual coordinates of {@code focused}. - * @param direction The direction in which to search. - * @return Returns the best candidate to focus in the given direction or {@code null} if there is - * no such candidate. - */ - private @Nullable AccessibilityNodeInfoCompat findFocusFromRect( - AccessibilityNodeInfoCompat focused, Rect focusedRect, int direction) { - // Using roughly the same algorithm as - // frameworks/base/core/java/android/view/FocusFinder.java#findNextFocusInAbsoluteDirection - - Rect bestCandidateRect = new Rect(focusedRect); - switch (direction) { - case TraversalStrategy.SEARCH_FOCUS_LEFT -> - bestCandidateRect.offset(focusedRect.width() + 1, 0); - case TraversalStrategy.SEARCH_FOCUS_RIGHT -> - bestCandidateRect.offset(-(focusedRect.width() + 1), 0); - case TraversalStrategy.SEARCH_FOCUS_UP -> - bestCandidateRect.offset(0, focusedRect.height() + 1); - case TraversalStrategy.SEARCH_FOCUS_DOWN -> - bestCandidateRect.offset(0, -(focusedRect.height() + 1)); - default -> {} - } - - AccessibilityNodeInfoCompat closest = null; - for (AccessibilityNodeInfoCompat focusable : focusableNodes) { - // Skip the currently-focused view. - if (focusable.equals(focused) || focusable.equals(root)) { - continue; - } - - Rect otherRect = new Rect(); - getAssumedRectInScreen(focusable, otherRect); - - if (isBetterCandidate(direction, focusedRect, otherRect, bestCandidateRect)) { - bestCandidateRect.set(otherRect); - closest = focusable; - } - } - - return closest; - } - - /** - * Selects an item to focus when there is no current accessibility focus. - * - *

    Uses a two-pronged strategy. First tries to see if there is an input-focused node, and if - * so, returns that node. Otherwise, returns the item that an OrderedTraversalStrategy would first - * focus; this has the advantage of working nicely for both LTR and RTL users. - */ - private @Nullable AccessibilityNodeInfoCompat getFirstOrderedFocus() { - Filter filter = - new Filter() { - @Override - public boolean accept(AccessibilityNodeInfoCompat node) { - return node != null && focusableNodes.contains(node); - } - }; - - // 1. Attempt to find input-focused node. - AccessibilityNodeInfoCompat inputFocused = focusFinder.findFocusCompat(FOCUS_INPUT); - - AccessibilityNodeInfoCompat target = - AccessibilityNodeInfoUtils.getSelfOrMatchingAncestor(inputFocused, filter); - if (target != null) { - return target; - } - - // 2. Just use the OrderedTraversalStrategy. - final OrderedTraversalStrategy orderedStrategy = new OrderedTraversalStrategy(root); - - return TraversalStrategyUtils.searchFocus( - orderedStrategy, root, TraversalStrategy.SEARCH_FOCUS_FORWARD, filter); - } - - @Override - public @Nullable AccessibilityNodeInfoCompat focusFirst( - AccessibilityNodeInfoCompat root, int direction) { - if (root == null) { - return null; - } - - Rect rootRect = new Rect(); - root.getBoundsInScreen(rootRect); - - AccessibilityNodeInfoCompat focusedNode = focusFinder.findFocusCompat(FOCUS_ACCESSIBILITY); - - Rect searchRect = new Rect(); - if (focusedNode != null) { - getSearchStartRect(focusedNode, direction, searchRect); - } else if (direction == TraversalStrategy.SEARCH_FOCUS_LEFT) { - searchRect.set(rootRect.right, rootRect.top, rootRect.right + 1, rootRect.bottom); - } else if (direction == TraversalStrategy.SEARCH_FOCUS_RIGHT) { - searchRect.set(rootRect.left - 1, rootRect.top, rootRect.left, rootRect.bottom); - } else if (direction == TraversalStrategy.SEARCH_FOCUS_UP) { - searchRect.set(rootRect.left, rootRect.bottom, rootRect.right, rootRect.bottom + 1); - } else { - searchRect.set(rootRect.left, rootRect.top - 1, rootRect.right, rootRect.top); - } - - return findFocusFromRect(focusedNode, searchRect, direction); - } - - @Override - public Map getSpeakingNodesCache() { - return null; - } - - /** - * Returns the bounding rect of the given node for directional navigation purposes. Any node that - * is a container of a focusable node will be reduced to a strip at its very top edge. - */ - private void getAssumedRectInScreen(AccessibilityNodeInfoCompat node, Rect assumedRect) { - node.getBoundsInScreen(assumedRect); - if (containerNodes.contains(node)) { - assumedRect.set(assumedRect.left, assumedRect.top, assumedRect.right, assumedRect.top + 1); - } - } - - /** - * Given a focus rectangle, returns another rectangle that is placed at the beginning of the row - * or column of the focused object, depending on the direction in which we are navigating. - * - *

    Example: - * - *

    -   *  +---------+
    -   *  |         | node=#
    -   * A|      #  | When direction=TraversalStrategy.SEARCH_FOCUS_RIGHT, then a rectangle A with
    -   *  |         |   same width and height as node gets returned.
    -   *  |         | When direction=TraversalStrategy.SEARCH_FOCUS_UP, then a rectangle B with same
    -   *  +---------+   width and height as node gets returned.
    -   *         B
    -   * 
    - */ - private void getSearchStartRect(AccessibilityNodeInfoCompat node, int direction, Rect rect) { - Rect focusedRect = new Rect(); - node.getBoundsInScreen(focusedRect); - - Rect rootBounds = new Rect(); - root.getBoundsInScreen(rootBounds); - - switch (direction) { - case TraversalStrategy.SEARCH_FOCUS_LEFT -> - // Start from right and move leftwards. - rect.set( - rootBounds.right, - focusedRect.top, - rootBounds.right + focusedRect.width(), - focusedRect.bottom); - case TraversalStrategy.SEARCH_FOCUS_RIGHT -> - // Start from left and move rightwards. - rect.set( - rootBounds.left - focusedRect.width(), - focusedRect.top, - rootBounds.left, - focusedRect.bottom); - case TraversalStrategy.SEARCH_FOCUS_UP -> - // Start from bottom and move upwards. - rect.set( - focusedRect.left, - rootBounds.bottom, - focusedRect.right, - rootBounds.bottom + focusedRect.height()); - case TraversalStrategy.SEARCH_FOCUS_DOWN -> - // Start from top and move downwards. - rect.set( - focusedRect.left, - rootBounds.top - focusedRect.height(), - focusedRect.right, - rootBounds.top); - default -> throw new IllegalArgumentException("direction must be a SearchDirection"); - } - } - - /* - * BEGIN CODE COPIED FROM frameworks/base/core/java/android/view/FocusFinder.java - * These lines were last revised 2009-03-03 in revision 9066cfe9. - * Modifications from original: - * - Uses TraversalStrategy.SEARCH_FOCUS_* constants instead of View.FOCUS_* constants - * - getWeightedDistanceFor() returns MAX_VALUE for very large values to prevent overflow - */ - - /** - * Is rect1 a better candidate than rect2 for a focus search in a particular direction from a - * source rect? This is the core routine that determines the order of focus searching. - * - * @param direction the direction (up, down, left, right) - * @param source The source we are searching from - * @param rect1 The candidate rectangle - * @param rect2 The current best candidate. - * @return Whether the candidate is the new best. - */ - private boolean isBetterCandidate(int direction, Rect source, Rect rect1, Rect rect2) { - - // to be a better candidate, need to at least be a candidate in the first - // place :) - if (!isCandidate(source, rect1, direction)) { - return false; - } - - // we know that rect1 is a candidate.. if rect2 is not a candidate, - // rect1 is better - if (!isCandidate(source, rect2, direction)) { - return true; - } - - // if rect1 is better by beam, it wins - if (beamBeats(direction, source, rect1, rect2)) { - return true; - } - - // if rect2 is better, then rect1 can't be :) - if (beamBeats(direction, source, rect2, rect1)) { - return false; - } - - // otherwise, do fudge-tastic comparison of the major and minor axis - return (getWeightedDistanceFor( - majorAxisDistance(direction, source, rect1), - minorAxisDistance(direction, source, rect1)) - < getWeightedDistanceFor( - majorAxisDistance(direction, source, rect2), - minorAxisDistance(direction, source, rect2))); - } - - /** - * One rectangle may be another candidate than another by virtue of being exclusively in the beam - * of the source rect. - * - * @return Whether rect1 is a better candidate than rect2 by virtue of it being in src's beam - */ - private boolean beamBeats(int direction, Rect source, Rect rect1, Rect rect2) { - final boolean rect1InSrcBeam = beamsOverlap(direction, source, rect1); - final boolean rect2InSrcBeam = beamsOverlap(direction, source, rect2); - - // if rect1 isn't exclusively in the src beam, it doesn't win - if (rect2InSrcBeam || !rect1InSrcBeam) { - return false; - } - - // we know rect1 is in the beam, and rect2 is not - - // if rect1 is to the direction of, and rect2 is not, rect1 wins. - // for example, for direction left, if rect1 is to the left of the source - // and rect2 is below, then we always prefer the in beam rect1, since rect2 - // could be reached by going down. - if (!isToDirectionOf(direction, source, rect2)) { - return true; - } - - // for horizontal directions, being exclusively in beam always wins - if ((direction == TraversalStrategy.SEARCH_FOCUS_LEFT - || direction == TraversalStrategy.SEARCH_FOCUS_RIGHT)) { - return true; - } - - // for vertical directions, beams only beat up to a point: - // now, as long as rect2 isn't completely closer, rect1 wins - // e.g. for direction down, completely closer means for rect2's top - // edge to be closer to the source's top edge than rect1's bottom edge. - return (majorAxisDistance(direction, source, rect1) - < majorAxisDistanceToFarEdge(direction, source, rect2)); - } - - /** - * Fudge-factor opportunity: how to calculate distance given major and minor axis distances. - * Warning: this fudge factor is finely tuned, be sure to run all focus tests if you dare tweak - * it. - */ - private int getWeightedDistanceFor(int majorAxisDistance, int minorAxisDistance) { - if (majorAxisDistance > 10000 || minorAxisDistance > 10000) { - return Integer.MAX_VALUE; - } else { - // Won't overflow; max possible value = 1400000000 < Integer.MAX_VALUE. - return 13 * majorAxisDistance * majorAxisDistance + minorAxisDistance * minorAxisDistance; - } - } - - /** - * Is destRect a candidate for the next focus given the direction? This checks whether the dest is - * at least partially to the direction of (e.g left of) from source. - * - *

    Includes an edge case for an empty rect (which is used in some cases when searching from a - * point on the screen). - */ - private boolean isCandidate(Rect srcRect, Rect destRect, int direction) { - switch (direction) { - case TraversalStrategy.SEARCH_FOCUS_LEFT -> { - return (srcRect.right > destRect.right || srcRect.left >= destRect.right) - && srcRect.left > destRect.left; - } - case TraversalStrategy.SEARCH_FOCUS_RIGHT -> { - return (srcRect.left < destRect.left || srcRect.right <= destRect.left) - && srcRect.right < destRect.right; - } - case TraversalStrategy.SEARCH_FOCUS_UP -> { - return (srcRect.bottom > destRect.bottom || srcRect.top >= destRect.bottom) - && srcRect.top > destRect.top; - } - case TraversalStrategy.SEARCH_FOCUS_DOWN -> { - return (srcRect.top < destRect.top || srcRect.bottom <= destRect.top) - && srcRect.bottom < destRect.bottom; - } - default -> {} - } - throw new IllegalArgumentException("direction must be a SearchDirection"); - } - - /** - * Do the "beams" w.r.t the given direction's axis of rect1 and rect2 overlap? - * - * @param direction the direction (up, down, left, right) - * @param rect1 The first rectangle - * @param rect2 The second rectangle - * @return whether the beams overlap - */ - private boolean beamsOverlap(int direction, Rect rect1, Rect rect2) { - switch (direction) { - case TraversalStrategy.SEARCH_FOCUS_LEFT, TraversalStrategy.SEARCH_FOCUS_RIGHT -> { - return (rect2.bottom >= rect1.top) && (rect2.top <= rect1.bottom); - } - case TraversalStrategy.SEARCH_FOCUS_UP, TraversalStrategy.SEARCH_FOCUS_DOWN -> { - return (rect2.right >= rect1.left) && (rect2.left <= rect1.right); - } - default -> {} - } - throw new IllegalArgumentException("direction must be a SearchDirection"); - } - - /** e.g. for left, is 'to left of' */ - private boolean isToDirectionOf(int direction, Rect src, Rect dest) { - switch (direction) { - case TraversalStrategy.SEARCH_FOCUS_LEFT -> { - return src.left >= dest.right; - } - case TraversalStrategy.SEARCH_FOCUS_RIGHT -> { - return src.right <= dest.left; - } - case TraversalStrategy.SEARCH_FOCUS_UP -> { - return src.top >= dest.bottom; - } - case TraversalStrategy.SEARCH_FOCUS_DOWN -> { - return src.bottom <= dest.top; - } - default -> {} - } - throw new IllegalArgumentException("direction must be a SearchDirection"); - } - - /** - * @return The distance from the edge furthest in the given direction of source to the edge - * nearest in the given direction of dest. If the dest is not in the direction from source, - * return 0. - */ - static int majorAxisDistance(int direction, Rect source, Rect dest) { - return Math.max(0, majorAxisDistanceRaw(direction, source, dest)); - } - - static int majorAxisDistanceRaw(int direction, Rect source, Rect dest) { - switch (direction) { - case TraversalStrategy.SEARCH_FOCUS_LEFT -> { - return source.left - dest.right; - } - case TraversalStrategy.SEARCH_FOCUS_RIGHT -> { - return dest.left - source.right; - } - case TraversalStrategy.SEARCH_FOCUS_UP -> { - return source.top - dest.bottom; - } - case TraversalStrategy.SEARCH_FOCUS_DOWN -> { - return dest.top - source.bottom; - } - default -> {} - } - throw new IllegalArgumentException("direction must be a SearchDirection"); - } - - /** - * @return The distance along the major axis w.r.t the direction from the edge of source to the - * far edge of dest. If the dest is not in the direction from source, return 1 (to break ties - * with {@link #majorAxisDistance}). - */ - static int majorAxisDistanceToFarEdge(int direction, Rect source, Rect dest) { - return Math.max(1, majorAxisDistanceToFarEdgeRaw(direction, source, dest)); - } - - static int majorAxisDistanceToFarEdgeRaw(int direction, Rect source, Rect dest) { - switch (direction) { - case TraversalStrategy.SEARCH_FOCUS_LEFT -> { - return source.left - dest.left; - } - case TraversalStrategy.SEARCH_FOCUS_RIGHT -> { - return dest.right - source.right; - } - case TraversalStrategy.SEARCH_FOCUS_UP -> { - return source.top - dest.top; - } - case TraversalStrategy.SEARCH_FOCUS_DOWN -> { - return dest.bottom - source.bottom; - } - default -> {} - } - throw new IllegalArgumentException("direction must be a SearchDirection"); - } - - /** - * Find the distance on the minor axis w.r.t the direction to the nearest edge of the destination - * rectangle. - * - * @param direction the direction (up, down, left, right) - * @param source The source rect. - * @param dest The destination rect. - * @return The distance. - */ - static int minorAxisDistance(int direction, Rect source, Rect dest) { - switch (direction) { - case TraversalStrategy.SEARCH_FOCUS_LEFT, TraversalStrategy.SEARCH_FOCUS_RIGHT -> { - // the distance between the center verticals - return Math.abs(((source.top + source.height() / 2) - ((dest.top + dest.height() / 2)))); - } - case TraversalStrategy.SEARCH_FOCUS_UP, TraversalStrategy.SEARCH_FOCUS_DOWN -> { - // the distance between the center horizontals - return Math.abs(((source.left + source.width() / 2) - ((dest.left + dest.width() / 2)))); - } - default -> {} - } - throw new IllegalArgumentException("direction must be a SearchDirection"); - } - - /* END CODE COPIED FROM frameworks/base/core/java/android/view/FocusFinder.java */ - -} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/traversal/DirectionalTraversalStrategy.kt b/utils/src/main/java/com/google/android/accessibility/utils/traversal/DirectionalTraversalStrategy.kt new file mode 100644 index 000000000..e27bdfd1a --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/traversal/DirectionalTraversalStrategy.kt @@ -0,0 +1,579 @@ +/* + * Copyright (C) The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Ported from Java to Kotlin. + */ + +package com.google.android.accessibility.utils.traversal + +import android.graphics.Rect +import android.view.accessibility.AccessibilityNodeInfo.FOCUS_ACCESSIBILITY +import android.view.accessibility.AccessibilityNodeInfo.FOCUS_INPUT +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import com.google.android.accessibility.utils.AccessibilityNodeInfoUtils +import com.google.android.accessibility.utils.Filter +import com.google.android.accessibility.utils.FocusFinder +import com.google.android.accessibility.utils.WebInterfaceUtils +import kotlin.math.abs +import kotlin.math.max + +class DirectionalTraversalStrategy( + /** The root node within which to traverse. */ + private val root: AccessibilityNodeInfoCompat?, + /** Instance for finding Accessibility/Input focus. */ + private val focusFinder: FocusFinder?, +) : TraversalStrategy { + + /** The bounds of the root node, padded slightly for intersection checks. */ + private val rootRectPadded: Rect + + /** A set of all visited nodes in root's hierarchy. */ + private val visitedNodes: MutableSet = HashSet() + + /** A list of only focusable nodes. */ + private val focusableNodes: MutableList = ArrayList() + + /** The set of focusable nodes that have focusable descendants. */ + private val containerNodes: MutableSet = HashSet() + + /** Cache of nodes that have speech for use by AccessibilityNodeInfoUtils. */ + private val speakingNodesCache: MutableMap = HashMap() + + init { + // The cached on-screen bounds of the root node. + val rootRect = Rect() + this.root?.getBoundsInScreen(rootRect) + + val fudge = -(rootRect.width() / 20) // 5% fudge factor to catch objects near edge. + rootRectPadded = Rect(rootRect) + rootRectPadded.inset(fudge, fudge) + + processNodes(this.root, /* forceRefresh= */ false) + } + + /** + * Goes through root and its descendant nodes, sorting out the focusable nodes and the container + * nodes for use in finding focus. Does not re-process visitedNodes. + * + * @return whether the root is focusable or has focusable children in its hierarchy + */ + private fun processNodes(root: AccessibilityNodeInfoCompat?, forceRefresh: Boolean): Boolean { + if (root == null || visitedNodes.contains(root)) { + return false + } + + if (forceRefresh) { + root.refresh() + } + + val currentRect = Rect() + root.getBoundsInScreen(currentRect) + + // Determine if the node is inside rootRect (within a fudge factor). If it is outside, we + // will optimize by skipping its entire hierarchy. + if (!Rect.intersects(currentRect, rootRectPadded)) { + return false + } + + visitedNodes.add(root) + + // When we reach a node that supports web navigation, we traverse using the web navigation + // actions, so we should not add any of its descendants to the list of focusable nodes. + if (WebInterfaceUtils.hasNativeWebContent(root)) { + focusableNodes.add(root) + return true + } else { + val isFocusable = AccessibilityNodeInfoUtils.shouldFocusNode(root, speakingNodesCache) + if (isFocusable) { + focusableNodes.add(root) + } + + var hasFocusableDescendants = false + val childCount = root.childCount + for (i in 0 until childCount) { + val child = root.getChild(i) + if (child != null) { + hasFocusableDescendants = hasFocusableDescendants or processNodes(child, forceRefresh) + } + } + + if (hasFocusableDescendants) { + containerNodes.add(root) + } + + return isFocusable || hasFocusableDescendants + } + } + + override fun findFocus( + startNode: AccessibilityNodeInfoCompat?, + direction: Int, + ): AccessibilityNodeInfoCompat? { + if (startNode == null) { + return null + } else if (startNode == root) { + return getFirstOrderedFocus() + } + + val focusedRect = Rect() + getAssumedRectInScreen(startNode, focusedRect) + + return findFocusFromRect(startNode, focusedRect, direction) + } + + /** + * Searches the best candidate to focus in the given direction. + * + * @param focused The node which is currently accessibility-focused. + * @param focusedRect The coordinates from which to start the search from. This may be different + * from the actual coordinates of {@code focused}. + * @param direction The direction in which to search. + * @return Returns the best candidate to focus in the given direction or {@code null} if there is + * no such candidate. + */ + private fun findFocusFromRect( + focused: AccessibilityNodeInfoCompat?, + focusedRect: Rect, + direction: Int, + ): AccessibilityNodeInfoCompat? { + // Using roughly the same algorithm as + // frameworks/base/core/java/android/view/FocusFinder.java#findNextFocusInAbsoluteDirection + + val bestCandidateRect = Rect(focusedRect) + when (direction) { + TraversalStrategy.SEARCH_FOCUS_LEFT -> bestCandidateRect.offset(focusedRect.width() + 1, 0) + TraversalStrategy.SEARCH_FOCUS_RIGHT -> + bestCandidateRect.offset(-(focusedRect.width() + 1), 0) + TraversalStrategy.SEARCH_FOCUS_UP -> bestCandidateRect.offset(0, focusedRect.height() + 1) + TraversalStrategy.SEARCH_FOCUS_DOWN -> + bestCandidateRect.offset(0, -(focusedRect.height() + 1)) + else -> {} + } + + var closest: AccessibilityNodeInfoCompat? = null + for (focusable in focusableNodes) { + // Skip the currently-focused view. + if (focusable == focused || focusable == root) { + continue + } + + val otherRect = Rect() + getAssumedRectInScreen(focusable, otherRect) + + if (isBetterCandidate(direction, focusedRect, otherRect, bestCandidateRect)) { + bestCandidateRect.set(otherRect) + closest = focusable + } + } + + return closest + } + + /** + * Selects an item to focus when there is no current accessibility focus. + * + *

    Uses a two-pronged strategy. First tries to see if there is an input-focused node, and if + * so, returns that node. Otherwise, returns the item that an OrderedTraversalStrategy would first + * focus; this has the advantage of working nicely for both LTR and RTL users. + */ + private fun getFirstOrderedFocus(): AccessibilityNodeInfoCompat? { + val filter = + object : Filter() { + override fun accept(node: AccessibilityNodeInfoCompat?): Boolean { + return node != null && focusableNodes.contains(node) + } + } + + // 1. Attempt to find input-focused node. + val inputFocused = focusFinder!!.findFocusCompat(FOCUS_INPUT) + + val target = AccessibilityNodeInfoUtils.getSelfOrMatchingAncestor(inputFocused, filter) + if (target != null) { + return target + } + + // 2. Just use the OrderedTraversalStrategy. + val orderedStrategy = OrderedTraversalStrategy(root) + + return TraversalStrategyUtils.searchFocus( + orderedStrategy, root, TraversalStrategy.SEARCH_FOCUS_FORWARD, filter) + } + + override fun focusFirst( + root: AccessibilityNodeInfoCompat?, + direction: Int, + ): AccessibilityNodeInfoCompat? { + if (root == null) { + return null + } + + val rootRect = Rect() + root.getBoundsInScreen(rootRect) + + val focusedNode = focusFinder!!.findFocusCompat(FOCUS_ACCESSIBILITY) + + val searchRect = Rect() + if (focusedNode != null) { + getSearchStartRect(focusedNode, direction, searchRect) + } else if (direction == TraversalStrategy.SEARCH_FOCUS_LEFT) { + searchRect.set(rootRect.right, rootRect.top, rootRect.right + 1, rootRect.bottom) + } else if (direction == TraversalStrategy.SEARCH_FOCUS_RIGHT) { + searchRect.set(rootRect.left - 1, rootRect.top, rootRect.left, rootRect.bottom) + } else if (direction == TraversalStrategy.SEARCH_FOCUS_UP) { + searchRect.set(rootRect.left, rootRect.bottom, rootRect.right, rootRect.bottom + 1) + } else { + searchRect.set(rootRect.left, rootRect.top - 1, rootRect.right, rootRect.top) + } + + return findFocusFromRect(focusedNode, searchRect, direction) + } + + override fun getSpeakingNodesCache(): Map? = null + + /** + * Returns the bounding rect of the given node for directional navigation purposes. Any node that + * is a container of a focusable node will be reduced to a strip at its very top edge. + */ + private fun getAssumedRectInScreen(node: AccessibilityNodeInfoCompat, assumedRect: Rect) { + node.getBoundsInScreen(assumedRect) + if (containerNodes.contains(node)) { + assumedRect.set(assumedRect.left, assumedRect.top, assumedRect.right, assumedRect.top + 1) + } + } + + /** + * Given a focus rectangle, returns another rectangle that is placed at the beginning of the row + * or column of the focused object, depending on the direction in which we are navigating. + * + *

    Example: + * + *

    +   *  +---------+
    +   *  |         | node=#
    +   * A|      #  | When direction=TraversalStrategy.SEARCH_FOCUS_RIGHT, then a rectangle A with
    +   *  |         |   same width and height as node gets returned.
    +   *  |         | When direction=TraversalStrategy.SEARCH_FOCUS_UP, then a rectangle B with same
    +   *  +---------+   width and height as node gets returned.
    +   *         B
    +   * 
    + */ + private fun getSearchStartRect(node: AccessibilityNodeInfoCompat, direction: Int, rect: Rect) { + val focusedRect = Rect() + node.getBoundsInScreen(focusedRect) + + val rootBounds = Rect() + root!!.getBoundsInScreen(rootBounds) + + when (direction) { + TraversalStrategy.SEARCH_FOCUS_LEFT -> + // Start from right and move leftwards. + rect.set( + rootBounds.right, + focusedRect.top, + rootBounds.right + focusedRect.width(), + focusedRect.bottom, + ) + TraversalStrategy.SEARCH_FOCUS_RIGHT -> + // Start from left and move rightwards. + rect.set( + rootBounds.left - focusedRect.width(), + focusedRect.top, + rootBounds.left, + focusedRect.bottom, + ) + TraversalStrategy.SEARCH_FOCUS_UP -> + // Start from bottom and move upwards. + rect.set( + focusedRect.left, + rootBounds.bottom, + focusedRect.right, + rootBounds.bottom + focusedRect.height(), + ) + TraversalStrategy.SEARCH_FOCUS_DOWN -> + // Start from top and move downwards. + rect.set( + focusedRect.left, + rootBounds.top - focusedRect.height(), + focusedRect.right, + rootBounds.top, + ) + else -> throw IllegalArgumentException("direction must be a SearchDirection") + } + } + + /* + * BEGIN CODE COPIED FROM frameworks/base/core/java/android/view/FocusFinder.java + * These lines were last revised 2009-03-03 in revision 9066cfe9. + * Modifications from original: + * - Uses TraversalStrategy.SEARCH_FOCUS_* constants instead of View.FOCUS_* constants + * - getWeightedDistanceFor() returns MAX_VALUE for very large values to prevent overflow + */ + + /** + * Is rect1 a better candidate than rect2 for a focus search in a particular direction from a + * source rect? This is the core routine that determines the order of focus searching. + * + * @param direction the direction (up, down, left, right) + * @param source The source we are searching from + * @param rect1 The candidate rectangle + * @param rect2 The current best candidate. + * @return Whether the candidate is the new best. + */ + private fun isBetterCandidate(direction: Int, source: Rect, rect1: Rect, rect2: Rect): Boolean { + // to be a better candidate, need to at least be a candidate in the first + // place :) + if (!isCandidate(source, rect1, direction)) { + return false + } + + // we know that rect1 is a candidate.. if rect2 is not a candidate, + // rect1 is better + if (!isCandidate(source, rect2, direction)) { + return true + } + + // if rect1 is better by beam, it wins + if (beamBeats(direction, source, rect1, rect2)) { + return true + } + + // if rect2 is better, then rect1 can't be :) + if (beamBeats(direction, source, rect2, rect1)) { + return false + } + + // otherwise, do fudge-tastic comparison of the major and minor axis + return (getWeightedDistanceFor( + majorAxisDistance(direction, source, rect1), + minorAxisDistance(direction, source, rect1), + ) < + getWeightedDistanceFor( + majorAxisDistance(direction, source, rect2), + minorAxisDistance(direction, source, rect2), + )) + } + + /** + * One rectangle may be another candidate than another by virtue of being exclusively in the beam + * of the source rect. + * + * @return Whether rect1 is a better candidate than rect2 by virtue of it being in src's beam + */ + private fun beamBeats(direction: Int, source: Rect, rect1: Rect, rect2: Rect): Boolean { + val rect1InSrcBeam = beamsOverlap(direction, source, rect1) + val rect2InSrcBeam = beamsOverlap(direction, source, rect2) + + // if rect1 isn't exclusively in the src beam, it doesn't win + if (rect2InSrcBeam || !rect1InSrcBeam) { + return false + } + + // we know rect1 is in the beam, and rect2 is not + + // if rect1 is to the direction of, and rect2 is not, rect1 wins. + // for example, for direction left, if rect1 is to the left of the source + // and rect2 is below, then we always prefer the in beam rect1, since rect2 + // could be reached by going down. + if (!isToDirectionOf(direction, source, rect2)) { + return true + } + + // for horizontal directions, being exclusively in beam always wins + if (direction == TraversalStrategy.SEARCH_FOCUS_LEFT || + direction == TraversalStrategy.SEARCH_FOCUS_RIGHT + ) { + return true + } + + // for vertical directions, beams only beat up to a point: + // now, as long as rect2 isn't completely closer, rect1 wins + // e.g. for direction down, completely closer means for rect2's top + // edge to be closer to the source's top edge than rect1's bottom edge. + return (majorAxisDistance(direction, source, rect1) < + majorAxisDistanceToFarEdge(direction, source, rect2)) + } + + /** + * Fudge-factor opportunity: how to calculate distance given major and minor axis distances. + * Warning: this fudge factor is finely tuned, be sure to run all focus tests if you dare tweak + * it. + */ + private fun getWeightedDistanceFor(majorAxisDistance: Int, minorAxisDistance: Int): Int { + return if (majorAxisDistance > 10000 || minorAxisDistance > 10000) { + Int.MAX_VALUE + } else { + // Won't overflow; max possible value = 1400000000 < Integer.MAX_VALUE. + 13 * majorAxisDistance * majorAxisDistance + minorAxisDistance * minorAxisDistance + } + } + + /** + * Is destRect a candidate for the next focus given the direction? This checks whether the dest is + * at least partially to the direction of (e.g left of) from source. + * + *

    Includes an edge case for an empty rect (which is used in some cases when searching from a + * point on the screen). + */ + private fun isCandidate(srcRect: Rect, destRect: Rect, direction: Int): Boolean { + when (direction) { + TraversalStrategy.SEARCH_FOCUS_LEFT -> { + return (srcRect.right > destRect.right || srcRect.left >= destRect.right) && + srcRect.left > destRect.left + } + TraversalStrategy.SEARCH_FOCUS_RIGHT -> { + return (srcRect.left < destRect.left || srcRect.right <= destRect.left) && + srcRect.right < destRect.right + } + TraversalStrategy.SEARCH_FOCUS_UP -> { + return (srcRect.bottom > destRect.bottom || srcRect.top >= destRect.bottom) && + srcRect.top > destRect.top + } + TraversalStrategy.SEARCH_FOCUS_DOWN -> { + return (srcRect.top < destRect.top || srcRect.bottom <= destRect.top) && + srcRect.bottom < destRect.bottom + } + else -> {} + } + throw IllegalArgumentException("direction must be a SearchDirection") + } + + /** + * Do the "beams" w.r.t the given direction's axis of rect1 and rect2 overlap? + * + * @param direction the direction (up, down, left, right) + * @param rect1 The first rectangle + * @param rect2 The second rectangle + * @return whether the beams overlap + */ + private fun beamsOverlap(direction: Int, rect1: Rect, rect2: Rect): Boolean { + when (direction) { + TraversalStrategy.SEARCH_FOCUS_LEFT, TraversalStrategy.SEARCH_FOCUS_RIGHT -> { + return (rect2.bottom >= rect1.top) && (rect2.top <= rect1.bottom) + } + TraversalStrategy.SEARCH_FOCUS_UP, TraversalStrategy.SEARCH_FOCUS_DOWN -> { + return (rect2.right >= rect1.left) && (rect2.left <= rect1.right) + } + else -> {} + } + throw IllegalArgumentException("direction must be a SearchDirection") + } + + /** e.g. for left, is 'to left of' */ + private fun isToDirectionOf(direction: Int, src: Rect, dest: Rect): Boolean { + when (direction) { + TraversalStrategy.SEARCH_FOCUS_LEFT -> { + return src.left >= dest.right + } + TraversalStrategy.SEARCH_FOCUS_RIGHT -> { + return src.right <= dest.left + } + TraversalStrategy.SEARCH_FOCUS_UP -> { + return src.top >= dest.bottom + } + TraversalStrategy.SEARCH_FOCUS_DOWN -> { + return src.bottom <= dest.top + } + else -> {} + } + throw IllegalArgumentException("direction must be a SearchDirection") + } + + companion object { + /** + * @return The distance from the edge furthest in the given direction of source to the edge + * nearest in the given direction of dest. If the dest is not in the direction from source, + * return 0. + */ + @JvmStatic + internal fun majorAxisDistance(direction: Int, source: Rect, dest: Rect): Int = + max(0, majorAxisDistanceRaw(direction, source, dest)) + + @JvmStatic + internal fun majorAxisDistanceRaw(direction: Int, source: Rect, dest: Rect): Int { + when (direction) { + TraversalStrategy.SEARCH_FOCUS_LEFT -> { + return source.left - dest.right + } + TraversalStrategy.SEARCH_FOCUS_RIGHT -> { + return dest.left - source.right + } + TraversalStrategy.SEARCH_FOCUS_UP -> { + return source.top - dest.bottom + } + TraversalStrategy.SEARCH_FOCUS_DOWN -> { + return dest.top - source.bottom + } + else -> {} + } + throw IllegalArgumentException("direction must be a SearchDirection") + } + + /** + * @return The distance along the major axis w.r.t the direction from the edge of source to the + * far edge of dest. If the dest is not in the direction from source, return 1 (to break + * ties with {@link #majorAxisDistance}). + */ + @JvmStatic + internal fun majorAxisDistanceToFarEdge(direction: Int, source: Rect, dest: Rect): Int = + max(1, majorAxisDistanceToFarEdgeRaw(direction, source, dest)) + + @JvmStatic + internal fun majorAxisDistanceToFarEdgeRaw(direction: Int, source: Rect, dest: Rect): Int { + when (direction) { + TraversalStrategy.SEARCH_FOCUS_LEFT -> { + return source.left - dest.left + } + TraversalStrategy.SEARCH_FOCUS_RIGHT -> { + return dest.right - source.right + } + TraversalStrategy.SEARCH_FOCUS_UP -> { + return source.top - dest.top + } + TraversalStrategy.SEARCH_FOCUS_DOWN -> { + return dest.bottom - source.bottom + } + else -> {} + } + throw IllegalArgumentException("direction must be a SearchDirection") + } + + /** + * Find the distance on the minor axis w.r.t the direction to the nearest edge of the + * destination rectangle. + * + * @param direction the direction (up, down, left, right) + * @param source The source rect. + * @param dest The destination rect. + * @return The distance. + */ + @JvmStatic + internal fun minorAxisDistance(direction: Int, source: Rect, dest: Rect): Int { + when (direction) { + TraversalStrategy.SEARCH_FOCUS_LEFT, TraversalStrategy.SEARCH_FOCUS_RIGHT -> { + // the distance between the center verticals + return abs((source.top + source.height() / 2) - (dest.top + dest.height() / 2)) + } + TraversalStrategy.SEARCH_FOCUS_UP, TraversalStrategy.SEARCH_FOCUS_DOWN -> { + // the distance between the center horizontals + return abs((source.left + source.width() / 2) - (dest.left + dest.width() / 2)) + } + else -> {} + } + throw IllegalArgumentException("direction must be a SearchDirection") + } + } + + /* END CODE COPIED FROM frameworks/base/core/java/android/view/FocusFinder.java */ +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/traversal/GridTraversalManager.java b/utils/src/main/java/com/google/android/accessibility/utils/traversal/GridTraversalManager.java deleted file mode 100644 index bdb1dc7da..000000000 --- a/utils/src/main/java/com/google/android/accessibility/utils/traversal/GridTraversalManager.java +++ /dev/null @@ -1,118 +0,0 @@ -package com.google.android.accessibility.utils.traversal; - -import static com.google.android.accessibility.utils.AccessibilityNodeInfoUtils.hasAncestor; - -import android.util.Pair; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; -import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat; -import com.google.android.accessibility.utils.AccessibilityNodeInfoUtils; -import com.google.android.accessibility.utils.Role; -import com.google.android.libraries.accessibility.utils.log.LogUtils; - -/** Utility class for managing traversal in grids. */ -public class GridTraversalManager { - private static final String TAG = "GridTraversalManager"; - - // Prevent instantiation. - private GridTraversalManager() {} - - /** - * Uses the {@link AccessibilityNodeInfoCompat.CollectionInfoCompat} of the grid, the {@link - * AccessibilityNodeInfoCompat.CollectionItemInfoCompat} of the view currently holding - * accessibility focus, and the {@link AccessibilityNodeInfoCompat.CollectionItemInfoCompat} of - * the view targeted for accessibility focus to evaluate the correctness of the current target and - * optionally recommend a different target node for accessibility focus. - * - * @param gridNode The node representing the grid. - * @param currentNode The node representing a cell within the grid which currently holds - * accessibility focus. - * @param targetNode The node representing a cell within the grid which is targeted for - * accessibility focus. - * @param searchDirection The search direction for finding the target node. - * @return The row, column positions of the suggested target node, or null if either the {@link - * AccessibilityNodeInfoCompat.CollectionInfoCompat} or {@link - * AccessibilityNodeInfoCompat.CollectionItemInfoCompat} are absent or incomplete, or if the - * grid does not support {@link - * android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction#ACTION_SCROLL_TO_POSITION}, - * or if {@code target} is already correct. - */ - @Nullable - public static Pair suggestOffScreenTarget( - @NonNull AccessibilityNodeInfoCompat gridNode, - @NonNull AccessibilityNodeInfoCompat currentNode, - @NonNull AccessibilityNodeInfoCompat targetNode, - int searchDirection) { - Pair alternateTarget = - getAlternateTarget(gridNode, currentNode, targetNode, searchDirection); - if (alternateTarget != null) { - int targetRow = targetNode.getCollectionItemInfo().getRowIndex(); - int targetColumn = targetNode.getCollectionItemInfo().getColumnIndex(); - // Ensure that the suggested target is different from the existing target. - if (targetRow != alternateTarget.first || targetColumn != alternateTarget.second) { - return alternateTarget; - } - LogUtils.d( - TAG, "No need to scroll because suggested row/column match the current " + "target"); - } - LogUtils.d(TAG, "No suggested target for scrolling"); - return null; - } - - @Nullable - private static Pair getAlternateTarget( - @NonNull AccessibilityNodeInfoCompat gridNode, - @NonNull AccessibilityNodeInfoCompat currentNode, - @NonNull AccessibilityNodeInfoCompat targetNode, - int searchDirection) { - if (!checkPreconditions(gridNode, currentNode, targetNode)) { - return null; - } - - int numRows = gridNode.getCollectionInfo().getRowCount(); - int numColumns = gridNode.getCollectionInfo().getColumnCount(); - int currentRow = currentNode.getCollectionItemInfo().getRowIndex(); - int currentColumn = currentNode.getCollectionItemInfo().getColumnIndex(); - - switch (searchDirection) { - case TraversalStrategy.SEARCH_FOCUS_FORWARD -> { - if (currentColumn + 1 == numColumns) { - if (currentRow + 1 == numRows) { - // End of grid. - return null; - } - // Send to the beginning of the next row. - return Pair.create(currentRow + 1, 0); - } - return Pair.create(currentRow, currentColumn + 1); - } - case TraversalStrategy.SEARCH_FOCUS_BACKWARD -> { - if (currentColumn == 0) { - if (currentRow == 0) { - // Beginning of grid. - return null; - } - // Send to the end of the previous row. - return Pair.create(currentRow - 1, numColumns - 1); - } - return Pair.create(currentRow, currentColumn - 1); - } - default -> { - return null; - } - } - } - - private static boolean checkPreconditions( - @NonNull AccessibilityNodeInfoCompat gridNode, - @NonNull AccessibilityNodeInfoCompat currentNode, - @NonNull AccessibilityNodeInfoCompat targetNode) { - return hasAncestor(currentNode, gridNode) - && hasAncestor(targetNode, gridNode) - && AccessibilityNodeInfoUtils.hasUsableCollectionItemInfo(currentNode, gridNode) - && AccessibilityNodeInfoUtils.hasUsableCollectionItemInfo(targetNode, gridNode) - && gridNode.getActionList().contains(AccessibilityActionCompat.ACTION_SCROLL_TO_POSITION) - && Role.getRole(gridNode) == Role.ROLE_GRID; - } -} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/traversal/GridTraversalManager.kt b/utils/src/main/java/com/google/android/accessibility/utils/traversal/GridTraversalManager.kt new file mode 100644 index 000000000..affba371a --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/traversal/GridTraversalManager.kt @@ -0,0 +1,127 @@ +/* + * Copyright (C) The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Ported from Java to Kotlin. + */ + +package com.google.android.accessibility.utils.traversal + +import android.util.Pair +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat +import com.google.android.accessibility.utils.AccessibilityNodeInfoUtils +import com.google.android.accessibility.utils.AccessibilityNodeInfoUtils.hasAncestor +import com.google.android.accessibility.utils.Role +import com.google.android.libraries.accessibility.utils.log.LogUtils + +/** Utility class for managing traversal in grids. */ +object GridTraversalManager { + private const val TAG = "GridTraversalManager" + + /** + * Uses the {@link AccessibilityNodeInfoCompat.CollectionInfoCompat} of the grid, the {@link + * AccessibilityNodeInfoCompat.CollectionItemInfoCompat} of the view currently holding + * accessibility focus, and the {@link AccessibilityNodeInfoCompat.CollectionItemInfoCompat} of + * the view targeted for accessibility focus to evaluate the correctness of the current target and + * optionally recommend a different target node for accessibility focus. + * + * @param gridNode The node representing the grid. + * @param currentNode The node representing a cell within the grid which currently holds + * accessibility focus. + * @param targetNode The node representing a cell within the grid which is targeted for + * accessibility focus. + * @param searchDirection The search direction for finding the target node. + * @return The row, column positions of the suggested target node, or null if either the {@link + * AccessibilityNodeInfoCompat.CollectionInfoCompat} or {@link + * AccessibilityNodeInfoCompat.CollectionItemInfoCompat} are absent or incomplete, or if the + * grid does not support {@link + * android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction#ACTION_SCROLL_TO_POSITION}, + * or if {@code target} is already correct. + */ + @JvmStatic + fun suggestOffScreenTarget( + gridNode: AccessibilityNodeInfoCompat, + currentNode: AccessibilityNodeInfoCompat, + targetNode: AccessibilityNodeInfoCompat, + searchDirection: Int, + ): Pair? { + val alternateTarget = getAlternateTarget(gridNode, currentNode, targetNode, searchDirection) + if (alternateTarget != null) { + val targetRow = targetNode.collectionItemInfo!!.rowIndex + val targetColumn = targetNode.collectionItemInfo!!.columnIndex + // Ensure that the suggested target is different from the existing target. + if (targetRow != alternateTarget.first || targetColumn != alternateTarget.second) { + return alternateTarget + } + LogUtils.d(TAG, "No need to scroll because suggested row/column match the current " + "target") + } + LogUtils.d(TAG, "No suggested target for scrolling") + return null + } + + private fun getAlternateTarget( + gridNode: AccessibilityNodeInfoCompat, + currentNode: AccessibilityNodeInfoCompat, + targetNode: AccessibilityNodeInfoCompat, + searchDirection: Int, + ): Pair? { + if (!checkPreconditions(gridNode, currentNode, targetNode)) { + return null + } + + val numRows = gridNode.collectionInfo!!.rowCount + val numColumns = gridNode.collectionInfo!!.columnCount + val currentRow = currentNode.collectionItemInfo!!.rowIndex + val currentColumn = currentNode.collectionItemInfo!!.columnIndex + + return when (searchDirection) { + TraversalStrategy.SEARCH_FOCUS_FORWARD -> { + if (currentColumn + 1 == numColumns) { + if (currentRow + 1 == numRows) { + // End of grid. + return null + } + // Send to the beginning of the next row. + return Pair.create(currentRow + 1, 0) + } + Pair.create(currentRow, currentColumn + 1) + } + TraversalStrategy.SEARCH_FOCUS_BACKWARD -> { + if (currentColumn == 0) { + if (currentRow == 0) { + // Beginning of grid. + return null + } + // Send to the end of the previous row. + return Pair.create(currentRow - 1, numColumns - 1) + } + Pair.create(currentRow, currentColumn - 1) + } + else -> null + } + } + + private fun checkPreconditions( + gridNode: AccessibilityNodeInfoCompat, + currentNode: AccessibilityNodeInfoCompat, + targetNode: AccessibilityNodeInfoCompat, + ): Boolean = + hasAncestor(currentNode, gridNode) && + hasAncestor(targetNode, gridNode) && + AccessibilityNodeInfoUtils.hasUsableCollectionItemInfo(currentNode, gridNode) && + AccessibilityNodeInfoUtils.hasUsableCollectionItemInfo(targetNode, gridNode) && + gridNode.actionList.contains(AccessibilityActionCompat.ACTION_SCROLL_TO_POSITION) && + Role.getRole(gridNode) == Role.ROLE_GRID +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/traversal/NodeCachedBoundsCalculator.java b/utils/src/main/java/com/google/android/accessibility/utils/traversal/NodeCachedBoundsCalculator.java deleted file mode 100644 index e0274fe43..000000000 --- a/utils/src/main/java/com/google/android/accessibility/utils/traversal/NodeCachedBoundsCalculator.java +++ /dev/null @@ -1,153 +0,0 @@ -/* - * Copyright (C) 2015 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not - * use this file except in compliance with the License. You may obtain a copy of - * the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations under - * the License. - */ - -package com.google.android.accessibility.utils.traversal; - -import android.graphics.Rect; -import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; -import com.google.android.accessibility.utils.AccessibilityNodeInfoUtils; -import com.google.android.libraries.accessibility.utils.log.LogUtils; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Map; -import java.util.Set; -import org.checkerframework.checker.nullness.qual.Nullable; - -/** - * Calculates the utility bounds of the node. If node is not supposed to get accessibility focus the - * utility bounds is calculated on the base of minimum rect that contains all accessibility - * focusable nodes inside node hierarchy rooted by this node. - */ -public class NodeCachedBoundsCalculator { - - private static final String TAG = "NodeCachedBoundsCalculator"; - - private static final Rect EMPTY_RECT = new Rect(); - - private final Map boundsMap = new HashMap<>(); - private final Set calculatingNodes = new HashSet<>(); - private final Rect tempRect = new Rect(); - private Map speakingNodesCache; - - public void setSpeakingNodesCache(Map speakingNodesCache) { - this.speakingNodesCache = speakingNodesCache; - } - - public @Nullable Rect getBounds(AccessibilityNodeInfoCompat node) { - Rect bounds = getBoundsInternal(node); - if (bounds.equals(EMPTY_RECT)) { - return null; - } - - return bounds; - } - - private Rect getBoundsInternal(AccessibilityNodeInfoCompat node) { - if (node == null) { - return EMPTY_RECT; - } - - if (calculatingNodes.contains(node)) { - LogUtils.w(TAG, "node tree loop detected while calculating node bounds"); - return EMPTY_RECT; - } - - Rect bounds = boundsMap.get(node); - if (bounds == null) { - calculatingNodes.add(node); - bounds = fetchBound(node); - boundsMap.put(node, bounds); - calculatingNodes.remove(node); - } - - return bounds; - } - - private Rect fetchBound(AccessibilityNodeInfoCompat node) { - if (node == null || !AccessibilityNodeInfoUtils.isVisible(node)) { - return EMPTY_RECT; - } - - if (AccessibilityNodeInfoUtils.shouldFocusNode(node, speakingNodesCache)) { - Rect bounds = new Rect(); - node.getBoundsInScreen(bounds); - return bounds; - } - - int childCount = node.getChildCount(); - int minTop = Integer.MAX_VALUE; - int minLeft = Integer.MAX_VALUE; - int maxBottom = Integer.MIN_VALUE; - int maxRight = Integer.MIN_VALUE; - AccessibilityNodeInfoCompat child = null; - boolean hasChildBounds = false; - for (int i = 0; i < childCount; i++) { - try { - child = node.getChild(i); - Rect bounds = getBoundsInternal(child); - if (!bounds.equals(EMPTY_RECT)) { - hasChildBounds = true; - if (bounds.top < minTop) { - minTop = bounds.top; - } - - if (bounds.left < minLeft) { - minLeft = bounds.left; - } - - if (bounds.right > maxRight) { - maxRight = bounds.right; - } - - if (bounds.bottom > maxBottom) { - maxBottom = bounds.bottom; - } - } - } finally { - } - } - - Rect bounds = new Rect(); - node.getBoundsInScreen(bounds); - if (hasChildBounds) { - bounds.top = Math.max(minTop, bounds.top); - bounds.left = Math.max(minLeft, bounds.left); - bounds.right = Math.min(maxRight, bounds.right); - bounds.bottom = Math.min(maxBottom, bounds.bottom); - } - - return bounds; - } - - /** - * If node is not supposed to be accessibility focused by TalkBack NodeBoundsCalculator calculates - * useful bounds of focusable children. The method checks if the node uses its children useful - * bounds or uses its own bounds - */ - public boolean usesChildrenBounds(AccessibilityNodeInfoCompat node) { - if (node == null) { - return false; - } - - Rect bounds = getBounds(node); - if (bounds == null) { - return false; - } - - node.getBoundsInScreen(tempRect); - return !tempRect.equals(bounds); - } -} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/traversal/NodeCachedBoundsCalculator.kt b/utils/src/main/java/com/google/android/accessibility/utils/traversal/NodeCachedBoundsCalculator.kt new file mode 100644 index 000000000..0484079ed --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/traversal/NodeCachedBoundsCalculator.kt @@ -0,0 +1,148 @@ +/* + * Copyright (C) The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Ported from Java to Kotlin. + */ + +package com.google.android.accessibility.utils.traversal + +import android.graphics.Rect +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import com.google.android.accessibility.utils.AccessibilityNodeInfoUtils +import com.google.android.libraries.accessibility.utils.log.LogUtils +import kotlin.math.max +import kotlin.math.min + +/** + * Calculates the utility bounds of the node. If node is not supposed to get accessibility focus the + * utility bounds is calculated on the base of minimum rect that contains all accessibility + * focusable nodes inside node hierarchy rooted by this node. + */ +class NodeCachedBoundsCalculator { + + private val boundsMap: MutableMap = HashMap() + private val calculatingNodes: MutableSet = HashSet() + private val tempRect = Rect() + private var speakingNodesCache: MutableMap? = null + + fun setSpeakingNodesCache(speakingNodesCache: MutableMap?) { + this.speakingNodesCache = speakingNodesCache + } + + fun getBounds(node: AccessibilityNodeInfoCompat?): Rect? { + val bounds = getBoundsInternal(node) + if (bounds == EMPTY_RECT) { + return null + } + + return bounds + } + + private fun getBoundsInternal(node: AccessibilityNodeInfoCompat?): Rect { + if (node == null) { + return EMPTY_RECT + } + + if (calculatingNodes.contains(node)) { + LogUtils.w(TAG, "node tree loop detected while calculating node bounds") + return EMPTY_RECT + } + + var bounds = boundsMap[node] + if (bounds == null) { + calculatingNodes.add(node) + bounds = fetchBound(node) + boundsMap[node] = bounds + calculatingNodes.remove(node) + } + + return bounds + } + + private fun fetchBound(node: AccessibilityNodeInfoCompat?): Rect { + if (node == null || !AccessibilityNodeInfoUtils.isVisible(node)) { + return EMPTY_RECT + } + + if (AccessibilityNodeInfoUtils.shouldFocusNode(node, speakingNodesCache)) { + val bounds = Rect() + node.getBoundsInScreen(bounds) + return bounds + } + + val childCount = node.childCount + var minTop = Int.MAX_VALUE + var minLeft = Int.MAX_VALUE + var maxBottom = Int.MIN_VALUE + var maxRight = Int.MIN_VALUE + var child: AccessibilityNodeInfoCompat? + var hasChildBounds = false + for (i in 0 until childCount) { + child = node.getChild(i) + val bounds = getBoundsInternal(child) + if (bounds != EMPTY_RECT) { + hasChildBounds = true + if (bounds.top < minTop) { + minTop = bounds.top + } + + if (bounds.left < minLeft) { + minLeft = bounds.left + } + + if (bounds.right > maxRight) { + maxRight = bounds.right + } + + if (bounds.bottom > maxBottom) { + maxBottom = bounds.bottom + } + } + } + + val bounds = Rect() + node.getBoundsInScreen(bounds) + if (hasChildBounds) { + bounds.top = max(minTop, bounds.top) + bounds.left = max(minLeft, bounds.left) + bounds.right = min(maxRight, bounds.right) + bounds.bottom = min(maxBottom, bounds.bottom) + } + + return bounds + } + + /** + * If node is not supposed to be accessibility focused by TalkBack NodeBoundsCalculator calculates + * useful bounds of focusable children. The method checks if the node uses its children useful + * bounds or uses its own bounds + */ + fun usesChildrenBounds(node: AccessibilityNodeInfoCompat?): Boolean { + if (node == null) { + return false + } + + val bounds = getBounds(node) ?: return false + + node.getBoundsInScreen(tempRect) + return tempRect != bounds + } + + private companion object { + const val TAG = "NodeCachedBoundsCalculator" + + val EMPTY_RECT = Rect() + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/traversal/NodeFocusFinder.java b/utils/src/main/java/com/google/android/accessibility/utils/traversal/NodeFocusFinder.kt similarity index 58% rename from utils/src/main/java/com/google/android/accessibility/utils/traversal/NodeFocusFinder.java rename to utils/src/main/java/com/google/android/accessibility/utils/traversal/NodeFocusFinder.kt index aa2deb29a..b230ccd30 100644 --- a/utils/src/main/java/com/google/android/accessibility/utils/traversal/NodeFocusFinder.java +++ b/utils/src/main/java/com/google/android/accessibility/utils/traversal/NodeFocusFinder.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2012 Google Inc. + * Copyright (C) The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -12,17 +12,18 @@ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. + * + * Ported from Java to Kotlin. */ -package com.google.android.accessibility.utils.traversal; +package com.google.android.accessibility.utils.traversal -import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; -import com.google.android.accessibility.utils.AccessibilityNodeInfoRef; -import org.checkerframework.checker.nullness.qual.Nullable; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import com.google.android.accessibility.utils.AccessibilityNodeInfoRef -public class NodeFocusFinder { - public static final int SEARCH_FORWARD = 1; - public static final int SEARCH_BACKWARD = -1; +object NodeFocusFinder { + const val SEARCH_FORWARD = 1 + const val SEARCH_BACKWARD = -1 /** * Perform in-order navigation from a given node in a particular direction. @@ -31,29 +32,29 @@ public class NodeFocusFinder { * @param direction The direction to travel. * @return The next node in the specified direction, or {@code null} if there are no more nodes. */ - public static @Nullable AccessibilityNodeInfoCompat focusSearch( - AccessibilityNodeInfoCompat node, int direction) { - final AccessibilityNodeInfoRef ref = AccessibilityNodeInfoRef.unOwned(node); - if (ref == null) { - return null; - } + @JvmStatic + fun focusSearch( + node: AccessibilityNodeInfoCompat?, + direction: Int, + ): AccessibilityNodeInfoCompat? { + val ref = AccessibilityNodeInfoRef.unOwned(node) ?: return null - switch (direction) { - case SEARCH_FORWARD -> { + when (direction) { + SEARCH_FORWARD -> { if (!ref.nextInOrder()) { - return null; + return null } - return ref.release(); + return ref.release() } - case SEARCH_BACKWARD -> { + SEARCH_BACKWARD -> { if (!ref.previousInOrder()) { - return null; + return null } - return ref.release(); + return ref.release() } - default -> {} + else -> {} } - return null; + return null } } diff --git a/utils/src/main/java/com/google/android/accessibility/utils/traversal/OrderedTraversalController.java b/utils/src/main/java/com/google/android/accessibility/utils/traversal/OrderedTraversalController.java deleted file mode 100644 index 0fb1192dc..000000000 --- a/utils/src/main/java/com/google/android/accessibility/utils/traversal/OrderedTraversalController.java +++ /dev/null @@ -1,374 +0,0 @@ -/* - * Copyright (C) 2015 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not - * use this file except in compliance with the License. You may obtain a copy of - * the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations under - * the License. - */ - -package com.google.android.accessibility.utils.traversal; - -import androidx.annotation.NonNull; -import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; -import com.google.android.accessibility.utils.AccessibilityNodeInfoUtils; -import com.google.android.accessibility.utils.Role; -import com.google.android.accessibility.utils.WebInterfaceUtils; -import com.google.android.libraries.accessibility.utils.log.LogUtils; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import org.checkerframework.checker.nullness.qual.Nullable; - -public class OrderedTraversalController { - - private static final String TAG = "OrderedTraversalCont"; - - private Map speakingNodesCache; - private @Nullable WorkingTree tree; - private final Map nodeTreeMap; - private @Nullable AccessibilityNodeInfoCompat initialFocusNode; - private final boolean makeFabFirst; - - public OrderedTraversalController() { - this(false); - } - - public OrderedTraversalController(boolean makeFabFirst) { - nodeTreeMap = new LinkedHashMap<>(); - this.makeFabFirst = makeFabFirst; - } - - public void setSpeakingNodesCache(Map speakingNodesCache) { - this.speakingNodesCache = speakingNodesCache; - } - - /** - * before start next traversal node search the controller must be initialized. The initialisation - * step includes traversal through all accessibility nodes hierarchy to collect information about - * traversal order of separate subtrees and moving subtries that has custom before/after traverse - * view order - * - * @param compatRoot - accessibility node that serves as root node for tree hierarchy the - * controller works with - * @param includeChildrenOfNodesWithWebActions whether to calculator order for nodes that support - * web actions. Although TalkBack uses the naviagation order specified by the nodes, Switch - * Access needs to know about all nodes at the time the tree is being created. - */ - public void initOrder( - @Nullable AccessibilityNodeInfoCompat compatRoot, - boolean includeChildrenOfNodesWithWebActions) { - if (compatRoot == null) { - return; - } - - NodeCachedBoundsCalculator boundsCalculator = new NodeCachedBoundsCalculator(); - boundsCalculator.setSpeakingNodesCache(speakingNodesCache); - tree = - createWorkingTree(compatRoot, null, boundsCalculator, includeChildrenOfNodesWithWebActions); - reorderTree(compatRoot); - } - - /** - * Creates tree that reproduces AccessibilityNodeInfoCompat tree hierarchy - * - * @param rootNode root node that is starting point for tree reproduction - * @param parent parent WorkingTree node for subtree that would be returned in this method - * @param includeChildrenOfNodesWithWebActions whether to calculator order for nodes that support - * web actions. Although TalkBack uses the naviagation order specified by the nodes, Switch - * Access needs to know about all nodes at the time the tree is being created. - * @return subtree that reproduces accessibility node hierarchy - */ - private @Nullable WorkingTree createWorkingTree( - @NonNull AccessibilityNodeInfoCompat rootNode, - @Nullable WorkingTree parent, - @NonNull NodeCachedBoundsCalculator boundsCalculator, - boolean includeChildrenOfNodesWithWebActions) { - if (nodeTreeMap.containsKey(rootNode)) { - LogUtils.w(TAG, "creating node tree with looped nodes - break the loop edge"); - return null; - } - - WorkingTree tree = new WorkingTree(rootNode, parent); - nodeTreeMap.put(rootNode, tree); - - // When we reach a node that supports web navigation, we traverse using the web navigation - // actions, so we should not try to determine the ordering of its descendants. - if (!includeChildrenOfNodesWithWebActions && WebInterfaceUtils.supportsWebActions(rootNode)) { - return tree; - } - - ReorderedChildrenIterator iterator = - ReorderedChildrenIterator.createAscendingIterator(rootNode, boundsCalculator); - while (iterator.hasNext()) { - AccessibilityNodeInfoCompat child = iterator.next(); - WorkingTree childSubTree = - createWorkingTree(child, tree, boundsCalculator, includeChildrenOfNodesWithWebActions); - if (childSubTree != null) { - tree.addChild(childSubTree); - } - } - return tree; - } - - /** - * reorder previously created tree according to after/before view traversal order on separate - * nodes - */ - private void reorderTree(AccessibilityNodeInfoCompat compatRoot) { - for (WorkingTree subtree : nodeTreeMap.values()) { - AccessibilityNodeInfoCompat node = subtree.getNode(); - if (makeFabFirst && isFab(node)) { - WorkingTree targetTree = nodeTreeMap.get(compatRoot).getNext(); - moveNodeBefore(subtree, targetTree); - } - if (AccessibilityNodeInfoUtils.hasRequestInitialAccessibilityFocus(node)) { - // TODO: Add test case after Roboletric in Google3 supports API 34. - initialFocusNode = node; - } - AccessibilityNodeInfoCompat beforeNode = node.getTraversalBefore(); - if (beforeNode != null) { - WorkingTree targetTree = nodeTreeMap.get(beforeNode); - moveNodeBefore(subtree, targetTree); - } else { - AccessibilityNodeInfoCompat afterNode = node.getTraversalAfter(); - if (afterNode != null) { - WorkingTree targetTree = nodeTreeMap.get(afterNode); - moveNodeAfter(subtree, targetTree); - } - } - } - } - - /** Moves movingTree before targetTree. */ - private void moveNodeBefore(@Nullable WorkingTree movingTree, @Nullable WorkingTree targetTree) { - if (movingTree == null || targetTree == null) { - return; - } - - if (movingTree.hasDescendant(targetTree)) { - // no operation if move child before parent - return; - } - - // Find subtree to move. - WorkingTree movingTreeRoot = getParentsThatAreMovedBeforeOrSameNode(movingTree); - - // Find destination for movingTreeRoot. - WorkingTree parent = targetTree.getParent(); - if (movingTreeRoot.hasDescendant(parent)) { - return; // Moving movingTreeRoot under its own descendant would create a loop. - } - - // Unlink moving subtree from tree. - detachSubtreeFromItsParent(movingTreeRoot); - - // swap target node with moving node on targets node parent children list - if (parent != null) { - parent.swapChild(targetTree, movingTreeRoot); - } - - movingTreeRoot.setParent(parent); - - // add target node as last child of moving node - movingTree.addChild(targetTree); - targetTree.setParent(movingTree); - } - - /** - * This method is called before moving subtree. It checks if parent of that node was moved on its - * place because it has before property to that node. In that case parent node should be moved - * with movingTree node. - * - * @return top node that should be moved with movingTree node. - */ - private WorkingTree getParentsThatAreMovedBeforeOrSameNode(WorkingTree movingTree) { - WorkingTree parent = movingTree.getParent(); - if (parent == null) { - return movingTree; - } - - AccessibilityNodeInfoCompat parentNode = parent.getNode(); - AccessibilityNodeInfoCompat parentNodeBefore = parentNode.getTraversalBefore(); - if (parentNodeBefore == null) { - return movingTree; - } - - if (parentNodeBefore.equals(movingTree.getNode())) { - return getParentsThatAreMovedBeforeOrSameNode(parent); - } - - return movingTree; - } - - private void detachSubtreeFromItsParent(WorkingTree subtree) { - WorkingTree movingTreeParent = subtree.getParent(); - if (movingTreeParent != null) { - movingTreeParent.removeChild(subtree); - } - subtree.setParent(null); - } - - private void moveNodeAfter(@Nullable WorkingTree movingTree, @Nullable WorkingTree targetTree) { - if (movingTree == null || targetTree == null) { - return; - } - - if (movingTree.hasDescendant(targetTree)) { - return; // Moving movingTree under its own descendant would create a loop. - } - movingTree = getParentsThatAreMovedBeforeOrSameNode(movingTree); - if (movingTree.hasDescendant(targetTree)) { - return; // Moving movingTree under its own descendant would create a loop. - } - detachSubtreeFromItsParent(movingTree); - targetTree.addChild(movingTree); - movingTree.setParent(targetTree); - } - - public @Nullable AccessibilityNodeInfoCompat findNext(AccessibilityNodeInfoCompat node) { - WorkingTree tree = nodeTreeMap.get(node); - if (tree == null) { - LogUtils.w(TAG, "findNext(), can't find WorkingTree for AccessibilityNodeInfo"); - return null; - } - - WorkingTree nextTree = tree.getNext(); - if (nextTree != null) { - return nextTree.getNode(); - } - - return null; - } - - public @Nullable AccessibilityNodeInfoCompat findPrevious(AccessibilityNodeInfoCompat node) { - WorkingTree tree = nodeTreeMap.get(node); - if (tree == null) { - LogUtils.w(TAG, "findPrevious(), can't find WorkingTree for AccessibilityNodeInfo"); - return null; - } - - WorkingTree prevTree = tree.getPrevious(); - if (prevTree != null) { - return prevTree.getNode(); - } - - return null; - } - - /** Searches first node to be focused */ - public @Nullable AccessibilityNodeInfoCompat findFirst() { - if (tree == null) { - return null; - } - - return tree.getRoot().getNode(); - } - - public @Nullable AccessibilityNodeInfoCompat findFirst(AccessibilityNodeInfoCompat rootNode) { - if (rootNode == null) { - return null; - } - - WorkingTree tree = nodeTreeMap.get(rootNode); - if (tree == null) { - return null; - } - - return tree.getNode(); - } - - public @Nullable AccessibilityNodeInfoCompat findInitial(AccessibilityNodeInfoCompat rootNode) { - if (rootNode == null) { - return null; - } - - WorkingTree tree = nodeTreeMap.get(rootNode); - if (tree == null) { - return null; - } - - if (tree.hasDescendant(nodeTreeMap.get(initialFocusNode))) { - return initialFocusNode; - } - - return null; - } - - /** Searches last node to be focused */ - public @Nullable AccessibilityNodeInfoCompat findLast() { - if (tree == null) { - return null; - } - - return tree.getRoot().getLastNode().getNode(); - } - - public @Nullable AccessibilityNodeInfoCompat findLast(AccessibilityNodeInfoCompat rootNode) { - if (rootNode == null) { - return null; - } - - WorkingTree tree = nodeTreeMap.get(rootNode); - if (tree == null) { - return null; - } - - return tree.getLastNode().getNode(); - } - - /** - * Checks for the resource id and package name of specific apps. We do not check for calendar or - * dialer since they both force accessibility focus and disrupt reordering for FABs. - */ - private boolean isFab(AccessibilityNodeInfoCompat node) { - if (Role.getRole(node) == Role.ROLE_FLOATING_ACTION_BUTTON) { - return true; - } - if (node.getViewIdResourceName() == null || node.getPackageName() == null) { - return false; - } - - String resourceName = node.getViewIdResourceName(); - CharSequence packageName = node.getPackageName(); - // Clock and Drive - if (resourceName.contains("fab") - && (packageName.toString().equals("com.google.android.deskclock") - || packageName.toString().equals("com.google.android.apps.docs"))) { - return true; - } - // Calendar and Contacts - if (resourceName.contains("floating_action_button") - && (packageName.toString().equals("com.google.android.calendar") - || packageName.toString().equals("com.google.android.contacts"))) { - return true; - } - - // Gmail - if (resourceName.contains("compose_button") - && packageName.toString().equals("com.google.android.gm")) { - return true; - } - - return false; - } - - /** Dumps the traversal order tree. */ - protected List dumpTree() { - List result = new ArrayList<>(); - AccessibilityNodeInfoCompat node = findFirst(); - while (node != null) { - result.add(node); - node = findNext(node); - } - return result; - } -} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/traversal/OrderedTraversalController.kt b/utils/src/main/java/com/google/android/accessibility/utils/traversal/OrderedTraversalController.kt new file mode 100644 index 000000000..c4f08154a --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/traversal/OrderedTraversalController.kt @@ -0,0 +1,320 @@ +/* + * Copyright (C) The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Ported from Java to Kotlin. + */ + +package com.google.android.accessibility.utils.traversal + +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import com.google.android.accessibility.utils.AccessibilityNodeInfoUtils +import com.google.android.accessibility.utils.Role +import com.google.android.accessibility.utils.WebInterfaceUtils +import com.google.android.libraries.accessibility.utils.log.LogUtils + +class OrderedTraversalController @JvmOverloads constructor(private val makeFabFirst: Boolean = false) { + + private var speakingNodesCache: MutableMap? = null + private var tree: WorkingTree? = null + private val nodeTreeMap: MutableMap = LinkedHashMap() + private var initialFocusNode: AccessibilityNodeInfoCompat? = null + + fun setSpeakingNodesCache(speakingNodesCache: MutableMap?) { + this.speakingNodesCache = speakingNodesCache + } + + /** + * before start next traversal node search the controller must be initialized. The initialisation + * step includes traversal through all accessibility nodes hierarchy to collect information about + * traversal order of separate subtrees and moving subtries that has custom before/after traverse + * view order + * + * @param compatRoot - accessibility node that serves as root node for tree hierarchy the + * controller works with + * @param includeChildrenOfNodesWithWebActions whether to calculator order for nodes that support + * web actions. Although TalkBack uses the naviagation order specified by the nodes, Switch + * Access needs to know about all nodes at the time the tree is being created. + */ + fun initOrder( + compatRoot: AccessibilityNodeInfoCompat?, + includeChildrenOfNodesWithWebActions: Boolean, + ) { + if (compatRoot == null) { + return + } + + val boundsCalculator = NodeCachedBoundsCalculator() + boundsCalculator.setSpeakingNodesCache(speakingNodesCache) + tree = + createWorkingTree(compatRoot, null, boundsCalculator, includeChildrenOfNodesWithWebActions) + reorderTree(compatRoot) + } + + /** + * Creates tree that reproduces AccessibilityNodeInfoCompat tree hierarchy + * + * @param rootNode root node that is starting point for tree reproduction + * @param parent parent WorkingTree node for subtree that would be returned in this method + * @param includeChildrenOfNodesWithWebActions whether to calculator order for nodes that support + * web actions. Although TalkBack uses the naviagation order specified by the nodes, Switch + * Access needs to know about all nodes at the time the tree is being created. + * @return subtree that reproduces accessibility node hierarchy + */ + private fun createWorkingTree( + rootNode: AccessibilityNodeInfoCompat, + parent: WorkingTree?, + boundsCalculator: NodeCachedBoundsCalculator, + includeChildrenOfNodesWithWebActions: Boolean, + ): WorkingTree? { + if (nodeTreeMap.containsKey(rootNode)) { + LogUtils.w(TAG, "creating node tree with looped nodes - break the loop edge") + return null + } + + val tree = WorkingTree(rootNode, parent) + nodeTreeMap[rootNode] = tree + + // When we reach a node that supports web navigation, we traverse using the web navigation + // actions, so we should not try to determine the ordering of its descendants. + if (!includeChildrenOfNodesWithWebActions && WebInterfaceUtils.supportsWebActions(rootNode)) { + return tree + } + + val iterator = ReorderedChildrenIterator.createAscendingIterator(rootNode, boundsCalculator) + while (iterator != null && iterator.hasNext()) { + val child = iterator.next() + val childSubTree = + createWorkingTree(child, tree, boundsCalculator, includeChildrenOfNodesWithWebActions) + if (childSubTree != null) { + tree.addChild(childSubTree) + } + } + return tree + } + + /** + * reorder previously created tree according to after/before view traversal order on separate + * nodes + */ + private fun reorderTree(compatRoot: AccessibilityNodeInfoCompat) { + for (subtree in nodeTreeMap.values) { + val node = subtree.getNode() + if (makeFabFirst && isFab(node)) { + val targetTree = nodeTreeMap[compatRoot]?.next + moveNodeBefore(subtree, targetTree) + } + if (AccessibilityNodeInfoUtils.hasRequestInitialAccessibilityFocus(node)) { + // TODO: Add test case after Roboletric in Google3 supports API 34. + initialFocusNode = node + } + val beforeNode = node.traversalBefore + if (beforeNode != null) { + val targetTree = nodeTreeMap[beforeNode] + moveNodeBefore(subtree, targetTree) + } else { + val afterNode = node.traversalAfter + if (afterNode != null) { + val targetTree = nodeTreeMap[afterNode] + moveNodeAfter(subtree, targetTree) + } + } + } + } + + /** Moves movingTree before targetTree. */ + private fun moveNodeBefore(movingTree: WorkingTree?, targetTree: WorkingTree?) { + if (movingTree == null || targetTree == null) { + return + } + + if (movingTree.hasDescendant(targetTree)) { + // no operation if move child before parent + return + } + + // Find subtree to move. + val movingTreeRoot = getParentsThatAreMovedBeforeOrSameNode(movingTree) + + // Find destination for movingTreeRoot. + val parent = targetTree.getParent() + if (movingTreeRoot.hasDescendant(parent)) { + return // Moving movingTreeRoot under its own descendant would create a loop. + } + + // Unlink moving subtree from tree. + detachSubtreeFromItsParent(movingTreeRoot) + + // swap target node with moving node on targets node parent children list + parent?.swapChild(targetTree, movingTreeRoot) + + movingTreeRoot.setParent(parent) + + // add target node as last child of moving node + movingTree.addChild(targetTree) + targetTree.setParent(movingTree) + } + + /** + * This method is called before moving subtree. It checks if parent of that node was moved on its + * place because it has before property to that node. In that case parent node should be moved + * with movingTree node. + * + * @return top node that should be moved with movingTree node. + */ + private fun getParentsThatAreMovedBeforeOrSameNode(movingTree: WorkingTree): WorkingTree { + val parent = movingTree.getParent() ?: return movingTree + + val parentNode = parent.getNode() + val parentNodeBefore = parentNode.traversalBefore ?: return movingTree + + if (parentNodeBefore == movingTree.getNode()) { + return getParentsThatAreMovedBeforeOrSameNode(parent) + } + + return movingTree + } + + private fun detachSubtreeFromItsParent(subtree: WorkingTree) { + val movingTreeParent = subtree.getParent() + movingTreeParent?.removeChild(subtree) + subtree.setParent(null) + } + + private fun moveNodeAfter(movingTree: WorkingTree?, targetTree: WorkingTree?) { + var movingTree = movingTree + if (movingTree == null || targetTree == null) { + return + } + + if (movingTree.hasDescendant(targetTree)) { + return // Moving movingTree under its own descendant would create a loop. + } + movingTree = getParentsThatAreMovedBeforeOrSameNode(movingTree) + if (movingTree.hasDescendant(targetTree)) { + return // Moving movingTree under its own descendant would create a loop. + } + detachSubtreeFromItsParent(movingTree) + targetTree.addChild(movingTree) + movingTree.setParent(targetTree) + } + + fun findNext(node: AccessibilityNodeInfoCompat?): AccessibilityNodeInfoCompat? { + val tree = nodeTreeMap[node] + if (tree == null) { + LogUtils.w(TAG, "findNext(), can't find WorkingTree for AccessibilityNodeInfo") + return null + } + + return tree.next?.getNode() + } + + fun findPrevious(node: AccessibilityNodeInfoCompat?): AccessibilityNodeInfoCompat? { + val tree = nodeTreeMap[node] + if (tree == null) { + LogUtils.w(TAG, "findPrevious(), can't find WorkingTree for AccessibilityNodeInfo") + return null + } + + return tree.previous?.getNode() + } + + /** Searches first node to be focused */ + fun findFirst(): AccessibilityNodeInfoCompat? = tree?.root?.getNode() + + fun findFirst(rootNode: AccessibilityNodeInfoCompat?): AccessibilityNodeInfoCompat? { + if (rootNode == null) { + return null + } + + return nodeTreeMap[rootNode]?.getNode() + } + + fun findInitial(rootNode: AccessibilityNodeInfoCompat?): AccessibilityNodeInfoCompat? { + if (rootNode == null) { + return null + } + + val tree = nodeTreeMap[rootNode] ?: return null + + if (tree.hasDescendant(nodeTreeMap[initialFocusNode])) { + return initialFocusNode + } + + return null + } + + /** Searches last node to be focused */ + fun findLast(): AccessibilityNodeInfoCompat? = tree?.root?.lastNode?.getNode() + + fun findLast(rootNode: AccessibilityNodeInfoCompat?): AccessibilityNodeInfoCompat? { + if (rootNode == null) { + return null + } + + return nodeTreeMap[rootNode]?.lastNode?.getNode() + } + + /** + * Checks for the resource id and package name of specific apps. We do not check for calendar or + * dialer since they both force accessibility focus and disrupt reordering for FABs. + */ + private fun isFab(node: AccessibilityNodeInfoCompat): Boolean { + if (Role.getRole(node) == Role.ROLE_FLOATING_ACTION_BUTTON) { + return true + } + if (node.viewIdResourceName == null || node.packageName == null) { + return false + } + + val resourceName = node.viewIdResourceName + val packageName = node.packageName + // Clock and Drive + if (resourceName.contains("fab") && + (packageName.toString() == "com.google.android.deskclock" || + packageName.toString() == "com.google.android.apps.docs") + ) { + return true + } + // Calendar and Contacts + if (resourceName.contains("floating_action_button") && + (packageName.toString() == "com.google.android.calendar" || + packageName.toString() == "com.google.android.contacts") + ) { + return true + } + + // Gmail + if (resourceName.contains("compose_button") && packageName.toString() == "com.google.android.gm") { + return true + } + + return false + } + + /** Dumps the traversal order tree. */ + internal fun dumpTree(): List { + val result = ArrayList() + var node = findFirst() + while (node != null) { + result.add(node) + node = findNext(node) + } + return result + } + + private companion object { + const val TAG = "OrderedTraversalCont" + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/traversal/OrderedTraversalStrategy.java b/utils/src/main/java/com/google/android/accessibility/utils/traversal/OrderedTraversalStrategy.java deleted file mode 100644 index c5373eba0..000000000 --- a/utils/src/main/java/com/google/android/accessibility/utils/traversal/OrderedTraversalStrategy.java +++ /dev/null @@ -1,104 +0,0 @@ -/* - * Copyright (C) 2015 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not - * use this file except in compliance with the License. You may obtain a copy of - * the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations under - * the License. - */ - -package com.google.android.accessibility.utils.traversal; - -import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import org.checkerframework.checker.nullness.qual.Nullable; - -/** - * Window could have its content views hierarchy. Views in that hierarchy could be traversed one - * after another. Every view inside that hierarchy could change its natural traverse order by - * setting traversal before/after view. See {@link android.view.View.getTraversalBefore()}, {@link - * android.view.View.getTraversalAfter()}. - * - *

    This strategy considers changes in the traverse order according to after/before view movements - */ -@SuppressWarnings("JavadocReference") -public class OrderedTraversalStrategy implements TraversalStrategy { - - private final OrderedTraversalController controller; - private final Map speakingNodesCache; - - public OrderedTraversalStrategy(@Nullable AccessibilityNodeInfoCompat rootNode) { - this(rootNode, false, false); - } - - /** TODO: Experimental code. Remove {@code makeFabFirst} when experiment is done. */ - public OrderedTraversalStrategy( - @Nullable AccessibilityNodeInfoCompat rootNode, - boolean includeChildrenOfNodesWithWebActions, - boolean makeFabFirst) { - speakingNodesCache = new HashMap<>(); - controller = new OrderedTraversalController(makeFabFirst); - controller.setSpeakingNodesCache(speakingNodesCache); - controller.initOrder(rootNode, includeChildrenOfNodesWithWebActions); - } - - @Override - public Map getSpeakingNodesCache() { - return speakingNodesCache; - } - - @Override - public @Nullable AccessibilityNodeInfoCompat findFocus( - AccessibilityNodeInfoCompat startNode, @SearchDirection int direction) { - switch (direction) { - case TraversalStrategy.SEARCH_FOCUS_FORWARD -> { - return focusNext(startNode); - } - case TraversalStrategy.SEARCH_FOCUS_BACKWARD -> { - return focusPrevious(startNode); - } - default -> {} - } - - return null; - } - - private @Nullable AccessibilityNodeInfoCompat focusNext(AccessibilityNodeInfoCompat node) { - return controller.findNext(node); - } - - private @Nullable AccessibilityNodeInfoCompat focusPrevious(AccessibilityNodeInfoCompat node) { - return controller.findPrevious(node); - } - - @Override - public @Nullable AccessibilityNodeInfoCompat focusFirst( - AccessibilityNodeInfoCompat root, @SearchDirection int direction) { - if (direction == SEARCH_FOCUS_FORWARD) { - return controller.findFirst(root); - } else if (direction == SEARCH_FOCUS_BACKWARD) { - return controller.findLast(root); - } else { - return null; - } - } - - @Override - public @Nullable AccessibilityNodeInfoCompat focusInitial(AccessibilityNodeInfoCompat root) { - return controller.findInitial(root); - } - - /** Dumps the traversal order tree. */ - public List dumpTree() { - return controller.dumpTree(); - } -} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/traversal/OrderedTraversalStrategy.kt b/utils/src/main/java/com/google/android/accessibility/utils/traversal/OrderedTraversalStrategy.kt new file mode 100644 index 000000000..964a0a0c7 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/traversal/OrderedTraversalStrategy.kt @@ -0,0 +1,91 @@ +/* + * Copyright (C) The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Ported from Java to Kotlin. + */ + +package com.google.android.accessibility.utils.traversal + +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat + +/** + * Window could have its content views hierarchy. Views in that hierarchy could be traversed one + * after another. Every view inside that hierarchy could change its natural traverse order by + * setting traversal before/after view. See {@link android.view.View.getTraversalBefore()}, {@link + * android.view.View.getTraversalAfter()}. + * + *

    This strategy considers changes in the traverse order according to after/before view movements + */ +class OrderedTraversalStrategy +@JvmOverloads +constructor( + rootNode: AccessibilityNodeInfoCompat?, + includeChildrenOfNodesWithWebActions: Boolean = false, + makeFabFirst: Boolean = false, +) : TraversalStrategy { + + private val controller: OrderedTraversalController + private val speakingNodesCache: MutableMap = HashMap() + + init { + controller = OrderedTraversalController(makeFabFirst) + controller.setSpeakingNodesCache(speakingNodesCache) + controller.initOrder(rootNode, includeChildrenOfNodesWithWebActions) + } + + override fun getSpeakingNodesCache(): Map = + speakingNodesCache + + override fun findFocus( + startNode: AccessibilityNodeInfoCompat?, + @TraversalStrategy.SearchDirection direction: Int, + ): AccessibilityNodeInfoCompat? { + when (direction) { + TraversalStrategy.SEARCH_FOCUS_FORWARD -> { + return focusNext(startNode) + } + TraversalStrategy.SEARCH_FOCUS_BACKWARD -> { + return focusPrevious(startNode) + } + else -> {} + } + + return null + } + + private fun focusNext(node: AccessibilityNodeInfoCompat?): AccessibilityNodeInfoCompat? = + controller.findNext(node) + + private fun focusPrevious(node: AccessibilityNodeInfoCompat?): AccessibilityNodeInfoCompat? = + controller.findPrevious(node) + + override fun focusFirst( + root: AccessibilityNodeInfoCompat?, + @TraversalStrategy.SearchDirection direction: Int, + ): AccessibilityNodeInfoCompat? = + if (direction == TraversalStrategy.SEARCH_FOCUS_FORWARD) { + controller.findFirst(root) + } else if (direction == TraversalStrategy.SEARCH_FOCUS_BACKWARD) { + controller.findLast(root) + } else { + null + } + + override fun focusInitial(root: AccessibilityNodeInfoCompat?): AccessibilityNodeInfoCompat? = + controller.findInitial(root) + + /** Dumps the traversal order tree. */ + fun dumpTree(): List = controller.dumpTree() +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/traversal/OrderedTraversalStrategyConfig.java b/utils/src/main/java/com/google/android/accessibility/utils/traversal/OrderedTraversalStrategyConfig.java deleted file mode 100644 index 5ad26240a..000000000 --- a/utils/src/main/java/com/google/android/accessibility/utils/traversal/OrderedTraversalStrategyConfig.java +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright (C) 2024 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not - * use this file except in compliance with the License. You may obtain a copy of - * the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations under - * the License. - */ -package com.google.android.accessibility.utils.traversal; - -import com.google.auto.value.AutoValue; - -/** Config class for {@link OrderedTraversalStrategy} */ -@AutoValue -public abstract class OrderedTraversalStrategyConfig { - - public abstract int searchDirection(); - - public abstract boolean includeChildrenOfNodesWithWebActions(); - - public abstract boolean makeFabFirst(); - - public static OrderedTraversalStrategyConfig.Builder builder() { - return new AutoValue_OrderedTraversalStrategyConfig.Builder() - .setSearchDirection(0) - .setIncludeChildrenOfNodesWithWebActions(false) - .setMakeFabFirst(false); - } - - /** Builder for TraversalStrategy config data. */ - @AutoValue.Builder - public abstract static class Builder { - - public abstract Builder setSearchDirection(int searchDirection); - - public abstract Builder setIncludeChildrenOfNodesWithWebActions(boolean value); - - public abstract Builder setMakeFabFirst(boolean value); - - public abstract OrderedTraversalStrategyConfig build(); - } -} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/traversal/OrderedTraversalStrategyConfig.kt b/utils/src/main/java/com/google/android/accessibility/utils/traversal/OrderedTraversalStrategyConfig.kt new file mode 100644 index 000000000..29cd9e55e --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/traversal/OrderedTraversalStrategyConfig.kt @@ -0,0 +1,95 @@ +/* + * Copyright (C) The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Ported from Java to Kotlin. + * (Era @AutoValue no original Java; o processador não roda em fonte Kotlin, + * então o valor+builder são escritos à mão com a MESMA API pública.) + */ + +package com.google.android.accessibility.utils.traversal + +/** Config class for {@link OrderedTraversalStrategy} */ +class OrderedTraversalStrategyConfig private constructor( + private val searchDirection: Int, + private val includeChildrenOfNodesWithWebActions: Boolean, + private val makeFabFirst: Boolean, +) { + + fun searchDirection(): Int = searchDirection + + fun includeChildrenOfNodesWithWebActions(): Boolean = includeChildrenOfNodesWithWebActions + + fun makeFabFirst(): Boolean = makeFabFirst + + override fun equals(other: Any?): Boolean { + if (other === this) return true + if (other !is OrderedTraversalStrategyConfig) return false + return searchDirection == other.searchDirection && + includeChildrenOfNodesWithWebActions == other.includeChildrenOfNodesWithWebActions && + makeFabFirst == other.makeFabFirst + } + + override fun hashCode(): Int { + var h = 1 + h *= 1000003 + h = h xor searchDirection + h *= 1000003 + h = h xor if (includeChildrenOfNodesWithWebActions) 1231 else 1237 + h *= 1000003 + h = h xor if (makeFabFirst) 1231 else 1237 + return h + } + + override fun toString(): String = + "OrderedTraversalStrategyConfig{" + + "searchDirection=$searchDirection, " + + "includeChildrenOfNodesWithWebActions=$includeChildrenOfNodesWithWebActions, " + + "makeFabFirst=$makeFabFirst}" + + /** Builder for TraversalStrategy config data. */ + class Builder internal constructor() { + private var searchDirection: Int = 0 + private var includeChildrenOfNodesWithWebActions: Boolean = false + private var makeFabFirst: Boolean = false + + fun setSearchDirection(searchDirection: Int): Builder { + this.searchDirection = searchDirection + return this + } + + fun setIncludeChildrenOfNodesWithWebActions(value: Boolean): Builder { + this.includeChildrenOfNodesWithWebActions = value + return this + } + + fun setMakeFabFirst(value: Boolean): Builder { + this.makeFabFirst = value + return this + } + + fun build(): OrderedTraversalStrategyConfig = + OrderedTraversalStrategyConfig( + searchDirection, includeChildrenOfNodesWithWebActions, makeFabFirst) + } + + companion object { + @JvmStatic + fun builder(): Builder = + Builder() + .setSearchDirection(0) + .setIncludeChildrenOfNodesWithWebActions(false) + .setMakeFabFirst(false) + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/traversal/ReorderedChildrenIterator.java b/utils/src/main/java/com/google/android/accessibility/utils/traversal/ReorderedChildrenIterator.java deleted file mode 100644 index 0655ef4ad..000000000 --- a/utils/src/main/java/com/google/android/accessibility/utils/traversal/ReorderedChildrenIterator.java +++ /dev/null @@ -1,276 +0,0 @@ -/* - * Copyright (C) 2015 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not - * use this file except in compliance with the License. You may obtain a copy of - * the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations under - * the License. - */ - -package com.google.android.accessibility.utils.traversal; - -import android.graphics.Rect; -import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; -import com.google.android.accessibility.utils.WebInterfaceUtils; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Iterator; -import java.util.List; -import org.checkerframework.checker.nullness.qual.Nullable; - -/** - * Children nodes iterator that iterates its children according the order of AccessibilityNodeInfo - * hierarchy. But for nodes that are not considered to be focused according to - * AccessibilityNodeInfoUtils.shouldFocusNode() rules we calculate new bounds that is minimum - * rectangle that contains all focusable children nodes. If that rectangle differs from real node - * bounds that node is reordered according needSwapNodeOrder() logic and could be traversed later. - * - *

    This class obtains new instances of AccessibilityNodeCompat. - */ -public class ReorderedChildrenIterator implements Iterator { - - public static ReorderedChildrenIterator createAscendingIterator( - AccessibilityNodeInfoCompat parent) { - return createAscendingIterator(parent, /* boundsCalculator= */ null); - } - - public static ReorderedChildrenIterator createDescendingIterator( - AccessibilityNodeInfoCompat parent) { - return createDescendingIterator(parent, /* boundsCalculator= */ null); - } - - public static @Nullable ReorderedChildrenIterator createAscendingIterator( - @Nullable AccessibilityNodeInfoCompat parent, - @Nullable NodeCachedBoundsCalculator boundsCalculator) { - if (parent == null) { - return null; - } - - return new ReorderedChildrenIterator(parent, /* isAscending= */ true, boundsCalculator); - } - - public static @Nullable ReorderedChildrenIterator createDescendingIterator( - AccessibilityNodeInfoCompat parent, @Nullable NodeCachedBoundsCalculator boundsCalculator) { - if (parent == null) { - return null; - } - - return new ReorderedChildrenIterator(parent, /* isAscending= */ false, boundsCalculator); - } - - private final AccessibilityNodeInfoCompat parent; - private int currentIndex; - private final List nodes; - private final boolean isAscending; - // TODO: Refactor to get RTL state. - private static final boolean RIGHT_TO_LEFT = false; - private final NodeCachedBoundsCalculator boundsCalculator; - - // Avoid constantly creating and discarding Rects. - private final Rect mTempLeftBounds = new Rect(); - private final Rect mTempRightBounds = new Rect(); - - private ReorderedChildrenIterator( - AccessibilityNodeInfoCompat parent, - boolean isAscending, - @Nullable NodeCachedBoundsCalculator boundsCalculator) { - this.parent = parent; - this.isAscending = isAscending; - this.boundsCalculator = - (boundsCalculator == null) ? new NodeCachedBoundsCalculator() : boundsCalculator; - - nodes = new ArrayList<>(this.parent.getChildCount()); - init(this.parent); - currentIndex = this.isAscending ? 0 : nodes.size() - 1; - } - - private void init(AccessibilityNodeInfoCompat node) { - fillNodesFromParent(); - if (!WebInterfaceUtils.isWebContainer(node) && needReordering(nodes)) { - reorder(nodes); - } - } - - private boolean needReordering(List nodes) { - if (nodes == null || nodes.size() == 1) { - return false; - } - - for (AccessibilityNodeInfoCompat node : nodes) { - if (boundsCalculator.usesChildrenBounds(node)) { - return true; - } - } - - return false; - } - - private void reorder(List nodes) { - if (nodes == null || nodes.size() == 1) { - return; - } - - int size = nodes.size(); - AccessibilityNodeInfoCompat[] nodeArray = new AccessibilityNodeInfoCompat[size]; - nodes.toArray(nodeArray); - - int currentIndex = size - 2; - while (currentIndex >= 0) { - AccessibilityNodeInfoCompat currentNode = nodeArray[currentIndex]; - if (boundsCalculator.usesChildrenBounds(currentNode)) { - moveNodeIfNecessary(nodeArray, currentIndex); - } - - currentIndex--; - } - - nodes.clear(); - nodes.addAll(Arrays.asList(nodeArray)); - } - - private void moveNodeIfNecessary(AccessibilityNodeInfoCompat[] nodeArray, int index) { - int size = nodeArray.length; - int nextIndex = index + 1; - AccessibilityNodeInfoCompat currentNode = nodeArray[index]; - while (nextIndex < size && needSwapNodeOrder(currentNode, nodeArray[nextIndex])) { - nodeArray[nextIndex - 1] = nodeArray[nextIndex]; - nodeArray[nextIndex] = currentNode; - nextIndex++; - } - } - - private boolean needSwapNodeOrder( - AccessibilityNodeInfoCompat leftNode, AccessibilityNodeInfoCompat rightNode) { - if (leftNode == null || rightNode == null) { - return false; - } - - Rect leftBounds = boundsCalculator.getBounds(leftNode); - Rect rightBounds = boundsCalculator.getBounds(rightNode); - - // Sometimes the bounds compare() is overzealous, so swap the items only if the adjusted - // (mBoundsCalculator) leftBounds > rightBounds but the original leftBounds < rightBounds, - // i.e. the compare() method returns the existing ordering for the original bounds but - // wants a swap for the adjusted bounds. - // Simply, if compare() says that the original system ordering is wrong, then we cannot - // trust its judgment in the adjusted bounds case. - // - // Example: - // (1) Page scrolled to top (2) Page scrolled to bottom. - // +----------+ +----------+ - // | App bar | | App bar | - // +----------+ +----------+ - // | Item 1 | | Item 2 | - // | Item 2 | | Item 3 | - // | Item 3 | | (spacer) | - // +----------+ +----------+ - // Note: App bar overlays the top part of the list; the top, left, and right edges of the - // list line up with the app bar. Assume that the spacer is not important for accessibility. - // In this example, the traversal order for (1) is Item 1 -> Item 2 -> Item 3 -> App bar - // but the traversal order for (2) gets reordered to App bar -> Item 2 -> Item 3. - // So during auto-scrolling the app bar is actually excluded from the traversal order until - // after the wrap-around. - if (compare(leftBounds, rightBounds) > 0) { - leftNode.getBoundsInScreen(mTempLeftBounds); - rightNode.getBoundsInScreen(mTempRightBounds); - return compare(mTempLeftBounds, mTempRightBounds) < 0; - } - - return false; - } - - /** - * Returns a negative value if the inputs are ordered {@code {leftBounds, rightBounds}} and a - * positive value if the inputs are ordered {@code {rightBounds, leftBounds}}. Guaranteed to not - * return 0. - * - *

    The ordering is determined via an algorithm similar to the {@link - * android.view.ViewGroup.ViewLocationHolder#COMPARISON_STRATEGY_STRIPE} strategy used by the - * framework to sort children of ViewGroups. This is essentially copied from {@link - * android.view.ViewGroup.ViewLocationHolder#compareTo} with minor modifications. - */ - private int compare(@Nullable Rect leftBounds, @Nullable Rect rightBounds) { - if (leftBounds == null || rightBounds == null) { - return -1; - } - - // First is above second. - if (leftBounds.bottom - rightBounds.top <= 0) { - return -1; - } - // First is below second. - if (leftBounds.top - rightBounds.bottom >= 0) { - return 1; - } - - // We are ordering left-to-right, top-to-bottom. - if (RIGHT_TO_LEFT) { - final int rightDifference = leftBounds.right - rightBounds.right; - if (rightDifference != 0) { - return -rightDifference; - } - } else { // LTR - final int leftDifference = leftBounds.left - rightBounds.left; - if (leftDifference != 0) { - return leftDifference; - } - } - // We are ordering left-to-right, top-to-bottom. - final int topDifference = leftBounds.top - rightBounds.top; - if (topDifference != 0) { - return topDifference; - } - // Break tie by height. - final int heightDifference = leftBounds.height() - rightBounds.height(); - if (heightDifference != 0) { - return -heightDifference; - } - // Break tie by width. - final int widthDifference = leftBounds.width() - rightBounds.width(); - if (widthDifference != 0) { - return -widthDifference; - } - // Break tie somehow. - return -1; - } - - private void fillNodesFromParent() { - int count = parent.getChildCount(); - for (int i = 0; i < count; i++) { - AccessibilityNodeInfoCompat node = parent.getChild(i); - if (node != null) { - nodes.add(node); - } - } - } - - @Override - public boolean hasNext() { - return isAscending ? currentIndex < nodes.size() : currentIndex >= 0; - } - - @Override - public @Nullable AccessibilityNodeInfoCompat next() { - AccessibilityNodeInfoCompat nextNode = nodes.get(currentIndex); - if (isAscending) { - currentIndex++; - } else { - currentIndex--; - } - - return nextNode; - } - - @Override - public void remove() { - throw new UnsupportedOperationException( - "ReorderedChildrenIterator does not support remove operation"); - } -} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/traversal/ReorderedChildrenIterator.kt b/utils/src/main/java/com/google/android/accessibility/utils/traversal/ReorderedChildrenIterator.kt new file mode 100644 index 000000000..f333ab48c --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/traversal/ReorderedChildrenIterator.kt @@ -0,0 +1,275 @@ +/* + * Copyright (C) The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Ported from Java to Kotlin. + */ + +package com.google.android.accessibility.utils.traversal + +import android.graphics.Rect +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import com.google.android.accessibility.utils.WebInterfaceUtils + +/** + * Children nodes iterator that iterates its children according the order of AccessibilityNodeInfo + * hierarchy. But for nodes that are not considered to be focused according to + * AccessibilityNodeInfoUtils.shouldFocusNode() rules we calculate new bounds that is minimum + * rectangle that contains all focusable children nodes. If that rectangle differs from real node + * bounds that node is reordered according needSwapNodeOrder() logic and could be traversed later. + * + *

    This class obtains new instances of AccessibilityNodeCompat. + */ +class ReorderedChildrenIterator private constructor( + private val parent: AccessibilityNodeInfoCompat, + private val isAscending: Boolean, + boundsCalculator: NodeCachedBoundsCalculator?, +) : MutableIterator { + + private var currentIndex: Int + private val nodes: MutableList + private val boundsCalculator: NodeCachedBoundsCalculator = + boundsCalculator ?: NodeCachedBoundsCalculator() + + // Avoid constantly creating and discarding Rects. + private val mTempLeftBounds = Rect() + private val mTempRightBounds = Rect() + + init { + nodes = ArrayList(this.parent.childCount) + init(this.parent) + currentIndex = if (this.isAscending) 0 else nodes.size - 1 + } + + private fun init(node: AccessibilityNodeInfoCompat) { + fillNodesFromParent() + if (!WebInterfaceUtils.isWebContainer(node) && needReordering(nodes)) { + reorder(nodes) + } + } + + private fun needReordering(nodes: List?): Boolean { + if (nodes == null || nodes.size == 1) { + return false + } + + for (node in nodes) { + if (boundsCalculator.usesChildrenBounds(node)) { + return true + } + } + + return false + } + + private fun reorder(nodes: MutableList?) { + if (nodes == null || nodes.size == 1) { + return + } + + val size = nodes.size + val nodeArray = arrayOfNulls(size) + for (i in nodes.indices) { + nodeArray[i] = nodes[i] + } + + var currentIndex = size - 2 + while (currentIndex >= 0) { + val currentNode = nodeArray[currentIndex] + if (boundsCalculator.usesChildrenBounds(currentNode)) { + moveNodeIfNecessary(nodeArray, currentIndex) + } + + currentIndex-- + } + + nodes.clear() + for (node in nodeArray) { + nodes.add(node!!) + } + } + + private fun moveNodeIfNecessary(nodeArray: Array, index: Int) { + val size = nodeArray.size + var nextIndex = index + 1 + val currentNode = nodeArray[index] + while (nextIndex < size && needSwapNodeOrder(currentNode, nodeArray[nextIndex])) { + nodeArray[nextIndex - 1] = nodeArray[nextIndex] + nodeArray[nextIndex] = currentNode + nextIndex++ + } + } + + private fun needSwapNodeOrder( + leftNode: AccessibilityNodeInfoCompat?, + rightNode: AccessibilityNodeInfoCompat?, + ): Boolean { + if (leftNode == null || rightNode == null) { + return false + } + + val leftBounds = boundsCalculator.getBounds(leftNode) + val rightBounds = boundsCalculator.getBounds(rightNode) + + // Sometimes the bounds compare() is overzealous, so swap the items only if the adjusted + // (mBoundsCalculator) leftBounds > rightBounds but the original leftBounds < rightBounds, + // i.e. the compare() method returns the existing ordering for the original bounds but + // wants a swap for the adjusted bounds. + // Simply, if compare() says that the original system ordering is wrong, then we cannot + // trust its judgment in the adjusted bounds case. + // + // Example: + // (1) Page scrolled to top (2) Page scrolled to bottom. + // +----------+ +----------+ + // | App bar | | App bar | + // +----------+ +----------+ + // | Item 1 | | Item 2 | + // | Item 2 | | Item 3 | + // | Item 3 | | (spacer) | + // +----------+ +----------+ + // Note: App bar overlays the top part of the list; the top, left, and right edges of the + // list line up with the app bar. Assume that the spacer is not important for accessibility. + // In this example, the traversal order for (1) is Item 1 -> Item 2 -> Item 3 -> App bar + // but the traversal order for (2) gets reordered to App bar -> Item 2 -> Item 3. + // So during auto-scrolling the app bar is actually excluded from the traversal order until + // after the wrap-around. + if (compare(leftBounds, rightBounds) > 0) { + leftNode.getBoundsInScreen(mTempLeftBounds) + rightNode.getBoundsInScreen(mTempRightBounds) + return compare(mTempLeftBounds, mTempRightBounds) < 0 + } + + return false + } + + /** + * Returns a negative value if the inputs are ordered {@code {leftBounds, rightBounds}} and a + * positive value if the inputs are ordered {@code {rightBounds, leftBounds}}. Guaranteed to not + * return 0. + * + *

    The ordering is determined via an algorithm similar to the {@link + * android.view.ViewGroup.ViewLocationHolder#COMPARISON_STRATEGY_STRIPE} strategy used by the + * framework to sort children of ViewGroups. This is essentially copied from {@link + * android.view.ViewGroup.ViewLocationHolder#compareTo} with minor modifications. + */ + private fun compare(leftBounds: Rect?, rightBounds: Rect?): Int { + if (leftBounds == null || rightBounds == null) { + return -1 + } + + // First is above second. + if (leftBounds.bottom - rightBounds.top <= 0) { + return -1 + } + // First is below second. + if (leftBounds.top - rightBounds.bottom >= 0) { + return 1 + } + + // We are ordering left-to-right, top-to-bottom. + if (RIGHT_TO_LEFT) { + val rightDifference = leftBounds.right - rightBounds.right + if (rightDifference != 0) { + return -rightDifference + } + } else { // LTR + val leftDifference = leftBounds.left - rightBounds.left + if (leftDifference != 0) { + return leftDifference + } + } + // We are ordering left-to-right, top-to-bottom. + val topDifference = leftBounds.top - rightBounds.top + if (topDifference != 0) { + return topDifference + } + // Break tie by height. + val heightDifference = leftBounds.height() - rightBounds.height() + if (heightDifference != 0) { + return -heightDifference + } + // Break tie by width. + val widthDifference = leftBounds.width() - rightBounds.width() + if (widthDifference != 0) { + return -widthDifference + } + // Break tie somehow. + return -1 + } + + private fun fillNodesFromParent() { + val count = parent.childCount + for (i in 0 until count) { + val node = parent.getChild(i) + if (node != null) { + nodes.add(node) + } + } + } + + override fun hasNext(): Boolean = if (isAscending) currentIndex < nodes.size else currentIndex >= 0 + + override fun next(): AccessibilityNodeInfoCompat { + val nextNode = nodes[currentIndex] + if (isAscending) { + currentIndex++ + } else { + currentIndex-- + } + + return nextNode + } + + override fun remove() { + throw UnsupportedOperationException( + "ReorderedChildrenIterator does not support remove operation") + } + + companion object { + // TODO: Refactor to get RTL state. + private const val RIGHT_TO_LEFT = false + + @JvmStatic + fun createAscendingIterator(parent: AccessibilityNodeInfoCompat): ReorderedChildrenIterator = + createAscendingIterator(parent, /* boundsCalculator= */ null)!! + + @JvmStatic + fun createDescendingIterator(parent: AccessibilityNodeInfoCompat): ReorderedChildrenIterator = + createDescendingIterator(parent, /* boundsCalculator= */ null)!! + + @JvmStatic + fun createAscendingIterator( + parent: AccessibilityNodeInfoCompat?, + boundsCalculator: NodeCachedBoundsCalculator?, + ): ReorderedChildrenIterator? { + if (parent == null) { + return null + } + + return ReorderedChildrenIterator(parent, /* isAscending= */ true, boundsCalculator) + } + + @JvmStatic + fun createDescendingIterator( + parent: AccessibilityNodeInfoCompat?, + boundsCalculator: NodeCachedBoundsCalculator?, + ): ReorderedChildrenIterator? { + if (parent == null) { + return null + } + + return ReorderedChildrenIterator(parent, /* isAscending= */ false, boundsCalculator) + } + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/traversal/SimpleTraversalStrategy.java b/utils/src/main/java/com/google/android/accessibility/utils/traversal/SimpleTraversalStrategy.java deleted file mode 100644 index eea401d11..000000000 --- a/utils/src/main/java/com/google/android/accessibility/utils/traversal/SimpleTraversalStrategy.java +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright (C) 2015 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not - * use this file except in compliance with the License. You may obtain a copy of - * the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations under - * the License. - */ - -package com.google.android.accessibility.utils.traversal; - -import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; -import com.google.android.accessibility.utils.AccessibilityNodeInfoRef; -import java.util.Map; -import org.checkerframework.checker.nullness.qual.Nullable; - -public class SimpleTraversalStrategy implements TraversalStrategy { - - @Override - public @Nullable AccessibilityNodeInfoCompat findFocus( - AccessibilityNodeInfoCompat startNode, @SearchDirection int direction) { - if (startNode == null) { - return null; - } - - AccessibilityNodeInfoRef ref = AccessibilityNodeInfoRef.obtain(startNode); - boolean focusFound = - direction == TraversalStrategy.SEARCH_FOCUS_FORWARD - ? ref.nextInOrder() - : ref.previousInOrder(); - if (focusFound) { - return ref.get(); - } - - return null; - } - - @Override - public @Nullable AccessibilityNodeInfoCompat focusFirst( - AccessibilityNodeInfoCompat root, @SearchDirection int direction) { - if (root == null) { - return null; - } - - if (direction == SEARCH_FOCUS_FORWARD) { - return root; - } else if (direction == SEARCH_FOCUS_BACKWARD) { - AccessibilityNodeInfoRef ref = AccessibilityNodeInfoRef.obtain(root); - if (ref.lastDescendant()) { - return ref.get(); - } else { - return null; - } - } - - return null; - } - - @Override - public Map getSpeakingNodesCache() { - return null; - } -} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/traversal/SimpleTraversalStrategy.kt b/utils/src/main/java/com/google/android/accessibility/utils/traversal/SimpleTraversalStrategy.kt new file mode 100644 index 000000000..23f346d0f --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/traversal/SimpleTraversalStrategy.kt @@ -0,0 +1,71 @@ +/* + * Copyright (C) The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Ported from Java to Kotlin. + */ + +package com.google.android.accessibility.utils.traversal + +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import com.google.android.accessibility.utils.AccessibilityNodeInfoRef + +class SimpleTraversalStrategy : TraversalStrategy { + + override fun findFocus( + startNode: AccessibilityNodeInfoCompat?, + @TraversalStrategy.SearchDirection direction: Int, + ): AccessibilityNodeInfoCompat? { + if (startNode == null) { + return null + } + + val ref = AccessibilityNodeInfoRef.obtain(startNode) + val focusFound = + if (direction == TraversalStrategy.SEARCH_FOCUS_FORWARD) { + ref.nextInOrder() + } else { + ref.previousInOrder() + } + if (focusFound) { + return ref.get() + } + + return null + } + + override fun focusFirst( + root: AccessibilityNodeInfoCompat?, + @TraversalStrategy.SearchDirection direction: Int, + ): AccessibilityNodeInfoCompat? { + if (root == null) { + return null + } + + if (direction == TraversalStrategy.SEARCH_FOCUS_FORWARD) { + return root + } else if (direction == TraversalStrategy.SEARCH_FOCUS_BACKWARD) { + val ref = AccessibilityNodeInfoRef.obtain(root) + return if (ref.lastDescendant()) { + ref.get() + } else { + null + } + } + + return null + } + + override fun getSpeakingNodesCache(): Map? = null +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/traversal/SpannableTraversalUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/traversal/SpannableTraversalUtils.kt similarity index 51% rename from utils/src/main/java/com/google/android/accessibility/utils/traversal/SpannableTraversalUtils.java rename to utils/src/main/java/com/google/android/accessibility/utils/traversal/SpannableTraversalUtils.kt index 15eb71a61..b62e1266d 100644 --- a/utils/src/main/java/com/google/android/accessibility/utils/traversal/SpannableTraversalUtils.java +++ b/utils/src/main/java/com/google/android/accessibility/utils/traversal/SpannableTraversalUtils.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2017 Google Inc. + * Copyright (C) The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -12,32 +12,27 @@ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. + * + * Ported from Java to Kotlin. */ -package com.google.android.accessibility.utils.traversal; +package com.google.android.accessibility.utils.traversal -import android.text.SpannableString; -import android.text.TextUtils; -import android.text.style.CharacterStyle; -import android.text.style.ClickableSpan; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; -import com.google.android.accessibility.utils.AccessibilityNodeInfoUtils; -import com.google.android.accessibility.utils.SpannableUtils; -import com.google.android.accessibility.utils.SpannableUtils.SpannableWithOffset; -import com.google.android.libraries.accessibility.utils.log.LogUtils; -import com.google.errorprone.annotations.CanIgnoreReturnValue; -import java.util.Arrays; -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import java.util.concurrent.atomic.AtomicInteger; +import android.text.TextUtils +import android.text.style.CharacterStyle +import android.text.style.ClickableSpan +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import com.google.android.accessibility.utils.AccessibilityNodeInfoUtils +import com.google.android.accessibility.utils.SpannableUtils +import com.google.android.accessibility.utils.SpannableUtils.SpannableWithOffset +import com.google.android.libraries.accessibility.utils.log.LogUtils +import com.google.errorprone.annotations.CanIgnoreReturnValue +import java.util.concurrent.atomic.AtomicInteger /** Utility methods for traversing a tree with spannable objects. */ -public final class SpannableTraversalUtils { +object SpannableTraversalUtils { - private static final String TAG = "SpannableTraversalUtils"; + private const val TAG = "SpannableTraversalUtils" /** * Returns whether the node hierarchy contains target {@link ClickableSpan}. @@ -46,29 +41,36 @@ public final class SpannableTraversalUtils { * IPC which depends on the implementation of {@link AccessibilityNodeInfo#setText(CharSequence)} * in the framework side. */ - public static boolean hasTargetClickableSpanInNodeTree( - AccessibilityNodeInfoCompat node, Class targetClickableSpanClass) { + @JvmStatic + fun hasTargetClickableSpanInNodeTree( + node: AccessibilityNodeInfoCompat?, + targetClickableSpanClass: Class, + ): Boolean { if (node == null) { - return false; + return false } - Set visitedNode = new HashSet<>(); + val visitedNode = HashSet() return searchSpannableStringsForCharacterStyleInNodeTree( - node, - visitedNode, - /* currentOffset= */ null, - /* result= */ null, - Arrays.asList(targetClickableSpanClass)); + node, + visitedNode, + /* currentOffset= */ null, + /* result= */ null, + listOf(targetClickableSpanClass), + ) } /** Returns whether the node hierarchy contains target {@link CharacterStyle} subclasses. */ - public static boolean hasTargetCharcterStyleInNodeTree( - AccessibilityNodeInfoCompat node, List> spanClasses) { + @JvmStatic + fun hasTargetCharcterStyleInNodeTree( + node: AccessibilityNodeInfoCompat?, + spanClasses: List>, + ): Boolean { if (node == null) { - return false; + return false } - Set visitedNode = new HashSet<>(); + val visitedNode = HashSet() return searchSpannableStringsForCharacterStyleInNodeTree( - node, visitedNode, /* currentOffset= */ null, /* result= */ null, spanClasses); + node, visitedNode, /* currentOffset= */ null, /* result= */ null, spanClasses) } /** @@ -78,32 +80,36 @@ public static boolean hasTargetCharcterStyleInNodeTree( * IPC which depends on the implementation of {@link AccessibilityNodeInfo#setText(CharSequence)} * in the framework side. */ - public static void getSpannableStringsWithTargetClickableSpanInNodeTree( - AccessibilityNodeInfoCompat node, - Class targetClickableSpanClass, - @NonNull List result) { + @JvmStatic + fun getSpannableStringsWithTargetClickableSpanInNodeTree( + node: AccessibilityNodeInfoCompat?, + targetClickableSpanClass: Class, + result: MutableList, + ) { if (node == null) { - return; + return } - Set visitedNodes = new HashSet<>(); + val visitedNodes = HashSet() searchSpannableStringsForCharacterStyleInNodeTree( - node, visitedNodes, new AtomicInteger(0), result, Arrays.asList(targetClickableSpanClass)); + node, visitedNodes, AtomicInteger(0), result, listOf(targetClickableSpanClass)) } /** * Gets {@link SpannableWithOffset} with target {@link CharacterStyle} subclasses within the node * tree. */ - public static void getSpannableStringsWithTargetCharacterStyleInNodeTree( - AccessibilityNodeInfoCompat node, - List> spanClasses, - @NonNull List result) { + @JvmStatic + fun getSpannableStringsWithTargetCharacterStyleInNodeTree( + node: AccessibilityNodeInfoCompat?, + spanClasses: List>, + result: MutableList, + ) { if (node == null) { - return; + return } - Set visitedNodes = new HashSet<>(); + val visitedNodes = HashSet() searchSpannableStringsForCharacterStyleInNodeTree( - node, visitedNodes, new AtomicInteger(0), result, spanClasses); + node, visitedNodes, AtomicInteger(0), result, spanClasses) } /** @@ -124,56 +130,55 @@ public static void getSpannableStringsWithTargetCharacterStyleInNodeTree( * @return {@code true} if any SpannableString is found in the node tree */ @CanIgnoreReturnValue - private static boolean searchSpannableStringsForCharacterStyleInNodeTree( - AccessibilityNodeInfoCompat root, - @NonNull Set visitedNodes, - @Nullable AtomicInteger currentOffset, - @Nullable List result, - List> spanClasses) { + private fun searchSpannableStringsForCharacterStyleInNodeTree( + root: AccessibilityNodeInfoCompat?, + visitedNodes: MutableSet, + currentOffset: AtomicInteger?, + result: MutableList?, + spanClasses: List>, + ): Boolean { if (root == null) { - return false; + return false } if (!visitedNodes.add(root)) { // Root already visited. Stop searching. - return false; + return false } - SpannableString string = SpannableUtils.getSpannableStringWithCharacterStyle(root, spanClasses); - boolean hasSpannableString = !TextUtils.isEmpty(string); + val string = SpannableUtils.getSpannableStringWithCharacterStyle(root, spanClasses) + val hasSpannableString = !TextUtils.isEmpty(string) if (hasSpannableString) { if (result == null) { // If we don't need to collect result and we found a Spannable String, return true. - return true; + return true } else { - result.add( - new SpannableWithOffset(string, currentOffset == null ? 0 : currentOffset.get())); + result.add(SpannableWithOffset(string, currentOffset?.get() ?: 0)) } } - if (currentOffset != null && root.getText() != null) { - currentOffset.addAndGet(root.getText().length()); + if (currentOffset != null && root.text != null) { + currentOffset.addAndGet(root.text.length) } // TODO: Check if we should search descendants of web content node. - if (!TextUtils.isEmpty(root.getContentDescription())) { + if (!TextUtils.isEmpty(root.contentDescription)) { // If root has content description, do not search the children nodes. - LogUtils.v(TAG, "Root has content description, skipping searching the children nodes."); - return hasSpannableString; + LogUtils.v(TAG, "Root has content description, skipping searching the children nodes.") + return hasSpannableString } - ReorderedChildrenIterator iterator = ReorderedChildrenIterator.createAscendingIterator(root); - boolean containsSpannableDescendants = false; + val iterator = ReorderedChildrenIterator.createAscendingIterator(root) + var containsSpannableDescendants = false while (iterator.hasNext()) { - AccessibilityNodeInfoCompat child = iterator.next(); + val child = iterator.next() if (AccessibilityNodeInfoUtils.FILTER_NON_FOCUSABLE_VISIBLE_NODE.accept(child)) { - containsSpannableDescendants |= + containsSpannableDescendants = + containsSpannableDescendants or searchSpannableStringsForCharacterStyleInNodeTree( - child, visitedNodes, currentOffset, result, spanClasses); + child, visitedNodes, currentOffset, result, spanClasses) } if (containsSpannableDescendants && result == null) { - return true; + return true } } - return hasSpannableString || containsSpannableDescendants; + return hasSpannableString || containsSpannableDescendants } - - private SpannableTraversalUtils() {} } diff --git a/utils/src/main/java/com/google/android/accessibility/utils/traversal/TraversalStrategy.java b/utils/src/main/java/com/google/android/accessibility/utils/traversal/TraversalStrategy.kt similarity index 50% rename from utils/src/main/java/com/google/android/accessibility/utils/traversal/TraversalStrategy.java rename to utils/src/main/java/com/google/android/accessibility/utils/traversal/TraversalStrategy.kt index 1ceafe5d2..ca782ffa0 100644 --- a/utils/src/main/java/com/google/android/accessibility/utils/traversal/TraversalStrategy.java +++ b/utils/src/main/java/com/google/android/accessibility/utils/traversal/TraversalStrategy.kt @@ -1,79 +1,57 @@ /* - * Copyright (C) 2015 The Android Open Source Project + * Copyright (C) The Android Open Source Project * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not - * use this file except in compliance with the License. You may obtain a copy of - * the License at + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations under - * the License. + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Ported from Java to Kotlin. */ -package com.google.android.accessibility.utils.traversal; +package com.google.android.accessibility.utils.traversal -import androidx.annotation.IntDef; -import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.util.Locale; -import java.util.Map; -import org.checkerframework.checker.nullness.qual.Nullable; +import androidx.annotation.IntDef +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import java.util.Locale /** * Strategy the is defined an order of traversing through the nodes of AccessibilityNodeInfo * hierarchy */ -public interface TraversalStrategy { - - public static final int SEARCH_FOCUS_UNKNOWN = 0; - public static final int SEARCH_FOCUS_FORWARD = 1; - public static final int SEARCH_FOCUS_BACKWARD = 2; - public static final int SEARCH_FOCUS_LEFT = 3; - public static final int SEARCH_FOCUS_RIGHT = 4; - public static final int SEARCH_FOCUS_UP = 5; - public static final int SEARCH_FOCUS_DOWN = 6; +interface TraversalStrategy { /** Direction to search for an item to focus. */ - @IntDef({ + @IntDef( SEARCH_FOCUS_FORWARD, SEARCH_FOCUS_BACKWARD, SEARCH_FOCUS_LEFT, SEARCH_FOCUS_RIGHT, SEARCH_FOCUS_UP, - SEARCH_FOCUS_DOWN - }) - @Retention(RetentionPolicy.SOURCE) - public @interface SearchDirection {} + SEARCH_FOCUS_DOWN, + ) + @Retention(AnnotationRetention.SOURCE) + annotation class SearchDirection /** Direction to search for an item to focus, or unknown. */ - @IntDef({ + @IntDef( SEARCH_FOCUS_UNKNOWN, SEARCH_FOCUS_FORWARD, SEARCH_FOCUS_BACKWARD, SEARCH_FOCUS_LEFT, SEARCH_FOCUS_RIGHT, SEARCH_FOCUS_UP, - SEARCH_FOCUS_DOWN - }) - @Retention(RetentionPolicy.SOURCE) - public @interface SearchDirectionOrUnknown {} - - static String getSymbolicName(@SearchDirection int direction) { - return switch (direction) { - case SEARCH_FOCUS_FORWARD -> "SEARCH_FOCUS_FORWARD"; - case SEARCH_FOCUS_BACKWARD -> "SEARCH_FOCUS_BACKWARD"; - case SEARCH_FOCUS_LEFT -> "SEARCH_FOCUS_LEFT"; - case SEARCH_FOCUS_RIGHT -> "SEARCH_FOCUS_RIGHT"; - case SEARCH_FOCUS_UP -> "SEARCH_FOCUS_UP"; - case SEARCH_FOCUS_DOWN -> "SEARCH_FOCUS_DOWN"; - default -> String.format(Locale.ENGLISH, "unavailable direction: %d", direction); - }; - } + SEARCH_FOCUS_DOWN, + ) + @Retention(AnnotationRetention.SOURCE) + annotation class SearchDirectionOrUnknown /** * The method searches next node to be focused @@ -83,8 +61,10 @@ static String getSymbolicName(@SearchDirection int direction) { * @return {@link androidx.core.view.accessibility.AccessibilityNodeInfoCompat} node that has next * focus */ - public @Nullable AccessibilityNodeInfoCompat findFocus( - AccessibilityNodeInfoCompat startNode, @SearchDirection int direction); + fun findFocus( + startNode: AccessibilityNodeInfoCompat?, + @SearchDirection direction: Int, + ): AccessibilityNodeInfoCompat? /** * Finds the first focusable accessibility node in hierarchy started from root node when searching @@ -98,8 +78,10 @@ static String getSymbolicName(@SearchDirection int direction) { * @param direction the direction to search from * @return returns the first node that could be focused */ - public @Nullable AccessibilityNodeInfoCompat focusFirst( - AccessibilityNodeInfoCompat root, @SearchDirection int direction); + fun focusFirst( + root: AccessibilityNodeInfoCompat?, + @SearchDirection direction: Int, + ): AccessibilityNodeInfoCompat? /** * Finds the initial focusable accessibility node in hierarchy started from root node. @@ -110,10 +92,7 @@ static String getSymbolicName(@SearchDirection int direction) { * @param root root node * @return returns the initial node that could be focused */ - public default @Nullable AccessibilityNodeInfoCompat focusInitial( - AccessibilityNodeInfoCompat root) { - return null; - } + fun focusInitial(root: AccessibilityNodeInfoCompat?): AccessibilityNodeInfoCompat? = null /** * Calculating if node is speaking node according to AccessibilityNodeInfoUtils.isSpeakingNode() @@ -122,5 +101,27 @@ static String getSymbolicName(@SearchDirection int direction) { * * @return speaking node cache map. Could be null if cache is not used by traversal strategy */ - public Map getSpeakingNodesCache(); + fun getSpeakingNodesCache(): Map? + + companion object { + const val SEARCH_FOCUS_UNKNOWN = 0 + const val SEARCH_FOCUS_FORWARD = 1 + const val SEARCH_FOCUS_BACKWARD = 2 + const val SEARCH_FOCUS_LEFT = 3 + const val SEARCH_FOCUS_RIGHT = 4 + const val SEARCH_FOCUS_UP = 5 + const val SEARCH_FOCUS_DOWN = 6 + + @JvmStatic + fun getSymbolicName(@SearchDirection direction: Int): String = + when (direction) { + SEARCH_FOCUS_FORWARD -> "SEARCH_FOCUS_FORWARD" + SEARCH_FOCUS_BACKWARD -> "SEARCH_FOCUS_BACKWARD" + SEARCH_FOCUS_LEFT -> "SEARCH_FOCUS_LEFT" + SEARCH_FOCUS_RIGHT -> "SEARCH_FOCUS_RIGHT" + SEARCH_FOCUS_UP -> "SEARCH_FOCUS_UP" + SEARCH_FOCUS_DOWN -> "SEARCH_FOCUS_DOWN" + else -> String.format(Locale.ENGLISH, "unavailable direction: %d", direction) + } + } } diff --git a/utils/src/main/java/com/google/android/accessibility/utils/traversal/TraversalStrategyUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/traversal/TraversalStrategyUtils.java deleted file mode 100644 index f3c4d4b62..000000000 --- a/utils/src/main/java/com/google/android/accessibility/utils/traversal/TraversalStrategyUtils.java +++ /dev/null @@ -1,456 +0,0 @@ -package com.google.android.accessibility.utils.traversal; - -import static com.google.android.accessibility.utils.DiagnosticOverlayUtils.SEARCH_FOCUS_FAIL; -import static com.google.android.accessibility.utils.traversal.TraversalStrategy.SEARCH_FOCUS_BACKWARD; -import static com.google.android.accessibility.utils.traversal.TraversalStrategy.SEARCH_FOCUS_DOWN; -import static com.google.android.accessibility.utils.traversal.TraversalStrategy.SEARCH_FOCUS_FORWARD; -import static com.google.android.accessibility.utils.traversal.TraversalStrategy.SEARCH_FOCUS_LEFT; -import static com.google.android.accessibility.utils.traversal.TraversalStrategy.SEARCH_FOCUS_RIGHT; -import static com.google.android.accessibility.utils.traversal.TraversalStrategy.SEARCH_FOCUS_UP; - -import android.graphics.Rect; -import android.view.View; -import android.view.accessibility.AccessibilityNodeInfo; -import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction; -import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; -import com.google.android.accessibility.utils.AccessibilityNodeInfoUtils; -import com.google.android.accessibility.utils.DiagnosticOverlayUtils; -import com.google.android.accessibility.utils.Filter; -import com.google.android.accessibility.utils.FocusFinder; -import com.google.android.accessibility.utils.Role; -import com.google.android.accessibility.utils.ScrollableNodeInfo; -import com.google.android.accessibility.utils.WebInterfaceUtils; -import com.google.android.libraries.accessibility.utils.log.LogUtils; -import java.util.HashSet; -import java.util.Set; -import org.checkerframework.checker.nullness.qual.NonNull; -import org.checkerframework.checker.nullness.qual.Nullable; - -public class TraversalStrategyUtils { - - private static final String TAG = "TraversalStrategyUtils"; - - private TraversalStrategyUtils() { - // Prevent utility class from being instantiated. - } - - /** - * Recycles the given traversal strategy. - * - * @deprecated Accessibility is discontinuing recycling. - */ - @Deprecated - public static void recycle(@Nullable TraversalStrategy traversalStrategy) {} - - /** - * Depending on whether the direction is spatial or logical, returns the appropriate traversal - * strategy to handle the case. - */ - public static TraversalStrategy getTraversalStrategy( - AccessibilityNodeInfoCompat root, - FocusFinder focusFinder, - @TraversalStrategy.SearchDirection int direction) { - return getTraversalStrategy(root, focusFinder, direction, false, false); - } - - /** - * Depending on whether the direction is spatial or logical, returns the appropriate traversal - * strategy to handle the case. - */ - public static TraversalStrategy getTraversalStrategy( - AccessibilityNodeInfoCompat root, - FocusFinder focusFinder, - OrderedTraversalStrategyConfig config) { - return getTraversalStrategy( - root, - focusFinder, - config.searchDirection(), - config.includeChildrenOfNodesWithWebActions(), - config.makeFabFirst()); - } - - /** TODO: Experimental code. Remove {@code makeFabFirst} when experiment is done. */ - public static TraversalStrategy getTraversalStrategy( - AccessibilityNodeInfoCompat root, - FocusFinder focusFinder, - @TraversalStrategy.SearchDirection int direction, - boolean includeChildrenOfNodesWithWebActions, - boolean makeFabFirst) { - switch (direction) { - case SEARCH_FOCUS_BACKWARD, SEARCH_FOCUS_FORWARD -> { - return new OrderedTraversalStrategy( - root, includeChildrenOfNodesWithWebActions, makeFabFirst); - } - case SEARCH_FOCUS_LEFT, SEARCH_FOCUS_RIGHT, SEARCH_FOCUS_UP, SEARCH_FOCUS_DOWN -> { - return new DirectionalTraversalStrategy(root, focusFinder); - } - default -> {} - } - - throw new IllegalArgumentException("direction must be a SearchDirection"); - } - - /** Converts {@link TraversalStrategy.SearchDirection} to view focus direction. */ - public static int nodeSearchDirectionToViewSearchDirection( - @TraversalStrategy.SearchDirection int direction) { - return switch (direction) { - case SEARCH_FOCUS_FORWARD -> View.FOCUS_FORWARD; - case SEARCH_FOCUS_BACKWARD -> View.FOCUS_BACKWARD; - case SEARCH_FOCUS_LEFT -> View.FOCUS_LEFT; - case SEARCH_FOCUS_RIGHT -> View.FOCUS_RIGHT; - case SEARCH_FOCUS_UP -> View.FOCUS_UP; - case SEARCH_FOCUS_DOWN -> View.FOCUS_DOWN; - default -> throw new IllegalArgumentException("Direction must be a SearchDirection"); - }; - } - - /** - * Determines whether the given search direction corresponds to an actual spatial direction as - * opposed to a logical direction. - */ - public static boolean isSpatialDirection(@TraversalStrategy.SearchDirection int direction) { - switch (direction) { - case SEARCH_FOCUS_FORWARD, SEARCH_FOCUS_BACKWARD -> { - return false; - } - case SEARCH_FOCUS_UP, SEARCH_FOCUS_DOWN, SEARCH_FOCUS_LEFT, SEARCH_FOCUS_RIGHT -> { - return true; - } - default -> {} - } - - throw new IllegalArgumentException("direction must be a SearchDirection"); - } - - /** Returns {@code true} if {@code searchDirection} is logical (forward or backward). */ - public static boolean isLogicalDirection(@TraversalStrategy.SearchDirection int direction) { - return direction == SEARCH_FOCUS_FORWARD || direction == SEARCH_FOCUS_BACKWARD; - } - - /** - * Converts a spatial direction to a logical direction based on whether the user is LTR or RTL. If - * the direction is already a logical direction, it is returned. - */ - @TraversalStrategy.SearchDirection - public static int getLogicalDirection( - @TraversalStrategy.SearchDirection int direction, boolean isRtl) { - @TraversalStrategy.SearchDirection int left; - @TraversalStrategy.SearchDirection int right; - if (isRtl) { - left = SEARCH_FOCUS_FORWARD; - right = SEARCH_FOCUS_BACKWARD; - } else { - left = SEARCH_FOCUS_BACKWARD; - right = SEARCH_FOCUS_FORWARD; - } - - switch (direction) { - case SEARCH_FOCUS_LEFT -> { - return left; - } - case SEARCH_FOCUS_RIGHT -> { - return right; - } - case SEARCH_FOCUS_UP, SEARCH_FOCUS_BACKWARD -> { - return SEARCH_FOCUS_BACKWARD; - } - case SEARCH_FOCUS_DOWN, SEARCH_FOCUS_FORWARD -> { - return SEARCH_FOCUS_FORWARD; - } - default -> {} - } - - throw new IllegalArgumentException("direction must be a SearchDirection"); - } - - /** - * Returns the scroll action for the given {@link TraversalStrategy.SearchDirection} if the scroll - * action is available on the current SDK version. Otherwise, returns 0. - */ - public static int convertSearchDirectionToScrollAction( - @TraversalStrategy.SearchDirection int direction) { - if (direction == SEARCH_FOCUS_FORWARD) { - return AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD; - } else if (direction == SEARCH_FOCUS_BACKWARD) { - return AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD; - } else { - if (direction == SEARCH_FOCUS_LEFT) { - return AccessibilityAction.ACTION_SCROLL_LEFT.getId(); - } else if (direction == SEARCH_FOCUS_RIGHT) { - return AccessibilityAction.ACTION_SCROLL_RIGHT.getId(); - } else if (direction == SEARCH_FOCUS_UP) { - return AccessibilityAction.ACTION_SCROLL_UP.getId(); - } else if (direction == SEARCH_FOCUS_DOWN) { - return AccessibilityAction.ACTION_SCROLL_DOWN.getId(); - } - } - - return 0; - } - - /** - * Returns the {@link TraversalStrategy.SearchDirectionOrUnknown} for the given scroll action; - * {@link TraversalStrategy#SEARCH_FOCUS_UNKNOWN} is returned for a scroll action that can't be - * handled (e.g. because the current API level doesn't support it). - */ - @TraversalStrategy.SearchDirectionOrUnknown - public static int convertScrollActionToSearchDirection(int scrollAction) { - if (scrollAction == AccessibilityNodeInfo.ACTION_SCROLL_FORWARD) { - return SEARCH_FOCUS_FORWARD; - } else if (scrollAction == AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD) { - return SEARCH_FOCUS_BACKWARD; - } else { - if (scrollAction == AccessibilityAction.ACTION_SCROLL_LEFT.getId()) { - return SEARCH_FOCUS_LEFT; - } else if (scrollAction == AccessibilityAction.ACTION_SCROLL_RIGHT.getId()) { - return SEARCH_FOCUS_RIGHT; - } else if (scrollAction == AccessibilityAction.ACTION_SCROLL_UP.getId()) { - return SEARCH_FOCUS_UP; - } else if (scrollAction == AccessibilityAction.ACTION_SCROLL_DOWN.getId()) { - return SEARCH_FOCUS_DOWN; - } - } - - return TraversalStrategy.SEARCH_FOCUS_UNKNOWN; - } - - /** - * Convenience method determining if the current item is at the edge of a scrollable view and - * suitable autoscroll. Calls {@code isEdgeListItem} with {@code FILTER_AUTO_SCROLL}. - * - * @param pivot The node to check. - * @param scrollableNodeInfo The info about the scrollable container for which is checked if the - * pivot is at the edge or not. - * @param ignoreDescendantsOfPivot Whether to ignore descendants of pivot when search down the - * node tree. - * @param searchDirection The direction in which to check. - * @return true if the current item is at the edge of a list. - */ - public static boolean isAutoScrollEdgeListItem( - AccessibilityNodeInfoCompat pivot, - @NonNull ScrollableNodeInfo scrollableNodeInfo, - boolean ignoreDescendantsOfPivot, - @TraversalStrategy.SearchDirection int searchDirection, - FocusFinder focusFinder) { - - Integer supportedDirection = scrollableNodeInfo.getSupportedScrollDirection(searchDirection); - if (supportedDirection == null) { - return false; - } - - TraversalStrategy traversalStrategy = - scrollableNodeInfo.getSupportedTraversalStrategy(supportedDirection, focusFinder); - return isMatchingEdgeListItem( - pivot, - scrollableNodeInfo.getNode(), - ignoreDescendantsOfPivot, - supportedDirection, - AccessibilityNodeInfoUtils.FILTER_AUTO_SCROLL, - traversalStrategy); - } - - /** - * Utility method for determining if a searching past a particular node will fall off the edge of - * a scrollable container. - * - * @param cursor Node to check. - * @param scrollableNode The scrollable container that for checking the cursor is at the edge or - * not. - * @param ignoreDescendantsOfCursor Whether to ignore descendants of cursor when search down the - * node tree. - * @param direction The direction in which to move from the cursor. - * @param filter Filter used to validate list-type ancestors. - * @param traversalStrategy - traversal strategy that is used to define order of node - * @return {@code true} if focusing search in the specified direction will fall off the edge of - * the container. - */ - private static boolean isMatchingEdgeListItem( - final AccessibilityNodeInfoCompat cursor, - final @NonNull AccessibilityNodeInfoCompat scrollableNode, - boolean ignoreDescendantsOfCursor, - @TraversalStrategy.SearchDirection int direction, - Filter filter, - TraversalStrategy traversalStrategy) { - AccessibilityNodeInfoCompat webViewNode = null; - - boolean cursorNodeNotContainedInScrollableList = - !scrollableNode.isScrollable() - || !(AccessibilityNodeInfoUtils.hasAncestor(cursor, scrollableNode) - || scrollableNode.equals(cursor)); - - if (cursorNodeNotContainedInScrollableList) { - return false; - } - - Filter focusNodeFilter = - AccessibilityNodeInfoUtils.FILTER_SHOULD_FOCUS; - if (ignoreDescendantsOfCursor) { - focusNodeFilter = - focusNodeFilter.and( - new Filter() { - @Override - public boolean accept(AccessibilityNodeInfoCompat obj) { - return !AccessibilityNodeInfoUtils.hasAncestor(obj, cursor); - } - }); - } - AccessibilityNodeInfoCompat nextFocusNode = - searchFocus(traversalStrategy, cursor, direction, focusNodeFilter); - - if ((nextFocusNode == null) || nextFocusNode.equals(scrollableNode)) { - // Can't move from this position. - return true; - } - - // if nextFocusNode is in WebView and not visible to user we still could set - // accessibility focus on it and WebView scrolls itself to show newly focused item - // on the screen. But there could be situation that node is inside WebView bounds but - // WebView is [partially] outside the screen bounds. In that case we don't ask WebView - // to set accessibility focus but try to scroll scrollable parent to get the WebView - // with nextFocusNode inside it to the screen bounds. - if (!nextFocusNode.isVisibleToUser() && WebInterfaceUtils.hasNativeWebContent(nextFocusNode)) { - webViewNode = - AccessibilityNodeInfoUtils.getMatchingAncestor( - nextFocusNode, - new Filter() { - @Override - public boolean accept(AccessibilityNodeInfoCompat node) { - return Role.getRole(node) == Role.ROLE_WEB_VIEW; - } - }); - - if (webViewNode != null - && (!webViewNode.isVisibleToUser() - || isNodeInBoundsOfOther(webViewNode, nextFocusNode))) { - return true; - } - } - - AccessibilityNodeInfoCompat searchedAncestor = - AccessibilityNodeInfoUtils.getMatchingAncestor(nextFocusNode, filter); - while (searchedAncestor != null) { - if (scrollableNode.equals(searchedAncestor)) { - return false; - } - searchedAncestor = AccessibilityNodeInfoUtils.getMatchingAncestor(searchedAncestor, filter); - } - // Moves outside of the scrollable container. - return true; - } - - /** - * Search focus that satisfied specified node filter from currentFocus to specified direction - * according to OrderTraversal strategy - * - * @param traversal - order traversal strategy - * @param currentFocus - node that is starting point of focus search - * @param direction - direction the target focus is searching to - * @param filter - filters focused node candidate - * @return node that could be focused next - */ - public static @Nullable AccessibilityNodeInfoCompat searchFocus( - TraversalStrategy traversal, - AccessibilityNodeInfoCompat currentFocus, - @TraversalStrategy.SearchDirection int direction, - Filter filter) { - if (traversal == null || currentFocus == null) { - return null; - } - - if (filter == null) { - filter = DEFAULT_FILTER; - } - - AccessibilityNodeInfoCompat targetNode = currentFocus; - Set seenNodes = new HashSet<>(); - - do { - seenNodes.add(targetNode); - targetNode = traversal.findFocus(targetNode, direction); - DiagnosticOverlayUtils.appendLog(SEARCH_FOCUS_FAIL, targetNode); - - if (seenNodes.contains(targetNode)) { - LogUtils.e(TAG, "Found duplicate during traversal: %s", targetNode); - return null; - } - } while (targetNode != null && !filter.accept(targetNode)); - - return targetNode; - } - - /** - * Finds the first focusable accessibility node in hierarchy started from root node when searching - * in the given direction. - * - *

    For example, if {@code direction} is {@link TraversalStrategy#SEARCH_FOCUS_FORWARD}, then - * the method should return the first node in the traversal order. If {@code direction} is {@link - * TraversalStrategy#SEARCH_FOCUS_BACKWARD} then the method should return the last node in the - * traversal order. - * - * @param traversalStrategy the traversal strategy - * @param root the root node - * @param direction the direction to search from - * @param nodeFilter the {@link Filter} to determine which nodes to focus - * @return returns the first node that matches nodeFilter - */ - public static @Nullable AccessibilityNodeInfoCompat findFirstFocusInNodeTree( - TraversalStrategy traversalStrategy, - AccessibilityNodeInfoCompat root, - @TraversalStrategy.SearchDirection int direction, - Filter nodeFilter) { - if (root == null) { - return null; - } - AccessibilityNodeInfoCompat firstNode = traversalStrategy.focusFirst(root, direction); - - if (nodeFilter.accept(firstNode)) { - return firstNode; - } - return TraversalStrategyUtils.searchFocus(traversalStrategy, firstNode, direction, nodeFilter); - } - - private static boolean isNodeInBoundsOfOther( - AccessibilityNodeInfoCompat outerNode, AccessibilityNodeInfoCompat innerNode) { - if (outerNode == null || innerNode == null) { - return false; - } - - Rect outerRect = new Rect(); - Rect innerRect = new Rect(); - outerNode.getBoundsInScreen(outerRect); - innerNode.getBoundsInScreen(innerRect); - - if (outerRect.top > innerRect.bottom || outerRect.bottom < innerRect.top) { - return false; - } - - //noinspection RedundantIfStatement - if (outerRect.left > innerRect.right || outerRect.right < innerRect.left) { - return false; - } - - return true; - } - - private static final Filter DEFAULT_FILTER = - new Filter() { - @Override - public boolean accept(AccessibilityNodeInfoCompat node) { - return node != null; - } - }; - - public static String directionToString( - @TraversalStrategy.SearchDirectionOrUnknown int direction) { - return switch (direction) { - case SEARCH_FOCUS_FORWARD -> "SEARCH_FOCUS_FORWARD"; - case SEARCH_FOCUS_BACKWARD -> "SEARCH_FOCUS_BACKWARD"; - case SEARCH_FOCUS_LEFT -> "SEARCH_FOCUS_LEFT"; - case SEARCH_FOCUS_RIGHT -> "SEARCH_FOCUS_RIGHT"; - case SEARCH_FOCUS_UP -> "SEARCH_FOCUS_UP"; - case SEARCH_FOCUS_DOWN -> "SEARCH_FOCUS_DOWN"; - case TraversalStrategy.SEARCH_FOCUS_UNKNOWN -> "SEARCH_FOCUS_UNKNOWN"; - default -> "(unhandled)"; - }; - } -} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/traversal/TraversalStrategyUtils.kt b/utils/src/main/java/com/google/android/accessibility/utils/traversal/TraversalStrategyUtils.kt new file mode 100644 index 000000000..da5766718 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/traversal/TraversalStrategyUtils.kt @@ -0,0 +1,466 @@ +/* + * Copyright (C) The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Ported from Java to Kotlin. + */ + +package com.google.android.accessibility.utils.traversal + +import android.graphics.Rect +import android.view.View +import android.view.accessibility.AccessibilityNodeInfo +import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import com.google.android.accessibility.utils.AccessibilityNodeInfoUtils +import com.google.android.accessibility.utils.DiagnosticOverlayUtils +import com.google.android.accessibility.utils.Filter +import com.google.android.accessibility.utils.FocusFinder +import com.google.android.accessibility.utils.Role +import com.google.android.accessibility.utils.ScrollableNodeInfo +import com.google.android.accessibility.utils.WebInterfaceUtils +import com.google.android.accessibility.utils.traversal.TraversalStrategy.Companion.SEARCH_FOCUS_BACKWARD +import com.google.android.accessibility.utils.traversal.TraversalStrategy.Companion.SEARCH_FOCUS_DOWN +import com.google.android.accessibility.utils.traversal.TraversalStrategy.Companion.SEARCH_FOCUS_FORWARD +import com.google.android.accessibility.utils.traversal.TraversalStrategy.Companion.SEARCH_FOCUS_LEFT +import com.google.android.accessibility.utils.traversal.TraversalStrategy.Companion.SEARCH_FOCUS_RIGHT +import com.google.android.accessibility.utils.traversal.TraversalStrategy.Companion.SEARCH_FOCUS_UP +import com.google.android.libraries.accessibility.utils.log.LogUtils + +object TraversalStrategyUtils { + + private const val TAG = "TraversalStrategyUtils" + + /** + * Recycles the given traversal strategy. + * + * @deprecated Accessibility is discontinuing recycling. + */ + @Deprecated("Accessibility is discontinuing recycling.") + @JvmStatic + fun recycle(traversalStrategy: TraversalStrategy?) {} + + /** + * Depending on whether the direction is spatial or logical, returns the appropriate traversal + * strategy to handle the case. + */ + @JvmStatic + fun getTraversalStrategy( + root: AccessibilityNodeInfoCompat?, + focusFinder: FocusFinder?, + @TraversalStrategy.SearchDirection direction: Int, + ): TraversalStrategy = getTraversalStrategy(root, focusFinder, direction, false, false) + + /** + * Depending on whether the direction is spatial or logical, returns the appropriate traversal + * strategy to handle the case. + */ + @JvmStatic + fun getTraversalStrategy( + root: AccessibilityNodeInfoCompat?, + focusFinder: FocusFinder?, + config: OrderedTraversalStrategyConfig, + ): TraversalStrategy = + getTraversalStrategy( + root, + focusFinder, + config.searchDirection(), + config.includeChildrenOfNodesWithWebActions(), + config.makeFabFirst(), + ) + + /** TODO: Experimental code. Remove {@code makeFabFirst} when experiment is done. */ + @JvmStatic + fun getTraversalStrategy( + root: AccessibilityNodeInfoCompat?, + focusFinder: FocusFinder?, + @TraversalStrategy.SearchDirection direction: Int, + includeChildrenOfNodesWithWebActions: Boolean, + makeFabFirst: Boolean, + ): TraversalStrategy { + when (direction) { + SEARCH_FOCUS_BACKWARD, + SEARCH_FOCUS_FORWARD -> { + return OrderedTraversalStrategy(root, includeChildrenOfNodesWithWebActions, makeFabFirst) + } + SEARCH_FOCUS_LEFT, + SEARCH_FOCUS_RIGHT, + SEARCH_FOCUS_UP, + SEARCH_FOCUS_DOWN -> { + return DirectionalTraversalStrategy(root, focusFinder) + } + else -> {} + } + + throw IllegalArgumentException("direction must be a SearchDirection") + } + + /** Converts {@link TraversalStrategy.SearchDirection} to view focus direction. */ + @JvmStatic + fun nodeSearchDirectionToViewSearchDirection( + @TraversalStrategy.SearchDirection direction: Int, + ): Int = + when (direction) { + SEARCH_FOCUS_FORWARD -> View.FOCUS_FORWARD + SEARCH_FOCUS_BACKWARD -> View.FOCUS_BACKWARD + SEARCH_FOCUS_LEFT -> View.FOCUS_LEFT + SEARCH_FOCUS_RIGHT -> View.FOCUS_RIGHT + SEARCH_FOCUS_UP -> View.FOCUS_UP + SEARCH_FOCUS_DOWN -> View.FOCUS_DOWN + else -> throw IllegalArgumentException("Direction must be a SearchDirection") + } + + /** + * Determines whether the given search direction corresponds to an actual spatial direction as + * opposed to a logical direction. + */ + @JvmStatic + fun isSpatialDirection(@TraversalStrategy.SearchDirection direction: Int): Boolean { + when (direction) { + SEARCH_FOCUS_FORWARD, SEARCH_FOCUS_BACKWARD -> return false + SEARCH_FOCUS_UP, SEARCH_FOCUS_DOWN, SEARCH_FOCUS_LEFT, SEARCH_FOCUS_RIGHT -> return true + else -> {} + } + + throw IllegalArgumentException("direction must be a SearchDirection") + } + + /** Returns {@code true} if {@code searchDirection} is logical (forward or backward). */ + @JvmStatic + fun isLogicalDirection(@TraversalStrategy.SearchDirection direction: Int): Boolean = + direction == SEARCH_FOCUS_FORWARD || direction == SEARCH_FOCUS_BACKWARD + + /** + * Converts a spatial direction to a logical direction based on whether the user is LTR or RTL. If + * the direction is already a logical direction, it is returned. + */ + @JvmStatic + @TraversalStrategy.SearchDirection + fun getLogicalDirection( + @TraversalStrategy.SearchDirection direction: Int, + isRtl: Boolean, + ): Int { + @TraversalStrategy.SearchDirection val left: Int + @TraversalStrategy.SearchDirection val right: Int + if (isRtl) { + left = SEARCH_FOCUS_FORWARD + right = SEARCH_FOCUS_BACKWARD + } else { + left = SEARCH_FOCUS_BACKWARD + right = SEARCH_FOCUS_FORWARD + } + + when (direction) { + SEARCH_FOCUS_LEFT -> return left + SEARCH_FOCUS_RIGHT -> return right + SEARCH_FOCUS_UP, SEARCH_FOCUS_BACKWARD -> return SEARCH_FOCUS_BACKWARD + SEARCH_FOCUS_DOWN, SEARCH_FOCUS_FORWARD -> return SEARCH_FOCUS_FORWARD + else -> {} + } + + throw IllegalArgumentException("direction must be a SearchDirection") + } + + /** + * Returns the scroll action for the given {@link TraversalStrategy.SearchDirection} if the scroll + * action is available on the current SDK version. Otherwise, returns 0. + */ + @JvmStatic + fun convertSearchDirectionToScrollAction( + @TraversalStrategy.SearchDirection direction: Int, + ): Int { + if (direction == SEARCH_FOCUS_FORWARD) { + return AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD + } else if (direction == SEARCH_FOCUS_BACKWARD) { + return AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD + } else { + if (direction == SEARCH_FOCUS_LEFT) { + return AccessibilityAction.ACTION_SCROLL_LEFT.id + } else if (direction == SEARCH_FOCUS_RIGHT) { + return AccessibilityAction.ACTION_SCROLL_RIGHT.id + } else if (direction == SEARCH_FOCUS_UP) { + return AccessibilityAction.ACTION_SCROLL_UP.id + } else if (direction == SEARCH_FOCUS_DOWN) { + return AccessibilityAction.ACTION_SCROLL_DOWN.id + } + } + + return 0 + } + + /** + * Returns the {@link TraversalStrategy.SearchDirectionOrUnknown} for the given scroll action; + * {@link TraversalStrategy#SEARCH_FOCUS_UNKNOWN} is returned for a scroll action that can't be + * handled (e.g. because the current API level doesn't support it). + */ + @JvmStatic + @TraversalStrategy.SearchDirectionOrUnknown + fun convertScrollActionToSearchDirection(scrollAction: Int): Int { + if (scrollAction == AccessibilityNodeInfo.ACTION_SCROLL_FORWARD) { + return SEARCH_FOCUS_FORWARD + } else if (scrollAction == AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD) { + return SEARCH_FOCUS_BACKWARD + } else { + if (scrollAction == AccessibilityAction.ACTION_SCROLL_LEFT.id) { + return SEARCH_FOCUS_LEFT + } else if (scrollAction == AccessibilityAction.ACTION_SCROLL_RIGHT.id) { + return SEARCH_FOCUS_RIGHT + } else if (scrollAction == AccessibilityAction.ACTION_SCROLL_UP.id) { + return SEARCH_FOCUS_UP + } else if (scrollAction == AccessibilityAction.ACTION_SCROLL_DOWN.id) { + return SEARCH_FOCUS_DOWN + } + } + + return TraversalStrategy.SEARCH_FOCUS_UNKNOWN + } + + /** + * Convenience method determining if the current item is at the edge of a scrollable view and + * suitable autoscroll. Calls {@code isEdgeListItem} with {@code FILTER_AUTO_SCROLL}. + * + * @param pivot The node to check. + * @param scrollableNodeInfo The info about the scrollable container for which is checked if the + * pivot is at the edge or not. + * @param ignoreDescendantsOfPivot Whether to ignore descendants of pivot when search down the + * node tree. + * @param searchDirection The direction in which to check. + * @return true if the current item is at the edge of a list. + */ + @JvmStatic + fun isAutoScrollEdgeListItem( + pivot: AccessibilityNodeInfoCompat, + scrollableNodeInfo: ScrollableNodeInfo, + ignoreDescendantsOfPivot: Boolean, + @TraversalStrategy.SearchDirection searchDirection: Int, + focusFinder: FocusFinder, + ): Boolean { + val supportedDirection = + scrollableNodeInfo.getSupportedScrollDirection(searchDirection) ?: return false + + val traversalStrategy = + scrollableNodeInfo.getSupportedTraversalStrategy(supportedDirection, focusFinder) + return isMatchingEdgeListItem( + pivot, + scrollableNodeInfo.node, + ignoreDescendantsOfPivot, + supportedDirection, + AccessibilityNodeInfoUtils.FILTER_AUTO_SCROLL, + traversalStrategy, + ) + } + + /** + * Utility method for determining if a searching past a particular node will fall off the edge of + * a scrollable container. + * + * @param cursor Node to check. + * @param scrollableNode The scrollable container that for checking the cursor is at the edge or + * not. + * @param ignoreDescendantsOfCursor Whether to ignore descendants of cursor when search down the + * node tree. + * @param direction The direction in which to move from the cursor. + * @param filter Filter used to validate list-type ancestors. + * @param traversalStrategy - traversal strategy that is used to define order of node + * @return {@code true} if focusing search in the specified direction will fall off the edge of + * the container. + */ + private fun isMatchingEdgeListItem( + cursor: AccessibilityNodeInfoCompat, + scrollableNode: AccessibilityNodeInfoCompat, + ignoreDescendantsOfCursor: Boolean, + @TraversalStrategy.SearchDirection direction: Int, + filter: Filter, + traversalStrategy: TraversalStrategy?, + ): Boolean { + var webViewNode: AccessibilityNodeInfoCompat? = null + + val cursorNodeNotContainedInScrollableList = + !scrollableNode.isScrollable || + !(AccessibilityNodeInfoUtils.hasAncestor(cursor, scrollableNode) || + scrollableNode == cursor) + + if (cursorNodeNotContainedInScrollableList) { + return false + } + + var focusNodeFilter: Filter = + AccessibilityNodeInfoUtils.FILTER_SHOULD_FOCUS + if (ignoreDescendantsOfCursor) { + focusNodeFilter = + focusNodeFilter.and( + object : Filter() { + override fun accept(obj: AccessibilityNodeInfoCompat): Boolean { + return !AccessibilityNodeInfoUtils.hasAncestor(obj, cursor) + } + }, + ) + } + val nextFocusNode = searchFocus(traversalStrategy, cursor, direction, focusNodeFilter) + + if (nextFocusNode == null || nextFocusNode == scrollableNode) { + // Can't move from this position. + return true + } + + // if nextFocusNode is in WebView and not visible to user we still could set + // accessibility focus on it and WebView scrolls itself to show newly focused item + // on the screen. But there could be situation that node is inside WebView bounds but + // WebView is [partially] outside the screen bounds. In that case we don't ask WebView + // to set accessibility focus but try to scroll scrollable parent to get the WebView + // with nextFocusNode inside it to the screen bounds. + if (!nextFocusNode.isVisibleToUser && WebInterfaceUtils.hasNativeWebContent(nextFocusNode)) { + webViewNode = + AccessibilityNodeInfoUtils.getMatchingAncestor( + nextFocusNode, + object : Filter() { + override fun accept(node: AccessibilityNodeInfoCompat): Boolean { + return Role.getRole(node) == Role.ROLE_WEB_VIEW + } + }, + ) + + if (webViewNode != null && + (!webViewNode.isVisibleToUser || isNodeInBoundsOfOther(webViewNode, nextFocusNode)) + ) { + return true + } + } + + var searchedAncestor = AccessibilityNodeInfoUtils.getMatchingAncestor(nextFocusNode, filter) + while (searchedAncestor != null) { + if (scrollableNode == searchedAncestor) { + return false + } + searchedAncestor = AccessibilityNodeInfoUtils.getMatchingAncestor(searchedAncestor, filter) + } + // Moves outside of the scrollable container. + return true + } + + /** + * Search focus that satisfied specified node filter from currentFocus to specified direction + * according to OrderTraversal strategy + * + * @param traversal - order traversal strategy + * @param currentFocus - node that is starting point of focus search + * @param direction - direction the target focus is searching to + * @param filter - filters focused node candidate + * @return node that could be focused next + */ + @JvmStatic + fun searchFocus( + traversal: TraversalStrategy?, + currentFocus: AccessibilityNodeInfoCompat?, + @TraversalStrategy.SearchDirection direction: Int, + filter: Filter?, + ): AccessibilityNodeInfoCompat? { + if (traversal == null || currentFocus == null) { + return null + } + + val nodeFilter = filter ?: DEFAULT_FILTER + + var targetNode: AccessibilityNodeInfoCompat? = currentFocus + val seenNodes = HashSet() + + do { + seenNodes.add(targetNode!!) + targetNode = traversal.findFocus(targetNode, direction) + DiagnosticOverlayUtils.appendLog(DiagnosticOverlayUtils.SEARCH_FOCUS_FAIL, targetNode) + + if (seenNodes.contains(targetNode)) { + LogUtils.e(TAG, "Found duplicate during traversal: %s", targetNode) + return null + } + } while (targetNode != null && !nodeFilter.accept(targetNode)) + + return targetNode + } + + /** + * Finds the first focusable accessibility node in hierarchy started from root node when searching + * in the given direction. + * + *

    For example, if {@code direction} is {@link TraversalStrategy#SEARCH_FOCUS_FORWARD}, then + * the method should return the first node in the traversal order. If {@code direction} is {@link + * TraversalStrategy#SEARCH_FOCUS_BACKWARD} then the method should return the last node in the + * traversal order. + * + * @param traversalStrategy the traversal strategy + * @param root the root node + * @param direction the direction to search from + * @param nodeFilter the {@link Filter} to determine which nodes to focus + * @return returns the first node that matches nodeFilter + */ + @JvmStatic + fun findFirstFocusInNodeTree( + traversalStrategy: TraversalStrategy, + root: AccessibilityNodeInfoCompat?, + @TraversalStrategy.SearchDirection direction: Int, + nodeFilter: Filter, + ): AccessibilityNodeInfoCompat? { + if (root == null) { + return null + } + val firstNode = traversalStrategy.focusFirst(root, direction) + + if (nodeFilter.accept(firstNode)) { + return firstNode + } + return searchFocus(traversalStrategy, firstNode, direction, nodeFilter) + } + + private fun isNodeInBoundsOfOther( + outerNode: AccessibilityNodeInfoCompat?, + innerNode: AccessibilityNodeInfoCompat?, + ): Boolean { + if (outerNode == null || innerNode == null) { + return false + } + + val outerRect = Rect() + val innerRect = Rect() + outerNode.getBoundsInScreen(outerRect) + innerNode.getBoundsInScreen(innerRect) + + if (outerRect.top > innerRect.bottom || outerRect.bottom < innerRect.top) { + return false + } + + if (outerRect.left > innerRect.right || outerRect.right < innerRect.left) { + return false + } + + return true + } + + private val DEFAULT_FILTER: Filter = + object : Filter() { + override fun accept(node: AccessibilityNodeInfoCompat?): Boolean = node != null + } + + @JvmStatic + fun directionToString(@TraversalStrategy.SearchDirectionOrUnknown direction: Int): String = + when (direction) { + SEARCH_FOCUS_FORWARD -> "SEARCH_FOCUS_FORWARD" + SEARCH_FOCUS_BACKWARD -> "SEARCH_FOCUS_BACKWARD" + SEARCH_FOCUS_LEFT -> "SEARCH_FOCUS_LEFT" + SEARCH_FOCUS_RIGHT -> "SEARCH_FOCUS_RIGHT" + SEARCH_FOCUS_UP -> "SEARCH_FOCUS_UP" + SEARCH_FOCUS_DOWN -> "SEARCH_FOCUS_DOWN" + TraversalStrategy.SEARCH_FOCUS_UNKNOWN -> "SEARCH_FOCUS_UNKNOWN" + else -> "(unhandled)" + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/traversal/WorkingTree.java b/utils/src/main/java/com/google/android/accessibility/utils/traversal/WorkingTree.java deleted file mode 100644 index 1b4e87e3b..000000000 --- a/utils/src/main/java/com/google/android/accessibility/utils/traversal/WorkingTree.java +++ /dev/null @@ -1,201 +0,0 @@ -/* - * Copyright (C) 2015 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not - * use this file except in compliance with the License. You may obtain a copy of - * the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations under - * the License. - */ - -package com.google.android.accessibility.utils.traversal; - -import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; -import com.google.android.libraries.accessibility.utils.log.LogUtils; -import com.google.common.collect.Iterables; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import org.checkerframework.checker.nullness.qual.NonNull; -import org.checkerframework.checker.nullness.qual.Nullable; - -/** Tree that represents Accessibility node hierarchy. It lets reorder the structure of the tree. */ -public class WorkingTree { - - private static final String TAG = "WorkingTree"; - - private final AccessibilityNodeInfoCompat node; - private @Nullable WorkingTree parent; - private final List children = new ArrayList<>(); - - public WorkingTree(@NonNull AccessibilityNodeInfoCompat node, @Nullable WorkingTree parent) { - this.node = node; - this.parent = parent; - } - - public @NonNull AccessibilityNodeInfoCompat getNode() { - return node; - } - - public @Nullable WorkingTree getParent() { - return parent; - } - - public void setParent(@Nullable WorkingTree parent) { - this.parent = parent; - } - - public void addChild(WorkingTree node) { - children.add(node); - } - - public boolean removeChild(WorkingTree child) { - return children.remove(child); - } - - /** Checks whether subTree is a descendant of this WorkingTree node. */ - public boolean hasDescendant(@Nullable WorkingTree tree) { - - if (ancestorsHaveLoop()) { - LogUtils.w(TAG, "Looped ancestors line"); - return false; - } - - // For each ancestor of target descendant node... - WorkingTree subTree = tree; - while (subTree != null) { - AccessibilityNodeInfoCompat node = subTree.getNode(); - - // If ancestor is this working tree node... target is descendant of this node. - if (this.node.equals(node)) { - return true; - } - - subTree = subTree.getParent(); - } - - return false; - } - - /** Checks whether subTree is a descendant of this WorkingTree node. */ - public boolean ancestorsHaveLoop() { - Set visitedNodes = new HashSet<>(); - - // For each ancestor node... - for (WorkingTree workNode = this; workNode != null; workNode = workNode.getParent()) { - AccessibilityNodeInfoCompat accessNode = workNode.getNode(); - if (visitedNodes.contains(accessNode)) { - return true; - } - visitedNodes.add(accessNode); - } - return false; - } - - public void swapChild(WorkingTree swappedChild, WorkingTree newChild) { - int position = children.indexOf(swappedChild); - if (position < 0) { - LogUtils.e(TAG, "WorkingTree IllegalStateException: swap child not found"); - return; - } - - children.set(position, newChild); - } - - public @Nullable WorkingTree getNext() { - if (!children.isEmpty()) { - return children.get(0); - } - - WorkingTree startNode = this; - while (startNode != null) { - WorkingTree nextSibling = startNode.getNextSibling(); - if (nextSibling != null) { - return nextSibling; - } - - startNode = startNode.getParent(); - } - - return null; - } - - public @Nullable WorkingTree getNextSibling() { - WorkingTree parent = getParent(); - if (parent == null) { - return null; - } - - int currentIndex = parent.children.indexOf(this); - if (currentIndex < 0) { - LogUtils.e(TAG, "WorkingTree IllegalStateException: swap child not found"); - return null; - } - - currentIndex++; - - if (currentIndex >= parent.children.size()) { - // it was last child - return null; - } - - return parent.children.get(currentIndex); - } - - public @Nullable WorkingTree getPrevious() { - WorkingTree previousSibling = getPreviousSibling(); - if (previousSibling != null) { - return previousSibling.getLastNode(); - } - - return getParent(); - } - - public @Nullable WorkingTree getPreviousSibling() { - WorkingTree parent = getParent(); - if (parent == null) { - return null; - } - - int currentIndex = parent.children.indexOf(this); - if (currentIndex < 0) { - LogUtils.e(TAG, "WorkingTree IllegalStateException: swap child not found"); - return null; - } - - currentIndex--; - - if (currentIndex < 0) { - // it was first child - return null; - } - - return parent.children.get(currentIndex); - } - - public WorkingTree getLastNode() { - WorkingTree node = this; - while (!node.children.isEmpty()) { - node = Iterables.getLast(node.children); - } - - return node; - } - - public WorkingTree getRoot() { - WorkingTree root = this; - WorkingTree parent; - while ((parent = root.getParent()) != null) { - root = parent; - } - - return root; - } -} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/traversal/WorkingTree.kt b/utils/src/main/java/com/google/android/accessibility/utils/traversal/WorkingTree.kt new file mode 100644 index 000000000..47a27becc --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/traversal/WorkingTree.kt @@ -0,0 +1,193 @@ +/* + * Copyright (C) The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Ported from Java to Kotlin. + */ + +package com.google.android.accessibility.utils.traversal + +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import com.google.android.libraries.accessibility.utils.log.LogUtils + +/** Tree that represents Accessibility node hierarchy. It lets reorder the structure of the tree. */ +class WorkingTree( + private val node: AccessibilityNodeInfoCompat, + private var parent: WorkingTree?, +) { + + private val children: MutableList = ArrayList() + + fun getNode(): AccessibilityNodeInfoCompat = node + + fun getParent(): WorkingTree? = parent + + fun setParent(parent: WorkingTree?) { + this.parent = parent + } + + fun addChild(node: WorkingTree) { + children.add(node) + } + + fun removeChild(child: WorkingTree): Boolean = children.remove(child) + + /** Checks whether subTree is a descendant of this WorkingTree node. */ + fun hasDescendant(tree: WorkingTree?): Boolean { + if (ancestorsHaveLoop()) { + LogUtils.w(TAG, "Looped ancestors line") + return false + } + + // For each ancestor of target descendant node... + var subTree = tree + while (subTree != null) { + val node = subTree.getNode() + + // If ancestor is this working tree node... target is descendant of this node. + if (this.node == node) { + return true + } + + subTree = subTree.getParent() + } + + return false + } + + /** Checks whether subTree is a descendant of this WorkingTree node. */ + fun ancestorsHaveLoop(): Boolean { + val visitedNodes = HashSet() + + // For each ancestor node... + var workNode: WorkingTree? = this + while (workNode != null) { + val accessNode = workNode.getNode() + if (visitedNodes.contains(accessNode)) { + return true + } + visitedNodes.add(accessNode) + workNode = workNode.getParent() + } + return false + } + + fun swapChild(swappedChild: WorkingTree, newChild: WorkingTree) { + val position = children.indexOf(swappedChild) + if (position < 0) { + LogUtils.e(TAG, "WorkingTree IllegalStateException: swap child not found") + return + } + + children[position] = newChild + } + + val next: WorkingTree? + get() { + if (children.isNotEmpty()) { + return children[0] + } + + var startNode: WorkingTree? = this + while (startNode != null) { + val nextSibling = startNode.nextSibling + if (nextSibling != null) { + return nextSibling + } + + startNode = startNode.getParent() + } + + return null + } + + val nextSibling: WorkingTree? + get() { + val parent = getParent() ?: return null + + var currentIndex = parent.children.indexOf(this) + if (currentIndex < 0) { + LogUtils.e(TAG, "WorkingTree IllegalStateException: swap child not found") + return null + } + + currentIndex++ + + if (currentIndex >= parent.children.size) { + // it was last child + return null + } + + return parent.children[currentIndex] + } + + val previous: WorkingTree? + get() { + val previousSibling = previousSibling + if (previousSibling != null) { + return previousSibling.lastNode + } + + return getParent() + } + + val previousSibling: WorkingTree? + get() { + val parent = getParent() ?: return null + + var currentIndex = parent.children.indexOf(this) + if (currentIndex < 0) { + LogUtils.e(TAG, "WorkingTree IllegalStateException: swap child not found") + return null + } + + currentIndex-- + + if (currentIndex < 0) { + // it was first child + return null + } + + return parent.children[currentIndex] + } + + val lastNode: WorkingTree + get() { + var node = this + while (node.children.isNotEmpty()) { + node = node.children.last() + } + + return node + } + + val root: WorkingTree + get() { + var root = this + var parent: WorkingTree? + while (true) { + parent = root.getParent() + if (parent == null) { + break + } + root = parent + } + + return root + } + + private companion object { + const val TAG = "WorkingTree" + } +} diff --git a/utils/src/test/java/com/google/android/accessibility/utils/AccessibilityNodeInfoUtilsCharacterizationTest.kt b/utils/src/test/java/com/google/android/accessibility/utils/AccessibilityNodeInfoUtilsCharacterizationTest.kt new file mode 100644 index 000000000..1cd76d0f3 --- /dev/null +++ b/utils/src/test/java/com/google/android/accessibility/utils/AccessibilityNodeInfoUtilsCharacterizationTest.kt @@ -0,0 +1,305 @@ +/* + * Copyright (C) The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Ported from Java to Kotlin. + */ + +package com.google.android.accessibility.utils + +import android.graphics.Rect +import android.view.View +import android.view.accessibility.AccessibilityNodeInfo +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.RangeInfoCompat +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** + * CARACTERIZAÇÃO do AccessibilityNodeInfoUtils — o coração + * semântico do foco. Fixa o contrato dos caminhos mais usados: + * + * - shouldFocusNode: invisível nunca; folha focável/clicável sempre; container + * acionável só se FALA (texto próprio ou filho não-acionável falante); + * não-focável com texto e SEM ancestral focável recebe o foco; + * - "clicável"/"focável" = flag OU ação anunciada; + * - parentesco (hasAncestor/hasDescendant/getMatchingAncestor/…); + * - findFocusFromHover sobe ao ancestral que deve ser focado; + * - busca BFS; suporte a ações; utilidades numéricas/texto + * (roundForProgressPercent com bordas 0/100, subsequenceSafe). + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class AccessibilityNodeInfoUtilsCharacterizationTest { + // ----- shouldFocusNode ----- + + @Test + fun `invisivel nunca recebe foco - folha clicavel visivel sempre`() { + val invisible = node() + invisible.isClickable = true + assertFalse(AccessibilityNodeInfoUtils.shouldFocusNode(invisible)) + + val leaf = visibleNode() + leaf.isClickable = true + assertTrue(AccessibilityNodeInfoUtils.shouldFocusNode(leaf)) + // Folha focável sem NADA a falar também recebe foco (botão sem rótulo). + assertTrue(AccessibilityNodeInfoUtils.FILTER_SHOULD_FOCUS.accept(leaf)) + } + + @Test + fun `container acionavel so recebe foco se FALA`() { + // Container clicável com filho focável e sem texto: o foco é do filho. + val silentParent = visibleNode() + silentParent.isClickable = true + val focusableChild = visibleNode() + focusableChild.isClickable = true + focusableChild.text = "filho" + addChild(silentParent, focusableChild) + assertFalse(AccessibilityNodeInfoUtils.shouldFocusNode(silentParent)) + assertTrue(AccessibilityNodeInfoUtils.shouldFocusNode(focusableChild)) + + // Container clicável cujo filho NÃO-acionável fala: o container fala por ele. + val speakingParent = visibleNode() + speakingParent.isClickable = true + val textChild = visibleNode() + textChild.text = "rótulo do botão" + addChild(speakingParent, textChild) + assertTrue(AccessibilityNodeInfoUtils.shouldFocusNode(speakingParent)) + // E o filho de texto, com ancestral focável, NÃO recebe foco próprio. + assertFalse(AccessibilityNodeInfoUtils.shouldFocusNode(textChild)) + } + + @Test + fun `nao-focavel com texto e sem ancestral focavel recebe o foco`() { + val plainText = visibleNode() + plainText.text = "parágrafo solto" + assertTrue(AccessibilityNodeInfoUtils.shouldFocusNode(plainText)) + } + + @Test + fun `findFocusFromHover sobe do texto ao ancestral clicavel`() { + val button = visibleNode() + button.isClickable = true + val label = visibleNode() + label.text = "abrir" + addChild(button, label) + assertEquals(button, AccessibilityNodeInfoUtils.findFocusFromHover(label)) + assertNull(AccessibilityNodeInfoUtils.findFocusFromHover(null)) + } + + // ----- acionabilidade por flag OU ação ----- + + @Test + fun `clicavel-focavel-longclicavel valem por flag OU acao anunciada`() { + val byFlag = visibleNode() + byFlag.isClickable = true + assertTrue(AccessibilityNodeInfoUtils.isClickable(byFlag)) + + val byAction = visibleNode() + byAction.addAction(AccessibilityActionCompat.ACTION_CLICK) + assertTrue(AccessibilityNodeInfoUtils.isClickable(byAction)) + assertTrue(AccessibilityNodeInfoUtils.isActionableForAccessibility(byAction)) + + val longByAction = visibleNode() + longByAction.addAction(AccessibilityActionCompat.ACTION_LONG_CLICK) + assertTrue(AccessibilityNodeInfoUtils.isLongClickable(longByAction)) + + val focusByAction = visibleNode() + focusByAction.addAction(AccessibilityActionCompat.ACTION_FOCUS) + assertTrue(AccessibilityNodeInfoUtils.isFocusable(focusByAction)) + + assertFalse(AccessibilityNodeInfoUtils.isClickable(visibleNode())) + assertFalse(AccessibilityNodeInfoUtils.isClickable(null)) + } + + @Test + fun `supportsAction e supportsAnyAction`() { + val node = visibleNode() + node.addAction(AccessibilityActionCompat.ACTION_SCROLL_FORWARD) + assertTrue( + AccessibilityNodeInfoUtils.supportsAction( + node, AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD), + ) + assertFalse( + AccessibilityNodeInfoUtils.supportsAction( + node, AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD), + ) + assertTrue( + AccessibilityNodeInfoUtils.supportsAnyAction( + node, + AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD, + AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD, + ), + ) + assertFalse( + AccessibilityNodeInfoUtils.supportsAnyAction( + null, AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD), + ) + } + + // ----- parentesco e buscas ----- + + @Test + fun `hasAncestor - hasDescendant - getMatchingAncestor`() { + val root = visibleNode() + val mid = visibleNode() + val leaf = visibleNode() + addChild(root, mid) + addChild(mid, leaf) + + assertTrue(AccessibilityNodeInfoUtils.hasAncestor(leaf, root)) + assertFalse(AccessibilityNodeInfoUtils.hasAncestor(root, leaf)) + assertTrue(AccessibilityNodeInfoUtils.hasDescendant(root, leaf)) + assertFalse(AccessibilityNodeInfoUtils.hasDescendant(leaf, root)) + + mid.isClickable = true + val clickableFilter = + object : Filter() { + override fun accept(n: AccessibilityNodeInfoCompat?): Boolean = + n != null && n.isClickable + } + // getMatchingAncestor NÃO inclui o próprio nó. + assertEquals(mid, AccessibilityNodeInfoUtils.getMatchingAncestor(leaf, clickableFilter)) + assertNull(AccessibilityNodeInfoUtils.getMatchingAncestor(mid, clickableFilter)) + // getSelfOrMatchingAncestor inclui. + assertEquals(mid, AccessibilityNodeInfoUtils.getSelfOrMatchingAncestor(mid, clickableFilter)) + assertTrue(AccessibilityNodeInfoUtils.isOrHasMatchingAncestor(mid, clickableFilter)) + assertTrue(AccessibilityNodeInfoUtils.hasMatchingDescendant(root, clickableFilter)) + } + + @Test + fun `getRoot sobe ate a raiz e searchFromBfs acha na ordem de largura`() { + val root = visibleNode() + val c1 = visibleNode() + val c2 = visibleNode() + c2.text = "alvo" + addChild(root, c1) + addChild(root, c2) + assertEquals(root, AccessibilityNodeInfoUtils.getRoot(c2)) + + val found = + AccessibilityNodeInfoUtils.searchFromBfs( + root, + object : Filter() { + override fun accept(n: AccessibilityNodeInfoCompat?): Boolean = + n != null && "alvo" == n.text?.toString() + }, + ) + assertEquals(c2, found) + } + + @Test + fun `isTopLevelScrollItem exige ACOES de scroll no pai - a flag mente`() { + // isScrollable ignora a FLAG isScrollable (WebViews mentem) — só as + // ações ACTION_SCROLL_* contam. + val flagOnly = visibleNode() + flagOnly.isScrollable = true + val itemOfFlagOnly = visibleNode() + addChild(flagOnly, itemOfFlagOnly) + assertFalse(AccessibilityNodeInfoUtils.isTopLevelScrollItem(itemOfFlagOnly)) + + val list = visibleNode() + list.isScrollable = true + list.addAction(AccessibilityActionCompat.ACTION_SCROLL_FORWARD) + val item = visibleNode() + addChild(list, item) + assertTrue(AccessibilityNodeInfoUtils.isTopLevelScrollItem(item)) + assertFalse(AccessibilityNodeInfoUtils.isTopLevelScrollItem(visibleNode())) + } + + // ----- utilidades ----- + + @Test + fun `toCompat null e null - getNodeBoundsInScreen copia os bounds`() { + assertNull(AccessibilityNodeInfoUtils.toCompat(null)) + val node = visibleNode() + node.setBoundsInScreen(Rect(1, 2, 30, 40)) + assertEquals(Rect(1, 2, 30, 40), AccessibilityNodeInfoUtils.getNodeBoundsInScreen(node)) + } + + @Test + fun `roundForProgressPercent nunca arredonda para 0 ou 100 dentro do intervalo`() { + // (0,1) força 1 e (99,100) força 99: progresso em andamento nunca soa + // como "0%" nem como "100%" antes da hora. + assertEquals(1, AccessibilityNodeInfoUtils.roundForProgressPercent(0.4)) + assertEquals(1, AccessibilityNodeInfoUtils.roundForProgressPercent(0.6)) + assertEquals(99, AccessibilityNodeInfoUtils.roundForProgressPercent(99.4)) + assertEquals(99, AccessibilityNodeInfoUtils.roundForProgressPercent(99.6)) + assertEquals(0, AccessibilityNodeInfoUtils.roundForProgressPercent(-1.0)) + assertEquals(100, AccessibilityNodeInfoUtils.roundForProgressPercent(150.0)) + assertEquals(50, AccessibilityNodeInfoUtils.roundForProgressPercent(50.4)) + } + + @Test + fun `subsequenceSafe apara indices fora do texto`() { + assertEquals("abc", AccessibilityNodeInfoUtils.subsequenceSafe("abc", -5, 99).toString()) + assertEquals("b", AccessibilityNodeInfoUtils.subsequenceSafe("abc", 1, 2).toString()) + // Índices invertidos são reordenados. + assertEquals("b", AccessibilityNodeInfoUtils.subsequenceSafe("abc", 2, 1).toString()) + assertEquals("", AccessibilityNodeInfoUtils.subsequenceSafe(null, 0, 1).toString()) + } + + @Test + fun `hasValidRangeInfo exige minimo menor-igual atual menor-igual maximo`() { + val valid = visibleNode() + valid.rangeInfo = RangeInfoCompat.obtain(RangeInfoCompat.RANGE_TYPE_INT, 0f, 100f, 50f) + assertTrue(AccessibilityNodeInfoUtils.hasValidRangeInfo(valid)) + + val invalid = visibleNode() + invalid.rangeInfo = RangeInfoCompat.obtain(RangeInfoCompat.RANGE_TYPE_INT, 0f, 100f, 150f) + assertFalse(AccessibilityNodeInfoUtils.hasValidRangeInfo(invalid)) + + assertFalse(AccessibilityNodeInfoUtils.hasValidRangeInfo(visibleNode())) + assertFalse(AccessibilityNodeInfoUtils.hasValidRangeInfo(null)) + } + + @Test + fun `getText prioriza o texto do no e actionToString conhece as basicas`() { + val node = visibleNode() + node.text = "texto" + assertEquals("texto", AccessibilityNodeInfoUtils.getText(node)?.toString()) + assertNull(AccessibilityNodeInfoUtils.getText(null)) + assertEquals( + "ACTION_CLICK", + AccessibilityNodeInfoUtils.actionToString(AccessibilityNodeInfoCompat.ACTION_CLICK), + ) + } + + // ----- fixtures ----- + + private fun node(): AccessibilityNodeInfoCompat { + val view = View(RuntimeEnvironment.getApplication()) + return AccessibilityNodeInfoCompat.wrap(AccessibilityNodeInfo.obtain(view)) + } + + private fun visibleNode(): AccessibilityNodeInfoCompat { + val compat = node() + compat.isVisibleToUser = true + compat.setBoundsInScreen(Rect(0, 0, 200, 100)) + return compat + } + + private fun addChild(parent: AccessibilityNodeInfoCompat, child: AccessibilityNodeInfoCompat) { + shadowOf(parent.unwrap()).addChild(child.unwrap()) + } +} diff --git a/utils/src/test/java/com/google/android/accessibility/utils/CoreUtilsCharacterizationTest.kt b/utils/src/test/java/com/google/android/accessibility/utils/CoreUtilsCharacterizationTest.kt new file mode 100644 index 000000000..666d13b8a --- /dev/null +++ b/utils/src/test/java/com/google/android/accessibility/utils/CoreUtilsCharacterizationTest.kt @@ -0,0 +1,189 @@ +/* + * Copyright (C) The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Ported from Java to Kotlin. + */ + +package com.google.android.accessibility.utils + +import android.accessibilityservice.AccessibilityService +import android.view.accessibility.AccessibilityEvent +import android.view.accessibility.AccessibilityNodeInfo +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.CollectionInfoCompat +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * CARACTERIZAÇÃO do miolo de utils/: + * + * Filter — and/or com curto-circuito; argumento null devolve o PRÓPRIO filtro; + * quirk: and() sobre um FilterAnd MUTA e devolve a MESMA instância (acumula). + * + * Role — precedência do mapeamento classe→papel: isTextEntryKey vence tudo; + * subclasses antes de superclasses (RadioButton antes de Button); ImageView + * clicável vira IMAGE_BUTTON; coleção sem classe explícita decide GRID×LIST + * pelas contagens (>1 linha E >1 coluna = GRID). + * + * FocusFinder — invólucro fino do findFocus do serviço: sem foco → null (sem + * exceção), para ambos os tipos de foco. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class CoreUtilsCharacterizationTest { + // ----- Filter ----- + + private fun filter(result: Boolean, log: MutableList, name: String) = + object : Filter() { + override fun accept(obj: String?): Boolean { + log += name + return result + } + } + + @Test + fun `and e or com curto-circuito e null devolve o proprio filtro`() { + val log = mutableListOf() + val yes = filter(true, log, "yes") + val no = filter(false, log, "no") + + assertTrue(yes.and(yes).accept("x")) + assertFalse(no.and(yes).accept("x")) + assertEquals(listOf("yes", "yes", "no"), log) // "no" curto-circuita o segundo + log.clear() + + assertTrue(no.or(yes).accept("x")) + assertEquals(listOf("no", "yes"), log) + + assertSame(yes, yes.and(null)) + assertSame(yes, yes.or(null)) + } + + @Test + fun `and sobre FilterAnd acumula e devolve a mesma instancia`() { + val log = mutableListOf() + val a = filter(true, log, "a") + val b = filter(true, log, "b") + val c = filter(false, log, "c") + + val ab = a.and(b) + val abc = ab.and(c) + assertSame("FilterAnd.and() muta e devolve this", ab, abc) + assertFalse(abc.accept("x")) + assertEquals(listOf("a", "b", "c"), log) + } + + @Test + fun `Filter node cria filtro a partir de lambda`() { + val nonNull = Filter.node { it != null } + assertTrue(nonNull.accept(AccessibilityNodeInfoCompat.obtain())) + assertFalse(nonNull.accept(null)) + } + + // ----- Role ----- + + @Test + fun `precedencia - isTextEntryKey vence a classe`() { + val node = node("android.widget.Button") + node.isTextEntryKey = true + assertEquals(Role.ROLE_TEXT_ENTRY_KEY, Role.getRole(node)) + } + + @Test + fun `subclasses antes de superclasses no mapeamento`() { + assertEquals(Role.ROLE_SWITCH, Role.getRole(node("android.widget.Switch"))) + assertEquals(Role.ROLE_RADIO_BUTTON, Role.getRole(node("android.widget.RadioButton"))) + assertEquals(Role.ROLE_CHECK_BOX, Role.getRole(node("android.widget.CheckBox"))) + assertEquals(Role.ROLE_TOGGLE_BUTTON, Role.getRole(node("android.widget.ToggleButton"))) + assertEquals(Role.ROLE_BUTTON, Role.getRole(node("android.widget.Button"))) + assertEquals(Role.ROLE_EDIT_TEXT, Role.getRole(node("android.widget.EditText"))) + assertEquals(Role.ROLE_SEEK_CONTROL, Role.getRole(node("android.widget.SeekBar"))) + assertEquals(Role.ROLE_PROGRESS_BAR, Role.getRole(node("android.widget.ProgressBar"))) + assertEquals(Role.ROLE_WEB_VIEW, Role.getRole(node("android.webkit.WebView"))) + assertEquals(Role.ROLE_GRID, Role.getRole(node("android.widget.GridView"))) + assertEquals(Role.ROLE_LIST, Role.getRole(node("android.widget.ListView"))) + assertEquals(Role.ROLE_DROP_DOWN_LIST, Role.getRole(node("android.widget.Spinner"))) + assertEquals(Role.ROLE_VIEW_GROUP, Role.getRole(node("android.widget.FrameLayout"))) + assertEquals(Role.ROLE_NONE, Role.getRole(null as AccessibilityNodeInfoCompat?)) + } + + @Test + fun `imagem clicavel e IMAGE_BUTTON, nao-clicavel e IMAGE`() { + val image = node("android.widget.ImageView") + assertEquals(Role.ROLE_IMAGE, Role.getRole(image)) + image.isClickable = true + assertEquals(Role.ROLE_IMAGE_BUTTON, Role.getRole(image)) + } + + @Test + fun `colecao sem classe explicita decide GRID x LIST pelas contagens`() { + val grid = node("android.widget.LinearLayout") + grid.setCollectionInfo(CollectionInfoCompat.obtain(3, 3, false)) + assertEquals(Role.ROLE_GRID, Role.getRole(grid)) + + val list = node("android.widget.LinearLayout") + list.setCollectionInfo(CollectionInfoCompat.obtain(5, 1, false)) + assertEquals(Role.ROLE_LIST, Role.getRole(list)) + } + + @Test + fun `roleToString e isAdjustableRole`() { + assertEquals("ROLE_BUTTON", Role.roleToString(Role.ROLE_BUTTON)) + assertEquals("ROLE_TEXT_ENTRY_KEY", Role.roleToString(Role.ROLE_TEXT_ENTRY_KEY)) + assertTrue(Role.isAdjustableRole(Role.ROLE_SEEK_CONTROL)) + assertTrue(Role.isAdjustableRole(Role.ROLE_NUMBER_PICKER)) + assertFalse(Role.isAdjustableRole(Role.ROLE_BUTTON)) + } + + @Test + fun `getSourceRole - evento null e NONE, Toast pela classe do evento`() { + assertEquals(Role.ROLE_NONE, Role.getSourceRole(null)) + val event = AccessibilityEvent.obtain() + event.className = "android.widget.Toast\$TN" + assertEquals(Role.ROLE_TOAST, Role.getSourceRole(event)) + } + + // ----- FocusFinder ----- + + class TestA11yService : AccessibilityService() { + override fun onAccessibilityEvent(event: AccessibilityEvent?) {} + + override fun onInterrupt() {} + } + + @Test + fun `focus finder sem foco devolve null para ambos os tipos`() { + val service = Robolectric.setupService(TestA11yService::class.java) + val finder = FocusFinder(service) + assertNull(finder.findAccessibilityFocus()) + assertNull(finder.findFocusCompat(AccessibilityNodeInfo.FOCUS_ACCESSIBILITY)) + assertNull(finder.findFocusCompat(AccessibilityNodeInfo.FOCUS_INPUT)) + assertNull(FocusFinder.getAccessibilityFocusNode(service, /* fallbackOnRoot= */ true)) + } + + private fun node(className: String): AccessibilityNodeInfoCompat { + val compat = AccessibilityNodeInfoCompat.obtain() + compat.className = className + return compat + } +} diff --git a/utils/src/test/java/com/google/android/accessibility/utils/WebAndScrollUtilsCharacterizationTest.kt b/utils/src/test/java/com/google/android/accessibility/utils/WebAndScrollUtilsCharacterizationTest.kt new file mode 100644 index 000000000..b0ef5dad3 --- /dev/null +++ b/utils/src/test/java/com/google/android/accessibility/utils/WebAndScrollUtilsCharacterizationTest.kt @@ -0,0 +1,203 @@ +/* + * Copyright (C) The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Ported from Java to Kotlin. + */ + +package com.google.android.accessibility.utils + +import android.graphics.Rect +import android.view.View +import android.view.accessibility.AccessibilityNodeInfo +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat +import com.google.android.accessibility.utils.traversal.TraversalStrategy +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** + * CARACTERIZAÇÃO de WebInterfaceUtils, AccessibilityNodeInfoRef e + * ScrollableNodeInfo: + * + * WebInterfaceUtils — nó é "web" quando anuncia NEXT/PREVIOUS_HTML_ELEMENT; + * Firefox (org.mozilla.*) conta como web container mesmo sem as ações; + * containsImage lê o extra "AccessibilityNodeInfo.hasImage"; conversão de + * direção de busca → direção web (FORWARD=1/BACKWARD=-1/UNKNOWN=0). + * + * AccessibilityNodeInfoRef — nextInOrder/previousInOrder em pré-ordem sobre + * nós VISÍVEIS; lastDescendant desce até o fim; unOwned(null) é null. + * + * ScrollableNodeInfo — direção suportada nativamente é devolvida como está; + * fallback lógico→espacial (FORWARD vira DOWN quando só há scroll vertical); + * espacial→lógico (RIGHT vira FORWARD em LTR); sem suporte → null. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class WebAndScrollUtilsCharacterizationTest { + // ----- WebInterfaceUtils ----- + + @Test + fun `no web e reconhecido pelas acoes de elemento HTML`() { + val web = node() + web.addAction(AccessibilityActionCompat(AccessibilityNodeInfoCompat.ACTION_NEXT_HTML_ELEMENT, null)) + assertTrue(WebInterfaceUtils.supportsWebActions(web)) + assertTrue(WebInterfaceUtils.hasNativeWebContent(web)) + assertTrue(WebInterfaceUtils.isWebContainer(web)) + + assertFalse(WebInterfaceUtils.supportsWebActions(node())) + assertFalse(WebInterfaceUtils.supportsWebActions(null)) + assertFalse(WebInterfaceUtils.isWebContainer(null)) + } + + @Test + fun `firefox conta como web container mesmo sem acoes html`() { + val firefox = node() + firefox.setPackageName("org.mozilla.firefox") + assertTrue(WebInterfaceUtils.isWebContainer(firefox)) + assertFalse(WebInterfaceUtils.supportsWebActions(firefox)) + } + + @Test + fun `containsImage le o extra hasImage`() { + val withImage = node() + withImage.extras.putString("AccessibilityNodeInfo.hasImage", "true") + assertTrue(WebInterfaceUtils.containsImage(withImage)) + assertFalse(WebInterfaceUtils.containsImage(node())) + assertFalse(WebInterfaceUtils.containsImage(null)) + } + + @Test + fun `conversao de direcao de busca para direcao web`() { + val context = RuntimeEnvironment.getApplication() + assertEquals( + WebInterfaceUtils.DIRECTION_FORWARD, + WebInterfaceUtils.searchDirectionToWebNavigationDirection( + context, TraversalStrategy.SEARCH_FOCUS_FORWARD), + ) + assertEquals( + WebInterfaceUtils.DIRECTION_BACKWARD, + WebInterfaceUtils.searchDirectionToWebNavigationDirection( + context, TraversalStrategy.SEARCH_FOCUS_BACKWARD), + ) + assertEquals( + 0, + WebInterfaceUtils.searchDirectionToWebNavigationDirection( + context, TraversalStrategy.SEARCH_FOCUS_UNKNOWN), + ) + assertEquals(1, WebInterfaceUtils.DIRECTION_FORWARD) + assertEquals(-1, WebInterfaceUtils.DIRECTION_BACKWARD) + assertEquals("HEADING", WebInterfaceUtils.HTML_ELEMENT_MOVE_BY_HEADING) + } + + // ----- AccessibilityNodeInfoRef ----- + + @Test + fun `nextInOrder anda em pre-ordem sobre nos visiveis`() { + val root = visibleNode() + val c1 = visibleNode() + val c2 = visibleNode() + val c2a = visibleNode() + shadowOf(root.unwrap()).addChild(c1.unwrap()) + shadowOf(root.unwrap()).addChild(c2.unwrap()) + shadowOf(c2.unwrap()).addChild(c2a.unwrap()) + + val ref = AccessibilityNodeInfoRef.obtain(root) + assertTrue(ref.nextInOrder()) + assertEquals(c1, ref.get()) + assertTrue(ref.nextInOrder()) + assertEquals(c2, ref.get()) + assertTrue(ref.nextInOrder()) + assertEquals(c2a, ref.get()) + assertFalse(ref.nextInOrder()) + } + + @Test + fun `previousInOrder e lastDescendant sao os inversos`() { + val root = visibleNode() + val c1 = visibleNode() + val c2 = visibleNode() + shadowOf(root.unwrap()).addChild(c1.unwrap()) + shadowOf(root.unwrap()).addChild(c2.unwrap()) + + val ref = AccessibilityNodeInfoRef.obtain(root) + assertTrue(ref.lastDescendant()) + assertEquals(c2, ref.get()) + assertTrue(ref.previousInOrder()) + assertEquals(c1, ref.get()) + assertTrue(ref.previousInOrder()) + assertEquals(root, ref.get()) + + assertNull(AccessibilityNodeInfoRef.unOwned(null)) + assertTrue(AccessibilityNodeInfoRef.isNull(null)) + } + + // ----- ScrollableNodeInfo ----- + + @Test + fun `direcao nativa e devolvida como esta - sem suporte e null`() { + val forwardOnly = visibleNode() + forwardOnly.addAction(AccessibilityActionCompat.ACTION_SCROLL_FORWARD) + val info = ScrollableNodeInfo(forwardOnly, /* rtl= */ false) + assertEquals( + TraversalStrategy.SEARCH_FOCUS_FORWARD, + info.getSupportedScrollDirection(TraversalStrategy.SEARCH_FOCUS_FORWARD), + ) + assertNull(info.getSupportedScrollDirection(TraversalStrategy.SEARCH_FOCUS_BACKWARD)) + } + + @Test + fun `fallbacks - FORWARD vira DOWN so-vertical, RIGHT vira FORWARD em LTR`() { + val verticalOnly = visibleNode() + verticalOnly.addAction(AccessibilityActionCompat.ACTION_SCROLL_DOWN) + val vertical = ScrollableNodeInfo(verticalOnly, /* rtl= */ false) + assertEquals( + TraversalStrategy.SEARCH_FOCUS_DOWN, + vertical.getSupportedScrollDirection(TraversalStrategy.SEARCH_FOCUS_FORWARD), + ) + + val logicalOnly = visibleNode() + logicalOnly.addAction(AccessibilityActionCompat.ACTION_SCROLL_FORWARD) + val logical = ScrollableNodeInfo(logicalOnly, /* rtl= */ false) + assertEquals( + TraversalStrategy.SEARCH_FOCUS_FORWARD, + logical.getSupportedScrollDirection(TraversalStrategy.SEARCH_FOCUS_RIGHT), + ) + } + + @Test + fun `getNode devolve o proprio no embrulhado`() { + val node = visibleNode() + assertEquals(node, ScrollableNodeInfo(node, false).node) + } + + private fun node(): AccessibilityNodeInfoCompat = + AccessibilityNodeInfoCompat.wrap(AccessibilityNodeInfo.obtain()) + + private fun visibleNode(): AccessibilityNodeInfoCompat { + val view = View(RuntimeEnvironment.getApplication()) + val compat = AccessibilityNodeInfoCompat.wrap(AccessibilityNodeInfo.obtain(view)) + compat.isVisibleToUser = true + compat.setBoundsInScreen(Rect(0, 0, 100, 100)) + return compat + } +} diff --git a/utils/src/test/java/com/google/android/accessibility/utils/gestures/GestureAnalyticsEventCharacterizationTest.kt b/utils/src/test/java/com/google/android/accessibility/utils/gestures/GestureAnalyticsEventCharacterizationTest.kt new file mode 100644 index 000000000..a538d6932 --- /dev/null +++ b/utils/src/test/java/com/google/android/accessibility/utils/gestures/GestureAnalyticsEventCharacterizationTest.kt @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2024 Google Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Ported from Java to Kotlin. + */ + +package com.google.android.accessibility.utils.gestures + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * CARACTERIZAÇÃO: valores dos eventos/extras de analytics — são + * persistidos/reportados, então os números são contrato. + */ +class GestureAnalyticsEventCharacterizationTest { + @Test + fun `ids dos eventos sao estaveis`() { + assertEquals(0, GestureAnalyticsEvent.EVENT_DOUBLE_TAP_SLOP_OVER_RANGE) + assertEquals(1, GestureAnalyticsEvent.EVENT_TAP_TO_TOUCH_EXPLORE) + } + + @Test + fun `extras de slop excedido sao estaveis`() { + assertEquals(0, GestureAnalyticsEvent.EXTRA_DEBUG_DOUBLE_TAP_SLOP_OVER_IN_10_PERCENT) + assertEquals(1, GestureAnalyticsEvent.EXTRA_DEBUG_DOUBLE_TAP_SLOP_OVER_IN_20_PERCENT) + assertEquals(2, GestureAnalyticsEvent.EXTRA_DEBUG_DOUBLE_TAP_SLOP_OVER_IN_50_PERCENT) + assertEquals(3, GestureAnalyticsEvent.EXTRA_DEBUG_DOUBLE_TAP_SLOP_OVER_IN_100_PERCENT) + assertEquals(4, GestureAnalyticsEvent.EXTRA_DEBUG_DOUBLE_TAP_SLOP_OVER_MORE_THAN_100_PERCENT) + assertEquals(0, GestureAnalyticsEvent.EXTRA_DEBUG_TAP_TO_TOUCH_EXPLORE_TOTAL_SAVED_TIME) + assertEquals(1, GestureAnalyticsEvent.EXTRA_DEBUG_TAP_TO_TOUCH_EXPLORE_HIT_COUNT) + } + + @Test + fun `construtor guarda evento e id do gesto`() { + val e = GestureAnalyticsEvent(GestureAnalyticsEvent.EVENT_TAP_TO_TOUCH_EXPLORE, 17) + assertEquals(GestureAnalyticsEvent.EVENT_TAP_TO_TOUCH_EXPLORE, e.event) + assertEquals(17, e.gestureId) + } +} diff --git a/utils/src/test/java/com/google/android/accessibility/utils/gestures/GestureConfigurationCharacterizationTest.kt b/utils/src/test/java/com/google/android/accessibility/utils/gestures/GestureConfigurationCharacterizationTest.kt new file mode 100644 index 000000000..bba75abf6 --- /dev/null +++ b/utils/src/test/java/com/google/android/accessibility/utils/gestures/GestureConfigurationCharacterizationTest.kt @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Ported from Java to Kotlin. + */ + +package com.google.android.accessibility.utils.gestures + +import android.view.ViewConfiguration +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** CARACTERIZAÇÃO: o timeout de multi-tap é getDoubleTapTimeout() − 50 ms (IPC). */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class GestureConfigurationCharacterizationTest { + @Test + fun `janela de multi-tap e o doubleTapTimeout do sistema menos 50ms`() { + assertEquals( + ViewConfiguration.getDoubleTapTimeout() - 50, + GestureConfiguration.DOUBLE_TAP_TIMEOUT_MS, + ) + } +} diff --git a/utils/src/test/java/com/google/android/accessibility/utils/gestures/GestureManifoldCharacterizationTest.kt b/utils/src/test/java/com/google/android/accessibility/utils/gestures/GestureManifoldCharacterizationTest.kt new file mode 100644 index 000000000..512f2860b --- /dev/null +++ b/utils/src/test/java/com/google/android/accessibility/utils/gestures/GestureManifoldCharacterizationTest.kt @@ -0,0 +1,189 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Ported from Java to Kotlin. + */ + +package com.google.android.accessibility.utils.gestures + +import android.accessibilityservice.AccessibilityGestureEvent +import android.accessibilityservice.AccessibilityService.GESTURE_2_FINGER_SINGLE_TAP +import android.accessibilityservice.AccessibilityService.GESTURE_DOUBLE_TAP +import android.accessibilityservice.AccessibilityService.GESTURE_SWIPE_RIGHT +import android.os.Looper +import android.view.MotionEvent +import com.google.common.collect.ImmutableList +import java.time.Duration +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** + * CARACTERIZAÇÃO do GestureManifold + GestureMatcherFactory: + * - a fábrica instancia os matchers pelo NOME do enum na lista de suporte + * (nomes desconhecidos são ignorados); + * - o manifold despacha onGestureCompleted UMA vez e CANCELA os demais + * matchers SEM notificar (sem cascata de onGestureCancelled); + * - onMotionEvent devolve true no evento que completa um gesto; + * - gestos multi-dedo só participam depois de setMultiFingerGesturesEnabled. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class GestureManifoldCharacterizationTest { + private val started = mutableListOf() + private val completed = mutableListOf() + private val cancelled = mutableListOf() + + private val manifoldListener = + object : GestureManifold.Listener { + override fun onGestureStarted(gestureId: Int) { + started += gestureId + } + + override fun onGestureCompleted(gestureEvent: AccessibilityGestureEvent) { + completed += gestureEvent.gestureId + } + + override fun onGestureCancelled(gestureId: Int) { + cancelled += gestureId + } + } + + private lateinit var manifold: GestureManifold + + @Before + fun setUp() { + manifold = + GestureManifold( + RuntimeEnvironment.getApplication(), + manifoldListener, + object : GestureManifold.GestureConfigProvider {}, + {}, + DISPLAY_ID, + ImmutableList.of( + "MAPPER_GESTURE_DOUBLE_TAP", + "MAPPER_GESTURE_SWIPE_RIGHT", + "MAPPER_GESTURE_2_FINGER_SINGLE_TAP", + "NOME_QUE_NAO_EXISTE", + ), + ) + } + + @Test + fun `duplo-toque completa via manifold e o evento final devolve true`() { + manifold.onMotionEvent(null, down(0)) + manifold.onMotionEvent(null, up(50)) + manifold.onMotionEvent(null, down(200)) + val handled = manifold.onMotionEvent(null, up(250)) + assertTrue("o evento que completa deve ser consumido", handled) + assertEquals(listOf(GESTURE_DOUBLE_TAP), completed) + } + + @Test + fun `ao completar, os outros matchers sao cancelados SEM notificar`() { + manifold.onMotionEvent(null, down(0)) + manifold.onMotionEvent(null, up(50)) + manifold.onMotionEvent(null, down(200)) + manifold.onMotionEvent(null, up(250)) + assertFalse( + "cancelamento em cascata não deve notificar", + cancelled.contains(GESTURE_SWIPE_RIGHT), + ) + } + + @Test + fun `swipe direita completa via manifold`() { + manifold.onMotionEvent(null, down(0)) + manifold.onMotionEvent(null, move(40, X + 40, Y)) + manifold.onMotionEvent(null, move(80, X + 80, Y)) + manifold.onMotionEvent(null, up(100, X + 90, Y)) + assertEquals(listOf(GESTURE_SWIPE_RIGHT), completed) + } + + @Test + fun `gesto multi-dedo so participa depois de habilitado`() { + // Desabilitado (default): toque de 2 dedos não completa nada. + twoFingerTap(0) + idle(400) + assertEquals(emptyList(), completed) + + manifold.clear() + cancelled.clear() + manifold.setMultiFingerGesturesEnabled(true) + twoFingerTap(1000) + idle(400) + assertEquals(listOf(GESTURE_2_FINGER_SINGLE_TAP), completed) + } + + private fun twoFingerTap(t: Long) { + manifold.onMotionEvent(null, down(t)) + manifold.onMotionEvent( + null, + multi(t + 10, MotionEvent.ACTION_POINTER_DOWN, 1, floatArrayOf(X, X + 100), floatArrayOf(Y, Y)), + ) + manifold.onMotionEvent( + null, + multi(t + 40, MotionEvent.ACTION_POINTER_UP, 1, floatArrayOf(X, X + 100), floatArrayOf(Y, Y)), + ) + manifold.onMotionEvent(null, up(t + 60)) + } + + private fun idle(millis: Long) { + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(millis)) + } + + private fun down(t: Long) = MotionEvent.obtain(t, t, MotionEvent.ACTION_DOWN, X, Y, 0) + + private fun up(t: Long, x: Float = X, y: Float = Y) = + MotionEvent.obtain(0, t, MotionEvent.ACTION_UP, x, y, 0) + + private fun move(t: Long, x: Float, y: Float) = + MotionEvent.obtain(0, t, MotionEvent.ACTION_MOVE, x, y, 0) + + private fun multi(t: Long, action: Int, actionIndex: Int, xs: FloatArray, ys: FloatArray): MotionEvent { + val count = xs.size + val props = + Array(count) { + MotionEvent.PointerProperties().apply { + id = it + toolType = MotionEvent.TOOL_TYPE_FINGER + } + } + val coords = + Array(count) { + MotionEvent.PointerCoords().apply { + x = xs[it] + y = ys[it] + pressure = 1f + size = 1f + } + } + val masked = action or (actionIndex shl MotionEvent.ACTION_POINTER_INDEX_SHIFT) + return MotionEvent.obtain(0, t, masked, count, props, coords, 0, 0, 1f, 1f, 0, 0, 0, 0) + } + + private companion object { + const val DISPLAY_ID = 0 + const val X = 200f + const val Y = 400f + } +} diff --git a/utils/src/test/java/com/google/android/accessibility/utils/gestures/GestureMatcherBaseCharacterizationTest.kt b/utils/src/test/java/com/google/android/accessibility/utils/gestures/GestureMatcherBaseCharacterizationTest.kt new file mode 100644 index 000000000..1c92584da --- /dev/null +++ b/utils/src/test/java/com/google/android/accessibility/utils/gestures/GestureMatcherBaseCharacterizationTest.kt @@ -0,0 +1,133 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Ported from Java to Kotlin. + */ + +package com.google.android.accessibility.utils.gestures + +import android.os.Handler +import android.os.Looper +import android.view.MotionEvent +import java.time.Duration +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** + * CARACTERIZAÇÃO da classe-base GestureMatcher: + * - após CANCELED ou COMPLETED, novos eventos são IGNORADOS (retornam o + * estado sem chamar handlers nem notificar de novo); + * - transições ADIADAS (cancelAfterX e completeAfterX) disparam pelo Handler + * e notificam o listener; + * - clear() remove transições pendentes e volta a CLEAR; + * - tipo de ação desconhecido (ex.: HOVER_MOVE) CANCELA por evento inválido. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class GestureMatcherBaseCharacterizationTest { + private val states = mutableListOf() + private val listener = GestureMatcher.StateChangeListener { _, state, _ -> states += state } + + /** Matcher de laboratório: o comportamento do onDown é injetável. */ + private class TestMatcher( + listener: StateChangeListener, + private val onDownAction: TestMatcher.(MotionEvent) -> Unit, + ) : GestureMatcher( + GESTURE_ID, + Handler(RuntimeEnvironment.getApplication().mainLooper), + listener, + GestureMatcher.AnalyticsEventLogger {}, + ) { + var downs = 0 + + override fun getGestureName(): String = "Test" + + override fun onDown(eventId: com.google.android.accessibility.utils.Performance.EventId?, event: MotionEvent) { + downs++ + onDownAction(event) + } + + fun cancelNow(event: MotionEvent) = cancelGesture(event) + + fun completeNow(event: MotionEvent) = completeGesture(null, event) + + fun cancelLater(event: MotionEvent) = cancelAfterDoubleTapTimeout(event) + + private companion object { + const val GESTURE_ID = 99 + } + } + + @Test + fun `eventos apos CANCELED sao ignorados sem re-notificar`() { + val matcher = TestMatcher(listener) { cancelNow(it) } + matcher.onMotionEvent(null, down(0)) + val notifications = states.size + val state = matcher.onMotionEvent(null, down(100)) + assertEquals(GestureMatcher.STATE_GESTURE_CANCELED, state) + assertEquals("não deve tratar nem notificar de novo", 1, matcher.downs) + assertEquals(notifications, states.size) + } + + @Test + fun `eventos apos COMPLETED sao ignorados`() { + val matcher = TestMatcher(listener) { completeNow(it) } + matcher.onMotionEvent(null, down(0)) + val state = matcher.onMotionEvent(null, down(100)) + assertEquals(GestureMatcher.STATE_GESTURE_COMPLETED, state) + assertEquals(1, matcher.downs) + } + + @Test + fun `transicao adiada dispara pelo handler e notifica`() { + val matcher = TestMatcher(listener) { cancelLater(it) } + matcher.onMotionEvent(null, down(0)) + assertEquals(GestureMatcher.STATE_CLEAR, matcher.state) + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(350)) + assertEquals(GestureMatcher.STATE_GESTURE_CANCELED, matcher.state) + assertEquals(GestureMatcher.STATE_GESTURE_CANCELED, states.last()) + } + + @Test + fun `clear remove a transicao pendente`() { + val matcher = TestMatcher(listener) { cancelLater(it) } + matcher.onMotionEvent(null, down(0)) + matcher.clear() + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(350)) + assertEquals(GestureMatcher.STATE_CLEAR, matcher.state) + assertFalse(states.contains(GestureMatcher.STATE_GESTURE_CANCELED)) + } + + @Test + fun `tipo de acao desconhecido cancela por evento invalido`() { + val matcher = TestMatcher(listener) {} + val hover = MotionEvent.obtain(0, 0, MotionEvent.ACTION_HOVER_MOVE, X, Y, 0) + val state = matcher.onMotionEvent(null, hover) + assertEquals(GestureMatcher.STATE_GESTURE_CANCELED, state) + } + + private fun down(t: Long) = MotionEvent.obtain(t, t, MotionEvent.ACTION_DOWN, X, Y, 0) + + private companion object { + const val X = 200f + const val Y = 400f + } +} diff --git a/utils/src/test/java/com/google/android/accessibility/utils/gestures/GestureUtilsCharacterizationTest.kt b/utils/src/test/java/com/google/android/accessibility/utils/gestures/GestureUtilsCharacterizationTest.kt new file mode 100644 index 000000000..c0da6b6dd --- /dev/null +++ b/utils/src/test/java/com/google/android/accessibility/utils/gestures/GestureUtilsCharacterizationTest.kt @@ -0,0 +1,128 @@ +/* + * Copyright (C) The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Ported from Java to Kotlin. + */ + +package com.google.android.accessibility.utils.gestures + +import android.graphics.PointF +import android.view.MotionEvent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * CARACTERIZAÇÃO dos utilitários de gesto: distâncias, janelas de + * tempo (borda INCLUSIVA no timeout, EXCLUSIVA na distância) e a decisão de + * drag por cosseno do ângulo entre os deslocamentos dos dois dedos. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class GestureUtilsCharacterizationTest { + @Test + fun `constantes de conversao metrica`() { + assertEquals(10, GestureUtils.MM_PER_CM) + assertEquals(2.54f, GestureUtils.CM_PER_INCH) + } + + @Test + fun `isTimedOut e verdadeiro quando deltaTime atinge o timeout (inclusivo)`() { + assertTrue(GestureUtils.isTimedOut(up(0), up(250), 250)) + assertFalse(GestureUtils.isTimedOut(up(0), up(249), 250)) + } + + @Test + fun `isMultiTap exige dentro do tempo E dentro da distancia (exclusivos)`() { + // Dentro do tempo e da distância → multi-tap. + assertTrue(GestureUtils.isMultiTap(up(0, 100f, 100f), up(200, 110f, 100f), 250, 20)) + // Na borda da distância (>=) já NÃO é. + assertFalse(GestureUtils.isMultiTap(up(0, 100f, 100f), up(200, 120f, 100f), 250, 20)) + // Fora do tempo não é. + assertFalse(GestureUtils.isMultiTap(up(0, 100f, 100f), up(250, 100f, 100f), 250, 20)) + // Nulos não são. + assertFalse(GestureUtils.isMultiTap(null, up(0, 100f, 100f), 250, 20)) + assertFalse(GestureUtils.isMultiTap(up(0, 100f, 100f), null, 250, 20)) + } + + @Test + fun `distance e dist medem euclidiana`() { + assertEquals(5.0, GestureUtils.distance(up(0, 0f, 0f), up(10, 3f, 4f)), 1e-6) + assertEquals(5f, GestureUtils.dist(0f, 0f, 3f, 4f)) + } + + @Test + fun `distanceClosestPointerToPoint usa o ponteiro mais proximo`() { + val move = multiMove(longArrayOf(0), floatArrayOf(0f, 30f), floatArrayOf(0f, 40f)) + // Ponto (3,4): dedo 1 está a 5; dedo 2 a ~45 → vale 5. + assertEquals(5.0, GestureUtils.distanceClosestPointerToPoint(PointF(3f, 4f), move), 1e-4) + } + + @Test + fun `dedos paralelos sao drag - pinca nao e - dedo parado conta como drag`() { + val cos45 = 0.525321989f + // Paralelos (ambos para baixo) → drag. + assertTrue(GestureUtils.isDraggingGesture(0f, 0f, 50f, 0f, 0f, 100f, 50f, 100f, cos45)) + // Direções opostas (pinça) → não é drag. + assertFalse(GestureUtils.isDraggingGesture(0f, 0f, 50f, 0f, -100f, 0f, 150f, 0f, cos45)) + // Um dedo parado → drag (vetor nulo conta como paralelo). + assertTrue(GestureUtils.isDraggingGesture(0f, 0f, 50f, 0f, 0f, 0f, 50f, 100f, cos45)) + // Perpendiculares (cos 0 < 0,5253) → não é drag. + assertFalse(GestureUtils.isDraggingGesture(0f, 0f, 50f, 0f, 100f, 0f, 50f, 100f, cos45)) + } + + @Test + fun `getActionIndex extrai o indice do ponteiro da acao`() { + val event = multiMove(longArrayOf(0), floatArrayOf(0f, 10f), floatArrayOf(0f, 10f)) + val withIndex1 = + MotionEvent.obtain(event).apply { + action = + MotionEvent.ACTION_POINTER_UP or + (1 shl MotionEvent.ACTION_POINTER_INDEX_SHIFT) + } + assertEquals(0, GestureUtils.getActionIndex(event)) + assertEquals(1, GestureUtils.getActionIndex(withIndex1)) + } + + private fun up(t: Long, x: Float = 0f, y: Float = 0f): MotionEvent = + MotionEvent.obtain(0, t, MotionEvent.ACTION_UP, x, y, 0) + + private fun multiMove(times: LongArray, xs: FloatArray, ys: FloatArray): MotionEvent { + val count = xs.size + val props = + Array(count) { + MotionEvent.PointerProperties().apply { + id = it + toolType = MotionEvent.TOOL_TYPE_FINGER + } + } + val coords = + Array(count) { + MotionEvent.PointerCoords().apply { + x = xs[it] + y = ys[it] + pressure = 1f + size = 1f + } + } + return MotionEvent.obtain( + 0, times[0], MotionEvent.ACTION_MOVE, count, props, coords, 0, 0, 1f, 1f, 0, 0, 0, 0, + ) + } +} diff --git a/utils/src/test/java/com/google/android/accessibility/utils/gestures/MultiFingerGesturesCharacterizationTest.kt b/utils/src/test/java/com/google/android/accessibility/utils/gestures/MultiFingerGesturesCharacterizationTest.kt new file mode 100644 index 000000000..2c19489f8 --- /dev/null +++ b/utils/src/test/java/com/google/android/accessibility/utils/gestures/MultiFingerGesturesCharacterizationTest.kt @@ -0,0 +1,240 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Ported from Java to Kotlin. + */ + +package com.google.android.accessibility.utils.gestures + +import android.os.Looper +import android.view.MotionEvent +import java.time.Duration +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** + * CARACTERIZAÇÃO dos matchers multi-dedo: + * + * MultiFingerMultiTap (ex.: toque duplo de 2 dedos): + * - um "tap" conta quando TODOS os dedos desceram e subiram dentro das + * janelas (tapTimeout escala com o nº de dedos: N × 100 ms); + * - o gesto COMEÇA no 1º tap completo e COMPLETA após o último tap + + * doubleTapTimeout (completeAfterDoubleTapTimeout); + * - dedos demais cancelam. + * + * MultiFingerMultiTapAndHold: SEGURAR no início do último tap completa após o + * longPressTimeout; SOLTAR no último tap cancela (não é hold). + * + * MultiFingerSwipe: todos os dedos na MESMA direção completam no UP; um dedo + * na direção errada cancela na hora. + * + * TwoFingerSecondFingerMultiTap (rotor): 2 dedos descem juntos, UM segura e o + * OUTRO tapeia N vezes; completa no UP final do dedo-âncora. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class MultiFingerGesturesCharacterizationTest { + private val states = mutableListOf() + private val listener = GestureMatcher.StateChangeListener { _, state, _ -> states += state } + private val context = RuntimeEnvironment.getApplication() + + private val oneX = floatArrayOf(X) + private val oneY = floatArrayOf(Y) + private val twoXs = floatArrayOf(X, X + 100) + private val twoYs = floatArrayOf(Y, Y) + + // ----- MultiFingerMultiTap ----- + + @Test + fun `toque duplo de 2 dedos completa apos a janela do ultimo tap`() { + val matcher = MultiFingerMultiTap(context, 2, 2, GESTURE_ID, listener) {} + twoFingerTap(matcher, 0) + twoFingerTap(matcher, 150) + assertFalse(states.contains(GestureMatcher.STATE_GESTURE_COMPLETED)) + idleMainLooper(GestureConfiguration.DOUBLE_TAP_TIMEOUT_MS + 50L) + assertEquals(GestureMatcher.STATE_GESTURE_COMPLETED, states.last()) + } + + @Test + fun `um so toque de 2 dedos comeca o gesto mas nao completa`() { + val matcher = MultiFingerMultiTap(context, 2, 2, GESTURE_ID, listener) {} + twoFingerTap(matcher, 0) + assertTrue(states.contains(GestureMatcher.STATE_GESTURE_STARTED)) + assertFalse(states.contains(GestureMatcher.STATE_GESTURE_COMPLETED)) + } + + @Test + fun `terceiro dedo cancela o matcher de 2 dedos`() { + val matcher = MultiFingerMultiTap(context, 2, 2, GESTURE_ID, listener) {} + matcher.onMotionEvent(null, down(0, X, Y)) + matcher.onMotionEvent(null, pointerDown(10, twoXs, twoYs)) + val threeXs = floatArrayOf(X, X + 100, X + 200) + val threeYs = floatArrayOf(Y, Y, Y) + val state = matcher.onMotionEvent(null, pointerDown(20, threeXs, threeYs)) + assertEquals(GestureMatcher.STATE_GESTURE_CANCELED, state) + } + + // ----- MultiFingerMultiTapAndHold ----- + + @Test + fun `2 dedos - tap e SEGURAR no 2o tap completa apos o longPressTimeout`() { + val matcher = MultiFingerMultiTapAndHold(context, 2, 2, GESTURE_HOLD_ID, listener) {} + twoFingerTap(matcher, 0) + // 2º tap desce e SEGURA. + matcher.onMotionEvent(null, down(150, X, Y)) + matcher.onMotionEvent(null, pointerDown(160, twoXs, twoYs)) + assertFalse(states.contains(GestureMatcher.STATE_GESTURE_COMPLETED)) + idleMainLooper(600) + assertEquals(GestureMatcher.STATE_GESTURE_COMPLETED, states.last()) + } + + @Test + fun `soltar no ultimo tap cancela o and-hold (nao e hold)`() { + val matcher = MultiFingerMultiTapAndHold(context, 2, 2, GESTURE_HOLD_ID, listener) {} + twoFingerTap(matcher, 0) + matcher.onMotionEvent(null, down(150, X, Y)) + matcher.onMotionEvent(null, pointerDown(160, twoXs, twoYs)) + matcher.onMotionEvent(null, pointerUp(200, twoXs, twoYs, 1)) + val state = matcher.onMotionEvent(null, up(220, X, Y)) + assertEquals(GestureMatcher.STATE_GESTURE_CANCELED, state) + } + + // ----- MultiFingerSwipe ----- + + @Test + fun `swipe de 2 dedos para cima completa quando os dois dedos sobem`() { + val matcher = MultiFingerSwipe(context, 2, MultiFingerSwipe.UP, GESTURE_SWIPE_ID, listener) {} + matcher.onMotionEvent(null, down(0, X, Y)) + matcher.onMotionEvent(null, pointerDown(10, twoXs, twoYs)) + matcher.onMotionEvent(null, moveMulti(30, floatArrayOf(X, X + 100), floatArrayOf(Y - 100, Y - 100))) + matcher.onMotionEvent(null, moveMulti(50, floatArrayOf(X, X + 100), floatArrayOf(Y - 200, Y - 200))) + matcher.onMotionEvent( + null, + pointerUp(70, floatArrayOf(X, X + 100), floatArrayOf(Y - 200, Y - 200), 1), + ) + val state = matcher.onMotionEvent(null, up(90, X, Y - 200)) + assertEquals(GestureMatcher.STATE_GESTURE_COMPLETED, state) + } + + @Test + fun `dedo na direcao errada cancela o swipe de 2 dedos`() { + val matcher = MultiFingerSwipe(context, 2, MultiFingerSwipe.UP, GESTURE_SWIPE_ID, listener) {} + matcher.onMotionEvent(null, down(0, X, Y)) + matcher.onMotionEvent(null, pointerDown(10, twoXs, twoYs)) + val state = + matcher.onMotionEvent( + null, + moveMulti(30, floatArrayOf(X + 100, X + 200), floatArrayOf(Y, Y)), // direita + ) + assertEquals(GestureMatcher.STATE_GESTURE_CANCELED, state) + } + + // ----- TwoFingerSecondFingerMultiTap (rotor) ----- + + @Test + fun `ancora segura e o 2o dedo tapeia 2x - completa no UP final da ancora`() { + val matcher = + TwoFingerSecondFingerMultiTap( + context, 2, TwoFingerSecondFingerMultiTap.ROTATE_DIRECTION_DONT_CARE, + GESTURE_ROTOR_ID, listener, + ) {} + matcher.onMotionEvent(null, down(0, X, Y)) + matcher.onMotionEvent(null, pointerDown(10, twoXs, twoYs)) // tap 1 desce + matcher.onMotionEvent(null, pointerUp(50, twoXs, twoYs, 1)) // tap 1 sobe + matcher.onMotionEvent(null, pointerDown(100, twoXs, twoYs)) // tap 2 desce + matcher.onMotionEvent(null, pointerUp(150, twoXs, twoYs, 1)) // tap 2 sobe + val state = matcher.onMotionEvent(null, up(200, X, Y)) // âncora levanta + assertEquals(GestureMatcher.STATE_GESTURE_COMPLETED, state) + } + + @Test + fun `tap do 2o dedo segurado alem do tapTimeout cancela o rotor`() { + val matcher = + TwoFingerSecondFingerMultiTap( + context, 2, TwoFingerSecondFingerMultiTap.ROTATE_DIRECTION_DONT_CARE, + GESTURE_ROTOR_ID, listener, + ) {} + matcher.onMotionEvent(null, down(0, X, Y)) + matcher.onMotionEvent(null, pointerDown(10, twoXs, twoYs)) + // 2 dedos → tapTimeout = 2×100 = 200 ms; segurou 250 ms → cancela. + val state = matcher.onMotionEvent(null, pointerUp(260, twoXs, twoYs, 1)) + assertEquals(GestureMatcher.STATE_GESTURE_CANCELED, state) + } + + /** Um toque completo de 2 dedos (desce 1→2, sobe 2→1), sem movimento. */ + private fun twoFingerTap(matcher: GestureMatcher, startTime: Long) { + matcher.onMotionEvent(null, down(startTime, X, Y)) + matcher.onMotionEvent(null, pointerDown(startTime + 10, twoXs, twoYs)) + matcher.onMotionEvent(null, pointerUp(startTime + 40, twoXs, twoYs, 1)) + matcher.onMotionEvent(null, up(startTime + 60, X, Y)) + } + + private fun idleMainLooper(millis: Long) { + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(millis)) + } + + private fun down(t: Long, x: Float, y: Float) = + MotionEvent.obtain(t, t, MotionEvent.ACTION_DOWN, x, y, 0) + + private fun up(t: Long, x: Float, y: Float) = + MotionEvent.obtain(0, t, MotionEvent.ACTION_UP, x, y, 0) + + private fun pointerDown(t: Long, xs: FloatArray, ys: FloatArray) = + multi(t, MotionEvent.ACTION_POINTER_DOWN, xs.size - 1, xs, ys) + + private fun pointerUp(t: Long, xs: FloatArray, ys: FloatArray, index: Int) = + multi(t, MotionEvent.ACTION_POINTER_UP, index, xs, ys) + + private fun moveMulti(t: Long, xs: FloatArray, ys: FloatArray) = + multi(t, MotionEvent.ACTION_MOVE, 0, xs, ys) + + private fun multi(t: Long, action: Int, actionIndex: Int, xs: FloatArray, ys: FloatArray): MotionEvent { + val count = xs.size + val props = + Array(count) { + MotionEvent.PointerProperties().apply { + id = it + toolType = MotionEvent.TOOL_TYPE_FINGER + } + } + val coords = + Array(count) { + MotionEvent.PointerCoords().apply { + x = xs[it] + y = ys[it] + pressure = 1f + size = 1f + } + } + val masked = action or (actionIndex shl MotionEvent.ACTION_POINTER_INDEX_SHIFT) + return MotionEvent.obtain(0, t, masked, count, props, coords, 0, 0, 1f, 1f, 0, 0, 0, 0) + } + + private companion object { + const val GESTURE_ID = 20 // GESTURE_2_FINGER_DOUBLE_TAP + const val GESTURE_HOLD_ID = 40 // GESTURE_2_FINGER_DOUBLE_TAP_AND_HOLD + const val GESTURE_SWIPE_ID = 26 // GESTURE_2_FINGER_SWIPE_UP + const val GESTURE_ROTOR_ID = -4 // GESTURE_TAP_HOLD_AND_2ND_FINGER_FORWARD_DOUBLE_TAP + const val X = 200f + const val Y = 400f + } +} diff --git a/utils/src/test/java/com/google/android/accessibility/utils/gestures/MultiTapAndHoldCharacterizationTest.kt b/utils/src/test/java/com/google/android/accessibility/utils/gestures/MultiTapAndHoldCharacterizationTest.kt new file mode 100644 index 000000000..e8d37bc05 --- /dev/null +++ b/utils/src/test/java/com/google/android/accessibility/utils/gestures/MultiTapAndHoldCharacterizationTest.kt @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Ported from Java to Kotlin. + */ + +package com.google.android.accessibility.utils.gestures + +import android.os.Looper +import android.view.MotionEvent +import android.view.ViewConfiguration +import java.time.Duration +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** + * CARACTERIZAÇÃO do MultiTapAndHold (duplo-toque-e-segura): + * - o gesto COMPLETA sozinho após o longPressTimeout com o dedo parado no + * toque final (completeAfterLongPressTimeout no DOWN final); + * - LEVANTAR no toque final antes do long-press CANCELA (não é hold); + * - um toque só, sem o seguinte, expira após o doubleTapTimeout. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class MultiTapAndHoldCharacterizationTest { + private val states = mutableListOf() + private lateinit var matcher: MultiTapAndHold + + @Before + fun setUp() { + matcher = + MultiTapAndHold( + RuntimeEnvironment.getApplication(), + /* taps= */ 2, + /* gesture= */ GESTURE_ID, + /* listener= */ { _, state, _ -> states += state }, + /* configProvider= */ object : GestureManifold.GestureConfigProvider {}, + /* logger= */ {}, + ) + } + + @Test + fun `segurar no toque final completa apos o longPressTimeout`() { + matcher.onMotionEvent(null, down(0, X, Y)) + matcher.onMotionEvent(null, up(50, X, Y)) + matcher.onMotionEvent(null, down(200, X, Y)) // 2º toque desce e SEGURA + assertFalse(states.contains(GestureMatcher.STATE_GESTURE_COMPLETED)) + idleMainLooper(ViewConfiguration.getLongPressTimeout().toLong() + 50) + assertEquals(GestureMatcher.STATE_GESTURE_COMPLETED, states.last()) + } + + @Test + fun `levantar no toque final antes do long-press cancela (nao e hold)`() { + matcher.onMotionEvent(null, down(0, X, Y)) + matcher.onMotionEvent(null, up(50, X, Y)) + matcher.onMotionEvent(null, down(200, X, Y)) + val state = matcher.onMotionEvent(null, up(250, X, Y)) + assertEquals(GestureMatcher.STATE_GESTURE_CANCELED, state) + } + + @Test + fun `um toque so expira depois da janela de duplo-toque`() { + matcher.onMotionEvent(null, down(0, X, Y)) + matcher.onMotionEvent(null, up(50, X, Y)) + idleMainLooper(GestureConfiguration.DOUBLE_TAP_TIMEOUT_MS + 50L) + assertEquals(GestureMatcher.STATE_GESTURE_CANCELED, states.last()) + } + + private fun idleMainLooper(millis: Long) { + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(millis)) + } + + private fun down(t: Long, x: Float, y: Float) = + MotionEvent.obtain(t, t, MotionEvent.ACTION_DOWN, x, y, 0) + + private fun up(t: Long, x: Float, y: Float) = + MotionEvent.obtain(0, t, MotionEvent.ACTION_UP, x, y, 0) + + private companion object { + const val GESTURE_ID = 18 // AccessibilityService.GESTURE_DOUBLE_TAP_AND_HOLD + const val X = 200f + const val Y = 400f + } +} diff --git a/utils/src/test/java/com/google/android/accessibility/utils/gestures/MultiTapCharacterizationTest.kt b/utils/src/test/java/com/google/android/accessibility/utils/gestures/MultiTapCharacterizationTest.kt new file mode 100644 index 000000000..ff1851c6e --- /dev/null +++ b/utils/src/test/java/com/google/android/accessibility/utils/gestures/MultiTapCharacterizationTest.kt @@ -0,0 +1,143 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Ported from Java to Kotlin. + */ + +package com.google.android.accessibility.utils.gestures + +import android.view.MotionEvent +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +/** + * CARACTERIZAÇÃO do MultiTap: fixa o comportamento observável do + * detector de duplo-toque ANTES da conversão para Kotlin. Este teste roda verde + * contra o MultiTap.java original e deve rodar verde, SEM ALTERAÇÃO, contra o + * MultiTap.kt convertido. + * + * Comportamentos fixados (MultiTap.java): + * - a janela entre toques é UP(n) → DOWN(n+1) ≤ doubleTapTimeout (onDown:100-105) + * — a duração do toque seguinte NÃO conta; + * - cada toque individual dura ≤ tapTimeout (isValidUpEvent:231-237); + * - o toque seguinte precisa cair dentro do doubleTapSlop do anterior + * (onDown:111-115); + * - mover além do touchSlop dentro de um toque cancela (onMove:150-156); + * - o gesto COMEÇA no DOWN do toque final (onDown:118-124) e COMPLETA no UP. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class MultiTapCharacterizationTest { + private val states = mutableListOf() + private lateinit var matcher: MultiTap + + @Before + fun setUp() { + matcher = + MultiTap( + RuntimeEnvironment.getApplication(), + /* taps= */ 2, + /* gesture= */ GESTURE_ID, + /* listener= */ { _, state, _ -> states += state }, + /* configProvider= */ object : GestureManifold.GestureConfigProvider {}, + /* logger= */ {}, + ) + } + + @Test + fun `duplo-toque completa quando o DOWN do 2o toque cai na janela apos o UP do 1o`() { + // UP1→DOWN2 = 240ms (≤ doubleTapTimeout=250) mas UP1→UP2 = 290ms: a + // duração do 2º toque NÃO conta — só o intervalo até o DOWN. + val up1 = 50L + val down2 = up1 + matcher.doubleTapTimeout - 10L + matcher.onMotionEvent(null, down(0, X, Y)) + matcher.onMotionEvent(null, up(up1, X, Y)) + matcher.onMotionEvent(null, down(down2, X, Y)) + val final = matcher.onMotionEvent(null, up(down2 + 50, X, Y)) + assertEquals(GestureMatcher.STATE_GESTURE_COMPLETED, final) + } + + @Test + fun `DOWN do 2o toque depois da janela cancela o duplo-toque`() { + val up1 = 50L + val down2 = up1 + matcher.doubleTapTimeout + 10L + matcher.onMotionEvent(null, down(0, X, Y)) + matcher.onMotionEvent(null, up(up1, X, Y)) + val state = matcher.onMotionEvent(null, down(down2, X, Y)) + assertEquals(GestureMatcher.STATE_GESTURE_CANCELED, state) + } + + @Test + fun `um toque so nao completa - notifica PROCESSING mas o estado interno fica CLEAR`() { + // GestureMatcher.setState: PROCESSING é notificado ao listener SEM virar + // estado interno (só estende o timer de exploração no monitor). + matcher.onMotionEvent(null, down(0, X, Y)) + val returned = matcher.onMotionEvent(null, up(50, X, Y)) + assertEquals(GestureMatcher.STATE_CLEAR, returned) + assertEquals(GestureMatcher.STATE_GESTURE_PROCESSING, states.last()) + } + + @Test + fun `toque individual mais longo que tapTimeout e invalido`() { + matcher.onMotionEvent(null, down(0, X, Y)) + val state = matcher.onMotionEvent(null, up(matcher.tapTimeout + 50L, X, Y)) + assertEquals(GestureMatcher.STATE_GESTURE_CANCELED, state) + } + + @Test + fun `2o toque fora do doubleTapSlop cancela`() { + matcher.onMotionEvent(null, down(0, X, Y)) + matcher.onMotionEvent(null, up(50, X, Y)) + val farX = X + matcher.doubleTapSlop + 10f + val state = matcher.onMotionEvent(null, down(150, farX, Y)) + assertEquals(GestureMatcher.STATE_GESTURE_CANCELED, state) + } + + @Test + fun `mover alem do touchSlop dentro do toque cancela`() { + matcher.onMotionEvent(null, down(0, X, Y)) + val state = matcher.onMotionEvent(null, move(20, X + matcher.touchSlop + 5f, Y)) + assertEquals(GestureMatcher.STATE_GESTURE_CANCELED, state) + } + + @Test + fun `o gesto COMECA no DOWN do toque final e o id do gesto e propagado`() { + matcher.onMotionEvent(null, down(0, X, Y)) + matcher.onMotionEvent(null, up(50, X, Y)) + val state = matcher.onMotionEvent(null, down(150, X, Y)) + assertEquals(GestureMatcher.STATE_GESTURE_STARTED, state) + assertEquals(GESTURE_ID, matcher.gestureId) + } + + private fun down(t: Long, x: Float, y: Float) = + MotionEvent.obtain(t, t, MotionEvent.ACTION_DOWN, x, y, 0) + + private fun up(t: Long, x: Float, y: Float) = + MotionEvent.obtain(0, t, MotionEvent.ACTION_UP, x, y, 0) + + private fun move(t: Long, x: Float, y: Float) = + MotionEvent.obtain(0, t, MotionEvent.ACTION_MOVE, x, y, 0) + + private companion object { + const val GESTURE_ID = 17 // AccessibilityService.GESTURE_DOUBLE_TAP + const val X = 200f + const val Y = 400f + } +} diff --git a/utils/src/test/java/com/google/android/accessibility/utils/gestures/SecondFingerGesturesCharacterizationTest.kt b/utils/src/test/java/com/google/android/accessibility/utils/gestures/SecondFingerGesturesCharacterizationTest.kt new file mode 100644 index 000000000..43d17f6a8 --- /dev/null +++ b/utils/src/test/java/com/google/android/accessibility/utils/gestures/SecondFingerGesturesCharacterizationTest.kt @@ -0,0 +1,172 @@ +/* + * Copyright (C) The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Ported from Java to Kotlin. + */ + +package com.google.android.accessibility.utils.gestures + +import android.os.Looper +import android.view.MotionEvent +import java.time.Duration +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** + * CARACTERIZAÇÃO da família do SPLIT-TAP (1º dedo segura, 2º toca): + * + * SecondFingerTap (split typing): + * - o 2º dedo só VALE se descer DEPOIS de doubleTapTimeout (300 ms) do 1º + * DOWN — antes disso é candidato a gesto de 2 dedos, e o matcher CANCELA; + * - ao completar, o matcher NOTIFICA COMPLETED e em seguida REINICIA + * (restart + startGesture) para aceitar o próximo split-tap na MESMA + * interação (digitação contínua); + * - levantar o 1º dedo (ACTION_UP) sempre cancela. + * + * SecondFingerTapAndHold: 2º dedo desce (após a janela) e SEGURA → completa + * após o longPressTimeout; levantar o 2º dedo antes cancela. + * + * SecondFingerMultiTap (variante antiga, rotor): 2 toques do 2º dedo dentro + * das janelas (tap ≤ 100 ms; entre toques ≤ 250 ms) completam. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class SecondFingerGesturesCharacterizationTest { + private val states = mutableListOf() + private val listener = GestureMatcher.StateChangeListener { _, state, _ -> states += state } + private val context = RuntimeEnvironment.getApplication() + + // ----- SecondFingerTap (split typing) ----- + + @Test + fun `split-tap valido - 2o dedo desce apos 300ms, toca e completa, e o matcher reinicia`() { + val matcher = SecondFingerTap(context, 1, GESTURE_SPLIT, listener) {} + matcher.onMotionEvent(null, down(0, X, Y)) + matcher.onMotionEvent(null, pointerDown(350, twoXs, twoYs)) + val state = matcher.onMotionEvent(null, pointerUp(400, twoXs, twoYs, 1)) + assertTrue(states.contains(GestureMatcher.STATE_GESTURE_COMPLETED)) + // Reinicia para o próximo split-tap na mesma interação (digitação contínua). + assertEquals(GestureMatcher.STATE_GESTURE_STARTED, state) + } + + @Test + fun `2o dedo cedo demais (antes de 300ms do 1o DOWN) cancela o split-tap`() { + val matcher = SecondFingerTap(context, 1, GESTURE_SPLIT, listener) {} + matcher.onMotionEvent(null, down(0, X, Y)) + val state = matcher.onMotionEvent(null, pointerDown(100, twoXs, twoYs)) + assertEquals(GestureMatcher.STATE_GESTURE_CANCELED, state) + } + + @Test + fun `levantar o 1o dedo cancela o split-tap`() { + val matcher = SecondFingerTap(context, 1, GESTURE_SPLIT, listener) {} + matcher.onMotionEvent(null, down(0, X, Y)) + val state = matcher.onMotionEvent(null, up(400, X, Y)) + assertEquals(GestureMatcher.STATE_GESTURE_CANCELED, state) + } + + // ----- SecondFingerTapAndHold ----- + + @Test + fun `segurar o 2o dedo completa apos o longPressTimeout`() { + val matcher = SecondFingerTapAndHold(context, 1, GESTURE_SPLIT_HOLD, listener) {} + matcher.onMotionEvent(null, down(0, X, Y)) + matcher.onMotionEvent(null, pointerDown(350, twoXs, twoYs)) + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(600)) + assertEquals(GestureMatcher.STATE_GESTURE_COMPLETED, states.last()) + } + + @Test + fun `levantar o 2o dedo antes do hold cancela`() { + val matcher = SecondFingerTapAndHold(context, 1, GESTURE_SPLIT_HOLD, listener) {} + matcher.onMotionEvent(null, down(0, X, Y)) + matcher.onMotionEvent(null, pointerDown(350, twoXs, twoYs)) + val state = matcher.onMotionEvent(null, pointerUp(400, twoXs, twoYs, 1)) + assertEquals(GestureMatcher.STATE_GESTURE_CANCELED, state) + } + + // ----- SecondFingerMultiTap (variante antiga) ----- + + @Test + fun `dois toques do 2o dedo dentro das janelas completam`() { + val matcher = SecondFingerMultiTap(context, 2, GESTURE_ROTATE, listener) {} + matcher.onMotionEvent(null, down(0, X, Y)) + matcher.onMotionEvent(null, pointerDown(10, twoXs, twoYs)) + matcher.onMotionEvent(null, pointerUp(50, twoXs, twoYs, 1)) // tap 1 (40ms) + matcher.onMotionEvent(null, pointerDown(150, twoXs, twoYs)) // 100ms depois (≤250) + val state = matcher.onMotionEvent(null, pointerUp(200, twoXs, twoYs, 1)) // tap 2 + assertEquals(GestureMatcher.STATE_GESTURE_COMPLETED, state) + } + + @Test + fun `toque do 2o dedo mais longo que o tapTimeout cancela`() { + val matcher = SecondFingerMultiTap(context, 2, GESTURE_ROTATE, listener) {} + matcher.onMotionEvent(null, down(0, X, Y)) + matcher.onMotionEvent(null, pointerDown(10, twoXs, twoYs)) + val state = matcher.onMotionEvent(null, pointerUp(200, twoXs, twoYs, 1)) // 190ms > 100 + assertEquals(GestureMatcher.STATE_GESTURE_CANCELED, state) + } + + private val twoXs = floatArrayOf(X, X + 120) + private val twoYs = floatArrayOf(Y, Y) + + private fun down(t: Long, x: Float, y: Float) = + MotionEvent.obtain(t, t, MotionEvent.ACTION_DOWN, x, y, 0) + + private fun up(t: Long, x: Float, y: Float) = + MotionEvent.obtain(0, t, MotionEvent.ACTION_UP, x, y, 0) + + private fun pointerDown(t: Long, xs: FloatArray, ys: FloatArray) = + multi(t, MotionEvent.ACTION_POINTER_DOWN, xs.size - 1, xs, ys) + + private fun pointerUp(t: Long, xs: FloatArray, ys: FloatArray, index: Int) = + multi(t, MotionEvent.ACTION_POINTER_UP, index, xs, ys) + + private fun multi(t: Long, action: Int, actionIndex: Int, xs: FloatArray, ys: FloatArray): MotionEvent { + val count = xs.size + val props = + Array(count) { + MotionEvent.PointerProperties().apply { + id = it + toolType = MotionEvent.TOOL_TYPE_FINGER + } + } + val coords = + Array(count) { + MotionEvent.PointerCoords().apply { + x = xs[it] + y = ys[it] + pressure = 1f + size = 1f + } + } + val masked = action or (actionIndex shl MotionEvent.ACTION_POINTER_INDEX_SHIFT) + return MotionEvent.obtain(0, t, masked, count, props, coords, 0, 0, 1f, 1f, 0, 0, 0, 0) + } + + private companion object { + const val GESTURE_SPLIT = -3 // GESTURE_FAKED_SPLIT_TYPING + const val GESTURE_SPLIT_HOLD = -8 // GESTURE_FAKED_SPLIT_TYPING_AND_HOLD + const val GESTURE_ROTATE = -4 + const val X = 200f + const val Y = 400f + } +} diff --git a/utils/src/test/java/com/google/android/accessibility/utils/gestures/SwipeCharacterizationTest.kt b/utils/src/test/java/com/google/android/accessibility/utils/gestures/SwipeCharacterizationTest.kt new file mode 100644 index 000000000..e8089b652 --- /dev/null +++ b/utils/src/test/java/com/google/android/accessibility/utils/gestures/SwipeCharacterizationTest.kt @@ -0,0 +1,160 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Ported from Java to Kotlin. + */ + +package com.google.android.accessibility.utils.gestures + +import android.view.MotionEvent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +/** + * CARACTERIZAÇÃO do Swipe de 1 dedo: + * - confirma como GESTO ao percorrer 1 cm dentro de 150 ms (config_*); + * - lento demais para COMEÇAR (150 ms sem 1 cm) cancela — vira exploração; + * - a PRIMEIRA direção errada cancela imediatamente; + * - swipe em ÂNGULO (2 direções) é segmentado por mudança de ~90°; + * - 2º dedo cancela; + * - UP com o matcher em CLEAR limpa SILENCIOSAMENTE (sem notificar cancel — + * comentário do onUp: não travar a próxima detecção). + * + * Métricas do Robolectric (mdpi): 1 cm ≈ 63 px; amostra 0,25 cm ≈ 15,7 px. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class SwipeCharacterizationTest { + private val states = mutableListOf() + + private fun swipe(vararg directions: Int): Swipe { + val context = RuntimeEnvironment.getApplication() + val listener = GestureMatcher.StateChangeListener { _, state, _ -> states += state } + val provider = object : GestureManifold.GestureConfigProvider {} + return if (directions.size == 1) { + Swipe(context, directions[0], GESTURE_ID, listener, provider) {} + } else { + Swipe(context, directions[0], directions[1], GESTURE_ID, listener, provider) {} + } + } + + @Test + fun `swipe reto rapido (1cm em menos de 150ms) completa`() { + val matcher = swipe(Swipe.RIGHT) + matcher.onMotionEvent(null, down(0, X, Y)) + matcher.onMotionEvent(null, move(50, X + 40, Y)) + matcher.onMotionEvent(null, move(100, X + 70, Y)) // cruzou 1cm (~63px) em 100ms + val state = matcher.onMotionEvent(null, up(120, X + 80, Y)) + assertEquals(GestureMatcher.STATE_GESTURE_COMPLETED, state) + } + + @Test + fun `comecar devagar demais cancela (vira exploracao)`() { + val matcher = swipe(Swipe.RIGHT) + matcher.onMotionEvent(null, down(0, X, Y)) + matcher.onMotionEvent(null, move(60, X + 30, Y)) + // 200ms sem completar 1cm → maxStartThreshold (150ms) estourou. + val state = matcher.onMotionEvent(null, move(200, X + 50, Y)) + assertEquals(GestureMatcher.STATE_GESTURE_CANCELED, state) + } + + @Test + fun `primeira direcao errada cancela imediatamente`() { + val matcher = swipe(Swipe.RIGHT) + matcher.onMotionEvent(null, down(0, X, Y)) + val state = matcher.onMotionEvent(null, move(50, X - 40, Y)) // esquerda + assertEquals(GestureMatcher.STATE_GESTURE_CANCELED, state) + } + + @Test + fun `swipe em angulo direita-baixo e segmentado e completa`() { + val matcher = swipe(Swipe.RIGHT, Swipe.DOWN) + matcher.onMotionEvent(null, down(0, X, Y)) + // Perna direita, amostrada a cada 20px. + matcher.onMotionEvent(null, move(10, X + 20, Y)) + matcher.onMotionEvent(null, move(20, X + 40, Y)) + matcher.onMotionEvent(null, move(30, X + 60, Y)) + matcher.onMotionEvent(null, move(40, X + 80, Y)) + // Perna para baixo. + matcher.onMotionEvent(null, move(55, X + 80, Y + 50)) + matcher.onMotionEvent(null, move(70, X + 80, Y + 100)) + matcher.onMotionEvent(null, move(85, X + 80, Y + 150)) + matcher.onMotionEvent(null, move(100, X + 80, Y + 200)) + val state = matcher.onMotionEvent(null, up(110, X + 80, Y + 205)) + assertEquals(GestureMatcher.STATE_GESTURE_COMPLETED, state) + } + + @Test + fun `segundo dedo cancela o swipe de 1 dedo`() { + val matcher = swipe(Swipe.RIGHT) + matcher.onMotionEvent(null, down(0, X, Y)) + val state = + matcher.onMotionEvent(null, pointerDown(10, floatArrayOf(X, X + 80), floatArrayOf(Y, Y))) + assertEquals(GestureMatcher.STATE_GESTURE_CANCELED, state) + } + + @Test + fun `up com o matcher em CLEAR limpa sem notificar cancelamento`() { + val matcher = swipe(Swipe.RIGHT) + matcher.onMotionEvent(null, down(0, X, Y)) + val state = matcher.onMotionEvent(null, up(30, X, Y)) // toque parado: nada de swipe + assertEquals(GestureMatcher.STATE_CLEAR, state) + assertFalse(states.contains(GestureMatcher.STATE_GESTURE_CANCELED)) + } + + private fun down(t: Long, x: Float, y: Float) = + MotionEvent.obtain(t, t, MotionEvent.ACTION_DOWN, x, y, 0) + + private fun up(t: Long, x: Float, y: Float) = + MotionEvent.obtain(0, t, MotionEvent.ACTION_UP, x, y, 0) + + private fun move(t: Long, x: Float, y: Float) = + MotionEvent.obtain(0, t, MotionEvent.ACTION_MOVE, x, y, 0) + + private fun pointerDown(t: Long, xs: FloatArray, ys: FloatArray): MotionEvent { + val count = xs.size + val props = + Array(count) { + MotionEvent.PointerProperties().apply { + id = it + toolType = MotionEvent.TOOL_TYPE_FINGER + } + } + val coords = + Array(count) { + MotionEvent.PointerCoords().apply { + x = xs[it] + y = ys[it] + pressure = 1f + size = 1f + } + } + val masked = + MotionEvent.ACTION_POINTER_DOWN or + ((count - 1) shl MotionEvent.ACTION_POINTER_INDEX_SHIFT) + return MotionEvent.obtain(0, t, masked, count, props, coords, 0, 0, 1f, 1f, 0, 0, 0, 0) + } + + private companion object { + const val GESTURE_ID = 4 // GESTURE_SWIPE_RIGHT + const val X = 200f + const val Y = 400f + } +} diff --git a/utils/src/test/java/com/google/android/accessibility/utils/gestures/TouchExploreMatchersCharacterizationTest.kt b/utils/src/test/java/com/google/android/accessibility/utils/gestures/TouchExploreMatchersCharacterizationTest.kt new file mode 100644 index 000000000..c594a9345 --- /dev/null +++ b/utils/src/test/java/com/google/android/accessibility/utils/gestures/TouchExploreMatchersCharacterizationTest.kt @@ -0,0 +1,114 @@ +/* + * Copyright (C) 2024 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + * + * Ported from Java to Kotlin. + */ + +package com.google.android.accessibility.utils.gestures + +import android.view.MotionEvent +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +/** + * CARACTERIZAÇÃO dos pseudo-gestos que ACELERAM a entrada em + * exploração por toque: + * + * TapToTouchExplore — completa no UP quando o toque foi LENTO DEMAIS para ser + * tap (durou mais que o tapTimeout): nenhum outro matcher de 1 dedo pode mais + * casar, então é seguro explorar já. Um tap RÁPIDO cancela (pode ser + * duplo-toque). + * + * TapUpToTouchExplore — completa no MOVE quando o dedo ficou PARADO (dentro de + * 1 cm) por mais que o maxStartThreshold (150 ms): não há mais swipe possível. + * Mover além de 1 cm cancela; levantar o dedo cancela. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class TouchExploreMatchersCharacterizationTest { + private val states = mutableListOf() + private val listener = GestureMatcher.StateChangeListener { _, state, _ -> states += state } + private val context = RuntimeEnvironment.getApplication() + + @Test + fun `toque mais lento que o tapTimeout completa o TapToTouchExplore no UP`() { + val matcher = TapToTouchExplore(context, GESTURE_TAP_EXPLORE, listener, provider) {} + matcher.onMotionEvent(null, down(0, X, Y)) + val state = matcher.onMotionEvent(null, up(150, X, Y)) // 150ms > tapTimeout(100) + assertEquals(GestureMatcher.STATE_GESTURE_COMPLETED, state) + } + + @Test + fun `tap rapido cancela o TapToTouchExplore (pode ser duplo-toque)`() { + val matcher = TapToTouchExplore(context, GESTURE_TAP_EXPLORE, listener, provider) {} + matcher.onMotionEvent(null, down(0, X, Y)) + val state = matcher.onMotionEvent(null, up(50, X, Y)) + assertEquals(GestureMatcher.STATE_GESTURE_CANCELED, state) + } + + @Test + fun `mover alem do touchSlop cancela o TapToTouchExplore`() { + val matcher = TapToTouchExplore(context, GESTURE_TAP_EXPLORE, listener, provider) {} + matcher.onMotionEvent(null, down(0, X, Y)) + val state = matcher.onMotionEvent(null, move(50, X + 100, Y)) + assertEquals(GestureMatcher.STATE_GESTURE_CANCELED, state) + } + + @Test + fun `dedo parado por mais de 150ms completa o TapUpToTouchExplore no MOVE`() { + val matcher = TapUpToTouchExplore(context, GESTURE_TAP_UP_EXPLORE, listener) {} + matcher.onMotionEvent(null, down(0, X, Y)) + val state = matcher.onMotionEvent(null, move(200, X + 5, Y)) // parado (<<1cm), 200ms + assertEquals(GestureMatcher.STATE_GESTURE_COMPLETED, state) + } + + @Test + fun `mover mais de 1cm cancela o TapUpToTouchExplore (pode ser swipe)`() { + val matcher = TapUpToTouchExplore(context, GESTURE_TAP_UP_EXPLORE, listener) {} + matcher.onMotionEvent(null, down(0, X, Y)) + val state = matcher.onMotionEvent(null, move(50, X + 100, Y)) // 100px > ~63px (1cm) + assertEquals(GestureMatcher.STATE_GESTURE_CANCELED, state) + } + + @Test + fun `levantar o dedo cancela o TapUpToTouchExplore`() { + val matcher = TapUpToTouchExplore(context, GESTURE_TAP_UP_EXPLORE, listener) {} + matcher.onMotionEvent(null, down(0, X, Y)) + val state = matcher.onMotionEvent(null, up(50, X, Y)) + assertEquals(GestureMatcher.STATE_GESTURE_CANCELED, state) + } + + private val provider = object : GestureManifold.GestureConfigProvider {} + + private fun down(t: Long, x: Float, y: Float) = + MotionEvent.obtain(t, t, MotionEvent.ACTION_DOWN, x, y, 0) + + private fun up(t: Long, x: Float, y: Float) = + MotionEvent.obtain(0, t, MotionEvent.ACTION_UP, x, y, 0) + + private fun move(t: Long, x: Float, y: Float) = + MotionEvent.obtain(0, t, MotionEvent.ACTION_MOVE, x, y, 0) + + private companion object { + const val GESTURE_TAP_EXPLORE = -6 // GESTURE_TOUCH_EXPLORE + const val GESTURE_TAP_UP_EXPLORE = -7 // GESTURE_TAP_UP_TOUCH_EXPLORE + const val X = 200f + const val Y = 400f + } +} diff --git a/utils/src/test/java/com/google/android/accessibility/utils/gestures/TwoFingerSingleTapAndLongHoldCharacterizationTest.kt b/utils/src/test/java/com/google/android/accessibility/utils/gestures/TwoFingerSingleTapAndLongHoldCharacterizationTest.kt new file mode 100644 index 000000000..8117a534f --- /dev/null +++ b/utils/src/test/java/com/google/android/accessibility/utils/gestures/TwoFingerSingleTapAndLongHoldCharacterizationTest.kt @@ -0,0 +1,122 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Ported from Java to Kotlin. + */ + +package com.google.android.accessibility.utils.gestures + +import android.os.Looper +import android.view.MotionEvent +import java.time.Duration +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** + * CARACTERIZAÇÃO do TwoFingerSingleTapAndLongHold (recuperação de + * mau-disparo): 2 dedos descem e SEGURAM → completa após 5000 ms (LONG_HOLD, + * maior que o long-press normal); levantar antes cancela (a variante "tap" + * deste padrão pertence ao MultiFingerMultiTap, não a esta classe). + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class TwoFingerSingleTapAndLongHoldCharacterizationTest { + private val states = mutableListOf() + private lateinit var matcher: TwoFingerSingleTapAndLongHold + + @Before + fun setUp() { + matcher = + TwoFingerSingleTapAndLongHold( + RuntimeEnvironment.getApplication(), + /* gestureId= */ GESTURE_ID, + /* listener= */ { _, state, _ -> states += state }, + /* logger= */ {}, + ) + } + + @Test + fun `dois dedos segurados completam apos os 5 segundos do LONG_HOLD`() { + matcher.onMotionEvent(null, down(0, X, Y)) + matcher.onMotionEvent(null, pointerDown(10, floatArrayOf(X, X + 80), floatArrayOf(Y, Y))) + idleMainLooper(4000) + assertFalse( + "não pode completar antes dos 5s", + states.contains(GestureMatcher.STATE_GESTURE_COMPLETED), + ) + idleMainLooper(1100) + assertEquals(GestureMatcher.STATE_GESTURE_COMPLETED, states.last()) + } + + @Test + fun `levantar os dedos antes do hold cancela`() { + matcher.onMotionEvent(null, down(0, X, Y)) + matcher.onMotionEvent(null, pointerDown(10, floatArrayOf(X, X + 80), floatArrayOf(Y, Y))) + matcher.onMotionEvent(null, pointerUp(100, floatArrayOf(X, X + 80), floatArrayOf(Y, Y), 1)) + val state = matcher.onMotionEvent(null, up(120, X, Y)) + assertEquals(GestureMatcher.STATE_GESTURE_CANCELED, state) + } + + private fun idleMainLooper(millis: Long) { + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(millis)) + } + + private fun down(t: Long, x: Float, y: Float) = + MotionEvent.obtain(t, t, MotionEvent.ACTION_DOWN, x, y, 0) + + private fun up(t: Long, x: Float, y: Float) = + MotionEvent.obtain(0, t, MotionEvent.ACTION_UP, x, y, 0) + + private fun pointerDown(t: Long, xs: FloatArray, ys: FloatArray) = + multi(t, MotionEvent.ACTION_POINTER_DOWN, xs.size - 1, xs, ys) + + private fun pointerUp(t: Long, xs: FloatArray, ys: FloatArray, index: Int) = + multi(t, MotionEvent.ACTION_POINTER_UP, index, xs, ys) + + private fun multi(t: Long, action: Int, actionIndex: Int, xs: FloatArray, ys: FloatArray): MotionEvent { + val count = xs.size + val props = + Array(count) { + MotionEvent.PointerProperties().apply { + id = it + toolType = MotionEvent.TOOL_TYPE_FINGER + } + } + val coords = + Array(count) { + MotionEvent.PointerCoords().apply { + x = xs[it] + y = ys[it] + pressure = 1f + size = 1f + } + } + val masked = action or (actionIndex shl MotionEvent.ACTION_POINTER_INDEX_SHIFT) + return MotionEvent.obtain(0, t, masked, count, props, coords, 0, 0, 1f, 1f, 0, 0, 0, 0) + } + + private companion object { + const val GESTURE_ID = 63 // GESTURE_2_FINGER_SINGLE_TAP_AND_HOLD + const val X = 200f + const val Y = 400f + } +} diff --git a/utils/src/test/java/com/google/android/accessibility/utils/traversal/DirectionalTraversalCharacterizationTest.kt b/utils/src/test/java/com/google/android/accessibility/utils/traversal/DirectionalTraversalCharacterizationTest.kt new file mode 100644 index 000000000..abb88b1a0 --- /dev/null +++ b/utils/src/test/java/com/google/android/accessibility/utils/traversal/DirectionalTraversalCharacterizationTest.kt @@ -0,0 +1,151 @@ +/* + * Copyright (C) The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Ported from Java to Kotlin. + */ + +package com.google.android.accessibility.utils.traversal + +import android.graphics.Rect +import android.text.SpannableString +import android.text.style.ClickableSpan +import android.text.style.URLSpan +import android.view.View +import android.view.accessibility.AccessibilityNodeInfo +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** + * CARACTERIZAÇÃO da navegação ESPACIAL e utilitários finais de + * traversal/: + * + * DirectionalTraversalStrategy — algoritmo do FocusFinder do framework + * (beams + distância ponderada 13·maior² + menor²) sobre a geometria dos nós: + * numa cruz A/B(direita)/C(abaixo), RIGHT acha B, DOWN acha C, LEFT/UP voltam + * para A, e LEFT sem candidato devolve null. + * + * NodeCachedBoundsCalculator — nó invisível não tem bounds úteis (null); + * nó focável visível usa os PRÓPRIOS bounds; usesChildrenBounds(null)=false. + * + * SpannableTraversalUtils — acha ClickableSpan (URLSpan) no texto do nó; + * texto puro não tem; contentDescription no nó PODA a busca nos filhos. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class DirectionalTraversalCharacterizationTest { + private lateinit var root: AccessibilityNodeInfoCompat + private lateinit var a: AccessibilityNodeInfoCompat + private lateinit var b: AccessibilityNodeInfoCompat + private lateinit var c: AccessibilityNodeInfoCompat + + @Before + fun setUp() { + root = node(Rect(0, 0, 1000, 1000), focusable = false) + a = node(Rect(0, 0, 100, 100)) + b = node(Rect(200, 0, 300, 100)) // à direita de A + c = node(Rect(0, 200, 100, 300)) // abaixo de A + shadowOf(root.unwrap()).addChild(a.unwrap()) + shadowOf(root.unwrap()).addChild(b.unwrap()) + shadowOf(root.unwrap()).addChild(c.unwrap()) + } + + @Test + fun `na cruz - RIGHT acha B, DOWN acha C, e os inversos voltam para A`() { + val strategy = DirectionalTraversalStrategy(root, null) + assertEquals(b, strategy.findFocus(a, TraversalStrategy.SEARCH_FOCUS_RIGHT)) + assertEquals(c, strategy.findFocus(a, TraversalStrategy.SEARCH_FOCUS_DOWN)) + assertEquals(a, strategy.findFocus(b, TraversalStrategy.SEARCH_FOCUS_LEFT)) + assertEquals(a, strategy.findFocus(c, TraversalStrategy.SEARCH_FOCUS_UP)) + } + + @Test + fun `sem candidato na direcao devolve null`() { + val strategy = DirectionalTraversalStrategy(root, null) + assertNull(strategy.findFocus(a, TraversalStrategy.SEARCH_FOCUS_LEFT)) + assertNull(strategy.findFocus(a, TraversalStrategy.SEARCH_FOCUS_UP)) + assertNull(strategy.findFocus(null, TraversalStrategy.SEARCH_FOCUS_RIGHT)) + } + + @Test + fun `bounds uteis - invisivel e null, focavel visivel usa os proprios bounds`() { + val calculator = NodeCachedBoundsCalculator() + val invisible = AccessibilityNodeInfoCompat.wrap(AccessibilityNodeInfo.obtain()) + assertNull(calculator.getBounds(invisible)) + assertFalse(calculator.usesChildrenBounds(null)) + assertFalse(calculator.usesChildrenBounds(a)) + + val bounds = calculator.getBounds(a) + assertEquals(Rect(0, 0, 100, 100), bounds) + // Cache: a mesma instância de Rect volta na segunda consulta. + assertSame(bounds, calculator.getBounds(a)) + } + + @Test + fun `spannable - URLSpan no texto e encontrado, texto puro nao`() { + val withLink = node(Rect(0, 0, 50, 50)) + val text = SpannableString("abrir link") + text.setSpan(URLSpan("https://x"), 0, 5, 0) + withLink.text = text + assertTrue( + SpannableTraversalUtils.hasTargetClickableSpanInNodeTree(withLink, ClickableSpan::class.java), + ) + + val plain = node(Rect(0, 0, 50, 50)) + plain.text = "sem link" + assertFalse( + SpannableTraversalUtils.hasTargetClickableSpanInNodeTree(plain, ClickableSpan::class.java), + ) + assertFalse( + SpannableTraversalUtils.hasTargetClickableSpanInNodeTree(null, ClickableSpan::class.java), + ) + } + + @Test + fun `spannable - contentDescription no no PODA a busca nos filhos`() { + val parent = node(Rect(0, 0, 500, 500), focusable = false) + parent.contentDescription = "container rotulado" + val child = node(Rect(0, 0, 50, 50), focusable = false) + val text = SpannableString("abrir link") + text.setSpan(URLSpan("https://x"), 0, 5, 0) + child.text = text + shadowOf(parent.unwrap()).addChild(child.unwrap()) + assertFalse( + SpannableTraversalUtils.hasTargetClickableSpanInNodeTree(parent, ClickableSpan::class.java), + ) + } + + private fun node(bounds: Rect, focusable: Boolean = true): AccessibilityNodeInfoCompat { + val view = View(RuntimeEnvironment.getApplication()) + val info = AccessibilityNodeInfo.obtain(view) + val compat = AccessibilityNodeInfoCompat.wrap(info) + compat.setBoundsInScreen(bounds) + compat.isVisibleToUser = true + if (focusable) { + compat.isClickable = true + } + return compat + } +} diff --git a/utils/src/test/java/com/google/android/accessibility/utils/traversal/OrderedTraversalCharacterizationTest.kt b/utils/src/test/java/com/google/android/accessibility/utils/traversal/OrderedTraversalCharacterizationTest.kt new file mode 100644 index 000000000..230cc1866 --- /dev/null +++ b/utils/src/test/java/com/google/android/accessibility/utils/traversal/OrderedTraversalCharacterizationTest.kt @@ -0,0 +1,126 @@ +/* + * Copyright (C) The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Ported from Java to Kotlin. + */ + +package com.google.android.accessibility.utils.traversal + +import android.view.View +import android.view.accessibility.AccessibilityNodeInfo +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** + * CARACTERIZAÇÃO da travessia ORDENADA (sem traversalBefore/After — + * a reordenação por before/after depende de fixtures que o Robolectric não + * monta; o que se fixa aqui é a ORDEM DE ÁRVORE em pré-ordem, a base de tudo): + * - OrderedTraversalStrategy percorre a árvore em PRÉ-ORDEM (dumpTree); + * - findFocus FORWARD/BACKWARD andam na ordem e no inverso; + * - focusFirst FORWARD = raiz; BACKWARD = último descendente; + * - nó desconhecido → null; + * - ReorderedChildrenIterator itera filhos em ordem ascendente/descendente. + * + * Árvore: root ── c1 + * └─ c2 ── c2a + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class OrderedTraversalCharacterizationTest { + private lateinit var root: AccessibilityNodeInfoCompat + private lateinit var c1: AccessibilityNodeInfoCompat + private lateinit var c2: AccessibilityNodeInfoCompat + private lateinit var c2a: AccessibilityNodeInfoCompat + + @Before + fun setUp() { + root = node() + c1 = node() + c2 = node() + c2a = node() + addChild(root, c1) + addChild(root, c2) + addChild(c2, c2a) + } + + @Test + fun `dumpTree e a pre-ordem da arvore`() { + val strategy = OrderedTraversalStrategy(root) + assertEquals(listOf(root, c1, c2, c2a), strategy.dumpTree()) + } + + @Test + fun `findFocus FORWARD anda na pre-ordem e devolve null no fim`() { + val strategy = OrderedTraversalStrategy(root) + assertEquals(c1, strategy.findFocus(root, TraversalStrategy.SEARCH_FOCUS_FORWARD)) + assertEquals(c2, strategy.findFocus(c1, TraversalStrategy.SEARCH_FOCUS_FORWARD)) + assertEquals(c2a, strategy.findFocus(c2, TraversalStrategy.SEARCH_FOCUS_FORWARD)) + assertNull(strategy.findFocus(c2a, TraversalStrategy.SEARCH_FOCUS_FORWARD)) + } + + @Test + fun `findFocus BACKWARD inverte a pre-ordem`() { + val strategy = OrderedTraversalStrategy(root) + assertEquals(c2, strategy.findFocus(c2a, TraversalStrategy.SEARCH_FOCUS_BACKWARD)) + assertEquals(c1, strategy.findFocus(c2, TraversalStrategy.SEARCH_FOCUS_BACKWARD)) + assertEquals(root, strategy.findFocus(c1, TraversalStrategy.SEARCH_FOCUS_BACKWARD)) + assertNull(strategy.findFocus(root, TraversalStrategy.SEARCH_FOCUS_BACKWARD)) + } + + @Test + fun `focusFirst FORWARD e a raiz - BACKWARD e o ultimo descendente`() { + val strategy = OrderedTraversalStrategy(root) + assertEquals(root, strategy.focusFirst(root, TraversalStrategy.SEARCH_FOCUS_FORWARD)) + assertEquals(c2a, strategy.focusFirst(root, TraversalStrategy.SEARCH_FOCUS_BACKWARD)) + } + + @Test + fun `no desconhecido devolve null`() { + val strategy = OrderedTraversalStrategy(root) + val stranger = node() + assertNull(strategy.findFocus(stranger, TraversalStrategy.SEARCH_FOCUS_FORWARD)) + } + + @Test + fun `iterator asc e desc percorrem os filhos na ordem da arvore`() { + val ascending = ReorderedChildrenIterator.createAscendingIterator(root) + assertEquals(c1, ascending.next()) + assertEquals(c2, ascending.next()) + assertFalse(ascending.hasNext()) + + val descending = ReorderedChildrenIterator.createDescendingIterator(root) + assertEquals(c2, descending.next()) + assertEquals(c1, descending.next()) + assertFalse(descending.hasNext()) + } + + private fun node(): AccessibilityNodeInfoCompat { + val view = View(RuntimeEnvironment.getApplication()) + return AccessibilityNodeInfoCompat.wrap(AccessibilityNodeInfo.obtain(view)) + } + + private fun addChild(parent: AccessibilityNodeInfoCompat, child: AccessibilityNodeInfoCompat) { + shadowOf(parent.unwrap()).addChild(child.unwrap()) + } +} diff --git a/utils/src/test/java/com/google/android/accessibility/utils/traversal/TraversalBasicsCharacterizationTest.kt b/utils/src/test/java/com/google/android/accessibility/utils/traversal/TraversalBasicsCharacterizationTest.kt new file mode 100644 index 000000000..ce1a169bb --- /dev/null +++ b/utils/src/test/java/com/google/android/accessibility/utils/traversal/TraversalBasicsCharacterizationTest.kt @@ -0,0 +1,104 @@ +/* + * Copyright (C) The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Ported from Java to Kotlin. + */ + +package com.google.android.accessibility.utils.traversal + +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * CARACTERIZAÇÃO dos fundamentos da varredura: + * - constantes de direção do TraversalStrategy (contrato com prefs/logs); + * - nomes simbólicos das direções; + * - defaults do builder do OrderedTraversalStrategyConfig (AutoValue); + * - SimpleTraversalStrategy: focusFirst FORWARD devolve a própria raiz e + * não usa cache de nós falantes; + * - NodeFocusFinder: direção desconhecida devolve null. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class TraversalBasicsCharacterizationTest { + @Test + fun `constantes de direcao sao estaveis`() { + assertEquals(0, TraversalStrategy.SEARCH_FOCUS_UNKNOWN) + assertEquals(1, TraversalStrategy.SEARCH_FOCUS_FORWARD) + assertEquals(2, TraversalStrategy.SEARCH_FOCUS_BACKWARD) + assertEquals(3, TraversalStrategy.SEARCH_FOCUS_LEFT) + assertEquals(4, TraversalStrategy.SEARCH_FOCUS_RIGHT) + assertEquals(5, TraversalStrategy.SEARCH_FOCUS_UP) + assertEquals(6, TraversalStrategy.SEARCH_FOCUS_DOWN) + assertEquals(1, NodeFocusFinder.SEARCH_FORWARD) + assertEquals(-1, NodeFocusFinder.SEARCH_BACKWARD) + } + + @Test + fun `nomes simbolicos das direcoes`() { + assertEquals( + "SEARCH_FOCUS_FORWARD", + TraversalStrategy.getSymbolicName(TraversalStrategy.SEARCH_FOCUS_FORWARD), + ) + assertEquals( + "SEARCH_FOCUS_BACKWARD", + TraversalStrategy.getSymbolicName(TraversalStrategy.SEARCH_FOCUS_BACKWARD), + ) + assertEquals("unavailable direction: 99", TraversalStrategy.getSymbolicName(99)) + } + + @Test + fun `builder do config tem defaults zero-false-false`() { + val config = OrderedTraversalStrategyConfig.builder().build() + assertEquals(0, config.searchDirection()) + assertFalse(config.includeChildrenOfNodesWithWebActions()) + assertFalse(config.makeFabFirst()) + } + + @Test + fun `builder do config preserva valores setados`() { + val config = + OrderedTraversalStrategyConfig.builder() + .setSearchDirection(TraversalStrategy.SEARCH_FOCUS_BACKWARD) + .setIncludeChildrenOfNodesWithWebActions(true) + .setMakeFabFirst(true) + .build() + assertEquals(TraversalStrategy.SEARCH_FOCUS_BACKWARD, config.searchDirection()) + assertEquals(true, config.includeChildrenOfNodesWithWebActions()) + assertEquals(true, config.makeFabFirst()) + } + + @Test + fun `simple strategy - focusFirst FORWARD devolve a raiz e nao ha cache`() { + val strategy = SimpleTraversalStrategy() + val root = AccessibilityNodeInfoCompat.obtain() + assertSame(root, strategy.focusFirst(root, TraversalStrategy.SEARCH_FOCUS_FORWARD)) + assertNull(strategy.getSpeakingNodesCache()) + assertNull(strategy.focusFirst(null, TraversalStrategy.SEARCH_FOCUS_FORWARD)) + } + + @Test + fun `node focus finder - direcao desconhecida devolve null`() { + val node = AccessibilityNodeInfoCompat.obtain() + assertNull(NodeFocusFinder.focusSearch(node, 0)) + } +} diff --git a/utils/src/test/java/com/google/android/accessibility/utils/traversal/TraversalStrategyUtilsCharacterizationTest.kt b/utils/src/test/java/com/google/android/accessibility/utils/traversal/TraversalStrategyUtilsCharacterizationTest.kt new file mode 100644 index 000000000..4f987a3e8 --- /dev/null +++ b/utils/src/test/java/com/google/android/accessibility/utils/traversal/TraversalStrategyUtilsCharacterizationTest.kt @@ -0,0 +1,228 @@ +/* + * Copyright (C) The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Ported from Java to Kotlin. + */ + +package com.google.android.accessibility.utils.traversal + +import android.view.View +import android.view.accessibility.AccessibilityNodeInfo +import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import com.google.android.accessibility.utils.Filter +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +/** + * CARACTERIZAÇÃO do TraversalStrategyUtils: + * - classificação e conversões de direção (lógica × espacial, RTL, ações de + * scroll ida-e-volta, direções de foco de View); + * - searchFocus: percorre a estratégia até o filtro aceitar; PROTEÇÃO DE + * CICLO devolve null; filtro null aceita o primeiro não-nulo; + * - findFirstFocusInNodeTree: usa focusFirst e cai para searchFocus; + * - direção inválida lança IllegalArgumentException. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class TraversalStrategyUtilsCharacterizationTest { + @Test + fun `classificacao logica x espacial`() { + assertFalse(TraversalStrategyUtils.isSpatialDirection(TraversalStrategy.SEARCH_FOCUS_FORWARD)) + assertFalse(TraversalStrategyUtils.isSpatialDirection(TraversalStrategy.SEARCH_FOCUS_BACKWARD)) + assertTrue(TraversalStrategyUtils.isSpatialDirection(TraversalStrategy.SEARCH_FOCUS_LEFT)) + assertTrue(TraversalStrategyUtils.isSpatialDirection(TraversalStrategy.SEARCH_FOCUS_DOWN)) + assertTrue(TraversalStrategyUtils.isLogicalDirection(TraversalStrategy.SEARCH_FOCUS_FORWARD)) + assertFalse(TraversalStrategyUtils.isLogicalDirection(TraversalStrategy.SEARCH_FOCUS_UP)) + assertThrows(IllegalArgumentException::class.java) { + TraversalStrategyUtils.isSpatialDirection(99) + } + } + + @Test + fun `getLogicalDirection espelha esquerda-direita conforme RTL`() { + // LTR: esquerda=trás, direita=frente. + assertEquals( + TraversalStrategy.SEARCH_FOCUS_BACKWARD, + TraversalStrategyUtils.getLogicalDirection(TraversalStrategy.SEARCH_FOCUS_LEFT, false), + ) + assertEquals( + TraversalStrategy.SEARCH_FOCUS_FORWARD, + TraversalStrategyUtils.getLogicalDirection(TraversalStrategy.SEARCH_FOCUS_RIGHT, false), + ) + // RTL: espelhado. + assertEquals( + TraversalStrategy.SEARCH_FOCUS_FORWARD, + TraversalStrategyUtils.getLogicalDirection(TraversalStrategy.SEARCH_FOCUS_LEFT, true), + ) + assertEquals( + TraversalStrategy.SEARCH_FOCUS_BACKWARD, + TraversalStrategyUtils.getLogicalDirection(TraversalStrategy.SEARCH_FOCUS_RIGHT, true), + ) + // Cima/baixo independem de RTL. + assertEquals( + TraversalStrategy.SEARCH_FOCUS_BACKWARD, + TraversalStrategyUtils.getLogicalDirection(TraversalStrategy.SEARCH_FOCUS_UP, true), + ) + assertEquals( + TraversalStrategy.SEARCH_FOCUS_FORWARD, + TraversalStrategyUtils.getLogicalDirection(TraversalStrategy.SEARCH_FOCUS_DOWN, false), + ) + } + + @Test + fun `conversoes de direcao para acoes de scroll fazem ida-e-volta`() { + val directions = + intArrayOf( + TraversalStrategy.SEARCH_FOCUS_FORWARD, + TraversalStrategy.SEARCH_FOCUS_BACKWARD, + TraversalStrategy.SEARCH_FOCUS_LEFT, + TraversalStrategy.SEARCH_FOCUS_RIGHT, + TraversalStrategy.SEARCH_FOCUS_UP, + TraversalStrategy.SEARCH_FOCUS_DOWN, + ) + for (direction in directions) { + val action = TraversalStrategyUtils.convertSearchDirectionToScrollAction(direction) + assertEquals(direction, TraversalStrategyUtils.convertScrollActionToSearchDirection(action)) + } + assertEquals( + AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD, + TraversalStrategyUtils.convertSearchDirectionToScrollAction( + TraversalStrategy.SEARCH_FOCUS_FORWARD, + ), + ) + assertEquals( + AccessibilityAction.ACTION_SCROLL_LEFT.id, + TraversalStrategyUtils.convertSearchDirectionToScrollAction( + TraversalStrategy.SEARCH_FOCUS_LEFT, + ), + ) + assertEquals( + TraversalStrategy.SEARCH_FOCUS_UNKNOWN, + TraversalStrategyUtils.convertScrollActionToSearchDirection(12345), + ) + } + + @Test + fun `conversao para direcoes de foco de View`() { + assertEquals( + View.FOCUS_FORWARD, + TraversalStrategyUtils.nodeSearchDirectionToViewSearchDirection( + TraversalStrategy.SEARCH_FOCUS_FORWARD, + ), + ) + assertEquals( + View.FOCUS_LEFT, + TraversalStrategyUtils.nodeSearchDirectionToViewSearchDirection( + TraversalStrategy.SEARCH_FOCUS_LEFT, + ), + ) + assertThrows(IllegalArgumentException::class.java) { + TraversalStrategyUtils.nodeSearchDirectionToViewSearchDirection(0) + } + } + + @Test + fun `searchFocus percorre a cadeia ate o filtro aceitar`() { + val a = distinctNode() + val b = distinctNode() + val c = distinctNode() + val strategy = chainStrategy(mapOf(a to b, b to c)) + val onlyC = + object : Filter() { + override fun accept(node: AccessibilityNodeInfoCompat?): Boolean = node == c + } + assertSame(c, TraversalStrategyUtils.searchFocus(strategy, a, FORWARD, onlyC)) + // Cadeia esgota sem o filtro aceitar → null. + val nothing = + object : Filter() { + override fun accept(node: AccessibilityNodeInfoCompat?): Boolean = false + } + assertNull(TraversalStrategyUtils.searchFocus(strategy, a, FORWARD, nothing)) + } + + @Test + fun `searchFocus com ciclo na estrategia devolve null (protecao)`() { + val a = distinctNode() + val b = distinctNode() + val strategy = chainStrategy(mapOf(a to b, b to a)) // ciclo A→B→A + val never = + object : Filter() { + override fun accept(node: AccessibilityNodeInfoCompat?): Boolean = false + } + assertNull(TraversalStrategyUtils.searchFocus(strategy, a, FORWARD, never)) + } + + @Test + fun `findFirstFocusInNodeTree usa focusFirst e cai para searchFocus`() { + val root = distinctNode() + val next = distinctNode() + val strategy = + object : TraversalStrategy by chainStrategy(mapOf(root to next)) { + override fun focusFirst( + r: AccessibilityNodeInfoCompat?, + direction: Int, + ): AccessibilityNodeInfoCompat? = root + } + val acceptRoot = + object : Filter() { + override fun accept(node: AccessibilityNodeInfoCompat?): Boolean = node == root + } + assertSame(root, TraversalStrategyUtils.findFirstFocusInNodeTree(strategy, root, FORWARD, acceptRoot)) + val acceptNext = + object : Filter() { + override fun accept(node: AccessibilityNodeInfoCompat?): Boolean = node == next + } + assertSame(next, TraversalStrategyUtils.findFirstFocusInNodeTree(strategy, root, FORWARD, acceptNext)) + assertNull(TraversalStrategyUtils.findFirstFocusInNodeTree(strategy, null, FORWARD, acceptNext)) + } + + /** Nós DISTINTOS (ids de view diferentes) para os HashSets de proteção de ciclo. */ + private fun distinctNode(): AccessibilityNodeInfoCompat { + val view = View(RuntimeEnvironment.getApplication()) + return AccessibilityNodeInfoCompat.wrap(AccessibilityNodeInfo.obtain(view)) + } + + /** Estratégia fake: findFocus segue o mapa; focusFirst devolve o próprio root. */ + private fun chainStrategy( + chain: Map, + ): TraversalStrategy = + object : TraversalStrategy { + override fun findFocus( + startNode: AccessibilityNodeInfoCompat?, + direction: Int, + ): AccessibilityNodeInfoCompat? = chain[startNode] + + override fun focusFirst( + root: AccessibilityNodeInfoCompat?, + direction: Int, + ): AccessibilityNodeInfoCompat? = root + + override fun getSpeakingNodesCache(): Map? = null + } + + private companion object { + const val FORWARD = TraversalStrategy.SEARCH_FOCUS_FORWARD + } +} diff --git a/utils/src/test/java/com/google/android/accessibility/utils/traversal/WorkingTreeCharacterizationTest.kt b/utils/src/test/java/com/google/android/accessibility/utils/traversal/WorkingTreeCharacterizationTest.kt new file mode 100644 index 000000000..25f407607 --- /dev/null +++ b/utils/src/test/java/com/google/android/accessibility/utils/traversal/WorkingTreeCharacterizationTest.kt @@ -0,0 +1,102 @@ +/* + * Copyright (C) The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Ported from Java to Kotlin. + */ + +package com.google.android.accessibility.utils.traversal + +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * CARACTERIZAÇÃO da WorkingTree — a árvore reordenável usada pelo + * OrderedTraversalController para aplicar traversalBefore/traversalAfter: + * - getNext percorre em PRÉ-ORDEM (filho, senão próximo irmão, senão sobe); + * - getPrevious é o inverso (irmão anterior desce até o último descendente); + * - swapChild substitui o filho NA MESMA posição; + * - getLastNode/getRoot navegam até as pontas. + * + * Árvore de teste: root ── a ── a1 + * └─ b (a1 é filho de a; b é irmão de a) + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class WorkingTreeCharacterizationTest { + private val root = WorkingTree(node(), null) + private val a = WorkingTree(node(), root) + private val a1 = WorkingTree(node(), a) + private val b = WorkingTree(node(), root) + + init { + root.addChild(a) + a.addChild(a1) + root.addChild(b) + } + + @Test + fun `getNext percorre em pre-ordem`() { + assertSame(a, root.next) + assertSame(a1, a.next) + assertSame(b, a1.next) // sobe de a1 e vai ao próximo irmão de a + assertNull(b.next) // fim da árvore + } + + @Test + fun `getPrevious e o inverso da pre-ordem`() { + assertSame(root, a.previous) + assertSame(a, a1.previous) + assertSame(a1, b.previous) // irmão anterior (a) desce ao último descendente (a1) + assertNull(root.previous) + } + + @Test + fun `irmãos - next e previous sibling`() { + assertSame(b, a.nextSibling) + assertNull(b.nextSibling) + assertSame(a, b.previousSibling) + assertNull(a.previousSibling) + assertNull(root.nextSibling) + } + + @Test + fun `getLastNode desce ate o ultimo descendente e getRoot sobe ate a raiz`() { + assertSame(b, root.lastNode) // último filho de root é b, sem filhos + assertSame(a1, a.lastNode) + assertSame(root, a1.root) + } + + @Test + fun `swapChild substitui o filho na mesma posicao`() { + val c = WorkingTree(node(), root) + root.swapChild(a, c) + assertSame(c, root.next) // c assumiu a 1ª posição + assertSame(b, c.nextSibling) + } + + @Test + fun `removeChild tira o filho da sequencia`() { + root.removeChild(a) + assertSame(b, root.next) + } + + private fun node(): AccessibilityNodeInfoCompat = AccessibilityNodeInfoCompat.obtain() +}