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

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

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

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

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

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

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