diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7ab710945..cc2c02ee6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -609,10 +609,6 @@ android:name=".etebase.EteBaseCalendarSettingsActivity" android:theme="@style/Tasks" /> - - diff --git a/app/src/main/java/org/tasks/caldav/BaseCaldavCalendarSettingsActivity.kt b/app/src/main/java/org/tasks/caldav/BaseCaldavCalendarSettingsActivity.kt index dd78ac702..c1361ea12 100644 --- a/app/src/main/java/org/tasks/caldav/BaseCaldavCalendarSettingsActivity.kt +++ b/app/src/main/java/org/tasks/caldav/BaseCaldavCalendarSettingsActivity.kt @@ -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() { diff --git a/app/src/main/java/org/tasks/data/CaldavAccount.kt b/app/src/main/java/org/tasks/data/CaldavAccount.kt index 5f36281f4..6401c370a 100644 --- a/app/src/main/java/org/tasks/data/CaldavAccount.kt +++ b/app/src/main/java/org/tasks/data/CaldavAccount.kt @@ -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) ?: "" } diff --git a/app/src/main/java/org/tasks/etebase/AddEteBaseAccountViewModel.kt b/app/src/main/java/org/tasks/etebase/AddEteBaseAccountViewModel.kt index f3658c78b..80a785948 100644 --- a/app/src/main/java/org/tasks/etebase/AddEteBaseAccountViewModel.kt +++ b/app/src/main/java/org/tasks/etebase/AddEteBaseAccountViewModel.kt @@ -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>() { + private val clientProvider: EteBaseClientProvider): CompletableViewModel() { 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() } } } \ No newline at end of file diff --git a/app/src/main/java/org/tasks/etebase/CreateUserInfoViewModel.kt b/app/src/main/java/org/tasks/etebase/CreateUserInfoViewModel.kt deleted file mode 100644 index d7ca15277..000000000 --- a/app/src/main/java/org/tasks/etebase/CreateUserInfoViewModel.kt +++ /dev/null @@ -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() { - suspend fun createUserInfo(caldavAccount: CaldavAccount, derivedKey: String) { - run { - clientProvider.forAccount(caldavAccount).createUserInfo(derivedKey) - derivedKey - } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/tasks/etebase/EncryptionSettingsActivity.kt b/app/src/main/java/org/tasks/etebase/EncryptionSettingsActivity.kt deleted file mode 100644 index 8a64567f9..000000000 --- a/app/src/main/java/org/tasks/etebase/EncryptionSettingsActivity.kt +++ /dev/null @@ -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" - } -} \ No newline at end of file diff --git a/app/src/main/java/org/tasks/etebase/EteBaseAccountSettingsActivity.kt b/app/src/main/java/org/tasks/etebase/EteBaseAccountSettingsActivity.kt index 6e1091366..fbc59998f 100644 --- a/app/src/main/java/org/tasks/etebase/EteBaseAccountSettingsActivity.kt +++ b/app/src/main/java/org/tasks/etebase/EteBaseAccountSettingsActivity.kt @@ -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) { + 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) { + private suspend fun updateAccount(session: String) { caldavAccount!!.error = "" - applyTo(caldavAccount!!, userInfoAndToken) + applyTo(caldavAccount!!, session) } - private suspend fun applyTo(account: CaldavAccount, userInfoAndToken: Pair) { + 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 - } } \ No newline at end of file diff --git a/app/src/main/java/org/tasks/etebase/EteBaseClient.kt b/app/src/main/java/org/tasks/etebase/EteBaseClient.kt index ffb567fb8..7ee099ae6 100644 --- a/app/src/main/java/org/tasks/etebase/EteBaseClient.kt +++ b/app/src/main/java/org/tasks/etebase/EteBaseClient.kt @@ -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 { + 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 = withContext(Dispatchers.IO) { - val result: MutableMap = 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>) -> Unit) = withContext(Dispatchers.IO) { - val journalEntryManager = JournalEntryManager(httpClient, httpUrl, journal.uid!!) - val crypto = getCrypto(userInfo, journal) - var journalEntries: List + callback: suspend (Pair>) -> 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?, 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) { + val itemManager = etebase.collectionManager.getItemManager(collection) + items.forEach { cache.itemSet(itemManager, collection.uid, it) } + } + + suspend fun uploadChanges(collection: Collection, items: List) { + 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) + } } } \ No newline at end of file diff --git a/app/src/main/java/org/tasks/etebase/EteBaseClientProvider.kt b/app/src/main/java/org/tasks/etebase/EteBaseClientProvider.kt index 1c471ba9e..455236bc9 100644 --- a/app/src/main/java/org/tasks/etebase/EteBaseClientProvider.kt +++ b/app/src/main/java/org/tasks/etebase/EteBaseClientProvider.kt @@ -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) + } + } } \ No newline at end of file diff --git a/app/src/main/java/org/tasks/etebase/EteBaseSynchronizer.kt b/app/src/main/java/org/tasks/etebase/EteBaseSynchronizer.kt index 184da6cb5..39ae15673 100644 --- a/app/src/main/java/org/tasks/etebase/EteBaseSynchronizer.kt +++ b/app/src/main/java/org/tasks/etebase/EteBaseSynchronizer.kt @@ -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 = 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() 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 = ArrayList() + val changes = ArrayList() 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> = 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>, + items: Pair>, dirty: MutableSet) { - 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) } } \ No newline at end of file diff --git a/app/src/main/java/org/tasks/etebase/EtebaseLocalCache.kt b/app/src/main/java/org/tasks/etebase/EtebaseLocalCache.kt new file mode 100644 index 000000000..de43fdf57 --- /dev/null +++ b/app/src/main/java/org/tasks/etebase/EtebaseLocalCache.kt @@ -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 = + 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 = 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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/tasks/etebase/UpdateEteBaseAccountViewModel.kt b/app/src/main/java/org/tasks/etebase/UpdateEteBaseAccountViewModel.kt index 80cacab90..ea100f5d0 100644 --- a/app/src/main/java/org/tasks/etebase/UpdateEteBaseAccountViewModel.kt +++ b/app/src/main/java/org/tasks/etebase/UpdateEteBaseAccountViewModel.kt @@ -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>() { - suspend fun updateAccount(url: String, user: String, pass: String?, token: String) { + private val clientProvider: EteBaseClientProvider) : CompletableViewModel() { + 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() } } } diff --git a/app/src/main/java/org/tasks/preferences/fragments/Advanced.kt b/app/src/main/java/org/tasks/preferences/fragments/Advanced.kt index 0bfa85737..937827e1b 100644 --- a/app/src/main/java/org/tasks/preferences/fragments/Advanced.kt +++ b/app/src/main/java/org/tasks/preferences/fragments/Advanced.kt @@ -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)