Browse Source

Merge pull request #9015 from nextcloud/calendarSync

Calendar backup/import
Tobias Kaminsky 3 năm trước cách đây
mục cha
commit
215c1c90db
57 tập tin đã thay đổi với 4262 bổ sung1112 xóa
  1. 6 0
      build.gradle
  2. 0 1
      drawable_resources/nav_contacts.svg
  3. 1 0
      lint.xml
  4. BIN
      screenshots/gplay/debug/com.owncloud.android.ui.fragment.BackupListFragmentIT_showCalendarAndContactsList.png
  5. BIN
      screenshots/gplay/debug/com.owncloud.android.ui.fragment.BackupListFragmentIT_showCalendarList.png
  6. BIN
      screenshots/gplay/debug/com.owncloud.android.ui.fragment.BackupListFragmentIT_showContactList.png
  7. BIN
      screenshots/gplay/debug/com.owncloud.android.ui.fragment.BackupListFragmentIT_showLoading.png
  8. 1 1
      scripts/analysis/findbugs-results.txt
  9. 1 1
      scripts/analysis/lint-results.txt
  10. 1 0
      spotbugs-filter.xml
  11. 131 0
      src/androidTest/assets/calendar.ics
  12. 113 0
      src/androidTest/java/com/owncloud/android/ui/fragment/BackupListFragmentIT.kt
  13. 0 49
      src/androidTest/java/com/owncloud/android/ui/fragment/ContactListFragmentIT.kt
  14. 2 0
      src/main/AndroidManifest.xml
  15. 4 4
      src/main/java/com/nextcloud/client/di/ComponentsModule.java
  16. 21 0
      src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt
  17. 36 0
      src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt
  18. 58 2
      src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt
  19. 80 0
      src/main/java/com/nextcloud/client/jobs/CalendarBackupWork.kt
  20. 77 0
      src/main/java/com/nextcloud/client/jobs/CalendarImportWork.kt
  21. 7 4
      src/main/java/com/nextcloud/client/jobs/ContactsImportWork.kt
  22. 8 0
      src/main/java/com/nextcloud/client/preferences/AppPreferences.java
  23. 26 2
      src/main/java/com/nextcloud/client/preferences/AppPreferencesImpl.java
  24. 7 7
      src/main/java/com/owncloud/android/ui/activity/ContactsPreferenceActivity.java
  25. 0 5
      src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java
  26. 7 13
      src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java
  27. 81 0
      src/main/java/com/owncloud/android/ui/asynctasks/LoadContactsTask.java
  28. 219 111
      src/main/java/com/owncloud/android/ui/fragment/contactsbackup/BackupFragment.java
  29. 403 0
      src/main/java/com/owncloud/android/ui/fragment/contactsbackup/BackupListAdapter.kt
  30. 490 0
      src/main/java/com/owncloud/android/ui/fragment/contactsbackup/BackupListFragment.java
  31. 49 0
      src/main/java/com/owncloud/android/ui/fragment/contactsbackup/BackupListHeaderViewHolder.kt
  32. 28 0
      src/main/java/com/owncloud/android/ui/fragment/contactsbackup/BackupListItemViewHolder.kt
  33. 79 0
      src/main/java/com/owncloud/android/ui/fragment/contactsbackup/CalendarItemViewHolder.java
  34. 43 0
      src/main/java/com/owncloud/android/ui/fragment/contactsbackup/ContactItemViewHolder.java
  35. 250 0
      src/main/java/com/owncloud/android/ui/fragment/contactsbackup/ContactListAdapter.java
  36. 0 735
      src/main/java/com/owncloud/android/ui/fragment/contactsbackup/ContactListFragment.java
  37. 68 0
      src/main/java/com/owncloud/android/ui/fragment/contactsbackup/ContactsAccount.java
  38. 37 0
      src/main/java/com/owncloud/android/ui/fragment/contactsbackup/VCardComparator.java
  39. 8 0
      src/main/java/com/owncloud/android/utils/MimeTypeUtil.java
  40. 2 1
      src/main/java/com/owncloud/android/utils/PermissionUtil.java
  41. 160 0
      src/main/java/third_parties/sufficientlysecure/AndroidCalendar.java
  42. 96 0
      src/main/java/third_parties/sufficientlysecure/CalendarSource.java
  43. 30 0
      src/main/java/third_parties/sufficientlysecure/DuplicateHandlingEnum.java
  44. 642 0
      src/main/java/third_parties/sufficientlysecure/ProcessVEvent.java
  45. 620 0
      src/main/java/third_parties/sufficientlysecure/SaveCalendar.java
  46. 0 25
      src/main/res/drawable/nav_contacts.xml
  47. 143 0
      src/main/res/layout/backup_fragment.xml
  48. 32 0
      src/main/res/layout/backup_list_item.xml
  49. 45 0
      src/main/res/layout/backup_list_item_header.xml
  50. 39 32
      src/main/res/layout/backuplist_fragment.xml
  51. 69 0
      src/main/res/layout/calendarlist_list_item.xml
  52. 2 2
      src/main/res/layout/contactlist_list_item.xml
  53. 0 96
      src/main/res/layout/contacts_backup_fragment.xml
  54. 0 5
      src/main/res/menu/partial_drawer_entries.xml
  55. 2 3
      src/main/res/values/setup.xml
  56. 35 10
      src/main/res/values/strings.xml
  57. 3 3
      src/main/res/xml/preferences.xml

+ 6 - 0
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"

+ 0 - 1
drawable_resources/nav_contacts.svg

@@ -1 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path d="M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,14C16.42,14 20,15.79 20,18V20H4V18C4,15.79 7.58,14 12,14Z" /></svg>

+ 1 - 0
lint.xml

@@ -43,6 +43,7 @@
 
     <issue id="Typos">
         <ignore path="**/values-**/strings.xml" />
+        <ignore path="**/values/setup.xml" />
     </issue>
 
     <issue id="TrustAllX509TrustManager">

BIN
screenshots/gplay/debug/com.owncloud.android.ui.fragment.BackupListFragmentIT_showCalendarAndContactsList.png


BIN
screenshots/gplay/debug/com.owncloud.android.ui.fragment.BackupListFragmentIT_showCalendarList.png


BIN
screenshots/gplay/debug/com.owncloud.android.ui.fragment.BackupListFragmentIT_showContactList.png


BIN
screenshots/gplay/debug/com.owncloud.android.ui.fragment.BackupListFragmentIT_showLoading.png


+ 1 - 1
scripts/analysis/findbugs-results.txt

@@ -1 +1 @@
-355
+349

+ 1 - 1
scripts/analysis/lint-results.txt

@@ -1,2 +1,2 @@
 DO NOT TOUCH; GENERATED BY DRONE
-      <span class="mdl-layout-title">Lint Report: 1 error and 112 warnings</span>
+      <span class="mdl-layout-title">Lint Report: 1 error and 111 warnings</span>

+ 1 - 0
spotbugs-filter.xml

@@ -45,6 +45,7 @@
 			<Package name="~butterknife\..*" />
 			<Package name="~de\.cotech\..*" />
 			<Package name="~pl\.droidsonroids\..*" />
+            <Package name="~third_parties\..*" />
 		</Or>
 	</Match>
 	 <Match>

+ 131 - 0
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

+ 113 - 0
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 <https://www.gnu.org/licenses/>.
+ */
+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)
+    }
+}

+ 0 - 49
src/androidTest/java/com/owncloud/android/ui/fragment/ContactListFragmentIT.kt

@@ -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 <https://www.gnu.org/licenses/>.
- */
-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)
-    }
-}

+ 2 - 0
src/main/AndroidManifest.xml

@@ -33,6 +33,8 @@
 
     <uses-permission android:name="android.permission.READ_CONTACTS" />
     <uses-permission android:name="android.permission.WRITE_CONTACTS" />
+    <uses-permission android:name="android.permission.READ_CALENDAR" />
+    <uses-permission android:name="android.permission.WRITE_CALENDAR" />
 
     <!-- USE_CREDENTIALS, MANAGE_ACCOUNTS and AUTHENTICATE_ACCOUNTS are needed for API <= 22.
         In API >= 23 they do not exist anymore -->

+ 4 - 4
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();

+ 21 - 0
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,

+ 36 - 0
src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt

@@ -71,6 +71,31 @@ interface BackgroundJobManager {
      */
     fun startImmediateContactsBackup(user: User): LiveData<JobInfo?>
 
+    /**
+     * 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<JobInfo?>
+
     /**
      * 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<JobInfo?>
 
+    /**
+     * 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<String, Int>): LiveData<JobInfo?>
+
     fun schedulePeriodicFilesSyncJob()
     fun startImmediateFilesSyncJob(skipCustomFolders: Boolean = false, overridePowerSaving: Boolean = false)
     fun scheduleOfflineSync()

+ 58 - 2
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<String, Int>): LiveData<JobInfo?> {
+
+        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<JobInfo?> {
         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<JobInfo?> {
+        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,

+ 80 - 0
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 <https://www.gnu.org/licenses/>.
+ */
+
+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()
+    }
+}

+ 77 - 0
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 <hello@ezaquarii.com>
+ *
+ * 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 <http://www.gnu.org/licenses/>.
+ */
+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<String>()
+        val calendars = inputData.keyValueMap as Map<String, AndroidCalendar>
+
+        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()
+    }
+}

+ 7 - 4
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 {

+ 8 - 0
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);
 }

+ 26 - 2
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);

+ 7 - 7
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();

+ 0 - 5
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) {

+ 7 - 13
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;
+            });
         }
     }
 

+ 81 - 0
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 <https://www.gnu.org/licenses/>.
+ */
+
+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<Void, Void, Boolean> {
+    private final WeakReference<BackupListFragment> backupListFragmentWeakReference;
+    private final OCFile ocFile;
+    private final List<VCard> 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);
+        }
+    }
+}

+ 219 - 111
src/main/java/com/owncloud/android/ui/fragment/contactsbackup/ContactsBackupFragment.java → 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<User> 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<User> 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<OCFile> 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<OCFile>() {
-            @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<OCFile> 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<OCFile> 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<OCFile> 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<OCFile> 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<OCFile> 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();
         }
     }
 }

+ 403 - 0
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 <https://www.gnu.org/licenses/>.
+ */
+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<Int> = HashSet(),
+    private val checkedCalendars: HashMap<String, Int> = HashMap(),
+    val backupListFragment: BackupListFragment,
+    val context: Context
+) : SectionedRecyclerViewAdapter<SectionedViewHolder>() {
+    private val calendarFiles = arrayListOf<OCFile>()
+    private val contacts = arrayListOf<VCard>()
+    private var availableContactAccounts = listOf<ContactsAccount>()
+
+    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<VCard>) {
+        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<Drawable>() {
+                override fun onResourceReady(resource: Drawable?, glideAnimation: GlideAnimation<in Drawable>?) {
+                    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<String> {
+        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<String, Int> {
+        return checkedCalendars
+    }
+
+    fun hasCalendarEntry(): Boolean {
+        return calendarFiles.isNotEmpty()
+    }
+
+    @Suppress("NestedBlockDepth", "TooGenericExceptionCaught")
+    private fun getAccountForImport(): List<ContactsAccount> {
+        val contactsAccounts = ArrayList<ContactsAccount>()
+
+        // 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
+    }
+}

+ 490 - 0
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 <hello@ezaquarii.com>
+ * <p>
+ * 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.
+ * <p>
+ * 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.
+ * <p>
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+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<VCard> vCards = new ArrayList<>();
+    private final List<OCFile> 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<String, Integer> 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<Integer> 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<VCard> 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;
+    }
+}

+ 49 - 0
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 <https://www.gnu.org/licenses/>.
+ */
+
+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<ContactsAccount?>(
+        context,
+        android.R.layout.simple_spinner_dropdown_item,
+        ArrayList()
+    )
+
+    init {
+        binding.spinner.adapter = adapter
+    }
+
+    fun setContactsAccount(accounts: List<ContactsAccount>) {
+        adapter.clear()
+        adapter.addAll(accounts)
+    }
+}

+ 28 - 0
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 <https://www.gnu.org/licenses/>.
+ */
+
+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)

+ 79 - 0
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 <https://www.gnu.org/licenses/>.
+ */
+
+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<AndroidCalendar> 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<AndroidCalendar> 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);
+        }
+    }
+}

+ 43 - 0
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 <https://www.gnu.org/licenses/>.
+ */
+
+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);
+    }
+}

+ 250 - 0
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 <https://www.gnu.org/licenses/>.
+ */
+
+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<ContactItemViewHolder> {
+    private static final int SINGLE_SELECTION = 1;
+
+    private List<VCard> vCards;
+    private Set<Integer> checkedVCards;
+
+    private Context context;
+
+    private UserAccountManager accountManager;
+    private ClientFactory clientFactory;
+
+    ContactListAdapter(UserAccountManager accountManager, ClientFactory clientFactory, Context context,
+                       List<VCard> vCards) {
+        this.vCards = vCards;
+        this.context = context;
+        this.checkedVCards = new HashSet<>();
+        this.accountManager = accountManager;
+        this.clientFactory = clientFactory;
+    }
+
+    ContactListAdapter(UserAccountManager accountManager,
+                       Context context,
+                       List<VCard> vCards,
+                       Set<Integer> 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<VCard> 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<Drawable>() {
+                @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;
+    }
+}

+ 0 - 735
src/main/java/com/owncloud/android/ui/fragment/contactsbackup/ContactListFragment.java

@@ -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 <hello@ezaquarii.com>
- * <p>
- * 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.
- * <p>
- * 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.
- * <p>
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-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<VCard> 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<Integer> 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<ContactsAccount> 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<VCard> {
-        @Override
-        public int compare(VCard o1, VCard o2) {
-            String contac1 = getDisplayName(o1);
-            String contac2 = getDisplayName(o2);
-
-            return contac1.compareToIgnoreCase(contac2);
-        }
-
-
-    }
-
-    private AsyncTask<Void, Void, Boolean> loadContactsTask = new AsyncTask<Void, Void, Boolean>() {
-
-        @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<ContactListFragment.ContactItemViewHolder> {
-    private static final int SINGLE_SELECTION = 1;
-
-    private List<VCard> vCards;
-    private Set<Integer> checkedVCards;
-
-    private Context context;
-
-    private UserAccountManager accountManager;
-    private ClientFactory clientFactory;
-
-    ContactListAdapter(UserAccountManager accountManager, ClientFactory clientFactory, Context context,
-                       List<VCard> vCards) {
-        this.vCards = vCards;
-        this.context = context;
-        this.checkedVCards = new HashSet<>();
-        this.accountManager = accountManager;
-        this.clientFactory = clientFactory;
-    }
-
-    ContactListAdapter(UserAccountManager accountManager,
-                       Context context,
-                       List<VCard> vCards,
-                       Set<Integer> 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<VCard> 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<Drawable>() {
-                @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();
-    }
-
-}

+ 68 - 0
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 <https://www.gnu.org/licenses/>.
+ */
+
+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;
+    }
+}

+ 37 - 0
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 <https://www.gnu.org/licenses/>.
+ */
+
+package com.owncloud.android.ui.fragment.contactsbackup;
+
+import java.util.Comparator;
+
+import ezvcard.VCard;
+
+public class VCardComparator implements Comparator<VCard> {
+    @Override
+    public int compare(VCard o1, VCard o2) {
+        String contact1 = BackupListFragment.getDisplayName(o1);
+        String contact2 = BackupListFragment.getDisplayName(o2);
+
+        return contact1.compareToIgnoreCase(contact2);
+    }
+}

+ 8 - 0
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);
     }

+ 2 - 1
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

+ 160 - 0
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 <https://www.gnu.org/licenses/>.
+ */
+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<AndroidCalendar> 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<AndroidCalendar> 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);
+    }
+}

+ 96 - 0
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 <https://www.gnu.org/licenses/>.
+ */
+
+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;
+    }
+}

+ 30 - 0
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 <https://www.gnu.org/licenses/>.
+ */
+
+package third_parties.sufficientlysecure;
+
+public enum DuplicateHandlingEnum {
+    DUP_REPLACE,
+    DUP_REPLACE_ANY,
+    DUP_IGNORE,
+    DUP_DONT_CHECK,
+}

+ 642 - 0
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 <dominik@dominikschuermann.de>
+ *  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 <http://www.gnu.org/licenses/>.
+ */
+
+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<Integer> 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<Integer> getReminders(List<Integer> 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<Integer> 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<Integer> 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<String> 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<String> 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("<non-null>") && got != null) {
+            got = "<non-null>"; // Sentinel for testing present and non-null
+        }
+        if (got == null) {
+            got = "<null>"; // 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<Integer> 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 <ms>-<uuid>@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;
+    }
+}

+ 620 - 0
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 <dominik@dominikschuermann.de>
+ *  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 <http://www.gnu.org/licenses/>.
+ */
+
+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<TimeZone> mInsertedTimeZones = new HashSet<>();
+    private final Set<String> 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<String> STATUS_ENUM = Arrays.asList("TENTATIVE", "CONFIRMED", "CANCELLED");
+    private static final List<String> CLASS_ENUM = Arrays.asList(null, "CONFIDENTIAL", "PRIVATE", "PUBLIC");
+    private static final List<String> 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<VEvent> 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<Long, String> 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<VEvent> 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<VEvent> 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<String> 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 <ms>-<uuid>@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);
+    }
+}

+ 0 - 25
src/main/res/drawable/nav_contacts.xml

@@ -1,25 +0,0 @@
-<!--
-  Nextcloud Android client application
-
-  Copyright (C) 2020 Nextcloud.
-
-  This program is free software; you can redistribute it and/or
-  modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
-  License as published by the Free Software Foundation; either
-  version 3 of the License, or 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 <http://www.gnu.org/licenses/>.
-
-  Icon provided by Android Material Library in Apache License 2.0
--->
-<vector android:height="24dp" android:tint="#757575"
-    android:viewportHeight="24.0" android:viewportWidth="24.0"
-    android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
-    <path android:fillColor="#FF000000" android:pathData="M12,12c2.21,0 4,-1.79 4,-4s-1.79,-4 -4,-4 -4,1.79 -4,4 1.79,4 4,4zM12,14c-2.67,0 -8,1.34 -8,4v2h16v-2c0,-2.66 -5.33,-4 -8,-4z"/>
-</vector>

+ 143 - 0
src/main/res/layout/backup_fragment.xml

@@ -0,0 +1,143 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  Nextcloud Android client application
+
+  Copyright (C) 2017 Tobias Kaminsky
+  Copyright (C) 2017 Nextcloud.
+
+  This program is free software; you can redistribute it and/or
+  modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
+  License as published by the Free Software Foundation; either
+  version 3 of the License, or 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 <http://www.gnu.org/licenses/>.
+-->
+<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:id="@+id/contacts_linear_layout"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent">
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_margin="@dimen/standard_margin"
+        android:orientation="vertical">
+
+        <TextView
+            android:id="@+id/data_to_back_up_title"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:text="@string/data_to_back_up"
+            android:textStyle="bold" />
+
+        <androidx.appcompat.widget.SwitchCompat
+            android:id="@+id/contacts"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:minHeight="48dp"
+            android:text="@string/contacts"
+            android:textColor="@color/text_color"
+            android:textSize="@dimen/two_line_primary_text_size" />
+
+        <androidx.appcompat.widget.SwitchCompat
+            android:id="@+id/calendar"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:minHeight="48dp"
+            android:text="@string/calendar"
+            android:textColor="@color/text_color"
+            android:textSize="@dimen/two_line_primary_text_size" />
+
+        <LinearLayout
+            android:layout_width="match_parent"
+            android:layout_height="match_parent"
+            android:orientation="vertical">
+
+            <TextView
+                android:id="@+id/backup_settings_title"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:text="@string/backup_settings"
+                android:textStyle="bold" />
+
+            <RelativeLayout
+                android:layout_width="match_parent"
+                android:layout_height="48dp">
+
+                <androidx.appcompat.widget.SwitchCompat
+                    android:id="@+id/daily_backup"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:layout_alignParentEnd="true"
+                    android:layout_centerVertical="true"
+                    android:textColor="@color/text_color"
+                    android:textSize="@dimen/two_line_primary_text_size" />
+
+                <LinearLayout
+                    android:layout_width="match_parent"
+                    android:layout_height="match_parent"
+                    android:layout_toStartOf="@id/daily_backup"
+                    android:orientation="vertical"
+                    android:gravity="center_vertical">
+
+                    <TextView
+                        android:layout_width="wrap_content"
+                        android:layout_height="wrap_content"
+                        android:text="@string/daily_backup"
+                        android:textColor="@color/text_color"
+                        android:textSize="14sp" />
+
+                    <TextView
+                        android:id="@+id/last_backup_with_date"
+                        android:layout_width="wrap_content"
+                        android:layout_height="wrap_content"
+                        android:text="@string/last_backup"
+                        android:textSize="12sp" />
+
+                </LinearLayout>
+
+            </RelativeLayout>
+
+        </LinearLayout>
+
+        <LinearLayout
+            android:layout_width="match_parent"
+            android:layout_height="match_parent"
+            android:layout_margin="@dimen/standard_margin"
+            android:orientation="horizontal">
+
+            <com.google.android.material.button.MaterialButton
+                android:id="@+id/backup_now"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_marginStart="@dimen/standard_half_margin"
+                android:layout_marginEnd="@dimen/standard_half_margin"
+                android:layout_weight="1"
+                android:text="@string/contacts_backup_button"
+                android:theme="@style/Button.Primary"
+                app:cornerRadius="@dimen/button_corner_radius" />
+
+            <com.google.android.material.button.MaterialButton
+                android:id="@+id/contacts_datepicker"
+                style="@style/OutlinedButton"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_marginStart="@dimen/standard_half_margin"
+                android:layout_marginEnd="@dimen/standard_half_margin"
+                android:layout_weight="1"
+                android:text="@string/restore_backup"
+                android:visibility="gone"
+                app:cornerRadius="@dimen/button_corner_radius"
+                tools:visibility="visible" />
+        </LinearLayout>
+
+    </LinearLayout>
+
+</ScrollView>

+ 32 - 0
src/main/res/layout/backup_list_item.xml

@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~
+  ~ 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 <https://www.gnu.org/licenses/>.
+  -->
+
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:orientation="vertical">
+
+    <TextView
+        android:id="@+id/name"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content" />
+</LinearLayout>

+ 45 - 0
src/main/res/layout/backup_list_item_header.xml

@@ -0,0 +1,45 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~
+  ~ 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 <https://www.gnu.org/licenses/>.
+  -->
+
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:orientation="vertical">
+
+    <TextView
+        android:id="@+id/name"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_margin="@dimen/standard_margin"
+        android:textAppearance="?android:attr/textAppearanceListItem" />
+
+    <Spinner
+        android:id="@+id/spinner"
+        android:layout_marginStart="@dimen/standard_margin"
+        android:layout_marginEnd="@dimen/standard_margin"
+        android:layout_width="match_parent"
+        android:layout_height="0dp"
+        android:layout_weight="1"
+        android:visibility="gone"
+        tools:visibility="visible" />
+</LinearLayout>

+ 39 - 32
src/main/res/layout/contactlist_fragment.xml → src/main/res/layout/backuplist_fragment.xml

@@ -18,64 +18,71 @@
   License along with this program. If not, see <http://www.gnu.org/licenses/>.
 -->
 <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
     android:layout_width="match_parent"
     android:layout_height="match_parent"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:animateLayoutChanges="true"
-    android:orientation="vertical">
+    android:animateLayoutChanges="true">
 
-    <LinearLayout
+    <androidx.recyclerview.widget.RecyclerView
+        android:id="@android:id/list"
         android:layout_width="match_parent"
         android:layout_height="match_parent"
-        android:orientation="vertical">
+        android:choiceMode="multipleChoice"
+        android:scrollbarStyle="outsideOverlay"
+        android:scrollbars="vertical"
+        android:layout_above="@+id/contactlist_restore_selected_container" />
 
-        <androidx.recyclerview.widget.RecyclerView
-            android:id="@+id/contactlist_recyclerview"
+    <LinearLayout
+        android:id="@+id/contactlist_restore_selected_container"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:background="@color/bg_default"
+        android:orientation="vertical"
+        android:visibility="gone"
+        tools:visibility="visible"
+        android:layout_alignParentBottom="true">
+
+        <ImageView
             android:layout_width="match_parent"
-            android:layout_height="0dp"
-            android:layout_weight="1"
-            android:choiceMode="multipleChoice"
-            tools:listitem="@layout/contactlist_list_item" />
+            android:layout_height="@dimen/uploader_list_separator_height"
+            android:contentDescription="@null"
+            android:src="@drawable/uploader_list_separator" />
 
-        <LinearLayout
-            android:id="@+id/contactlist_restore_selected_container"
+        <com.google.android.material.button.MaterialButton
+            android:id="@+id/restore_selected"
+            style="@style/Button.Borderless"
             android:layout_width="match_parent"
             android:layout_height="wrap_content"
-            android:background="@color/bg_default"
-            android:orientation="vertical"
-            android:visibility="gone">
-
-            <ImageView
-                android:layout_width="match_parent"
-                android:layout_height="@dimen/uploader_list_separator_height"
-                android:src="@drawable/uploader_list_separator"
-                android:contentDescription="@null"/>
-
-            <com.google.android.material.button.MaterialButton
-                android:id="@+id/contactlist_restore_selected"
-                style="@style/Button.Borderless"
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:text="@string/contaclist_restore_selected" />
-
-        </LinearLayout>
+            android:text="@string/restore_selected" />
+
     </LinearLayout>
 
     <LinearLayout
         android:id="@+id/loading_list_container"
         android:layout_width="match_parent"
         android:layout_height="wrap_content"
-        android:orientation="vertical">
+        android:orientation="vertical"
+        app:layout_constraintTop_toTopOf="parent">
 
         <include layout="@layout/contactlist_list_item_shimmer" />
+
         <include layout="@layout/contactlist_list_item_shimmer" />
+
         <include layout="@layout/contactlist_list_item_shimmer" />
+
         <include layout="@layout/contactlist_list_item_shimmer" />
+
         <include layout="@layout/contactlist_list_item_shimmer" />
+
         <include layout="@layout/contactlist_list_item_shimmer" />
+
         <include layout="@layout/contactlist_list_item_shimmer" />
+
         <include layout="@layout/contactlist_list_item_shimmer" />
+
         <include layout="@layout/contactlist_list_item_shimmer" />
+
         <include layout="@layout/contactlist_list_item_shimmer" />
     </LinearLayout>
 </RelativeLayout>

+ 69 - 0
src/main/res/layout/calendarlist_list_item.xml

@@ -0,0 +1,69 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  Nextcloud Android client application
+
+  Copyright (C) 2021 Tobias Kaminsky
+  Copyright (C) 2021 Nextcloud
+
+  This program is free software; you can redistribute it and/or
+  modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
+  License as published by the Free Software Foundation; either
+  version 3 of the License, or 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 <http://www.gnu.org/licenses/>.
+-->
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content">
+
+    <ImageView
+        android:id="@+id/icon"
+        android:layout_width="@dimen/contactlist_item_icon_layout_width"
+        android:layout_height="@dimen/contactlist_item_icon_layout_height"
+        android:layout_margin="@dimen/standard_margin"
+        android:contentDescription="@string/contactlist_item_icon"
+        android:scaleType="centerCrop"
+        android:src="@drawable/file_calendar"
+        app:srcCompat="@drawable/file_calendar"
+        tools:srcCompat="@drawable/file_calendar"
+        android:layout_gravity="center_vertical" />
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:orientation="vertical"
+        android:gravity="start">
+
+        <CheckedTextView
+            android:id="@+id/name"
+            android:layout_width="match_parent"
+            android:layout_height="@dimen/standard_list_item_size"
+            android:layout_marginEnd="@dimen/standard_margin"
+            android:layout_weight="1"
+            android:checkMark="?android:attr/listChoiceIndicatorMultiple"
+            android:ellipsize="marquee"
+            android:gravity="center_vertical|start"
+            android:maxLines="2"
+            android:paddingStart="8dp"
+            android:paddingEnd="0dp"
+            android:textAppearance="?android:attr/textAppearanceListItem" />
+
+        <Spinner
+            android:id="@+id/spinner"
+            android:layout_width="match_parent"
+            android:layout_height="@dimen/standard_list_item_size"
+            android:layout_weight="1"
+            android:visibility="gone"
+            android:gravity="start"
+            tools:visibility="visible" />
+
+    </LinearLayout>
+
+</LinearLayout>

+ 2 - 2
src/main/res/layout/contactlist_list_item.xml

@@ -23,7 +23,7 @@
               android:layout_height="@dimen/standard_list_item_size">
 
     <ImageView
-        android:id="@+id/contactlist_item_icon"
+        android:id="@+id/icon"
         android:layout_width="@dimen/contactlist_item_icon_layout_width"
         android:layout_height="@dimen/contactlist_item_icon_layout_height"
         android:layout_margin="@dimen/standard_margin"
@@ -32,7 +32,7 @@
         android:contentDescription="@string/contactlist_item_icon"/>
 
     <CheckedTextView
-        android:id="@+id/contactlist_item_name"
+        android:id="@+id/name"
         android:layout_width="0dp"
         android:layout_height="@dimen/standard_list_item_size"
         android:layout_marginEnd="@dimen/standard_margin"

+ 0 - 96
src/main/res/layout/contacts_backup_fragment.xml

@@ -1,96 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!--
-  Nextcloud Android client application
-
-  Copyright (C) 2017 Tobias Kaminsky
-  Copyright (C) 2017 Nextcloud.
-
-  This program is free software; you can redistribute it and/or
-  modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
-  License as published by the Free Software Foundation; either
-  version 3 of the License, or 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 <http://www.gnu.org/licenses/>.
--->
-<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    android:id="@+id/contacts_linear_layout"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent">
-
-    <LinearLayout
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:orientation="vertical">
-
-        <androidx.appcompat.widget.SwitchCompat
-            android:id="@+id/contacts_automatic_backup"
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:layout_margin="@dimen/standard_margin"
-            android:text="@string/contacts_automatic_backup"
-            android:textAppearance="?android:attr/textAppearanceMedium"
-            android:textColor="@color/text_color" />
-
-        <LinearLayout
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:orientation="horizontal">
-
-            <TextView
-                android:id="@+id/contacts_last_backup"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:layout_margin="@dimen/standard_margin"
-                android:layout_weight="1"
-                android:text="@string/contacts_last_backup"
-                android:textAppearance="?android:attr/textAppearanceMedium"
-                android:textColor="@color/text_color"/>
-
-            <TextView
-                android:id="@+id/contacts_last_backup_timestamp"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:layout_margin="@dimen/standard_margin"
-                android:layout_weight="1"
-                android:gravity="end"
-                android:text="@string/contacts_preference_backup_never"
-                android:textAppearance="?android:attr/textAppearanceMedium"/>
-        </LinearLayout>
-
-        <LinearLayout
-            android:layout_width="match_parent"
-            android:layout_height="match_parent"
-            android:orientation="horizontal">
-
-            <com.google.android.material.button.MaterialButton
-                android:id="@+id/contacts_backup_now"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:layout_margin="@dimen/standard_margin"
-                android:layout_weight="1"
-                android:text="@string/contacts_backup_button"
-                android:theme="@style/Button.Primary"
-                app:cornerRadius="@dimen/button_corner_radius" />
-
-            <com.google.android.material.button.MaterialButton
-                android:id="@+id/contacts_datepicker"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:layout_margin="@dimen/standard_margin"
-                android:layout_weight="1"
-                android:text="@string/contacts_preference_choose_date"
-                android:theme="@style/Button"
-                android:visibility="gone"
-                app:cornerRadius="@dimen/button_corner_radius" />
-        </LinearLayout>
-
-    </LinearLayout>
-
-</ScrollView>

+ 0 - 5
src/main/res/menu/partial_drawer_entries.xml

@@ -102,11 +102,6 @@
     <group
         android:id="@+id/drawer_menu_bottom"
         android:checkableBehavior="single">
-        <item
-            android:id="@+id/nav_contacts"
-            android:icon="@drawable/nav_contacts"
-            android:orderInCategory="3"
-            android:title="@string/actionbar_contacts"/>
         <item
             android:id="@+id/nav_settings"
             android:icon="@drawable/nav_settings"

+ 2 - 3
src/main/res/values/setup.xml

@@ -32,10 +32,10 @@
     <bool name="show_external_links">true</bool>
     <bool name="show_outdated_server_warning">true</bool>
 
-    <!-- Contacts backup -->
-    <bool name="contacts_backup">true</bool>
+    <!-- Calendar & Contacts backup -->
     <string name="contacts_backup_folder">/.Contacts-Backup</string>
     <integer name="contacts_backup_expire">-1</integer>
+    <string name="calendar_backup_folder">/.Calendar-Backup</string>
 
     <!-- What's new -->
     <bool name="show_whats_new">true</bool>
@@ -58,7 +58,6 @@
     <bool name="shared_enabled">true</bool>
     <bool name="videos_enabled">false</bool>
     <bool name="show_drawer_logout">false</bool>
-    <bool name="show_drawer_contacts_backup">false</bool> <!-- if false it will shown in settings -->
 
     <!-- Help, imprint and feedback, and other things -->
     <bool name="passcode_enabled">true</bool>

+ 35 - 10
src/main/res/values/strings.xml

@@ -51,7 +51,7 @@
     <string name="prefs_calendar_contacts_address_resolve_error">Server address for the account could not be resolved for DAVx5 (formerly known as DAVdroid)</string>
     <string name="prefs_calendar_contacts_no_store_error">Neither F-Droid nor Google Play is installed</string>
     <string name="prefs_calendar_contacts_sync_setup_successful">Calendar &amp; contacts sync set up</string>
-    <string name="prefs_daily_contacts_sync_summary">Daily backup of your contacts</string>
+    <string name="prefs_daily_backup_summary">Daily backup of your calendar &amp; contacts</string>
     <string name="prefs_sycned_folders_summary">Manage folders for auto upload</string>
     <string name="prefs_help">Help</string>
     <string name="prefs_recommend">Recommend to friend</string>
@@ -187,6 +187,22 @@
         <item quantity="one">Failed to copy %1$d file from the %2$s folder into</item>
         <item quantity="other">Failed to copy %1$d files from the %2$s folder into</item>
     </plurals>
+    <plurals name="wrote_n_events_to">
+        <item quantity="one">Wrote %1$d event to %2$s</item>
+        <item quantity="other">Wrote %1$d events to %2$s</item>
+    </plurals>
+    <plurals name="created_n_uids_to">
+        <item quantity="one">Created %1$d fresh UID</item>
+        <item quantity="other">Created %1$d fresh UIDs</item>
+    </plurals>
+    <plurals name="processed_n_entries">
+        <item quantity="one">Processed %d entry.</item>
+        <item quantity="other">Processed %d entries.</item>
+    </plurals>
+    <plurals name="found_n_duplicates">
+        <item quantity="one">Found %d duplicate entry.</item>
+        <item quantity="other">Found %d duplicate entries.</item>
+    </plurals>
     <string name="sync_foreign_files_forgotten_explanation">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.</string>
     <string name="sync_current_folder_was_removed">The folder %1$s does not exist anymore</string>
     <string name="foreign_files_move">Move all</string>
@@ -571,21 +587,15 @@
     <string name="activities_no_results_message">No events like additions, changes and shares yet.</string>
     <string name="prefs_category_about">About</string>
 
-    <string name="actionbar_contacts">Back up contacts</string>
-    <string name="actionbar_contacts_restore">Restore contacts</string>
+    <string name="actionbar_calendar_contacts_restore">Restore contacts &amp; calendar</string>
     <string name="contacts_backup_button">Back up now</string>
-    <string name="contacts_automatic_backup">Automatic backup</string>
-    <string name="contacts_last_backup">Last backup</string>
-    <string name="contacts_read_permission">Permission to read contact list needed</string>
-    <string name="contaclist_restore_selected">Restore selected contacts</string>
-    <string name="contactlist_account_chooser_title">Choose account to import</string>
     <string name="contactlist_no_permission">No permission given, nothing imported.</string>
-    <string name="contacts_preference_choose_date">Choose date</string>
-    <string name="contacts_preference_backup_never">never</string>
+    <string name="restore_backup">Restore backup</string>
     <string name="contacts_preferences_no_file_found">No file found</string>
     <string name="contacts_preferences_something_strange_happened">Could not find your last backup!</string>
     <string name="contacts_preferences_backup_scheduled">Backup scheduled and will start shortly</string>
     <string name="contacts_preferences_import_scheduled">Import scheduled and will start shortly</string>
+    <string name="backup_title">Contacts &amp; calendar backup</string>
 
     <string name="drawer_logout">Log out</string>
     <string name="picture_set_as_no_app">No app found to set a picture with</string>
@@ -962,6 +972,21 @@
     <string name="choose_template_helper_text">Please choose a template and enter a file name.</string>
     <string name="strict_mode">Strict mode: no HTTP connection allowed!</string>
     <string name="fullscreen">Fullscreen</string>
+    <string name="destination_filename">Destination filename</string>
+    <string name="suggest">Suggest</string>
+    <string name="enter_destination_filename">Enter destination filename</string>
+    <string name="did_not_check_for_dupes">Did not check for duplicates.</string>
+    <string name="last_backup">Last backup: %1$s</string>
+    <string name="error_choosing_date">Error choosing date</string>
+    <string name="restore_selected">Restore selected</string>
+    <string name="calendars">Calendars</string>
+    <string name="contacts">Contacts</string>
+    <string name="data_to_back_up">Data to back up</string>
+    <string name="calendar">Calendar</string>
+    <string name="backup_settings">Backup settings</string>
+    <string name="daily_backup">Daily backup</string>
+    <string name="calendar_name_linewrap" translatable="false">%1$s\n%2$s</string>
+    <string name="no_calendar_exists">No calendar exists</string>
     <string name="more">More</string>
     <string name="write_email">Send email</string>
     <string name="no_actions">No actions for this user</string>

+ 3 - 3
src/main/res/xml/preferences.xml

@@ -68,9 +68,9 @@
 					android:key="calendar_contacts"
 					android:summary="@string/prefs_calendar_contacts_summary" />
 		<Preference
-			android:title="@string/actionbar_contacts"
-			android:key="contacts"
-			android:summary="@string/prefs_daily_contacts_sync_summary"/>
+            android:title="@string/backup_title"
+            android:key="backup"
+            android:summary="@string/prefs_daily_backup_summary" />
 		<Preference
 			android:title="@string/prefs_e2e_mnemonic"
 			android:key="mnemonic"