EteSync v2 support

pull/1244/head
Alex Baker 4 years ago
parent 9470eb2786
commit b55a783138

@ -609,10 +609,6 @@
android:name=".etebase.EteBaseCalendarSettingsActivity"
android:theme="@style/Tasks" />
<activity
android:name=".etebase.EncryptionSettingsActivity"
android:theme="@style/Tasks" />
<activity
android:name=".preferences.MainPreferences"
android:theme="@style/Tasks" />

@ -80,7 +80,7 @@ abstract class BaseCaldavCalendarSettingsActivity : BaseListSettingsActivity() {
get() = caldavCalendar == null
override val toolbarTitle: String
get() = if (isNew) getString(R.string.new_list) else caldavCalendar!!.name!!
get() = if (isNew) getString(R.string.new_list) else caldavCalendar!!.name ?: ""
@OnTextChanged(R.id.name)
fun onNameChanged() {

@ -47,6 +47,7 @@ class CaldavAccount : Parcelable {
@ColumnInfo(name = "cda_repeat")
var isSuppressRepeatingTasks = false
@Deprecated("use etebase")
@ColumnInfo(name = "cda_encryption_key")
@Transient
var encryptionKey: String? = null
@ -78,6 +79,7 @@ class CaldavAccount : Parcelable {
return encryption.decrypt(password) ?: ""
}
@Deprecated("use etebase")
fun getEncryptionPassword(encryption: KeyStoreEncryption): String {
return encryption.decrypt(encryptionKey) ?: ""
}

@ -1,23 +1,15 @@
package org.tasks.etebase
import androidx.core.util.Pair
import androidx.hilt.lifecycle.ViewModelInject
import com.etesync.journalmanager.UserInfoManager.UserInfo
import org.tasks.ui.CompletableViewModel
class AddEteBaseAccountViewModel @ViewModelInject constructor(
private val clientProvider: EteBaseClientProvider): CompletableViewModel<Pair<UserInfo, String>>() {
private val clientProvider: EteBaseClientProvider): CompletableViewModel<String>() {
suspend fun addAccount(url: String, username: String, password: String) {
run {
val token =
clientProvider
.forUrl(url, username, null, null)
.setForeground()
.getToken(password)
Pair.create(
clientProvider.forUrl(url, username, null, token!!).userInfo(),
token
)
clientProvider
.forUrl(url, username, password, foreground = true)
.getSession()
}
}
}

@ -1,15 +0,0 @@
package org.tasks.etebase
import androidx.hilt.lifecycle.ViewModelInject
import org.tasks.data.CaldavAccount
import org.tasks.ui.CompletableViewModel
class CreateUserInfoViewModel @ViewModelInject constructor(
private val clientProvider: EteBaseClientProvider): CompletableViewModel<String>() {
suspend fun createUserInfo(caldavAccount: CaldavAccount, derivedKey: String) {
run {
clientProvider.forAccount(caldavAccount).createUserInfo(derivedKey)
derivedKey
}
}
}

@ -1,188 +0,0 @@
package org.tasks.etebase
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.MenuItem
import android.view.View
import androidx.activity.viewModels
import androidx.appcompat.widget.Toolbar
import androidx.lifecycle.lifecycleScope
import at.bitfire.dav4jvm.exception.HttpException
import butterknife.ButterKnife
import butterknife.OnTextChanged
import com.etesync.journalmanager.Constants.Companion.CURRENT_VERSION
import com.etesync.journalmanager.Crypto.CryptoManager
import com.etesync.journalmanager.Crypto.deriveKey
import com.etesync.journalmanager.Exceptions.IntegrityException
import com.etesync.journalmanager.Exceptions.VersionTooNewException
import com.etesync.journalmanager.UserInfoManager
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import org.tasks.R
import org.tasks.Strings.isNullOrEmpty
import org.tasks.data.CaldavAccount
import org.tasks.databinding.ActivityEtesyncEncryptionSettingsBinding
import org.tasks.injection.ThemedInjectingAppCompatActivity
import org.tasks.security.KeyStoreEncryption
import org.tasks.ui.DisplayableException
import java.net.ConnectException
import javax.inject.Inject
@AndroidEntryPoint
class EncryptionSettingsActivity : ThemedInjectingAppCompatActivity(), Toolbar.OnMenuItemClickListener {
@Inject lateinit var encryption: KeyStoreEncryption
private lateinit var binding: ActivityEtesyncEncryptionSettingsBinding
private var userInfo: UserInfoManager.UserInfo? = null
private var caldavAccount: CaldavAccount? = null
private val createUserInfoViewModel: CreateUserInfoViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityEtesyncEncryptionSettingsBinding.inflate(layoutInflater)
setContentView(binding.root)
ButterKnife.bind(this)
val intent = intent
caldavAccount = intent.getParcelableExtra(EXTRA_ACCOUNT)
userInfo = intent.getSerializableExtra(EXTRA_USER_INFO) as UserInfoManager.UserInfo
if (userInfo == null) {
binding.description.visibility = View.VISIBLE
binding.repeatEncryptionPasswordLayout.visibility = View.VISIBLE
}
val toolbar = binding.toolbar.toolbar
toolbar.title = if (caldavAccount == null) getString(R.string.add_account) else caldavAccount!!.name
toolbar.navigationIcon = getDrawable(R.drawable.ic_outline_save_24px)
toolbar.setNavigationOnClickListener { save() }
toolbar.inflateMenu(R.menu.menu_help)
toolbar.setOnMenuItemClickListener(this)
themeColor.apply(toolbar)
createUserInfoViewModel.observe(this, { returnDerivedKey(it) }, this::requestFailed)
if (createUserInfoViewModel.inProgress) {
showProgressIndicator()
}
}
private fun showProgressIndicator() {
binding.progressBar.progressBar.visibility = View.VISIBLE
}
private fun hideProgressIndicator() {
binding.progressBar.progressBar.visibility = View.GONE
}
private fun requestInProgress() = binding.progressBar.progressBar.visibility == View.VISIBLE
private fun returnDerivedKey(derivedKey: String) {
hideProgressIndicator()
val result = Intent()
result.putExtra(EXTRA_DERIVED_KEY, derivedKey)
setResult(Activity.RESULT_OK, result)
finish()
return
}
private fun save() = lifecycleScope.launch {
if (requestInProgress()) {
return@launch
}
val encryptionPassword = newEncryptionPassword
val derivedKey = caldavAccount!!.getEncryptionPassword(encryption)
if (isNullOrEmpty(encryptionPassword) && isNullOrEmpty(derivedKey)) {
binding.encryptionPasswordLayout.error = getString(R.string.encryption_password_required)
return@launch
}
if (userInfo == null) {
val repeatEncryptionPassword = binding.repeatEncryptionPassword.text.toString().trim { it <= ' ' }
if (encryptionPassword != repeatEncryptionPassword) {
binding.repeatEncryptionPasswordLayout.error = getString(R.string.passwords_do_not_match)
return@launch
}
}
val key = if (isNullOrEmpty(encryptionPassword)) derivedKey else deriveKey(caldavAccount!!.username!!, encryptionPassword)
val cryptoManager: CryptoManager
cryptoManager = try {
val version = if (userInfo == null) CURRENT_VERSION else userInfo!!.version!!.toInt()
CryptoManager(version, key, "userInfo")
} catch (e: VersionTooNewException) {
requestFailed(e)
return@launch
} catch (e: IntegrityException) {
requestFailed(e)
return@launch
}
if (userInfo == null) {
showProgressIndicator()
createUserInfoViewModel.createUserInfo(caldavAccount!!, key)
} else {
try {
userInfo!!.verify(cryptoManager)
returnDerivedKey(key)
} catch (e: IntegrityException) {
binding.encryptionPasswordLayout.error = getString(R.string.encryption_password_wrong)
}
}
}
private fun requestFailed(t: Throwable) {
hideProgressIndicator()
when (t) {
is HttpException -> showSnackbar(t.message)
is DisplayableException -> showSnackbar(t.resId)
is ConnectException -> showSnackbar(R.string.network_error)
else -> showSnackbar(R.string.error_adding_account, t.message!!)
}
}
private fun showSnackbar(resId: Int, vararg formatArgs: Any) =
showSnackbar(getString(resId, *formatArgs))
private fun showSnackbar(message: String?) =
newSnackbar(message).show()
private fun newSnackbar(message: String?): Snackbar {
val snackbar = Snackbar.make(binding.rootLayout, message!!, 8000)
.setTextColor(getColor(R.color.snackbar_text_color))
.setActionTextColor(getColor(R.color.snackbar_action_color))
snackbar
.view
.setBackgroundColor(getColor(R.color.snackbar_background))
return snackbar
}
@OnTextChanged(R.id.repeat_encryption_password)
fun onRpeatEncryptionPasswordChanged() {
binding.repeatEncryptionPasswordLayout.error = null
}
@OnTextChanged(R.id.encryption_password)
fun onEncryptionPasswordChanged() {
binding.encryptionPasswordLayout.error = null
}
private val newEncryptionPassword: String
get() = binding.encryptionPassword.text.toString().trim { it <= ' ' }
override fun finish() {
if (!requestInProgress()) {
super.finish()
}
}
override fun onMenuItemClick(item: MenuItem): Boolean {
return if (item.itemId == R.id.menu_help) {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.url_etesync))))
true
} else {
onOptionsItemSelected(item)
}
}
companion object {
const val EXTRA_USER_INFO = "extra_user_info"
const val EXTRA_ACCOUNT = "extra_account"
const val EXTRA_DERIVED_KEY = "extra_derived_key"
}
}

@ -1,28 +1,19 @@
package org.tasks.etebase
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.view.View
import androidx.activity.viewModels
import androidx.appcompat.widget.Toolbar
import androidx.core.util.Pair
import androidx.lifecycle.lifecycleScope
import butterknife.OnCheckedChanged
import com.etesync.journalmanager.Crypto.CryptoManager
import com.etesync.journalmanager.Exceptions.IntegrityException
import com.etesync.journalmanager.Exceptions.VersionTooNewException
import com.etesync.journalmanager.UserInfoManager
import com.todoroo.astrid.data.Task
import com.todoroo.astrid.helper.UUIDHelper
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import org.tasks.R
import org.tasks.Strings.isNullOrEmpty
import org.tasks.analytics.Constants
import org.tasks.caldav.BaseCaldavAccountSettingsActivity
import org.tasks.data.CaldavAccount
import timber.log.Timber
import javax.inject.Inject
@AndroidEntryPoint
@ -56,52 +47,27 @@ class EteBaseAccountSettingsActivity : BaseCaldavAccountSettingsActivity(), Tool
override val description: Int
get() = R.string.etesync_account_description
private suspend fun addAccount(userInfoAndToken: Pair<UserInfoManager.UserInfo, String>) {
private suspend fun addAccount(session: String) {
caldavAccount = CaldavAccount()
caldavAccount!!.accountType = CaldavAccount.TYPE_ETEBASE
caldavAccount!!.uuid = UUIDHelper.newUUID()
applyTo(caldavAccount!!, userInfoAndToken)
applyTo(caldavAccount!!, session)
}
private suspend fun updateAccount(userInfoAndToken: Pair<UserInfoManager.UserInfo, String>) {
private suspend fun updateAccount(session: String) {
caldavAccount!!.error = ""
applyTo(caldavAccount!!, userInfoAndToken)
applyTo(caldavAccount!!, session)
}
private suspend fun applyTo(account: CaldavAccount, userInfoAndToken: Pair<UserInfoManager.UserInfo, String>) {
private suspend fun applyTo(account: CaldavAccount, session: String) {
hideProgressIndicator()
account.name = newName
account.url = newURL
account.username = newUsername
val token = userInfoAndToken.second
if (token != account.getPassword(encryption)) {
account.password = encryption.encrypt(token!!)
if (session != account.getPassword(encryption)) {
account.password = encryption.encrypt(session)
}
val userInfo = userInfoAndToken.first
if (testUserInfo(userInfo)) {
saveAccountAndFinish()
} else {
val intent = Intent(this, EncryptionSettingsActivity::class.java)
intent.putExtra(EncryptionSettingsActivity.EXTRA_USER_INFO, userInfo)
intent.putExtra(EncryptionSettingsActivity.EXTRA_ACCOUNT, account)
startActivityForResult(intent, REQUEST_ENCRYPTION_PASSWORD)
}
}
private fun testUserInfo(userInfo: UserInfoManager.UserInfo?): Boolean {
val encryptionKey = caldavAccount!!.getEncryptionPassword(encryption)
if (userInfo != null && !isNullOrEmpty(encryptionKey)) {
try {
val cryptoManager = CryptoManager(userInfo.version!!.toInt(), encryptionKey, "userInfo")
userInfo.verify(cryptoManager)
return true
} catch (e: IntegrityException) {
Timber.e(e)
} catch (e: VersionTooNewException) {
Timber.e(e)
}
}
return false
saveAccountAndFinish()
}
@OnCheckedChanged(R.id.show_advanced)
@ -133,10 +99,10 @@ class EteBaseAccountSettingsActivity : BaseCaldavAccountSettingsActivity(), Tool
}
override val newURL: String
get() {
val url = super.newURL
return if (isNullOrEmpty(url)) getString(R.string.etesync_url) else url // TODO: change to etebase url
}
get() =
super.newURL
.takeIf { it.isNotBlank() }
?: getString(R.string.etebase_url)
override val newPassword: String
get() = binding.password.text.toString().trim { it <= ' ' }
@ -144,20 +110,6 @@ class EteBaseAccountSettingsActivity : BaseCaldavAccountSettingsActivity(), Tool
override val helpUrl: String
get() = getString(R.string.url_etesync)
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == REQUEST_ENCRYPTION_PASSWORD) {
if (resultCode == Activity.RESULT_OK) {
lifecycleScope.launch {
val key = data!!.getStringExtra(EncryptionSettingsActivity.EXTRA_DERIVED_KEY)!!
caldavAccount!!.encryptionKey = encryption.encrypt(key)
saveAccountAndFinish()
}
}
} else {
super.onActivityResult(requestCode, resultCode, data)
}
}
private suspend fun saveAccountAndFinish() {
if (caldavAccount!!.id == Task.NO_ID) {
caldavDao.insert(caldavAccount!!)
@ -173,11 +125,7 @@ class EteBaseAccountSettingsActivity : BaseCaldavAccountSettingsActivity(), Tool
}
override suspend fun removeAccount() {
caldavAccount?.let { clientProvider.forAccount(it).invalidateToken() }
caldavAccount?.let { clientProvider.forAccount(it).logout() }
super.removeAccount()
}
companion object {
private const val REQUEST_ENCRYPTION_PASSWORD = 10101
}
}

@ -1,177 +1,159 @@
package org.tasks.etebase
import androidx.core.util.Pair
import at.bitfire.cert4android.CustomCertManager
import com.etesync.journalmanager.*
import com.etesync.journalmanager.Constants.Companion.CURRENT_VERSION
import com.etesync.journalmanager.Crypto.AsymmetricKeyPair
import com.etesync.journalmanager.Crypto.CryptoManager
import android.content.Context
import com.etebase.client.*
import com.etebase.client.Collection
import com.etesync.journalmanager.Exceptions
import com.etesync.journalmanager.Exceptions.IntegrityException
import com.etesync.journalmanager.Exceptions.VersionTooNewException
import com.etesync.journalmanager.JournalManager.Journal
import com.etesync.journalmanager.UserInfoManager.UserInfo.Companion.generate
import com.etesync.journalmanager.model.CollectionInfo
import com.etesync.journalmanager.model.CollectionInfo.Companion.fromJson
import com.etesync.journalmanager.model.SyncEntry
import com.google.common.collect.Lists
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import org.tasks.data.CaldavCalendar
import org.tasks.data.CaldavDao
import org.tasks.data.CaldavTask
import org.tasks.time.DateTimeUtils.currentTimeMillis
import timber.log.Timber
import java.io.IOException
import java.util.*
class EteBaseClient(
private val customCertManager: CustomCertManager,
private val username: String?,
private val encryptionPassword: String?,
private val token: String?,
private val httpClient: OkHttpClient,
private val httpUrl: HttpUrl
private val context: Context,
private val username: String,
private val etebase: Account,
private val caldavDao: CaldavDao
) {
private val journalManager = JournalManager(httpClient, httpUrl)
private val cache = EtebaseLocalCache.getInstance(context, username)
@Throws(IOException::class, Exceptions.HttpException::class)
suspend fun getToken(password: String?): String? = withContext(Dispatchers.IO) {
JournalAuthenticator(httpClient, httpUrl).getAuthToken(username!!, password!!)
}
fun getSession(): String = etebase.save(null)
@Throws(Exceptions.HttpException::class)
suspend fun userInfo(): UserInfoManager.UserInfo? = withContext(Dispatchers.IO) {
val userInfoManager = UserInfoManager(httpClient, httpUrl)
userInfoManager.fetch(username!!)
}
@Throws(VersionTooNewException::class, IntegrityException::class)
fun getCrypto(userInfo: UserInfoManager.UserInfo?, journal: Journal): CryptoManager {
if (journal.key == null) {
return CryptoManager(journal.version, encryptionPassword!!, journal.uid!!)
suspend fun getCollections(): List<Collection> {
val collectionManager = etebase.collectionManager
val response = withContext(Dispatchers.IO) {
collectionManager.list(TYPE_TASKS)
}
if (userInfo == null) {
throw RuntimeException("Missing userInfo")
response.data.forEach {
cache.collectionSet(collectionManager, it)
}
val cryptoManager = CryptoManager(userInfo.version!!.toInt(), encryptionPassword!!, "userInfo")
val keyPair = AsymmetricKeyPair(userInfo.getContent(cryptoManager)!!, userInfo.pubkey!!)
return CryptoManager(journal.version, keyPair, journal.key!!)
}
private fun convertJournalToCollection(userInfo: UserInfoManager.UserInfo?, journal: Journal): CollectionInfo? {
return try {
val cryptoManager = getCrypto(userInfo, journal)
journal.verify(cryptoManager)
val collection = fromJson(journal.getContent(cryptoManager))
collection.updateFromJournal(journal)
collection
} catch (e: IntegrityException) {
Timber.e(e)
null
} catch (e: VersionTooNewException) {
Timber.e(e)
null
response.removedMemberships.forEach {
cache.collectionUnset(collectionManager, it)
}
}
@Throws(Exceptions.HttpException::class)
suspend fun getCalendars(userInfo: UserInfoManager.UserInfo?): Map<Journal, CollectionInfo> = withContext(Dispatchers.IO) {
val result: MutableMap<Journal, CollectionInfo> = HashMap()
for (journal in journalManager.list()) {
val collection = convertJournalToCollection(userInfo, journal)
if (collection != null) {
if (TYPE_TASKS == collection.type) {
Timber.v("Found %s", collection)
result[journal] = collection
} else {
Timber.v("Ignoring %s", collection)
}
}
}
result
return cache.collectionList(collectionManager)
}
@Throws(IntegrityException::class, Exceptions.HttpException::class, VersionTooNewException::class)
suspend fun getSyncEntries(
userInfo: UserInfoManager.UserInfo?,
journal: Journal,
suspend fun fetchItems(
collection: Collection,
calendar: CaldavCalendar,
callback: suspend (List<Pair<JournalEntryManager.Entry, SyncEntry>>) -> Unit) = withContext(Dispatchers.IO) {
val journalEntryManager = JournalEntryManager(httpClient, httpUrl, journal.uid!!)
val crypto = getCrypto(userInfo, journal)
var journalEntries: List<JournalEntryManager.Entry>
callback: suspend (Pair<String?, List<Item>>) -> Unit
) {
val itemManager = etebase.collectionManager.getItemManager(collection)
var stoken = calendar.ctag
do {
journalEntries = journalEntryManager.list(crypto, calendar.ctag, MAX_FETCH)
callback.invoke(journalEntries.map {
Pair.create(it, SyncEntry.fromJournalEntry(crypto, it))
})
} while (journalEntries.size >= MAX_FETCH)
val items = withContext(Dispatchers.IO) {
itemManager.list(FetchOptions().stoken(stoken).limit(MAX_FETCH))
}
stoken = items.stoken
callback(Pair(stoken, items.data.toList()))
} while (!items.isDone)
}
@Throws(Exceptions.HttpException::class)
suspend fun pushEntries(journal: Journal, entries: List<JournalEntryManager.Entry>?, remoteCtag: String?) = withContext(Dispatchers.IO) {
var remoteCtag = remoteCtag
val journalEntryManager = JournalEntryManager(httpClient, httpUrl, journal.uid!!)
for (partition in Lists.partition(entries!!, MAX_PUSH)) {
journalEntryManager.create(partition, remoteCtag)
remoteCtag = partition[partition.size - 1].uid
suspend fun updateItem(collection: Collection, task: CaldavTask, content: ByteArray): Item {
val itemManager = etebase.collectionManager.getItemManager(collection)
val item = cache.itemGet(itemManager, collection.uid, task.`object`!!)
?: itemManager
.create(ItemMetadata().apply { name = task.remoteId!! }, "")
.apply {
task.`object` = uid
caldavDao.update(task)
}
item.meta = item.meta.let { meta ->
meta.mtime = currentTimeMillis()
meta
}
item.content = content
return item
}
fun setForeground(): EteBaseClient {
customCertManager.appInForeground = true
return this
suspend fun deleteItem(collection: Collection, uid: String): Item? {
val itemManager = etebase.collectionManager.getItemManager(collection)
return cache.itemGet(itemManager, collection.uid, uid)?.apply { delete() }
}
suspend fun invalidateToken() = withContext(Dispatchers.IO) {
suspend fun updateCache(collection: Collection, items: List<Item>) {
val itemManager = etebase.collectionManager.getItemManager(collection)
items.forEach { cache.itemSet(itemManager, collection.uid, it) }
}
suspend fun uploadChanges(collection: Collection, items: List<Item>) {
val itemManager = etebase.collectionManager.getItemManager(collection)
withContext(Dispatchers.IO) {
itemManager.batch(items.toTypedArray())
}
}
suspend fun getItem(collection: Collection, uid: String): Item? =
cache.itemGet(
etebase.collectionManager.getItemManager(collection),
collection.uid,
uid
)
suspend fun logout() {
try {
JournalAuthenticator(httpClient, httpUrl).invalidateAuthToken(token!!)
EtebaseLocalCache.clear(context, username)
withContext(Dispatchers.IO) {
etebase.logout()
}
} catch (e: Exception) {
Timber.e(e)
}
}
@Throws(VersionTooNewException::class, IntegrityException::class, Exceptions.HttpException::class)
suspend fun makeCollection(name: String?, color: Int): String = withContext(Dispatchers.IO) {
val uid = Journal.genUid()
val collectionInfo = CollectionInfo()
collectionInfo.displayName = name
collectionInfo.type = TYPE_TASKS
collectionInfo.uid = uid
collectionInfo.selected = true
collectionInfo.color = if (color == 0) null else color
val crypto = CryptoManager(collectionInfo.version, encryptionPassword!!, uid)
journalManager.create(Journal(crypto, collectionInfo.toJson(), uid))
uid
}
suspend fun makeCollection(name: String, color: Int) =
etebase
.collectionManager
.create(TYPE_TASKS, ItemMetadata(), "")
.let { setAndUpload(it, name, color) }
@Throws(VersionTooNewException::class, IntegrityException::class, Exceptions.HttpException::class)
suspend fun updateCollection(calendar: CaldavCalendar, name: String?, color: Int): String = withContext(Dispatchers.IO) {
val uid = calendar.url
val journal = journalManager.fetch(uid!!)
val userInfo = userInfo()
val crypto = getCrypto(userInfo, journal)
val collectionInfo = convertJournalToCollection(userInfo, journal)
collectionInfo!!.displayName = name
collectionInfo.color = if (color == 0) null else color
journalManager.update(Journal(crypto, collectionInfo.toJson(), uid))
uid
}
suspend fun updateCollection(calendar: CaldavCalendar, name: String, color: Int) =
cache
.collectionGet(etebase.collectionManager, calendar.url!!)
.let { setAndUpload(it, name, color) }
@Throws(Exceptions.HttpException::class)
suspend fun deleteCollection(calendar: CaldavCalendar) = withContext(Dispatchers.IO) {
journalManager.delete(Journal.fakeWithUid(calendar.url!!))
}
@Throws(Exceptions.HttpException::class, VersionTooNewException::class, IntegrityException::class, IOException::class)
suspend fun createUserInfo(derivedKey: String?) = withContext(Dispatchers.IO) {
val cryptoManager = CryptoManager(CURRENT_VERSION, derivedKey!!, "userInfo")
val userInfo: UserInfoManager.UserInfo = generate(cryptoManager, username!!)
UserInfoManager(httpClient, httpUrl).create(userInfo)
suspend fun deleteCollection(calendar: CaldavCalendar) =
cache
.collectionGet(etebase.collectionManager, calendar.url!!)
.apply { delete() }
.let { setAndUpload(it) }
private suspend fun setAndUpload(
collection: Collection,
name: String? = null,
color: Int? = null
): String {
collection.meta = collection.meta.let { meta ->
name?.let { meta.name = it }
color?.let { meta.color = it.toHexColor() }
meta
}
val collectionManager = etebase.collectionManager
withContext(Dispatchers.IO) {
collectionManager.upload(collection)
}
cache.collectionSet(collectionManager, collection)
return collection.uid
}
companion object {
private const val TYPE_TASKS = "TASKS"
private const val MAX_FETCH = 50
private const val MAX_PUSH = 30
private const val TYPE_TASKS = "etebase.vtodo"
private const val MAX_FETCH = 30L
private fun Int.toHexColor(): String? = takeIf { this != 0 }?.let {
java.lang.String.format("#%06X", 0xFFFFFF and it)
}
}
}

@ -1,21 +1,28 @@
package org.tasks.etebase
import android.content.Context
import android.os.Build
import at.bitfire.cert4android.CustomCertManager
import com.etesync.journalmanager.util.TokenAuthenticator
import com.etebase.client.Account
import com.etebase.client.Client
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Response
import okhttp3.internal.tls.OkHostnameVerifier
import org.tasks.BuildConfig
import org.tasks.DebugNetworkInterceptor
import org.tasks.caldav.MemoryCookieStore
import org.tasks.data.CaldavAccount
import org.tasks.data.CaldavDao
import org.tasks.preferences.Preferences
import org.tasks.security.KeyStoreEncryption
import java.io.IOException
import java.security.KeyManagementException
import java.security.NoSuchAlgorithmException
import java.util.*
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.net.ssl.SSLContext
@ -24,46 +31,38 @@ class EteBaseClientProvider @Inject constructor(
@ApplicationContext private val context: Context,
private val encryption: KeyStoreEncryption,
private val preferences: Preferences,
private val interceptor: DebugNetworkInterceptor
private val interceptor: DebugNetworkInterceptor,
private val caldavDao: CaldavDao
) {
@Throws(NoSuchAlgorithmException::class, KeyManagementException::class)
suspend fun forAccount(account: CaldavAccount): EteBaseClient {
return forUrl(
account.url!!,
account.username,
account.getEncryptionPassword(encryption),
account.username!!,
null,
account.getPassword(encryption))
}
@Throws(KeyManagementException::class, NoSuchAlgorithmException::class)
suspend fun forUrl(url: String, username: String?, encryptionPassword: String?, token: String?): EteBaseClient = withContext(Dispatchers.IO) {
val customCertManager = newCertManager()
EteBaseClient(
customCertManager,
username,
encryptionPassword,
token,
createHttpClient(token, customCertManager),
url.toHttpUrl()
)
suspend fun forUrl(url: String, username: String, password: String?, session: String? = null, foreground: Boolean = false): EteBaseClient = withContext(Dispatchers.IO) {
val httpClient = createHttpClient(foreground)
val client = Client.create(httpClient, url)
val etebase = session
?.let { Account.restore(client, it, null) }
?: Account.login(client, username, password!!)
EteBaseClient(context, username, etebase, caldavDao)
}
private suspend fun newCertManager() = withContext(Dispatchers.Default) {
CustomCertManager(context)
}
private fun createHttpClient(
token: String?,
customCertManager: CustomCertManager,
foreground: Boolean = false
): OkHttpClient {
customCertManager.appInForeground = foreground
private suspend fun createHttpClient(foreground: Boolean): OkHttpClient {
val customCertManager = withContext(Dispatchers.Default) {
CustomCertManager(context, foreground)
}
val hostnameVerifier = customCertManager.hostnameVerifier(OkHostnameVerifier)
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(null, arrayOf(customCertManager), null)
val builder = OkHttpClient()
.newBuilder()
.addNetworkInterceptor(TokenAuthenticator(null, token))
.addNetworkInterceptor(UserAgentInterceptor)
.cookieJar(MemoryCookieStore())
.followRedirects(false)
.followSslRedirects(true)
@ -77,4 +76,18 @@ class EteBaseClientProvider @Inject constructor(
}
return builder.build()
}
private object UserAgentInterceptor : Interceptor {
private val userAgent = "${BuildConfig.APPLICATION_ID}/${BuildConfig.VERSION_NAME} (okhttp3) Android/${Build.VERSION.RELEASE}"
@Throws(IOException::class)
override fun intercept(chain: Interceptor.Chain): Response {
val locale = Locale.getDefault()
val request = chain.request().newBuilder()
.header("User-Agent", userAgent)
.header("Accept-Language", locale.language + "-" + locale.country + ", " + locale.language + ";q=0.7, *;q=0.5")
.build()
return chain.proceed(request)
}
}
}

@ -1,16 +1,13 @@
package org.tasks.etebase
import android.content.Context
import androidx.core.util.Pair
import android.graphics.Color
import at.bitfire.ical4android.ICalendar.Companion.prodId
import com.etebase.client.Collection
import com.etebase.client.Item
import com.etesync.journalmanager.Exceptions
import com.etesync.journalmanager.Exceptions.IntegrityException
import com.etesync.journalmanager.Exceptions.VersionTooNewException
import com.etesync.journalmanager.JournalEntryManager
import com.etesync.journalmanager.JournalEntryManager.Entry.Companion.getFakeWithUid
import com.etesync.journalmanager.JournalManager.Journal
import com.etesync.journalmanager.UserInfoManager
import com.etesync.journalmanager.model.SyncEntry
import com.todoroo.astrid.helper.UUIDHelper
import com.todoroo.astrid.service.TaskDeleter
import dagger.hilt.android.qualifiers.ApplicationContext
@ -31,7 +28,6 @@ import java.security.KeyManagementException
import java.security.NoSuchAlgorithmException
import java.util.*
import javax.inject.Inject
import kotlin.collections.HashSet
class EteBaseSynchronizer @Inject constructor(
@param:ApplicationContext private val context: Context,
@ -58,10 +54,6 @@ class EteBaseSynchronizer @Inject constructor(
setError(account, context.getString(R.string.password_required))
return
}
if (isNullOrEmpty(account.encryptionKey)) {
setError(account, context.getString(R.string.encryption_password_required))
return
}
try {
synchronize(account)
} catch (e: KeyManagementException) {
@ -80,36 +72,34 @@ class EteBaseSynchronizer @Inject constructor(
@Throws(KeyManagementException::class, NoSuchAlgorithmException::class, Exceptions.HttpException::class, IntegrityException::class, VersionTooNewException::class)
private suspend fun synchronize(account: CaldavAccount) {
val client = clientProvider.forAccount(account)
val userInfo = client.userInfo()
val resources = client.getCalendars(userInfo)
val uids: Set<String> = resources.values.mapNotNull { it.uid }.toHashSet()
val collections = client.getCollections()
val uids = collections.map { it.uid }
Timber.d("Found uids: %s", uids)
for (calendar in caldavDao.findDeletedCalendars(account.uuid!!, uids.toList())) {
for (calendar in caldavDao.findDeletedCalendars(account.uuid!!, uids)) {
taskDeleter.delete(calendar)
}
for ((key, collection) in resources) {
for (collection in collections) {
val uid = collection.uid
var calendar = caldavDao.getCalendarByUrl(account.uuid!!, uid!!)
val colorInt = collection.color
val color = colorInt ?: 0
var calendar = caldavDao.getCalendarByUrl(account.uuid!!, uid)
val meta = collection.meta
val color = meta.color?.let { Color.parseColor(it) } ?: 0
if (calendar == null) {
calendar = CaldavCalendar()
calendar.name = collection.displayName
calendar.name = meta.name
calendar.account = account.uuid
calendar.url = collection.uid
calendar.uuid = UUIDHelper.newUUID()
calendar.color = color
caldavDao.insert(calendar)
} else {
if (calendar.name != collection.displayName
|| calendar.color != color) {
calendar.name = collection.displayName
if (calendar.name != meta.name || calendar.color != color) {
calendar.name = meta.name
calendar.color = color
caldavDao.update(calendar)
localBroadcastManager.broadcastRefreshList()
}
}
sync(client, userInfo!!, calendar, key)
sync(client, calendar, collection)
}
setError(account, "")
}
@ -126,59 +116,47 @@ class EteBaseSynchronizer @Inject constructor(
@Throws(IntegrityException::class, Exceptions.HttpException::class, VersionTooNewException::class)
private suspend fun sync(
client: EteBaseClient,
userInfo: UserInfoManager.UserInfo,
caldavCalendar: CaldavCalendar,
journal: Journal) {
collection: Collection
) {
Timber.d("sync(%s)", caldavCalendar)
val localChanges = HashMap<String?, CaldavTaskContainer>()
for (task in caldavDao.getCaldavTasksToPush(caldavCalendar.uuid!!)) {
localChanges[task.remoteId] = task
}
var remoteCtag = journal.lastUid
val remoteCtag = collection.stoken
if (isNullOrEmpty(remoteCtag) || remoteCtag != caldavCalendar.ctag) {
Timber.v("Applying remote changes")
client.getSyncEntries(userInfo, journal, caldavCalendar) {
Timber.d("${caldavCalendar.name}: Applying remote changes")
client.fetchItems(collection, caldavCalendar) {
applyEntries(caldavCalendar, it, localChanges.keys)
client.updateCache(collection, it.second)
}
} else {
Timber.d("%s up to date", caldavCalendar.name)
Timber.d("${caldavCalendar.name} up to date")
}
val changes: MutableList<SyncEntry> = ArrayList()
val changes = ArrayList<Item>()
for (task in caldavDao.getMoved(caldavCalendar.uuid!!)) {
val vtodo = task.vtodo
if (!isNullOrEmpty(vtodo)) {
changes.add(SyncEntry(vtodo!!, SyncEntry.Actions.DELETE))
}
client.deleteItem(collection, task.remoteId!!)?.let { changes.add(it) }
}
for (task in localChanges.values) {
val vtodo = task.vtodo
val existingTask = !isNullOrEmpty(vtodo)
if (task.isDeleted) {
if (existingTask) {
changes.add(SyncEntry(vtodo!!, SyncEntry.Actions.DELETE))
client.deleteItem(collection, task.remoteId!!)?.let { changes.add(it) }
}
} else {
changes.add(
SyncEntry(
String(iCal.toVtodo(task.caldavTask, task.task)),
if (existingTask) SyncEntry.Actions.CHANGE else SyncEntry.Actions.ADD))
changes.add(client.updateItem(
collection,
task.caldavTask,
iCal.toVtodo(task.caldavTask, task.task)
))
}
}
remoteCtag = caldavCalendar.ctag
val crypto = client.getCrypto(userInfo, journal)
val updates: MutableList<Pair<JournalEntryManager.Entry, SyncEntry>> = ArrayList()
var previous: JournalEntryManager.Entry? = if (isNullOrEmpty(remoteCtag)) null else getFakeWithUid(remoteCtag!!)
for (syncEntry in changes) {
val entry = JournalEntryManager.Entry()
entry.update(crypto, syncEntry.toJson(), previous)
updates.add(Pair.create(entry, syncEntry))
previous = entry
}
if (updates.size > 0) {
Timber.v("Pushing local changes")
client.pushEntries(journal, updates.mapNotNull { it.first }, remoteCtag)
Timber.v("Applying local changes")
applyEntries(caldavCalendar, updates, HashSet())
if (changes.isNotEmpty()) {
client.uploadChanges(collection, changes)
applyEntries(caldavCalendar, Pair(caldavCalendar.ctag, changes), HashSet())
client.updateCache(collection, changes)
}
Timber.d("UPDATE %s", caldavCalendar)
caldavDao.update(caldavCalendar)
@ -188,37 +166,34 @@ class EteBaseSynchronizer @Inject constructor(
private suspend fun applyEntries(
caldavCalendar: CaldavCalendar,
syncEntries: List<Pair<JournalEntryManager.Entry, SyncEntry>>,
items: Pair<String?, List<Item>>,
dirty: MutableSet<String?>) {
for (entry in syncEntries) {
val journalEntry = entry.first
val syncEntry = entry.second
val action = syncEntry!!.action
val vtodo = syncEntry.content
Timber.v("%s: %s", action, vtodo)
for (item in items.second) {
val vtodo = item.contentString
val task = fromVtodo(vtodo) ?: continue
val remoteId = task.uid
val caldavTask = caldavDao.getTaskByRemoteId(caldavCalendar.uuid!!, remoteId!!)
when (action) {
SyncEntry.Actions.ADD, SyncEntry.Actions.CHANGE -> if (dirty.contains(remoteId)) {
if (item.isDeleted) {
dirty.remove(remoteId)
if (caldavTask != null) {
if (caldavTask.isDeleted()) {
caldavDao.delete(caldavTask)
} else {
taskDeleter.delete(caldavTask.task)
}
}
} else {
caldavTask?.`object` = item.uid
if (dirty.contains(remoteId)) {
caldavTask!!.vtodo = vtodo
caldavDao.update(caldavTask)
} else {
iCal.fromVtodo(caldavCalendar, caldavTask, task, vtodo, null, null)
}
SyncEntry.Actions.DELETE -> {
dirty.remove(remoteId)
if (caldavTask != null) {
if (caldavTask.isDeleted()) {
caldavDao.delete(caldavTask)
} else {
taskDeleter.delete(caldavTask.task)
}
}
iCal.fromVtodo(caldavCalendar, caldavTask, task, vtodo, item.uid, null)
}
}
caldavCalendar.ctag = journalEntry!!.uid
caldavDao.update(caldavCalendar)
}
caldavCalendar.ctag = items.first
Timber.d("Setting stoken to ${caldavCalendar.ctag}")
caldavDao.update(caldavCalendar)
}
}

@ -0,0 +1,105 @@
package org.tasks.etebase
import android.content.Context
import com.etebase.client.*
import com.etebase.client.Collection
import com.etebase.client.exceptions.EtebaseException
import com.etebase.client.exceptions.UrlParseException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import java.util.*
class EtebaseLocalCache private constructor(context: Context, username: String) {
private val fsCache: FileSystemCache = FileSystemCache.create(context.filesDir.absolutePath, username)
private suspend fun clearUserCache() {
withContext(Dispatchers.IO) {
fsCache.clearUserCache()
}
}
suspend fun collectionList(colMgr: CollectionManager): List<Collection> =
withContext(Dispatchers.IO) {
fsCache._unstable_collectionList(colMgr).filter { !it.isDeleted }
}
suspend fun collectionGet(colMgr: CollectionManager, colUid: String): Collection =
withContext(Dispatchers.IO) {
fsCache.collectionGet(colMgr, colUid)
}
suspend fun collectionSet(colMgr: CollectionManager, collection: Collection) {
if (collection.isDeleted) {
collectionUnset(colMgr, collection.uid)
} else {
withContext(Dispatchers.IO) {
fsCache.collectionSet(colMgr, collection)
}
}
}
suspend fun collectionUnset(colMgr: CollectionManager, collection: RemovedCollection) {
collectionUnset(colMgr, collection.uid())
}
private suspend fun collectionUnset(colMgr: CollectionManager, colUid: String) {
withContext(Dispatchers.IO) {
try {
fsCache.collectionUnset(colMgr, colUid)
} catch (e: UrlParseException) {
// Ignore, as it just means the file doesn't exist
}
}
}
suspend fun itemGet(itemMgr: ItemManager, colUid: String, itemUid: String): Item? =
withContext(Dispatchers.IO) {
// Need the try because the inner call doesn't return null on missing, but an error
try {
fsCache.itemGet(itemMgr, colUid, itemUid)
} catch (e: EtebaseException) {
null
}
}
suspend fun itemSet(itemMgr: ItemManager, colUid: String, item: Item) {
withContext(Dispatchers.IO) {
if (item.isDeleted) {
fsCache.itemUnset(itemMgr, colUid, item.uid)
} else {
fsCache.itemSet(itemMgr, colUid, item)
}
}
}
companion object {
private val localCacheCache: HashMap<String, EtebaseLocalCache> = HashMap()
fun getInstance(context: Context, username: String): EtebaseLocalCache {
synchronized(localCacheCache) {
val cached = localCacheCache[username]
return if (cached != null) {
cached
} else {
val ret = EtebaseLocalCache(context, username)
localCacheCache[username] = ret
ret
}
}
}
fun clear(context: Context) = runBlocking {
val users = synchronized(localCacheCache) {
localCacheCache.keys.toList()
}
users.forEach { clear(context, it) }
}
suspend fun clear(context: Context, username: String) {
val localCache = getInstance(context, username)
localCache.clearUserCache()
localCacheCache.remove(username)
}
}
}

@ -1,30 +1,19 @@
package org.tasks.etebase
import androidx.core.util.Pair
import androidx.hilt.lifecycle.ViewModelInject
import com.etesync.journalmanager.UserInfoManager.UserInfo
import org.tasks.Strings.isNullOrEmpty
import org.tasks.ui.CompletableViewModel
class UpdateEteBaseAccountViewModel @ViewModelInject constructor(
private val clientProvider: EteBaseClientProvider) : CompletableViewModel<Pair<UserInfo, String>>() {
suspend fun updateAccount(url: String, user: String, pass: String?, token: String) {
private val clientProvider: EteBaseClientProvider) : CompletableViewModel<String>() {
suspend fun updateAccount(url: String, user: String, pass: String?, session: String) {
run {
if (isNullOrEmpty(pass)) {
Pair.create(
clientProvider.forUrl(url, user, null, token).setForeground().userInfo(),
token
)
clientProvider.forUrl(url, user, null, session, true).getSession()
} else {
val newToken =
clientProvider
.forUrl(url, user, null, null)
.setForeground()
.getToken(pass)!!
Pair.create(
clientProvider.forUrl(url, user, null, newToken).userInfo(),
newToken
)
clientProvider
.forUrl(url, user, pass, foreground = true)
.getSession()
}
}
}

@ -15,6 +15,7 @@ import org.tasks.PermissionUtil
import org.tasks.R
import org.tasks.calendars.CalendarEventProvider
import org.tasks.data.TaskDao
import org.tasks.etebase.EtebaseLocalCache
import org.tasks.files.FileHelper
import org.tasks.injection.InjectingPreferenceFragment
import org.tasks.preferences.FragmentPermissionRequestor
@ -205,8 +206,9 @@ class Advanced : InjectingPreferenceFragment() {
.newDialog()
.setMessage(R.string.EPr_delete_task_data_warning)
.setPositiveButton(R.string.EPr_delete_task_data) { _, _ ->
context?.deleteDatabase(database.name)
requireContext().deleteDatabase(database.name)
val context = requireContext()
context.deleteDatabase(database.name)
EtebaseLocalCache.clear(context)
restart()
}
.setNegativeButton(R.string.cancel, null)

Loading…
Cancel
Save