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

@ -127,7 +127,7 @@
<!-- Address book account --> <!-- Address book account -->
<service <service
android:name=".syncadapter.NullAuthenticatorService" 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> <intent-filter>
<action android:name="android.accounts.AccountAuthenticator"/> <action android:name="android.accounts.AccountAuthenticator"/>
</intent-filter> </intent-filter>
@ -233,6 +233,14 @@
android:exported="false" android:exported="false"
android:parentActivityName=".ui.AccountsActivity"> android:parentActivityName=".ui.AccountsActivity">
</activity> </activity>
<activity
android:name=".ui.etebase.CollectionActivity"
android:exported="false"
/>
<activity
android:name=".ui.etebase.InvitationsActivity"
android:exported="false"
/>
<activity <activity
android:name=".ui.ViewCollectionActivity" android:name=".ui.ViewCollectionActivity"
android:exported="false" android:exported="false"

@ -36,8 +36,12 @@ constructor(internal val context: Context, internal val account: Account) {
var uri: URI? var uri: URI?
get() { get() {
val uri = accountManager.getUserData(account, KEY_URI)
if (uri == null) {
return null
}
try { try {
return URI(accountManager.getUserData(account, KEY_URI)) return URI(uri)
} catch (e: URISyntaxException) { } catch (e: URISyntaxException) {
return null return null
} }
@ -73,6 +77,12 @@ constructor(internal val context: Context, internal val account: Account) {
get() = accountManager.getUserData(account, KEY_WIFI_ONLY_SSID) get() = accountManager.getUserData(account, KEY_WIFI_ONLY_SSID)
set(ssid) = accountManager.setUserData(account, KEY_WIFI_ONLY_SSID, 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 // 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_PRIVATE_KEY = "asymmetric_private_key"
private val KEY_ASYMMETRIC_PUBLIC_KEY = "asymmetric_public_key" private val KEY_ASYMMETRIC_PUBLIC_KEY = "asymmetric_public_key"
private val KEY_WIFI_ONLY = "wifi_only" private val KEY_WIFI_ONLY = "wifi_only"
private val KEY_ETEBASE_SESSION = "etebase_session"
// sync on WiFi only (default: false) // sync on WiFi only (default: false)
private val KEY_WIFI_ONLY_SSID = "wifi_only_ssid" // restrict sync to specific WiFi SSID 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 val SYNC_INTERVAL_MANUALLY: Long = -1
// XXX: Workaround a bug in Android where passing a bundle to addAccountExplicitly doesn't work. // 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_SETTINGS_VERSION, CURRENT_VERSION.toString())
accountManager.setUserData(account, KEY_USERNAME, userName) 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) { private fun update(fromVersion: Int) {
Logger.log.info("Updating from version " + fromVersion + " to " + BuildConfig.VERSION_CODE) 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) { if (fromVersion < 7) {
/* Fix all of the etags to be non-null */ /* Fix all of the etags to be non-null */
val am = AccountManager.get(this) 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) { fun migrateServices(dbHelper: ServiceDB.OpenHelper) {
val db = dbHelper.readableDatabase val db = dbHelper.readableDatabase
val data = this.data 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 dashboard = webUri.buildUpon().appendEncodedPath("dashboard/").build();
public static final Uri faqUri = webUri.buildUpon().appendEncodedPath("faq/").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 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 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; 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", public final static String KEY_ACCOUNT = "account",
KEY_COLLECTION_INFO = "collectionInfo"; 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 certManager: CustomCertManager? = null
private var certificateAlias: String? = null private var certificateAlias: String? = null
private var cache: Cache? = null
private val orig = sharedClient.newBuilder() private val orig = sharedClient.newBuilder()

@ -48,23 +48,6 @@ class CollectionInfo : com.etesync.journalmanager.model.CollectionInfo() {
return info 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 { fun fromJson(json: String): CollectionInfo {
return GsonBuilder().excludeFieldsWithoutExposeAnnotation().create().fromJson(json, CollectionInfo::class.java) 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.Groups
import android.provider.ContactsContract.RawContacts import android.provider.ContactsContract.RawContacts
import at.bitfire.vcard4android.* import at.bitfire.vcard4android.*
import com.etebase.client.CollectionAccessLevel
import com.etesync.syncadapter.App import com.etesync.syncadapter.App
import com.etesync.syncadapter.CachedCollection
import com.etesync.syncadapter.log.Logger import com.etesync.syncadapter.log.Logger
import com.etesync.syncadapter.model.CollectionInfo import com.etesync.syncadapter.model.CollectionInfo
import com.etesync.syncadapter.model.JournalEntity import com.etesync.syncadapter.model.JournalEntity
@ -70,6 +72,37 @@ class LocalAddressBook(
return addressBook 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) fun find(context: Context, provider: ContentProviderClient?, mainAccount: Account?) = AccountManager.get(context)
.getAccountsByType(App.addressBookAccountType) .getAccountsByType(App.addressBookAccountType)
@ -103,6 +136,13 @@ class LocalAddressBook(
return sb.toString() 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 { fun initialUserData(mainAccount: Account, url: String): Bundle {
val bundle = Bundle(3) val bundle = Bundle(3)
bundle.putString(USER_DATA_MAIN_ACCOUNT_NAME, mainAccount.name) bundle.putString(USER_DATA_MAIN_ACCOUNT_NAME, mainAccount.name)
@ -181,6 +221,37 @@ class LocalAddressBook(
ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true) 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() { fun delete() {
val accountManager = AccountManager.get(context) 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 = fun findGroupById(id: Long): LocalGroup =
queryGroups("${Groups._ID}=?", arrayOf(id.toString())).firstOrNull() queryGroups("${Groups._ID}=?", arrayOf(id.toString())).firstOrNull()
?: throw FileNotFoundException() ?: throw FileNotFoundException()

@ -18,9 +18,13 @@ import android.os.RemoteException
import android.provider.CalendarContract import android.provider.CalendarContract
import android.provider.CalendarContract.* import android.provider.CalendarContract.*
import at.bitfire.ical4android.* import at.bitfire.ical4android.*
import com.etebase.client.CollectionAccessLevel
import com.etesync.syncadapter.CachedCollection
import com.etesync.syncadapter.log.Logger import com.etesync.syncadapter.log.Logger
import com.etesync.syncadapter.model.JournalEntity import com.etesync.syncadapter.model.JournalEntity
import com.etesync.syncadapter.resource.LocalEvent.Companion.COLUMN_UID
import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.StringUtils
import org.dmfs.tasks.contract.TaskContract
import java.util.* import java.util.*
import java.util.logging.Level import java.util.logging.Level
@ -31,7 +35,21 @@ class LocalCalendar private constructor(
): AndroidCalendar<LocalEvent>(account, provider, LocalEvent.Factory, id), LocalCollection<LocalEvent> { ): AndroidCalendar<LocalEvent>(account, provider, LocalEvent.Factory, id), LocalCollection<LocalEvent> {
companion object { 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 val COLUMN_CTAG = Calendars.CAL_SYNC1
@ -50,6 +68,21 @@ class LocalCalendar private constructor(
return AndroidCalendar.create(account, provider, values) 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? fun findByName(account: Account, provider: ContentProviderClient, factory: Factory, name: String): LocalCalendar?
= AndroidCalendar.find(account, provider, factory, Calendars.NAME + "==?", arrayOf(name)).firstOrNull() = 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), ", ")) values.put(Calendars.ALLOWED_ATTENDEE_TYPES, StringUtils.join(intArrayOf(CalendarContract.Attendees.TYPE_OPTIONAL, CalendarContract.Attendees.TYPE_REQUIRED, CalendarContract.Attendees.TYPE_RESOURCE), ", "))
return values 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? override val url: String?
@ -93,6 +150,9 @@ class LocalCalendar private constructor(
fun update(journalEntity: JournalEntity, updateColor: Boolean) = fun update(journalEntity: JournalEntity, updateColor: Boolean) =
update(valuesFromCollectionInfo(journalEntity, updateColor)) update(valuesFromCollectionInfo(journalEntity, updateColor))
fun update(cachedCollection: CachedCollection, updateColor: Boolean) =
update(valuesFromCachedCollection(cachedCollection, updateColor))
override fun findDeleted() = override fun findDeleted() =
queryEvents("${Events.DELETED}!=0 AND ${Events.ORIGINAL_ID} IS NULL", null) queryEvents("${Events.DELETED}!=0 AND ${Events.ORIGINAL_ID} IS NULL", null)
@ -122,7 +182,10 @@ class LocalCalendar private constructor(
= queryEvents(null, null) = queryEvents(null, null)
override fun findByUid(uid: String): LocalEvent? 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() { fun processDirtyExceptions() {
// process deleted exceptions // process deleted exceptions

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

@ -52,7 +52,7 @@ class LocalContact : AndroidContact, LocalAddress {
override// The same now override// The same now
val uuid: String? val uuid: String?
get() = fileName get() = contact?.uid
override val isLocalOnly: Boolean override val isLocalOnly: Boolean
get() = TextUtils.isEmpty(eTag) get() = TextUtils.isEmpty(eTag)
@ -88,9 +88,11 @@ class LocalContact : AndroidContact, LocalAddress {
addressBook.provider?.update(rawContactSyncURI(), values, null, null) addressBook.provider?.update(rawContactSyncURI(), values, null, null)
} }
override fun clearDirty(eTag: String) { override fun clearDirty(eTag: String?) {
val values = ContentValues(3) 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) values.put(ContactsContract.RawContacts.DIRTY, 0)
if (LocalContact.HASH_HACK) { if (LocalContact.HASH_HACK) {
@ -105,15 +107,26 @@ class LocalContact : AndroidContact, LocalAddress {
this.eTag = eTag this.eTag = eTag
} }
override fun prepareForUpload() { override fun legacyPrepareForUpload(fileName_: String?) {
val uid = UUID.randomUUID().toString() val uid = UUID.randomUUID().toString()
val values = ContentValues(2) 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) values.put(AndroidContact.COLUMN_UID, uid)
addressBook.provider?.update(rawContactSyncURI(), values, null, null) addressBook.provider?.update(rawContactSyncURI(), values, null, null)
fileName = uid contact?.uid = uid
this.fileName = fileName
} }
override fun populateData(mimeType: String, row: ContentValues) { 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 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 eTag: String? = null
var weAreOrganizer = true var weAreOrganizer = true
override val content: String override val content: String
get() { 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() val os = ByteArrayOutputStream()
event?.write(os) event?.write(os)
@ -58,7 +58,7 @@ class LocalEvent : AndroidEvent, LocalResource<Event> {
override// Now the same override// Now the same
val uuid: String? val uuid: String?
get() = fileName get() = event?.uid
constructor(calendar: AndroidCalendar<*>, event: Event, fileName: String?, eTag: String?) : super(calendar, event) { constructor(calendar: AndroidCalendar<*>, event: Event, fileName: String?, eTag: String?) : super(calendar, event) {
this.fileName = fileName this.fileName = fileName
@ -133,7 +133,7 @@ class LocalEvent : AndroidEvent, LocalResource<Event> {
/* custom queries */ /* custom queries */
override fun prepareForUpload() { override fun legacyPrepareForUpload(fileName_: String?) {
var uid: String? = null var uid: String? = null
val c = calendar.provider.query(eventSyncURI(), arrayOf(COLUMN_UID), null, null, null) val c = calendar.provider.query(eventSyncURI(), arrayOf(COLUMN_UID), null, null, null)
if (c.moveToNext()) if (c.moveToNext())
@ -142,30 +142,42 @@ class LocalEvent : AndroidEvent, LocalResource<Event> {
uid = UUID.randomUUID().toString() uid = UUID.randomUUID().toString()
c.close() c.close()
val newFileName = uid
val fileName = fileName_ ?: uid
val values = ContentValues(2) val values = ContentValues(2)
values.put(Events._SYNC_ID, newFileName) values.put(Events._SYNC_ID, fileName)
values.put(COLUMN_UID, uid) values.put(COLUMN_UID, uid)
calendar.provider.update(eventSyncURI(), values, null, null) calendar.provider.update(eventSyncURI(), values, null, null)
fileName = newFileName this.fileName = fileName
val event = this.event val event = this.event
if (event != null) if (event != null)
event.uid = uid 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() { override fun resetDeleted() {
val values = ContentValues(1) val values = ContentValues(1)
values.put(CalendarContract.Events.DELETED, 0) values.put(CalendarContract.Events.DELETED, 0)
calendar.provider.update(eventSyncURI(), values, null, null) calendar.provider.update(eventSyncURI(), values, null, null)
} }
override fun clearDirty(eTag: String) { override fun clearDirty(eTag: String?) {
val values = ContentValues(2) val values = ContentValues(2)
values.put(CalendarContract.Events.DIRTY, 0) values.put(CalendarContract.Events.DIRTY, 0)
values.put(COLUMN_ETAG, eTag) if (eTag != null) {
values.put(COLUMN_ETAG, eTag)
}
if (event != null) if (event != null)
values.put(COLUMN_SEQUENCE, event?.sequence) values.put(COLUMN_SEQUENCE, event?.sequence)
calendar.provider.update(eventSyncURI(), values, null, null) calendar.provider.update(eventSyncURI(), values, null, null)

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

@ -10,6 +10,7 @@ package com.etesync.syncadapter.resource
interface LocalResource<in TData: Any> { interface LocalResource<in TData: Any> {
val uuid: String? val uuid: String?
val fileName: String?
/** True if doesn't exist on server yet, false otherwise. */ /** True if doesn't exist on server yet, false otherwise. */
val isLocalOnly: Boolean val isLocalOnly: Boolean
@ -19,9 +20,11 @@ interface LocalResource<in TData: Any> {
fun delete(): Int 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() 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 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 eTag: String? = null
override val content: String override val content: String
@ -49,7 +49,7 @@ class LocalTask : AndroidTask, LocalResource<Task> {
override// Now the same override// Now the same
val uuid: String? val uuid: String?
get() = fileName get() = task?.uid
constructor(taskList: AndroidTaskList<*>, task: Task, fileName: String?, eTag: String?) constructor(taskList: AndroidTaskList<*>, task: Task, fileName: String?, eTag: String?)
: super(taskList, task) { : super(taskList, task) {
@ -96,7 +96,7 @@ class LocalTask : AndroidTask, LocalResource<Task> {
/* custom queries */ /* custom queries */
override fun prepareForUpload() { override fun legacyPrepareForUpload(fileName_: String?) {
var uid: String? = null var uid: String? = null
val c = taskList.provider.client.query(taskSyncURI(), arrayOf(COLUMN_UID), null, null, null) val c = taskList.provider.client.query(taskSyncURI(), arrayOf(COLUMN_UID), null, null, null)
if (c.moveToNext()) if (c.moveToNext())
@ -106,27 +106,40 @@ class LocalTask : AndroidTask, LocalResource<Task> {
c.close() c.close()
val fileName = fileName_ ?: uid
val values = ContentValues(2) val values = ContentValues(2)
values.put(TaskContract.Tasks._SYNC_ID, uid) values.put(TaskContract.Tasks._SYNC_ID, fileName)
values.put(COLUMN_UID, uid) values.put(COLUMN_UID, uid)
taskList.provider.client.update(taskSyncURI(), values, null, null) taskList.provider.client.update(taskSyncURI(), values, null, null)
fileName = uid this.fileName = fileName
val task = this.task val task = this.task
if (task != null) if (task != null)
task.uid = uid 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() { override fun resetDeleted() {
val values = ContentValues(1) val values = ContentValues(1)
values.put(TaskContract.Tasks._DELETED, 0) values.put(TaskContract.Tasks._DELETED, 0)
taskList.provider.client.update(taskSyncURI(), values, null, null) taskList.provider.client.update(taskSyncURI(), values, null, null)
} }
override fun clearDirty(eTag: String) { override fun clearDirty(eTag: String?) {
val values = ContentValues(2) val values = ContentValues(2)
values.put(TaskContract.Tasks._DIRTY, 0) values.put(TaskContract.Tasks._DIRTY, 0)
values.put(COLUMN_ETAG, eTag) if (eTag != null) {
values.put(COLUMN_ETAG, eTag)
}
if (task != null) if (task != null)
values.put(COLUMN_SEQUENCE, task?.sequence) values.put(COLUMN_SEQUENCE, task?.sequence)
taskList.provider.client.update(taskSyncURI(), values, null, null) 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.CalendarStorageException
import at.bitfire.ical4android.TaskProvider import at.bitfire.ical4android.TaskProvider
import at.bitfire.ical4android.TaskProvider.ProviderName import at.bitfire.ical4android.TaskProvider.ProviderName
import com.etesync.syncadapter.CachedCollection
import com.etesync.syncadapter.model.JournalEntity import com.etesync.syncadapter.model.JournalEntity
import org.dmfs.tasks.contract.TaskContract.TaskLists import org.dmfs.tasks.contract.TaskContract.TaskLists
import org.dmfs.tasks.contract.TaskContract.Tasks import org.dmfs.tasks.contract.TaskContract.Tasks
@ -54,6 +55,14 @@ class LocalTaskList private constructor(
return create(account, provider, values) 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? fun findByName(account: Account, provider: TaskProvider, factory: Factory, name: String): LocalTaskList?
= AndroidTaskList.find(account, provider, factory, TaskLists._SYNC_ID + "==?", arrayOf(name)).firstOrNull() = AndroidTaskList.find(account, provider, factory, TaskLists._SYNC_ID + "==?", arrayOf(name)).firstOrNull()
@ -70,6 +79,18 @@ class LocalTaskList private constructor(
return values 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? override val url: String?
@ -78,6 +99,9 @@ class LocalTaskList private constructor(
fun update(journalEntity: JournalEntity, updateColor: Boolean) = fun update(journalEntity: JournalEntity, updateColor: Boolean) =
update(valuesFromCollectionInfo(journalEntity, updateColor)) update(valuesFromCollectionInfo(journalEntity, updateColor))
fun update(cachedCollection: CachedCollection, updateColor: Boolean) =
update(valuesFromCachedCollection(cachedCollection, updateColor))
override fun findDeleted() = queryTasks("${Tasks._DELETED}!=0", null) override fun findDeleted() = queryTasks("${Tasks._DELETED}!=0", null)
override fun findDirty(limit: Int?): List<LocalTask> { override fun findDirty(limit: Int?): List<LocalTask> {
@ -101,7 +125,10 @@ class LocalTaskList private constructor(
= queryTasks(Tasks._SYNC_ID + " IS NULL", null) = queryTasks(Tasks._SYNC_ID + " IS NULL", null)
override fun findByUid(uid: String): LocalTask? 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 { override fun count(): Long {
try { try {

@ -15,10 +15,7 @@ import android.content.*
import android.os.Bundle import android.os.Bundle
import android.provider.ContactsContract import android.provider.ContactsContract
import at.bitfire.vcard4android.ContactsStorageException import at.bitfire.vcard4android.ContactsStorageException
import com.etesync.syncadapter.AccountSettings import com.etesync.syncadapter.*
import com.etesync.syncadapter.App
import com.etesync.syncadapter.Constants
import com.etesync.syncadapter.R
import com.etesync.syncadapter.log.Logger import com.etesync.syncadapter.log.Logger
import com.etesync.syncadapter.model.CollectionInfo import com.etesync.syncadapter.model.CollectionInfo
import com.etesync.syncadapter.model.JournalEntity import com.etesync.syncadapter.model.JournalEntity
@ -36,9 +33,6 @@ class AddressBooksSyncAdapterService : SyncAdapterService() {
private class AddressBooksSyncAdapter(context: Context) : SyncAdapterService.SyncAdapter(context) { 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) { override fun onPerformSyncDo(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
val contactsProvider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY) val contactsProvider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)
if (contactsProvider == null) { if (contactsProvider == null) {
@ -53,7 +47,11 @@ class AddressBooksSyncAdapterService : SyncAdapterService() {
RefreshCollections(account, CollectionInfo.Type.ADDRESS_BOOK).run() RefreshCollections(account, CollectionInfo.Type.ADDRESS_BOOK).run()
updateLocalAddressBooks(contactsProvider, account) if (settings.isLegacy) {
legacyUpdateLocalAddressBooks(contactsProvider, account)
} else {
updateLocalAddressBooks(contactsProvider, account, settings)
}
contactsProvider.release() contactsProvider.release()
@ -63,15 +61,57 @@ class AddressBooksSyncAdapterService : SyncAdapterService() {
val syncExtras = Bundle(extras) val syncExtras = Bundle(extras)
syncExtras.putBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_SETTINGS, true) syncExtras.putBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_SETTINGS, true)
syncExtras.putBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_BACKOFF, 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) ContentResolver.requestSync(addressBookAccount, ContactsContract.AUTHORITY, syncExtras)
} }
Logger.log.info("Address book sync complete") 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) @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 context = context
val data = (getContext().applicationContext as App).data val data = (getContext().applicationContext as App).data
val service = JournalModel.Service.fetchOrCreate(data, account.name, CollectionInfo.Type.ADDRESS_BOOK) 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.Event
import at.bitfire.ical4android.InvalidCalendarException import at.bitfire.ical4android.InvalidCalendarException
import at.bitfire.vcard4android.ContactsStorageException import at.bitfire.vcard4android.ContactsStorageException
import com.etebase.client.Item
import com.etesync.syncadapter.AccountSettings import com.etesync.syncadapter.AccountSettings
import com.etesync.syncadapter.Constants import com.etesync.syncadapter.Constants
import com.etesync.syncadapter.R 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) get() = context.getString(R.string.sync_error_calendar, account.name)
override val syncSuccessfullyTitle: String 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) account.name)
init { init {
@ -59,7 +60,9 @@ constructor(context: Context, account: Account, settings: AccountSettings, extra
if (!super.prepare()) if (!super.prepare())
return false return false
journal = JournalEntryManager(httpClient.okHttpClient, remote, localCalendar().name!!) if (isLegacy) {
journal = JournalEntryManager(httpClient.okHttpClient, remote, localCalendar().name!!)
}
return true return true
} }
@ -77,6 +80,32 @@ constructor(context: Context, account: Account, settings: AccountSettings, extra
return localCollection as LocalCalendar 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) @Throws(IOException::class, ContactsStorageException::class, CalendarStorageException::class, InvalidCalendarException::class)
override fun processSyncEntryImpl(cEntry: SyncEntry) { override fun processSyncEntryImpl(cEntry: SyncEntry) {
val inputReader = StringReader(cEntry.content) val inputReader = StringReader(cEntry.content)
@ -93,7 +122,7 @@ constructor(context: Context, account: Account, settings: AccountSettings, extra
val local = localCollection!!.findByUid(event.uid!!) val local = localCollection!!.findByUid(event.uid!!)
if (cEntry.isAction(SyncEntry.Actions.ADD) || cEntry.isAction(SyncEntry.Actions.CHANGE)) { if (cEntry.isAction(SyncEntry.Actions.ADD) || cEntry.isAction(SyncEntry.Actions.CHANGE)) {
processEvent(event, local) legacyProcessEvent(event, local)
} else { } else {
if (local != null) { if (local != null) {
Logger.log.info("Removing local record #" + local.id + " which has been deleted on the server") 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) @Throws(CalendarStorageException::class, ContactsStorageException::class, IOException::class)
override fun createLocalEntries() { override fun prepareLocal() {
super.createLocalEntries() super.prepareLocal()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
createInviteAttendeesNotification() createInviteAttendeesNotification()
@ -128,7 +157,7 @@ constructor(context: Context, account: Account, settings: AccountSettings, extra
private fun createInviteAttendeesNotification(event: Event, icsContent: String) { private fun createInviteAttendeesNotification(event: Event, icsContent: String) {
val intent = EventEmailInvitation(context, account).createIntent(event, icsContent) val intent = EventEmailInvitation(context, account).createIntent(event, icsContent)
if (intent != null) { if (intent != null) {
val notificationHelper = SyncNotification(context, event.uid!!, event.uid!!.hashCode()) val notificationHelper = SyncNotification(context, icsContent, event.hashCode())
notificationHelper.notify( notificationHelper.notify(
context.getString( context.getString(
R.string.sync_calendar_attendees_notification_title, event.summary), 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) @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 var localEvent = _localEvent
// delete local event, if it exists // delete local event, if it exists
if (localEvent != null) { if (localEvent != null) {

@ -13,10 +13,7 @@ import android.os.Bundle
import android.provider.CalendarContract import android.provider.CalendarContract
import at.bitfire.ical4android.AndroidCalendar import at.bitfire.ical4android.AndroidCalendar
import at.bitfire.ical4android.CalendarStorageException import at.bitfire.ical4android.CalendarStorageException
import com.etesync.syncadapter.AccountSettings import com.etesync.syncadapter.*
import com.etesync.syncadapter.App
import com.etesync.syncadapter.Constants
import com.etesync.syncadapter.R
import com.etesync.syncadapter.log.Logger import com.etesync.syncadapter.log.Logger
import com.etesync.syncadapter.model.CollectionInfo import com.etesync.syncadapter.model.CollectionInfo
import com.etesync.syncadapter.model.JournalEntity import com.etesync.syncadapter.model.JournalEntity
@ -32,9 +29,6 @@ class CalendarsSyncAdapterService : SyncAdapterService() {
private class SyncAdapter(context: Context) : SyncAdapterService.SyncAdapter(context) { 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) { override fun onPerformSyncDo(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
val settings = AccountSettings(context, account) val settings = AccountSettings(context, account)
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(settings)) if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(settings))
@ -42,7 +36,11 @@ class CalendarsSyncAdapterService : SyncAdapterService() {
RefreshCollections(account, CollectionInfo.Type.CALENDAR).run() 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()!! val principal = settings.uri?.toHttpUrlOrNull()!!
@ -56,8 +54,52 @@ class CalendarsSyncAdapterService : SyncAdapterService() {
Logger.log.info("Calendar sync complete") Logger.log.info("Calendar sync complete")
} }
@Throws(CalendarStorageException::class)
private fun updateLocalCalendars(provider: ContentProviderClient, account: Account, settings: AccountSettings) { 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 data = (context.applicationContext as App).data
val service = JournalModel.Service.fetchOrCreate(data, account.name, CollectionInfo.Type.CALENDAR) 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) { 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) { override fun onPerformSyncDo(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
val addressBook = LocalAddressBook(context, account, provider) val addressBook = LocalAddressBook(context, account, provider)

@ -19,6 +19,7 @@ import at.bitfire.vcard4android.ContactsStorageException
import com.etesync.journalmanager.Exceptions import com.etesync.journalmanager.Exceptions
import com.etesync.journalmanager.JournalEntryManager import com.etesync.journalmanager.JournalEntryManager
import com.etesync.journalmanager.model.SyncEntry import com.etesync.journalmanager.model.SyncEntry
import com.etebase.client.Item
import com.etesync.syncadapter.AccountSettings import com.etesync.syncadapter.AccountSettings
import com.etesync.syncadapter.Constants import com.etesync.syncadapter.Constants
import com.etesync.syncadapter.HttpClient 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 return true
} }
@ -127,6 +130,32 @@ constructor(context: Context, account: Account, settings: AccountSettings, extra
return localCollection as LocalAddressBook 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) @Throws(IOException::class, ContactsStorageException::class, CalendarStorageException::class)
override fun processSyncEntryImpl(cEntry: SyncEntry) { override fun processSyncEntryImpl(cEntry: SyncEntry) {
val inputReader = StringReader(cEntry.content) val inputReader = StringReader(cEntry.content)
@ -142,7 +171,7 @@ constructor(context: Context, account: Account, settings: AccountSettings, extra
val local = localCollection!!.findByUid(contact.uid!!) val local = localCollection!!.findByUid(contact.uid!!)
if (cEntry.isAction(SyncEntry.Actions.ADD) || cEntry.isAction(SyncEntry.Actions.CHANGE)) { if (cEntry.isAction(SyncEntry.Actions.ADD) || cEntry.isAction(SyncEntry.Actions.CHANGE)) {
processContact(contact, local) legacyProcessContact(contact, local)
} else { } else {
if (local != null) { if (local != null) {
Logger.log.info("Removing local record which has been deleted on the server") 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) @Throws(IOException::class, ContactsStorageException::class)
private fun processContact(newData: Contact, _local: LocalAddress?): LocalAddress { private fun legacyProcessContact(newData: Contact, _local: LocalAddress?): LocalAddress {
var local = _local var local = _local
val uuid = newData.uid val uuid = newData.uid
// update local contact, if it exists // update local contact, if it exists

@ -22,11 +22,14 @@ import androidx.core.app.NotificationManagerCompat
import androidx.core.util.Pair import androidx.core.util.Pair
import at.bitfire.ical4android.CalendarStorageException import at.bitfire.ical4android.CalendarStorageException
import at.bitfire.vcard4android.ContactsStorageException 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.syncadapter.*
import com.etesync.journalmanager.Crypto import com.etesync.journalmanager.Crypto
import com.etesync.journalmanager.Exceptions import com.etesync.journalmanager.Exceptions
import com.etesync.journalmanager.JournalManager import com.etesync.journalmanager.JournalManager
import com.etesync.syncadapter.*
import com.etesync.syncadapter.log.Logger import com.etesync.syncadapter.log.Logger
import com.etesync.syncadapter.model.CollectionInfo import com.etesync.syncadapter.model.CollectionInfo
import com.etesync.syncadapter.model.JournalEntity import com.etesync.syncadapter.model.JournalEntity
@ -96,8 +99,8 @@ abstract class SyncAdapterService : Service() {
} }
abstract class SyncAdapter(context: Context) : AbstractThreadedSyncAdapter(context, false) { abstract class SyncAdapter(context: Context) : AbstractThreadedSyncAdapter(context, false) {
abstract val syncErrorTitle: Int private val syncErrorTitle: Int = R.string.sync_error_generic
abstract val notificationManager: SyncNotification private val notificationManager = SyncNotification(context, "refresh-collections", Constants.NOTIFICATION_REFRESH_COLLECTIONS)
abstract fun onPerformSyncDo(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) 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) { } catch (e: Exceptions.ServiceUnavailableException) {
syncResult.stats.numIoExceptions++ syncResult.stats.numIoExceptions++
syncResult.delayUntil = if (e.retryAfter > 0) e.retryAfter else Constants.DEFAULT_RETRY_DELAY 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) { } catch (e: Exceptions.IgnorableHttpException) {
// Ignore // Ignore
} catch (e: Exception) { } catch (e: Exception) {
@ -132,7 +141,7 @@ abstract class SyncAdapterService : Service() {
val detailsIntent = notificationManager.detailsIntent val detailsIntent = notificationManager.detailsIntent
detailsIntent.putExtra(Constants.KEY_ACCOUNT, account) 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_AUTHORITY, authority)
detailsIntent.putExtra(DebugInfoActivity.KEY_PHASE, syncPhase) detailsIntent.putExtra(DebugInfoActivity.KEY_PHASE, syncPhase)
} }
@ -208,30 +217,70 @@ abstract class SyncAdapterService : Service() {
val settings = AccountSettings(context, account) val settings = AccountSettings(context, account)
val httpClient = HttpClient.Builder(context, settings).setForeground(false).build() val httpClient = HttpClient.Builder(context, settings).setForeground(false).build()
val journalsManager = JournalManager(httpClient.okHttpClient, settings.uri?.toHttpUrlOrNull()!!) if (settings.isLegacy) {
val journalsManager = JournalManager(httpClient.okHttpClient, settings.uri?.toHttpUrlOrNull()!!)
var journals = journalFetcher.list(journalsManager, settings, serviceType)
var journals = journalFetcher.list(journalsManager, settings, serviceType)
if (journals.isEmpty()) {
journals = LinkedList() if (journals.isEmpty()) {
try { journals = LinkedList()
val info = CollectionInfo.defaultForServiceType(serviceType) try {
val uid = JournalManager.Journal.genUid() val info = CollectionInfo.defaultForServiceType(serviceType)
info.uid = uid val uid = JournalManager.Journal.genUid()
val crypto = Crypto.CryptoManager(info.version, settings.password(), uid) info.uid = uid
val journal = JournalManager.Journal(crypto, info.toJson(), uid) val crypto = Crypto.CryptoManager(info.version, settings.password(), uid)
journalsManager.create(journal) val journal = JournalManager.Journal(crypto, info.toJson(), uid)
journals.add(Pair(journal, info)) journalsManager.create(journal)
} catch (e: Exceptions.AssociateNotAllowedException) { journals.add(Pair(journal, info))
// Skip for now } 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() 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 data = (context.applicationContext as App).data
val service = JournalModel.Service.fetchOrCreate(data, account.name, serviceType) val service = JournalModel.Service.fetchOrCreate(data, account.name, serviceType)
@ -269,5 +318,6 @@ abstract class SyncAdapterService : Service() {
companion object { companion object {
val journalFetcher = CachedJournalFetcher() 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.CalendarStorageException
import at.bitfire.ical4android.InvalidCalendarException import at.bitfire.ical4android.InvalidCalendarException
import at.bitfire.vcard4android.ContactsStorageException 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.*
import com.etesync.syncadapter.Constants.KEY_ACCOUNT import com.etesync.syncadapter.Constants.KEY_ACCOUNT
import com.etesync.journalmanager.Crypto 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.log.Logger
import com.etesync.syncadapter.model.* import com.etesync.syncadapter.model.*
import com.etesync.journalmanager.model.SyncEntry.Actions.ADD 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.resource.*
import com.etesync.syncadapter.ui.AccountsActivity import com.etesync.syncadapter.ui.AccountsActivity
import com.etesync.syncadapter.ui.DebugInfoActivity import com.etesync.syncadapter.ui.DebugInfoActivity
import com.etesync.syncadapter.ui.ViewCollectionActivity import com.etesync.syncadapter.ui.ViewCollectionActivity
import com.etesync.syncadapter.ui.etebase.CollectionActivity
import org.jetbrains.anko.defaultSharedPreferences import org.jetbrains.anko.defaultSharedPreferences
import java.io.Closeable import java.io.Closeable
import java.io.FileNotFoundException import java.io.FileNotFoundException
@ -41,21 +49,35 @@ import kotlin.concurrent.withLock
abstract class SyncManager<T: LocalResource<*>> @Throws(Exceptions.IntegrityException::class, Exceptions.GenericCryptoException::class) 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 { 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 notificationManager: SyncNotification
protected val info: CollectionInfo protected lateinit var info: CollectionInfo
protected var localCollection: LocalCollection<T>? = null protected var localCollection: LocalCollection<T>? = null
protected var httpClient: HttpClient 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 protected var journal: JournalEntryManager? = null
private var _journalEntity: JournalEntity? = null private var _journalEntity: JournalEntity? = null
private var numDiscarded = 0 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. * 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 // create HttpClient with given logger
httpClient = HttpClient.Builder(context, settings).setForeground(false).build() httpClient = HttpClient.Builder(context, settings).setForeground(false).build()
data = (context.applicationContext as App).data if (isLegacy) {
val serviceEntity = JournalModel.Service.fetchOrCreate(data, accountName, serviceType) data = (context.applicationContext as App).data
info = JournalEntity.fetch(data, serviceEntity, journalUid)!!.info val serviceEntity = JournalModel.Service.fetchOrCreate(data, accountName, serviceType)
info = JournalEntity.fetch(data, serviceEntity, journalUid)!!.info
// dismiss previous error notifications Logger.log.info(String.format(Locale.getDefault(), "Syncing collection %s (version: %d)", journalUid, info.version))
notificationManager = SyncNotification(context, journalUid, notificationId())
notificationManager.cancel()
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) { } else {
crypto = Crypto.CryptoManager(info.version, settings.keyPair!!, journalEntity.encryptedKey) crypto = Crypto.CryptoManager(info.version, settings.password(), info.uid!!)
}
} else { } 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 protected abstract fun notificationId(): Int
@ -114,6 +146,10 @@ constructor(protected val context: Context, protected val account: Account, prot
@TargetApi(21) @TargetApi(21)
fun performSync() { fun performSync() {
syncItemsTotal = 0
syncItemsDeleted = 0
syncItemsChanged = 0
var syncPhase = R.string.sync_phase_prepare var syncPhase = R.string.sync_phase_prepare
try { try {
Logger.log.info("Sync phase: " + context.getString(syncPhase)) 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)) Logger.log.info("Sync phase: " + context.getString(syncPhase))
prepareFetch() prepareFetch()
do { if (isLegacy) {
if (Thread.interrupted()) do {
throw InterruptedException() if (Thread.interrupted())
syncPhase = R.string.sync_phase_fetch_entries throw InterruptedException()
Logger.log.info("Sync phase: " + context.getString(syncPhase)) syncPhase = R.string.sync_phase_fetch_entries
fetchEntries() Logger.log.info("Sync phase: " + context.getString(syncPhase))
fetchEntries()
if (Thread.interrupted())
throw InterruptedException() if (Thread.interrupted())
syncPhase = R.string.sync_phase_apply_remote_entries throw InterruptedException()
Logger.log.info("Sync phase: " + context.getString(syncPhase)) syncPhase = R.string.sync_phase_apply_remote_entries
applyRemoteEntries() Logger.log.info("Sync phase: " + context.getString(syncPhase))
} while (remoteEntries!!.size == MAX_FETCH) applyRemoteEntries()
} while (remoteEntries!!.size == MAX_FETCH)
do {
if (Thread.interrupted()) do {
throw InterruptedException() if (Thread.interrupted())
syncPhase = R.string.sync_phase_prepare_local throw InterruptedException()
Logger.log.info("Sync phase: " + context.getString(syncPhase)) syncPhase = R.string.sync_phase_prepare_local
prepareLocal() Logger.log.info("Sync phase: " + context.getString(syncPhase))
prepareLocal()
/* Create journal entries out of local changes. */
if (Thread.interrupted()) /* Create journal entries out of local changes. */
throw InterruptedException() if (Thread.interrupted())
syncPhase = R.string.sync_phase_create_local_entries throw InterruptedException()
Logger.log.info("Sync phase: " + context.getString(syncPhase)) syncPhase = R.string.sync_phase_create_local_entries
createLocalEntries() Logger.log.info("Sync phase: " + context.getString(syncPhase))
createLocalEntries()
if (Thread.interrupted())
throw InterruptedException() if (Thread.interrupted())
syncPhase = R.string.sync_phase_apply_local_entries throw InterruptedException()
Logger.log.info("Sync phase: " + context.getString(syncPhase)) syncPhase = R.string.sync_phase_apply_local_entries
/* FIXME: Skipping this now, because we already override with remote. Logger.log.info("Sync phase: " + context.getString(syncPhase))
applyLocalEntries(); /* FIXME: Skipping this now, because we already override with remote.
*/ applyLocalEntries();
*/
if (Thread.interrupted())
throw InterruptedException() if (Thread.interrupted())
syncPhase = R.string.sync_phase_push_entries throw InterruptedException()
Logger.log.info("Sync phase: " + context.getString(syncPhase)) syncPhase = R.string.sync_phase_push_entries
pushEntries() Logger.log.info("Sync phase: " + context.getString(syncPhase))
} while (localEntries!!.size == MAX_PUSH) 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 */ /* Cleanup and finalize changes */
if (Thread.interrupted()) if (Thread.interrupted())
@ -187,6 +276,11 @@ constructor(protected val context: Context, protected val account: Account, prot
} catch (e: SSLHandshakeException) { } catch (e: SSLHandshakeException) {
syncResult.stats.numIoExceptions++ 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) notificationManager.setThrowable(e)
val detailsIntent = notificationManager.detailsIntent val detailsIntent = notificationManager.detailsIntent
detailsIntent.putExtra(KEY_ACCOUNT, account) detailsIntent.putExtra(KEY_ACCOUNT, account)
@ -197,15 +291,21 @@ constructor(protected val context: Context, protected val account: Account, prot
} catch (e: Exceptions.ServiceUnavailableException) { } catch (e: Exceptions.ServiceUnavailableException) {
syncResult.stats.numIoExceptions++ syncResult.stats.numIoExceptions++
syncResult.delayUntil = if (e.retryAfter > 0) e.retryAfter else Constants.DEFAULT_RETRY_DELAY 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) { } catch (e: InterruptedException) {
// Restart sync if interrupted // Restart sync if interrupted
syncResult.fullSyncRequested = true syncResult.fullSyncRequested = true
} catch (e: Exceptions.IgnorableHttpException) { } catch (e: Exceptions.IgnorableHttpException) {
// Ignore // Ignore
} catch (e: Exception) { } catch (e: Exception) {
if (e is Exceptions.UnauthorizedException) { if (e is Exceptions.UnauthorizedException || e is UnauthorizedException) {
syncResult.stats.numAuthExceptions++ syncResult.stats.numAuthExceptions++
} else if (e is Exceptions.HttpException) { } else if (e is Exceptions.HttpException || e is HttpException) {
syncResult.stats.numParseExceptions++ syncResult.stats.numParseExceptions++
} else if (e is CalendarStorageException || e is ContactsStorageException) { } else if (e is CalendarStorageException || e is ContactsStorageException) {
syncResult.databaseError = true syncResult.databaseError = true
@ -241,38 +341,28 @@ constructor(protected val context: Context, protected val account: Account, prot
private fun notifyUserOnSync() { private fun notifyUserOnSync() {
val changeNotification = context.defaultSharedPreferences.getBoolean(App.CHANGE_NOTIFICATION, true) val changeNotification = context.defaultSharedPreferences.getBoolean(App.CHANGE_NOTIFICATION, true)
if (remoteEntries!!.isEmpty() || !changeNotification) {
if (!changeNotification || (syncItemsTotal == 0)) {
return return
} }
val notificationHelper = SyncNotification(context, val notificationHelper = SyncNotification(context,
System.currentTimeMillis().toString(), notificationId()) 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 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, notificationHelper.notify(syncSuccessfullyTitle,
String.format(context.getString(R.string.sync_successfully_modified), String.format(context.getString(R.string.sync_successfully_modified),
resources.getQuantityString(R.plurals.sync_successfully, resources.getQuantityString(R.plurals.sync_successfully,
remoteEntries!!.size, remoteEntries!!.size)), syncItemsTotal, syncItemsTotal)),
String.format(context.getString(R.string.sync_successfully_modified_full), String.format(context.getString(R.string.sync_successfully_modified_full),
resources.getQuantityString(R.plurals.sync_successfully, resources.getQuantityString(R.plurals.sync_successfully,
added, added), syncItemsChanged, syncItemsChanged),
resources.getQuantityString(R.plurals.sync_successfully, resources.getQuantityString(R.plurals.sync_successfully,
changed, changed), syncItemsDeleted, syncItemsDeleted)),
resources.getQuantityString(R.plurals.sync_successfully,
deleted, deleted)),
intent) intent)
} }
@ -287,6 +377,25 @@ constructor(protected val context: Context, protected val account: Account, prot
return true 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) @Throws(IOException::class, ContactsStorageException::class, CalendarStorageException::class, InvalidCalendarException::class)
protected abstract fun processSyncEntryImpl(cEntry: SyncEntry) protected abstract fun processSyncEntryImpl(cEntry: SyncEntry)
@ -317,38 +426,54 @@ constructor(protected val context: Context, protected val account: Account, prot
throw e 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) @Throws(IOException::class, CalendarStorageException::class, ContactsStorageException::class)
protected fun applyLocalEntries() { protected fun prepareFetch() {
// FIXME: Need a better strategy if (isLegacy) {
// We re-apply local entries so our changes override whatever was written in the remote. remoteCTag = journalEntity.getLastUid(data)
val strTotal = localEntries!!.size.toString() } 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 var i = 0
for (entry in localEntries!!) { for (item in items) {
if (Thread.interrupted()) { if (Thread.interrupted()) {
throw InterruptedException() throw InterruptedException()
} }
i++ 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) processItem(item)
if (cEntry.isAction(SyncEntry.Actions.DELETE)) { persistItem(item)
continue
}
Logger.log.info("Processing resource for journal entry")
processSyncEntry(cEntry)
} }
} }
@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) @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() val count = data.count(EntryEntity::class.java).where(EntryEntity.JOURNAL.eq(journalEntity)).get().value()
if (remoteCTag != null && count == 0) { if (remoteCTag != null && count == 0) {
// If we are updating an existing installation with no saved journal, we need to add // 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) @Throws(IOException::class, ContactsStorageException::class, CalendarStorageException::class, InvalidCalendarException::class, InterruptedException::class)
protected fun applyRemoteEntries() { private fun applyRemoteEntries() {
// Process new vcards from server // Process new vcards from server
val strTotal = remoteEntries!!.size.toString() val strTotal = remoteEntries!!.size.toString()
var i = 0 var i = 0
syncItemsTotal += remoteEntries!!.size
for (entry in remoteEntries!!) { for (entry in remoteEntries!!) {
if (Thread.interrupted()) { if (Thread.interrupted()) {
throw InterruptedException() 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) @Throws(Exceptions.HttpException::class, IOException::class, ContactsStorageException::class, CalendarStorageException::class)
protected fun pushEntries() { private fun pushEntries() {
// upload dirty contacts // upload dirty contacts
var pushed = 0 var pushed = 0
// FIXME: Deal with failure (someone else uploaded before we go here) // 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) @Throws(CalendarStorageException::class, ContactsStorageException::class, IOException::class)
protected open fun createLocalEntries() { private fun createLocalEntries() {
localEntries = LinkedList() localEntries = LinkedList()
// Not saving, just creating a fake one until we load it from a local db // 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) @Throws(CalendarStorageException::class, ContactsStorageException::class, FileNotFoundException::class)
protected fun prepareLocal() { protected open fun prepareLocal() {
localDeleted = processLocallyDeleted() localDeleted = processLocallyDeleted()
localDirty = localCollection!!.findDirty(MAX_PUSH) localDirty = localCollection!!.findDirty(MAX_PUSH)
// This is done after fetching the local dirty so all the ones we are using will be prepared // 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 localList = localCollection!!.findDeleted()
val ret = ArrayList<T>(localList.size) 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) { for (local in localList) {
Logger.log.info("Restoring locally deleted resource on a read only collection: ${local.uuid}") Logger.log.info("Restoring locally deleted resource on a read only collection: ${local.uuid}")
local.resetDeleted() local.resetDeleted()
@ -541,8 +789,11 @@ constructor(protected val context: Context, protected val account: Account, prot
if (local.uuid != null) { if (local.uuid != null) {
Logger.log.info(local.uuid + " has been deleted locally -> deleting from server") Logger.log.info(local.uuid + " has been deleted locally -> deleting from server")
} else { } else {
Logger.log.fine("Entry deleted before ever syncing - genarting a UUID") if (isLegacy) {
local.prepareForUpload() // It's done later for non-legacy
Logger.log.fine("Entry deleted before ever syncing - genarting a UUID")
local.legacyPrepareForUpload(null)
}
} }
ret.add(local) ret.add(local)
@ -556,20 +807,22 @@ constructor(protected val context: Context, protected val account: Account, prot
@Throws(CalendarStorageException::class, ContactsStorageException::class) @Throws(CalendarStorageException::class, ContactsStorageException::class)
protected open fun prepareDirty() { protected open fun prepareDirty() {
if (journalEntity.isReadOnly) { val readOnly = (isLegacy && journalEntity.isReadOnly) || (!isLegacy && (cachedCollection.col.accessLevel == CollectionAccessLevel.ReadOnly))
if (readOnly) {
for (local in localDirty) { for (local in localDirty) {
Logger.log.info("Restoring locally modified resource on a read only collection: ${local.uuid}") Logger.log.info("Restoring locally modified resource on a read only collection: ${local.uuid}")
if (local.uuid == null) { if (local.uuid == null) {
// If it was only local, delete. // If it was only local, delete.
local.delete() local.delete()
} else { } else {
local.clearDirty(local.uuid!!) local.clearDirty(null)
} }
numDiscarded++ numDiscarded++
} }
localDirty = LinkedList() localDirty = LinkedList()
} else { } else if (isLegacy) {
// It's done later for non-legacy
// assign file names and UIDs to new entries // assign file names and UIDs to new entries
Logger.log.info("Looking for local entries without a uuid") Logger.log.info("Looking for local entries without a uuid")
for (local in localDirty) { 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") 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 androidx.core.app.NotificationManagerCompat
import at.bitfire.ical4android.CalendarStorageException import at.bitfire.ical4android.CalendarStorageException
import at.bitfire.vcard4android.ContactsStorageException import at.bitfire.vcard4android.ContactsStorageException
import com.etebase.client.exceptions.*
import com.etesync.syncadapter.AccountSettings import com.etesync.syncadapter.AccountSettings
import com.etesync.syncadapter.Constants import com.etesync.syncadapter.Constants
import com.etesync.syncadapter.R import com.etesync.syncadapter.R
@ -27,7 +28,8 @@ class SyncNotification(internal val context: Context, internal val notificationT
internal val notificationManager: NotificationManagerCompat internal val notificationManager: NotificationManagerCompat
lateinit var detailsIntent: Intent lateinit var detailsIntent: Intent
internal set internal set
internal var messageString: Int = 0 internal var messageInt: Int = 0
internal var messageString: String? = null
private var throwable: Throwable? = null private var throwable: Throwable? = null
@ -37,30 +39,33 @@ class SyncNotification(internal val context: Context, internal val notificationT
fun setThrowable(e: Throwable) { fun setThrowable(e: Throwable) {
throwable = e throwable = e
if (e is Exceptions.UnauthorizedException) { if (e is Exceptions.UnauthorizedException || e is UnauthorizedException) {
Logger.log.log(Level.SEVERE, "Not authorized anymore", e) 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) { } else if (e is Exceptions.UserInactiveException) {
Logger.log.log(Level.SEVERE, "User inactive") Logger.log.log(Level.SEVERE, "User inactive")
messageString = R.string.sync_error_user_inactive messageInt = R.string.sync_error_user_inactive
} else if (e is Exceptions.ServiceUnavailableException) { } else if (e is Exceptions.ServiceUnavailableException || e is TemporaryServerErrorException) {
Logger.log.log(Level.SEVERE, "Service unavailable") 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) { } else if (e is Exceptions.ReadOnlyException) {
Logger.log.log(Level.SEVERE, "Journal is read only", e) Logger.log.log(Level.SEVERE, "Journal is read only", e)
messageString = R.string.sync_error_journal_readonly messageInt = R.string.sync_error_journal_readonly
} else if (e is Exceptions.HttpException) { } 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) 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) { } else if (e is CalendarStorageException || e is ContactsStorageException || e is SQLiteException) {
Logger.log.log(Level.SEVERE, "Couldn't access local storage", e) 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) { } else if (e is Exceptions.IntegrityException) {
Logger.log.log(Level.SEVERE, "Integrity error", e) Logger.log.log(Level.SEVERE, "Integrity error", e)
messageString = R.string.sync_error_integrity messageInt = R.string.sync_error_integrity
} else { } else {
Logger.log.log(Level.SEVERE, "Unknown sync error", e) 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) 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) { fun notify(title: String, state: String) {
val message = context.getString(messageString, state) val message = messageString ?: context.getString(messageInt, state)
notify(title, message, null, detailsIntent) notify(title, message, null, detailsIntent)
} }

@ -18,10 +18,7 @@ import android.os.Bundle
import at.bitfire.ical4android.AndroidTaskList import at.bitfire.ical4android.AndroidTaskList
import at.bitfire.ical4android.TaskProvider import at.bitfire.ical4android.TaskProvider
import at.bitfire.ical4android.TaskProvider.ProviderName import at.bitfire.ical4android.TaskProvider.ProviderName
import com.etesync.syncadapter.AccountSettings import com.etesync.syncadapter.*
import com.etesync.syncadapter.App
import com.etesync.syncadapter.Constants
import com.etesync.syncadapter.R
import com.etesync.syncadapter.log.Logger import com.etesync.syncadapter.log.Logger
import com.etesync.syncadapter.model.CollectionInfo import com.etesync.syncadapter.model.CollectionInfo
import com.etesync.syncadapter.model.JournalEntity import com.etesync.syncadapter.model.JournalEntity
@ -42,9 +39,6 @@ class TasksSyncAdapterService: SyncAdapterService() {
context: Context, context: Context,
private val name: ProviderName private val name: ProviderName
): SyncAdapter(context) { ): 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) { override fun onPerformSyncDo(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
val taskProvider = TaskProvider.fromProviderClient(context, provider, name) val taskProvider = TaskProvider.fromProviderClient(context, provider, name)
@ -63,8 +57,11 @@ class TasksSyncAdapterService: SyncAdapterService() {
RefreshCollections(account, CollectionInfo.Type.TASKS).run() 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()!! val principal = accountSettings.uri?.toHttpUrlOrNull()!!
for (taskList in AndroidTaskList.find(account, taskProvider, LocalTaskList.Factory, "${TaskContract.TaskLists.SYNC_ENABLED}!=0", null)) { 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) { 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 val data = (context.applicationContext as App).data
var service = JournalModel.Service.fetchOrCreate(data, account.name, CollectionInfo.Type.TASKS) 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.content.SyncResult
import android.os.Bundle import android.os.Bundle
import at.bitfire.ical4android.Task import at.bitfire.ical4android.Task
import com.etebase.client.Item
import com.etesync.syncadapter.AccountSettings import com.etesync.syncadapter.AccountSettings
import com.etesync.syncadapter.Constants import com.etesync.syncadapter.Constants
import com.etesync.syncadapter.R import com.etesync.syncadapter.R
@ -43,7 +44,7 @@ class TasksSyncManager(
get() = context.getString(R.string.sync_error_tasks, account.name) get() = context.getString(R.string.sync_error_tasks, account.name)
override val syncSuccessfullyTitle: String 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) account.name)
init { init {
@ -58,7 +59,9 @@ class TasksSyncManager(
if (!super.prepare()) if (!super.prepare())
return false return false
journal = JournalEntryManager(httpClient.okHttpClient, remote, localTaskList().url!!) if (isLegacy) {
journal = JournalEntryManager(httpClient.okHttpClient, remote, localTaskList().url!!)
}
return true return true
} }
@ -68,6 +71,32 @@ class TasksSyncManager(
return localCollection as LocalTaskList 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) { override fun processSyncEntryImpl(cEntry: SyncEntry) {
val inputReader = StringReader(cEntry.content) val inputReader = StringReader(cEntry.content)
@ -83,7 +112,7 @@ class TasksSyncManager(
val local = localCollection!!.findByUid(event.uid!!) val local = localCollection!!.findByUid(event.uid!!)
if (cEntry.isAction(SyncEntry.Actions.ADD) || cEntry.isAction(SyncEntry.Actions.CHANGE)) { if (cEntry.isAction(SyncEntry.Actions.ADD) || cEntry.isAction(SyncEntry.Actions.CHANGE)) {
processTask(event, local) legacyProcessTask(event, local)
} else { } else {
if (local != null) { if (local != null) {
Logger.log.info("Removing local record #" + local.id + " which has been deleted on the server") 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 var localTask = _localTask
// delete local Task, if it exists // delete local Task, if it exists
if (localTask != null) { if (localTask != null) {

@ -25,20 +25,27 @@ import android.widget.*
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import at.bitfire.ical4android.TaskProvider
import at.bitfire.ical4android.TaskProvider.Companion.OPENTASK_PROVIDERS import at.bitfire.ical4android.TaskProvider.Companion.OPENTASK_PROVIDERS
import at.bitfire.vcard4android.ContactsStorageException 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.Crypto
import com.etesync.journalmanager.Exceptions import com.etesync.journalmanager.Exceptions
import com.etesync.journalmanager.JournalAuthenticator import com.etesync.journalmanager.JournalAuthenticator
import com.etesync.syncadapter.*
import com.etesync.syncadapter.Constants.*
import com.etesync.syncadapter.log.Logger import com.etesync.syncadapter.log.Logger
import com.etesync.syncadapter.model.CollectionInfo import com.etesync.syncadapter.model.CollectionInfo
import com.etesync.syncadapter.model.JournalEntity import com.etesync.syncadapter.model.JournalEntity
import com.etesync.syncadapter.model.MyEntityDataStore
import com.etesync.syncadapter.model.ServiceEntity import com.etesync.syncadapter.model.ServiceEntity
import com.etesync.syncadapter.resource.LocalAddressBook import com.etesync.syncadapter.resource.LocalAddressBook
import com.etesync.syncadapter.resource.LocalCalendar import com.etesync.syncadapter.resource.LocalCalendar
import com.etesync.syncadapter.syncadapter.requestSync 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.ui.setup.SetupUserInfoFragment
import com.etesync.syncadapter.utils.HintManager import com.etesync.syncadapter.utils.HintManager
import com.etesync.syncadapter.utils.ShowcaseBuilder 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 { class AccountActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, PopupMenu.OnMenuItemClickListener, LoaderManager.LoaderCallbacks<AccountActivity.AccountInfo>, Refreshable {
private lateinit var account: Account private lateinit var account: Account
private lateinit var settings: AccountSettings
private var accountInfo: AccountInfo? = null private var accountInfo: AccountInfo? = null
internal var listCalDAV: ListView? = null internal var listCalDAV: ListView? = null
@ -64,22 +72,30 @@ class AccountActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, PopupMe
private val onItemClickListener = AdapterView.OnItemClickListener { parent, view, position, _ -> private val onItemClickListener = AdapterView.OnItemClickListener { parent, view, position, _ ->
val list = parent as ListView val list = parent as ListView
val adapter = list.adapter as ArrayAdapter<*> val adapter = list.adapter as ArrayAdapter<*>
val journalEntity = adapter.getItem(position) as JournalEntity val info = adapter.getItem(position) as CollectionListItemInfo
val info = journalEntity.getInfo()
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? private val formattedFingerprint: String?
get() { get() {
try { try {
val settings = AccountSettings(this, account) if (settings.isLegacy) {
return Crypto.AsymmetricCryptoManager.getPrettyKeyFingerprint(settings.keyPair!!.publicKey) 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) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
return null return e.localizedMessage
} }
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -87,6 +103,7 @@ class AccountActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, PopupMe
account = intent.getParcelableExtra(EXTRA_ACCOUNT) account = intent.getParcelableExtra(EXTRA_ACCOUNT)
title = account.name title = account.name
settings = AccountSettings(this, account)
setContentView(R.layout.activity_account) setContentView(R.layout.activity_account)
@ -131,13 +148,19 @@ class AccountActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, PopupMe
HintManager.setHintSeen(this, HINT_VIEW_COLLECTION, true) HintManager.setHintSeen(this, HINT_VIEW_COLLECTION, true)
} }
if (!SetupUserInfoFragment.hasUserInfo(this, account)) { if (settings.isLegacy) {
SetupUserInfoFragment.newInstance(account).show(supportFragmentManager, null) if (!SetupUserInfoFragment.hasUserInfo(this, account)) {
SetupUserInfoFragment.newInstance(account).show(supportFragmentManager, null)
}
} }
} }
override fun onCreateOptionsMenu(menu: Menu): Boolean { override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.activity_account, menu) menuInflater.inflate(R.menu.activity_account, menu)
if (settings.isLegacy) {
val invitations = menu.findItem(R.id.invitations)
invitations.setVisible(false)
}
return true return true
} }
@ -167,6 +190,10 @@ class AccountActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, PopupMe
.setPositiveButton(android.R.string.yes) { _, _ -> }.create() .setPositiveButton(android.R.string.yes) { _, _ -> }.create()
dialog.show() dialog.show()
} }
R.id.invitations -> {
val intent = InvitationsActivity.newIntent(this, account)
startActivity(intent)
}
else -> return super.onOptionsItemSelected(item) else -> return super.onOptionsItemSelected(item)
} }
return true return true
@ -195,19 +222,31 @@ class AccountActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, PopupMe
val info: CollectionInfo val info: CollectionInfo
when (item.itemId) { when (item.itemId) {
R.id.create_calendar -> { R.id.create_calendar -> {
info = CollectionInfo() if (settings.isLegacy) {
info.enumType = CollectionInfo.Type.CALENDAR info = CollectionInfo()
startActivity(CreateCollectionActivity.newIntent(this@AccountActivity, account, info)) 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 -> { R.id.create_tasklist -> {
info = CollectionInfo() if (settings.isLegacy) {
info.enumType = CollectionInfo.Type.TASKS info = CollectionInfo()
startActivity(CreateCollectionActivity.newIntent(this@AccountActivity, account, info)) 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 -> { R.id.create_addressbook -> {
info = CollectionInfo() if (settings.isLegacy) {
info.enumType = CollectionInfo.Type.ADDRESS_BOOK info = CollectionInfo()
startActivity(CreateCollectionActivity.newIntent(this@AccountActivity, account, info)) 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 -> { R.id.install_tasksorg -> {
installPackage(tasksOrgPackage) installPackage(tasksOrgPackage)
@ -227,10 +266,9 @@ class AccountActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, PopupMe
internal var taskdav: ServiceInfo? = null internal var taskdav: ServiceInfo? = null
class ServiceInfo { class ServiceInfo {
internal var id: Long = 0
internal var refreshing: Boolean = false 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) listCardDAV!!.setAlpha(if (info.carddav!!.refreshing) 0.5f else 1f)
val adapter = CollectionListAdapter(this, account) val adapter = CollectionListAdapter(this, account)
adapter.addAll(info.carddav!!.journals!!) adapter.addAll(info.carddav!!.infos!!)
listCardDAV!!.adapter = adapter listCardDAV!!.adapter = adapter
listCardDAV!!.onItemClickListener = onItemClickListener listCardDAV!!.onItemClickListener = onItemClickListener
} }
@ -268,7 +306,7 @@ class AccountActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, PopupMe
listCalDAV!!.setAlpha(if (info.caldav!!.refreshing) 0.5f else 1f) listCalDAV!!.setAlpha(if (info.caldav!!.refreshing) 0.5f else 1f)
val adapter = CollectionListAdapter(this, account) val adapter = CollectionListAdapter(this, account)
adapter.addAll(info.caldav!!.journals!!) adapter.addAll(info.caldav!!.infos!!)
listCalDAV!!.adapter = adapter listCalDAV!!.adapter = adapter
listCalDAV!!.onItemClickListener = onItemClickListener listCalDAV!!.onItemClickListener = onItemClickListener
} }
@ -282,7 +320,7 @@ class AccountActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, PopupMe
listTaskDAV!!.setAlpha(if (info.taskdav!!.refreshing) 0.5f else 1f) listTaskDAV!!.setAlpha(if (info.taskdav!!.refreshing) 0.5f else 1f)
val adapter = CollectionListAdapter(this, account) val adapter = CollectionListAdapter(this, account)
adapter.addAll(info.taskdav!!.journals!!) adapter.addAll(info.taskdav!!.infos!!)
listTaskDAV!!.adapter = adapter listTaskDAV!!.adapter = adapter
listTaskDAV!!.onItemClickListener = onItemClickListener listTaskDAV!!.onItemClickListener = onItemClickListener
@ -342,50 +380,121 @@ class AccountActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, PopupMe
forceLoad() 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 { override fun loadInBackground(): AccountInfo {
val info = 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 -> {
CollectionInfo.Type.CALENDAR -> { info.caldav = AccountInfo.ServiceInfo()
info.caldav = AccountInfo.ServiceInfo() info.caldav!!.refreshing = davService != null && davService!!.isRefreshing(id) ||
info.caldav!!.id = id ContentResolver.isSyncActive(account, CalendarContract.AUTHORITY)
info.caldav!!.refreshing = davService != null && davService!!.isRefreshing(id) || info.caldav!!.infos = getLegacyJournals(data, serviceEntity)
ContentResolver.isSyncActive(account, CalendarContract.AUTHORITY) }
info.caldav!!.journals = JournalEntity.getJournals(data, serviceEntity) CollectionInfo.Type.TASKS -> {
} info.taskdav = AccountInfo.ServiceInfo()
CollectionInfo.Type.TASKS -> { info.taskdav!!.refreshing = davService != null && davService!!.isRefreshing(id) ||
info.taskdav = AccountInfo.ServiceInfo() OPENTASK_PROVIDERS.any {
info.taskdav!!.id = id ContentResolver.isSyncActive(account, it.authority)
info.taskdav!!.refreshing = davService != null && davService!!.isRefreshing(id) || }
OPENTASK_PROVIDERS.any { info.taskdav!!.infos = getLegacyJournals(data, serviceEntity)
ContentResolver.isSyncActive(account, it.authority) }
}
info.taskdav!!.journals = JournalEntity.getJournals(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 return info
} }
} }
@ -393,15 +502,16 @@ class AccountActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, PopupMe
/* LIST ADAPTERS */ /* 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 { override fun getView(position: Int, _v: View?, parent: ViewGroup): View {
var v = _v var v = _v
if (v == null) if (v == null)
v = LayoutInflater.from(context).inflate(R.layout.account_collection_item, parent, false) v = LayoutInflater.from(context).inflate(R.layout.account_collection_item, parent, false)
val journalEntity = getItem(position) val info = getItem(position)!!
val info = journalEntity!!.info
var tv = v!!.findViewById<View>(R.id.title) as TextView var tv = v!!.findViewById<View>(R.id.title) as TextView
tv.text = if (TextUtils.isEmpty(info.displayName)) info.uid else info.displayName 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) 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 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 shared.visibility = if (isOwner) View.GONE else View.VISIBLE
return v return v
@ -437,17 +547,32 @@ class AccountActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, PopupMe
private fun deleteAccount() { private fun deleteAccount() {
val accountManager = AccountManager.get(this) val accountManager = AccountManager.get(this)
val settings = AccountSettings(this@AccountActivity, account) val settings = AccountSettings(this@AccountActivity, account)
val authToken = settings.authToken
val principal = settings.uri?.toHttpUrlOrNull()
doAsync { doAsync {
try { if (settings.isLegacy) {
val httpClient = HttpClient.Builder(this@AccountActivity, null, authToken).build().okHttpClient val authToken = settings.authToken
val journalAuthenticator = JournalAuthenticator(httpClient, principal!!) val principal = settings.uri?.toHttpUrlOrNull()
journalAuthenticator.invalidateAuthToken(authToken)
} catch (e: Exceptions.HttpException) { try {
// Ignore failures for now val httpClient = HttpClient.Builder(this@AccountActivity, null, authToken).build().okHttpClient
Logger.log.warning(e.toString()) 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 { class AccountListFragment : ListFragment(), LoaderManager.LoaderCallbacks<Array<Account>>, AdapterView.OnItemClickListener {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { 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) return inflater.inflate(R.layout.account_list, container, false)
} }
@ -58,7 +58,7 @@ class AccountListFragment : ListFragment(), LoaderManager.LoaderCallbacks<Array<
// loader // loader
override fun onCreateLoader(id: Int, args: Bundle?): Loader<Array<Account>> { 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>) { override fun onLoadFinished(loader: Loader<Array<Account>>, accounts: Array<Account>) {

@ -17,12 +17,13 @@ import android.os.Bundle
import android.provider.CalendarContract import android.provider.CalendarContract
import android.text.TextUtils import android.text.TextUtils
import android.view.MenuItem import android.view.MenuItem
import android.widget.Toast
import androidx.core.app.NavUtils import androidx.core.app.NavUtils
import androidx.fragment.app.Fragment
import androidx.loader.app.LoaderManager import androidx.loader.app.LoaderManager
import androidx.loader.content.AsyncTaskLoader import androidx.loader.content.AsyncTaskLoader
import androidx.loader.content.Loader import androidx.loader.content.Loader
import androidx.preference.* import androidx.preference.*
import at.bitfire.ical4android.TaskProvider
import at.bitfire.ical4android.TaskProvider.Companion.OPENTASK_PROVIDERS import at.bitfire.ical4android.TaskProvider.Companion.OPENTASK_PROVIDERS
import com.etesync.syncadapter.* import com.etesync.syncadapter.*
import com.etesync.syncadapter.Constants.KEY_ACCOUNT import com.etesync.syncadapter.Constants.KEY_ACCOUNT
@ -43,7 +44,8 @@ class AccountSettingsActivity : BaseActivity() {
supportActionBar!!.setDisplayHomeAsUpEnabled(true) supportActionBar!!.setDisplayHomeAsUpEnabled(true)
if (savedInstanceState == null) { if (savedInstanceState == null) {
val frag = AccountSettingsFragment() val settings = AccountSettings(this, account)
val frag: Fragment = if (settings.isLegacy) LegacyAccountSettingsFragment() else AccountSettingsFragment()
frag.arguments = intent.extras frag.arguments = intent.extras
supportFragmentManager.beginTransaction() supportFragmentManager.beginTransaction()
.replace(android.R.id.content, frag) .replace(android.R.id.content, frag)
@ -60,139 +62,226 @@ class AccountSettingsActivity : BaseActivity() {
} else } else
return false return false
} }
}
class AccountSettingsFragment : PreferenceFragmentCompat(), LoaderManager.LoaderCallbacks<AccountSettings> { class AccountSettingsFragment() : PreferenceFragmentCompat(), LoaderManager.LoaderCallbacks<AccountSettings> {
internal lateinit var account: Account internal lateinit var account: Account
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) 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) { override fun onCreatePreferences(bundle: Bundle, s: String) {
addPreferencesFromResource(R.xml.settings_account) addPreferencesFromResource(R.xml.settings_account)
} }
override fun onCreateLoader(id: Int, args: Bundle?): Loader<AccountSettings> { override fun onCreateLoader(id: Int, args: Bundle?): Loader<AccountSettings> {
return AccountSettingsLoader(context!!, args!!.getParcelable(KEY_ACCOUNT) as Account) return AccountSettingsLoader(context!!, args!!.getParcelable(KEY_ACCOUNT) as Account)
} }
override fun onLoadFinished(loader: Loader<AccountSettings>, settings: AccountSettings?) { override fun onLoadFinished(loader: Loader<AccountSettings>, settings: AccountSettings?) {
if (settings == null) { if (settings == null) {
activity!!.finish() activity!!.finish()
return 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 // Category: encryption
val prefManageAccount = findPreference("manage_account") val prefEncryptionPassword = findPreference("password")
prefManageAccount.onPreferenceClickListener = Preference.OnPreferenceClickListener { _ -> prefEncryptionPassword.onPreferenceClickListener = Preference.OnPreferenceClickListener { _ ->
WebViewActivity.openUrl(activity!!, Constants.dashboard.buildUpon().appendQueryParameter("email", account.name).build()) startActivity(ChangeEncryptionPasswordActivity.newIntent(activity!!, account))
true true
} }
// category: authentication val prefSync = findPreference("sync_interval") as ListPreference
val prefPassword = findPreference("password") as EditTextPreference val syncInterval = settings.getSyncInterval(CalendarContract.AUTHORITY) // Calendar is the baseline interval
prefPassword.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> if (syncInterval != null) {
val credentials = if (newValue != null) LoginCredentials(settings.uri, account.name, newValue as String) else null prefSync.value = syncInterval.toString()
LoginCredentialsChangeFragment.newInstance(account, credentials!!).show(fragmentManager!!, null) 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) loaderManager.restartLoader(0, arguments, this@AccountSettingsFragment)
false false
} }
} else {
prefSync.isEnabled = false
prefSync.setSummary(R.string.settings_sync_summary_not_available)
}
// Category: encryption val prefWifiOnly = findPreference("sync_wifi_only") as SwitchPreferenceCompat
val prefEncryptionPassword = findPreference("encryption_password") prefWifiOnly.isChecked = settings.syncWifiOnly
prefEncryptionPassword.onPreferenceClickListener = Preference.OnPreferenceClickListener { _ -> prefWifiOnly.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, wifiOnly ->
startActivity(ChangeEncryptionPasswordActivity.newIntent(activity!!, account)) settings.setSyncWiFiOnly(wifiOnly as Boolean)
true loaderManager.restartLoader(0, arguments, this@AccountSettingsFragment)
} false
}
val prefSync = findPreference("sync_interval") as ListPreference val prefWifiOnlySSID = findPreference("sync_wifi_only_ssid") as EditTextPreference
val syncInterval = settings.getSyncInterval(CalendarContract.AUTHORITY) // Calendar is the baseline interval val onlySSID = settings.syncWifiOnlySSID
if (syncInterval != null) { prefWifiOnlySSID.text = onlySSID
prefSync.value = syncInterval.toString() if (onlySSID != null)
if (syncInterval == AccountSettings.SYNC_INTERVAL_MANUALLY) prefWifiOnlySSID.summary = getString(R.string.settings_sync_wifi_only_ssid_on, onlySSID)
prefSync.setSummary(R.string.settings_sync_summary_manually) else
else prefWifiOnlySSID.setSummary(R.string.settings_sync_wifi_only_ssid_off)
prefSync.summary = getString(R.string.settings_sync_summary_periodically, prefSync.entry) prefWifiOnlySSID.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
prefSync.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> val ssid = newValue as String
val newInterval = java.lang.Long.parseLong(newValue as String) settings.syncWifiOnlySSID = if (!TextUtils.isEmpty(ssid)) ssid else null
settings.setSyncInterval(App.addressBooksAuthority, newInterval) loaderManager.restartLoader(0, arguments, this@AccountSettingsFragment)
settings.setSyncInterval(CalendarContract.AUTHORITY, newInterval) false
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 prefWifiOnly = findPreference("sync_wifi_only") as SwitchPreferenceCompat override fun onLoaderReset(loader: Loader<AccountSettings>) {}
prefWifiOnly.isChecked = settings.syncWifiOnly
prefWifiOnly.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, wifiOnly ->
settings.setSyncWiFiOnly(wifiOnly as Boolean)
loaderManager.restartLoader(0, arguments, this@AccountSettingsFragment)
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) class LegacyAccountSettingsFragment : PreferenceFragmentCompat(), LoaderManager.LoaderCallbacks<AccountSettings> {
else internal lateinit var account: Account
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
}
}
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 { override fun onCreateLoader(id: Int, args: Bundle?): Loader<AccountSettings> {
internal lateinit var listenerHandle: Any return AccountSettingsLoader(context!!, args!!.getParcelable(KEY_ACCOUNT) as Account)
}
override fun onStartLoading() { override fun onLoadFinished(loader: Loader<AccountSettings>, settings: AccountSettings?) {
forceLoad() if (settings == null) {
listenerHandle = ContentResolver.addStatusChangeListener(ContentResolver.SYNC_OBSERVER_TYPE_SETTINGS, this) activity!!.finish()
return
} }
override fun onStopLoading() { // Category: dashboard
ContentResolver.removeStatusChangeListener(listenerHandle) val prefManageAccount = findPreference("manage_account")
prefManageAccount.onPreferenceClickListener = Preference.OnPreferenceClickListener { _ ->
WebViewActivity.openUrl(activity!!, Constants.dashboard.buildUpon().appendQueryParameter("email", account.name).build())
true
} }
override fun abandon() { // category: authentication
onStopLoading() 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? { // Category: encryption
val settings: AccountSettings val prefEncryptionPassword = findPreference("encryption_password")
try { prefEncryptionPassword.onPreferenceClickListener = Preference.OnPreferenceClickListener { _ ->
settings = AccountSettings(context, account) startActivity(ChangeEncryptionPasswordActivity.newIntent(activity!!, account))
} catch (e: InvalidAccountException) { true
return null }
}
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) { val prefWifiOnly = findPreference("sync_wifi_only") as SwitchPreferenceCompat
Logger.log.fine("Reloading account settings") prefWifiOnly.isChecked = settings.syncWifiOnly
forceLoad() 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.os.Bundle
import android.view.View import android.view.View
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import com.etebase.client.Client
import com.etesync.syncadapter.AccountSettings import com.etesync.syncadapter.AccountSettings
import com.etesync.syncadapter.HttpClient import com.etesync.syncadapter.HttpClient
import com.etesync.syncadapter.R import com.etesync.syncadapter.R
@ -53,7 +54,7 @@ open class ChangeEncryptionPasswordActivity : BaseActivity() {
AlertDialog.Builder(this) AlertDialog.Builder(this)
.setTitle(R.string.wrong_encryption_password) .setTitle(R.string.wrong_encryption_password)
.setIcon(R.drawable.ic_error_dark) .setIcon(R.drawable.ic_error_dark)
.setMessage(getString(R.string.wrong_encryption_password_content, e.localizedMessage)) .setMessage(e.localizedMessage)
.setPositiveButton(android.R.string.ok) { _, _ -> .setPositiveButton(android.R.string.ok) { _, _ ->
// dismiss // dismiss
}.show() }.show()
@ -62,6 +63,45 @@ open class ChangeEncryptionPasswordActivity : BaseActivity() {
fun changePasswordDo(old_password: String, new_password: String) { fun changePasswordDo(old_password: String, new_password: String) {
val settings = AccountSettings(this, account) 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 { doAsync {
val httpClient = HttpClient.Builder(this@ChangeEncryptionPasswordActivity, settings).setForeground(false).build().okHttpClient 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) { override fun onItemClick(parent: AdapterView<*>, view: View, position: Int, id: Long) {
val member = listAdapter?.getItem(position) as JournalManager.Member val member = listAdapter?.getItem(position) as JournalManager.Member
AlertDialog.Builder(activity!!) AlertDialog.Builder(requireActivity())
.setIcon(R.drawable.ic_info_dark) .setIcon(R.drawable.ic_info_dark)
.setTitle(R.string.collection_members_remove_title) .setTitle(R.string.collection_members_remove_title)
.setMessage(getString(R.string.collection_members_remove, member.user)) .setMessage(getString(R.string.collection_members_remove, member.user))
.setPositiveButton(android.R.string.yes) { dialog, which -> .setPositiveButton(android.R.string.yes) { dialog, which ->
val frag = RemoveMemberFragment.newInstance(account, info, member.user!!) val frag = RemoveMemberFragment.newInstance(account, info, member.user!!)
frag.show(fragmentManager!!, null) frag.show(requireFragmentManager(), null)
} }
.setNegativeButton(android.R.string.no) { dialog, which -> }.show() .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.model.CollectionInfo
import com.etesync.syncadapter.ui.BaseActivity import com.etesync.syncadapter.ui.BaseActivity
class ImportActivity : BaseActivity(), SelectImportMethod, ResultFragment.OnImportCallback, DialogInterface { class ImportActivity : BaseActivity(), SelectImportMethod, DialogInterface {
private lateinit var account: Account private lateinit var account: Account
protected lateinit var info: CollectionInfo protected lateinit var info: CollectionInfo
@ -83,13 +83,6 @@ class ImportActivity : BaseActivity(), SelectImportMethod, ResultFragment.OnImpo
return super.onKeyDown(keyCode, event) 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() { override fun cancel() {
finish() finish()
} }
@ -108,9 +101,9 @@ class ImportActivity : BaseActivity(), SelectImportMethod, ResultFragment.OnImpo
// This makes sure that the container activity has implemented // This makes sure that the container activity has implemented
// the callback interface. If not, it throws an exception // the callback interface. If not, it throws an exception
try { try {
mSelectImportMethod = activity as SelectImportMethod? mSelectImportMethod = activity as SelectImportMethod
} catch (e: ClassCastException) { } 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.CalendarContract
import android.provider.ContactsContract import android.provider.ContactsContract
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import androidx.fragment.app.commit
import at.bitfire.ical4android.* import at.bitfire.ical4android.*
import at.bitfire.ical4android.TaskProvider.Companion.OPENTASK_PROVIDERS
import at.bitfire.vcard4android.BatchOperation import at.bitfire.vcard4android.BatchOperation
import at.bitfire.vcard4android.Contact import at.bitfire.vcard4android.Contact
import at.bitfire.vcard4android.ContactsStorageException import at.bitfire.vcard4android.ContactsStorageException
import com.etesync.syncadapter.Constants.KEY_ACCOUNT import com.etesync.syncadapter.CachedCollection
import com.etesync.syncadapter.Constants.KEY_COLLECTION_INFO import com.etesync.syncadapter.Constants.*
import com.etesync.syncadapter.R import com.etesync.syncadapter.R
import com.etesync.syncadapter.log.Logger import com.etesync.syncadapter.log.Logger
import com.etesync.syncadapter.model.CollectionInfo import com.etesync.syncadapter.model.CollectionInfo
@ -35,19 +35,14 @@ import java.io.InputStream
import java.io.InputStreamReader 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 private var inputStream: InputStream? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
isCancelable = false isCancelable = false
retainInstance = true 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) { override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
@ -57,7 +52,7 @@ class ImportFragment : DialogFragment() {
} else { } else {
val data = ImportResult() val data = ImportResult()
data.e = Exception(getString(R.string.import_permission_required)) data.e = Exception(getString(R.string.import_permission_required))
(activity as ResultFragment.OnImportCallback).onImportResult(data) onImportResult(data)
dismissAllowingStateLoss() dismissAllowingStateLoss()
} }
@ -118,7 +113,7 @@ class ImportFragment : DialogFragment() {
intent.addCategory(Intent.CATEGORY_OPENABLE) intent.addCategory(Intent.CATEGORY_OPENABLE)
intent.action = Intent.ACTION_GET_CONTENT intent.action = Intent.ACTION_GET_CONTENT
when (info.enumType) { when (enumType) {
CollectionInfo.Type.CALENDAR -> intent.type = "text/calendar" CollectionInfo.Type.CALENDAR -> intent.type = "text/calendar"
CollectionInfo.Type.TASKS -> intent.type = "text/calendar" CollectionInfo.Type.TASKS -> intent.type = "text/calendar"
CollectionInfo.Type.ADDRESS_BOOK -> intent.type = "text/x-vcard" CollectionInfo.Type.ADDRESS_BOOK -> intent.type = "text/x-vcard"
@ -132,7 +127,7 @@ class ImportFragment : DialogFragment() {
val data = ImportResult() val data = ImportResult()
data.e = Exception("Failed to open file chooser.\nPlease install one.") data.e = Exception("Failed to open file chooser.\nPlease install one.")
(activity as ResultFragment.OnImportCallback).onImportResult(data) onImportResult(data)
dismissAllowingStateLoss() dismissAllowingStateLoss()
} }
@ -146,7 +141,7 @@ class ImportFragment : DialogFragment() {
if (data != null) { if (data != null) {
// Get the URI of the selected file // Get the URI of the selected file
val uri = data.data!! 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 { try {
inputStream = activity!!.contentResolver.openInputStream(uri) inputStream = activity!!.contentResolver.openInputStream(uri)
@ -157,7 +152,7 @@ class ImportFragment : DialogFragment() {
val importResult = ImportResult() val importResult = ImportResult()
importResult.e = e importResult.e = e
(activity as ResultFragment.OnImportCallback).onImportResult(importResult) onImportResult(importResult)
dismissAllowingStateLoss() dismissAllowingStateLoss()
} }
@ -172,7 +167,7 @@ class ImportFragment : DialogFragment() {
} }
fun loadFinished(data: ImportResult) { fun loadFinished(data: ImportResult) {
(activity as ResultFragment.OnImportCallback).onImportResult(data) onImportResult(data)
Logger.log.info("Finished import") Logger.log.info("Finished import")
@ -217,7 +212,7 @@ class ImportFragment : DialogFragment() {
val context = context!! val context = context!!
val importReader = InputStreamReader(inputStream) val importReader = InputStreamReader(inputStream)
if (info.enumType == CollectionInfo.Type.CALENDAR) { if (enumType == CollectionInfo.Type.CALENDAR) {
val events = Event.eventsFromReader(importReader, null) val events = Event.eventsFromReader(importReader, null)
importReader.close() importReader.close()
@ -239,7 +234,7 @@ class ImportFragment : DialogFragment() {
val localCalendar: LocalCalendar? val localCalendar: LocalCalendar?
try { try {
localCalendar = LocalCalendar.findByName(account, provider, LocalCalendar.Factory, info.uid!!) localCalendar = LocalCalendar.findByName(account, provider, LocalCalendar.Factory, uid!!)
if (localCalendar == null) { if (localCalendar == null) {
throw FileNotFoundException("Failed to load local resource.") throw FileNotFoundException("Failed to load local resource.")
} }
@ -270,7 +265,7 @@ class ImportFragment : DialogFragment() {
entryProcessed() entryProcessed()
} }
} else if (info.enumType == CollectionInfo.Type.TASKS) { } else if (enumType == CollectionInfo.Type.TASKS) {
val tasks = Task.tasksFromReader(importReader) val tasks = Task.tasksFromReader(importReader)
importReader.close() importReader.close()
@ -297,7 +292,7 @@ class ImportFragment : DialogFragment() {
provider?.let { provider?.let {
val localTaskList: LocalTaskList? val localTaskList: LocalTaskList?
try { try {
localTaskList = LocalTaskList.findByName(account, it, LocalTaskList.Factory, info.uid!!) localTaskList = LocalTaskList.findByName(account, it, LocalTaskList.Factory, uid!!)
if (localTaskList == null) { if (localTaskList == null) {
throw FileNotFoundException("Failed to load local resource.") throw FileNotFoundException("Failed to load local resource.")
} }
@ -325,7 +320,7 @@ class ImportFragment : DialogFragment() {
entryProcessed() entryProcessed()
} }
} }
} else if (info.enumType == CollectionInfo.Type.ADDRESS_BOOK) { } else if (enumType == CollectionInfo.Type.ADDRESS_BOOK) {
val uidToLocalId = HashMap<String?, Long>() val uidToLocalId = HashMap<String?, Long>()
val downloader = ContactsSyncManager.ResourceDownloader(context) val downloader = ContactsSyncManager.ResourceDownloader(context)
val contacts = Contact.fromReader(importReader, downloader) val contacts = Contact.fromReader(importReader, downloader)
@ -346,7 +341,7 @@ class ImportFragment : DialogFragment() {
return result return result
} }
val localAddressBook = LocalAddressBook.findByUid(context, provider, account, info.uid!!) val localAddressBook = LocalAddressBook.findByUid(context, provider, account, uid!!)
if (localAddressBook == null) { if (localAddressBook == null) {
throw FileNotFoundException("Failed to load local address book.") 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 { companion object {
private val REQUEST_CODE = 6384 // onActivityResult request private val REQUEST_CODE = 6384 // onActivityResult request
private val TAG_PROGRESS_MAX = "progressMax" private val TAG_PROGRESS_MAX = "progressMax"
fun newInstance(account: Account, info: CollectionInfo): ImportFragment { fun newInstance(account: Account, info: CollectionInfo): ImportFragment {
val frag = ImportFragment() return ImportFragment(account, info.uid!!, info.enumType!!)
val args = Bundle(1) }
args.putParcelable(KEY_ACCOUNT, account)
args.putSerializable(KEY_COLLECTION_INFO, info) fun newInstance(account: Account, cachedCollection: CachedCollection): ImportFragment {
frag.arguments = args val enumType = when (cachedCollection.meta.collectionType) {
return frag 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.ImageView
import android.widget.TextView import android.widget.TextView
import androidx.fragment.app.ListFragment import androidx.fragment.app.ListFragment
import androidx.fragment.app.commit
import at.bitfire.ical4android.CalendarStorageException 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.R
import com.etesync.syncadapter.log.Logger import com.etesync.syncadapter.log.Logger
import com.etesync.syncadapter.model.CollectionInfo import com.etesync.syncadapter.model.CollectionInfo
@ -24,17 +23,10 @@ import com.etesync.syncadapter.resource.LocalCalendar
import com.etesync.syncadapter.resource.LocalEvent import com.etesync.syncadapter.resource.LocalEvent
class LocalCalendarImportFragment : ListFragment() { class LocalCalendarImportFragment(private val account: Account, private val uid: String) : ListFragment() {
private lateinit var account: Account
private lateinit var info: CollectionInfo
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
retainInstance = true 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? { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
@ -200,7 +192,7 @@ class LocalCalendarImportFragment : ListFragment() {
if (progressDialog.isShowing && !activity.isDestroyed) { if (progressDialog.isShowing && !activity.isDestroyed) {
progressDialog.dismiss() progressDialog.dismiss()
} }
(activity as ResultFragment.OnImportCallback).onImportResult(result) onImportResult(result)
} }
private fun importEvents(fromCalendar: LocalCalendar): ResultFragment.ImportResult { private fun importEvents(fromCalendar: LocalCalendar): ResultFragment.ImportResult {
@ -208,7 +200,7 @@ class LocalCalendarImportFragment : ListFragment() {
try { try {
val localCalendar = LocalCalendar.findByName(account, val localCalendar = LocalCalendar.findByName(account,
context!!.contentResolver.acquireContentProviderClient(CalendarContract.CONTENT_URI)!!, context!!.contentResolver.acquireContentProviderClient(CalendarContract.CONTENT_URI)!!,
LocalCalendar.Factory, info!!.uid!!) LocalCalendar.Factory, uid)
val localEvents = fromCalendar.findAll() val localEvents = fromCalendar.findAll()
val total = localEvents.size val total = localEvents.size
progressDialog.max = total 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 { companion object {
fun newInstance(account: Account, info: CollectionInfo): LocalCalendarImportFragment { fun newInstance(account: Account, info: CollectionInfo): LocalCalendarImportFragment {
val frag = LocalCalendarImportFragment() return LocalCalendarImportFragment(account, info.uid!!)
val args = Bundle(1)
args.putParcelable(KEY_ACCOUNT, account)
args.putSerializable(KEY_COLLECTION_INFO, info)
frag.arguments = args
return frag
} }
} }
} }

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

@ -19,7 +19,7 @@ class ResultFragment : DialogFragment() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
result = arguments!!.getSerializable(KEY_RESULT) as ImportResult result = requireArguments().getSerializable(KEY_RESULT) as ImportResult
} }
override fun onDismiss(dialog: DialogInterface) { override fun onDismiss(dialog: DialogInterface) {
@ -32,7 +32,7 @@ class ResultFragment : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
if (result!!.isFailed) { if (result!!.isFailed) {
return AlertDialog.Builder(activity!!) return AlertDialog.Builder(requireActivity())
.setTitle(R.string.import_dialog_failed_title) .setTitle(R.string.import_dialog_failed_title)
.setIcon(R.drawable.ic_error_dark) .setIcon(R.drawable.ic_error_dark)
.setMessage(getString(R.string.import_dialog_failed_body, result!!.e!!.localizedMessage)) .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 { companion object {
private val KEY_RESULT = "result" 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) { override fun onItemClick(parent: AdapterView<*>, view: View, position: Int, id: Long) {
val entry = listAdapter?.getItem(position) as EntryEntity 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) { 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 package com.etesync.syncadapter.ui.setup
import android.content.Context 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.syncadapter.HttpClient
import com.etesync.journalmanager.Crypto import com.etesync.journalmanager.Crypto
import com.etesync.journalmanager.Exceptions import com.etesync.journalmanager.Exceptions
import com.etesync.journalmanager.JournalAuthenticator import com.etesync.journalmanager.JournalAuthenticator
import com.etesync.journalmanager.UserInfoManager import com.etesync.journalmanager.UserInfoManager
import com.etesync.syncadapter.Constants
import com.etesync.syncadapter.log.Logger import com.etesync.syncadapter.log.Logger
import com.etesync.syncadapter.model.CollectionInfo import com.etesync.syncadapter.model.CollectionInfo
import okhttp3.HttpUrl import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.IOException import java.io.IOException
import java.io.Serializable import java.io.Serializable
import java.net.URI import java.net.URI
@ -30,45 +35,102 @@ class BaseConfigurationFinder(protected val context: Context, protected val cred
httpClient = HttpClient.Builder(context).build().okHttpClient 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 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 authtoken: String? = null
var userInfo: UserInfoManager.UserInfo? = null var userInfo: UserInfoManager.UserInfo? = null
try { try {
authtoken = authenticator.getAuthToken(credentials.userName, credentials.password) authtoken = authenticator.getAuthToken(credentials.userName, credentials.password)
val authenticatedHttpClient = HttpClient.Builder(context, credentials.uri.host, authtoken!!).build().okHttpClient val authenticatedHttpClient = HttpClient.Builder(context, uri.host, authtoken!!).build().okHttpClient
val userInfoManager = UserInfoManager(authenticatedHttpClient, credentials.uri.toHttpUrlOrNull()!!) val userInfoManager = UserInfoManager(authenticatedHttpClient, uri.toHttpUrlOrNull()!!)
userInfo = userInfoManager.fetch(credentials.userName) userInfo = userInfoManager.fetch(credentials.userName)
} catch (e: Exceptions.HttpException) { } catch (e: Exceptions.HttpException) {
Logger.log.warning(e.message) Logger.log.warning(e.localizedMessage)
exception = e exception = e
} catch (e: IOException) { } catch (e: IOException) {
Logger.log.warning(e.message) Logger.log.warning(e.localizedMessage)
exception = e exception = e
} }
return Configuration( return Configuration(
credentials.uri, uri,
credentials.userName, authtoken, credentials.userName,
cardDavConfig, calDavConfig, null,
authtoken,
userInfo, userInfo,
exception exception
) )
} }
protected fun findInitialConfiguration(service: CollectionInfo.Type): Configuration.ServiceInfo { fun findInitialConfigurationEtebase(): Configuration {
// put discovered information here var exception: Throwable? = null
val config = Configuration.ServiceInfo()
Logger.log.info("Finding initial " + service.toString() + " service configuration") 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 // data classes
@ -76,7 +138,7 @@ class BaseConfigurationFinder(protected val context: Context, protected val cred
class Configuration class Configuration
// We have to use URI here because HttpUrl is not serializable! // 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 rawPassword: String? = null
var password: String? = null var password: String? = null
var keyPair: Crypto.AsymmetricKeyPair? = null var keyPair: Crypto.AsymmetricKeyPair? = null
@ -84,6 +146,9 @@ class BaseConfigurationFinder(protected val context: Context, protected val cred
val isFailed: Boolean val isFailed: Boolean
get() = this.error != null get() = this.error != null
val isLegacy: Boolean
get() = this.authtoken != null
class ServiceInfo : Serializable { class ServiceInfo : Serializable {
val collections: Map<String, CollectionInfo> = HashMap() val collections: Map<String, CollectionInfo> = HashMap()
@ -93,7 +158,7 @@ class BaseConfigurationFinder(protected val context: Context, protected val cred
} }
override fun toString(): String { 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.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.widget.Toast
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import androidx.loader.app.LoaderManager import androidx.loader.app.LoaderManager
@ -27,8 +28,8 @@ class DetectConfigurationFragment : DialogFragment(), LoaderManager.LoaderCallba
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val progress = ProgressDialog(activity) val progress = ProgressDialog(activity)
progress.setTitle(R.string.login_configuration_detection) progress.setTitle(R.string.setting_up_encryption)
progress.setMessage(getString(R.string.login_querying_server)) progress.setMessage(getString(R.string.setting_up_encryption_content))
progress.isIndeterminate = true progress.isIndeterminate = true
progress.setCanceledOnTouchOutside(false) progress.setCanceledOnTouchOutside(false)
isCancelable = false isCancelable = false
@ -38,26 +39,36 @@ class DetectConfigurationFragment : DialogFragment(), LoaderManager.LoaderCallba
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
Logger.log.fine("DetectConfigurationFragment: loading")
loaderManager.initLoader(0, arguments, this) loaderManager.initLoader(0, arguments, this)
} }
override fun onCreateLoader(id: Int, args: Bundle?): Loader<Configuration> { 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?) { override fun onLoadFinished(loader: Loader<Configuration>, data: Configuration?) {
if (data != null) { if (data != null) {
if (data.isFailed) if (data.isFailed) {
// no service found: show error message Logger.log.warning("Failed login configuration ${data.error?.localizedMessage}")
fragmentManager!!.beginTransaction() // no service found: show error message
requireFragmentManager().beginTransaction()
.add(NothingDetectedFragment.newInstance(data.error!!.localizedMessage), null) .add(NothingDetectedFragment.newInstance(data.error!!.localizedMessage), null)
.commitAllowingStateLoss() .commitAllowingStateLoss()
else } else if (data.isLegacy) {
// service found: continue // legacy service found: continue
fragmentManager!!.beginTransaction() Logger.log.info("Found legacy account - asking for encryption details")
requireFragmentManager().beginTransaction()
.replace(android.R.id.content, EncryptionDetailsFragment.newInstance(data)) .replace(android.R.id.content, EncryptionDetailsFragment.newInstance(data))
.addToBackStack(null) .addToBackStack(null)
.commitAllowingStateLoss() .commitAllowingStateLoss()
} else {
Logger.log.info("Found Etebase account account")
requireFragmentManager().beginTransaction()
.replace(android.R.id.content, CreateAccountFragment.newInstance(data))
.addToBackStack(null)
.commitAllowingStateLoss()
}
} else } else
Logger.log.severe("Configuration detection failed") Logger.log.severe("Configuration detection failed")
@ -71,14 +82,9 @@ class DetectConfigurationFragment : DialogFragment(), LoaderManager.LoaderCallba
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
return AlertDialog.Builder(activity!!) return AlertDialog.Builder(activity!!)
.setTitle(R.string.login_configuration_detection) .setTitle(R.string.setting_up_encryption)
.setIcon(R.drawable.ic_error_dark) .setIcon(R.drawable.ic_error_dark)
.setMessage(R.string.login_wrong_username_or_password) .setMessage(requireArguments().getString(KEY_LOGS))
.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)
}
.setPositiveButton(android.R.string.ok) { dialog, which -> .setPositiveButton(android.R.string.ok) { dialog, which ->
// dismiss // dismiss
} }

@ -30,7 +30,7 @@ class LoginActivity : BaseActivity() {
if (savedInstanceState == null) if (savedInstanceState == null)
// first call, add fragment // first call, add fragment
supportFragmentManager.beginTransaction() supportFragmentManager.beginTransaction()
.replace(android.R.id.content, LoginCredentialsFragment()) .replace(android.R.id.content, LoginCredentialsFragment(null, null))
.commit() .commit()
} }
@ -43,16 +43,4 @@ class LoginActivity : BaseActivity() {
fun showHelp(item: MenuItem) { fun showHelp(item: MenuItem) {
WebViewActivity.openUrl(this, Constants.helpUri) 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.URI
import java.net.URISyntaxException import java.net.URISyntaxException
class LoginCredentials(_uri: URI?, val userName: String, val password: String) : Parcelable { class LoginCredentials(val 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
}
override fun describeContents(): Int { override fun describeContents(): Int {
return 0 return 0

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

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

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

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

@ -40,6 +40,7 @@
android:id="@+id/encryption_password" android:id="@+id/encryption_password"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="14dp"
app:passwordToggleEnabled="true"> app:passwordToggleEnabled="true">
<com.google.android.material.textfield.TextInputEditText <com.google.android.material.textfield.TextInputEditText
android:layout_width="match_parent" 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:text="@string/login_enter_service_details"
android:layout_marginBottom="14dp"/> android:layout_marginBottom="14dp"/>
<TextView <com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" 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 <com.google.android.material.textfield.TextInputLayout
android:id="@+id/url_password" android:id="@+id/url_password"
android:layout_width="match_parent" android:layout_width="match_parent"
@ -81,12 +81,16 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content"> android:layout_height="wrap_content">
<EditText <com.google.android.material.textfield.TextInputLayout
android:id="@+id/custom_server"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content">
android:hint="@string/login_custom_server" <com.google.android.material.textfield.TextInputEditText
android:inputType="textUri"/> 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> </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" android:title="@string/account_show_fingerprint"
app:showAsAction="ifRoom"/> app:showAsAction="ifRoom"/>
<item android:id="@+id/invitations"
android:title="@string/invitations_title"
app:showAsAction="never"/>
<item android:id="@+id/delete_account" <item android:id="@+id/delete_account"
android:title="@string/account_delete" android:title="@string/account_delete"
app:showAsAction="never"/> 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="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="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> <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_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="login_view_logs">Vis logger</string>
<string name="setting_up_encryption">Setter opp kryptering</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="account_creation_failed">Kunne ikke opprette konto</string>
<string name="wrong_encryption_password">Feil krypteringspassord</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. <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_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_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" 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">Journalen \"%s\" er skrivebeskyttet</string>
<string name="sync_journal_readonly_message">Journalen er skrivebeskyttet, så alle endringene dine (%d) har blitt omgjort.</string> <string name="sync_journal_readonly_message">Journalen er skrivebeskyttet, så alle endringene dine (%d) har blitt omgjort.</string>
<!-- Calendar invites --> <!-- Calendar invites -->
@ -361,4 +362,4 @@
<string name="app_settings_prefer_tasksorg">Foretrekk Tasks.org-gjøremålstilbyder</string> <string name="app_settings_prefer_tasksorg">Foretrekk Tasks.org-gjøremålstilbyder</string>
<string name="app_settings_sync">Synkroniser</string> <string name="app_settings_sync">Synkroniser</string>
<string name="accounts_missing_permissions">Manglende tilganger: %s</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_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_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" 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 --> <!-- cert4android -->
<string name="certificate_notification_connection_security">EteSync: Bezpieczeństwo połączenia</string> <string name="certificate_notification_connection_security">EteSync: Bezpieczeństwo połączenia</string>

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<plurals name="sync_successfully"> <plurals name="sync_successfully">
<item quantity="one">%d entry</item> <item quantity="one">%d item</item>
<item quantity="other">%d entries</item> <item quantity="other">%d items</item>
</plurals> </plurals>
</resources> </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="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="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">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="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_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> <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="collection_members_adding">Adding member</string>
<string name="trust_fingerprint_title">Verify security fingerprint</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="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_removing">Removing member</string>
<string name="collection_members_remove_error">Error 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_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">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 --> <!-- JournalItemActivity -->
<string name="about">About</string> <string name="about">About</string>
<string name="journal_item_tab_main">Main</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_tab_raw">Raw</string>
<string name="journal_item_attendees">Attendees</string> <string name="journal_item_attendees">Attendees</string>
<string name="journal_item_reminders">Reminders</string> <string name="journal_item_reminders">Reminders</string>
@ -207,8 +220,10 @@
<!-- AddAccountActivity --> <!-- AddAccountActivity -->
<string name="login_title">Add account</string> <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">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_password">Password</string>
<string name="login_custom_server">EteSync Server URL</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> <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_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_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_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_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_password_required">Password required</string>
<string name="login_login">Log In</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_service_details_description">This is your login password, *not* your encryption password!</string>
<string name="login_forgot_password">Forgot 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_wrong_username_or_password">Couldn\'t authenticate (probably wrong username or password).\nHave you registered?</string>
<string name="login_view_logs">View logs</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">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="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 --> <!-- ChangeEncryptionPasswordActivity -->
<string name="change_encryption_password_title">Change Encryption Password</string> <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> <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> <string name="delete_collection_deleting_collection">Deleting collection</string>
<!-- JournalViewer --> <!-- JournalViewer -->
<string name="journal_entries_list_empty">Journal is empty.\n(Maybe it\'s still syncing?)</string> <string name="journal_entries_list_empty">Collection is empty.\nMaybe it\'s still syncing?</string>
<string name="journal_entries_loading">Loading journal entries...</string> <string name="journal_entries_loading">Loading change log entries...</string>
<!-- ExceptionInfoFragment --> <!-- ExceptionInfoFragment -->
<string name="exception">An error has occurred.</string> <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="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">EteSync permissions</string>
<string name="sync_error_permissions_text">Additional permissions required</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_calendar">Calendar sync failed (%s)</string>
<string name="sync_error_contacts">Contacts sync failed (%s)</string> <string name="sync_error_contacts">Contacts sync failed (%s)</string>
<string name="sync_error_tasks">Tasks 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_unavailable">Could not connect to server while %s</string>
<string name="sync_error_local_storage">Database error 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_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_prepare">preparing synchronization</string>
<string name="sync_phase_journals">syncronizing journals</string> <string name="sync_phase_journals">syncronizing journals</string>
<string name="sync_phase_prepare_fetch">preparing for fetch</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_contacts" formatted="false">Contacts modified (%s)</string>
<string name="sync_successfully_tasks" formatted="false">Tasks \"%s\" 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" 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">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> <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>
<PreferenceCategory android:title="@string/settings_encryption"> <PreferenceCategory android:title="@string/settings_sync">
<Preference <Preference
android:key="password"
android:title="@string/settings_encryption_password" android:title="@string/settings_encryption_password"
android:key="encryption_password"
android:summary="@string/settings_encryption_password_summary" 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 <ListPreference
android:key="sync_interval" 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