From 21f03c9454cea30cd05a85758f33981ae805dcb1 Mon Sep 17 00:00:00 2001 From: Trevor Paley <10186337+TheUnlocked@users.noreply.github.com> Date: Wed, 8 May 2024 09:16:13 -0700 Subject: [PATCH] Add Mangamo (#2862) * Add Mangamo * Mangamo: Remove excess fields and use assertions * Mangamo: improve Firestore queries * Mangamo: synchronize auth * Mangamo: fix serialization bug when no fields are returned from query * Mangamo: exclude disabled manga from latest and improve chapter update query * Mangamo: clean up DTO objects * Mangamo: add custom 401 messaging, use emoji for payment indication * Mangamo: make manga/chapter URLs relative --- src/en/mangamo/build.gradle | 8 + .../mangamo/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 4020 bytes .../mangamo/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 2266 bytes .../mangamo/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 5246 bytes .../mangamo/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 9499 bytes .../res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 12869 bytes .../en/mangamo/FirestoreRequestFactory.kt | 187 +++++++ .../tachiyomi/extension/en/mangamo/Mangamo.kt | 463 ++++++++++++++++++ .../extension/en/mangamo/MangamoAuth.kt | 110 +++++ .../extension/en/mangamo/MangamoConstants.kt | 24 + .../extension/en/mangamo/MangamoHelper.kt | 72 +++ .../extension/en/mangamo/cachedBy.kt | 22 + .../extension/en/mangamo/dto/ChapterDto.kt | 14 + .../extension/en/mangamo/dto/DocumentDto.kt | 61 +++ .../en/mangamo/dto/FirebaseAuthDto.kt | 18 + .../en/mangamo/dto/MangamoLoginDto.kt | 8 + .../extension/en/mangamo/dto/PageDto.kt | 10 + .../extension/en/mangamo/dto/SeriesDto.kt | 35 ++ .../en/mangamo/dto/TokenRefreshDto.kt | 14 + .../extension/en/mangamo/dto/UserDto.kt | 8 + 20 files changed, 1054 insertions(+) create mode 100644 src/en/mangamo/build.gradle create mode 100644 src/en/mangamo/res/mipmap-hdpi/ic_launcher.png create mode 100644 src/en/mangamo/res/mipmap-mdpi/ic_launcher.png create mode 100644 src/en/mangamo/res/mipmap-xhdpi/ic_launcher.png create mode 100644 src/en/mangamo/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 src/en/mangamo/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/FirestoreRequestFactory.kt create mode 100644 src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/Mangamo.kt create mode 100644 src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/MangamoAuth.kt create mode 100644 src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/MangamoConstants.kt create mode 100644 src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/MangamoHelper.kt create mode 100644 src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/cachedBy.kt create mode 100644 src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/ChapterDto.kt create mode 100644 src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/DocumentDto.kt create mode 100644 src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/FirebaseAuthDto.kt create mode 100644 src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/MangamoLoginDto.kt create mode 100644 src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/PageDto.kt create mode 100644 src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/SeriesDto.kt create mode 100644 src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/TokenRefreshDto.kt create mode 100644 src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/UserDto.kt diff --git a/src/en/mangamo/build.gradle b/src/en/mangamo/build.gradle new file mode 100644 index 000000000..b5dd1e62a --- /dev/null +++ b/src/en/mangamo/build.gradle @@ -0,0 +1,8 @@ +ext { + extName = 'Mangamo' + extClass = '.Mangamo' + extVersionCode = 1 + isNsfw = false +} + +apply from: "$rootDir/common.gradle" diff --git a/src/en/mangamo/res/mipmap-hdpi/ic_launcher.png b/src/en/mangamo/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..32b7bb7d3c60919bcf88478350867e6578d8db33 GIT binary patch literal 4020 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 literal 0 HcmV?d00001 diff --git a/src/en/mangamo/res/mipmap-xhdpi/ic_launcher.png b/src/en/mangamo/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..2c6ae750bcd1e84defd9f5f008bc053cf5f613d5 GIT binary patch literal 5246 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 literal 0 HcmV?d00001 diff --git a/src/en/mangamo/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/mangamo/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..2a497942a625ae7255d6930d58d50b2ae698a359 GIT binary patch literal 12869 zcma)DWl&sAu)WK|0)a&n2<{pzIKdWo36KE6-Q9hI6Wlde(4Zl>2MF%&9(*A`i2Ks{d1pok0Iax`y=eF~|iUxUpsx8~1J~tp2H5qZB za+Gu*04M=DNihvieS{uHmcEpms7K@e?cM`)w-mm#VOWPcQNrzevU9qJ>CPzKXCyrcy}ICB z?1;FZTq8UJIv(t5JdTA%vz`PjMzaJGRj>Ee3}32K68$zg!D>?g9U$cnA%Dw2(gUIn z_D`_dG2m?2QPwY9K^)(UI|OgofMy|%?@Z$;oh8|i$LlS?-dSf!5sVP{=6%}|bn@I8 z{QvGOg9Ok_n0pj0g(2-by1O-|P1svBE|MvKa$jOr24((xgDNo-RdRU!FiTt3O!2$8 zNJrw;OUJjZ|M(tG2H~o7TY2`0rekI0}&2A|Qpu~Ywhdb1Y50GHyv@!GB zY%%&1?MBCq*7o*xa00EDLIQ0SRer@JH|BBvNmCQ^r+|qQzXypnxrDf?$5w>%s6wv4REAWsCE3hk0fAy_b=TdTL3X3LjiTKWyE*Fb1zsUBwDk4bkDS_&wC!^VMzFT z`KKoiGCj_4otQmk5)>G@H@RMR8T&R@q2n>S6)T#(Ps(7VI4~q9t=u*9;HQs193A;h zC;NK2Rc>{ch@Jaa6cLwl<4OA+8$I>>FfnprIr<;~PkEFW7Ij!{{hCPI)K;>cDr+y7 zPOVmZa2t7HSL=(RG(I=i;Q0H?t7`$6YQNN{mq5`z_@@O6ZnRFd;ja&6A1l05u}`y* zU&uASUdl>Lnsh&tWs8v9zU`YR{q8?gs;AV~ z&A#n&)oOs6F=g^k50*1Hi3}?K z)+^0!RSfyNtgLZ9F6E4X;(2jw_^riQ_Cs1z_mCW?j=G~7>=T71_O<>bmZpq~ihNs} zq)8uMG?4_ieb(()T1ig}5%%`*ulCgUy^wzx>;*v`GfyV_8p-CHy}D*C^KVyMz0QZi z_nO22LowSHsc*9&DInLxHy1?tKmw%k*vuZJk;|en*DVMPFjg?cLZcmS?;=(&ijsCl zzV(hBEl4IZlu-aileBQk!ff*V)2*Kswu=R60aHau*#X(aqK9+%cFVFCOKW(-7pn3N zD=Fh=f0%^B_8Z6}D!y9iCU^@p?^?|jTcJWA`XXpRD$&bs@8}vSP%aM~(ivP)zj$C2 zaS@elsWv4s$&306ShJ zLs|I8N)1^2WQjb}*VKh3fr*pdpX`myuGcJ*3 z$25hmX{h8GFq4#pllR(AKI}yCTG{RdrEhspH$J@l;#H+YZyDp@&sF$yOxg0%nh)FY zBBk{@mVvbHQ&H&a*RSVikpRq|<>T=h5QovTI%CTLl*u38{^4h$jz4lInm_S5mb=Kl z#$nZ!y*SY94&6l_>2F1)^nW-^vhVo1w`ddLfBDAxZ7M(UjGo{#I@$7}0WMU!B!FVp z^Iimb(Mpvoy+MI6Ea+Z#O6PEe?Xfyg-40tMu{v;uDg-wBv?};);R!L56toiQ-;^@&`#t>HKdC z+5IB+#-6e)Z(aZV7!aYMS%G5Yysut(6nk^Ofh{?wCsXU`B>&OYxQH!A#BCf43UEnC-PVVO0~wFWZaOx7jhAPEUxhN1STo6$z`$-R2F>Iz5`aM=mMb5KaeGnjk~MrZOnGC?Qv zbCqZJv(l1LhXfU;VFMY>0^23{F;+1B>MF=~NU8uywY0u)`q*t;fxn_VhHsAv7 zl#6~HxXWsD4T=IBum~C)yr;%p-@3a=U`Z{k8kUGY#_yFDhdbiE^!H|wJZ-SZrdq6~ z(pjms;%HOzUKk4hd%~nXJvN+v@H8)raZObjV1DSaBIP>BWbN?FHn z;D*C9L%%pCbXG=!Ka{jb;772vC0VZdB{zNJk$T_JnsTgVxa!L_0p-J4@nYyAqST)1 zbs#&SB*MN0Uzgp=kx;$#ITE|5^kDKhnl2sr#4Rjt1J1d6n~v?*dhcv~%ZR~1}zf`7Zi1WvwOJXE&eJs{SYIfgR|-HT0n zt!%I@gJJ`O3T>*C_L_F@^(*4GOXRs~g#pEUCp8sgwnqA2<<~lE)ZTr9FV$;BvP?DH z`HnVo9+UP+)>KdV01!7|(UC$WMrUW(I__-v_|;uapB0`zFu%;Nvj#qzR}wkvKfp_M zIzRDVa|gm#P#L=}ih7ovWIXvxaI1Ha$acqMI&j_4t)504@E(pV24i-)mC063ezl&D zGLiY%ir*np@n%KXngjKh+j~|#ilf_bB?}f^x4AW55$7X-xYWGD$*JGV&&V+0(V@U? z6zK4Rzf{`w4#7m`UGhTiYPQStd!qb_P!x6HSP~P{!(B&NH5T8V7Xtp(G)?o0m?0L~ zV?-0v&juS##APmt0L#)9sRS-0{%ya;^*fCm3@ITRx0q_7T^!*nzWBoq1_mSXG1fWL z0u#%B~lVG(EM-*}I>TLgEy2RUQn2KiUAq&?XfU<^z_Xh{{7 zYR#b&O}lt{zHf7aYB2}&rO8O`^iG~0Pa& zJG8DFAxv%OU+tlUixdluZwt3#6o>`;D`hy_TXXkifu8wb5f|iyXD+oOW;^2Wi2?IU zScqPWMS<2?TanGG%vxIfL58B@O1icwt*~k>>9fmvn#m-Uk}5BbxQT-gG;Z$A_7qP2 z!q)e-x*G}Z?O&*6f031`<%}a~F?9+}$1s%B#6|C^CJy6me#wflFK9y$9oKC;9v@;< z#%z|roeaNCM(C5m8g>uL{P1kU={P@hJDp!vsnY;Q0}Rjy)j$~;>IFJ8TEk5k$vHXY zO>sbesI+R-m`|wc3yiI{D`Nc<)g>gD^;Qe8nZP1=l*?Oet7*}{BU6NObD<&#T_7|y z$DVUIZevQ1l{) znWyKovp-e5yNDVmFW+;e>m2bVY8ttLuFS5(_y@-2;}*p^o3b*V+#M4N7V3*i(GRTw z*DW)jiCAI~w|*809S?{kk=uzP^W;gU`X{T5**n<+1mq7rfA2@B)*)08IIMPEGd=7WL&IbN57o5Bq*tUr&V3-`yag> z9Rvt~QW~+$t@Arz*p8g|%1F62(?$)sfI2M)#%4~Y7%NX56BJ%h##lAEfA_^!avb3~ z`)X2NR^9M7(x7pd0hc#@_hl;`GV*Ly9$yQW8o*8mJZctFhe^PK!WO(LT0VZ|O zx#Ij2Iuqx*)42d8*HtfO#J3(n*FG{y2cOKlaI^Tgdk#zFfHpfchYra!%vAKwHXqwB zyg_b8vdpb(fWhQ2OJ|(v;~(16qz#jU(x00BaU0P%@g^}&s8d$G+;!~6KOEP|&sBr> z^>XcefSYN8}l^uu$Fg)!zAi4nOYG&tYwNse&{nG!u@}{ z#lifPPg5_sa|O?s#YkkW2BY&3JTUSB>`khA)F1C0JSBJoq8*Cy`d&KO6a2EngG3@y zP4fq}YTzAxITTMrxx}qW+NcZVs0iZbF@Cm~%&pG~-zs zi}&}2QsHO%14|LBm9|{d2D7@fpf1_1@T3%*0oyg^SLZLBdWDyFaASW1A3!max1!b$ zUeTbuSCpxC`%_fCV55dF%hOmqRiD`6+`!9hkzdr2f^FSZ=xu@)^S?;>9%>s@;cTyS zwcCs0i1p)(=%vf+Sih*fKn&9+H40xDB*POxXCIiM};@NajbwyRd?G`td{+`r7MN+517CWZTSum1cS{ ztS#W`=iqEIk`*ntq&}1*Kx86;uJM=j1bXdnBtB_yE=%C zR&hiGC25bb1Ut~^MB_28ZQ+!m-6pzAnxCj_UC1SaF?SaOg2^J;w(;OPFWu3qpZBPV zdo0KEPQSgrS^Npg9opE&KQbx6*uKTJ7OX0;G5SCs`IF6o3p$EK1K{kxQYxCwn3)CM zDx#~zVgy8`V^jA7x zr}3g%RyxLS1TgdRE4Y-keJWL-l9oLuvM@C%s=2V7tPh946bZ!hF0D9ILnm0N@Oct& ztMqhkL;^?00g=)OT_J^(XGKqA?2jNCTrv^wpUc((H*ysU-*Zfep_-|{S0$P z+Jq5O(q-rV@0j_wwiXrk@%7lqbrhpJ^+VklFBAFDE(0#hrf;0+x|8cE)Y_yeV^EtGAzO2BwD3_x_?l$_^00n zrbH^t@AZg*fW#xd`G3xiy{}Uh?q>{2!J8QybQvm(204G>DZxIcb`A<8*J!fNUz-P$ z@<4H(H@zh4&v4~$0EoFc=DC+c7?I#lj}G~%9m}*zWd(57h_Ayom8ac`-? zjp=<*WNE?aCFD(D{TzvE2<&?tr$%Lk2?;8_=)vb)N>1cnre4f))$F+NbUa?a?$ET? zW_jTk8$94kj7;*0Z&fyU4KQX5%B(Q*iHv zCB+$j`-?8hlekB!w56--(3I#YD1B90S!(7p#L5ZWN?h7+2cAKrFm zX>D&BW7YNZXjwVpNBn#cIIm=Z!m~FRX=5rfiqgp&7O-_W0(mgq!v&gM%OM@MH!N56H?~@&VdtS?gGs|bFx{Mr@M~te3K$HRZk5a6=%1Za=>8H zV@V_!UNGjZsD&vJXEx^~L2X%b=omx|JD%&XM{$)M$80ZafTVGn$=4F1rGI5_X0*9$ zp#>9YFLb}P=dtd6;e=TX>RcFZTy^cmZ0Ok>WxC&2VZMCFd*i70opnGdlOj?#?H!ut zN7RYT&iG@^PC*p)0XNnpVdYek+HgXQzV{pCSXQcxRT9`r`x!RQW?Pkfz>yS3A3RN8 z(m8Z2lbIeSZH(;xTO?1Ukq1^bEn|zPTeWBLQGI|1xfU2@S#KbNfN|P(cw{vqFa9i} zJ=jrefc;u;Hfznq4sZx#c%%g3%m4^)W2NG=EoCcKLWrmvil-BLqFc4U4w1Ra0(M&KA#d4)IwMUr4_1u(JSa= zEN+pVJ{d1#Uf8ap-pGwL%oBP>=Nni~&wc)_dez9u}}{pvXGhGn}{+=s9Yp#g$eX??DBK zUL~7l6&0SNNi zlsmm9qbU=nNXLcE|mC*DO(%^^yOXckb~`3vea)%~fb$Z2atnq}p; zoj_quktW%sNXFwO#8W}sxl&XLqOJWixGu~)=UDzqhH@qL00Zl#k<7ds3lHV;0Tlik z3h=$Y0vz)P=sBT`B)g>7_O39swZ|{Jt3dlbbo~IkmYlogYYFDLRizl9mR8T4Iir2bjE)9lA_Ovrs5<@r{%SZ-emD2qOMB@c8Jo*J%Ec#uAO>t)okBQnfXZUTj% zdtee1u@n?;H8%5NmIW=64 z4Rhh=#ib;%;mc0u^!Bp|k}YC}&EwvEqsVf(IkHgiLZmL!5Ph2j?mq=_eb#;O7<99V z>Mu+ZzMWqrEZ<)jiHSQdV1dqOuR;_;2DtKq{pFYK+2~Y$Zw>+>HxsexQ9x4A1t66 za>`3Lt2O}U>?Rp`j0ZK_7n{D4+*M-3$pX1eKR6Dci0(QnfbO@$#YvAY=R3y|Y->05 zMD_{!+j440)}#bBUG&jVrSA>BVWGcU3n|ix$SJz@aw=ScH$zPCasl}3)np4eM)g}k z9?0L9jsu?Jf?eYP0PEp;2N>e@aDQ$@-$b(tV4nh*$b74PXcQYzT_BX&Kg3bILPo*6 z6Z+@7TR?<7XQUyQX{h68y(iXkP)1izF^JX`E7{5#UcDX&@!XDQ+?W*rVA)y2 ztvBQ|G0=YNOWf(st5FCV6=a8IuS#?@*veV9Ak@5%ex#*W{L8y}jgLlR$iHq({xFmv zE(amK_}e5VFzJN3I|pc&_SnFa;z;^@DN8rdy$+acG!(OhWiK;*c za(JC=;2-44SJh%vxF7b=kl?%Cu=!#RHQSr+JOFu-<6MUye@+#pSX5?i<1PTSjYz zy$!qb_sQ-IP=qXfWm74tkXi(tV$e{wf#Xbd*4<8~p8bGjLDp$IY(%Pyg2tUux=U}T zHPJ#PGBW?t(XoEBWe3t$8VXTu1Gd30LL_X&5kF*XJmGTpXy^${%|>AM?2c;x0#@)Y>=)#zi6b*fC6*0R=~WXu{8lcATscU z!&n{MIB4XAct=sOAG!trNGIYKoh@-B8u>FdkiUyy#uC<1L92iQ6l`KKvK_RF-Fs+Xw zx0d{`l>?yo;XVZk26I6)@%(MG)*sE6y5ePNr6PUbVF2O&Jw*`;XBl{CL>tp$HVZU$ zdJ^f2!}kIlpT&X3d*>r?u-$P!F^nsT^K*@BbsxrBGc7@XYK)q^C?P+G=59{at%Xn6 zus%)a=`fes`N9ukAdH^2p=+I(+%#nLJeXEX3(QSNfC?{y{8i9+cHVCP%mE|bdH@Fs z+9LuCr?Mt~+CKZyD<3c&fidbdI~Qma=`Rip; zt=iQ~#F?9pZL#G7|3y8YtT25X1_gA)k$^l2LB_SUF&SZF+}69}g`+ul14ZEG^)v*o zg0}>$zs{b*bhM*sZK`7T4FK(+M4DNt1;)$(ujjvd6hQ+xu8*1+k_p(WmaJz0dvx+i z!0-hYV5t%$)MHvfoLeU<0yUgHoC6ka#p!YQC>DF(4jF+0`p8-@`sk>4F25(#Lx!ex zz=~-cd4E!5*>t$DT77T2h0xeZsdjwuth-N+MWMdoK8{EVu#Vqr8f@!5pTaMRkT2M_Nz|bUHomU=5(W|6qg;i4;eq>% zeDmIINm{&c2LXAKMkS)PXtS$IVIWgXQQuVTGha1C{Op`BNX$xitew#Y#_=Ewr7`aYul#FoXB?WO z{-BBLD-LwaaZmt9C$#qCAE9xGJQZ+SA&$h>7ZlFB?k2Ik_qCAq;g%Y1*z+x@Lh5%+ z|174+^RZlu(c+&#ZUo%K|INq04be2tIHxqRLevZfvNZ$TutnMH5d^ZuN^S+-crj@B z@gFcwS|QJHf)O(QCke7XN4j{Fq{<~JQy?bUYrSko;C)fq?j@R5VE0YY2h?eY-{RmU z>6nzx+#~fG2Zx--uNZhfu|2=@FmqA|lT1Z`y{j2z$N4K1fIs?~a+|(ep&$f~ynpC# zx(AA7*0=}8lRpMx91XSTls^6K&Z88tuO~iP!T^@zhS4D)x?@%3PgGIv=jI*=Je`jL z`_Dv4O|b~nH96)+^;HY(g!bEzK zU$j^80k?N|I#-~A?iCM%f3y7dM6HkB{uaqLf@PrlLOWm{k5J&Q>FfIG*1tz1g-9;*xljT3q{zYuy3ee6U2(1s}?mzb1;Z*=fx zqEAuDORU4~`fM9YpO=Uc(G=G|$3PapKcWaZ%rLY=3Zd`#0It0{oYk2r{Sj9n-Orus z@@nl4z>(1OtaSWLbq#ar{==%{;bl|C2jqhI#e-KV+_mIWb@OY`!Zs&16r{tsC&R+X z?V~%%djI;3dA-xcM-JEWH`zEH>Eqch3{3geT!zhxFA$odK zvNiR0F~}E>qjz?f_NO0NyiK$TxU&wzWMY@Q4<}n5-^#~;1xYn>>rfoOh-++UY9jYF zZ~$Zpe`OVjmGe78oETDINp&_K7Lz`dWgO_r4#T98N?q%8&H@OVqr~UBY~X4Br)DEk z)xqXz4NWc*hqbexs$JH+rD2m9KNZ_R>yv<2=nJN>`;5j$z|Lya zZ#DKdW+`*O=5nw`b9OwR7 zT5k*k33gPMO&VwaUW=SuOSNxxTC~R+nOYU`wUA=5Y%EUAYA_sUQ_JGY`NvZ>1U{pi zA$~tk9*2jc;EQl(KS$5Ud6UdLu$nYFNNmxa9Kw30G(2BmHu9UBphkv*mEs|R&U#}A zG16}{oK*e{4Y#+>GC`qeNa|<}u_jYP<^-BTqGza7t;85x%=T$>esPx7oJv1lLt>I| z+`Srem?bRqA-rmJ!i`zu?RoUv4g7n7+W_6|z1~Q)J^&|9!^vWP zj?Pih1pg+1Io|F|%67iu3_FvxpL%I5xXFmKWUly6$js=9G@!LPO(^tVRZGlXI8AuL zLjC!;_20}6TypJMh=Tp{ic|-iy38&jLI6sJ4xUu+2 z2-st;SGI}>b=~nFy}4 z_>Z9_G74Y*4pGc@kRsxBYk?%TsJ!Z6d|Nzp+(S7sXI;g-x+R)vb`cA614>KJ>sVQ7QL@;Z3J}5lCEmSRMDeC>&N%-U#iNiLlq$zxQ?2|s7F!fMic!fKHBkLE zfw?Y?#!dR78)vE8t+;I#*y@cgo5t8q9H)wiU{o{BmbSfX^)m9lo5f@s`HaG)*07FD ztQh=fDB|&=*il(Mq6qC0=a4unV%{t@iGWq!a!zmNtFEVUU1k|sxtB7(*w=>&{_k8V zr*8kQ#(@v=ZlkV$+rsRkf^{16;)W(?%YzIU_G-epQE&|3vFLsA3#m0?-Z;5|{tPr> zhQG>N?Vkhh$53ST{Z1Cw3J?6Z(v-0M;5vc&&~hs6)@>3JrhUfQ_D`=3q*@1w^K=u} z2u)hHpm=qOmPjSN*>V8&!#)|9QYoW2;_l9Z5vMTZX!M~tt>Cxk2&n(T`5n#Hs7PW? zLMuGkMJdPeSW(!rqJLDBV}m(v%+HTJF72r~WVj>b7G)1uet9*+G5=P2SWt*`bo=F)ZI+_F)9OW_pwuTm(n!>V#CHZ@vPo7;mket9P1Gnp-q_r!hz7y#M$q_ zUv9}I8ca{5+)p*|s#Y~q_?ry)W%Ai_&%v#c2h9l%UZb%e&h^XqF0b~H^Jrq+tG8^; zvR)pJ4dNtef|aEud_Zpu*-|)!N9mQId$FuV?2j8FQe6@rpN!8%u@GF8pA{39N?WT58$tHC!Leu-r26ygMbgQ_te9A53j;n;U`Q>49JESNCD@2@F;(!<{= zli=e9vT5>1Bx_Nez61%zO^66}4)dwt9aP z0Q#ip_-Gd)|G->m^l=c_Q?1q1YNP+d?cw(fcnz)d2jYT*#}86-?j;^)+XJYT1|8(| zyhj0PF!uYWHa{8&&<1(Ug$5UC!y)G~AI76z^k|Iu6k^KyZItuiwf(2r>bA@Xv=o%1 z001D|Tm-_8-B+bJh$jI!JsdKgo%{G)yfXP5+IgG#Vz9sKD_xJphnX{#OwwU+h~Mfxp7sfPXDR$p#i;e_w!FE(Gc51Nlw-QVt zk+mPRqP77pV3vk|>IA^iZ;o031_0}*PyTudv@+Kh7cAd1w43odXx4uug*%olIY0@ka4%YOSnFcO;wz^uO;XnDhi+t?)xD> z*p2L#EVU&twvcAW5F^YEwK*syR)2SEk5}&v_7NIBiR&e$Nuj zZzq2#5jXca8G;6OVV`)J_bgqeN7}1M!~aXC047?LX2YJPX$!XhGvGaiM87qoM7!!8 P@Lc4ilqD<04FmrN9M?%G literal 0 HcmV?d00001 diff --git a/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/FirestoreRequestFactory.kt b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/FirestoreRequestFactory.kt new file mode 100644 index 000000000..5c2546aec --- /dev/null +++ b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/FirestoreRequestFactory.kt @@ -0,0 +1,187 @@ +package eu.kanade.tachiyomi.extension.en.mangamo + +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST +import okhttp3.Headers +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody + +class FirestoreRequestFactory( + private val helper: MangamoHelper, + private val auth: MangamoAuth, +) { + + open class DocumentQuery { + var fields = listOf() + } + + class CollectionQuery : DocumentQuery() { + + var filter: Filter? = null + var orderBy: List? = null + + // Firestore supports cursors, but this is simpler and probably good enough + var limit: Int? = null + var offset: Int? = null + + class OrderByTerm(private val field: String, private val direction: Direction) { + enum class Direction { ASCENDING, DESCENDING } + + fun toJsonString() = """{"direction":"$direction","field":{"fieldPath":"$field"}}""" + } + + fun ascending(field: String) = + OrderByTerm(field, OrderByTerm.Direction.ASCENDING) + fun descending(field: String) = + OrderByTerm(field, OrderByTerm.Direction.DESCENDING) + + sealed interface Filter { + fun toJsonString(): String + + class CompositeFilter(private val op: Operator, private val filters: List) : Filter { + enum class Operator { AND, OR } + + override fun toJsonString(): String = + """{"compositeFilter":{"op":"$op","filters":[${filters.joinToString { it.toJsonString() }}]}}""" + } + + class FieldFilter(private val fieldName: String, private val op: Operator, private val value: Any?) : Filter { + enum class Operator { + LESS_THAN, + LESS_THAN_OR_EQUAL, + GREATER_THAN, + GREATER_THAN_OR_EQUAL, + EQUAL, + NOT_EQUAL, + ARRAY_CONTAINS, + IN, + ARRAY_CONTAINS_ANY, + NOT_IN, + } + + override fun toJsonString(): String { + val valueTerm = + when (value) { + null -> "{\"nullValue\":null}" + is Int -> "{\"integerValue\":$value}" + is Double -> "{\"doubleValue\":$value}" + is String -> "{\"stringValue\":\"$value\"}" + is Boolean -> "{\"booleanValue\":$value}" + else -> throw Exception("${value.javaClass} not supported in field filters") + } + return """{"fieldFilter":{"op":"$op","field":{"fieldPath":"$fieldName"},"value":$valueTerm}}""" + } + } + + class UnaryFilter(private val fieldName: String, private val op: Operator) : Filter { + enum class Operator { IS_NAN, IS_NULL, IS_NOT_NAN, IS_NOT_NULL } + + override fun toJsonString(): String { + return """{"unaryFilter":{"op":"$op","field":{"fieldPath":"$fieldName"}}}""" + } + } + } + + fun and(vararg filters: Filter) = + Filter.CompositeFilter(Filter.CompositeFilter.Operator.AND, filters.toList()) + fun or(vararg filters: Filter) = + Filter.CompositeFilter(Filter.CompositeFilter.Operator.OR, filters.toList()) + fun isLessThan(fieldName: String, value: Any?) = + Filter.FieldFilter(fieldName, Filter.FieldFilter.Operator.LESS_THAN, value) + fun isLessThanOrEqual(fieldName: String, value: Any?) = + Filter.FieldFilter(fieldName, Filter.FieldFilter.Operator.LESS_THAN_OR_EQUAL, value) + fun isGreaterThan(fieldName: String, value: Any?) = + Filter.FieldFilter(fieldName, Filter.FieldFilter.Operator.GREATER_THAN, value) + fun isGreaterThanOrEqual(fieldName: String, value: Any?) = + Filter.FieldFilter(fieldName, Filter.FieldFilter.Operator.GREATER_THAN_OR_EQUAL, value) + fun isEqual(fieldName: String, value: Any?) = + Filter.FieldFilter(fieldName, Filter.FieldFilter.Operator.EQUAL, value) + fun isNotEqual(fieldName: String, value: Any?) = + Filter.FieldFilter(fieldName, Filter.FieldFilter.Operator.NOT_EQUAL, value) + fun contains(fieldName: String, value: Any?) = + Filter.FieldFilter(fieldName, Filter.FieldFilter.Operator.ARRAY_CONTAINS, value) + fun isIn(fieldName: String, value: Any?) = + Filter.FieldFilter(fieldName, Filter.FieldFilter.Operator.IN, value) + fun containsAny(fieldName: String, value: Any?) = + Filter.FieldFilter(fieldName, Filter.FieldFilter.Operator.ARRAY_CONTAINS_ANY, value) + fun isNotIn(fieldName: String, value: Any?) = + Filter.FieldFilter(fieldName, Filter.FieldFilter.Operator.NOT_IN, value) + fun isNaN(fieldName: String) = + Filter.UnaryFilter(fieldName, Filter.UnaryFilter.Operator.IS_NAN) + fun isNull(fieldName: String) = + Filter.UnaryFilter(fieldName, Filter.UnaryFilter.Operator.IS_NULL) + fun isNotNaN(fieldName: String) = + Filter.UnaryFilter(fieldName, Filter.UnaryFilter.Operator.IS_NOT_NAN) + fun isNotNull(fieldName: String) = + Filter.UnaryFilter(fieldName, Filter.UnaryFilter.Operator.IS_NOT_NULL) + } + + fun getDocument(path: String, query: DocumentQuery.() -> Unit = {}): Request { + val queryInfo = DocumentQuery() + query(queryInfo) + + val urlBuilder = "${MangamoConstants.FIRESTORE_API_BASE_PATH}/$path".toHttpUrl().newBuilder() + + for (field in queryInfo.fields) { + urlBuilder.addQueryParameter("mask.fieldPaths", field) + } + + val headers = Headers.Builder() + .add("Authorization", "Bearer ${auth.getIdToken()}") + .build() + + return GET(urlBuilder.build(), headers) + } + + private fun deconstructCollectionPath(path: String): Pair { + val pivot = path.lastIndexOf('/') + if (pivot == -1) { + return Pair("", path) + } + return Pair(path.substring(0, pivot), path.substring(pivot + 1)) + } + + fun getCollection( + fullPath: String, + query: CollectionQuery.() -> Unit = {}, + ): Request { + val queryInfo = CollectionQuery() + query(queryInfo) + + val structuredQuery = mutableMapOf() + + val (path, collectionId) = deconstructCollectionPath(fullPath) + + structuredQuery["from"] = "{\"collectionId\":\"$collectionId\"}" + + if (queryInfo.fields.isNotEmpty()) { + structuredQuery["select"] = "{\"fields\":[${queryInfo.fields.joinToString { + "{\"fieldPath\":\"$it\"}" + }}]}" + } + + if (queryInfo.filter != null) { + structuredQuery["where"] = queryInfo.filter!!.toJsonString() + } + + if (queryInfo.orderBy != null) { + structuredQuery["orderBy"] = "[${queryInfo.orderBy!!.joinToString { it.toJsonString() }}]" + } + + structuredQuery["offset"] = queryInfo.offset?.toString() + structuredQuery["limit"] = queryInfo.limit?.toString() + + val headers = helper.jsonHeaders.newBuilder() + .add("Authorization", "Bearer ${auth.getIdToken()}") + .build() + + val body = "{\"structuredQuery\":{${ + structuredQuery.entries + .filter { it.value != null } + .joinToString { "\"${it.key}\":${it.value}" } + }}}".toRequestBody() + + return POST("${MangamoConstants.FIRESTORE_API_BASE_PATH}/$path:runQuery", headers, body) + } +} diff --git a/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/Mangamo.kt b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/Mangamo.kt new file mode 100644 index 000000000..f0ed6dafd --- /dev/null +++ b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/Mangamo.kt @@ -0,0 +1,463 @@ +package eu.kanade.tachiyomi.extension.en.mangamo + +import android.app.Application +import android.content.SharedPreferences +import androidx.preference.EditTextPreference +import androidx.preference.MultiSelectListPreference +import androidx.preference.PreferenceScreen +import eu.kanade.tachiyomi.extension.en.mangamo.MangamoHelper.Companion.parseJson +import eu.kanade.tachiyomi.extension.en.mangamo.dto.ChapterDto +import eu.kanade.tachiyomi.extension.en.mangamo.dto.DocumentDto +import eu.kanade.tachiyomi.extension.en.mangamo.dto.PageDto +import eu.kanade.tachiyomi.extension.en.mangamo.dto.QueryResultDto +import eu.kanade.tachiyomi.extension.en.mangamo.dto.SeriesDto +import eu.kanade.tachiyomi.extension.en.mangamo.dto.UserDto +import eu.kanade.tachiyomi.extension.en.mangamo.dto.documents +import eu.kanade.tachiyomi.extension.en.mangamo.dto.elements +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.network.asObservableSuccess +import eu.kanade.tachiyomi.source.ConfigurableSource +import eu.kanade.tachiyomi.source.model.FilterList +import eu.kanade.tachiyomi.source.model.MangasPage +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import eu.kanade.tachiyomi.source.online.HttpSource +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import rx.Observable +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import java.io.IOException + +class Mangamo : ConfigurableSource, HttpSource() { + + override val name = "Mangamo" + + override val lang = "en" + + override val baseUrl = "https://mangamo.com" + + override val supportsLatest = true + + private val preferences: SharedPreferences by lazy { + Injekt.get().getSharedPreferences("source_$id", 0x0000) + } + + private val helper = MangamoHelper(headers) + + private var userToken = "" + get() { + if (field == "") { + field = preferences.getString(MangamoConstants.USER_TOKEN_PREF, "")!! + + if (field == "") { + field = MangamoAuth.createAnonymousUserToken(client) + preferences.edit() + .putString(MangamoConstants.USER_TOKEN_PREF, field) + .apply() + } + } + return field + } + + private val auth by cachedBy({ userToken }) { + MangamoAuth(helper, client, userToken) + } + + private val firestore by cachedBy({ auth }) { + FirestoreRequestFactory(helper, auth) + } + + private val user by cachedBy({ Pair(userToken, firestore) }) { + val response = client.newCall( + firestore.getDocument("Users/$userToken") { + fields = listOf(UserDto::isSubscribed.name) + }, + ).execute() + response.body.string().parseJson>().fields + } + + private val coinMangaPref + get() = preferences.getStringSet(MangamoConstants.HIDE_COIN_MANGA_PREF, setOf())!! + private val exclusivesOnlyPref + get() = preferences.getStringSet(MangamoConstants.EXCLUSIVES_ONLY_PREF, setOf())!! + + override val client: OkHttpClient = super.client.newBuilder() + .addNetworkInterceptor { + val request = it.request() + val response = it.proceed(request) + + if (request.url.toString().startsWith("${MangamoConstants.FIREBASE_FUNCTION_BASE_PATH}/page")) { + if (response.code == 401) { + throw IOException("You don't have access to this chapter") + } + } + response + } + .addNetworkInterceptor { + val response = it.proceed(it.request()) + + // Add Cache-Control to Firestore queries + if (it.request().url.toString().startsWith(MangamoConstants.FIRESTORE_API_BASE_PATH)) { + return@addNetworkInterceptor response.newBuilder() + .header("Cache-Control", "public, max-age=${MangamoConstants.FIRESTORE_CACHE_LENGTH}") + .build() + } + response + } + .build() + + private val seriesRequiredFields = listOf( + SeriesDto::id.name, + SeriesDto::name.name, + SeriesDto::name_lowercase.name, + SeriesDto::description.name, + SeriesDto::authors.name, + SeriesDto::genres.name, + SeriesDto::ongoing.name, + SeriesDto::releaseStatusTag.name, + SeriesDto::titleArt.name, + ) + + private fun processSeries(dto: SeriesDto) = SManga.create().apply { + author = dto.authors?.joinToString { it.name } + description = dto.description + genre = dto.genres?.joinToString { it.name } + status = helper.getSeriesStatus(dto) + thumbnail_url = dto.titleArt + title = dto.name!! + url = helper.getSeriesUrl(dto) + initialized = true + } + + private fun parseMangaPage(response: Response, filterPredicate: (SeriesDto) -> Boolean = { true }): MangasPage { + val collection = response.body.string().parseJson>() + + val isDone = collection.documents.size < MangamoConstants.BROWSE_PAGE_SIZE + + val results = collection.elements.filter(filterPredicate) + + return MangasPage(results.map { processSeries(it) }, !isDone) + } + + // Popular manga + + override fun popularMangaRequest(page: Int): Request = firestore.getCollection("Series") { + limit = MangamoConstants.BROWSE_PAGE_SIZE + offset = (page - 1) * MangamoConstants.BROWSE_PAGE_SIZE + + val fields = seriesRequiredFields.toMutableList() + this.fields = fields + + if (coinMangaPref.contains(MangamoConstants.HIDE_COIN_MANGA_OPTION_IN_BROWSE)) { + fields += SeriesDto::onlyTransactional.name + } + + val prefFilters = + if (exclusivesOnlyPref.contains(MangamoConstants.EXCLUSIVES_ONLY_OPTION_IN_BROWSE)) { + isEqual(SeriesDto::onlyOnMangamo.name, true) + } else { + null + } + + filter = and( + *listOfNotNull( + isEqual(SeriesDto::enabled.name, true), + prefFilters, + ).toTypedArray(), + ) + } + + override fun popularMangaParse(response: Response): MangasPage = parseMangaPage(response) { + if (coinMangaPref.contains(MangamoConstants.HIDE_COIN_MANGA_OPTION_IN_BROWSE)) { + if (it.onlyTransactional == true) { + return@parseMangaPage false + } + } + true + } + + // Latest manga + + override fun latestUpdatesRequest(page: Int): Request = firestore.getCollection("Series") { + limit = MangamoConstants.BROWSE_PAGE_SIZE + offset = (page - 1) * MangamoConstants.BROWSE_PAGE_SIZE + + val fields = seriesRequiredFields.toMutableList() + this.fields = fields + + fields += SeriesDto::enabled.name + + if (coinMangaPref.contains(MangamoConstants.HIDE_COIN_MANGA_OPTION_IN_BROWSE)) { + fields += SeriesDto::onlyTransactional.name + } + + if (exclusivesOnlyPref.contains(MangamoConstants.EXCLUSIVES_ONLY_OPTION_IN_BROWSE)) { + fields += SeriesDto::onlyOnMangamo.name + } + + orderBy = listOf(descending(SeriesDto::updatedAt.name)) + + // Filters can't be used with orderBy because firebase wants there to be indexes + // on various fields to support those queries and we can't create them. + // Therefore, all filtering has to be done on the client in the parse method. + } + + override fun latestUpdatesParse(response: Response): MangasPage = parseMangaPage(response) { + if (it.enabled != true) { + return@parseMangaPage false + } + if (coinMangaPref.contains(MangamoConstants.HIDE_COIN_MANGA_OPTION_IN_BROWSE)) { + if (it.onlyTransactional == true) { + return@parseMangaPage false + } + } + if (exclusivesOnlyPref.contains(MangamoConstants.EXCLUSIVES_ONLY_OPTION_IN_BROWSE)) { + if (it.onlyOnMangamo != true) { + return@parseMangaPage false + } + } + true + } + + // Search manga + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request = firestore.getCollection("Series") { + limit = MangamoConstants.BROWSE_PAGE_SIZE + offset = (page - 1) * MangamoConstants.BROWSE_PAGE_SIZE + + val fields = seriesRequiredFields.toMutableList() + this.fields = fields + + if (coinMangaPref.contains(MangamoConstants.HIDE_COIN_MANGA_OPTION_IN_BROWSE)) { + fields += SeriesDto::onlyTransactional.name + } + + if (exclusivesOnlyPref.contains(MangamoConstants.EXCLUSIVES_ONLY_OPTION_IN_SEARCH)) { + fields += SeriesDto::onlyOnMangamo.name + } + + // Adding additional filters makes Firestore complain about wanting an index + // so we filter on the client in parse, just like for Latest. + + filter = and( + isEqual(SeriesDto::enabled.name, true), + isGreaterThanOrEqual(SeriesDto::name_lowercase.name, query.lowercase()), + isLessThanOrEqual(SeriesDto::name_lowercase.name, query.lowercase() + "\uf8ff"), + ) + } + + override fun searchMangaParse(response: Response): MangasPage = parseMangaPage(response) { + if (coinMangaPref.contains(MangamoConstants.HIDE_COIN_MANGA_OPTION_IN_SEARCH)) { + if (it.onlyTransactional == true) { + return@parseMangaPage false + } + } + if (exclusivesOnlyPref.contains(MangamoConstants.EXCLUSIVES_ONLY_OPTION_IN_SEARCH)) { + if (it.onlyOnMangamo != true) { + return@parseMangaPage false + } + } + true + } + + // Manga details + + override fun getMangaUrl(manga: SManga): String { + return baseUrl + manga.url + } + + override fun mangaDetailsRequest(manga: SManga): Request { + val uri = getMangaUrl(manga).toHttpUrl() + + val seriesId = uri.queryParameter(MangamoConstants.SERIES_QUERY_PARAM)!!.toInt() + + return firestore.getDocument("Series/$seriesId") { + fields = seriesRequiredFields + } + } + + override fun mangaDetailsParse(response: Response): SManga { + val dto = response.body.string().parseJson>().fields + return processSeries(dto) + } + + // Chapter list section + + override fun fetchChapterList(manga: SManga): Observable> { + val uri = getMangaUrl(manga).toHttpUrl() + + val seriesId = uri.queryParameter(MangamoConstants.SERIES_QUERY_PARAM)!!.toInt() + + val seriesObservable = client.newCall( + firestore.getDocument("Series/$seriesId") { + fields = listOf( + SeriesDto::maxFreeChapterNumber.name, + SeriesDto::maxMeteredReadingChapterNumber.name, + SeriesDto::onlyTransactional.name, + ) + }, + ).asObservableSuccess().map { response -> + response.body.string().parseJson>().fields + } + + val chaptersObservable = client.newCall( + firestore.getCollection("Series/$seriesId/chapters") { + fields = listOf( + ChapterDto::enabled.name, + ChapterDto::id.name, + ChapterDto::seriesId.name, + ChapterDto::chapterNumber.name, + ChapterDto::name.name, + ChapterDto::createdAt.name, + ChapterDto::onlyTransactional.name, + ) + + orderBy = listOf(descending(ChapterDto::chapterNumber.name)) + }, + ).asObservableSuccess().map { response -> + response.body.string().parseJson>().elements + } + + val hideCoinChapters = coinMangaPref.contains(MangamoConstants.HIDE_COIN_MANGA_OPTION_CHAPTERS) + + return Observable.combineLatest(seriesObservable, chaptersObservable) { series, chapters -> + chapters + .mapNotNull { chapter -> + if (chapter.enabled != true) { + return@mapNotNull null + } + + val isUserSubscribed = user.isSubscribed == true + + val isFreeChapter = chapter.chapterNumber!! <= (series.maxFreeChapterNumber ?: 0) + val isMeteredChapter = chapter.chapterNumber <= (series.maxMeteredReadingChapterNumber ?: 0) + val isCoinChapter = chapter.onlyTransactional == true || + (series.onlyTransactional == true && !isFreeChapter) + + if (hideCoinChapters && isCoinChapter) { + return@mapNotNull null + } + + SChapter.create().apply { + chapter_number = chapter.chapterNumber + date_upload = chapter.createdAt!! + name = chapter.name + + if (isCoinChapter) { + " \uD83E\uDE99" // coin emoji + } else if (isFreeChapter || isUserSubscribed) { + "" + } else if (isMeteredChapter) { + " \uD83D\uDD52" // three-o-clock emoji + } else { + // subscriber chapter + " \uD83D\uDD12" // lock emoji + } + url = helper.getChapterUrl(chapter) + } + } + } + } + + override fun chapterListParse(response: Response): List = + throw UnsupportedOperationException() + + private fun getPagesImagesRequest(series: Int, chapter: Int): Request { + return POST( + "${MangamoConstants.FIREBASE_FUNCTION_BASE_PATH}/page/$series/$chapter", + helper.jsonHeaders, + "{\"idToken\":\"${auth.getIdToken()}\"}".toRequestBody(), + ) + } + + override fun pageListRequest(chapter: SChapter): Request { + val uri = (baseUrl + chapter.url).toHttpUrl() + + val seriesId = uri.queryParameter(MangamoConstants.SERIES_QUERY_PARAM)!!.toInt() + val chapterId = uri.queryParameter(MangamoConstants.CHAPTER_QUERY_PARAM)!!.toInt() + + return getPagesImagesRequest(seriesId, chapterId) + } + + override fun pageListParse(response: Response): List { + val data = response.body.string().parseJson>() + + return data.map { + Page(it.pageNumber - 1, imageUrl = it.uri) + }.sortedBy { it.index } + } + + override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException() + + override fun setupPreferenceScreen(screen: PreferenceScreen) { + val userTokenPref = EditTextPreference(screen.context).apply { + key = MangamoConstants.USER_TOKEN_PREF + summary = "If you are a paying user, enter your user token to authenticate." + title = "User Token" + + dialogMessage = """ + Copy your token from the Mangamo app by going to My Manga > Profile icon (top right) > About and tapping on the "User" string at the bottom. + + Then replace the auto-generated token you see below with your personal token. + """.trimIndent() + + setDefaultValue("") + + setOnPreferenceChangeListener { _, newValue -> + userToken = newValue as String + true + } + } + + val hideCoinMangaPref = MultiSelectListPreference(screen.context).apply { + key = MangamoConstants.HIDE_COIN_MANGA_PREF + title = "Hide Coin Manga" + + summary = """ + Hide manga that require coins. + + For technical reasons, manga where a subscription only gives access to some chapters are not considered coin manga, even if coins are required to access all chapters. + """.trimIndent() + + entries = arrayOf( + "Hide in Popular/Latest", + "Hide in Search", + "Hide Coin Chapters", + ) + + entryValues = arrayOf( + MangamoConstants.HIDE_COIN_MANGA_OPTION_IN_BROWSE, + MangamoConstants.HIDE_COIN_MANGA_OPTION_IN_SEARCH, + MangamoConstants.HIDE_COIN_MANGA_OPTION_CHAPTERS, + ) + + setDefaultValue(setOf()) + } + + val exclusivesOnly = MultiSelectListPreference(screen.context).apply { + key = MangamoConstants.EXCLUSIVES_ONLY_PREF + title = "Only Show Exclusives" + summary = "Only show Mangamo-exclusive manga." + + entries = arrayOf( + "In Popular/Latest", + "In Search", + ) + + entryValues = arrayOf( + MangamoConstants.EXCLUSIVES_ONLY_OPTION_IN_BROWSE, + MangamoConstants.EXCLUSIVES_ONLY_OPTION_IN_SEARCH, + ) + + setDefaultValue(setOf()) + } + + screen.addPreference(userTokenPref) + screen.addPreference(hideCoinMangaPref) + screen.addPreference(exclusivesOnly) + } +} diff --git a/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/MangamoAuth.kt b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/MangamoAuth.kt new file mode 100644 index 000000000..95036d8a5 --- /dev/null +++ b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/MangamoAuth.kt @@ -0,0 +1,110 @@ +package eu.kanade.tachiyomi.extension.en.mangamo + +import eu.kanade.tachiyomi.extension.en.mangamo.MangamoHelper.Companion.parseJson +import eu.kanade.tachiyomi.extension.en.mangamo.dto.FirebaseAuthDto +import eu.kanade.tachiyomi.extension.en.mangamo.dto.FirebaseRegisterDto +import eu.kanade.tachiyomi.extension.en.mangamo.dto.MangamoLoginDto +import eu.kanade.tachiyomi.extension.en.mangamo.dto.TokenRefreshDto +import eu.kanade.tachiyomi.network.POST +import okhttp3.Headers +import okhttp3.OkHttpClient +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.internal.EMPTY_HEADERS + +class MangamoAuth( + private val helper: MangamoHelper, + private val client: OkHttpClient, + private val userToken: String, +) { + + private lateinit var currentToken: String + private lateinit var refreshToken: String + private var expirationTime: Long = 0 + + fun getIdToken(): String { + synchronized(this) { + if (!this::currentToken.isInitialized) { + obtainInitialIdToken() + } + refreshIfNecessary() + return currentToken + } + } + + fun forceRefresh() { + obtainInitialIdToken() + } + + private fun expireIn(seconds: Long) { + expirationTime = System.currentTimeMillis() + (seconds - 1) * 1000 + } + + private fun obtainInitialIdToken() { + val mangamoLoginResponse = client.newCall( + POST( + "${MangamoConstants.FIREBASE_FUNCTION_BASE_PATH}/v3/login", + helper.jsonHeaders, + "{\"purchaserInfo\":{\"originalAppUserId\":\"$userToken\"}}".toRequestBody(), + ), + ).execute() + + val customToken = mangamoLoginResponse.body.string().parseJson().accessToken + + val googleIdentityResponse = client.newCall( + POST( + "https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key=${MangamoConstants.FIREBASE_API_KEY}", + EMPTY_HEADERS, + "{\"token\":\"$customToken\",\"returnSecureToken\":true}".toRequestBody(), + ), + ).execute() + + val tokenInfo = googleIdentityResponse.body.string().parseJson() + + currentToken = tokenInfo.idToken + refreshToken = tokenInfo.refreshToken + expireIn(tokenInfo.expiresIn) + } + + private fun refreshIfNecessary() { + if (System.currentTimeMillis() > expirationTime) { + val headers = Headers.Builder() + .add("Content-Type", "application/x-www-form-urlencoded") + .build() + + val refreshResponse = client.newCall( + POST( + "https://securetoken.googleapis.com/v1/token?key=${MangamoConstants.FIREBASE_API_KEY}", + headers, + "grant_type=refresh_token&refresh_token=$refreshToken".toRequestBody(), + ), + ).execute() + + if (refreshResponse.code == 200) { + val tokenInfo = refreshResponse.body.string().parseJson() + + currentToken = tokenInfo.idToken + refreshToken = tokenInfo.refreshToken + expireIn(tokenInfo.expiresIn) + } else { + // Refresh token may have expired + obtainInitialIdToken() + } + } + } + + companion object { + fun createAnonymousUserToken(client: OkHttpClient): String { + val googleIdentityResponse = client.newCall( + POST( + "https://identitytoolkit.googleapis.com/v1/accounts:signUp?key=${MangamoConstants.FIREBASE_API_KEY}", + EMPTY_HEADERS, + "{\"returnSecureToken\":true}".toRequestBody(), + ), + ).execute() + + val tokenInfo = googleIdentityResponse.body.string().parseJson() + + return tokenInfo.localId + } + } +} diff --git a/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/MangamoConstants.kt b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/MangamoConstants.kt new file mode 100644 index 000000000..2b638c87d --- /dev/null +++ b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/MangamoConstants.kt @@ -0,0 +1,24 @@ +package eu.kanade.tachiyomi.extension.en.mangamo + +object MangamoConstants { + const val USER_TOKEN_PREF = "userToken" + const val HIDE_COIN_MANGA_PREF = "hideCoinManga" + const val EXCLUSIVES_ONLY_PREF = "onlyShowExclusives" + + const val HIDE_COIN_MANGA_OPTION_IN_BROWSE = "inBrowse" + const val HIDE_COIN_MANGA_OPTION_IN_SEARCH = "inSearch" + const val HIDE_COIN_MANGA_OPTION_CHAPTERS = "chapters" + + const val EXCLUSIVES_ONLY_OPTION_IN_BROWSE = "inBrowse" + const val EXCLUSIVES_ONLY_OPTION_IN_SEARCH = "inSearch" + + const val FIREBASE_API_KEY = "AIzaSyCU00GBJ4BPSK5owyaXvHZIXwMJ5Rq5F8c" + const val FIREBASE_FUNCTION_BASE_PATH = "https://us-central1-mangamoapp1.cloudfunctions.net/api" + const val FIRESTORE_API_BASE_PATH = "https://firestore.googleapis.com/v1/projects/mangamoapp1/databases/(default)/documents" + const val FIRESTORE_CACHE_LENGTH = 600 + + const val SERIES_QUERY_PARAM = "series" + const val CHAPTER_QUERY_PARAM = "chapter" + + const val BROWSE_PAGE_SIZE = 50 +} diff --git a/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/MangamoHelper.kt b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/MangamoHelper.kt new file mode 100644 index 000000000..3c55d9f4e --- /dev/null +++ b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/MangamoHelper.kt @@ -0,0 +1,72 @@ +package eu.kanade.tachiyomi.extension.en.mangamo + +import eu.kanade.tachiyomi.extension.en.mangamo.dto.ChapterDto +import eu.kanade.tachiyomi.extension.en.mangamo.dto.DocumentDto +import eu.kanade.tachiyomi.extension.en.mangamo.dto.DocumentDtoInternal +import eu.kanade.tachiyomi.extension.en.mangamo.dto.DocumentSerializer +import eu.kanade.tachiyomi.extension.en.mangamo.dto.SeriesDto +import eu.kanade.tachiyomi.source.model.SManga +import kotlinx.serialization.KSerializer +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.serializer +import okhttp3.Headers + +class MangamoHelper(headers: Headers) { + + companion object { + + val json = Json { + isLenient = true + ignoreUnknownKeys = true + explicitNulls = false + + serializersModule = SerializersModule { + contextual(DocumentDto::class) { DocumentSerializer(DocumentDtoInternal.serializer(it[0])) } + } + } + + @Suppress("UNCHECKED_CAST") + inline fun String.parseJson(): T { + return when (T::class) { + DocumentDto::class -> json.decodeFromString( + DocumentSerializer(serializer() as KSerializer>) as KSerializer, + this, + ) + else -> json.decodeFromString(this) + } + } + } + + val jsonHeaders = headers.newBuilder() + .set("Content-Type", "application/json") + .build() + + private fun getCatalogUrl(series: SeriesDto): String { + val lowercaseHyphenated = series.name_lowercase!!.replace(' ', '-') + return "/catalog/$lowercaseHyphenated" + } + + fun getSeriesUrl(series: SeriesDto): String { + return "${getCatalogUrl(series)}?${MangamoConstants.SERIES_QUERY_PARAM}=${series.id}" + } + + fun getChapterUrl(chapter: ChapterDto): String { + return "?${MangamoConstants.SERIES_QUERY_PARAM}=${chapter.seriesId}&${MangamoConstants.CHAPTER_QUERY_PARAM}=${chapter.id}" + } + + fun getSeriesStatus(series: SeriesDto): Int = + when (series.releaseStatusTag) { + "Ongoing" -> SManga.ONGOING + "series-complete" -> SManga.COMPLETED + "Completed" -> SManga.COMPLETED + "Paused" -> SManga.ON_HIATUS + else -> + if (series.ongoing == true) { + SManga.ONGOING + } else { + SManga.UNKNOWN + } + } +} diff --git a/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/cachedBy.kt b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/cachedBy.kt new file mode 100644 index 000000000..5231b5f02 --- /dev/null +++ b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/cachedBy.kt @@ -0,0 +1,22 @@ +package eu.kanade.tachiyomi.extension.en.mangamo + +import kotlin.reflect.KProperty + +@Suppress("ClassName") +class cachedBy(private val dependencies: () -> Any?, private val callback: () -> T) { + private object UNINITIALIZED + private var cachedValue: Any? = UNINITIALIZED + private var lastDeps: Any? = UNINITIALIZED + + @Suppress("UNCHECKED_CAST") + operator fun getValue(thisRef: Any?, property: KProperty<*>): T { + synchronized(this) { + val newDeps = dependencies() + if (newDeps != lastDeps) { + lastDeps = newDeps + cachedValue = callback() + } + return cachedValue as T + } + } +} diff --git a/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/ChapterDto.kt b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/ChapterDto.kt new file mode 100644 index 000000000..4c4397ae8 --- /dev/null +++ b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/ChapterDto.kt @@ -0,0 +1,14 @@ +package eu.kanade.tachiyomi.extension.en.mangamo.dto + +import kotlinx.serialization.Serializable + +@Serializable +class ChapterDto( + val id: Int? = null, + val chapterNumber: Float? = null, + val createdAt: Long? = null, + val enabled: Boolean? = null, + val name: String? = null, + val onlyTransactional: Boolean? = null, + val seriesId: Int? = null, +) diff --git a/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/DocumentDto.kt b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/DocumentDto.kt new file mode 100644 index 000000000..b6fe8d1e0 --- /dev/null +++ b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/DocumentDto.kt @@ -0,0 +1,61 @@ +package eu.kanade.tachiyomi.extension.en.mangamo.dto + +import kotlinx.serialization.Contextual +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonTransformingSerializer +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject + +@Serializable +class DocumentWrapper(val document: DocumentDto?) + +typealias QueryResultDto = @Serializable List> + +val QueryResultDto.documents + get() = mapNotNull { it.document } + +val QueryResultDto.elements + get() = mapNotNull { it.document?.fields } + +typealias DocumentDto = @Contextual DocumentDtoInternal + +@Serializable +class DocumentDtoInternal( + val fields: T, +) + +class DocumentSerializer(dataSerializer: KSerializer>) : + JsonTransformingSerializer>(dataSerializer as KSerializer>) { + + override fun transformDeserialize(element: JsonElement): JsonElement { + val objMap = element.jsonObject.toMap(HashMap()) + + if (objMap.containsKey("fields")) { + objMap["fields"] = reduceFieldsObject(objMap["fields"]!!) + } else { + objMap["fields"] = JsonObject(mapOf()) + } + + return JsonObject(objMap) + } + + private fun reduceFieldsObject(fields: JsonElement): JsonElement { + return JsonObject(fields.jsonObject.mapValues { reduceField(it.value) }) + } + + private fun reduceField(element: JsonElement): JsonElement { + val valueContainer = element.jsonObject.entries.first() + + return when (valueContainer.key) { + "arrayValue" -> valueContainer.value.jsonObject["values"]?.jsonArray + ?.map { reduceField(it) } + .let { JsonArray(it ?: listOf()) } + "mapValue" -> reduceFieldsObject(valueContainer.value.jsonObject["fields"]!!) + else -> valueContainer.value + } + } +} diff --git a/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/FirebaseAuthDto.kt b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/FirebaseAuthDto.kt new file mode 100644 index 000000000..36c113580 --- /dev/null +++ b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/FirebaseAuthDto.kt @@ -0,0 +1,18 @@ +package eu.kanade.tachiyomi.extension.en.mangamo.dto + +import kotlinx.serialization.Serializable + +@Serializable +class FirebaseAuthDto( + val idToken: String, + val refreshToken: String, + val expiresIn: Long, +) + +@Serializable +class FirebaseRegisterDto( + val localId: String, + val idToken: String, + val refreshToken: String, + val expiresIn: Long, +) diff --git a/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/MangamoLoginDto.kt b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/MangamoLoginDto.kt new file mode 100644 index 000000000..c67545ba6 --- /dev/null +++ b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/MangamoLoginDto.kt @@ -0,0 +1,8 @@ +package eu.kanade.tachiyomi.extension.en.mangamo.dto + +import kotlinx.serialization.Serializable + +@Serializable +class MangamoLoginDto( + val accessToken: String, +) diff --git a/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/PageDto.kt b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/PageDto.kt new file mode 100644 index 000000000..415589d13 --- /dev/null +++ b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/PageDto.kt @@ -0,0 +1,10 @@ +package eu.kanade.tachiyomi.extension.en.mangamo.dto + +import kotlinx.serialization.Serializable + +@Serializable +class PageDto( + val id: Int, + val pageNumber: Int, + val uri: String, +) diff --git a/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/SeriesDto.kt b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/SeriesDto.kt new file mode 100644 index 000000000..301eaf89a --- /dev/null +++ b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/SeriesDto.kt @@ -0,0 +1,35 @@ +package eu.kanade.tachiyomi.extension.en.mangamo.dto + +import kotlinx.serialization.Serializable + +@Serializable +class SeriesDto( + val id: Int? = null, + val authors: List? = null, + val description: String? = null, + val enabled: Boolean? = null, + val genres: List? = null, + val maxFreeChapterNumber: Int? = null, + val maxMeteredReadingChapterNumber: Int? = null, + val name: String? = null, + @Suppress("PropertyName") + val name_lowercase: String? = null, + val ongoing: Boolean? = null, + val onlyOnMangamo: Boolean? = null, + val onlyTransactional: Boolean? = null, + val releaseStatusTag: String? = null, + val titleArt: String? = null, + val updatedAt: Long? = null, +) + +@Serializable +class AuthorDto( + val id: Int, + val name: String, +) + +@Serializable +class GenreDto( + val id: Int, + val name: String, +) diff --git a/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/TokenRefreshDto.kt b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/TokenRefreshDto.kt new file mode 100644 index 000000000..c9d63af62 --- /dev/null +++ b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/TokenRefreshDto.kt @@ -0,0 +1,14 @@ +package eu.kanade.tachiyomi.extension.en.mangamo.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +class TokenRefreshDto( + @SerialName("expires_in") + val expiresIn: Long, + @SerialName("id_token") + val idToken: String, + @SerialName("refresh_token") + val refreshToken: String, +) diff --git a/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/UserDto.kt b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/UserDto.kt new file mode 100644 index 000000000..c050b00ab --- /dev/null +++ b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/UserDto.kt @@ -0,0 +1,8 @@ +package eu.kanade.tachiyomi.extension.en.mangamo.dto + +import kotlinx.serialization.Serializable + +@Serializable +class UserDto( + val isSubscribed: Boolean? = null, +)