diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f5b521078..7ab710945 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -381,6 +381,10 @@ android:name=".etesync.EteSyncAccountSettingsActivity" android:theme="@style/Tasks" /> + + @@ -601,6 +605,14 @@ android:name=".etesync.EncryptionSettingsActivity" android:theme="@style/Tasks" /> + + + + diff --git a/app/src/main/java/org/tasks/analytics/Constants.kt b/app/src/main/java/org/tasks/analytics/Constants.kt index f91440b72..c6713e17e 100644 --- a/app/src/main/java/org/tasks/analytics/Constants.kt +++ b/app/src/main/java/org/tasks/analytics/Constants.kt @@ -6,4 +6,5 @@ object Constants { const val SYNC_TYPE_DAVX5 = "davx5" const val SYNC_TYPE_GOOGLE_TASKS = "google_tasks" const val SYNC_TYPE_ETESYNC_OT = "etesync_ot" + const val SYNC_TYPE_ETEBASE = "etebase" } \ No newline at end of file diff --git a/app/src/main/java/org/tasks/caldav/BaseCaldavAccountSettingsActivity.kt b/app/src/main/java/org/tasks/caldav/BaseCaldavAccountSettingsActivity.kt index ad891851e..0d20845e4 100644 --- a/app/src/main/java/org/tasks/caldav/BaseCaldavAccountSettingsActivity.kt +++ b/app/src/main/java/org/tasks/caldav/BaseCaldavAccountSettingsActivity.kt @@ -108,7 +108,7 @@ abstract class BaseCaldavAccountSettingsActivity : ThemedInjectingAppCompatActiv get() = !inventory.hasPro @get:StringRes - protected abstract val description: Int + protected open val description = 0 override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) diff --git a/app/src/main/java/org/tasks/data/CaldavAccount.kt b/app/src/main/java/org/tasks/data/CaldavAccount.kt index 8532f0118..5f36281f4 100644 --- a/app/src/main/java/org/tasks/data/CaldavAccount.kt +++ b/app/src/main/java/org/tasks/data/CaldavAccount.kt @@ -12,6 +12,7 @@ import org.tasks.activities.BaseListSettingsActivity import org.tasks.caldav.CaldavCalendarSettingsActivity import org.tasks.caldav.LocalListSettingsActivity import org.tasks.data.OpenTaskDao.Companion.ACCOUNT_TYPE_ETESYNC +import org.tasks.etebase.EteBaseCalendarSettingsActivity import org.tasks.etesync.EteSyncCalendarSettingsActivity import org.tasks.opentasks.OpenTasksListSettingsActivity import org.tasks.security.KeyStoreEncryption @@ -84,9 +85,13 @@ class CaldavAccount : Parcelable { val isCaldavAccount: Boolean get() = accountType == TYPE_CALDAV + @Deprecated("use etebase") val isEteSyncAccount: Boolean get() = accountType == TYPE_ETESYNC + val isEteBaseAccount: Boolean + get() = accountType == TYPE_ETEBASE + val isOpenTasks: Boolean get() = accountType == TYPE_OPENTASKS @@ -100,6 +105,7 @@ class CaldavAccount : Parcelable { TYPE_ETESYNC -> EteSyncCalendarSettingsActivity::class.java TYPE_LOCAL -> LocalListSettingsActivity::class.java TYPE_OPENTASKS -> OpenTasksListSettingsActivity::class.java + TYPE_ETEBASE -> EteBaseCalendarSettingsActivity::class.java else -> CaldavCalendarSettingsActivity::class.java } @@ -159,10 +165,11 @@ class CaldavAccount : Parcelable { companion object { const val TYPE_CALDAV = 0 - const val TYPE_ETESYNC = 1 + @Deprecated("use etebase") const val TYPE_ETESYNC = 1 const val TYPE_LOCAL = 2 const val TYPE_OPENTASKS = 3 const val TYPE_TASKS = 4 + const val TYPE_ETEBASE = 5 fun String?.openTaskType(): String? = this?.split(":")?.get(0) diff --git a/app/src/main/java/org/tasks/etebase/AddEteBaseAccountViewModel.kt b/app/src/main/java/org/tasks/etebase/AddEteBaseAccountViewModel.kt new file mode 100644 index 000000000..30deac0aa --- /dev/null +++ b/app/src/main/java/org/tasks/etebase/AddEteBaseAccountViewModel.kt @@ -0,0 +1,17 @@ +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 client: EteBaseClient): CompletableViewModel>() { + suspend fun addAccount(url: String, username: String, password: String) { + run { + client.setForeground() + val token = client.forUrl(url, username, null, null).getToken(password) + Pair.create(client.forUrl(url, username, null, token!!).userInfo(), token) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/tasks/etebase/CreateCalendarViewModel.kt b/app/src/main/java/org/tasks/etebase/CreateCalendarViewModel.kt new file mode 100644 index 000000000..dfd4d5b43 --- /dev/null +++ b/app/src/main/java/org/tasks/etebase/CreateCalendarViewModel.kt @@ -0,0 +1,12 @@ +package org.tasks.etebase + +import androidx.hilt.lifecycle.ViewModelInject +import org.tasks.data.CaldavAccount +import org.tasks.ui.CompletableViewModel + +class CreateCalendarViewModel @ViewModelInject constructor( + private val client: EteBaseClient) : CompletableViewModel() { + suspend fun createCalendar(account: CaldavAccount, name: String, color: Int) { + run { client.forAccount(account).makeCollection(name, color) } + } +} \ 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 new file mode 100644 index 000000000..02a7f5bd1 --- /dev/null +++ b/app/src/main/java/org/tasks/etebase/CreateUserInfoViewModel.kt @@ -0,0 +1,15 @@ +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 client: EteBaseClient): CompletableViewModel() { + suspend fun createUserInfo(caldavAccount: CaldavAccount, derivedKey: String) { + run { + client.forAccount(caldavAccount).createUserInfo(derivedKey) + derivedKey + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/tasks/etebase/DeleteCalendarViewModel.kt b/app/src/main/java/org/tasks/etebase/DeleteCalendarViewModel.kt new file mode 100644 index 000000000..0c4906e61 --- /dev/null +++ b/app/src/main/java/org/tasks/etebase/DeleteCalendarViewModel.kt @@ -0,0 +1,13 @@ +package org.tasks.etebase + +import androidx.hilt.lifecycle.ViewModelInject +import org.tasks.data.CaldavAccount +import org.tasks.data.CaldavCalendar +import org.tasks.ui.ActionViewModel + +class DeleteCalendarViewModel @ViewModelInject constructor( + private val client: EteBaseClient) : ActionViewModel() { + suspend fun deleteCalendar(account: CaldavAccount, calendar: CaldavCalendar) { + run { client.forAccount(account).deleteCollection(calendar) } + } +} \ 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 new file mode 100644 index 000000000..8a64567f9 --- /dev/null +++ b/app/src/main/java/org/tasks/etebase/EncryptionSettingsActivity.kt @@ -0,0 +1,188 @@ +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 new file mode 100644 index 000000000..37cd0246c --- /dev/null +++ b/app/src/main/java/org/tasks/etebase/EteBaseAccountSettingsActivity.kt @@ -0,0 +1,183 @@ +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 +class EteBaseAccountSettingsActivity : BaseCaldavAccountSettingsActivity(), Toolbar.OnMenuItemClickListener { + @Inject lateinit var eteBaseClient: EteBaseClient + + private val addAccountViewModel: AddEteBaseAccountViewModel by viewModels() + private val updateAccountViewModel: UpdateEteBaseAccountViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding.repeat.visibility = View.GONE + binding.showAdvanced.visibility = View.VISIBLE + updateUrlVisibility() + } + + override fun onResume() { + super.onResume() + if (!isFinishing) { + addAccountViewModel.observe(this, this::addAccount, this::requestFailed) + updateAccountViewModel.observe(this, this::updateAccount, this::requestFailed) + } + } + + override fun onPause() { + super.onPause() + addAccountViewModel.removeObserver(this) + updateAccountViewModel.removeObserver(this) + } + + override val description: Int + get() = R.string.etesync_account_description + + private suspend fun addAccount(userInfoAndToken: Pair) { + caldavAccount = CaldavAccount() + caldavAccount!!.accountType = CaldavAccount.TYPE_ETEBASE + caldavAccount!!.uuid = UUIDHelper.newUUID() + applyTo(caldavAccount!!, userInfoAndToken) + } + + private suspend fun updateAccount(userInfoAndToken: Pair) { + caldavAccount!!.error = "" + applyTo(caldavAccount!!, userInfoAndToken) + } + + private suspend fun applyTo(account: CaldavAccount, userInfoAndToken: Pair) { + hideProgressIndicator() + account.name = newName + account.url = newURL + account.username = newUsername + val token = userInfoAndToken.second + if (token != account.getPassword(encryption)) { + account.password = encryption.encrypt(token!!) + } + 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 + } + + @OnCheckedChanged(R.id.show_advanced) + fun toggleUrl() { + updateUrlVisibility() + } + + private fun updateUrlVisibility() { + binding.urlLayout.visibility = if (binding.showAdvanced.isChecked) View.VISIBLE else View.GONE + } + + override fun needsValidation(): Boolean { + return super.needsValidation() || isNullOrEmpty(caldavAccount!!.encryptionKey) + } + + override suspend fun addAccount(url: String, username: String, password: String) = + addAccountViewModel.addAccount(url, username, password) + + override suspend fun updateAccount(url: String, username: String, password: String) = + updateAccountViewModel.updateAccount( + url, + username, + if (PASSWORD_MASK == password) null else password, + caldavAccount!!.getPassword(encryption)) + + override suspend fun updateAccount() { + caldavAccount!!.name = newName + saveAccountAndFinish() + } + + 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 + } + + override val newPassword: String + get() = binding.password.text.toString().trim { it <= ' ' } + + 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!!) + firebase.logEvent( + R.string.event_sync_add_account, + R.string.param_type to Constants.SYNC_TYPE_ETEBASE + ) + } else { + caldavDao.update(caldavAccount!!) + } + setResult(Activity.RESULT_OK) + finish() + } + + override suspend fun removeAccount() { + caldavAccount?.let { eteBaseClient.forAccount(it).invalidateToken() } + 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/EteBaseCalendarSettingsActivity.kt b/app/src/main/java/org/tasks/etebase/EteBaseCalendarSettingsActivity.kt new file mode 100644 index 000000000..6d7be65d3 --- /dev/null +++ b/app/src/main/java/org/tasks/etebase/EteBaseCalendarSettingsActivity.kt @@ -0,0 +1,33 @@ +package org.tasks.etebase + +import android.os.Bundle +import androidx.activity.viewModels +import dagger.hilt.android.AndroidEntryPoint +import org.tasks.caldav.BaseCaldavCalendarSettingsActivity +import org.tasks.data.CaldavAccount +import org.tasks.data.CaldavCalendar + +@AndroidEntryPoint +class EteBaseCalendarSettingsActivity : BaseCaldavCalendarSettingsActivity() { + private val createCalendarViewModel: CreateCalendarViewModel by viewModels() + private val deleteCalendarViewModel: DeleteCalendarViewModel by viewModels() + private val updateCalendarViewModel: UpdateCalendarViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + createCalendarViewModel.observe(this, this::createSuccessful, this::requestFailed) + deleteCalendarViewModel.observe(this, this::onDeleted, this::requestFailed) + updateCalendarViewModel.observe(this, { updateCalendar() }, this::requestFailed) + } + + override suspend fun createCalendar(caldavAccount: CaldavAccount, name: String, color: Int) = + createCalendarViewModel.createCalendar(caldavAccount, name, color) + + override suspend fun updateNameAndColor( + account: CaldavAccount, calendar: CaldavCalendar, name: String, color: Int) = + updateCalendarViewModel.updateCalendar(account, calendar, name, color) + + override suspend fun deleteCalendar(caldavAccount: CaldavAccount, caldavCalendar: CaldavCalendar) = + deleteCalendarViewModel.deleteCalendar(caldavAccount, caldavCalendar) +} \ 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 new file mode 100644 index 000000000..8db849a98 --- /dev/null +++ b/app/src/main/java/org/tasks/etebase/EteBaseClient.kt @@ -0,0 +1,277 @@ +package org.tasks.etebase + +import android.content.Context +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 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.etesync.journalmanager.util.TokenAuthenticator +import com.google.common.collect.Lists +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import okhttp3.internal.tls.OkHostnameVerifier +import org.tasks.DebugNetworkInterceptor +import org.tasks.caldav.MemoryCookieStore +import org.tasks.data.CaldavAccount +import org.tasks.data.CaldavCalendar +import org.tasks.preferences.Preferences +import org.tasks.security.KeyStoreEncryption +import timber.log.Timber +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 + +class EteBaseClient { + private val encryption: KeyStoreEncryption + private val preferences: Preferences + private val interceptor: DebugNetworkInterceptor + private val username: String? + private val token: String? + private val encryptionPassword: String? + private val httpClient: OkHttpClient? + private val httpUrl: HttpUrl? + private val context: Context + private val journalManager: JournalManager? + private var foreground = false + + @Inject + constructor( + @ApplicationContext context: Context, + encryption: KeyStoreEncryption, + preferences: Preferences, + interceptor: DebugNetworkInterceptor) { + this.context = context + this.encryption = encryption + this.preferences = preferences + this.interceptor = interceptor + username = null + token = null + encryptionPassword = null + httpClient = null + httpUrl = null + journalManager = null + } + + private constructor( + context: Context, + encryption: KeyStoreEncryption, + preferences: Preferences, + interceptor: DebugNetworkInterceptor, + url: String?, + username: String?, + encryptionPassword: String?, + token: String?, + foreground: Boolean) { + this.context = context + this.encryption = encryption + this.preferences = preferences + this.interceptor = interceptor + this.username = username + this.encryptionPassword = encryptionPassword + this.token = token + this.foreground = foreground + val customCertManager = CustomCertManager(context) + customCertManager.appInForeground = 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)) + .cookieJar(MemoryCookieStore()) + .followRedirects(false) + .followSslRedirects(true) + .sslSocketFactory(sslContext.socketFactory, customCertManager) + .hostnameVerifier(hostnameVerifier) + .connectTimeout(15, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .readTimeout(120, TimeUnit.SECONDS) + if (preferences.isFlipperEnabled) { + interceptor.apply(builder) + } + httpClient = builder.build() + httpUrl = url?.toHttpUrlOrNull() + journalManager = JournalManager(httpClient, httpUrl!!) + } + + @Throws(NoSuchAlgorithmException::class, KeyManagementException::class) + suspend fun forAccount(account: CaldavAccount): EteBaseClient { + return forUrl( + account.url, + account.username, + account.getEncryptionPassword(encryption), + account.getPassword(encryption)) + } + + @Throws(KeyManagementException::class, NoSuchAlgorithmException::class) + suspend fun forUrl(url: String?, username: String?, encryptionPassword: String?, token: String?): EteBaseClient = withContext(Dispatchers.IO) { + EteBaseClient( + context, + encryption, + preferences, + interceptor, + url, + username, + encryptionPassword, + token, + foreground) + } + + @Throws(IOException::class, Exceptions.HttpException::class) + suspend fun getToken(password: String?): String? = withContext(Dispatchers.IO) { + JournalAuthenticator(httpClient!!, httpUrl!!).getAuthToken(username!!, password!!) + } + + @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!!) + } + if (userInfo == null) { + throw RuntimeException("Missing userInfo") + } + 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 + } + } + + @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 + } + + @Throws(IntegrityException::class, Exceptions.HttpException::class, VersionTooNewException::class) + suspend fun getSyncEntries( + userInfo: UserInfoManager.UserInfo?, + journal: Journal, + calendar: CaldavCalendar, + callback: suspend (List>) -> Unit) = withContext(Dispatchers.IO) { + val journalEntryManager = JournalEntryManager(httpClient!!, httpUrl!!, journal.uid!!) + val crypto = getCrypto(userInfo, journal) + var journalEntries: List + 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) + } + + @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 + } + } + + fun setForeground() { + foreground = true + } + + suspend fun invalidateToken() = withContext(Dispatchers.IO) { + try { + JournalAuthenticator(httpClient!!, httpUrl!!).invalidateAuthToken(token!!) + } 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 + } + + @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 + } + + @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) + } + + companion object { + private const val TYPE_TASKS = "TASKS" + private const val MAX_FETCH = 50 + private const val MAX_PUSH = 30 + } +} \ 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 new file mode 100644 index 000000000..7b5b9f336 --- /dev/null +++ b/app/src/main/java/org/tasks/etebase/EteBaseSynchronizer.kt @@ -0,0 +1,224 @@ +package org.tasks.etebase + +import android.content.Context +import androidx.core.util.Pair +import at.bitfire.ical4android.ICalendar.Companion.prodId +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 +import net.fortuna.ical4j.model.property.ProdId +import org.tasks.BuildConfig +import org.tasks.LocalBroadcastManager +import org.tasks.R +import org.tasks.Strings.isNullOrEmpty +import org.tasks.billing.Inventory +import org.tasks.caldav.iCalendar +import org.tasks.caldav.iCalendar.Companion.fromVtodo +import org.tasks.data.CaldavAccount +import org.tasks.data.CaldavCalendar +import org.tasks.data.CaldavDao +import org.tasks.data.CaldavTaskContainer +import timber.log.Timber +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, + private val caldavDao: CaldavDao, + private val localBroadcastManager: LocalBroadcastManager, + private val taskDeleter: TaskDeleter, + private val inventory: Inventory, + private val client: EteBaseClient, + private val iCal: iCalendar) { + companion object { + init { + prodId = ProdId("+//IDN tasks.org//android-" + BuildConfig.VERSION_CODE + "//EN") + } + } + + suspend fun sync(account: CaldavAccount) { + Thread.currentThread().contextClassLoader = context.classLoader + + if (!inventory.hasPro) { + setError(account, context.getString(R.string.requires_pro_subscription)) + return + } + if (isNullOrEmpty(account.password)) { + 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) { + setError(account, e.message) + } catch (e: NoSuchAlgorithmException) { + setError(account, e.message) + } catch (e: Exceptions.HttpException) { + setError(account, e.message) + } catch (e: IntegrityException) { + setError(account, e.message) + } catch (e: VersionTooNewException) { + setError(account, e.message) + } + } + + @Throws(KeyManagementException::class, NoSuchAlgorithmException::class, Exceptions.HttpException::class, IntegrityException::class, VersionTooNewException::class) + private suspend fun synchronize(account: CaldavAccount) { + val client = client.forAccount(account) + val userInfo = client.userInfo() + val resources = client.getCalendars(userInfo) + val uids: Set = resources.values.mapNotNull { it.uid }.toHashSet() + Timber.d("Found uids: %s", uids) + for (calendar in caldavDao.findDeletedCalendars(account.uuid!!, uids.toList())) { + taskDeleter.delete(calendar) + } + for ((key, collection) in resources) { + val uid = collection.uid + var calendar = caldavDao.getCalendarByUrl(account.uuid!!, uid!!) + val colorInt = collection.color + val color = colorInt ?: 0 + if (calendar == null) { + calendar = CaldavCalendar() + calendar.name = collection.displayName + 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 + calendar.color = color + caldavDao.update(calendar) + localBroadcastManager.broadcastRefreshList() + } + } + sync(client, userInfo!!, calendar, key) + } + setError(account, "") + } + + private suspend fun setError(account: CaldavAccount, message: String?) { + account.error = message + caldavDao.update(account) + localBroadcastManager.broadcastRefreshList() + if (!isNullOrEmpty(message)) { + Timber.e(message) + } + } + + @Throws(IntegrityException::class, Exceptions.HttpException::class, VersionTooNewException::class) + private suspend fun sync( + client: EteBaseClient, + userInfo: UserInfoManager.UserInfo, + caldavCalendar: CaldavCalendar, + journal: Journal) { + Timber.d("sync(%s)", caldavCalendar) + val localChanges = HashMap() + for (task in caldavDao.getCaldavTasksToPush(caldavCalendar.uuid!!)) { + localChanges[task.remoteId] = task + } + var remoteCtag = journal.lastUid + if (isNullOrEmpty(remoteCtag) || remoteCtag != caldavCalendar.ctag) { + Timber.v("Applying remote changes") + client.getSyncEntries(userInfo, journal, caldavCalendar) { + applyEntries(caldavCalendar, it, localChanges.keys) + } + } else { + Timber.d("%s up to date", caldavCalendar.name) + } + val changes: MutableList = ArrayList() + for (task in caldavDao.getMoved(caldavCalendar.uuid!!)) { + val vtodo = task.vtodo + if (!isNullOrEmpty(vtodo)) { + changes.add(SyncEntry(vtodo!!, SyncEntry.Actions.DELETE)) + } + } + 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)) + } + } else { + changes.add( + SyncEntry( + String(iCal.toVtodo(task.caldavTask, task.task)), + if (existingTask) SyncEntry.Actions.CHANGE else SyncEntry.Actions.ADD)) + } + } + 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()) + } + Timber.d("UPDATE %s", caldavCalendar) + caldavDao.update(caldavCalendar) + caldavDao.updateParents(caldavCalendar.uuid!!) + localBroadcastManager.broadcastRefresh() + } + + private suspend fun applyEntries( + caldavCalendar: CaldavCalendar, + syncEntries: List>, + 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) + 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)) { + 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) + } + } + } + } + caldavCalendar.ctag = journalEntry!!.uid + caldavDao.update(caldavCalendar) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/tasks/etebase/UpdateCalendarViewModel.kt b/app/src/main/java/org/tasks/etebase/UpdateCalendarViewModel.kt new file mode 100644 index 000000000..d234cb040 --- /dev/null +++ b/app/src/main/java/org/tasks/etebase/UpdateCalendarViewModel.kt @@ -0,0 +1,13 @@ +package org.tasks.etebase + +import androidx.hilt.lifecycle.ViewModelInject +import org.tasks.data.CaldavAccount +import org.tasks.data.CaldavCalendar +import org.tasks.ui.CompletableViewModel + +class UpdateCalendarViewModel @ViewModelInject constructor( + private val client: EteBaseClient): CompletableViewModel() { + suspend fun updateCalendar(account: CaldavAccount, calendar: CaldavCalendar, name: String, color: Int) { + run { client.forAccount(account).updateCollection(calendar, name, color) } + } +} \ 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 new file mode 100644 index 000000000..37497595c --- /dev/null +++ b/app/src/main/java/org/tasks/etebase/UpdateEteBaseAccountViewModel.kt @@ -0,0 +1,22 @@ +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 client: EteBaseClient) : CompletableViewModel>() { + suspend fun updateAccount(url: String, user: String, pass: String?, token: String) { + run { + client.setForeground() + if (isNullOrEmpty(pass)) { + Pair.create(client.forUrl(url, user, null, token).userInfo(), token) + } else { + val newToken = client.forUrl(url, user, null, null).getToken(pass) + Pair.create(client.forUrl(url, user, null, newToken).userInfo(), newToken) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/tasks/etesync/AddEteSyncAccountViewModel.kt b/app/src/main/java/org/tasks/etesync/AddEteSyncAccountViewModel.kt index 20345c188..9056aaabd 100644 --- a/app/src/main/java/org/tasks/etesync/AddEteSyncAccountViewModel.kt +++ b/app/src/main/java/org/tasks/etesync/AddEteSyncAccountViewModel.kt @@ -5,6 +5,7 @@ import androidx.hilt.lifecycle.ViewModelInject import com.etesync.journalmanager.UserInfoManager.UserInfo import org.tasks.ui.CompletableViewModel +@Deprecated("use etebase") class AddEteSyncAccountViewModel @ViewModelInject constructor( private val client: EteSyncClient): CompletableViewModel>() { suspend fun addAccount(url: String, username: String, password: String) { diff --git a/app/src/main/java/org/tasks/etesync/CreateCalendarViewModel.kt b/app/src/main/java/org/tasks/etesync/CreateCalendarViewModel.kt index cc13de013..a70ea6fc2 100644 --- a/app/src/main/java/org/tasks/etesync/CreateCalendarViewModel.kt +++ b/app/src/main/java/org/tasks/etesync/CreateCalendarViewModel.kt @@ -4,6 +4,7 @@ import androidx.hilt.lifecycle.ViewModelInject import org.tasks.data.CaldavAccount import org.tasks.ui.CompletableViewModel +@Deprecated("use etebase") class CreateCalendarViewModel @ViewModelInject constructor( private val client: EteSyncClient) : CompletableViewModel() { suspend fun createCalendar(account: CaldavAccount, name: String, color: Int) { diff --git a/app/src/main/java/org/tasks/etesync/CreateUserInfoViewModel.kt b/app/src/main/java/org/tasks/etesync/CreateUserInfoViewModel.kt index 12e7ac367..22bfc1bf3 100644 --- a/app/src/main/java/org/tasks/etesync/CreateUserInfoViewModel.kt +++ b/app/src/main/java/org/tasks/etesync/CreateUserInfoViewModel.kt @@ -4,6 +4,7 @@ import androidx.hilt.lifecycle.ViewModelInject import org.tasks.data.CaldavAccount import org.tasks.ui.CompletableViewModel +@Deprecated("use etebase") class CreateUserInfoViewModel @ViewModelInject constructor( private val client: EteSyncClient): CompletableViewModel() { suspend fun createUserInfo(caldavAccount: CaldavAccount, derivedKey: String) { diff --git a/app/src/main/java/org/tasks/etesync/DeleteCalendarViewModel.kt b/app/src/main/java/org/tasks/etesync/DeleteCalendarViewModel.kt index fcb497352..4af0817f2 100644 --- a/app/src/main/java/org/tasks/etesync/DeleteCalendarViewModel.kt +++ b/app/src/main/java/org/tasks/etesync/DeleteCalendarViewModel.kt @@ -5,6 +5,7 @@ import org.tasks.data.CaldavAccount import org.tasks.data.CaldavCalendar import org.tasks.ui.ActionViewModel +@Deprecated("use etebase") class DeleteCalendarViewModel @ViewModelInject constructor( private val client: EteSyncClient) : ActionViewModel() { suspend fun deleteCalendar(account: CaldavAccount, calendar: CaldavCalendar) { diff --git a/app/src/main/java/org/tasks/etesync/EncryptionSettingsActivity.kt b/app/src/main/java/org/tasks/etesync/EncryptionSettingsActivity.kt index 69144c4ef..08f5c54bf 100644 --- a/app/src/main/java/org/tasks/etesync/EncryptionSettingsActivity.kt +++ b/app/src/main/java/org/tasks/etesync/EncryptionSettingsActivity.kt @@ -31,6 +31,7 @@ import org.tasks.ui.DisplayableException import java.net.ConnectException import javax.inject.Inject +@Deprecated("use etebase") @AndroidEntryPoint class EncryptionSettingsActivity : ThemedInjectingAppCompatActivity(), Toolbar.OnMenuItemClickListener { @Inject lateinit var encryption: KeyStoreEncryption diff --git a/app/src/main/java/org/tasks/etesync/EteSyncAccountSettingsActivity.kt b/app/src/main/java/org/tasks/etesync/EteSyncAccountSettingsActivity.kt index 5a8a48320..c66609fb7 100644 --- a/app/src/main/java/org/tasks/etesync/EteSyncAccountSettingsActivity.kt +++ b/app/src/main/java/org/tasks/etesync/EteSyncAccountSettingsActivity.kt @@ -25,6 +25,7 @@ import org.tasks.data.CaldavAccount import timber.log.Timber import javax.inject.Inject +@Deprecated("use etebase") @AndroidEntryPoint class EteSyncAccountSettingsActivity : BaseCaldavAccountSettingsActivity(), Toolbar.OnMenuItemClickListener { @Inject lateinit var eteSyncClient: EteSyncClient @@ -53,9 +54,6 @@ class EteSyncAccountSettingsActivity : BaseCaldavAccountSettingsActivity(), Tool updateAccountViewModel.removeObserver(this) } - override val description: Int - get() = R.string.etesync_account_description - private suspend fun addAccount(userInfoAndToken: Pair) { caldavAccount = CaldavAccount() caldavAccount!!.accountType = CaldavAccount.TYPE_ETESYNC diff --git a/app/src/main/java/org/tasks/etesync/EteSyncCalendarSettingsActivity.kt b/app/src/main/java/org/tasks/etesync/EteSyncCalendarSettingsActivity.kt index 175e7037a..9f7170e48 100644 --- a/app/src/main/java/org/tasks/etesync/EteSyncCalendarSettingsActivity.kt +++ b/app/src/main/java/org/tasks/etesync/EteSyncCalendarSettingsActivity.kt @@ -7,6 +7,7 @@ import org.tasks.caldav.BaseCaldavCalendarSettingsActivity import org.tasks.data.CaldavAccount import org.tasks.data.CaldavCalendar +@Deprecated("use etebase") @AndroidEntryPoint class EteSyncCalendarSettingsActivity : BaseCaldavCalendarSettingsActivity() { private val createCalendarViewModel: CreateCalendarViewModel by viewModels() diff --git a/app/src/main/java/org/tasks/etesync/EteSyncClient.kt b/app/src/main/java/org/tasks/etesync/EteSyncClient.kt index 4a94d3766..22b7e99d7 100644 --- a/app/src/main/java/org/tasks/etesync/EteSyncClient.kt +++ b/app/src/main/java/org/tasks/etesync/EteSyncClient.kt @@ -38,6 +38,7 @@ import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.net.ssl.SSLContext +@Deprecated("use etebase") class EteSyncClient { private val encryption: KeyStoreEncryption private val preferences: Preferences diff --git a/app/src/main/java/org/tasks/etesync/EteSynchronizer.kt b/app/src/main/java/org/tasks/etesync/EteSynchronizer.kt index 9a1fc01e6..bea6523ef 100644 --- a/app/src/main/java/org/tasks/etesync/EteSynchronizer.kt +++ b/app/src/main/java/org/tasks/etesync/EteSynchronizer.kt @@ -33,6 +33,7 @@ import java.util.* import javax.inject.Inject import kotlin.collections.HashSet +@Deprecated("use etebase") class EteSynchronizer @Inject constructor( @param:ApplicationContext private val context: Context, private val caldavDao: CaldavDao, diff --git a/app/src/main/java/org/tasks/etesync/UpdateCalendarViewModel.kt b/app/src/main/java/org/tasks/etesync/UpdateCalendarViewModel.kt index 2d9eb8b6d..df85d4fd9 100644 --- a/app/src/main/java/org/tasks/etesync/UpdateCalendarViewModel.kt +++ b/app/src/main/java/org/tasks/etesync/UpdateCalendarViewModel.kt @@ -5,6 +5,7 @@ import org.tasks.data.CaldavAccount import org.tasks.data.CaldavCalendar import org.tasks.ui.CompletableViewModel +@Deprecated("use etebase") class UpdateCalendarViewModel @ViewModelInject constructor( private val client: EteSyncClient): CompletableViewModel() { suspend fun updateCalendar(account: CaldavAccount, calendar: CaldavCalendar, name: String, color: Int) { diff --git a/app/src/main/java/org/tasks/etesync/UpdateEteSyncAccountViewModel.kt b/app/src/main/java/org/tasks/etesync/UpdateEteSyncAccountViewModel.kt index 46a93757c..9f4282c1f 100644 --- a/app/src/main/java/org/tasks/etesync/UpdateEteSyncAccountViewModel.kt +++ b/app/src/main/java/org/tasks/etesync/UpdateEteSyncAccountViewModel.kt @@ -6,6 +6,7 @@ import com.etesync.journalmanager.UserInfoManager.UserInfo import org.tasks.Strings.isNullOrEmpty import org.tasks.ui.CompletableViewModel +@Deprecated("use etebase") class UpdateEteSyncAccountViewModel @ViewModelInject constructor( private val client: EteSyncClient) : CompletableViewModel>() { suspend fun updateAccount(url: String, user: String, pass: String?, token: String) { diff --git a/app/src/main/java/org/tasks/jobs/SyncEteBaseWork.kt b/app/src/main/java/org/tasks/jobs/SyncEteBaseWork.kt new file mode 100644 index 000000000..58ccfd5bf --- /dev/null +++ b/app/src/main/java/org/tasks/jobs/SyncEteBaseWork.kt @@ -0,0 +1,42 @@ +package org.tasks.jobs + +import android.content.Context +import androidx.hilt.Assisted +import androidx.hilt.work.WorkerInject +import androidx.work.WorkerParameters +import kotlinx.coroutines.* +import org.tasks.LocalBroadcastManager +import org.tasks.R +import org.tasks.analytics.Firebase +import org.tasks.data.CaldavAccount.Companion.TYPE_ETEBASE +import org.tasks.data.CaldavDao +import org.tasks.etebase.EteBaseSynchronizer +import org.tasks.preferences.Preferences + +class SyncEteBaseWork @WorkerInject constructor( + @Assisted context: Context, + @Assisted workerParams: WorkerParameters, + firebase: Firebase, + localBroadcastManager: LocalBroadcastManager, + preferences: Preferences, + private val caldavDao: CaldavDao, + private val synchronizer: EteBaseSynchronizer +) : SyncWork(context, workerParams, firebase, localBroadcastManager, preferences) { + + override suspend fun enabled() = caldavDao.getAccounts(TYPE_ETEBASE).isNotEmpty() + + override val syncStatus = R.string.p_sync_ongoing_etebase + + override suspend fun doSync() { + jobs().awaitAll() + } + + private suspend fun jobs(): List> = coroutineScope { + caldavDao.getAccounts(TYPE_ETEBASE) + .map { + async(Dispatchers.IO) { + synchronizer.sync(it) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/tasks/jobs/SyncEteSyncWork.kt b/app/src/main/java/org/tasks/jobs/SyncEteSyncWork.kt index afcb69b87..0f2a0e5da 100644 --- a/app/src/main/java/org/tasks/jobs/SyncEteSyncWork.kt +++ b/app/src/main/java/org/tasks/jobs/SyncEteSyncWork.kt @@ -13,6 +13,7 @@ import org.tasks.data.CaldavDao import org.tasks.etesync.EteSynchronizer import org.tasks.preferences.Preferences +@Deprecated("use etebase") class SyncEteSyncWork @WorkerInject constructor( @Assisted context: Context, @Assisted workerParams: WorkerParameters, diff --git a/app/src/main/java/org/tasks/jobs/WorkManager.kt b/app/src/main/java/org/tasks/jobs/WorkManager.kt index 4495777a5..98263cab3 100644 --- a/app/src/main/java/org/tasks/jobs/WorkManager.kt +++ b/app/src/main/java/org/tasks/jobs/WorkManager.kt @@ -20,8 +20,11 @@ interface WorkManager { fun caldavSync(immediate: Boolean) + @Deprecated("use etebase") fun eteSync(immediate: Boolean) + fun eteBaseSync(immediate: Boolean) + fun openTaskSync(immediate: Boolean) fun reverseGeocode(place: Place) @@ -53,11 +56,13 @@ interface WorkManager { const val TAG_MIDNIGHT_REFRESH = "tag_midnight_refresh" const val TAG_SYNC_GOOGLE_TASKS = "tag_sync_google_tasks" const val TAG_SYNC_CALDAV = "tag_sync_caldav" - const val TAG_SYNC_ETESYNC = "tag_sync_etesync" + @Deprecated("use etebase") const val TAG_SYNC_ETESYNC = "tag_sync_etesync" + const val TAG_SYNC_ETEBASE = "tag_sync_etebase" const val TAG_SYNC_OPENTASK = "tag_sync_opentask" const val TAG_BACKGROUND_SYNC_GOOGLE_TASKS = "tag_background_sync_google_tasks" const val TAG_BACKGROUND_SYNC_CALDAV = "tag_background_sync_caldav" - const val TAG_BACKGROUND_SYNC_ETESYNC = "tag_background_sync_etesync" + @Deprecated("use etebase") const val TAG_BACKGROUND_SYNC_ETESYNC = "tag_background_sync_etesync" + const val TAG_BACKGROUND_SYNC_ETEBASE = "tag_background_sync_etebase" const val TAG_BACKGROUND_SYNC_OPENTASKS = "tag_background_sync_opentasks" const val TAG_REMOTE_CONFIG = "tag_remote_config" const val TAG_MIGRATE_LOCAL = "tag_migrate_local" diff --git a/app/src/main/java/org/tasks/jobs/WorkManagerImpl.kt b/app/src/main/java/org/tasks/jobs/WorkManagerImpl.kt index 849e0a757..2641ed738 100644 --- a/app/src/main/java/org/tasks/jobs/WorkManagerImpl.kt +++ b/app/src/main/java/org/tasks/jobs/WorkManagerImpl.kt @@ -14,6 +14,7 @@ import org.tasks.BuildConfig import org.tasks.R import org.tasks.data.* import org.tasks.data.CaldavAccount.Companion.TYPE_CALDAV +import org.tasks.data.CaldavAccount.Companion.TYPE_ETEBASE import org.tasks.data.CaldavAccount.Companion.TYPE_ETESYNC import org.tasks.data.CaldavAccount.Companion.TYPE_OPENTASKS import org.tasks.data.CaldavAccount.Companion.TYPE_TASKS @@ -24,6 +25,7 @@ import org.tasks.jobs.SyncWork.Companion.EXTRA_IMMEDIATE import org.tasks.jobs.WorkManager.Companion.MAX_CLEANUP_LENGTH import org.tasks.jobs.WorkManager.Companion.REMOTE_CONFIG_INTERVAL_HOURS import org.tasks.jobs.WorkManager.Companion.TAG_BACKGROUND_SYNC_CALDAV +import org.tasks.jobs.WorkManager.Companion.TAG_BACKGROUND_SYNC_ETEBASE import org.tasks.jobs.WorkManager.Companion.TAG_BACKGROUND_SYNC_ETESYNC import org.tasks.jobs.WorkManager.Companion.TAG_BACKGROUND_SYNC_GOOGLE_TASKS import org.tasks.jobs.WorkManager.Companion.TAG_BACKGROUND_SYNC_OPENTASKS @@ -33,6 +35,7 @@ import org.tasks.jobs.WorkManager.Companion.TAG_MIGRATE_LOCAL import org.tasks.jobs.WorkManager.Companion.TAG_REFRESH import org.tasks.jobs.WorkManager.Companion.TAG_REMOTE_CONFIG import org.tasks.jobs.WorkManager.Companion.TAG_SYNC_CALDAV +import org.tasks.jobs.WorkManager.Companion.TAG_SYNC_ETEBASE import org.tasks.jobs.WorkManager.Companion.TAG_SYNC_ETESYNC import org.tasks.jobs.WorkManager.Companion.TAG_SYNC_GOOGLE_TASKS import org.tasks.jobs.WorkManager.Companion.TAG_SYNC_OPENTASK @@ -101,6 +104,9 @@ class WorkManagerImpl constructor( override fun eteSync(immediate: Boolean) = sync(immediate, TAG_SYNC_ETESYNC, SyncEteSyncWork::class.java) + override fun eteBaseSync(immediate: Boolean) = + sync(immediate, TAG_SYNC_ETEBASE, SyncEteBaseWork::class.java) + override fun openTaskSync(immediate: Boolean) = sync(immediate, TAG_SYNC_OPENTASK, SyncOpenTasksWork::class.java, false) @@ -168,6 +174,13 @@ class WorkManagerImpl constructor( enabled && caldavDao.getAccounts(TYPE_ETESYNC).isNotEmpty(), unmetered) } + throttle.run { + scheduleBackgroundSync( + TAG_BACKGROUND_SYNC_ETEBASE, + SyncEteBaseWork::class.java, + enabled && caldavDao.getAccounts(TYPE_ETEBASE).isNotEmpty(), + unmetered) + } throttle.run { scheduleBackgroundSync( TAG_BACKGROUND_SYNC_OPENTASKS, diff --git a/app/src/main/java/org/tasks/opentasks/OpenTaskAccountSettingsActivity.kt b/app/src/main/java/org/tasks/opentasks/OpenTaskAccountSettingsActivity.kt index 369ad8ba5..5a480537f 100644 --- a/app/src/main/java/org/tasks/opentasks/OpenTaskAccountSettingsActivity.kt +++ b/app/src/main/java/org/tasks/opentasks/OpenTaskAccountSettingsActivity.kt @@ -25,9 +25,6 @@ class OpenTaskAccountSettingsActivity : BaseCaldavAccountSettingsActivity(), Too } } - override val description: Int - get() = 0 - override val newPassword: String? get() = "" diff --git a/app/src/main/java/org/tasks/preferences/fragments/Synchronization.kt b/app/src/main/java/org/tasks/preferences/fragments/Synchronization.kt index 31a88041b..6ade99730 100644 --- a/app/src/main/java/org/tasks/preferences/fragments/Synchronization.kt +++ b/app/src/main/java/org/tasks/preferences/fragments/Synchronization.kt @@ -24,6 +24,7 @@ import org.tasks.data.GoogleTaskAccount import org.tasks.data.GoogleTaskListDao import org.tasks.data.OpenTaskDao.Companion.ACCOUNT_TYPE_DAVx5 import org.tasks.data.OpenTaskDao.Companion.ACCOUNT_TYPE_ETESYNC +import org.tasks.etebase.EteBaseAccountSettingsActivity import org.tasks.etesync.EteSyncAccountSettingsActivity import org.tasks.injection.InjectingPreferenceFragment import org.tasks.jobs.WorkManager @@ -149,7 +150,8 @@ class Synchronization : InjectingPreferenceFragment() { if (isNullOrEmpty(error)) { preference.setSummary(when { account.isCaldavAccount -> R.string.caldav - account.isEteSyncAccount + account.isEteSyncAccount -> R.string.etesync_v1 + account.isEteBaseAccount || (account.isOpenTasks && account.uuid?.startsWith(ACCOUNT_TYPE_ETESYNC) == true) -> R.string.etesync @@ -165,6 +167,7 @@ class Synchronization : InjectingPreferenceFragment() { val intent = Intent(context, when { account.isCaldavAccount -> CaldavAccountSettingsActivity::class.java account.isEteSyncAccount -> EteSyncAccountSettingsActivity::class.java + account.isEteBaseAccount -> EteBaseAccountSettingsActivity::class.java account.isOpenTasks -> OpenTaskAccountSettingsActivity::class.java else -> throw IllegalArgumentException("Unexpected account type: $account") }) diff --git a/app/src/main/java/org/tasks/sync/AddAccountDialog.kt b/app/src/main/java/org/tasks/sync/AddAccountDialog.kt index d5175881a..716f926f4 100644 --- a/app/src/main/java/org/tasks/sync/AddAccountDialog.kt +++ b/app/src/main/java/org/tasks/sync/AddAccountDialog.kt @@ -17,7 +17,7 @@ import dagger.hilt.android.AndroidEntryPoint import org.tasks.R import org.tasks.caldav.CaldavAccountSettingsActivity import org.tasks.dialogs.DialogBuilder -import org.tasks.etesync.EteSyncAccountSettingsActivity +import org.tasks.etebase.EteBaseAccountSettingsActivity import org.tasks.preferences.fragments.Synchronization.Companion.REQUEST_CALDAV_SETTINGS import org.tasks.preferences.fragments.Synchronization.Companion.REQUEST_GOOGLE_TASKS import org.tasks.themes.DrawableUtil @@ -65,7 +65,7 @@ class AddAccountDialog : DialogFragment() { Intent(activity, CaldavAccountSettingsActivity::class.java), REQUEST_CALDAV_SETTINGS) 3 -> activity?.startActivityForResult( - Intent(activity, EteSyncAccountSettingsActivity::class.java), + Intent(activity, EteBaseAccountSettingsActivity::class.java), REQUEST_CALDAV_SETTINGS) } dialog.dismiss() diff --git a/app/src/main/java/org/tasks/sync/SyncAdapters.kt b/app/src/main/java/org/tasks/sync/SyncAdapters.kt index 44934697e..533e863db 100644 --- a/app/src/main/java/org/tasks/sync/SyncAdapters.kt +++ b/app/src/main/java/org/tasks/sync/SyncAdapters.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.* import org.tasks.LocalBroadcastManager import org.tasks.R import org.tasks.data.CaldavAccount.Companion.TYPE_CALDAV +import org.tasks.data.CaldavAccount.Companion.TYPE_ETEBASE import org.tasks.data.CaldavAccount.Companion.TYPE_ETESYNC import org.tasks.data.CaldavAccount.Companion.TYPE_OPENTASKS import org.tasks.data.CaldavAccount.Companion.TYPE_TASKS @@ -15,6 +16,7 @@ import org.tasks.data.GoogleTaskListDao import org.tasks.data.OpenTaskDao import org.tasks.jobs.WorkManager import org.tasks.jobs.WorkManager.Companion.TAG_SYNC_CALDAV +import org.tasks.jobs.WorkManager.Companion.TAG_SYNC_ETEBASE import org.tasks.jobs.WorkManager.Companion.TAG_SYNC_ETESYNC import org.tasks.jobs.WorkManager.Companion.TAG_SYNC_GOOGLE_TASKS import org.tasks.jobs.WorkManager.Companion.TAG_SYNC_OPENTASK @@ -36,7 +38,8 @@ class SyncAdapters @Inject constructor( private val scope = CoroutineScope(newSingleThreadExecutor().asCoroutineDispatcher() + SupervisorJob()) private val googleTasks = Debouncer(TAG_SYNC_GOOGLE_TASKS) { workManager.googleTaskSync(it) } private val caldav = Debouncer(TAG_SYNC_CALDAV) { workManager.caldavSync(it) } - private val eteSync = Debouncer(TAG_SYNC_ETESYNC) { workManager.eteSync(it) } + @Deprecated("use etebase") private val eteSync = Debouncer(TAG_SYNC_ETESYNC) { workManager.eteSync(it) } + private val eteBaseSync = Debouncer(TAG_SYNC_ETEBASE) { workManager.eteBaseSync(it) } private val opentasks = Debouncer(TAG_SYNC_OPENTASK) { workManager.openTaskSync(it) } private val syncStatus = Debouncer("sync_status") { if (preferences.getBoolean(R.string.p_sync_ongoing_android, false) != it @@ -62,6 +65,9 @@ class SyncAdapters @Inject constructor( if (caldavDao.isAccountType(task.id, TYPE_ETESYNC)) { eteSync.sync(false) } + if (caldavDao.isAccountType(task.id, TYPE_ETEBASE)) { + eteBaseSync.sync(false) + } if (caldavDao.isAccountType(task.id, TYPE_OPENTASKS)) { opentasks.sync(false) } @@ -84,6 +90,7 @@ class SyncAdapters @Inject constructor( val googleTasksEnabled = async { isGoogleTaskSyncEnabled() } val caldavEnabled = async { isCaldavSyncEnabled() } val eteSyncEnabled = async { isEteSyncEnabled() } + val eteBaseEnabled = async { isEteBaseEnabled() } val opentasksEnabled = async { isOpenTaskSyncEnabled() } if (googleTasksEnabled.await()) { @@ -98,6 +105,10 @@ class SyncAdapters @Inject constructor( eteSync.sync(immediate) } + if (eteBaseEnabled.await()) { + eteBaseSync.sync(immediate) + } + if (opentasksEnabled.await()) { opentasks.sync(immediate) } @@ -108,8 +119,11 @@ class SyncAdapters @Inject constructor( private suspend fun isCaldavSyncEnabled() = caldavDao.getAccounts(TYPE_CALDAV, TYPE_TASKS).isNotEmpty() + @Deprecated("use etebase") private suspend fun isEteSyncEnabled() = caldavDao.getAccounts(TYPE_ETESYNC).isNotEmpty() + private suspend fun isEteBaseEnabled() = caldavDao.getAccounts(TYPE_ETEBASE).isNotEmpty() + private suspend fun isOpenTaskSyncEnabled() = caldavDao.getAccounts(TYPE_OPENTASKS).isNotEmpty() || openTaskDao.newAccounts().isNotEmpty() diff --git a/app/src/main/res/values/keys.xml b/app/src/main/res/values/keys.xml index 2fa5c2a1f..e55ba0803 100644 --- a/app/src/main/res/values/keys.xml +++ b/app/src/main/res/values/keys.xml @@ -12,6 +12,7 @@ Tasks Shortcut CalDAV EteSync + EteSync v1 DAVx⁵ Tasks.org https://api.etesync.com @@ -390,6 +391,7 @@ sync_ongoing_google_tasks sync_ongoing_caldav sync_ongoing_etesync + sync_ongoing_etebase sync_ongoing_opentasks sync_ongoing_android last_backup