From 042c41356952297eec9f0fa3173c1fba36b95182 Mon Sep 17 00:00:00 2001 From: tobiasKaminsky Date: Mon, 16 Aug 2021 07:20:11 +0200 Subject: [PATCH] Calendar backup/import Signed-off-by: tobiasKaminsky --- build.gradle | 6 + drawable_resources/nav_contacts.svg | 1 - lint.xml | 1 + ...FragmentIT_showCalendarAndContactsList.png | Bin 0 -> 15010 bytes ....BackupListFragmentIT_showCalendarList.png | Bin 0 -> 10745 bytes ...t.BackupListFragmentIT_showContactList.png | Bin 0 -> 10292 bytes ...gment.BackupListFragmentIT_showLoading.png | Bin 0 -> 8155 bytes scripts/analysis/findbugs-results.txt | 2 +- scripts/analysis/lint-results.txt | 2 +- spotbugs-filter.xml | 1 + src/androidTest/assets/calendar.ics | 131 ++++ .../ui/fragment/BackupListFragmentIT.kt | 113 +++ .../ui/fragment/ContactListFragmentIT.kt | 49 -- src/main/AndroidManifest.xml | 2 + .../nextcloud/client/di/ComponentsModule.java | 8 +- .../client/jobs/BackgroundJobFactory.kt | 21 + .../client/jobs/BackgroundJobManager.kt | 36 + .../client/jobs/BackgroundJobManagerImpl.kt | 60 +- .../client/jobs/CalendarBackupWork.kt | 80 ++ .../client/jobs/CalendarImportWork.kt | 77 ++ .../client/jobs/ContactsImportWork.kt | 11 +- .../client/preferences/AppPreferences.java | 8 + .../preferences/AppPreferencesImpl.java | 28 +- .../activity/ContactsPreferenceActivity.java | 14 +- .../android/ui/activity/DrawerActivity.java | 5 - .../android/ui/activity/SettingsActivity.java | 20 +- .../ui/asynctasks/LoadContactsTask.java | 81 ++ ...ackupFragment.java => BackupFragment.java} | 330 +++++--- .../contactsbackup/BackupListAdapter.kt | 403 ++++++++++ .../contactsbackup/BackupListFragment.java | 490 ++++++++++++ .../BackupListHeaderViewHolder.kt | 49 ++ .../BackupListItemViewHolder.kt | 28 + .../CalendarItemViewHolder.java | 79 ++ .../contactsbackup/ContactItemViewHolder.java | 43 + .../contactsbackup/ContactListAdapter.java | 250 ++++++ .../contactsbackup/ContactListFragment.java | 735 ------------------ .../contactsbackup/ContactsAccount.java | 68 ++ .../contactsbackup/VCardComparator.java | 37 + .../owncloud/android/utils/MimeTypeUtil.java | 8 + .../android/utils/PermissionUtil.java | 3 +- .../sufficientlysecure/AndroidCalendar.java | 160 ++++ .../sufficientlysecure/CalendarSource.java | 96 +++ .../DuplicateHandlingEnum.java | 30 + .../sufficientlysecure/ProcessVEvent.java | 642 +++++++++++++++ .../sufficientlysecure/SaveCalendar.java | 620 +++++++++++++++ src/main/res/drawable/nav_contacts.xml | 25 - src/main/res/layout/backup_fragment.xml | 143 ++++ src/main/res/layout/backup_list_item.xml | 32 + .../res/layout/backup_list_item_header.xml | 45 ++ ...t_fragment.xml => backuplist_fragment.xml} | 69 +- .../res/layout/calendarlist_list_item.xml | 69 ++ src/main/res/layout/contactlist_list_item.xml | 4 +- .../res/layout/contacts_backup_fragment.xml | 96 --- src/main/res/menu/partial_drawer_entries.xml | 5 - src/main/res/values/setup.xml | 5 +- src/main/res/values/strings.xml | 45 +- src/main/res/xml/preferences.xml | 6 +- 57 files changed, 4261 insertions(+), 1111 deletions(-) delete mode 100644 drawable_resources/nav_contacts.svg create mode 100644 screenshots/gplay/debug/com.owncloud.android.ui.fragment.BackupListFragmentIT_showCalendarAndContactsList.png create mode 100644 screenshots/gplay/debug/com.owncloud.android.ui.fragment.BackupListFragmentIT_showCalendarList.png create mode 100644 screenshots/gplay/debug/com.owncloud.android.ui.fragment.BackupListFragmentIT_showContactList.png create mode 100644 screenshots/gplay/debug/com.owncloud.android.ui.fragment.BackupListFragmentIT_showLoading.png create mode 100644 src/androidTest/assets/calendar.ics create mode 100644 src/androidTest/java/com/owncloud/android/ui/fragment/BackupListFragmentIT.kt delete mode 100644 src/androidTest/java/com/owncloud/android/ui/fragment/ContactListFragmentIT.kt create mode 100644 src/main/java/com/nextcloud/client/jobs/CalendarBackupWork.kt create mode 100644 src/main/java/com/nextcloud/client/jobs/CalendarImportWork.kt create mode 100644 src/main/java/com/owncloud/android/ui/asynctasks/LoadContactsTask.java rename src/main/java/com/owncloud/android/ui/fragment/contactsbackup/{ContactsBackupFragment.java => BackupFragment.java} (58%) create mode 100644 src/main/java/com/owncloud/android/ui/fragment/contactsbackup/BackupListAdapter.kt create mode 100644 src/main/java/com/owncloud/android/ui/fragment/contactsbackup/BackupListFragment.java create mode 100644 src/main/java/com/owncloud/android/ui/fragment/contactsbackup/BackupListHeaderViewHolder.kt create mode 100644 src/main/java/com/owncloud/android/ui/fragment/contactsbackup/BackupListItemViewHolder.kt create mode 100644 src/main/java/com/owncloud/android/ui/fragment/contactsbackup/CalendarItemViewHolder.java create mode 100644 src/main/java/com/owncloud/android/ui/fragment/contactsbackup/ContactItemViewHolder.java create mode 100644 src/main/java/com/owncloud/android/ui/fragment/contactsbackup/ContactListAdapter.java delete mode 100644 src/main/java/com/owncloud/android/ui/fragment/contactsbackup/ContactListFragment.java create mode 100644 src/main/java/com/owncloud/android/ui/fragment/contactsbackup/ContactsAccount.java create mode 100644 src/main/java/com/owncloud/android/ui/fragment/contactsbackup/VCardComparator.java create mode 100644 src/main/java/third_parties/sufficientlysecure/AndroidCalendar.java create mode 100644 src/main/java/third_parties/sufficientlysecure/CalendarSource.java create mode 100644 src/main/java/third_parties/sufficientlysecure/DuplicateHandlingEnum.java create mode 100644 src/main/java/third_parties/sufficientlysecure/ProcessVEvent.java create mode 100644 src/main/java/third_parties/sufficientlysecure/SaveCalendar.java delete mode 100644 src/main/res/drawable/nav_contacts.xml create mode 100644 src/main/res/layout/backup_fragment.xml create mode 100644 src/main/res/layout/backup_list_item.xml create mode 100644 src/main/res/layout/backup_list_item_header.xml rename src/main/res/layout/{contactlist_fragment.xml => backuplist_fragment.xml} (61%) create mode 100644 src/main/res/layout/calendarlist_list_item.xml delete mode 100644 src/main/res/layout/contacts_backup_fragment.xml diff --git a/build.gradle b/build.gradle index 623669cee5..984b7ab910 100644 --- a/build.gradle +++ b/build.gradle @@ -328,6 +328,12 @@ dependencies { implementation "io.noties:prism4j:$prismVersion" kapt "io.noties:prism4j-bundler:$prismVersion" + implementation ('org.mnode.ical4j:ical4j:1.0.6') { + ['org.apache.commons','commons-logging'].each { + exclude group: "$it" + } + } + // dependencies for local unit tests testImplementation 'junit:junit:4.13.2' testImplementation "org.mockito:mockito-core:$mockitoVersion" diff --git a/drawable_resources/nav_contacts.svg b/drawable_resources/nav_contacts.svg deleted file mode 100644 index 815b654c84..0000000000 --- a/drawable_resources/nav_contacts.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/lint.xml b/lint.xml index 4e0e3058bd..54ed10f516 100644 --- a/lint.xml +++ b/lint.xml @@ -43,6 +43,7 @@ + diff --git a/screenshots/gplay/debug/com.owncloud.android.ui.fragment.BackupListFragmentIT_showCalendarAndContactsList.png b/screenshots/gplay/debug/com.owncloud.android.ui.fragment.BackupListFragmentIT_showCalendarAndContactsList.png new file mode 100644 index 0000000000000000000000000000000000000000..71af22fc5304c9926d2c12a0ba4372295fd10b74 GIT binary patch literal 15010 zcmeI3XH-+&*6$-0tRN36N>zCTl`g#orKxmj3WQLkH|bJBL{N|xmEN0#4x)4jL8K@F zA@m*vDFH(S0wD=W?&h3x&pGe7_kHhp@45He8GPG=y>|9mbImp9@BiPgj0|+noa8-EHU0e99P9T;mf_p6G0#ul zylx+dBJ+p7LN&U?XS&2o)oLcZy5=@|v8aci(;)%E8Tv{rP)hFHd5`?N-dl4_?38y< zb89Hqg~yw)Jm--TW=+nNw9U1O#H?1D#z3JyT}|9Z>=u_Xnxx zAO735%R`4i5o~|E_P2ld+qM5cH-(k3&|eAX-t}&$I88g}!F~mUToNPr?ZU^*ESB=p ztbM=MEoZvRW#!2D{`?~2Hd@M!7?2)RDTA~ImRU7=QJmAyzhvXfe91153wDo_Xn)FJ zc*Zh+>_Pbzp4cjFeGx>b+)`ogZ{rQ&R1*e;9locL2|O#z$Tsk3$c`k`Awf3NEIm4! zKjivcC=unz9B=TPsT;R+FZOJha!TcYh}6q?s|E~k@Hd!e*)VFge>_r%|gZH1P85w3P zNbRg*1*=-t%FDcQ?G$1oEwHd#K5ls@(OMi_kdBJ6);0e4%f_RW3hYFKUg!^6YZi;jf;+JtT zn$MlCsd?8{>y6%9h^y;7De|*dAo%AS>*|ZG?>Ac3ky;lXy<32ttGZbW@kY}F#jVoB zA;Z2Hu{7MrX)%#Ao;ugo-B)2%hGDsPj5!fWyOgMQ5dVY~wcj0wUE{^Ck!g_V9xGHtENH&!tGJ4^A012y8QVnTc?#&IS;z{3>iSI0Ig0HbRoaB&85DS8^ zj)Iw#K|C-~rJGNq0xQYCKeG?g`x9<>SIV> zNxzRn^!&_P`0YqG+VuG@-8LicUh#{oGE9KG4Yvwa(!X#@lz zbJm%=9`zI!R`sFpW^)E}5dk*ff`<8o4}4-3$PU^4^~fwVF4CMMQF)`u!3H`SQiEnt zX9wYwrL+Kp8;B_|SpK#s4B zU}*6Qq8<6e$^?(^kJwCVm-C5i6R-+secTTT89D(?rdVEuOdvfHa6 zF|bPxC%l?|yu|lO3eFg9z(Gf-L7Kd#i_c6(!xx^NWlcCcP>OBiijB0YGtl%D?TQe* zv%Z|={Z01G0*H&X^ek(`LjH%HajTW(Y5Sb|EEhqA@`>R>c5v6P2nF?M8&t>=g<-54 zj~v=7pC@vguoekfqZI3;mc)fKO+LTm@k&oKPG0;tsD@!(DXrC_IK68FazqH*v-hio zam#pX<9T2czhB$gArwu{@1daRaLSGyeP_ZDSQEH7)IaSU>;C=5qV^rLt@d$Y8%|>A z5qlMRH7g)xx@gVuC&0kmAHDlB-VEw%5S>S77rP`hO~O1hVuUDrxKiHx$e= zLB~mMu{T_`#8kucti=E1WF5Z>0>ojObyCc92v+mrK#wu!&2#$U#-|d5+Diwz z4_$Y=9yAwV!L1zVq4uQLp_R~JrR5mwY9ckQq*+z7dq^Zx7`?xRA)%S{8f|rL_d7d> z9|5ax<)Ojbp+WL9Z^UZzEZN36O-|epFQ*UE1JCgtm*?z$8l^fTj>gh)L$400YIznZd@&_jBv=|j z7M^jkHYs%UMhLs<8q*LpF1ZP}b*;Rb9r-6s^44H=T4+B)rcJVJ``XB)?*y5;@ZCD* zww{)VM$`k7IkQiJ2H#i7BV9I@cqqZ z?FaO7AFOH9_vfclA?7|TXIUp!x8p4JcJM`` zvGPB)Rod>C_InL|`s0Ne@iw+jMVhGNuscw>H*5NUasOE5K)>8Gbtc=y=qV}CDsTc1 z7VxwFNU^X{6nG{WUx=4<%fhxjf)a!>Q#JIeUaj<4kOuMsr(K(O`67eIA35hJrEx^U zW1Xg&n)FYHmACH~2Db(Z_<1JW2!brdEMr!2@rPY@@H$fEipQR6ed*3#LtgiG(9A!t z|4FJ!G;{YB2j- zSif_|txq6IJGr%bJZ@DuvCJ9RGrnC8*q}>-uZwoi!3sE{^o{3R`i#5;wBC1z#|!-~ zmEu}}i6F(!>!T8;5?jV6(#iD=O;H|FiI7Xw@yMUN5^fmy zOmi|1^I8*ui;7gIa^)rMsc25z&Q>@Ffkxo+E{<1Gt!WH)C^ z%d|ZIq(0z!XdB_*#i8@F*8%49Jr3K3-d~dNW4fGxK&i3djpx_Kx%|9}c(t}2M%Nbdo2K0=%<9B| z$W)XHSQiFU?uqNPH8g2JWYRWjd-=__tFy}6>c<)pyR&|S`{av<_)HeADmXm$kMcR< zWxe{YcE%V-y$OS2UOD|PrD#_e9(gFGE?F(p_*E$D=a#0scK=*r??R`v^me$6(_b^e zLf7~ApV=^xM;0GnN#zmNS0>b%B)CIlx`92H_sYoIAaK$ydfzNffVtN@qI%)Sx$T#W z>H_tHrCOSPZUom`o1xa4e;!lUIwQ(*#m36Z=%GxO_T{dv*lD{fY`=^XOAqyYl5n%F z&7OwE_`%d>2Jhc@7^fLbp;~h^r!A?D~s#$z-E& zTtHY3TWLe8novuXapK{o3V^;*p9QrXTpEX}R)ax7^|`BgS8PyVL*C9Q~tv0d9DKUaWc*xd(jf7K`QrW@JD&@4zcPjRORw))R5P3n<8Vt+!dK#d~M*F?H!a1TJ zse?rH=o#oHyKRMvO^5|}sCuI?%&^W;e}ZmaijDFW`5!t7X!n_#xbUg%JmgYn17n%I7O4yO&M=3R z!dl+@KYzk75h!Nzw|QiuNtq`649NiZiCYO5<*#|Zf7nav>|GqbggB$izJc<$^u{Iz zUqZ0)<;b-!@6@@ZjTbxm-(_v58nEGlE%)KUA~&omMdC9RuI&)Vh3HR;j}8;O!``%V zH;CtGc5N@Lpp&07b5)P&HG)C5iDyVYN$C4fgUn2-zZ+ziSRpoBu(oo1;j;xkc5?X*Fw@pWN&1(N zNG`TZ$t@{vgd}ukYN{xmzV;xY{WvLa9xOAFEimnjA8lAtaqUcYbuDr~R#H$TB-`S_ zF)hJI-TA6H`Kx7Oq@dUq{Uok?i%1j0307Gs9SYt8u))%Giv$tm_M{ZL2E)3f7D-FtfPdjz&~RsMVeBoW9B6|_n0}vomr|py zwKm*aPc^vOBY?i|Ry@G>Z28=(*4=Qm@cWU2F!!Hb{m-EBVaYt-w zaA4@lhdA*Kkw$!)`J>Y1%Z>NlJNPW&uucxh;eb~|ULI2?b%VUK_-BG_%Y?u;=9u3~ z{lEIX-NaKiyRQ-U~F@BkC|6)n%BB!QxOH<0{`i>gd7Zszrp)W}Yfu zhhBQq(E206|C>Ky`)-!9VI$`ANFX-LH)9d*U5@5bJj0{6b zpH;4Tb|!Cqx}=YjG9vW%MlYE^tD*(n6G-bMM+c#@Xyt|wbWo&Y9AoRDcKkyq(*OB; zCnB5TtizunQae@7H@bMN8qQjLj{DC4KLc4qDCM$3V7xtAhpVsn1p1kXqjB@Fycy6!Pt2TZChm zf9u)qtT!LJ1LtaIp80<#Xbe9CwV!JcpL+Bo&lmMeAC#@gCV!XHkaZ-m|a};T;F5Lk)GdpBLhDS+`t}~$lE;)K& z*8HhsAy+cLE>?)Y2{;wxv2R=JqLepv+i8qO&ideS#Q?%oPTAE9kX1k2MB9y(7meOd~F^TT4ckVa?UI66@a&`kpVC?-pUI4s5}&XhR1wo z``x`HZaVd;{*Gh+J)2NMJqe&_`%DARsapq3N~~eh-W90FD#-PNU}jkOw~mOzD=q93 zh;UNTXoW?Mf9DC|{^1>Kpbf?E&Un}O3?IKdypj{VoPh^;iUE1n&(QM8r=ytmjmnST z?gNdHVN&Wf7PiX^yxt)v}Z4DysPwEK3lF4?WC5Cxw6iRLU6MzOHxonVCe}9e>yZ4DdRQ!yeuLCBzNQo}(NTC<8hY;yI{B`{h9( z^51%VSM6c1ZL8uv4lgZ{Zh(6|JfDma{iw%ALDLD|ichW%=_K-IB&tzYdEaX7X?_t1 z{Z(rweA`fU$5iR(YmH%ZE6)@S87e{o;068wTrS=v%#_IE!lcjeDanB4GwI6yt43pm z95q>R24&==NagElpp6duz@g9R_A3uv$q5?H4c*9~?WREjr~H8_ECy(^0vPQ+QGF)0 zDEnpKSy~AIq!nEzfEXmLRn#Cy48f{%VcP@2@D~?r-uLo{{@h5IbVf6FYDOk&9Yz3% zI#aqc8ZK}|>GII}@yRK%amA9OA*V-AYF&J>DuH++%2jC0lVJYxI3_`UUID!OMG)~$ zta0{3xfQTa!}mT5EO(qVZjGN%1*kGbR_ZLFAez6gep^BDdZ%pWZqY6cAbD!+({X2K z?V|aH`GRKOq#4gpR`T-3AwhHgJ(rw+X8L9)A5`3m=MYM3rN=PF2G<;8dapiPj1ua( z`m1il01x1^is@)L@UUUvm2xp$b0>_e^Y(uV#j%E{cNdjKR&2tu4biw9GNb!0Z5eBFp zI6G_`|H$Ya8K!oU&*@kzmx34E_1XIJb20>gS>yK>5rYFOSgtmJrkK-Vd%u6+hjk?e z28N8?5$|IqX}~0`SudC5g=eCH-7r5w_iSED&DyB7KWRgV2phBF=C(@gNi8@_$NPLR zf?dLDVNGXF8o3q|01kII4~VSOD76+)5P-Z?s_*3w{ zz~wgH$Zq?(pevcAI4AsC;jy+3mQ(Klk)fjJsJHzQn2f8=$EOY$?BuOvPRH2sXA0Od zo0&vMnWYd=-%;O~)QL-*AM@xQNQ2`?LZs^tNE?#eB&jd8B&zvT98eS}SLLJ#H>qni z&C8jj`B36mIuQrN#XcS2H(%*wspm8RS{nj8%Z6>;%>2Of1G2Qg=%Rd3x#GQVYPTe977#R`F5jg;SO808QZA_L6*tWR?)UlHEHavC@!pbe)1+#ecl zqsI{!zQ1}b2K!U%W5~6o(yCg=>%x88KcWT9k8UfiS0>Sl?dL;Gs#(?Z;jCsMRa?Ho z0^QlG)AmupiX7uoTdJ8}g*D3US3BG$9y@ec#+%ou*{f7rHWJ%u%iQiNHl%w1iD3ZR zRi=9@$Ep;$EkwW{cnr$(OiIBlbI_~QIR_wjJe*Jn?z{8|xv@1W^7ki{qVlP*U?1|jafe4>UxS^rA1Nl*XV*)^xm{Q^eKtb?9?Oxn7rC*hj z4qZ0`wuydeyH&n+`ovCS25RT#U-du>E?8zj!qb}hc$?OHD60D`+c{ACk~$*4dfwXx z0+Tl{TbBiOGy;38kbX9K#R5@_wacC+&m&h zZlMzHEC$v~)mJt&Sy@8TloKdZhG4pX?)Z7XehVwldVJrlk4s0@^W{Z+rbZ6V9w6~T zC~K}WKoQ^{!7qPCaWy}3%WP~R*3oDETe<|_cDG1|<$1q8c9q{9BtSU$8T#CUos?vt zbb}Q}cn{nA5sf((k}SI%-&_BU_4LKpL_7Y$xb-|P`NtVkTP&A{vT~X<*MM}8T=U7M z+KGQ5HP6R5`S?K72g}h?LDR(##XbqRmtC-qMdbDR?zw@5H4ivrJI`+b^)?C7B&nM$ z>lMv?o4+wPL8MIU5_~fbDswLwkVXa!q8w&R|74M@t~NKA^|&Vf*>W~gx!oI3K@=6@ zo`wCY?cLJXEXSGtsriu{$*z{3sIU;j+c~Di37>^kwl`D8(s&wG6Q;xJuAZwWH{I2> z2CHr0=F<;;^2*I_*wFedyUr+?$6sFKwfA}lG!{1ZLah_TGJo|Z@>vNE742Hjo-4NA z4)!7EmIn7$=@nfN2)bGkp3=02A&it5CVUs~+QG)nB$!*;JR`;`EO*y>JKdD=G4zj^ z?>|o_R>NrD7J5WB$$Rg!0Xb?!wgT$=^%~^@lE=HvCj`jnYo|%Tp(4w=1?1t3lHKlg zb4bA0exom@YU4nx1agXdyIiP4->Kh_THr|UA63N)_AhEbV4*8ttxn*FzPQ*H2;l6J z8E-VIXK}1s%zTm2CRM1q!No62ZNf|*2%}y=s+~P!6xuun+u!V^h`Rwtst5mN)(+AI zXxL_0^jF0U^}I8&$gS!6pBIPB>BKRu5rbJ*QELBsE8Rn;iU=HNW@sJw)7dMY2Za%^ z$-Gck_~=A1V-w-09}ZB&;(AVdTtyaI8Lx28zT3)vq)p z3}B+JDQ4yw)ip|8i&EgWmX$oXnuOrS_S|3UtxL|?ZzAQWUr!Oy%0ThaXfwdQFBN*z= zdK$XVlbGpV{Ydt@=ZL<%{z1mdjpR=oOlTd?0aReuimHY_`g(mDkNtdDd<+`MqFdW> zGvhB^IuTB^`+cW6toc`AwB3P`tUEjJ(^Kqo{5}xjOj`5TeL%JTTJ)!K&_?!3O7G$e z-;r_dKkPz(yx>B9{H7$r2T(&wZK+wW46yzQ$lcPE=rtJe1(=REr2JX%c8f(ajg{Ib zQj@(RJ6O%_UkB<^S~;?k?PoC6>u~weX_Xm3%^L=kzc(%Z_*)Q+mc|HXVL`&jtZBLs ztctAH`rR}7q10}oeztTf&8jCpnd@1z%FtrDqZDpqX97A{-)3*)XA;+rbGe#$s%&(F zHP=Mz`uJEQ1V{StvTMC1kTCrdQ&rOj>R*cBFHF67xD>EO@cwIdi2u4#`VSQFKS75b z9&99_=_^sj?O#13Ablgid*Y^Nv*t?O!!*h-N05tnMEL&+-p#*z5x%+TgHN3R8-k(k z1*1sEIK}X0(2yt#9V`%kkH*~2@!y|5Rcs=^nsfCeKzW9x$n5&jvr>Onjg>f9hB1&8 zWoXlr^CtWhNj?(|4RArO*il@X<%KA z_Vn}%YePx|rBW-)I?{Bf;~+dUl{h7iW*|Qan#5fY8*ZSl7*>eSsxj$rSJdtyPV?o! z+xMmnhhqU$mnAl@$YcP>aNAvtTUt>JTdzOu-t1N8u%7v8f4=k{o>;tf8nKdWaBJ7H zI&@72*R~^0p0aZo$?=B0=`o<*tTGDOXT|vIZkKl)mTWvqJ*?5LN2?xEtZ^B` zH4J%-E3UY+bHEaJYWTC2(pdZnT1v?1dAcaQ#Z<7}8F9!pXPEdSIU1}tH(jqMc>fdn zlRe+asbb6Re};L@k5mkfb;6?&`jF``)GsS0Ie2=hr*T+_u2kH;;+^y@-1n(c32A5c zc4T?(wl$uz4wY39o2t^cEVH2b-^M>)lrMG!!@l1N5SKTQEEKU@AHJuhaskXNCOu4U z-rZ$U|MPT~z_**!$ z8;Fp1}QlnvKbqx|aD_rS7Jxvk@W?A1NU`*1BK%Df;?1VF%%oS0*J(nT*h) z_UeG%ERQ1>n@YftHm<;R#|oQ2iqkAH7&Ml zV+ARO>>xkGd>!dv+=ILAyHk4~;NV@T#)$8!I!xqfXZk%8_kB9%l}Ux$g7nQhp_p~3 z#EXFci3g)R5Afhlb|Oxu08w4fc-eL0ivx|VKN7zIcQ)l$A+HQiz}amYdTiA|F5?mW z|BL_^{v!fxz^A2(y$H#Au*Y=&l4f@Jdne2r(T9sO{*@B*hB($;x+||MOEMW>hn!!= zo|p7lvoITcRJa~i2CE3(de*qM&fJEBzY5M9S_b{Vr}F4;mZ;ql2u4+pUA)_D^r{&b$MH#b6LFBS+uI5laM;@A!W8cKe-jojM^9M#jwQ z;Xa;QP`xkw2UnWgx5)d#Y)K^sx+SVqJ1QU{$k_Ew6!1e1>r_rl=TKMQ*Le&E;zLh0 z48qpHC~p_)Tu)pw#LJo>{#;ob4Si23bdA`p8phf1y(v5o&)_G7g@Dpq|BeU)Ct6(- z&~2%QB|^p>_{rT?I{MD za#A-k8MPyS;9usX>V?j4nOknWP22WG+{{s_G&;GFe$zt1b#C5uZ}qF|M|Y?p_8P9h z)B$Xl^J2tKM8asrmpJLEuJn8Nife}ev~Li93)hBc&l1ZVUmN*U!uqUPWJrTlghSDu z$pGwL#&A70^Rd~2fPs}Z_~^+IF{LR ziILYIMC``8(C!Ww;b90N*7YI2w#i73aH=l~xPPeuFhUe_#$1$ad{QzlD}L6*s~JOb z$;zFD+irv+7z`UeOW~2hL+S2^QD+7F;}mBDUVYD=%4*Oo9nYolE4DuAeaTwh{Ysmr zbgVAL$}5I5%2)UVNYdii-xaX`+&}nV%Vz({f%)IP3HCDnqSRIR3&4rCBmv6LI6^%o z(f0blms2h&p_xzofDw0nao+s5_@Rz$-c`VBLTO|pjGR$8U={xHbTC?CNiFMgl}p9h0;TYI?O-vK2?a4*Q#-~?$^9%1C&e?q;@ zX+U;@lz2#T21VbU32vJ>sQff)4v2ssd2=C7LM`ZM2;tC^k884^p>s|`{Tgj@sXjoN zI|zwjFlfZV)CrKf$@R|te(AyNf(hm?XK+>-=W|85AsPvvWCQ#}ZS?ZY7Ek~d8>&(M zWU36RY`+tSw7g@`X(e)=(=zxE?s3SBmIfLCfDE?>TMYwHDqSNCAU629 zAe_FKRf8VTe(FsFtSQrDs$NTBqJGW-3JXxHRlvVXa>54!J>e1_U|9XofD}O_7e7!X z>T|EwB7i4t)&a(}lJP1ln&HuO&cmwW7dTC@Hwj$CxmI#xctpklFCENcH$m#%V^Sy3 zzMOpk^Bt~k-I#uisU8Jf9w}C*oa$YUHZiE{Bez-kWadO8u|+y12V{_&c;8M5C^?7sHW$}nHZu*LBLL}Ab|%z4AOOTp-cTTX&yG4r3{R7r^B)zvr7B4)79XSVEf}d&raWB2{${rY5@lieB>6FBk4I0b}J-?}E4S zHWYB_Y~Nb@IOUYJG@%0cQG>H8LVtf^^=s77yHU9TP!WBgH+aY`X{$cwZ~!a4<{rX! z#wz&ZjEgq+qE2uQ|Dh}TW*i_QVQ>GaK|DJ1W22S$4ZP zSuX>aS4Kj4w;OQnO_i>IWY*OYhkpWg*^v+pRkqyCZ10cFC^a0DK^_5y%cXscJB70b z>^c-0;A~7yedLreTci3h!p1AZSUOQ%glqMHC+qO~XnGxer7q60^IC)L9?;2~&F2B+ z^pTeVdab;<}SA7=_*zlp%I#(&RhXAGpyud0l=WV zR$Ann6mrBjem_1xj`Q8$u`LlO_zn4#{J(&8VtVDeNQmP&z@#PD=%M)$u2M1^t^^o& zx8=H&>_s~O16&qseHRt*{~=Y*-%1T#D=R@Bbbe+t55F>Ecg~CH185Q8d&`5L2jp?t z6HvS#C^I0RtALS(+ZTdLZ5-H?1;T#c4p+&XTvS&sf$9kzi`9C4T0pI>XA#>71JH!o zQDY5z+ef4#i^c<#2C#}oEQS{_uPFh}PQL+4Bbx4M=Jzxkq*-w{$~5M6BAtg8zL+3i z?H6qPF~PPCaOC+8U692bM?Uq8GKqTPpoOLZFe#TduW`h+(iuRG24E@{F|5I^n(V$( zE2j0V=hdCjK%gtbt(d=^9KZPaEEoUH7S%pNNjROlbW2-IvE}XoQ{u9PyYOxN-4a#_EQRPDK} znWiz?UNqG@n@zr`ccDzegt9!|8nW3#)c|?Q>@dYG52UGkR|eZF zXAT!wb3^7C>(`<|%T#p){;p3-)4@&-_@*I&6g_`+$f3(W-XoDQ=&eC&*3KyE;il(t zc=%kaFO?eSx{grYeaM!xcExl8IGPC6>nA;Trow3izbg6Vugskq_&~wAs^i9|5?vw2 zMeP8N9|r9C@(?B*W^c*_A~OvRC_^9D!{!h4DwT5B=QHyCvXs&>956<(L2wM?Ct!jK z(y9~c0#Hg)J^-F~Kv&k%>cMK?Ag>IQzASG7h~Kqd()qx6Tewq^^r)u2dk- zB5nu!6cjuRRI!0gE$y0Q8;$L*LzyAK5XuGA^URx5(4}#U(5Vy^R@+2&((&yP17*)t zQRU~VseH1HiaVXsTAs}zMz9&j8`UoI%BH@zbFPZ61IE5hPL5sG5K{X3E|=s=Y@bFy z_0{;`C9?_9TqllIpYu(R#4@$x6hDI`MScOfUOK=K7~6i?_~R*3Y0hAx@h2=;U_CR* z9S@T6_A!jPXdx|}VHqaShsmgPs6#i@U! zc^w61`uuAj$^3Wg)xUqD^Pd~a{;w+If8B-l525~VcD8^03!;Bd&VTVu*1sp`-;?uy o=d}5E@7O;7A8Rgd!~g&Q literal 0 HcmV?d00001 diff --git a/screenshots/gplay/debug/com.owncloud.android.ui.fragment.BackupListFragmentIT_showCalendarList.png b/screenshots/gplay/debug/com.owncloud.android.ui.fragment.BackupListFragmentIT_showCalendarList.png new file mode 100644 index 0000000000000000000000000000000000000000..c85d01c2763db5a4cac4fcb45bb0a11f1c4e30d8 GIT binary patch literal 10745 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

xtj@c7`175P$|VQ*juop4$_fD3$9YZe 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 G@qYjSu0;0$ literal 0 HcmV?d00001 diff --git a/screenshots/gplay/debug/com.owncloud.android.ui.fragment.BackupListFragmentIT_showContactList.png b/screenshots/gplay/debug/com.owncloud.android.ui.fragment.BackupListFragmentIT_showContactList.png new file mode 100644 index 0000000000000000000000000000000000000000..c61a0a17ce44574b4e2ef79a0f629dfdee81dc93 GIT binary patch literal 10292 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 BPXzz~ literal 0 HcmV?d00001 diff --git a/screenshots/gplay/debug/com.owncloud.android.ui.fragment.BackupListFragmentIT_showLoading.png b/screenshots/gplay/debug/com.owncloud.android.ui.fragment.BackupListFragmentIT_showLoading.png new file mode 100644 index 0000000000000000000000000000000000000000..7d12490a9630cc4ba39d8be871ca38af9f1cab34 GIT binary patch literal 8155 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 Report: 1 error and 112 warnings + Lint Report: 1 error and 111 warnings diff --git a/spotbugs-filter.xml b/spotbugs-filter.xml index ec53db7df8..ad1844bbe2 100644 --- a/spotbugs-filter.xml +++ b/spotbugs-filter.xml @@ -45,6 +45,7 @@ + diff --git a/src/androidTest/assets/calendar.ics b/src/androidTest/assets/calendar.ics new file mode 100644 index 0000000000..d505884c3f --- /dev/null +++ b/src/androidTest/assets/calendar.ics @@ -0,0 +1,131 @@ +BEGIN:VCALENDAR +PRODID:-//dummy@gmail.com//iCal Import/Export 3.18.0 Alpha1// + EN +VERSION:2.0 +METHOD:PUBLISH +CALSCALE:GREGORIAN +X-WR-TIMEZONE:UTC +BEGIN:VTIMEZONE +TZID:Europe/Berlin +LAST-MODIFIED:20201011T015911Z +TZURL:http://tzurl.org/zoneinfo/Europe/Berlin +X-LIC-LOCATION:Europe/Berlin +X-PROLEPTIC-TZNAME:LMT +BEGIN:STANDARD +TZNAME:CET +TZOFFSETFROM:+005328 +TZOFFSETTO:+0100 +DTSTART:18930401T000000 +END:STANDARD +BEGIN:DAYLIGHT +TZNAME:CEST +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +DTSTART:19160430T230000 +RDATE:19400401T020000 +RDATE:19430329T020000 +RDATE:19460414T020000 +RDATE:19470406T030000 +RDATE:19480418T020000 +RDATE:19490410T020000 +RDATE:19800406T020000 +END:DAYLIGHT +BEGIN:STANDARD +TZNAME:CET +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +DTSTART:19161001T010000 +RDATE:19421102T030000 +RDATE:19431004T030000 +RDATE:19441002T030000 +RDATE:19451118T030000 +RDATE:19461007T030000 +END:STANDARD +BEGIN:DAYLIGHT +TZNAME:CEST +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +DTSTART:19170416T020000 +RRULE:FREQ=YEARLY;UNTIL=19180415T010000Z;BYMONTH=4;BYDAY=3MO +END:DAYLIGHT +BEGIN:STANDARD +TZNAME:CET +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +DTSTART:19170917T030000 +RRULE:FREQ=YEARLY;UNTIL=19180916T010000Z;BYMONTH=9;BYDAY=3MO +END:STANDARD +BEGIN:DAYLIGHT +TZNAME:CEST +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +DTSTART:19440403T020000 +RRULE:FREQ=YEARLY;UNTIL=19450402T010000Z;BYMONTH=4;BYDAY=1MO +END:DAYLIGHT +BEGIN:DAYLIGHT +TZNAME:CEMT +TZOFFSETFROM:+0200 +TZOFFSETTO:+0300 +DTSTART:19450524T010000 +RDATE:19470511T020000 +END:DAYLIGHT +BEGIN:DAYLIGHT +TZNAME:CEST +TZOFFSETFROM:+0300 +TZOFFSETTO:+0200 +DTSTART:19450924T030000 +RDATE:19470629T030000 +END:DAYLIGHT +BEGIN:STANDARD +TZNAME:CET +TZOFFSETFROM:+0100 +TZOFFSETTO:+0100 +DTSTART:19460101T000000 +RDATE:19800101T000000 +END:STANDARD +BEGIN:STANDARD +TZNAME:CET +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +DTSTART:19471005T030000 +RRULE:FREQ=YEARLY;UNTIL=19491002T010000Z;BYMONTH=10;BYDAY=1SU +END:STANDARD +BEGIN:STANDARD +TZNAME:CET +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +DTSTART:19800928T030000 +RRULE:FREQ=YEARLY;UNTIL=19950924T010000Z;BYMONTH=9;BYDAY=-1SU +END:STANDARD +BEGIN:DAYLIGHT +TZNAME:CEST +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +DTSTART:19810329T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +END:DAYLIGHT +BEGIN:STANDARD +TZNAME:CET +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +DTSTART:19961027T030000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +DTSTAMP:20210820T083606Z +UID:16294485666795adb8b4b08e94d3cb4445e1e3ee18fd9@nextcloud.com +SUMMARY:Test event +DESCRIPTION: +ORGANIZER:mailto:dummy@gmail.com +LOCATION: +STATUS:CONFIRMED +DTSTART;TZID=Europe/Berlin:20210806T090000 +DTEND:20210806T080000Z +BEGIN:VALARM +TRIGGER:-PT30M +ACTION:DISPLAY +DESCRIPTION:Test event +END:VALARM +END:VEVENT +END:VCALENDAR diff --git a/src/androidTest/java/com/owncloud/android/ui/fragment/BackupListFragmentIT.kt b/src/androidTest/java/com/owncloud/android/ui/fragment/BackupListFragmentIT.kt new file mode 100644 index 0000000000..ab2746c7f1 --- /dev/null +++ b/src/androidTest/java/com/owncloud/android/ui/fragment/BackupListFragmentIT.kt @@ -0,0 +1,113 @@ +/* + * Nextcloud Android client application + * + * @author Tobias Kaminsky + * Copyright (C) 2021 Tobias Kaminsky + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.owncloud.android.ui.fragment + +import android.Manifest +import androidx.test.espresso.intent.rule.IntentsTestRule +import androidx.test.rule.GrantPermissionRule +import com.owncloud.android.AbstractIT +import com.owncloud.android.R +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.ui.activity.ContactsPreferenceActivity +import com.owncloud.android.ui.fragment.contactsbackup.BackupListFragment +import com.owncloud.android.utils.ScreenshotTest +import org.junit.Rule +import org.junit.Test + +class BackupListFragmentIT : AbstractIT() { + @get:Rule + val testActivityRule = IntentsTestRule(ContactsPreferenceActivity::class.java, true, false) + + @get:Rule + val permissionRule: GrantPermissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CALENDAR) + + @Test + @ScreenshotTest + fun showLoading() { + val sut = testActivityRule.launchActivity(null) + val file = OCFile("/", "00000001") + val transaction = sut.supportFragmentManager.beginTransaction() + + transaction.replace(R.id.frame_container, BackupListFragment.newInstance(file, user)) + transaction.commit() + + waitForIdleSync() + screenshot(sut) + } + + @Test + @ScreenshotTest + fun showContactList() { + val sut = testActivityRule.launchActivity(null) + val transaction = sut.supportFragmentManager.beginTransaction() + val file = getFile("vcard.vcf") + val ocFile = OCFile("/vcard.vcf", "00000002") + ocFile.storagePath = file.absolutePath + ocFile.mimeType = "text/vcard" + + transaction.replace(R.id.frame_container, BackupListFragment.newInstance(ocFile, user)) + transaction.commit() + + waitForIdleSync() + shortSleep() + screenshot(sut) + } + + @Test + @ScreenshotTest + fun showCalendarList() { + val sut = testActivityRule.launchActivity(null) + val transaction = sut.supportFragmentManager.beginTransaction() + val file = getFile("calendar.ics") + val ocFile = OCFile("/Private calender_2020-09-01_10-45-20.ics.ics", "00000003") + ocFile.storagePath = file.absolutePath + ocFile.mimeType = "text/calendar" + + transaction.replace(R.id.frame_container, BackupListFragment.newInstance(ocFile, user)) + transaction.commit() + + waitForIdleSync() + screenshot(sut) + } + + @Test + @ScreenshotTest + fun showCalendarAndContactsList() { + val sut = testActivityRule.launchActivity(null) + val transaction = sut.supportFragmentManager.beginTransaction() + + val calendarFile = getFile("calendar.ics") + val calendarOcFile = OCFile("/Private calender_2020-09-01_10-45-20.ics", "00000003") + calendarOcFile.storagePath = calendarFile.absolutePath + calendarOcFile.mimeType = "text/calendar" + + val contactFile = getFile("vcard.vcf") + val contactOcFile = OCFile("/vcard.vcf", "00000002") + contactOcFile.storagePath = contactFile.absolutePath + contactOcFile.mimeType = "text/vcard" + + val files = arrayOf(calendarOcFile, contactOcFile) + transaction.replace(R.id.frame_container, BackupListFragment.newInstance(files, user)) + transaction.commit() + + waitForIdleSync() + screenshot(sut) + } +} diff --git a/src/androidTest/java/com/owncloud/android/ui/fragment/ContactListFragmentIT.kt b/src/androidTest/java/com/owncloud/android/ui/fragment/ContactListFragmentIT.kt deleted file mode 100644 index a44aaae90e..0000000000 --- a/src/androidTest/java/com/owncloud/android/ui/fragment/ContactListFragmentIT.kt +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Nextcloud Android client application - * - * @author Andy Scherzinger - * Copyright (C) 2020 Andy Scherzinger - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -package com.owncloud.android.ui.fragment - -import androidx.test.espresso.intent.rule.IntentsTestRule -import com.owncloud.android.AbstractIT -import com.owncloud.android.R -import com.owncloud.android.datamodel.OCFile -import com.owncloud.android.ui.activity.ContactsPreferenceActivity -import com.owncloud.android.ui.fragment.contactsbackup.ContactListFragment -import com.owncloud.android.utils.ScreenshotTest -import org.junit.Rule -import org.junit.Test - -class ContactListFragmentIT : AbstractIT() { - @get:Rule - val testActivityRule = IntentsTestRule(ContactsPreferenceActivity::class.java, true, false) - - val file = OCFile("/", "00000001") - - @Test - @ScreenshotTest - fun showContactListFragmentLoading() { - val sut = testActivityRule.launchActivity(null) - val transaction = sut.supportFragmentManager.beginTransaction() - transaction.replace(R.id.frame_container, ContactListFragment.newInstance(file, user)) - transaction.commit() - - waitForIdleSync() - screenshot(sut) - } -} diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index fdf673f446..e170bd5ecd 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -33,6 +33,8 @@ + + diff --git a/src/main/java/com/nextcloud/client/di/ComponentsModule.java b/src/main/java/com/nextcloud/client/di/ComponentsModule.java index f2fa392b88..c0aed8b4c7 100644 --- a/src/main/java/com/nextcloud/client/di/ComponentsModule.java +++ b/src/main/java/com/nextcloud/client/di/ComponentsModule.java @@ -78,8 +78,8 @@ import com.owncloud.android.ui.fragment.FileDetailSharingFragment; import com.owncloud.android.ui.fragment.GalleryFragment; import com.owncloud.android.ui.fragment.LocalFileListFragment; import com.owncloud.android.ui.fragment.OCFileListFragment; -import com.owncloud.android.ui.fragment.contactsbackup.ContactListFragment; -import com.owncloud.android.ui.fragment.contactsbackup.ContactsBackupFragment; +import com.owncloud.android.ui.fragment.contactsbackup.BackupFragment; +import com.owncloud.android.ui.fragment.contactsbackup.BackupListFragment; import com.owncloud.android.ui.preview.PreviewImageActivity; import com.owncloud.android.ui.preview.PreviewImageFragment; import com.owncloud.android.ui.preview.PreviewMediaFragment; @@ -155,13 +155,13 @@ abstract class ComponentsModule { abstract ChooseRichDocumentsTemplateDialogFragment chooseRichDocumentsTemplateDialogFragment(); @ContributesAndroidInjector - abstract ContactsBackupFragment contactsBackupFragment(); + abstract BackupFragment contactsBackupFragment(); @ContributesAndroidInjector abstract PreviewImageFragment previewImageFragment(); @ContributesAndroidInjector - abstract ContactListFragment chooseContactListFragment(); + abstract BackupListFragment chooseContactListFragment(); @ContributesAndroidInjector abstract PreviewMediaFragment previewMediaFragment(); diff --git a/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt b/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt index 9d0a9c4e9e..f27b091a45 100644 --- a/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt +++ b/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt @@ -87,6 +87,8 @@ class BackgroundJobFactory @Inject constructor( MediaFoldersDetectionWork::class -> createMediaFoldersDetectionWork(context, workerParameters) NotificationWork::class -> createNotificationWork(context, workerParameters) AccountRemovalWork::class -> createAccountRemovalWork(context, workerParameters) + CalendarBackupWork::class -> createCalendarBackupWork(context, workerParameters) + CalendarImportWork::class -> createCalendarImportWork(context, workerParameters) else -> null // caller falls back to default factory } } @@ -131,6 +133,25 @@ class BackgroundJobFactory @Inject constructor( ) } + private fun createCalendarBackupWork(context: Context, params: WorkerParameters): CalendarBackupWork { + return CalendarBackupWork( + context, + params, + contentResolver, + accountManager, + preferences + ) + } + + private fun createCalendarImportWork(context: Context, params: WorkerParameters): CalendarImportWork { + return CalendarImportWork( + context, + params, + logger, + contentResolver + ) + } + private fun createFilesSyncWork(context: Context, params: WorkerParameters): FilesSyncWork { return FilesSyncWork( context = context, diff --git a/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt b/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt index ba0f0fc133..5c50815243 100644 --- a/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt +++ b/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt @@ -71,6 +71,31 @@ interface BackgroundJobManager { */ fun startImmediateContactsBackup(user: User): LiveData + /** + * Schedule periodic calendar backups job. Operating system will + * decide when to start the job. + * + * This call is idempotent - there can be only one scheduled job + * at any given time. + * + * @param user User for which job will be scheduled. + */ + fun schedulePeriodicCalendarBackup(user: User) + + /** + * Cancel periodic calendar backup. Existing tasks might finish, but no new + * invocations will occur. + */ + fun cancelPeriodicCalendarBackup(user: User) + + /** + * Immediately start single calendar backup job. + * This job will launch independently from periodic calendar backup. + * + * @return Job info with current status; status is null if job does not exist + */ + fun startImmediateCalendarBackup(user: User): LiveData + /** * Immediately start contacts import job. Import job will be started only once. * If new job is started while existing job is running - request will be ignored @@ -90,6 +115,17 @@ interface BackgroundJobManager { selectedContacts: IntArray ): LiveData + /** + * Immediately start calendar import job. Import job will be started only once. + * If new job is started while existing job is running - request will be ignored + * and currently running job will continue running. + * + * @param calendarPaths Array of paths of calendar files to import from + * + * @return Job info with current status; status is null if job does not exist + */ + fun startImmediateCalendarImport(calendarPaths: Map): LiveData + fun schedulePeriodicFilesSyncJob() fun startImmediateFilesSyncJob(skipCustomFolders: Boolean = false, overridePowerSaving: Boolean = false) fun scheduleOfflineSync() diff --git a/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt b/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt index fd0fe59fa7..db6532e1d2 100644 --- a/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt +++ b/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt @@ -68,6 +68,8 @@ internal class BackgroundJobManagerImpl( const val JOB_PERIODIC_CONTACTS_BACKUP = "periodic_contacts_backup" const val JOB_IMMEDIATE_CONTACTS_BACKUP = "immediate_contacts_backup" const val JOB_IMMEDIATE_CONTACTS_IMPORT = "immediate_contacts_import" + const val JOB_PERIODIC_CALENDAR_BACKUP = "periodic_calendar_backup" + const val JOB_IMMEDIATE_CALENDAR_IMPORT = "immediate_calendar_import" const val JOB_PERIODIC_FILES_SYNC = "periodic_files_sync" const val JOB_IMMEDIATE_FILES_SYNC = "immediate_files_sync" const val JOB_PERIODIC_OFFLINE_SYNC = "periodic_offline_sync" @@ -75,6 +77,7 @@ internal class BackgroundJobManagerImpl( const val JOB_IMMEDIATE_MEDIA_FOLDER_DETECTION = "immediate_media_folder_detection" const val JOB_NOTIFICATION = "notification" const val JOB_ACCOUNT_REMOVAL = "account_removal" + const val JOB_IMMEDIATE_CALENDAR_BACKUP = "immediate_calendar_backup" const val JOB_TEST = "test_job" @@ -85,7 +88,7 @@ internal class BackgroundJobManagerImpl( const val TAG_PREFIX_START_TIMESTAMP = "timestamp" val PREFIXES = setOf(TAG_PREFIX_NAME, TAG_PREFIX_USER, TAG_PREFIX_START_TIMESTAMP) const val NOT_SET_VALUE = "not set" - const val PERIODIC_CONTACTS_BACKUP_INTERVAL_MINUTES = 24 * 60L + const val PERIODIC_BACKUP_INTERVAL_MINUTES = 24 * 60L const val DEFAULT_PERIODIC_JOB_INTERVAL_MINUTES = 15L const val DEFAULT_IMMEDIATE_JOB_DELAY_SEC = 3L @@ -228,7 +231,7 @@ internal class BackgroundJobManagerImpl( val request = periodicRequestBuilder( jobClass = ContactsBackupWork::class, jobName = JOB_PERIODIC_CONTACTS_BACKUP, - intervalMins = PERIODIC_CONTACTS_BACKUP_INTERVAL_MINUTES, + intervalMins = PERIODIC_BACKUP_INTERVAL_MINUTES, user = user ).setInputData(data).build() @@ -267,6 +270,26 @@ internal class BackgroundJobManagerImpl( return workManager.getJobInfo(request.id) } + override fun startImmediateCalendarImport(calendarPaths: Map): LiveData { + + val data = Data.Builder() + .putAll(calendarPaths) + .build() + + val constraints = Constraints.Builder() + .setRequiresCharging(false) + .build() + + val request = oneTimeRequestBuilder(CalendarImportWork::class, JOB_IMMEDIATE_CALENDAR_IMPORT) + .setInputData(data) + .setConstraints(constraints) + .build() + + workManager.enqueueUniqueWork(JOB_IMMEDIATE_CALENDAR_IMPORT, ExistingWorkPolicy.KEEP, request) + + return workManager.getJobInfo(request.id) + } + override fun startImmediateContactsBackup(user: User): LiveData { val data = Data.Builder() .putString(ContactsBackupWork.ACCOUNT, user.accountName) @@ -281,6 +304,39 @@ internal class BackgroundJobManagerImpl( return workManager.getJobInfo(request.id) } + override fun startImmediateCalendarBackup(user: User): LiveData { + val data = Data.Builder() + .putString(CalendarBackupWork.ACCOUNT, user.accountName) + .putBoolean(CalendarBackupWork.FORCE, true) + .build() + + val request = oneTimeRequestBuilder(CalendarBackupWork::class, JOB_IMMEDIATE_CALENDAR_BACKUP, user) + .setInputData(data) + .build() + + workManager.enqueueUniqueWork(JOB_IMMEDIATE_CALENDAR_BACKUP, ExistingWorkPolicy.KEEP, request) + return workManager.getJobInfo(request.id) + } + + override fun schedulePeriodicCalendarBackup(user: User) { + val data = Data.Builder() + .putString(CalendarBackupWork.ACCOUNT, user.accountName) + .putBoolean(CalendarBackupWork.FORCE, true) + .build() + val request = periodicRequestBuilder( + jobClass = CalendarBackupWork::class, + jobName = JOB_PERIODIC_CALENDAR_BACKUP, + intervalMins = PERIODIC_BACKUP_INTERVAL_MINUTES, + user = user + ).setInputData(data).build() + + workManager.enqueueUniquePeriodicWork(JOB_PERIODIC_CALENDAR_BACKUP, ExistingPeriodicWorkPolicy.KEEP, request) + } + + override fun cancelPeriodicCalendarBackup(user: User) { + workManager.cancelJob(JOB_PERIODIC_CALENDAR_BACKUP, user) + } + override fun schedulePeriodicFilesSyncJob() { val request = periodicRequestBuilder( jobClass = FilesSyncWork::class, diff --git a/src/main/java/com/nextcloud/client/jobs/CalendarBackupWork.kt b/src/main/java/com/nextcloud/client/jobs/CalendarBackupWork.kt new file mode 100644 index 0000000000..4e6095521c --- /dev/null +++ b/src/main/java/com/nextcloud/client/jobs/CalendarBackupWork.kt @@ -0,0 +1,80 @@ +/* + * + * Nextcloud Android client application + * + * @author Tobias Kaminsky + * Copyright (C) 2021 Tobias Kaminsky + * Copyright (C) 2021 Nextcloud GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.nextcloud.client.jobs + +import android.content.ContentResolver +import android.content.Context +import android.text.TextUtils +import androidx.work.Worker +import androidx.work.WorkerParameters +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.preferences.AppPreferences +import com.owncloud.android.lib.common.utils.Log_OC +import third_parties.sufficientlysecure.AndroidCalendar +import third_parties.sufficientlysecure.SaveCalendar +import java.util.Calendar + +class CalendarBackupWork( + appContext: Context, + params: WorkerParameters, + private val contentResolver: ContentResolver, + private val accountManager: UserAccountManager, + private val preferences: AppPreferences +) : Worker(appContext, params) { + + companion object { + val TAG = CalendarBackupWork::class.java.simpleName + const val ACCOUNT = "account" + const val FORCE = "force" + const val JOB_INTERVAL_MS: Long = 24 * 60 * 60 * 1000 + } + + override fun doWork(): Result { + val accountName = inputData.getString(ACCOUNT) ?: "" + val optionalUser = accountManager.getUser(accountName) + if (!optionalUser.isPresent || TextUtils.isEmpty(accountName)) { // no account provided + return Result.failure() + } + val lastExecution = preferences.calendarLastBackup + + val force = inputData.getBoolean(FORCE, false) + if (force || lastExecution + JOB_INTERVAL_MS < Calendar.getInstance().timeInMillis) { + + AndroidCalendar.loadAll(contentResolver).forEach { calendar -> + SaveCalendar( + applicationContext, + calendar, + preferences, + accountManager.user + ).start() + } + + // store execution date + preferences.calendarLastBackup = Calendar.getInstance().timeInMillis + } else { + Log_OC.d(TAG, "last execution less than 24h ago") + } + + return Result.success() + } +} diff --git a/src/main/java/com/nextcloud/client/jobs/CalendarImportWork.kt b/src/main/java/com/nextcloud/client/jobs/CalendarImportWork.kt new file mode 100644 index 0000000000..13d3513aea --- /dev/null +++ b/src/main/java/com/nextcloud/client/jobs/CalendarImportWork.kt @@ -0,0 +1,77 @@ +/* + * Nextcloud Android client application + * + * @author Tobias Kaminsky + * Copyright (C) 2017 Tobias Kaminsky + * Copyright (C) 2017 Nextcloud GmbH. + * Copyright (C) 2020 Chris Narkiewicz + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.nextcloud.client.jobs + +import android.content.ContentResolver +import android.content.Context +import androidx.work.Worker +import androidx.work.WorkerParameters +import com.nextcloud.client.logger.Logger +import net.fortuna.ical4j.data.CalendarBuilder +import third_parties.sufficientlysecure.AndroidCalendar +import third_parties.sufficientlysecure.CalendarSource +import third_parties.sufficientlysecure.ProcessVEvent +import java.io.File + +class CalendarImportWork( + private val appContext: Context, + params: WorkerParameters, + private val logger: Logger, + private val contentResolver: ContentResolver +) : Worker(appContext, params) { + + companion object { + const val TAG = "CalendarImportWork" + const val SELECTED_CALENDARS = "selected_contacts_indices" + } + + override fun doWork(): Result { + val calendarPaths = inputData.getStringArray(SELECTED_CALENDARS) ?: arrayOf() + val calendars = inputData.keyValueMap as Map + + val calendarBuilder = CalendarBuilder() + + for ((path, selectedCalendar) in calendars) { + logger.d(TAG, "Import calendar from $path") + + val file = File(path) + val calendarSource = CalendarSource( + file.toURI().toURL().toString(), + null, + null, + null, + appContext + ) + + val calendars = AndroidCalendar.loadAll(contentResolver)[0] + + ProcessVEvent( + appContext, + calendarBuilder.build(calendarSource.stream), + selectedCalendar, + true + ).run() + } + + return Result.success() + } +} diff --git a/src/main/java/com/nextcloud/client/jobs/ContactsImportWork.kt b/src/main/java/com/nextcloud/client/jobs/ContactsImportWork.kt index 6abd9d4965..fdb74f0743 100644 --- a/src/main/java/com/nextcloud/client/jobs/ContactsImportWork.kt +++ b/src/main/java/com/nextcloud/client/jobs/ContactsImportWork.kt @@ -29,8 +29,8 @@ import android.provider.ContactsContract import androidx.work.Worker import androidx.work.WorkerParameters import com.nextcloud.client.logger.Logger -import com.owncloud.android.ui.fragment.contactsbackup.ContactListFragment -import com.owncloud.android.ui.fragment.contactsbackup.ContactListFragment.VCardComparator +import com.owncloud.android.ui.fragment.contactsbackup.BackupListFragment +import com.owncloud.android.ui.fragment.contactsbackup.VCardComparator import ezvcard.Ezvcard import ezvcard.VCard import third_parties.ezvcard_android.ContactOperations @@ -70,7 +70,10 @@ class ContactsImportWork( try { val operations = ContactOperations(applicationContext, contactsAccountName, contactsAccountType) vCards.addAll(Ezvcard.parse(file).all()) - Collections.sort(vCards, VCardComparator()) + Collections.sort( + vCards, + VCardComparator() + ) cursor = contentResolver.query( ContactsContract.Contacts.CONTENT_URI, null, @@ -91,7 +94,7 @@ class ContactsImportWork( } for (contactIndex in selectedContactsIndices) { val vCard = vCards[contactIndex] - if (ContactListFragment.getDisplayName(vCard).isEmpty()) { + if (BackupListFragment.getDisplayName(vCard).isEmpty()) { if (!ownContactMap.containsKey(vCard)) { operations.insertContact(vCard) } else { diff --git a/src/main/java/com/nextcloud/client/preferences/AppPreferences.java b/src/main/java/com/nextcloud/client/preferences/AppPreferences.java index 7f89486e81..105b119c60 100644 --- a/src/main/java/com/nextcloud/client/preferences/AppPreferences.java +++ b/src/main/java/com/nextcloud/client/preferences/AppPreferences.java @@ -361,4 +361,12 @@ public interface AppPreferences { void resetPinWrongAttempts(); int pinBruteForceDelay(); + + String getUidPid(); + + void setUidPid(String uidPid); + + long getCalendarLastBackup(); + + void setCalendarLastBackup(long timestamp); } diff --git a/src/main/java/com/nextcloud/client/preferences/AppPreferencesImpl.java b/src/main/java/com/nextcloud/client/preferences/AppPreferencesImpl.java index bbfca9d072..19b116d8db 100644 --- a/src/main/java/com/nextcloud/client/preferences/AppPreferencesImpl.java +++ b/src/main/java/com/nextcloud/client/preferences/AppPreferencesImpl.java @@ -90,6 +90,10 @@ public final class AppPreferencesImpl implements AppPreferences { private static final String PREF__PHOTO_SEARCH_TIMESTAMP = "photo_search_timestamp"; private static final String PREF__POWER_CHECK_DISABLED = "power_check_disabled"; private static final String PREF__PIN_BRUTE_FORCE_COUNT = "pin_brute_force_count"; + private static final String PREF__UID_PID = "uid_pid"; + + private static final String PREF__CALENDAR_AUTOMATIC_BACKUP = "calendar_automatic_backup"; + private static final String PREF__CALENDAR_LAST_BACKUP = "calendar_last_backup"; private final Context context; private final SharedPreferences preferences; @@ -97,8 +101,8 @@ public final class AppPreferencesImpl implements AppPreferences { private final ListenerRegistry listeners; /** - * Adapter delegating raw {@link SharedPreferences.OnSharedPreferenceChangeListener} calls - * with key-value pairs to respective {@link com.nextcloud.client.preferences.AppPreferences.Listener} method. + * Adapter delegating raw {@link SharedPreferences.OnSharedPreferenceChangeListener} calls with key-value pairs to + * respective {@link com.nextcloud.client.preferences.AppPreferences.Listener} method. */ static class ListenerRegistry implements SharedPreferences.OnSharedPreferenceChangeListener { private final AppPreferences preferences; @@ -660,6 +664,26 @@ public final class AppPreferencesImpl implements AppPreferences { return computeBruteForceDelay(count); } + @Override + public String getUidPid() { + return preferences.getString(PREF__UID_PID, ""); + } + + @Override + public void setUidPid(String uidPid) { + preferences.edit().putString(PREF__UID_PID, uidPid).apply(); + } + + @Override + public long getCalendarLastBackup() { + return preferences.getLong(PREF__CALENDAR_LAST_BACKUP, 0); + } + + @Override + public void setCalendarLastBackup(long timestamp) { + preferences.edit().putLong(PREF__CALENDAR_LAST_BACKUP, timestamp).apply(); + } + @VisibleForTesting public int computeBruteForceDelay(int count) { return (int) Math.min(count / 3d, 10); diff --git a/src/main/java/com/owncloud/android/ui/activity/ContactsPreferenceActivity.java b/src/main/java/com/owncloud/android/ui/activity/ContactsPreferenceActivity.java index 31752e7b9b..074de51a10 100644 --- a/src/main/java/com/owncloud/android/ui/activity/ContactsPreferenceActivity.java +++ b/src/main/java/com/owncloud/android/ui/activity/ContactsPreferenceActivity.java @@ -31,8 +31,8 @@ import com.nextcloud.client.jobs.BackgroundJobManager; import com.owncloud.android.R; import com.owncloud.android.datamodel.OCFile; import com.owncloud.android.ui.fragment.FileFragment; -import com.owncloud.android.ui.fragment.contactsbackup.ContactListFragment; -import com.owncloud.android.ui.fragment.contactsbackup.ContactsBackupFragment; +import com.owncloud.android.ui.fragment.contactsbackup.BackupFragment; +import com.owncloud.android.ui.fragment.contactsbackup.BackupListFragment; import javax.inject.Inject; @@ -48,7 +48,7 @@ public class ContactsPreferenceActivity extends FileActivity implements FileFrag public static final String EXTRA_FILE = "FILE"; public static final String EXTRA_USER = "USER"; /** - * Warning: default for this extra is different between this activity and {@link ContactsBackupFragment} + * Warning: default for this extra is different between this activity and {@link BackupFragment} */ public static final String EXTRA_SHOW_SIDEBAR = "SHOW_SIDEBAR"; public static final String PREFERENCE_CONTACTS_AUTOMATIC_BACKUP = "PREFERENCE_CONTACTS_AUTOMATIC_BACKUP"; @@ -84,7 +84,7 @@ public class ContactsPreferenceActivity extends FileActivity implements FileFrag setupToolbar(); // setup drawer - setupDrawer(R.id.nav_contacts); + //setupDrawer(R.id.nav_contacts); // TODO needed? // show sidebar? boolean showSidebar = getIntent().getBooleanExtra(EXTRA_SHOW_SIDEBAR, true); @@ -105,12 +105,12 @@ public class ContactsPreferenceActivity extends FileActivity implements FileFrag FragmentTransaction transaction = getSupportFragmentManager().beginTransaction(); if (intent == null || intent.getParcelableExtra(EXTRA_FILE) == null || intent.getParcelableExtra(EXTRA_USER) == null) { - ContactsBackupFragment fragment = ContactsBackupFragment.create(showSidebar); + BackupFragment fragment = BackupFragment.create(showSidebar); transaction.add(R.id.frame_container, fragment); } else { OCFile file = intent.getParcelableExtra(EXTRA_FILE); User user = intent.getParcelableExtra(EXTRA_USER); - ContactListFragment contactListFragment = ContactListFragment.newInstance(file, user); + BackupListFragment contactListFragment = BackupListFragment.newInstance(file, user); transaction.add(R.id.frame_container, contactListFragment); } transaction.commit(); @@ -139,7 +139,7 @@ public class ContactsPreferenceActivity extends FileActivity implements FileFrag @Override public void onBackPressed() { - if (getSupportFragmentManager().findFragmentByTag(ContactListFragment.TAG) != null) { + if (getSupportFragmentManager().findFragmentByTag(BackupListFragment.TAG) != null) { getSupportFragmentManager().popBackStack(BACKUP_TO_LIST, FragmentManager.POP_BACK_STACK_INCLUSIVE); } else { finish(); diff --git a/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java b/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java index 926c71e56e..294fb72239 100644 --- a/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java +++ b/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java @@ -403,9 +403,6 @@ public abstract class DrawerActivity extends ToolbarActivity DrawerMenuUtil.removeMenuItem(menu, R.id.nav_community, !getResources().getBoolean(R.bool.participate_enabled)); DrawerMenuUtil.removeMenuItem(menu, R.id.nav_shared, !getResources().getBoolean(R.bool.shared_enabled)); - DrawerMenuUtil.removeMenuItem(menu, R.id.nav_contacts, !getResources().getBoolean(R.bool.contacts_backup) - || !getResources().getBoolean(R.bool.show_drawer_contacts_backup)); - DrawerMenuUtil.removeMenuItem(menu, R.id.nav_logout, !getResources().getBoolean(R.bool.show_drawer_logout)); } @@ -450,8 +447,6 @@ public abstract class DrawerActivity extends ToolbarActivity startActivity(ActivitiesActivity.class, Intent.FLAG_ACTIVITY_CLEAR_TOP); } else if (itemId == R.id.nav_notifications) { startActivity(NotificationsActivity.class); - } else if (itemId == R.id.nav_contacts) { - ContactsPreferenceActivity.startActivity(this); } else if (itemId == R.id.nav_settings) { startActivity(SettingsActivity.class); } else if (itemId == R.id.nav_community) { diff --git a/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java b/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java index db82aff5b7..259491ce33 100644 --- a/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java +++ b/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java @@ -325,7 +325,7 @@ public class SettingsActivity extends ThemedPreferenceActivity setupCalendarPreference(preferenceCategoryMore); - setupContactsBackupPreference(preferenceCategoryMore); + setupBackupPreference(); setupE2EMnemonicPreference(preferenceCategoryMore); @@ -474,19 +474,13 @@ public class SettingsActivity extends ThemedPreferenceActivity } } - private void setupContactsBackupPreference(PreferenceCategory preferenceCategoryMore) { - boolean contactsBackupEnabled = !getResources().getBoolean(R.bool.show_drawer_contacts_backup) - && getResources().getBoolean(R.bool.contacts_backup); - Preference pContactsBackup = findPreference("contacts"); + private void setupBackupPreference() { + Preference pContactsBackup = findPreference("backup"); if (pContactsBackup != null) { - if (contactsBackupEnabled) { - pContactsBackup.setOnPreferenceClickListener(preference -> { - ContactsPreferenceActivity.startActivityWithoutSidebar(this); - return true; - }); - } else { - preferenceCategoryMore.removePreference(pContactsBackup); - } + pContactsBackup.setOnPreferenceClickListener(preference -> { + ContactsPreferenceActivity.startActivityWithoutSidebar(this); + return true; + }); } } diff --git a/src/main/java/com/owncloud/android/ui/asynctasks/LoadContactsTask.java b/src/main/java/com/owncloud/android/ui/asynctasks/LoadContactsTask.java new file mode 100644 index 0000000000..b93dcb3efa --- /dev/null +++ b/src/main/java/com/owncloud/android/ui/asynctasks/LoadContactsTask.java @@ -0,0 +1,81 @@ +/* + * + * Nextcloud Android client application + * + * @author Tobias Kaminsky + * Copyright (C) 2021 Tobias Kaminsky + * Copyright (C) 2021 Nextcloud GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.owncloud.android.ui.asynctasks; + +import android.os.AsyncTask; + +import com.owncloud.android.datamodel.OCFile; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.ui.fragment.contactsbackup.BackupListFragment; +import com.owncloud.android.ui.fragment.contactsbackup.VCardComparator; + +import java.io.File; +import java.io.IOException; +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import ezvcard.Ezvcard; +import ezvcard.VCard; + +public class LoadContactsTask extends AsyncTask { + private final WeakReference backupListFragmentWeakReference; + private final OCFile ocFile; + private final List vCards = new ArrayList<>(); + + public LoadContactsTask(BackupListFragment backupListFragment, OCFile ocFile) { + this.backupListFragmentWeakReference = new WeakReference<>(backupListFragment); + this.ocFile = ocFile; + } + + @Override + protected void onPreExecute() { + if (backupListFragmentWeakReference.get() != null && !backupListFragmentWeakReference.get().hasCalendarEntry()) { + backupListFragmentWeakReference.get().showLoadingMessage(true); + } + } + + @Override + protected Boolean doInBackground(Void... voids) { + if (!isCancelled()) { + File file = new File(ocFile.getStoragePath()); + try { + vCards.addAll(Ezvcard.parse(file).all()); + Collections.sort(vCards, new VCardComparator()); + } catch (IOException e) { + Log_OC.e(this, "IO Exception: " + file.getAbsolutePath()); + return Boolean.FALSE; + } + return Boolean.TRUE; + } + return Boolean.FALSE; + } + + @Override + protected void onPostExecute(Boolean bool) { + if (!isCancelled() && bool && backupListFragmentWeakReference.get() != null) { + backupListFragmentWeakReference.get().loadVCards(vCards); + } + } +} diff --git a/src/main/java/com/owncloud/android/ui/fragment/contactsbackup/ContactsBackupFragment.java b/src/main/java/com/owncloud/android/ui/fragment/contactsbackup/BackupFragment.java similarity index 58% rename from src/main/java/com/owncloud/android/ui/fragment/contactsbackup/ContactsBackupFragment.java rename to src/main/java/com/owncloud/android/ui/fragment/contactsbackup/BackupFragment.java index a678a6dec6..982c4fc0a0 100644 --- a/src/main/java/com/owncloud/android/ui/fragment/contactsbackup/ContactsBackupFragment.java +++ b/src/main/java/com/owncloud/android/ui/fragment/contactsbackup/BackupFragment.java @@ -21,10 +21,8 @@ package com.owncloud.android.ui.fragment.contactsbackup; import android.Manifest; -import android.accounts.Account; import android.app.DatePickerDialog; import android.content.Context; -import android.content.DialogInterface; import android.content.Intent; import android.os.AsyncTask; import android.os.Bundle; @@ -34,14 +32,14 @@ import android.view.View; import android.view.ViewGroup; import android.widget.CompoundButton; import android.widget.DatePicker; +import android.widget.Toast; -import com.google.android.material.snackbar.Snackbar; import com.nextcloud.client.account.User; import com.nextcloud.client.di.Injectable; import com.nextcloud.client.jobs.BackgroundJobManager; import com.nextcloud.java.util.Optional; import com.owncloud.android.R; -import com.owncloud.android.databinding.ContactsBackupFragmentBinding; +import com.owncloud.android.databinding.BackupFragmentBinding; import com.owncloud.android.datamodel.ArbitraryDataProvider; import com.owncloud.android.datamodel.FileDataStorageManager; import com.owncloud.android.datamodel.OCFile; @@ -51,17 +49,17 @@ import com.owncloud.android.ui.activity.ContactsPreferenceActivity; import com.owncloud.android.ui.activity.SettingsActivity; import com.owncloud.android.ui.fragment.FileFragment; import com.owncloud.android.utils.DisplayUtils; +import com.owncloud.android.utils.MimeTypeUtil; import com.owncloud.android.utils.PermissionUtil; import com.owncloud.android.utils.theme.ThemeButtonUtils; import com.owncloud.android.utils.theme.ThemeCheckableUtils; import com.owncloud.android.utils.theme.ThemeColorUtils; -import com.owncloud.android.utils.theme.ThemeSnackbarUtils; import com.owncloud.android.utils.theme.ThemeToolbarUtils; import com.owncloud.android.utils.theme.ThemeUtils; +import java.util.ArrayList; import java.util.Calendar; import java.util.Collections; -import java.util.Comparator; import java.util.Date; import java.util.List; @@ -76,15 +74,15 @@ import third_parties.daveKoeller.AlphanumComparator; import static com.owncloud.android.ui.activity.ContactsPreferenceActivity.PREFERENCE_CONTACTS_AUTOMATIC_BACKUP; import static com.owncloud.android.ui.activity.ContactsPreferenceActivity.PREFERENCE_CONTACTS_LAST_BACKUP; -public class ContactsBackupFragment extends FileFragment implements DatePickerDialog.OnDateSetListener, Injectable { - public static final String TAG = ContactsBackupFragment.class.getSimpleName(); +public class BackupFragment extends FileFragment implements DatePickerDialog.OnDateSetListener, Injectable { + public static final String TAG = BackupFragment.class.getSimpleName(); private static final String ARG_SHOW_SIDEBAR = "SHOW_SIDEBAR"; private static final String KEY_CALENDAR_PICKER_OPEN = "IS_CALENDAR_PICKER_OPEN"; private static final String KEY_CALENDAR_DAY = "CALENDAR_DAY"; private static final String KEY_CALENDAR_MONTH = "CALENDAR_MONTH"; private static final String KEY_CALENDAR_YEAR = "CALENDAR_YEAR"; - private ContactsBackupFragmentBinding binding; + private BackupFragmentBinding binding; @Inject BackgroundJobManager backgroundJobManager; @@ -93,13 +91,15 @@ public class ContactsBackupFragment extends FileFragment implements DatePickerDi private DatePickerDialog datePickerDialog; - private CompoundButton.OnCheckedChangeListener onCheckedChangeListener; + private CompoundButton.OnCheckedChangeListener dailyBackupCheckedChangeListener; + private CompoundButton.OnCheckedChangeListener contactsCheckedListener; + private CompoundButton.OnCheckedChangeListener calendarCheckedListener; private ArbitraryDataProvider arbitraryDataProvider; private User user; private boolean showSidebar = true; - public static ContactsBackupFragment create(boolean showSidebar) { - ContactsBackupFragment fragment = new ContactsBackupFragment(); + public static BackupFragment create(boolean showSidebar) { + BackupFragment fragment = new BackupFragment(); Bundle bundle = new Bundle(); bundle.putBoolean(ARG_SHOW_SIDEBAR, showSidebar); fragment.setArguments(bundle); @@ -114,7 +114,7 @@ public class ContactsBackupFragment extends FileFragment implements DatePickerDi getContext().getTheme().applyStyle(R.style.FallbackThemingTheme, true); } - binding = ContactsBackupFragmentBinding.inflate(inflater, container, false); + binding = BackupFragmentBinding.inflate(inflater, container, false); View view = binding.getRoot(); setHasOptionsMenu(true); @@ -129,7 +129,7 @@ public class ContactsBackupFragment extends FileFragment implements DatePickerDi ActionBar actionBar = contactsPreferenceActivity != null ? contactsPreferenceActivity.getSupportActionBar() : null; if (actionBar != null) { - ThemeToolbarUtils.setColoredTitle(actionBar, getString(R.string.actionbar_contacts), getContext()); + ThemeToolbarUtils.setColoredTitle(actionBar, getString(R.string.backup_title), getContext()); actionBar.setDisplayHomeAsUpEnabled(true); ThemeToolbarUtils.tintBackButton(actionBar, getContext()); @@ -138,42 +138,84 @@ public class ContactsBackupFragment extends FileFragment implements DatePickerDi arbitraryDataProvider = new ArbitraryDataProvider(getContext().getContentResolver()); ThemeCheckableUtils.tintSwitch( - binding.contactsAutomaticBackup, ThemeColorUtils.primaryAccentColor(getContext())); - binding.contactsAutomaticBackup.setChecked( + binding.contacts, ThemeColorUtils.primaryAccentColor(getContext())); + ThemeCheckableUtils.tintSwitch( + binding.calendar, ThemeColorUtils.primaryAccentColor(getContext())); + ThemeCheckableUtils.tintSwitch( + binding.dailyBackup, ThemeColorUtils.primaryAccentColor(getContext())); + binding.dailyBackup.setChecked( arbitraryDataProvider.getBooleanValue(user, PREFERENCE_CONTACTS_AUTOMATIC_BACKUP)); - onCheckedChangeListener = (buttonView, isChecked) -> { + binding.contacts.setChecked(checkContactBackupPermission()); + binding.calendar.setChecked(checkCalendarBackupPermission()); + + dailyBackupCheckedChangeListener = (buttonView, isChecked) -> { if (checkAndAskForContactsReadPermission()) { setAutomaticBackup(isChecked); } }; - binding.contactsAutomaticBackup.setOnCheckedChangeListener(onCheckedChangeListener); - binding.contactsBackupNow.setOnClickListener(v -> backupContacts()); + contactsCheckedListener = (buttonView, isChecked) -> { + if (isChecked) { + if (checkAndAskForContactsReadPermission()) { + binding.backupNow.setVisibility(View.VISIBLE); + } + } else { + if (!binding.calendar.isChecked()) { + binding.backupNow.setVisibility(View.INVISIBLE); + } + } + }; + binding.contacts.setOnCheckedChangeListener(contactsCheckedListener); + + calendarCheckedListener = (buttonView, isChecked) -> { + if (isChecked) { + if (checkAndAskForCalendarReadPermission()) { + binding.backupNow.setVisibility(View.VISIBLE); + } + } else { + if (!binding.contacts.isChecked()) { + binding.backupNow.setVisibility(View.INVISIBLE); + } + } + }; + + binding.calendar.setOnCheckedChangeListener(calendarCheckedListener); + + binding.dailyBackup.setOnCheckedChangeListener(dailyBackupCheckedChangeListener); + binding.backupNow.setOnClickListener(v -> backup()); + binding.backupNow.setEnabled(checkBackupNowPermission()); + binding.backupNow.setVisibility(checkBackupNowPermission() ? View.VISIBLE : View.GONE); + binding.contactsDatepicker.setOnClickListener(v -> openCleanDate()); // display last backup Long lastBackupTimestamp = arbitraryDataProvider.getLongValue(user, PREFERENCE_CONTACTS_LAST_BACKUP); if (lastBackupTimestamp == -1) { - binding.contactsLastBackupTimestamp.setText(R.string.contacts_preference_backup_never); + binding.lastBackupWithDate.setVisibility(View.GONE); } else { - binding.contactsLastBackupTimestamp.setText( - DisplayUtils.getRelativeTimestamp(contactsPreferenceActivity, lastBackupTimestamp)); + binding.lastBackupWithDate.setText( + String.format(getString(R.string.last_backup), + DisplayUtils.getRelativeTimestamp(contactsPreferenceActivity, lastBackupTimestamp))); } if (savedInstanceState != null && savedInstanceState.getBoolean(KEY_CALENDAR_PICKER_OPEN, false)) { if (savedInstanceState.getInt(KEY_CALENDAR_YEAR, -1) != -1 && - savedInstanceState.getInt(KEY_CALENDAR_MONTH, -1) != -1 && - savedInstanceState.getInt(KEY_CALENDAR_DAY, -1) != -1) { + savedInstanceState.getInt(KEY_CALENDAR_MONTH, -1) != -1 && + savedInstanceState.getInt(KEY_CALENDAR_DAY, -1) != -1) { selectedDate = new Date(savedInstanceState.getInt(KEY_CALENDAR_YEAR), - savedInstanceState.getInt(KEY_CALENDAR_MONTH), savedInstanceState.getInt(KEY_CALENDAR_DAY)); + savedInstanceState.getInt(KEY_CALENDAR_MONTH), savedInstanceState.getInt(KEY_CALENDAR_DAY)); } calendarPickerOpen = true; } - ThemeButtonUtils.colorPrimaryButton(binding.contactsBackupNow, getContext()); - ThemeButtonUtils.colorPrimaryButton(binding.contactsDatepicker, getContext()); + ThemeButtonUtils.colorPrimaryButton(binding.backupNow, getContext()); + ThemeButtonUtils.themeBorderlessButton(binding.contactsDatepicker); + + int primaryAccentColor = ThemeColorUtils.primaryAccentColor(getContext()); + binding.dataToBackUpTitle.setTextColor(primaryAccentColor); + binding.backupSettingsTitle.setTextColor(primaryAccentColor); return view; } @@ -281,45 +323,69 @@ public class ContactsBackupFragment extends FileFragment implements DatePickerDi for (int index = 0; index < permissions.length; index++) { if (Manifest.permission.READ_CONTACTS.equalsIgnoreCase(permissions[index])) { if (grantResults[index] >= 0) { - setAutomaticBackup(true); - } else { - binding.contactsAutomaticBackup.setOnCheckedChangeListener(null); - binding.contactsAutomaticBackup.setChecked(false); - binding.contactsAutomaticBackup.setOnCheckedChangeListener(onCheckedChangeListener); + // if approved, exit for loop + break; } - break; + // if not accepted, disable again + binding.contacts.setOnCheckedChangeListener(null); + binding.contacts.setChecked(false); + binding.contacts.setOnCheckedChangeListener(contactsCheckedListener); } } } - if (requestCode == PermissionUtil.PERMISSIONS_READ_CONTACTS_MANUALLY) { + if (requestCode == PermissionUtil.PERMISSIONS_READ_CALENDAR_AUTOMATIC) { for (int index = 0; index < permissions.length; index++) { - if (Manifest.permission.READ_CONTACTS.equalsIgnoreCase(permissions[index])) { + if (Manifest.permission.READ_CALENDAR.equalsIgnoreCase(permissions[index])) { if (grantResults[index] >= 0) { - startContactsBackupJob(); + // if approved, exit for loop + break; } - - break; } + + // if not accepted, disable again + binding.calendar.setOnCheckedChangeListener(null); + binding.calendar.setChecked(false); + binding.calendar.setOnCheckedChangeListener(calendarCheckedListener); + + binding.backupNow.setVisibility(checkBackupNowPermission() ? View.VISIBLE : View.GONE); } } + + binding.backupNow.setVisibility(checkBackupNowPermission() ? View.VISIBLE : View.GONE); + binding.backupNow.setEnabled(checkBackupNowPermission()); } - public void backupContacts() { - if (checkAndAskForContactsReadPermission()) { + public void backup() { + if (binding.contacts.isChecked() && checkAndAskForContactsReadPermission()) { startContactsBackupJob(); } + + if (binding.calendar.isChecked() && checkAndAskForCalendarReadPermission()) { + startCalendarBackupJob(); + } + + DisplayUtils.showSnackMessage(requireView().findViewById(R.id.contacts_linear_layout), + R.string.contacts_preferences_backup_scheduled); } private void startContactsBackupJob() { - ContactsPreferenceActivity activity = (ContactsPreferenceActivity)getActivity(); + ContactsPreferenceActivity activity = (ContactsPreferenceActivity) getActivity(); if (activity != null) { Optional optionalUser = activity.getUser(); if (optionalUser.isPresent()) { backgroundJobManager.startImmediateContactsBackup(optionalUser.get()); - DisplayUtils.showSnackMessage(getView().findViewById(R.id.contacts_linear_layout), - R.string.contacts_preferences_backup_scheduled); + } + } + } + + private void startCalendarBackupJob() { + ContactsPreferenceActivity activity = (ContactsPreferenceActivity) getActivity(); + if (activity != null) { + Optional optionalUser = activity.getUser(); + if (optionalUser.isPresent()) { + backgroundJobManager.startImmediateCalendarBackup(optionalUser.get()); } } } @@ -337,12 +403,15 @@ public class ContactsBackupFragment extends FileFragment implements DatePickerDi User user = optionalUser.get(); if (enabled) { backgroundJobManager.schedulePeriodicContactsBackup(user); + backgroundJobManager.schedulePeriodicCalendarBackup(user); } else { backgroundJobManager.cancelPeriodicContactsBackup(user); + backgroundJobManager.cancelPeriodicCalendarBackup(user); } - arbitraryDataProvider.storeOrUpdateKeyValue(user.getAccountName(), PREFERENCE_CONTACTS_AUTOMATIC_BACKUP, - String.valueOf(enabled)); + arbitraryDataProvider.storeOrUpdateKeyValue(user.getAccountName(), + PREFERENCE_CONTACTS_AUTOMATIC_BACKUP, + String.valueOf(enabled)); } private boolean checkAndAskForContactsReadPermission() { @@ -352,58 +421,69 @@ public class ContactsBackupFragment extends FileFragment implements DatePickerDi if (PermissionUtil.checkSelfPermission(contactsPreferenceActivity, Manifest.permission.READ_CONTACTS)) { return true; } else { - // Check if we should show an explanation - if (PermissionUtil.shouldShowRequestPermissionRationale(contactsPreferenceActivity, - android.Manifest.permission.READ_CONTACTS)) { - // Show explanation to the user and then request permission - Snackbar snackbar = DisplayUtils.createSnackbar( - getView().findViewById(R.id.contacts_linear_layout), - R.string.contacts_read_permission, Snackbar.LENGTH_INDEFINITE) - .setAction(R.string.common_ok, v -> requestPermissions( - new String[]{Manifest.permission.READ_CONTACTS}, - PermissionUtil.PERMISSIONS_READ_CONTACTS_AUTOMATIC) - ); - - ThemeSnackbarUtils.colorSnackbar(contactsPreferenceActivity, snackbar); - - snackbar.show(); - - return false; - } else { - // No explanation needed, request the permission. - requestPermissions(new String[]{Manifest.permission.READ_CONTACTS}, - PermissionUtil.PERMISSIONS_READ_CONTACTS_AUTOMATIC); - return false; - } + // No explanation needed, request the permission. + requestPermissions(new String[]{Manifest.permission.READ_CONTACTS}, + PermissionUtil.PERMISSIONS_READ_CONTACTS_AUTOMATIC); + return false; } } + private boolean checkAndAskForCalendarReadPermission() { + final ContactsPreferenceActivity contactsPreferenceActivity = (ContactsPreferenceActivity) getActivity(); + + // check permissions + if (PermissionUtil.checkSelfPermission(contactsPreferenceActivity, Manifest.permission.READ_CALENDAR)) { + return true; + } else { + // No explanation needed, request the permission. + requestPermissions(new String[]{Manifest.permission.READ_CALENDAR}, + PermissionUtil.PERMISSIONS_READ_CALENDAR_AUTOMATIC); + return false; + } + } + + private boolean checkBackupNowPermission() { + return (checkCalendarBackupPermission() && binding.calendar.isChecked()) || + (checkContactBackupPermission() && binding.contacts.isChecked()); + } + + private boolean checkCalendarBackupPermission() { + return PermissionUtil.checkSelfPermission(getContext(), Manifest.permission.READ_CALENDAR); + } + + private boolean checkContactBackupPermission() { + return PermissionUtil.checkSelfPermission(getContext(), Manifest.permission.READ_CONTACTS); + } + public void openCleanDate() { - openDate(null); + if (checkAndAskForCalendarReadPermission() && checkAndAskForContactsReadPermission()) { + openDate(null); + } } public void openDate(@Nullable Date savedDate) { final ContactsPreferenceActivity contactsPreferenceActivity = (ContactsPreferenceActivity) getActivity(); - String backupFolderString = getResources().getString(R.string.contacts_backup_folder) + OCFile.PATH_SEPARATOR; - OCFile backupFolder = contactsPreferenceActivity.getStorageManager().getFileByPath(backupFolderString); + if (contactsPreferenceActivity == null) { + Toast.makeText(getContext(), getString(R.string.error_choosing_date), Toast.LENGTH_LONG).show(); + return; + } - List backupFiles = contactsPreferenceActivity.getStorageManager().getFolderContent(backupFolder, - false); + String contactsBackupFolderString = + getResources().getString(R.string.contacts_backup_folder) + OCFile.PATH_SEPARATOR; + String calendarBackupFolderString = + getResources().getString(R.string.calendar_backup_folder) + OCFile.PATH_SEPARATOR; - Collections.sort(backupFiles, new Comparator() { - @Override - public int compare(OCFile o1, OCFile o2) { - if (o1.getModificationTimestamp() == o2.getModificationTimestamp()) { - return 0; - } + FileDataStorageManager storageManager = contactsPreferenceActivity.getStorageManager(); - if (o1.getModificationTimestamp() > o2.getModificationTimestamp()) { - return 1; - } else { - return -1; - } - } + OCFile contactsBackupFolder = storageManager.getFileByDecryptedRemotePath(contactsBackupFolderString); + OCFile calendarBackupFolder = storageManager.getFileByDecryptedRemotePath(calendarBackupFolderString); + + List backupFiles = storageManager.getFolderContent(contactsBackupFolder, false); + backupFiles.addAll(storageManager.getFolderContent(calendarBackupFolder, false)); + + Collections.sort(backupFiles, (o1, o2) -> { + return Long.compare(o1.getModificationTimestamp(), o2.getModificationTimestamp()); }); Calendar cal = Calendar.getInstance(); @@ -427,12 +507,7 @@ public class ContactsBackupFragment extends FileFragment implements DatePickerDi .getModificationTimestamp()); datePickerDialog.getDatePicker().setMinDate(backupFiles.get(0).getModificationTimestamp()); - datePickerDialog.setOnDismissListener(new DialogInterface.OnDismissListener() { - @Override - public void onDismiss(DialogInterface dialog) { - selectedDate = null; - } - }); + datePickerDialog.setOnDismissListener(dialog -> selectedDate = null); datePickerDialog.setTitle(""); datePickerDialog.show(); @@ -480,12 +555,26 @@ public class ContactsBackupFragment extends FileFragment implements DatePickerDi @Override public void onDateSet(DatePicker view, int year, int month, int dayOfMonth) { final ContactsPreferenceActivity contactsPreferenceActivity = (ContactsPreferenceActivity) getActivity(); + + if (contactsPreferenceActivity == null) { + Toast.makeText(getContext(), getString(R.string.error_choosing_date), Toast.LENGTH_LONG).show(); + return; + } + selectedDate = new Date(year, month, dayOfMonth); - String backupFolderString = getResources().getString(R.string.contacts_backup_folder) + OCFile.PATH_SEPARATOR; - OCFile backupFolder = contactsPreferenceActivity.getStorageManager().getFileByPath(backupFolderString); - List backupFiles = contactsPreferenceActivity.getStorageManager().getFolderContent( - backupFolder, false); + String contactsBackupFolderString = + getResources().getString(R.string.contacts_backup_folder) + OCFile.PATH_SEPARATOR; + String calendarBackupFolderString = + getResources().getString(R.string.calendar_backup_folder) + OCFile.PATH_SEPARATOR; + + FileDataStorageManager storageManager = contactsPreferenceActivity.getStorageManager(); + + OCFile contactsBackupFolder = storageManager.getFileByDecryptedRemotePath(contactsBackupFolderString); + OCFile calendarBackupFolder = storageManager.getFileByDecryptedRemotePath(calendarBackupFolderString); + + List backupFiles = storageManager.getFolderContent(contactsBackupFolder, false); + backupFiles.addAll(storageManager.getFolderContent(calendarBackupFolder, false)); // find file with modification with date and time between 00:00 and 23:59 // if more than one file exists, take oldest @@ -498,38 +587,57 @@ public class ContactsBackupFragment extends FileFragment implements DatePickerDi date.set(Calendar.SECOND, 1); date.set(Calendar.MILLISECOND, 0); date.set(Calendar.AM_PM, Calendar.AM); - Long start = date.getTimeInMillis(); + long start = date.getTimeInMillis(); // end date.set(Calendar.HOUR, 23); date.set(Calendar.MINUTE, 59); date.set(Calendar.SECOND, 59); - Long end = date.getTimeInMillis(); + long end = date.getTimeInMillis(); - OCFile backupToRestore = null; + OCFile contactsBackupToRestore = null; + List calendarBackupsToRestore = new ArrayList<>(); for (OCFile file : backupFiles) { if (start < file.getModificationTimestamp() && end > file.getModificationTimestamp()) { - if (backupToRestore == null) { - backupToRestore = file; - } else if (backupToRestore.getModificationTimestamp() < file.getModificationTimestamp()) { - backupToRestore = file; + // contact + if (MimeTypeUtil.isVCard(file)) { + if (contactsBackupToRestore == null) { + contactsBackupToRestore = file; + } else if (contactsBackupToRestore.getModificationTimestamp() < file.getModificationTimestamp()) { + contactsBackupToRestore = file; + } + } + + // calendars + if (MimeTypeUtil.isCalendar(file)) { + calendarBackupsToRestore.add(file); } } } - if (backupToRestore != null) { + List backupToRestore = new ArrayList<>(); + + if (contactsBackupToRestore != null) { + backupToRestore.add(contactsBackupToRestore); + } + + backupToRestore.addAll(calendarBackupsToRestore); + + + if (backupToRestore.isEmpty()) { + DisplayUtils.showSnackMessage(getView().findViewById(R.id.contacts_linear_layout), + R.string.contacts_preferences_no_file_found); + } else { final User user = contactsPreferenceActivity.getUser().orElseThrow(RuntimeException::new); - Fragment contactListFragment = ContactListFragment.newInstance(backupToRestore, user); + OCFile[] files = new OCFile[backupToRestore.size()]; + Fragment contactListFragment = BackupListFragment.newInstance(backupToRestore.toArray(files), user); contactsPreferenceActivity.getSupportFragmentManager(). - beginTransaction() - .replace(R.id.frame_container, contactListFragment, ContactListFragment.TAG) - .addToBackStack(ContactsPreferenceActivity.BACKUP_TO_LIST) - .commit(); - } else { - DisplayUtils.showSnackMessage(getView().findViewById(R.id.contacts_linear_layout), - R.string.contacts_preferences_no_file_found); + beginTransaction() + .replace(R.id.frame_container, contactListFragment, BackupListFragment.TAG) + .addToBackStack(ContactsPreferenceActivity.BACKUP_TO_LIST) + .commit(); } } } diff --git a/src/main/java/com/owncloud/android/ui/fragment/contactsbackup/BackupListAdapter.kt b/src/main/java/com/owncloud/android/ui/fragment/contactsbackup/BackupListAdapter.kt new file mode 100644 index 0000000000..2852e3eaf9 --- /dev/null +++ b/src/main/java/com/owncloud/android/ui/fragment/contactsbackup/BackupListAdapter.kt @@ -0,0 +1,403 @@ +/* + * + * Nextcloud Android client application + * + * @author Tobias Kaminsky + * Copyright (C) 2021 Tobias Kaminsky + * Copyright (C) 2021 Nextcloud GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.owncloud.android.ui.fragment.contactsbackup + +import android.annotation.SuppressLint +import android.content.Context +import android.content.res.Resources +import android.database.Cursor +import android.graphics.BitmapFactory +import android.graphics.PorterDuff +import android.graphics.drawable.Drawable +import android.provider.ContactsContract +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.AdapterView +import android.widget.CheckedTextView +import android.widget.ImageView +import com.afollestad.sectionedrecyclerview.SectionedRecyclerViewAdapter +import com.afollestad.sectionedrecyclerview.SectionedViewHolder +import com.bumptech.glide.request.animation.GlideAnimation +import com.bumptech.glide.request.target.SimpleTarget +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.network.ClientFactory +import com.owncloud.android.R +import com.owncloud.android.databinding.BackupListItemHeaderBinding +import com.owncloud.android.databinding.CalendarlistListItemBinding +import com.owncloud.android.databinding.ContactlistListItemBinding +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.ui.TextDrawable +import com.owncloud.android.ui.fragment.contactsbackup.BackupListFragment.getDisplayName +import com.owncloud.android.utils.BitmapUtils +import com.owncloud.android.utils.DisplayUtils +import com.owncloud.android.utils.theme.ThemeColorUtils +import ezvcard.VCard +import ezvcard.property.Photo +import third_parties.sufficientlysecure.AndroidCalendar + +@Suppress("LongParameterList", "TooManyFunctions") +class BackupListAdapter( + val accountManager: UserAccountManager, + val clientFactory: ClientFactory, + private val checkedVCards: HashSet = HashSet(), + private val checkedCalendars: HashMap = HashMap(), + val backupListFragment: BackupListFragment, + val context: Context +) : SectionedRecyclerViewAdapter() { + private val calendarFiles = arrayListOf() + private val contacts = arrayListOf() + private var availableContactAccounts = listOf() + + companion object { + const val SECTION_CALENDAR = 0 + const val SECTION_CONTACTS = 1 + + const val VIEW_TYPE_CALENDAR = 2 + const val VIEW_TYPE_CONTACTS = 3 + + const val SINGLE_SELECTION = 1 + + const val SINGLE_ACCOUNT = 1 + } + + init { + shouldShowHeadersForEmptySections(false) + shouldShowFooters(false) + availableContactAccounts = getAccountForImport() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SectionedViewHolder { + return when (viewType) { + VIEW_TYPE_HEADER -> { + BackupListHeaderViewHolder( + BackupListItemHeaderBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ), + context + ) + } + VIEW_TYPE_CONTACTS -> { + ContactItemViewHolder( + ContactlistListItemBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ) + ) + } + else -> { + CalendarItemViewHolder( + CalendarlistListItemBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ), + context + ) + } + } + } + + override fun onBindViewHolder( + holder: SectionedViewHolder?, + section: Int, + relativePosition: Int, + absolutePosition: Int + ) { + if (section == SECTION_CALENDAR) { + bindCalendarViewHolder(holder as CalendarItemViewHolder, relativePosition) + } + + if (section == SECTION_CONTACTS) { + bindContactViewHolder(holder as ContactItemViewHolder, relativePosition) + } + } + + override fun getItemCount(section: Int): Int { + return if (section == SECTION_CALENDAR) { + calendarFiles.size + } else { + contacts.size + } + } + + override fun getSectionCount(): Int { + return 2 + } + + override fun getItemViewType(section: Int, relativePosition: Int, absolutePosition: Int): Int { + return if (section == SECTION_CALENDAR) { + VIEW_TYPE_CALENDAR + } else { + VIEW_TYPE_CONTACTS + } + } + + override fun onBindHeaderViewHolder(holder: SectionedViewHolder?, section: Int, expanded: Boolean) { + val headerViewHolder = holder as BackupListHeaderViewHolder + + headerViewHolder.binding.name.setTextColor(ThemeColorUtils.primaryColor(context)) + + if (section == SECTION_CALENDAR) { + headerViewHolder.binding.name.text = context.resources.getString(R.string.calendars) + headerViewHolder.binding.spinner.visibility = View.GONE + } else { + headerViewHolder.binding.name.text = context.resources.getString(R.string.contacts) + if (checkedVCards.isNotEmpty()) { + headerViewHolder.binding.spinner.visibility = View.VISIBLE + + holder.binding.spinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { + backupListFragment.setSelectedAccount(availableContactAccounts[position]) + } + + override fun onNothingSelected(parent: AdapterView<*>?) { + backupListFragment.setSelectedAccount(null) + } + } + + headerViewHolder.setContactsAccount(availableContactAccounts) + } + } + } + + override fun onBindFooterViewHolder(holder: SectionedViewHolder?, section: Int) { + // not needed + } + + fun addCalendar(file: OCFile) { + calendarFiles.add(file) + notifyItemInserted(calendarFiles.size - 1) + } + + @SuppressLint("NotifyDataSetChanged") + fun replaceVcards(vCards: MutableList) { + contacts.clear() + contacts.addAll(vCards) + notifyDataSetChanged() + } + + fun bindContactViewHolder(holder: ContactItemViewHolder, position: Int) { + val vCard = contacts[position] + + setChecked(checkedVCards.contains(position), holder.binding.name) + + holder.binding.name.text = getDisplayName(vCard) + + // photo + if (vCard.photos.size > 0) { + setPhoto(holder.binding.icon, vCard.photos[0]) + } else { + try { + holder.binding.icon.setImageDrawable( + TextDrawable.createNamedAvatar( + holder.binding.name.text.toString(), + context.resources.getDimension(R.dimen.list_item_avatar_icon_radius) + ) + ) + } catch (e: Resources.NotFoundException) { + holder.binding.icon.setImageResource(R.drawable.ic_user) + } + } + + holder.setVCardListener { toggleVCard(holder, position) } + } + + private fun setChecked(checked: Boolean, checkedTextView: CheckedTextView) { + checkedTextView.isChecked = checked + if (checked) { + checkedTextView.checkMarkDrawable + .setColorFilter(ThemeColorUtils.primaryColor(context), PorterDuff.Mode.SRC_ATOP) + } else { + checkedTextView.checkMarkDrawable.clearColorFilter() + } + } + + private fun toggleVCard(holder: ContactItemViewHolder, position: Int) { + holder.binding.name.isChecked = !holder.binding.name.isChecked + if (holder.binding.name.isChecked) { + holder.binding.name.checkMarkDrawable.setColorFilter( + ThemeColorUtils.primaryColor(context), + PorterDuff.Mode.SRC_ATOP + ) + checkedVCards.add(position) + } else { + holder.binding.name.checkMarkDrawable.clearColorFilter() + checkedVCards.remove(position) + } + + showRestoreButton() + notifySectionChanged(SECTION_CONTACTS) + } + + private fun setPhoto(imageView: ImageView, firstPhoto: Photo) { + val url = firstPhoto.url + val data = firstPhoto.data + if (data != null && data.isNotEmpty()) { + val thumbnail = BitmapFactory.decodeByteArray(data, 0, data.size) + val drawable = BitmapUtils.bitmapToCircularBitmapDrawable( + context.resources, + thumbnail + ) + imageView.setImageDrawable(drawable) + } else if (url != null) { + val target = object : SimpleTarget() { + override fun onResourceReady(resource: Drawable?, glideAnimation: GlideAnimation?) { + imageView.setImageDrawable(resource) + } + + override fun onLoadFailed(e: java.lang.Exception?, errorDrawable: Drawable?) { + super.onLoadFailed(e, errorDrawable) + imageView.setImageDrawable(errorDrawable) + } + } + + DisplayUtils.downloadIcon( + accountManager, + clientFactory, + context, + url, + target, + R.drawable.ic_user, + imageView.width, + imageView.height + ) + } + } + + private fun bindCalendarViewHolder(holder: CalendarItemViewHolder, position: Int) { + val ocFile: OCFile = calendarFiles[position] + + setChecked(checkedCalendars.containsValue(position), holder.binding.name) + val name = ocFile.fileName + val calendarName = name.substring(0, name.indexOf("_")) + val date = name.substring(name.lastIndexOf("_") + 1).replace(".ics", "").replace("-", ":") + holder.binding.name.text = context.resources.getString(R.string.calendar_name_linewrap, calendarName, date) + holder.setCalendars(ArrayList(AndroidCalendar.loadAll(context.contentResolver))) + holder.binding.spinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>?, view: View?, calendarPosition: Int, id: Long) { + checkedCalendars[calendarFiles[position].storagePath] = calendarPosition + } + + override fun onNothingSelected(parent: AdapterView<*>?) { + checkedCalendars[calendarFiles[position].storagePath] = -1 + } + } + + holder.setListener { toggleCalendar(holder, position) } + } + + private fun toggleCalendar(holder: CalendarItemViewHolder, position: Int) { + val checkedTextView = holder.binding.name + checkedTextView.isChecked = !checkedTextView.isChecked + if (checkedTextView.isChecked) { + checkedTextView.checkMarkDrawable.setColorFilter( + ThemeColorUtils.primaryColor(context), + PorterDuff.Mode.SRC_ATOP + ) + holder.showCalendars(true) + checkedCalendars[calendarFiles[position].storagePath] = 0 + } else { + checkedTextView.checkMarkDrawable.clearColorFilter() + checkedCalendars.remove(calendarFiles[position].storagePath) + holder.showCalendars(false) + } + + showRestoreButton() + } + + private fun showRestoreButton() { + val checkedEmpty = checkedCalendars.isEmpty() && checkedVCards.isEmpty() + val noCalendarAvailable = + checkedCalendars.isNotEmpty() && AndroidCalendar.loadAll(context.contentResolver).isEmpty() + + if (checkedEmpty || noCalendarAvailable) { + backupListFragment.showRestoreButton(false) + } else { + backupListFragment.showRestoreButton(true) + } + } + + fun getCheckedCalendarStringArray(): Array { + return checkedCalendars.keys.toTypedArray() + } + + fun getCheckedContactsIntArray(): IntArray { + return checkedVCards.toIntArray() + } + + fun selectAll(selectAll: Boolean) { + if (selectAll) { + contacts.forEachIndexed { index, _ -> checkedVCards.add(index) } + } else { + checkedVCards.clear() + checkedCalendars.clear() + } + + showRestoreButton() + } + + fun getCheckedCalendarPathsArray(): Map { + return checkedCalendars + } + + fun hasCalendarEntry(): Boolean { + return calendarFiles.isNotEmpty() + } + + @Suppress("NestedBlockDepth", "TooGenericExceptionCaught") + private fun getAccountForImport(): List { + val contactsAccounts = ArrayList() + + // add local one + contactsAccounts.add(ContactsAccount("Local contacts", null, null)) + + var cursor: Cursor? = null + try { + cursor = context.contentResolver.query( + ContactsContract.RawContacts.CONTENT_URI, + arrayOf( + ContactsContract.RawContacts.ACCOUNT_NAME, + ContactsContract.RawContacts.ACCOUNT_TYPE + ), + null, + null, + null + ) + if (cursor != null && cursor.count > 0) { + while (cursor.moveToNext()) { + val name = cursor.getString(cursor.getColumnIndex(ContactsContract.RawContacts.ACCOUNT_NAME)) + val type = cursor.getString(cursor.getColumnIndex(ContactsContract.RawContacts.ACCOUNT_TYPE)) + val account = ContactsAccount(name, name, type) + if (!contactsAccounts.contains(account)) { + contactsAccounts.add(account) + } + } + cursor.close() + } + } catch (e: Exception) { + Log_OC.d(BackupListFragment.TAG, e.message) + } finally { + cursor?.close() + } + + return contactsAccounts + } +} diff --git a/src/main/java/com/owncloud/android/ui/fragment/contactsbackup/BackupListFragment.java b/src/main/java/com/owncloud/android/ui/fragment/contactsbackup/BackupListFragment.java new file mode 100644 index 0000000000..4884ef1f33 --- /dev/null +++ b/src/main/java/com/owncloud/android/ui/fragment/contactsbackup/BackupListFragment.java @@ -0,0 +1,490 @@ +/* + * Nextcloud Android client application + * + * @author Tobias Kaminsky + * Copyright (C) 2017 Tobias Kaminsky + * Copyright (C) 2017 Nextcloud GmbH. + * Copyright (C) 2020 Chris Narkiewicz + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.owncloud.android.ui.fragment.contactsbackup; + +import android.Manifest; +import android.app.Activity; +import android.os.Bundle; +import android.os.Parcelable; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Toast; + +import com.google.android.material.snackbar.Snackbar; +import com.nextcloud.client.account.User; +import com.nextcloud.client.account.UserAccountManager; +import com.nextcloud.client.di.Injectable; +import com.nextcloud.client.files.downloader.DownloadRequest; +import com.nextcloud.client.files.downloader.Request; +import com.nextcloud.client.files.downloader.Transfer; +import com.nextcloud.client.files.downloader.TransferManagerConnection; +import com.nextcloud.client.files.downloader.TransferState; +import com.nextcloud.client.jobs.BackgroundJobManager; +import com.nextcloud.client.network.ClientFactory; +import com.owncloud.android.R; +import com.owncloud.android.databinding.BackuplistFragmentBinding; +import com.owncloud.android.datamodel.OCFile; +import com.owncloud.android.ui.activity.ContactsPreferenceActivity; +import com.owncloud.android.ui.asynctasks.LoadContactsTask; +import com.owncloud.android.ui.events.VCardToggleEvent; +import com.owncloud.android.ui.fragment.FileFragment; +import com.owncloud.android.utils.MimeTypeUtil; +import com.owncloud.android.utils.PermissionUtil; +import com.owncloud.android.utils.theme.ThemeColorUtils; +import com.owncloud.android.utils.theme.ThemeToolbarUtils; + +import org.greenrobot.eventbus.EventBus; +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; + +import javax.inject.Inject; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.ActionBar; +import androidx.recyclerview.widget.LinearLayoutManager; +import ezvcard.VCard; +import kotlin.Unit; + +/** + * This fragment shows all contacts or calendars from files and allows to import them. + */ +public class BackupListFragment extends FileFragment implements Injectable { + public static final String TAG = BackupListFragment.class.getSimpleName(); + + public static final String FILE_NAMES = "FILE_NAMES"; + public static final String FILE_NAME = "FILE_NAME"; + public static final String USER = "USER"; + public static final String CHECKED_CALENDAR_ITEMS_ARRAY_KEY = "CALENDAR_CHECKED_ITEMS"; + public static final String CHECKED_CONTACTS_ITEMS_ARRAY_KEY = "CONTACTS_CHECKED_ITEMS"; + + private BackuplistFragmentBinding binding; + + private BackupListAdapter listAdapter; + private final List vCards = new ArrayList<>(); + private final List ocFiles = new ArrayList<>(); + @Inject UserAccountManager accountManager; + @Inject ClientFactory clientFactory; + @Inject BackgroundJobManager backgroundJobManager; + private TransferManagerConnection fileDownloader; + private LoadContactsTask loadContactsTask = null; + private ContactsAccount selectedAccount; + + public static BackupListFragment newInstance(OCFile file, User user) { + BackupListFragment frag = new BackupListFragment(); + Bundle arguments = new Bundle(); + arguments.putParcelable(FILE_NAME, file); + arguments.putParcelable(USER, user); + frag.setArguments(arguments); + + return frag; + } + + public static BackupListFragment newInstance(OCFile[] files, User user) { + BackupListFragment frag = new BackupListFragment(); + Bundle arguments = new Bundle(); + arguments.putParcelableArray(FILE_NAMES, files); + arguments.putParcelable(USER, user); + frag.setArguments(arguments); + + return frag; + } + + /** + * {@inheritDoc} + */ + @Override + public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) { + super.onCreateOptionsMenu(menu, inflater); + inflater.inflate(R.menu.fragment_contact_list, menu); + } + + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + + binding = BackuplistFragmentBinding.inflate(inflater, container, false); + View view = binding.getRoot(); + + setHasOptionsMenu(true); + + ContactsPreferenceActivity contactsPreferenceActivity = (ContactsPreferenceActivity) getActivity(); + + if (contactsPreferenceActivity != null) { + ActionBar actionBar = contactsPreferenceActivity.getSupportActionBar(); + if (actionBar != null) { + ThemeToolbarUtils.setColoredTitle(actionBar, R.string.actionbar_calendar_contacts_restore, getContext()); + actionBar.setDisplayHomeAsUpEnabled(true); + } + contactsPreferenceActivity.setDrawerIndicatorEnabled(false); + } + + if (savedInstanceState == null) { + listAdapter = new BackupListAdapter(accountManager, + clientFactory, + new HashSet<>(), + new HashMap<>(), + this, + requireContext()); + } else { + HashMap checkedCalendarItems = new HashMap<>(); + String[] checkedCalendarItemsArray = savedInstanceState.getStringArray(CHECKED_CALENDAR_ITEMS_ARRAY_KEY); + if (checkedCalendarItemsArray != null) { + for (String checkedItem : checkedCalendarItemsArray) { + checkedCalendarItems.put(checkedItem, -1); + } + } + if (checkedCalendarItems.size() > 0) { + showRestoreButton(true); + } + + HashSet checkedContactsItems = new HashSet<>(); + int[] checkedContactsItemsArray = savedInstanceState.getIntArray(CHECKED_CONTACTS_ITEMS_ARRAY_KEY); + if (checkedContactsItemsArray != null) { + for (int checkedItem : checkedContactsItemsArray) { + checkedContactsItems.add(checkedItem); + } + } + if (checkedContactsItems.size() > 0) { + showRestoreButton(true); + } + + listAdapter = new BackupListAdapter(accountManager, + clientFactory, + checkedContactsItems, + checkedCalendarItems, + this, + requireContext()); + } + + binding.list.setAdapter(listAdapter); + binding.list.setLayoutManager(new LinearLayoutManager(getContext())); + + Bundle arguments = getArguments(); + if (arguments == null) { + return view; + } + + if (arguments.getParcelable(FILE_NAME) != null) { + ocFiles.add(arguments.getParcelable(FILE_NAME)); + } else if (arguments.getParcelableArray(FILE_NAMES) != null) { + for (Parcelable file : arguments.getParcelableArray(FILE_NAMES)) { + ocFiles.add((OCFile) file); + } + } else { + return view; + } + + User user = getArguments().getParcelable(USER); + fileDownloader = new TransferManagerConnection(getActivity(), user); + fileDownloader.registerTransferListener(this::onDownloadUpdate); + fileDownloader.bind(); + + for (OCFile file : ocFiles) { + if (!file.isDown()) { + Request request = new DownloadRequest(user, file); + fileDownloader.enqueue(request); + } + + if (MimeTypeUtil.isVCard(file) && file.isDown()) { + setFile(file); + loadContactsTask = new LoadContactsTask(this, file); + loadContactsTask.execute(); + } + + if (MimeTypeUtil.isCalendar(file) && file.isDown()) { + showLoadingMessage(false); + listAdapter.addCalendar(file); + } + } + + binding.restoreSelected.setOnClickListener(v -> { + if (checkAndAskForCalendarWritePermission()) { + importCalendar(); + } + + if (listAdapter.getCheckedContactsIntArray().length > 0 && checkAndAskForContactsWritePermission()) { + importContacts(selectedAccount); + return; + } + + Snackbar + .make( + binding.list, + R.string.contacts_preferences_import_scheduled, + Snackbar.LENGTH_LONG + ) + .show(); + + closeFragment(); + }); + + binding.restoreSelected.setTextColor(ThemeColorUtils.primaryAccentColor(getContext())); + + return view; + } + + @Override + public void onDetach() { + super.onDetach(); + if (fileDownloader != null) { + fileDownloader.unbind(); + } + } + + @Override + public void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + outState.putStringArray(CHECKED_CALENDAR_ITEMS_ARRAY_KEY, listAdapter.getCheckedCalendarStringArray()); + outState.putIntArray(CHECKED_CONTACTS_ITEMS_ARRAY_KEY, listAdapter.getCheckedContactsIntArray()); + } + + @Subscribe(threadMode = ThreadMode.MAIN) + public void onMessageEvent(VCardToggleEvent event) { + if (event.showRestoreButton) { + binding.contactlistRestoreSelectedContainer.setVisibility(View.VISIBLE); + } else { + binding.contactlistRestoreSelectedContainer.setVisibility(View.GONE); + } + } + + public void showRestoreButton(boolean show) { + binding.contactlistRestoreSelectedContainer.setVisibility(show ? View.VISIBLE : View.GONE); + } + + @Override + public void onDestroy() { + super.onDestroy(); + ContactsPreferenceActivity contactsPreferenceActivity = (ContactsPreferenceActivity) getActivity(); + contactsPreferenceActivity.setDrawerIndicatorEnabled(true); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + binding = null; + } + + public void onResume() { + super.onResume(); + ContactsPreferenceActivity contactsPreferenceActivity = (ContactsPreferenceActivity) getActivity(); + contactsPreferenceActivity.setDrawerIndicatorEnabled(false); + } + + @Override + public void onStart() { + super.onStart(); + EventBus.getDefault().register(this); + } + + @Override + public void onStop() { + EventBus.getDefault().unregister(this); + if (loadContactsTask != null) { + loadContactsTask.cancel(true); + } + super.onStop(); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + boolean retval; + int itemId = item.getItemId(); + + if (itemId == android.R.id.home) { + closeFragment(); + retval = true; + } else if (itemId == R.id.action_select_all) { + item.setChecked(!item.isChecked()); + setSelectAllMenuItem(item, item.isChecked()); + listAdapter.selectAll(item.isChecked()); + retval = true; + } else { + retval = super.onOptionsItemSelected(item); + } + + return retval; + } + + public void showLoadingMessage(boolean showIt) { + binding.loadingListContainer.setVisibility(showIt ? View.VISIBLE : View.GONE); + } + + private void setSelectAllMenuItem(MenuItem selectAll, boolean checked) { + selectAll.setChecked(checked); + if (checked) { + selectAll.setIcon(R.drawable.ic_select_none); + } else { + selectAll.setIcon(R.drawable.ic_select_all); + } + } + + private void importContacts(ContactsAccount account) { + backgroundJobManager.startImmediateContactsImport(account.getName(), + account.getType(), + getFile().getStoragePath(), + listAdapter.getCheckedContactsIntArray()); + + Snackbar + .make( + binding.list, + R.string.contacts_preferences_import_scheduled, + Snackbar.LENGTH_LONG + ) + .show(); + + closeFragment(); + } + + private void importCalendar() { + backgroundJobManager.startImmediateCalendarImport(listAdapter.getCheckedCalendarPathsArray()); + + Snackbar + .make( + binding.list, + R.string.contacts_preferences_import_scheduled, + Snackbar.LENGTH_LONG + ) + .show(); + + closeFragment(); + } + + private void closeFragment() { + ContactsPreferenceActivity contactsPreferenceActivity = (ContactsPreferenceActivity) getActivity(); + if (contactsPreferenceActivity != null) { + contactsPreferenceActivity.onBackPressed(); + } + } + + private boolean checkAndAskForContactsWritePermission() { + // check permissions + if (!PermissionUtil.checkSelfPermission(getContext(), Manifest.permission.WRITE_CONTACTS)) { + requestPermissions(new String[]{Manifest.permission.WRITE_CONTACTS}, + PermissionUtil.PERMISSIONS_WRITE_CONTACTS); + return false; + } else { + return true; + } + } + + private boolean checkAndAskForCalendarWritePermission() { + // check permissions + if (!PermissionUtil.checkSelfPermission(getContext(), Manifest.permission.WRITE_CALENDAR)) { + requestPermissions(new String[]{Manifest.permission.WRITE_CALENDAR}, + PermissionUtil.PERMISSIONS_WRITE_CALENDAR); + return false; + } else { + return true; + } + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + + if (requestCode == PermissionUtil.PERMISSIONS_WRITE_CONTACTS) { + for (int index = 0; index < permissions.length; index++) { + if (Manifest.permission.WRITE_CONTACTS.equalsIgnoreCase(permissions[index])) { + if (grantResults[index] >= 0) { + importContacts(selectedAccount); + } else { + if (getView() != null) { + Snackbar.make(getView(), R.string.contactlist_no_permission, Snackbar.LENGTH_LONG) + .show(); + } else { + Toast.makeText(getContext(), R.string.contactlist_no_permission, Toast.LENGTH_LONG).show(); + } + } + break; + } + } + } + + if (requestCode == PermissionUtil.PERMISSIONS_WRITE_CALENDAR) { + for (int index = 0; index < permissions.length; index++) { + if (Manifest.permission.WRITE_CALENDAR.equalsIgnoreCase(permissions[index])) { + if (grantResults[index] >= 0) { + importContacts(selectedAccount); + } else { + if (getView() != null) { + Snackbar.make(getView(), R.string.contactlist_no_permission, Snackbar.LENGTH_LONG) + .show(); + } else { + Toast.makeText(getContext(), R.string.contactlist_no_permission, Toast.LENGTH_LONG).show(); + } + } + break; + } + } + } + } + + private Unit onDownloadUpdate(Transfer download) { + final Activity activity = getActivity(); + if (download.getState() == TransferState.COMPLETED && activity != null) { + OCFile ocFile = download.getFile(); + + if (MimeTypeUtil.isVCard(ocFile)) { + setFile(ocFile); + loadContactsTask = new LoadContactsTask(this, ocFile); + loadContactsTask.execute(); + } + } + return Unit.INSTANCE; + } + + public void loadVCards(List cards) { + showLoadingMessage(false); + vCards.clear(); + vCards.addAll(cards); + listAdapter.replaceVcards(vCards); + } + + public static String getDisplayName(VCard vCard) { + if (vCard.getFormattedName() != null) { + return vCard.getFormattedName().getValue(); + } else if (vCard.getTelephoneNumbers() != null && vCard.getTelephoneNumbers().size() > 0) { + return vCard.getTelephoneNumbers().get(0).getText(); + } else if (vCard.getEmails() != null && vCard.getEmails().size() > 0) { + return vCard.getEmails().get(0).getValue(); + } + + return ""; + } + + public boolean hasCalendarEntry() { + return listAdapter.hasCalendarEntry(); + } + + public void setSelectedAccount(ContactsAccount account) { + selectedAccount = account; + } +} diff --git a/src/main/java/com/owncloud/android/ui/fragment/contactsbackup/BackupListHeaderViewHolder.kt b/src/main/java/com/owncloud/android/ui/fragment/contactsbackup/BackupListHeaderViewHolder.kt new file mode 100644 index 0000000000..73a76cb534 --- /dev/null +++ b/src/main/java/com/owncloud/android/ui/fragment/contactsbackup/BackupListHeaderViewHolder.kt @@ -0,0 +1,49 @@ +/* + * + * Nextcloud Android client application + * + * @author Tobias Kaminsky + * Copyright (C) 2021 Tobias Kaminsky + * Copyright (C) 2021 Nextcloud GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.owncloud.android.ui.fragment.contactsbackup + +import android.content.Context +import android.widget.ArrayAdapter +import com.afollestad.sectionedrecyclerview.SectionedViewHolder +import com.owncloud.android.databinding.BackupListItemHeaderBinding +import java.util.ArrayList + +class BackupListHeaderViewHolder( + val binding: BackupListItemHeaderBinding, + val context: Context +) : SectionedViewHolder(binding.root) { + val adapter = ArrayAdapter( + context, + android.R.layout.simple_spinner_dropdown_item, + ArrayList() + ) + + init { + binding.spinner.adapter = adapter + } + + fun setContactsAccount(accounts: List) { + adapter.clear() + adapter.addAll(accounts) + } +} diff --git a/src/main/java/com/owncloud/android/ui/fragment/contactsbackup/BackupListItemViewHolder.kt b/src/main/java/com/owncloud/android/ui/fragment/contactsbackup/BackupListItemViewHolder.kt new file mode 100644 index 0000000000..b3d8b97117 --- /dev/null +++ b/src/main/java/com/owncloud/android/ui/fragment/contactsbackup/BackupListItemViewHolder.kt @@ -0,0 +1,28 @@ +/* + * + * Nextcloud Android client application + * + * @author Tobias Kaminsky + * Copyright (C) 2021 Tobias Kaminsky + * Copyright (C) 2021 Nextcloud GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.owncloud.android.ui.fragment.contactsbackup + +import com.afollestad.sectionedrecyclerview.SectionedViewHolder +import com.owncloud.android.databinding.BackupListItemBinding + +class BackupListItemViewHolder(val binding: BackupListItemBinding) : SectionedViewHolder(binding.root) diff --git a/src/main/java/com/owncloud/android/ui/fragment/contactsbackup/CalendarItemViewHolder.java b/src/main/java/com/owncloud/android/ui/fragment/contactsbackup/CalendarItemViewHolder.java new file mode 100644 index 0000000000..9f65d45655 --- /dev/null +++ b/src/main/java/com/owncloud/android/ui/fragment/contactsbackup/CalendarItemViewHolder.java @@ -0,0 +1,79 @@ +/* + * + * Nextcloud Android client application + * + * @author Tobias Kaminsky + * Copyright (C) 2021 Tobias Kaminsky + * Copyright (C) 2021 Nextcloud GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.owncloud.android.ui.fragment.contactsbackup; + +import android.content.Context; +import android.view.View; +import android.widget.ArrayAdapter; +import android.widget.Toast; + +import com.afollestad.sectionedrecyclerview.SectionedViewHolder; +import com.owncloud.android.R; +import com.owncloud.android.databinding.CalendarlistListItemBinding; + +import java.util.ArrayList; + +import third_parties.sufficientlysecure.AndroidCalendar; + +class CalendarItemViewHolder extends SectionedViewHolder { + public CalendarlistListItemBinding binding; + private final ArrayAdapter adapter; + private final Context context; + + CalendarItemViewHolder(CalendarlistListItemBinding binding, Context context) { + super(binding.getRoot()); + + this.binding = binding; + this.context = context; + + adapter = new ArrayAdapter<>(context, + android.R.layout.simple_spinner_dropdown_item, + new ArrayList<>()); + + binding.spinner.setAdapter(adapter); + } + + public void setCalendars(ArrayList calendars) { + adapter.clear(); + adapter.addAll(calendars); + } + + public void setListener(View.OnClickListener onClickListener) { + itemView.setOnClickListener(onClickListener); + } + + public void showCalendars(boolean show) { + if (show) { + if (adapter.isEmpty()) { + Toast.makeText(context, + context.getResources().getString(R.string.no_calendar_exists), + Toast.LENGTH_LONG) + .show(); + } else { + binding.spinner.setVisibility(View.VISIBLE); + } + } else { + binding.spinner.setVisibility(View.GONE); + } + } +} diff --git a/src/main/java/com/owncloud/android/ui/fragment/contactsbackup/ContactItemViewHolder.java b/src/main/java/com/owncloud/android/ui/fragment/contactsbackup/ContactItemViewHolder.java new file mode 100644 index 0000000000..0ff7f05841 --- /dev/null +++ b/src/main/java/com/owncloud/android/ui/fragment/contactsbackup/ContactItemViewHolder.java @@ -0,0 +1,43 @@ +/* + * + * Nextcloud Android client application + * + * @author Tobias Kaminsky + * Copyright (C) 2021 Tobias Kaminsky + * Copyright (C) 2021 Nextcloud GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.owncloud.android.ui.fragment.contactsbackup; + +import android.view.View; + +import com.afollestad.sectionedrecyclerview.SectionedViewHolder; +import com.owncloud.android.databinding.ContactlistListItemBinding; + +public class ContactItemViewHolder extends SectionedViewHolder { + public ContactlistListItemBinding binding; + + ContactItemViewHolder(ContactlistListItemBinding binding) { + super(binding.getRoot()); + + this.binding = binding; + binding.getRoot().setTag(this); + } + + public void setVCardListener(View.OnClickListener onClickListener) { + itemView.setOnClickListener(onClickListener); + } +} diff --git a/src/main/java/com/owncloud/android/ui/fragment/contactsbackup/ContactListAdapter.java b/src/main/java/com/owncloud/android/ui/fragment/contactsbackup/ContactListAdapter.java new file mode 100644 index 0000000000..26d89bd805 --- /dev/null +++ b/src/main/java/com/owncloud/android/ui/fragment/contactsbackup/ContactListAdapter.java @@ -0,0 +1,250 @@ +/* + * + * Nextcloud Android client application + * + * @author Tobias Kaminsky + * Copyright (C) 2021 Tobias Kaminsky + * Copyright (C) 2021 Nextcloud GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.owncloud.android.ui.fragment.contactsbackup; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.PorterDuff; +import android.graphics.drawable.Drawable; +import android.view.LayoutInflater; +import android.view.ViewGroup; +import android.widget.CheckedTextView; +import android.widget.ImageView; + +import com.bumptech.glide.request.animation.GlideAnimation; +import com.bumptech.glide.request.target.SimpleTarget; +import com.nextcloud.client.account.UserAccountManager; +import com.nextcloud.client.network.ClientFactory; +import com.owncloud.android.R; +import com.owncloud.android.databinding.ContactlistListItemBinding; +import com.owncloud.android.ui.TextDrawable; +import com.owncloud.android.ui.events.VCardToggleEvent; +import com.owncloud.android.utils.BitmapUtils; +import com.owncloud.android.utils.DisplayUtils; +import com.owncloud.android.utils.theme.ThemeColorUtils; + +import org.greenrobot.eventbus.EventBus; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import androidx.annotation.NonNull; +import androidx.core.graphics.drawable.RoundedBitmapDrawable; +import androidx.recyclerview.widget.RecyclerView; +import ezvcard.VCard; +import ezvcard.property.Photo; + +import static com.owncloud.android.ui.fragment.contactsbackup.BackupListFragment.getDisplayName; + +class ContactListAdapter extends RecyclerView.Adapter { + private static final int SINGLE_SELECTION = 1; + + private List vCards; + private Set checkedVCards; + + private Context context; + + private UserAccountManager accountManager; + private ClientFactory clientFactory; + + ContactListAdapter(UserAccountManager accountManager, ClientFactory clientFactory, Context context, + List vCards) { + this.vCards = vCards; + this.context = context; + this.checkedVCards = new HashSet<>(); + this.accountManager = accountManager; + this.clientFactory = clientFactory; + } + + ContactListAdapter(UserAccountManager accountManager, + Context context, + List vCards, + Set checkedVCards) { + this.vCards = vCards; + this.context = context; + this.checkedVCards = checkedVCards; + this.accountManager = accountManager; + } + + public int getCheckedCount() { + if (checkedVCards != null) { + return checkedVCards.size(); + } else { + return 0; + } + } + + public void replaceVCards(List vCards) { + this.vCards = vCards; + notifyDataSetChanged(); + } + + public int[] getCheckedIntArray() { + int[] intArray; + if (checkedVCards != null && checkedVCards.size() > 0) { + intArray = new int[checkedVCards.size()]; + int i = 0; + for (int position : checkedVCards) { + intArray[i] = position; + i++; + } + return intArray; + } else { + return new int[0]; + } + } + + @NonNull + @Override + public ContactItemViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new ContactItemViewHolder(ContactlistListItemBinding.inflate(LayoutInflater.from(parent.getContext()), + parent, + false)); + } + + @Override + public void onBindViewHolder(@NonNull final ContactItemViewHolder holder, final int position) { + final int verifiedPosition = holder.getAdapterPosition(); + final VCard vcard = vCards.get(verifiedPosition); + + if (vcard != null) { + + setChecked(checkedVCards.contains(position), holder.binding.name); + + holder.binding.name.setText(getDisplayName(vcard)); + + // photo + if (vcard.getPhotos().size() > 0) { + setPhoto(holder.binding.icon, vcard.getPhotos().get(0)); + } else { + try { + holder.binding.icon.setImageDrawable( + TextDrawable.createNamedAvatar( + holder.binding.name.getText().toString(), + context.getResources().getDimension(R.dimen.list_item_avatar_icon_radius) + ) + ); + } catch (Exception e) { + holder.binding.icon.setImageResource(R.drawable.ic_user); + } + } + + holder.setVCardListener(v -> toggleVCard(holder, verifiedPosition)); + } + } + + private void setPhoto(ImageView imageView, Photo firstPhoto) { + String url = firstPhoto.getUrl(); + byte[] data = firstPhoto.getData(); + + if (data != null && data.length > 0) { + Bitmap thumbnail = BitmapFactory.decodeByteArray(data, 0, data.length); + RoundedBitmapDrawable drawable = BitmapUtils.bitmapToCircularBitmapDrawable(context.getResources(), + thumbnail); + + imageView.setImageDrawable(drawable); + } else if (url != null) { + SimpleTarget target = new SimpleTarget() { + @Override + public void onResourceReady(Drawable resource, GlideAnimation glideAnimation) { + imageView.setImageDrawable(resource); + } + + @Override + public void onLoadFailed(Exception e, Drawable errorDrawable) { + super.onLoadFailed(e, errorDrawable); + imageView.setImageDrawable(errorDrawable); + } + }; + DisplayUtils.downloadIcon(accountManager, + clientFactory, + context, + url, + target, + R.drawable.ic_user, + imageView.getWidth(), + imageView.getHeight()); + } + } + + private void setChecked(boolean checked, CheckedTextView checkedTextView) { + checkedTextView.setChecked(checked); + + if (checked) { + checkedTextView.getCheckMarkDrawable() + .setColorFilter(ThemeColorUtils.primaryColor(context), PorterDuff.Mode.SRC_ATOP); + } else { + checkedTextView.getCheckMarkDrawable().clearColorFilter(); + } + } + + private void toggleVCard(ContactItemViewHolder holder, int verifiedPosition) { + holder.binding.name.setChecked(!holder.binding.name.isChecked()); + + if (holder.binding.name.isChecked()) { + holder.binding.name.getCheckMarkDrawable().setColorFilter(ThemeColorUtils.primaryColor(context), + PorterDuff.Mode.SRC_ATOP); + + checkedVCards.add(verifiedPosition); + if (checkedVCards.size() == SINGLE_SELECTION) { + EventBus.getDefault().post(new VCardToggleEvent(true)); + } + } else { + holder.binding.name.getCheckMarkDrawable().clearColorFilter(); + + checkedVCards.remove(verifiedPosition); + + if (checkedVCards.isEmpty()) { + EventBus.getDefault().post(new VCardToggleEvent(false)); + } + } + } + + @Override + public int getItemCount() { + return vCards.size(); + } + + public void selectAllFiles(boolean select) { + checkedVCards = new HashSet<>(); + if (select) { + for (int i = 0; i < vCards.size(); i++) { + checkedVCards.add(i); + } + } + + if (checkedVCards.size() > 0) { + EventBus.getDefault().post(new VCardToggleEvent(true)); + } else { + EventBus.getDefault().post(new VCardToggleEvent(false)); + } + + notifyDataSetChanged(); + } + + public boolean isEmpty() { + return getItemCount() == 0; + } +} diff --git a/src/main/java/com/owncloud/android/ui/fragment/contactsbackup/ContactListFragment.java b/src/main/java/com/owncloud/android/ui/fragment/contactsbackup/ContactListFragment.java deleted file mode 100644 index 6b2b6f730f..0000000000 --- a/src/main/java/com/owncloud/android/ui/fragment/contactsbackup/ContactListFragment.java +++ /dev/null @@ -1,735 +0,0 @@ -/* - * Nextcloud Android client application - * - * @author Tobias Kaminsky - * Copyright (C) 2017 Tobias Kaminsky - * Copyright (C) 2017 Nextcloud GmbH. - * Copyright (C) 2020 Chris Narkiewicz - *

- * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * at your option) any later version. - *

- * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - *

- * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package com.owncloud.android.ui.fragment.contactsbackup; - -import android.Manifest; -import android.app.Activity; -import android.content.Context; -import android.content.DialogInterface; -import android.database.Cursor; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import android.graphics.PorterDuff; -import android.graphics.drawable.Drawable; -import android.os.AsyncTask; -import android.os.Bundle; -import android.os.Handler; -import android.provider.ContactsContract; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ArrayAdapter; -import android.widget.CheckedTextView; -import android.widget.ImageView; -import android.widget.Toast; - -import com.bumptech.glide.request.animation.GlideAnimation; -import com.bumptech.glide.request.target.SimpleTarget; -import com.google.android.material.snackbar.Snackbar; -import com.nextcloud.client.account.User; -import com.nextcloud.client.account.UserAccountManager; -import com.nextcloud.client.di.Injectable; -import com.nextcloud.client.files.downloader.Direction; -import com.nextcloud.client.files.downloader.DownloadRequest; -import com.nextcloud.client.files.downloader.Request; -import com.nextcloud.client.files.downloader.Transfer; -import com.nextcloud.client.files.downloader.TransferManagerConnection; -import com.nextcloud.client.files.downloader.TransferState; -import com.nextcloud.client.jobs.BackgroundJobManager; -import com.nextcloud.client.network.ClientFactory; -import com.owncloud.android.R; -import com.owncloud.android.databinding.ContactlistFragmentBinding; -import com.owncloud.android.datamodel.OCFile; -import com.owncloud.android.lib.common.utils.Log_OC; -import com.owncloud.android.ui.TextDrawable; -import com.owncloud.android.ui.activity.ContactsPreferenceActivity; -import com.owncloud.android.ui.events.VCardToggleEvent; -import com.owncloud.android.ui.fragment.FileFragment; -import com.owncloud.android.utils.BitmapUtils; -import com.owncloud.android.utils.DisplayUtils; -import com.owncloud.android.utils.PermissionUtil; -import com.owncloud.android.utils.theme.ThemeColorUtils; -import com.owncloud.android.utils.theme.ThemeToolbarUtils; - -import org.greenrobot.eventbus.EventBus; -import org.greenrobot.eventbus.Subscribe; -import org.greenrobot.eventbus.ThreadMode; - -import java.io.File; -import java.io.IOException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.Comparator; -import java.util.HashSet; -import java.util.List; -import java.util.Set; - -import javax.inject.Inject; - -import androidx.annotation.NonNull; -import androidx.appcompat.app.ActionBar; -import androidx.appcompat.app.AlertDialog; -import androidx.core.graphics.drawable.RoundedBitmapDrawable; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import ezvcard.Ezvcard; -import ezvcard.VCard; -import ezvcard.property.Photo; -import kotlin.Unit; - -import static com.owncloud.android.ui.fragment.contactsbackup.ContactListFragment.getDisplayName; - -/** - * This fragment shows all contacts from a file and allows to import them. - */ -public class ContactListFragment extends FileFragment implements Injectable { - public static final String TAG = ContactListFragment.class.getSimpleName(); - - public static final String FILE_NAME = "FILE_NAME"; - public static final String USER = "USER"; - public static final String CHECKED_ITEMS_ARRAY_KEY = "CHECKED_ITEMS"; - - private static final int SINGLE_ACCOUNT = 1; - - private ContactlistFragmentBinding binding; - - private ContactListAdapter contactListAdapter; - private final List vCards = new ArrayList<>(); - private OCFile ocFile; - @Inject UserAccountManager accountManager; - @Inject ClientFactory clientFactory; - @Inject BackgroundJobManager backgroundJobManager; - private TransferManagerConnection fileDownloader; - - public static ContactListFragment newInstance(OCFile file, User user) { - ContactListFragment frag = new ContactListFragment(); - Bundle arguments = new Bundle(); - arguments.putParcelable(FILE_NAME, file); - arguments.putParcelable(USER, user); - frag.setArguments(arguments); - return frag; - } - - /** - * {@inheritDoc} - */ - @Override - public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) { - super.onCreateOptionsMenu(menu, inflater); - inflater.inflate(R.menu.fragment_contact_list, menu); - } - - @Override - public View onCreateView(@NonNull final LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - - binding = ContactlistFragmentBinding.inflate(inflater, container, false); - View view = binding.getRoot(); - - setHasOptionsMenu(true); - - ContactsPreferenceActivity contactsPreferenceActivity = (ContactsPreferenceActivity) getActivity(); - - if (contactsPreferenceActivity != null) { - ActionBar actionBar = contactsPreferenceActivity.getSupportActionBar(); - if (actionBar != null) { - ThemeToolbarUtils.setColoredTitle(actionBar, R.string.actionbar_contacts_restore, getContext()); - actionBar.setDisplayHomeAsUpEnabled(true); - } - contactsPreferenceActivity.setDrawerIndicatorEnabled(false); - } - - if (savedInstanceState == null) { - contactListAdapter = new ContactListAdapter(accountManager, clientFactory, getContext(), vCards); - } else { - Set checkedItems = new HashSet<>(); - int[] itemsArray = savedInstanceState.getIntArray(CHECKED_ITEMS_ARRAY_KEY); - if (itemsArray != null) { - for (int checkedItem : itemsArray) { - checkedItems.add(checkedItem); - } - } - if (checkedItems.size() > 0) { - onMessageEvent(new VCardToggleEvent(true)); - } - contactListAdapter = new ContactListAdapter(accountManager, getContext(), vCards, checkedItems); - } - binding.contactlistRecyclerview.setAdapter(contactListAdapter); - binding.contactlistRecyclerview.setLayoutManager(new LinearLayoutManager(getContext())); - - ocFile = getArguments().getParcelable(FILE_NAME); - setFile(ocFile); - User user = getArguments().getParcelable(USER); - fileDownloader = new TransferManagerConnection(getActivity(), user); - fileDownloader.registerTransferListener(this::onDownloadUpdate); - fileDownloader.bind(); - if (!ocFile.isDown()) { - Request request = new DownloadRequest(user, ocFile); - fileDownloader.enqueue(request); - } else { - loadContactsTask.execute(); - } - - binding.contactlistRestoreSelected.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - if (checkAndAskForContactsWritePermission()) { - getAccountForImport(); - } - } - }); - - binding.contactlistRestoreSelected.setTextColor(ThemeColorUtils.primaryAccentColor(getContext())); - - return view; - } - - @Override - public void onDetach() { - super.onDetach(); - if (fileDownloader != null) { - fileDownloader.unbind(); - } - } - - @Override - public void onSaveInstanceState(@NonNull Bundle outState) { - super.onSaveInstanceState(outState); - outState.putIntArray(CHECKED_ITEMS_ARRAY_KEY, contactListAdapter.getCheckedIntArray()); - } - - @Subscribe(threadMode = ThreadMode.MAIN) - public void onMessageEvent(VCardToggleEvent event) { - if (event.showRestoreButton) { - binding.contactlistRestoreSelectedContainer.setVisibility(View.VISIBLE); - } else { - binding.contactlistRestoreSelectedContainer.setVisibility(View.GONE); - } - } - - @Override - public void onDestroy() { - super.onDestroy(); - ContactsPreferenceActivity contactsPreferenceActivity = (ContactsPreferenceActivity) getActivity(); - contactsPreferenceActivity.setDrawerIndicatorEnabled(true); - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - binding = null; - } - - public void onResume() { - super.onResume(); - ContactsPreferenceActivity contactsPreferenceActivity = (ContactsPreferenceActivity) getActivity(); - contactsPreferenceActivity.setDrawerIndicatorEnabled(false); - } - - @Override - public void onStart() { - super.onStart(); - EventBus.getDefault().register(this); - } - - @Override - public void onStop() { - EventBus.getDefault().unregister(this); - if (loadContactsTask != null) { - loadContactsTask.cancel(true); - } - super.onStop(); - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - boolean retval; - int itemId = item.getItemId(); - - if (itemId == android.R.id.home) { - ContactsPreferenceActivity contactsPreferenceActivity = (ContactsPreferenceActivity) getActivity(); - if (contactsPreferenceActivity != null) { - contactsPreferenceActivity.onBackPressed(); - } - retval = true; - } else if (itemId == R.id.action_select_all) { - item.setChecked(!item.isChecked()); - setSelectAllMenuItem(item, item.isChecked()); - contactListAdapter.selectAllFiles(item.isChecked()); - retval = true; - } else { - retval = super.onOptionsItemSelected(item); - } - - return retval; - } - - private void setLoadingMessage() { - binding.loadingListContainer.setVisibility(View.VISIBLE); - } - - private void setSelectAllMenuItem(MenuItem selectAll, boolean checked) { - selectAll.setChecked(checked); - if (checked) { - selectAll.setIcon(R.drawable.ic_select_none); - } else { - selectAll.setIcon(R.drawable.ic_select_all); - } - } - - static class ContactItemViewHolder extends RecyclerView.ViewHolder { - private ImageView badge; - private CheckedTextView name; - - ContactItemViewHolder(View itemView) { - super(itemView); - - badge = itemView.findViewById(R.id.contactlist_item_icon); - name = itemView.findViewById(R.id.contactlist_item_name); - - - itemView.setTag(this); - } - - public void setVCardListener(View.OnClickListener onClickListener) { - itemView.setOnClickListener(onClickListener); - } - - public ImageView getBadge() { - return badge; - } - - public void setBadge(ImageView badge) { - this.badge = badge; - } - - public CheckedTextView getName() { - return name; - } - - public void setName(CheckedTextView name) { - this.name = name; - } - } - - private void importContacts(ContactsAccount account) { - backgroundJobManager.startImmediateContactsImport(account.name, - account.type, - getFile().getStoragePath(), - contactListAdapter.getCheckedIntArray()); - - Snackbar - .make( - binding.contactlistRecyclerview, - R.string.contacts_preferences_import_scheduled, - Snackbar.LENGTH_LONG - ) - .show(); - - Handler handler = new Handler(); - handler.postDelayed(new Runnable() { - @Override - public void run() { - if (getFragmentManager().getBackStackEntryCount() > 0) { - getFragmentManager().popBackStack(); - } else { - getActivity().finish(); - } - } - }, 1750); - } - - private void getAccountForImport() { - final ArrayList contactsAccounts = new ArrayList<>(); - - // add local one - contactsAccounts.add(new ContactsAccount("Local contacts", null, null)); - - Cursor cursor = null; - try { - cursor = getContext().getContentResolver().query(ContactsContract.RawContacts.CONTENT_URI, - new String[]{ContactsContract.RawContacts.ACCOUNT_NAME, ContactsContract.RawContacts.ACCOUNT_TYPE}, - null, - null, - null); - - if (cursor != null && cursor.getCount() > 0) { - while (cursor.moveToNext()) { - String name = cursor.getString(cursor.getColumnIndex(ContactsContract.RawContacts.ACCOUNT_NAME)); - String type = cursor.getString(cursor.getColumnIndex(ContactsContract.RawContacts.ACCOUNT_TYPE)); - - ContactsAccount account = new ContactsAccount(name, name, type); - - if (!contactsAccounts.contains(account)) { - contactsAccounts.add(account); - } - } - - cursor.close(); - } - } catch (Exception e) { - Log_OC.d(TAG, e.getMessage()); - } finally { - if (cursor != null) { - cursor.close(); - } - } - - if (contactsAccounts.size() == SINGLE_ACCOUNT) { - importContacts(contactsAccounts.get(0)); - } else { - ArrayAdapter adapter = new ArrayAdapter<>(getContext(), android.R.layout.simple_list_item_1, contactsAccounts); - AlertDialog.Builder builder = new AlertDialog.Builder(getContext()); - builder.setTitle(R.string.contactlist_account_chooser_title) - .setAdapter(adapter, new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - importContacts(contactsAccounts.get(which)); - } - }).show(); - } - } - - private boolean checkAndAskForContactsWritePermission() { - // check permissions - if (!PermissionUtil.checkSelfPermission(getContext(), Manifest.permission.WRITE_CONTACTS)) { - requestPermissions(new String[]{Manifest.permission.WRITE_CONTACTS}, - PermissionUtil.PERMISSIONS_WRITE_CONTACTS); - return false; - } else { - return true; - } - } - - @Override - public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { - super.onRequestPermissionsResult(requestCode, permissions, grantResults); - - if (requestCode == PermissionUtil.PERMISSIONS_WRITE_CONTACTS) { - for (int index = 0; index < permissions.length; index++) { - if (Manifest.permission.WRITE_CONTACTS.equalsIgnoreCase(permissions[index])) { - if (grantResults[index] >= 0) { - getAccountForImport(); - } else { - if (getView() != null) { - Snackbar.make(getView(), R.string.contactlist_no_permission, Snackbar.LENGTH_LONG) - .show(); - } else { - Toast.makeText(getContext(), R.string.contactlist_no_permission, Toast.LENGTH_LONG).show(); - } - } - break; - } - } - } - } - - private class ContactsAccount { - private String displayName; - private String name; - private String type; - - ContactsAccount(String displayName, String name, String type) { - this.displayName = displayName; - this.name = name; - this.type = type; - } - - @Override - public boolean equals(Object obj) { - if (obj instanceof ContactsAccount) { - ContactsAccount other = (ContactsAccount) obj; - return this.name.equalsIgnoreCase(other.name) && this.type.equalsIgnoreCase(other.type); - } else { - return false; - } - } - - @NonNull - @Override - public String toString() { - return displayName; - } - - @Override - public int hashCode() { - return Arrays.hashCode(new Object[]{displayName, name, type}); - } - } - - private Unit onDownloadUpdate(Transfer download) { - final Activity activity = getActivity(); - if (download.getState() == TransferState.COMPLETED && activity != null) { - ocFile = download.getFile(); - loadContactsTask.execute(); - } - return Unit.INSTANCE; - } - - public static class VCardComparator implements Comparator { - @Override - public int compare(VCard o1, VCard o2) { - String contac1 = getDisplayName(o1); - String contac2 = getDisplayName(o2); - - return contac1.compareToIgnoreCase(contac2); - } - - - } - - private AsyncTask loadContactsTask = new AsyncTask() { - - @Override - protected void onPreExecute() { - setLoadingMessage(); - } - - @Override - protected Boolean doInBackground(Void... voids) { - if (!isCancelled()) { - File file = new File(ocFile.getStoragePath()); - try { - vCards.addAll(Ezvcard.parse(file).all()); - Collections.sort(vCards, new VCardComparator()); - } catch (IOException e) { - Log_OC.e(TAG, "IO Exception: " + file.getAbsolutePath()); - return Boolean.FALSE; - } - return Boolean.TRUE; - } - return Boolean.FALSE; - } - - @Override - protected void onPostExecute(Boolean bool) { - if (!isCancelled()) { - binding.loadingListContainer.setVisibility(View.GONE); - contactListAdapter.replaceVCards(vCards); - } - } - }; - - public static String getDisplayName(VCard vCard) { - if (vCard.getFormattedName() != null) { - return vCard.getFormattedName().getValue(); - } else if (vCard.getTelephoneNumbers() != null && vCard.getTelephoneNumbers().size() > 0) { - return vCard.getTelephoneNumbers().get(0).getText(); - } else if (vCard.getEmails() != null && vCard.getEmails().size() > 0) { - return vCard.getEmails().get(0).getValue(); - } - - return ""; - } -} - -class ContactListAdapter extends RecyclerView.Adapter { - private static final int SINGLE_SELECTION = 1; - - private List vCards; - private Set checkedVCards; - - private Context context; - - private UserAccountManager accountManager; - private ClientFactory clientFactory; - - ContactListAdapter(UserAccountManager accountManager, ClientFactory clientFactory, Context context, - List vCards) { - this.vCards = vCards; - this.context = context; - this.checkedVCards = new HashSet<>(); - this.accountManager = accountManager; - this.clientFactory = clientFactory; - } - - ContactListAdapter(UserAccountManager accountManager, - Context context, - List vCards, - Set checkedVCards) { - this.vCards = vCards; - this.context = context; - this.checkedVCards = checkedVCards; - this.accountManager = accountManager; - } - - public int getCheckedCount() { - if (checkedVCards != null) { - return checkedVCards.size(); - } else { - return 0; - } - } - - public void replaceVCards(List vCards) { - this.vCards = vCards; - notifyDataSetChanged(); - } - - public int[] getCheckedIntArray() { - int[] intArray; - if (checkedVCards != null && checkedVCards.size() > 0) { - intArray = new int[checkedVCards.size()]; - int i = 0; - for (int position : checkedVCards) { - intArray[i] = position; - i++; - } - return intArray; - } else { - return new int[0]; - } - } - - @NonNull - @Override - public ContactListFragment.ContactItemViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - View view = LayoutInflater.from(context).inflate(R.layout.contactlist_list_item, parent, false); - - return new ContactListFragment.ContactItemViewHolder(view); - } - - @Override - public void onBindViewHolder(@NonNull final ContactListFragment.ContactItemViewHolder holder, final int position) { - final int verifiedPosition = holder.getAdapterPosition(); - final VCard vcard = vCards.get(verifiedPosition); - - if (vcard != null) { - - setChecked(checkedVCards.contains(position), holder.getName()); - - holder.getName().setText(getDisplayName(vcard)); - - // photo - if (vcard.getPhotos().size() > 0) { - setPhoto(holder.getBadge(), vcard.getPhotos().get(0)); - } else { - try { - holder.getBadge().setImageDrawable( - TextDrawable.createNamedAvatar( - holder.getName().getText().toString(), - context.getResources().getDimension(R.dimen.list_item_avatar_icon_radius) - ) - ); - } catch (Exception e) { - holder.getBadge().setImageResource(R.drawable.ic_user); - } - } - - holder.setVCardListener(v -> toggleVCard(holder, verifiedPosition)); - } - } - - private void setPhoto(ImageView imageView, Photo firstPhoto) { - String url = firstPhoto.getUrl(); - byte[] data = firstPhoto.getData(); - - if (data != null && data.length > 0) { - Bitmap thumbnail = BitmapFactory.decodeByteArray(data, 0, data.length); - RoundedBitmapDrawable drawable = BitmapUtils.bitmapToCircularBitmapDrawable(context.getResources(), - thumbnail); - - imageView.setImageDrawable(drawable); - } else if (url != null) { - SimpleTarget target = new SimpleTarget() { - @Override - public void onResourceReady(Drawable resource, GlideAnimation glideAnimation) { - imageView.setImageDrawable(resource); - } - - @Override - public void onLoadFailed(Exception e, Drawable errorDrawable) { - super.onLoadFailed(e, errorDrawable); - imageView.setImageDrawable(errorDrawable); - } - }; - DisplayUtils.downloadIcon(accountManager, - clientFactory, - context, - url, - target, - R.drawable.ic_user, - imageView.getWidth(), - imageView.getHeight()); - } - } - - private void setChecked(boolean checked, CheckedTextView checkedTextView) { - checkedTextView.setChecked(checked); - - if (checked) { - checkedTextView.getCheckMarkDrawable() - .setColorFilter(ThemeColorUtils.primaryColor(context), PorterDuff.Mode.SRC_ATOP); - } else { - checkedTextView.getCheckMarkDrawable().clearColorFilter(); - } - } - - private void toggleVCard(ContactListFragment.ContactItemViewHolder holder, int verifiedPosition) { - holder.getName().setChecked(!holder.getName().isChecked()); - - if (holder.getName().isChecked()) { - holder.getName().getCheckMarkDrawable().setColorFilter(ThemeColorUtils.primaryColor(context), - PorterDuff.Mode.SRC_ATOP); - - checkedVCards.add(verifiedPosition); - if (checkedVCards.size() == SINGLE_SELECTION) { - EventBus.getDefault().post(new VCardToggleEvent(true)); - } - } else { - holder.getName().getCheckMarkDrawable().clearColorFilter(); - - checkedVCards.remove(verifiedPosition); - - if (checkedVCards.isEmpty()) { - EventBus.getDefault().post(new VCardToggleEvent(false)); - } - } - } - - @Override - public int getItemCount() { - return vCards.size(); - } - - public void selectAllFiles(boolean select) { - checkedVCards = new HashSet<>(); - if (select) { - for (int i = 0; i < vCards.size(); i++) { - checkedVCards.add(i); - } - } - - if (checkedVCards.size() > 0) { - EventBus.getDefault().post(new VCardToggleEvent(true)); - } else { - EventBus.getDefault().post(new VCardToggleEvent(false)); - } - - notifyDataSetChanged(); - } - -} diff --git a/src/main/java/com/owncloud/android/ui/fragment/contactsbackup/ContactsAccount.java b/src/main/java/com/owncloud/android/ui/fragment/contactsbackup/ContactsAccount.java new file mode 100644 index 0000000000..dde131d16b --- /dev/null +++ b/src/main/java/com/owncloud/android/ui/fragment/contactsbackup/ContactsAccount.java @@ -0,0 +1,68 @@ +/* + * + * Nextcloud Android client application + * + * @author Tobias Kaminsky + * Copyright (C) 2021 Tobias Kaminsky + * Copyright (C) 2021 Nextcloud GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.owncloud.android.ui.fragment.contactsbackup; + +import java.util.Arrays; + +import androidx.annotation.NonNull; + +public class ContactsAccount { + private final String displayName; + private final String name; + private final String type; + + ContactsAccount(String displayName, String name, String type) { + this.displayName = displayName; + this.name = name; + this.type = type; + } + + @Override + public boolean equals(Object obj) { + if (obj instanceof ContactsAccount) { + ContactsAccount other = (ContactsAccount) obj; + return this.name.equalsIgnoreCase(other.name) && this.type.equalsIgnoreCase(other.type); + } else { + return false; + } + } + + @NonNull + @Override + public String toString() { + return displayName; + } + + @Override + public int hashCode() { + return Arrays.hashCode(new Object[]{displayName, name, type}); + } + + public String getName() { + return name; + } + + public String getType() { + return type; + } +} diff --git a/src/main/java/com/owncloud/android/ui/fragment/contactsbackup/VCardComparator.java b/src/main/java/com/owncloud/android/ui/fragment/contactsbackup/VCardComparator.java new file mode 100644 index 0000000000..0a101ab056 --- /dev/null +++ b/src/main/java/com/owncloud/android/ui/fragment/contactsbackup/VCardComparator.java @@ -0,0 +1,37 @@ +/* + * + * Nextcloud Android client application + * + * @author Tobias Kaminsky + * Copyright (C) 2021 Tobias Kaminsky + * Copyright (C) 2021 Nextcloud GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.owncloud.android.ui.fragment.contactsbackup; + +import java.util.Comparator; + +import ezvcard.VCard; + +public class VCardComparator implements Comparator { + @Override + public int compare(VCard o1, VCard o2) { + String contact1 = BackupListFragment.getDisplayName(o1); + String contact2 = BackupListFragment.getDisplayName(o2); + + return contact1.compareToIgnoreCase(contact2); + } +} diff --git a/src/main/java/com/owncloud/android/utils/MimeTypeUtil.java b/src/main/java/com/owncloud/android/utils/MimeTypeUtil.java index 5a76065539..6f06fff178 100644 --- a/src/main/java/com/owncloud/android/utils/MimeTypeUtil.java +++ b/src/main/java/com/owncloud/android/utils/MimeTypeUtil.java @@ -328,6 +328,14 @@ public final class MimeTypeUtil { return isVCard(file.getMimeType()) || isVCard(getMimeTypeFromPath(file.getRemotePath())); } + public static boolean isCalendar(OCFile file) { + return isCalendar(file.getMimeType()) || isCalendar(getMimeTypeFromPath(file.getRemotePath())); + } + + public static boolean isCalendar(String mimeType) { + return "text/calendar".equalsIgnoreCase(mimeType); + } + public static boolean isFolder(String mimeType) { return MimeType.DIRECTORY.equalsIgnoreCase(mimeType); } diff --git a/src/main/java/com/owncloud/android/utils/PermissionUtil.java b/src/main/java/com/owncloud/android/utils/PermissionUtil.java index c276dffaa6..0829b3169b 100644 --- a/src/main/java/com/owncloud/android/utils/PermissionUtil.java +++ b/src/main/java/com/owncloud/android/utils/PermissionUtil.java @@ -13,9 +13,10 @@ import androidx.core.content.ContextCompat; public final class PermissionUtil { public static final int PERMISSIONS_WRITE_EXTERNAL_STORAGE = 1; public static final int PERMISSIONS_READ_CONTACTS_AUTOMATIC = 2; - public static final int PERMISSIONS_READ_CONTACTS_MANUALLY = 3; public static final int PERMISSIONS_WRITE_CONTACTS = 4; public static final int PERMISSIONS_CAMERA = 5; + public static final int PERMISSIONS_READ_CALENDAR_AUTOMATIC = 6; + public static final int PERMISSIONS_WRITE_CALENDAR = 7; private PermissionUtil() { // utility class -> private constructor diff --git a/src/main/java/third_parties/sufficientlysecure/AndroidCalendar.java b/src/main/java/third_parties/sufficientlysecure/AndroidCalendar.java new file mode 100644 index 0000000000..770c8cf298 --- /dev/null +++ b/src/main/java/third_parties/sufficientlysecure/AndroidCalendar.java @@ -0,0 +1,160 @@ +/* + * + * Nextcloud Android client application + * + * @author Tobias Kaminsky + * Copyright (C) 2021 Tobias Kaminsky + * Copyright (C) 2021 Nextcloud GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package third_parties.sufficientlysecure; + +import android.content.ContentProviderClient; +import android.content.ContentResolver; +import android.database.Cursor; +import android.net.Uri; +import android.provider.CalendarContract.Calendars; +import android.provider.CalendarContract.Events; + +import com.owncloud.android.lib.common.utils.Log_OC; + +import java.util.ArrayList; +import java.util.List; + + +public class AndroidCalendar { + private static final String TAG = "ICS_AndroidCalendar"; + + public long mId; + public String mIdStr; + public String mName; + public String mDisplayName; + public String mAccountName; + public String mAccountType; + public String mOwner; + public boolean mIsActive; + public String mTimezone; + public int mNumEntries; + + private static final String[] CAL_COLS = new String[]{ + Calendars._ID, + Calendars.DELETED, + Calendars.NAME, + Calendars.CALENDAR_DISPLAY_NAME, + Calendars.ACCOUNT_NAME, + Calendars.ACCOUNT_TYPE, + Calendars.OWNER_ACCOUNT, + Calendars.VISIBLE, + Calendars.CALENDAR_TIME_ZONE}; + + private static final String[] CAL_ID_COLS = new String[]{Events._ID}; + private static final String CAL_ID_WHERE = Events.CALENDAR_ID + "=?"; + + // Load all available calendars. + // If an empty list is returned the caller probably needs to enable calendar + // read permissions in App Ops/XPrivacy etc. + public static List loadAll(ContentResolver resolver) { + + if (missing(resolver, Calendars.CONTENT_URI) || + missing(resolver, Events.CONTENT_URI)) { + return new ArrayList<>(); + } + + Cursor cur; + try { + cur = resolver.query(Calendars.CONTENT_URI, CAL_COLS, null, null, null); + } catch (Exception except) { + Log_OC.w(TAG, "Calendar provider is missing columns, continuing anyway"); + cur = resolver.query(Calendars.CONTENT_URI, null, null, null, null); + } + List calendars = new ArrayList<>(cur.getCount()); + + while (cur.moveToNext()) { + if (getLong(cur, Calendars.DELETED) != 0) { + continue; + } + + AndroidCalendar calendar = new AndroidCalendar(); + calendar.mId = getLong(cur, Calendars._ID); + if (calendar.mId == -1) { + continue; + } + calendar.mIdStr = getString(cur, Calendars._ID); + calendar.mName = getString(cur, Calendars.NAME); + calendar.mDisplayName = getString(cur, Calendars.CALENDAR_DISPLAY_NAME); + calendar.mAccountName = getString(cur, Calendars.ACCOUNT_NAME); + calendar.mAccountType = getString(cur, Calendars.ACCOUNT_TYPE); + calendar.mOwner = getString(cur, Calendars.OWNER_ACCOUNT); + calendar.mIsActive = getLong(cur, Calendars.VISIBLE) == 1; + calendar.mTimezone = getString(cur, Calendars.CALENDAR_TIME_ZONE); + + final String[] args = new String[]{calendar.mIdStr}; + Cursor eventsCur = resolver.query(Events.CONTENT_URI, CAL_ID_COLS, CAL_ID_WHERE, args, null); + calendar.mNumEntries = eventsCur.getCount(); + eventsCur.close(); + calendars.add(calendar); + } + cur.close(); + + return calendars; + } + + private static int getColumnIndex(Cursor cur, String dbName) { + return dbName == null ? -1 : cur.getColumnIndex(dbName); + } + + private static long getLong(Cursor cur, String dbName) { + int i = getColumnIndex(cur, dbName); + return i == -1 ? -1 : cur.getLong(i); + } + + private static String getString(Cursor cur, String dbName) { + int i = getColumnIndex(cur, dbName); + return i == -1 ? null : cur.getString(i); + } + + private static boolean missing(ContentResolver resolver, Uri uri) { + // Determine if a provider is missing + ContentProviderClient provider = resolver.acquireContentProviderClient(uri); + if (provider != null) { + provider.release(); + } + return provider == null; + } + + @Override + public String toString() { + return mDisplayName + " (" + mIdStr + ")"; + } + + private boolean differ(final String lhs, final String rhs) { + if (lhs == null) { + return rhs != null; + } + return rhs == null || !lhs.equals(rhs); + } + + public boolean differsFrom(AndroidCalendar other) { + return mId != other.mId || + mIsActive != other.mIsActive || + mNumEntries != other.mNumEntries || + differ(mName, other.mName) || + differ(mDisplayName, other.mDisplayName) || + differ(mAccountName, other.mAccountName) || + differ(mAccountType, other.mAccountType) || + differ(mOwner, other.mOwner) || + differ(mTimezone, other.mTimezone); + } +} diff --git a/src/main/java/third_parties/sufficientlysecure/CalendarSource.java b/src/main/java/third_parties/sufficientlysecure/CalendarSource.java new file mode 100644 index 0000000000..e9f2b58714 --- /dev/null +++ b/src/main/java/third_parties/sufficientlysecure/CalendarSource.java @@ -0,0 +1,96 @@ +/* + * + * Nextcloud Android client application + * + * @author Tobias Kaminsky + * Copyright (C) 2021 Tobias Kaminsky + * Copyright (C) 2021 Nextcloud GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package third_parties.sufficientlysecure; + +import android.content.Context; +import android.net.Uri; + +import org.apache.commons.codec.binary.Base64; + +import java.io.IOException; +import java.io.InputStream; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLConnection; + +public class CalendarSource { + private static final String HTTP_SEP = "://"; + + private URL mUrl = null; + private Uri mUri = null; + private final String mString; + private final String mUsername; + private final String mPassword; + private final Context context; + + public CalendarSource(String url, + Uri uri, + String username, + String password, + Context context) throws MalformedURLException { + if (url != null) { + mUrl = new URL(url); + mString = mUrl.toString(); + } else { + mUri = uri; + mString = uri.toString(); + } + mUsername = username; + mPassword = password; + this.context = context; + } + + public URLConnection getConnection() throws IOException { + if (mUsername != null) { + String protocol = mUrl.getProtocol(); + String userPass = mUsername + ":" + mPassword; + + if (protocol.equalsIgnoreCase("ftp") || protocol.equalsIgnoreCase("ftps")) { + String external = mUrl.toExternalForm(); + String end = external.substring(protocol.length() + HTTP_SEP.length()); + return new URL(protocol + HTTP_SEP + userPass + "@" + end).openConnection(); + } + + if (protocol.equalsIgnoreCase("http") || protocol.equalsIgnoreCase("https")) { + String encoded = new String(new Base64().encode(userPass.getBytes("UTF-8"))); + URLConnection connection = mUrl.openConnection(); + connection.setRequestProperty("Authorization", "Basic " + encoded); + return connection; + } + } + return mUrl.openConnection(); + } + + public InputStream getStream() throws IOException { + if (mUri != null) { + return context.getContentResolver().openInputStream(mUri); + } + URLConnection c = this.getConnection(); + return c == null ? null : c.getInputStream(); + } + + @Override + public String toString() { + return mString; + } +} diff --git a/src/main/java/third_parties/sufficientlysecure/DuplicateHandlingEnum.java b/src/main/java/third_parties/sufficientlysecure/DuplicateHandlingEnum.java new file mode 100644 index 0000000000..85d846dd4b --- /dev/null +++ b/src/main/java/third_parties/sufficientlysecure/DuplicateHandlingEnum.java @@ -0,0 +1,30 @@ +/* + * + * Nextcloud Android client application + * + * @author Tobias Kaminsky + * Copyright (C) 2021 Tobias Kaminsky + * Copyright (C) 2021 Nextcloud GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package third_parties.sufficientlysecure; + +public enum DuplicateHandlingEnum { + DUP_REPLACE, + DUP_REPLACE_ANY, + DUP_IGNORE, + DUP_DONT_CHECK, +} diff --git a/src/main/java/third_parties/sufficientlysecure/ProcessVEvent.java b/src/main/java/third_parties/sufficientlysecure/ProcessVEvent.java new file mode 100644 index 0000000000..60887c548b --- /dev/null +++ b/src/main/java/third_parties/sufficientlysecure/ProcessVEvent.java @@ -0,0 +1,642 @@ +/* + * Copyright (C) 2015 Jon Griffiths (jon_p_griffiths@yahoo.com) + * Copyright (C) 2013 Dominik Schürmann + * Copyright (C) 2010-2011 Lukas Aichbauer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package third_parties.sufficientlysecure; + +import android.annotation.SuppressLint; +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Context; +import android.content.res.Resources; +import android.database.Cursor; +import android.net.MailTo; +import android.net.ParseException; +import android.net.Uri; +import android.provider.CalendarContract.Events; +import android.provider.CalendarContract.Reminders; +import android.text.TextUtils; +import android.text.format.DateUtils; + +import com.nextcloud.client.preferences.AppPreferences; +import com.owncloud.android.R; +import com.owncloud.android.lib.common.utils.Log_OC; + +import net.fortuna.ical4j.model.Calendar; +import net.fortuna.ical4j.model.ComponentList; +import net.fortuna.ical4j.model.DateTime; +import net.fortuna.ical4j.model.Dur; +import net.fortuna.ical4j.model.Parameter; +import net.fortuna.ical4j.model.Property; +import net.fortuna.ical4j.model.component.VAlarm; +import net.fortuna.ical4j.model.component.VEvent; +import net.fortuna.ical4j.model.parameter.FbType; +import net.fortuna.ical4j.model.parameter.Related; +import net.fortuna.ical4j.model.property.Action; +import net.fortuna.ical4j.model.property.DateProperty; +import net.fortuna.ical4j.model.property.Duration; +import net.fortuna.ical4j.model.property.FreeBusy; +import net.fortuna.ical4j.model.property.Transp; +import net.fortuna.ical4j.model.property.Trigger; + +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import javax.inject.Inject; + + +@SuppressLint("NewApi") +public class ProcessVEvent { + private static final String TAG = "ICS_ProcessVEvent"; + + private static final Duration ONE_DAY = createDuration("P1D"); + private static final Duration ZERO_SECONDS = createDuration("PT0S"); + + private static final String[] EVENT_QUERY_COLUMNS = new String[]{Events.CALENDAR_ID, Events._ID}; + private static final int EVENT_QUERY_CALENDAR_ID_COL = 0; + private static final int EVENT_QUERY_ID_COL = 1; + + private final Calendar mICalCalendar; + private final boolean mIsInserter; + private final AndroidCalendar selectedCal; + + private Context context; + + @Inject AppPreferences preferences; + + // UID generation + long mUidMs = 0; + String mUidTail = null; + + private final class Options { + private final List mDefaultReminders; + + public Options(Context context) { + mDefaultReminders = new ArrayList<>(); // RemindersDialog.getSavedRemindersInMinutes(this); // TODO check + mDefaultReminders.add(0); + mDefaultReminders.add(5); + mDefaultReminders.add(10); + mDefaultReminders.add(30); + mDefaultReminders.add(60); + } + + public List getReminders(List eventReminders) { + if (eventReminders.size() > 0 && getImportReminders()) { + return eventReminders; + } + return mDefaultReminders; + } + + public boolean getKeepUids() { + return true; // upstream this is a setting // TODO check if we need to also have this as a setting + } + + private boolean getImportReminders() { + return true; // upstream this is a setting // TODO check if we need to also have this as a setting + } + + private boolean getGlobalUids() { + return false; // upstream this is a setting // TODO check if we need to also have this as a setting + } + + private boolean getTestFileSupport() { + return false; // upstream this is a setting // TODO check if we need to also have this as a setting + } + + public DuplicateHandlingEnum getDuplicateHandling() { +// return DuplicateHandlingEnum.values()[getEnumInt(PREF_DUPLICATE_HANDLING, 0)]; + return DuplicateHandlingEnum.values()[0]; // TODO is option needed? + } + +// private int getEnumInt(final String key, final int def) { +// return Integer.parseInt(getString(key, String.valueOf(def))); +// } + } + + public ProcessVEvent(Context context, Calendar iCalCalendar, AndroidCalendar selectedCal, boolean isInserter) { + this.context = context; + mICalCalendar = iCalCalendar; + this.selectedCal = selectedCal; + mIsInserter = isInserter; + } + + // TODO how to run? + public void run() throws Exception { + final Options options = new Options(context); + List reminders = new ArrayList<>(); + + ComponentList events = mICalCalendar.getComponents(VEvent.VEVENT); + + ContentResolver resolver = context.getContentResolver(); + int numDel = 0; + int numIns = 0; + int numDups = 0; + + ContentValues cAlarm = new ContentValues(); + cAlarm.put(Reminders.METHOD, Reminders.METHOD_ALERT); + + final DuplicateHandlingEnum dupes = options.getDuplicateHandling(); + + Log_OC.i(TAG, (mIsInserter ? "Insert" : "Delete") + " for id " + selectedCal.mIdStr); + Log_OC.d(TAG, "Duplication option is " + dupes.ordinal()); + + for (Object ve : events) { + VEvent e = (VEvent) ve; + Log_OC.d(TAG, "source event: " + e.toString()); + + if (e.getRecurrenceId() != null) { + // FIXME: Support these edited instances + Log_OC.w(TAG, "Ignoring edited instance of a recurring event"); + continue; + } + + long insertCalendarId = selectedCal.mId; // Calendar id to insert to + + ContentValues c = convertToDB(e, options, reminders, selectedCal.mId); + + Cursor cur = null; + boolean mustDelete = !mIsInserter; + + // Determine if we need to delete a duplicate event in order to update it + if (!mustDelete && dupes != DuplicateHandlingEnum.DUP_DONT_CHECK) { + + cur = query(resolver, options, c); + while (!mustDelete && cur != null && cur.moveToNext()) { + if (dupes == DuplicateHandlingEnum.DUP_REPLACE) { + mustDelete = cur.getLong(EVENT_QUERY_CALENDAR_ID_COL) == selectedCal.mId; + } else { + mustDelete = true; // Replacing all (or ignoring, handled just below) + } + } + + if (mustDelete) { + if (dupes == DuplicateHandlingEnum.DUP_IGNORE) { + Log_OC.i(TAG, "Avoiding inserting a duplicate event"); + numDups++; + cur.close(); + continue; + } + cur.moveToPosition(-1); // Rewind for use below + } + } + + if (mustDelete) { + if (cur == null) { + cur = query(resolver, options, c); + } + + while (cur != null && cur.moveToNext()) { + long rowCalendarId = cur.getLong(EVENT_QUERY_CALENDAR_ID_COL); + + if (dupes == DuplicateHandlingEnum.DUP_REPLACE + && rowCalendarId != selectedCal.mId) { + Log_OC.i(TAG, "Avoiding deleting duplicate event in calendar " + rowCalendarId); + continue; // Not in the destination calendar + } + + String id = cur.getString(EVENT_QUERY_ID_COL); + Uri eventUri = Uri.withAppendedPath(Events.CONTENT_URI, id); + numDel += resolver.delete(eventUri, null, null); + String where = Reminders.EVENT_ID + "=?"; + resolver.delete(Reminders.CONTENT_URI, where, new String[]{id}); + if (mIsInserter && rowCalendarId != selectedCal.mId + && dupes == DuplicateHandlingEnum.DUP_REPLACE_ANY) { + // Must update this event in the calendar this row came from + Log_OC.i(TAG, "Changing calendar: " + rowCalendarId + " to " + insertCalendarId); + insertCalendarId = rowCalendarId; + } + } + } + + if (cur != null) { + cur.close(); + } + + if (!mIsInserter) { + continue; + } + + if (Events.UID_2445 != null && !c.containsKey(Events.UID_2445)) { + // Create a UID for this event to use. We create it here so if + // exported multiple times it will always have the same id. + c.put(Events.UID_2445, generateUid()); // TODO use + } + + c.put(Events.CALENDAR_ID, insertCalendarId); + if (options.getTestFileSupport()) { + processEventTests(e, c, reminders); + numIns++; + continue; + } + + Uri uri = insertAndLog(resolver, Events.CONTENT_URI, c, "Event"); + if (uri == null) { + continue; + } + + final long id = Long.parseLong(uri.getLastPathSegment()); + + for (int time : options.getReminders(reminders)) { + cAlarm.put(Reminders.EVENT_ID, id); + cAlarm.put(Reminders.MINUTES, time); + insertAndLog(resolver, Reminders.CONTENT_URI, cAlarm, "Reminder"); + } + numIns++; + } + + selectedCal.mNumEntries += numIns; + selectedCal.mNumEntries -= numDel; + + Resources res = context.getResources(); + int n = mIsInserter ? numIns : numDel; + String msg = res.getQuantityString(R.plurals.processed_n_entries, n, n) + "\n"; + if (mIsInserter) { + msg += "\n"; + if (options.getDuplicateHandling() == DuplicateHandlingEnum.DUP_DONT_CHECK) { + msg += res.getString(R.string.did_not_check_for_dupes); + } else { + msg += res.getQuantityString(R.plurals.found_n_duplicates, numDups, numDups); + } + } + + // TODO show failure in starting context + // DisplayUtils.showSnackMessage(context, msg); + } + + // Munge a VEvent so Android won't reject it, then convert to ContentValues for inserting + private ContentValues convertToDB(VEvent e, Options options, + List reminders, long calendarId) { + reminders.clear(); + + boolean allDay = false; + boolean startIsDate = !(e.getStartDate().getDate() instanceof DateTime); + boolean isRecurring = hasProperty(e, Property.RRULE) || hasProperty(e, Property.RDATE); + + if (startIsDate) { + // If the start date is a DATE we expect the end date to be a date too and the + // event is all-day, midnight to midnight (RFC 2445). + allDay = true; + } + + if (!hasProperty(e, Property.DTEND) && !hasProperty(e, Property.DURATION)) { + // No end date or duration given. + // Since we added a duration above when the start date is a DATE: + // - The start date is a DATETIME, the event lasts no time at all (RFC 2445). + e.getProperties().add(ZERO_SECONDS); + // Zero time events are always free (RFC 2445), so override/set TRANSP accordingly. + removeProperty(e, Property.TRANSP); + e.getProperties().add(Transp.TRANSPARENT); + } + + if (isRecurring) { + // Recurring event. Android insists on a duration. + if (!hasProperty(e, Property.DURATION)) { + // Calculate duration from start to end date + Duration d = new Duration(e.getStartDate().getDate(), e.getEndDate().getDate()); + e.getProperties().add(d); + } + removeProperty(e, Property.DTEND); + } else { + // Non-recurring event. Android insists on an end date. + if (!hasProperty(e, Property.DTEND)) { + // Calculate end date from duration, set it and remove the duration. + e.getProperties().add(e.getEndDate()); + } + removeProperty(e, Property.DURATION); + } + + // Now calculate the db values for the event + ContentValues c = new ContentValues(); + + c.put(Events.CALENDAR_ID, calendarId); + copyProperty(c, Events.TITLE, e, Property.SUMMARY); + copyProperty(c, Events.DESCRIPTION, e, Property.DESCRIPTION); + + if (e.getOrganizer() != null) { + URI uri = e.getOrganizer().getCalAddress(); + try { + MailTo mailTo = MailTo.parse(uri.toString()); + c.put(Events.ORGANIZER, mailTo.getTo()); + c.put(Events.GUESTS_CAN_MODIFY, 1); // Ensure we can edit if not the organiser + } catch (ParseException ignored) { + Log_OC.e(TAG, "Failed to parse Organiser URI " + uri.toString()); + } + } + + copyProperty(c, Events.EVENT_LOCATION, e, Property.LOCATION); + + if (hasProperty(e, Property.STATUS)) { + String status = e.getProperty(Property.STATUS).getValue(); + switch (status) { + case "TENTATIVE": + c.put(Events.STATUS, Events.STATUS_TENTATIVE); + break; + case "CONFIRMED": + c.put(Events.STATUS, Events.STATUS_CONFIRMED); + break; + case "CANCELLED": // NOTE: In ical4j it is CANCELLED with two L + c.put(Events.STATUS, Events.STATUS_CANCELED); + break; + } + } + + copyProperty(c, Events.DURATION, e, Property.DURATION); + + if (allDay) { + c.put(Events.ALL_DAY, 1); + } + + copyDateProperty(c, Events.DTSTART, Events.EVENT_TIMEZONE, e.getStartDate()); + if (hasProperty(e, Property.DTEND)) { + copyDateProperty(c, Events.DTEND, Events.EVENT_END_TIMEZONE, e.getEndDate()); + } + + if (hasProperty(e, Property.CLASS)) { + String access = e.getProperty(Property.CLASS).getValue(); + int accessLevel = Events.ACCESS_DEFAULT; + switch (access) { + case "CONFIDENTIAL": + accessLevel = Events.ACCESS_CONFIDENTIAL; + break; + case "PRIVATE": + accessLevel = Events.ACCESS_PRIVATE; + break; + case "PUBLIC": + accessLevel = Events.ACCESS_PUBLIC; + break; + } + + c.put(Events.ACCESS_LEVEL, accessLevel); + } + + // Work out availability. This is confusing as FREEBUSY and TRANSP overlap. + if (Events.AVAILABILITY != null) { + int availability = Events.AVAILABILITY_BUSY; + if (hasProperty(e, Property.TRANSP)) { + if (e.getTransparency() == Transp.TRANSPARENT) { + availability = Events.AVAILABILITY_FREE; + } + + } else if (hasProperty(e, Property.FREEBUSY)) { + FreeBusy fb = (FreeBusy) e.getProperty(Property.FREEBUSY); + FbType fbType = (FbType) fb.getParameter(Parameter.FBTYPE); + if (fbType != null && fbType == FbType.FREE) { + availability = Events.AVAILABILITY_FREE; + } else if (fbType != null && fbType == FbType.BUSY_TENTATIVE) { + availability = Events.AVAILABILITY_TENTATIVE; + } + } + c.put(Events.AVAILABILITY, availability); + } + + copyProperty(c, Events.RRULE, e, Property.RRULE); + copyProperty(c, Events.RDATE, e, Property.RDATE); + copyProperty(c, Events.EXRULE, e, Property.EXRULE); + copyProperty(c, Events.EXDATE, e, Property.EXDATE); + copyProperty(c, Events.CUSTOM_APP_URI, e, Property.URL); + copyProperty(c, Events.UID_2445, e, Property.UID); + if (c.containsKey(Events.UID_2445) && TextUtils.isEmpty(c.getAsString(Events.UID_2445))) { + // Remove null/empty UIDs + c.remove(Events.UID_2445); + } + + for (Object alarm : e.getAlarms()) { + VAlarm a = (VAlarm) alarm; + + if (a.getAction() != Action.AUDIO && a.getAction() != Action.DISPLAY) { + continue; // Ignore email and procedure alarms + } + + Trigger t = a.getTrigger(); + final long startMs = e.getStartDate().getDate().getTime(); + long alarmStartMs = startMs; + long alarmMs; + + // FIXME: - Support for repeating alarms + // - Check the calendars max number of alarms + if (t.getDateTime() != null) { + alarmMs = t.getDateTime().getTime(); // Absolute + } else if (t.getDuration() != null && t.getDuration().isNegative()) { + Related rel = (Related) t.getParameter(Parameter.RELATED); + if (rel != null && rel == Related.END) { + alarmStartMs = e.getEndDate().getDate().getTime(); + } + alarmMs = alarmStartMs - durationToMs(t.getDuration()); // Relative + } else { + continue; + } + + int reminder = (int) ((startMs - alarmMs) / DateUtils.MINUTE_IN_MILLIS); + if (reminder >= 0 && !reminders.contains(reminder)) { + reminders.add(reminder); + } + } + + if (options.getReminders(reminders).size() > 0) { + c.put(Events.HAS_ALARM, 1); + } + + // FIXME: Attendees, SELF_ATTENDEE_STATUS + return c; + } + + private static Duration createDuration(String value) { + Duration d = new Duration(); + d.setValue(value); + return d; + } + + private static long durationToMs(Dur d) { + long ms = 0; + ms += d.getSeconds() * DateUtils.SECOND_IN_MILLIS; + ms += d.getMinutes() * DateUtils.MINUTE_IN_MILLIS; + ms += d.getHours() * DateUtils.HOUR_IN_MILLIS; + ms += d.getDays() * DateUtils.DAY_IN_MILLIS; + ms += d.getWeeks() * DateUtils.WEEK_IN_MILLIS; + return ms; + } + + private boolean hasProperty(VEvent e, String name) { + return e.getProperty(name) != null; + } + + private void removeProperty(VEvent e, String name) { + Property p = e.getProperty(name); + if (p != null) { + e.getProperties().remove(p); + } + } + + private void copyProperty(ContentValues c, String dbName, VEvent e, String evName) { + if (dbName != null) { + Property p = e.getProperty(evName); + if (p != null) { + c.put(dbName, p.getValue()); + } + } + } + + private void copyDateProperty(ContentValues c, String dbName, String dbTzName, DateProperty date) { + if (dbName != null && date.getDate() != null) { + c.put(dbName, date.getDate().getTime()); // ms since epoc in GMT + if (dbTzName != null) { + if (date.isUtc() || date.getTimeZone() == null) { + c.put(dbTzName, "UTC"); + } else { + c.put(dbTzName, date.getTimeZone().getID()); + } + } + } + } + + private Uri insertAndLog(ContentResolver resolver, Uri uri, ContentValues c, String type) { + Log_OC.d(TAG, "Inserting " + type + " values: " + c); + + Uri result = resolver.insert(uri, c); + if (result == null) { + Log_OC.e(TAG, "failed to insert " + type); + Log_OC.e(TAG, "failed " + type + " values: " + c); // Not already logged, dump now + } else { + Log_OC.d(TAG, "Insert " + type + " returned " + result.toString()); + } + return result; + } + + private Cursor queryEvents(ContentResolver resolver, StringBuilder b, List argsList) { + final String where = b.toString(); + final String[] args = argsList.toArray(new String[argsList.size()]); + return resolver.query(Events.CONTENT_URI, EVENT_QUERY_COLUMNS, where, args, null); + } + + private Cursor query(ContentResolver resolver, Options options, ContentValues c) { + + StringBuilder b = new StringBuilder(); + List argsList = new ArrayList<>(); + + if (options.getKeepUids() && Events.UID_2445 != null && c.containsKey(Events.UID_2445)) { + // Use our UID to query, either globally or per-calendar unique + if (!options.getGlobalUids()) { + b.append(Events.CALENDAR_ID).append("=? AND "); + argsList.add(c.getAsString(Events.CALENDAR_ID)); + } + b.append(Events.UID_2445).append("=?"); + argsList.add(c.getAsString(Events.UID_2445)); + return queryEvents(resolver, b, argsList); + } + + // Without UIDs, the best we can do is check the start date and title within + // the current calendar, even though this may return false duplicates. + if (!c.containsKey(Events.CALENDAR_ID) || !c.containsKey(Events.DTSTART)) { + return null; + } + + b.append(Events.CALENDAR_ID).append("=? AND "); + b.append(Events.DTSTART).append("=? AND "); + b.append(Events.TITLE); + + argsList.add(c.getAsString(Events.CALENDAR_ID)); + argsList.add(c.getAsString(Events.DTSTART)); + + if (c.containsKey(Events.TITLE)) { + b.append("=?"); + argsList.add(c.getAsString(Events.TITLE)); + } else { + b.append(" is null"); + } + + return queryEvents(resolver, b, argsList); + } + + private void checkTestValue(VEvent e, ContentValues c, String keyValue, String testName) { + String[] parts = keyValue.split("="); + String key = parts[0]; + String expected = parts.length > 1 ? parts[1] : ""; + String got = c.getAsString(key); + + if (expected.equals("") && got != null) { + got = ""; // Sentinel for testing present and non-null + } + if (got == null) { + got = ""; // Sentinel for testing not present values + } + + if (!expected.equals(got)) { + Log_OC.e(TAG, " " + keyValue + " -> FAILED"); + Log_OC.e(TAG, " values: " + c); + String error = "Test " + testName + " FAILED, expected '" + keyValue + "', got '" + got + "'"; + throw new RuntimeException(error); + } + Log_OC.i(TAG, " " + keyValue + " -> PASSED"); + } + + private void processEventTests(VEvent e, ContentValues c, List reminders) { + + Property testName = e.getProperty("X-TEST-NAME"); + if (testName == null) { + return; // Not a test case + } + + // This is a test event. Verify it using the embedded meta data. + Log_OC.i(TAG, "Processing test case " + testName.getValue() + "..."); + + String reminderValues = ""; + String sep = ""; + for (Integer i : reminders) { + reminderValues += sep + i; + sep = ","; + } + c.put("reminders", reminderValues); + + for (Object o : e.getProperties()) { + Property p = (Property) o; + switch (p.getName()) { + case "X-TEST-VALUE": + checkTestValue(e, c, p.getValue(), testName.getValue()); + break; + case "X-TEST-MIN-VERSION": + final int ver = Integer.parseInt(p.getValue()); + if (android.os.Build.VERSION.SDK_INT < ver) { + Log_OC.e(TAG, " -> SKIPPED (MIN-VERSION < " + ver + ")"); + return; + } + break; + } + } + } + + // TODO move this to some common place + private String generateUid() { + // Generated UIDs take the form -@nextcloud.com. + if (mUidTail == null) { + String uidPid = preferences.getUidPid(); + if (uidPid.length() == 0) { + uidPid = UUID.randomUUID().toString().replace("-", ""); + preferences.setUidPid(uidPid); + } + mUidTail = uidPid + "@nextcloud.com"; + } + + mUidMs = Math.max(mUidMs, System.currentTimeMillis()); + String uid = mUidMs + mUidTail; + mUidMs++; + + return uid; + } +} diff --git a/src/main/java/third_parties/sufficientlysecure/SaveCalendar.java b/src/main/java/third_parties/sufficientlysecure/SaveCalendar.java new file mode 100644 index 0000000000..7a89395180 --- /dev/null +++ b/src/main/java/third_parties/sufficientlysecure/SaveCalendar.java @@ -0,0 +1,620 @@ +/* + * Copyright (C) 2015 Jon Griffiths (jon_p_griffiths@yahoo.com) + * Copyright (C) 2013 Dominik Schürmann + * Copyright (C) 2010-2011 Lukas Aichbauer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package third_parties.sufficientlysecure; + +import android.annotation.SuppressLint; +import android.app.AlertDialog; +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.content.DialogInterface; +import android.content.pm.PackageManager.NameNotFoundException; +import android.content.res.Resources; +import android.database.Cursor; +import android.database.DatabaseUtils; +import android.net.Uri; +import android.provider.CalendarContract; +import android.provider.CalendarContract.Events; +import android.provider.CalendarContract.Reminders; +import android.text.TextUtils; +import android.text.format.DateFormat; +import android.text.format.DateUtils; +import android.view.View; +import android.view.WindowManager; +import android.widget.EditText; + +import com.nextcloud.client.account.User; +import com.nextcloud.client.di.Injectable; +import com.nextcloud.client.files.downloader.PostUploadAction; +import com.nextcloud.client.files.downloader.Request; +import com.nextcloud.client.files.downloader.TransferManagerConnection; +import com.nextcloud.client.files.downloader.UploadRequest; +import com.nextcloud.client.files.downloader.UploadTrigger; +import com.nextcloud.client.preferences.AppPreferences; +import com.owncloud.android.R; +import com.owncloud.android.datamodel.OCFile; +import com.owncloud.android.files.services.NameCollisionPolicy; +import com.owncloud.android.lib.common.utils.Log_OC; + +import net.fortuna.ical4j.data.CalendarOutputter; +import net.fortuna.ical4j.model.Calendar; +import net.fortuna.ical4j.model.Date; +import net.fortuna.ical4j.model.DateTime; +import net.fortuna.ical4j.model.Dur; +import net.fortuna.ical4j.model.Period; +import net.fortuna.ical4j.model.Property; +import net.fortuna.ical4j.model.PropertyFactoryImpl; +import net.fortuna.ical4j.model.PropertyList; +import net.fortuna.ical4j.model.TimeZone; +import net.fortuna.ical4j.model.TimeZoneRegistry; +import net.fortuna.ical4j.model.TimeZoneRegistryFactory; +import net.fortuna.ical4j.model.component.VAlarm; +import net.fortuna.ical4j.model.component.VEvent; +import net.fortuna.ical4j.model.parameter.FbType; +import net.fortuna.ical4j.model.property.Action; +import net.fortuna.ical4j.model.property.CalScale; +import net.fortuna.ical4j.model.property.Description; +import net.fortuna.ical4j.model.property.DtEnd; +import net.fortuna.ical4j.model.property.DtStamp; +import net.fortuna.ical4j.model.property.DtStart; +import net.fortuna.ical4j.model.property.Duration; +import net.fortuna.ical4j.model.property.FreeBusy; +import net.fortuna.ical4j.model.property.Method; +import net.fortuna.ical4j.model.property.Organizer; +import net.fortuna.ical4j.model.property.ProdId; +import net.fortuna.ical4j.model.property.Transp; +import net.fortuna.ical4j.model.property.Version; +import net.fortuna.ical4j.model.property.XProperty; +import net.fortuna.ical4j.util.CompatibilityHints; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.net.URISyntaxException; +import java.text.ParseException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +@SuppressLint("NewApi") +public class SaveCalendar implements Injectable { + private static final String TAG = "ICS_SaveCalendar"; + + private final PropertyFactoryImpl mPropertyFactory = PropertyFactoryImpl.getInstance(); + private TimeZoneRegistry mTzRegistry; + private final Set mInsertedTimeZones = new HashSet<>(); + private final Set mFailedOrganisers = new HashSet<>(); + boolean mAllCols; + private final Context activity; + private final AndroidCalendar selectedCal; + private final AppPreferences preferences; + private final User user; + + // UID generation + long mUidMs = 0; + String mUidTail = null; + + private static final List STATUS_ENUM = Arrays.asList("TENTATIVE", "CONFIRMED", "CANCELLED"); + private static final List CLASS_ENUM = Arrays.asList(null, "CONFIDENTIAL", "PRIVATE", "PUBLIC"); + private static final List AVAIL_ENUM = Arrays.asList(null, "FREE", "BUSY-TENTATIVE"); + + private static final String[] EVENT_COLS = new String[]{ + Events._ID, Events.ORIGINAL_ID, Events.UID_2445, Events.TITLE, Events.DESCRIPTION, + Events.ORGANIZER, Events.EVENT_LOCATION, Events.STATUS, Events.ALL_DAY, Events.RDATE, + Events.RRULE, Events.DTSTART, Events.EVENT_TIMEZONE, Events.DURATION, Events.DTEND, + Events.EVENT_END_TIMEZONE, Events.ACCESS_LEVEL, Events.AVAILABILITY, Events.EXDATE, + Events.EXRULE, Events.CUSTOM_APP_PACKAGE, Events.CUSTOM_APP_URI, Events.HAS_ALARM + }; + + private static final String[] REMINDER_COLS = new String[]{ + Reminders.MINUTES, Reminders.METHOD + }; + + public SaveCalendar(Context activity, AndroidCalendar calendar, AppPreferences preferences, User user) { + this.activity = activity; // TODO rename + this.selectedCal = calendar; + this.preferences = preferences; + this.user = user; + } + + public void start() throws Exception { + mInsertedTimeZones.clear(); + mFailedOrganisers.clear(); + mAllCols = false; + + String file = selectedCal.mDisplayName + "_" + + DateFormat.format("yyyy-MM-dd_HH-mm-ss", java.util.Calendar.getInstance()).toString() + + ".ics"; + + File fileName = new File(activity.getCacheDir(), file); + + Log_OC.i(TAG, "Save id " + selectedCal.mIdStr + " to file " + fileName.getAbsolutePath()); + + String name = activity.getPackageName(); + String ver; + try { + ver = activity.getPackageManager().getPackageInfo(name, 0).versionName; + } catch (NameNotFoundException e) { + ver = "Unknown Build"; + } + + String prodId = "-//" + selectedCal.mOwner + "//iCal Import/Export " + ver + "//EN"; + Calendar cal = new Calendar(); + cal.getProperties().add(new ProdId(prodId)); + cal.getProperties().add(Version.VERSION_2_0); + cal.getProperties().add(Method.PUBLISH); + cal.getProperties().add(CalScale.GREGORIAN); + + if (selectedCal.mTimezone != null) { + // We don't write any events with floating times, but export this + // anyway so the default timezone for new events is correct when + // the file is imported into a system that supports it. + cal.getProperties().add(new XProperty("X-WR-TIMEZONE", selectedCal.mTimezone)); + } + + // query events + ContentResolver resolver = activity.getContentResolver(); + int numberOfCreatedUids = 0; + if (Events.UID_2445 != null) { + numberOfCreatedUids = ensureUids(activity, resolver, selectedCal); + } + boolean relaxed = true; // settings.getIcal4jValidationRelaxed(); // TODO is this option needed? default true + CompatibilityHints.setHintEnabled(CompatibilityHints.KEY_RELAXED_VALIDATION, relaxed); + List events = getEvents(resolver, selectedCal, cal); + + for (VEvent v : events) { + cal.getComponents().add(v); + } + + new CalendarOutputter().output(cal, new FileOutputStream(fileName)); + + Resources res = activity.getResources(); + String msg = res.getQuantityString(R.plurals.wrote_n_events_to, events.size(), events.size(), file); + if (numberOfCreatedUids > 0) { + msg += "\n" + res.getQuantityString(R.plurals.created_n_uids_to, numberOfCreatedUids, numberOfCreatedUids); + } + + // TODO replace DisplayUtils.showSnackMessage(activity, msg); + + upload(fileName); + } + + private int ensureUids(Context activity, ContentResolver resolver, AndroidCalendar cal) { + String[] cols = new String[]{Events._ID}; + String[] args = new String[]{cal.mIdStr}; + Map newUids = new HashMap<>(); + Cursor cur = resolver.query(Events.CONTENT_URI, cols, + Events.CALENDAR_ID + " = ? AND " + Events.UID_2445 + " IS NULL", args, null); + while (cur.moveToNext()) { + Long id = getLong(cur, Events._ID); + String uid = generateUid(); + newUids.put(id, uid); + } + for (Long id : newUids.keySet()) { + String uid = newUids.get(id); + Uri updateUri = ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, id); + ContentValues c = new ContentValues(); + c.put(Events.UID_2445, uid); + resolver.update(updateUri, c, null, null); + Log_OC.i(TAG, "Generated UID " + uid + " for event " + id); + } + return newUids.size(); + } + + private List getEvents(ContentResolver resolver, AndroidCalendar cal_src, Calendar cal_dst) { + String where = Events.CALENDAR_ID + "=?"; + String[] args = new String[]{cal_src.mIdStr}; + String sortBy = Events.CALENDAR_ID + " ASC"; + Cursor cur; + try { + cur = resolver.query(Events.CONTENT_URI, mAllCols ? null : EVENT_COLS, + where, args, sortBy); + } catch (Exception except) { + Log_OC.w(TAG, "Calendar provider is missing columns, continuing anyway"); + int n = 0; + for (n = 0; n < EVENT_COLS.length; ++n) { + if (EVENT_COLS[n] == null) { + Log_OC.e(TAG, "Invalid EVENT_COLS index " + Integer.toString(n)); + } + } + cur = resolver.query(Events.CONTENT_URI, null, where, args, sortBy); + } + + DtStamp timestamp = new DtStamp(); // Same timestamp for all events + + // Collect up events and add them after any timezones + List events = new ArrayList<>(); + while (cur.moveToNext()) { + VEvent e = convertFromDb(cur, cal_dst, timestamp); + if (e != null) { + events.add(e); + Log_OC.d(TAG, "Adding event: " + e.toString()); + } + } + cur.close(); + return events; + } + + private String calculateFileName(final String displayName) { + // Replace all non-alnum chars with '_' + String stripped = displayName.replaceAll("[^a-zA-Z0-9_-]", "_"); + // Replace repeated '_' with a single '_' + return stripped.replaceAll("(_)\\1{1,}", "$1"); + } + + private void getFileImpl(final String previousFile, final String suggestedFile, + final String[] result) { + + final EditText input = new EditText(activity); + input.setHint(R.string.destination_filename); + input.setText(previousFile); + input.selectAll(); + + final int ok = android.R.string.ok; + final int cancel = android.R.string.cancel; + final int suggest = R.string.suggest; + AlertDialog.Builder builder = new AlertDialog.Builder(activity); + AlertDialog dlg = builder.setIcon(R.mipmap.ic_launcher) + .setTitle(R.string.enter_destination_filename) + .setView(input) + .setPositiveButton(ok, new DialogInterface.OnClickListener() { + public void onClick(DialogInterface iface, int id) { + result[0] = input.getText().toString(); + } + }) + .setNeutralButton(suggest, new DialogInterface.OnClickListener() { + public void onClick(DialogInterface iface, int id) { + } + }) + .setNegativeButton(cancel, new DialogInterface.OnClickListener() { + public void onClick(DialogInterface iface, int id) { + result[0] = ""; + } + }) + .setOnCancelListener(new DialogInterface.OnCancelListener() { + public void onCancel(DialogInterface iface) { + result[0] = ""; + } + }) + .create(); + int state = WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE; + dlg.getWindow().setSoftInputMode(state); + dlg.show(); + // Overriding 'Suggest' here prevents it from closing the dialog + dlg.getButton(DialogInterface.BUTTON_NEUTRAL) + .setOnClickListener(new View.OnClickListener() { + public void onClick(View onClick) { + input.setText(suggestedFile); + input.setSelection(input.getText().length()); + } + }); + } + + private VEvent convertFromDb(Cursor cur, Calendar cal, DtStamp timestamp) { + Log_OC.d(TAG, "cursor: " + DatabaseUtils.dumpCurrentRowToString(cur)); + + if (hasStringValue(cur, Events.ORIGINAL_ID)) { + // FIXME: Support these edited instances + Log_OC.w(TAG, "Ignoring edited instance of a recurring event"); + return null; + } + + PropertyList l = new PropertyList(); + l.add(timestamp); + copyProperty(l, Property.UID, cur, Events.UID_2445); + + String summary = copyProperty(l, Property.SUMMARY, cur, Events.TITLE); + String description = copyProperty(l, Property.DESCRIPTION, cur, Events.DESCRIPTION); + + String organizer = getString(cur, Events.ORGANIZER); + if (!TextUtils.isEmpty(organizer)) { + // The check for mailto: here handles early versions of this code which + // incorrectly left it in the organizer column. + if (!organizer.startsWith("mailto:")) { + organizer = "mailto:" + organizer; + } + try { + l.add(new Organizer(organizer)); + } catch (URISyntaxException ignored) { + if (!mFailedOrganisers.contains(organizer)) { + Log_OC.e(TAG, "Failed to create mailTo for organizer " + organizer); + mFailedOrganisers.add(organizer); + } + } + } + + copyProperty(l, Property.LOCATION, cur, Events.EVENT_LOCATION); + copyEnumProperty(l, Property.STATUS, cur, Events.STATUS, STATUS_ENUM); + + boolean allDay = TextUtils.equals(getString(cur, Events.ALL_DAY), "1"); + boolean isTransparent; + DtEnd dtEnd = null; + + if (allDay) { + // All day event + isTransparent = true; + Date start = getDateTime(cur, Events.DTSTART, null, null); + Date end = getDateTime(cur, Events.DTEND, null, null); + l.add(new DtStart(new Date(start))); + + if (end != null) { + dtEnd = new DtEnd(new Date(end)); + } else { + dtEnd = new DtEnd(utcDateFromMs(start.getTime() + DateUtils.DAY_IN_MILLIS)); + } + + l.add(dtEnd); + } else { + // Regular or zero-time event. Start date must be a date-time + Date startDate = getDateTime(cur, Events.DTSTART, Events.EVENT_TIMEZONE, cal); + l.add(new DtStart(startDate)); + + // Use duration if we have one, otherwise end date + if (hasStringValue(cur, Events.DURATION)) { + isTransparent = getString(cur, Events.DURATION).equals("PT0S"); + if (!isTransparent) { + copyProperty(l, Property.DURATION, cur, Events.DURATION); + } + } else { + String endTz = Events.EVENT_END_TIMEZONE; + if (endTz == null) { + endTz = Events.EVENT_TIMEZONE; + } + Date end = getDateTime(cur, Events.DTEND, endTz, cal); + dtEnd = new DtEnd(end); + isTransparent = startDate.getTime() == end.getTime(); + if (!isTransparent) { + l.add(dtEnd); + } + } + } + + copyEnumProperty(l, Property.CLASS, cur, Events.ACCESS_LEVEL, CLASS_ENUM); + + int availability = getInt(cur, Events.AVAILABILITY); + if (availability > Events.AVAILABILITY_TENTATIVE) { + availability = -1; // Unknown/Invalid + } + + if (isTransparent) { + // This event is ordinarily transparent. If availability shows that its + // not free, then mark it opaque. + if (availability >= 0 && availability != Events.AVAILABILITY_FREE) { + l.add(Transp.OPAQUE); + } + + } else if (availability > Events.AVAILABILITY_BUSY) { + // This event is ordinarily busy but differs, so output a FREEBUSY + // period covering the time of the event + FreeBusy fb = new FreeBusy(); + fb.getParameters().add(new FbType(AVAIL_ENUM.get(availability))); + DateTime start = new DateTime(((DtStart) l.getProperty(Property.DTSTART)).getDate()); + + if (dtEnd != null) { + fb.getPeriods().add(new Period(start, new DateTime(dtEnd.getDate()))); + } else { + Duration d = (Duration) l.getProperty(Property.DURATION); + fb.getPeriods().add(new Period(start, d.getDuration())); + } + l.add(fb); + } + + copyProperty(l, Property.RRULE, cur, Events.RRULE); + copyProperty(l, Property.RDATE, cur, Events.RDATE); + copyProperty(l, Property.EXRULE, cur, Events.EXRULE); + copyProperty(l, Property.EXDATE, cur, Events.EXDATE); + if (TextUtils.isEmpty(getString(cur, Events.CUSTOM_APP_PACKAGE))) { + // Only copy URL if there is no app i.e. we probably imported it. + copyProperty(l, Property.URL, cur, Events.CUSTOM_APP_URI); + } + + VEvent e = new VEvent(l); + + if (getInt(cur, Events.HAS_ALARM) == 1) { + // Add alarms + + String s = summary == null ? (description == null ? "" : description) : summary; + Description desc = new Description(s); + + ContentResolver resolver = activity.getContentResolver(); + long eventId = getLong(cur, Events._ID); + Cursor alarmCur; + alarmCur = Reminders.query(resolver, eventId, mAllCols ? null : REMINDER_COLS); + while (alarmCur.moveToNext()) { + int mins = getInt(alarmCur, Reminders.MINUTES); + if (mins == -1) { + mins = 60; // FIXME: Get the real default + } + + // FIXME: We should support other types if possible + int method = getInt(alarmCur, Reminders.METHOD); + if (method == Reminders.METHOD_DEFAULT || method == Reminders.METHOD_ALERT) { + VAlarm alarm = new VAlarm(new Dur(0, 0, -mins, 0)); + alarm.getProperties().add(Action.DISPLAY); + alarm.getProperties().add(desc); + e.getAlarms().add(alarm); + } + } + alarmCur.close(); + } + + return e; + } + + private int getColumnIndex(Cursor cur, String dbName) { + return dbName == null ? -1 : cur.getColumnIndex(dbName); + } + + private String getString(Cursor cur, String dbName) { + int i = getColumnIndex(cur, dbName); + return i == -1 ? null : cur.getString(i); + } + + private long getLong(Cursor cur, String dbName) { + int i = getColumnIndex(cur, dbName); + return i == -1 ? -1 : cur.getLong(i); + } + + private int getInt(Cursor cur, String dbName) { + int i = getColumnIndex(cur, dbName); + return i == -1 ? -1 : cur.getInt(i); + } + + private boolean hasStringValue(Cursor cur, String dbName) { + int i = getColumnIndex(cur, dbName); + return i != -1 && !TextUtils.isEmpty(cur.getString(i)); + } + + private Date utcDateFromMs(long ms) { + // This date will be UTC provided the default false value of the iCal4j property + // "net.fortuna.ical4j.timezone.date.floating" has not been changed. + return new Date(ms); + } + + private boolean isUtcTimeZone(final String tz) { + if (TextUtils.isEmpty(tz)) { + return true; + } + final String utz = tz.toUpperCase(Locale.US); + return utz.equals("UTC") || utz.equals("UTC-0") || utz.equals("UTC+0") || utz.endsWith("/UTC"); + } + + private Date getDateTime(Cursor cur, String dbName, String dbTzName, Calendar cal) { + int i = getColumnIndex(cur, dbName); + if (i == -1 || cur.isNull(i)) { + Log_OC.e(TAG, "No valid " + dbName + " column found, index: " + Integer.toString(i)); + return null; + } + + if (cal == null) { + return utcDateFromMs(cur.getLong(i)); // Ignore timezone for date-only dates + } else if (dbTzName == null) { + Log_OC.e(TAG, "No valid tz " + dbName + " column given"); + } + + String tz = getString(cur, dbTzName); + final boolean isUtc = isUtcTimeZone(tz); + + DateTime dt = new DateTime(isUtc); + if (dt.isUtc() != isUtc) { + throw new RuntimeException("UTC mismatch after construction"); + } + dt.setTime(cur.getLong(i)); + if (dt.isUtc() != isUtc) { + throw new RuntimeException("UTC mismatch after setTime"); + } + + if (!isUtc) { + if (mTzRegistry == null) { + mTzRegistry = TimeZoneRegistryFactory.getInstance().createRegistry(); + if (mTzRegistry == null) { + throw new RuntimeException("Failed to create TZ registry"); + } + } + TimeZone t = mTzRegistry.getTimeZone(tz); + if (t == null) { + Log_OC.e(TAG, "Unknown TZ " + tz + ", assuming UTC"); + } else { + dt.setTimeZone(t); + if (!mInsertedTimeZones.contains(t)) { + cal.getComponents().add(t.getVTimeZone()); + mInsertedTimeZones.add(t); + } + } + } + return dt; + } + + private String copyProperty(PropertyList l, String evName, Cursor cur, String dbName) { + // None of the exceptions caught below should be able to be thrown AFAICS. + try { + String value = getString(cur, dbName); + if (value != null) { + Property p = mPropertyFactory.createProperty(evName); + p.setValue(value); + l.add(p); + return value; + } + } catch (IOException | URISyntaxException | ParseException ignored) { + } + return null; + } + + private void copyEnumProperty(PropertyList l, String evName, Cursor cur, String dbName, + List vals) { + // None of the exceptions caught below should be able to be thrown AFAICS. + try { + int i = getColumnIndex(cur, dbName); + if (i != -1 && !cur.isNull(i)) { + int value = (int) cur.getLong(i); + if (value >= 0 && value < vals.size() && vals.get(value) != null) { + Property p = mPropertyFactory.createProperty(evName); + p.setValue(vals.get(value)); + l.add(p); + } + } + } catch (IOException | URISyntaxException | ParseException ignored) { + } + } + + // TODO move this to some common place + private String generateUid() { + // Generated UIDs take the form -@nextcloud.com. + if (mUidTail == null) { + String uidPid = preferences.getUidPid(); + if (uidPid.length() == 0) { + uidPid = UUID.randomUUID().toString().replace("-", ""); + preferences.setUidPid(uidPid); + } + mUidTail = uidPid + "@nextcloud.com"; + } + + mUidMs = Math.max(mUidMs, System.currentTimeMillis()); + String uid = mUidMs + mUidTail; + mUidMs++; + + return uid; + } + + private void upload(File file) { + String backupFolder = activity.getResources().getString(R.string.calendar_backup_folder) + + OCFile.PATH_SEPARATOR; + + Request request = new UploadRequest.Builder(user, file.getAbsolutePath(), backupFolder + file.getName()) + .setFileSize(file.length()) + .setNameConflicPolicy(NameCollisionPolicy.RENAME) + .setCreateRemoteFolder(true) + .setTrigger(UploadTrigger.USER) + .setPostAction(PostUploadAction.MOVE_TO_APP) + .setRequireWifi(false) + .setRequireCharging(false) + .build(); + + TransferManagerConnection connection = new TransferManagerConnection(activity, user); + connection.enqueue(request); + } +} diff --git a/src/main/res/drawable/nav_contacts.xml b/src/main/res/drawable/nav_contacts.xml deleted file mode 100644 index 3ca6e6ec7e..0000000000 --- a/src/main/res/drawable/nav_contacts.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - diff --git a/src/main/res/layout/backup_fragment.xml b/src/main/res/layout/backup_fragment.xml new file mode 100644 index 0000000000..a441e1d552 --- /dev/null +++ b/src/main/res/layout/backup_fragment.xml @@ -0,0 +1,143 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/res/layout/backup_list_item.xml b/src/main/res/layout/backup_list_item.xml new file mode 100644 index 0000000000..ed1d14425f --- /dev/null +++ b/src/main/res/layout/backup_list_item.xml @@ -0,0 +1,32 @@ + + + + + + diff --git a/src/main/res/layout/backup_list_item_header.xml b/src/main/res/layout/backup_list_item_header.xml new file mode 100644 index 0000000000..e81878105a --- /dev/null +++ b/src/main/res/layout/backup_list_item_header.xml @@ -0,0 +1,45 @@ + + + + + + + + diff --git a/src/main/res/layout/contactlist_fragment.xml b/src/main/res/layout/backuplist_fragment.xml similarity index 61% rename from src/main/res/layout/contactlist_fragment.xml rename to src/main/res/layout/backuplist_fragment.xml index 92eae883a9..e8394f3d7d 100644 --- a/src/main/res/layout/contactlist_fragment.xml +++ b/src/main/res/layout/backuplist_fragment.xml @@ -18,64 +18,71 @@ License along with this program. If not, see . --> + android:animateLayoutChanges="true"> - + android:choiceMode="multipleChoice" + android:scrollbarStyle="outsideOverlay" + android:scrollbars="vertical" + android:layout_above="@+id/contactlist_restore_selected_container" /> - + + + android:layout_height="@dimen/uploader_list_separator_height" + android:contentDescription="@null" + android:src="@drawable/uploader_list_separator" /> - + android:text="@string/restore_selected" /> - - - - - + android:orientation="vertical" + app:layout_constraintTop_toTopOf="parent"> + + + + + + + + + diff --git a/src/main/res/layout/calendarlist_list_item.xml b/src/main/res/layout/calendarlist_list_item.xml new file mode 100644 index 0000000000..1835d03b3b --- /dev/null +++ b/src/main/res/layout/calendarlist_list_item.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + diff --git a/src/main/res/layout/contactlist_list_item.xml b/src/main/res/layout/contactlist_list_item.xml index 148581c2c1..d2a32426ef 100644 --- a/src/main/res/layout/contactlist_list_item.xml +++ b/src/main/res/layout/contactlist_list_item.xml @@ -23,7 +23,7 @@ android:layout_height="@dimen/standard_list_item_size"> - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/main/res/menu/partial_drawer_entries.xml b/src/main/res/menu/partial_drawer_entries.xml index 2a3c2a0f4d..b8da994af9 100644 --- a/src/main/res/menu/partial_drawer_entries.xml +++ b/src/main/res/menu/partial_drawer_entries.xml @@ -102,11 +102,6 @@ - true true - - true + /.Contacts-Backup -1 + /.Calendar-Backup true @@ -58,7 +58,6 @@ true false false - false true diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index aff311ed0b..0f7e64aee3 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -51,7 +51,7 @@ Server address for the account could not be resolved for DAVx5 (formerly known as DAVdroid) Neither F-Droid nor Google Play is installed Calendar & contacts sync set up - Daily backup of your contacts + Daily backup of your calendar & contacts Manage folders for auto upload Help Recommend to friend @@ -187,6 +187,22 @@ Failed to copy %1$d file from the %2$s folder into Failed to copy %1$d files from the %2$s folder into + + Wrote %1$d event to %2$s + Wrote %1$d events to %2$s + + + Created %1$d fresh UID + Created %1$d fresh UIDs + + + Processed %d entry. + Processed %d entries. + + + Found %d duplicate entry. + Found %d duplicate entries. + As of version 1.3.16, files uploaded from this device are copied into the local %1$s folder to prevent data loss when a single file is synced with multiple accounts.\n\nDue to this change, all files uploaded with earlier versions of this app were copied into the %2$s folder. However, an error prevented the completion of this operation during account synchronization. You may either leave the file(s) as is and delete the link to %3$s, or move the file(s) into the %1$s folder and retain the link to %4$s.\n\nListed below are the local file(s), and the remote file(s) in %5$s they were linked to. The folder %1$s does not exist anymore Move all @@ -571,21 +587,15 @@ No events like additions, changes and shares yet. About - Back up contacts - Restore contacts + Restore contacts & calendar Back up now - Automatic backup - Last backup - Permission to read contact list needed - Restore selected contacts - Choose account to import No permission given, nothing imported. - Choose date - never + Restore backup No file found Could not find your last backup! Backup scheduled and will start shortly Import scheduled and will start shortly + Contacts & calendar backup Log out No app found to set a picture with @@ -961,5 +971,20 @@ Please choose a template and enter a file name. Strict mode: no HTTP connection allowed! Fullscreen + Destination filename + Suggest + Enter destination filename + Did not check for duplicates. + Last backup: %1$s + Error choosing date + Restore selected + Calendars + Contacts + Data to back up + Calendar + Backup settings + Daily backup + %1$s\n%2$s + No calendar exists More diff --git a/src/main/res/xml/preferences.xml b/src/main/res/xml/preferences.xml index 7c7d73ad41..aeec72b03b 100644 --- a/src/main/res/xml/preferences.xml +++ b/src/main/res/xml/preferences.xml @@ -68,9 +68,9 @@ android:key="calendar_contacts" android:summary="@string/prefs_calendar_contacts_summary" /> + android:title="@string/backup_title" + android:key="backup" + android:summary="@string/prefs_daily_backup_summary" />