From 15c6f18750a78474fe5ed2084070a2a60693a353 Mon Sep 17 00:00:00 2001 From: Moxie Marlinspike Date: Wed, 21 Oct 2015 15:32:29 -0700 Subject: [PATCH] Support for an audio view to allow in-app playback of audio. Closes #4270 // FREEBIE --- .../ic_download_circle_fill_white_48dp.png | Bin 0 -> 2450 bytes .../ic_pause_circle_fill_white_48dp.png | Bin 0 -> 831 bytes .../ic_play_circle_fill_white_48dp.png | Bin 0 -> 786 bytes .../ic_download_circle_fill_white_48dp.png | Bin 0 -> 1573 bytes .../ic_pause_circle_fill_white_48dp.png | Bin 0 -> 524 bytes .../ic_play_circle_fill_white_48dp.png | Bin 0 -> 559 bytes .../ic_download_circle_fill_white_48dp.png | Bin 0 -> 3178 bytes .../ic_pause_circle_fill_white_48dp.png | Bin 0 -> 959 bytes .../ic_play_circle_fill_white_48dp.png | Bin 0 -> 1038 bytes .../ic_download_circle_fill_white_48dp.png | Bin 0 -> 5510 bytes .../ic_pause_circle_fill_white_48dp.png | Bin 0 -> 1728 bytes .../ic_play_circle_fill_white_48dp.png | Bin 0 -> 1554 bytes .../ic_download_circle_fill_white_48dp.png | Bin 0 -> 2426 bytes .../ic_pause_circle_fill_white_48dp.png | Bin 0 -> 2389 bytes .../ic_play_circle_fill_white_48dp.png | Bin 0 -> 2128 bytes res/layout/audio_view.xml | 91 +++++ res/layout/conversation_activity.xml | 33 +- res/layout/conversation_item_received.xml | 28 +- res/layout/conversation_item_sent.xml | 22 +- ...utton.xml => media_view_remove_button.xml} | 4 +- res/layout/thumbnail_view.xml | 5 - res/values/attrs.xml | 5 + .../securesms/BindableConversationItem.java | 3 +- .../securesms/ConversationAdapter.java | 29 +- .../securesms/ConversationFragment.java | 3 +- .../securesms/ConversationItem.java | 74 +++- .../securesms/ConversationUpdateItem.java | 2 +- .../securesms/ImageMediaAdapter.java | 2 +- .../securesms/MessageDetailsActivity.java | 3 +- .../securesms/attachments/UriAttachment.java | 20 +- .../audio/AudioAttachmentServer.java | 376 ++++++++++++++++++ .../securesms/audio/AudioSlidePlayer.java | 237 +++++++++++ .../securesms/components/AnimatingToggle.java | 8 + .../securesms/components/AudioView.java | 233 +++++++++++ .../components/RemovableMediaView.java | 65 +++ .../securesms/components/ThumbnailView.java | 96 +---- .../securesms/database/MmsSmsDatabase.java | 1 - .../securesms/mms/AttachmentManager.java | 32 +- .../securesms/mms/SlideClickListener.java | 7 + .../thoughtcrime/securesms/mms/SlideDeck.java | 11 + 40 files changed, 1228 insertions(+), 162 deletions(-) create mode 100644 res/drawable-hdpi/ic_download_circle_fill_white_48dp.png create mode 100644 res/drawable-hdpi/ic_pause_circle_fill_white_48dp.png create mode 100644 res/drawable-hdpi/ic_play_circle_fill_white_48dp.png create mode 100644 res/drawable-mdpi/ic_download_circle_fill_white_48dp.png create mode 100644 res/drawable-mdpi/ic_pause_circle_fill_white_48dp.png create mode 100644 res/drawable-mdpi/ic_play_circle_fill_white_48dp.png create mode 100644 res/drawable-xhdpi/ic_download_circle_fill_white_48dp.png create mode 100644 res/drawable-xhdpi/ic_pause_circle_fill_white_48dp.png create mode 100644 res/drawable-xhdpi/ic_play_circle_fill_white_48dp.png create mode 100644 res/drawable-xxhdpi/ic_download_circle_fill_white_48dp.png create mode 100644 res/drawable-xxhdpi/ic_pause_circle_fill_white_48dp.png create mode 100644 res/drawable-xxhdpi/ic_play_circle_fill_white_48dp.png create mode 100644 res/drawable-xxxhdpi/ic_download_circle_fill_white_48dp.png create mode 100644 res/drawable-xxxhdpi/ic_pause_circle_fill_white_48dp.png create mode 100644 res/drawable-xxxhdpi/ic_play_circle_fill_white_48dp.png create mode 100644 res/layout/audio_view.xml rename res/layout/{thumbnail_view_remove_button.xml => media_view_remove_button.xml} (79%) create mode 100644 src/org/thoughtcrime/securesms/audio/AudioAttachmentServer.java create mode 100644 src/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java create mode 100644 src/org/thoughtcrime/securesms/components/AudioView.java create mode 100644 src/org/thoughtcrime/securesms/components/RemovableMediaView.java create mode 100644 src/org/thoughtcrime/securesms/mms/SlideClickListener.java diff --git a/res/drawable-hdpi/ic_download_circle_fill_white_48dp.png b/res/drawable-hdpi/ic_download_circle_fill_white_48dp.png new file mode 100644 index 0000000000000000000000000000000000000000..47d4fc7cd6787ece8d47356080d50d8a53e2cf81 GIT binary patch literal 2450 zcmV;D32pX?P)9|xX$+)wsyb;67bK!u^`XWRq!=ZYrE>A1DWpZP(o)03$Ww&ja$ z`Ixl5Sh7HE)23C`6E2DJBcJS|wuj(@4|WI~fF&DOP+)hb&-3l~u0M8X+4FCkKa;Q;*cA+-rjEY_xB$e9v<%N?(Y8X(9lp%YisK_K=OHL*oH%%)2D7? z-KqXalx>Nt?7SpwE0ktz+o=MMZh|@L}uHrAs5Y@_z-f4Pltc zWcbM-buy@fwsG+@Og;S($p7jX9;;!BwGP{Vp%41fuyqV|2~}!3J3Fn($;sCc{3pVM zl`&ajn-K-DTRxSbjw>h!8`@Uk0QmYCK1b-=jHNDsE5?nwYUAVMFD)-Gzbk>~c{~xt z5GE*0QzBMDDfs>)VmPWpvvq8>gB0g|`}SGW)6+jij42U@>0uHIDNI?R^mkLJRmAcp zV=`l`I;3sV+uOV6!Gj0C(ZVpDwsp`>#G>pbmfGt2O6^2U?;y4oGuEmBcMDZ^PngxLN8kvgSNax7a&DixwbhYnfy@87?J?f3A14vIJ7<9Il?1PqLq z>@#2k@N@20fg^y}@atRL!J$LThFK7%lxKT0tle#=+7Qfr7knp)*hK^Bt@B2WZE*}5y(0=HMe@g?kY%02a$ zD?w`i*|TRyA@o<+^#KfsOee5 z+2xEpWnh}w#n5 ze&KGSL?G73+>VTl^y7Q0l^=h4R!Wb+)||+#m0Y<~xSJ^1A(bGS9IsGmBDOAq8p77( zxFJ{BSA1NFKy1=Izs;%m;&^FwT)eoNhc&ayz#%CKj<7=k)m~zu!Sl(nJfhU2qGX|b$Zfdy@#63B@V&pd@g;Y} z{{V817rWf@oHqftyDHl>8yg${#lvT^r>BQqGr7om4BdcAYv0=1+M;^=%{R#_KPKZi zj&5#lZl=$lKR+5@0+h4JU5Z9UAq=pNfH~h8A2RKpQ{7{*GZFm zr2v$QSFT)no&Gqx%dg|O(k+U#GGo--9B_Tj*fDw(b=@w;LNl1>aTX{M-zGZUuDv)Zao;h>oWf=QkezaN5It>s{@N|nftt1u| z2RY&24RAbaa`po? zyQLcubz153)rM^!egUUJlVh;rvvc~(pTkanDGInmq|28tzrhc`M=Vb9^p|%dnf_u} z-~_4{orot;86v4(NZHTt=5JwqIPMdu%>MT9Fs9)BgdU@uf|tuf?k;Mvi@7ffah#n~ z@P2G^iVVgiPBp7O;z^u2W1B157uaU5bQ0&=u}tD5jA_6voKj1NPuVo!+j<&se7&)f zvz(O8$=P%)leOzeq(WvI6Upx)=7?(L3Xtji$z-yV)BG8LnQjr(8=;gbPAkNCw`xlf z43ShBg8(Zk0kJ-MZH-=C@*5= zOkv6_>irNtPuJz5UK1|M!cTMQHaM&cnTVH#zXu<~Ps_4!g)%P|_%>e#z;$LIwiYr0(y~e94IJ9ZLnj)=VP4U_6?Cd;B%iWoonSamC&3#_Y zS5q`zZc-`!SgjX33-)>(bHG#DJTXSufG^bN?XZr}5U2tg0#(5O0{{U3|6BM@*mDBg QDF6Tf07*qoM6N<$g7KcpOaK4? literal 0 HcmV?d00001 diff --git a/res/drawable-hdpi/ic_pause_circle_fill_white_48dp.png b/res/drawable-hdpi/ic_pause_circle_fill_white_48dp.png new file mode 100644 index 0000000000000000000000000000000000000000..340c65b4f4c86d16458f18c4acdcd9cd253d3575 GIT binary patch literal 831 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 X4ZseTzB0+N00000NkvXXu0mjfDU|h3 literal 0 HcmV?d00001 diff --git a/res/drawable-mdpi/ic_pause_circle_fill_white_48dp.png b/res/drawable-mdpi/ic_pause_circle_fill_white_48dp.png new file mode 100644 index 0000000000000000000000000000000000000000..55b14334b2e297f755b72af38074550b6c0f232d GIT binary patch literal 524 zcmV+n0`vWeP)004R> z004l5008;`004mK004C`008P>0026e000+ooVrmw00002VoOIv0RM-N%)bBt010qN zS#tmY3ljhU3ljkVnw%H_00D$aL_t(o!|m8TP69y~#_<)GLOKJXP~jF3I0%UpoP&=8 zpwdFfCg=%BXbg$xAY4F#jj-Cf+x#tz@nd#oXJ==l1rHSo%nyc{cizFl$@bwK02@G# zCIc3%@d*j>S+k%|gIs!`%7`aq*>X+A94Ip*BrX9SB@>{-3%c=XCj;`_ku)BzE>PeB zlPoFd0C~gVKL|G-xFt=zIG{t?T&jRFuc?7OML96TMy4{Ls5*tpY0%@T~$n5eTgU!5;vbPf_MS za^}mlZx!f@K*K6fiz;?oYrtbvv2(bw1`MOmgNmgC=Z!Pjk?C0h#_}_{q}*W3-84<&J3E)9@$mN?>ovbi!{JYK+jrMsq9)>}S6@Kd>=UeW$do{1 zTaAA%%w(6q3$EBGYR>Tct~I~L*6A^4#g2f09V_N^seP5S!(R#i8F&XP-&&5p{*u4| O0000004R> z004l5008;`004mK004C`008P>0026e000+ooVrmw00002VoOIv0RM-N%)bBt010qN zS#tmY3ljhU3ljkVnw%H_00E~-L_t(o!|m9yP69y?fZ-F5LOO$?P~icjhUkNsNI~LT zAUpsnEreWxCiWyWhQ#L}_y7`YM6@#TuK6vDQFC{@vwKIhU}(Ah$gnfBdk)TOKh7FR z1ITbhixE?n1OzOZGNQ>LndCr;4v)meoC}KfK!E`Py72MXu>tD5AdMw8Ga$<~rs3gA zfgE?(#F(4}$XYJ{Nw|98iX`FbfI3OzL%5&bxzq%5mb-h_b9UNq2A zKIU-1y^uJM0y2u8^9eZQxDf#}Hp0M>=Ap0e$x>Af11%vSOVwEz7>NN%s)k`;Y5?d{ zH3I+DXXM>${Y;%Rm)5jN-RrmMCG`j@ubIwfBbJ{<%}T{7Wnuqm@uS4<-4V=UnTr!-~*;JZi5O_oQ41Z002ovPDHLkV1im<<(>cl literal 0 HcmV?d00001 diff --git a/res/drawable-xhdpi/ic_download_circle_fill_white_48dp.png b/res/drawable-xhdpi/ic_download_circle_fill_white_48dp.png new file mode 100644 index 0000000000000000000000000000000000000000..4fcc4d38a49d5557b9ee1d1d81752f8db59839f6 GIT binary patch literal 3178 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 literal 0 HcmV?d00001 diff --git a/res/drawable-xhdpi/ic_play_circle_fill_white_48dp.png b/res/drawable-xhdpi/ic_play_circle_fill_white_48dp.png new file mode 100644 index 0000000000000000000000000000000000000000..12ed3bfcb86e94e2a9aaeb0e38cb1d7330df1e39 GIT binary patch literal 1038 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 WFy;j-YGiu=0000004R> z004l5008;`004mK004C`008P>0026e000+ooVrmw00002VoOIv0RM-N%)bBt010qN zS#tmY3ljhU3ljkVnw%H_00oLkL_t(|+U?!nOIK$c!11G7uEv()*sqov($&5f13@&j z5`sa2i#CUBG?|%{!5W(v-V60Nbdh064K|b9?n;u3YHg8C8oN$3b|rrOF{!@t-o=*@ zjr{sO=RBY1`JChPdG0-rFTS7i{C-YA;7g(62|SPo@<1NQ19>11(5T6xX@PDZ1H9f+_GE114VU#|aDN8fZHhTDlCE~;) zqa5eGv;kGn!wpt36|0PMjPi5uN zKvzgp{Ae0zA8Cs{CV@&EtaHhC8cWOpwUfp;WD2NMWD)sm=P&)KVtzx#0JVu!FW^J| zbWC*64A31Rpn%sIbP9CS1kf&_%Ki8E-KJVYe4q=$KyFj*TwI_Eq1|D;@Dz8cw#3$W zKs{nr`~Q<%rP|SWKsT%bxk|OMI6&J3mv&zEJQt~!zVdbtvfo<&klg> zr`qMHKxLv6tc9LzH`TIiiUzb>v|8&m4>nV+F&a>xQy`nE)*B6I)G?5~R2zu~H0vD5 zR;oRU0`!(}rHY>ow~=b);elGz0NF^j=I}t>ih$%(tus8(X=OmNsn#DJ=#n}h$y6H( z4>Yb2NG{dJ!UIhy1(Hd%$?!n4YJntDZ8|(qRxyzIRGSMAw4xlyY^p7X2U=ARWHQxq z9>_S5{uIl&O)>wH_RV~}W?pYcF{7iSczr_+HLisRy66Y1g@I0~1Ip-{?(h?4>j3TD zP*bi=;ejgE0A+Me+6T<|WgowAn2(4Hhpv~NGuoG)o1FsL+`P~bb^FVrBOtrmU*>r= z>ISM&CqNm?8>oh(?=U;=0LcCpysqd#?{A1roNve320U&NDC1-s@YQ(xq>otxax!Kkd^f4CMAWRK1AN~Qo%D3<`Wa)RyS)|NIcIxHIml^b|fC?3+am{2NKP5 zM*b$w&D=|H=des3D4>eRiR78#16$W6*Cp^q&ILZQdExc}o+gUt2_M_Ovi);%iQ#!x z+=XtYFa1B95TYDky83KHTLKd8N%UWji4TwLSN^(Qi2Cj^_vxBC#f|<(P(YQ^ zuH)S({T>ORfXxaAAZN^c)z=JAei*A~q(2{aNNNKppjOFz(L}fj4SPTVrJQs;F`MOt z*)Oh|1eptx01O+b01gWR<6 zN^X_<&a*U7zBF)7Xq&Za&X2 zU4=L9CKo7QHql5gBTR>adBh04G!!~CkQSilrGjQU>1T*BCYffAWpdC8Br&59EP7kO%TW9>@cEAYVL?2NEd%0-uEMIeYgg!2kdN07*qoM6N<$ Ef{P&5?EnA( literal 0 HcmV?d00001 diff --git a/res/drawable-xxxhdpi/ic_download_circle_fill_white_48dp.png b/res/drawable-xxxhdpi/ic_download_circle_fill_white_48dp.png new file mode 100644 index 0000000000000000000000000000000000000000..db6ce0ff60188ac881707fb17e712edbc97ef564 GIT binary patch literal 2426 zcmV-=35E8FP)G000002VoOIv0063u zBQgL0010qNS#tmY3ljhU3ljkVnw%H_000McNliru-wG8K92N?NGQj`<2@6R?K~#9! z?VW9GmE{@7|963w4mT~QA9PvPmZsPS%G-em1Gm%!Gxo*EHXNeGmu?W|7PD;G+?=O# zTe2n9;0HFrWEBYlGgQqM+*=?>Nfi_k#!v>d#AP9H3gTSoDQ*AzQ0H8<_BrQ%eV*s~ zCH>fQp6CC+uj{(+`+3fF1A-t3f*=TjAP9mW2!bF8f*>Rv6_3kgCF)U&I@I7gWRO7y zSD^ysCb3dXyFU&m%aABRGmz(4#Jj z0mTWQ!Oge{i_vK0`iSG$kKOo_x*!IOr&TkSV;L3{J>H`*jDy&PE_F-{_$cYG$4aa~ zlY_7N8+Ks3Iw2E)_X{jnvzxAYk$19E#INOgzD-|Tr=w?&0 z#Gk}=78oZA{4pmcWF^e$*oyfv95*jxl{#hnOUkUv--bi6lpnYOhj~}rHyO4t?)-+6 zW150E4PBU>fD-m9zO2r~&H%PxCz3L!?#HcaU#w~X@5CRFoFz>Lf9A><8Ni3|6v`7d zldiy4J`j(>tGJ#?r6c$nz%pz^TY@*U9>+SxU=3gyHsg+j?$YFjV{umyj_B&3T1mYP@{ z8!&*=@CvGsKKhZ>-y_nl0jxw*#NesVCMgE+{4u# zYXBSY8m1uK{0no`X)h7LNq9Q-@&lj5R-MQ9Iw|N!F@4r+nuYj*hZMw_IF!1%|JZAS zIqD6k5x_D$A@aXZ1)eDNZMsrZ&^jy>-9Nw*e8XW1Vg`Rt>Edf_@@Pw!aVhw%?S9e40E#z6*b0Ki<2%3a4aKW2-DPCwj$tung5K-MBHyF z0{9916IVN}X>p7I;37V)25dyYSKZ1VTdMFCJ1OYvqOEzQA_qAeaoqW|I-V&A02>tPoX;V1;1=*o-DowFsJ{HamzK0n0_xB3W+MfMud+kwjE1(maD1 zoU`B5AW^d*U>Ma=9#KY<^=2RP$D9dRYAyxcBzl%Wi#Y=pi<%{{#83_3ROJ11*o}1! z07fuXT`~~?i=bpUmp~a7nMpxe(XvFcW(=4mN|w4F76beeLAi0WJ1g<7uT?8GGt(NO~f2Gog)8r13=P$Me(jezS!MGdaiHy|S_YEUf(_$4C- z#6AM95*0O=qHjQjsHj1Oz5(T;q6Xz+Ky5lc2yLnzP8A3Z8vzw1?^AZ zC8O=HbD@t&=FFZ}seeJ|40j;UrAU7Ato|jPHr@n2mm2v={Zp8D&_W;nTuS68U)C?e z{TA-EJv^= zy5wWdj?2s?Mt(9<1IuWSB=blLJFJfuEpRG7u*ckmPqn@nbC8G} zCZEwon8^lj>~VZ_axUiblMzqZi~3s1F30O6!->do@&SMs%#NR#9u5%C#dh?QWTWX2 zJ{DICyvQH9hkEok<92x1FEfycJR~1GYH>RKkc-{uC&_vn)3|+cv%s7Dd#pqiTgf~OZyU={NrSrCN$=jif`DXt|$ zu^)OrWARbT1cbU(yE%QAHax1x|b z`3G=|zWj!I#;JX{8%&k_M62pC@j-@~_Ni@nFjWS8Up*7vpy*&)_=P#ujGhq2$FU{_ zqkn_CFH{4{(23TB%ioE6mBD&vv~jB%!ClyxX!(C-R(|81a~0KEJepYff2qEy;xOCe z552#-V&j#Q@7d7G(lMP}iOxhdz8^rVor$Nlb5eODo=waK#d+LfX4z%E2K?XZIEzDx zkpBR(w&b_kzFwWdVvoNlFvTW(UcD9PO_SApC$@$65?3W*->TkuqXRQ^vo;|$j1&1F z2Vy1uWj??OiCV+3iQTc0e=nyea2ZL(H9Q}@_~+P0g`~o3xITcF+sA&vyu;}$ww_FA zX?`?nRjrn^NhcTw{x=pyANi<#RlAl+c49?R~^9)>`~?_Psm^YLmdLKd^ojOj(k#1Ky4 zD30I|dO{RF76yF4iVD=B4z;MmwWvl0)tG_`l%oPb0fQ*u-^e46ew;-wdeMtMmA>~? s1wjx5K@bE%5ClOG1VIo4K?sun0OSGXY#5w#jsO4v07*qoM6N<$fG00000WV@Og>004R> z004l5008;`004mK004C`008P>0026e000+ooVrmw00002VoOIv0RM-N%)bBt010qN zS#tmY3ljhU3ljkVnw%H_00_-VL_t(|+U=chY?aj=$3M41O9w{F?F;qAXz9uz6$%t0 zte~q2CcGk6L5Yq(8y%)@lT6*52xBl`EWypW$b_0zmZ8>RS(Z)rFKiAP9MXcogcWKb z8bj9BI)_u=im&!yk*^ZT9uzmv)!L`j}VAO?s5f*2qM z2x5R3Acz5CfFK5l0fHDH1_)w+7$Aht7*GD6g_Kc3DU-OKG-=XIpn!bxfdK~S=K?+S z(9Jo{aE4Ca=AsXOSSqI%WF|77DrPW)GVNF$;xtD&#vu;SD+cJ1<~DAnnyI8LJT1cs zTG-2U;@G2pZKBFkCEGzY)*7EQcJyBILiz%HYXYRA92 zj|O%}-%v+wKnd$u#WfNA`Uf=fQ_d#UfVn(OEmuYS3J$WHt+Xc1fH`btaSpYGzhfhZ zlVU&xkFzA_`hywvu%44~Hz3Vs)<&LoMo&lE*yMKD=F)&X>e)&_W$2T)mYp7|SNwypbQOIl=}v6O}F^U<$jKgMa*kd+6{#0@l#xrTlOcM_A=| z1QfD^d-0n_>gn^-fXOsdiT|{6M|jf};TcfF%Q@fWRPUPJ&OX1|fUod}*vJoQ{>*Zp z4A{so$tO+}u#4~ck%ChEga?QxPw-H#8<59#)(~Hwq2BH^gsxg{>WI8 zML&ydjE-#>FquO!8ujW-P*j=E5t+9Eg)}Epen|5&^;c9l7EZ>_Lu-?5}>|0#<3 zzN-kR;LV)fU>+rxn9jdkM8M-B|0v_4AG(NuIlLiSk3xo7ycu=`Y!;nIf1UN!(T#w) z92A|e$ZWmCe!3CxkSJ_dA-xDF;j}csSr}w8?}QZr>m10R%JMJl@0&7)bzw!o=XlRS zQGsj{WkUYGIlbJVexySu0+vQr)4?x_xHFs-R43ZnS4uqvO?7l~*8da)P|j)F5pcJ> z*cvFSrEb77QB{Lwwhfr>q?lNIP}WP;fLhU1lUln5EEYvIQB+K=7nJ7T&WaqzqZb61 zDOOpcRFm~KsWGk1RV=cXf^HQ(15jhnfND`Q01Irj0TbzVFpC(sHUNg0$bW1^z=-aZlngu`76T$hLAmUWa3L3*jsoJ-2*YK@({CC9Wikje7>t#g zr=Swi(Etkul!}T5l$aYZNmPtB0h1M^w#Y`s%6}a7dI4NSi{Fr_d%9(IZEc0x(1o=WRqlucJCCapQP)`Bj%B zTSUzO>{t1angM%7%>evWDdQ@o#EEoCsRHdmSaJ44s;Uw2zLaa#=5-bM)wiX*B8qDA zs@lg?4cH@!YSL`ifaAO+nre-KPuevAnnYC%l+{u%TRBDlc8CP`g1$h9?FeXB8(0Xy z-&Ez-+nn1V%Gy^-r+9ZE?{Tf9AcaxW4eA>@bt2#*jq-9U(5SPYR__9bNp#2s4q3Rw zWZqT(xZVuUS$2!USv#Qb>U>=H5}ztrAIri_y^E}L=QvyWy=Z&|exrMjnEpj>vuKqf zexvrVDs{Io=+E*WmWjDhc}afB}GV zLgyk*NfD1v&_%i08kl(mKrfGp;-BErAo2%|07$W)FNx|O^BOh5W|>lf=ccA{MCwTV zpFS#S5BAmIn@vsxxj#ClYzZpANd&+c_H&b{PYbtjISK=yj3bG!sCAx7lXG-t``tSD zX5!!fdb4AUrW+2QlbnX6r4! zgpq;(&T@MKs|xk8#9V$`HRJZN+Ufb*=*h5x12!IHYuEn^37Z}IF2A<(pp?ZIcPkGj zME)P~h^+wSd!arW^+*2l0@n&CV4n%=P5fDx9{7^}kfeXOw6@pNMVEM!k?CMddC zVrSUpCItVj`H$-M~p zh!ZU2J`YNPcTvZeUCHk<0zTqeHgSK>Zf?KCQ)~&g$I}-B0H(5qJ96%nO@^1)5M+b7 zFDYn*lPqO6zs)K6_fW~*?&Xgr0*0Bz!z_(F9bKY{t-43+;$Z;5=XrqD6h-vwz5IeF zZR~CE*8qTV+({ibJI?s8(LghOQUCF&oSC>U=PvHzMhCxilqQ#31b) z<0x$$$VK7(FkplNN-3p;Qm&(zG{uakfP4yoeg^30L%QjvhjVn&NhfFgnEp#8YfESD zAs0G}0b+n428aQI7$61+Vt^PRhyh}NAO?s5f*2qM2oCrk{$u!8u$T7<00000NkvXX Hu0mjf`P6S% literal 0 HcmV?d00001 diff --git a/res/drawable-xxxhdpi/ic_play_circle_fill_white_48dp.png b/res/drawable-xxxhdpi/ic_play_circle_fill_white_48dp.png new file mode 100644 index 0000000000000000000000000000000000000000..d08a2ed51b3a2c6d1d4b202ac000f89384c47152 GIT binary patch literal 2128 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 literal 0 HcmV?d00001 diff --git a/res/layout/audio_view.xml b/res/layout/audio_view.xml new file mode 100644 index 0000000000..a83df65ad0 --- /dev/null +++ b/res/layout/audio_view.xml @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/res/layout/conversation_activity.xml b/res/layout/conversation_activity.xml index 48a4716e46..0a5ba8ed5d 100644 --- a/res/layout/conversation_activity.xml +++ b/res/layout/conversation_activity.xml @@ -37,13 +37,32 @@ android:background="?android:windowBackground" android:visibility="gone"> - + + + + + + + diff --git a/res/layout/conversation_item_received.xml b/res/layout/conversation_item_received.xml index 4f38aaf108..3cadef9b63 100644 --- a/res/layout/conversation_item_received.xml +++ b/res/layout/conversation_item_received.xml @@ -1,12 +1,14 @@ - + + tools:visibility="gone" /> + + + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:id="@+id/conversation_item" + android:layout_width="fill_parent" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:background="@drawable/conversation_item_background"> + + + android:layout_gravity="top|right" + android:src="@drawable/conversation_attachment_close_circle" + android:visibility="gone"/> diff --git a/res/layout/thumbnail_view.xml b/res/layout/thumbnail_view.xml index fc420fd56c..1e446b0552 100644 --- a/res/layout/thumbnail_view.xml +++ b/res/layout/thumbnail_view.xml @@ -16,9 +16,4 @@ android:layout_gravity="center" android:layout="@layout/transfer_controls_stub" /> - diff --git a/res/values/attrs.xml b/res/values/attrs.xml index cbada251db..d89c892452 100644 --- a/res/values/attrs.xml +++ b/res/values/attrs.xml @@ -134,4 +134,9 @@ + + + + + diff --git a/src/org/thoughtcrime/securesms/BindableConversationItem.java b/src/org/thoughtcrime/securesms/BindableConversationItem.java index 553b9f5ac9..c6fd33cfd9 100644 --- a/src/org/thoughtcrime/securesms/BindableConversationItem.java +++ b/src/org/thoughtcrime/securesms/BindableConversationItem.java @@ -4,6 +4,7 @@ import android.support.annotation.NonNull; import org.thoughtcrime.securesms.crypto.MasterSecret; import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.recipients.Recipients; import java.util.Locale; import java.util.Set; @@ -13,5 +14,5 @@ public interface BindableConversationItem extends Unbindable { @NonNull MessageRecord messageRecord, @NonNull Locale locale, @NonNull Set batchSelected, - boolean groupThread); + @NonNull Recipients recipients); } diff --git a/src/org/thoughtcrime/securesms/ConversationAdapter.java b/src/org/thoughtcrime/securesms/ConversationAdapter.java index 94d310385e..9a2b599ed9 100644 --- a/src/org/thoughtcrime/securesms/ConversationAdapter.java +++ b/src/org/thoughtcrime/securesms/ConversationAdapter.java @@ -35,6 +35,7 @@ import org.thoughtcrime.securesms.database.MmsSmsColumns; import org.thoughtcrime.securesms.database.MmsSmsDatabase; import org.thoughtcrime.securesms.database.SmsDatabase; import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.recipients.Recipients; import org.thoughtcrime.securesms.util.LRUCache; import java.lang.ref.SoftReference; @@ -68,12 +69,12 @@ public class ConversationAdapter private final Set batchSelected = Collections.synchronizedSet(new HashSet()); - private final ItemClickListener clickListener; - private final MasterSecret masterSecret; - private final Locale locale; - private final boolean groupThread; - private final MmsSmsDatabase db; - private final LayoutInflater inflater; + private final ItemClickListener clickListener; + private final MasterSecret masterSecret; + private final Locale locale; + private final Recipients recipients; + private final MmsSmsDatabase db; + private final LayoutInflater inflater; protected static class ViewHolder extends RecyclerView.ViewHolder { public ViewHolder(final @NonNull V itemView) { @@ -96,15 +97,15 @@ public class ConversationAdapter @NonNull Locale locale, @Nullable ItemClickListener clickListener, @Nullable Cursor cursor, - boolean groupThread) + @NonNull Recipients recipients) { super(context, cursor); - this.masterSecret = masterSecret; - this.locale = locale; - this.clickListener = clickListener; - this.groupThread = groupThread; - this.inflater = LayoutInflater.from(context); - this.db = DatabaseFactory.getMmsSmsDatabase(context); + this.masterSecret = masterSecret; + this.locale = locale; + this.clickListener = clickListener; + this.recipients = recipients; + this.inflater = LayoutInflater.from(context); + this.db = DatabaseFactory.getMmsSmsDatabase(context); } @Override @@ -118,7 +119,7 @@ public class ConversationAdapter String type = cursor.getString(cursor.getColumnIndexOrThrow(MmsSmsDatabase.TRANSPORT)); MessageRecord messageRecord = getMessageRecord(id, cursor, type); - viewHolder.getView().bind(masterSecret, messageRecord, locale, batchSelected, groupThread); + viewHolder.getView().bind(masterSecret, messageRecord, locale, batchSelected, recipients); } @Override public ViewHolder onCreateItemViewHolder(ViewGroup parent, int viewType) { diff --git a/src/org/thoughtcrime/securesms/ConversationFragment.java b/src/org/thoughtcrime/securesms/ConversationFragment.java index 16cb98a5ab..81fb1c8062 100644 --- a/src/org/thoughtcrime/securesms/ConversationFragment.java +++ b/src/org/thoughtcrime/securesms/ConversationFragment.java @@ -142,8 +142,7 @@ public class ConversationFragment extends Fragment private void initializeListAdapter() { if (this.recipients != null && this.threadId != -1) { - list.setAdapter(new ConversationAdapter(getActivity(), masterSecret, locale, selectionClickListener, null, - (!this.recipients.isSingleRecipient()) || this.recipients.isGroupRecipient())); + list.setAdapter(new ConversationAdapter(getActivity(), masterSecret, locale, selectionClickListener, null, this.recipients)); getLoaderManager().restartLoader(0, Bundle.EMPTY, this); } } diff --git a/src/org/thoughtcrime/securesms/ConversationItem.java b/src/org/thoughtcrime/securesms/ConversationItem.java index 2b5b8c1332..a16cff52de 100644 --- a/src/org/thoughtcrime/securesms/ConversationItem.java +++ b/src/org/thoughtcrime/securesms/ConversationItem.java @@ -41,13 +41,14 @@ import android.widget.Toast; import com.afollestad.materialdialogs.AlertDialogWrapper; +import org.thoughtcrime.securesms.components.AudioView; import org.thoughtcrime.securesms.components.AvatarImageView; import org.thoughtcrime.securesms.components.ThumbnailView; import org.thoughtcrime.securesms.crypto.MasterSecret; +import org.thoughtcrime.securesms.database.AttachmentDatabase; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.MmsDatabase; import org.thoughtcrime.securesms.database.MmsSmsDatabase; -import org.thoughtcrime.securesms.database.AttachmentDatabase; import org.thoughtcrime.securesms.database.SmsDatabase; import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch; import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord; @@ -58,10 +59,13 @@ import org.thoughtcrime.securesms.jobs.MmsSendJob; import org.thoughtcrime.securesms.jobs.SmsSendJob; import org.thoughtcrime.securesms.mms.PartAuthority; import org.thoughtcrime.securesms.mms.Slide; +import org.thoughtcrime.securesms.mms.SlideClickListener; import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.Recipients; import org.thoughtcrime.securesms.util.DateUtils; import org.thoughtcrime.securesms.util.Util; +import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Set; @@ -75,7 +79,7 @@ import java.util.Set; */ public class ConversationItem extends LinearLayout - implements Recipient.RecipientModifiedListener, BindableConversationItem + implements Recipient.RecipientModifiedListener, Recipients.RecipientsModifiedListener, BindableConversationItem { private final static String TAG = ConversationItem.class.getSimpleName(); @@ -98,11 +102,13 @@ public class ConversationItem extends LinearLayout private View pendingIndicator; private ImageView pendingApprovalIndicator; - private StatusManager statusManager; - private Set batchSelected; - private ThumbnailView mediaThumbnail; - private Button mmsDownloadButton; - private TextView mmsDownloadingLabel; + private @NonNull Set batchSelected = new HashSet<>(); + private @Nullable Recipients conversationRecipients; + private @NonNull StatusManager statusManager; + private @NonNull ThumbnailView mediaThumbnail; + private @NonNull AudioView audioView; + private @NonNull Button mmsDownloadButton; + private @NonNull TextView mmsDownloadingLabel; private int defaultBubbleColor; @@ -152,15 +158,20 @@ public class ConversationItem extends LinearLayout this.pendingApprovalIndicator = (ImageView) findViewById(R.id.pending_approval_indicator); this.pendingIndicator = findViewById(R.id.pending_indicator); this.mediaThumbnail = (ThumbnailView) findViewById(R.id.image_view); + this.audioView = (AudioView) findViewById(R.id.audio_view); this.statusManager = new StatusManager(pendingIndicator, sentIndicator, deliveredIndicator, failedIndicator, pendingApprovalIndicator); setOnClickListener(new ClickListener(null)); - PassthroughClickListener passthroughClickListener = new PassthroughClickListener(); - if (mmsDownloadButton != null) mmsDownloadButton.setOnClickListener(mmsDownloadClickListener); + PassthroughClickListener passthroughClickListener = new PassthroughClickListener(); + AttachmentDownloadClickListener downloadClickListener = new AttachmentDownloadClickListener(); + + mmsDownloadButton.setOnClickListener(mmsDownloadClickListener); mediaThumbnail.setThumbnailClickListener(new ThumbnailClickListener()); - mediaThumbnail.setDownloadClickListener(new ThumbnailDownloadClickListener()); + mediaThumbnail.setDownloadClickListener(downloadClickListener); mediaThumbnail.setOnLongClickListener(passthroughClickListener); mediaThumbnail.setOnClickListener(passthroughClickListener); + audioView.setDownloadClickListener(downloadClickListener); + audioView.setOnLongClickListener(passthroughClickListener); bodyText.setOnLongClickListener(passthroughClickListener); bodyText.setOnClickListener(passthroughClickListener); } @@ -170,16 +181,18 @@ public class ConversationItem extends LinearLayout @NonNull MessageRecord messageRecord, @NonNull Locale locale, @NonNull Set batchSelected, - boolean groupThread) + @NonNull Recipients conversationRecipients) { this.masterSecret = masterSecret; this.messageRecord = messageRecord; this.locale = locale; this.batchSelected = batchSelected; - this.groupThread = groupThread; + this.conversationRecipients = conversationRecipients; + this.groupThread = !conversationRecipients.isSingleRecipient() || conversationRecipients.isGroupRecipient(); this.recipient = messageRecord.getIndividualRecipient(); this.recipient.addListener(this); + this.conversationRecipients.addListener(this); setInteractionState(messageRecord); setBodyText(messageRecord); @@ -218,6 +231,7 @@ public class ConversationItem extends LinearLayout if (messageRecord.isOutgoing()) { bodyBubble.getBackground().setColorFilter(defaultBubbleColor, PorterDuff.Mode.MULTIPLY); mediaThumbnail.setBackgroundColorHint(defaultBubbleColor); + audioView.setTint(conversationRecipients.getColor().toConversationColor(context)); } else { int color = recipient.getColor().toConversationColor(context); bodyBubble.getBackground().setColorFilter(color, PorterDuff.Mode.MULTIPLY); @@ -237,7 +251,13 @@ public class ConversationItem extends LinearLayout return TextUtils.isEmpty(messageRecord.getDisplayBody()) && messageRecord.isMms(); } - private boolean hasMedia(MessageRecord messageRecord) { + private boolean hasAudio(MessageRecord messageRecord) { + return messageRecord.isMms() && + !messageRecord.isMmsNotification() && + ((MediaMmsMessageRecord)messageRecord).getSlideDeck().getAudioSlide() != null; + } + + private boolean hasThumbnail(MessageRecord messageRecord) { return messageRecord.isMms() && !messageRecord.isMmsNotification() && ((MediaMmsMessageRecord)messageRecord).getSlideDeck().getThumbnailSlide() != null; @@ -256,20 +276,33 @@ public class ConversationItem extends LinearLayout } private void setMediaAttributes(MessageRecord messageRecord) { + boolean showControls = !messageRecord.isFailed() && (!messageRecord.isOutgoing() || messageRecord.isPending()); + if (messageRecord.isMmsNotification()) { mediaThumbnail.setVisibility(View.GONE); + audioView.setVisibility(View.GONE); + bodyText.setLayoutParams(new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)); setNotificationMmsAttributes((NotificationMmsMessageRecord) messageRecord); - } else if (hasMedia(messageRecord)) { + } else if (hasAudio(messageRecord)) { + audioView.setVisibility(View.VISIBLE); + mediaThumbnail.setVisibility(View.GONE); + + //noinspection ConstantConditions + audioView.setAudio(masterSecret, ((MediaMmsMessageRecord) messageRecord).getSlideDeck().getAudioSlide(), showControls); + bodyText.setLayoutParams(new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)); + } else if (hasThumbnail(messageRecord)) { mediaThumbnail.setVisibility(View.VISIBLE); + audioView.setVisibility(View.GONE); + //noinspection ConstantConditions mediaThumbnail.setImageResource(masterSecret, ((MediaMmsMessageRecord)messageRecord).getSlideDeck().getThumbnailSlide(), - !messageRecord.isFailed() && (!messageRecord.isOutgoing() || messageRecord.isPending()), - false); + showControls); bodyText.setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)); } else { mediaThumbnail.setVisibility(View.GONE); + audioView.setVisibility(View.GONE); bodyText.setLayoutParams(new LayoutParams(LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)); } } @@ -400,7 +433,12 @@ public class ConversationItem extends LinearLayout }); } - private class ThumbnailDownloadClickListener implements ThumbnailView.ThumbnailClickListener { + @Override + public void onModified(Recipients recipient) { + onModified(recipient.getPrimaryRecipient()); + } + + private class AttachmentDownloadClickListener implements SlideClickListener { @Override public void onClick(View v, final Slide slide) { DatabaseFactory.getAttachmentDatabase(context).setTransferState(messageRecord.getId(), slide.asAttachment(), @@ -408,7 +446,7 @@ public class ConversationItem extends LinearLayout } } - private class ThumbnailClickListener implements ThumbnailView.ThumbnailClickListener { + private class ThumbnailClickListener implements SlideClickListener { private void fireIntent(Slide slide) { Log.w(TAG, "Clicked: " + slide.getUri() + " , " + slide.getContentType()); Intent intent = new Intent(Intent.ACTION_VIEW); diff --git a/src/org/thoughtcrime/securesms/ConversationUpdateItem.java b/src/org/thoughtcrime/securesms/ConversationUpdateItem.java index b42def811d..c31b8b68a3 100644 --- a/src/org/thoughtcrime/securesms/ConversationUpdateItem.java +++ b/src/org/thoughtcrime/securesms/ConversationUpdateItem.java @@ -56,7 +56,7 @@ public class ConversationUpdateItem extends LinearLayout @NonNull MessageRecord messageRecord, @NonNull Locale locale, @NonNull Set batchSelected, - boolean groupThread) + @NonNull Recipients conversationRecipients) { bind(messageRecord, locale); } diff --git a/src/org/thoughtcrime/securesms/ImageMediaAdapter.java b/src/org/thoughtcrime/securesms/ImageMediaAdapter.java index cd27ca41fe..3d51d6bf51 100644 --- a/src/org/thoughtcrime/securesms/ImageMediaAdapter.java +++ b/src/org/thoughtcrime/securesms/ImageMediaAdapter.java @@ -70,7 +70,7 @@ public class ImageMediaAdapter extends CursorRecyclerViewAdapter { Slide slide = MediaUtil.getSlideForAttachment(getContext(), imageRecord.getAttachment()); if (slide != null) { - imageView.setImageResource(masterSecret, slide, false, false); + imageView.setImageResource(masterSecret, slide, false); } imageView.setOnClickListener(new OnMediaClickListener(imageRecord)); diff --git a/src/org/thoughtcrime/securesms/MessageDetailsActivity.java b/src/org/thoughtcrime/securesms/MessageDetailsActivity.java index bd59046fec..45ffce71fd 100644 --- a/src/org/thoughtcrime/securesms/MessageDetailsActivity.java +++ b/src/org/thoughtcrime/securesms/MessageDetailsActivity.java @@ -172,8 +172,7 @@ public class MessageDetailsActivity extends PassphraseRequiredActionBarActivity } toFrom.setText(toFromRes); conversationItem.bind(masterSecret, messageRecord, dynamicLanguage.getCurrentLocale(), - new HashSet(), - recipients != messageRecord.getRecipients()); + new HashSet(), recipients); recipientsList.setAdapter(new MessageDetailsRecipientAdapter(this, masterSecret, messageRecord, recipients, isPushGroup)); } diff --git a/src/org/thoughtcrime/securesms/attachments/UriAttachment.java b/src/org/thoughtcrime/securesms/attachments/UriAttachment.java index 8c2749878b..c7b73bd528 100644 --- a/src/org/thoughtcrime/securesms/attachments/UriAttachment.java +++ b/src/org/thoughtcrime/securesms/attachments/UriAttachment.java @@ -13,15 +13,15 @@ import java.io.InputStream; public class UriAttachment extends Attachment { - private final Uri dataUri; - private final Uri thumbnailUri; + private final @NonNull Uri dataUri; + private final @NonNull Uri thumbnailUri; - public UriAttachment(Uri uri, String contentType, int transferState, long size) { + public UriAttachment(@NonNull Uri uri, @NonNull String contentType, int transferState, long size) { this(uri, uri, contentType, transferState, size); } - public UriAttachment(Uri dataUri, Uri thumbnailUri, - String contentType, int transferState, long size) + public UriAttachment(@NonNull Uri dataUri, @NonNull Uri thumbnailUri, + @NonNull String contentType, int transferState, long size) { super(contentType, transferState, size, null, null, null); this.dataUri = dataUri; @@ -39,4 +39,14 @@ public class UriAttachment extends Attachment { public Uri getThumbnailUri() { return thumbnailUri; } + + @Override + public boolean equals(Object other) { + return other != null && other instanceof UriAttachment && ((UriAttachment) other).dataUri.equals(this.dataUri); + } + + @Override + public int hashCode() { + return dataUri.hashCode(); + } } diff --git a/src/org/thoughtcrime/securesms/audio/AudioAttachmentServer.java b/src/org/thoughtcrime/securesms/audio/AudioAttachmentServer.java new file mode 100644 index 0000000000..31e67a767e --- /dev/null +++ b/src/org/thoughtcrime/securesms/audio/AudioAttachmentServer.java @@ -0,0 +1,376 @@ +package org.thoughtcrime.securesms.audio; + + +import android.content.Context; +import android.net.Uri; +import android.support.annotation.NonNull; +import android.util.Log; + +import org.spongycastle.util.encoders.Hex; +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.crypto.MasterSecret; +import org.thoughtcrime.securesms.mms.PartAuthority; +import org.thoughtcrime.securesms.util.Util; + +import java.io.BufferedOutputStream; +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.SocketException; +import java.net.SocketTimeoutException; +import java.net.UnknownHostException; +import java.security.MessageDigest; +import java.util.Map; +import java.util.Properties; +import java.util.StringTokenizer; + +/** + * @author Stefan "frostymarvelous" Froelich + */ +public class AudioAttachmentServer implements Runnable { + + private static final String TAG = AudioAttachmentServer.class.getSimpleName(); + + private final Context context; + private final MasterSecret masterSecret; + private final Attachment attachment; + private final ServerSocket socket; + private final int port; + private final String auth; + + private volatile boolean isRunning; + + public AudioAttachmentServer(Context context, MasterSecret masterSecret, Attachment attachment) + throws IOException + { + try { + this.context = context; + this.masterSecret = masterSecret; + this.attachment = attachment; + this.socket = new ServerSocket(0, 0, InetAddress.getByAddress(new byte[]{127, 0, 0, 1})); + this.port = socket.getLocalPort(); + this.auth = new String(Hex.encode(Util.getSecretBytes(16))); + + this.socket.setSoTimeout(5000); + } catch (UnknownHostException e) { + throw new AssertionError(e); + } + } + + public Uri getUri() { + return Uri.parse(String.format("http://127.0.0.1:%d/%s", port, auth)); + } + + public void start() { + isRunning = true; + new Thread(this).start(); + } + + public void stop() { + isRunning = false; + } + + @Override + public void run() { + while (isRunning) { + Socket client = null; + + try { + client = socket.accept(); + + if (client != null) { + StreamToMediaPlayerTask task = new StreamToMediaPlayerTask(client, "/" + auth); + + if (task.processRequest()) { + task.execute(); + } + } + + } catch (SocketTimeoutException e) { + Log.w(TAG, e); + } catch (IOException e) { + Log.e(TAG, "Error connecting to client", e); + } finally { + try {if (client != null) client.close();} catch (IOException e) {} + } + } + + Log.d(TAG, "Proxy interrupted. Shutting down."); + } + + + private class StreamToMediaPlayerTask { + + private final @NonNull Socket client; + private final @NonNull String auth; + + private long cbSkip; + private Properties parameters; + private Properties request; + private Properties requestHeaders; +// private String filePath; + + public StreamToMediaPlayerTask(@NonNull Socket client, @NonNull String auth) { + this.client = client; + this.auth = auth; + } + + public boolean processRequest() throws IOException { + InputStream is = client.getInputStream(); + final int bufferSize = 8192; + byte[] buffer = new byte[bufferSize]; + int splitByte = 0; + int readLength = 0; + + { + int read = is.read(buffer, 0, bufferSize); + while (read > 0) { + readLength += read; + splitByte = findHeaderEnd(buffer, readLength); + if (splitByte > 0) + break; + read = is.read(buffer, readLength, bufferSize - readLength); + } + } + + // Create a BufferedReader for parsing the header. + ByteArrayInputStream hbis = new ByteArrayInputStream(buffer, 0, readLength); + BufferedReader hin = new BufferedReader(new InputStreamReader(hbis)); + + request = new Properties(); + parameters = new Properties(); + requestHeaders = new Properties(); + + try { + decodeHeader(hin, request, parameters, requestHeaders); + } catch (InterruptedException e1) { + Log.e(TAG, "Exception: " + e1.getMessage()); + e1.printStackTrace(); + } + + for (Map.Entry e : requestHeaders.entrySet()) { + Log.i(TAG, "Header: " + e.getKey() + " : " + e.getValue()); + } + + String range = requestHeaders.getProperty("range"); + + if (range != null) { + Log.i(TAG, "range is: " + range); + range = range.substring(6); + int charPos = range.indexOf('-'); + if (charPos > 0) { + range = range.substring(0, charPos); + } + cbSkip = Long.parseLong(range); + Log.i(TAG, "range found!! " + cbSkip); + } + + if(!request.get("method").equals("GET")) { + Log.e(TAG, "Only GET is supported"); + return false; + } + + String receivedAuth = request.getProperty("uri"); + + if (receivedAuth == null || !MessageDigest.isEqual(receivedAuth.getBytes(), auth.getBytes())) { + Log.w(TAG, "Bad auth token!"); + return false; + } + +// filePath = request.getProperty("uri"); + + return true; + } + + protected void execute() throws IOException { + InputStream inputStream = PartAuthority.getAttachmentStream(context, masterSecret, attachment.getDataUri()); + long fileSize = attachment.getSize(); + + String headers = ""; + if (cbSkip > 0) {// It is a seek or skip request if there's a Range + // header + headers += "HTTP/1.1 206 Partial Content\r\n"; + headers += "Content-Type: " + attachment.getContentType() + "\r\n"; + headers += "Accept-Ranges: bytes\r\n"; + headers += "Content-Length: " + (fileSize - cbSkip) + "\r\n"; + headers += "Content-Range: bytes " + cbSkip + "-" + (fileSize - 1) + "/" + fileSize + "\r\n"; + headers += "Connection: Keep-Alive\r\n"; + headers += "\r\n"; + } else { + headers += "HTTP/1.1 200 OK\r\n"; + headers += "Content-Type: " + attachment.getContentType() + "\r\n"; + headers += "Accept-Ranges: bytes\r\n"; + headers += "Content-Length: " + fileSize + "\r\n"; + headers += "Connection: Keep-Alive\r\n"; + headers += "\r\n"; + } + + Log.i(TAG, "headers: " + headers); + + OutputStream output = null; + byte[] buff = new byte[64 * 1024]; + try { + output = new BufferedOutputStream(client.getOutputStream(), 32 * 1024); + output.write(headers.getBytes()); + + inputStream.skip(cbSkip); +// dataSource.skipFully(data, cbSkip);//try to skip as much as possible + + // Loop as long as there's stuff to send and client has not closed + int cbRead; + while (!client.isClosed() && (cbRead = inputStream.read(buff, 0, buff.length)) != -1) { + output.write(buff, 0, cbRead); + } + } + catch (SocketException socketException) { + Log.e(TAG, "SocketException() thrown, proxy client has probably closed. This can exit harmlessly"); + } + catch (Exception e) { + Log.e(TAG, "Exception thrown from streaming task:"); + Log.e(TAG, e.getClass().getName() + " : " + e.getLocalizedMessage()); + } + + // Cleanup + try { + if (output != null) { + output.close(); + } + client.close(); + } + catch (IOException e) { + Log.e(TAG, "IOException while cleaning up streaming task:"); + Log.e(TAG, e.getClass().getName() + " : " + e.getLocalizedMessage()); + e.printStackTrace(); + } + } + + /** + * Find byte index separating header from body. It must be the last byte of + * the first two sequential new lines. + **/ + private int findHeaderEnd(final byte[] buf, int rlen) { + int splitbyte = 0; + while (splitbyte + 3 < rlen) { + if (buf[splitbyte] == '\r' && buf[splitbyte + 1] == '\n' + && buf[splitbyte + 2] == '\r' && buf[splitbyte + 3] == '\n') + return splitbyte + 4; + splitbyte++; + } + return 0; + } + + + /** + * Decodes the sent headers and loads the data into java Properties' key - + * value pairs + **/ + private void decodeHeader(BufferedReader in, Properties pre, + Properties parms, Properties header) throws InterruptedException { + try { + // Read the request line + String inLine = in.readLine(); + if (inLine == null) + return; + StringTokenizer st = new StringTokenizer(inLine); + if (!st.hasMoreTokens()) + Log.e(TAG, + "BAD REQUEST: Syntax error. Usage: GET /example/file.html"); + + String method = st.nextToken(); + pre.put("method", method); + + if (!st.hasMoreTokens()) + Log.e(TAG, + "BAD REQUEST: Missing URI. Usage: GET /example/file.html"); + + String uri = st.nextToken(); + + // Decode parameters from the URI + int qmi = uri.indexOf('?'); + if (qmi >= 0) { + decodeParms(uri.substring(qmi + 1), parms); + uri = decodePercent(uri.substring(0, qmi)); + } else + uri = decodePercent(uri); + + // If there's another token, it's protocol version, + // followed by HTTP headers. Ignore version but parse headers. + // NOTE: this now forces header names lowercase since they are + // case insensitive and vary by client. + if (st.hasMoreTokens()) { + String line = in.readLine(); + while (line != null && line.trim().length() > 0) { + int p = line.indexOf(':'); + if (p >= 0) + header.put(line.substring(0, p).trim().toLowerCase(), + line.substring(p + 1).trim()); + line = in.readLine(); + } + } + + pre.put("uri", uri); + } catch (IOException ioe) { + Log.e(TAG, + "SERVER INTERNAL ERROR: IOException: " + ioe.getMessage()); + } + } + + /** + * Decodes parameters in percent-encoded URI-format ( e.g. + * "name=Jack%20Daniels&pass=Single%20Malt" ) and adds them to given + * Properties. NOTE: this doesn't support multiple identical keys due to the + * simplicity of Properties -- if you need multiples, you might want to + * replace the Properties with a Hashtable of Vectors or such. + */ + private void decodeParms(String parms, Properties p) + throws InterruptedException { + if (parms == null) + return; + + StringTokenizer st = new StringTokenizer(parms, "&"); + while (st.hasMoreTokens()) { + String e = st.nextToken(); + int sep = e.indexOf('='); + if (sep >= 0) + p.put(decodePercent(e.substring(0, sep)).trim(), + decodePercent(e.substring(sep + 1))); + } + } + + /** + * Decodes the percent encoding scheme.
+ * For example: "an+example%20string" -> "an example string" + */ + private String decodePercent(String str) throws InterruptedException { + try { + StringBuffer sb = new StringBuffer(); + for (int i = 0; i < str.length(); i++) { + char c = str.charAt(i); + switch (c) { + case '+': + sb.append(' '); + break; + case '%': + sb.append((char) Integer.parseInt( + str.substring(i + 1, i + 3), 16)); + i += 2; + break; + default: + sb.append(c); + break; + } + } + return sb.toString(); + } catch (Exception e) { + Log.e(TAG, "BAD REQUEST: Bad percent-encoding."); + return null; + } + } + } +} \ No newline at end of file diff --git a/src/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java b/src/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java new file mode 100644 index 0000000000..bb0dab829f --- /dev/null +++ b/src/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java @@ -0,0 +1,237 @@ +package org.thoughtcrime.securesms.audio; + +import android.content.Context; +import android.media.AudioManager; +import android.media.MediaPlayer; +import android.os.Handler; +import android.os.Message; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.util.Log; +import android.util.Pair; + +import org.thoughtcrime.securesms.crypto.MasterSecret; +import org.thoughtcrime.securesms.mms.AudioSlide; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libaxolotl.util.guava.Optional; + +import java.io.IOException; +import java.lang.ref.WeakReference; + +public class AudioSlidePlayer { + + private static final String TAG = AudioSlidePlayer.class.getSimpleName(); + + private static @NonNull Optional playing = Optional.absent(); + + private final @NonNull Context context; + private final @NonNull MasterSecret masterSecret; + private final @NonNull AudioSlide slide; + private final @NonNull Handler progressEventHandler; + + private @NonNull WeakReference listener; + private @Nullable MediaPlayer mediaPlayer; + private @Nullable AudioAttachmentServer audioAttachmentServer; + + public synchronized static AudioSlidePlayer createFor(@NonNull Context context, + @NonNull MasterSecret masterSecret, + @NonNull AudioSlide slide, + @NonNull Listener listener) + { + if (playing.isPresent() && playing.get().getAudioSlide().equals(slide)) { + playing.get().setListener(listener); + return playing.get(); + } else { + return new AudioSlidePlayer(context, masterSecret, slide, listener); + } + } + + private AudioSlidePlayer(@NonNull Context context, + @NonNull MasterSecret masterSecret, + @NonNull AudioSlide slide, + @NonNull Listener listener) + { + this.context = context; + this.masterSecret = masterSecret; + this.slide = slide; + this.listener = new WeakReference<>(listener); + this.progressEventHandler = new ProgressEventHandler(this); + } + + public void play(final double progress) throws IOException { + if (this.mediaPlayer != null) return; + + this.mediaPlayer = new MediaPlayer(); + this.audioAttachmentServer = new AudioAttachmentServer(context, masterSecret, slide.asAttachment()); + + audioAttachmentServer.start(); + + mediaPlayer.setDataSource(context, audioAttachmentServer.getUri()); + mediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC); + mediaPlayer.setOnPreparedListener(new MediaPlayer.OnPreparedListener() { + @Override + public void onPrepared(MediaPlayer mp) { + Log.w(TAG, "onPrepared"); + if (progress > 0) { + mediaPlayer.seekTo((int)(mediaPlayer.getDuration() * progress)); + } + + mediaPlayer.start(); + + notifyOnStart(); + setPlaying(AudioSlidePlayer.this); + progressEventHandler.sendEmptyMessage(0); + } + }); + + mediaPlayer.setOnCompletionListener(new MediaPlayer.OnCompletionListener() { + @Override + public void onCompletion(MediaPlayer mp) { + Log.w(TAG, "onComplete"); + mediaPlayer = null; + audioAttachmentServer.stop(); + audioAttachmentServer = null; + + notifyOnStop(); + progressEventHandler.removeMessages(0); + } + }); + + mediaPlayer.setOnErrorListener(new MediaPlayer.OnErrorListener() { + @Override + public boolean onError(MediaPlayer mp, int what, int extra) { + Log.w(TAG, "MediaPlayer Error: " + what + " , " + extra); + notifyOnStop(); + return true; + } + }); + + mediaPlayer.prepareAsync(); + } + + public void stop() { + Log.w(TAG, "Stop called!"); + shutdown(); + } + + public void setListener(@NonNull Listener listener) { + this.listener = new WeakReference<>(listener); + + if (this.mediaPlayer != null && this.mediaPlayer.isPlaying()) { + notifyOnStart(); + } + } + + public @NonNull AudioSlide getAudioSlide() { + return slide; + } + + private void shutdown() { + removePlaying(this); + + if (this.mediaPlayer != null) { + this.mediaPlayer.stop(); + } + + if (this.audioAttachmentServer != null) { + this.audioAttachmentServer.stop(); + } + + this.mediaPlayer = null; + this.audioAttachmentServer = null; + } + + private Pair getProgress() { + if (mediaPlayer == null || mediaPlayer.getCurrentPosition() <= 0 || mediaPlayer.getDuration() <= 0) { + return new Pair<>(0D, 0); + } else { + return new Pair<>((double) mediaPlayer.getCurrentPosition() / (double) mediaPlayer.getDuration(), + mediaPlayer.getCurrentPosition()); + } + } + + private void notifyOnStart() { + Util.runOnMain(new Runnable() { + @Override + public void run() { + getListener().onStart(); + } + }); + } + + private void notifyOnStop() { + Util.runOnMain(new Runnable() { + @Override + public void run() { + getListener().onStop(); + } + }); + } + + private void notifyOnProgress(final double progress, final long millis) { + Util.runOnMain(new Runnable() { + @Override + public void run() { + getListener().onProgress(progress, millis); + } + }); + } + + private @NonNull Listener getListener() { + Listener listener = this.listener.get(); + + if (listener != null) return listener; + else return new Listener() { + @Override + public void onStart() {} + @Override + public void onStop() {} + @Override + public void onProgress(double progress, long millis) {} + }; + } + + private synchronized static void setPlaying(@NonNull AudioSlidePlayer player) { + if (playing.isPresent() && playing.get() != player) { + playing.get().notifyOnStop(); + playing.get().stop(); + } + + playing = Optional.of(player); + } + + private synchronized static void removePlaying(@NonNull AudioSlidePlayer player) { + if (playing.isPresent() && playing.get() == player) { + playing = Optional.absent(); + } + } + + public interface Listener { + public void onStart(); + public void onStop(); + public void onProgress(double progress, long millis); + } + + private static class ProgressEventHandler extends Handler { + + private final WeakReference playerReference; + + private ProgressEventHandler(@NonNull AudioSlidePlayer player) { + this.playerReference = new WeakReference<>(player); + } + + @Override + public void handleMessage(Message msg) { + AudioSlidePlayer player = playerReference.get(); + + if (player == null || player.mediaPlayer == null || !player.mediaPlayer.isPlaying()) { + return; + } + + Pair progress = player.getProgress(); + player.notifyOnProgress(progress.first, progress.second); + sendEmptyMessageDelayed(0, 50); + } + } + +} diff --git a/src/org/thoughtcrime/securesms/components/AnimatingToggle.java b/src/org/thoughtcrime/securesms/components/AnimatingToggle.java index 293e07fd31..b8979fcc7a 100644 --- a/src/org/thoughtcrime/securesms/components/AnimatingToggle.java +++ b/src/org/thoughtcrime/securesms/components/AnimatingToggle.java @@ -57,4 +57,12 @@ public class AnimatingToggle extends FrameLayout { current = view; } + + public void displayQuick(@Nullable View view) { + if (view == current) return; + if (current != null) current.setVisibility(View.GONE); + if (view != null) view.setVisibility(View.VISIBLE); + + current = view; + } } diff --git a/src/org/thoughtcrime/securesms/components/AudioView.java b/src/org/thoughtcrime/securesms/components/AudioView.java new file mode 100644 index 0000000000..b0e8427292 --- /dev/null +++ b/src/org/thoughtcrime/securesms/components/AudioView.java @@ -0,0 +1,233 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.os.Build; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.util.AttributeSet; +import android.util.Log; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.SeekBar; +import android.widget.TextView; + +import com.pnikosis.materialishprogress.ProgressWheel; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.audio.AudioSlidePlayer; +import org.thoughtcrime.securesms.crypto.MasterSecret; +import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.jobs.PartProgressEvent; +import org.thoughtcrime.securesms.mms.AudioSlide; +import org.thoughtcrime.securesms.mms.SlideClickListener; +import org.thoughtcrime.securesms.util.Util; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +public class AudioView extends FrameLayout implements AudioSlidePlayer.Listener { + + private static final String TAG = AudioView.class.getSimpleName(); + + private final @NonNull AnimatingToggle controlToggle; + private final @NonNull ImageView playButton; + private final @NonNull ImageView pauseButton; + private final @NonNull ImageView downloadButton; + private final @NonNull ProgressWheel downloadProgress; + private final @NonNull SeekBar seekBar; + private final @NonNull TextView timestamp; + + private @Nullable SlideClickListener downloadListener; + private @Nullable AudioSlidePlayer audioSlidePlayer; + private int backwardsCounter; + + public AudioView(Context context) { + this(context, null); + } + + public AudioView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public AudioView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + inflate(context, R.layout.audio_view, this); + + this.controlToggle = (AnimatingToggle) findViewById(R.id.control_toggle); + this.playButton = (ImageView) findViewById(R.id.play); + this.pauseButton = (ImageView) findViewById(R.id.pause); + this.downloadButton = (ImageView) findViewById(R.id.download); + this.downloadProgress = (ProgressWheel) findViewById(R.id.download_progress); + this.seekBar = (SeekBar) findViewById(R.id.seek); + this.timestamp = (TextView) findViewById(R.id.timestamp); + + this.playButton.setOnClickListener(new PlayClickedListener()); + this.pauseButton.setOnClickListener(new PauseClickedListener()); + this.seekBar.setOnSeekBarChangeListener(new SeekBarModifiedListener()); + + if (attrs != null) { + TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.AudioView, 0, 0); + setTint(typedArray.getColor(R.styleable.AudioView_tintColor, Color.WHITE)); + typedArray.recycle(); + } + } + + public void setAudio(final @NonNull MasterSecret masterSecret, + final @NonNull AudioSlide audio, + final boolean showControls) + { + + if (showControls && audio.isPendingDownload()) { + controlToggle.displayQuick(downloadButton); + seekBar.setEnabled(false); + downloadButton.setOnClickListener(new DownloadClickedListener(audio)); + if (downloadProgress.isSpinning()) downloadProgress.stopSpinning(); + } else if (showControls && audio.getTransferState() == AttachmentDatabase.TRANSFER_PROGRESS_STARTED) { + controlToggle.displayQuick(downloadProgress); + seekBar.setEnabled(false); + downloadProgress.spin(); + } else { + controlToggle.displayQuick(playButton); + seekBar.setEnabled(true); + if (downloadProgress.isSpinning()) downloadProgress.stopSpinning(); + } + + this.audioSlidePlayer = AudioSlidePlayer.createFor(getContext(), masterSecret, audio, this); + } + + public void cleanup() { + if (this.audioSlidePlayer != null && pauseButton.getVisibility() == View.VISIBLE) { + this.audioSlidePlayer.stop(); + } + } + + public void setDownloadClickListener(@Nullable SlideClickListener listener) { + this.downloadListener = listener; + } + + @Override + public void onStart() { + this.controlToggle.display(this.pauseButton); + } + + @Override + public void onStop() { + this.controlToggle.display(this.playButton); + } + + @Override + public void onProgress(double progress, long millis) { + int seekProgress = (int)Math.floor(progress * this.seekBar.getMax()); + + if (seekProgress > seekBar.getProgress() || backwardsCounter > 3) { + backwardsCounter = 0; + this.seekBar.setProgress(seekProgress); + this.timestamp.setText(String.format("%02d:%02d", + TimeUnit.MILLISECONDS.toMinutes(millis), + TimeUnit.MILLISECONDS.toSeconds(millis))); + } else { + backwardsCounter++; + } + } + + public void setTint(int tint) { + this.playButton.setColorFilter(tint, PorterDuff.Mode.SRC_IN); + this.pauseButton.setColorFilter(tint, PorterDuff.Mode.SRC_IN); + this.downloadButton.setColorFilter(tint, PorterDuff.Mode.SRC_IN); + this.downloadProgress.setBarColor(tint); + + this.timestamp.setTextColor(tint); + this.seekBar.getProgressDrawable().setColorFilter(tint, PorterDuff.Mode.SRC_IN); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + this.seekBar.getThumb().setColorFilter(tint, PorterDuff.Mode.SRC_IN); + } + } + + private double getProgress() { + if (this.seekBar.getProgress() <= 0 || this.seekBar.getMax() <= 0) { + return 0; + } else { + return (double)this.seekBar.getProgress() / (double)this.seekBar.getMax(); + } + } + + private class PlayClickedListener implements View.OnClickListener { + @Override + public void onClick(View v) { + try { + Log.w(TAG, "playbutton onClick"); + if (audioSlidePlayer != null) { + controlToggle.display(pauseButton); + audioSlidePlayer.play(getProgress()); + } + } catch (IOException e) { + Log.w(TAG, e); + } + } + } + + private class PauseClickedListener implements View.OnClickListener { + @Override + public void onClick(View v) { + Log.w(TAG, "pausebutton onClick"); + if (audioSlidePlayer != null) { + controlToggle.display(playButton); + audioSlidePlayer.stop(); + } + } + } + + private class DownloadClickedListener implements View.OnClickListener { + private final @NonNull AudioSlide slide; + + private DownloadClickedListener(@NonNull AudioSlide slide) { + this.slide = slide; + } + + @Override + public void onClick(View v) { + if (downloadListener != null) downloadListener.onClick(v, slide); + } + } + + private class SeekBarModifiedListener implements SeekBar.OnSeekBarChangeListener { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {} + + @Override + public synchronized void onStartTrackingTouch(SeekBar seekBar) { + if (audioSlidePlayer != null && pauseButton.getVisibility() == View.VISIBLE) { + audioSlidePlayer.stop(); + } + } + + @Override + public synchronized void onStopTrackingTouch(SeekBar seekBar) { + try { + if (audioSlidePlayer != null && pauseButton.getVisibility() == View.VISIBLE) { + audioSlidePlayer.play(getProgress()); + } + } catch (IOException e) { + Log.w(TAG, e); + } + } + } + + @SuppressWarnings("unused") + public void onEventAsync(final PartProgressEvent event) { + if (audioSlidePlayer != null && event.attachment.equals(this.audioSlidePlayer.getAudioSlide().asAttachment())) { + Util.runOnMain(new Runnable() { + @Override + public void run() { + downloadProgress.setInstantProgress(((float) event.progress) / event.total); + } + }); + } + } + +} diff --git a/src/org/thoughtcrime/securesms/components/RemovableMediaView.java b/src/org/thoughtcrime/securesms/components/RemovableMediaView.java new file mode 100644 index 0000000000..44ef5ef067 --- /dev/null +++ b/src/org/thoughtcrime/securesms/components/RemovableMediaView.java @@ -0,0 +1,65 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.ImageView; + +import org.thoughtcrime.securesms.R; + +public class RemovableMediaView extends FrameLayout { + + private final @NonNull ImageView remove; + private final int removeSize; + + private @Nullable View current; + + public RemovableMediaView(Context context) { + this(context, null); + } + + public RemovableMediaView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public RemovableMediaView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + this.remove = (ImageView)LayoutInflater.from(context).inflate(R.layout.media_view_remove_button, this, false); + this.removeSize = getResources().getDimensionPixelSize(R.dimen.media_bubble_remove_button_size); + + this.remove.setVisibility(View.GONE); + } + + @Override + public void onFinishInflate() { + super.onFinishInflate(); + this.addView(remove); + } + + public void display(@Nullable View view) { + if (view == current) return; + if (current != null) current.setVisibility(View.GONE); + + if (view != null) { + MarginLayoutParams params = (MarginLayoutParams)view.getLayoutParams(); + params.setMargins(0, removeSize / 2, removeSize / 2, 0); + view.setLayoutParams(params); + + view.setVisibility(View.VISIBLE); + remove.setVisibility(View.VISIBLE); + } else { + remove.setVisibility(View.GONE); + } + + current = view; + } + + public void setRemoveClickListener(View.OnClickListener listener) { + this.remove.setOnClickListener(listener); + } +} diff --git a/src/org/thoughtcrime/securesms/components/ThumbnailView.java b/src/org/thoughtcrime/securesms/components/ThumbnailView.java index c1dda574a5..8820c6e3b0 100644 --- a/src/org/thoughtcrime/securesms/components/ThumbnailView.java +++ b/src/org/thoughtcrime/securesms/components/ThumbnailView.java @@ -17,35 +17,31 @@ import android.widget.ImageView; import com.bumptech.glide.DrawableRequestBuilder; import com.bumptech.glide.GenericRequestBuilder; import com.bumptech.glide.Glide; -import com.bumptech.glide.load.resource.bitmap.GlideBitmapDrawable; -import com.bumptech.glide.load.resource.drawable.GlideDrawable; -import com.bumptech.glide.request.RequestListener; -import com.bumptech.glide.request.target.Target; import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.attachments.Attachment; import org.thoughtcrime.securesms.crypto.MasterSecret; import org.thoughtcrime.securesms.database.AttachmentDatabase; import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri; import org.thoughtcrime.securesms.mms.RoundedCorners; import org.thoughtcrime.securesms.mms.Slide; +import org.thoughtcrime.securesms.mms.SlideClickListener; import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.ViewUtil; import org.whispersystems.libaxolotl.util.guava.Optional; public class ThumbnailView extends FrameLayout { + private static final String TAG = ThumbnailView.class.getSimpleName(); private ImageView image; - private ImageView removeButton; private int backgroundColorHint; private int radius; private OnClickListener parentClickListener; - private Optional transferControls = Optional.absent(); - private ThumbnailClickListener thumbnailClickListener = null; - private ThumbnailClickListener downloadClickListener = null; - private Slide slide = null; + private Optional transferControls = Optional.absent(); + private SlideClickListener thumbnailClickListener = null; + private SlideClickListener downloadClickListener = null; + private Slide slide = null; public ThumbnailView(Context context) { this(context, null); @@ -57,9 +53,11 @@ public class ThumbnailView extends FrameLayout { public ThumbnailView(final Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); + inflate(context, R.layout.thumbnail_view, this); - radius = getResources().getDimensionPixelSize(R.dimen.message_bubble_corner_radius); - image = (ImageView) findViewById(R.id.thumbnail_image); + + this.radius = getResources().getDimensionPixelSize(R.dimen.message_bubble_corner_radius); + this.image = (ImageView) findViewById(R.id.thumbnail_image); super.setOnClickListener(new ThumbnailClickDispatcher()); if (attrs != null) { @@ -86,21 +84,6 @@ public class ThumbnailView extends FrameLayout { if (transferControls.isPresent()) transferControls.get().setClickable(clickable); } - @Override - protected void onLayout(boolean changed, int left, int top, int right, int bottom) { - super.onLayout(changed, left, top, right, bottom); - if (removeButton != null) { - final int paddingHorizontal = removeButton.getWidth() / 2; - final int paddingVertical = removeButton.getHeight() / 2; - image.setPadding(paddingHorizontal, paddingVertical, paddingHorizontal, 0); - } - } - - private ImageView getRemoveButton() { - if (removeButton == null) removeButton = ViewUtil.inflateStub(this, R.id.remove_button_stub); - return removeButton; - } - private TransferControlView getTransferControls() { if (!transferControls.isPresent()) { transferControls = Optional.of((TransferControlView)ViewUtil.inflateStub(this, R.id.transfer_controls_stub)); @@ -112,9 +95,8 @@ public class ThumbnailView extends FrameLayout { this.backgroundColorHint = color; } - public void setImageResource(@NonNull MasterSecret masterSecret, @NonNull Slide slide, - boolean showControls, boolean showRemove) - { + public void setImageResource(@NonNull MasterSecret masterSecret, @NonNull Slide slide, boolean showControls) { + if (Util.equals(slide, this.slide)) { Log.w(TAG, "Not re-loading slide " + slide.asAttachment().getDataUri()); return; @@ -137,22 +119,16 @@ public class ThumbnailView extends FrameLayout { this.slide = slide; - if (slide.getThumbnailUri() != null) buildThumbnailGlideRequest(slide, masterSecret, showRemove).into(image); + if (slide.getThumbnailUri() != null) buildThumbnailGlideRequest(slide, masterSecret).into(image); else if (slide.hasPlaceholder()) buildPlaceholderGlideRequest(slide).into(image); else Glide.clear(image); } - public void setThumbnailClickListener(ThumbnailClickListener listener) { + public void setThumbnailClickListener(SlideClickListener listener) { this.thumbnailClickListener = listener; } - public void setRemoveClickListener(OnClickListener listener) { - getRemoveButton().setOnClickListener(listener); - final int pad = getResources().getDimensionPixelSize(R.dimen.media_bubble_remove_button_size); - image.setPadding(pad, pad, pad, 0); - } - - public void setDownloadClickListener(ThumbnailClickListener listener) { + public void setDownloadClickListener(SlideClickListener listener) { this.downloadClickListener = listener; } @@ -174,15 +150,11 @@ public class ThumbnailView extends FrameLayout { !((Activity)getContext()).isDestroyed(); } - private GenericRequestBuilder buildThumbnailGlideRequest(@NonNull Slide slide, @NonNull MasterSecret masterSecret, boolean showRemove) { + private GenericRequestBuilder buildThumbnailGlideRequest(@NonNull Slide slide, @NonNull MasterSecret masterSecret) { DrawableRequestBuilder builder = Glide.with(getContext()).load(new DecryptableUri(masterSecret, slide.getThumbnailUri())) .crossFade() .transform(new RoundedCorners(getContext(), true, radius, backgroundColorHint)); - if (showRemove) { - builder = builder.listener(new ThumbnailSetListener(slide.asAttachment())); - } - if (slide.isInProgress()) return builder; else return builder.error(R.drawable.ic_missing_thumbnail_picture); } @@ -193,10 +165,6 @@ public class ThumbnailView extends FrameLayout { .fitCenter(); } - public interface ThumbnailClickListener { - void onClick(View v, Slide slide); - } - private class ThumbnailClickDispatcher implements View.OnClickListener { @Override public void onClick(View view) { @@ -220,36 +188,4 @@ public class ThumbnailView extends FrameLayout { } } } - - private class ThumbnailSetListener implements RequestListener { - - private final Attachment attachment; - - public ThumbnailSetListener(@NonNull Attachment attachment) { - this.attachment = attachment; - } - - @Override - public boolean onException(Exception e, Object model, Target target, boolean isFirstResource) { - return false; - } - - @Override - public boolean onResourceReady(GlideDrawable resource, Object model, Target target, boolean isFromMemoryCache, boolean isFirstResource) { - if (resource instanceof GlideBitmapDrawable) { - Log.w(TAG, "onResourceReady() for a Bitmap. Saving."); - attachment.setThumbnail(((GlideBitmapDrawable) resource).getBitmap()); - } - LayoutParams layoutParams = (LayoutParams) getRemoveButton().getLayoutParams(); - if (resource.getIntrinsicWidth() < getWidth()) { - layoutParams.topMargin = 0; - layoutParams.rightMargin = Math.max(0, (getWidth() - image.getPaddingRight() - resource.getIntrinsicWidth()) / 2); - } else { - layoutParams.topMargin = Math.max(0, (getHeight() - image.getPaddingTop() - resource.getIntrinsicHeight()) / 2); - layoutParams.rightMargin = 0; - } - getRemoveButton().setLayoutParams(layoutParams); - return false; - } - } } diff --git a/src/org/thoughtcrime/securesms/database/MmsSmsDatabase.java b/src/org/thoughtcrime/securesms/database/MmsSmsDatabase.java index 3f2cdee56d..97d3bda371 100644 --- a/src/org/thoughtcrime/securesms/database/MmsSmsDatabase.java +++ b/src/org/thoughtcrime/securesms/database/MmsSmsDatabase.java @@ -279,7 +279,6 @@ public class MmsSmsDatabase extends Database { return getCurrent(); } - @TargetApi(Build.VERSION_CODES.HONEYCOMB) public MessageRecord getCurrent() { String type = cursor.getString(cursor.getColumnIndexOrThrow(TRANSPORT)); diff --git a/src/org/thoughtcrime/securesms/mms/AttachmentManager.java b/src/org/thoughtcrime/securesms/mms/AttachmentManager.java index 6b6b9cb2a1..b8b103d597 100644 --- a/src/org/thoughtcrime/securesms/mms/AttachmentManager.java +++ b/src/org/thoughtcrime/securesms/mms/AttachmentManager.java @@ -34,6 +34,8 @@ import android.view.animation.Animation; import android.widget.Toast; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.AudioView; +import org.thoughtcrime.securesms.components.RemovableMediaView; import org.thoughtcrime.securesms.components.ThumbnailView; import org.thoughtcrime.securesms.crypto.MasterSecret; import org.thoughtcrime.securesms.providers.CaptureProvider; @@ -43,24 +45,29 @@ import org.thoughtcrime.securesms.util.MediaUtil; import java.io.IOException; public class AttachmentManager { + private final static String TAG = AttachmentManager.class.getSimpleName(); - private final Context context; - private final View attachmentView; - private final ThumbnailView thumbnail; - private final SlideDeck slideDeck; - private final AttachmentListener attachmentListener; + private final @NonNull Context context; + private final @NonNull View attachmentView; + private final @NonNull RemovableMediaView removableMediaView; + private final @NonNull ThumbnailView thumbnail; + private final @NonNull AudioView audioView; + private final @NonNull SlideDeck slideDeck; + private final @NonNull AttachmentListener attachmentListener; private Uri captureUri; - public AttachmentManager(Activity view, AttachmentListener listener) { + public AttachmentManager(@NonNull Activity view, @NonNull AttachmentListener listener) { this.attachmentView = view.findViewById(R.id.attachment_editor); this.thumbnail = (ThumbnailView) view.findViewById(R.id.attachment_thumbnail); + this.audioView = (AudioView) view.findViewById(R.id.attachment_audio); + this.removableMediaView = (RemovableMediaView) view.findViewById(R.id.removable_media_view); this.slideDeck = new SlideDeck(); this.context = view; this.attachmentListener = listener; - thumbnail.setRemoveClickListener(new RemoveButtonListener()); + removableMediaView.setRemoveClickListener(new RemoveButtonListener()); } public void clear() { @@ -81,6 +88,7 @@ public class AttachmentManager { }); attachmentView.startAnimation(animation); + audioView.cleanup(); } public void cleanup() { @@ -135,7 +143,15 @@ public class AttachmentManager { } else { slideDeck.addSlide(slide); attachmentView.setVisibility(View.VISIBLE); - thumbnail.setImageResource(masterSecret, slide, false, true); + + if (slide.hasAudio()) { + audioView.setAudio(masterSecret, (AudioSlide)slide, false); + removableMediaView.display(audioView); + } else { + thumbnail.setImageResource(masterSecret, slide, false); + removableMediaView.display(thumbnail); + } + attachmentListener.onAttachmentChanged(); } } diff --git a/src/org/thoughtcrime/securesms/mms/SlideClickListener.java b/src/org/thoughtcrime/securesms/mms/SlideClickListener.java new file mode 100644 index 0000000000..f7d1345ca0 --- /dev/null +++ b/src/org/thoughtcrime/securesms/mms/SlideClickListener.java @@ -0,0 +1,7 @@ +package org.thoughtcrime.securesms.mms; + +import android.view.View; + +public interface SlideClickListener { + void onClick(View v, Slide slide); +} diff --git a/src/org/thoughtcrime/securesms/mms/SlideDeck.java b/src/org/thoughtcrime/securesms/mms/SlideDeck.java index 546eef1967..8709494587 100644 --- a/src/org/thoughtcrime/securesms/mms/SlideDeck.java +++ b/src/org/thoughtcrime/securesms/mms/SlideDeck.java @@ -81,6 +81,17 @@ public class SlideDeck { return slide; } } + + return null; + } + + public @Nullable AudioSlide getAudioSlide() { + for (Slide slide : slides) { + if (slide.hasAudio()) { + return (AudioSlide)slide; + } + } + return null; } }