Merge: add EteSync 2.0 support

pull/131/head
Tom Hacohen 4 years ago
commit d4ef9f7fe3

@ -7,9 +7,9 @@
*/
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply plugin: 'kotlin-android-extensions'
android {
compileSdkVersion rootProject.ext.compileSdkVersion
@ -21,8 +21,8 @@ android {
minSdkVersion 19
targetSdkVersion 29
versionCode 114
versionName "1.16.2"
versionCode 200
versionName "2.0.0"
buildConfigField "boolean", "customCerts", "true"
}
@ -134,6 +134,8 @@ dependencies {
implementation "org.jetbrains.anko:anko-commons:0.10.4"
implementation "com.etesync:journalmanager:1.1.1"
def etebaseVersion = '0.2.0'
implementation "com.etebase:client:$etebaseVersion"
def acraVersion = '5.3.0'
implementation "ch.acra:acra-mail:$acraVersion"
@ -141,9 +143,16 @@ dependencies {
def supportVersion = '1.0.0'
implementation "androidx.legacy:legacy-support-core-ui:$supportVersion"
implementation "androidx.core:core:$supportVersion"
implementation "androidx.fragment:fragment:$supportVersion"
implementation "androidx.appcompat:appcompat:1.1.0"
implementation "androidx.cardview:cardview:1.0.0"
// KTX extensions
implementation "androidx.core:core-ktx:1.3.1"
implementation "androidx.fragment:fragment-ktx:1.2.5"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.2.0"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.2.0"
implementation 'com.google.android.material:material:1.2.0-beta01'
implementation "androidx.legacy:legacy-preference-v14:$supportVersion"
implementation 'com.github.yukuku:ambilwarna:2.0.1'

@ -127,7 +127,7 @@
<!-- Address book account -->
<service
android:name=".syncadapter.NullAuthenticatorService"
android:exported="true"> <!-- Since Android 11, this must be true so that Google Contacts shows the address book accounts -->
android:exported="true"> <!-- Since Android 11, this must be true so that Google Contacts shows the address book accounts -->
<intent-filter>
<action android:name="android.accounts.AccountAuthenticator"/>
</intent-filter>
@ -233,6 +233,14 @@
android:exported="false"
android:parentActivityName=".ui.AccountsActivity">
</activity>
<activity
android:name=".ui.etebase.CollectionActivity"
android:exported="false"
/>
<activity
android:name=".ui.etebase.InvitationsActivity"
android:exported="false"
/>
<activity
android:name=".ui.ViewCollectionActivity"
android:exported="false"

@ -36,8 +36,12 @@ constructor(internal val context: Context, internal val account: Account) {
var uri: URI?
get() {
val uri = accountManager.getUserData(account, KEY_URI)
if (uri == null) {
return null
}
try {
return URI(accountManager.getUserData(account, KEY_URI))
return URI(uri)
} catch (e: URISyntaxException) {
return null
}
@ -73,6 +77,12 @@ constructor(internal val context: Context, internal val account: Account) {
get() = accountManager.getUserData(account, KEY_WIFI_ONLY_SSID)
set(ssid) = accountManager.setUserData(account, KEY_WIFI_ONLY_SSID, ssid)
var etebaseSession: String?
get() = accountManager.getUserData(account, KEY_ETEBASE_SESSION)
set(value) = accountManager.setUserData(account, KEY_ETEBASE_SESSION, value)
val isLegacy: Boolean
get() = authToken != null
// CalDAV settings
@ -216,6 +226,7 @@ constructor(internal val context: Context, internal val account: Account) {
private val KEY_ASYMMETRIC_PRIVATE_KEY = "asymmetric_private_key"
private val KEY_ASYMMETRIC_PUBLIC_KEY = "asymmetric_public_key"
private val KEY_WIFI_ONLY = "wifi_only"
private val KEY_ETEBASE_SESSION = "etebase_session"
// sync on WiFi only (default: false)
private val KEY_WIFI_ONLY_SSID = "wifi_only_ssid" // restrict sync to specific WiFi SSID
@ -243,10 +254,10 @@ constructor(internal val context: Context, internal val account: Account) {
val SYNC_INTERVAL_MANUALLY: Long = -1
// XXX: Workaround a bug in Android where passing a bundle to addAccountExplicitly doesn't work.
fun setUserData(accountManager: AccountManager, account: Account, uri: URI, userName: String) {
fun setUserData(accountManager: AccountManager, account: Account, uri: URI?, userName: String) {
accountManager.setUserData(account, KEY_SETTINGS_VERSION, CURRENT_VERSION.toString())
accountManager.setUserData(account, KEY_USERNAME, userName)
accountManager.setUserData(account, KEY_URI, uri.toString())
accountManager.setUserData(account, KEY_URI, uri?.toString())
}
}
}

@ -158,22 +158,6 @@ class App : Application() {
private fun update(fromVersion: Int) {
Logger.log.info("Updating from version " + fromVersion + " to " + BuildConfig.VERSION_CODE)
if (fromVersion < 6) {
val data = this.data
val dbHelper = ServiceDB.OpenHelper(this)
val collections = readCollections(dbHelper)
for (info in collections) {
val journalEntity = JournalEntity(data, info)
data.insert(journalEntity)
}
val db = dbHelper.writableDatabase
db.delete(ServiceDB.Collections._TABLE, null, null)
db.close()
}
if (fromVersion < 7) {
/* Fix all of the etags to be non-null */
val am = AccountManager.get(this)
@ -234,21 +218,6 @@ class App : Application() {
}
private fun readCollections(dbHelper: ServiceDB.OpenHelper): List<CollectionInfo> {
val db = dbHelper.writableDatabase
val collections = LinkedList<CollectionInfo>()
val cursor = db.query(ServiceDB.Collections._TABLE, null, null, null, null, null, null)
while (cursor.moveToNext()) {
val values = ContentValues()
DatabaseUtils.cursorRowToContentValues(cursor, values)
collections.add(CollectionInfo.fromDB(values))
}
db.close()
cursor.close()
return collections
}
fun migrateServices(dbHelper: ServiceDB.OpenHelper) {
val db = dbHelper.readableDatabase
val data = this.data

@ -32,9 +32,10 @@ public class Constants {
public static final Uri dashboard = webUri.buildUpon().appendEncodedPath("dashboard/").build();
public static final Uri faqUri = webUri.buildUpon().appendEncodedPath("faq/").build();
public static final Uri helpUri = webUri.buildUpon().appendEncodedPath("user-guide/android/").build();
public static final Uri forgotPassword = webUri.buildUpon().appendEncodedPath("accounts/password/reset/").build();
public static final Uri forgotPassword = faqUri.buildUpon().fragment("forgot-password").build();
public static final Uri serviceUrl = Uri.parse((DEBUG_REMOTE_URL == null) ? "https://api.etesync.com/" : DEBUG_REMOTE_URL);
public static final String etebaseServiceUrl = (DEBUG_REMOTE_URL == null) ? "https://api.etebase.com/partner/etesync/" : DEBUG_REMOTE_URL;
public static final String PRODID_BASE = "-//EteSync//" + BuildConfig.APPLICATION_ID + "/" + BuildConfig.VERSION_NAME;
@ -43,4 +44,8 @@ public class Constants {
public final static String KEY_ACCOUNT = "account",
KEY_COLLECTION_INFO = "collectionInfo";
public final static String ETEBASE_TYPE_ADDRESS_BOOK = "etebase.vcard";
public final static String ETEBASE_TYPE_CALENDAR = "etebase.vevent";
public final static String ETEBASE_TYPE_TASKS = "etebase.vtodo";
}

@ -0,0 +1,166 @@
package com.etesync.syncadapter
import android.content.Context
import com.etebase.client.*
import com.etebase.client.Collection
import okhttp3.OkHttpClient
import java.io.File
import java.util.*
/*
File structure:
cache_dir/
user1/ <--- the name of the user
stoken <-- the stokens of the collection fetch
cols/
UID1/ - The uid of the first col
...
UID2/ - The uid of the second col
col <-- the col itself
stoken <-- the stoken of the items fetch
items/
item_uid1 <-- the item with uid 1
item_uid2
...
*/
class EtebaseLocalCache private constructor(context: Context, username: String) {
private val filesDir: File = File(context.filesDir, username)
private val colsDir: File
init {
colsDir = File(filesDir, "cols")
colsDir.mkdirs()
}
private fun getCollectionItemsDir(colUid: String): File {
val colsDir = File(filesDir, "cols")
val colDir = File(colsDir, colUid)
return File(colDir, "items")
}
fun clearUserCache() {
filesDir.deleteRecursively()
}
fun saveStoken(stoken: String) {
val stokenFile = File(filesDir, "stoken")
stokenFile.writeText(stoken)
}
fun loadStoken(): String? {
val stokenFile = File(filesDir, "stoken")
return if (stokenFile.exists()) stokenFile.readText() else null
}
fun collectionSaveStoken(colUid: String, stoken: String) {
val colDir = File(colsDir, colUid)
val stokenFile = File(colDir, "stoken")
stokenFile.writeText(stoken)
}
fun collectionLoadStoken(colUid: String): String? {
val colDir = File(colsDir, colUid)
val stokenFile = File(colDir, "stoken")
return if (stokenFile.exists()) stokenFile.readText() else null
}
fun collectionList(colMgr: CollectionManager, withDeleted: Boolean = false): List<CachedCollection> {
return colsDir.list().map {
val colDir = File(colsDir, it)
val colFile = File(colDir, "col")
val content = colFile.readBytes()
colMgr.cacheLoad(content)
}.filter { withDeleted || !it.isDeleted }.map{
CachedCollection(it, it.meta)
}
}
fun collectionGet(colMgr: CollectionManager, colUid: String): CachedCollection? {
val colDir = File(colsDir, colUid)
val colFile = File(colDir, "col")
if (!colFile.exists()) {
return null
}
val content = colFile.readBytes()
return colMgr.cacheLoad(content).let {
CachedCollection(it, it.meta)
}
}
fun collectionSet(colMgr: CollectionManager, collection: Collection) {
val colDir = File(colsDir, collection.uid)
colDir.mkdirs()
val colFile = File(colDir, "col")
colFile.writeBytes(colMgr.cacheSaveWithContent(collection))
val itemsDir = getCollectionItemsDir(collection.uid)
itemsDir.mkdirs()
}
fun collectionUnset(colMgr: CollectionManager, colUid: String) {
val colDir = File(colsDir, colUid)
colDir.deleteRecursively()
}
fun itemList(itemMgr: ItemManager, colUid: String, withDeleted: Boolean = false): List<CachedItem> {
val itemsDir = getCollectionItemsDir(colUid)
return itemsDir.list().map {
val itemFile = File(itemsDir, it)
val content = itemFile.readBytes()
itemMgr.cacheLoad(content)
}.filter { withDeleted || !it.isDeleted }.map {
CachedItem(it, it.meta, it.contentString)
}
}
fun itemGet(itemMgr: ItemManager, colUid: String, itemUid: String): CachedItem? {
val itemsDir = getCollectionItemsDir(colUid)
val itemFile = File(itemsDir, itemUid)
if (!itemFile.exists()) {
return null
}
val content = itemFile.readBytes()
return itemMgr.cacheLoad(content).let {
CachedItem(it, it.meta, it.contentString)
}
}
fun itemSet(itemMgr: ItemManager, colUid: String, item: Item) {
val itemsDir = getCollectionItemsDir(colUid)
val itemFile = File(itemsDir, item.uid)
itemFile.writeBytes(itemMgr.cacheSaveWithContent(item))
}
fun itemUnset(itemMgr: ItemManager, colUid: String, itemUid: String) {
val itemsDir = getCollectionItemsDir(colUid)
val itemFile = File(itemsDir, itemUid)
itemFile.delete()
}
companion object {
private val localCacheCache: HashMap<String, EtebaseLocalCache> = HashMap()
fun getInstance(context: Context, username: String): EtebaseLocalCache {
synchronized(localCacheCache) {
val cached = localCacheCache.get(username)
if (cached != null) {
return cached
} else {
val ret = EtebaseLocalCache(context, username)
localCacheCache.set(username, ret)
return ret
}
}
}
// FIXME: If we ever cache this we need to cache bust on changePassword
fun getEtebase(context: Context, httpClient: OkHttpClient, settings: AccountSettings): Account {
val client = Client.create(httpClient, settings.uri?.toString())
return Account.restore(client, settings.etebaseSession!!, null)
}
}
}
data class CachedCollection(val col: Collection, val meta: CollectionMetadata)
data class CachedItem(val item: Item, val meta: ItemMetadata, val content: String)

@ -68,7 +68,6 @@ class HttpClient private constructor(
) {
private var certManager: CustomCertManager? = null
private var certificateAlias: String? = null
private var cache: Cache? = null
private val orig = sharedClient.newBuilder()

@ -48,23 +48,6 @@ class CollectionInfo : com.etesync.journalmanager.model.CollectionInfo() {
return info
}
fun fromDB(values: ContentValues): CollectionInfo {
val info = CollectionInfo()
info.id = values.getAsLong(Collections.ID)!!
info.serviceID = values.getAsInteger(Collections.SERVICE_ID)!!
info.uid = values.getAsString(Collections.URL)
info.displayName = values.getAsString(Collections.DISPLAY_NAME)
info.description = values.getAsString(Collections.DESCRIPTION)
info.color = values.getAsInteger(Collections.COLOR)
info.timeZone = values.getAsString(Collections.TIME_ZONE)
info.selected = values.getAsInteger(Collections.SYNC) != 0
return info
}
fun fromJson(json: String): CollectionInfo {
return GsonBuilder().excludeFieldsWithoutExposeAnnotation().create().fromJson(json, CollectionInfo::class.java)
}

@ -19,7 +19,9 @@ import android.provider.ContactsContract.CommonDataKinds.GroupMembership
import android.provider.ContactsContract.Groups
import android.provider.ContactsContract.RawContacts
import at.bitfire.vcard4android.*
import com.etebase.client.CollectionAccessLevel
import com.etesync.syncadapter.App
import com.etesync.syncadapter.CachedCollection
import com.etesync.syncadapter.log.Logger
import com.etesync.syncadapter.model.CollectionInfo
import com.etesync.syncadapter.model.JournalEntity
@ -70,6 +72,37 @@ class LocalAddressBook(
return addressBook
}
fun create(context: Context, provider: ContentProviderClient, mainAccount: Account, cachedCollection: CachedCollection): LocalAddressBook {
val col = cachedCollection.col
val accountManager = AccountManager.get(context)
val account = Account(accountName(mainAccount, cachedCollection), App.addressBookAccountType)
val userData = initialUserData(mainAccount, col.uid)
Logger.log.log(Level.INFO, "Creating local address book $account", userData)
if (!accountManager.addAccountExplicitly(account, null, userData))
throw IllegalStateException("Couldn't create address book account")
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
// Android < 7 seems to lose the initial user data sometimes, so set it a second time
// https://forums.bitfire.at/post/11644
userData.keySet().forEach { key ->
accountManager.setUserData(account, key, userData.getString(key))
}
}
val addressBook = LocalAddressBook(context, account, provider)
ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 1)
ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true)
val values = ContentValues(2)
values.put(ContactsContract.Settings.SHOULD_SYNC, 1)
values.put(ContactsContract.Settings.UNGROUPED_VISIBLE, 1)
addressBook.settings = values
addressBook.readOnly = col.accessLevel == CollectionAccessLevel.ReadOnly
return addressBook
}
fun find(context: Context, provider: ContentProviderClient?, mainAccount: Account?) = AccountManager.get(context)
.getAccountsByType(App.addressBookAccountType)
@ -103,6 +136,13 @@ class LocalAddressBook(
return sb.toString()
}
fun accountName(mainAccount: Account, cachedCollection: CachedCollection): String {
val col = cachedCollection.col
val meta = cachedCollection.meta
val displayName = meta.name
return "${displayName} (${mainAccount.name} ${col.uid.substring(0, 2)})"
}
fun initialUserData(mainAccount: Account, url: String): Bundle {
val bundle = Bundle(3)
bundle.putString(USER_DATA_MAIN_ACCOUNT_NAME, mainAccount.name)
@ -181,6 +221,37 @@ class LocalAddressBook(
ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true)
}
fun update(cachedCollection: CachedCollection) {
val col = cachedCollection.col
val newAccountName = accountName(mainAccount, cachedCollection)
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
if (account.name != newAccountName && Build.VERSION.SDK_INT >= 21) {
val accountManager = AccountManager.get(context)
val future = accountManager.renameAccount(account, newAccountName, {
try {
// update raw contacts to new account name
if (provider != null) {
val values = ContentValues(1)
values.put(RawContacts.ACCOUNT_NAME, newAccountName)
provider.update(syncAdapterURI(RawContacts.CONTENT_URI), values, RawContacts.ACCOUNT_NAME + "=? AND " + RawContacts.ACCOUNT_TYPE + "=?",
arrayOf(account.name, account.type))
}
} catch (e: RemoteException) {
Logger.log.log(Level.WARNING, "Couldn't re-assign contacts to new account name", e)
}
}, null)
account = future.result
}
readOnly = col.accessLevel == CollectionAccessLevel.ReadOnly
Logger.log.info("Address book write permission? = ${!readOnly}")
// make sure it will still be synchronized when contacts are updated
ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true)
}
fun delete() {
val accountManager = AccountManager.get(context)
@ -279,6 +350,16 @@ class LocalAddressBook(
}
}
override fun findByFilename(filename: String): LocalAddress? {
val found = queryContacts("${AndroidContact.COLUMN_FILENAME}=?", arrayOf(filename)).firstOrNull()
if (found != null) {
return found
} else {
return queryGroups("${AndroidGroup.COLUMN_FILENAME}=?", arrayOf(filename)).firstOrNull()
}
}
fun findGroupById(id: Long): LocalGroup =
queryGroups("${Groups._ID}=?", arrayOf(id.toString())).firstOrNull()
?: throw FileNotFoundException()

@ -18,9 +18,13 @@ import android.os.RemoteException
import android.provider.CalendarContract
import android.provider.CalendarContract.*
import at.bitfire.ical4android.*
import com.etebase.client.CollectionAccessLevel
import com.etesync.syncadapter.CachedCollection
import com.etesync.syncadapter.log.Logger
import com.etesync.syncadapter.model.JournalEntity
import com.etesync.syncadapter.resource.LocalEvent.Companion.COLUMN_UID
import org.apache.commons.lang3.StringUtils
import org.dmfs.tasks.contract.TaskContract
import java.util.*
import java.util.logging.Level
@ -31,7 +35,21 @@ class LocalCalendar private constructor(
): AndroidCalendar<LocalEvent>(account, provider, LocalEvent.Factory, id), LocalCollection<LocalEvent> {
companion object {
val defaultColor = -0x743cb6 // light green 500
val defaultColor = -0x743cb6 // light green 500 - should be "8BC349"?
fun parseColor(color_: String?): Int {
if (color_.isNullOrBlank()) {
return defaultColor
}
val color = color_.replaceFirst("^#".toRegex(), "")
if (color.length == 8) {
return (color.substring(0, 6).toLong(16) or (color.substring(6, 8).toLong(16) shl 24)).toInt()
} else if (color.length == 6) {
return (color.toLong(16) or (0xFF000000)).toInt()
} else {
return defaultColor
}
}
val COLUMN_CTAG = Calendars.CAL_SYNC1
@ -50,6 +68,21 @@ class LocalCalendar private constructor(
return AndroidCalendar.create(account, provider, values)
}
fun create(account: Account, provider: ContentProviderClient, cachedCollection: CachedCollection): Uri {
val values = valuesFromCachedCollection(cachedCollection, true)
// ACCOUNT_NAME and ACCOUNT_TYPE are required (see docs)! If it's missing, other apps will crash.
values.put(Calendars.ACCOUNT_NAME, account.name)
values.put(Calendars.ACCOUNT_TYPE, account.type)
values.put(Calendars.OWNER_ACCOUNT, account.name)
// flag as visible & synchronizable at creation, might be changed by user at any time
values.put(Calendars.VISIBLE, 1)
values.put(Calendars.SYNC_EVENTS, 1)
return AndroidCalendar.create(account, provider, values)
}
fun findByName(account: Account, provider: ContentProviderClient, factory: Factory, name: String): LocalCalendar?
= AndroidCalendar.find(account, provider, factory, Calendars.NAME + "==?", arrayOf(name)).firstOrNull()
@ -85,6 +118,30 @@ class LocalCalendar private constructor(
values.put(Calendars.ALLOWED_ATTENDEE_TYPES, StringUtils.join(intArrayOf(CalendarContract.Attendees.TYPE_OPTIONAL, CalendarContract.Attendees.TYPE_REQUIRED, CalendarContract.Attendees.TYPE_RESOURCE), ", "))
return values
}
private fun valuesFromCachedCollection(cachedCollection: CachedCollection, withColor: Boolean): ContentValues {
val values = ContentValues()
val col = cachedCollection.col
val meta = cachedCollection.meta
values.put(Calendars.NAME, col.uid)
values.put(Calendars.CALENDAR_DISPLAY_NAME, meta.name)
if (withColor)
values.put(Calendars.CALENDAR_COLOR, parseColor(meta.color))
if (col.accessLevel == CollectionAccessLevel.ReadOnly)
values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_READ)
else {
values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_OWNER)
values.put(Calendars.CAN_MODIFY_TIME_ZONE, 1)
values.put(Calendars.CAN_ORGANIZER_RESPOND, 1)
}
values.put(Calendars.ALLOWED_REMINDERS, Reminders.METHOD_ALERT)
values.put(Calendars.ALLOWED_AVAILABILITY, StringUtils.join(intArrayOf(Reminders.AVAILABILITY_TENTATIVE, Reminders.AVAILABILITY_FREE, Reminders.AVAILABILITY_BUSY), ","))
values.put(Calendars.ALLOWED_ATTENDEE_TYPES, StringUtils.join(intArrayOf(CalendarContract.Attendees.TYPE_OPTIONAL, CalendarContract.Attendees.TYPE_REQUIRED, CalendarContract.Attendees.TYPE_RESOURCE), ", "))
return values
}
}
override val url: String?
@ -93,6 +150,9 @@ class LocalCalendar private constructor(
fun update(journalEntity: JournalEntity, updateColor: Boolean) =
update(valuesFromCollectionInfo(journalEntity, updateColor))
fun update(cachedCollection: CachedCollection, updateColor: Boolean) =
update(valuesFromCachedCollection(cachedCollection, updateColor))
override fun findDeleted() =
queryEvents("${Events.DELETED}!=0 AND ${Events.ORIGINAL_ID} IS NULL", null)
@ -122,7 +182,10 @@ class LocalCalendar private constructor(
= queryEvents(null, null)
override fun findByUid(uid: String): LocalEvent?
= queryEvents(Events._SYNC_ID + " =? ", arrayOf(uid)).firstOrNull()
= queryEvents(COLUMN_UID + " =? ", arrayOf(uid)).firstOrNull()
override fun findByFilename(filename: String): LocalEvent?
= queryEvents(Events._SYNC_ID + " =? ", arrayOf(filename)).firstOrNull()
fun processDirtyExceptions() {
// process deleted exceptions

@ -17,6 +17,7 @@ interface LocalCollection<out T: LocalResource<*>> {
fun findAll(): List<T>
fun findByUid(uid: String): T?
fun findByFilename(filename: String): T?
fun count(): Long

@ -52,7 +52,7 @@ class LocalContact : AndroidContact, LocalAddress {
override// The same now
val uuid: String?
get() = fileName
get() = contact?.uid
override val isLocalOnly: Boolean
get() = TextUtils.isEmpty(eTag)
@ -88,9 +88,11 @@ class LocalContact : AndroidContact, LocalAddress {
addressBook.provider?.update(rawContactSyncURI(), values, null, null)
}
override fun clearDirty(eTag: String) {
override fun clearDirty(eTag: String?) {
val values = ContentValues(3)
values.put(AndroidContact.COLUMN_ETAG, eTag)
if (eTag != null) {
values.put(AndroidContact.COLUMN_ETAG, eTag)
}
values.put(ContactsContract.RawContacts.DIRTY, 0)
if (LocalContact.HASH_HACK) {
@ -105,15 +107,26 @@ class LocalContact : AndroidContact, LocalAddress {
this.eTag = eTag
}
override fun prepareForUpload() {
override fun legacyPrepareForUpload(fileName_: String?) {
val uid = UUID.randomUUID().toString()
val values = ContentValues(2)
values.put(AndroidContact.COLUMN_FILENAME, uid)
val fileName = fileName_ ?: uid
values.put(AndroidContact.COLUMN_FILENAME, fileName)
values.put(AndroidContact.COLUMN_UID, uid)
addressBook.provider?.update(rawContactSyncURI(), values, null, null)
this.fileName = fileName
}
override fun prepareForUpload(fileName: String, uid: String) {
val values = ContentValues(2)
values.put(AndroidContact.COLUMN_FILENAME, fileName)
values.put(AndroidContact.COLUMN_UID, uid)
addressBook.provider?.update(rawContactSyncURI(), values, null, null)
fileName = uid
contact?.uid = uid
this.fileName = fileName
}
override fun populateData(mimeType: String, row: ContentValues) {

@ -38,14 +38,14 @@ class LocalEvent : AndroidEvent, LocalResource<Event> {
private var saveAsDirty = false // When true, the resource will be saved as dirty
private var fileName: String? = null
override var fileName: String? = null
var eTag: String? = null
var weAreOrganizer = true
override val content: String
get() {
Logger.log.log(Level.FINE, "Preparing upload of event " + fileName!!, event)
Logger.log.log(Level.FINE, "Preparing upload of event $fileName} ${event}")
val os = ByteArrayOutputStream()
event?.write(os)
@ -58,7 +58,7 @@ class LocalEvent : AndroidEvent, LocalResource<Event> {
override// Now the same
val uuid: String?
get() = fileName
get() = event?.uid
constructor(calendar: AndroidCalendar<*>, event: Event, fileName: String?, eTag: String?) : super(calendar, event) {
this.fileName = fileName
@ -133,7 +133,7 @@ class LocalEvent : AndroidEvent, LocalResource<Event> {
/* custom queries */
override fun prepareForUpload() {
override fun legacyPrepareForUpload(fileName_: String?) {
var uid: String? = null
val c = calendar.provider.query(eventSyncURI(), arrayOf(COLUMN_UID), null, null, null)
if (c.moveToNext())
@ -142,30 +142,42 @@ class LocalEvent : AndroidEvent, LocalResource<Event> {
uid = UUID.randomUUID().toString()
c.close()
val newFileName = uid
val fileName = fileName_ ?: uid
val values = ContentValues(2)
values.put(Events._SYNC_ID, newFileName)
values.put(Events._SYNC_ID, fileName)
values.put(COLUMN_UID, uid)
calendar.provider.update(eventSyncURI(), values, null, null)
fileName = newFileName
this.fileName = fileName
val event = this.event
if (event != null)
event.uid = uid
}
override fun prepareForUpload(fileName: String, uid: String) {
val values = ContentValues(2)
values.put(Events._SYNC_ID, fileName)
values.put(COLUMN_UID, uid)
calendar.provider.update(eventSyncURI(), values, null, null)
event?.uid = uid
this.fileName = fileName
}
override fun resetDeleted() {
val values = ContentValues(1)
values.put(CalendarContract.Events.DELETED, 0)
calendar.provider.update(eventSyncURI(), values, null, null)
}
override fun clearDirty(eTag: String) {
override fun clearDirty(eTag: String?) {
val values = ContentValues(2)
values.put(CalendarContract.Events.DIRTY, 0)
values.put(COLUMN_ETAG, eTag)
if (eTag != null) {
values.put(COLUMN_ETAG, eTag)
}
if (event != null)
values.put(COLUMN_SEQUENCE, event?.sequence)
calendar.provider.update(eventSyncURI(), values, null, null)

@ -120,13 +120,15 @@ class LocalGroup : AndroidGroup, LocalAddress {
return values
}
override fun clearDirty(eTag: String) {
override fun clearDirty(eTag: String?) {
val id = requireNotNull(id)
val values = ContentValues(2)
values.put(Groups.DIRTY, 0)
this.eTag = eTag
values.put(AndroidGroup.COLUMN_ETAG, eTag)
if (eTag != null) {
values.put(AndroidGroup.COLUMN_ETAG, eTag)
}
update(values)
// update cached group memberships
@ -154,15 +156,26 @@ class LocalGroup : AndroidGroup, LocalAddress {
batch.commit()
}
override fun prepareForUpload() {
override fun legacyPrepareForUpload(fileName_: String?) {
val uid = UUID.randomUUID().toString()
val values = ContentValues(2)
values.put(AndroidGroup.COLUMN_FILENAME, uid)
val fileName = fileName_ ?: uid
values.put(AndroidGroup.COLUMN_FILENAME, fileName)
values.put(AndroidGroup.COLUMN_UID, uid)
update(values)
fileName = uid
this.fileName = fileName
}
override fun prepareForUpload(fileName: String, uid: String) {
val values = ContentValues(2)
values.put(AndroidGroup.COLUMN_FILENAME, fileName)
values.put(AndroidGroup.COLUMN_UID, uid)
addressBook.provider?.update(groupSyncUri(), values, null, null)
contact?.uid = uid
this.fileName = fileName
}
override fun resetDeleted() {

@ -10,6 +10,7 @@ package com.etesync.syncadapter.resource
interface LocalResource<in TData: Any> {
val uuid: String?
val fileName: String?
/** True if doesn't exist on server yet, false otherwise. */
val isLocalOnly: Boolean
@ -19,9 +20,11 @@ interface LocalResource<in TData: Any> {
fun delete(): Int
fun prepareForUpload()
fun legacyPrepareForUpload(fileName: String?)
fun clearDirty(eTag: String)
fun prepareForUpload(fileName: String, uid: String)
fun clearDirty(eTag: String?)
fun resetDeleted()
}

@ -31,7 +31,7 @@ class LocalTask : AndroidTask, LocalResource<Task> {
private var saveAsDirty = false // When true, the resource will be saved as dirty
private var fileName: String? = null
override var fileName: String? = null
var eTag: String? = null
override val content: String
@ -49,7 +49,7 @@ class LocalTask : AndroidTask, LocalResource<Task> {
override// Now the same
val uuid: String?
get() = fileName
get() = task?.uid
constructor(taskList: AndroidTaskList<*>, task: Task, fileName: String?, eTag: String?)
: super(taskList, task) {
@ -96,7 +96,7 @@ class LocalTask : AndroidTask, LocalResource<Task> {
/* custom queries */
override fun prepareForUpload() {
override fun legacyPrepareForUpload(fileName_: String?) {
var uid: String? = null
val c = taskList.provider.client.query(taskSyncURI(), arrayOf(COLUMN_UID), null, null, null)
if (c.moveToNext())
@ -106,27 +106,40 @@ class LocalTask : AndroidTask, LocalResource<Task> {
c.close()
val fileName = fileName_ ?: uid
val values = ContentValues(2)
values.put(TaskContract.Tasks._SYNC_ID, uid)
values.put(TaskContract.Tasks._SYNC_ID, fileName)
values.put(COLUMN_UID, uid)
taskList.provider.client.update(taskSyncURI(), values, null, null)
fileName = uid
this.fileName = fileName
val task = this.task
if (task != null)
task.uid = uid
}
override fun prepareForUpload(fileName: String, uid: String) {
val values = ContentValues(2)
values.put(TaskContract.Tasks._SYNC_ID, fileName)
values.put(COLUMN_UID, uid)
taskList.provider.client.update(taskSyncURI(), values, null, null)
task?.uid = uid
this.fileName = fileName
}
override fun resetDeleted() {
val values = ContentValues(1)
values.put(TaskContract.Tasks._DELETED, 0)
taskList.provider.client.update(taskSyncURI(), values, null, null)
}
override fun clearDirty(eTag: String) {
override fun clearDirty(eTag: String?) {
val values = ContentValues(2)
values.put(TaskContract.Tasks._DIRTY, 0)
values.put(COLUMN_ETAG, eTag)
if (eTag != null) {
values.put(COLUMN_ETAG, eTag)
}
if (task != null)
values.put(COLUMN_SEQUENCE, task?.sequence)
taskList.provider.client.update(taskSyncURI(), values, null, null)

@ -19,6 +19,7 @@ import at.bitfire.ical4android.AndroidTaskListFactory
import at.bitfire.ical4android.CalendarStorageException
import at.bitfire.ical4android.TaskProvider
import at.bitfire.ical4android.TaskProvider.ProviderName
import com.etesync.syncadapter.CachedCollection
import com.etesync.syncadapter.model.JournalEntity
import org.dmfs.tasks.contract.TaskContract.TaskLists
import org.dmfs.tasks.contract.TaskContract.Tasks
@ -54,6 +55,14 @@ class LocalTaskList private constructor(
return create(account, provider, values)
}
fun create(account: Account, provider: TaskProvider, cachedCollection: CachedCollection): Uri {
val values = valuesFromCachedCollection(cachedCollection, true)
values.put(TaskLists.OWNER, account.name)
values.put(TaskLists.SYNC_ENABLED, 1)
values.put(TaskLists.VISIBLE, 1)
return create(account, provider, values)
}
fun findByName(account: Account, provider: TaskProvider, factory: Factory, name: String): LocalTaskList?
= AndroidTaskList.find(account, provider, factory, TaskLists._SYNC_ID + "==?", arrayOf(name)).firstOrNull()
@ -70,6 +79,18 @@ class LocalTaskList private constructor(
return values
}
private fun valuesFromCachedCollection(cachedCollection: CachedCollection, withColor: Boolean): ContentValues {
val col = cachedCollection.col
val meta = cachedCollection.meta
val values = ContentValues(3)
values.put(TaskLists._SYNC_ID, col.uid)
values.put(TaskLists.LIST_NAME, meta.name)
if (withColor)
values.put(TaskLists.LIST_COLOR, LocalCalendar.parseColor(meta.color))
return values
}
}
override val url: String?
@ -78,6 +99,9 @@ class LocalTaskList private constructor(
fun update(journalEntity: JournalEntity, updateColor: Boolean) =
update(valuesFromCollectionInfo(journalEntity, updateColor))
fun update(cachedCollection: CachedCollection, updateColor: Boolean) =
update(valuesFromCachedCollection(cachedCollection, updateColor))
override fun findDeleted() = queryTasks("${Tasks._DELETED}!=0", null)
override fun findDirty(limit: Int?): List<LocalTask> {
@ -101,7 +125,10 @@ class LocalTaskList private constructor(
= queryTasks(Tasks._SYNC_ID + " IS NULL", null)
override fun findByUid(uid: String): LocalTask?
= queryTasks(Tasks._SYNC_ID + " =? ", arrayOf(uid)).firstOrNull()
= queryTasks(LocalTask.COLUMN_UID + " =? ", arrayOf(uid)).firstOrNull()
override fun findByFilename(filename: String): LocalTask?
= queryTasks(Tasks._SYNC_ID + " =? ", arrayOf(filename)).firstOrNull()
override fun count(): Long {
try {

@ -15,10 +15,7 @@ import android.content.*
import android.os.Bundle
import android.provider.ContactsContract
import at.bitfire.vcard4android.ContactsStorageException
import com.etesync.syncadapter.AccountSettings
import com.etesync.syncadapter.App
import com.etesync.syncadapter.Constants
import com.etesync.syncadapter.R
import com.etesync.syncadapter.*
import com.etesync.syncadapter.log.Logger
import com.etesync.syncadapter.model.CollectionInfo
import com.etesync.syncadapter.model.JournalEntity
@ -36,9 +33,6 @@ class AddressBooksSyncAdapterService : SyncAdapterService() {
private class AddressBooksSyncAdapter(context: Context) : SyncAdapterService.SyncAdapter(context) {
override val syncErrorTitle = R.string.sync_error_contacts
override val notificationManager = SyncNotification(context, "journals-contacts", Constants.NOTIFICATION_CONTACTS_SYNC)
override fun onPerformSyncDo(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
val contactsProvider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)
if (contactsProvider == null) {
@ -53,7 +47,11 @@ class AddressBooksSyncAdapterService : SyncAdapterService() {
RefreshCollections(account, CollectionInfo.Type.ADDRESS_BOOK).run()
updateLocalAddressBooks(contactsProvider, account)
if (settings.isLegacy) {
legacyUpdateLocalAddressBooks(contactsProvider, account)
} else {
updateLocalAddressBooks(contactsProvider, account, settings)
}
contactsProvider.release()
@ -63,15 +61,57 @@ class AddressBooksSyncAdapterService : SyncAdapterService() {
val syncExtras = Bundle(extras)
syncExtras.putBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_SETTINGS, true)
syncExtras.putBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_BACKOFF, true)
syncExtras.putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true) // run immediately (don't queue)
ContentResolver.requestSync(addressBookAccount, ContactsContract.AUTHORITY, syncExtras)
}
Logger.log.info("Address book sync complete")
}
private fun updateLocalAddressBooks(provider: ContentProviderClient, account: Account, settings: AccountSettings) {
val remote = HashMap<String, CachedCollection>()
val etebaseLocalCache = EtebaseLocalCache.getInstance(context, account.name)
val collections: List<CachedCollection>
synchronized(etebaseLocalCache) {
val httpClient = HttpClient.Builder(context, settings).setForeground(false).build()
val etebase = EtebaseLocalCache.getEtebase(context, httpClient.okHttpClient, settings)
val colMgr = etebase.collectionManager
collections = etebaseLocalCache.collectionList(colMgr).filter { it.meta.collectionType == Constants.ETEBASE_TYPE_ADDRESS_BOOK }
}
for (collection in collections) {
remote[collection.col.uid] = collection
}
val local = LocalAddressBook.find(context, provider, account)
// delete obsolete local calendar
for (addressBook in local) {
val url = addressBook.url
val collection = remote[url]
if (collection == null) {
Logger.log.fine("Deleting obsolete local addressBook $url")
addressBook.delete()
} else {
// remote CollectionInfo found for this local collection, update data
Logger.log.fine("Updating local addressBook $url")
addressBook.update(collection)
// we already have a local addressBook for this remote collection, don't take into consideration anymore
remote.remove(url)
}
}
// create new local calendars
for (url in remote.keys) {
val cachedCollection = remote[url]!!
Logger.log.info("Adding local calendar list $cachedCollection")
LocalAddressBook.create(context, provider, account, cachedCollection)
}
}
@Throws(ContactsStorageException::class, AuthenticatorException::class, OperationCanceledException::class, IOException::class)
private fun updateLocalAddressBooks(provider: ContentProviderClient, account: Account) {
private fun legacyUpdateLocalAddressBooks(provider: ContentProviderClient, account: Account) {
val context = context
val data = (getContext().applicationContext as App).data
val service = JournalModel.Service.fetchOrCreate(data, account.name, CollectionInfo.Type.ADDRESS_BOOK)

@ -17,6 +17,7 @@ import at.bitfire.ical4android.CalendarStorageException
import at.bitfire.ical4android.Event
import at.bitfire.ical4android.InvalidCalendarException
import at.bitfire.vcard4android.ContactsStorageException
import com.etebase.client.Item
import com.etesync.syncadapter.AccountSettings
import com.etesync.syncadapter.Constants
import com.etesync.syncadapter.R
@ -43,7 +44,7 @@ constructor(context: Context, account: Account, settings: AccountSettings, extra
get() = context.getString(R.string.sync_error_calendar, account.name)
override val syncSuccessfullyTitle: String
get() = context.getString(R.string.sync_successfully_calendar, info.displayName,
get() = context.getString(R.string.sync_successfully_calendar, localCalendar().displayName,
account.name)
init {
@ -59,7 +60,9 @@ constructor(context: Context, account: Account, settings: AccountSettings, extra
if (!super.prepare())
return false
journal = JournalEntryManager(httpClient.okHttpClient, remote, localCalendar().name!!)
if (isLegacy) {
journal = JournalEntryManager(httpClient.okHttpClient, remote, localCalendar().name!!)
}
return true
}
@ -77,6 +80,32 @@ constructor(context: Context, account: Account, settings: AccountSettings, extra
return localCollection as LocalCalendar
}
override fun processItem(item: Item) {
val local = localCollection!!.findByFilename(item.uid)
if (!item.isDeleted) {
val inputReader = StringReader(String(item.content))
val events = Event.eventsFromReader(inputReader)
if (events.size == 0) {
Logger.log.warning("Received VCard without data, ignoring")
return
} else if (events.size > 1) {
Logger.log.warning("Received multiple VCALs, using first one")
}
val event = events[0]
processEvent(item, event, local)
} else {
if (local != null) {
Logger.log.info("Removing local record #" + local.id + " which has been deleted on the server")
local.delete()
} else {
Logger.log.warning("Tried deleting a non-existent record: " + item.uid)
}
}
}
@Throws(IOException::class, ContactsStorageException::class, CalendarStorageException::class, InvalidCalendarException::class)
override fun processSyncEntryImpl(cEntry: SyncEntry) {
val inputReader = StringReader(cEntry.content)
@ -93,7 +122,7 @@ constructor(context: Context, account: Account, settings: AccountSettings, extra
val local = localCollection!!.findByUid(event.uid!!)
if (cEntry.isAction(SyncEntry.Actions.ADD) || cEntry.isAction(SyncEntry.Actions.CHANGE)) {
processEvent(event, local)
legacyProcessEvent(event, local)
} else {
if (local != null) {
Logger.log.info("Removing local record #" + local.id + " which has been deleted on the server")
@ -105,8 +134,8 @@ constructor(context: Context, account: Account, settings: AccountSettings, extra
}
@Throws(CalendarStorageException::class, ContactsStorageException::class, IOException::class)
override fun createLocalEntries() {
super.createLocalEntries()
override fun prepareLocal() {
super.prepareLocal()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
createInviteAttendeesNotification()
@ -128,7 +157,7 @@ constructor(context: Context, account: Account, settings: AccountSettings, extra
private fun createInviteAttendeesNotification(event: Event, icsContent: String) {
val intent = EventEmailInvitation(context, account).createIntent(event, icsContent)
if (intent != null) {
val notificationHelper = SyncNotification(context, event.uid!!, event.uid!!.hashCode())
val notificationHelper = SyncNotification(context, icsContent, event.hashCode())
notificationHelper.notify(
context.getString(
R.string.sync_calendar_attendees_notification_title, event.summary),
@ -138,8 +167,26 @@ constructor(context: Context, account: Account, settings: AccountSettings, extra
}
}
private fun processEvent(item: Item, newData: Event, _localEvent: LocalEvent?): LocalEvent {
var localEvent = _localEvent
// delete local event, if it exists
if (localEvent != null) {
Logger.log.info("Updating " + newData.uid + " in local calendar")
localEvent.eTag = item.etag
localEvent.update(newData)
syncResult.stats.numUpdates++
} else {
Logger.log.info("Adding " + newData.uid + " to local calendar")
localEvent = LocalEvent(localCalendar(), newData, item.uid, item.etag)
localEvent.add()
syncResult.stats.numInserts++
}
return localEvent
}
@Throws(IOException::class, ContactsStorageException::class, CalendarStorageException::class)
private fun processEvent(newData: Event, _localEvent: LocalEvent?): LocalEvent {
private fun legacyProcessEvent(newData: Event, _localEvent: LocalEvent?): LocalEvent {
var localEvent = _localEvent
// delete local event, if it exists
if (localEvent != null) {

@ -13,10 +13,7 @@ import android.os.Bundle
import android.provider.CalendarContract
import at.bitfire.ical4android.AndroidCalendar
import at.bitfire.ical4android.CalendarStorageException
import com.etesync.syncadapter.AccountSettings
import com.etesync.syncadapter.App
import com.etesync.syncadapter.Constants
import com.etesync.syncadapter.R
import com.etesync.syncadapter.*
import com.etesync.syncadapter.log.Logger
import com.etesync.syncadapter.model.CollectionInfo
import com.etesync.syncadapter.model.JournalEntity
@ -32,9 +29,6 @@ class CalendarsSyncAdapterService : SyncAdapterService() {
private class SyncAdapter(context: Context) : SyncAdapterService.SyncAdapter(context) {
override val syncErrorTitle = R.string.sync_error_calendar
override val notificationManager = SyncNotification(context, "journals-calendar", Constants.NOTIFICATION_CALENDAR_SYNC)
override fun onPerformSyncDo(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
val settings = AccountSettings(context, account)
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(settings))
@ -42,7 +36,11 @@ class CalendarsSyncAdapterService : SyncAdapterService() {
RefreshCollections(account, CollectionInfo.Type.CALENDAR).run()
updateLocalCalendars(provider, account, settings)
if (settings.isLegacy) {
legacyUpdateLocalCalendars(provider, account, settings)
} else {
updateLocalCalendars(provider, account, settings)
}
val principal = settings.uri?.toHttpUrlOrNull()!!
@ -56,8 +54,52 @@ class CalendarsSyncAdapterService : SyncAdapterService() {
Logger.log.info("Calendar sync complete")
}
@Throws(CalendarStorageException::class)
private fun updateLocalCalendars(provider: ContentProviderClient, account: Account, settings: AccountSettings) {
val remote = HashMap<String, CachedCollection>()
val etebaseLocalCache = EtebaseLocalCache.getInstance(context, account.name)
val collections: List<CachedCollection>
synchronized(etebaseLocalCache) {
val httpClient = HttpClient.Builder(context, settings).setForeground(false).build()
val etebase = EtebaseLocalCache.getEtebase(context, httpClient.okHttpClient, settings)
val colMgr = etebase.collectionManager
collections = etebaseLocalCache.collectionList(colMgr).filter { it.meta.collectionType == Constants.ETEBASE_TYPE_CALENDAR }
}
for (collection in collections) {
remote[collection.col.uid] = collection
}
val local = AndroidCalendar.find(account, provider, LocalCalendar.Factory, null, null)
val updateColors = settings.manageCalendarColors
// delete obsolete local calendar
for (calendar in local) {
val url = calendar.name
val collection = remote[url]
if (collection == null) {
Logger.log.fine("Deleting obsolete local calendar $url")
calendar.delete()
} else {
// remote CollectionInfo found for this local collection, update data
Logger.log.fine("Updating local calendar $url")
calendar.update(collection, updateColors)
// we already have a local calendar for this remote collection, don't take into consideration anymore
remote.remove(url)
}
}
// create new local calendars
for (url in remote.keys) {
val cachedCollection = remote[url]!!
Logger.log.info("Adding local calendar list $cachedCollection")
LocalCalendar.create(account, provider, cachedCollection)
}
}
@Throws(CalendarStorageException::class)
private fun legacyUpdateLocalCalendars(provider: ContentProviderClient, account: Account, settings: AccountSettings) {
val data = (context.applicationContext as App).data
val service = JournalModel.Service.fetchOrCreate(data, account.name, CollectionInfo.Type.CALENDAR)

@ -27,9 +27,6 @@ class ContactsSyncAdapterService : SyncAdapterService() {
private class ContactsSyncAdapter(context: Context) : SyncAdapterService.SyncAdapter(context) {
override val syncErrorTitle = R.string.sync_error_contacts
override val notificationManager = SyncNotification(context, "journals-contacts", Constants.NOTIFICATION_CONTACTS_SYNC)
override fun onPerformSyncDo(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
val addressBook = LocalAddressBook(context, account, provider)

@ -19,6 +19,7 @@ import at.bitfire.vcard4android.ContactsStorageException
import com.etesync.journalmanager.Exceptions
import com.etesync.journalmanager.JournalEntryManager
import com.etesync.journalmanager.model.SyncEntry
import com.etebase.client.Item
import com.etesync.syncadapter.AccountSettings
import com.etesync.syncadapter.Constants
import com.etesync.syncadapter.HttpClient
@ -77,7 +78,9 @@ constructor(context: Context, account: Account, settings: AccountSettings, extra
}
}
journal = JournalEntryManager(httpClient.okHttpClient, remote, localAddressBook.url)
if (isLegacy) {
journal = JournalEntryManager(httpClient.okHttpClient, remote, localAddressBook.url)
}
return true
}
@ -127,6 +130,32 @@ constructor(context: Context, account: Account, settings: AccountSettings, extra
return localCollection as LocalAddressBook
}
override fun processItem(item: Item) {
val local = localCollection!!.findByFilename(item.uid)
if (!item.isDeleted) {
val inputReader = StringReader(String(item.content))
val contacts = Contact.fromReader(inputReader, resourceDownloader)
if (contacts.size == 0) {
Logger.log.warning("Received VCard without data, ignoring")
return
} else if (contacts.size > 1) {
Logger.log.warning("Received multiple VCALs, using first one")
}
val contact = contacts[0]
processContact(item, contact, local)
} else {
if (local != null) {
Logger.log.info("Removing local record which has been deleted on the server")
local.delete()
} else {
Logger.log.warning("Tried deleting a non-existent record: " + item.uid)
}
}
}
@Throws(IOException::class, ContactsStorageException::class, CalendarStorageException::class)
override fun processSyncEntryImpl(cEntry: SyncEntry) {
val inputReader = StringReader(cEntry.content)
@ -142,7 +171,7 @@ constructor(context: Context, account: Account, settings: AccountSettings, extra
val local = localCollection!!.findByUid(contact.uid!!)
if (cEntry.isAction(SyncEntry.Actions.ADD) || cEntry.isAction(SyncEntry.Actions.CHANGE)) {
processContact(contact, local)
legacyProcessContact(contact, local)
} else {
if (local != null) {
Logger.log.info("Removing local record which has been deleted on the server")
@ -153,8 +182,65 @@ constructor(context: Context, account: Account, settings: AccountSettings, extra
}
}
private fun processContact(item: Item, newData: Contact, _local: LocalAddress?): LocalAddress {
var local = _local
val uuid = newData.uid
// update local contact, if it exists
if (local != null) {
Logger.log.log(Level.INFO, "Updating $uuid in local address book")
if (local is LocalGroup && newData.group) {
// update group
val group: LocalGroup = local
group.eTag = item.etag
group.update(newData)
syncResult.stats.numUpdates++
} else if (local is LocalContact && !newData.group) {
// update contact
val contact: LocalContact = local
contact.eTag = item.etag
contact.update(newData)
syncResult.stats.numUpdates++
} else {
// group has become an individual contact or vice versa
try {
local.delete()
local = null
} catch (e: CalendarStorageException) {
// CalendarStorageException is not used by LocalGroup and LocalContact
}
}
}
if (local == null) {
if (newData.group) {
Logger.log.log(Level.INFO, "Creating local group", item.uid)
val group = LocalGroup(localAddressBook(), newData, item.uid, item.etag)
group.add()
local = group
} else {
Logger.log.log(Level.INFO, "Creating local contact", item.uid)
val contact = LocalContact(localAddressBook(), newData, item.uid, item.etag)
contact.add()
local = contact
}
syncResult.stats.numInserts++
}
if (LocalContact.HASH_HACK && local is LocalContact)
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
local.updateHashCode(null)
return local
}
@Throws(IOException::class, ContactsStorageException::class)
private fun processContact(newData: Contact, _local: LocalAddress?): LocalAddress {
private fun legacyProcessContact(newData: Contact, _local: LocalAddress?): LocalAddress {
var local = _local
val uuid = newData.uid
// update local contact, if it exists

@ -22,11 +22,14 @@ import androidx.core.app.NotificationManagerCompat
import androidx.core.util.Pair
import at.bitfire.ical4android.CalendarStorageException
import at.bitfire.vcard4android.ContactsStorageException
import com.etebase.client.FetchOptions
import com.etebase.client.exceptions.ConnectionException
import com.etebase.client.exceptions.TemporaryServerErrorException
import com.etebase.client.exceptions.UnauthorizedException
import com.etesync.syncadapter.*
import com.etesync.journalmanager.Crypto
import com.etesync.journalmanager.Exceptions
import com.etesync.journalmanager.JournalManager
import com.etesync.syncadapter.*
import com.etesync.syncadapter.log.Logger
import com.etesync.syncadapter.model.CollectionInfo
import com.etesync.syncadapter.model.JournalEntity
@ -96,8 +99,8 @@ abstract class SyncAdapterService : Service() {
}
abstract class SyncAdapter(context: Context) : AbstractThreadedSyncAdapter(context, false) {
abstract val syncErrorTitle: Int
abstract val notificationManager: SyncNotification
private val syncErrorTitle: Int = R.string.sync_error_generic
private val notificationManager = SyncNotification(context, "refresh-collections", Constants.NOTIFICATION_REFRESH_COLLECTIONS)
abstract fun onPerformSyncDo(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult)
@ -117,6 +120,12 @@ abstract class SyncAdapterService : Service() {
} catch (e: Exceptions.ServiceUnavailableException) {
syncResult.stats.numIoExceptions++
syncResult.delayUntil = if (e.retryAfter > 0) e.retryAfter else Constants.DEFAULT_RETRY_DELAY
} catch (e: TemporaryServerErrorException) {
syncResult.stats.numIoExceptions++
syncResult.delayUntil = Constants.DEFAULT_RETRY_DELAY
} catch (e: ConnectionException) {
syncResult.stats.numIoExceptions++
syncResult.delayUntil = Constants.DEFAULT_RETRY_DELAY
} catch (e: Exceptions.IgnorableHttpException) {
// Ignore
} catch (e: Exception) {
@ -132,7 +141,7 @@ abstract class SyncAdapterService : Service() {
val detailsIntent = notificationManager.detailsIntent
detailsIntent.putExtra(Constants.KEY_ACCOUNT, account)
if (e !is Exceptions.UnauthorizedException) {
if (e !is Exceptions.UnauthorizedException && e !is UnauthorizedException) {
detailsIntent.putExtra(DebugInfoActivity.KEY_AUTHORITY, authority)
detailsIntent.putExtra(DebugInfoActivity.KEY_PHASE, syncPhase)
}
@ -208,30 +217,70 @@ abstract class SyncAdapterService : Service() {
val settings = AccountSettings(context, account)
val httpClient = HttpClient.Builder(context, settings).setForeground(false).build()
val journalsManager = JournalManager(httpClient.okHttpClient, settings.uri?.toHttpUrlOrNull()!!)
var journals = journalFetcher.list(journalsManager, settings, serviceType)
if (journals.isEmpty()) {
journals = LinkedList()
try {
val info = CollectionInfo.defaultForServiceType(serviceType)
val uid = JournalManager.Journal.genUid()
info.uid = uid
val crypto = Crypto.CryptoManager(info.version, settings.password(), uid)
val journal = JournalManager.Journal(crypto, info.toJson(), uid)
journalsManager.create(journal)
journals.add(Pair(journal, info))
} catch (e: Exceptions.AssociateNotAllowedException) {
// Skip for now
if (settings.isLegacy) {
val journalsManager = JournalManager(httpClient.okHttpClient, settings.uri?.toHttpUrlOrNull()!!)
var journals = journalFetcher.list(journalsManager, settings, serviceType)
if (journals.isEmpty()) {
journals = LinkedList()
try {
val info = CollectionInfo.defaultForServiceType(serviceType)
val uid = JournalManager.Journal.genUid()
info.uid = uid
val crypto = Crypto.CryptoManager(info.version, settings.password(), uid)
val journal = JournalManager.Journal(crypto, info.toJson(), uid)
journalsManager.create(journal)
journals.add(Pair(journal, info))
} catch (e: Exceptions.AssociateNotAllowedException) {
// Skip for now
}
}
legacySaveCollections(journals)
httpClient.close()
return
}
val etebaseLocalCache = EtebaseLocalCache.getInstance(context, account.name)
synchronized(etebaseLocalCache) {
val cacheAge = 5 * 1000 // 5 seconds - it's just a hack for burst fetching
val now = System.currentTimeMillis()
val lastCollectionsFetch = collectionLastFetchMap[account.name] ?: 0
if (abs(now - lastCollectionsFetch) <= cacheAge) {
return@synchronized
}
val etebase = EtebaseLocalCache.getEtebase(context, httpClient.okHttpClient, settings)
val colMgr = etebase.collectionManager
var stoken = etebaseLocalCache.loadStoken()
var done = false
while (!done) {
val colList = colMgr.list(FetchOptions().stoken(stoken))
for (col in colList.data) {
etebaseLocalCache.collectionSet(colMgr, col)
}
for (col in colList.removedMemberships) {
etebaseLocalCache.collectionUnset(colMgr, col.uid())
}
stoken = colList.stoken
done = colList.isDone
if (stoken != null) {
etebaseLocalCache.saveStoken(stoken)
}
}
collectionLastFetchMap[account.name] = now
}
saveCollections(journals)
httpClient.close()
}
private fun saveCollections(journals: Iterable<Pair<JournalManager.Journal, CollectionInfo>>) {
private fun legacySaveCollections(journals: Iterable<Pair<JournalManager.Journal, CollectionInfo>>) {
val data = (context.applicationContext as App).data
val service = JournalModel.Service.fetchOrCreate(data, account.name, serviceType)
@ -269,5 +318,6 @@ abstract class SyncAdapterService : Service() {
companion object {
val journalFetcher = CachedJournalFetcher()
var collectionLastFetchMap = HashMap<String, Long>()
}
}

@ -16,6 +16,11 @@ import android.os.Bundle
import at.bitfire.ical4android.CalendarStorageException
import at.bitfire.ical4android.InvalidCalendarException
import at.bitfire.vcard4android.ContactsStorageException
import com.etebase.client.*
import com.etebase.client.exceptions.ConnectionException
import com.etebase.client.exceptions.HttpException
import com.etebase.client.exceptions.TemporaryServerErrorException
import com.etebase.client.exceptions.UnauthorizedException
import com.etesync.syncadapter.*
import com.etesync.syncadapter.Constants.KEY_ACCOUNT
import com.etesync.journalmanager.Crypto
@ -25,10 +30,13 @@ import com.etesync.journalmanager.model.SyncEntry
import com.etesync.syncadapter.log.Logger
import com.etesync.syncadapter.model.*
import com.etesync.journalmanager.model.SyncEntry.Actions.ADD
import com.etesync.syncadapter.HttpClient
import com.etesync.syncadapter.R
import com.etesync.syncadapter.resource.*
import com.etesync.syncadapter.ui.AccountsActivity
import com.etesync.syncadapter.ui.DebugInfoActivity
import com.etesync.syncadapter.ui.ViewCollectionActivity
import com.etesync.syncadapter.ui.etebase.CollectionActivity
import org.jetbrains.anko.defaultSharedPreferences
import java.io.Closeable
import java.io.FileNotFoundException
@ -41,21 +49,35 @@ import kotlin.concurrent.withLock
abstract class SyncManager<T: LocalResource<*>> @Throws(Exceptions.IntegrityException::class, Exceptions.GenericCryptoException::class)
constructor(protected val context: Context, protected val account: Account, protected val settings: AccountSettings, protected val extras: Bundle, protected val authority: String, protected val syncResult: SyncResult, journalUid: String, protected val serviceType: CollectionInfo.Type, accountName: String): Closeable {
// FIXME: remove all of the lateinit once we remove legacy (and make immutable)
// RemoteEntries and the likes are probably also just relevant for legacy
protected val isLegacy: Boolean = settings.isLegacy
protected val notificationManager: SyncNotification
protected val info: CollectionInfo
protected lateinit var info: CollectionInfo
protected var localCollection: LocalCollection<T>? = null
protected var httpClient: HttpClient
protected lateinit var etebaseLocalCache: EtebaseLocalCache
protected lateinit var etebase: com.etebase.client.Account
protected lateinit var colMgr: CollectionManager
protected lateinit var itemMgr: ItemManager
protected lateinit var cachedCollection: CachedCollection
// Sync counters
private var syncItemsTotal = 0
private var syncItemsDeleted = 0
private var syncItemsChanged = 0
protected var journal: JournalEntryManager? = null
private var _journalEntity: JournalEntity? = null
private var numDiscarded = 0
private val crypto: Crypto.CryptoManager
private lateinit var crypto: Crypto.CryptoManager
private val data: MyEntityDataStore
private lateinit var data: MyEntityDataStore
/**
* remote CTag (uuid of the last entry on the server). We update it when we fetch/push and save when everything works.
@ -89,21 +111,31 @@ constructor(protected val context: Context, protected val account: Account, prot
// create HttpClient with given logger
httpClient = HttpClient.Builder(context, settings).setForeground(false).build()
data = (context.applicationContext as App).data
val serviceEntity = JournalModel.Service.fetchOrCreate(data, accountName, serviceType)
info = JournalEntity.fetch(data, serviceEntity, journalUid)!!.info
if (isLegacy) {
data = (context.applicationContext as App).data
val serviceEntity = JournalModel.Service.fetchOrCreate(data, accountName, serviceType)
info = JournalEntity.fetch(data, serviceEntity, journalUid)!!.info
// dismiss previous error notifications
notificationManager = SyncNotification(context, journalUid, notificationId())
notificationManager.cancel()
Logger.log.info(String.format(Locale.getDefault(), "Syncing collection %s (version: %d)", journalUid, info.version))
Logger.log.info(String.format(Locale.getDefault(), "Syncing collection %s (version: %d)", journalUid, info.version))
if (journalEntity.encryptedKey != null) {
crypto = Crypto.CryptoManager(info.version, settings.keyPair!!, journalEntity.encryptedKey)
if (journalEntity.encryptedKey != null) {
crypto = Crypto.CryptoManager(info.version, settings.keyPair!!, journalEntity.encryptedKey)
} else {
crypto = Crypto.CryptoManager(info.version, settings.password(), info.uid!!)
}
} else {
crypto = Crypto.CryptoManager(info.version, settings.password(), info.uid!!)
etebaseLocalCache = EtebaseLocalCache.getInstance(context, accountName)
etebase = EtebaseLocalCache.getEtebase(context, httpClient.okHttpClient, settings)
colMgr = etebase.collectionManager
synchronized(etebaseLocalCache) {
cachedCollection = etebaseLocalCache.collectionGet(colMgr, journalUid)!!
}
itemMgr = colMgr.getItemManager(cachedCollection.col)
}
// dismiss previous error notifications
notificationManager = SyncNotification(context, journalUid, notificationId())
notificationManager.cancel()
}
protected abstract fun notificationId(): Int
@ -114,6 +146,10 @@ constructor(protected val context: Context, protected val account: Account, prot
@TargetApi(21)
fun performSync() {
syncItemsTotal = 0
syncItemsDeleted = 0
syncItemsChanged = 0
var syncPhase = R.string.sync_phase_prepare
try {
Logger.log.info("Sync phase: " + context.getString(syncPhase))
@ -128,48 +164,101 @@ constructor(protected val context: Context, protected val account: Account, prot
Logger.log.info("Sync phase: " + context.getString(syncPhase))
prepareFetch()
do {
if (Thread.interrupted())
throw InterruptedException()
syncPhase = R.string.sync_phase_fetch_entries
Logger.log.info("Sync phase: " + context.getString(syncPhase))
fetchEntries()
if (Thread.interrupted())
throw InterruptedException()
syncPhase = R.string.sync_phase_apply_remote_entries
Logger.log.info("Sync phase: " + context.getString(syncPhase))
applyRemoteEntries()
} while (remoteEntries!!.size == MAX_FETCH)
do {
if (Thread.interrupted())
throw InterruptedException()
syncPhase = R.string.sync_phase_prepare_local
Logger.log.info("Sync phase: " + context.getString(syncPhase))
prepareLocal()
/* Create journal entries out of local changes. */
if (Thread.interrupted())
throw InterruptedException()
syncPhase = R.string.sync_phase_create_local_entries
Logger.log.info("Sync phase: " + context.getString(syncPhase))
createLocalEntries()
if (Thread.interrupted())
throw InterruptedException()
syncPhase = R.string.sync_phase_apply_local_entries
Logger.log.info("Sync phase: " + context.getString(syncPhase))
/* FIXME: Skipping this now, because we already override with remote.
applyLocalEntries();
*/
if (Thread.interrupted())
throw InterruptedException()
syncPhase = R.string.sync_phase_push_entries
Logger.log.info("Sync phase: " + context.getString(syncPhase))
pushEntries()
} while (localEntries!!.size == MAX_PUSH)
if (isLegacy) {
do {
if (Thread.interrupted())
throw InterruptedException()
syncPhase = R.string.sync_phase_fetch_entries
Logger.log.info("Sync phase: " + context.getString(syncPhase))
fetchEntries()
if (Thread.interrupted())
throw InterruptedException()
syncPhase = R.string.sync_phase_apply_remote_entries
Logger.log.info("Sync phase: " + context.getString(syncPhase))
applyRemoteEntries()
} while (remoteEntries!!.size == MAX_FETCH)
do {
if (Thread.interrupted())
throw InterruptedException()
syncPhase = R.string.sync_phase_prepare_local
Logger.log.info("Sync phase: " + context.getString(syncPhase))
prepareLocal()
/* Create journal entries out of local changes. */
if (Thread.interrupted())
throw InterruptedException()
syncPhase = R.string.sync_phase_create_local_entries
Logger.log.info("Sync phase: " + context.getString(syncPhase))
createLocalEntries()
if (Thread.interrupted())
throw InterruptedException()
syncPhase = R.string.sync_phase_apply_local_entries
Logger.log.info("Sync phase: " + context.getString(syncPhase))
/* FIXME: Skipping this now, because we already override with remote.
applyLocalEntries();
*/
if (Thread.interrupted())
throw InterruptedException()
syncPhase = R.string.sync_phase_push_entries
Logger.log.info("Sync phase: " + context.getString(syncPhase))
pushEntries()
} while (localEntries!!.size == MAX_PUSH)
} else {
var itemList: ItemListResponse?
var stoken = synchronized(etebaseLocalCache) {
etebaseLocalCache.collectionLoadStoken(cachedCollection.col.uid)
}
// Push local changes
var chunkPushItems: List<Item>
do {
if (Thread.interrupted())
throw InterruptedException()
syncPhase = R.string.sync_phase_prepare_local
Logger.log.info("Sync phase: " + context.getString(syncPhase))
prepareLocal()
/* Create push items out of local changes. */
if (Thread.interrupted())
throw InterruptedException()
syncPhase = R.string.sync_phase_create_local_entries
Logger.log.info("Sync phase: " + context.getString(syncPhase))
chunkPushItems = createPushItems()
if (Thread.interrupted())
throw InterruptedException()
syncPhase = R.string.sync_phase_push_entries
Logger.log.info("Sync phase: " + context.getString(syncPhase))
pushItems(chunkPushItems)
} while (chunkPushItems.size == MAX_PUSH)
do {
if (Thread.interrupted())
throw InterruptedException()
syncPhase = R.string.sync_phase_fetch_entries
Logger.log.info("Sync phase: " + context.getString(syncPhase))
itemList = fetchItems(stoken)
if (itemList == null) {
break
}
if (Thread.interrupted())
throw InterruptedException()
syncPhase = R.string.sync_phase_apply_remote_entries
Logger.log.info("Sync phase: " + context.getString(syncPhase))
applyRemoteItems(itemList)
stoken = itemList.stoken
if (stoken != null) {
synchronized(etebaseLocalCache) {
etebaseLocalCache.collectionSaveStoken(cachedCollection.col.uid, stoken)
}
}
} while (!itemList!!.isDone)
}
/* Cleanup and finalize changes */
if (Thread.interrupted())
@ -187,6 +276,11 @@ constructor(protected val context: Context, protected val account: Account, prot
} catch (e: SSLHandshakeException) {
syncResult.stats.numIoExceptions++
notificationManager.setThrowable(e)
val detailsIntent = notificationManager.detailsIntent
detailsIntent.putExtra(KEY_ACCOUNT, account)
notificationManager.notify(syncErrorTitle, context.getString(syncPhase))
} catch (e: FileNotFoundException) {
notificationManager.setThrowable(e)
val detailsIntent = notificationManager.detailsIntent
detailsIntent.putExtra(KEY_ACCOUNT, account)
@ -197,15 +291,21 @@ constructor(protected val context: Context, protected val account: Account, prot
} catch (e: Exceptions.ServiceUnavailableException) {
syncResult.stats.numIoExceptions++
syncResult.delayUntil = if (e.retryAfter > 0) e.retryAfter else Constants.DEFAULT_RETRY_DELAY
} catch (e: TemporaryServerErrorException) {
syncResult.stats.numIoExceptions++
syncResult.delayUntil = Constants.DEFAULT_RETRY_DELAY
} catch (e: ConnectionException) {
syncResult.stats.numIoExceptions++
syncResult.delayUntil = Constants.DEFAULT_RETRY_DELAY
} catch (e: InterruptedException) {
// Restart sync if interrupted
syncResult.fullSyncRequested = true
} catch (e: Exceptions.IgnorableHttpException) {
// Ignore
} catch (e: Exception) {
if (e is Exceptions.UnauthorizedException) {
if (e is Exceptions.UnauthorizedException || e is UnauthorizedException) {
syncResult.stats.numAuthExceptions++
} else if (e is Exceptions.HttpException) {
} else if (e is Exceptions.HttpException || e is HttpException) {
syncResult.stats.numParseExceptions++
} else if (e is CalendarStorageException || e is ContactsStorageException) {
syncResult.databaseError = true
@ -241,38 +341,28 @@ constructor(protected val context: Context, protected val account: Account, prot
private fun notifyUserOnSync() {
val changeNotification = context.defaultSharedPreferences.getBoolean(App.CHANGE_NOTIFICATION, true)
if (remoteEntries!!.isEmpty() || !changeNotification) {
if (!changeNotification || (syncItemsTotal == 0)) {
return
}
val notificationHelper = SyncNotification(context,
System.currentTimeMillis().toString(), notificationId())
var deleted = 0
var added = 0
var changed = 0
for (entry in remoteEntries!!) {
val cEntry = SyncEntry.fromJournalEntry(crypto, entry)
val action = cEntry.action
when (action) {
ADD -> added++
SyncEntry.Actions.DELETE -> deleted++
SyncEntry.Actions.CHANGE -> changed++
}
}
val resources = context.resources
val intent = ViewCollectionActivity.newIntent(context, account, info)
val intent = if (isLegacy) {
ViewCollectionActivity.newIntent(context, account, info)
} else {
CollectionActivity.newIntent(context, account, cachedCollection.col.uid)
}
notificationHelper.notify(syncSuccessfullyTitle,
String.format(context.getString(R.string.sync_successfully_modified),
resources.getQuantityString(R.plurals.sync_successfully,
remoteEntries!!.size, remoteEntries!!.size)),
syncItemsTotal, syncItemsTotal)),
String.format(context.getString(R.string.sync_successfully_modified_full),
resources.getQuantityString(R.plurals.sync_successfully,
added, added),
syncItemsChanged, syncItemsChanged),
resources.getQuantityString(R.plurals.sync_successfully,
changed, changed),
resources.getQuantityString(R.plurals.sync_successfully,
deleted, deleted)),
syncItemsDeleted, syncItemsDeleted)),
intent)
}
@ -287,6 +377,25 @@ constructor(protected val context: Context, protected val account: Account, prot
return true
}
protected abstract fun processItem(item: Item)
private fun persistItem(item: Item) {
synchronized(etebaseLocalCache) {
// FIXME: it's terrible that we are fetching and decrypting the item here - we really don't have to
val cached = etebaseLocalCache.itemGet(itemMgr, cachedCollection.col.uid, item.uid)
if (cached?.item?.etag != item.etag) {
syncItemsTotal++
if (item.isDeleted) {
syncItemsDeleted++
} else {
syncItemsChanged++
}
etebaseLocalCache.itemSet(itemMgr, cachedCollection.col.uid, item)
}
}
}
@Throws(IOException::class, ContactsStorageException::class, CalendarStorageException::class, InvalidCalendarException::class)
protected abstract fun processSyncEntryImpl(cEntry: SyncEntry)
@ -317,38 +426,54 @@ constructor(protected val context: Context, protected val account: Account, prot
throw e
}
}
when (syncEntry.action) {
ADD -> syncItemsChanged++
SyncEntry.Actions.DELETE -> syncItemsDeleted++
SyncEntry.Actions.CHANGE -> syncItemsChanged++
}
}
@Throws(IOException::class, ContactsStorageException::class, CalendarStorageException::class, Exceptions.HttpException::class, InvalidCalendarException::class, InterruptedException::class)
protected fun applyLocalEntries() {
// FIXME: Need a better strategy
// We re-apply local entries so our changes override whatever was written in the remote.
val strTotal = localEntries!!.size.toString()
@Throws(IOException::class, CalendarStorageException::class, ContactsStorageException::class)
protected fun prepareFetch() {
if (isLegacy) {
remoteCTag = journalEntity.getLastUid(data)
} else {
remoteCTag = cachedCollection.col.stoken
}
}
private fun fetchItems(stoken: String?): ItemListResponse? {
if (remoteCTag != stoken) {
val ret = itemMgr.list(FetchOptions().stoken(stoken))
Logger.log.info("Fetched items. Done=${ret.isDone}")
return ret
} else {
Logger.log.info("Skipping fetch because local stoken == lastStoken (${remoteCTag})")
return null
}
}
private fun applyRemoteItems(itemList: ItemListResponse) {
val items = itemList.data
// Process new vcards from server
val size = items.size
var i = 0
for (entry in localEntries!!) {
for (item in items) {
if (Thread.interrupted()) {
throw InterruptedException()
}
i++
Logger.log.info("Processing (" + i.toString() + "/" + strTotal + ") " + entry.toString())
Logger.log.info("Processing (${i}/${size}) UID=${item.uid} Etag=${item.etag}")
val cEntry = SyncEntry.fromJournalEntry(crypto, entry)
if (cEntry.isAction(SyncEntry.Actions.DELETE)) {
continue
}
Logger.log.info("Processing resource for journal entry")
processSyncEntry(cEntry)
processItem(item)
persistItem(item)
}
}
@Throws(IOException::class, CalendarStorageException::class, ContactsStorageException::class)
protected fun prepareFetch() {
remoteCTag = journalEntity.getLastUid(data)
}
@Throws(Exceptions.HttpException::class, ContactsStorageException::class, CalendarStorageException::class, Exceptions.IntegrityException::class)
protected fun fetchEntries() {
private fun fetchEntries() {
val count = data.count(EntryEntity::class.java).where(EntryEntity.JOURNAL.eq(journalEntity)).get().value()
if (remoteCTag != null && count == 0) {
// If we are updating an existing installation with no saved journal, we need to add
@ -377,11 +502,13 @@ constructor(protected val context: Context, protected val account: Account, prot
}
@Throws(IOException::class, ContactsStorageException::class, CalendarStorageException::class, InvalidCalendarException::class, InterruptedException::class)
protected fun applyRemoteEntries() {
private fun applyRemoteEntries() {
// Process new vcards from server
val strTotal = remoteEntries!!.size.toString()
var i = 0
syncItemsTotal += remoteEntries!!.size
for (entry in remoteEntries!!) {
if (Thread.interrupted()) {
throw InterruptedException()
@ -406,7 +533,7 @@ constructor(protected val context: Context, protected val account: Account, prot
}
@Throws(Exceptions.HttpException::class, IOException::class, ContactsStorageException::class, CalendarStorageException::class)
protected fun pushEntries() {
private fun pushEntries() {
// upload dirty contacts
var pushed = 0
// FIXME: Deal with failure (someone else uploaded before we go here)
@ -456,8 +583,128 @@ constructor(protected val context: Context, protected val account: Account, prot
}
}
private fun pushItems(chunkPushItems_: List<Item>) {
var chunkPushItems = chunkPushItems_
// upload dirty contacts
var pushed = 0
try {
if (!chunkPushItems.isEmpty()) {
val items = chunkPushItems
itemMgr.batch(items.toTypedArray())
// Persist the items
synchronized(etebaseLocalCache) {
val colUid = cachedCollection.col.uid
for (item in items) {
etebaseLocalCache.itemSet(itemMgr, colUid, item)
}
}
pushed += items.size
}
} finally {
// FIXME: A bit fragile, we assume the order in createPushItems
var left = pushed
for (local in localDeleted!!) {
if (pushed-- <= 0) {
break
}
local.delete()
}
if (left > 0) {
localDeleted = localDeleted?.drop(left)
chunkPushItems = chunkPushItems.drop(left - pushed)
}
left = pushed
var i = 0
for (local in localDirty) {
if (pushed-- <= 0) {
break
}
Logger.log.info("Added/changed resource with filename: " + local.fileName)
local.clearDirty(chunkPushItems[i].etag)
i++
}
if (left > 0) {
localDirty = localDirty.drop(left)
chunkPushItems.drop(left)
}
if (pushed > 0) {
Logger.log.severe("Unprocessed localentries left, this should never happen!")
}
}
}
private fun itemUpdateMtime(item: Item) {
val meta = item.meta
meta.setMtime(System.currentTimeMillis())
item.meta = meta
}
private fun createPushItems(): List<Item> {
val ret = LinkedList<Item>()
val colUid = cachedCollection.col.uid
synchronized(etebaseLocalCache) {
for (local in localDeleted!!) {
val item = etebaseLocalCache.itemGet(itemMgr, colUid, local.fileName!!)!!.item
itemUpdateMtime(item)
item.delete()
ret.add(item)
if (ret.size == MAX_PUSH) {
return ret
}
}
}
synchronized(etebaseLocalCache) {
for (local in localDirty) {
val cacheItem = if (local.fileName != null) etebaseLocalCache.itemGet(itemMgr, colUid, local.fileName!!) else null
val item: Item
if (cacheItem != null) {
item = cacheItem.item
itemUpdateMtime(item)
} else {
val uid = UUID.randomUUID().toString()
val meta = ItemMetadata()
meta.name = uid
meta.mtime = System.currentTimeMillis()
item = itemMgr.create(meta, "")
local.prepareForUpload(item.uid, uid)
}
try {
item.setContent(local.content)
} catch (e: Exception) {
Logger.log.warning("Failed creating local entry ${local.uuid}")
if (local is LocalContact) {
Logger.log.warning("Contact with title ${local.contact?.displayName}")
} else if (local is LocalEvent) {
Logger.log.warning("Event with title ${local.event?.summary}")
} else if (local is LocalTask) {
Logger.log.warning("Task with title ${local.task?.summary}")
}
throw e
}
ret.add(item)
if (ret.size == MAX_PUSH) {
return ret
}
}
}
return ret
}
@Throws(CalendarStorageException::class, ContactsStorageException::class, IOException::class)
protected open fun createLocalEntries() {
private fun createLocalEntries() {
localEntries = LinkedList()
// Not saving, just creating a fake one until we load it from a local db
@ -510,7 +757,7 @@ constructor(protected val context: Context, protected val account: Account, prot
/**
*/
@Throws(CalendarStorageException::class, ContactsStorageException::class, FileNotFoundException::class)
protected fun prepareLocal() {
protected open fun prepareLocal() {
localDeleted = processLocallyDeleted()
localDirty = localCollection!!.findDirty(MAX_PUSH)
// This is done after fetching the local dirty so all the ones we are using will be prepared
@ -527,7 +774,8 @@ constructor(protected val context: Context, protected val account: Account, prot
val localList = localCollection!!.findDeleted()
val ret = ArrayList<T>(localList.size)
if (journalEntity.isReadOnly) {
val readOnly = (isLegacy && journalEntity.isReadOnly) || (!isLegacy && (cachedCollection.col.accessLevel == CollectionAccessLevel.ReadOnly))
if (readOnly) {
for (local in localList) {
Logger.log.info("Restoring locally deleted resource on a read only collection: ${local.uuid}")
local.resetDeleted()
@ -541,8 +789,11 @@ constructor(protected val context: Context, protected val account: Account, prot
if (local.uuid != null) {
Logger.log.info(local.uuid + " has been deleted locally -> deleting from server")
} else {
Logger.log.fine("Entry deleted before ever syncing - genarting a UUID")
local.prepareForUpload()
if (isLegacy) {
// It's done later for non-legacy
Logger.log.fine("Entry deleted before ever syncing - genarting a UUID")
local.legacyPrepareForUpload(null)
}
}
ret.add(local)
@ -556,20 +807,22 @@ constructor(protected val context: Context, protected val account: Account, prot
@Throws(CalendarStorageException::class, ContactsStorageException::class)
protected open fun prepareDirty() {
if (journalEntity.isReadOnly) {
val readOnly = (isLegacy && journalEntity.isReadOnly) || (!isLegacy && (cachedCollection.col.accessLevel == CollectionAccessLevel.ReadOnly))
if (readOnly) {
for (local in localDirty) {
Logger.log.info("Restoring locally modified resource on a read only collection: ${local.uuid}")
if (local.uuid == null) {
// If it was only local, delete.
local.delete()
} else {
local.clearDirty(local.uuid!!)
local.clearDirty(null)
}
numDiscarded++
}
localDirty = LinkedList()
} else {
} else if (isLegacy) {
// It's done later for non-legacy
// assign file names and UIDs to new entries
Logger.log.info("Looking for local entries without a uuid")
for (local in localDirty) {
@ -578,7 +831,7 @@ constructor(protected val context: Context, protected val account: Account, prot
}
Logger.log.fine("Found local record without file name; generating file name/UID if necessary")
local.prepareForUpload()
local.legacyPrepareForUpload(null)
}
}
}

@ -11,6 +11,7 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import at.bitfire.ical4android.CalendarStorageException
import at.bitfire.vcard4android.ContactsStorageException
import com.etebase.client.exceptions.*
import com.etesync.syncadapter.AccountSettings
import com.etesync.syncadapter.Constants
import com.etesync.syncadapter.R
@ -27,7 +28,8 @@ class SyncNotification(internal val context: Context, internal val notificationT
internal val notificationManager: NotificationManagerCompat
lateinit var detailsIntent: Intent
internal set
internal var messageString: Int = 0
internal var messageInt: Int = 0
internal var messageString: String? = null
private var throwable: Throwable? = null
@ -37,30 +39,33 @@ class SyncNotification(internal val context: Context, internal val notificationT
fun setThrowable(e: Throwable) {
throwable = e
if (e is Exceptions.UnauthorizedException) {
if (e is Exceptions.UnauthorizedException || e is UnauthorizedException) {
Logger.log.log(Level.SEVERE, "Not authorized anymore", e)
messageString = R.string.sync_error_unauthorized
messageInt = R.string.sync_error_unauthorized
} else if (e is Exceptions.UserInactiveException) {
Logger.log.log(Level.SEVERE, "User inactive")
messageString = R.string.sync_error_user_inactive
} else if (e is Exceptions.ServiceUnavailableException) {
messageInt = R.string.sync_error_user_inactive
} else if (e is Exceptions.ServiceUnavailableException || e is TemporaryServerErrorException) {
Logger.log.log(Level.SEVERE, "Service unavailable")
messageString = R.string.sync_error_unavailable
messageInt = R.string.sync_error_unavailable
} else if (e is Exceptions.ReadOnlyException) {
Logger.log.log(Level.SEVERE, "Journal is read only", e)
messageString = R.string.sync_error_journal_readonly
} else if (e is Exceptions.HttpException) {
messageInt = R.string.sync_error_journal_readonly
} else if (e is PermissionDeniedException) {
Logger.log.log(Level.SEVERE, "Permission denied", e)
messageString = context.getString(R.string.sync_error_permission_denied, e.localizedMessage)
} else if (e is Exceptions.HttpException || e is ServerErrorException) {
Logger.log.log(Level.SEVERE, "HTTP Exception during sync", e)
messageString = R.string.sync_error_http_dav
messageInt = R.string.sync_error_http_dav
} else if (e is CalendarStorageException || e is ContactsStorageException || e is SQLiteException) {
Logger.log.log(Level.SEVERE, "Couldn't access local storage", e)
messageString = R.string.sync_error_local_storage
messageInt = R.string.sync_error_local_storage
} else if (e is Exceptions.IntegrityException) {
Logger.log.log(Level.SEVERE, "Integrity error", e)
messageString = R.string.sync_error_integrity
messageInt = R.string.sync_error_integrity
} else {
Logger.log.log(Level.SEVERE, "Unknown sync error", e)
messageString = R.string.sync_error
messageInt = R.string.sync_error
}
detailsIntent = Intent(context, NotificationHandlerActivity::class.java)
@ -69,7 +74,7 @@ class SyncNotification(internal val context: Context, internal val notificationT
}
fun notify(title: String, state: String) {
val message = context.getString(messageString, state)
val message = messageString ?: context.getString(messageInt, state)
notify(title, message, null, detailsIntent)
}

@ -18,10 +18,7 @@ import android.os.Bundle
import at.bitfire.ical4android.AndroidTaskList
import at.bitfire.ical4android.TaskProvider
import at.bitfire.ical4android.TaskProvider.ProviderName
import com.etesync.syncadapter.AccountSettings
import com.etesync.syncadapter.App
import com.etesync.syncadapter.Constants
import com.etesync.syncadapter.R
import com.etesync.syncadapter.*
import com.etesync.syncadapter.log.Logger
import com.etesync.syncadapter.model.CollectionInfo
import com.etesync.syncadapter.model.JournalEntity
@ -42,9 +39,6 @@ class TasksSyncAdapterService: SyncAdapterService() {
context: Context,
private val name: ProviderName
): SyncAdapter(context) {
override val syncErrorTitle = R.string.sync_error_tasks
override val notificationManager = SyncNotification(context, "journals-tasks", Constants.NOTIFICATION_TASK_SYNC)
override fun onPerformSyncDo(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
val taskProvider = TaskProvider.fromProviderClient(context, provider, name)
@ -63,8 +57,11 @@ class TasksSyncAdapterService: SyncAdapterService() {
RefreshCollections(account, CollectionInfo.Type.TASKS).run()
updateLocalTaskLists(taskProvider, account, accountSettings)
if (accountSettings.isLegacy) {
legacyUpdateLocalTaskLists(taskProvider, account, accountSettings)
} else {
updateLocalTaskLists(taskProvider, account, accountSettings)
}
val principal = accountSettings.uri?.toHttpUrlOrNull()!!
for (taskList in AndroidTaskList.find(account, taskProvider, LocalTaskList.Factory, "${TaskContract.TaskLists.SYNC_ENABLED}!=0", null)) {
@ -78,6 +75,50 @@ class TasksSyncAdapterService: SyncAdapterService() {
}
private fun updateLocalTaskLists(provider: TaskProvider, account: Account, settings: AccountSettings) {
val remote = HashMap<String, CachedCollection>()
val etebaseLocalCache = EtebaseLocalCache.getInstance(context, account.name)
val collections: List<CachedCollection>
synchronized(etebaseLocalCache) {
val httpClient = HttpClient.Builder(context, settings).setForeground(false).build()
val etebase = EtebaseLocalCache.getEtebase(context, httpClient.okHttpClient, settings)
val colMgr = etebase.collectionManager
collections = etebaseLocalCache.collectionList(colMgr).filter { it.meta.collectionType == Constants.ETEBASE_TYPE_TASKS }
}
for (collection in collections) {
remote[collection.col.uid] = collection
}
val local = AndroidTaskList.find(account, provider, LocalTaskList.Factory, null, null)
val updateColors = settings.manageCalendarColors
// delete obsolete local calendar
for (taskList in local) {
val url = taskList.syncId
val collection = remote[url]
if (collection == null) {
Logger.log.fine("Deleting obsolete local taskList $url")
taskList.delete()
} else {
// remote CollectionInfo found for this local collection, update data
Logger.log.fine("Updating local taskList $url")
taskList.update(collection, updateColors)
// we already have a local taskList for this remote collection, don't take into consideration anymore
remote.remove(url)
}
}
// create new local calendars
for (url in remote.keys) {
val cachedCollection = remote[url]!!
Logger.log.info("Adding local calendar list $cachedCollection")
LocalTaskList.create(account, provider, cachedCollection)
}
}
private fun legacyUpdateLocalTaskLists(provider: TaskProvider, account: Account, settings: AccountSettings) {
val data = (context.applicationContext as App).data
var service = JournalModel.Service.fetchOrCreate(data, account.name, CollectionInfo.Type.TASKS)

@ -13,6 +13,7 @@ import android.content.Context
import android.content.SyncResult
import android.os.Bundle
import at.bitfire.ical4android.Task
import com.etebase.client.Item
import com.etesync.syncadapter.AccountSettings
import com.etesync.syncadapter.Constants
import com.etesync.syncadapter.R
@ -43,7 +44,7 @@ class TasksSyncManager(
get() = context.getString(R.string.sync_error_tasks, account.name)
override val syncSuccessfullyTitle: String
get() = context.getString(R.string.sync_successfully_tasks, info.displayName,
get() = context.getString(R.string.sync_successfully_tasks, localTaskList().name!!,
account.name)
init {
@ -58,7 +59,9 @@ class TasksSyncManager(
if (!super.prepare())
return false
journal = JournalEntryManager(httpClient.okHttpClient, remote, localTaskList().url!!)
if (isLegacy) {
journal = JournalEntryManager(httpClient.okHttpClient, remote, localTaskList().url!!)
}
return true
}
@ -68,6 +71,32 @@ class TasksSyncManager(
return localCollection as LocalTaskList
}
override fun processItem(item: Item) {
val local = localCollection!!.findByFilename(item.uid)
if (!item.isDeleted) {
val inputReader = StringReader(String(item.content))
val tasks = Task.tasksFromReader(inputReader)
if (tasks.size == 0) {
Logger.log.warning("Received VCard without data, ignoring")
return
} else if (tasks.size > 1) {
Logger.log.warning("Received multiple VCALs, using first one")
}
val task = tasks[0]
processTask(item, task, local)
} else {
if (local != null) {
Logger.log.info("Removing local record #" + local.id + " which has been deleted on the server")
local.delete()
} else {
Logger.log.warning("Tried deleting a non-existent record: " + item.uid)
}
}
}
override fun processSyncEntryImpl(cEntry: SyncEntry) {
val inputReader = StringReader(cEntry.content)
@ -83,7 +112,7 @@ class TasksSyncManager(
val local = localCollection!!.findByUid(event.uid!!)
if (cEntry.isAction(SyncEntry.Actions.ADD) || cEntry.isAction(SyncEntry.Actions.CHANGE)) {
processTask(event, local)
legacyProcessTask(event, local)
} else {
if (local != null) {
Logger.log.info("Removing local record #" + local.id + " which has been deleted on the server")
@ -94,7 +123,25 @@ class TasksSyncManager(
}
}
private fun processTask(newData: Task, _localTask: LocalTask?): LocalTask {
private fun processTask(item: Item, newData: Task, _localTask: LocalTask?): LocalTask {
var localTask = _localTask
// delete local Task, if it exists
if (localTask != null) {
Logger.log.info("Updating " + item.uid + " in local calendar")
localTask.eTag = item.etag
localTask.update(newData)
syncResult.stats.numUpdates++
} else {
Logger.log.info("Adding " + item.uid + " to local calendar")
localTask = LocalTask(localTaskList(), newData, item.uid, item.etag)
localTask.add()
syncResult.stats.numInserts++
}
return localTask
}
private fun legacyProcessTask(newData: Task, _localTask: LocalTask?): LocalTask {
var localTask = _localTask
// delete local Task, if it exists
if (localTask != null) {

@ -25,20 +25,27 @@ import android.widget.*
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.Toolbar
import androidx.core.content.ContextCompat
import at.bitfire.ical4android.TaskProvider
import at.bitfire.ical4android.TaskProvider.Companion.OPENTASK_PROVIDERS
import at.bitfire.vcard4android.ContactsStorageException
import com.etesync.syncadapter.*
import com.etebase.client.CollectionAccessLevel
import com.etebase.client.CollectionManager
import com.etebase.client.Utils
import com.etebase.client.exceptions.EtebaseException
import com.etesync.journalmanager.Crypto
import com.etesync.journalmanager.Exceptions
import com.etesync.journalmanager.JournalAuthenticator
import com.etesync.syncadapter.*
import com.etesync.syncadapter.Constants.*
import com.etesync.syncadapter.log.Logger
import com.etesync.syncadapter.model.CollectionInfo
import com.etesync.syncadapter.model.JournalEntity
import com.etesync.syncadapter.model.MyEntityDataStore
import com.etesync.syncadapter.model.ServiceEntity
import com.etesync.syncadapter.resource.LocalAddressBook
import com.etesync.syncadapter.resource.LocalCalendar
import com.etesync.syncadapter.syncadapter.requestSync
import com.etesync.syncadapter.ui.etebase.CollectionActivity
import com.etesync.syncadapter.ui.etebase.InvitationsActivity
import com.etesync.syncadapter.ui.setup.SetupUserInfoFragment
import com.etesync.syncadapter.utils.HintManager
import com.etesync.syncadapter.utils.ShowcaseBuilder
@ -52,6 +59,7 @@ import java.util.logging.Level
class AccountActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, PopupMenu.OnMenuItemClickListener, LoaderManager.LoaderCallbacks<AccountActivity.AccountInfo>, Refreshable {
private lateinit var account: Account
private lateinit var settings: AccountSettings
private var accountInfo: AccountInfo? = null
internal var listCalDAV: ListView? = null
@ -64,22 +72,30 @@ class AccountActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, PopupMe
private val onItemClickListener = AdapterView.OnItemClickListener { parent, view, position, _ ->
val list = parent as ListView
val adapter = list.adapter as ArrayAdapter<*>
val journalEntity = adapter.getItem(position) as JournalEntity
val info = journalEntity.getInfo()
val info = adapter.getItem(position) as CollectionListItemInfo
startActivity(ViewCollectionActivity.newIntent(this@AccountActivity, account, info))
if (settings.isLegacy) {
startActivity(ViewCollectionActivity.newIntent(this@AccountActivity, account, info.legacyInfo!!))
} else {
startActivity(CollectionActivity.newIntent(this@AccountActivity, account, info.uid))
}
}
private val formattedFingerprint: String?
get() {
try {
val settings = AccountSettings(this, account)
return Crypto.AsymmetricCryptoManager.getPrettyKeyFingerprint(settings.keyPair!!.publicKey)
if (settings.isLegacy) {
val settings = AccountSettings(this, account)
return Crypto.AsymmetricCryptoManager.getPrettyKeyFingerprint(settings.keyPair!!.publicKey)
} else {
val etebase = EtebaseLocalCache.getEtebase(this, HttpClient.sharedClient, settings)
val invitationManager = etebase.invitationManager
return Utils.prettyFingerprint(invitationManager.pubkey)
}
} catch (e: Exception) {
e.printStackTrace()
return null
return e.localizedMessage
}
}
override fun onCreate(savedInstanceState: Bundle?) {
@ -87,6 +103,7 @@ class AccountActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, PopupMe
account = intent.getParcelableExtra(EXTRA_ACCOUNT)
title = account.name
settings = AccountSettings(this, account)
setContentView(R.layout.activity_account)
@ -131,13 +148,19 @@ class AccountActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, PopupMe
HintManager.setHintSeen(this, HINT_VIEW_COLLECTION, true)
}
if (!SetupUserInfoFragment.hasUserInfo(this, account)) {
SetupUserInfoFragment.newInstance(account).show(supportFragmentManager, null)
if (settings.isLegacy) {
if (!SetupUserInfoFragment.hasUserInfo(this, account)) {
SetupUserInfoFragment.newInstance(account).show(supportFragmentManager, null)
}
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.activity_account, menu)
if (settings.isLegacy) {
val invitations = menu.findItem(R.id.invitations)
invitations.setVisible(false)
}
return true
}
@ -167,6 +190,10 @@ class AccountActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, PopupMe
.setPositiveButton(android.R.string.yes) { _, _ -> }.create()
dialog.show()
}
R.id.invitations -> {
val intent = InvitationsActivity.newIntent(this, account)
startActivity(intent)
}
else -> return super.onOptionsItemSelected(item)
}
return true
@ -195,19 +222,31 @@ class AccountActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, PopupMe
val info: CollectionInfo
when (item.itemId) {
R.id.create_calendar -> {
info = CollectionInfo()
info.enumType = CollectionInfo.Type.CALENDAR
startActivity(CreateCollectionActivity.newIntent(this@AccountActivity, account, info))
if (settings.isLegacy) {
info = CollectionInfo()
info.enumType = CollectionInfo.Type.CALENDAR
startActivity(CreateCollectionActivity.newIntent(this@AccountActivity, account, info))
} else {
startActivity(CollectionActivity.newCreateCollectionIntent(this@AccountActivity, account, ETEBASE_TYPE_CALENDAR))
}
}
R.id.create_tasklist -> {
info = CollectionInfo()
info.enumType = CollectionInfo.Type.TASKS
startActivity(CreateCollectionActivity.newIntent(this@AccountActivity, account, info))
if (settings.isLegacy) {
info = CollectionInfo()
info.enumType = CollectionInfo.Type.TASKS
startActivity(CreateCollectionActivity.newIntent(this@AccountActivity, account, info))
} else {
startActivity(CollectionActivity.newCreateCollectionIntent(this@AccountActivity, account, ETEBASE_TYPE_TASKS))
}
}
R.id.create_addressbook -> {
info = CollectionInfo()
info.enumType = CollectionInfo.Type.ADDRESS_BOOK
startActivity(CreateCollectionActivity.newIntent(this@AccountActivity, account, info))
if (settings.isLegacy) {
info = CollectionInfo()
info.enumType = CollectionInfo.Type.ADDRESS_BOOK
startActivity(CreateCollectionActivity.newIntent(this@AccountActivity, account, info))
} else {
startActivity(CollectionActivity.newCreateCollectionIntent(this@AccountActivity, account, ETEBASE_TYPE_ADDRESS_BOOK))
}
}
R.id.install_tasksorg -> {
installPackage(tasksOrgPackage)
@ -227,10 +266,9 @@ class AccountActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, PopupMe
internal var taskdav: ServiceInfo? = null
class ServiceInfo {
internal var id: Long = 0
internal var refreshing: Boolean = false
internal var journals: List<JournalEntity>? = null
internal var infos: List<CollectionListItemInfo>? = null
}
}
@ -254,7 +292,7 @@ class AccountActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, PopupMe
listCardDAV!!.setAlpha(if (info.carddav!!.refreshing) 0.5f else 1f)
val adapter = CollectionListAdapter(this, account)
adapter.addAll(info.carddav!!.journals!!)
adapter.addAll(info.carddav!!.infos!!)
listCardDAV!!.adapter = adapter
listCardDAV!!.onItemClickListener = onItemClickListener
}
@ -268,7 +306,7 @@ class AccountActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, PopupMe
listCalDAV!!.setAlpha(if (info.caldav!!.refreshing) 0.5f else 1f)
val adapter = CollectionListAdapter(this, account)
adapter.addAll(info.caldav!!.journals!!)
adapter.addAll(info.caldav!!.infos!!)
listCalDAV!!.adapter = adapter
listCalDAV!!.onItemClickListener = onItemClickListener
}
@ -282,7 +320,7 @@ class AccountActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, PopupMe
listTaskDAV!!.setAlpha(if (info.taskdav!!.refreshing) 0.5f else 1f)
val adapter = CollectionListAdapter(this, account)
adapter.addAll(info.taskdav!!.journals!!)
adapter.addAll(info.taskdav!!.infos!!)
listTaskDAV!!.adapter = adapter
listTaskDAV!!.onItemClickListener = onItemClickListener
@ -342,50 +380,121 @@ class AccountActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, PopupMe
forceLoad()
}
private fun getLegacyJournals(data: MyEntityDataStore, serviceEntity: ServiceEntity): List<CollectionListItemInfo> {
return JournalEntity.getJournals(data, serviceEntity).map {
val info = it.info
val isAdmin = it.isOwner(account.name)
CollectionListItemInfo(it.uid, info.enumType!!, info.displayName!!, info.description ?: "", info.color, it.isReadOnly, isAdmin, info)
}
}
private fun getCollections(etebaseLocalCache: EtebaseLocalCache, colMgr: CollectionManager, type: CollectionInfo.Type): List<CollectionListItemInfo> {
val strType = when (type) {
CollectionInfo.Type.ADDRESS_BOOK -> ETEBASE_TYPE_ADDRESS_BOOK
CollectionInfo.Type.CALENDAR -> ETEBASE_TYPE_CALENDAR
CollectionInfo.Type.TASKS -> ETEBASE_TYPE_TASKS
}
synchronized(etebaseLocalCache) {
return etebaseLocalCache.collectionList(colMgr).map {
val meta = it.meta
if (strType != meta.collectionType) {
return@map null
}
val accessLevel = it.col.accessLevel
val isReadOnly = accessLevel == CollectionAccessLevel.ReadOnly
val isAdmin = accessLevel == CollectionAccessLevel.Admin
val metaColor = meta.color
val color = if (!metaColor.isNullOrBlank()) LocalCalendar.parseColor(metaColor) else null
CollectionListItemInfo(it.col.uid, type, meta.name, meta.description
?: "", color, isReadOnly, isAdmin, null)
}.filterNotNull()
}
}
override fun loadInBackground(): AccountInfo {
val info = AccountInfo()
val settings: AccountSettings
try {
settings = AccountSettings(context, account)
} catch (e: InvalidAccountException) {
return info
}
if (settings.isLegacy) {
val data = (context.applicationContext as App).data
for (serviceEntity in data.select(ServiceEntity::class.java).where(ServiceEntity.ACCOUNT.eq(account.name)).get()) {
val id = serviceEntity.id.toLong()
val service = serviceEntity.type!!
when (service) {
CollectionInfo.Type.ADDRESS_BOOK -> {
info.carddav = AccountInfo.ServiceInfo()
info.carddav!!.refreshing = davService != null && davService!!.isRefreshing(id) || ContentResolver.isSyncActive(account, App.addressBooksAuthority)
info.carddav!!.infos = getLegacyJournals(data, serviceEntity)
val accountManager = AccountManager.get(context)
for (addrBookAccount in accountManager.getAccountsByType(App.addressBookAccountType)) {
val addressBook = LocalAddressBook(context, addrBookAccount, null)
try {
if (account == addressBook.mainAccount)
info.carddav!!.refreshing = info.carddav!!.refreshing or ContentResolver.isSyncActive(addrBookAccount, ContactsContract.AUTHORITY)
} catch (e: ContactsStorageException) {
}
val data = (context.applicationContext as App).data
for (serviceEntity in data.select(ServiceEntity::class.java).where(ServiceEntity.ACCOUNT.eq(account.name)).get()) {
val id = serviceEntity.id.toLong()
val service = serviceEntity.type!!
when (service) {
CollectionInfo.Type.ADDRESS_BOOK -> {
info.carddav = AccountInfo.ServiceInfo()
info.carddav!!.id = id
info.carddav!!.refreshing = davService != null && davService!!.isRefreshing(id) || ContentResolver.isSyncActive(account, App.addressBooksAuthority)
info.carddav!!.journals = JournalEntity.getJournals(data, serviceEntity)
val accountManager = AccountManager.get(context)
for (addrBookAccount in accountManager.getAccountsByType(App.addressBookAccountType)) {
val addressBook = LocalAddressBook(context, addrBookAccount, null)
try {
if (account == addressBook.mainAccount)
info.carddav!!.refreshing = info.carddav!!.refreshing or ContentResolver.isSyncActive(addrBookAccount, ContactsContract.AUTHORITY)
} catch (e: ContactsStorageException) {
}
}
}
CollectionInfo.Type.CALENDAR -> {
info.caldav = AccountInfo.ServiceInfo()
info.caldav!!.id = id
info.caldav!!.refreshing = davService != null && davService!!.isRefreshing(id) ||
ContentResolver.isSyncActive(account, CalendarContract.AUTHORITY)
info.caldav!!.journals = JournalEntity.getJournals(data, serviceEntity)
}
CollectionInfo.Type.TASKS -> {
info.taskdav = AccountInfo.ServiceInfo()
info.taskdav!!.id = id
info.taskdav!!.refreshing = davService != null && davService!!.isRefreshing(id) ||
OPENTASK_PROVIDERS.any {
ContentResolver.isSyncActive(account, it.authority)
}
info.taskdav!!.journals = JournalEntity.getJournals(data, serviceEntity)
CollectionInfo.Type.CALENDAR -> {
info.caldav = AccountInfo.ServiceInfo()
info.caldav!!.refreshing = davService != null && davService!!.isRefreshing(id) ||
ContentResolver.isSyncActive(account, CalendarContract.AUTHORITY)
info.caldav!!.infos = getLegacyJournals(data, serviceEntity)
}
CollectionInfo.Type.TASKS -> {
info.taskdav = AccountInfo.ServiceInfo()
info.taskdav!!.refreshing = davService != null && davService!!.isRefreshing(id) ||
OPENTASK_PROVIDERS.any {
ContentResolver.isSyncActive(account, it.authority)
}
info.taskdav!!.infos = getLegacyJournals(data, serviceEntity)
}
}
}
return info
}
val etebaseLocalCache = EtebaseLocalCache.getInstance(context, account.name)
val httpClient = HttpClient.Builder(context).build().okHttpClient
val etebase = EtebaseLocalCache.getEtebase(context, httpClient, settings)
val colMgr = etebase.collectionManager
info.carddav = AccountInfo.ServiceInfo()
info.carddav!!.refreshing = ContentResolver.isSyncActive(account, App.addressBooksAuthority)
info.carddav!!.infos = getCollections(etebaseLocalCache, colMgr, CollectionInfo.Type.ADDRESS_BOOK)
val accountManager = AccountManager.get(context)
for (addrBookAccount in accountManager.getAccountsByType(App.addressBookAccountType)) {
val addressBook = LocalAddressBook(context, addrBookAccount, null)
try {
if (account == addressBook.mainAccount)
info.carddav!!.refreshing = info.carddav!!.refreshing or ContentResolver.isSyncActive(addrBookAccount, ContactsContract.AUTHORITY)
} catch (e: ContactsStorageException) {
}
}
info.caldav = AccountInfo.ServiceInfo()
info.caldav!!.refreshing = ContentResolver.isSyncActive(account, CalendarContract.AUTHORITY)
info.caldav!!.infos = getCollections(etebaseLocalCache, colMgr, CollectionInfo.Type.CALENDAR)
info.taskdav = AccountInfo.ServiceInfo()
info.taskdav!!.refreshing = OPENTASK_PROVIDERS.any {
ContentResolver.isSyncActive(account, it.authority)
}
info.taskdav!!.infos = getCollections(etebaseLocalCache, colMgr, CollectionInfo.Type.TASKS)
return info
}
}
@ -393,15 +502,16 @@ class AccountActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, PopupMe
/* LIST ADAPTERS */
class CollectionListAdapter(context: Context, private val account: Account) : ArrayAdapter<JournalEntity>(context, R.layout.account_collection_item) {
data class CollectionListItemInfo(val uid: String, val enumType: CollectionInfo.Type, val displayName: String, val description: String, val color: Int?, val isReadOnly: Boolean, val isAdmin: Boolean, val legacyInfo: CollectionInfo?)
class CollectionListAdapter(context: Context, private val account: Account) : ArrayAdapter<CollectionListItemInfo>(context, R.layout.account_collection_item) {
override fun getView(position: Int, _v: View?, parent: ViewGroup): View {
var v = _v
if (v == null)
v = LayoutInflater.from(context).inflate(R.layout.account_collection_item, parent, false)
val journalEntity = getItem(position)
val info = journalEntity!!.info
val info = getItem(position)!!
var tv = v!!.findViewById<View>(R.id.title) as TextView
tv.text = if (TextUtils.isEmpty(info.displayName)) info.uid else info.displayName
@ -422,10 +532,10 @@ class AccountActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, PopupMe
}
val readOnly = v.findViewById<View>(R.id.read_only)
readOnly.visibility = if (journalEntity.isReadOnly) View.VISIBLE else View.GONE
readOnly.visibility = if (info.isReadOnly) View.VISIBLE else View.GONE
val shared = v.findViewById<View>(R.id.shared)
val isOwner = journalEntity.isOwner(account.name)
val isOwner = info.isAdmin
shared.visibility = if (isOwner) View.GONE else View.VISIBLE
return v
@ -437,17 +547,32 @@ class AccountActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, PopupMe
private fun deleteAccount() {
val accountManager = AccountManager.get(this)
val settings = AccountSettings(this@AccountActivity, account)
val authToken = settings.authToken
val principal = settings.uri?.toHttpUrlOrNull()
doAsync {
try {
val httpClient = HttpClient.Builder(this@AccountActivity, null, authToken).build().okHttpClient
val journalAuthenticator = JournalAuthenticator(httpClient, principal!!)
journalAuthenticator.invalidateAuthToken(authToken)
} catch (e: Exceptions.HttpException) {
// Ignore failures for now
Logger.log.warning(e.toString())
if (settings.isLegacy) {
val authToken = settings.authToken
val principal = settings.uri?.toHttpUrlOrNull()
try {
val httpClient = HttpClient.Builder(this@AccountActivity, null, authToken).build().okHttpClient
val journalAuthenticator = JournalAuthenticator(httpClient, principal!!)
journalAuthenticator.invalidateAuthToken(authToken)
} catch (e: Exceptions.HttpException) {
// Ignore failures for now
Logger.log.warning(e.toString())
}
} else {
val etebaseLocalCache = EtebaseLocalCache.getInstance(this@AccountActivity, account.name)
etebaseLocalCache.clearUserCache()
try {
val httpClient = HttpClient.Builder(this@AccountActivity).build()
val etebase = EtebaseLocalCache.getEtebase(this@AccountActivity, httpClient.okHttpClient, settings)
etebase.logout()
} catch(e: EtebaseException) {
// Ignore failures for now
Logger.log.warning(e.toString())
}
}
}

@ -32,7 +32,7 @@ import com.etesync.syncadapter.R
class AccountListFragment : ListFragment(), LoaderManager.LoaderCallbacks<Array<Account>>, AdapterView.OnItemClickListener {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
listAdapter = AccountListAdapter(context!!)
listAdapter = AccountListAdapter(requireContext())
return inflater.inflate(R.layout.account_list, container, false)
}
@ -58,7 +58,7 @@ class AccountListFragment : ListFragment(), LoaderManager.LoaderCallbacks<Array<
// loader
override fun onCreateLoader(id: Int, args: Bundle?): Loader<Array<Account>> {
return AccountLoader(context!!)
return AccountLoader(requireContext())
}
override fun onLoadFinished(loader: Loader<Array<Account>>, accounts: Array<Account>) {

@ -17,12 +17,13 @@ import android.os.Bundle
import android.provider.CalendarContract
import android.text.TextUtils
import android.view.MenuItem
import android.widget.Toast
import androidx.core.app.NavUtils
import androidx.fragment.app.Fragment
import androidx.loader.app.LoaderManager
import androidx.loader.content.AsyncTaskLoader
import androidx.loader.content.Loader
import androidx.preference.*
import at.bitfire.ical4android.TaskProvider
import at.bitfire.ical4android.TaskProvider.Companion.OPENTASK_PROVIDERS
import com.etesync.syncadapter.*
import com.etesync.syncadapter.Constants.KEY_ACCOUNT
@ -43,7 +44,8 @@ class AccountSettingsActivity : BaseActivity() {
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
if (savedInstanceState == null) {
val frag = AccountSettingsFragment()
val settings = AccountSettings(this, account)
val frag: Fragment = if (settings.isLegacy) LegacyAccountSettingsFragment() else AccountSettingsFragment()
frag.arguments = intent.extras
supportFragmentManager.beginTransaction()
.replace(android.R.id.content, frag)
@ -60,139 +62,226 @@ class AccountSettingsActivity : BaseActivity() {
} else
return false
}
}
class AccountSettingsFragment : PreferenceFragmentCompat(), LoaderManager.LoaderCallbacks<AccountSettings> {
internal lateinit var account: Account
class AccountSettingsFragment() : PreferenceFragmentCompat(), LoaderManager.LoaderCallbacks<AccountSettings> {
internal lateinit var account: Account
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
account = arguments?.getParcelable(KEY_ACCOUNT)!!
account = arguments?.getParcelable(KEY_ACCOUNT)!!
loaderManager.initLoader(0, arguments, this)
}
loaderManager.initLoader(0, arguments, this)
}
override fun onCreatePreferences(bundle: Bundle, s: String) {
addPreferencesFromResource(R.xml.settings_account)
}
override fun onCreatePreferences(bundle: Bundle, s: String) {
addPreferencesFromResource(R.xml.settings_account)
}
override fun onCreateLoader(id: Int, args: Bundle?): Loader<AccountSettings> {
return AccountSettingsLoader(context!!, args!!.getParcelable(KEY_ACCOUNT) as Account)
}
override fun onCreateLoader(id: Int, args: Bundle?): Loader<AccountSettings> {
return AccountSettingsLoader(context!!, args!!.getParcelable(KEY_ACCOUNT) as Account)
}
override fun onLoadFinished(loader: Loader<AccountSettings>, settings: AccountSettings?) {
if (settings == null) {
activity!!.finish()
return
}
override fun onLoadFinished(loader: Loader<AccountSettings>, settings: AccountSettings?) {
if (settings == null) {
activity!!.finish()
return
}
// Category: dashboard
val prefManageAccount = findPreference("manage_account")
prefManageAccount.onPreferenceClickListener = Preference.OnPreferenceClickListener { _ ->
Toast.makeText(requireContext(), "Not yet supported", Toast.LENGTH_LONG).show()
true
}
// Category: dashboard
val prefManageAccount = findPreference("manage_account")
prefManageAccount.onPreferenceClickListener = Preference.OnPreferenceClickListener { _ ->
WebViewActivity.openUrl(activity!!, Constants.dashboard.buildUpon().appendQueryParameter("email", account.name).build())
true
}
// Category: encryption
val prefEncryptionPassword = findPreference("password")
prefEncryptionPassword.onPreferenceClickListener = Preference.OnPreferenceClickListener { _ ->
startActivity(ChangeEncryptionPasswordActivity.newIntent(activity!!, account))
true
}
// category: authentication
val prefPassword = findPreference("password") as EditTextPreference
prefPassword.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
val credentials = if (newValue != null) LoginCredentials(settings.uri, account.name, newValue as String) else null
LoginCredentialsChangeFragment.newInstance(account, credentials!!).show(fragmentManager!!, null)
val prefSync = findPreference("sync_interval") as ListPreference
val syncInterval = settings.getSyncInterval(CalendarContract.AUTHORITY) // Calendar is the baseline interval
if (syncInterval != null) {
prefSync.value = syncInterval.toString()
if (syncInterval == AccountSettings.SYNC_INTERVAL_MANUALLY)
prefSync.setSummary(R.string.settings_sync_summary_manually)
else
prefSync.summary = getString(R.string.settings_sync_summary_periodically, prefSync.entry)
prefSync.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
val newInterval = java.lang.Long.parseLong(newValue as String)
settings.setSyncInterval(App.addressBooksAuthority, newInterval)
settings.setSyncInterval(CalendarContract.AUTHORITY, newInterval)
OPENTASK_PROVIDERS.forEach {
settings.setSyncInterval(it.authority, newInterval)
}
loaderManager.restartLoader(0, arguments, this@AccountSettingsFragment)
false
}
} else {
prefSync.isEnabled = false
prefSync.setSummary(R.string.settings_sync_summary_not_available)
}
// Category: encryption
val prefEncryptionPassword = findPreference("encryption_password")
prefEncryptionPassword.onPreferenceClickListener = Preference.OnPreferenceClickListener { _ ->
startActivity(ChangeEncryptionPasswordActivity.newIntent(activity!!, account))
true
}
val prefWifiOnly = findPreference("sync_wifi_only") as SwitchPreferenceCompat
prefWifiOnly.isChecked = settings.syncWifiOnly
prefWifiOnly.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, wifiOnly ->
settings.setSyncWiFiOnly(wifiOnly as Boolean)
loaderManager.restartLoader(0, arguments, this@AccountSettingsFragment)
false
}
val prefSync = findPreference("sync_interval") as ListPreference
val syncInterval = settings.getSyncInterval(CalendarContract.AUTHORITY) // Calendar is the baseline interval
if (syncInterval != null) {
prefSync.value = syncInterval.toString()
if (syncInterval == AccountSettings.SYNC_INTERVAL_MANUALLY)
prefSync.setSummary(R.string.settings_sync_summary_manually)
else
prefSync.summary = getString(R.string.settings_sync_summary_periodically, prefSync.entry)
prefSync.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
val newInterval = java.lang.Long.parseLong(newValue as String)
settings.setSyncInterval(App.addressBooksAuthority, newInterval)
settings.setSyncInterval(CalendarContract.AUTHORITY, newInterval)
OPENTASK_PROVIDERS.forEach {
settings.setSyncInterval(it.authority, newInterval)
}
loaderManager.restartLoader(0, arguments, this@AccountSettingsFragment)
false
}
} else {
prefSync.isEnabled = false
prefSync.setSummary(R.string.settings_sync_summary_not_available)
}
val prefWifiOnlySSID = findPreference("sync_wifi_only_ssid") as EditTextPreference
val onlySSID = settings.syncWifiOnlySSID
prefWifiOnlySSID.text = onlySSID
if (onlySSID != null)
prefWifiOnlySSID.summary = getString(R.string.settings_sync_wifi_only_ssid_on, onlySSID)
else
prefWifiOnlySSID.setSummary(R.string.settings_sync_wifi_only_ssid_off)
prefWifiOnlySSID.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
val ssid = newValue as String
settings.syncWifiOnlySSID = if (!TextUtils.isEmpty(ssid)) ssid else null
loaderManager.restartLoader(0, arguments, this@AccountSettingsFragment)
false
}
}
val prefWifiOnly = findPreference("sync_wifi_only") as SwitchPreferenceCompat
prefWifiOnly.isChecked = settings.syncWifiOnly
prefWifiOnly.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, wifiOnly ->
settings.setSyncWiFiOnly(wifiOnly as Boolean)
loaderManager.restartLoader(0, arguments, this@AccountSettingsFragment)
false
}
override fun onLoaderReset(loader: Loader<AccountSettings>) {}
val prefWifiOnlySSID = findPreference("sync_wifi_only_ssid") as EditTextPreference
val onlySSID = settings.syncWifiOnlySSID
prefWifiOnlySSID.text = onlySSID
if (onlySSID != null)
prefWifiOnlySSID.summary = getString(R.string.settings_sync_wifi_only_ssid_on, onlySSID)
else
prefWifiOnlySSID.setSummary(R.string.settings_sync_wifi_only_ssid_off)
prefWifiOnlySSID.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
val ssid = newValue as String
settings.syncWifiOnlySSID = if (!TextUtils.isEmpty(ssid)) ssid else null
loaderManager.restartLoader(0, arguments, this@AccountSettingsFragment)
false
}
}
}
class LegacyAccountSettingsFragment : PreferenceFragmentCompat(), LoaderManager.LoaderCallbacks<AccountSettings> {
internal lateinit var account: Account
override fun onLoaderReset(loader: Loader<AccountSettings>) {}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
account = arguments?.getParcelable(KEY_ACCOUNT)!!
loaderManager.initLoader(0, arguments, this)
}
override fun onCreatePreferences(bundle: Bundle, s: String) {
addPreferencesFromResource(R.xml.settings_account_legacy)
}
private class AccountSettingsLoader(context: Context, internal val account: Account) : AsyncTaskLoader<AccountSettings>(context), SyncStatusObserver {
internal lateinit var listenerHandle: Any
override fun onCreateLoader(id: Int, args: Bundle?): Loader<AccountSettings> {
return AccountSettingsLoader(context!!, args!!.getParcelable(KEY_ACCOUNT) as Account)
}
override fun onStartLoading() {
forceLoad()
listenerHandle = ContentResolver.addStatusChangeListener(ContentResolver.SYNC_OBSERVER_TYPE_SETTINGS, this)
override fun onLoadFinished(loader: Loader<AccountSettings>, settings: AccountSettings?) {
if (settings == null) {
activity!!.finish()
return
}
override fun onStopLoading() {
ContentResolver.removeStatusChangeListener(listenerHandle)
// Category: dashboard
val prefManageAccount = findPreference("manage_account")
prefManageAccount.onPreferenceClickListener = Preference.OnPreferenceClickListener { _ ->
WebViewActivity.openUrl(activity!!, Constants.dashboard.buildUpon().appendQueryParameter("email", account.name).build())
true
}
override fun abandon() {
onStopLoading()
// category: authentication
val prefPassword = findPreference("password") as EditTextPreference
prefPassword.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
val credentials = if (newValue != null) LoginCredentials(settings.uri, account.name, newValue as String) else null
LoginCredentialsChangeFragment.newInstance(account, credentials!!).show(fragmentManager!!, null)
loaderManager.restartLoader(0, arguments, this@LegacyAccountSettingsFragment)
false
}
override fun loadInBackground(): AccountSettings? {
val settings: AccountSettings
try {
settings = AccountSettings(context, account)
} catch (e: InvalidAccountException) {
return null
}
// Category: encryption
val prefEncryptionPassword = findPreference("encryption_password")
prefEncryptionPassword.onPreferenceClickListener = Preference.OnPreferenceClickListener { _ ->
startActivity(ChangeEncryptionPasswordActivity.newIntent(activity!!, account))
true
}
return settings
val prefSync = findPreference("sync_interval") as ListPreference
val syncInterval = settings.getSyncInterval(CalendarContract.AUTHORITY) // Calendar is the baseline interval
if (syncInterval != null) {
prefSync.value = syncInterval.toString()
if (syncInterval == AccountSettings.SYNC_INTERVAL_MANUALLY)
prefSync.setSummary(R.string.settings_sync_summary_manually)
else
prefSync.summary = getString(R.string.settings_sync_summary_periodically, prefSync.entry)
prefSync.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
val newInterval = java.lang.Long.parseLong(newValue as String)
settings.setSyncInterval(App.addressBooksAuthority, newInterval)
settings.setSyncInterval(CalendarContract.AUTHORITY, newInterval)
OPENTASK_PROVIDERS.forEach {
settings.setSyncInterval(it.authority, newInterval)
}
loaderManager.restartLoader(0, arguments, this@LegacyAccountSettingsFragment)
false
}
} else {
prefSync.isEnabled = false
prefSync.setSummary(R.string.settings_sync_summary_not_available)
}
override fun onStatusChanged(which: Int) {
Logger.log.fine("Reloading account settings")
forceLoad()
val prefWifiOnly = findPreference("sync_wifi_only") as SwitchPreferenceCompat
prefWifiOnly.isChecked = settings.syncWifiOnly
prefWifiOnly.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, wifiOnly ->
settings.setSyncWiFiOnly(wifiOnly as Boolean)
loaderManager.restartLoader(0, arguments, this@LegacyAccountSettingsFragment)
false
}
val prefWifiOnlySSID = findPreference("sync_wifi_only_ssid") as EditTextPreference
val onlySSID = settings.syncWifiOnlySSID
prefWifiOnlySSID.text = onlySSID
if (onlySSID != null)
prefWifiOnlySSID.summary = getString(R.string.settings_sync_wifi_only_ssid_on, onlySSID)
else
prefWifiOnlySSID.setSummary(R.string.settings_sync_wifi_only_ssid_off)
prefWifiOnlySSID.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
val ssid = newValue as String
settings.syncWifiOnlySSID = if (!TextUtils.isEmpty(ssid)) ssid else null
loaderManager.restartLoader(0, arguments, this@LegacyAccountSettingsFragment)
false
}
}
override fun onLoaderReset(loader: Loader<AccountSettings>) {}
}
private class AccountSettingsLoader(context: Context, internal val account: Account) : AsyncTaskLoader<AccountSettings>(context), SyncStatusObserver {
internal lateinit var listenerHandle: Any
override fun onStartLoading() {
forceLoad()
listenerHandle = ContentResolver.addStatusChangeListener(ContentResolver.SYNC_OBSERVER_TYPE_SETTINGS, this)
}
override fun onStopLoading() {
ContentResolver.removeStatusChangeListener(listenerHandle)
}
override fun abandon() {
onStopLoading()
}
override fun loadInBackground(): AccountSettings? {
val settings: AccountSettings
try {
settings = AccountSettings(context, account)
} catch (e: InvalidAccountException) {
return null
}
return settings
}
override fun onStatusChanged(which: Int) {
Logger.log.fine("Reloading account settings")
forceLoad()
}
}

@ -15,6 +15,7 @@ import android.content.Intent
import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AlertDialog
import com.etebase.client.Client
import com.etesync.syncadapter.AccountSettings
import com.etesync.syncadapter.HttpClient
import com.etesync.syncadapter.R
@ -53,7 +54,7 @@ open class ChangeEncryptionPasswordActivity : BaseActivity() {
AlertDialog.Builder(this)
.setTitle(R.string.wrong_encryption_password)
.setIcon(R.drawable.ic_error_dark)
.setMessage(getString(R.string.wrong_encryption_password_content, e.localizedMessage))
.setMessage(e.localizedMessage)
.setPositiveButton(android.R.string.ok) { _, _ ->
// dismiss
}.show()
@ -62,6 +63,45 @@ open class ChangeEncryptionPasswordActivity : BaseActivity() {
fun changePasswordDo(old_password: String, new_password: String) {
val settings = AccountSettings(this, account)
if (settings.isLegacy) {
legacyChangePasswordDo(settings, old_password, new_password)
return
}
doAsync {
val httpClient = HttpClient.Builder(this@ChangeEncryptionPasswordActivity).setForeground(true).build().okHttpClient
try {
Logger.log.info("Loging in with old password")
val client = Client.create(httpClient, settings.uri?.toString())
val etebase = com.etebase.client.Account.login(client, account.name, old_password)
Logger.log.info("Login successful")
etebase.changePassword(new_password)
settings.etebaseSession = etebase.save(null)
uiThread {
progress.dismiss()
AlertDialog.Builder(this@ChangeEncryptionPasswordActivity)
.setTitle(R.string.change_encryption_password_success_title)
.setMessage(R.string.change_encryption_password_success_body)
.setPositiveButton(android.R.string.ok) { _, _ ->
this@ChangeEncryptionPasswordActivity.finish()
}.show()
requestSync(applicationContext, account)
}
} catch (e: Exception) {
uiThread {
changePasswordError(e)
}
return@doAsync
}
}
}
fun legacyChangePasswordDo(settings: AccountSettings, old_password: String, new_password: String) {
doAsync {
val httpClient = HttpClient.Builder(this@ChangeEncryptionPasswordActivity, settings).setForeground(false).build().okHttpClient

@ -106,13 +106,13 @@ class CollectionMembersListFragment : ListFragment(), AdapterView.OnItemClickLis
override fun onItemClick(parent: AdapterView<*>, view: View, position: Int, id: Long) {
val member = listAdapter?.getItem(position) as JournalManager.Member
AlertDialog.Builder(activity!!)
AlertDialog.Builder(requireActivity())
.setIcon(R.drawable.ic_info_dark)
.setTitle(R.string.collection_members_remove_title)
.setMessage(getString(R.string.collection_members_remove, member.user))
.setPositiveButton(android.R.string.yes) { dialog, which ->
val frag = RemoveMemberFragment.newInstance(account, info, member.user!!)
frag.show(fragmentManager!!, null)
frag.show(requireFragmentManager(), null)
}
.setNegativeButton(android.R.string.no) { dialog, which -> }.show()
}

@ -0,0 +1,170 @@
package com.etesync.syncadapter.ui.etebase
import android.accounts.Account
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.viewModels
import androidx.fragment.app.commit
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.observe
import com.etebase.client.CollectionManager
import com.etebase.client.CollectionMetadata
import com.etesync.syncadapter.*
import com.etesync.syncadapter.ui.BaseActivity
import org.jetbrains.anko.doAsync
import org.jetbrains.anko.uiThread
class CollectionActivity() : BaseActivity() {
private lateinit var account: Account
private val model: AccountViewModel by viewModels()
private val collectionModel: CollectionViewModel by viewModels()
private val itemsModel: ItemsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
account = intent.extras!!.getParcelable(EXTRA_ACCOUNT)!!
val colUid = intent.extras!!.getString(EXTRA_COLLECTION_UID)
val colType = intent.extras!!.getString(EXTRA_COLLECTION_TYPE)
setContentView(R.layout.etebase_collection_activity)
if (savedInstanceState == null) {
model.loadAccount(this, account)
if (colUid != null) {
model.observe(this) {
collectionModel.loadCollection(it, colUid)
collectionModel.observe(this) { cachedCollection ->
itemsModel.loadItems(it, cachedCollection)
}
}
supportFragmentManager.commit {
replace(R.id.fragment_container, ViewCollectionFragment())
}
} else if (colType != null) {
model.observe(this) {
doAsync {
val meta = CollectionMetadata(colType, "")
val cachedCollection = CachedCollection(it.colMgr.create(meta, ""), meta)
uiThread {
supportFragmentManager.commit {
replace(R.id.fragment_container, EditCollectionFragment(cachedCollection, true))
}
}
}
}
}
}
supportActionBar?.setDisplayHomeAsUpEnabled(true)
}
companion object {
private val EXTRA_ACCOUNT = "account"
private val EXTRA_COLLECTION_UID = "collectionUid"
private val EXTRA_COLLECTION_TYPE = "collectionType"
fun newIntent(context: Context, account: Account, colUid: String): Intent {
val intent = Intent(context, CollectionActivity::class.java)
intent.putExtra(EXTRA_ACCOUNT, account)
intent.putExtra(EXTRA_COLLECTION_UID, colUid)
return intent
}
fun newCreateCollectionIntent(context: Context, account: Account, colType: String): Intent {
val intent = Intent(context, CollectionActivity::class.java)
intent.putExtra(EXTRA_ACCOUNT, account)
intent.putExtra(EXTRA_COLLECTION_TYPE, colType)
return intent
}
}
}
class AccountViewModel : ViewModel() {
private val holder = MutableLiveData<AccountHolder>()
fun loadAccount(context: Context, account: Account) {
doAsync {
val settings = AccountSettings(context, account)
val etebaseLocalCache = EtebaseLocalCache.getInstance(context, account.name)
val httpClient = HttpClient.Builder(context).setForeground(true).build().okHttpClient
val etebase = EtebaseLocalCache.getEtebase(context, httpClient, settings)
val colMgr = etebase.collectionManager
uiThread {
holder.value = AccountHolder(
account,
etebaseLocalCache,
etebase,
colMgr
)
}
}
}
fun observe(owner: LifecycleOwner, observer: (AccountHolder) -> Unit) =
holder.observe(owner, observer)
val value: AccountHolder?
get() = holder.value
}
data class AccountHolder(val account: Account, val etebaseLocalCache: EtebaseLocalCache, val etebase: com.etebase.client.Account, val colMgr: CollectionManager)
class CollectionViewModel : ViewModel() {
private val collection = MutableLiveData<CachedCollection>()
fun loadCollection(accountHolder: AccountHolder, colUid: String) {
doAsync {
val etebaseLocalCache = accountHolder.etebaseLocalCache
val colMgr = accountHolder.colMgr
val cachedCollection = synchronized(etebaseLocalCache) {
etebaseLocalCache.collectionGet(colMgr, colUid)!!
}
uiThread {
collection.value = cachedCollection
}
}
}
fun observe(owner: LifecycleOwner, observer: (CachedCollection) -> Unit) =
collection.observe(owner, observer)
val value: CachedCollection?
get() = collection.value
}
class ItemsViewModel : ViewModel() {
private val cachedItems = MutableLiveData<List<CachedItem>>()
fun loadItems(accountCollectionHolder: AccountHolder, cachedCollection: CachedCollection) {
doAsync {
val col = cachedCollection.col
val itemMgr = accountCollectionHolder.colMgr.getItemManager(col)
val items = accountCollectionHolder.etebaseLocalCache.itemList(itemMgr, col.uid, withDeleted = true)
uiThread {
cachedItems.value = items
}
}
}
fun observe(owner: LifecycleOwner, observer: (List<CachedItem>) -> Unit) =
cachedItems.observe(owner, observer)
val value: List<CachedItem>?
get() = cachedItems.value
}
class LoadingViewModel : ViewModel() {
private val loading = MutableLiveData<Boolean>()
fun setLoading(value: Boolean) {
loading.value = value
}
fun observe(owner: LifecycleOwner, observer: (Boolean) -> Unit) =
loading.observe(owner, observer)
}

@ -0,0 +1,532 @@
package com.etesync.syncadapter.ui.etebase
import android.content.Context
import android.os.Bundle
import android.provider.CalendarContract
import android.provider.ContactsContract
import android.text.format.DateFormat
import android.text.format.DateUtils
import android.view.*
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentPagerAdapter
import androidx.fragment.app.activityViewModels
import androidx.viewpager.widget.ViewPager
import at.bitfire.ical4android.Event
import at.bitfire.ical4android.InvalidCalendarException
import at.bitfire.ical4android.Task
import at.bitfire.ical4android.TaskProvider
import at.bitfire.vcard4android.Contact
import com.etesync.syncadapter.CachedCollection
import com.etesync.syncadapter.CachedItem
import com.etesync.syncadapter.Constants
import com.etesync.syncadapter.R
import com.etesync.syncadapter.resource.*
import com.etesync.syncadapter.ui.BaseActivity
import com.etesync.syncadapter.utils.EventEmailInvitation
import com.etesync.syncadapter.utils.TaskProviderHandling
import com.google.android.material.tabs.TabLayout
import ezvcard.util.PartialDate
import org.jetbrains.anko.doAsync
import org.jetbrains.anko.uiThread
import java.io.IOException
import java.io.StringReader
import java.text.SimpleDateFormat
import java.util.*
import java.util.concurrent.Future
class CollectionItemFragment(private val cachedItem: CachedItem) : Fragment() {
private val model: AccountViewModel by activityViewModels()
private val collectionModel: CollectionViewModel by activityViewModels()
private var emailInvitationEvent: Event? = null
private var emailInvitationEventString: String? = null
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val ret = inflater.inflate(R.layout.journal_item_activity, container, false)
setHasOptionsMenu(true)
if (savedInstanceState == null) {
collectionModel.observe(this) {
(activity as? BaseActivity?)?.supportActionBar?.title = it.meta.name
if (container != null) {
initUi(inflater, ret, it)
}
}
}
return ret
}
private fun initUi(inflater: LayoutInflater, v: View, cachedCollection: CachedCollection) {
val viewPager = v.findViewById<ViewPager>(R.id.viewpager)
viewPager.adapter = TabsAdapter(childFragmentManager, this, requireContext(), cachedCollection, cachedItem)
val tabLayout = v.findViewById<TabLayout>(R.id.tabs)
tabLayout.setupWithViewPager(viewPager)
v.findViewById<View>(R.id.journal_list_item).visibility = View.GONE
}
fun allowSendEmail(event: Event?, icsContent: String) {
emailInvitationEvent = event
emailInvitationEventString = icsContent
activity?.invalidateOptionsMenu()
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.collection_item_fragment, menu)
menu.setGroupVisible(R.id.journal_item_menu_event_invite, emailInvitationEvent != null)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
val accountHolder = model.value!!
when (item.itemId) {
R.id.on_send_event_invite -> {
val account = accountHolder.account
val intent = EventEmailInvitation(requireContext(), account).createIntent(emailInvitationEvent!!, emailInvitationEventString!!)
startActivity(intent)
}
R.id.on_restore_item -> {
restoreItem(accountHolder)
}
}
return super.onOptionsItemSelected(item)
}
fun restoreItem(accountHolder: AccountHolder) {
// FIXME: This code makes the assumption that providers are all available. May not be true for tasks, and potentially others too.
val context = requireContext()
val account = accountHolder.account
val cachedCol = collectionModel.value!!
when (cachedCol.meta.collectionType) {
Constants.ETEBASE_TYPE_CALENDAR -> {
val provider = context.contentResolver.acquireContentProviderClient(CalendarContract.CONTENT_URI)!!
val localCalendar = LocalCalendar.findByName(account, provider, LocalCalendar.Factory, cachedCol.col.uid)!!
val event = Event.eventsFromReader(StringReader(cachedItem.content))[0]
var localEvent = localCalendar.findByUid(event.uid!!)
if (localEvent != null) {
localEvent.updateAsDirty(event)
} else {
localEvent = LocalEvent(localCalendar, event, event.uid, null)
localEvent.addAsDirty()
}
}
Constants.ETEBASE_TYPE_TASKS -> {
TaskProviderHandling.getWantedTaskSyncProvider(context)?.let {
val provider = TaskProvider.acquire(context, it)!!
val localTaskList = LocalTaskList.findByName(account, provider, LocalTaskList.Factory, cachedCol.col.uid)!!
val task = Task.tasksFromReader(StringReader(cachedItem.content))[0]
var localTask = localTaskList.findByUid(task.uid!!)
if (localTask != null) {
localTask.updateAsDirty(task)
} else {
localTask = LocalTask(localTaskList, task, task.uid, null)
localTask.addAsDirty()
}
}
}
Constants.ETEBASE_TYPE_ADDRESS_BOOK -> {
val provider = context.contentResolver.acquireContentProviderClient(ContactsContract.RawContacts.CONTENT_URI)!!
val localAddressBook = LocalAddressBook.findByUid(context, provider, account, cachedCol.col.uid)!!
val contact = Contact.fromReader(StringReader(cachedItem.content), null)[0]
if (contact.group) {
// FIXME: not currently supported
} else {
var localContact = localAddressBook.findByUid(contact.uid!!) as LocalContact?
if (localContact != null) {
localContact.updateAsDirty(contact)
} else {
localContact = LocalContact(localAddressBook, contact, contact.uid, null)
localContact.createAsDirty()
}
}
}
}
val dialog = AlertDialog.Builder(context)
.setTitle(R.string.journal_item_restore_action)
.setIcon(R.drawable.ic_restore_black)
.setMessage(R.string.journal_item_restore_dialog_body)
.setPositiveButton(android.R.string.ok) { dialog, which ->
// dismiss
}
.create()
dialog.show()
}
}
private class TabsAdapter(fm: FragmentManager, private val mainFragment: CollectionItemFragment, private val context: Context, private val cachedCollection: CachedCollection, private val cachedItem: CachedItem) : FragmentPagerAdapter(fm) {
override fun getCount(): Int {
// FIXME: Make it depend on info enumType (only have non-raw for known types)
return 3
}
override fun getPageTitle(position: Int): CharSequence? {
return if (position == 0) {
context.getString(R.string.journal_item_tab_main)
} else if (position == 1) {
context.getString(R.string.journal_item_tab_raw)
} else {
context.getString(R.string.journal_item_tab_revisions)
}
}
override fun getItem(position: Int): Fragment {
return if (position == 0) {
PrettyFragment(mainFragment, cachedCollection, cachedItem.content)
} else if (position == 1) {
TextFragment(cachedItem.content)
} else {
ItemRevisionsListFragment(cachedCollection, cachedItem)
}
}
}
class TextFragment(private val content: String) : Fragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val v = inflater.inflate(R.layout.text_fragment, container, false)
val tv = v.findViewById<View>(R.id.content) as TextView
tv.text = content
return v
}
}
class PrettyFragment(private val mainFragment: CollectionItemFragment, private val cachedCollection: CachedCollection, private val content: String) : Fragment() {
private var asyncTask: Future<Unit>? = null
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
var v: View? = null
when (cachedCollection.meta.collectionType) {
Constants.ETEBASE_TYPE_ADDRESS_BOOK -> {
v = inflater.inflate(R.layout.contact_info, container, false)
asyncTask = loadContactTask(v)
}
Constants.ETEBASE_TYPE_CALENDAR -> {
v = inflater.inflate(R.layout.event_info, container, false)
asyncTask = loadEventTask(v)
}
Constants.ETEBASE_TYPE_TASKS -> {
v = inflater.inflate(R.layout.task_info, container, false)
asyncTask = loadTaskTask(v)
}
}
return v
}
override fun onDestroyView() {
super.onDestroyView()
if (asyncTask != null)
asyncTask!!.cancel(true)
}
private fun loadEventTask(view: View): Future<Unit> {
return doAsync {
var event: Event? = null
val inputReader = StringReader(content)
try {
event = Event.eventsFromReader(inputReader, null)[0]
} catch (e: InvalidCalendarException) {
e.printStackTrace()
} catch (e: IOException) {
e.printStackTrace()
}
if (event != null) {
uiThread {
val loader = view.findViewById<View>(R.id.event_info_loading_msg)
loader.visibility = View.GONE
val contentContainer = view.findViewById<View>(R.id.event_info_scroll_view)
contentContainer.visibility = View.VISIBLE
setTextViewText(view, R.id.title, event.summary)
setTextViewText(view, R.id.when_datetime, getDisplayedDatetime(event.dtStart?.date?.time!!, event.dtEnd?.date!!.time, event.isAllDay(), context))
setTextViewText(view, R.id.where, event.location)
val organizer = event.organizer
if (organizer != null) {
val tv = view.findViewById<View>(R.id.organizer) as TextView
tv.text = organizer.calAddress.toString().replaceFirst("mailto:".toRegex(), "")
} else {
val organizerView = view.findViewById<View>(R.id.organizer_container)
organizerView.visibility = View.GONE
}
setTextViewText(view, R.id.description, event.description)
var first = true
var sb = StringBuilder()
for (attendee in event.attendees) {
if (first) {
first = false
sb.append(getString(R.string.journal_item_attendees)).append(": ")
} else {
sb.append(", ")
}
sb.append(attendee.calAddress.toString().replaceFirst("mailto:".toRegex(), ""))
}
setTextViewText(view, R.id.attendees, sb.toString())
first = true
sb = StringBuilder()
for (alarm in event.alarms) {
if (first) {
first = false
sb.append(getString(R.string.journal_item_reminders)).append(": ")
} else {
sb.append(", ")
}
sb.append(alarm.trigger.value)
}
setTextViewText(view, R.id.reminders, sb.toString())
if (event.attendees.isNotEmpty()) {
mainFragment.allowSendEmail(event, content)
}
}
}
}
}
private fun loadTaskTask(view: View): Future<Unit> {
return doAsync {
var task: Task? = null
val inputReader = StringReader(content)
try {
task = Task.tasksFromReader(inputReader)[0]
} catch (e: InvalidCalendarException) {
e.printStackTrace()
} catch (e: IOException) {
e.printStackTrace()
}
if (task != null) {
uiThread {
val loader = view.findViewById<View>(R.id.task_info_loading_msg)
loader.visibility = View.GONE
val contentContainer = view.findViewById<View>(R.id.task_info_scroll_view)
contentContainer.visibility = View.VISIBLE
setTextViewText(view, R.id.title, task.summary)
setTextViewText(view, R.id.where, task.location)
val organizer = task.organizer
if (organizer != null) {
val tv = view.findViewById<View>(R.id.organizer) as TextView
tv.text = organizer.calAddress.toString().replaceFirst("mailto:".toRegex(), "")
} else {
val organizerView = view.findViewById<View>(R.id.organizer_container)
organizerView.visibility = View.GONE
}
setTextViewText(view, R.id.description, task.description)
}
}
}
}
private fun loadContactTask(view: View): Future<Unit> {
return doAsync {
var contact: Contact? = null
val reader = StringReader(content)
try {
contact = Contact.fromReader(reader, null)[0]
} catch (e: IOException) {
e.printStackTrace()
}
if (contact != null) {
uiThread {
val loader = view.findViewById<View>(R.id.loading_msg)
loader.visibility = View.GONE
val contentContainer = view.findViewById<View>(R.id.content_container)
contentContainer.visibility = View.VISIBLE
val tv = view.findViewById<View>(R.id.display_name) as TextView
tv.text = contact.displayName
if (contact.group) {
showGroup(contact)
} else {
showContact(contact)
}
}
}
}
}
private fun showGroup(contact: Contact) {
val view = requireView()
val mainCard = view.findViewById<View>(R.id.main_card) as ViewGroup
addInfoItem(view.context, mainCard, getString(R.string.journal_item_member_count), null, contact.members.size.toString())
for (member in contact.members) {
addInfoItem(view.context, mainCard, getString(R.string.journal_item_member), null, member)
}
}
private fun showContact(contact: Contact) {
val view = requireView()
val mainCard = view.findViewById<View>(R.id.main_card) as ViewGroup
val aboutCard = view.findViewById<View>(R.id.about_card) as ViewGroup
aboutCard.findViewById<View>(R.id.title_container).visibility = View.VISIBLE
// TEL
for (labeledPhone in contact.phoneNumbers) {
val types = labeledPhone.property.types
val type = if (types.size > 0) types[0].value else null
addInfoItem(view.context, mainCard, getString(R.string.journal_item_phone), type, labeledPhone.property.text)
}
// EMAIL
for (labeledEmail in contact.emails) {
val types = labeledEmail.property.types
val type = if (types.size > 0) types[0].value else null
addInfoItem(view.context, mainCard, getString(R.string.journal_item_email), type, labeledEmail.property.value)
}
// ORG, TITLE, ROLE
if (contact.organization != null) {
addInfoItem(view.context, aboutCard, getString(R.string.journal_item_organization), contact.jobTitle, contact.organization?.values!![0])
}
if (contact.jobDescription != null) {
addInfoItem(view.context, aboutCard, getString(R.string.journal_item_job_description), null, contact.jobTitle)
}
// IMPP
for (labeledImpp in contact.impps) {
addInfoItem(view.context, mainCard, getString(R.string.journal_item_impp), labeledImpp.property.protocol, labeledImpp.property.handle)
}
// NICKNAME
if (contact.nickName != null && !contact.nickName?.values?.isEmpty()!!) {
addInfoItem(view.context, aboutCard, getString(R.string.journal_item_nickname), null, contact.nickName?.values!![0])
}
// ADR
for (labeledAddress in contact.addresses) {
val types = labeledAddress.property.types
val type = if (types.size > 0) types[0].value else null
addInfoItem(view.context, mainCard, getString(R.string.journal_item_address), type, labeledAddress.property.label)
}
// NOTE
if (contact.note != null) {
addInfoItem(view.context, aboutCard, getString(R.string.journal_item_note), null, contact.note)
}
// URL
for (labeledUrl in contact.urls) {
addInfoItem(view.context, aboutCard, getString(R.string.journal_item_website), null, labeledUrl.property.value)
}
// ANNIVERSARY
if (contact.anniversary != null) {
addInfoItem(view.context, aboutCard, getString(R.string.journal_item_anniversary), null, getDisplayedDate(contact.anniversary?.date, contact.anniversary?.partialDate))
}
// BDAY
if (contact.birthDay != null) {
addInfoItem(view.context, aboutCard, getString(R.string.journal_item_birthday), null, getDisplayedDate(contact.birthDay?.date, contact.birthDay?.partialDate))
}
// RELATED
for (related in contact.relations) {
val types = related.types
val type = if (types.size > 0) types[0].value else null
addInfoItem(view.context, aboutCard, getString(R.string.journal_item_relation), type, related.text)
}
// PHOTO
// if (contact.photo != null)
}
private fun getDisplayedDate(date: Date?, partialDate: PartialDate?): String? {
if (date != null) {
val epochDate = date.time
return getDisplayedDatetime(epochDate, epochDate, true, context)
} else if (partialDate != null){
val formatter = SimpleDateFormat("d MMMM", Locale.getDefault())
val calendar = GregorianCalendar()
calendar.set(Calendar.DAY_OF_MONTH, partialDate.date!!)
calendar.set(Calendar.MONTH, partialDate.month!! - 1)
return formatter.format(calendar.time)
}
return null
}
companion object {
private fun addInfoItem(context: Context, parent: ViewGroup, type: String, label: String?, value: String?): View {
val layout = parent.findViewById<View>(R.id.container) as ViewGroup
val infoItem = LayoutInflater.from(context).inflate(R.layout.contact_info_item, layout, false)
layout.addView(infoItem)
setTextViewText(infoItem, R.id.type, type)
setTextViewText(infoItem, R.id.title, label)
setTextViewText(infoItem, R.id.content, value)
parent.visibility = View.VISIBLE
return infoItem
}
private fun setTextViewText(parent: View, id: Int, text: String?) {
val tv = parent.findViewById<View>(id) as TextView
if (text == null) {
tv.visibility = View.GONE
} else {
tv.text = text
}
}
fun getDisplayedDatetime(startMillis: Long, endMillis: Long, allDay: Boolean, context: Context?): String? {
// Configure date/time formatting.
val flagsDate = DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_WEEKDAY
var flagsTime = DateUtils.FORMAT_SHOW_TIME
if (DateFormat.is24HourFormat(context)) {
flagsTime = flagsTime or DateUtils.FORMAT_24HOUR
}
val datetimeString: String
if (allDay) {
// For multi-day allday events or single-day all-day events that are not
// today or tomorrow, use framework formatter.
// We need to remove 24hrs because full day events are from the start of a day until the start of the next
var adjustedEnd = endMillis - 24 * 60 * 60 * 1000;
if (adjustedEnd < startMillis) {
adjustedEnd = startMillis;
}
val f = Formatter(StringBuilder(50), Locale.getDefault())
datetimeString = DateUtils.formatDateRange(context, f, startMillis,
adjustedEnd, flagsDate).toString()
} else {
// For multiday events, shorten day/month names.
// Example format: "Fri Apr 6, 5:00pm - Sun, Apr 8, 6:00pm"
val flagsDatetime = flagsDate or flagsTime or DateUtils.FORMAT_ABBREV_MONTH or
DateUtils.FORMAT_ABBREV_WEEKDAY
datetimeString = DateUtils.formatDateRange(context, startMillis, endMillis,
flagsDatetime)
}
return datetimeString
}
}
}

@ -0,0 +1,168 @@
package com.etesync.syncadapter.ui.etebase
import android.app.Dialog
import android.app.ProgressDialog
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.CheckBox
import android.widget.EditText
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import com.etebase.client.CollectionAccessLevel
import com.etebase.client.Utils
import com.etebase.client.exceptions.EtebaseException
import com.etebase.client.exceptions.NotFoundException
import com.etesync.syncadapter.CachedCollection
import com.etesync.syncadapter.Constants
import com.etesync.syncadapter.R
import com.etesync.syncadapter.resource.LocalCalendar
import com.etesync.syncadapter.syncadapter.requestSync
import com.etesync.syncadapter.ui.BaseActivity
import org.jetbrains.anko.doAsync
import org.jetbrains.anko.uiThread
class CollectionMembersFragment : Fragment() {
private val model: AccountViewModel by activityViewModels()
private val collectionModel: CollectionViewModel by activityViewModels()
private var isAdmin: Boolean = false
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val ret = if (collectionModel.value!!.col.accessLevel == CollectionAccessLevel.Admin) {
isAdmin = true
inflater.inflate(R.layout.etebase_view_collection_members, container, false)
} else {
inflater.inflate(R.layout.etebase_view_collection_members_no_access, container, false)
}
if (savedInstanceState == null) {
collectionModel.observe(this) {
(activity as? BaseActivity?)?.supportActionBar?.setTitle(R.string.collection_members_title)
if (container != null) {
initUi(inflater, ret, it)
}
}
}
return ret
}
private fun initUi(inflater: LayoutInflater, v: View, cachedCollection: CachedCollection) {
val meta = cachedCollection.meta
val colorSquare = v.findViewById<View>(R.id.color)
val color = LocalCalendar.parseColor(meta.color)
when (meta.collectionType) {
Constants.ETEBASE_TYPE_CALENDAR -> {
colorSquare.setBackgroundColor(color)
}
Constants.ETEBASE_TYPE_TASKS -> {
colorSquare.setBackgroundColor(color)
}
Constants.ETEBASE_TYPE_ADDRESS_BOOK -> {
colorSquare.visibility = View.GONE
}
}
val title = v.findViewById<View>(R.id.display_name) as TextView
title.text = meta.name
val desc = v.findViewById<View>(R.id.description) as TextView
desc.text = meta.description
if (isAdmin) {
v.findViewById<View>(R.id.add_member).setOnClickListener {
addMemberClicked()
}
} else {
v.findViewById<Button>(R.id.leave).setOnClickListener {
doAsync {
val membersManager = model.value!!.colMgr.getMemberManager(cachedCollection.col)
membersManager.leave()
val applicationContext = activity?.applicationContext
if (applicationContext != null) {
requestSync(applicationContext, model.value!!.account)
}
activity?.finish()
}
}
}
v.findViewById<View>(R.id.progressBar).visibility = View.GONE
}
private fun addMemberClicked() {
val view = View.inflate(requireContext(), R.layout.add_member_fragment, null)
val dialog = AlertDialog.Builder(requireContext())
.setTitle(R.string.collection_members_add)
.setIcon(R.drawable.ic_account_add_dark)
.setPositiveButton(android.R.string.yes) { _, _ ->
val username = view.findViewById<EditText>(R.id.username).text.toString()
val readOnly = view.findViewById<CheckBox>(R.id.read_only).isChecked
val frag = AddMemberFragment(model.value!!, collectionModel.value!!, username, if (readOnly) CollectionAccessLevel.ReadOnly else CollectionAccessLevel.ReadWrite)
frag.show(childFragmentManager, null)
}
.setNegativeButton(android.R.string.no) { _, _ -> }
dialog.setView(view)
dialog.show()
}
}
class AddMemberFragment(private val accountHolder: AccountHolder, private val cachedCollection: CachedCollection, private val username: String, private val accessLevel: CollectionAccessLevel) : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val progress = ProgressDialog(context)
progress.setTitle(R.string.collection_members_adding)
progress.setMessage(getString(R.string.please_wait))
progress.isIndeterminate = true
progress.setCanceledOnTouchOutside(false)
isCancelable = false
doAsync {
val invitationManager = accountHolder.etebase.invitationManager
try {
val profile = invitationManager.fetchUserProfile(username)
val fingerprint = Utils.prettyFingerprint(profile.pubkey)
uiThread {
val view = LayoutInflater.from(context).inflate(R.layout.fingerprint_alertdialog, null)
(view.findViewById<View>(R.id.body) as TextView).text = getString(R.string.trust_fingerprint_body, username)
(view.findViewById<View>(R.id.fingerprint) as TextView).text = fingerprint
AlertDialog.Builder(requireContext())
.setIcon(R.drawable.ic_fingerprint_dark)
.setTitle(R.string.trust_fingerprint_title)
.setView(view)
.setPositiveButton(android.R.string.ok) { _, _ ->
doAsync {
try {
invitationManager.invite(cachedCollection.col, username, profile.pubkey, accessLevel)
uiThread { dismiss() }
} catch (e: EtebaseException) {
uiThread { handleError(e.localizedMessage) }
}
}
}
.setNegativeButton(android.R.string.cancel) { _, _ -> dismiss() }.show()
}
} catch (e: NotFoundException) {
uiThread { handleError(getString(R.string.collection_members_error_user_not_found, username)) }
} catch (e: EtebaseException) {
uiThread { handleError(e.localizedMessage) }
}
}
return progress
}
private fun handleError(message: String) {
AlertDialog.Builder(requireContext())
.setIcon(R.drawable.ic_error_dark)
.setTitle(R.string.collection_members_add_error)
.setMessage(message)
.setPositiveButton(android.R.string.yes) { _, _ -> }.show()
dismiss()
}
}

@ -0,0 +1,166 @@
package com.etesync.syncadapter.ui.etebase
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.ListFragment
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.observe
import com.etebase.client.CollectionAccessLevel
import com.etebase.client.CollectionMember
import com.etebase.client.FetchOptions
import com.etesync.syncadapter.CachedCollection
import com.etesync.syncadapter.R
import org.jetbrains.anko.doAsync
import org.jetbrains.anko.uiThread
import java.util.*
import java.util.concurrent.Future
class CollectionMembersListFragment : ListFragment(), AdapterView.OnItemClickListener {
private val model: AccountViewModel by activityViewModels()
private val collectionModel: CollectionViewModel by activityViewModels()
private val membersModel: CollectionMembersViewModel by viewModels()
private var emptyTextView: TextView? = null
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val view = inflater.inflate(R.layout.collection_members_list, container, false)
//This is instead of setEmptyText() function because of Google bug
//See: https://code.google.com/p/android/issues/detail?id=21742
emptyTextView = view.findViewById<TextView>(android.R.id.empty)
return view
}
private fun setListAdapterMembers(members: List<CollectionMember>) {
val context = context
if (context != null) {
val listAdapter = MembersListAdapter(context)
setListAdapter(listAdapter)
listAdapter.addAll(members)
emptyTextView!!.setText(R.string.collection_members_list_empty)
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
model.observe(this) {
collectionModel.observe(this) { cachedCollection ->
membersModel.loadMembers(it, cachedCollection)
}
}
membersModel.observe(this) {
setListAdapterMembers(it)
}
listView.onItemClickListener = this
}
override fun onDestroyView() {
super.onDestroyView()
membersModel.cancelLoad()
}
override fun onItemClick(parent: AdapterView<*>, view: View, position: Int, id: Long) {
val member = listAdapter?.getItem(position) as CollectionMember
if (member.accessLevel == CollectionAccessLevel.Admin) {
AlertDialog.Builder(requireActivity())
.setIcon(R.drawable.ic_error_dark)
.setTitle(R.string.collection_members_remove_title)
.setMessage(R.string.collection_members_remove_admin)
.setNegativeButton(android.R.string.ok) { _, _ -> }.show()
return
}
AlertDialog.Builder(requireActivity())
.setIcon(R.drawable.ic_info_dark)
.setTitle(R.string.collection_members_remove_title)
.setMessage(getString(R.string.collection_members_remove, member.username))
.setPositiveButton(android.R.string.yes) { dialog, which ->
membersModel.removeMember(model.value!!, collectionModel.value!!, member.username)
}
.setNegativeButton(android.R.string.no) { dialog, which -> }.show()
}
internal inner class MembersListAdapter(context: Context) : ArrayAdapter<CollectionMember>(context, R.layout.collection_members_list_item) {
override fun getView(position: Int, _v: View?, parent: ViewGroup): View {
var v = _v
if (v == null)
v = LayoutInflater.from(context).inflate(R.layout.collection_members_list_item, parent, false)
val member = getItem(position)
val tv = v!!.findViewById<View>(R.id.title) as TextView
tv.text = member!!.username
// FIXME: Also mark admins
val readOnly = v.findViewById<View>(R.id.read_only)
readOnly.visibility = if (member.accessLevel == CollectionAccessLevel.ReadOnly) View.VISIBLE else View.GONE
return v
}
}
}
class CollectionMembersViewModel : ViewModel() {
private val members = MutableLiveData<List<CollectionMember>>()
private var asyncTask: Future<Unit>? = null
fun loadMembers(accountCollectionHolder: AccountHolder, cachedCollection: CachedCollection) {
asyncTask = doAsync {
val ret = LinkedList<CollectionMember>()
val col = cachedCollection.col
val memberManager = accountCollectionHolder.colMgr.getMemberManager(col)
var iterator: String? = null
var done = false
while (!done) {
val chunk = memberManager.list(FetchOptions().iterator(iterator).limit(30))
iterator = chunk.stoken
done = chunk.isDone
ret.addAll(chunk.data)
}
uiThread {
members.value = ret
}
}
}
fun removeMember(accountCollectionHolder: AccountHolder, cachedCollection: CachedCollection, username: String) {
doAsync {
val col = cachedCollection.col
val memberManager = accountCollectionHolder.colMgr.getMemberManager(col)
memberManager.remove(username)
val ret = members.value!!.filter { it.username != username }
uiThread {
members.value = ret
}
}
}
fun cancelLoad() {
asyncTask?.cancel(true)
}
fun observe(owner: LifecycleOwner, observer: (List<CollectionMember>) -> Unit) =
members.observe(owner, observer)
}

@ -0,0 +1,254 @@
package com.etesync.syncadapter.ui.etebase
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import android.text.TextUtils
import android.view.*
import android.widget.EditText
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.commit
import androidx.fragment.app.viewModels
import com.etebase.client.Collection
import com.etebase.client.exceptions.EtebaseException
import com.etesync.syncadapter.CachedCollection
import com.etesync.syncadapter.Constants
import com.etesync.syncadapter.R
import com.etesync.syncadapter.resource.LocalCalendar
import com.etesync.syncadapter.syncadapter.requestSync
import com.etesync.syncadapter.ui.BaseActivity
import org.apache.commons.lang3.StringUtils
import org.jetbrains.anko.doAsync
import org.jetbrains.anko.uiThread
import yuku.ambilwarna.AmbilWarnaDialog
class EditCollectionFragment(private val cachedCollection: CachedCollection, private val isCreating: Boolean = false) : Fragment() {
private val model: AccountViewModel by activityViewModels()
private val collectionModel: CollectionViewModel by activityViewModels()
private val itemsModel: ItemsViewModel by activityViewModels()
private val loadingModel: LoadingViewModel by viewModels()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val ret = inflater.inflate(R.layout.activity_create_collection, container, false)
setHasOptionsMenu(true)
if (savedInstanceState == null) {
updateTitle()
if (container != null) {
initUi(inflater, ret)
}
}
return ret
}
fun updateTitle() {
cachedCollection.let {
var titleId: Int = R.string.create_calendar
if (isCreating) {
when (cachedCollection.meta.collectionType) {
Constants.ETEBASE_TYPE_CALENDAR -> {
titleId = R.string.create_calendar
}
Constants.ETEBASE_TYPE_TASKS -> {
titleId = R.string.create_tasklist
}
Constants.ETEBASE_TYPE_ADDRESS_BOOK -> {
titleId = R.string.create_addressbook
}
}
} else {
titleId = R.string.edit_collection
}
(activity as? BaseActivity?)?.supportActionBar?.setTitle(titleId)
}
}
private fun initUi(inflater: LayoutInflater, v: View) {
val title = v.findViewById<EditText>(R.id.display_name)
val desc = v.findViewById<EditText>(R.id.description)
val meta = cachedCollection.meta
title.setText(meta.name)
desc.setText(meta.description)
val colorSquare = v.findViewById<View>(R.id.color)
when (cachedCollection.meta.collectionType) {
Constants.ETEBASE_TYPE_CALENDAR -> {
title.setHint(R.string.create_calendar_display_name_hint)
val color = LocalCalendar.parseColor(meta.color)
colorSquare.setBackgroundColor(color)
colorSquare.setOnClickListener {
AmbilWarnaDialog(context, (colorSquare.background as ColorDrawable).color, true, object : AmbilWarnaDialog.OnAmbilWarnaListener {
override fun onCancel(dialog: AmbilWarnaDialog) {}
override fun onOk(dialog: AmbilWarnaDialog, color: Int) {
colorSquare.setBackgroundColor(color)
}
}).show()
}
}
Constants.ETEBASE_TYPE_TASKS -> {
title.setHint(R.string.create_tasklist_display_name_hint)
val color = LocalCalendar.parseColor(meta.color)
colorSquare.setBackgroundColor(color)
colorSquare.setOnClickListener {
AmbilWarnaDialog(context, (colorSquare.background as ColorDrawable).color, true, object : AmbilWarnaDialog.OnAmbilWarnaListener {
override fun onCancel(dialog: AmbilWarnaDialog) {}
override fun onOk(dialog: AmbilWarnaDialog, color: Int) {
colorSquare.setBackgroundColor(color)
}
}).show()
}
}
Constants.ETEBASE_TYPE_ADDRESS_BOOK -> {
title.setHint(R.string.create_addressbook_display_name_hint)
val colorGroup = v.findViewById<View>(R.id.color_group)
colorGroup.visibility = View.GONE
}
}
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
super.onCreateOptionsMenu(menu, inflater)
inflater.inflate(R.menu.fragment_edit_collection, menu)
if (isCreating) {
menu.findItem(R.id.on_delete).setVisible(false)
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.on_delete -> {
deleteColection()
}
R.id.on_save -> {
saveCollection()
}
}
return super.onOptionsItemSelected(item)
}
private fun deleteColection() {
val meta = cachedCollection.meta
val name = meta.name
AlertDialog.Builder(requireContext())
.setTitle(R.string.delete_collection_confirm_title)
.setMessage(getString(R.string.delete_collection_confirm_warning, name))
.setPositiveButton(android.R.string.yes) { dialog, _ ->
doDeleteCollection()
dialog.dismiss()
}
.setNegativeButton(android.R.string.no) { _, _ -> }
.show()
}
private fun doDeleteCollection() {
loadingModel.setLoading(true)
doAsync {
try {
val col = cachedCollection.col
col.delete()
uploadCollection(col)
val applicationContext = activity?.applicationContext
if (applicationContext != null) {
requestSync(applicationContext, model.value!!.account)
}
activity?.finish()
} catch (e: EtebaseException) {
uiThread {
AlertDialog.Builder(requireContext())
.setIcon(R.drawable.ic_info_dark)
.setTitle(R.string.exception)
.setMessage(e.localizedMessage)
.setPositiveButton(android.R.string.yes) { _, _ -> }.show()
}
} finally {
uiThread {
loadingModel.setLoading(false)
}
}
}
}
private fun saveCollection() {
var ok = true
val meta = cachedCollection.meta
val v = requireView()
var edit = v.findViewById<EditText>(R.id.display_name)
meta.name = edit.text.toString()
if (TextUtils.isEmpty(meta.name)) {
edit.error = getString(R.string.create_collection_display_name_required)
ok = false
}
edit = v.findViewById<EditText>(R.id.description)
meta.description = StringUtils.trimToNull(edit.text.toString())
if (ok) {
when (meta.collectionType) {
Constants.ETEBASE_TYPE_CALENDAR, Constants.ETEBASE_TYPE_TASKS -> {
val view = v.findViewById<View>(R.id.color)
val color = (view.background as ColorDrawable).color
meta.color = String.format("#%06X", 0xFFFFFF and color)
}
Constants.ETEBASE_TYPE_ADDRESS_BOOK -> {
}
}
loadingModel.setLoading(true)
doAsync {
try {
val col = cachedCollection.col
col.meta = meta
uploadCollection(col)
val applicationContext = activity?.applicationContext
if (applicationContext != null) {
requestSync(applicationContext, model.value!!.account)
}
if (isCreating) {
// Load the items since we just created it
itemsModel.loadItems(model.value!!, cachedCollection)
parentFragmentManager.commit {
replace(R.id.fragment_container, ViewCollectionFragment())
}
} else {
parentFragmentManager.popBackStack()
}
} catch (e: EtebaseException) {
uiThread {
AlertDialog.Builder(requireContext())
.setIcon(R.drawable.ic_info_dark)
.setTitle(R.string.exception)
.setMessage(e.localizedMessage)
.setPositiveButton(android.R.string.yes) { _, _ -> }.show()
}
} finally {
uiThread {
loadingModel.setLoading(false)
}
}
}
}
}
private fun uploadCollection(col: Collection) {
val accountHolder = model.value!!
val etebaseLocalCache = accountHolder.etebaseLocalCache
val colMgr = accountHolder.colMgr
colMgr.upload(col)
synchronized(etebaseLocalCache) {
etebaseLocalCache.collectionSet(colMgr, col)
}
collectionModel.loadCollection(model.value!!, col.uid)
}
}

@ -0,0 +1,79 @@
package com.etesync.syncadapter.ui.etebase
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.commit
import com.etesync.syncadapter.CachedCollection
import com.etesync.syncadapter.Constants
import com.etesync.syncadapter.R
import com.etesync.syncadapter.ui.BaseActivity
import com.etesync.syncadapter.ui.importlocal.ImportFragment
import com.etesync.syncadapter.ui.importlocal.LocalCalendarImportFragment
import com.etesync.syncadapter.ui.importlocal.LocalContactImportFragment
class ImportCollectionFragment : Fragment() {
private val model: AccountViewModel by activityViewModels()
private val collectionModel: CollectionViewModel by activityViewModels()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val ret = inflater.inflate(R.layout.import_actions_list, container, false)
setHasOptionsMenu(true)
if (savedInstanceState == null) {
collectionModel.observe(this) {
(activity as? BaseActivity?)?.supportActionBar?.setTitle(R.string.import_dialog_title)
if (container != null) {
initUi(inflater, ret, it)
}
}
}
return ret
}
private fun initUi(inflater: LayoutInflater, v: View, cachedCollection: CachedCollection) {
val accountHolder = model.value!!
var card = v.findViewById<View>(R.id.import_file)
var img = card.findViewById<View>(R.id.action_icon) as ImageView
var text = card.findViewById<View>(R.id.action_text) as TextView
img.setImageResource(R.drawable.ic_file_white)
text.setText(R.string.import_button_file)
card.setOnClickListener {
parentFragmentManager.commit {
add(ImportFragment.newInstance(accountHolder.account, cachedCollection), null)
}
}
card = v.findViewById(R.id.import_account)
img = card.findViewById<View>(R.id.action_icon) as ImageView
text = card.findViewById<View>(R.id.action_text) as TextView
img.setImageResource(R.drawable.ic_account_circle_white)
text.setText(R.string.import_button_local)
card.setOnClickListener {
if (cachedCollection.meta.collectionType == Constants.ETEBASE_TYPE_CALENDAR) {
parentFragmentManager.commit {
replace(R.id.fragment_container, LocalCalendarImportFragment(accountHolder.account, cachedCollection.col.uid))
addToBackStack(null)
}
} else if (cachedCollection.meta.collectionType == Constants.ETEBASE_TYPE_ADDRESS_BOOK) {
parentFragmentManager.commit {
replace(R.id.fragment_container, LocalContactImportFragment(accountHolder.account, cachedCollection.col.uid))
addToBackStack(null)
}
}
// FIXME: should be in the fragments once we kill legacy
(activity as? BaseActivity?)?.supportActionBar?.setTitle(R.string.import_select_account)
}
if (collectionModel.value!!.meta.collectionType == Constants.ETEBASE_TYPE_TASKS) {
card.visibility = View.GONE
}
}
}

@ -0,0 +1,43 @@
package com.etesync.syncadapter.ui.etebase
import android.accounts.Account
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.viewModels
import androidx.fragment.app.commit
import com.etesync.syncadapter.R
import com.etesync.syncadapter.ui.BaseActivity
class InvitationsActivity : BaseActivity() {
private lateinit var account: Account
private val model: AccountViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
account = intent.extras!!.getParcelable(EXTRA_ACCOUNT)!!
setContentView(R.layout.etebase_collection_activity)
if (savedInstanceState == null) {
model.loadAccount(this, account)
title = getString(R.string.invitations_title)
supportFragmentManager.commit {
replace(R.id.fragment_container, InvitationsListFragment())
}
}
supportActionBar?.setDisplayHomeAsUpEnabled(true)
}
companion object {
private val EXTRA_ACCOUNT = "account"
fun newIntent(context: Context, account: Account): Intent {
val intent = Intent(context, InvitationsActivity::class.java)
intent.putExtra(EXTRA_ACCOUNT, account)
return intent
}
}
}

@ -0,0 +1,172 @@
package com.etesync.syncadapter.ui.etebase
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.*
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.ListFragment
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.observe
import com.etebase.client.CollectionAccessLevel
import com.etebase.client.FetchOptions
import com.etebase.client.SignedInvitation
import com.etebase.client.Utils
import com.etesync.syncadapter.R
import org.jetbrains.anko.doAsync
import org.jetbrains.anko.uiThread
import java.util.*
import java.util.concurrent.Future
class InvitationsListFragment : ListFragment(), AdapterView.OnItemClickListener {
private val model: AccountViewModel by activityViewModels()
private val invitationsModel: InvitationsViewModel by viewModels()
private var emptyTextView: TextView? = null
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val view = inflater.inflate(R.layout.invitations_list, container, false)
//This is instead of setEmptyText() function because of Google bug
//See: https://code.google.com/p/android/issues/detail?id=21742
emptyTextView = view.findViewById<TextView>(android.R.id.empty)
return view
}
private fun setListAdapterInvitations(invitations: List<SignedInvitation>) {
val context = context
if (context != null) {
val listAdapter = InvitationsListAdapter(context)
setListAdapter(listAdapter)
listAdapter.addAll(invitations)
emptyTextView!!.setText(R.string.invitations_list_empty)
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
model.observe(this) {
invitationsModel.loadInvitations(it)
}
invitationsModel.observe(this) {
setListAdapterInvitations(it)
}
listView.onItemClickListener = this
}
override fun onDestroyView() {
super.onDestroyView()
invitationsModel.cancelLoad()
}
override fun onItemClick(parent: AdapterView<*>, view_: View, position: Int, id: Long) {
val invitation = listAdapter?.getItem(position) as SignedInvitation
val fingerprint = Utils.prettyFingerprint(invitation.fromPubkey)
val view = layoutInflater.inflate(R.layout.invitation_alert_dialog, null)
view.findViewById<TextView>(R.id.body).text = getString(R.string.invitations_accept_reject_dialog)
view.findViewById<TextView>(R.id.fingerprint).text = fingerprint
AlertDialog.Builder(requireContext())
.setTitle(R.string.invitations_title)
.setIcon(R.drawable.ic_email_black)
.setView(view)
.setNegativeButton(R.string.invitations_reject) { dialogInterface, i ->
invitationsModel.reject(model.value!!, invitation)
}
.setPositiveButton(R.string.invitations_accept) { dialogInterface, i ->
invitationsModel.accept(model.value!!, invitation)
}
.show()
return
}
internal inner class InvitationsListAdapter(context: Context) : ArrayAdapter<SignedInvitation>(context, R.layout.invitations_list_item) {
override fun getView(position: Int, _v: View?, parent: ViewGroup): View {
var v = _v
if (v == null)
v = LayoutInflater.from(context).inflate(R.layout.invitations_list_item, parent, false)
val invitation = getItem(position)!!
val tv = v!!.findViewById<View>(R.id.title) as TextView
// FIXME: Should have a sensible string here
tv.text = "Invitation ${position}"
// FIXME: Also mark admins
val readOnly = v.findViewById<View>(R.id.read_only)
readOnly.visibility = if (invitation.accessLevel == CollectionAccessLevel.ReadOnly) View.VISIBLE else View.GONE
return v
}
}
}
class InvitationsViewModel : ViewModel() {
private val invitations = MutableLiveData<List<SignedInvitation>>()
private var asyncTask: Future<Unit>? = null
fun loadInvitations(accountCollectionHolder: AccountHolder) {
asyncTask = doAsync {
val ret = LinkedList<SignedInvitation>()
val invitationManager = accountCollectionHolder.etebase.invitationManager
var iterator: String? = null
var done = false
while (!done) {
val chunk = invitationManager.listIncoming(FetchOptions().iterator(iterator).limit(30))
iterator = chunk.stoken
done = chunk.isDone
ret.addAll(chunk.data)
}
uiThread {
invitations.value = ret
}
}
}
fun accept(accountCollectionHolder: AccountHolder, invitation: SignedInvitation) {
doAsync {
val invitationManager = accountCollectionHolder.etebase.invitationManager
invitationManager.accept(invitation)
val ret = invitations.value!!.filter { it != invitation }
uiThread {
invitations.value = ret
}
}
}
fun reject(accountCollectionHolder: AccountHolder, invitation: SignedInvitation) {
doAsync {
val invitationManager = accountCollectionHolder.etebase.invitationManager
invitationManager.reject(invitation)
val ret = invitations.value!!.filter { it != invitation }
uiThread {
invitations.value = ret
}
}
}
fun cancelLoad() {
asyncTask?.cancel(true)
}
fun observe(owner: LifecycleOwner, observer: (List<SignedInvitation>) -> Unit) =
invitations.observe(owner, observer)
}

@ -0,0 +1,147 @@
package com.etesync.syncadapter.ui.etebase
import android.content.Context
import android.os.Bundle
import android.os.Parcelable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.TextView
import androidx.fragment.app.ListFragment
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.commit
import androidx.fragment.app.viewModels
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.observe
import com.etebase.client.FetchOptions
import com.etesync.syncadapter.CachedCollection
import com.etesync.syncadapter.CachedItem
import com.etesync.syncadapter.R
import com.etesync.syncadapter.ui.etebase.ListEntriesFragment.Companion.setItemView
import org.jetbrains.anko.doAsync
import org.jetbrains.anko.uiThread
import java.util.*
import java.util.concurrent.Future
class ItemRevisionsListFragment(private val cachedCollection: CachedCollection, private val cachedItem: CachedItem) : ListFragment(), AdapterView.OnItemClickListener {
private val model: AccountViewModel by activityViewModels()
private val revisionsModel: RevisionsViewModel by viewModels()
private var state: Parcelable? = null
private var emptyTextView: TextView? = null
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val view = inflater.inflate(R.layout.journal_viewer_list, container, false)
//This is instead of setEmptyText() function because of Google bug
//See: https://code.google.com/p/android/issues/detail?id=21742
emptyTextView = view.findViewById<View>(android.R.id.empty) as TextView
return view
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
var restored = false
revisionsModel.loadRevisions(model.value!!, cachedCollection, cachedItem)
revisionsModel.observe(this) {
val entries = it.sortedByDescending { item ->
item.meta.mtime ?: 0
}
val listAdapter = EntriesListAdapter(requireContext(), cachedCollection)
setListAdapter(listAdapter)
listAdapter.addAll(entries)
if(!restored && (state != null)) {
listView.onRestoreInstanceState(state)
restored = true
}
emptyTextView!!.text = getString(R.string.journal_entries_list_empty)
}
listView.onItemClickListener = this
}
override fun onPause() {
state = listView.onSaveInstanceState()
super.onPause()
}
override fun onDestroyView() {
super.onDestroyView()
revisionsModel.cancelLoad()
}
override fun onItemClick(parent: AdapterView<*>, view: View, position: Int, id: Long) {
val item = listAdapter?.getItem(position) as CachedItem
activity?.supportFragmentManager?.commit {
replace(R.id.fragment_container, CollectionItemFragment(item))
addToBackStack(null)
}
}
internal inner class EntriesListAdapter(context: Context, val cachedCollection: CachedCollection) : ArrayAdapter<CachedItem>(context, R.layout.journal_viewer_list_item) {
override fun getView(position: Int, _v: View?, parent: ViewGroup): View {
var v = _v
if (v == null)
v = LayoutInflater.from(context).inflate(R.layout.journal_viewer_list_item, parent, false)!!
val item = getItem(position)
setItemView(v, cachedCollection.meta.collectionType, item)
/* FIXME: handle entry error:
val entryError = data.select(EntryErrorEntity::class.java).where(EntryErrorEntity.ENTRY.eq(entryEntity)).limit(1).get().firstOrNull()
if (entryError != null) {
val errorIcon = v.findViewById<View>(R.id.error) as ImageView
errorIcon.visibility = View.VISIBLE
}
*/
return v
}
}
}
class RevisionsViewModel : ViewModel() {
private val revisions = MutableLiveData<List<CachedItem>>()
private var asyncTask: Future<Unit>? = null
fun loadRevisions(accountCollectionHolder: AccountHolder, cachedCollection: CachedCollection, cachedItem: CachedItem) {
asyncTask = doAsync {
val ret = LinkedList<CachedItem>()
val col = cachedCollection.col
val itemManager = accountCollectionHolder.colMgr.getItemManager(col)
var iterator: String? = null
var done = false
while (!done) {
val chunk = itemManager.itemRevisions(cachedItem.item, FetchOptions().iterator(iterator).limit(30))
iterator = chunk.iterator
done = chunk.isDone
ret.addAll(chunk.data.map { CachedItem(it, it.meta, it.contentString) })
}
uiThread {
revisions.value = ret
}
}
}
fun cancelLoad() {
asyncTask?.cancel(true)
}
fun observe(owner: LifecycleOwner, observer: (List<CachedItem>) -> Unit) =
revisions.observe(owner, observer)
}

@ -0,0 +1,152 @@
package com.etesync.syncadapter.ui.etebase
import android.content.Context
import android.os.Bundle
import android.os.Parcelable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.ImageView
import android.widget.TextView
import androidx.fragment.app.ListFragment
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.commit
import com.etesync.syncadapter.CachedCollection
import com.etesync.syncadapter.CachedItem
import com.etesync.syncadapter.Constants
import com.etesync.syncadapter.R
import java.text.SimpleDateFormat
class ListEntriesFragment : ListFragment(), AdapterView.OnItemClickListener {
private val collectionModel: CollectionViewModel by activityViewModels()
private val itemsModel: ItemsViewModel by activityViewModels()
private var state: Parcelable? = null
private var emptyTextView: TextView? = null
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val view = inflater.inflate(R.layout.journal_viewer_list, container, false)
//This is instead of setEmptyText() function because of Google bug
//See: https://code.google.com/p/android/issues/detail?id=21742
emptyTextView = view.findViewById<View>(android.R.id.empty) as TextView
return view
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
var restored = false
collectionModel.observe(this) { col ->
itemsModel.observe(this) {
val entries = it.sortedByDescending { item ->
item.meta.mtime ?: 0
}
val listAdapter = EntriesListAdapter(requireContext(), col)
setListAdapter(listAdapter)
listAdapter.addAll(entries)
if(!restored && (state != null)) {
listView.onRestoreInstanceState(state)
restored = true
}
emptyTextView!!.text = getString(R.string.journal_entries_list_empty)
}
}
listView.onItemClickListener = this
}
override fun onPause() {
state = listView.onSaveInstanceState()
super.onPause()
}
override fun onItemClick(parent: AdapterView<*>, view: View, position: Int, id: Long) {
val item = listAdapter?.getItem(position) as CachedItem
activity?.supportFragmentManager?.commit {
replace(R.id.fragment_container, CollectionItemFragment(item))
addToBackStack(EditCollectionFragment::class.java.name)
}
}
internal inner class EntriesListAdapter(context: Context, val cachedCollection: CachedCollection) : ArrayAdapter<CachedItem>(context, R.layout.journal_viewer_list_item) {
override fun getView(position: Int, _v: View?, parent: ViewGroup): View {
var v = _v
if (v == null)
v = LayoutInflater.from(context).inflate(R.layout.journal_viewer_list_item, parent, false)!!
val item = getItem(position)
setItemView(v, cachedCollection.meta.collectionType, item)
/* FIXME: handle entry error:
val entryError = data.select(EntryErrorEntity::class.java).where(EntryErrorEntity.ENTRY.eq(entryEntity)).limit(1).get().firstOrNull()
if (entryError != null) {
val errorIcon = v.findViewById<View>(R.id.error) as ImageView
errorIcon.visibility = View.VISIBLE
}
*/
return v
}
}
companion object {
private val dateFormatter = SimpleDateFormat()
private fun getLine(content: String?, prefix: String): String? {
var content: String? = content ?: return null
val start = content!!.indexOf(prefix)
if (start >= 0) {
val end = content.indexOf("\n", start)
content = content.substring(start + prefix.length, end)
} else {
content = null
}
return content
}
fun setItemView(v: View, collectionType: String, item: CachedItem) {
var tv = v.findViewById<View>(R.id.title) as TextView
// FIXME: hacky way to make it show sensible info
val prefix: String = when (collectionType) {
Constants.ETEBASE_TYPE_CALENDAR, Constants.ETEBASE_TYPE_TASKS -> {
"SUMMARY:"
}
Constants.ETEBASE_TYPE_ADDRESS_BOOK -> {
"FN:"
}
else -> {
""
}
}
val fullContent = item.content
var content = getLine(fullContent, prefix)
content = content ?: "Not found"
tv.text = content
tv = v.findViewById<View>(R.id.description) as TextView
// FIXME: Don't use a hard-coded string
content = "Modified: ${dateFormatter.format(item.meta.mtime ?: 0)}"
tv.text = content
val action = v.findViewById<View>(R.id.action) as ImageView
if (item.item.isDeleted) {
action.setImageResource(R.drawable.action_delete)
} else {
action.setImageResource(R.drawable.action_change)
}
}
}
}

@ -0,0 +1,226 @@
/*
* Copyright © 2013 2016 Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package com.etesync.syncadapter.ui.etebase
import android.app.Dialog
import android.app.ProgressDialog
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.CheckedTextView
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.fragment.app.commit
import androidx.fragment.app.viewModels
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.observe
import com.etebase.client.Account
import com.etebase.client.Client
import com.etebase.client.User
import com.etebase.client.exceptions.EtebaseException
import com.etesync.syncadapter.Constants
import com.etesync.syncadapter.HttpClient
import com.etesync.syncadapter.R
import com.etesync.syncadapter.ui.setup.BaseConfigurationFinder
import com.etesync.syncadapter.ui.setup.CreateAccountFragment
import com.etesync.syncadapter.ui.setup.DetectConfigurationFragment
import com.etesync.syncadapter.ui.setup.LoginCredentialsFragment
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import net.cachapa.expandablelayout.ExpandableLayout
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.jetbrains.anko.doAsync
import org.jetbrains.anko.uiThread
import java.net.URI
import java.util.concurrent.Future
class SignupFragment(private val initialUsername: String?, private val initialPassword: String?) : Fragment() {
internal lateinit var editUserName: TextInputLayout
internal lateinit var editEmail: TextInputLayout
internal lateinit var editPassword: TextInputLayout
internal lateinit var showAdvanced: CheckedTextView
internal lateinit var customServer: TextInputEditText
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val v = inflater.inflate(R.layout.signup_fragment, container, false)
editUserName = v.findViewById(R.id.user_name)
editEmail = v.findViewById(R.id.email)
editPassword = v.findViewById(R.id.url_password)
showAdvanced = v.findViewById(R.id.show_advanced)
customServer = v.findViewById(R.id.custom_server)
if (savedInstanceState == null) {
editUserName.editText?.setText(initialUsername ?: "")
editPassword.editText?.setText(initialPassword ?: "")
}
val login = v.findViewById<Button>(R.id.login)
login.setOnClickListener {
parentFragmentManager.commit {
replace(android.R.id.content, LoginCredentialsFragment(editUserName.editText?.text.toString(), editPassword.editText?.text.toString()))
}
}
val createAccount = v.findViewById<Button>(R.id.create_account)
createAccount.setOnClickListener {
val credentials = validateData()
if (credentials != null) {
SignupDoFragment(credentials).show(fragmentManager!!, null)
}
}
val advancedLayout = v.findViewById<View>(R.id.advanced_layout) as ExpandableLayout
showAdvanced.setOnClickListener {
if (showAdvanced.isChecked) {
showAdvanced.isChecked = false
advancedLayout.collapse()
} else {
showAdvanced.isChecked = true
advancedLayout.expand()
}
}
return v
}
protected fun validateData(): SignupCredentials? {
var valid = true
val userName = editUserName.editText?.text.toString()
if (userName.isEmpty()) {
editUserName.error = getString(R.string.login_username_error)
valid = false
} else {
editUserName.error = null
}
val email = editEmail.editText?.text.toString()
if (email.isEmpty()) {
editEmail.error = getString(R.string.login_email_address_error)
valid = false
} else {
editEmail.error = null
}
val password = editPassword.editText?.text.toString()
if (password.isEmpty()) {
editPassword.error = getString(R.string.signup_password_restrictions)
valid = false
} else {
editPassword.error = null
}
var uri: URI? = null
if (showAdvanced.isChecked) {
val server = customServer.text.toString()
// If this field is null, just use the default
if (!server.isEmpty()) {
val url = server.toHttpUrlOrNull()
if (url != null) {
uri = url.toUri()
customServer.error = null
} else {
customServer.error = getString(R.string.login_custom_server_error)
valid = false
}
}
}
return if (valid) SignupCredentials(uri, userName, email, password) else null
}
}
class SignupDoFragment(private val signupCredentials: SignupCredentials) : DialogFragment() {
private val model: ConfigurationViewModel by viewModels()
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val progress = ProgressDialog(activity)
progress.setTitle(R.string.setting_up_encryption)
progress.setMessage(getString(R.string.setting_up_encryption_content))
progress.isIndeterminate = true
progress.setCanceledOnTouchOutside(false)
isCancelable = false
return progress
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (savedInstanceState == null) {
model.signup(requireContext(), signupCredentials)
model.observe(this) {
if (it.isFailed) {
// no service found: show error message
requireFragmentManager().beginTransaction()
.add(DetectConfigurationFragment.NothingDetectedFragment.newInstance(it.error!!.localizedMessage), null)
.commitAllowingStateLoss()
} else {
requireFragmentManager().beginTransaction()
.replace(android.R.id.content, CreateAccountFragment.newInstance(it))
.addToBackStack(null)
.commitAllowingStateLoss()
}
dismissAllowingStateLoss()
}
}
}
}
class ConfigurationViewModel : ViewModel() {
private val account = MutableLiveData<BaseConfigurationFinder.Configuration>()
private var asyncTask: Future<Unit>? = null
fun signup(context: Context, credentials: SignupCredentials) {
asyncTask = doAsync {
val httpClient = HttpClient.Builder(context).build().okHttpClient
val uri = credentials.uri ?: URI(Constants.etebaseServiceUrl)
var etebaseSession: String? = null
var exception: Throwable? = null
try {
val client = Client.create(httpClient, uri.toString())
val user = User(credentials.userName, credentials.email)
val etebase = Account.signup(client, user, credentials.password)
etebaseSession = etebase.save(null)
} catch (e: EtebaseException) {
exception = e
}
uiThread {
account.value = BaseConfigurationFinder.Configuration(
uri,
credentials.userName,
etebaseSession,
null,
null,
exception
)
}
}
}
fun cancelLoad() {
asyncTask?.cancel(true)
}
fun observe(owner: LifecycleOwner, observer: (BaseConfigurationFinder.Configuration) -> Unit) =
account.observe(owner, observer)
}
data class SignupCredentials(val uri: URI?, val userName: String, val email: String, val password: String)

@ -0,0 +1,157 @@
package com.etesync.syncadapter.ui.etebase
import android.content.DialogInterface
import android.os.Bundle
import android.view.*
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.commit
import com.etebase.client.CollectionAccessLevel
import com.etesync.syncadapter.CachedCollection
import com.etesync.syncadapter.Constants
import com.etesync.syncadapter.R
import com.etesync.syncadapter.resource.LocalCalendar
import com.etesync.syncadapter.ui.BaseActivity
import com.etesync.syncadapter.ui.WebViewActivity
import com.etesync.syncadapter.utils.HintManager
import com.etesync.syncadapter.utils.ShowcaseBuilder
import com.google.android.material.floatingactionbutton.FloatingActionButton
import tourguide.tourguide.ToolTip
import java.util.*
class ViewCollectionFragment : Fragment() {
private val collectionModel: CollectionViewModel by activityViewModels()
private val itemsModel: ItemsViewModel by activityViewModels()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val ret = inflater.inflate(R.layout.view_collection_fragment, container, false)
setHasOptionsMenu(true)
if (savedInstanceState == null) {
collectionModel.observe(this) {
(activity as? BaseActivity?)?.supportActionBar?.title = it.meta.name
if (container != null) {
initUi(inflater, ret, it)
}
}
}
return ret
}
private fun initUi(inflater: LayoutInflater, container: View, cachedCollection: CachedCollection) {
val title = container.findViewById<TextView>(R.id.display_name)
if (!HintManager.getHintSeen(requireContext(), HINT_IMPORT)) {
val tourGuide = ShowcaseBuilder.getBuilder(requireActivity())
.setToolTip(ToolTip().setTitle(getString(R.string.tourguide_title)).setDescription(getString(R.string.account_showcase_import)).setGravity(Gravity.BOTTOM))
.setPointer(null)
tourGuide.mOverlay.setHoleRadius(0)
tourGuide.playOn(title)
HintManager.setHintSeen(requireContext(), HINT_IMPORT, true)
}
val fab = container.findViewById<FloatingActionButton>(R.id.fab)
fab?.setOnClickListener {
AlertDialog.Builder(requireContext())
.setIcon(R.drawable.ic_info_dark)
.setTitle(R.string.use_native_apps_title)
.setMessage(R.string.use_native_apps_body)
.setNegativeButton(R.string.navigation_drawer_guide, { _: DialogInterface, _: Int -> WebViewActivity.openUrl(requireContext(), Constants.helpUri) })
.setPositiveButton(android.R.string.yes) { _, _ -> }.show()
}
val col = cachedCollection.col
val meta = cachedCollection.meta
val isAdmin = col.accessLevel == CollectionAccessLevel.Admin
val colorSquare = container.findViewById<View>(R.id.color)
val color = LocalCalendar.parseColor(meta.color)
when (meta.collectionType) {
Constants.ETEBASE_TYPE_CALENDAR -> {
colorSquare.setBackgroundColor(color)
}
Constants.ETEBASE_TYPE_TASKS -> {
colorSquare.setBackgroundColor(color)
val tasksNotShowing = container.findViewById<View>(R.id.tasks_not_showing)
tasksNotShowing.visibility = View.VISIBLE
}
Constants.ETEBASE_TYPE_ADDRESS_BOOK -> {
colorSquare.visibility = View.GONE
}
}
title.text = meta.name
val desc = container.findViewById<TextView>(R.id.description)
desc.text = meta.description
val owner = container.findViewById<TextView>(R.id.owner)
if (isAdmin) {
owner.visibility = View.GONE
} else {
owner.visibility = View.VISIBLE
owner.text = "Shared with us" // FIXME: Figure out how to represent it and don't use a hardcoded string
}
itemsModel.observe(this) {
val stats = container.findViewById<TextView>(R.id.stats)
container.findViewById<View>(R.id.progressBar).visibility = View.GONE
stats.text = String.format(Locale.getDefault(), "Change log items: %d", it.size)
}
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
super.onCreateOptionsMenu(menu, inflater)
inflater.inflate(R.menu.fragment_view_collection, menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
val cachedCollection = collectionModel.value!!
when (item.itemId) {
R.id.on_edit -> {
if (cachedCollection.col.accessLevel == CollectionAccessLevel.Admin) {
parentFragmentManager.commit {
replace(R.id.fragment_container, EditCollectionFragment(cachedCollection))
addToBackStack(EditCollectionFragment::class.java.name)
}
} else {
val dialog = AlertDialog.Builder(requireContext())
.setIcon(R.drawable.ic_info_dark)
.setTitle(R.string.not_allowed_title)
.setMessage(R.string.edit_owner_only_anon)
.setPositiveButton(android.R.string.yes) { _, _ -> }.create()
dialog.show()
}
}
R.id.on_manage_members -> {
parentFragmentManager.commit {
replace(R.id.fragment_container, CollectionMembersFragment())
addToBackStack(null)
}
}
R.id.on_import -> {
if (cachedCollection.col.accessLevel != CollectionAccessLevel.ReadOnly) {
parentFragmentManager.commit {
replace(R.id.fragment_container, ImportCollectionFragment())
addToBackStack(null)
}
} else {
val dialog = AlertDialog.Builder(requireContext())
.setIcon(R.drawable.ic_info_dark)
.setTitle(R.string.not_allowed_title)
.setMessage(R.string.edit_owner_only_anon)
.setPositiveButton(android.R.string.yes) { _, _ -> }.create()
dialog.show()
}
}
}
return super.onOptionsItemSelected(item)
}
companion object {
private val HINT_IMPORT = "Import"
}
}

@ -14,7 +14,7 @@ import com.etesync.syncadapter.R
import com.etesync.syncadapter.model.CollectionInfo
import com.etesync.syncadapter.ui.BaseActivity
class ImportActivity : BaseActivity(), SelectImportMethod, ResultFragment.OnImportCallback, DialogInterface {
class ImportActivity : BaseActivity(), SelectImportMethod, DialogInterface {
private lateinit var account: Account
protected lateinit var info: CollectionInfo
@ -83,13 +83,6 @@ class ImportActivity : BaseActivity(), SelectImportMethod, ResultFragment.OnImpo
return super.onKeyDown(keyCode, event)
}
override fun onImportResult(importResult: ResultFragment.ImportResult) {
val fragment = ResultFragment.newInstance(importResult)
supportFragmentManager.beginTransaction()
.add(fragment, "importResult")
.commitAllowingStateLoss()
}
override fun cancel() {
finish()
}
@ -108,9 +101,9 @@ class ImportActivity : BaseActivity(), SelectImportMethod, ResultFragment.OnImpo
// This makes sure that the container activity has implemented
// the callback interface. If not, it throws an exception
try {
mSelectImportMethod = activity as SelectImportMethod?
mSelectImportMethod = activity as SelectImportMethod
} catch (e: ClassCastException) {
throw ClassCastException(activity!!.toString() + " must implement MyInterface ")
throw ClassCastException(activity.toString() + " must implement MyInterface ")
}
}

@ -14,13 +14,13 @@ import android.os.Bundle
import android.provider.CalendarContract
import android.provider.ContactsContract
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.commit
import at.bitfire.ical4android.*
import at.bitfire.ical4android.TaskProvider.Companion.OPENTASK_PROVIDERS
import at.bitfire.vcard4android.BatchOperation
import at.bitfire.vcard4android.Contact
import at.bitfire.vcard4android.ContactsStorageException
import com.etesync.syncadapter.Constants.KEY_ACCOUNT
import com.etesync.syncadapter.Constants.KEY_COLLECTION_INFO
import com.etesync.syncadapter.CachedCollection
import com.etesync.syncadapter.Constants.*
import com.etesync.syncadapter.R
import com.etesync.syncadapter.log.Logger
import com.etesync.syncadapter.model.CollectionInfo
@ -35,19 +35,14 @@ import java.io.InputStream
import java.io.InputStreamReader
class ImportFragment : DialogFragment() {
class ImportFragment(private val account: Account, private val uid: String, private val enumType: CollectionInfo.Type) : DialogFragment() {
private lateinit var account: Account
private lateinit var info: CollectionInfo
private var inputStream: InputStream? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
isCancelable = false
retainInstance = true
account = arguments!!.getParcelable(KEY_ACCOUNT)!!
info = arguments!!.getSerializable(KEY_COLLECTION_INFO) as CollectionInfo
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
@ -57,7 +52,7 @@ class ImportFragment : DialogFragment() {
} else {
val data = ImportResult()
data.e = Exception(getString(R.string.import_permission_required))
(activity as ResultFragment.OnImportCallback).onImportResult(data)
onImportResult(data)
dismissAllowingStateLoss()
}
@ -118,7 +113,7 @@ class ImportFragment : DialogFragment() {
intent.addCategory(Intent.CATEGORY_OPENABLE)
intent.action = Intent.ACTION_GET_CONTENT
when (info.enumType) {
when (enumType) {
CollectionInfo.Type.CALENDAR -> intent.type = "text/calendar"
CollectionInfo.Type.TASKS -> intent.type = "text/calendar"
CollectionInfo.Type.ADDRESS_BOOK -> intent.type = "text/x-vcard"
@ -132,7 +127,7 @@ class ImportFragment : DialogFragment() {
val data = ImportResult()
data.e = Exception("Failed to open file chooser.\nPlease install one.")
(activity as ResultFragment.OnImportCallback).onImportResult(data)
onImportResult(data)
dismissAllowingStateLoss()
}
@ -146,7 +141,7 @@ class ImportFragment : DialogFragment() {
if (data != null) {
// Get the URI of the selected file
val uri = data.data!!
Logger.log.info("Starting import into ${info.uid} from file ${uri}")
Logger.log.info("Starting import into ${uid} from file ${uri}")
try {
inputStream = activity!!.contentResolver.openInputStream(uri)
@ -157,7 +152,7 @@ class ImportFragment : DialogFragment() {
val importResult = ImportResult()
importResult.e = e
(activity as ResultFragment.OnImportCallback).onImportResult(importResult)
onImportResult(importResult)
dismissAllowingStateLoss()
}
@ -172,7 +167,7 @@ class ImportFragment : DialogFragment() {
}
fun loadFinished(data: ImportResult) {
(activity as ResultFragment.OnImportCallback).onImportResult(data)
onImportResult(data)
Logger.log.info("Finished import")
@ -217,7 +212,7 @@ class ImportFragment : DialogFragment() {
val context = context!!
val importReader = InputStreamReader(inputStream)
if (info.enumType == CollectionInfo.Type.CALENDAR) {
if (enumType == CollectionInfo.Type.CALENDAR) {
val events = Event.eventsFromReader(importReader, null)
importReader.close()
@ -239,7 +234,7 @@ class ImportFragment : DialogFragment() {
val localCalendar: LocalCalendar?
try {
localCalendar = LocalCalendar.findByName(account, provider, LocalCalendar.Factory, info.uid!!)
localCalendar = LocalCalendar.findByName(account, provider, LocalCalendar.Factory, uid!!)
if (localCalendar == null) {
throw FileNotFoundException("Failed to load local resource.")
}
@ -270,7 +265,7 @@ class ImportFragment : DialogFragment() {
entryProcessed()
}
} else if (info.enumType == CollectionInfo.Type.TASKS) {
} else if (enumType == CollectionInfo.Type.TASKS) {
val tasks = Task.tasksFromReader(importReader)
importReader.close()
@ -297,7 +292,7 @@ class ImportFragment : DialogFragment() {
provider?.let {
val localTaskList: LocalTaskList?
try {
localTaskList = LocalTaskList.findByName(account, it, LocalTaskList.Factory, info.uid!!)
localTaskList = LocalTaskList.findByName(account, it, LocalTaskList.Factory, uid!!)
if (localTaskList == null) {
throw FileNotFoundException("Failed to load local resource.")
}
@ -325,7 +320,7 @@ class ImportFragment : DialogFragment() {
entryProcessed()
}
}
} else if (info.enumType == CollectionInfo.Type.ADDRESS_BOOK) {
} else if (enumType == CollectionInfo.Type.ADDRESS_BOOK) {
val uidToLocalId = HashMap<String?, Long>()
val downloader = ContactsSyncManager.ResourceDownloader(context)
val contacts = Contact.fromReader(importReader, downloader)
@ -346,7 +341,7 @@ class ImportFragment : DialogFragment() {
return result
}
val localAddressBook = LocalAddressBook.findByUid(context, provider, account, info.uid!!)
val localAddressBook = LocalAddressBook.findByUid(context, provider, account, uid!!)
if (localAddressBook == null) {
throw FileNotFoundException("Failed to load local address book.")
}
@ -424,18 +419,30 @@ class ImportFragment : DialogFragment() {
}
}
fun onImportResult(importResult: ImportResult) {
val fragment = ResultFragment.newInstance(importResult)
parentFragmentManager.commit(true) {
add(fragment, "importResult")
}
}
companion object {
private val REQUEST_CODE = 6384 // onActivityResult request
private val TAG_PROGRESS_MAX = "progressMax"
fun newInstance(account: Account, info: CollectionInfo): ImportFragment {
val frag = ImportFragment()
val args = Bundle(1)
args.putParcelable(KEY_ACCOUNT, account)
args.putSerializable(KEY_COLLECTION_INFO, info)
frag.arguments = args
return frag
return ImportFragment(account, info.uid!!, info.enumType!!)
}
fun newInstance(account: Account, cachedCollection: CachedCollection): ImportFragment {
val enumType = when (cachedCollection.meta.collectionType) {
ETEBASE_TYPE_CALENDAR -> CollectionInfo.Type.CALENDAR
ETEBASE_TYPE_TASKS -> CollectionInfo.Type.TASKS
ETEBASE_TYPE_ADDRESS_BOOK -> CollectionInfo.Type.ADDRESS_BOOK
else -> throw Exception("Got unsupported collection type")
}
return ImportFragment(account, cachedCollection.col.uid, enumType)
}
}
}

@ -14,9 +14,8 @@ import android.widget.ExpandableListView
import android.widget.ImageView
import android.widget.TextView
import androidx.fragment.app.ListFragment
import androidx.fragment.app.commit
import at.bitfire.ical4android.CalendarStorageException
import com.etesync.syncadapter.Constants.KEY_ACCOUNT
import com.etesync.syncadapter.Constants.KEY_COLLECTION_INFO
import com.etesync.syncadapter.R
import com.etesync.syncadapter.log.Logger
import com.etesync.syncadapter.model.CollectionInfo
@ -24,17 +23,10 @@ import com.etesync.syncadapter.resource.LocalCalendar
import com.etesync.syncadapter.resource.LocalEvent
class LocalCalendarImportFragment : ListFragment() {
private lateinit var account: Account
private lateinit var info: CollectionInfo
class LocalCalendarImportFragment(private val account: Account, private val uid: String) : ListFragment() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
retainInstance = true
account = arguments!!.getParcelable(KEY_ACCOUNT)!!
info = arguments!!.getSerializable(KEY_COLLECTION_INFO) as CollectionInfo
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
@ -200,7 +192,7 @@ class LocalCalendarImportFragment : ListFragment() {
if (progressDialog.isShowing && !activity.isDestroyed) {
progressDialog.dismiss()
}
(activity as ResultFragment.OnImportCallback).onImportResult(result)
onImportResult(result)
}
private fun importEvents(fromCalendar: LocalCalendar): ResultFragment.ImportResult {
@ -208,7 +200,7 @@ class LocalCalendarImportFragment : ListFragment() {
try {
val localCalendar = LocalCalendar.findByName(account,
context!!.contentResolver.acquireContentProviderClient(CalendarContract.CONTENT_URI)!!,
LocalCalendar.Factory, info!!.uid!!)
LocalCalendar.Factory, uid)
val localEvents = fromCalendar.findAll()
val total = localEvents.size
progressDialog.max = total
@ -248,15 +240,17 @@ class LocalCalendarImportFragment : ListFragment() {
}
}
fun onImportResult(importResult: ResultFragment.ImportResult) {
val fragment = ResultFragment.newInstance(importResult)
parentFragmentManager.commit(true) {
add(fragment, "importResult")
}
}
companion object {
fun newInstance(account: Account, info: CollectionInfo): LocalCalendarImportFragment {
val frag = LocalCalendarImportFragment()
val args = Bundle(1)
args.putParcelable(KEY_ACCOUNT, account)
args.putSerializable(KEY_COLLECTION_INFO, info)
frag.arguments = args
return frag
return LocalCalendarImportFragment(account, info.uid!!)
}
}
}

@ -18,11 +18,10 @@ import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.fragment.app.Fragment
import androidx.fragment.app.commit
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import at.bitfire.vcard4android.ContactsStorageException
import com.etesync.syncadapter.Constants.KEY_ACCOUNT
import com.etesync.syncadapter.Constants.KEY_COLLECTION_INFO
import com.etesync.syncadapter.R
import com.etesync.syncadapter.log.Logger
import com.etesync.syncadapter.model.CollectionInfo
@ -32,18 +31,12 @@ import com.etesync.syncadapter.resource.LocalGroup
import java.util.*
class LocalContactImportFragment : Fragment() {
private lateinit var account: Account
private lateinit var info: CollectionInfo
class LocalContactImportFragment(private val account: Account, private val uid: String) : Fragment() {
private var recyclerView: RecyclerView? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
retainInstance = true
account = arguments!!.getParcelable(KEY_ACCOUNT)
info = arguments!!.getSerializable(KEY_COLLECTION_INFO) as CollectionInfo
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
@ -134,15 +127,16 @@ class LocalContactImportFragment : Fragment() {
if (progressDialog.isShowing && !activity.isDestroyed) {
progressDialog.dismiss()
}
(activity as ResultFragment.OnImportCallback).onImportResult(result)
onImportResult(result)
}
private fun importContacts(localAddressBook: LocalAddressBook): ResultFragment.ImportResult {
val result = ResultFragment.ImportResult()
try {
val addressBook = LocalAddressBook.findByUid(context!!,
context!!.contentResolver.acquireContentProviderClient(ContactsContract.RawContacts.CONTENT_URI)!!,
account, info.uid!!)!!
val addressBook = LocalAddressBook.findByUid(requireContext(),
requireContext().contentResolver.acquireContentProviderClient(ContactsContract.RawContacts.CONTENT_URI)!!,
account, uid)
?: throw Exception("Could not find address book")
val localContacts = localAddressBook.findAllContacts()
val localGroups = localAddressBook.findAllGroups()
val oldIdToNewId = HashMap<Long, Long>()
@ -214,6 +208,13 @@ class LocalContactImportFragment : Fragment() {
}
}
fun onImportResult(importResult: ResultFragment.ImportResult) {
val fragment = ResultFragment.newInstance(importResult)
parentFragmentManager.commit(true) {
add(fragment, "importResult")
}
}
class ImportContactAdapter
/**
* Initialize the dataset of the Adapter.
@ -316,13 +317,7 @@ class LocalContactImportFragment : Fragment() {
companion object {
fun newInstance(account: Account, info: CollectionInfo): LocalContactImportFragment {
val frag = LocalContactImportFragment()
val args = Bundle(1)
args.putParcelable(KEY_ACCOUNT, account)
args.putSerializable(KEY_COLLECTION_INFO, info)
frag.arguments = args
return frag
return LocalContactImportFragment(account, info.uid!!)
}
}
}

@ -19,7 +19,7 @@ class ResultFragment : DialogFragment() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
result = arguments!!.getSerializable(KEY_RESULT) as ImportResult
result = requireArguments().getSerializable(KEY_RESULT) as ImportResult
}
override fun onDismiss(dialog: DialogInterface) {
@ -32,7 +32,7 @@ class ResultFragment : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
if (result!!.isFailed) {
return AlertDialog.Builder(activity!!)
return AlertDialog.Builder(requireActivity())
.setTitle(R.string.import_dialog_failed_title)
.setIcon(R.drawable.ic_error_dark)
.setMessage(getString(R.string.import_dialog_failed_body, result!!.e!!.localizedMessage))
@ -72,10 +72,6 @@ class ResultFragment : DialogFragment() {
}
}
interface OnImportCallback {
fun onImportResult(importResult: ImportResult)
}
companion object {
private val KEY_RESULT = "result"

@ -88,7 +88,7 @@ class ListEntriesFragment : ListFragment(), AdapterView.OnItemClickListener {
override fun onItemClick(parent: AdapterView<*>, view: View, position: Int, id: Long) {
val entry = listAdapter?.getItem(position) as EntryEntity
startActivity(JournalItemActivity.newIntent(context!!, account, info, entry.content))
startActivity(JournalItemActivity.newIntent(requireContext(), account, info, entry.content))
}
internal inner class EntriesListAdapter(context: Context) : ArrayAdapter<EntryEntity>(context, R.layout.journal_viewer_list_item) {

@ -8,16 +8,21 @@
package com.etesync.syncadapter.ui.setup
import android.content.Context
import com.etebase.client.Account
import com.etebase.client.Client
import com.etebase.client.exceptions.EtebaseException
import com.etesync.syncadapter.HttpClient
import com.etesync.journalmanager.Crypto
import com.etesync.journalmanager.Exceptions
import com.etesync.journalmanager.JournalAuthenticator
import com.etesync.journalmanager.UserInfoManager
import com.etesync.syncadapter.Constants
import com.etesync.syncadapter.log.Logger
import com.etesync.syncadapter.model.CollectionInfo
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.IOException
import java.io.Serializable
import java.net.URI
@ -30,45 +35,102 @@ class BaseConfigurationFinder(protected val context: Context, protected val cred
httpClient = HttpClient.Builder(context).build().okHttpClient
}
private fun isServerEtebase(): Boolean {
if (credentials.uri != null) {
val remote = credentials.uri.toHttpUrlOrNull()!!.newBuilder()
.addPathSegments("api/v1/authentication/is_etebase/")
.build()
fun findInitialConfiguration(): Configuration {
val request = Request.Builder()
.get()
.url(remote)
.build()
val response = httpClient.newCall(request).execute()
return response.isSuccessful
} else {
return !credentials.userName.contains("@")
}
}
fun findInitialConfigurationLegacy(): Configuration {
var exception: Throwable? = null
val cardDavConfig = findInitialConfiguration(CollectionInfo.Type.ADDRESS_BOOK)
val calDavConfig = findInitialConfiguration(CollectionInfo.Type.CALENDAR)
val authenticator = JournalAuthenticator(httpClient, credentials.uri?.toHttpUrlOrNull()!!)
val uri = credentials.uri ?: URI(Constants.serviceUrl.toString())
val authenticator = JournalAuthenticator(httpClient, uri.toHttpUrlOrNull()!!)
var authtoken: String? = null
var userInfo: UserInfoManager.UserInfo? = null
try {
authtoken = authenticator.getAuthToken(credentials.userName, credentials.password)
val authenticatedHttpClient = HttpClient.Builder(context, credentials.uri.host, authtoken!!).build().okHttpClient
val userInfoManager = UserInfoManager(authenticatedHttpClient, credentials.uri.toHttpUrlOrNull()!!)
val authenticatedHttpClient = HttpClient.Builder(context, uri.host, authtoken!!).build().okHttpClient
val userInfoManager = UserInfoManager(authenticatedHttpClient, uri.toHttpUrlOrNull()!!)
userInfo = userInfoManager.fetch(credentials.userName)
} catch (e: Exceptions.HttpException) {
Logger.log.warning(e.message)
Logger.log.warning(e.localizedMessage)
exception = e
} catch (e: IOException) {
Logger.log.warning(e.message)
Logger.log.warning(e.localizedMessage)
exception = e
}
return Configuration(
credentials.uri,
credentials.userName, authtoken,
cardDavConfig, calDavConfig,
uri,
credentials.userName,
null,
authtoken,
userInfo,
exception
)
}
protected fun findInitialConfiguration(service: CollectionInfo.Type): Configuration.ServiceInfo {
// put discovered information here
val config = Configuration.ServiceInfo()
Logger.log.info("Finding initial " + service.toString() + " service configuration")
fun findInitialConfigurationEtebase(): Configuration {
var exception: Throwable? = null
val uri = credentials.uri ?: URI(Constants.etebaseServiceUrl)
return config
var etebaseSession: String? = null
try {
val client = Client.create(httpClient, uri.toString())
val etebase = Account.login(client, credentials.userName, credentials.password)
etebaseSession = etebase.save(null)
} catch (e: java.lang.Exception) {
Logger.log.warning(e.localizedMessage)
exception = e
}
return Configuration(
uri,
credentials.userName,
etebaseSession,
null,
null,
exception
)
}
fun findInitialConfiguration(): Configuration {
try {
if (isServerEtebase()) {
Logger.log.fine("Attempting to login to etebase")
return findInitialConfigurationEtebase()
} else {
Logger.log.fine("Attempting to login to EteSync legacy")
return findInitialConfigurationLegacy()
}
} catch (e: Exception) {
return Configuration(
credentials.uri,
credentials.userName,
null,
null,
null,
e
)
}
}
// data classes
@ -76,7 +138,7 @@ class BaseConfigurationFinder(protected val context: Context, protected val cred
class Configuration
// We have to use URI here because HttpUrl is not serializable!
(val url: URI, val userName: String, val authtoken: String?, val cardDAV: ServiceInfo, val calDAV: ServiceInfo, var userInfo: UserInfoManager.UserInfo?, var error: Throwable?) : Serializable {
(val url: URI?, val userName: String, val etebaseSession: String?, val authtoken: String?, var userInfo: UserInfoManager.UserInfo?, var error: Throwable?) : Serializable {
var rawPassword: String? = null
var password: String? = null
var keyPair: Crypto.AsymmetricKeyPair? = null
@ -84,6 +146,9 @@ class BaseConfigurationFinder(protected val context: Context, protected val cred
val isFailed: Boolean
get() = this.error != null
val isLegacy: Boolean
get() = this.authtoken != null
class ServiceInfo : Serializable {
val collections: Map<String, CollectionInfo> = HashMap()
@ -93,7 +158,7 @@ class BaseConfigurationFinder(protected val context: Context, protected val cred
}
override fun toString(): String {
return "BaseConfigurationFinder.Configuration(url=" + this.url + ", userName=" + this.userName + ", keyPair=" + this.keyPair + ", cardDAV=" + this.cardDAV + ", calDAV=" + this.calDAV + ", error=" + this.error + ", failed=" + this.isFailed + ")"
return "BaseConfigurationFinder.Configuration(url=" + this.url + ", userName=" + this.userName + ", keyPair=" + this.keyPair + ", error=" + this.error + ", failed=" + this.isFailed + ")"
}
}

@ -0,0 +1,103 @@
/*
* Copyright © 2013 2016 Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package com.etesync.syncadapter.ui.setup
import android.accounts.Account
import android.accounts.AccountManager
import android.app.Activity
import android.app.Dialog
import android.app.ProgressDialog
import android.os.Bundle
import android.provider.CalendarContract
import androidx.fragment.app.DialogFragment
import at.bitfire.ical4android.TaskProvider.Companion.OPENTASK_PROVIDERS
import com.etesync.syncadapter.*
import com.etesync.syncadapter.log.Logger
import com.etesync.syncadapter.ui.setup.BaseConfigurationFinder.Configuration
import com.etesync.syncadapter.utils.AndroidCompat
import com.etesync.syncadapter.utils.TaskProviderHandling
import java.util.logging.Level
class CreateAccountFragment : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val progress = ProgressDialog(activity)
progress.setTitle(R.string.setting_up_encryption)
progress.setMessage(getString(R.string.setting_up_encryption_content))
progress.isIndeterminate = true
progress.setCanceledOnTouchOutside(false)
isCancelable = false
return progress
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val config = requireArguments().getSerializable(KEY_CONFIG) as Configuration
val activity = requireActivity()
if (createAccount(config.userName, config)) {
activity.setResult(Activity.RESULT_OK)
activity.finish()
}
}
@Throws(InvalidAccountException::class)
protected fun createAccount(accountName: String, config: Configuration): Boolean {
val account = Account(accountName, App.accountType)
// create Android account
Logger.log.log(Level.INFO, "Creating Android account with initial config", arrayOf(account, config.userName, config.url))
val accountManager = AccountManager.get(context)
if (!accountManager.addAccountExplicitly(account, config.password, null))
return false
AccountSettings.setUserData(accountManager, account, config.url, config.userName)
// add entries for account to service DB
Logger.log.log(Level.INFO, "Writing account configuration to database", config)
try {
val settings = AccountSettings(requireContext(), account)
settings.etebaseSession = config.etebaseSession
// contact sync is automatically enabled by isAlwaysSyncable="true" in res/xml/sync_contacts.xml
settings.setSyncInterval(App.addressBooksAuthority, Constants.DEFAULT_SYNC_INTERVAL.toLong())
// calendar sync is automatically enabled by isAlwaysSyncable="true" in res/xml/sync_contacts.xml
settings.setSyncInterval(CalendarContract.AUTHORITY, Constants.DEFAULT_SYNC_INTERVAL.toLong())
OPENTASK_PROVIDERS.forEach {
// enable task sync if OpenTasks is installed
// further changes will be handled by PackageChangedReceiver
TaskProviderHandling.updateTaskSync(requireContext(), it)
}
} catch (e: InvalidAccountException) {
Logger.log.log(Level.SEVERE, "Couldn't access account settings", e)
AndroidCompat.removeAccount(accountManager, account)
throw e
}
return true
}
companion object {
private val KEY_CONFIG = "config"
fun newInstance(config: Configuration): CreateAccountFragment {
val frag = CreateAccountFragment()
val args = Bundle(1)
args.putSerializable(KEY_CONFIG, config)
frag.arguments = args
return frag
}
}
}

@ -13,6 +13,7 @@ import android.app.ProgressDialog
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import androidx.loader.app.LoaderManager
@ -27,8 +28,8 @@ class DetectConfigurationFragment : DialogFragment(), LoaderManager.LoaderCallba
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val progress = ProgressDialog(activity)
progress.setTitle(R.string.login_configuration_detection)
progress.setMessage(getString(R.string.login_querying_server))
progress.setTitle(R.string.setting_up_encryption)
progress.setMessage(getString(R.string.setting_up_encryption_content))
progress.isIndeterminate = true
progress.setCanceledOnTouchOutside(false)
isCancelable = false
@ -38,26 +39,36 @@ class DetectConfigurationFragment : DialogFragment(), LoaderManager.LoaderCallba
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Logger.log.fine("DetectConfigurationFragment: loading")
loaderManager.initLoader(0, arguments, this)
}
override fun onCreateLoader(id: Int, args: Bundle?): Loader<Configuration> {
return ServerConfigurationLoader(context!!, args!!.getParcelable(ARG_LOGIN_CREDENTIALS) as LoginCredentials)
return ServerConfigurationLoader(requireContext(), args!!.getParcelable(ARG_LOGIN_CREDENTIALS) as LoginCredentials)
}
override fun onLoadFinished(loader: Loader<Configuration>, data: Configuration?) {
if (data != null) {
if (data.isFailed)
// no service found: show error message
fragmentManager!!.beginTransaction()
if (data.isFailed) {
Logger.log.warning("Failed login configuration ${data.error?.localizedMessage}")
// no service found: show error message
requireFragmentManager().beginTransaction()
.add(NothingDetectedFragment.newInstance(data.error!!.localizedMessage), null)
.commitAllowingStateLoss()
else
// service found: continue
fragmentManager!!.beginTransaction()
} else if (data.isLegacy) {
// legacy service found: continue
Logger.log.info("Found legacy account - asking for encryption details")
requireFragmentManager().beginTransaction()
.replace(android.R.id.content, EncryptionDetailsFragment.newInstance(data))
.addToBackStack(null)
.commitAllowingStateLoss()
} else {
Logger.log.info("Found Etebase account account")
requireFragmentManager().beginTransaction()
.replace(android.R.id.content, CreateAccountFragment.newInstance(data))
.addToBackStack(null)
.commitAllowingStateLoss()
}
} else
Logger.log.severe("Configuration detection failed")
@ -71,14 +82,9 @@ class DetectConfigurationFragment : DialogFragment(), LoaderManager.LoaderCallba
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
return AlertDialog.Builder(activity!!)
.setTitle(R.string.login_configuration_detection)
.setTitle(R.string.setting_up_encryption)
.setIcon(R.drawable.ic_error_dark)
.setMessage(R.string.login_wrong_username_or_password)
.setNeutralButton(R.string.login_view_logs) { dialog, which ->
val intent = DebugInfoActivity.newIntent(context, this::class.toString())
intent.putExtra(DebugInfoActivity.KEY_LOGS, arguments!!.getString(KEY_LOGS))
startActivity(intent)
}
.setMessage(requireArguments().getString(KEY_LOGS))
.setPositiveButton(android.R.string.ok) { dialog, which ->
// dismiss
}

@ -30,7 +30,7 @@ class LoginActivity : BaseActivity() {
if (savedInstanceState == null)
// first call, add fragment
supportFragmentManager.beginTransaction()
.replace(android.R.id.content, LoginCredentialsFragment())
.replace(android.R.id.content, LoginCredentialsFragment(null, null))
.commit()
}
@ -43,16 +43,4 @@ class LoginActivity : BaseActivity() {
fun showHelp(item: MenuItem) {
WebViewActivity.openUrl(this, Constants.helpUri)
}
companion object {
/**
* When set, and [.EXTRA_PASSWORD] is set too, the user name field will be set to this value.
*/
val EXTRA_USERNAME = "username"
/**
* When set, the password field will be set to this value.
*/
val EXTRA_PASSWORD = "password"
}
}

@ -15,22 +15,7 @@ import com.etesync.syncadapter.log.Logger
import java.net.URI
import java.net.URISyntaxException
class LoginCredentials(_uri: URI?, val userName: String, val password: String) : Parcelable {
val uri: URI?
init {
var uri = _uri
if (uri == null) {
try {
uri = URI(Constants.serviceUrl.toString())
} catch (e: URISyntaxException) {
Logger.log.severe("Should never happen, it's a constant")
}
}
this.uri = uri
}
class LoginCredentials(val uri: URI?, val userName: String, val password: String) : Parcelable {
override fun describeContents(): Int {
return 0

@ -12,7 +12,6 @@ import android.accounts.Account
import android.app.Dialog
import android.app.ProgressDialog
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
@ -32,8 +31,8 @@ class LoginCredentialsChangeFragment : DialogFragment(), LoaderManager.LoaderCal
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val progress = ProgressDialog(activity)
progress.setTitle(R.string.login_configuration_detection)
progress.setMessage(getString(R.string.login_querying_server))
progress.setTitle(R.string.setting_up_encryption)
progress.setMessage(getString(R.string.setting_up_encryption_content))
progress.isIndeterminate = true
progress.setCanceledOnTouchOutside(false)
isCancelable = false
@ -84,7 +83,7 @@ class LoginCredentialsChangeFragment : DialogFragment(), LoaderManager.LoaderCal
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
return AlertDialog.Builder(activity!!)
.setTitle(R.string.login_configuration_detection)
.setTitle(R.string.setting_up_encryption)
.setIcon(R.drawable.ic_error_dark)
.setMessage(R.string.login_wrong_username_or_password)
.setNeutralButton(R.string.login_view_logs) { dialog, which ->

@ -17,15 +17,19 @@ import android.widget.CheckedTextView
import android.widget.EditText
import android.widget.TextView
import androidx.fragment.app.Fragment
import androidx.fragment.app.commit
import androidx.fragment.app.replace
import com.etesync.syncadapter.Constants
import com.etesync.syncadapter.R
import com.etesync.syncadapter.ui.WebViewActivity
import com.etesync.syncadapter.ui.etebase.SignupFragment
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import net.cachapa.expandablelayout.ExpandableLayout
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import java.net.URI
class LoginCredentialsFragment : Fragment() {
class LoginCredentialsFragment(private val initialUsername: String?, private val initialPassword: String?) : Fragment() {
internal lateinit var editUserName: EditText
internal lateinit var editUrlPassword: TextInputLayout
@ -36,28 +40,21 @@ class LoginCredentialsFragment : Fragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val v = inflater.inflate(R.layout.login_credentials_fragment, container, false)
editUserName = v.findViewById<View>(R.id.user_name) as EditText
editUrlPassword = v.findViewById<View>(R.id.url_password) as TextInputLayout
showAdvanced = v.findViewById<View>(R.id.show_advanced) as CheckedTextView
customServer = v.findViewById<View>(R.id.custom_server) as EditText
editUserName = v.findViewById<TextInputEditText>(R.id.user_name)
editUrlPassword = v.findViewById<TextInputLayout>(R.id.url_password)
showAdvanced = v.findViewById<CheckedTextView>(R.id.show_advanced)
customServer = v.findViewById<TextInputEditText>(R.id.custom_server)
if (savedInstanceState == null) {
val activity = activity
val intent = activity?.intent
if (intent != null) {
// we've got initial login data
val username = intent.getStringExtra(LoginActivity.EXTRA_USERNAME)
val password = intent.getStringExtra(LoginActivity.EXTRA_PASSWORD)
editUserName.setText(username)
editUrlPassword.editText?.setText(password)
}
editUserName.setText(initialUsername ?: "")
editUrlPassword.editText?.setText(initialPassword ?: "")
}
val createAccount = v.findViewById<View>(R.id.create_account) as Button
createAccount.setOnClickListener {
val createUri = Constants.registrationUrl.buildUpon().appendQueryParameter("email", editUserName.text.toString()).build()
WebViewActivity.openUrl(context!!, createUri)
parentFragmentManager.commit {
replace(android.R.id.content, SignupFragment(editUserName.text.toString(), editUrlPassword.editText?.text.toString()))
}
}
val login = v.findViewById<View>(R.id.login) as Button
@ -92,12 +89,16 @@ class LoginCredentialsFragment : Fragment() {
if (userName.isEmpty()) {
editUserName.error = getString(R.string.login_email_address_error)
valid = false
} else {
editUserName.error = null
}
val password = editUrlPassword.editText?.text.toString()
if (password.isEmpty()) {
editUrlPassword.error = getString(R.string.login_password_required)
valid = false
} else {
editUrlPassword.error = null
}
var uri: URI? = null
@ -108,6 +109,7 @@ class LoginCredentialsFragment : Fragment() {
val url = server.toHttpUrlOrNull()
if (url != null) {
uri = url.toUri()
customServer.error = null
} else {
customServer.error = getString(R.string.login_custom_server_error)
valid = false

@ -134,13 +134,13 @@ class SetupEncryptionFragment : DialogFragment() {
}
// insert CardDAV service
insertService(accountName, CollectionInfo.Type.ADDRESS_BOOK, config.cardDAV)
insertService(accountName, CollectionInfo.Type.ADDRESS_BOOK)
// contact sync is automatically enabled by isAlwaysSyncable="true" in res/xml/sync_contacts.xml
settings.setSyncInterval(App.addressBooksAuthority, Constants.DEFAULT_SYNC_INTERVAL.toLong())
// insert CalDAV service
insertService(accountName, CollectionInfo.Type.CALENDAR, config.calDAV)
insertService(accountName, CollectionInfo.Type.CALENDAR)
// calendar sync is automatically enabled by isAlwaysSyncable="true" in res/xml/sync_contacts.xml
settings.setSyncInterval(CalendarContract.AUTHORITY, Constants.DEFAULT_SYNC_INTERVAL.toLong())
@ -160,8 +160,9 @@ class SetupEncryptionFragment : DialogFragment() {
return true
}
protected fun insertService(accountName: String, serviceType: CollectionInfo.Type, info: BaseConfigurationFinder.Configuration.ServiceInfo) {
val data = (context!!.applicationContext as App).data
protected fun insertService(accountName: String, serviceType: CollectionInfo.Type) {
val info = Configuration.ServiceInfo()
val data = (requireContext().applicationContext as App).data
// insert service
val serviceEntity = ServiceEntity.fetchOrCreate(data, accountName, serviceType)

@ -28,6 +28,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/please_wait"
android:textIsSelectable="true"
android:typeface="monospace" />
</LinearLayout>

@ -40,6 +40,7 @@
android:id="@+id/encryption_password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="14dp"
app:passwordToggleEnabled="true">
<com.google.android.material.textfield.TextInputEditText
android:layout_width="match_parent"

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<FrameLayout
android:id="@+id/fragment_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
/>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<include
layout="@layout/collection_header"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="@dimen/activity_margin" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="@dimen/activity_margin">
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="right" />
</LinearLayout>
<LinearLayout
android:id="@+id/add_member"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="@dimen/activity_margin"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_weight="1"
android:text="@string/collection_members_add"
android:textAppearance="?android:attr/textAppearanceMedium" />
<ImageView
android:id="@+id/icon"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_gravity="center"
android:src="@drawable/ic_account_add_dark" />
</LinearLayout>
<fragment android:name="com.etesync.syncadapter.ui.etebase.CollectionMembersListFragment"
android:id="@+id/list_entries_container"
android:layout_weight="1"
android:layout_width="match_parent"
android:layout_height="0dp" />
</LinearLayout>

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<include
layout="@layout/collection_header"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="@dimen/activity_margin" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="@dimen/activity_margin">
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="right" />
</LinearLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="4dp"
android:text="@string/collection_members_no_access" />
<Button
android:id="@+id/leave"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="14dp"
android:text="@string/collection_members_leave" />
</LinearLayout>

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:orientation="vertical"
android:padding="18dp">
<TextView
android:id="@+id/body"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:text="@string/invitations_accept_reject_dialog"
android:textAppearance="?android:attr/textAppearanceMedium" />
<TextView
android:id="@+id/fingerprint"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="monospace"
android:gravity="center"
android:textAlignment="center"
android:textAppearance="?android:attr/textAppearanceSmall"
tools:text="aaaa 1111 bbbb cccc dddd eeee\n1111" />
</LinearLayout>

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<ListView
android:id="@id/android:list"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<TextView
android:id="@id/android:empty"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_margin="16dp"
android:gravity="center"
android:text="@string/invitations_loading"
android:textAppearance="@style/TextAppearance.AppCompat.Large" />
</LinearLayout>

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="8dp">
<TextView
android:id="@+id/title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textAppearance="?android:attr/textAppearanceMedium"
tools:text="Title" />
<ImageView
android:id="@+id/read_only"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginRight="8dp"
android:src="@drawable/ic_readonly_dark"
android:visibility="gone" />
</LinearLayout>

@ -29,19 +29,19 @@
android:text="@string/login_enter_service_details"
android:layout_marginBottom="14dp"/>
<TextView
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/login_service_details_description"
android:layout_marginBottom="14dp"/>
android:layout_marginBottom="14dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/user_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/login_username"
android:autofillHints="emailAddress"
android:inputType="textEmailAddress"/>
</com.google.android.material.textfield.TextInputLayout>
<EditText
android:id="@+id/user_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/login_email_address"
android:autofillHints="emailAddress"
android:inputType="textEmailAddress"/>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/url_password"
android:layout_width="match_parent"
@ -81,12 +81,16 @@
android:layout_width="match_parent"
android:layout_height="wrap_content">
<EditText
android:id="@+id/custom_server"
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/login_custom_server"
android:inputType="textUri"/>
android:layout_height="wrap_content">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/custom_server"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/login_custom_server"
android:inputType="textUri"/>
</com.google.android.material.textfield.TextInputLayout>
</net.cachapa.expandablelayout.ExpandableLayout>

@ -0,0 +1,134 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright © 2013 2016 Ricki Hirner (bitfire web engineering).
~ All rights reserved. This program and the accompanying materials
~ are made available under the terms of the GNU Public License v3.0
~ which accompanies this distribution, and is available at
~ http://www.gnu.org/licenses/gpl.html
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ScrollView android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_margin="@dimen/activity_margin">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
style="@style/login_type_headline"
android:text="@string/signup_title"
android:layout_marginBottom="14dp"/>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/user_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="14dp">
<com.google.android.material.textfield.TextInputEditText
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/login_username"
android:autofillHints="emailAddress"
android:inputType="textEmailAddress"/>
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/email"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="14dp">
<com.google.android.material.textfield.TextInputEditText
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/login_email_address"
android:autofillHints="emailAddress"
android:inputType="textEmailAddress"/>
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/url_password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:passwordToggleEnabled="true">
<com.google.android.material.textfield.TextInputEditText
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="monospace"
android:autofillHints="password"
android:inputType="textPassword"
android:hint="@string/login_password"/>
</com.google.android.material.textfield.TextInputLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/login_encryption_check_password"/>
<CheckedTextView
android:id="@+id/show_advanced"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:layout_marginLeft="4dp"
android:textSize="18sp"
android:gravity="center_vertical"
android:checkMark="?android:attr/listChoiceIndicatorMultiple"
android:text="@string/login_toggle_advanced" />
<net.cachapa.expandablelayout.ExpandableLayout
android:id="@+id/advanced_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/custom_server"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/login_custom_server"
android:inputType="textUri"/>
</com.google.android.material.textfield.TextInputLayout>
</net.cachapa.expandablelayout.ExpandableLayout>
</LinearLayout>
</ScrollView>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
style="@style/stepper_nav_bar">
<Button
android:id="@+id/login"
android:layout_width="0dp"
android:layout_weight="1"
android:text="@string/login_login"
style="@style/stepper_nav_button"/>
<Space
android:layout_width="0dp"
android:layout_weight="1"
style="@style/stepper_nav_button"/>
<Button
android:id="@+id/create_account"
android:layout_width="0dp"
android:layout_weight="1"
android:text="@string/login_signup"
style="@style/stepper_nav_button"/>
</LinearLayout>
</LinearLayout>

@ -0,0 +1,68 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<include
layout="@layout/collection_header"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="@dimen/activity_margin" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="@dimen/activity_margin"
android:text="@string/change_journal_title"
android:textAppearance="?android:attr/textAppearanceMedium" />
<TextView
android:id="@+id/tasks_not_showing"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="@dimen/activity_margin"
android:visibility="gone"
android:text="@string/tasks_not_showing" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="@dimen/activity_margin">
<TextView
android:id="@+id/stats"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Stats:" />
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="right" />
</LinearLayout>
<fragment android:name="com.etesync.syncadapter.ui.etebase.ListEntriesFragment"
android:id="@+id/list_entries_container"
android:layout_weight="1"
android:layout_width="match_parent"
android:layout_height="0dp" />
</LinearLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="@dimen/fab_margin"
android:src="@drawable/ic_add_light" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

@ -25,6 +25,10 @@
android:title="@string/account_show_fingerprint"
app:showAsAction="ifRoom"/>
<item android:id="@+id/invitations"
android:title="@string/invitations_title"
app:showAsAction="never"/>
<item android:id="@+id/delete_account"
android:title="@string/account_delete"
app:showAsAction="never"/>

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright © 2013 2016 Ricki Hirner (bitfire web engineering).
~ All rights reserved. This program and the accompanying materials
~ are made available under the terms of the GNU Public License v3.0
~ which accompanies this distribution, and is available at
~ http://www.gnu.org/licenses/gpl.html
-->
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<group android:id="@+id/journal_item_menu_event_invite">
<item android:title="@string/calendar_attendees_send_email_action"
android:id="@+id/on_send_event_invite"
android:icon="@drawable/ic_email_black"
app:showAsAction="always" />
</group>
<group android:id="@+id/journal_item_menu_restore">
<item android:title="@string/journal_item_restore_action"
android:icon="@drawable/ic_restore_black"
android:id="@+id/on_restore_item"
app:showAsAction="never" />
</group>
</menu>

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright © 2013 2016 Ricki Hirner (bitfire web engineering).
~ All rights reserved. This program and the accompanying materials
~ are made available under the terms of the GNU Public License v3.0
~ which accompanies this distribution, and is available at
~ http://www.gnu.org/licenses/gpl.html
-->
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/on_delete"
android:icon="@drawable/ic_delete_dark"
android:title="@string/delete_collection"
app:showAsAction="always" />
<item
android:id="@+id/on_save"
android:icon="@drawable/ic_save_dark"
android:title="@string/create_collection_create"
app:showAsAction="always" />
</menu>

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright © 2013 2016 Ricki Hirner (bitfire web engineering).
~ All rights reserved. This program and the accompanying materials
~ are made available under the terms of the GNU Public License v3.0
~ which accompanies this distribution, and is available at
~ http://www.gnu.org/licenses/gpl.html
-->
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item android:title="@string/view_collection_edit"
android:id="@+id/on_edit"
android:icon="@drawable/ic_edit_dark"
app:showAsAction="always" />
<item android:title="@string/view_collection_members"
android:id="@+id/on_manage_members"
android:icon="@drawable/ic_members_dark"
app:showAsAction="ifRoom"/>
<item android:title="@string/view_collection_import"
android:id="@+id/on_import"
app:showAsAction="never"/>
</menu>

@ -188,4 +188,4 @@
<string name="certificate_notification_connection_security">EteSync: Verbindungs-Sicherheit</string>
<string name="trust_certificate_unknown_certificate_found">EteSync hat ein unbekanntes Zertifikat vorgefunden. Wollen Sie ihm vertrauen?</string>
<string name="app_settings_log_verbose">Ausführliche Protokollierung</string>
</resources>
</resources>

@ -197,7 +197,8 @@
<string name="login_wrong_username_or_password">Kunne ikke logge inn (sannsynligvis feil brukernavn eller passord).\nHar du registrert deg?</string>
<string name="login_view_logs">Vis logger</string>
<string name="setting_up_encryption">Setter opp kryptering</string>
<string name="setting_up_encryption_content">Vent, setter opp kryptering …</string>
<string name="setting_up_encryption_content">Vennligst vent, setter opp kryptering…</string>
<string name="account_creation_failed">Kunne ikke opprette konto</string>
<string name="wrong_encryption_password">Feil krypteringspassord</string>
<string name="wrong_encryption_password_content">Mottok en integritetsfeil ved tilgang til kontoen din, noe som mest sannsynlig skyldes at du skrev inn feil krypteringspassord.
@ -316,7 +317,7 @@
<string name="sync_successfully_contacts" formatted="false">Kontakter endret (%s)</string>
<string name="sync_successfully_tasks" formatted="false">Gjøremålene «%s» er endret (%s)</string>
<string name="sync_successfully_modified" formatted="false">%s endret.</string>
<string name="sync_successfully_modified_full" formatted="false">%s lagt til.\n%s oppdatert.\n%s slettet.</string>
<string name="sync_successfully_modified_full" formatted="false">%s oppdatert.\n%s slettet.</string>
<string name="sync_journal_readonly">Journalen \"%s\" er skrivebeskyttet</string>
<string name="sync_journal_readonly_message">Journalen er skrivebeskyttet, så alle endringene dine (%d) har blitt omgjort.</string>
<!-- Calendar invites -->
@ -361,4 +362,4 @@
<string name="app_settings_prefer_tasksorg">Foretrekk Tasks.org-gjøremålstilbyder</string>
<string name="app_settings_sync">Synkroniser</string>
<string name="accounts_missing_permissions">Manglende tilganger: %s</string>
</resources>
</resources>

@ -287,7 +287,7 @@
<string name="sync_successfully_calendar" formatted="false">Kalendarz \"%s\" został zmodyfikowany (%s)</string>
<string name="sync_successfully_contacts" formatted="false">Kontakty zostały zmodyfikowane (%s)</string>
<string name="sync_successfully_modified" formatted="false">%s został zmodyfikowany.</string>
<string name="sync_successfully_modified_full" formatted="false">%s został dodany. \n%s został zaktualizowany.\n%s został usunięty.</string>
<string name="sync_successfully_modified_full" formatted="false">%s został zaktualizowany.\n%s został usunięty.</string>
<!-- cert4android -->
<string name="certificate_notification_connection_security">EteSync: Bezpieczeństwo połączenia</string>

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<plurals name="sync_successfully">
<item quantity="one">%d entry</item>
<item quantity="other">%d entries</item>
<item quantity="one">%d item</item>
<item quantity="other">%d items</item>
</plurals>
</resources>

@ -147,6 +147,7 @@
<string name="members_owner_only">Only the owner of this collection (%s) is allowed to view its members.</string>
<string name="not_allowed_title">Not Allowed</string>
<string name="edit_owner_only">Only the owner of this collection (%s) is allowed to edit it.</string>
<string name="edit_owner_only_anon">Only the owner of this collection is allowed to edit it.</string>
<string name="members_old_journals_not_allowed">Sharing of old-style journals is not allowed. In order to share this journal, create a new one, and copy its contents over using the \"import\" dialog. If you are experiencing any issues, please contact support.</string>
<string name="use_native_apps_title">Did you know?</string>
<string name="use_native_apps_body">EteSync seamlessly integrates with Android, so to use it, just use your existing address book and calendar apps!\n\nFor more information, please check out the user guide.</string>
@ -160,15 +161,27 @@
<string name="collection_members_adding">Adding member</string>
<string name="trust_fingerprint_title">Verify security fingerprint</string>
<string name="trust_fingerprint_body">Verify %s\'s security fingerprint to ensure the encryption is secure.</string>
<string name="collection_members_error_user_not_found">User (%s) not found. Have they setup their encryption password from one of the apps?</string>
<string name="collection_members_error_user_not_found">User (%s) not found</string>
<string name="collection_members_removing">Removing member</string>
<string name="collection_members_remove_error">Error removing member</string>
<string name="collection_members_remove_title">Remove member</string>
<string name="collection_members_remove">Would you like to revoke %s\'s access?\nPlease be advised that a malicious user would potentially be able to retain access to encryption keys. Please refer to the FAQ for more information.</string>
<string name="collection_members_remove_admin">Removing access to admins is currently not supported.</string>
<string name="collection_members_no_access">Only admins are allowed to manage collection memberships. Would you like to leave the collection?</string>
<string name="collection_members_leave">Leave</string>
<!-- Invitations -->
<string name="invitations_title">Invitations</string>
<string name="invitations_loading">Loading invitations...</string>
<string name="invitations_list_empty">No invitations</string>
<string name="invitations_accept_reject_dialog">Would you like to accept or reject the invitation?</string>
<string name="invitations_accept">Accept</string>
<string name="invitations_reject">Reject</string>
<!-- JournalItemActivity -->
<string name="about">About</string>
<string name="journal_item_tab_main">Main</string>
<string name="journal_item_tab_revisions">Revisions</string>
<string name="journal_item_tab_raw">Raw</string>
<string name="journal_item_attendees">Attendees</string>
<string name="journal_item_reminders">Reminders</string>
@ -207,8 +220,10 @@
<!-- AddAccountActivity -->
<string name="login_title">Add account</string>
<string name="login_username">Username</string>
<string name="login_username_error">Valid username required</string>
<string name="login_email_address">Email</string>
<string name="login_email_address_error">Valid email address required</string>
<string name="login_email_address_error">Valid email required</string>
<string name="login_password">Password</string>
<string name="login_custom_server">EteSync Server URL</string>
<string name="login_custom_server_error">Invalid URL found, did you forget to include https://?</string>
@ -216,7 +231,7 @@
<string name="login_encryption_password">Encryption Password</string>
<string name="login_encryption_set_new_password">Please set your encryption password below, and make sure you got it right, as it *can\'t* be recovered if lost!</string>
<string name="login_encryption_enter_password">You are logged in as \"%s\". Please enter your encryption password to continue, or log out from the side menu.</string>
<string name="login_encryption_check_password">* Please double-check the password, as it can\'t be recovered if wrong!</string>
<string name="login_encryption_check_password">* Please make sure you remember your password, as it can\'t be recovered if lost!</string>
<string name="login_encryption_extra_info">* This password is used to encrypt your data, unlike the previous one, which is used to log into the service.\nYou are asked to choose a separate encryption password for security reasons. For more information, please refer to the FAQ at: %s</string>
<string name="login_password_required">Password required</string>
<string name="login_login">Log In</string>
@ -229,8 +244,6 @@
<string name="login_service_details_description">This is your login password, *not* your encryption password!</string>
<string name="login_forgot_password">Forgot password?</string>
<string name="login_configuration_detection">Configuration detection</string>
<string name="login_querying_server">Please wait, querying server…</string>
<string name="login_wrong_username_or_password">Couldn\'t authenticate (probably wrong username or password).\nHave you registered?</string>
<string name="login_view_logs">View logs</string>
@ -241,6 +254,9 @@
<string name="wrong_encryption_password">Wrong encryption password</string>
<string name="wrong_encryption_password_content">Got an integrity error while accessing your account, which most likely means you put in the wrong encryption password.\nPlease note that the username is case sensitive, so please also try different capitalizations, for example make the first character uppercase.\n\nError: %s</string>
<string name="signup_title">Enter Signup Details</string>
<string name="signup_password_restrictions">Password should be at least 8 characters long</string>
<!-- ChangeEncryptionPasswordActivity -->
<string name="change_encryption_password_title">Change Encryption Password</string>
<string name="change_encryption_password_extra_info">Please don\'t use this tool if you believe your encryption password has been compromised. Contact support instead.</string>
@ -332,8 +348,8 @@
<string name="delete_collection_deleting_collection">Deleting collection</string>
<!-- JournalViewer -->
<string name="journal_entries_list_empty">Journal is empty.\n(Maybe it\'s still syncing?)</string>
<string name="journal_entries_loading">Loading journal entries...</string>
<string name="journal_entries_list_empty">Collection is empty.\nMaybe it\'s still syncing?</string>
<string name="journal_entries_loading">Loading change log entries...</string>
<!-- ExceptionInfoFragment -->
<string name="exception">An error has occurred.</string>
@ -347,6 +363,7 @@
<string name="debug_info_more_data_shared">Clicking share will open the email app with the data below, as well as some additional debug information, attached. It may contain some sensitive information, so please review it before sending.</string>
<string name="sync_error_permissions">EteSync permissions</string>
<string name="sync_error_permissions_text">Additional permissions required</string>
<string name="sync_error_generic">Sync failed (%s)</string>
<string name="sync_error_calendar">Calendar sync failed (%s)</string>
<string name="sync_error_contacts">Contacts sync failed (%s)</string>
<string name="sync_error_tasks">Tasks sync failed (%s)</string>
@ -356,6 +373,7 @@
<string name="sync_error_unavailable">Could not connect to server while %s</string>
<string name="sync_error_local_storage">Database error while %s</string>
<string name="sync_error_journal_readonly">Journal is read only</string>
<string name="sync_error_permission_denied">Permission denied: %s</string>
<string name="sync_phase_prepare">preparing synchronization</string>
<string name="sync_phase_journals">syncronizing journals</string>
<string name="sync_phase_prepare_fetch">preparing for fetch</string>
@ -372,7 +390,7 @@
<string name="sync_successfully_contacts" formatted="false">Contacts modified (%s)</string>
<string name="sync_successfully_tasks" formatted="false">Tasks \"%s\" modified (%s)</string>
<string name="sync_successfully_modified" formatted="false">%s modified.</string>
<string name="sync_successfully_modified_full" formatted="false">%s added.\n%s updated.\n%s deleted.</string>
<string name="sync_successfully_modified_full" formatted="false">%s updated.\n%s deleted.</string>
<string name="sync_journal_readonly">Journal \"%s\" is read only</string>
<string name="sync_journal_readonly_message">The journal is read only so all of your changes (%d) have been reverted.</string>

@ -18,25 +18,12 @@
</PreferenceCategory>
<PreferenceCategory android:title="@string/settings_encryption">
<PreferenceCategory android:title="@string/settings_sync">
<Preference
android:key="password"
android:title="@string/settings_encryption_password"
android:key="encryption_password"
android:summary="@string/settings_encryption_password_summary"
android:persistent="false"
/>
</PreferenceCategory>
<PreferenceCategory android:title="@string/settings_sync">
<EditTextPreference
android:key="password"
android:title="@string/settings_password"
android:persistent="false"
android:inputType="textPassword"
android:summary="@string/settings_password_summary"
android:dialogTitle="@string/settings_enter_password" />
/>
<ListPreference
android:key="sync_interval"

@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) 2013 2015 Ricki Hirner (bitfire web engineering).
~ All rights reserved. This program and the accompanying materials
~ are made available under the terms of the GNU Public License v3.0
~ which accompanies this distribution, and is available at
~ http://www.gnu.org/licenses/gpl.html
-->
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
<PreferenceCategory android:title="@string/settings_manage_account">
<Preference
android:key="manage_account"
android:title="@string/settings_account_dashboard"
android:persistent="false"
android:summary="@string/settings_manage_account_summary" />
</PreferenceCategory>
<PreferenceCategory android:title="@string/settings_encryption">
<Preference
android:title="@string/settings_encryption_password"
android:key="encryption_password"
android:summary="@string/settings_encryption_password_summary"
android:persistent="false"
/>
</PreferenceCategory>
<PreferenceCategory android:title="@string/settings_sync">
<EditTextPreference
android:key="password"
android:title="@string/settings_password"
android:persistent="false"
android:inputType="textPassword"
android:summary="@string/settings_password_summary"
android:dialogTitle="@string/settings_enter_password" />
<ListPreference
android:key="sync_interval"
android:persistent="false"
android:title="@string/settings_sync_interval"
android:entries="@array/settings_sync_interval_names"
android:entryValues="@array/settings_sync_interval_seconds" />
<SwitchPreferenceCompat
android:key="sync_wifi_only"
android:persistent="false"
android:title="@string/settings_sync_wifi_only"
android:summaryOn="@string/settings_sync_wifi_only_on"
android:summaryOff="@string/settings_sync_wifi_only_off"
/>
<EditTextPreference
android:key="sync_wifi_only_ssid"
android:dependency="sync_wifi_only"
android:persistent="false"
android:title="@string/settings_sync_wifi_only_ssid"
android:dialogMessage="@string/settings_sync_wifi_only_ssid_message"/>
</PreferenceCategory>
</PreferenceScreen>
Loading…
Cancel
Save