Calendar backup/import

Signed-off-by: tobiasKaminsky <tobias@kaminsky.me>
This commit is contained in:
tobiasKaminsky 2021-08-16 07:20:11 +02:00
parent 2693c103b3
commit 042c413569
No known key found for this signature in database
GPG key ID: 0E00D4D47D0C5AF7
57 changed files with 4261 additions and 1111 deletions

View file

@ -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"

View file

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

Before

Width:  |  Height:  |  Size: 422 B

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8 KiB

View file

@ -1 +1 @@
355
349

View file

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

View file

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

View file

@ -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

View file

@ -0,0 +1,113 @@
/*
* Nextcloud Android client application
*
* @author Tobias Kaminsky
* Copyright (C) 2021 Tobias Kaminsky
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.owncloud.android.ui.fragment
import android.Manifest
import androidx.test.espresso.intent.rule.IntentsTestRule
import androidx.test.rule.GrantPermissionRule
import com.owncloud.android.AbstractIT
import com.owncloud.android.R
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.ui.activity.ContactsPreferenceActivity
import com.owncloud.android.ui.fragment.contactsbackup.BackupListFragment
import com.owncloud.android.utils.ScreenshotTest
import org.junit.Rule
import org.junit.Test
class BackupListFragmentIT : AbstractIT() {
@get:Rule
val testActivityRule = IntentsTestRule(ContactsPreferenceActivity::class.java, true, false)
@get:Rule
val permissionRule: GrantPermissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CALENDAR)
@Test
@ScreenshotTest
fun showLoading() {
val sut = testActivityRule.launchActivity(null)
val file = OCFile("/", "00000001")
val transaction = sut.supportFragmentManager.beginTransaction()
transaction.replace(R.id.frame_container, BackupListFragment.newInstance(file, user))
transaction.commit()
waitForIdleSync()
screenshot(sut)
}
@Test
@ScreenshotTest
fun showContactList() {
val sut = testActivityRule.launchActivity(null)
val transaction = sut.supportFragmentManager.beginTransaction()
val file = getFile("vcard.vcf")
val ocFile = OCFile("/vcard.vcf", "00000002")
ocFile.storagePath = file.absolutePath
ocFile.mimeType = "text/vcard"
transaction.replace(R.id.frame_container, BackupListFragment.newInstance(ocFile, user))
transaction.commit()
waitForIdleSync()
shortSleep()
screenshot(sut)
}
@Test
@ScreenshotTest
fun showCalendarList() {
val sut = testActivityRule.launchActivity(null)
val transaction = sut.supportFragmentManager.beginTransaction()
val file = getFile("calendar.ics")
val ocFile = OCFile("/Private calender_2020-09-01_10-45-20.ics.ics", "00000003")
ocFile.storagePath = file.absolutePath
ocFile.mimeType = "text/calendar"
transaction.replace(R.id.frame_container, BackupListFragment.newInstance(ocFile, user))
transaction.commit()
waitForIdleSync()
screenshot(sut)
}
@Test
@ScreenshotTest
fun showCalendarAndContactsList() {
val sut = testActivityRule.launchActivity(null)
val transaction = sut.supportFragmentManager.beginTransaction()
val calendarFile = getFile("calendar.ics")
val calendarOcFile = OCFile("/Private calender_2020-09-01_10-45-20.ics", "00000003")
calendarOcFile.storagePath = calendarFile.absolutePath
calendarOcFile.mimeType = "text/calendar"
val contactFile = getFile("vcard.vcf")
val contactOcFile = OCFile("/vcard.vcf", "00000002")
contactOcFile.storagePath = contactFile.absolutePath
contactOcFile.mimeType = "text/vcard"
val files = arrayOf(calendarOcFile, contactOcFile)
transaction.replace(R.id.frame_container, BackupListFragment.newInstance(files, user))
transaction.commit()
waitForIdleSync()
screenshot(sut)
}
}

View file

@ -1,49 +0,0 @@
/*
* Nextcloud Android client application
*
* @author Andy Scherzinger
* Copyright (C) 2020 Andy Scherzinger
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.owncloud.android.ui.fragment
import androidx.test.espresso.intent.rule.IntentsTestRule
import com.owncloud.android.AbstractIT
import com.owncloud.android.R
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.ui.activity.ContactsPreferenceActivity
import com.owncloud.android.ui.fragment.contactsbackup.ContactListFragment
import com.owncloud.android.utils.ScreenshotTest
import org.junit.Rule
import org.junit.Test
class ContactListFragmentIT : AbstractIT() {
@get:Rule
val testActivityRule = IntentsTestRule(ContactsPreferenceActivity::class.java, true, false)
val file = OCFile("/", "00000001")
@Test
@ScreenshotTest
fun showContactListFragmentLoading() {
val sut = testActivityRule.launchActivity(null)
val transaction = sut.supportFragmentManager.beginTransaction()
transaction.replace(R.id.frame_container, ContactListFragment.newInstance(file, user))
transaction.commit()
waitForIdleSync()
screenshot(sut)
}
}

View file

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

View file

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

View file

@ -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,

View file

@ -71,6 +71,31 @@ interface BackgroundJobManager {
*/
fun startImmediateContactsBackup(user: User): LiveData<JobInfo?>
/**
* Schedule periodic calendar backups job. Operating system will
* decide when to start the job.
*
* This call is idempotent - there can be only one scheduled job
* at any given time.
*
* @param user User for which job will be scheduled.
*/
fun schedulePeriodicCalendarBackup(user: User)
/**
* Cancel periodic calendar backup. Existing tasks might finish, but no new
* invocations will occur.
*/
fun cancelPeriodicCalendarBackup(user: User)
/**
* Immediately start single calendar backup job.
* This job will launch independently from periodic calendar backup.
*
* @return Job info with current status; status is null if job does not exist
*/
fun startImmediateCalendarBackup(user: User): LiveData<JobInfo?>
/**
* Immediately start contacts import job. Import job will be started only once.
* If new job is started while existing job is running - request will be ignored
@ -90,6 +115,17 @@ interface BackgroundJobManager {
selectedContacts: IntArray
): LiveData<JobInfo?>
/**
* Immediately start calendar import job. Import job will be started only once.
* If new job is started while existing job is running - request will be ignored
* and currently running job will continue running.
*
* @param calendarPaths Array of paths of calendar files to import from
*
* @return Job info with current status; status is null if job does not exist
*/
fun startImmediateCalendarImport(calendarPaths: Map<String, Int>): LiveData<JobInfo?>
fun schedulePeriodicFilesSyncJob()
fun startImmediateFilesSyncJob(skipCustomFolders: Boolean = false, overridePowerSaving: Boolean = false)
fun scheduleOfflineSync()

View file

@ -68,6 +68,8 @@ internal class BackgroundJobManagerImpl(
const val JOB_PERIODIC_CONTACTS_BACKUP = "periodic_contacts_backup"
const val JOB_IMMEDIATE_CONTACTS_BACKUP = "immediate_contacts_backup"
const val JOB_IMMEDIATE_CONTACTS_IMPORT = "immediate_contacts_import"
const val JOB_PERIODIC_CALENDAR_BACKUP = "periodic_calendar_backup"
const val JOB_IMMEDIATE_CALENDAR_IMPORT = "immediate_calendar_import"
const val JOB_PERIODIC_FILES_SYNC = "periodic_files_sync"
const val JOB_IMMEDIATE_FILES_SYNC = "immediate_files_sync"
const val JOB_PERIODIC_OFFLINE_SYNC = "periodic_offline_sync"
@ -75,6 +77,7 @@ internal class BackgroundJobManagerImpl(
const val JOB_IMMEDIATE_MEDIA_FOLDER_DETECTION = "immediate_media_folder_detection"
const val JOB_NOTIFICATION = "notification"
const val JOB_ACCOUNT_REMOVAL = "account_removal"
const val JOB_IMMEDIATE_CALENDAR_BACKUP = "immediate_calendar_backup"
const val JOB_TEST = "test_job"
@ -85,7 +88,7 @@ internal class BackgroundJobManagerImpl(
const val TAG_PREFIX_START_TIMESTAMP = "timestamp"
val PREFIXES = setOf(TAG_PREFIX_NAME, TAG_PREFIX_USER, TAG_PREFIX_START_TIMESTAMP)
const val NOT_SET_VALUE = "not set"
const val PERIODIC_CONTACTS_BACKUP_INTERVAL_MINUTES = 24 * 60L
const val PERIODIC_BACKUP_INTERVAL_MINUTES = 24 * 60L
const val DEFAULT_PERIODIC_JOB_INTERVAL_MINUTES = 15L
const val DEFAULT_IMMEDIATE_JOB_DELAY_SEC = 3L
@ -228,7 +231,7 @@ internal class BackgroundJobManagerImpl(
val request = periodicRequestBuilder(
jobClass = ContactsBackupWork::class,
jobName = JOB_PERIODIC_CONTACTS_BACKUP,
intervalMins = PERIODIC_CONTACTS_BACKUP_INTERVAL_MINUTES,
intervalMins = PERIODIC_BACKUP_INTERVAL_MINUTES,
user = user
).setInputData(data).build()
@ -267,6 +270,26 @@ internal class BackgroundJobManagerImpl(
return workManager.getJobInfo(request.id)
}
override fun startImmediateCalendarImport(calendarPaths: Map<String, Int>): LiveData<JobInfo?> {
val data = Data.Builder()
.putAll(calendarPaths)
.build()
val constraints = Constraints.Builder()
.setRequiresCharging(false)
.build()
val request = oneTimeRequestBuilder(CalendarImportWork::class, JOB_IMMEDIATE_CALENDAR_IMPORT)
.setInputData(data)
.setConstraints(constraints)
.build()
workManager.enqueueUniqueWork(JOB_IMMEDIATE_CALENDAR_IMPORT, ExistingWorkPolicy.KEEP, request)
return workManager.getJobInfo(request.id)
}
override fun startImmediateContactsBackup(user: User): LiveData<JobInfo?> {
val data = Data.Builder()
.putString(ContactsBackupWork.ACCOUNT, user.accountName)
@ -281,6 +304,39 @@ internal class BackgroundJobManagerImpl(
return workManager.getJobInfo(request.id)
}
override fun startImmediateCalendarBackup(user: User): LiveData<JobInfo?> {
val data = Data.Builder()
.putString(CalendarBackupWork.ACCOUNT, user.accountName)
.putBoolean(CalendarBackupWork.FORCE, true)
.build()
val request = oneTimeRequestBuilder(CalendarBackupWork::class, JOB_IMMEDIATE_CALENDAR_BACKUP, user)
.setInputData(data)
.build()
workManager.enqueueUniqueWork(JOB_IMMEDIATE_CALENDAR_BACKUP, ExistingWorkPolicy.KEEP, request)
return workManager.getJobInfo(request.id)
}
override fun schedulePeriodicCalendarBackup(user: User) {
val data = Data.Builder()
.putString(CalendarBackupWork.ACCOUNT, user.accountName)
.putBoolean(CalendarBackupWork.FORCE, true)
.build()
val request = periodicRequestBuilder(
jobClass = CalendarBackupWork::class,
jobName = JOB_PERIODIC_CALENDAR_BACKUP,
intervalMins = PERIODIC_BACKUP_INTERVAL_MINUTES,
user = user
).setInputData(data).build()
workManager.enqueueUniquePeriodicWork(JOB_PERIODIC_CALENDAR_BACKUP, ExistingPeriodicWorkPolicy.KEEP, request)
}
override fun cancelPeriodicCalendarBackup(user: User) {
workManager.cancelJob(JOB_PERIODIC_CALENDAR_BACKUP, user)
}
override fun schedulePeriodicFilesSyncJob() {
val request = periodicRequestBuilder(
jobClass = FilesSyncWork::class,

View file

@ -0,0 +1,80 @@
/*
*
* Nextcloud Android client application
*
* @author Tobias Kaminsky
* Copyright (C) 2021 Tobias Kaminsky
* Copyright (C) 2021 Nextcloud GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.nextcloud.client.jobs
import android.content.ContentResolver
import android.content.Context
import android.text.TextUtils
import androidx.work.Worker
import androidx.work.WorkerParameters
import com.nextcloud.client.account.UserAccountManager
import com.nextcloud.client.preferences.AppPreferences
import com.owncloud.android.lib.common.utils.Log_OC
import third_parties.sufficientlysecure.AndroidCalendar
import third_parties.sufficientlysecure.SaveCalendar
import java.util.Calendar
class CalendarBackupWork(
appContext: Context,
params: WorkerParameters,
private val contentResolver: ContentResolver,
private val accountManager: UserAccountManager,
private val preferences: AppPreferences
) : Worker(appContext, params) {
companion object {
val TAG = CalendarBackupWork::class.java.simpleName
const val ACCOUNT = "account"
const val FORCE = "force"
const val JOB_INTERVAL_MS: Long = 24 * 60 * 60 * 1000
}
override fun doWork(): Result {
val accountName = inputData.getString(ACCOUNT) ?: ""
val optionalUser = accountManager.getUser(accountName)
if (!optionalUser.isPresent || TextUtils.isEmpty(accountName)) { // no account provided
return Result.failure()
}
val lastExecution = preferences.calendarLastBackup
val force = inputData.getBoolean(FORCE, false)
if (force || lastExecution + JOB_INTERVAL_MS < Calendar.getInstance().timeInMillis) {
AndroidCalendar.loadAll(contentResolver).forEach { calendar ->
SaveCalendar(
applicationContext,
calendar,
preferences,
accountManager.user
).start()
}
// store execution date
preferences.calendarLastBackup = Calendar.getInstance().timeInMillis
} else {
Log_OC.d(TAG, "last execution less than 24h ago")
}
return Result.success()
}
}

View file

@ -0,0 +1,77 @@
/*
* Nextcloud Android client application
*
* @author Tobias Kaminsky
* Copyright (C) 2017 Tobias Kaminsky
* Copyright (C) 2017 Nextcloud GmbH.
* Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.nextcloud.client.jobs
import android.content.ContentResolver
import android.content.Context
import androidx.work.Worker
import androidx.work.WorkerParameters
import com.nextcloud.client.logger.Logger
import net.fortuna.ical4j.data.CalendarBuilder
import third_parties.sufficientlysecure.AndroidCalendar
import third_parties.sufficientlysecure.CalendarSource
import third_parties.sufficientlysecure.ProcessVEvent
import java.io.File
class CalendarImportWork(
private val appContext: Context,
params: WorkerParameters,
private val logger: Logger,
private val contentResolver: ContentResolver
) : Worker(appContext, params) {
companion object {
const val TAG = "CalendarImportWork"
const val SELECTED_CALENDARS = "selected_contacts_indices"
}
override fun doWork(): Result {
val calendarPaths = inputData.getStringArray(SELECTED_CALENDARS) ?: arrayOf<String>()
val calendars = inputData.keyValueMap as Map<String, AndroidCalendar>
val calendarBuilder = CalendarBuilder()
for ((path, selectedCalendar) in calendars) {
logger.d(TAG, "Import calendar from $path")
val file = File(path)
val calendarSource = CalendarSource(
file.toURI().toURL().toString(),
null,
null,
null,
appContext
)
val calendars = AndroidCalendar.loadAll(contentResolver)[0]
ProcessVEvent(
appContext,
calendarBuilder.build(calendarSource.stream),
selectedCalendar,
true
).run()
}
return Result.success()
}
}

View file

@ -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 {

View file

@ -361,4 +361,12 @@ public interface AppPreferences {
void resetPinWrongAttempts();
int pinBruteForceDelay();
String getUidPid();
void setUidPid(String uidPid);
long getCalendarLastBackup();
void setCalendarLastBackup(long timestamp);
}

View file

@ -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);

View file

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

View file

@ -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) {

View file

@ -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;
});
}
}

View file

@ -0,0 +1,81 @@
/*
*
* Nextcloud Android client application
*
* @author Tobias Kaminsky
* Copyright (C) 2021 Tobias Kaminsky
* Copyright (C) 2021 Nextcloud GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.owncloud.android.ui.asynctasks;
import android.os.AsyncTask;
import com.owncloud.android.datamodel.OCFile;
import com.owncloud.android.lib.common.utils.Log_OC;
import com.owncloud.android.ui.fragment.contactsbackup.BackupListFragment;
import com.owncloud.android.ui.fragment.contactsbackup.VCardComparator;
import java.io.File;
import java.io.IOException;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import ezvcard.Ezvcard;
import ezvcard.VCard;
public class LoadContactsTask extends AsyncTask<Void, Void, Boolean> {
private final WeakReference<BackupListFragment> backupListFragmentWeakReference;
private final OCFile ocFile;
private final List<VCard> vCards = new ArrayList<>();
public LoadContactsTask(BackupListFragment backupListFragment, OCFile ocFile) {
this.backupListFragmentWeakReference = new WeakReference<>(backupListFragment);
this.ocFile = ocFile;
}
@Override
protected void onPreExecute() {
if (backupListFragmentWeakReference.get() != null && !backupListFragmentWeakReference.get().hasCalendarEntry()) {
backupListFragmentWeakReference.get().showLoadingMessage(true);
}
}
@Override
protected Boolean doInBackground(Void... voids) {
if (!isCancelled()) {
File file = new File(ocFile.getStoragePath());
try {
vCards.addAll(Ezvcard.parse(file).all());
Collections.sort(vCards, new VCardComparator());
} catch (IOException e) {
Log_OC.e(this, "IO Exception: " + file.getAbsolutePath());
return Boolean.FALSE;
}
return Boolean.TRUE;
}
return Boolean.FALSE;
}
@Override
protected void onPostExecute(Boolean bool) {
if (!isCancelled() && bool && backupListFragmentWeakReference.get() != null) {
backupListFragmentWeakReference.get().loadVCards(vCards);
}
}
}

View file

@ -21,10 +21,8 @@
package com.owncloud.android.ui.fragment.contactsbackup;
import android.Manifest;
import android.accounts.Account;
import android.app.DatePickerDialog;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.os.AsyncTask;
import android.os.Bundle;
@ -34,14 +32,14 @@ import android.view.View;
import android.view.ViewGroup;
import android.widget.CompoundButton;
import android.widget.DatePicker;
import android.widget.Toast;
import com.google.android.material.snackbar.Snackbar;
import com.nextcloud.client.account.User;
import com.nextcloud.client.di.Injectable;
import com.nextcloud.client.jobs.BackgroundJobManager;
import com.nextcloud.java.util.Optional;
import com.owncloud.android.R;
import com.owncloud.android.databinding.ContactsBackupFragmentBinding;
import com.owncloud.android.databinding.BackupFragmentBinding;
import com.owncloud.android.datamodel.ArbitraryDataProvider;
import com.owncloud.android.datamodel.FileDataStorageManager;
import com.owncloud.android.datamodel.OCFile;
@ -51,17 +49,17 @@ import com.owncloud.android.ui.activity.ContactsPreferenceActivity;
import com.owncloud.android.ui.activity.SettingsActivity;
import com.owncloud.android.ui.fragment.FileFragment;
import com.owncloud.android.utils.DisplayUtils;
import com.owncloud.android.utils.MimeTypeUtil;
import com.owncloud.android.utils.PermissionUtil;
import com.owncloud.android.utils.theme.ThemeButtonUtils;
import com.owncloud.android.utils.theme.ThemeCheckableUtils;
import com.owncloud.android.utils.theme.ThemeColorUtils;
import com.owncloud.android.utils.theme.ThemeSnackbarUtils;
import com.owncloud.android.utils.theme.ThemeToolbarUtils;
import com.owncloud.android.utils.theme.ThemeUtils;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.List;
@ -76,15 +74,15 @@ import third_parties.daveKoeller.AlphanumComparator;
import static com.owncloud.android.ui.activity.ContactsPreferenceActivity.PREFERENCE_CONTACTS_AUTOMATIC_BACKUP;
import static com.owncloud.android.ui.activity.ContactsPreferenceActivity.PREFERENCE_CONTACTS_LAST_BACKUP;
public class ContactsBackupFragment extends FileFragment implements DatePickerDialog.OnDateSetListener, Injectable {
public static final String TAG = ContactsBackupFragment.class.getSimpleName();
public class BackupFragment extends FileFragment implements DatePickerDialog.OnDateSetListener, Injectable {
public static final String TAG = BackupFragment.class.getSimpleName();
private static final String ARG_SHOW_SIDEBAR = "SHOW_SIDEBAR";
private static final String KEY_CALENDAR_PICKER_OPEN = "IS_CALENDAR_PICKER_OPEN";
private static final String KEY_CALENDAR_DAY = "CALENDAR_DAY";
private static final String KEY_CALENDAR_MONTH = "CALENDAR_MONTH";
private static final String KEY_CALENDAR_YEAR = "CALENDAR_YEAR";
private ContactsBackupFragmentBinding binding;
private BackupFragmentBinding binding;
@Inject BackgroundJobManager backgroundJobManager;
@ -93,13 +91,15 @@ public class ContactsBackupFragment extends FileFragment implements DatePickerDi
private DatePickerDialog datePickerDialog;
private CompoundButton.OnCheckedChangeListener onCheckedChangeListener;
private CompoundButton.OnCheckedChangeListener dailyBackupCheckedChangeListener;
private CompoundButton.OnCheckedChangeListener contactsCheckedListener;
private CompoundButton.OnCheckedChangeListener calendarCheckedListener;
private ArbitraryDataProvider arbitraryDataProvider;
private User user;
private boolean showSidebar = true;
public static ContactsBackupFragment create(boolean showSidebar) {
ContactsBackupFragment fragment = new ContactsBackupFragment();
public static BackupFragment create(boolean showSidebar) {
BackupFragment fragment = new BackupFragment();
Bundle bundle = new Bundle();
bundle.putBoolean(ARG_SHOW_SIDEBAR, showSidebar);
fragment.setArguments(bundle);
@ -114,7 +114,7 @@ public class ContactsBackupFragment extends FileFragment implements DatePickerDi
getContext().getTheme().applyStyle(R.style.FallbackThemingTheme, true);
}
binding = ContactsBackupFragmentBinding.inflate(inflater, container, false);
binding = BackupFragmentBinding.inflate(inflater, container, false);
View view = binding.getRoot();
setHasOptionsMenu(true);
@ -129,7 +129,7 @@ public class ContactsBackupFragment extends FileFragment implements DatePickerDi
ActionBar actionBar = contactsPreferenceActivity != null ? contactsPreferenceActivity.getSupportActionBar() : null;
if (actionBar != null) {
ThemeToolbarUtils.setColoredTitle(actionBar, getString(R.string.actionbar_contacts), getContext());
ThemeToolbarUtils.setColoredTitle(actionBar, getString(R.string.backup_title), getContext());
actionBar.setDisplayHomeAsUpEnabled(true);
ThemeToolbarUtils.tintBackButton(actionBar, getContext());
@ -138,42 +138,84 @@ public class ContactsBackupFragment extends FileFragment implements DatePickerDi
arbitraryDataProvider = new ArbitraryDataProvider(getContext().getContentResolver());
ThemeCheckableUtils.tintSwitch(
binding.contactsAutomaticBackup, ThemeColorUtils.primaryAccentColor(getContext()));
binding.contactsAutomaticBackup.setChecked(
binding.contacts, ThemeColorUtils.primaryAccentColor(getContext()));
ThemeCheckableUtils.tintSwitch(
binding.calendar, ThemeColorUtils.primaryAccentColor(getContext()));
ThemeCheckableUtils.tintSwitch(
binding.dailyBackup, ThemeColorUtils.primaryAccentColor(getContext()));
binding.dailyBackup.setChecked(
arbitraryDataProvider.getBooleanValue(user, PREFERENCE_CONTACTS_AUTOMATIC_BACKUP));
onCheckedChangeListener = (buttonView, isChecked) -> {
binding.contacts.setChecked(checkContactBackupPermission());
binding.calendar.setChecked(checkCalendarBackupPermission());
dailyBackupCheckedChangeListener = (buttonView, isChecked) -> {
if (checkAndAskForContactsReadPermission()) {
setAutomaticBackup(isChecked);
}
};
binding.contactsAutomaticBackup.setOnCheckedChangeListener(onCheckedChangeListener);
binding.contactsBackupNow.setOnClickListener(v -> backupContacts());
contactsCheckedListener = (buttonView, isChecked) -> {
if (isChecked) {
if (checkAndAskForContactsReadPermission()) {
binding.backupNow.setVisibility(View.VISIBLE);
}
} else {
if (!binding.calendar.isChecked()) {
binding.backupNow.setVisibility(View.INVISIBLE);
}
}
};
binding.contacts.setOnCheckedChangeListener(contactsCheckedListener);
calendarCheckedListener = (buttonView, isChecked) -> {
if (isChecked) {
if (checkAndAskForCalendarReadPermission()) {
binding.backupNow.setVisibility(View.VISIBLE);
}
} else {
if (!binding.contacts.isChecked()) {
binding.backupNow.setVisibility(View.INVISIBLE);
}
}
};
binding.calendar.setOnCheckedChangeListener(calendarCheckedListener);
binding.dailyBackup.setOnCheckedChangeListener(dailyBackupCheckedChangeListener);
binding.backupNow.setOnClickListener(v -> backup());
binding.backupNow.setEnabled(checkBackupNowPermission());
binding.backupNow.setVisibility(checkBackupNowPermission() ? View.VISIBLE : View.GONE);
binding.contactsDatepicker.setOnClickListener(v -> openCleanDate());
// display last backup
Long lastBackupTimestamp = arbitraryDataProvider.getLongValue(user, PREFERENCE_CONTACTS_LAST_BACKUP);
if (lastBackupTimestamp == -1) {
binding.contactsLastBackupTimestamp.setText(R.string.contacts_preference_backup_never);
binding.lastBackupWithDate.setVisibility(View.GONE);
} else {
binding.contactsLastBackupTimestamp.setText(
DisplayUtils.getRelativeTimestamp(contactsPreferenceActivity, lastBackupTimestamp));
binding.lastBackupWithDate.setText(
String.format(getString(R.string.last_backup),
DisplayUtils.getRelativeTimestamp(contactsPreferenceActivity, lastBackupTimestamp)));
}
if (savedInstanceState != null && savedInstanceState.getBoolean(KEY_CALENDAR_PICKER_OPEN, false)) {
if (savedInstanceState.getInt(KEY_CALENDAR_YEAR, -1) != -1 &&
savedInstanceState.getInt(KEY_CALENDAR_MONTH, -1) != -1 &&
savedInstanceState.getInt(KEY_CALENDAR_DAY, -1) != -1) {
savedInstanceState.getInt(KEY_CALENDAR_MONTH, -1) != -1 &&
savedInstanceState.getInt(KEY_CALENDAR_DAY, -1) != -1) {
selectedDate = new Date(savedInstanceState.getInt(KEY_CALENDAR_YEAR),
savedInstanceState.getInt(KEY_CALENDAR_MONTH), savedInstanceState.getInt(KEY_CALENDAR_DAY));
savedInstanceState.getInt(KEY_CALENDAR_MONTH), savedInstanceState.getInt(KEY_CALENDAR_DAY));
}
calendarPickerOpen = true;
}
ThemeButtonUtils.colorPrimaryButton(binding.contactsBackupNow, getContext());
ThemeButtonUtils.colorPrimaryButton(binding.contactsDatepicker, getContext());
ThemeButtonUtils.colorPrimaryButton(binding.backupNow, getContext());
ThemeButtonUtils.themeBorderlessButton(binding.contactsDatepicker);
int primaryAccentColor = ThemeColorUtils.primaryAccentColor(getContext());
binding.dataToBackUpTitle.setTextColor(primaryAccentColor);
binding.backupSettingsTitle.setTextColor(primaryAccentColor);
return view;
}
@ -281,45 +323,69 @@ public class ContactsBackupFragment extends FileFragment implements DatePickerDi
for (int index = 0; index < permissions.length; index++) {
if (Manifest.permission.READ_CONTACTS.equalsIgnoreCase(permissions[index])) {
if (grantResults[index] >= 0) {
setAutomaticBackup(true);
} else {
binding.contactsAutomaticBackup.setOnCheckedChangeListener(null);
binding.contactsAutomaticBackup.setChecked(false);
binding.contactsAutomaticBackup.setOnCheckedChangeListener(onCheckedChangeListener);
// if approved, exit for loop
break;
}
break;
// if not accepted, disable again
binding.contacts.setOnCheckedChangeListener(null);
binding.contacts.setChecked(false);
binding.contacts.setOnCheckedChangeListener(contactsCheckedListener);
}
}
}
if (requestCode == PermissionUtil.PERMISSIONS_READ_CONTACTS_MANUALLY) {
if (requestCode == PermissionUtil.PERMISSIONS_READ_CALENDAR_AUTOMATIC) {
for (int index = 0; index < permissions.length; index++) {
if (Manifest.permission.READ_CONTACTS.equalsIgnoreCase(permissions[index])) {
if (Manifest.permission.READ_CALENDAR.equalsIgnoreCase(permissions[index])) {
if (grantResults[index] >= 0) {
startContactsBackupJob();
// if approved, exit for loop
break;
}
break;
}
// if not accepted, disable again
binding.calendar.setOnCheckedChangeListener(null);
binding.calendar.setChecked(false);
binding.calendar.setOnCheckedChangeListener(calendarCheckedListener);
binding.backupNow.setVisibility(checkBackupNowPermission() ? View.VISIBLE : View.GONE);
}
}
binding.backupNow.setVisibility(checkBackupNowPermission() ? View.VISIBLE : View.GONE);
binding.backupNow.setEnabled(checkBackupNowPermission());
}
public void backupContacts() {
if (checkAndAskForContactsReadPermission()) {
public void backup() {
if (binding.contacts.isChecked() && checkAndAskForContactsReadPermission()) {
startContactsBackupJob();
}
if (binding.calendar.isChecked() && checkAndAskForCalendarReadPermission()) {
startCalendarBackupJob();
}
DisplayUtils.showSnackMessage(requireView().findViewById(R.id.contacts_linear_layout),
R.string.contacts_preferences_backup_scheduled);
}
private void startContactsBackupJob() {
ContactsPreferenceActivity activity = (ContactsPreferenceActivity)getActivity();
ContactsPreferenceActivity activity = (ContactsPreferenceActivity) getActivity();
if (activity != null) {
Optional<User> optionalUser = activity.getUser();
if (optionalUser.isPresent()) {
backgroundJobManager.startImmediateContactsBackup(optionalUser.get());
DisplayUtils.showSnackMessage(getView().findViewById(R.id.contacts_linear_layout),
R.string.contacts_preferences_backup_scheduled);
}
}
}
private void startCalendarBackupJob() {
ContactsPreferenceActivity activity = (ContactsPreferenceActivity) getActivity();
if (activity != null) {
Optional<User> optionalUser = activity.getUser();
if (optionalUser.isPresent()) {
backgroundJobManager.startImmediateCalendarBackup(optionalUser.get());
}
}
}
@ -337,12 +403,15 @@ public class ContactsBackupFragment extends FileFragment implements DatePickerDi
User user = optionalUser.get();
if (enabled) {
backgroundJobManager.schedulePeriodicContactsBackup(user);
backgroundJobManager.schedulePeriodicCalendarBackup(user);
} else {
backgroundJobManager.cancelPeriodicContactsBackup(user);
backgroundJobManager.cancelPeriodicCalendarBackup(user);
}
arbitraryDataProvider.storeOrUpdateKeyValue(user.getAccountName(), PREFERENCE_CONTACTS_AUTOMATIC_BACKUP,
String.valueOf(enabled));
arbitraryDataProvider.storeOrUpdateKeyValue(user.getAccountName(),
PREFERENCE_CONTACTS_AUTOMATIC_BACKUP,
String.valueOf(enabled));
}
private boolean checkAndAskForContactsReadPermission() {
@ -352,58 +421,69 @@ public class ContactsBackupFragment extends FileFragment implements DatePickerDi
if (PermissionUtil.checkSelfPermission(contactsPreferenceActivity, Manifest.permission.READ_CONTACTS)) {
return true;
} else {
// Check if we should show an explanation
if (PermissionUtil.shouldShowRequestPermissionRationale(contactsPreferenceActivity,
android.Manifest.permission.READ_CONTACTS)) {
// Show explanation to the user and then request permission
Snackbar snackbar = DisplayUtils.createSnackbar(
getView().findViewById(R.id.contacts_linear_layout),
R.string.contacts_read_permission, Snackbar.LENGTH_INDEFINITE)
.setAction(R.string.common_ok, v -> requestPermissions(
new String[]{Manifest.permission.READ_CONTACTS},
PermissionUtil.PERMISSIONS_READ_CONTACTS_AUTOMATIC)
);
ThemeSnackbarUtils.colorSnackbar(contactsPreferenceActivity, snackbar);
snackbar.show();
return false;
} else {
// No explanation needed, request the permission.
requestPermissions(new String[]{Manifest.permission.READ_CONTACTS},
PermissionUtil.PERMISSIONS_READ_CONTACTS_AUTOMATIC);
return false;
}
// No explanation needed, request the permission.
requestPermissions(new String[]{Manifest.permission.READ_CONTACTS},
PermissionUtil.PERMISSIONS_READ_CONTACTS_AUTOMATIC);
return false;
}
}
private boolean checkAndAskForCalendarReadPermission() {
final ContactsPreferenceActivity contactsPreferenceActivity = (ContactsPreferenceActivity) getActivity();
// check permissions
if (PermissionUtil.checkSelfPermission(contactsPreferenceActivity, Manifest.permission.READ_CALENDAR)) {
return true;
} else {
// No explanation needed, request the permission.
requestPermissions(new String[]{Manifest.permission.READ_CALENDAR},
PermissionUtil.PERMISSIONS_READ_CALENDAR_AUTOMATIC);
return false;
}
}
private boolean checkBackupNowPermission() {
return (checkCalendarBackupPermission() && binding.calendar.isChecked()) ||
(checkContactBackupPermission() && binding.contacts.isChecked());
}
private boolean checkCalendarBackupPermission() {
return PermissionUtil.checkSelfPermission(getContext(), Manifest.permission.READ_CALENDAR);
}
private boolean checkContactBackupPermission() {
return PermissionUtil.checkSelfPermission(getContext(), Manifest.permission.READ_CONTACTS);
}
public void openCleanDate() {
openDate(null);
if (checkAndAskForCalendarReadPermission() && checkAndAskForContactsReadPermission()) {
openDate(null);
}
}
public void openDate(@Nullable Date savedDate) {
final ContactsPreferenceActivity contactsPreferenceActivity = (ContactsPreferenceActivity) getActivity();
String backupFolderString = getResources().getString(R.string.contacts_backup_folder) + OCFile.PATH_SEPARATOR;
OCFile backupFolder = contactsPreferenceActivity.getStorageManager().getFileByPath(backupFolderString);
if (contactsPreferenceActivity == null) {
Toast.makeText(getContext(), getString(R.string.error_choosing_date), Toast.LENGTH_LONG).show();
return;
}
List<OCFile> backupFiles = contactsPreferenceActivity.getStorageManager().getFolderContent(backupFolder,
false);
String contactsBackupFolderString =
getResources().getString(R.string.contacts_backup_folder) + OCFile.PATH_SEPARATOR;
String calendarBackupFolderString =
getResources().getString(R.string.calendar_backup_folder) + OCFile.PATH_SEPARATOR;
Collections.sort(backupFiles, new Comparator<OCFile>() {
@Override
public int compare(OCFile o1, OCFile o2) {
if (o1.getModificationTimestamp() == o2.getModificationTimestamp()) {
return 0;
}
FileDataStorageManager storageManager = contactsPreferenceActivity.getStorageManager();
if (o1.getModificationTimestamp() > o2.getModificationTimestamp()) {
return 1;
} else {
return -1;
}
}
OCFile contactsBackupFolder = storageManager.getFileByDecryptedRemotePath(contactsBackupFolderString);
OCFile calendarBackupFolder = storageManager.getFileByDecryptedRemotePath(calendarBackupFolderString);
List<OCFile> backupFiles = storageManager.getFolderContent(contactsBackupFolder, false);
backupFiles.addAll(storageManager.getFolderContent(calendarBackupFolder, false));
Collections.sort(backupFiles, (o1, o2) -> {
return Long.compare(o1.getModificationTimestamp(), o2.getModificationTimestamp());
});
Calendar cal = Calendar.getInstance();
@ -427,12 +507,7 @@ public class ContactsBackupFragment extends FileFragment implements DatePickerDi
.getModificationTimestamp());
datePickerDialog.getDatePicker().setMinDate(backupFiles.get(0).getModificationTimestamp());
datePickerDialog.setOnDismissListener(new DialogInterface.OnDismissListener() {
@Override
public void onDismiss(DialogInterface dialog) {
selectedDate = null;
}
});
datePickerDialog.setOnDismissListener(dialog -> selectedDate = null);
datePickerDialog.setTitle("");
datePickerDialog.show();
@ -480,12 +555,26 @@ public class ContactsBackupFragment extends FileFragment implements DatePickerDi
@Override
public void onDateSet(DatePicker view, int year, int month, int dayOfMonth) {
final ContactsPreferenceActivity contactsPreferenceActivity = (ContactsPreferenceActivity) getActivity();
if (contactsPreferenceActivity == null) {
Toast.makeText(getContext(), getString(R.string.error_choosing_date), Toast.LENGTH_LONG).show();
return;
}
selectedDate = new Date(year, month, dayOfMonth);
String backupFolderString = getResources().getString(R.string.contacts_backup_folder) + OCFile.PATH_SEPARATOR;
OCFile backupFolder = contactsPreferenceActivity.getStorageManager().getFileByPath(backupFolderString);
List<OCFile> backupFiles = contactsPreferenceActivity.getStorageManager().getFolderContent(
backupFolder, false);
String contactsBackupFolderString =
getResources().getString(R.string.contacts_backup_folder) + OCFile.PATH_SEPARATOR;
String calendarBackupFolderString =
getResources().getString(R.string.calendar_backup_folder) + OCFile.PATH_SEPARATOR;
FileDataStorageManager storageManager = contactsPreferenceActivity.getStorageManager();
OCFile contactsBackupFolder = storageManager.getFileByDecryptedRemotePath(contactsBackupFolderString);
OCFile calendarBackupFolder = storageManager.getFileByDecryptedRemotePath(calendarBackupFolderString);
List<OCFile> backupFiles = storageManager.getFolderContent(contactsBackupFolder, false);
backupFiles.addAll(storageManager.getFolderContent(calendarBackupFolder, false));
// find file with modification with date and time between 00:00 and 23:59
// if more than one file exists, take oldest
@ -498,38 +587,57 @@ public class ContactsBackupFragment extends FileFragment implements DatePickerDi
date.set(Calendar.SECOND, 1);
date.set(Calendar.MILLISECOND, 0);
date.set(Calendar.AM_PM, Calendar.AM);
Long start = date.getTimeInMillis();
long start = date.getTimeInMillis();
// end
date.set(Calendar.HOUR, 23);
date.set(Calendar.MINUTE, 59);
date.set(Calendar.SECOND, 59);
Long end = date.getTimeInMillis();
long end = date.getTimeInMillis();
OCFile backupToRestore = null;
OCFile contactsBackupToRestore = null;
List<OCFile> calendarBackupsToRestore = new ArrayList<>();
for (OCFile file : backupFiles) {
if (start < file.getModificationTimestamp() && end > file.getModificationTimestamp()) {
if (backupToRestore == null) {
backupToRestore = file;
} else if (backupToRestore.getModificationTimestamp() < file.getModificationTimestamp()) {
backupToRestore = file;
// contact
if (MimeTypeUtil.isVCard(file)) {
if (contactsBackupToRestore == null) {
contactsBackupToRestore = file;
} else if (contactsBackupToRestore.getModificationTimestamp() < file.getModificationTimestamp()) {
contactsBackupToRestore = file;
}
}
// calendars
if (MimeTypeUtil.isCalendar(file)) {
calendarBackupsToRestore.add(file);
}
}
}
if (backupToRestore != null) {
List<OCFile> backupToRestore = new ArrayList<>();
if (contactsBackupToRestore != null) {
backupToRestore.add(contactsBackupToRestore);
}
backupToRestore.addAll(calendarBackupsToRestore);
if (backupToRestore.isEmpty()) {
DisplayUtils.showSnackMessage(getView().findViewById(R.id.contacts_linear_layout),
R.string.contacts_preferences_no_file_found);
} else {
final User user = contactsPreferenceActivity.getUser().orElseThrow(RuntimeException::new);
Fragment contactListFragment = ContactListFragment.newInstance(backupToRestore, user);
OCFile[] files = new OCFile[backupToRestore.size()];
Fragment contactListFragment = BackupListFragment.newInstance(backupToRestore.toArray(files), user);
contactsPreferenceActivity.getSupportFragmentManager().
beginTransaction()
.replace(R.id.frame_container, contactListFragment, ContactListFragment.TAG)
.addToBackStack(ContactsPreferenceActivity.BACKUP_TO_LIST)
.commit();
} else {
DisplayUtils.showSnackMessage(getView().findViewById(R.id.contacts_linear_layout),
R.string.contacts_preferences_no_file_found);
beginTransaction()
.replace(R.id.frame_container, contactListFragment, BackupListFragment.TAG)
.addToBackStack(ContactsPreferenceActivity.BACKUP_TO_LIST)
.commit();
}
}
}

View file

@ -0,0 +1,403 @@
/*
*
* Nextcloud Android client application
*
* @author Tobias Kaminsky
* Copyright (C) 2021 Tobias Kaminsky
* Copyright (C) 2021 Nextcloud GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.owncloud.android.ui.fragment.contactsbackup
import android.annotation.SuppressLint
import android.content.Context
import android.content.res.Resources
import android.database.Cursor
import android.graphics.BitmapFactory
import android.graphics.PorterDuff
import android.graphics.drawable.Drawable
import android.provider.ContactsContract
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.CheckedTextView
import android.widget.ImageView
import com.afollestad.sectionedrecyclerview.SectionedRecyclerViewAdapter
import com.afollestad.sectionedrecyclerview.SectionedViewHolder
import com.bumptech.glide.request.animation.GlideAnimation
import com.bumptech.glide.request.target.SimpleTarget
import com.nextcloud.client.account.UserAccountManager
import com.nextcloud.client.network.ClientFactory
import com.owncloud.android.R
import com.owncloud.android.databinding.BackupListItemHeaderBinding
import com.owncloud.android.databinding.CalendarlistListItemBinding
import com.owncloud.android.databinding.ContactlistListItemBinding
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.lib.common.utils.Log_OC
import com.owncloud.android.ui.TextDrawable
import com.owncloud.android.ui.fragment.contactsbackup.BackupListFragment.getDisplayName
import com.owncloud.android.utils.BitmapUtils
import com.owncloud.android.utils.DisplayUtils
import com.owncloud.android.utils.theme.ThemeColorUtils
import ezvcard.VCard
import ezvcard.property.Photo
import third_parties.sufficientlysecure.AndroidCalendar
@Suppress("LongParameterList", "TooManyFunctions")
class BackupListAdapter(
val accountManager: UserAccountManager,
val clientFactory: ClientFactory,
private val checkedVCards: HashSet<Int> = HashSet(),
private val checkedCalendars: HashMap<String, Int> = HashMap(),
val backupListFragment: BackupListFragment,
val context: Context
) : SectionedRecyclerViewAdapter<SectionedViewHolder>() {
private val calendarFiles = arrayListOf<OCFile>()
private val contacts = arrayListOf<VCard>()
private var availableContactAccounts = listOf<ContactsAccount>()
companion object {
const val SECTION_CALENDAR = 0
const val SECTION_CONTACTS = 1
const val VIEW_TYPE_CALENDAR = 2
const val VIEW_TYPE_CONTACTS = 3
const val SINGLE_SELECTION = 1
const val SINGLE_ACCOUNT = 1
}
init {
shouldShowHeadersForEmptySections(false)
shouldShowFooters(false)
availableContactAccounts = getAccountForImport()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SectionedViewHolder {
return when (viewType) {
VIEW_TYPE_HEADER -> {
BackupListHeaderViewHolder(
BackupListItemHeaderBinding.inflate(
LayoutInflater.from(parent.context), parent, false
),
context
)
}
VIEW_TYPE_CONTACTS -> {
ContactItemViewHolder(
ContactlistListItemBinding.inflate(
LayoutInflater.from(parent.context), parent, false
)
)
}
else -> {
CalendarItemViewHolder(
CalendarlistListItemBinding.inflate(
LayoutInflater.from(parent.context), parent, false
),
context
)
}
}
}
override fun onBindViewHolder(
holder: SectionedViewHolder?,
section: Int,
relativePosition: Int,
absolutePosition: Int
) {
if (section == SECTION_CALENDAR) {
bindCalendarViewHolder(holder as CalendarItemViewHolder, relativePosition)
}
if (section == SECTION_CONTACTS) {
bindContactViewHolder(holder as ContactItemViewHolder, relativePosition)
}
}
override fun getItemCount(section: Int): Int {
return if (section == SECTION_CALENDAR) {
calendarFiles.size
} else {
contacts.size
}
}
override fun getSectionCount(): Int {
return 2
}
override fun getItemViewType(section: Int, relativePosition: Int, absolutePosition: Int): Int {
return if (section == SECTION_CALENDAR) {
VIEW_TYPE_CALENDAR
} else {
VIEW_TYPE_CONTACTS
}
}
override fun onBindHeaderViewHolder(holder: SectionedViewHolder?, section: Int, expanded: Boolean) {
val headerViewHolder = holder as BackupListHeaderViewHolder
headerViewHolder.binding.name.setTextColor(ThemeColorUtils.primaryColor(context))
if (section == SECTION_CALENDAR) {
headerViewHolder.binding.name.text = context.resources.getString(R.string.calendars)
headerViewHolder.binding.spinner.visibility = View.GONE
} else {
headerViewHolder.binding.name.text = context.resources.getString(R.string.contacts)
if (checkedVCards.isNotEmpty()) {
headerViewHolder.binding.spinner.visibility = View.VISIBLE
holder.binding.spinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
backupListFragment.setSelectedAccount(availableContactAccounts[position])
}
override fun onNothingSelected(parent: AdapterView<*>?) {
backupListFragment.setSelectedAccount(null)
}
}
headerViewHolder.setContactsAccount(availableContactAccounts)
}
}
}
override fun onBindFooterViewHolder(holder: SectionedViewHolder?, section: Int) {
// not needed
}
fun addCalendar(file: OCFile) {
calendarFiles.add(file)
notifyItemInserted(calendarFiles.size - 1)
}
@SuppressLint("NotifyDataSetChanged")
fun replaceVcards(vCards: MutableList<VCard>) {
contacts.clear()
contacts.addAll(vCards)
notifyDataSetChanged()
}
fun bindContactViewHolder(holder: ContactItemViewHolder, position: Int) {
val vCard = contacts[position]
setChecked(checkedVCards.contains(position), holder.binding.name)
holder.binding.name.text = getDisplayName(vCard)
// photo
if (vCard.photos.size > 0) {
setPhoto(holder.binding.icon, vCard.photos[0])
} else {
try {
holder.binding.icon.setImageDrawable(
TextDrawable.createNamedAvatar(
holder.binding.name.text.toString(),
context.resources.getDimension(R.dimen.list_item_avatar_icon_radius)
)
)
} catch (e: Resources.NotFoundException) {
holder.binding.icon.setImageResource(R.drawable.ic_user)
}
}
holder.setVCardListener { toggleVCard(holder, position) }
}
private fun setChecked(checked: Boolean, checkedTextView: CheckedTextView) {
checkedTextView.isChecked = checked
if (checked) {
checkedTextView.checkMarkDrawable
.setColorFilter(ThemeColorUtils.primaryColor(context), PorterDuff.Mode.SRC_ATOP)
} else {
checkedTextView.checkMarkDrawable.clearColorFilter()
}
}
private fun toggleVCard(holder: ContactItemViewHolder, position: Int) {
holder.binding.name.isChecked = !holder.binding.name.isChecked
if (holder.binding.name.isChecked) {
holder.binding.name.checkMarkDrawable.setColorFilter(
ThemeColorUtils.primaryColor(context),
PorterDuff.Mode.SRC_ATOP
)
checkedVCards.add(position)
} else {
holder.binding.name.checkMarkDrawable.clearColorFilter()
checkedVCards.remove(position)
}
showRestoreButton()
notifySectionChanged(SECTION_CONTACTS)
}
private fun setPhoto(imageView: ImageView, firstPhoto: Photo) {
val url = firstPhoto.url
val data = firstPhoto.data
if (data != null && data.isNotEmpty()) {
val thumbnail = BitmapFactory.decodeByteArray(data, 0, data.size)
val drawable = BitmapUtils.bitmapToCircularBitmapDrawable(
context.resources,
thumbnail
)
imageView.setImageDrawable(drawable)
} else if (url != null) {
val target = object : SimpleTarget<Drawable>() {
override fun onResourceReady(resource: Drawable?, glideAnimation: GlideAnimation<in Drawable>?) {
imageView.setImageDrawable(resource)
}
override fun onLoadFailed(e: java.lang.Exception?, errorDrawable: Drawable?) {
super.onLoadFailed(e, errorDrawable)
imageView.setImageDrawable(errorDrawable)
}
}
DisplayUtils.downloadIcon(
accountManager,
clientFactory,
context,
url,
target,
R.drawable.ic_user,
imageView.width,
imageView.height
)
}
}
private fun bindCalendarViewHolder(holder: CalendarItemViewHolder, position: Int) {
val ocFile: OCFile = calendarFiles[position]
setChecked(checkedCalendars.containsValue(position), holder.binding.name)
val name = ocFile.fileName
val calendarName = name.substring(0, name.indexOf("_"))
val date = name.substring(name.lastIndexOf("_") + 1).replace(".ics", "").replace("-", ":")
holder.binding.name.text = context.resources.getString(R.string.calendar_name_linewrap, calendarName, date)
holder.setCalendars(ArrayList(AndroidCalendar.loadAll(context.contentResolver)))
holder.binding.spinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View?, calendarPosition: Int, id: Long) {
checkedCalendars[calendarFiles[position].storagePath] = calendarPosition
}
override fun onNothingSelected(parent: AdapterView<*>?) {
checkedCalendars[calendarFiles[position].storagePath] = -1
}
}
holder.setListener { toggleCalendar(holder, position) }
}
private fun toggleCalendar(holder: CalendarItemViewHolder, position: Int) {
val checkedTextView = holder.binding.name
checkedTextView.isChecked = !checkedTextView.isChecked
if (checkedTextView.isChecked) {
checkedTextView.checkMarkDrawable.setColorFilter(
ThemeColorUtils.primaryColor(context),
PorterDuff.Mode.SRC_ATOP
)
holder.showCalendars(true)
checkedCalendars[calendarFiles[position].storagePath] = 0
} else {
checkedTextView.checkMarkDrawable.clearColorFilter()
checkedCalendars.remove(calendarFiles[position].storagePath)
holder.showCalendars(false)
}
showRestoreButton()
}
private fun showRestoreButton() {
val checkedEmpty = checkedCalendars.isEmpty() && checkedVCards.isEmpty()
val noCalendarAvailable =
checkedCalendars.isNotEmpty() && AndroidCalendar.loadAll(context.contentResolver).isEmpty()
if (checkedEmpty || noCalendarAvailable) {
backupListFragment.showRestoreButton(false)
} else {
backupListFragment.showRestoreButton(true)
}
}
fun getCheckedCalendarStringArray(): Array<String> {
return checkedCalendars.keys.toTypedArray()
}
fun getCheckedContactsIntArray(): IntArray {
return checkedVCards.toIntArray()
}
fun selectAll(selectAll: Boolean) {
if (selectAll) {
contacts.forEachIndexed { index, _ -> checkedVCards.add(index) }
} else {
checkedVCards.clear()
checkedCalendars.clear()
}
showRestoreButton()
}
fun getCheckedCalendarPathsArray(): Map<String, Int> {
return checkedCalendars
}
fun hasCalendarEntry(): Boolean {
return calendarFiles.isNotEmpty()
}
@Suppress("NestedBlockDepth", "TooGenericExceptionCaught")
private fun getAccountForImport(): List<ContactsAccount> {
val contactsAccounts = ArrayList<ContactsAccount>()
// add local one
contactsAccounts.add(ContactsAccount("Local contacts", null, null))
var cursor: Cursor? = null
try {
cursor = context.contentResolver.query(
ContactsContract.RawContacts.CONTENT_URI,
arrayOf(
ContactsContract.RawContacts.ACCOUNT_NAME,
ContactsContract.RawContacts.ACCOUNT_TYPE
),
null,
null,
null
)
if (cursor != null && cursor.count > 0) {
while (cursor.moveToNext()) {
val name = cursor.getString(cursor.getColumnIndex(ContactsContract.RawContacts.ACCOUNT_NAME))
val type = cursor.getString(cursor.getColumnIndex(ContactsContract.RawContacts.ACCOUNT_TYPE))
val account = ContactsAccount(name, name, type)
if (!contactsAccounts.contains(account)) {
contactsAccounts.add(account)
}
}
cursor.close()
}
} catch (e: Exception) {
Log_OC.d(BackupListFragment.TAG, e.message)
} finally {
cursor?.close()
}
return contactsAccounts
}
}

View file

@ -0,0 +1,490 @@
/*
* Nextcloud Android client application
*
* @author Tobias Kaminsky
* Copyright (C) 2017 Tobias Kaminsky
* Copyright (C) 2017 Nextcloud GmbH.
* Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
* <p>
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* at your option) any later version.
* <p>
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* <p>
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.owncloud.android.ui.fragment.contactsbackup;
import android.Manifest;
import android.app.Activity;
import android.os.Bundle;
import android.os.Parcelable;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Toast;
import com.google.android.material.snackbar.Snackbar;
import com.nextcloud.client.account.User;
import com.nextcloud.client.account.UserAccountManager;
import com.nextcloud.client.di.Injectable;
import com.nextcloud.client.files.downloader.DownloadRequest;
import com.nextcloud.client.files.downloader.Request;
import com.nextcloud.client.files.downloader.Transfer;
import com.nextcloud.client.files.downloader.TransferManagerConnection;
import com.nextcloud.client.files.downloader.TransferState;
import com.nextcloud.client.jobs.BackgroundJobManager;
import com.nextcloud.client.network.ClientFactory;
import com.owncloud.android.R;
import com.owncloud.android.databinding.BackuplistFragmentBinding;
import com.owncloud.android.datamodel.OCFile;
import com.owncloud.android.ui.activity.ContactsPreferenceActivity;
import com.owncloud.android.ui.asynctasks.LoadContactsTask;
import com.owncloud.android.ui.events.VCardToggleEvent;
import com.owncloud.android.ui.fragment.FileFragment;
import com.owncloud.android.utils.MimeTypeUtil;
import com.owncloud.android.utils.PermissionUtil;
import com.owncloud.android.utils.theme.ThemeColorUtils;
import com.owncloud.android.utils.theme.ThemeToolbarUtils;
import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import javax.inject.Inject;
import androidx.annotation.NonNull;
import androidx.appcompat.app.ActionBar;
import androidx.recyclerview.widget.LinearLayoutManager;
import ezvcard.VCard;
import kotlin.Unit;
/**
* This fragment shows all contacts or calendars from files and allows to import them.
*/
public class BackupListFragment extends FileFragment implements Injectable {
public static final String TAG = BackupListFragment.class.getSimpleName();
public static final String FILE_NAMES = "FILE_NAMES";
public static final String FILE_NAME = "FILE_NAME";
public static final String USER = "USER";
public static final String CHECKED_CALENDAR_ITEMS_ARRAY_KEY = "CALENDAR_CHECKED_ITEMS";
public static final String CHECKED_CONTACTS_ITEMS_ARRAY_KEY = "CONTACTS_CHECKED_ITEMS";
private BackuplistFragmentBinding binding;
private BackupListAdapter listAdapter;
private final List<VCard> vCards = new ArrayList<>();
private final List<OCFile> ocFiles = new ArrayList<>();
@Inject UserAccountManager accountManager;
@Inject ClientFactory clientFactory;
@Inject BackgroundJobManager backgroundJobManager;
private TransferManagerConnection fileDownloader;
private LoadContactsTask loadContactsTask = null;
private ContactsAccount selectedAccount;
public static BackupListFragment newInstance(OCFile file, User user) {
BackupListFragment frag = new BackupListFragment();
Bundle arguments = new Bundle();
arguments.putParcelable(FILE_NAME, file);
arguments.putParcelable(USER, user);
frag.setArguments(arguments);
return frag;
}
public static BackupListFragment newInstance(OCFile[] files, User user) {
BackupListFragment frag = new BackupListFragment();
Bundle arguments = new Bundle();
arguments.putParcelableArray(FILE_NAMES, files);
arguments.putParcelable(USER, user);
frag.setArguments(arguments);
return frag;
}
/**
* {@inheritDoc}
*/
@Override
public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) {
super.onCreateOptionsMenu(menu, inflater);
inflater.inflate(R.menu.fragment_contact_list, menu);
}
@Override
public View onCreateView(@NonNull final LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
binding = BackuplistFragmentBinding.inflate(inflater, container, false);
View view = binding.getRoot();
setHasOptionsMenu(true);
ContactsPreferenceActivity contactsPreferenceActivity = (ContactsPreferenceActivity) getActivity();
if (contactsPreferenceActivity != null) {
ActionBar actionBar = contactsPreferenceActivity.getSupportActionBar();
if (actionBar != null) {
ThemeToolbarUtils.setColoredTitle(actionBar, R.string.actionbar_calendar_contacts_restore, getContext());
actionBar.setDisplayHomeAsUpEnabled(true);
}
contactsPreferenceActivity.setDrawerIndicatorEnabled(false);
}
if (savedInstanceState == null) {
listAdapter = new BackupListAdapter(accountManager,
clientFactory,
new HashSet<>(),
new HashMap<>(),
this,
requireContext());
} else {
HashMap<String, Integer> checkedCalendarItems = new HashMap<>();
String[] checkedCalendarItemsArray = savedInstanceState.getStringArray(CHECKED_CALENDAR_ITEMS_ARRAY_KEY);
if (checkedCalendarItemsArray != null) {
for (String checkedItem : checkedCalendarItemsArray) {
checkedCalendarItems.put(checkedItem, -1);
}
}
if (checkedCalendarItems.size() > 0) {
showRestoreButton(true);
}
HashSet<Integer> checkedContactsItems = new HashSet<>();
int[] checkedContactsItemsArray = savedInstanceState.getIntArray(CHECKED_CONTACTS_ITEMS_ARRAY_KEY);
if (checkedContactsItemsArray != null) {
for (int checkedItem : checkedContactsItemsArray) {
checkedContactsItems.add(checkedItem);
}
}
if (checkedContactsItems.size() > 0) {
showRestoreButton(true);
}
listAdapter = new BackupListAdapter(accountManager,
clientFactory,
checkedContactsItems,
checkedCalendarItems,
this,
requireContext());
}
binding.list.setAdapter(listAdapter);
binding.list.setLayoutManager(new LinearLayoutManager(getContext()));
Bundle arguments = getArguments();
if (arguments == null) {
return view;
}
if (arguments.getParcelable(FILE_NAME) != null) {
ocFiles.add(arguments.getParcelable(FILE_NAME));
} else if (arguments.getParcelableArray(FILE_NAMES) != null) {
for (Parcelable file : arguments.getParcelableArray(FILE_NAMES)) {
ocFiles.add((OCFile) file);
}
} else {
return view;
}
User user = getArguments().getParcelable(USER);
fileDownloader = new TransferManagerConnection(getActivity(), user);
fileDownloader.registerTransferListener(this::onDownloadUpdate);
fileDownloader.bind();
for (OCFile file : ocFiles) {
if (!file.isDown()) {
Request request = new DownloadRequest(user, file);
fileDownloader.enqueue(request);
}
if (MimeTypeUtil.isVCard(file) && file.isDown()) {
setFile(file);
loadContactsTask = new LoadContactsTask(this, file);
loadContactsTask.execute();
}
if (MimeTypeUtil.isCalendar(file) && file.isDown()) {
showLoadingMessage(false);
listAdapter.addCalendar(file);
}
}
binding.restoreSelected.setOnClickListener(v -> {
if (checkAndAskForCalendarWritePermission()) {
importCalendar();
}
if (listAdapter.getCheckedContactsIntArray().length > 0 && checkAndAskForContactsWritePermission()) {
importContacts(selectedAccount);
return;
}
Snackbar
.make(
binding.list,
R.string.contacts_preferences_import_scheduled,
Snackbar.LENGTH_LONG
)
.show();
closeFragment();
});
binding.restoreSelected.setTextColor(ThemeColorUtils.primaryAccentColor(getContext()));
return view;
}
@Override
public void onDetach() {
super.onDetach();
if (fileDownloader != null) {
fileDownloader.unbind();
}
}
@Override
public void onSaveInstanceState(@NonNull Bundle outState) {
super.onSaveInstanceState(outState);
outState.putStringArray(CHECKED_CALENDAR_ITEMS_ARRAY_KEY, listAdapter.getCheckedCalendarStringArray());
outState.putIntArray(CHECKED_CONTACTS_ITEMS_ARRAY_KEY, listAdapter.getCheckedContactsIntArray());
}
@Subscribe(threadMode = ThreadMode.MAIN)
public void onMessageEvent(VCardToggleEvent event) {
if (event.showRestoreButton) {
binding.contactlistRestoreSelectedContainer.setVisibility(View.VISIBLE);
} else {
binding.contactlistRestoreSelectedContainer.setVisibility(View.GONE);
}
}
public void showRestoreButton(boolean show) {
binding.contactlistRestoreSelectedContainer.setVisibility(show ? View.VISIBLE : View.GONE);
}
@Override
public void onDestroy() {
super.onDestroy();
ContactsPreferenceActivity contactsPreferenceActivity = (ContactsPreferenceActivity) getActivity();
contactsPreferenceActivity.setDrawerIndicatorEnabled(true);
}
@Override
public void onDestroyView() {
super.onDestroyView();
binding = null;
}
public void onResume() {
super.onResume();
ContactsPreferenceActivity contactsPreferenceActivity = (ContactsPreferenceActivity) getActivity();
contactsPreferenceActivity.setDrawerIndicatorEnabled(false);
}
@Override
public void onStart() {
super.onStart();
EventBus.getDefault().register(this);
}
@Override
public void onStop() {
EventBus.getDefault().unregister(this);
if (loadContactsTask != null) {
loadContactsTask.cancel(true);
}
super.onStop();
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
boolean retval;
int itemId = item.getItemId();
if (itemId == android.R.id.home) {
closeFragment();
retval = true;
} else if (itemId == R.id.action_select_all) {
item.setChecked(!item.isChecked());
setSelectAllMenuItem(item, item.isChecked());
listAdapter.selectAll(item.isChecked());
retval = true;
} else {
retval = super.onOptionsItemSelected(item);
}
return retval;
}
public void showLoadingMessage(boolean showIt) {
binding.loadingListContainer.setVisibility(showIt ? View.VISIBLE : View.GONE);
}
private void setSelectAllMenuItem(MenuItem selectAll, boolean checked) {
selectAll.setChecked(checked);
if (checked) {
selectAll.setIcon(R.drawable.ic_select_none);
} else {
selectAll.setIcon(R.drawable.ic_select_all);
}
}
private void importContacts(ContactsAccount account) {
backgroundJobManager.startImmediateContactsImport(account.getName(),
account.getType(),
getFile().getStoragePath(),
listAdapter.getCheckedContactsIntArray());
Snackbar
.make(
binding.list,
R.string.contacts_preferences_import_scheduled,
Snackbar.LENGTH_LONG
)
.show();
closeFragment();
}
private void importCalendar() {
backgroundJobManager.startImmediateCalendarImport(listAdapter.getCheckedCalendarPathsArray());
Snackbar
.make(
binding.list,
R.string.contacts_preferences_import_scheduled,
Snackbar.LENGTH_LONG
)
.show();
closeFragment();
}
private void closeFragment() {
ContactsPreferenceActivity contactsPreferenceActivity = (ContactsPreferenceActivity) getActivity();
if (contactsPreferenceActivity != null) {
contactsPreferenceActivity.onBackPressed();
}
}
private boolean checkAndAskForContactsWritePermission() {
// check permissions
if (!PermissionUtil.checkSelfPermission(getContext(), Manifest.permission.WRITE_CONTACTS)) {
requestPermissions(new String[]{Manifest.permission.WRITE_CONTACTS},
PermissionUtil.PERMISSIONS_WRITE_CONTACTS);
return false;
} else {
return true;
}
}
private boolean checkAndAskForCalendarWritePermission() {
// check permissions
if (!PermissionUtil.checkSelfPermission(getContext(), Manifest.permission.WRITE_CALENDAR)) {
requestPermissions(new String[]{Manifest.permission.WRITE_CALENDAR},
PermissionUtil.PERMISSIONS_WRITE_CALENDAR);
return false;
} else {
return true;
}
}
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (requestCode == PermissionUtil.PERMISSIONS_WRITE_CONTACTS) {
for (int index = 0; index < permissions.length; index++) {
if (Manifest.permission.WRITE_CONTACTS.equalsIgnoreCase(permissions[index])) {
if (grantResults[index] >= 0) {
importContacts(selectedAccount);
} else {
if (getView() != null) {
Snackbar.make(getView(), R.string.contactlist_no_permission, Snackbar.LENGTH_LONG)
.show();
} else {
Toast.makeText(getContext(), R.string.contactlist_no_permission, Toast.LENGTH_LONG).show();
}
}
break;
}
}
}
if (requestCode == PermissionUtil.PERMISSIONS_WRITE_CALENDAR) {
for (int index = 0; index < permissions.length; index++) {
if (Manifest.permission.WRITE_CALENDAR.equalsIgnoreCase(permissions[index])) {
if (grantResults[index] >= 0) {
importContacts(selectedAccount);
} else {
if (getView() != null) {
Snackbar.make(getView(), R.string.contactlist_no_permission, Snackbar.LENGTH_LONG)
.show();
} else {
Toast.makeText(getContext(), R.string.contactlist_no_permission, Toast.LENGTH_LONG).show();
}
}
break;
}
}
}
}
private Unit onDownloadUpdate(Transfer download) {
final Activity activity = getActivity();
if (download.getState() == TransferState.COMPLETED && activity != null) {
OCFile ocFile = download.getFile();
if (MimeTypeUtil.isVCard(ocFile)) {
setFile(ocFile);
loadContactsTask = new LoadContactsTask(this, ocFile);
loadContactsTask.execute();
}
}
return Unit.INSTANCE;
}
public void loadVCards(List<VCard> cards) {
showLoadingMessage(false);
vCards.clear();
vCards.addAll(cards);
listAdapter.replaceVcards(vCards);
}
public static String getDisplayName(VCard vCard) {
if (vCard.getFormattedName() != null) {
return vCard.getFormattedName().getValue();
} else if (vCard.getTelephoneNumbers() != null && vCard.getTelephoneNumbers().size() > 0) {
return vCard.getTelephoneNumbers().get(0).getText();
} else if (vCard.getEmails() != null && vCard.getEmails().size() > 0) {
return vCard.getEmails().get(0).getValue();
}
return "";
}
public boolean hasCalendarEntry() {
return listAdapter.hasCalendarEntry();
}
public void setSelectedAccount(ContactsAccount account) {
selectedAccount = account;
}
}

View file

@ -0,0 +1,49 @@
/*
*
* Nextcloud Android client application
*
* @author Tobias Kaminsky
* Copyright (C) 2021 Tobias Kaminsky
* Copyright (C) 2021 Nextcloud GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.owncloud.android.ui.fragment.contactsbackup
import android.content.Context
import android.widget.ArrayAdapter
import com.afollestad.sectionedrecyclerview.SectionedViewHolder
import com.owncloud.android.databinding.BackupListItemHeaderBinding
import java.util.ArrayList
class BackupListHeaderViewHolder(
val binding: BackupListItemHeaderBinding,
val context: Context
) : SectionedViewHolder(binding.root) {
val adapter = ArrayAdapter<ContactsAccount?>(
context,
android.R.layout.simple_spinner_dropdown_item,
ArrayList()
)
init {
binding.spinner.adapter = adapter
}
fun setContactsAccount(accounts: List<ContactsAccount>) {
adapter.clear()
adapter.addAll(accounts)
}
}

View file

@ -0,0 +1,28 @@
/*
*
* Nextcloud Android client application
*
* @author Tobias Kaminsky
* Copyright (C) 2021 Tobias Kaminsky
* Copyright (C) 2021 Nextcloud GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.owncloud.android.ui.fragment.contactsbackup
import com.afollestad.sectionedrecyclerview.SectionedViewHolder
import com.owncloud.android.databinding.BackupListItemBinding
class BackupListItemViewHolder(val binding: BackupListItemBinding) : SectionedViewHolder(binding.root)

View file

@ -0,0 +1,79 @@
/*
*
* Nextcloud Android client application
*
* @author Tobias Kaminsky
* Copyright (C) 2021 Tobias Kaminsky
* Copyright (C) 2021 Nextcloud GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.owncloud.android.ui.fragment.contactsbackup;
import android.content.Context;
import android.view.View;
import android.widget.ArrayAdapter;
import android.widget.Toast;
import com.afollestad.sectionedrecyclerview.SectionedViewHolder;
import com.owncloud.android.R;
import com.owncloud.android.databinding.CalendarlistListItemBinding;
import java.util.ArrayList;
import third_parties.sufficientlysecure.AndroidCalendar;
class CalendarItemViewHolder extends SectionedViewHolder {
public CalendarlistListItemBinding binding;
private final ArrayAdapter<AndroidCalendar> adapter;
private final Context context;
CalendarItemViewHolder(CalendarlistListItemBinding binding, Context context) {
super(binding.getRoot());
this.binding = binding;
this.context = context;
adapter = new ArrayAdapter<>(context,
android.R.layout.simple_spinner_dropdown_item,
new ArrayList<>());
binding.spinner.setAdapter(adapter);
}
public void setCalendars(ArrayList<AndroidCalendar> calendars) {
adapter.clear();
adapter.addAll(calendars);
}
public void setListener(View.OnClickListener onClickListener) {
itemView.setOnClickListener(onClickListener);
}
public void showCalendars(boolean show) {
if (show) {
if (adapter.isEmpty()) {
Toast.makeText(context,
context.getResources().getString(R.string.no_calendar_exists),
Toast.LENGTH_LONG)
.show();
} else {
binding.spinner.setVisibility(View.VISIBLE);
}
} else {
binding.spinner.setVisibility(View.GONE);
}
}
}

View file

@ -0,0 +1,43 @@
/*
*
* Nextcloud Android client application
*
* @author Tobias Kaminsky
* Copyright (C) 2021 Tobias Kaminsky
* Copyright (C) 2021 Nextcloud GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.owncloud.android.ui.fragment.contactsbackup;
import android.view.View;
import com.afollestad.sectionedrecyclerview.SectionedViewHolder;
import com.owncloud.android.databinding.ContactlistListItemBinding;
public class ContactItemViewHolder extends SectionedViewHolder {
public ContactlistListItemBinding binding;
ContactItemViewHolder(ContactlistListItemBinding binding) {
super(binding.getRoot());
this.binding = binding;
binding.getRoot().setTag(this);
}
public void setVCardListener(View.OnClickListener onClickListener) {
itemView.setOnClickListener(onClickListener);
}
}

View file

@ -0,0 +1,250 @@
/*
*
* Nextcloud Android client application
*
* @author Tobias Kaminsky
* Copyright (C) 2021 Tobias Kaminsky
* Copyright (C) 2021 Nextcloud GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.owncloud.android.ui.fragment.contactsbackup;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.PorterDuff;
import android.graphics.drawable.Drawable;
import android.view.LayoutInflater;
import android.view.ViewGroup;
import android.widget.CheckedTextView;
import android.widget.ImageView;
import com.bumptech.glide.request.animation.GlideAnimation;
import com.bumptech.glide.request.target.SimpleTarget;
import com.nextcloud.client.account.UserAccountManager;
import com.nextcloud.client.network.ClientFactory;
import com.owncloud.android.R;
import com.owncloud.android.databinding.ContactlistListItemBinding;
import com.owncloud.android.ui.TextDrawable;
import com.owncloud.android.ui.events.VCardToggleEvent;
import com.owncloud.android.utils.BitmapUtils;
import com.owncloud.android.utils.DisplayUtils;
import com.owncloud.android.utils.theme.ThemeColorUtils;
import org.greenrobot.eventbus.EventBus;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import androidx.annotation.NonNull;
import androidx.core.graphics.drawable.RoundedBitmapDrawable;
import androidx.recyclerview.widget.RecyclerView;
import ezvcard.VCard;
import ezvcard.property.Photo;
import static com.owncloud.android.ui.fragment.contactsbackup.BackupListFragment.getDisplayName;
class ContactListAdapter extends RecyclerView.Adapter<ContactItemViewHolder> {
private static final int SINGLE_SELECTION = 1;
private List<VCard> vCards;
private Set<Integer> checkedVCards;
private Context context;
private UserAccountManager accountManager;
private ClientFactory clientFactory;
ContactListAdapter(UserAccountManager accountManager, ClientFactory clientFactory, Context context,
List<VCard> vCards) {
this.vCards = vCards;
this.context = context;
this.checkedVCards = new HashSet<>();
this.accountManager = accountManager;
this.clientFactory = clientFactory;
}
ContactListAdapter(UserAccountManager accountManager,
Context context,
List<VCard> vCards,
Set<Integer> checkedVCards) {
this.vCards = vCards;
this.context = context;
this.checkedVCards = checkedVCards;
this.accountManager = accountManager;
}
public int getCheckedCount() {
if (checkedVCards != null) {
return checkedVCards.size();
} else {
return 0;
}
}
public void replaceVCards(List<VCard> vCards) {
this.vCards = vCards;
notifyDataSetChanged();
}
public int[] getCheckedIntArray() {
int[] intArray;
if (checkedVCards != null && checkedVCards.size() > 0) {
intArray = new int[checkedVCards.size()];
int i = 0;
for (int position : checkedVCards) {
intArray[i] = position;
i++;
}
return intArray;
} else {
return new int[0];
}
}
@NonNull
@Override
public ContactItemViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
return new ContactItemViewHolder(ContactlistListItemBinding.inflate(LayoutInflater.from(parent.getContext()),
parent,
false));
}
@Override
public void onBindViewHolder(@NonNull final ContactItemViewHolder holder, final int position) {
final int verifiedPosition = holder.getAdapterPosition();
final VCard vcard = vCards.get(verifiedPosition);
if (vcard != null) {
setChecked(checkedVCards.contains(position), holder.binding.name);
holder.binding.name.setText(getDisplayName(vcard));
// photo
if (vcard.getPhotos().size() > 0) {
setPhoto(holder.binding.icon, vcard.getPhotos().get(0));
} else {
try {
holder.binding.icon.setImageDrawable(
TextDrawable.createNamedAvatar(
holder.binding.name.getText().toString(),
context.getResources().getDimension(R.dimen.list_item_avatar_icon_radius)
)
);
} catch (Exception e) {
holder.binding.icon.setImageResource(R.drawable.ic_user);
}
}
holder.setVCardListener(v -> toggleVCard(holder, verifiedPosition));
}
}
private void setPhoto(ImageView imageView, Photo firstPhoto) {
String url = firstPhoto.getUrl();
byte[] data = firstPhoto.getData();
if (data != null && data.length > 0) {
Bitmap thumbnail = BitmapFactory.decodeByteArray(data, 0, data.length);
RoundedBitmapDrawable drawable = BitmapUtils.bitmapToCircularBitmapDrawable(context.getResources(),
thumbnail);
imageView.setImageDrawable(drawable);
} else if (url != null) {
SimpleTarget target = new SimpleTarget<Drawable>() {
@Override
public void onResourceReady(Drawable resource, GlideAnimation glideAnimation) {
imageView.setImageDrawable(resource);
}
@Override
public void onLoadFailed(Exception e, Drawable errorDrawable) {
super.onLoadFailed(e, errorDrawable);
imageView.setImageDrawable(errorDrawable);
}
};
DisplayUtils.downloadIcon(accountManager,
clientFactory,
context,
url,
target,
R.drawable.ic_user,
imageView.getWidth(),
imageView.getHeight());
}
}
private void setChecked(boolean checked, CheckedTextView checkedTextView) {
checkedTextView.setChecked(checked);
if (checked) {
checkedTextView.getCheckMarkDrawable()
.setColorFilter(ThemeColorUtils.primaryColor(context), PorterDuff.Mode.SRC_ATOP);
} else {
checkedTextView.getCheckMarkDrawable().clearColorFilter();
}
}
private void toggleVCard(ContactItemViewHolder holder, int verifiedPosition) {
holder.binding.name.setChecked(!holder.binding.name.isChecked());
if (holder.binding.name.isChecked()) {
holder.binding.name.getCheckMarkDrawable().setColorFilter(ThemeColorUtils.primaryColor(context),
PorterDuff.Mode.SRC_ATOP);
checkedVCards.add(verifiedPosition);
if (checkedVCards.size() == SINGLE_SELECTION) {
EventBus.getDefault().post(new VCardToggleEvent(true));
}
} else {
holder.binding.name.getCheckMarkDrawable().clearColorFilter();
checkedVCards.remove(verifiedPosition);
if (checkedVCards.isEmpty()) {
EventBus.getDefault().post(new VCardToggleEvent(false));
}
}
}
@Override
public int getItemCount() {
return vCards.size();
}
public void selectAllFiles(boolean select) {
checkedVCards = new HashSet<>();
if (select) {
for (int i = 0; i < vCards.size(); i++) {
checkedVCards.add(i);
}
}
if (checkedVCards.size() > 0) {
EventBus.getDefault().post(new VCardToggleEvent(true));
} else {
EventBus.getDefault().post(new VCardToggleEvent(false));
}
notifyDataSetChanged();
}
public boolean isEmpty() {
return getItemCount() == 0;
}
}

View file

@ -1,735 +0,0 @@
/*
* Nextcloud Android client application
*
* @author Tobias Kaminsky
* Copyright (C) 2017 Tobias Kaminsky
* Copyright (C) 2017 Nextcloud GmbH.
* Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
* <p>
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* at your option) any later version.
* <p>
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* <p>
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.owncloud.android.ui.fragment.contactsbackup;
import android.Manifest;
import android.app.Activity;
import android.content.Context;
import android.content.DialogInterface;
import android.database.Cursor;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.PorterDuff;
import android.graphics.drawable.Drawable;
import android.os.AsyncTask;
import android.os.Bundle;
import android.os.Handler;
import android.provider.ContactsContract;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.CheckedTextView;
import android.widget.ImageView;
import android.widget.Toast;
import com.bumptech.glide.request.animation.GlideAnimation;
import com.bumptech.glide.request.target.SimpleTarget;
import com.google.android.material.snackbar.Snackbar;
import com.nextcloud.client.account.User;
import com.nextcloud.client.account.UserAccountManager;
import com.nextcloud.client.di.Injectable;
import com.nextcloud.client.files.downloader.Direction;
import com.nextcloud.client.files.downloader.DownloadRequest;
import com.nextcloud.client.files.downloader.Request;
import com.nextcloud.client.files.downloader.Transfer;
import com.nextcloud.client.files.downloader.TransferManagerConnection;
import com.nextcloud.client.files.downloader.TransferState;
import com.nextcloud.client.jobs.BackgroundJobManager;
import com.nextcloud.client.network.ClientFactory;
import com.owncloud.android.R;
import com.owncloud.android.databinding.ContactlistFragmentBinding;
import com.owncloud.android.datamodel.OCFile;
import com.owncloud.android.lib.common.utils.Log_OC;
import com.owncloud.android.ui.TextDrawable;
import com.owncloud.android.ui.activity.ContactsPreferenceActivity;
import com.owncloud.android.ui.events.VCardToggleEvent;
import com.owncloud.android.ui.fragment.FileFragment;
import com.owncloud.android.utils.BitmapUtils;
import com.owncloud.android.utils.DisplayUtils;
import com.owncloud.android.utils.PermissionUtil;
import com.owncloud.android.utils.theme.ThemeColorUtils;
import com.owncloud.android.utils.theme.ThemeToolbarUtils;
import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import javax.inject.Inject;
import androidx.annotation.NonNull;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AlertDialog;
import androidx.core.graphics.drawable.RoundedBitmapDrawable;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import ezvcard.Ezvcard;
import ezvcard.VCard;
import ezvcard.property.Photo;
import kotlin.Unit;
import static com.owncloud.android.ui.fragment.contactsbackup.ContactListFragment.getDisplayName;
/**
* This fragment shows all contacts from a file and allows to import them.
*/
public class ContactListFragment extends FileFragment implements Injectable {
public static final String TAG = ContactListFragment.class.getSimpleName();
public static final String FILE_NAME = "FILE_NAME";
public static final String USER = "USER";
public static final String CHECKED_ITEMS_ARRAY_KEY = "CHECKED_ITEMS";
private static final int SINGLE_ACCOUNT = 1;
private ContactlistFragmentBinding binding;
private ContactListAdapter contactListAdapter;
private final List<VCard> vCards = new ArrayList<>();
private OCFile ocFile;
@Inject UserAccountManager accountManager;
@Inject ClientFactory clientFactory;
@Inject BackgroundJobManager backgroundJobManager;
private TransferManagerConnection fileDownloader;
public static ContactListFragment newInstance(OCFile file, User user) {
ContactListFragment frag = new ContactListFragment();
Bundle arguments = new Bundle();
arguments.putParcelable(FILE_NAME, file);
arguments.putParcelable(USER, user);
frag.setArguments(arguments);
return frag;
}
/**
* {@inheritDoc}
*/
@Override
public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) {
super.onCreateOptionsMenu(menu, inflater);
inflater.inflate(R.menu.fragment_contact_list, menu);
}
@Override
public View onCreateView(@NonNull final LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
binding = ContactlistFragmentBinding.inflate(inflater, container, false);
View view = binding.getRoot();
setHasOptionsMenu(true);
ContactsPreferenceActivity contactsPreferenceActivity = (ContactsPreferenceActivity) getActivity();
if (contactsPreferenceActivity != null) {
ActionBar actionBar = contactsPreferenceActivity.getSupportActionBar();
if (actionBar != null) {
ThemeToolbarUtils.setColoredTitle(actionBar, R.string.actionbar_contacts_restore, getContext());
actionBar.setDisplayHomeAsUpEnabled(true);
}
contactsPreferenceActivity.setDrawerIndicatorEnabled(false);
}
if (savedInstanceState == null) {
contactListAdapter = new ContactListAdapter(accountManager, clientFactory, getContext(), vCards);
} else {
Set<Integer> checkedItems = new HashSet<>();
int[] itemsArray = savedInstanceState.getIntArray(CHECKED_ITEMS_ARRAY_KEY);
if (itemsArray != null) {
for (int checkedItem : itemsArray) {
checkedItems.add(checkedItem);
}
}
if (checkedItems.size() > 0) {
onMessageEvent(new VCardToggleEvent(true));
}
contactListAdapter = new ContactListAdapter(accountManager, getContext(), vCards, checkedItems);
}
binding.contactlistRecyclerview.setAdapter(contactListAdapter);
binding.contactlistRecyclerview.setLayoutManager(new LinearLayoutManager(getContext()));
ocFile = getArguments().getParcelable(FILE_NAME);
setFile(ocFile);
User user = getArguments().getParcelable(USER);
fileDownloader = new TransferManagerConnection(getActivity(), user);
fileDownloader.registerTransferListener(this::onDownloadUpdate);
fileDownloader.bind();
if (!ocFile.isDown()) {
Request request = new DownloadRequest(user, ocFile);
fileDownloader.enqueue(request);
} else {
loadContactsTask.execute();
}
binding.contactlistRestoreSelected.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
if (checkAndAskForContactsWritePermission()) {
getAccountForImport();
}
}
});
binding.contactlistRestoreSelected.setTextColor(ThemeColorUtils.primaryAccentColor(getContext()));
return view;
}
@Override
public void onDetach() {
super.onDetach();
if (fileDownloader != null) {
fileDownloader.unbind();
}
}
@Override
public void onSaveInstanceState(@NonNull Bundle outState) {
super.onSaveInstanceState(outState);
outState.putIntArray(CHECKED_ITEMS_ARRAY_KEY, contactListAdapter.getCheckedIntArray());
}
@Subscribe(threadMode = ThreadMode.MAIN)
public void onMessageEvent(VCardToggleEvent event) {
if (event.showRestoreButton) {
binding.contactlistRestoreSelectedContainer.setVisibility(View.VISIBLE);
} else {
binding.contactlistRestoreSelectedContainer.setVisibility(View.GONE);
}
}
@Override
public void onDestroy() {
super.onDestroy();
ContactsPreferenceActivity contactsPreferenceActivity = (ContactsPreferenceActivity) getActivity();
contactsPreferenceActivity.setDrawerIndicatorEnabled(true);
}
@Override
public void onDestroyView() {
super.onDestroyView();
binding = null;
}
public void onResume() {
super.onResume();
ContactsPreferenceActivity contactsPreferenceActivity = (ContactsPreferenceActivity) getActivity();
contactsPreferenceActivity.setDrawerIndicatorEnabled(false);
}
@Override
public void onStart() {
super.onStart();
EventBus.getDefault().register(this);
}
@Override
public void onStop() {
EventBus.getDefault().unregister(this);
if (loadContactsTask != null) {
loadContactsTask.cancel(true);
}
super.onStop();
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
boolean retval;
int itemId = item.getItemId();
if (itemId == android.R.id.home) {
ContactsPreferenceActivity contactsPreferenceActivity = (ContactsPreferenceActivity) getActivity();
if (contactsPreferenceActivity != null) {
contactsPreferenceActivity.onBackPressed();
}
retval = true;
} else if (itemId == R.id.action_select_all) {
item.setChecked(!item.isChecked());
setSelectAllMenuItem(item, item.isChecked());
contactListAdapter.selectAllFiles(item.isChecked());
retval = true;
} else {
retval = super.onOptionsItemSelected(item);
}
return retval;
}
private void setLoadingMessage() {
binding.loadingListContainer.setVisibility(View.VISIBLE);
}
private void setSelectAllMenuItem(MenuItem selectAll, boolean checked) {
selectAll.setChecked(checked);
if (checked) {
selectAll.setIcon(R.drawable.ic_select_none);
} else {
selectAll.setIcon(R.drawable.ic_select_all);
}
}
static class ContactItemViewHolder extends RecyclerView.ViewHolder {
private ImageView badge;
private CheckedTextView name;
ContactItemViewHolder(View itemView) {
super(itemView);
badge = itemView.findViewById(R.id.contactlist_item_icon);
name = itemView.findViewById(R.id.contactlist_item_name);
itemView.setTag(this);
}
public void setVCardListener(View.OnClickListener onClickListener) {
itemView.setOnClickListener(onClickListener);
}
public ImageView getBadge() {
return badge;
}
public void setBadge(ImageView badge) {
this.badge = badge;
}
public CheckedTextView getName() {
return name;
}
public void setName(CheckedTextView name) {
this.name = name;
}
}
private void importContacts(ContactsAccount account) {
backgroundJobManager.startImmediateContactsImport(account.name,
account.type,
getFile().getStoragePath(),
contactListAdapter.getCheckedIntArray());
Snackbar
.make(
binding.contactlistRecyclerview,
R.string.contacts_preferences_import_scheduled,
Snackbar.LENGTH_LONG
)
.show();
Handler handler = new Handler();
handler.postDelayed(new Runnable() {
@Override
public void run() {
if (getFragmentManager().getBackStackEntryCount() > 0) {
getFragmentManager().popBackStack();
} else {
getActivity().finish();
}
}
}, 1750);
}
private void getAccountForImport() {
final ArrayList<ContactsAccount> contactsAccounts = new ArrayList<>();
// add local one
contactsAccounts.add(new ContactsAccount("Local contacts", null, null));
Cursor cursor = null;
try {
cursor = getContext().getContentResolver().query(ContactsContract.RawContacts.CONTENT_URI,
new String[]{ContactsContract.RawContacts.ACCOUNT_NAME, ContactsContract.RawContacts.ACCOUNT_TYPE},
null,
null,
null);
if (cursor != null && cursor.getCount() > 0) {
while (cursor.moveToNext()) {
String name = cursor.getString(cursor.getColumnIndex(ContactsContract.RawContacts.ACCOUNT_NAME));
String type = cursor.getString(cursor.getColumnIndex(ContactsContract.RawContacts.ACCOUNT_TYPE));
ContactsAccount account = new ContactsAccount(name, name, type);
if (!contactsAccounts.contains(account)) {
contactsAccounts.add(account);
}
}
cursor.close();
}
} catch (Exception e) {
Log_OC.d(TAG, e.getMessage());
} finally {
if (cursor != null) {
cursor.close();
}
}
if (contactsAccounts.size() == SINGLE_ACCOUNT) {
importContacts(contactsAccounts.get(0));
} else {
ArrayAdapter adapter = new ArrayAdapter<>(getContext(), android.R.layout.simple_list_item_1, contactsAccounts);
AlertDialog.Builder builder = new AlertDialog.Builder(getContext());
builder.setTitle(R.string.contactlist_account_chooser_title)
.setAdapter(adapter, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
importContacts(contactsAccounts.get(which));
}
}).show();
}
}
private boolean checkAndAskForContactsWritePermission() {
// check permissions
if (!PermissionUtil.checkSelfPermission(getContext(), Manifest.permission.WRITE_CONTACTS)) {
requestPermissions(new String[]{Manifest.permission.WRITE_CONTACTS},
PermissionUtil.PERMISSIONS_WRITE_CONTACTS);
return false;
} else {
return true;
}
}
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (requestCode == PermissionUtil.PERMISSIONS_WRITE_CONTACTS) {
for (int index = 0; index < permissions.length; index++) {
if (Manifest.permission.WRITE_CONTACTS.equalsIgnoreCase(permissions[index])) {
if (grantResults[index] >= 0) {
getAccountForImport();
} else {
if (getView() != null) {
Snackbar.make(getView(), R.string.contactlist_no_permission, Snackbar.LENGTH_LONG)
.show();
} else {
Toast.makeText(getContext(), R.string.contactlist_no_permission, Toast.LENGTH_LONG).show();
}
}
break;
}
}
}
}
private class ContactsAccount {
private String displayName;
private String name;
private String type;
ContactsAccount(String displayName, String name, String type) {
this.displayName = displayName;
this.name = name;
this.type = type;
}
@Override
public boolean equals(Object obj) {
if (obj instanceof ContactsAccount) {
ContactsAccount other = (ContactsAccount) obj;
return this.name.equalsIgnoreCase(other.name) && this.type.equalsIgnoreCase(other.type);
} else {
return false;
}
}
@NonNull
@Override
public String toString() {
return displayName;
}
@Override
public int hashCode() {
return Arrays.hashCode(new Object[]{displayName, name, type});
}
}
private Unit onDownloadUpdate(Transfer download) {
final Activity activity = getActivity();
if (download.getState() == TransferState.COMPLETED && activity != null) {
ocFile = download.getFile();
loadContactsTask.execute();
}
return Unit.INSTANCE;
}
public static class VCardComparator implements Comparator<VCard> {
@Override
public int compare(VCard o1, VCard o2) {
String contac1 = getDisplayName(o1);
String contac2 = getDisplayName(o2);
return contac1.compareToIgnoreCase(contac2);
}
}
private AsyncTask<Void, Void, Boolean> loadContactsTask = new AsyncTask<Void, Void, Boolean>() {
@Override
protected void onPreExecute() {
setLoadingMessage();
}
@Override
protected Boolean doInBackground(Void... voids) {
if (!isCancelled()) {
File file = new File(ocFile.getStoragePath());
try {
vCards.addAll(Ezvcard.parse(file).all());
Collections.sort(vCards, new VCardComparator());
} catch (IOException e) {
Log_OC.e(TAG, "IO Exception: " + file.getAbsolutePath());
return Boolean.FALSE;
}
return Boolean.TRUE;
}
return Boolean.FALSE;
}
@Override
protected void onPostExecute(Boolean bool) {
if (!isCancelled()) {
binding.loadingListContainer.setVisibility(View.GONE);
contactListAdapter.replaceVCards(vCards);
}
}
};
public static String getDisplayName(VCard vCard) {
if (vCard.getFormattedName() != null) {
return vCard.getFormattedName().getValue();
} else if (vCard.getTelephoneNumbers() != null && vCard.getTelephoneNumbers().size() > 0) {
return vCard.getTelephoneNumbers().get(0).getText();
} else if (vCard.getEmails() != null && vCard.getEmails().size() > 0) {
return vCard.getEmails().get(0).getValue();
}
return "";
}
}
class ContactListAdapter extends RecyclerView.Adapter<ContactListFragment.ContactItemViewHolder> {
private static final int SINGLE_SELECTION = 1;
private List<VCard> vCards;
private Set<Integer> checkedVCards;
private Context context;
private UserAccountManager accountManager;
private ClientFactory clientFactory;
ContactListAdapter(UserAccountManager accountManager, ClientFactory clientFactory, Context context,
List<VCard> vCards) {
this.vCards = vCards;
this.context = context;
this.checkedVCards = new HashSet<>();
this.accountManager = accountManager;
this.clientFactory = clientFactory;
}
ContactListAdapter(UserAccountManager accountManager,
Context context,
List<VCard> vCards,
Set<Integer> checkedVCards) {
this.vCards = vCards;
this.context = context;
this.checkedVCards = checkedVCards;
this.accountManager = accountManager;
}
public int getCheckedCount() {
if (checkedVCards != null) {
return checkedVCards.size();
} else {
return 0;
}
}
public void replaceVCards(List<VCard> vCards) {
this.vCards = vCards;
notifyDataSetChanged();
}
public int[] getCheckedIntArray() {
int[] intArray;
if (checkedVCards != null && checkedVCards.size() > 0) {
intArray = new int[checkedVCards.size()];
int i = 0;
for (int position : checkedVCards) {
intArray[i] = position;
i++;
}
return intArray;
} else {
return new int[0];
}
}
@NonNull
@Override
public ContactListFragment.ContactItemViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(context).inflate(R.layout.contactlist_list_item, parent, false);
return new ContactListFragment.ContactItemViewHolder(view);
}
@Override
public void onBindViewHolder(@NonNull final ContactListFragment.ContactItemViewHolder holder, final int position) {
final int verifiedPosition = holder.getAdapterPosition();
final VCard vcard = vCards.get(verifiedPosition);
if (vcard != null) {
setChecked(checkedVCards.contains(position), holder.getName());
holder.getName().setText(getDisplayName(vcard));
// photo
if (vcard.getPhotos().size() > 0) {
setPhoto(holder.getBadge(), vcard.getPhotos().get(0));
} else {
try {
holder.getBadge().setImageDrawable(
TextDrawable.createNamedAvatar(
holder.getName().getText().toString(),
context.getResources().getDimension(R.dimen.list_item_avatar_icon_radius)
)
);
} catch (Exception e) {
holder.getBadge().setImageResource(R.drawable.ic_user);
}
}
holder.setVCardListener(v -> toggleVCard(holder, verifiedPosition));
}
}
private void setPhoto(ImageView imageView, Photo firstPhoto) {
String url = firstPhoto.getUrl();
byte[] data = firstPhoto.getData();
if (data != null && data.length > 0) {
Bitmap thumbnail = BitmapFactory.decodeByteArray(data, 0, data.length);
RoundedBitmapDrawable drawable = BitmapUtils.bitmapToCircularBitmapDrawable(context.getResources(),
thumbnail);
imageView.setImageDrawable(drawable);
} else if (url != null) {
SimpleTarget target = new SimpleTarget<Drawable>() {
@Override
public void onResourceReady(Drawable resource, GlideAnimation glideAnimation) {
imageView.setImageDrawable(resource);
}
@Override
public void onLoadFailed(Exception e, Drawable errorDrawable) {
super.onLoadFailed(e, errorDrawable);
imageView.setImageDrawable(errorDrawable);
}
};
DisplayUtils.downloadIcon(accountManager,
clientFactory,
context,
url,
target,
R.drawable.ic_user,
imageView.getWidth(),
imageView.getHeight());
}
}
private void setChecked(boolean checked, CheckedTextView checkedTextView) {
checkedTextView.setChecked(checked);
if (checked) {
checkedTextView.getCheckMarkDrawable()
.setColorFilter(ThemeColorUtils.primaryColor(context), PorterDuff.Mode.SRC_ATOP);
} else {
checkedTextView.getCheckMarkDrawable().clearColorFilter();
}
}
private void toggleVCard(ContactListFragment.ContactItemViewHolder holder, int verifiedPosition) {
holder.getName().setChecked(!holder.getName().isChecked());
if (holder.getName().isChecked()) {
holder.getName().getCheckMarkDrawable().setColorFilter(ThemeColorUtils.primaryColor(context),
PorterDuff.Mode.SRC_ATOP);
checkedVCards.add(verifiedPosition);
if (checkedVCards.size() == SINGLE_SELECTION) {
EventBus.getDefault().post(new VCardToggleEvent(true));
}
} else {
holder.getName().getCheckMarkDrawable().clearColorFilter();
checkedVCards.remove(verifiedPosition);
if (checkedVCards.isEmpty()) {
EventBus.getDefault().post(new VCardToggleEvent(false));
}
}
}
@Override
public int getItemCount() {
return vCards.size();
}
public void selectAllFiles(boolean select) {
checkedVCards = new HashSet<>();
if (select) {
for (int i = 0; i < vCards.size(); i++) {
checkedVCards.add(i);
}
}
if (checkedVCards.size() > 0) {
EventBus.getDefault().post(new VCardToggleEvent(true));
} else {
EventBus.getDefault().post(new VCardToggleEvent(false));
}
notifyDataSetChanged();
}
}

View file

@ -0,0 +1,68 @@
/*
*
* Nextcloud Android client application
*
* @author Tobias Kaminsky
* Copyright (C) 2021 Tobias Kaminsky
* Copyright (C) 2021 Nextcloud GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.owncloud.android.ui.fragment.contactsbackup;
import java.util.Arrays;
import androidx.annotation.NonNull;
public class ContactsAccount {
private final String displayName;
private final String name;
private final String type;
ContactsAccount(String displayName, String name, String type) {
this.displayName = displayName;
this.name = name;
this.type = type;
}
@Override
public boolean equals(Object obj) {
if (obj instanceof ContactsAccount) {
ContactsAccount other = (ContactsAccount) obj;
return this.name.equalsIgnoreCase(other.name) && this.type.equalsIgnoreCase(other.type);
} else {
return false;
}
}
@NonNull
@Override
public String toString() {
return displayName;
}
@Override
public int hashCode() {
return Arrays.hashCode(new Object[]{displayName, name, type});
}
public String getName() {
return name;
}
public String getType() {
return type;
}
}

View file

@ -0,0 +1,37 @@
/*
*
* Nextcloud Android client application
*
* @author Tobias Kaminsky
* Copyright (C) 2021 Tobias Kaminsky
* Copyright (C) 2021 Nextcloud GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.owncloud.android.ui.fragment.contactsbackup;
import java.util.Comparator;
import ezvcard.VCard;
public class VCardComparator implements Comparator<VCard> {
@Override
public int compare(VCard o1, VCard o2) {
String contact1 = BackupListFragment.getDisplayName(o1);
String contact2 = BackupListFragment.getDisplayName(o2);
return contact1.compareToIgnoreCase(contact2);
}
}

View file

@ -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);
}

View file

@ -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

View file

@ -0,0 +1,160 @@
/*
*
* Nextcloud Android client application
*
* @author Tobias Kaminsky
* Copyright (C) 2021 Tobias Kaminsky
* Copyright (C) 2021 Nextcloud GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package third_parties.sufficientlysecure;
import android.content.ContentProviderClient;
import android.content.ContentResolver;
import android.database.Cursor;
import android.net.Uri;
import android.provider.CalendarContract.Calendars;
import android.provider.CalendarContract.Events;
import com.owncloud.android.lib.common.utils.Log_OC;
import java.util.ArrayList;
import java.util.List;
public class AndroidCalendar {
private static final String TAG = "ICS_AndroidCalendar";
public long mId;
public String mIdStr;
public String mName;
public String mDisplayName;
public String mAccountName;
public String mAccountType;
public String mOwner;
public boolean mIsActive;
public String mTimezone;
public int mNumEntries;
private static final String[] CAL_COLS = new String[]{
Calendars._ID,
Calendars.DELETED,
Calendars.NAME,
Calendars.CALENDAR_DISPLAY_NAME,
Calendars.ACCOUNT_NAME,
Calendars.ACCOUNT_TYPE,
Calendars.OWNER_ACCOUNT,
Calendars.VISIBLE,
Calendars.CALENDAR_TIME_ZONE};
private static final String[] CAL_ID_COLS = new String[]{Events._ID};
private static final String CAL_ID_WHERE = Events.CALENDAR_ID + "=?";
// Load all available calendars.
// If an empty list is returned the caller probably needs to enable calendar
// read permissions in App Ops/XPrivacy etc.
public static List<AndroidCalendar> loadAll(ContentResolver resolver) {
if (missing(resolver, Calendars.CONTENT_URI) ||
missing(resolver, Events.CONTENT_URI)) {
return new ArrayList<>();
}
Cursor cur;
try {
cur = resolver.query(Calendars.CONTENT_URI, CAL_COLS, null, null, null);
} catch (Exception except) {
Log_OC.w(TAG, "Calendar provider is missing columns, continuing anyway");
cur = resolver.query(Calendars.CONTENT_URI, null, null, null, null);
}
List<AndroidCalendar> calendars = new ArrayList<>(cur.getCount());
while (cur.moveToNext()) {
if (getLong(cur, Calendars.DELETED) != 0) {
continue;
}
AndroidCalendar calendar = new AndroidCalendar();
calendar.mId = getLong(cur, Calendars._ID);
if (calendar.mId == -1) {
continue;
}
calendar.mIdStr = getString(cur, Calendars._ID);
calendar.mName = getString(cur, Calendars.NAME);
calendar.mDisplayName = getString(cur, Calendars.CALENDAR_DISPLAY_NAME);
calendar.mAccountName = getString(cur, Calendars.ACCOUNT_NAME);
calendar.mAccountType = getString(cur, Calendars.ACCOUNT_TYPE);
calendar.mOwner = getString(cur, Calendars.OWNER_ACCOUNT);
calendar.mIsActive = getLong(cur, Calendars.VISIBLE) == 1;
calendar.mTimezone = getString(cur, Calendars.CALENDAR_TIME_ZONE);
final String[] args = new String[]{calendar.mIdStr};
Cursor eventsCur = resolver.query(Events.CONTENT_URI, CAL_ID_COLS, CAL_ID_WHERE, args, null);
calendar.mNumEntries = eventsCur.getCount();
eventsCur.close();
calendars.add(calendar);
}
cur.close();
return calendars;
}
private static int getColumnIndex(Cursor cur, String dbName) {
return dbName == null ? -1 : cur.getColumnIndex(dbName);
}
private static long getLong(Cursor cur, String dbName) {
int i = getColumnIndex(cur, dbName);
return i == -1 ? -1 : cur.getLong(i);
}
private static String getString(Cursor cur, String dbName) {
int i = getColumnIndex(cur, dbName);
return i == -1 ? null : cur.getString(i);
}
private static boolean missing(ContentResolver resolver, Uri uri) {
// Determine if a provider is missing
ContentProviderClient provider = resolver.acquireContentProviderClient(uri);
if (provider != null) {
provider.release();
}
return provider == null;
}
@Override
public String toString() {
return mDisplayName + " (" + mIdStr + ")";
}
private boolean differ(final String lhs, final String rhs) {
if (lhs == null) {
return rhs != null;
}
return rhs == null || !lhs.equals(rhs);
}
public boolean differsFrom(AndroidCalendar other) {
return mId != other.mId ||
mIsActive != other.mIsActive ||
mNumEntries != other.mNumEntries ||
differ(mName, other.mName) ||
differ(mDisplayName, other.mDisplayName) ||
differ(mAccountName, other.mAccountName) ||
differ(mAccountType, other.mAccountType) ||
differ(mOwner, other.mOwner) ||
differ(mTimezone, other.mTimezone);
}
}

View file

@ -0,0 +1,96 @@
/*
*
* Nextcloud Android client application
*
* @author Tobias Kaminsky
* Copyright (C) 2021 Tobias Kaminsky
* Copyright (C) 2021 Nextcloud GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package third_parties.sufficientlysecure;
import android.content.Context;
import android.net.Uri;
import org.apache.commons.codec.binary.Base64;
import java.io.IOException;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLConnection;
public class CalendarSource {
private static final String HTTP_SEP = "://";
private URL mUrl = null;
private Uri mUri = null;
private final String mString;
private final String mUsername;
private final String mPassword;
private final Context context;
public CalendarSource(String url,
Uri uri,
String username,
String password,
Context context) throws MalformedURLException {
if (url != null) {
mUrl = new URL(url);
mString = mUrl.toString();
} else {
mUri = uri;
mString = uri.toString();
}
mUsername = username;
mPassword = password;
this.context = context;
}
public URLConnection getConnection() throws IOException {
if (mUsername != null) {
String protocol = mUrl.getProtocol();
String userPass = mUsername + ":" + mPassword;
if (protocol.equalsIgnoreCase("ftp") || protocol.equalsIgnoreCase("ftps")) {
String external = mUrl.toExternalForm();
String end = external.substring(protocol.length() + HTTP_SEP.length());
return new URL(protocol + HTTP_SEP + userPass + "@" + end).openConnection();
}
if (protocol.equalsIgnoreCase("http") || protocol.equalsIgnoreCase("https")) {
String encoded = new String(new Base64().encode(userPass.getBytes("UTF-8")));
URLConnection connection = mUrl.openConnection();
connection.setRequestProperty("Authorization", "Basic " + encoded);
return connection;
}
}
return mUrl.openConnection();
}
public InputStream getStream() throws IOException {
if (mUri != null) {
return context.getContentResolver().openInputStream(mUri);
}
URLConnection c = this.getConnection();
return c == null ? null : c.getInputStream();
}
@Override
public String toString() {
return mString;
}
}

View file

@ -0,0 +1,30 @@
/*
*
* Nextcloud Android client application
*
* @author Tobias Kaminsky
* Copyright (C) 2021 Tobias Kaminsky
* Copyright (C) 2021 Nextcloud GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package third_parties.sufficientlysecure;
public enum DuplicateHandlingEnum {
DUP_REPLACE,
DUP_REPLACE_ANY,
DUP_IGNORE,
DUP_DONT_CHECK,
}

View file

@ -0,0 +1,642 @@
/*
* Copyright (C) 2015 Jon Griffiths (jon_p_griffiths@yahoo.com)
* Copyright (C) 2013 Dominik Schürmann <dominik@dominikschuermann.de>
* Copyright (C) 2010-2011 Lukas Aichbauer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package third_parties.sufficientlysecure;
import android.annotation.SuppressLint;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.res.Resources;
import android.database.Cursor;
import android.net.MailTo;
import android.net.ParseException;
import android.net.Uri;
import android.provider.CalendarContract.Events;
import android.provider.CalendarContract.Reminders;
import android.text.TextUtils;
import android.text.format.DateUtils;
import com.nextcloud.client.preferences.AppPreferences;
import com.owncloud.android.R;
import com.owncloud.android.lib.common.utils.Log_OC;
import net.fortuna.ical4j.model.Calendar;
import net.fortuna.ical4j.model.ComponentList;
import net.fortuna.ical4j.model.DateTime;
import net.fortuna.ical4j.model.Dur;
import net.fortuna.ical4j.model.Parameter;
import net.fortuna.ical4j.model.Property;
import net.fortuna.ical4j.model.component.VAlarm;
import net.fortuna.ical4j.model.component.VEvent;
import net.fortuna.ical4j.model.parameter.FbType;
import net.fortuna.ical4j.model.parameter.Related;
import net.fortuna.ical4j.model.property.Action;
import net.fortuna.ical4j.model.property.DateProperty;
import net.fortuna.ical4j.model.property.Duration;
import net.fortuna.ical4j.model.property.FreeBusy;
import net.fortuna.ical4j.model.property.Transp;
import net.fortuna.ical4j.model.property.Trigger;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import javax.inject.Inject;
@SuppressLint("NewApi")
public class ProcessVEvent {
private static final String TAG = "ICS_ProcessVEvent";
private static final Duration ONE_DAY = createDuration("P1D");
private static final Duration ZERO_SECONDS = createDuration("PT0S");
private static final String[] EVENT_QUERY_COLUMNS = new String[]{Events.CALENDAR_ID, Events._ID};
private static final int EVENT_QUERY_CALENDAR_ID_COL = 0;
private static final int EVENT_QUERY_ID_COL = 1;
private final Calendar mICalCalendar;
private final boolean mIsInserter;
private final AndroidCalendar selectedCal;
private Context context;
@Inject AppPreferences preferences;
// UID generation
long mUidMs = 0;
String mUidTail = null;
private final class Options {
private final List<Integer> mDefaultReminders;
public Options(Context context) {
mDefaultReminders = new ArrayList<>(); // RemindersDialog.getSavedRemindersInMinutes(this); // TODO check
mDefaultReminders.add(0);
mDefaultReminders.add(5);
mDefaultReminders.add(10);
mDefaultReminders.add(30);
mDefaultReminders.add(60);
}
public List<Integer> getReminders(List<Integer> eventReminders) {
if (eventReminders.size() > 0 && getImportReminders()) {
return eventReminders;
}
return mDefaultReminders;
}
public boolean getKeepUids() {
return true; // upstream this is a setting // TODO check if we need to also have this as a setting
}
private boolean getImportReminders() {
return true; // upstream this is a setting // TODO check if we need to also have this as a setting
}
private boolean getGlobalUids() {
return false; // upstream this is a setting // TODO check if we need to also have this as a setting
}
private boolean getTestFileSupport() {
return false; // upstream this is a setting // TODO check if we need to also have this as a setting
}
public DuplicateHandlingEnum getDuplicateHandling() {
// return DuplicateHandlingEnum.values()[getEnumInt(PREF_DUPLICATE_HANDLING, 0)];
return DuplicateHandlingEnum.values()[0]; // TODO is option needed?
}
// private int getEnumInt(final String key, final int def) {
// return Integer.parseInt(getString(key, String.valueOf(def)));
// }
}
public ProcessVEvent(Context context, Calendar iCalCalendar, AndroidCalendar selectedCal, boolean isInserter) {
this.context = context;
mICalCalendar = iCalCalendar;
this.selectedCal = selectedCal;
mIsInserter = isInserter;
}
// TODO how to run?
public void run() throws Exception {
final Options options = new Options(context);
List<Integer> reminders = new ArrayList<>();
ComponentList events = mICalCalendar.getComponents(VEvent.VEVENT);
ContentResolver resolver = context.getContentResolver();
int numDel = 0;
int numIns = 0;
int numDups = 0;
ContentValues cAlarm = new ContentValues();
cAlarm.put(Reminders.METHOD, Reminders.METHOD_ALERT);
final DuplicateHandlingEnum dupes = options.getDuplicateHandling();
Log_OC.i(TAG, (mIsInserter ? "Insert" : "Delete") + " for id " + selectedCal.mIdStr);
Log_OC.d(TAG, "Duplication option is " + dupes.ordinal());
for (Object ve : events) {
VEvent e = (VEvent) ve;
Log_OC.d(TAG, "source event: " + e.toString());
if (e.getRecurrenceId() != null) {
// FIXME: Support these edited instances
Log_OC.w(TAG, "Ignoring edited instance of a recurring event");
continue;
}
long insertCalendarId = selectedCal.mId; // Calendar id to insert to
ContentValues c = convertToDB(e, options, reminders, selectedCal.mId);
Cursor cur = null;
boolean mustDelete = !mIsInserter;
// Determine if we need to delete a duplicate event in order to update it
if (!mustDelete && dupes != DuplicateHandlingEnum.DUP_DONT_CHECK) {
cur = query(resolver, options, c);
while (!mustDelete && cur != null && cur.moveToNext()) {
if (dupes == DuplicateHandlingEnum.DUP_REPLACE) {
mustDelete = cur.getLong(EVENT_QUERY_CALENDAR_ID_COL) == selectedCal.mId;
} else {
mustDelete = true; // Replacing all (or ignoring, handled just below)
}
}
if (mustDelete) {
if (dupes == DuplicateHandlingEnum.DUP_IGNORE) {
Log_OC.i(TAG, "Avoiding inserting a duplicate event");
numDups++;
cur.close();
continue;
}
cur.moveToPosition(-1); // Rewind for use below
}
}
if (mustDelete) {
if (cur == null) {
cur = query(resolver, options, c);
}
while (cur != null && cur.moveToNext()) {
long rowCalendarId = cur.getLong(EVENT_QUERY_CALENDAR_ID_COL);
if (dupes == DuplicateHandlingEnum.DUP_REPLACE
&& rowCalendarId != selectedCal.mId) {
Log_OC.i(TAG, "Avoiding deleting duplicate event in calendar " + rowCalendarId);
continue; // Not in the destination calendar
}
String id = cur.getString(EVENT_QUERY_ID_COL);
Uri eventUri = Uri.withAppendedPath(Events.CONTENT_URI, id);
numDel += resolver.delete(eventUri, null, null);
String where = Reminders.EVENT_ID + "=?";
resolver.delete(Reminders.CONTENT_URI, where, new String[]{id});
if (mIsInserter && rowCalendarId != selectedCal.mId
&& dupes == DuplicateHandlingEnum.DUP_REPLACE_ANY) {
// Must update this event in the calendar this row came from
Log_OC.i(TAG, "Changing calendar: " + rowCalendarId + " to " + insertCalendarId);
insertCalendarId = rowCalendarId;
}
}
}
if (cur != null) {
cur.close();
}
if (!mIsInserter) {
continue;
}
if (Events.UID_2445 != null && !c.containsKey(Events.UID_2445)) {
// Create a UID for this event to use. We create it here so if
// exported multiple times it will always have the same id.
c.put(Events.UID_2445, generateUid()); // TODO use
}
c.put(Events.CALENDAR_ID, insertCalendarId);
if (options.getTestFileSupport()) {
processEventTests(e, c, reminders);
numIns++;
continue;
}
Uri uri = insertAndLog(resolver, Events.CONTENT_URI, c, "Event");
if (uri == null) {
continue;
}
final long id = Long.parseLong(uri.getLastPathSegment());
for (int time : options.getReminders(reminders)) {
cAlarm.put(Reminders.EVENT_ID, id);
cAlarm.put(Reminders.MINUTES, time);
insertAndLog(resolver, Reminders.CONTENT_URI, cAlarm, "Reminder");
}
numIns++;
}
selectedCal.mNumEntries += numIns;
selectedCal.mNumEntries -= numDel;
Resources res = context.getResources();
int n = mIsInserter ? numIns : numDel;
String msg = res.getQuantityString(R.plurals.processed_n_entries, n, n) + "\n";
if (mIsInserter) {
msg += "\n";
if (options.getDuplicateHandling() == DuplicateHandlingEnum.DUP_DONT_CHECK) {
msg += res.getString(R.string.did_not_check_for_dupes);
} else {
msg += res.getQuantityString(R.plurals.found_n_duplicates, numDups, numDups);
}
}
// TODO show failure in starting context
// DisplayUtils.showSnackMessage(context, msg);
}
// Munge a VEvent so Android won't reject it, then convert to ContentValues for inserting
private ContentValues convertToDB(VEvent e, Options options,
List<Integer> reminders, long calendarId) {
reminders.clear();
boolean allDay = false;
boolean startIsDate = !(e.getStartDate().getDate() instanceof DateTime);
boolean isRecurring = hasProperty(e, Property.RRULE) || hasProperty(e, Property.RDATE);
if (startIsDate) {
// If the start date is a DATE we expect the end date to be a date too and the
// event is all-day, midnight to midnight (RFC 2445).
allDay = true;
}
if (!hasProperty(e, Property.DTEND) && !hasProperty(e, Property.DURATION)) {
// No end date or duration given.
// Since we added a duration above when the start date is a DATE:
// - The start date is a DATETIME, the event lasts no time at all (RFC 2445).
e.getProperties().add(ZERO_SECONDS);
// Zero time events are always free (RFC 2445), so override/set TRANSP accordingly.
removeProperty(e, Property.TRANSP);
e.getProperties().add(Transp.TRANSPARENT);
}
if (isRecurring) {
// Recurring event. Android insists on a duration.
if (!hasProperty(e, Property.DURATION)) {
// Calculate duration from start to end date
Duration d = new Duration(e.getStartDate().getDate(), e.getEndDate().getDate());
e.getProperties().add(d);
}
removeProperty(e, Property.DTEND);
} else {
// Non-recurring event. Android insists on an end date.
if (!hasProperty(e, Property.DTEND)) {
// Calculate end date from duration, set it and remove the duration.
e.getProperties().add(e.getEndDate());
}
removeProperty(e, Property.DURATION);
}
// Now calculate the db values for the event
ContentValues c = new ContentValues();
c.put(Events.CALENDAR_ID, calendarId);
copyProperty(c, Events.TITLE, e, Property.SUMMARY);
copyProperty(c, Events.DESCRIPTION, e, Property.DESCRIPTION);
if (e.getOrganizer() != null) {
URI uri = e.getOrganizer().getCalAddress();
try {
MailTo mailTo = MailTo.parse(uri.toString());
c.put(Events.ORGANIZER, mailTo.getTo());
c.put(Events.GUESTS_CAN_MODIFY, 1); // Ensure we can edit if not the organiser
} catch (ParseException ignored) {
Log_OC.e(TAG, "Failed to parse Organiser URI " + uri.toString());
}
}
copyProperty(c, Events.EVENT_LOCATION, e, Property.LOCATION);
if (hasProperty(e, Property.STATUS)) {
String status = e.getProperty(Property.STATUS).getValue();
switch (status) {
case "TENTATIVE":
c.put(Events.STATUS, Events.STATUS_TENTATIVE);
break;
case "CONFIRMED":
c.put(Events.STATUS, Events.STATUS_CONFIRMED);
break;
case "CANCELLED": // NOTE: In ical4j it is CANCELLED with two L
c.put(Events.STATUS, Events.STATUS_CANCELED);
break;
}
}
copyProperty(c, Events.DURATION, e, Property.DURATION);
if (allDay) {
c.put(Events.ALL_DAY, 1);
}
copyDateProperty(c, Events.DTSTART, Events.EVENT_TIMEZONE, e.getStartDate());
if (hasProperty(e, Property.DTEND)) {
copyDateProperty(c, Events.DTEND, Events.EVENT_END_TIMEZONE, e.getEndDate());
}
if (hasProperty(e, Property.CLASS)) {
String access = e.getProperty(Property.CLASS).getValue();
int accessLevel = Events.ACCESS_DEFAULT;
switch (access) {
case "CONFIDENTIAL":
accessLevel = Events.ACCESS_CONFIDENTIAL;
break;
case "PRIVATE":
accessLevel = Events.ACCESS_PRIVATE;
break;
case "PUBLIC":
accessLevel = Events.ACCESS_PUBLIC;
break;
}
c.put(Events.ACCESS_LEVEL, accessLevel);
}
// Work out availability. This is confusing as FREEBUSY and TRANSP overlap.
if (Events.AVAILABILITY != null) {
int availability = Events.AVAILABILITY_BUSY;
if (hasProperty(e, Property.TRANSP)) {
if (e.getTransparency() == Transp.TRANSPARENT) {
availability = Events.AVAILABILITY_FREE;
}
} else if (hasProperty(e, Property.FREEBUSY)) {
FreeBusy fb = (FreeBusy) e.getProperty(Property.FREEBUSY);
FbType fbType = (FbType) fb.getParameter(Parameter.FBTYPE);
if (fbType != null && fbType == FbType.FREE) {
availability = Events.AVAILABILITY_FREE;
} else if (fbType != null && fbType == FbType.BUSY_TENTATIVE) {
availability = Events.AVAILABILITY_TENTATIVE;
}
}
c.put(Events.AVAILABILITY, availability);
}
copyProperty(c, Events.RRULE, e, Property.RRULE);
copyProperty(c, Events.RDATE, e, Property.RDATE);
copyProperty(c, Events.EXRULE, e, Property.EXRULE);
copyProperty(c, Events.EXDATE, e, Property.EXDATE);
copyProperty(c, Events.CUSTOM_APP_URI, e, Property.URL);
copyProperty(c, Events.UID_2445, e, Property.UID);
if (c.containsKey(Events.UID_2445) && TextUtils.isEmpty(c.getAsString(Events.UID_2445))) {
// Remove null/empty UIDs
c.remove(Events.UID_2445);
}
for (Object alarm : e.getAlarms()) {
VAlarm a = (VAlarm) alarm;
if (a.getAction() != Action.AUDIO && a.getAction() != Action.DISPLAY) {
continue; // Ignore email and procedure alarms
}
Trigger t = a.getTrigger();
final long startMs = e.getStartDate().getDate().getTime();
long alarmStartMs = startMs;
long alarmMs;
// FIXME: - Support for repeating alarms
// - Check the calendars max number of alarms
if (t.getDateTime() != null) {
alarmMs = t.getDateTime().getTime(); // Absolute
} else if (t.getDuration() != null && t.getDuration().isNegative()) {
Related rel = (Related) t.getParameter(Parameter.RELATED);
if (rel != null && rel == Related.END) {
alarmStartMs = e.getEndDate().getDate().getTime();
}
alarmMs = alarmStartMs - durationToMs(t.getDuration()); // Relative
} else {
continue;
}
int reminder = (int) ((startMs - alarmMs) / DateUtils.MINUTE_IN_MILLIS);
if (reminder >= 0 && !reminders.contains(reminder)) {
reminders.add(reminder);
}
}
if (options.getReminders(reminders).size() > 0) {
c.put(Events.HAS_ALARM, 1);
}
// FIXME: Attendees, SELF_ATTENDEE_STATUS
return c;
}
private static Duration createDuration(String value) {
Duration d = new Duration();
d.setValue(value);
return d;
}
private static long durationToMs(Dur d) {
long ms = 0;
ms += d.getSeconds() * DateUtils.SECOND_IN_MILLIS;
ms += d.getMinutes() * DateUtils.MINUTE_IN_MILLIS;
ms += d.getHours() * DateUtils.HOUR_IN_MILLIS;
ms += d.getDays() * DateUtils.DAY_IN_MILLIS;
ms += d.getWeeks() * DateUtils.WEEK_IN_MILLIS;
return ms;
}
private boolean hasProperty(VEvent e, String name) {
return e.getProperty(name) != null;
}
private void removeProperty(VEvent e, String name) {
Property p = e.getProperty(name);
if (p != null) {
e.getProperties().remove(p);
}
}
private void copyProperty(ContentValues c, String dbName, VEvent e, String evName) {
if (dbName != null) {
Property p = e.getProperty(evName);
if (p != null) {
c.put(dbName, p.getValue());
}
}
}
private void copyDateProperty(ContentValues c, String dbName, String dbTzName, DateProperty date) {
if (dbName != null && date.getDate() != null) {
c.put(dbName, date.getDate().getTime()); // ms since epoc in GMT
if (dbTzName != null) {
if (date.isUtc() || date.getTimeZone() == null) {
c.put(dbTzName, "UTC");
} else {
c.put(dbTzName, date.getTimeZone().getID());
}
}
}
}
private Uri insertAndLog(ContentResolver resolver, Uri uri, ContentValues c, String type) {
Log_OC.d(TAG, "Inserting " + type + " values: " + c);
Uri result = resolver.insert(uri, c);
if (result == null) {
Log_OC.e(TAG, "failed to insert " + type);
Log_OC.e(TAG, "failed " + type + " values: " + c); // Not already logged, dump now
} else {
Log_OC.d(TAG, "Insert " + type + " returned " + result.toString());
}
return result;
}
private Cursor queryEvents(ContentResolver resolver, StringBuilder b, List<String> argsList) {
final String where = b.toString();
final String[] args = argsList.toArray(new String[argsList.size()]);
return resolver.query(Events.CONTENT_URI, EVENT_QUERY_COLUMNS, where, args, null);
}
private Cursor query(ContentResolver resolver, Options options, ContentValues c) {
StringBuilder b = new StringBuilder();
List<String> argsList = new ArrayList<>();
if (options.getKeepUids() && Events.UID_2445 != null && c.containsKey(Events.UID_2445)) {
// Use our UID to query, either globally or per-calendar unique
if (!options.getGlobalUids()) {
b.append(Events.CALENDAR_ID).append("=? AND ");
argsList.add(c.getAsString(Events.CALENDAR_ID));
}
b.append(Events.UID_2445).append("=?");
argsList.add(c.getAsString(Events.UID_2445));
return queryEvents(resolver, b, argsList);
}
// Without UIDs, the best we can do is check the start date and title within
// the current calendar, even though this may return false duplicates.
if (!c.containsKey(Events.CALENDAR_ID) || !c.containsKey(Events.DTSTART)) {
return null;
}
b.append(Events.CALENDAR_ID).append("=? AND ");
b.append(Events.DTSTART).append("=? AND ");
b.append(Events.TITLE);
argsList.add(c.getAsString(Events.CALENDAR_ID));
argsList.add(c.getAsString(Events.DTSTART));
if (c.containsKey(Events.TITLE)) {
b.append("=?");
argsList.add(c.getAsString(Events.TITLE));
} else {
b.append(" is null");
}
return queryEvents(resolver, b, argsList);
}
private void checkTestValue(VEvent e, ContentValues c, String keyValue, String testName) {
String[] parts = keyValue.split("=");
String key = parts[0];
String expected = parts.length > 1 ? parts[1] : "";
String got = c.getAsString(key);
if (expected.equals("<non-null>") && got != null) {
got = "<non-null>"; // Sentinel for testing present and non-null
}
if (got == null) {
got = "<null>"; // Sentinel for testing not present values
}
if (!expected.equals(got)) {
Log_OC.e(TAG, " " + keyValue + " -> FAILED");
Log_OC.e(TAG, " values: " + c);
String error = "Test " + testName + " FAILED, expected '" + keyValue + "', got '" + got + "'";
throw new RuntimeException(error);
}
Log_OC.i(TAG, " " + keyValue + " -> PASSED");
}
private void processEventTests(VEvent e, ContentValues c, List<Integer> reminders) {
Property testName = e.getProperty("X-TEST-NAME");
if (testName == null) {
return; // Not a test case
}
// This is a test event. Verify it using the embedded meta data.
Log_OC.i(TAG, "Processing test case " + testName.getValue() + "...");
String reminderValues = "";
String sep = "";
for (Integer i : reminders) {
reminderValues += sep + i;
sep = ",";
}
c.put("reminders", reminderValues);
for (Object o : e.getProperties()) {
Property p = (Property) o;
switch (p.getName()) {
case "X-TEST-VALUE":
checkTestValue(e, c, p.getValue(), testName.getValue());
break;
case "X-TEST-MIN-VERSION":
final int ver = Integer.parseInt(p.getValue());
if (android.os.Build.VERSION.SDK_INT < ver) {
Log_OC.e(TAG, " -> SKIPPED (MIN-VERSION < " + ver + ")");
return;
}
break;
}
}
}
// TODO move this to some common place
private String generateUid() {
// Generated UIDs take the form <ms>-<uuid>@nextcloud.com.
if (mUidTail == null) {
String uidPid = preferences.getUidPid();
if (uidPid.length() == 0) {
uidPid = UUID.randomUUID().toString().replace("-", "");
preferences.setUidPid(uidPid);
}
mUidTail = uidPid + "@nextcloud.com";
}
mUidMs = Math.max(mUidMs, System.currentTimeMillis());
String uid = mUidMs + mUidTail;
mUidMs++;
return uid;
}
}

View file

@ -0,0 +1,620 @@
/*
* Copyright (C) 2015 Jon Griffiths (jon_p_griffiths@yahoo.com)
* Copyright (C) 2013 Dominik Schürmann <dominik@dominikschuermann.de>
* Copyright (C) 2010-2011 Lukas Aichbauer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package third_parties.sufficientlysecure;
import android.annotation.SuppressLint;
import android.app.AlertDialog;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
import android.content.DialogInterface;
import android.content.pm.PackageManager.NameNotFoundException;
import android.content.res.Resources;
import android.database.Cursor;
import android.database.DatabaseUtils;
import android.net.Uri;
import android.provider.CalendarContract;
import android.provider.CalendarContract.Events;
import android.provider.CalendarContract.Reminders;
import android.text.TextUtils;
import android.text.format.DateFormat;
import android.text.format.DateUtils;
import android.view.View;
import android.view.WindowManager;
import android.widget.EditText;
import com.nextcloud.client.account.User;
import com.nextcloud.client.di.Injectable;
import com.nextcloud.client.files.downloader.PostUploadAction;
import com.nextcloud.client.files.downloader.Request;
import com.nextcloud.client.files.downloader.TransferManagerConnection;
import com.nextcloud.client.files.downloader.UploadRequest;
import com.nextcloud.client.files.downloader.UploadTrigger;
import com.nextcloud.client.preferences.AppPreferences;
import com.owncloud.android.R;
import com.owncloud.android.datamodel.OCFile;
import com.owncloud.android.files.services.NameCollisionPolicy;
import com.owncloud.android.lib.common.utils.Log_OC;
import net.fortuna.ical4j.data.CalendarOutputter;
import net.fortuna.ical4j.model.Calendar;
import net.fortuna.ical4j.model.Date;
import net.fortuna.ical4j.model.DateTime;
import net.fortuna.ical4j.model.Dur;
import net.fortuna.ical4j.model.Period;
import net.fortuna.ical4j.model.Property;
import net.fortuna.ical4j.model.PropertyFactoryImpl;
import net.fortuna.ical4j.model.PropertyList;
import net.fortuna.ical4j.model.TimeZone;
import net.fortuna.ical4j.model.TimeZoneRegistry;
import net.fortuna.ical4j.model.TimeZoneRegistryFactory;
import net.fortuna.ical4j.model.component.VAlarm;
import net.fortuna.ical4j.model.component.VEvent;
import net.fortuna.ical4j.model.parameter.FbType;
import net.fortuna.ical4j.model.property.Action;
import net.fortuna.ical4j.model.property.CalScale;
import net.fortuna.ical4j.model.property.Description;
import net.fortuna.ical4j.model.property.DtEnd;
import net.fortuna.ical4j.model.property.DtStamp;
import net.fortuna.ical4j.model.property.DtStart;
import net.fortuna.ical4j.model.property.Duration;
import net.fortuna.ical4j.model.property.FreeBusy;
import net.fortuna.ical4j.model.property.Method;
import net.fortuna.ical4j.model.property.Organizer;
import net.fortuna.ical4j.model.property.ProdId;
import net.fortuna.ical4j.model.property.Transp;
import net.fortuna.ical4j.model.property.Version;
import net.fortuna.ical4j.model.property.XProperty;
import net.fortuna.ical4j.util.CompatibilityHints;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.net.URISyntaxException;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
@SuppressLint("NewApi")
public class SaveCalendar implements Injectable {
private static final String TAG = "ICS_SaveCalendar";
private final PropertyFactoryImpl mPropertyFactory = PropertyFactoryImpl.getInstance();
private TimeZoneRegistry mTzRegistry;
private final Set<TimeZone> mInsertedTimeZones = new HashSet<>();
private final Set<String> mFailedOrganisers = new HashSet<>();
boolean mAllCols;
private final Context activity;
private final AndroidCalendar selectedCal;
private final AppPreferences preferences;
private final User user;
// UID generation
long mUidMs = 0;
String mUidTail = null;
private static final List<String> STATUS_ENUM = Arrays.asList("TENTATIVE", "CONFIRMED", "CANCELLED");
private static final List<String> CLASS_ENUM = Arrays.asList(null, "CONFIDENTIAL", "PRIVATE", "PUBLIC");
private static final List<String> AVAIL_ENUM = Arrays.asList(null, "FREE", "BUSY-TENTATIVE");
private static final String[] EVENT_COLS = new String[]{
Events._ID, Events.ORIGINAL_ID, Events.UID_2445, Events.TITLE, Events.DESCRIPTION,
Events.ORGANIZER, Events.EVENT_LOCATION, Events.STATUS, Events.ALL_DAY, Events.RDATE,
Events.RRULE, Events.DTSTART, Events.EVENT_TIMEZONE, Events.DURATION, Events.DTEND,
Events.EVENT_END_TIMEZONE, Events.ACCESS_LEVEL, Events.AVAILABILITY, Events.EXDATE,
Events.EXRULE, Events.CUSTOM_APP_PACKAGE, Events.CUSTOM_APP_URI, Events.HAS_ALARM
};
private static final String[] REMINDER_COLS = new String[]{
Reminders.MINUTES, Reminders.METHOD
};
public SaveCalendar(Context activity, AndroidCalendar calendar, AppPreferences preferences, User user) {
this.activity = activity; // TODO rename
this.selectedCal = calendar;
this.preferences = preferences;
this.user = user;
}
public void start() throws Exception {
mInsertedTimeZones.clear();
mFailedOrganisers.clear();
mAllCols = false;
String file = selectedCal.mDisplayName + "_" +
DateFormat.format("yyyy-MM-dd_HH-mm-ss", java.util.Calendar.getInstance()).toString() +
".ics";
File fileName = new File(activity.getCacheDir(), file);
Log_OC.i(TAG, "Save id " + selectedCal.mIdStr + " to file " + fileName.getAbsolutePath());
String name = activity.getPackageName();
String ver;
try {
ver = activity.getPackageManager().getPackageInfo(name, 0).versionName;
} catch (NameNotFoundException e) {
ver = "Unknown Build";
}
String prodId = "-//" + selectedCal.mOwner + "//iCal Import/Export " + ver + "//EN";
Calendar cal = new Calendar();
cal.getProperties().add(new ProdId(prodId));
cal.getProperties().add(Version.VERSION_2_0);
cal.getProperties().add(Method.PUBLISH);
cal.getProperties().add(CalScale.GREGORIAN);
if (selectedCal.mTimezone != null) {
// We don't write any events with floating times, but export this
// anyway so the default timezone for new events is correct when
// the file is imported into a system that supports it.
cal.getProperties().add(new XProperty("X-WR-TIMEZONE", selectedCal.mTimezone));
}
// query events
ContentResolver resolver = activity.getContentResolver();
int numberOfCreatedUids = 0;
if (Events.UID_2445 != null) {
numberOfCreatedUids = ensureUids(activity, resolver, selectedCal);
}
boolean relaxed = true; // settings.getIcal4jValidationRelaxed(); // TODO is this option needed? default true
CompatibilityHints.setHintEnabled(CompatibilityHints.KEY_RELAXED_VALIDATION, relaxed);
List<VEvent> events = getEvents(resolver, selectedCal, cal);
for (VEvent v : events) {
cal.getComponents().add(v);
}
new CalendarOutputter().output(cal, new FileOutputStream(fileName));
Resources res = activity.getResources();
String msg = res.getQuantityString(R.plurals.wrote_n_events_to, events.size(), events.size(), file);
if (numberOfCreatedUids > 0) {
msg += "\n" + res.getQuantityString(R.plurals.created_n_uids_to, numberOfCreatedUids, numberOfCreatedUids);
}
// TODO replace DisplayUtils.showSnackMessage(activity, msg);
upload(fileName);
}
private int ensureUids(Context activity, ContentResolver resolver, AndroidCalendar cal) {
String[] cols = new String[]{Events._ID};
String[] args = new String[]{cal.mIdStr};
Map<Long, String> newUids = new HashMap<>();
Cursor cur = resolver.query(Events.CONTENT_URI, cols,
Events.CALENDAR_ID + " = ? AND " + Events.UID_2445 + " IS NULL", args, null);
while (cur.moveToNext()) {
Long id = getLong(cur, Events._ID);
String uid = generateUid();
newUids.put(id, uid);
}
for (Long id : newUids.keySet()) {
String uid = newUids.get(id);
Uri updateUri = ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, id);
ContentValues c = new ContentValues();
c.put(Events.UID_2445, uid);
resolver.update(updateUri, c, null, null);
Log_OC.i(TAG, "Generated UID " + uid + " for event " + id);
}
return newUids.size();
}
private List<VEvent> getEvents(ContentResolver resolver, AndroidCalendar cal_src, Calendar cal_dst) {
String where = Events.CALENDAR_ID + "=?";
String[] args = new String[]{cal_src.mIdStr};
String sortBy = Events.CALENDAR_ID + " ASC";
Cursor cur;
try {
cur = resolver.query(Events.CONTENT_URI, mAllCols ? null : EVENT_COLS,
where, args, sortBy);
} catch (Exception except) {
Log_OC.w(TAG, "Calendar provider is missing columns, continuing anyway");
int n = 0;
for (n = 0; n < EVENT_COLS.length; ++n) {
if (EVENT_COLS[n] == null) {
Log_OC.e(TAG, "Invalid EVENT_COLS index " + Integer.toString(n));
}
}
cur = resolver.query(Events.CONTENT_URI, null, where, args, sortBy);
}
DtStamp timestamp = new DtStamp(); // Same timestamp for all events
// Collect up events and add them after any timezones
List<VEvent> events = new ArrayList<>();
while (cur.moveToNext()) {
VEvent e = convertFromDb(cur, cal_dst, timestamp);
if (e != null) {
events.add(e);
Log_OC.d(TAG, "Adding event: " + e.toString());
}
}
cur.close();
return events;
}
private String calculateFileName(final String displayName) {
// Replace all non-alnum chars with '_'
String stripped = displayName.replaceAll("[^a-zA-Z0-9_-]", "_");
// Replace repeated '_' with a single '_'
return stripped.replaceAll("(_)\\1{1,}", "$1");
}
private void getFileImpl(final String previousFile, final String suggestedFile,
final String[] result) {
final EditText input = new EditText(activity);
input.setHint(R.string.destination_filename);
input.setText(previousFile);
input.selectAll();
final int ok = android.R.string.ok;
final int cancel = android.R.string.cancel;
final int suggest = R.string.suggest;
AlertDialog.Builder builder = new AlertDialog.Builder(activity);
AlertDialog dlg = builder.setIcon(R.mipmap.ic_launcher)
.setTitle(R.string.enter_destination_filename)
.setView(input)
.setPositiveButton(ok, new DialogInterface.OnClickListener() {
public void onClick(DialogInterface iface, int id) {
result[0] = input.getText().toString();
}
})
.setNeutralButton(suggest, new DialogInterface.OnClickListener() {
public void onClick(DialogInterface iface, int id) {
}
})
.setNegativeButton(cancel, new DialogInterface.OnClickListener() {
public void onClick(DialogInterface iface, int id) {
result[0] = "";
}
})
.setOnCancelListener(new DialogInterface.OnCancelListener() {
public void onCancel(DialogInterface iface) {
result[0] = "";
}
})
.create();
int state = WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE;
dlg.getWindow().setSoftInputMode(state);
dlg.show();
// Overriding 'Suggest' here prevents it from closing the dialog
dlg.getButton(DialogInterface.BUTTON_NEUTRAL)
.setOnClickListener(new View.OnClickListener() {
public void onClick(View onClick) {
input.setText(suggestedFile);
input.setSelection(input.getText().length());
}
});
}
private VEvent convertFromDb(Cursor cur, Calendar cal, DtStamp timestamp) {
Log_OC.d(TAG, "cursor: " + DatabaseUtils.dumpCurrentRowToString(cur));
if (hasStringValue(cur, Events.ORIGINAL_ID)) {
// FIXME: Support these edited instances
Log_OC.w(TAG, "Ignoring edited instance of a recurring event");
return null;
}
PropertyList l = new PropertyList();
l.add(timestamp);
copyProperty(l, Property.UID, cur, Events.UID_2445);
String summary = copyProperty(l, Property.SUMMARY, cur, Events.TITLE);
String description = copyProperty(l, Property.DESCRIPTION, cur, Events.DESCRIPTION);
String organizer = getString(cur, Events.ORGANIZER);
if (!TextUtils.isEmpty(organizer)) {
// The check for mailto: here handles early versions of this code which
// incorrectly left it in the organizer column.
if (!organizer.startsWith("mailto:")) {
organizer = "mailto:" + organizer;
}
try {
l.add(new Organizer(organizer));
} catch (URISyntaxException ignored) {
if (!mFailedOrganisers.contains(organizer)) {
Log_OC.e(TAG, "Failed to create mailTo for organizer " + organizer);
mFailedOrganisers.add(organizer);
}
}
}
copyProperty(l, Property.LOCATION, cur, Events.EVENT_LOCATION);
copyEnumProperty(l, Property.STATUS, cur, Events.STATUS, STATUS_ENUM);
boolean allDay = TextUtils.equals(getString(cur, Events.ALL_DAY), "1");
boolean isTransparent;
DtEnd dtEnd = null;
if (allDay) {
// All day event
isTransparent = true;
Date start = getDateTime(cur, Events.DTSTART, null, null);
Date end = getDateTime(cur, Events.DTEND, null, null);
l.add(new DtStart(new Date(start)));
if (end != null) {
dtEnd = new DtEnd(new Date(end));
} else {
dtEnd = new DtEnd(utcDateFromMs(start.getTime() + DateUtils.DAY_IN_MILLIS));
}
l.add(dtEnd);
} else {
// Regular or zero-time event. Start date must be a date-time
Date startDate = getDateTime(cur, Events.DTSTART, Events.EVENT_TIMEZONE, cal);
l.add(new DtStart(startDate));
// Use duration if we have one, otherwise end date
if (hasStringValue(cur, Events.DURATION)) {
isTransparent = getString(cur, Events.DURATION).equals("PT0S");
if (!isTransparent) {
copyProperty(l, Property.DURATION, cur, Events.DURATION);
}
} else {
String endTz = Events.EVENT_END_TIMEZONE;
if (endTz == null) {
endTz = Events.EVENT_TIMEZONE;
}
Date end = getDateTime(cur, Events.DTEND, endTz, cal);
dtEnd = new DtEnd(end);
isTransparent = startDate.getTime() == end.getTime();
if (!isTransparent) {
l.add(dtEnd);
}
}
}
copyEnumProperty(l, Property.CLASS, cur, Events.ACCESS_LEVEL, CLASS_ENUM);
int availability = getInt(cur, Events.AVAILABILITY);
if (availability > Events.AVAILABILITY_TENTATIVE) {
availability = -1; // Unknown/Invalid
}
if (isTransparent) {
// This event is ordinarily transparent. If availability shows that its
// not free, then mark it opaque.
if (availability >= 0 && availability != Events.AVAILABILITY_FREE) {
l.add(Transp.OPAQUE);
}
} else if (availability > Events.AVAILABILITY_BUSY) {
// This event is ordinarily busy but differs, so output a FREEBUSY
// period covering the time of the event
FreeBusy fb = new FreeBusy();
fb.getParameters().add(new FbType(AVAIL_ENUM.get(availability)));
DateTime start = new DateTime(((DtStart) l.getProperty(Property.DTSTART)).getDate());
if (dtEnd != null) {
fb.getPeriods().add(new Period(start, new DateTime(dtEnd.getDate())));
} else {
Duration d = (Duration) l.getProperty(Property.DURATION);
fb.getPeriods().add(new Period(start, d.getDuration()));
}
l.add(fb);
}
copyProperty(l, Property.RRULE, cur, Events.RRULE);
copyProperty(l, Property.RDATE, cur, Events.RDATE);
copyProperty(l, Property.EXRULE, cur, Events.EXRULE);
copyProperty(l, Property.EXDATE, cur, Events.EXDATE);
if (TextUtils.isEmpty(getString(cur, Events.CUSTOM_APP_PACKAGE))) {
// Only copy URL if there is no app i.e. we probably imported it.
copyProperty(l, Property.URL, cur, Events.CUSTOM_APP_URI);
}
VEvent e = new VEvent(l);
if (getInt(cur, Events.HAS_ALARM) == 1) {
// Add alarms
String s = summary == null ? (description == null ? "" : description) : summary;
Description desc = new Description(s);
ContentResolver resolver = activity.getContentResolver();
long eventId = getLong(cur, Events._ID);
Cursor alarmCur;
alarmCur = Reminders.query(resolver, eventId, mAllCols ? null : REMINDER_COLS);
while (alarmCur.moveToNext()) {
int mins = getInt(alarmCur, Reminders.MINUTES);
if (mins == -1) {
mins = 60; // FIXME: Get the real default
}
// FIXME: We should support other types if possible
int method = getInt(alarmCur, Reminders.METHOD);
if (method == Reminders.METHOD_DEFAULT || method == Reminders.METHOD_ALERT) {
VAlarm alarm = new VAlarm(new Dur(0, 0, -mins, 0));
alarm.getProperties().add(Action.DISPLAY);
alarm.getProperties().add(desc);
e.getAlarms().add(alarm);
}
}
alarmCur.close();
}
return e;
}
private int getColumnIndex(Cursor cur, String dbName) {
return dbName == null ? -1 : cur.getColumnIndex(dbName);
}
private String getString(Cursor cur, String dbName) {
int i = getColumnIndex(cur, dbName);
return i == -1 ? null : cur.getString(i);
}
private long getLong(Cursor cur, String dbName) {
int i = getColumnIndex(cur, dbName);
return i == -1 ? -1 : cur.getLong(i);
}
private int getInt(Cursor cur, String dbName) {
int i = getColumnIndex(cur, dbName);
return i == -1 ? -1 : cur.getInt(i);
}
private boolean hasStringValue(Cursor cur, String dbName) {
int i = getColumnIndex(cur, dbName);
return i != -1 && !TextUtils.isEmpty(cur.getString(i));
}
private Date utcDateFromMs(long ms) {
// This date will be UTC provided the default false value of the iCal4j property
// "net.fortuna.ical4j.timezone.date.floating" has not been changed.
return new Date(ms);
}
private boolean isUtcTimeZone(final String tz) {
if (TextUtils.isEmpty(tz)) {
return true;
}
final String utz = tz.toUpperCase(Locale.US);
return utz.equals("UTC") || utz.equals("UTC-0") || utz.equals("UTC+0") || utz.endsWith("/UTC");
}
private Date getDateTime(Cursor cur, String dbName, String dbTzName, Calendar cal) {
int i = getColumnIndex(cur, dbName);
if (i == -1 || cur.isNull(i)) {
Log_OC.e(TAG, "No valid " + dbName + " column found, index: " + Integer.toString(i));
return null;
}
if (cal == null) {
return utcDateFromMs(cur.getLong(i)); // Ignore timezone for date-only dates
} else if (dbTzName == null) {
Log_OC.e(TAG, "No valid tz " + dbName + " column given");
}
String tz = getString(cur, dbTzName);
final boolean isUtc = isUtcTimeZone(tz);
DateTime dt = new DateTime(isUtc);
if (dt.isUtc() != isUtc) {
throw new RuntimeException("UTC mismatch after construction");
}
dt.setTime(cur.getLong(i));
if (dt.isUtc() != isUtc) {
throw new RuntimeException("UTC mismatch after setTime");
}
if (!isUtc) {
if (mTzRegistry == null) {
mTzRegistry = TimeZoneRegistryFactory.getInstance().createRegistry();
if (mTzRegistry == null) {
throw new RuntimeException("Failed to create TZ registry");
}
}
TimeZone t = mTzRegistry.getTimeZone(tz);
if (t == null) {
Log_OC.e(TAG, "Unknown TZ " + tz + ", assuming UTC");
} else {
dt.setTimeZone(t);
if (!mInsertedTimeZones.contains(t)) {
cal.getComponents().add(t.getVTimeZone());
mInsertedTimeZones.add(t);
}
}
}
return dt;
}
private String copyProperty(PropertyList l, String evName, Cursor cur, String dbName) {
// None of the exceptions caught below should be able to be thrown AFAICS.
try {
String value = getString(cur, dbName);
if (value != null) {
Property p = mPropertyFactory.createProperty(evName);
p.setValue(value);
l.add(p);
return value;
}
} catch (IOException | URISyntaxException | ParseException ignored) {
}
return null;
}
private void copyEnumProperty(PropertyList l, String evName, Cursor cur, String dbName,
List<String> vals) {
// None of the exceptions caught below should be able to be thrown AFAICS.
try {
int i = getColumnIndex(cur, dbName);
if (i != -1 && !cur.isNull(i)) {
int value = (int) cur.getLong(i);
if (value >= 0 && value < vals.size() && vals.get(value) != null) {
Property p = mPropertyFactory.createProperty(evName);
p.setValue(vals.get(value));
l.add(p);
}
}
} catch (IOException | URISyntaxException | ParseException ignored) {
}
}
// TODO move this to some common place
private String generateUid() {
// Generated UIDs take the form <ms>-<uuid>@nextcloud.com.
if (mUidTail == null) {
String uidPid = preferences.getUidPid();
if (uidPid.length() == 0) {
uidPid = UUID.randomUUID().toString().replace("-", "");
preferences.setUidPid(uidPid);
}
mUidTail = uidPid + "@nextcloud.com";
}
mUidMs = Math.max(mUidMs, System.currentTimeMillis());
String uid = mUidMs + mUidTail;
mUidMs++;
return uid;
}
private void upload(File file) {
String backupFolder = activity.getResources().getString(R.string.calendar_backup_folder)
+ OCFile.PATH_SEPARATOR;
Request request = new UploadRequest.Builder(user, file.getAbsolutePath(), backupFolder + file.getName())
.setFileSize(file.length())
.setNameConflicPolicy(NameCollisionPolicy.RENAME)
.setCreateRemoteFolder(true)
.setTrigger(UploadTrigger.USER)
.setPostAction(PostUploadAction.MOVE_TO_APP)
.setRequireWifi(false)
.setRequireCharging(false)
.build();
TransferManagerConnection connection = new TransferManagerConnection(activity, user);
connection.enqueue(request);
}
}

View file

@ -1,25 +0,0 @@
<!--
Nextcloud Android client application
Copyright (C) 2020 Nextcloud.
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
License as published by the Free Software Foundation; either
version 3 of the License, or any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU AFFERO GENERAL PUBLIC LICENSE for more details.
You should have received a copy of the GNU Affero General Public
License along with this program. If not, see <http://www.gnu.org/licenses/>.
Icon provided by Android Material Library in Apache License 2.0
-->
<vector android:height="24dp" android:tint="#757575"
android:viewportHeight="24.0" android:viewportWidth="24.0"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M12,12c2.21,0 4,-1.79 4,-4s-1.79,-4 -4,-4 -4,1.79 -4,4 1.79,4 4,4zM12,14c-2.67,0 -8,1.34 -8,4v2h16v-2c0,-2.66 -5.33,-4 -8,-4z"/>
</vector>

View file

@ -0,0 +1,143 @@
<?xml version="1.0" encoding="utf-8"?><!--
Nextcloud Android client application
Copyright (C) 2017 Tobias Kaminsky
Copyright (C) 2017 Nextcloud.
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
License as published by the Free Software Foundation; either
version 3 of the License, or any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU AFFERO GENERAL PUBLIC LICENSE for more details.
You should have received a copy of the GNU Affero General Public
License along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/contacts_linear_layout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="@dimen/standard_margin"
android:orientation="vertical">
<TextView
android:id="@+id/data_to_back_up_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/data_to_back_up"
android:textStyle="bold" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/contacts"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="48dp"
android:text="@string/contacts"
android:textColor="@color/text_color"
android:textSize="@dimen/two_line_primary_text_size" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/calendar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="48dp"
android:text="@string/calendar"
android:textColor="@color/text_color"
android:textSize="@dimen/two_line_primary_text_size" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<TextView
android:id="@+id/backup_settings_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/backup_settings"
android:textStyle="bold" />
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="48dp">
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/daily_backup"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:textColor="@color/text_color"
android:textSize="@dimen/two_line_primary_text_size" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_toStartOf="@id/daily_backup"
android:orientation="vertical"
android:gravity="center_vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/daily_backup"
android:textColor="@color/text_color"
android:textSize="14sp" />
<TextView
android:id="@+id/last_backup_with_date"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/last_backup"
android:textSize="12sp" />
</LinearLayout>
</RelativeLayout>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_margin="@dimen/standard_margin"
android:orientation="horizontal">
<com.google.android.material.button.MaterialButton
android:id="@+id/backup_now"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/standard_half_margin"
android:layout_marginEnd="@dimen/standard_half_margin"
android:layout_weight="1"
android:text="@string/contacts_backup_button"
android:theme="@style/Button.Primary"
app:cornerRadius="@dimen/button_corner_radius" />
<com.google.android.material.button.MaterialButton
android:id="@+id/contacts_datepicker"
style="@style/OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/standard_half_margin"
android:layout_marginEnd="@dimen/standard_half_margin"
android:layout_weight="1"
android:text="@string/restore_backup"
android:visibility="gone"
app:cornerRadius="@dimen/button_corner_radius"
tools:visibility="visible" />
</LinearLayout>
</LinearLayout>
</ScrollView>

View file

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?><!--
~
~ Nextcloud Android client application
~
~ @author Tobias Kaminsky
~ Copyright (C) 2021 Tobias Kaminsky
~ Copyright (C) 2021 Nextcloud GmbH
~
~ This program is free software: you can redistribute it and/or modify
~ it under the terms of the GNU Affero General Public License as published by
~ the Free Software Foundation, either version 3 of the License, or
~ (at your option) any later version.
~
~ This program is distributed in the hope that it will be useful,
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
~ GNU Affero General Public License for more details.
~
~ You should have received a copy of the GNU Affero General Public License
~ along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/name"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>

View file

@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?><!--
~
~ Nextcloud Android client application
~
~ @author Tobias Kaminsky
~ Copyright (C) 2021 Tobias Kaminsky
~ Copyright (C) 2021 Nextcloud GmbH
~
~ This program is free software: you can redistribute it and/or modify
~ it under the terms of the GNU Affero General Public License as published by
~ the Free Software Foundation, either version 3 of the License, or
~ (at your option) any later version.
~
~ This program is distributed in the hope that it will be useful,
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
~ GNU Affero General Public License for more details.
~
~ You should have received a copy of the GNU Affero General Public License
~ along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
xmlns:tools="http://schemas.android.com/tools"
android:orientation="vertical">
<TextView
android:id="@+id/name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="@dimen/standard_margin"
android:textAppearance="?android:attr/textAppearanceListItem" />
<Spinner
android:id="@+id/spinner"
android:layout_marginStart="@dimen/standard_margin"
android:layout_marginEnd="@dimen/standard_margin"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:visibility="gone"
tools:visibility="visible" />
</LinearLayout>

View file

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

View file

@ -0,0 +1,69 @@
<?xml version="1.0" encoding="utf-8"?><!--
Nextcloud Android client application
Copyright (C) 2021 Tobias Kaminsky
Copyright (C) 2021 Nextcloud
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
License as published by the Free Software Foundation; either
version 3 of the License, or any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU AFFERO GENERAL PUBLIC LICENSE for more details.
You should have received a copy of the GNU Affero General Public
License along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/icon"
android:layout_width="@dimen/contactlist_item_icon_layout_width"
android:layout_height="@dimen/contactlist_item_icon_layout_height"
android:layout_margin="@dimen/standard_margin"
android:contentDescription="@string/contactlist_item_icon"
android:scaleType="centerCrop"
android:src="@drawable/file_calendar"
app:srcCompat="@drawable/file_calendar"
tools:srcCompat="@drawable/file_calendar"
android:layout_gravity="center_vertical" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:gravity="start">
<CheckedTextView
android:id="@+id/name"
android:layout_width="match_parent"
android:layout_height="@dimen/standard_list_item_size"
android:layout_marginEnd="@dimen/standard_margin"
android:layout_weight="1"
android:checkMark="?android:attr/listChoiceIndicatorMultiple"
android:ellipsize="marquee"
android:gravity="center_vertical|start"
android:maxLines="2"
android:paddingStart="8dp"
android:paddingEnd="0dp"
android:textAppearance="?android:attr/textAppearanceListItem" />
<Spinner
android:id="@+id/spinner"
android:layout_width="match_parent"
android:layout_height="@dimen/standard_list_item_size"
android:layout_weight="1"
android:visibility="gone"
android:gravity="start"
tools:visibility="visible" />
</LinearLayout>
</LinearLayout>

View file

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

View file

@ -1,96 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Nextcloud Android client application
Copyright (C) 2017 Tobias Kaminsky
Copyright (C) 2017 Nextcloud.
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
License as published by the Free Software Foundation; either
version 3 of the License, or any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU AFFERO GENERAL PUBLIC LICENSE for more details.
You should have received a copy of the GNU Affero General Public
License along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/contacts_linear_layout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/contacts_automatic_backup"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="@dimen/standard_margin"
android:text="@string/contacts_automatic_backup"
android:textAppearance="?android:attr/textAppearanceMedium"
android:textColor="@color/text_color" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:id="@+id/contacts_last_backup"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="@dimen/standard_margin"
android:layout_weight="1"
android:text="@string/contacts_last_backup"
android:textAppearance="?android:attr/textAppearanceMedium"
android:textColor="@color/text_color"/>
<TextView
android:id="@+id/contacts_last_backup_timestamp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="@dimen/standard_margin"
android:layout_weight="1"
android:gravity="end"
android:text="@string/contacts_preference_backup_never"
android:textAppearance="?android:attr/textAppearanceMedium"/>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal">
<com.google.android.material.button.MaterialButton
android:id="@+id/contacts_backup_now"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="@dimen/standard_margin"
android:layout_weight="1"
android:text="@string/contacts_backup_button"
android:theme="@style/Button.Primary"
app:cornerRadius="@dimen/button_corner_radius" />
<com.google.android.material.button.MaterialButton
android:id="@+id/contacts_datepicker"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="@dimen/standard_margin"
android:layout_weight="1"
android:text="@string/contacts_preference_choose_date"
android:theme="@style/Button"
android:visibility="gone"
app:cornerRadius="@dimen/button_corner_radius" />
</LinearLayout>
</LinearLayout>
</ScrollView>

View file

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

View file

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

View file

@ -51,7 +51,7 @@
<string name="prefs_calendar_contacts_address_resolve_error">Server address for the account could not be resolved for DAVx5 (formerly known as DAVdroid)</string>
<string name="prefs_calendar_contacts_no_store_error">Neither F-Droid nor Google Play is installed</string>
<string name="prefs_calendar_contacts_sync_setup_successful">Calendar &amp; contacts sync set up</string>
<string name="prefs_daily_contacts_sync_summary">Daily backup of your contacts</string>
<string name="prefs_daily_backup_summary">Daily backup of your calendar &amp; contacts</string>
<string name="prefs_sycned_folders_summary">Manage folders for auto upload</string>
<string name="prefs_help">Help</string>
<string name="prefs_recommend">Recommend to friend</string>
@ -187,6 +187,22 @@
<item quantity="one">Failed to copy %1$d file from the %2$s folder into</item>
<item quantity="other">Failed to copy %1$d files from the %2$s folder into</item>
</plurals>
<plurals name="wrote_n_events_to">
<item quantity="one">Wrote %1$d event to %2$s</item>
<item quantity="other">Wrote %1$d events to %2$s</item>
</plurals>
<plurals name="created_n_uids_to">
<item quantity="one">Created %1$d fresh UID</item>
<item quantity="other">Created %1$d fresh UIDs</item>
</plurals>
<plurals name="processed_n_entries">
<item quantity="one">Processed %d entry.</item>
<item quantity="other">Processed %d entries.</item>
</plurals>
<plurals name="found_n_duplicates">
<item quantity="one">Found %d duplicate entry.</item>
<item quantity="other">Found %d duplicate entries.</item>
</plurals>
<string name="sync_foreign_files_forgotten_explanation">As of version 1.3.16, files uploaded from this device are copied into the local %1$s folder to prevent data loss when a single file is synced with multiple accounts.\n\nDue to this change, all files uploaded with earlier versions of this app were copied into the %2$s folder. However, an error prevented the completion of this operation during account synchronization. You may either leave the file(s) as is and delete the link to %3$s, or move the file(s) into the %1$s folder and retain the link to %4$s.\n\nListed below are the local file(s), and the remote file(s) in %5$s they were linked to.</string>
<string name="sync_current_folder_was_removed">The folder %1$s does not exist anymore</string>
<string name="foreign_files_move">Move all</string>
@ -571,21 +587,15 @@
<string name="activities_no_results_message">No events like additions, changes and shares yet.</string>
<string name="prefs_category_about">About</string>
<string name="actionbar_contacts">Back up contacts</string>
<string name="actionbar_contacts_restore">Restore contacts</string>
<string name="actionbar_calendar_contacts_restore">Restore contacts &amp; calendar</string>
<string name="contacts_backup_button">Back up now</string>
<string name="contacts_automatic_backup">Automatic backup</string>
<string name="contacts_last_backup">Last backup</string>
<string name="contacts_read_permission">Permission to read contact list needed</string>
<string name="contaclist_restore_selected">Restore selected contacts</string>
<string name="contactlist_account_chooser_title">Choose account to import</string>
<string name="contactlist_no_permission">No permission given, nothing imported.</string>
<string name="contacts_preference_choose_date">Choose date</string>
<string name="contacts_preference_backup_never">never</string>
<string name="restore_backup">Restore backup</string>
<string name="contacts_preferences_no_file_found">No file found</string>
<string name="contacts_preferences_something_strange_happened">Could not find your last backup!</string>
<string name="contacts_preferences_backup_scheduled">Backup scheduled and will start shortly</string>
<string name="contacts_preferences_import_scheduled">Import scheduled and will start shortly</string>
<string name="backup_title">Contacts &amp; calendar backup</string>
<string name="drawer_logout">Log out</string>
<string name="picture_set_as_no_app">No app found to set a picture with</string>
@ -961,5 +971,20 @@
<string name="choose_template_helper_text">Please choose a template and enter a file name.</string>
<string name="strict_mode">Strict mode: no HTTP connection allowed!</string>
<string name="fullscreen">Fullscreen</string>
<string name="destination_filename">Destination filename</string>
<string name="suggest">Suggest</string>
<string name="enter_destination_filename">Enter destination filename</string>
<string name="did_not_check_for_dupes">Did not check for duplicates.</string>
<string name="last_backup">Last backup: %1$s</string>
<string name="error_choosing_date">Error choosing date</string>
<string name="restore_selected">Restore selected</string>
<string name="calendars">Calendars</string>
<string name="contacts">Contacts</string>
<string name="data_to_back_up">Data to back up</string>
<string name="calendar">Calendar</string>
<string name="backup_settings">Backup settings</string>
<string name="daily_backup">Daily backup</string>
<string name="calendar_name_linewrap" translatable="false">%1$s\n%2$s</string>
<string name="no_calendar_exists">No calendar exists</string>
<string name="more">More</string>
</resources>

View file

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