From 86f75e14ce8b7bcd4e903d90ebd0eee7d727d032 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Wed, 29 Jul 2026 08:53:36 +0200 Subject: [PATCH 1/4] Open every format the core renders, not just half of them odrcore v6 keeps reference html output for doc, ppt, xls and pptx next to the ones the app already claimed, so the "TODO: enable pptx too" that sat in CoreLoader was the only thing keeping presentations out - and the legacy binary formats reached the core only by accident, through the doc mime type that was listed before ppt and xls ever were. What the loader claims is now one list: opendocument, the whole ooxml family, the three legacy microsoft formats and pdf. Prefixes, so the templates and the macro enabled variants come along - the latter are spelled vnd.ms-word/vnd.ms-excel/vnd.ms-powerpoint rather than under the openxmlformats prefix, which is why those appear without their subtypes. Text, csv and images stay out: the core renders them too, but RawLoader is what gives them their player, and it only gets a turn when the core loader says no. The STRICT_CATCH filter follows, so the system offers us pptx/ppt/xls. Two things fall out of it. The doOoxml flag is gone - it has been passed true since ooxml stopped being experimental and now gates nothing. And the ".doc-files are not real documents" special case becomes a check for all three legacy types: the core does parse them now, but declares them neither editable nor savable, so keeping one open only buys a second parse. pdf changes which viewer a failed upload falls back to, so that stays explicit: google docs still gets the pdf, the microsoft viewer keeps everything else the core knows. Verified on device - .pptx, .ppt, .doc and .xls all render through the core, with CoreTest covering each of them. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012eTZB8rnh9PrdNn1YZexue --- CLAUDE.md | 16 +++ app/src/androidTest/assets/11KB.doc | Bin 0 -> 28160 bytes .../assets/file_example_XLS_10.xls | Bin 0 -> 8704 bytes .../androidTest/assets/style-various-1.ppt | Bin 0 -> 708096 bytes .../androidTest/assets/style-various-1.pptx | Bin 0 -> 57211 bytes .../app/opendocument/droid/test/CoreTest.kt | 74 +++++++++++++- app/src/main/AndroidManifest.xml | 47 ++++++++- .../droid/background/CoreLoader.kt | 62 ++++++++---- .../droid/background/LoaderService.kt | 2 +- .../droid/background/OnlineLoader.kt | 8 +- .../droid/background/CoreLoaderTest.kt | 92 ++++++++++++++++++ .../droid/background/OnlineLoaderTest.kt | 4 +- .../droid/background/RawLoaderTest.kt | 2 +- 13 files changed, 281 insertions(+), 26 deletions(-) create mode 100644 app/src/androidTest/assets/11KB.doc create mode 100644 app/src/androidTest/assets/file_example_XLS_10.xls create mode 100644 app/src/androidTest/assets/style-various-1.ppt create mode 100644 app/src/androidTest/assets/style-various-1.pptx create mode 100644 app/src/test/java/app/opendocument/droid/background/CoreLoaderTest.kt diff --git a/CLAUDE.md b/CLAUDE.md index 897ac0ac0f12..608f5f70d329 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -145,6 +145,22 @@ the `.pro` suffix) differ on purpose - do not "fix" the mismatch: `AndroidFileCache`, the SharedPreferences name in `MainActivity`) follows `applicationId` and must stay that way, or existing users lose their saved prefs. +### Supported file types are declared twice, and both copies have to move together + +`CoreLoader.MIME_PREFIXES` is what the app offers itself for; the `STRICT_CATCH` +`activity-alias` in `AndroidManifest.xml` is what the system offers it for. **Keep the two +in step** - a format added to one and forgotten in the other is either a loader that never +gets the file, or an app that is missing from the share sheet. + +The set follows odrcore: whatever it keeps reference html output for in +`test/data/reference-output` is what the app claims - odf, ooxml, the legacy binary +doc/ppt/xls and pdf. Text, csv and images are the core's too, but belong to `RawLoader`, +which only gets its turn when `CoreLoader.isSupported()` says no. + +The kotlin side matches by *prefix*, so it also claims the ooxml templates, slideshows and +macro-enabled variants. An intent-filter matches a mime type exactly, so every one of those +spellings has to be named in the manifest on its own. + ### Language Kotlin; support is built into AGP 9, no kotlin plugin is applied. The only java left is diff --git a/app/src/androidTest/assets/11KB.doc b/app/src/androidTest/assets/11KB.doc new file mode 100644 index 0000000000000000000000000000000000000000..3940e5d26a6503f0bf792e0b2fbff9358beff623 GIT binary patch literal 28160 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 E2ktrrk^lez literal 0 HcmV?d00001 diff --git a/app/src/androidTest/assets/file_example_XLS_10.xls b/app/src/androidTest/assets/file_example_XLS_10.xls new file mode 100644 index 0000000000000000000000000000000000000000..1464ee150a463d887ac5197242c2dc26d95e1eee GIT binary patch literal 8704 zcmeHMU2I&%6+UI<|F3$&_`l5D^4%V2YGeu1XCGE|ENMkSA5 zQBpD}Ig|x?6f3**v=KWd*zHnU2IIw2^slC~!ar|;tJ#Y6LHoyD1oBu^e!uoDy1e@S z`x26uBIj`x`2hKpoMvn|>}AGN|F3jN$KW{}!yFzm2XN=eS)l9WMMYoN>j7C04WvFv z_3DiyZ$n_ar}Wyc*LAo$@|w2!n<723S^fyE%8#GsLkta~PCxd4T0F@}Q!Ny(f9>lW zWE1a}$Qn7v=zDbV_~2;&om(Pn|EtYvE{rseulM9UjWEn03UMJwM^$9ocP*RE&5n4FmQ{bJ8g z5fU8y&O0u$$jPT1`GM&yEO)Npj{lvW)%(uzvGF|r%*xar>=HQ^=&N+R! z_Q{ry5tIZ+4Hpw~Z$$o&Nb-^lIo|-j6&Xj8Bk96SF?HO_`e=HmSG~s=kNUX@zW^6H zoS)4V3y$54(<;E*#gn;G{}_Ky|P88kZLXT6LM%DZ#vqCb&3QH1gg zXnOW_^>lab?e5u+9)`Vw2eu(^W;&hC7xU0RTJXJM>QEtF^wK%%-`>^TOa0r?>&SFE zlRk}x7*kQ(XMB$8TO_g z&gUmkc@%Ta=P>p|h1qNty$%+O9*UvaLJo?9ULoUq>}A-`OnC+Ld`}v5xgW5bd;N6A z%S}{Z!L9NdT5-#ayKTt0siwAS8Nv|7JRy<|Plz>G(g`dzV`{4|ZxV}m={Fz$Wq54l z-~)=67`3Wt8#f)+|1s2@kL=PGyY+fTbvMW@T-J`m9qlL6S$`&V+A0%ih&Sb@Iz}E>f+zIafx+CS>BEED1NS>{|M;E@XZBnuAKV$Z ze@@zQ^qs);_&Xz=PN!3AUn?EC8-1^mAF2(-`N?V?ijhL*C5f*`*t*Oi?q|KH3rNXn zQ;Y)Y6%!|+7=5J~{5SW5VHe$wtjaaLtKXV*t)Vsl=$ERrXSj@g}Y`f8-hlsSGt;V`0yL`o> zx7mW-WWl~_!EUx-TP)buELh5dZM9(S7VPU5>=p~Q&4R&Qks58f-Gc3~U^^|?E(^BX zf^}N3Jr?X%3)W@9x-D3b1>4(%{YZnTNSnr?%Y1Ud;muAnQV0^=$w=V_%?KoVzp0Bt zqTd@S28kYTq!vi@b@H*dgi5Sq1WV`(y^%tY=DFTTjG`&P2ovGKwWTXD& zW9nK^_aHVsCB`h%j=k`qkkFWDB=XVojTGbyVKxcRt02}U9xqXGL2!+HsMrd~%rMg>zxz8p3sCE9$x7oK|(u(s(l zg83$3nVJ-wUj!|0tlKl9e6{&Sus-wlj9`5d^8NjWkE=T0XH@wZb2x{n@-f;NDaaRX z;EO8XM|I7hJ)_F^44jkm-XGkC`MJuMtG5f<(^bB)sy*pX2_96GA+CIk$>fVGU#czz`QikZ_98 zcAy8R$hSl5EorEZf1Y2m;yU$#c$QBDICtH$XHEqef||eiE1n9b8i7|mD+#1 z40Bw5t~_0oIJYv(mGtGbfP>Wy!&f=Gq#MF&i_Ga&jxZz-r^gHVnfzoiHTcN5pGkdl z|DKcHn4ih`x=jwHCnphPuRUS^8PaR);tGO|W9fqX`r?zn_~f&@rjyVA%$42SfBRR4 zLKhW0jIjPRGRYr7=8@tFWct9<$n=A=$n=F@Bhw#VL1wUch43)E%E5So4m<`|(d- z_$0jcof-)=wl8+!NcchBB>Q}WrL!+xEgy)XbQ+Jv0~wh5UXGAEI1b6yOG%1Zj2 zSm1ho|IzhtiXpaX(nm_%BuMw->r+7@b^^gIVab1ty#;)kMjA2V@O8xt}QIQ-u z4CX9i!Ce@`g9cp2mHdnyXd2fP{am)dNB9$&W%v$-x%umGID`TK)$?u5)@ z{#ktW;J3eH_zF0wYeJ-muOWFLi>Dn`e#XnC&Y$+`twx}z41i%=$KdN1$mNZv_ZsHU Z7+zd_O3SWUuKk1G-VO@v2M!<+NDPtyM}R1t{sso3%-bOQur>Cr z>|WXDNq{3r3X%cY_s+lt$hxk0#UY@i04YH#kQ#gj(txxe9Y_x{fQ;aCkO^c4S%5pp z3bKJOKz5J=y;k~`1qm6E?FvI>(tS*}y~9wy|PCUNgX z;d+EXIVWV2YmuxYdAux_c(RS_&F>`)8kH8ct5$6BP5r7BO-tp9X$q-Qy=u*Ba*fc^ zE{A!!j`YV>r#!A=jWm?>vhuV-@IkZZVXpIpb~ReO)wx8YgjP3O$v*HbCV{qntr%iHfpjOd1I zpqp`2?V8`XB+DS*$)#$QigJyU>sE6|No|&MT?nv?s$IQarL`A~>(3e2pO0F9$@s?w z!-n(54fuP>u<>H#rVEBm_&1wz z7o)abHf*~Tx$Tl+TS(-#5W}|1QQLzfwqG{xxMbWB6tN>HYDchPN3d~6NaT(yhMmE0 zb_Pf83^wcxG48w^vGa1&&a1|NU}Hc?L_kPXz~wgqmyH2eA_J}%c3qC#bs2w+yRJm+ zx?42OD;WyxAQRvHMEY?kmRKS0i_aM(qwY><%^Vxg4?Qa^#+? zhCNq}dqN}jghuVXY}|V#a_^O>y`jdv*9`kENA9~EweM=gzN`3a*mupi?|Rg}>&E?8 zqV``g?7teZKQwZGs9}GoasRb9`)?QyT*ZImfzUSxLL(1ci#l-4c<_qh;MK^3p~i#P zA`V`QI(Xf1@VfEfjmSe+jfX-b4qY=Ix*mDxy5Z0bzD7$j!(jHw{M+e#;mb8W|XB2)y4MW_zmOnn~}$FMja0`9=~fmaU=4?%{M1*8c*DcIB_fL z#4W>#+s2bO4JU6IPliRF3^SYzGoHNt=Hy+&sayDOJQWss>UPAb+fk=(8&8KBPv4F@ zeaCS6j`8%}$TPPh&)hbixf6Njj^WH*}C+fmo!-c!X3-=-~JTP3m zW4w4b^5R{?#e2q!_aiRekGlBKccFQzVA@~+wPA`jVZa} zXp3tH!smG$Z~5j(WU+0Lulr@|kz?STr>=*$KCAn4|6)E>mt|eZQA|wGQRe;zDr*CVdFkI>$*I>zwYMq z9`pNl^4Ru`f6pI69voZr!rNhMricdhnvHsNWBqxTp!~lS8MR@0sSYoTc{q1DK5^sb z5htc}2@Va?MHC+z*r$e5|t{^BENtZ(M=?fud>TQ<4fEo0Knix!0hbuEz5 z@Liw6EtYNcy5^g<>z2%=m$i8{aH;R@&2?_Qu39|vz#2)y4mT~Czg3fkoz5KEv*P%= zmc5p(9N{^AmS2m2Qf*4q`!(&W$f_k)4{kQ8=F|sEYUR6?^1$rknKl%AFh6j7f9JZ} z>Q_4&TBiEc)AOgdo7gT#vZc-Q24}h$_C*EcZ6e_G98}zJRA7c?BJURru}fW zd6yPbZ>|j-UA#y3a%mS%-}*T(LllDk4a%7{--TS_2*YMkK zyUiRwZ;5w`lX;F@?s?$hVehc0jm>ut-g<6bSiht>SNVHxOmk~+&j?Z#0J7+Sb6W~7Q1g0a_I2K{@c25d?(Gm z-#T-v>fiMUey}CegB>$hjwQ|jf0aYdY;1sp58`tscz-hM}B&x$&- zAbo5Hw)j{df(ZrzZIIcKG}k7_wFpo z`0_xW@AhBFvAN5J4fgJT%<5CQS$nT$ix-zMYzjN^a6@pxW?oHqci(=tMZTkna$TRY zrm6FzBLPWTJ^E$aXD4+{E_Ci)Am`KYpwigb0ougi~y|cE_h^a5qiy?#1dGUf7s zfahx`)vK1MW}3TqHt5=SZhm^eYSmc*ZsVY~$U$ydq@_wuTI5^?#xpe!Q zmaFDb?R3*FEt-0C+G4zPE|0;m%kY(*(;mA8quQo_-JpB5iFdvkAL7|1ZN%^?`+etC ztFg|Zj(x7XH69)Rs>W>nVI^Jv#d}JoeYK(Cx*YR!k4ROn)8#J;FCCFXzozf5;Qpts zJsakc*06w6^IYD)_Vf=s)U(pB*V8OZ@8_ZGdtuh$!1ZAlPkW_H>5{?!d9p&fz`(;Z z#|8ctF)DcZwZr}P+_&3*{>g;S{@>qka&2?Q8i^NmzVe;VDc(PPAJngeojyEE zlizpympb3C@Um`?4xXHP=69be;k7sX9I@uh*@f$+()&H>)opg0du4j(x!x-{^B1Fa z_1dpUJFk7-RTsx4J~r2{-kuRP`wd#!+%20+PLH#J?Q5P+__A6?a>T^YlW zJWsqlH$HT3HJd5d26U)pT#z*7&BTxijKQ$^jRYfg1C zDgo_l6e(1ufBU|ESpzD14|%j@*8H#^v%GBT9Mvu9!!e(gYhR>Q8P_@!3l>p2hdkJD zF|t^h(A|j=2VBg$uh|seY>#$@r=C)vjk0;%vWBg%tW2tSU%YU6!}>D)S7ozTzB{(o z=Xl+pr+7q$MXW2_Y|OO!`J2|-=`knYinGdDJEhm^jAwq$-p>C@P@czSUxxP?f2Gve zIWwj;tXE-NL|Erxo~3{86*~FbRCDdRjy&3P&pDUuGh3EDv+m9K8)LWE%GN8-q43L{ zHvW1pYG3MY%eps8gEqT>fkDsvI}SXN`F@f47qSk#GB91$GKZdJosd3x$1bkB0^39! zvU}~~Rxn^ziH>g1Jf9_rNHXEfg&p}i9+(j6)%WV$p;?!VyqZ19m54X1J>0J>{!F== z+`ZSGA!~mbH@Z&A@fAzuNS}YguG*<5r#b3tIQGQ3(Y!kWE0TB*%@ve$W>jc}ZbvV! zIWpZa=|wH)4d3UjGUCjzRvo4^o?fBK=tT`?RBoYPIW+HgRocybQu^+VM5CUBoXHYl zNHwX`GOkUME)FfDeu+f%e>u_4;|T~=qvk+4WfdM=6Z4RrG8-cYdUAA zf@!9db*ZuMkoSX=Ka4FAdDTohqgve3tE#aYIP0WFnuCN?@bRqBTp7Rxv+BkmdWb1+Scp)zW(8dlNB!U z^Ms)lrhc0*>X&p%8T&!&e{}M4G#(ps(SC^2;{K%uPrq!Jrp|&ZEruMZQ6+!o(l>hl zyth5Fc|l7@~= znl67*@1oP&Oe*nka+FKIq9d{{8}R$FB6;de_^noE|5k%`pWf^6gVPYF2q(Y$DGZZ0 z|90(W<1%U1oocgtYM1Aa>K^{>c#u!>B4=0K-CwSB>ef>_y-0tq{j-kUPj5Krv~pXi zn`6I@tTtm|)^^EP=D9S-=y5aU<}N?XY!H=x%#KIjzu0tVf55PP3mezBU(!8y*@h$Q zO`E*=k9s4T`5wp?6*BE;75U!Xv|1`A^SX ze&Tvv)Yy(QXFt&mEHLtQom#7ptlcwZd(ErY&Ne=Jw0iSR6I&MfJ=x8sBp{tb{dc>TT@T@XH-FeqFFH{hYFofBzyf;${D-T`un| zd8Ksmegkhj-?6jb#^b;2o0I2sknfCI<9@pM-It@&G#*|Jf&Cx7uK>)=TPSEb!O zculd{es`W6UA#QQ%nkJ?+zABub&sJRbWHmlm|S5`!wr3wP>+j>vN7O`Kru; zDtCJKYt^S@rrwWsrnsD?b&7p6w?E!cGF_vtBQLh<>%AguPrE!{`Yj8(`A4Z)1&7p6 zmF?xoN!`D_nWR}7!?HZVug6AJSz0tp#|c%NoO}KAsy}uNT(aY*yR+{8cy?B@YMqpY z4~h;PV0^Y@=hG#|+Pzwizf$-K9%Y+6& zM-~?-eKA$yYuR%CGHX_W*6S*!%Coa%k*S;eZR#E3H@Mu{6J70>4>{K9=7ys$C-x3I zc{gNH^2Oa!Y@E4bc!9c}-%Q!}TjDeIS5^(ZvCZXV&l*P8&Xo?V?KEab=U&h6R|qQg zO$D!PZlCR&y=>OK>Q08#r<&Z}?00qAy}sj@ZCv=;*yG-mubUP2&oZdg{j^V44-0>| zW8+w_t+@y1J>$FM+T(h+s&8B6-@vbW#E}Y4E9~|pn>V!Qks700lr~IR{Nm--(Et&X2vfHc`2*B|l5NJ}1vNg^>U$`=egLs%F2?HA9iUUQb?hD>>Eo;KE~xoAy4J`?IaSI|nB%+%VbDL7SqU zJssNo>CC(h=8c@)e@gcJ#czk#O#k_}RSzu~S?<(=R+GDKZ(e9r&hVOpYF}>z{6P)4gMA<>>9Yzjo>7M_P{m#XD2ec5O%H-um35X7MF;X9jJU zlc;6mbBR_T`Ml0odkWv0zdG&2sCmDR^PTu4Je6+wy`M_Aec13=9*5EH^S|v7mbr1! zb`Eu(czcyzIOt&Cx=&hX898#nJpX40hx%?R?6^B+j-%HuSJ~j?;?`qcs@)l;?JDJ* zeP;f}9p_XFUc5c@z`QT>-|OZ&B5k=!-!y0z&}@FvBKA`%IzRAe8rW&2lXGagaVK+4 zon7tNodXw+6yCqB^_)aomlYnibV=}qS(^;m++GdwToAB(OSfeWosRu6I{)OZW4_p$ z^5(3?d2zAaz$>$Su>`OvTU;c;A*C-z;@G?`C1=qjzQp2{iH zHptevDrb|VmM@`fbd@&XdgxW_gwOL=+|dT>i!E_C^@DZ7=S$z(`=`WiZsxSgp~tKp z;&ps~q&U_{?B*UTEqC-Ic1F6b;l>AB=#G9zX+3`5lo;Pz`;IN)ybHc(%lAc!V|ikw ztU0ZU*n;**rvZ8w-LV~F$v=)*wu2L+uTfsHx%&)-(=uN8D8I)^X41+>!RX;>7;E6L#`x z?}`)qbHeStv2pK;vlSca1ERl|TOvJ9XWMaH6(@(Q*m74z3BQ0>^fZJw!mIs4wcmaQ zLZlC079kPXP+!H6yw^x%UZ(X^h<8~A@ut_Lc$GM^Pb3W|Aj{=kmsc0_YjX1|w#O7M z`&O1qUzWTgYcr*T?9KE(wfR-XElBz@c0tll3uJv{fu>g(#~|Ax`JjYnG{1gse$8Zl z&1`;^z7<)|-Tayrui_z_`TZB>*X-uk9Ol=Y=GQOHuer>x5?9j8qUgdUKW(KX375Eu z6I=J(D9Sy2Iq|JctraKJxKyUibawdOj%Ah1kIl|x zn2w`_y+jLl7A4$uu82{d+KV&Oto8wlqE~0tW#UnrkcM8JSC`3m&?cmzSLfAbatvq_ z($K5(>M}VlvQ994p#{H1z7cx=i+&HX#kYIm)n#&SXcN-VtMlqI+4tInH1z7cx=hX? zZ9*D)bzWU2=an`g4ZS+AE|YUjn~;WHomZF1`KL`tL$A)O%jBHYCZwU4c~|8`lF~Tn zvM5IFICZexCs>4a-q^R1l@)`jpV@S>MA;WaKz-ozj@RY*oJ-gT&mxV+<;d41xvolt zy>^&&#fq!5ku^57kLsmKATbWil$! za#=QuWinb(rhHc4Q_H!!>->xfBjMK3?W+e8M zDO29AXEX+y((&{2LYf#gI32GNf;5eReirMgpX=r2B}0)TmKIxqJ6+to5|* zlQd*ou!TxMKtP=9$uh-H-981|8rS#rnr>94W1Hold|xHbGN>*dWzal`Z>tQd#XX~Q z#}0{E&Wjl*o4Zyv)Z(15D~tX2t~jY;#R<3fwtsh=t=Lc>pcu5zu~i1uw#_Y0D^fBa zhlA(>l9KII%Xl%j)?I?*D4G9;xVEfFUKSlE+KPPMR>ZMo#dIZ$<8w|Sjx8&=Qd)dY zv=#ZBJBVY;iWbNH9aelyV{j#L)rzQ5t;nlP&a%+v)~azWt=NuZX+@1=X~lM&=wn6p zzvTRuR&2+yw4%nbv|=5HwYW#HYF#U>jbr69!leXm#1LW2+O2j5$~j~-N?BW{xZ|&! zo9epJdrezcj*gh?q81!Sx^h&+T-QF%bmeG>xvqnG-RPr0+XgubVy^2Qk8deoOpB%F zFY)`9>bmj!mg>6k`gZ3!pngkNC3;^~HhNF0{;PR>1o5JnGSL2PV;t($7K%J5_+zqeRlbw>x6i*6#a*mtU$F)AC zIb4TuXS*MOi2zk{T*idJKXelB-T`vok-Y0*@;*L+TBgePC8Q_WoNqg$=)>YVE!wu9&)It$J2bwsry2Xr7|Ii4qKL~qHtc7X$j0j;ZOg&dv3zHIU%+@5yT|Zr2)4CSsZdnUMMa5P5z*-pM*sO&~ zrN>6CVqt#P!e9oTt!iO$`0^?6eqWMa!fRnJX;};V7uCY#cw#M#ePJl6p3%9Zer>y^ zqt=e1uSi;{MXpHijuX43Tv|cj9f!3rbp)MOqq44>vM(&UpDy}X(fZHW7iQUxA;*f; zLoKbaFD$N}IVmHt76u7u$6+n(?^X-*w5)}p)b_!(FvPK03zJ4F8?~_PUhl6}NV9#y zY8C&$zOXoa-H3-T=t`h3_Jz6NsX+g#zA!nS0QwASbA~E(IR2-3ra=NL< zHoAR5tLfM}gS7r8>vjw+j;(f#m~mo%j;(f#m~kxYrmTgzOVP)`5s6yOiX#%mgd7u^ z5jk_!+V|gS+@*D2SXs+jnA=Cx!Vt%1EezqdYGDftvKA(XNvyuGIDGjOdcQA8FJZM- zsfCqyWMA07s4q;8r%zf7GhiSi?+Xjyh(w#C3$1&7v_3amE%|vQL&J5HBN8n~nQ8rJ z9FfQoiR$^mT9~^jr(i8it8=gx_Rpz>EyA%Y_l3>IYi)BaO!}X6$mpdn%+s_yp7(_z zj#dk^ye~!4`n07$4?K;6rN97|0$B>gsVAk_W(uDU5W97oPX~y*%|G16m_oM2tF5b+ z^+s)Mle_6mV=0iOKp6>V>sbY7{r`?^bL%6NB`0Rz>-?ofo64!9)3yZwJmLcgSd<2V2+U%};rq=tOeYCQSM-X6H3qvFP2iL+7$7U@I;kIgF*-O5^R`H*#g~j3P zMm&5;F(Bc0bMroef7P$T$??Qm7=N1^1tQkMEXzbvqvEf^#U7KxUxkZ%q>`;ODCX#< z=ynYLDqM6elzm~i3L9+o_^}q28fv0Pw<(IeR}BB15mA;7KS)BYheht zRSR2KinTB~++sabD-K^irQh#M())kZ!q^w~FX{`E3;h1rE?c2Jb7mbI{=A5jZK9GkT;gxjixWiRvoTE%~|FDwpUH{#(-vd)Cp!d%j_ z7WOZyg~{>6S{Q3#D0xfS$$A`}+7_#oJ8hSt-W?};?44yPh#3=vDh@H%`7N)ZD17G-+RBeCAt4yqgDT@7D zD>@hwD>3`EqK|B?U(41R6f;ir8KlLLT8L&pzMmuI^moUJK7+K+v30Cy+g97^*E(#~ zmM)lVB*9;;X2n{VyH4xHQ}fCA)iu?1C1Rd!RtlepuZ#F9y`*PC9I z!0Cz?YB+&`MrG~VwK$qz+4Jcfd^$($GNfACz!5Z1dZePukgSD8-%A#4g|#r-R@B<1 zWqrexCm~xx7H6uPmVHOvR2?BFD@whHwJ;x%@OGRWYhluQVQIv&$4||_SPLs_e$tAf zq%*lv>zcNHJv6$W5$W@G0@`JYRQioC%V3&#jz||Ydr>99JhCttjFP-NeMEplC?XQMVINL zw^nL8yf2LRg~k0e>X^q04l|o)kk;R%nv6b!v_2|ZXHd*Iu|LPw85A>4^ckdmj;%9D zi^IM!EuWFhAZGto{CfPkRbGc{)Y@k8pG)=%efv#f3B2Vqt|(RSO&Y&#HyJ^%aF&{|Cq$3WkBcYXW`LRCS1=3A26Mn%Fb~WJ3&29~8(0JugWtgtuoNr<%fSk;608EN z!5Xj@tOM)8A7BI62sVMuU<;7z$u_VZ>;OAK0N4d~gFRp`*a!B51K=Py1P+5EAP^h{ z$G~xL0-OY=z-e#>oCW8=c_5`W1N>gXYY+$qA>cB&07vLp$1zv+UAOb{!C}056+msYlbf<7X%ne3sK_W%3 z_*9HOU);N>M}>M^s7Ho+T>L+3;bYV0aJ6o=z~(4+Kw2$k$QyI3g=toq>slT&@oBZ_ zqVzPiTC`I-zoX^CMX8IWte}=%~0T_0S&D4s9OqTdy)w>xuQG z{p8&#Rz`aNqFQ)G>@4R`*%!)Rd1YTH`$AK>YdtGcOFD&XO*qPHQUj3dgVxTF8uQUD z1ac+xF^6zODqIbW*>5g(GQ*E$eN&9ege=Zvl6|3;$5%?T71qL83;$<5!(VSThT7HQ zj#J48Bic`4ab^w$#iKge*13E+6K>VQ8y-s9nu0OF~+iQj5dBE6b9QR?>EVM~@!w zUPPBccpsN~q(qmrbc&i6vG0m~S6aJeP3UQ^utI`OXv5lE2B-n#y-p`J5u=!=uCBVJJY+HJYhm_sJUM^Lz6;jESPNqk-j)&A(Ht{(q12GhDHVA!%1vXKk&r1`fnOKqISte(*m9p;`SH}@#96_cYF-h>EjSAz_5B`pK zB>Z>!E*9ZfbXDxaXXDk|TxvFzn;NO5W>3>{hwxmcV>W5H8(ys^JM;Y&3gT#`X3J8b z4&k4+6vz=QY&&J!X>{3`ci%?WGAymA_c~bC7_3WlvBzrg?pv+C7G00xGqAKaS+<>O z?V%TN(bro4+@;8X=S}nOTg}Md>+W025iDITN3b;gh!HG^qm=@4p(v@{O{zntjXAahY+9Ssp7=qO)x8w(hsl;+(J;t6@7%^f9T$VJ*!1m=x2NRe{kF{q;kI&{h1EYrZew~_Glqj%KgSu{WZqhraMQ>Q z^K+clXFEHDTYdw(IKF{TjrV^8`J9Bc!VGo})@^sIpU&1d!0y5RvHcA^EZW`dQjfcZ%Gw6NyjgH`ZxsdO=) zKz`#BSsR&bdP>{YME zVn`=PTg?o%jy4PoHF?wHHEwSoa(&qLFFD9r6g{%o zB^8pvgv>)P7gjkhkptPg$qUw%$xcaOdUaJUx~f;2OXlzH0ISXpkvQb#8vG2eb1h5! zZ6(}3e41%_JV)Okj&=?H+9D^@AzX4Yd*o!^nAIzL<8f3Wfe$(t@oNoA8Eh^7D{iKz ztxAu}*t8ezO~00aQ0sF}d_@0#V}87wXL@#y>QQIPwRC8Ml2#C%*=|0Fb}&CrO;S|z zc!?oI=kZ_To$P|7G4go4RmwWSfD7_`S1iV*pezQoN-4)O53`G9EcB6Si??j zjdL+OX0>0Jm*1FA_^-?Vn5r=ZyDC4Wsw1M6Ev{mth3Q{yr8a&6Bfla!!01#3Ov~?i zIO5&wW_xv>8NUg|Y;N-@sbo_pV9Dfd{$Q%fY%2H{mENuZ39<8dC-yIhauo62%^ zZ<+#;V9Cd7j-oStGg)5CMjW~JYm==w$bE9la#!V{y)zC6Er*lO)F#a9b@%`|T0S&~ z^MEqR;d0`2wPg<1QNr!R4?{$}YZ-`RGlxTX%p7jmCplj~EQc$Ajr=QfIBy(Q+PP{M z%h-*07?ZvKA?35!*96NP?u1+_tV;b85YiRDQ6s;M(*tL;i#flu4D&FBM~4)!u~q@a zLy_?4wY+Ss<%PAPKSRw~En~=1LH1j}x5vM+XEGeI9m5kTdF2Zj&9pYHL*0dN6X=L2#WKtwY)Re)g58crG78P9l5AG zO8MRKYwf*F`FO9jWLrK_8al>AhaUZ*| zRHy9L4IVsLIcK_2^}Ok?QRk>QTGgfwIvO2!xVChb+EhPD)auG9rQLdXMyWsSYX0Q7 zz$aMM3Di(Ef0gpBr9W8~GcS}jG}(7{Z);Mq@;GtCJnkP{?svxyjw_F|$1y5L#E0f_ z<;{6qalD?e%;Ru)O1b|s(v5eyA91uit~lbo&Ew=ykI}N>Q1?^kaZ62ZV&-vDGK$XQ z3My(I*BRy79;P!?J3plfBKG;IhN|bMb^a#HVxFHLp1=7Z4b7mP+28`y*`?ePBL{ZTR@G7SQJAe4+tfuUh64 zebjv7HqwoEK7lw|K2Z_zw4+>y@R<38&nM3()?*W_ui4Vq5S>pHQq+8+6-q*_l@3Z9 zl!@fmTIJVYwHzb0Bogb2u7=vqF=W~QHODZ!{Ihb5n%G`#zuC)YA;+*Dv8+e%P~BiW zfkSI!Y~|jc3Nq3@Q&S2wc8;drOqsRUtNFQ(I=FRUmf0p@!PdH)L5t^9^Z(*ZkS(kzkCv z->`W2L!AF(55`?d&MH5a8e3Z!?18=qf2BCu%JZRJOa6Hv=k0=jZIq74fjy8D_rNmE zXj(~1QAapweclr0fOQwNPNj{|@r3o^QOH*_z zv_oq36Ed3;j1E27Xr_ zROm1CBjx4tx@4x-C>OJz*5~(zYze*h)J^0@AiD1K3JCWdhQc`kQwBRLd7J z_hqUCq-}Zav6Xh}1f*@5Yuifuvjn7Vncvw;J52)8w#+MRrJXhbY0JKp{5iTT6mwsu zOF-IMn$hKmm}#d^K-yZG(O2M@X=g}4+FF{?*RGgpXG}oaTAI=Me$2E#Pe9sQyNY&x zTIaEu5}39ed)o10op$B~q^;%O+PQC?c9sODE!(Xfi`HqoCm?O@+Mwkt)@f%=VA`_X z+Hr23cD4khtzCDtJj*)mFA|uxY`1m}S*M*n0cmU3G%Y{0PCG{e)0XYl&QI&Kb0#2d zIoIVHEU)qz)@gs4z_cYz%lyQ$zeHNV*MjO#J;n)5RNKm zhiOTcPG{G){1hs%>u<>-_8y4D(x*%*`78m}M@8c0m*H^S?DXSEifL%8&V zR6s32I-M*>Z>p^=G4IG#?0!^IrVK%=d05&mKOe2d)`*ALj|=Jfw|`&_om`P1)nxyL8I`@F2> zKF5jl!TTI>Z0>V8DL?2u|G<52PXBMW&t~gxRE1cp+8tZEil6#x~*THsby^y9^Wv=Trgw(^xnsyjf zBO@8*Z%d%vRpEjAd!v{4#xiXUb?lNa)@_9m)xKEL0i$nRtk(B6EtB5*-Wa3fj{bVN z2RUXee^Wg96m4yHtSSFmnqzD4{gFl|Q)~&5G2D_?Yv7N6vUXo{On;0MlChey!~@@P zo@+4vx&a)IeR0xbQv1k$RCMO?Jh8)druAZnXN)6U#vR#8U;SJe!DXkUmQ~Q#jiNH9 zm|^PY#SBwFD`uGbIWfcJ`4g3qqI_0#-?@BB^gBsE`kka4{Z7)1ekb?qWX&-S#K^&X83&KAvcWH^- z<*Rq*E|OWl&0Wmt|Lv|QvR!|{KG#OJ$EW1j)pDQz`Vspaacu5$g#W4g+?@X3ZlB9` z{RR76MjyxbJl9+9^SK|f&k@JwK1cYUy3ft&|LyjmuuITtF@Cb5q*6A_RbV_M7^pB{hQ`%P5DFbjzl6QTb^5ge9C9s)J`Mi}*Y19Vq^;q_U zPU$)fqZvlvnF2rQlnrBa%F=NvTTxeS$CF&kP z+Pm}sJ5Lz{xnIrv6Hf`xnvnWP>%$3 zrpJXzfNXKhN!I5?12jMbG(ZD1Km#;D12jMbG(ZD1Km#;D12jMbG(ZD1Km#;D12jMb zG(ZD1Km#;D12jMbG(ZD1Km#;D12jMbG(ZD1Km#;D12jMbG(ZD1Km#;D12jMbG(ZD1 zKm#;D12jMbG(ZD1Km#;D12jMbf0ltLB>X;-;kju}01wVnTtIT*3fw>nkP@T*7w6Ig`E|#P;B$}(WCmG)JID&~AU@>_kR9XzIl-4ee#7A_@HNN{@_@X+1K`1Z ziYLeq3IH!q5EKH1K@m_C6a(_x)+InmAiwHU8k7NLK{-$!Q~-Dgpi&7`2H$`x01pLJ zz6I4lbx;G;1hoJj7N~p&>VUeS9>Bu`l?I?8Xaw*eL8UQh0-6FmR8VOSS^#g*65t_& zN^9T)e1RXpLkE?%pdDxrI)IL#6X*=OfUck$=nnir56~0z0=+>W&=>Rr{lNe*5DWr? zfgTJ2c(5UUNmNnra6@G{zyl7Ik>E!l58e9-j0R)CSTGL!490_BzyvT6Oaha^6fhM` z1Jl6_FcbU=W`WsY4wwt(f%#woSO|Uti@;*=J6Hmif@NSiSOHdoRbVw(1J;6dU_JN) zYyca01$KiyU@zDQ_Jaf9AUFgLgCigi90kX~ac}~h1gF4h za0Z+O=fHVz0bB%^KoAH9A>cB&0X|058ES@EW`U5g-yo0Ru1sc_yO#3b`Gy2M!<+NDPtyN01aG15UshxPauq z6}W*EASFlzQiIPx8ju#G1L;8qkP&3vdTnK{oIO$PRLVoZw543w#B>2Dw2V zkQaD>e83ar2L*r^CYxUw32K4b;5#7C)2|Eaf%>2UXb2jC??GeG1T+QBKy%Onc!QQee%-w_@BzNS z53~VoK|9bMbO0SeC(s#m0bM~i&>i@L9-t@a1$u)%pfBhL`hx*rAQ%J&13ef5egH$k zFfbg903*SVU=;WXj0R)CSTGL!490_BzyvT6Oaha^6fhM`1Jl6_FcbU=W`WsY4wwt( zf%#woSO|Uti@;*=J6Hmif@NSiSOHdoRbVw(1J;6dU_JN)Yyca01$KiyU@zDQ_Jaf9AUFgLgCigi90kX~ac}~h1gF4ha0Z+O=fHVz0bB%^KoAH9 zA>cB&0X|058ES@EW`U z5g-yo0Ru1sdBCa;*a3Us01|=3APH~;NkKB;1e}2jNDf?q8%P0Cf>aE!#3j72{gE3$%7zcg^ z0kz!34R5$z-%xF%mwqne6Rp41iyhrU@`a|ECEZwGO!%1 z04u>Nuo|oZYr#6O9{d3|fQ?`i*bKIStzaA24t9W@AOP$FyTKl?7wiN3!2xg(90G^I z5fBKDf@9z~H~~(AQ{Xf>1I~hT;5@hhE`m!S2n2%=a2Z?yS3xMa2Cjn};3l{Q!oY2C z2iyhszqV5DB7y0T_XThd0X)8_VzQ*aHWU z2qXqcfFno>k^v{+3|v5R;0oM83Xl?{0;$1gAPq6a~dVaZm!31f@V}PzIC* zR5rR)CdY6<7_{fVE&9SP%XH z8^A`e32X*iz*evgYzI5QP7nZgf!$ya*bDZ7{onvN2o8b6;0Oo=N5L_09Gn0r!6|SW zoB?OSIdC3a02jd}5Cnoj2)GQcfU6)BTm#p^4R90O0%71bxC8Ejd*D8J03L!zARIgf zPry^~3_J%fz)SE7yasPT1c(GtzyORu!Ot7$fE}<04j>Uo43YpxkQ5{XPQV$sfaJgx zxPcTPB}fHQgU>)3kQSr^=|KjN5qu6Zfy^Kaa0gjIHt+?=4sw8;;7gDTd`mih<&w1SkngfzqH1C=1Gg@}L5!2r7Zf;2Tf{R0ZFH zYM?r(0cwI;pf>mp)B$xtJy0Js01ZJS@I7b@nt-OD8E6h#0B_I|v;qdS!wA0S`9G_@ zhw0J)4bT7$&;Sk401eOp4bT7$&;Sk401eOp4bT7$&;Sk401eOp4bT7$&;Sk401eOp z4bT7$&;Sk401eOp4bT7$&;Sk401eOp4bT7$&;Sk401eOp4bT7$&;Sk401eOp4bT7$ z&;Sk401eOp4bT7$&;Sk401eOp4bT7$&;Sk401eOp4bT7$&;Sk401eOp4bT7$&;Sk4 z01eOp4bT7$&;Sk401eOp4bT7$&;Sk401eOp4bT7$&;Sk401eOp4bT7$&;Sk401eOp z4bT7$&;Sk401eOp4bT7$&;Sk401eOp4bT7$&;Sk401eOp4bT7$&;Sk401eOp4bT7$ z&;Sk401eOp4bT7$&;Sk401eOp4bT7$&;Sk401eOp4bT7$&;Sk401eOp4bT7$&;Sk4 z01eOp4bT7$&;Sk401eOp4bT7$&;Sk401eOp4bT7$&;Sk401eOp4bT7$&;Sk401eOp z4bT7$&;Sk401eOp4bT7$&;Sk401eOp4bT7$&;Sk401eOp4bT7$&;Sk401eOp4bT7$ z&;Sk401eOp4bT7$&;Sk401eOp4bT7$&;Sk401eOp4bT7$&;Sk401eOp4bT7$&;Sk4 z01eOp4bT7$&;Sk401eOp4bT7$&;Sk401eOp4bT7$&;Sk401eOp4bT7$&;Sk401eOp z4bT7$&;Sk401eOp4bT7$&;Sk401eOp4bT7$&;Sk401eOp4bT7$&;Sk401eOp4bT7$ z&;Sk401eOp4bT7$&;Sk401eOp4bT7$&;Sk401eOp4bT7$&;Sk401eOp4bT7$&;Sk4 z01eOp4bT7$&;Sk401eOp4bT7$&;Sk401eOp4bT7$&;Sk401eOp4bT7$&;Sk401eOp z4bT7$&;Sk401eOp4bT7$&;Sk401eOp4bT7$&;Sk401eOp4bT7$&;Sk401eOp4bT7$ z&;Sk401eOp4bT7$&;Sk401eOp4bT7$&;Sk401eOp4bT7$&;Sk401eOp4bT7$&;Sk4 z01eOp4bT7$&;Sk401eOp4bT7$&;Sk401eOp4bT7$&;Sk401eOp4bT7$&;Sk401eOp z4bT7$&;Sk401eOp4bT7$&;Sk401eOp4bT7$&;Sk401eOp4bT7$&;Sk401eOp4bT7$ z&;Sk401eOp4bT7$&;Sk401eOp4bT7$&;Sk401eOp4bT7$&;Sk401eOp4bT7$&;Sk4 z01eOp4bT7$&;Sk401eOp4bT7$&;Sk401eOp4bT7$&;Sk401eOp4bT7$&;Sk401eOp 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 - + @@ -253,7 +253,10 @@ + + + @@ -325,6 +328,27 @@ + + + + + + + + + + + + + + + + + + + + + @@ -413,6 +437,27 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/app/opendocument/droid/background/CoreLoader.kt b/app/src/main/java/app/opendocument/droid/background/CoreLoader.kt index 887f2c1bb97a..49f614d83401 100644 --- a/app/src/main/java/app/opendocument/droid/background/CoreLoader.kt +++ b/app/src/main/java/app/opendocument/droid/background/CoreLoader.kt @@ -43,8 +43,7 @@ import java.io.IOException */ // the context is nullable like FileLoader's own field, which close() clears and the unit // tests never set - isSupported() is pure and does not need one -class CoreLoader(context: Context?, private val doOoxml: Boolean) : - FileLoader(context, LoaderType.CORE) { +class CoreLoader(context: Context?) : FileLoader(context, LoaderType.CORE) { private var server: HttpServer? = null private var httpThread: Thread? = null @@ -131,22 +130,25 @@ class CoreLoader(context: Context?, private val doOoxml: Boolean) : else FALLBACK_SERVER_PORT } + /** + * What the core renders itself: opendocument, ooxml, the three legacy binary microsoft formats + * and pdf. odrcore keeps reference html output for every one of them, doc, ppt and xls + * included, so there is nothing left here to hold back. + * + * Text, csv and images are left out although the core takes those too - [RawLoader] is what + * gives them their player or their viewer, and this answer is what lets it have its turn. The + * rest of it decides whether a failed load is worth reporting and which viewer [OnlineLoader] + * falls back to. + * + * Matching by prefix carries the variants along: the ooxml prefix covers the templates, and the + * macro enabled ones are spelled `vnd.ms-word.document.macroEnabled.12` and so on, which is why + * the legacy powerpoint and excel types appear here without their subtypes and why + * `vnd.ms-word` is listed next to `msword`. + */ override fun isSupported(options: Options): Boolean { val fileType = options.fileType ?: return false - return fileType.startsWith("application/vnd.oasis.opendocument") || - fileType.startsWith("application/x-vnd.oasis.opendocument") || - fileType.startsWith("application/vnd.oasis.opendocument.text-master") || - fileType.startsWith("application/msword") || - (doOoxml && - (fileType.startsWith( - "application/vnd.openxmlformats-officedocument.wordprocessingml.document" - ) || - fileType.startsWith( - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" - ) - // TODO: enable pptx too - // fileType.startsWith("application/vnd.openxmlformats-officedocument.presentationml.presentation") - )) + + return MIME_PREFIXES.any { fileType.startsWith(it) } } override fun loadSync(options: Options) { @@ -232,8 +234,10 @@ class CoreLoader(context: Context?, private val doOoxml: Boolean) : if (keepDocument) { closeDocument() - // .doc-files are not real documents in core - if (file.isDocumentFile && file.fileType() != FileType.LEGACY_WORD_DOCUMENT) { + // only what [edit] can do something with: the core parses the legacy binary formats + // but declares them neither editable nor savable, so holding one open would buy a + // second parse and nothing else + if (file.isDocumentFile && !isLegacy(file.fileType())) { // TODO this will cause a second load document = file.asDocumentFile().document() } @@ -326,12 +330,34 @@ class CoreLoader(context: Context?, private val doOoxml: Boolean) : document = null } + private fun isLegacy(fileType: FileType): Boolean = + fileType == FileType.LEGACY_WORD_DOCUMENT || + fileType == FileType.LEGACY_POWERPOINT_PRESENTATION || + fileType == FileType.LEGACY_EXCEL_WORKSHEETS + /** A translated view of a document, ready to be opened in the WebView. */ data class HostedView(val name: String, val url: String) companion object { private const val TAG = "CoreLoader" + /** See [isSupported]. Mirrored by the STRICT_CATCH filter in AndroidManifest.xml. */ + private val MIME_PREFIXES = + listOf( + // odf, including the master documents and the x- spelling some providers use + "application/vnd.oasis.opendocument", + "application/x-vnd.oasis.opendocument", + // ooxml: word, excel and powerpoint, their templates and their macro variants + "application/vnd.openxmlformats-officedocument", + "application/vnd.ms-word", + // legacy binary microsoft: doc, xls and ppt + "application/msword", + "application/vnd.ms-excel", + "application/vnd.ms-powerpoint", + // not a document, but the core renders it just the same + "application/pdf", + ) + /** The loopback address the server binds to. */ private const val SERVER_BIND_ADDRESS = "127.0.0.1" diff --git a/app/src/main/java/app/opendocument/droid/background/LoaderService.kt b/app/src/main/java/app/opendocument/droid/background/LoaderService.kt index f04a8ef8de31..cb2088a971a7 100644 --- a/app/src/main/java/app/opendocument/droid/background/LoaderService.kt +++ b/app/src/main/java/app/opendocument/droid/background/LoaderService.kt @@ -60,7 +60,7 @@ class LoaderService : Service(), FileLoader.FileLoaderListener { crashManager, ) - coreLoader = CoreLoader(this, true) + coreLoader = CoreLoader(this) coreLoader.initialize(this, mainHandler, backgroundHandler, analyticsManager, crashManager) rawLoader = RawLoader(this, coreLoader) diff --git a/app/src/main/java/app/opendocument/droid/background/OnlineLoader.kt b/app/src/main/java/app/opendocument/droid/background/OnlineLoader.kt index 7ff1c6ac46b1..b1cbdfe72aee 100644 --- a/app/src/main/java/app/opendocument/droid/background/OnlineLoader.kt +++ b/app/src/main/java/app/opendocument/droid/background/OnlineLoader.kt @@ -142,8 +142,12 @@ class OnlineLoader(context: Context?, private val coreLoader: CoreLoader) : } private fun buildViewerUri(options: Options, downloadUrl: String): Uri { - if (coreLoader.isSupported(options)) { - // ODF does not seem to be supported by google docs viewer + // ODF does not seem to be supported by google docs viewer, but pdf is the one thing the + // microsoft viewer will not take - and the core opens both, so what it supports no longer + // tells the two apart on its own + val isPdf = options.fileType?.startsWith("application/pdf") == true + + if (coreLoader.isSupported(options) && !isPdf) { return Uri.parse(MICROSOFT_VIEWER_URL + downloadUrl) } diff --git a/app/src/test/java/app/opendocument/droid/background/CoreLoaderTest.kt b/app/src/test/java/app/opendocument/droid/background/CoreLoaderTest.kt new file mode 100644 index 000000000000..2b1ef296cdae --- /dev/null +++ b/app/src/test/java/app/opendocument/droid/background/CoreLoaderTest.kt @@ -0,0 +1,92 @@ +package app.opendocument.droid.background + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * What the core is expected to render. odrcore v6 keeps reference html output for every format + * asserted here, so these are the types a load is expected to succeed for. + */ +class CoreLoaderTest { + + private lateinit var coreLoader: CoreLoader + + @Before + fun setUp() { + // no context: isSupported() is pure, and constructing a loader has no side effects + coreLoader = CoreLoader(null) + } + + private fun isSupported(fileType: String?): Boolean { + val options = FileLoader.Options() + options.fileType = fileType + + return coreLoader.isSupported(options) + } + + @Test + fun opendocumentIsSupported() { + assertTrue(isSupported("application/vnd.oasis.opendocument.text")) + assertTrue(isSupported("application/vnd.oasis.opendocument.spreadsheet")) + assertTrue(isSupported("application/vnd.oasis.opendocument.presentation")) + assertTrue(isSupported("application/vnd.oasis.opendocument.graphics")) + assertTrue(isSupported("application/vnd.oasis.opendocument.text-master")) + // the spelling some providers use + assertTrue(isSupported("application/x-vnd.oasis.opendocument.text")) + } + + @Test + fun ooxmlIsSupported() { + assertTrue( + isSupported("application/vnd.openxmlformats-officedocument.wordprocessingml.document") + ) + assertTrue(isSupported("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")) + assertTrue( + isSupported("application/vnd.openxmlformats-officedocument.presentationml.presentation") + ) + assertTrue( + isSupported("application/vnd.openxmlformats-officedocument.wordprocessingml.template") + ) + } + + @Test + fun theMacroEnabledOoxmlVariantsAreSupported() { + // spelled outside the openxmlformats family, which is why the legacy types below are + // matched by their prefix and vnd.ms-word is listed at all + assertTrue(isSupported("application/vnd.ms-word.document.macroEnabled.12")) + assertTrue(isSupported("application/vnd.ms-excel.sheet.macroEnabled.12")) + assertTrue(isSupported("application/vnd.ms-powerpoint.presentation.macroEnabled.12")) + } + + @Test + fun theLegacyBinaryFormatsAreSupported() { + assertTrue(isSupported("application/msword")) + assertTrue(isSupported("application/vnd.ms-excel")) + assertTrue(isSupported("application/vnd.ms-powerpoint")) + } + + @Test + fun pdfIsSupported() { + assertTrue(isSupported("application/pdf")) + } + + @Test + fun whatRawLoaderShowsIsNotClaimed() { + // the core does render these, but RawLoader is what gives them their player or viewer + assertFalse(isSupported("text/plain")) + assertFalse(isSupported("text/csv")) + assertFalse(isSupported("image/png")) + assertFalse(isSupported("application/zip")) + } + + @Test + fun whatTheCoreCannotOpenIsNotClaimed() { + assertFalse(isSupported("application/vnd.wordperfect")) + assertFalse(isSupported("text/rtf")) + assertFalse(isSupported("application/vnd.apple.pages")) + assertFalse(isSupported("application/octet-stream")) + assertFalse(isSupported(null)) + } +} diff --git a/app/src/test/java/app/opendocument/droid/background/OnlineLoaderTest.kt b/app/src/test/java/app/opendocument/droid/background/OnlineLoaderTest.kt index 20ce05bb644d..230764ee872f 100644 --- a/app/src/test/java/app/opendocument/droid/background/OnlineLoaderTest.kt +++ b/app/src/test/java/app/opendocument/droid/background/OnlineLoaderTest.kt @@ -10,7 +10,7 @@ class OnlineLoaderTest { @Before fun setUp() { - onlineLoader = OnlineLoader(null, CoreLoader(null, true)) + onlineLoader = OnlineLoader(null, CoreLoader(null)) } private fun options(fileType: String): FileLoader.Options { @@ -84,6 +84,6 @@ class OnlineLoaderTest { Assert.assertFalse(isConvertible("text/plain")) Assert.assertFalse(isConvertible("image/png")) Assert.assertFalse(isConvertible("application/zip")) - Assert.assertFalse(isConvertible("application/vnd.ms-excel.sheet.macroEnabled.12")) + Assert.assertFalse(isConvertible("application/vnd.apple.pages")) } } diff --git a/app/src/test/java/app/opendocument/droid/background/RawLoaderTest.kt b/app/src/test/java/app/opendocument/droid/background/RawLoaderTest.kt index c682c7a8ad86..e49e0244174b 100644 --- a/app/src/test/java/app/opendocument/droid/background/RawLoaderTest.kt +++ b/app/src/test/java/app/opendocument/droid/background/RawLoaderTest.kt @@ -10,7 +10,7 @@ class RawLoaderTest { @Before fun setUp() { - rawLoader = RawLoader(null, CoreLoader(null, true)) + rawLoader = RawLoader(null, CoreLoader(null)) } private fun isSupported(fileType: String): Boolean { From d053b3f4f63972dc6a773ffbc2b43c690b22f57e Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Wed, 29 Jul 2026 08:58:10 +0200 Subject: [PATCH 2/4] Ask the core what can be edited instead of guessing by mime type doc, ppt and xls now reach CoreLoader, and prepareMenu offered Edit for every CORE result except ODS and PDF - so the legacy formats got a button that could only ever end in a failed save, because host() would not keep a document open for them. The list of exceptions was never ours to keep. odrcore answers this itself: Document.isEditable()/isSavable() is false for the three legacy binary formats, for ooxml spreadsheets and presentations, and for every spreadsheet including odf - the last being the core's own TODO, the same gap as #442 that the UI was spelling out by mime prefix. host() now keeps the document only when the core says yes, which makes "we have a document" the answer, and it travels to the fragment on Result. While in there, two more copies of the same idea: - CoreLoader.MIME_PREFIXES moves out into SupportedDocumentTypes, which adds what RawLoader shows and an extension fallback for the application/octet-stream a provider volunteers when it knows nothing better. What stays separate is the manifest, which XML keeps out of reach - so SupportedFormatsTest walks odrcore's FileType values, asks Odr.mimetypeByFileType for each, and asserts the table and the package manager agree. Adding a format to one side and forgetting the other is now a red test, and the ooxml templates, slideshows and macro enabled variants that walk cannot reach get a case of their own; without them spelled out in the manifest a .dotx was not offered to us at all. The core cannot replace the table itself: its lookups are native, and know one canonical mime per format - no templates, no macroEnabled, no x-vnd.oasis, which is exactly what providers hand out. - MainActivity kept the catch-all switch's component names, preference and toggling inline. That is CatchAllSetting now, so a test can ask for STRICT_CATCH without going through the UI - which SupportedFormatsTest has to, or a previous run that flipped the switch leaves CATCH_ALL answering for everything. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012eTZB8rnh9PrdNn1YZexue --- CLAUDE.md | 50 ++++- .../app/opendocument/droid/test/CoreTest.kt | 36 ++++ .../droid/test/SupportedFormatsTest.kt | 201 ++++++++++++++++++ app/src/main/AndroidManifest.xml | 18 ++ .../droid/background/CatchAllSetting.kt | 80 +++++++ .../droid/background/CoreLoader.kt | 70 +++--- .../droid/background/FileLoader.kt | 9 + .../background/SupportedDocumentTypes.kt | 114 ++++++++++ .../droid/ui/activity/DocumentFragment.kt | 34 +-- .../droid/ui/activity/MainActivity.kt | 49 +---- .../background/SupportedDocumentTypesTest.kt | 87 ++++++++ 11 files changed, 628 insertions(+), 120 deletions(-) create mode 100644 app/src/androidTest/java/app/opendocument/droid/test/SupportedFormatsTest.kt create mode 100644 app/src/main/java/app/opendocument/droid/background/CatchAllSetting.kt create mode 100644 app/src/main/java/app/opendocument/droid/background/SupportedDocumentTypes.kt create mode 100644 app/src/test/java/app/opendocument/droid/background/SupportedDocumentTypesTest.kt diff --git a/CLAUDE.md b/CLAUDE.md index 608f5f70d329..0a04d4a319f7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -145,21 +145,51 @@ the `.pro` suffix) differ on purpose - do not "fix" the mismatch: `AndroidFileCache`, the SharedPreferences name in `MainActivity`) follows `applicationId` and must stay that way, or existing users lose their saved prefs. -### Supported file types are declared twice, and both copies have to move together - -`CoreLoader.MIME_PREFIXES` is what the app offers itself for; the `STRICT_CATCH` -`activity-alias` in `AndroidManifest.xml` is what the system offers it for. **Keep the two -in step** - a format added to one and forgotten in the other is either a loader that never -gets the file, or an app that is missing from the share sheet. +### Supported file types are declared twice, and a test keeps them in step + +`SupportedDocumentTypes` is the single kotlin table: mime prefixes for what the core +renders, mime prefixes for what `RawLoader` shows, plus an extension fallback for the +`application/octet-stream` a provider volunteers when it knows nothing better. +`CoreLoader.isSupported()` defers to it - it used to keep a second copy of the same +prefixes. + +The `STRICT_CATCH` `activity-alias` in `AndroidManifest.xml` is the second declaration and +cannot be collapsed into the first, because XML cannot read a kotlin list. It is covered +instead: `SupportedFormatsTest` (instrumented) walks odrcore's own `FileType` values, asks +`Odr.mimetypeByFileType` for each, and asserts that `SupportedDocumentTypes` and the package +manager give the same answer - so a format added on one side and forgotten on the other +fails CI rather than shipping. + +That walk has a blind spot, and a second test covers it. The table matches by *prefix*, so +it also claims the ooxml templates, slideshows and macro-enabled variants - spellings +odrcore never produces, because `mimetypeByFileType` names one canonical mime per format. An +intent-filter, on the other hand, matches a mime type exactly, so every one of those has to +be spelled out in the manifest. `theOoxmlVariantsReachUsToo` lists them and asserts both +sides say yes. The set follows odrcore: whatever it keeps reference html output for in `test/data/reference-output` is what the app claims - odf, ooxml, the legacy binary -doc/ppt/xls and pdf. Text, csv and images are the core's too, but belong to `RawLoader`, +doc/ppt/xls and pdf. Text, csv and images are the core's too but belong to `RawLoader`, which only gets its turn when `CoreLoader.isSupported()` says no. -The kotlin side matches by *prefix*, so it also claims the ooxml templates, slideshows and -macro-enabled variants. An intent-filter matches a mime type exactly, so every one of those -spellings has to be named in the manifest on its own. +Why the app has a table at all when odrcore has `Odr.fileTypeByMimetype` / +`Odr.fileTypeByFileExtension`: both are native calls into `libodr_jni`, and both know exactly +one canonical mime type per format - no templates, no macro-enabled variants, no +`application/x-vnd.oasis...`, which are the spellings content providers actually hand out. +The guessing stage needs to be broader than the core's own lookup. What the core *does* +decide is everything after the file is in the cache: `MetadataLoader` runs libmagic over the +copy, and `CoreLoader.isDocumentEditable` asks the opened document. + +### Editability comes from the core, never from a mime type + +`Document.isEditable()`/`isSavable()` is what decides whether `DocumentFragment` puts the +Edit item up, carried across on `FileLoader.Result.isEditable`. `CoreLoader.host()` only +holds a document open when the core says yes, so "we have a document" *is* the answer. + +Do not reintroduce a list of editable formats in the UI. The core already says no to the +legacy binary doc/ppt/xls, to ooxml spreadsheets and presentations, and to every spreadsheet +including odf (its own TODO, the same gap as issue #442) - all of which the app used to spell +out by mime prefix and got wrong the moment a new format started going through `CoreLoader`. ### Language diff --git a/app/src/androidTest/java/app/opendocument/droid/test/CoreTest.kt b/app/src/androidTest/java/app/opendocument/droid/test/CoreTest.kt index ae4b3b021ec0..f829aa4553a6 100644 --- a/app/src/androidTest/java/app/opendocument/droid/test/CoreTest.kt +++ b/app/src/androidTest/java/app/opendocument/droid/test/CoreTest.kt @@ -181,6 +181,42 @@ class CoreTest { Assert.assertFalse("hosting the XLS file should produce a view", views.isEmpty()) } + /** + * Which of the formats the core renders it can also write back again - the answer + * `DocumentFragment` puts the Edit button up by. Offering it for a document the core will not + * save only gets as far as a failed save, which is what happened to the legacy binary formats + * when they started going through the core. + */ + @Test + fun testEditableFormats() { + assertEditable("odt-editable", requireFile(testFile), true) + assertEditable("docx-editable", requireFile(docxTestFile), true) + + // the core declares these read only: the three legacy binary formats, ooxml presentations + // and every spreadsheet - the last being issue #442, which the core has its own TODO for + assertEditable("doc-editable", requireFile(docTestFile), false) + assertEditable("ppt-editable", requireFile(pptTestFile), false) + assertEditable("xls-editable", requireFile(xlsTestFile), false) + assertEditable("pptx-editable", requireFile(pptxTestFile), false) + assertEditable("ods-editable", requireFile(spreadsheetTestFile), false) + } + + private fun assertEditable(prefix: String, file: File, expected: Boolean) { + coreLoader.host( + prefix = prefix, + inputPath = file.absolutePath, + cachePath = File(cacheDir(), prefix).path, + editable = true, + keepDocument = true, + ) + + Assert.assertEquals( + "the core should report ${file.name} as ${if (expected) "editable" else "read only"}", + expected, + coreLoader.isDocumentEditable, + ) + } + @Test fun testPasswordProtectedDocumentWithoutPassword() { Assert.assertThrows(OdrException.FileEncrypted::class.java) { diff --git a/app/src/androidTest/java/app/opendocument/droid/test/SupportedFormatsTest.kt b/app/src/androidTest/java/app/opendocument/droid/test/SupportedFormatsTest.kt new file mode 100644 index 000000000000..fadf868f7300 --- /dev/null +++ b/app/src/androidTest/java/app/opendocument/droid/test/SupportedFormatsTest.kt @@ -0,0 +1,201 @@ +package app.opendocument.droid.test + +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import androidx.test.platform.app.InstrumentationRegistry +import app.opendocument.core.FileType +import app.opendocument.core.Odr +import app.opendocument.droid.background.CatchAllSetting +import app.opendocument.droid.background.CoreLoader +import app.opendocument.droid.background.SupportedDocumentTypes +import org.junit.Assert +import org.junit.BeforeClass +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Keeps the two remaining declarations of what the app opens in step with each other, so that + * changing one and forgetting the other is a failing test rather than a bug report. + * + * [SupportedDocumentTypes] is one of them. The other is the STRICT_CATCH `activity-alias` in + * AndroidManifest.xml, which is XML and cannot read a kotlin list - but it can be *asked*: an + * intent-filter is only worth anything if the package manager resolves a real intent through it, + * which is what [resolvesToUs] does here. There used to be a third copy in `CoreLoader`; that one + * is gone, it defers to [SupportedDocumentTypes] now. + * + * The mime side needs no list of its own at all: [FileType] is the core's complete set of formats + * and [Odr.mimetypeByFileType] names each one, so the test walks what odrcore knows and demands + * that the app and the manifest give the same answer for every entry. + */ +@SmallTest +@RunWith(AndroidJUnit4::class) +class SupportedFormatsTest { + + /** + * For every format odrcore can name a mime type for, what the app offers itself for and what + * the manifest offers it for have to be the same answer. + * + * This is the assertion that catches a format being added to the loader without being added to + * the manifest, which is the failure the user sees as "the app is not in the share sheet". + */ + @Test + fun theManifestClaimsExactlyWhatTheAppClaims() { + var checked = 0 + + for (fileType in FileType.values()) { + val mimeType = mimeTypeOrNull(fileType) ?: continue + checked++ + + val claimedByApp = SupportedDocumentTypes.isSupported(mimeType, null) + val claimedByManifest = resolvesToUs(mimeType, "document") + + Assert.assertEquals( + "$fileType ($mimeType): the app says $claimedByApp, the manifest says" + + " $claimedByManifest - AndroidManifest.xml and SupportedDocumentTypes have" + + " drifted apart", + claimedByApp, + claimedByManifest, + ) + } + + // a guard on the guard: if the core ever stops naming mime types the loop above would + // silently assert nothing at all + Assert.assertTrue("no file type was checked", checked > 10) + } + + /** Every extension the folder browser offers for has to be one odrcore actually recognises. */ + @Test + fun theExtensionFallbackNamesFormatsTheCoreKnows() { + for (extension in SupportedDocumentTypes.EXTENSIONS) { + Assert.assertNotEquals( + "odrcore does not know the extension .$extension", + FileType.UNKNOWN, + Odr.fileTypeByFileExtension(extension), + ) + } + } + + /** + * The case the extension fallback exists for: a provider that volunteers nothing better than + * `application/octet-stream`. The app goes by the name, and the manifest has to as well - it + * claims `application/octet-stream` outright, so this is really a check that the claim stays. + */ + @Test + fun anOctetStreamWithAKnownExtensionReachesUs() { + for (extension in SupportedDocumentTypes.EXTENSIONS) { + Assert.assertTrue( + "the app does not offer for document.$extension", + SupportedDocumentTypes.isSupported( + "application/octet-stream", + "document.$extension", + ), + ) + Assert.assertTrue( + "the manifest does not offer for document.$extension", + resolvesToUs("application/octet-stream", "document.$extension"), + ) + } + } + + /** + * The spellings the mime *prefixes* carry along - the ooxml templates, slideshows and macro + * enabled variants - which [theManifestClaimsExactlyWhatTheAppClaims] cannot reach: odrcore + * names one canonical mime type per format, so its [FileType] walk never produces any of these. + * + * They still have to hold, because an intent-filter matches a mime type exactly. Without them + * spelled out in the manifest a .dotx opened from the folder browser while the same file from + * another app did not offer OpenDocument at all. + */ + @Test + fun theOoxmlVariantsReachUsToo() { + val variants = + listOf( + "application/vnd.openxmlformats-officedocument.wordprocessingml.template", + "application/vnd.openxmlformats-officedocument.spreadsheetml.template", + "application/vnd.openxmlformats-officedocument.presentationml.template", + "application/vnd.openxmlformats-officedocument.presentationml.slideshow", + "application/vnd.ms-word.document.macroEnabled.12", + "application/vnd.ms-word.template.macroEnabled.12", + "application/vnd.ms-excel.sheet.macroEnabled.12", + "application/vnd.ms-excel.template.macroEnabled.12", + "application/vnd.ms-powerpoint.presentation.macroEnabled.12", + "application/vnd.ms-powerpoint.template.macroEnabled.12", + "application/vnd.ms-powerpoint.slideshow.macroEnabled.12", + ) + + for (mimeType in variants) { + Assert.assertTrue( + "the app does not offer for $mimeType", + SupportedDocumentTypes.isSupported(mimeType, null), + ) + Assert.assertTrue( + "the manifest does not offer for $mimeType", + resolvesToUs(mimeType, "document"), + ) + } + } + + /** What the app deliberately stays out of - the reason the catch-all filter defaults to off. */ + @Test + fun unrelatedTypesReachNeither() { + for (mimeType in listOf("text/vcard", "text/calendar", "audio/mpeg", "video/mp4")) { + Assert.assertFalse( + "the app should not offer for $mimeType", + SupportedDocumentTypes.isSupported(mimeType, null), + ) + Assert.assertFalse( + "the manifest should not offer for $mimeType", + resolvesToUs(mimeType, "document"), + ) + } + } + + /** + * Whether an `ACTION_VIEW` for this mime type and filename lands on our own activity - the only + * thing an intent-filter is actually good for, and the one question a hand written XML list can + * still be asked. + */ + private fun resolvesToUs(mimeType: String, filename: String): Boolean { + val context = InstrumentationRegistry.getInstrumentation().targetContext + + val intent = + Intent(Intent.ACTION_VIEW).apply { + addCategory(Intent.CATEGORY_DEFAULT) + setDataAndType(Uri.parse("content://app.opendocument.test/$filename"), mimeType) + } + + return context.packageManager + .queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY) + .any { it.activityInfo?.packageName == context.packageName } + } + + /** Null for the types odrcore has no mime type for - word perfect, rtf, cfb and the like. */ + private fun mimeTypeOrNull(fileType: FileType): String? = + try { + Odr.mimetypeByFileType(fileType) + } catch (e: RuntimeException) { + null + } + + companion object { + + // @JvmStatic because junit requires @BeforeClass to be static + @JvmStatic + @BeforeClass + fun prepare() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + + // Odr's tables live in libodr_jni; nothing here opens a file, so the data paths that + // initializeCore also sets up are along for the ride rather than needed + CoreLoader.initializeCore(context) + + // STRICT_CATCH is what this test is about, and the component states survive a test run + // - a previous one that flipped the switch would otherwise leave CATCH_ALL answering + // for everything. False is the shipped default, so nothing has to be put back. + CatchAllSetting.setEnabled(context, false) + } + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a4d335588669..35e54b3a9277 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -257,6 +257,24 @@ + + + + + + + + + + + + diff --git a/app/src/main/java/app/opendocument/droid/background/CatchAllSetting.kt b/app/src/main/java/app/opendocument/droid/background/CatchAllSetting.kt new file mode 100644 index 000000000000..d366d02b443f --- /dev/null +++ b/app/src/main/java/app/opendocument/droid/background/CatchAllSetting.kt @@ -0,0 +1,80 @@ +package app.opendocument.droid.background + +import android.content.ComponentName +import android.content.Context +import android.content.SharedPreferences +import android.content.pm.PackageManager + +/** + * Whether the app offers itself for every file type, or only for the document types it actually + * supports. + * + * Backed by the two activity aliases in the manifest: exactly one of them is enabled at a time, and + * which one decides how broad the intent filter the system sees is. + */ +object CatchAllSetting { + + private const val PREF_CATCH_ALL_ENABLED = "catch_all_enabled" + + // these keep the historical at.tomtasche.reader names on purpose: the component name is + // what the OS persists when a user picks "always open .odt with this app", and what + // setComponentEnabledSetting stores the toggle against. renaming them would drop those + // defaults for every existing install, so the manifest declares the aliases under the old + // names too. + private const val CATCH_ALL_COMPONENT = "at.tomtasche.reader.ui.activity.MainActivity.CATCH_ALL" + private const val STRICT_CATCH_COMPONENT = + "at.tomtasche.reader.ui.activity.MainActivity.STRICT_CATCH" + + /** + * Puts the aliases back in the state the stored setting asks for. + * + * Has to run on every launch, not just when the landing screen is shown: users upgrading from a + * version that shipped different alias defaults are only corrected here, and the app is + * regularly started straight into a document by an external intent. + */ + fun applyOnLaunch(context: Context): Boolean { + val enabled = isEnabled(context) + + apply(context, enabled) + + return enabled + } + + fun isEnabled(context: Context): Boolean = + // catch-all is only active if the user explicitly opted in. new installs and existing + // users who never touched the switch default to STRICT_CATCH, so we no longer volunteer + // to open unrelated file types like contacts (issue #477). + preferences(context).getBoolean(PREF_CATCH_ALL_ENABLED, false) + + fun setEnabled(context: Context, enabled: Boolean) { + preferences(context).edit().putBoolean(PREF_CATCH_ALL_ENABLED, enabled).apply() + + apply(context, enabled) + } + + private fun apply(context: Context, enabled: Boolean) { + toggleComponent(context, CATCH_ALL_COMPONENT, enabled) + toggleComponent(context, STRICT_CATCH_COMPONENT, !enabled) + } + + private fun toggleComponent(context: Context, className: String, enabled: Boolean) { + val newState = + if (enabled) PackageManager.COMPONENT_ENABLED_STATE_ENABLED + else PackageManager.COMPONENT_ENABLED_STATE_DISABLED + + context.packageManager.setComponentEnabledSetting( + ComponentName(context, className), + newState, + PackageManager.DONT_KILL_APP, + ) + } + + /** + * The preferences android.preference.PreferenceManager used to hand out. That class is + * deprecated and its androidx replacement lives in a whole preference-ui library we do not + * otherwise need, so the default file is opened by name instead - keeping the existing + * catch-all setting of users who upgrade. + */ + private fun preferences(context: Context): SharedPreferences = + context.getSharedPreferences(context.packageName + "_preferences", Context.MODE_PRIVATE) +} diff --git a/app/src/main/java/app/opendocument/droid/background/CoreLoader.kt b/app/src/main/java/app/opendocument/droid/background/CoreLoader.kt index 49f614d83401..f062007df1d3 100644 --- a/app/src/main/java/app/opendocument/droid/background/CoreLoader.kt +++ b/app/src/main/java/app/opendocument/droid/background/CoreLoader.kt @@ -9,7 +9,6 @@ import app.opendocument.core.DecodePreference import app.opendocument.core.DecodedFile import app.opendocument.core.Document import app.opendocument.core.DocumentType -import app.opendocument.core.FileType import app.opendocument.core.GlobalParams import app.opendocument.core.Html import app.opendocument.core.HtmlConfig @@ -52,6 +51,18 @@ class CoreLoader(context: Context?) : FileLoader(context, LoaderType.CORE) { private var lastInputPath: String? = null private var lastCachePath: String? = null + /** + * Whether the document [host] last opened is one [edit] can do something with. + * + * This is the core's own answer, not a list of formats kept here: [host] only holds a document + * open when it reports itself editable and savable, so having one is the answer. The core says + * no to the legacy binary formats, to ooxml spreadsheets and presentations and to opendocument + * spreadsheets - the last of those being the same gap as issue #442, which the app used to + * check for by mime type on its own. + */ + val isDocumentEditable: Boolean + get() = document != null + // the port the server actually got, which is not always the preferred one. see bind() private var serverPort = PREFERRED_SERVER_PORT @@ -131,25 +142,16 @@ class CoreLoader(context: Context?) : FileLoader(context, LoaderType.CORE) { } /** - * What the core renders itself: opendocument, ooxml, the three legacy binary microsoft formats - * and pdf. odrcore keeps reference html output for every one of them, doc, ppt and xls - * included, so there is nothing left here to hold back. + * What the core renders itself, out of [SupportedDocumentTypes] so the folder browser and this + * cannot disagree about it. * * Text, csv and images are left out although the core takes those too - [RawLoader] is what * gives them their player or their viewer, and this answer is what lets it have its turn. The * rest of it decides whether a failed load is worth reporting and which viewer [OnlineLoader] * falls back to. - * - * Matching by prefix carries the variants along: the ooxml prefix covers the templates, and the - * macro enabled ones are spelled `vnd.ms-word.document.macroEnabled.12` and so on, which is why - * the legacy powerpoint and excel types appear here without their subtypes and why - * `vnd.ms-word` is listed next to `msword`. */ - override fun isSupported(options: Options): Boolean { - val fileType = options.fileType ?: return false - - return MIME_PREFIXES.any { fileType.startsWith(it) } - } + override fun isSupported(options: Options): Boolean = + SupportedDocumentTypes.isRenderedByCore(options.fileType) override fun loadSync(options: Options) { val result = Result(type, options) @@ -193,6 +195,8 @@ class CoreLoader(context: Context?) : FileLoader(context, LoaderType.CORE) { keepDocument = true, ) + result.isEditable = isDocumentEditable + for (view in views) { result.partTitles.add(view.name) result.partUris.add(Uri.parse(view.url)) @@ -234,12 +238,18 @@ class CoreLoader(context: Context?) : FileLoader(context, LoaderType.CORE) { if (keepDocument) { closeDocument() - // only what [edit] can do something with: the core parses the legacy binary formats - // but declares them neither editable nor savable, so holding one open would buy a - // second parse and nothing else - if (file.isDocumentFile && !isLegacy(file.fileType())) { + if (file.isDocumentFile) { // TODO this will cause a second load - document = file.asDocumentFile().document() + val document = file.asDocumentFile().document() + + // keep only what [edit] can do something with, and let the core be the one to say + // so - see [isDocumentEditable]. Holding a read only document open buys a second + // parse and nothing else. + if (document.isEditable && document.isSavable) { + this.document = document + } else { + document.close() + } } } @@ -330,34 +340,12 @@ class CoreLoader(context: Context?) : FileLoader(context, LoaderType.CORE) { document = null } - private fun isLegacy(fileType: FileType): Boolean = - fileType == FileType.LEGACY_WORD_DOCUMENT || - fileType == FileType.LEGACY_POWERPOINT_PRESENTATION || - fileType == FileType.LEGACY_EXCEL_WORKSHEETS - /** A translated view of a document, ready to be opened in the WebView. */ data class HostedView(val name: String, val url: String) companion object { private const val TAG = "CoreLoader" - /** See [isSupported]. Mirrored by the STRICT_CATCH filter in AndroidManifest.xml. */ - private val MIME_PREFIXES = - listOf( - // odf, including the master documents and the x- spelling some providers use - "application/vnd.oasis.opendocument", - "application/x-vnd.oasis.opendocument", - // ooxml: word, excel and powerpoint, their templates and their macro variants - "application/vnd.openxmlformats-officedocument", - "application/vnd.ms-word", - // legacy binary microsoft: doc, xls and ppt - "application/msword", - "application/vnd.ms-excel", - "application/vnd.ms-powerpoint", - // not a document, but the core renders it just the same - "application/pdf", - ) - /** The loopback address the server binds to. */ private const val SERVER_BIND_ADDRESS = "127.0.0.1" diff --git a/app/src/main/java/app/opendocument/droid/background/FileLoader.kt b/app/src/main/java/app/opendocument/droid/background/FileLoader.kt index c9eaf94b6e6f..8785706eefd8 100644 --- a/app/src/main/java/app/opendocument/droid/background/FileLoader.kt +++ b/app/src/main/java/app/opendocument/droid/background/FileLoader.kt @@ -200,6 +200,13 @@ abstract class FileLoader(context: Context?, protected val type: LoaderType) { val partTitles: MutableList = LinkedList() val partUris: MutableList = LinkedList() + /** + * Whether the loaded document can be edited and saved again, as the core reports it - see + * [CoreLoader.isDocumentEditable]. False for every other loader, which have nothing to + * edit. + */ + var isEditable: Boolean = false + override fun describeContents(): Int = 0 override fun writeToParcel(parcel: Parcel, flags: Int) { @@ -207,6 +214,7 @@ abstract class FileLoader(context: Context?, protected val type: LoaderType) { parcel.writeParcelable(options, 0) parcel.writeList(partTitles) parcel.writeList(partUris) + ParcelUtil.writeBoolean(parcel, isEditable) } companion object { @@ -225,6 +233,7 @@ abstract class FileLoader(context: Context?, protected val type: LoaderType) { return Result(loaderType, options).also { parcel.readList(it.partTitles, classLoader) parcel.readList(it.partUris, classLoader) + it.isEditable = ParcelUtil.readBoolean(parcel) } } diff --git a/app/src/main/java/app/opendocument/droid/background/SupportedDocumentTypes.kt b/app/src/main/java/app/opendocument/droid/background/SupportedDocumentTypes.kt new file mode 100644 index 000000000000..bb07ebc63301 --- /dev/null +++ b/app/src/main/java/app/opendocument/droid/background/SupportedDocumentTypes.kt @@ -0,0 +1,114 @@ +package app.opendocument.droid.background + +/** + * The one list of what the app claims to open, for both of the places that have to guess. + * + * There used to be two copies of this - one here for the folder browser, one in [CoreLoader] for + * the loader - which is one copy too many for a set that only ever changes as a whole. + * [isRenderedByCore] is what [CoreLoader.isSupported] answers with, [isSupported] adds what + * [RawLoader] shows and the extension fallback on top, and the STRICT_CATCH `activity-alias` in + * AndroidManifest.xml is the third and last declaration, which XML keeps out of reach. + * `SupportedFormatsTest` walks this table and asks the package manager whether the manifest still + * agrees, so the two cannot drift silently any more. + * + * Deliberately free of Android dependencies, and of the core's own tables - odrcore does know + * [app.opendocument.core.Odr.fileTypeByMimetype] and `fileTypeByFileExtension`, but both are native + * calls into libodr_jni, and both only know one canonical mime type per format: no templates, no + * macro enabled variants, no `application/x-vnd.oasis...`, which are exactly the spellings a + * content provider hands out. What the core does decide is everything *after* the file is in the + * cache - [MetadataLoader] runs its libmagic over it, and [CoreLoader.isDocumentEditable] asks the + * opened document itself. + * + * So this is a guess, not the real answer: a folder listing only has a name and whatever mime type + * the provider volunteered, and it has to go on appearances. + */ +object SupportedDocumentTypes { + + /** + * What the core renders itself: opendocument, ooxml, the three legacy binary microsoft formats + * and pdf. odrcore keeps reference html output for every one of them, doc, ppt and xls + * included. + * + * Matching by prefix carries the variants along: the ooxml prefix covers the templates, and the + * macro enabled ones are spelled `vnd.ms-word.document.macroEnabled.12` and so on, which is why + * the legacy powerpoint and excel types appear here without their subtypes and why + * `vnd.ms-word` is listed next to `msword`. + */ + private val CORE_MIME_PREFIXES = + listOf( + // odf, including the master documents and the x- spelling some providers use + "application/vnd.oasis.opendocument", + "application/x-vnd.oasis.opendocument", + // ooxml: word, excel and powerpoint, their templates and their macro variants + "application/vnd.openxmlformats-officedocument", + "application/vnd.ms-word", + // legacy binary microsoft: doc, xls and ppt + "application/msword", + "application/vnd.ms-excel", + "application/vnd.ms-powerpoint", + // not a document, but the core renders it just the same + "application/pdf", + ) + + /** + * What [RawLoader] shows instead - text, csv, images and zip - which the core would take too + * but which get their own player or viewer here. + * + * Narrower than [RawLoader.isSupported], which also takes audio and video: those are worth + * playing once someone hands us one, but not worth offering the app for. + */ + private val RAW_MIME_PREFIXES = listOf("text/plain", "text/csv", "image/", "application/zip") + + /** + * The extensions to fall back on, spelling out the same set once more because providers + * regularly volunteer nothing better than `application/octet-stream`. + * + * Public so `SupportedFormatsTest` can walk it: it holds every one of these against the core's + * own extension table and against the manifest. + */ + val EXTENSIONS: Set = + setOf( + "odt", + "ods", + "odp", + "odg", + "ott", + "ots", + "otp", + "otg", + "doc", + "docx", + "xls", + "xlsx", + "ppt", + "pptx", + "pdf", + "txt", + "csv", + "zip", + ) + + /** Whether [CoreLoader] is expected to render this - see [CORE_MIME_PREFIXES]. */ + fun isRenderedByCore(mimeType: String?): Boolean = matches(mimeType, CORE_MIME_PREFIXES) + + /** Whether the folder browser should offer this at all. */ + fun isSupported(mimeType: String?, filename: String?): Boolean { + // a recognised extension has to be able to win on its own, or every octet-stream is lost + val extension = MimeTypeResolver.parseExtension(filename)?.lowercase() + if (extension != null && extension in EXTENSIONS) { + return true + } + + return isRenderedByCore(mimeType) || matches(mimeType, RAW_MIME_PREFIXES) + } + + private fun matches(mimeType: String?, prefixes: List): Boolean { + if (mimeType == null) { + return false + } + + val normalized = mimeType.lowercase() + + return prefixes.any { normalized.startsWith(it) } + } +} diff --git a/app/src/main/java/app/opendocument/droid/ui/activity/DocumentFragment.kt b/app/src/main/java/app/opendocument/droid/ui/activity/DocumentFragment.kt index 5fe7aa31c5bc..a5478df9c63e 100644 --- a/app/src/main/java/app/opendocument/droid/ui/activity/DocumentFragment.kt +++ b/app/src/main/java/app/opendocument/droid/ui/activity/DocumentFragment.kt @@ -254,7 +254,7 @@ class DocumentFragment : Fragment(), LoaderService.LoaderListener, MenuProvider menu.findItem(R.id.menu_print).isVisible = true // the other menu items are dynamically enabled based on the loaded document - state.lastResult?.let { prepareMenu(it.loaderType, it.options.fileType) } + state.lastResult?.let { prepareMenu(it) } } override fun onMenuItemSelected(menuItem: MenuItem): Boolean { @@ -400,31 +400,15 @@ class DocumentFragment : Fragment(), LoaderService.LoaderListener, MenuProvider menu.findItem(R.id.menu_tts).isVisible = enabled } - private fun prepareMenu(loaderType: FileLoader.LoaderType, fileType: String?) { - var isEditEnabled = false - var isDarkModeSupported = true + private fun prepareMenu(result: FileLoader.Result) { + // whether editing is on offer is the core's answer, not a list of formats kept here: it + // knows which of the documents it renders it can also write back, which is why neither the + // legacy binary formats nor the spreadsheets of issue #442 need naming + toggleDocumentMenu(true, result.isEditable) - if (loaderType == FileLoader.LoaderType.CORE) { - isEditEnabled = true - - // Edit is currently broken for ODS spreadsheets - // See: https://github.com/opendocument-app/OpenDocument.droid/issues/442 - if ( - fileType != null && - fileType.startsWith("application/vnd.oasis.opendocument.spreadsheet") - ) { - isEditEnabled = false - } - - // Edit is not supported for PDF documents - if (fileType != null && fileType.startsWith("application/pdf")) { - isEditEnabled = false - isDarkModeSupported = false - } - } - - toggleDocumentMenu(true, isEditEnabled) - pageView?.toggleDarkMode(isDarkModeSupported) + // pdf is the one thing worth leaving alone: inverting a page of scanned paper helps nobody + val fileType = result.options.fileType + pageView?.toggleDarkMode(fileType?.startsWith("application/pdf") != true) } private fun requestInAppRating(activity: Activity) { diff --git a/app/src/main/java/app/opendocument/droid/ui/activity/MainActivity.kt b/app/src/main/java/app/opendocument/droid/ui/activity/MainActivity.kt index 6e11765a9714..c453e9117a93 100644 --- a/app/src/main/java/app/opendocument/droid/ui/activity/MainActivity.kt +++ b/app/src/main/java/app/opendocument/droid/ui/activity/MainActivity.kt @@ -3,11 +3,8 @@ package app.opendocument.droid.ui.activity import android.app.Activity import android.content.ActivityNotFoundException import android.content.ComponentName -import android.content.Context import android.content.Intent import android.content.ServiceConnection -import android.content.SharedPreferences -import android.content.pm.PackageManager import android.content.pm.ResolveInfo import android.content.res.Configuration import android.net.Uri @@ -33,6 +30,7 @@ import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import androidx.fragment.app.DialogFragment import app.opendocument.droid.R +import app.opendocument.droid.background.CatchAllSetting import app.opendocument.droid.background.LoaderService import app.opendocument.droid.background.LoaderServiceQueue import app.opendocument.droid.background.PersistedUriPermissions @@ -282,34 +280,14 @@ class MainActivity : AppCompatActivity(), MenuProvider { } private fun initializeCatchAllSwitch() { - // these keep the historical at.tomtasche.reader names on purpose: the component - // name is what the OS persists when a user picks "always open .odt with this - // app", and what setComponentEnabledSetting below stores the toggle against. - // renaming them would drop those defaults for every existing install, so the - // manifest declares the aliases under the old names too. - val catchAllComponent = - ComponentName(this, "at.tomtasche.reader.ui.activity.MainActivity.CATCH_ALL") - val strictCatchComponent = - ComponentName(this, "at.tomtasche.reader.ui.activity.MainActivity.STRICT_CATCH") - - val preferences = getDefaultSharedPreferences() - - // catch-all is only active if the user explicitly opted in. new installs and - // existing users who never touched the switch default to STRICT_CATCH, so we - // no longer volunteer to open unrelated file types like contacts (issue #477). - val isCatchAllEnabled = preferences.getBoolean(PREF_CATCH_ALL_ENABLED, false) - - // retoggle components for users upgrading to latest version of app - toggleComponent(catchAllComponent, isCatchAllEnabled) - toggleComponent(strictCatchComponent, !isCatchAllEnabled) + // retoggles the aliases for users upgrading from a version that shipped different + // defaults, and answers with what the stored setting says + val isCatchAllEnabled = CatchAllSetting.applyOnLaunch(this) val catchAllSwitch = findViewById(R.id.landing_catch_all) catchAllSwitch.setOnCheckedChangeListener { _, isChecked -> - preferences.edit().putBoolean(PREF_CATCH_ALL_ENABLED, isChecked).apply() - - toggleComponent(catchAllComponent, isChecked) - toggleComponent(strictCatchComponent, !isChecked) + CatchAllSetting.setEnabled(this, isChecked) } catchAllSwitch.isChecked = isCatchAllEnabled @@ -319,22 +297,6 @@ class MainActivity : AppCompatActivity(), MenuProvider { ) } - /** - * The preferences android.preference.PreferenceManager used to hand out. That class is - * deprecated and its androidx replacement lives in a whole preference-ui library we do not - * otherwise need, so the default file is opened by name instead - keeping the existing - * catch-all setting of users who upgrade. - */ - private fun getDefaultSharedPreferences(): SharedPreferences = - getSharedPreferences(packageName + "_preferences", Context.MODE_PRIVATE) - - private fun toggleComponent(component: ComponentName, enabled: Boolean) { - val newState = - if (enabled) PackageManager.COMPONENT_ENABLED_STATE_ENABLED - else PackageManager.COMPONENT_ENABLED_STATE_DISABLED - packageManager.setComponentEnabledSetting(component, newState, PackageManager.DONT_KILL_APP) - } - override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) @@ -787,7 +749,6 @@ class MainActivity : AppCompatActivity(), MenuProvider { const val SAVED_KEY_OPENED_EXTERNALLY = "OPENED_EXTERNALLY" const val GOOGLE_REQUEST_CODE = 1993 const val DOCUMENT_FRAGMENT_TAG = "document_fragment" - const val PREF_CATCH_ALL_ENABLED = "catch_all_enabled" // taken from: https://stackoverflow.com/a/36829889/198996 private fun isTesting(): Boolean = diff --git a/app/src/test/java/app/opendocument/droid/background/SupportedDocumentTypesTest.kt b/app/src/test/java/app/opendocument/droid/background/SupportedDocumentTypesTest.kt new file mode 100644 index 000000000000..224856f7480f --- /dev/null +++ b/app/src/test/java/app/opendocument/droid/background/SupportedDocumentTypesTest.kt @@ -0,0 +1,87 @@ +package app.opendocument.droid.background + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SupportedDocumentTypesTest { + + @Test + fun opendocumentTypesAreSupported() { + assertTrue(supported("application/vnd.oasis.opendocument.text", "report.odt")) + assertTrue(supported("application/vnd.oasis.opendocument.spreadsheet", "budget.ods")) + assertTrue(supported("application/vnd.oasis.opendocument.presentation", "deck.odp")) + assertTrue(supported("application/vnd.oasis.opendocument.graphics", "drawing.odg")) + assertTrue(supported("application/vnd.oasis.opendocument.text-template", "letter.ott")) + } + + @Test + fun officeAndPdfTypesAreSupported() { + assertTrue( + supported( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "notes.docx", + ) + ) + assertTrue(supported("application/msword", "old.doc")) + assertTrue(supported("application/pdf", "manual.pdf")) + assertTrue(supported("text/plain", "readme.txt")) + assertTrue(supported("text/csv", "rows.csv")) + assertTrue(supported("image/png", "scan.png")) + } + + @Test + fun presentationsAndTheLegacyBinaryFormatsAreSupported() { + assertTrue( + supported( + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "deck.pptx", + ) + ) + assertTrue(supported("application/vnd.ms-powerpoint", "deck.ppt")) + assertTrue(supported("application/vnd.ms-excel", "budget.xls")) + // the octet-stream a provider volunteers for them, caught by the extension instead + assertTrue(supported("application/octet-stream", "deck.pptx")) + assertTrue(supported("application/octet-stream", "deck.ppt")) + assertTrue(supported("application/octet-stream", "budget.xls")) + } + + @Test + fun aKnownExtensionWinsOverAnUnhelpfulMimeType() { + // this is the common case: providers fall back to octet-stream for anything they do not + // have a mapping for, which includes most opendocument files + assertTrue(supported("application/octet-stream", "report.odt")) + assertTrue(supported("application/octet-stream", "budget.ods")) + } + + @Test + fun aKnownMimeTypeWinsOverAMissingExtension() { + assertTrue(supported("application/pdf", "scan-without-extension")) + } + + @Test + fun unrelatedTypesAreNotSupported() { + // the reason catch-all defaults to off - see issue #477 + assertFalse(supported("text/vcard", "contact.vcf")) + assertFalse(supported("text/x-vcard", "contact.vcf")) + assertFalse(supported("audio/mpeg", "song.mp3")) + assertFalse(supported("video/mp4", "clip.mp4")) + assertFalse(supported("application/octet-stream", "firmware.bin")) + } + + @Test + fun nothingKnownAtAllIsNotSupported() { + assertFalse(supported(null, null)) + assertFalse(supported(null, "mystery")) + assertFalse(supported("application/octet-stream", null)) + } + + @Test + fun matchingIsCaseInsensitive() { + assertTrue(supported("APPLICATION/PDF", "MANUAL.PDF")) + assertTrue(supported(null, "Report.ODT")) + } + + private fun supported(mimeType: String?, filename: String?) = + SupportedDocumentTypes.isSupported(mimeType, filename) +} From d1cd93f277085da2eea9f4d97e5c9fcee1fc14d7 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Wed, 29 Jul 2026 10:24:35 +0200 Subject: [PATCH 3/4] Let a file with no mime type reach us by its name too Codex on #548: the eleven ooxml variant mime types the last commit spelled out only help a provider that sends the exact mime type. A file manager that sends ACTION_VIEW with just a content:/file: uri falls through to the pathPattern filters, and those listed docx, xlsx, pptx, doc, xls and ppt - so a .dotx or .pptm was claimed by mime and not by name. The gap was older than that, and wider: ott, ots, otp and otg have been in SupportedDocumentTypes for as long as it has existed, with no pattern for any of them. Nothing noticed because the only test of the extension fallback went through application/octet-stream, which the filter claims outright - it matches whatever the patterns happen to say, so it could never fail on a missing one. So aFileWithNoMimeTypeReachesUsByItsExtension asks the question that route actually poses: no type at all, one assertion per entry of EXTENSIONS. Verified it catches this by deleting the .ott and .dotx patterns again - "the manifest has no pathPattern for .ott". EXTENSIONS grows the eleven variants to match, and splits in two on the way. odrcore's fileTypeByFileExtension names one suffix per format and answers UNKNOWN for every one of them, though it renders them all - a .dotx is the same wordprocessing package as a .docx, and libmagic identifies it by content once the file is cached. CORE_EXTENSIONS is the half held against that table; OOXML_VARIANT_EXTENSIONS is the half that asserts the core still does not name them, so a core that learns them turns red rather than leaving the split there for no reason. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012eTZB8rnh9PrdNn1YZexue --- .../droid/test/SupportedFormatsTest.kt | 65 +++++- app/src/main/AndroidManifest.xml | 210 ++++++++++++++++++ .../background/SupportedDocumentTypes.kt | 49 +++- 3 files changed, 315 insertions(+), 9 deletions(-) diff --git a/app/src/androidTest/java/app/opendocument/droid/test/SupportedFormatsTest.kt b/app/src/androidTest/java/app/opendocument/droid/test/SupportedFormatsTest.kt index fadf868f7300..04b42fda0877 100644 --- a/app/src/androidTest/java/app/opendocument/droid/test/SupportedFormatsTest.kt +++ b/app/src/androidTest/java/app/opendocument/droid/test/SupportedFormatsTest.kt @@ -66,10 +66,10 @@ class SupportedFormatsTest { Assert.assertTrue("no file type was checked", checked > 10) } - /** Every extension the folder browser offers for has to be one odrcore actually recognises. */ + /** Every extension named after the core's own table has to be one odrcore still recognises. */ @Test fun theExtensionFallbackNamesFormatsTheCoreKnows() { - for (extension in SupportedDocumentTypes.EXTENSIONS) { + for (extension in SupportedDocumentTypes.CORE_EXTENSIONS) { Assert.assertNotEquals( "odrcore does not know the extension .$extension", FileType.UNKNOWN, @@ -78,6 +78,26 @@ class SupportedFormatsTest { } } + /** + * The other half of the fallback, and why it is kept apart: odrcore's extension table names one + * suffix per format, so it answers UNKNOWN for the ooxml templates, macro enabled documents and + * slideshows even though it renders all of them - a `.dotx` is the same wordprocessing package + * as a `.docx`, and libmagic identifies it by content once the file is in the cache. + * + * Kept as an assertion rather than a comment so that a future core which does learn them turns + * this red instead of leaving the split in place for no reason. + */ + @Test + fun theOoxmlVariantExtensionsAreOnesTheCoreDoesNotName() { + for (extension in SupportedDocumentTypes.OOXML_VARIANT_EXTENSIONS) { + Assert.assertEquals( + "odrcore now knows .$extension - move it to CORE_EXTENSIONS", + FileType.UNKNOWN, + Odr.fileTypeByFileExtension(extension), + ) + } + } + /** * The case the extension fallback exists for: a provider that volunteers nothing better than * `application/octet-stream`. The app goes by the name, and the manifest has to as well - it @@ -100,6 +120,37 @@ class SupportedFormatsTest { } } + /** + * A file manager that sends `ACTION_VIEW` with no mime type at all, which is the case the + * `pathPattern` filters exist for - and the one an `application/octet-stream` check cannot + * stand in for, because that mime type is claimed outright and so matches whatever the patterns + * happen to say. + * + * This is what pins the pattern list against [SupportedDocumentTypes.EXTENSIONS]: leaving an + * extension out of the manifest was invisible until now, and `.ott` had been missing from it + * for as long as the table has claimed it. + */ + @Test + fun aFileWithNoMimeTypeReachesUsByItsExtension() { + for (extension in SupportedDocumentTypes.EXTENSIONS) { + Assert.assertTrue( + "the manifest has no pathPattern for .$extension", + resolvesToUs(null, "document.$extension"), + ) + } + } + + /** And the same route stays shut for what the app does not claim. */ + @Test + fun aFileWithNoMimeTypeAndAnUnrelatedExtensionReachesNobody() { + for (extension in listOf("vcf", "mp3", "mp4", "bin", "apk")) { + Assert.assertFalse( + "the manifest offers for .$extension", + resolvesToUs(null, "document.$extension"), + ) + } + } + /** * The spellings the mime *prefixes* carry along - the ooxml templates, slideshows and macro * enabled variants - which [theManifestClaimsExactlyWhatTheAppClaims] cannot reach: odrcore @@ -157,14 +208,20 @@ class SupportedFormatsTest { * Whether an `ACTION_VIEW` for this mime type and filename lands on our own activity - the only * thing an intent-filter is actually good for, and the one question a hand written XML list can * still be asked. + * + * A null [mimeType] is the no-type intent a file manager sends, which only the `pathPattern` + * filters can answer; `setData` rather than `setDataAndType`, because passing a null type to + * the latter clears the data along with it. */ - private fun resolvesToUs(mimeType: String, filename: String): Boolean { + private fun resolvesToUs(mimeType: String?, filename: String): Boolean { val context = InstrumentationRegistry.getInstrumentation().targetContext + val uri = Uri.parse("content://app.opendocument.test/$filename") + val intent = Intent(Intent.ACTION_VIEW).apply { addCategory(Intent.CATEGORY_DEFAULT) - setDataAndType(Uri.parse("content://app.opendocument.test/$filename"), mimeType) + if (mimeType == null) setData(uri) else setDataAndType(uri, mimeType) } return context.packageManager diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 35e54b3a9277..3a560a3dcf0d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -318,6 +318,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -332,6 +360,27 @@ + + + + + + + + + + + + + + + + + + + + + @@ -346,6 +395,27 @@ + + + + + + + + + + + + + + + + + + + + + @@ -360,6 +430,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -427,6 +532,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -441,6 +574,27 @@ + + + + + + + + + + + + + + + + + + + + + @@ -455,6 +609,27 @@ + + + + + + + + + + + + + + + + + + + + + @@ -469,6 +644,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/app/opendocument/droid/background/SupportedDocumentTypes.kt b/app/src/main/java/app/opendocument/droid/background/SupportedDocumentTypes.kt index bb07ebc63301..1c40505a2a0e 100644 --- a/app/src/main/java/app/opendocument/droid/background/SupportedDocumentTypes.kt +++ b/app/src/main/java/app/opendocument/droid/background/SupportedDocumentTypes.kt @@ -60,13 +60,12 @@ object SupportedDocumentTypes { private val RAW_MIME_PREFIXES = listOf("text/plain", "text/csv", "image/", "application/zip") /** - * The extensions to fall back on, spelling out the same set once more because providers - * regularly volunteer nothing better than `application/octet-stream`. + * The extensions odrcore's own `fileTypeByFileExtension` names, one suffix per format. * - * Public so `SupportedFormatsTest` can walk it: it holds every one of these against the core's - * own extension table and against the manifest. + * Public so `SupportedFormatsTest` can hold each of them against that table - the assertion + * that this list stays a subset of what the core recognises. */ - val EXTENSIONS: Set = + val CORE_EXTENSIONS: Set = setOf( "odt", "ods", @@ -88,6 +87,46 @@ object SupportedDocumentTypes { "zip", ) + /** + * The ooxml templates, macro enabled documents and slideshows, which odrcore's extension table + * leaves out although it renders every one of them - they are the same packages as the `x` + * suffixed ones, and libmagic identifies them by content once the file is in the cache. + * + * Kept apart from [CORE_EXTENSIONS] so that a test can say which of the two invariants applies + * to which half, rather than quietly dropping the check for all of them. + */ + val OOXML_VARIANT_EXTENSIONS: Set = + setOf( + "docm", + "dotx", + "dotm", + "xlsm", + "xltx", + "xltm", + "pptm", + "potx", + "potm", + "ppsx", + "ppsm", + ) + + /** + * The extensions to fall back on, spelling out the same set once more because providers + * regularly volunteer nothing better than `application/octet-stream` - and because a file + * manager that sends `ACTION_VIEW` with no mime type at all leaves the name as the only thing + * anyone can go on. That second case is what the `pathPattern` filters in AndroidManifest.xml + * cover, and they have to list exactly this set. + * + * The ooxml variants are here for the same reason [CORE_MIME_PREFIXES] matches by prefix: a + * `.dotx` is the same wordprocessing package as a `.docx`. Note that odrcore's own + * `fileTypeByFileExtension` does *not* know them - its extension table names one suffix per + * format - which is why [SupportedFormatsTest] holds only [CORE_EXTENSIONS] against it. + * + * Public so `SupportedFormatsTest` can walk it: it holds every one of these against the + * manifest. + */ + val EXTENSIONS: Set = CORE_EXTENSIONS + OOXML_VARIANT_EXTENSIONS + /** Whether [CoreLoader] is expected to render this - see [CORE_MIME_PREFIXES]. */ fun isRenderedByCore(mimeType: String?): Boolean = matches(mimeType, CORE_MIME_PREFIXES) From bd35e4f5d21f9da734f32376e7c12c26e81acca5 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Wed, 29 Jul 2026 10:56:24 +0200 Subject: [PATCH 4/4] Keep the core server for the life of the process instead of stopping it The api 32 job crashed the lite run after 13 tests, in the middle of testPDF, with a SIGSEGV inside httplib's accept loop. Chasing it turned up two separate races, both on the teardown path, and odrcore has no safe way through either: #00 httplib::Server::listen_internal() #01 odr::HttpServer::Impl::listen() const #16 CoreLoader.initialize$lambda$0 close() destroyed the server while the thread that called listen() was still inside it. HttpServer.close() frees the native server there and then, so that thread segfaults the next time it touches it - six seconds and two tests later, here. Waiting first does not settle it either: stop() only asks, and the accept loop comes back on its own time. Waiting longer surfaced the second one, an abort rather than a segfault: fdsan: attempted to close file descriptor 101, expected to be unowned, actually owned by ZipArchive 0x73adcf9fe0 #02 close+16 #03 httplib::Server::listen_internal()+920 stop() closes the listening socket itself, and listen_internal() closes it again on the way out. In between the number is free, and in a jvm process something always takes it - art had opened the apk and got 101. fdsan catches the second close and aborts; without fdsan it would quietly close an unrelated file. So the loader stops tearing down. The server is created on the first initialize() and never stopped: close() calls clear(), which drops the documents this loader published and leaves the socket listening, and the process exit reclaims the rest. That was already what happened - nothing here ever outlived its process - and it is what makes both races unreachable, since both need a stop() or a destroy that no longer comes. Smaller as a result: no join, no timeout, no thread to wait on the listening one, and no blocking work left in close() at all, which LoaderService.onDestroy runs on the main thread. bind()'s fallback also stops being reached by our own teardown, because the port is now bound once rather than once per service lifetime. Reported upstream as opendocument-app/OpenDocument.core#631. Until it is fixed, bind and listen once, clear() between documents, never stop(). Both flavors, five runs each, 25 tests, green, and no tombstone written. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012eTZB8rnh9PrdNn1YZexue --- .../droid/background/CoreLoader.kt | 195 +++++++++++------- 1 file changed, 116 insertions(+), 79 deletions(-) diff --git a/app/src/main/java/app/opendocument/droid/background/CoreLoader.kt b/app/src/main/java/app/opendocument/droid/background/CoreLoader.kt index f062007df1d3..7498c5429d0c 100644 --- a/app/src/main/java/app/opendocument/droid/background/CoreLoader.kt +++ b/app/src/main/java/app/opendocument/droid/background/CoreLoader.kt @@ -44,9 +44,6 @@ import java.io.IOException // tests never set - isSupported() is pure and does not need one class CoreLoader(context: Context?) : FileLoader(context, LoaderType.CORE) { - private var server: HttpServer? = null - private var httpThread: Thread? = null - private var document: Document? = null private var lastInputPath: String? = null private var lastCachePath: String? = null @@ -63,8 +60,13 @@ class CoreLoader(context: Context?) : FileLoader(context, LoaderType.CORE) { val isDocumentEditable: Boolean get() = document != null - // the port the server actually got, which is not always the preferred one. see bind() - private var serverPort = PREFERRED_SERVER_PORT + // the shared server and the port it actually got, which is not always the preferred one. + // see bind() and startSharedServer() + private val server: HttpServer? + get() = sharedServer + + private val serverPort: Int + get() = sharedServerPort override fun initialize( listener: FileLoaderListener, @@ -78,69 +80,11 @@ class CoreLoader(context: Context?) : FileLoader(context, LoaderType.CORE) { // right after new CoreLoader() anyway. initializeCore(context) - val server = HttpServer() - this.server = server - - serverPort = bind(server, crashManager) - - httpThread = - Thread { - try { - server.listen() - } catch (e: Throwable) { - crashManager.log(e) - } - } - .also { it.start() } + startSharedServer(crashManager) super.initialize(listener, mainHandler, backgroundHandler, analyticsManager, crashManager) } - /** - * Binds [server], preferring [PREFERRED_SERVER_PORT], and returns the port it got. - * - * The preferred port is occupied more often than it looks. Both flavors hardcode it, so lite - * and pro collide whenever they run on one device - the instrumented tests do exactly that, one - * flavor after the other - and a port does not come free the moment its owner goes away: the - * sockets the webview opened linger in TIME_WAIT for a minute. That second half is what - * [HttpServer.Options.reuseAddress] covers, on by default since the core started setting - * SO_REUSEADDR rather than cpp-httplib's SO_REUSEPORT; a live server of the other flavor still - * holds the port for real, hence the fallback to any free one. - * - * A failed bind used to be invisible: the old `listen(host, port)` returned void either way, so - * an occupied port left the app with no server and nothing to show for it - every document then - * failed with ERR_CONNECTION_REFUSED behind chrome's own error page. [HttpServer.bind] throws - * instead, and reports back the port that a request has to be addressed to. - */ - private fun bind(server: HttpServer, crashManager: CrashManager): Int { - // a preference of ANY_PORT is a request for an arbitrary free port, which is what the - // fallback below asks for anyway - there is nothing to try first - if (PREFERRED_SERVER_PORT != ANY_PORT) { - try { - return server.bind(SERVER_BIND_ADDRESS, PREFERRED_SERVER_PORT) - } catch (e: Throwable) { - crashManager.log( - IOException( - "core server cannot bind $SERVER_BIND_ADDRESS:$PREFERRED_SERVER_PORT", - e, - ) - ) - } - } - - try { - return server.bind(SERVER_BIND_ADDRESS, ANY_PORT) - } catch (e: Throwable) { - crashManager.log(IOException("core server cannot bind any port", e)) - } - - // nothing is bound at this point, so [listen] will fail as well and this port only decides - // which url the documents that follow are refused at. It must not be ANY_PORT, which would - // address every one of them to :0. - return if (PREFERRED_SERVER_PORT != ANY_PORT) PREFERRED_SERVER_PORT - else FALLBACK_SERVER_PORT - } - /** * What the core renders itself, out of [SupportedDocumentTypes] so the folder browser and this * cannot disagree about it. @@ -313,24 +257,14 @@ class CoreLoader(context: Context?) : FileLoader(context, LoaderType.CORE) { return outputFile } + /** + * Drops what this loader published and leaves the server itself running - see + * [startSharedServer] for why it is never stopped. + */ override fun close() { super.close() - val server = this.server - if (server != null) { - server.stop() - httpThread?.let { - try { - it.join(1000) - } catch (e: InterruptedException) { - crashManager.log(e) - } - } - httpThread = null - - server.close() - this.server = null - } + server?.clear() closeDocument() } @@ -346,6 +280,109 @@ class CoreLoader(context: Context?) : FileLoader(context, LoaderType.CORE) { companion object { private const val TAG = "CoreLoader" + /** + * The one http server of the process, started on the first [initialize] and never stopped. + * + * Never stopped on purpose. Both of the ways odrcore lets a server go are unsafe while the + * thread that called [HttpServer.listen] is still inside it, and that thread only comes + * back some time after `stop()` asks it to: + * + * - `HttpServer.close()` destroys the native server there and then, which is a use after + * free that surfaces as a SIGSEGV in `httplib::Server::listen_internal`. Dropping the + * reference instead is no better - odrcore's `NativeResource` frees the handle from a + * reference queue too. + * - `stop()` on its own closes the listening socket while the accept loop is still using + * it, and closes it a second time on the way out. In between, the fd number is handed to + * whatever opens a file next; on android fdsan catches that and aborts the process + * ("attempted to close file descriptor N, actually owned by ZipArchive"), and on a + * platform without fdsan it would quietly close someone else's file. + * + * Both are teardown only, so the loader does not tear down: [close] calls + * [HttpServer.clear] to drop the documents it published and leaves the socket listening. + * The process exit reclaims it, which is what was really doing the work before - nothing + * here ever outlived its process. Reported upstream as + * opendocument-app/OpenDocument.core#631. + * + * A second consequence is that the port is bound once rather than once per service + * lifetime, so [bind]'s fallback stops being reached by our own teardown. + */ + private var sharedServer: HttpServer? = null + + private var sharedServerPort = PREFERRED_SERVER_PORT + + /** + * Binds [server], preferring [PREFERRED_SERVER_PORT], and returns the port it got. + * + * The preferred port is occupied more often than it looks. Both flavors hardcode it, so + * lite and pro collide whenever they run on one device - the instrumented tests do exactly + * that, one flavor after the other - and a port does not come free the moment its owner + * goes away: the sockets the webview opened linger in TIME_WAIT for a minute. That second + * half is what [HttpServer.Options.reuseAddress] covers, on by default since the core + * started setting SO_REUSEADDR rather than cpp-httplib's SO_REUSEPORT; a live server of the + * other flavor still holds the port for real, hence the fallback to any free one. + * + * A failed bind used to be invisible: the old `listen(host, port)` returned void either + * way, so an occupied port left the app with no server and nothing to show for it - every + * document then failed with ERR_CONNECTION_REFUSED behind chrome's own error page. + * [HttpServer.bind] throws instead, and reports back the port that a request has to be + * addressed to. + */ + private fun bind(server: HttpServer, crashManager: CrashManager): Int { + // a preference of ANY_PORT is a request for an arbitrary free port, which is what the + // fallback below asks for anyway - there is nothing to try first + if (PREFERRED_SERVER_PORT != ANY_PORT) { + try { + return server.bind(SERVER_BIND_ADDRESS, PREFERRED_SERVER_PORT) + } catch (e: Throwable) { + crashManager.log( + IOException( + "core server cannot bind $SERVER_BIND_ADDRESS:$PREFERRED_SERVER_PORT", + e, + ) + ) + } + } + + try { + return server.bind(SERVER_BIND_ADDRESS, ANY_PORT) + } catch (e: Throwable) { + crashManager.log(IOException("core server cannot bind any port", e)) + } + + // nothing is bound at this point, so [listen] will fail as well and this port only + // decides which url the documents that follow are refused at. It must not be + // ANY_PORT, which would address every one of them to :0. + return if (PREFERRED_SERVER_PORT != ANY_PORT) PREFERRED_SERVER_PORT + else FALLBACK_SERVER_PORT + } + + /** Starts [sharedServer] if this is the first loader to ask. */ + @Synchronized + private fun startSharedServer(crashManager: CrashManager) { + if (sharedServer != null) { + return + } + + val server = HttpServer() + + sharedServer = server + sharedServerPort = bind(server, crashManager) + + Thread { + try { + server.listen() + } catch (e: Throwable) { + crashManager.log(e) + } + } + .also { + // daemon so it cannot keep the process alive: it is never asked to stop + it.isDaemon = true + it.name = "odr-http-server" + it.start() + } + } + /** The loopback address the server binds to. */ private const val SERVER_BIND_ADDRESS = "127.0.0.1"