From 16ae98f9eb0e8a4f53165a13e7b8b03bb602dccd Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Wed, 30 Dec 2020 14:07:29 -0600 Subject: [PATCH] Generate app passwords for Tasks.org --- .../java/org/tasks/billing/PurchaseDialog.kt | 6 +- .../java/org/tasks/caldav/CaldavClient.kt | 2 +- .../org/tasks/caldav/CaldavClientProvider.kt | 22 ++-- .../main/java/org/tasks/caldav/TasksClient.kt | 75 ++++++++++++ .../org/tasks/dialogs/AlertDialogBuilder.java | 7 +- .../org/tasks/preferences/IconPreference.kt | 2 +- .../preferences/fragments/TasksAccount.kt | 108 +++++++++++++++++- .../fragments/TasksAccountViewModel.kt | 103 +++++++++++++++++ app/src/main/java/org/tasks/ui/Toaster.java | 15 ++- .../main/res/layout/dialog_app_password.xml | 72 ++++++++++++ app/src/main/res/values/keys.xml | 7 +- app/src/main/res/values/strings.xml | 10 ++ app/src/main/res/xml/preferences_tasks.xml | 20 ++++ 13 files changed, 423 insertions(+), 26 deletions(-) create mode 100644 app/src/main/java/org/tasks/caldav/TasksClient.kt create mode 100644 app/src/main/java/org/tasks/preferences/fragments/TasksAccountViewModel.kt create mode 100644 app/src/main/res/layout/dialog_app_password.xml diff --git a/app/src/main/java/org/tasks/billing/PurchaseDialog.kt b/app/src/main/java/org/tasks/billing/PurchaseDialog.kt index bf1875bd8..7ff6badb5 100644 --- a/app/src/main/java/org/tasks/billing/PurchaseDialog.kt +++ b/app/src/main/java/org/tasks/billing/PurchaseDialog.kt @@ -135,9 +135,8 @@ _${getString(R.string.upgrade_tasks_no_account)}_ """ --- #### ${getString(R.string.upgrade_tasks_account)} -* ${getString(R.string.upgrade_sync_with_tasks)} -* ${getString(R.string.upgrade_open_internet_standards)} -* ${getString(R.string.upgrade_privacy)} +* ${getString(R.string.upgrade_sync_tasks)} +* ${getString(R.string.upgrade_third_party_apps)} * [${getString(R.string.upgrade_coming_soon)}](${getString(R.string.help_url_sync)}) """ } @@ -163,6 +162,7 @@ _${getString(R.string.upgrade_tasks_no_account)}_ --- * ${getString(R.string.upgrade_free_trial)} * ${getString(R.string.upgrade_downgrade)} +* ${getString(R.string.upgrade_support_development)} """ } binding.text.text = markwon.toMarkdown(benefits) diff --git a/app/src/main/java/org/tasks/caldav/CaldavClient.kt b/app/src/main/java/org/tasks/caldav/CaldavClient.kt index da09a7630..200d07bd3 100644 --- a/app/src/main/java/org/tasks/caldav/CaldavClient.kt +++ b/app/src/main/java/org/tasks/caldav/CaldavClient.kt @@ -31,7 +31,7 @@ import java.security.KeyManagementException import java.security.NoSuchAlgorithmException import java.util.* -class CaldavClient( +open class CaldavClient( private val provider: CaldavClientProvider, private val customCertManager: CustomCertManager, val httpClient: OkHttpClient, diff --git a/app/src/main/java/org/tasks/caldav/CaldavClientProvider.kt b/app/src/main/java/org/tasks/caldav/CaldavClientProvider.kt index c0321ceec..2e5c7ac32 100644 --- a/app/src/main/java/org/tasks/caldav/CaldavClientProvider.kt +++ b/app/src/main/java/org/tasks/caldav/CaldavClientProvider.kt @@ -45,6 +45,13 @@ class CaldavClientProvider @Inject constructor( ) } + suspend fun forTasksAccount(account: CaldavAccount): TasksClient { + if (!account.isTasksOrg) { + throw IllegalArgumentException() + } + return forAccount(account) as TasksClient + } + suspend fun forAccount(account: CaldavAccount, url: String? = account.url): CaldavClient { val auth = getAuthInterceptor( account.username, @@ -52,12 +59,12 @@ class CaldavClientProvider @Inject constructor( account.url ) val customCertManager = newCertManager() - return CaldavClient( - this, - customCertManager, - createHttpClient(auth, customCertManager), - url?.toHttpUrlOrNull() - ) + val client = createHttpClient(auth, customCertManager) + return if (account.isTasksOrg) { + TasksClient(this, customCertManager, client, url?.toHttpUrlOrNull()) + } else { + CaldavClient(this, customCertManager, client, url?.toHttpUrlOrNull()) + } } private suspend fun newCertManager() = withContext(Dispatchers.Default) { @@ -74,8 +81,7 @@ class CaldavClientProvider @Inject constructor( else -> BasicDigestAuthHandler(null, username, password) } - private fun createHttpClient(auth: Interceptor?, customCertManager: CustomCertManager, foreground: Boolean = false): OkHttpClient { - customCertManager.appInForeground = foreground + private fun createHttpClient(auth: Interceptor?, customCertManager: CustomCertManager): OkHttpClient { val hostnameVerifier = customCertManager.hostnameVerifier(OkHostnameVerifier) val sslContext = SSLContext.getInstance("TLS") sslContext.init(null, arrayOf(customCertManager), null) diff --git a/app/src/main/java/org/tasks/caldav/TasksClient.kt b/app/src/main/java/org/tasks/caldav/TasksClient.kt new file mode 100644 index 000000000..956d2f5a4 --- /dev/null +++ b/app/src/main/java/org/tasks/caldav/TasksClient.kt @@ -0,0 +1,75 @@ +package org.tasks.caldav + +import at.bitfire.cert4android.CustomCertManager +import at.bitfire.dav4jvm.exception.HttpException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.* +import org.json.JSONObject + +class TasksClient constructor( + provider: CaldavClientProvider, + certManager: CustomCertManager, + httpClient: OkHttpClient, + private val httpUrl: HttpUrl? +) : CaldavClient(provider, certManager, httpClient, httpUrl) { + suspend fun generateNewPassword(description: String?): JSONObject? = withContext(Dispatchers.IO) { + val url = httpUrl?.resolve(ENDPOINT_PASSWORDS) ?: return@withContext null + httpClient + .newCall(Request.Builder() + .post(FormBody.Builder() + .apply { + if (!description.isNullOrBlank()) { + add(FORM_DESCRIPTION, description) + } + } + .build() + ) + .url(url) + .build()) + .execute() + .use { response -> + if (!response.isSuccessful) { + throw HttpException(response) + } + response.body?.use { body -> JSONObject(body.string()) } + } + } + + suspend fun deletePassword(id: Int) = withContext(Dispatchers.IO) { + val url = httpUrl?.resolve(ENDPOINT_PASSWORDS) ?: return@withContext false + httpClient + .newCall(Request.Builder() + .delete(FormBody.Builder().add(FORM_SESSION_ID, id.toString()).build()) + .url(url) + .build()) + .execute() + .use { response -> + if (!response.isSuccessful) { + throw HttpException(response) + } + } + } + + suspend fun getAppPasswords(): JSONObject? = withContext(Dispatchers.IO) { + val url = httpUrl?.resolve(ENDPOINT_PASSWORDS) ?: return@withContext null + httpClient + .newCall(Request.Builder() + .get() + .url(url) + .build()) + .execute() + .use { response -> + if (!response.isSuccessful) { + throw HttpException(response) + } + response.body?.use { body -> JSONObject(body.string()) } + } + } + + companion object { + private const val ENDPOINT_PASSWORDS = "/app-passwords" + private const val FORM_DESCRIPTION = "description" + private const val FORM_SESSION_ID = "session_id" + } +} \ No newline at end of file diff --git a/app/src/main/java/org/tasks/dialogs/AlertDialogBuilder.java b/app/src/main/java/org/tasks/dialogs/AlertDialogBuilder.java index b1134204b..fffb0d2f8 100644 --- a/app/src/main/java/org/tasks/dialogs/AlertDialogBuilder.java +++ b/app/src/main/java/org/tasks/dialogs/AlertDialogBuilder.java @@ -48,7 +48,7 @@ public class AlertDialogBuilder { return this; } - AlertDialogBuilder setTitle(int title, Object... formatArgs) { + public AlertDialogBuilder setTitle(int title, Object... formatArgs) { builder.setTitle(context.getString(title, formatArgs)); return this; } @@ -125,6 +125,11 @@ public class AlertDialogBuilder { return this; } + public AlertDialogBuilder setCancelable(boolean cancelable) { + builder.setCancelable(cancelable); + return this; + } + public AlertDialog create() { return builder.create(); } diff --git a/app/src/main/java/org/tasks/preferences/IconPreference.kt b/app/src/main/java/org/tasks/preferences/IconPreference.kt index fa8815b3a..f3c1e8d28 100644 --- a/app/src/main/java/org/tasks/preferences/IconPreference.kt +++ b/app/src/main/java/org/tasks/preferences/IconPreference.kt @@ -9,7 +9,7 @@ import androidx.preference.Preference import androidx.preference.PreferenceViewHolder import org.tasks.R -class IconPreference(context: Context?, attrs: AttributeSet?) : Preference(context, attrs) { +class IconPreference(context: Context?, attrs: AttributeSet? = null) : Preference(context, attrs) { private var imageView: ImageView? = null diff --git a/app/src/main/java/org/tasks/preferences/fragments/TasksAccount.kt b/app/src/main/java/org/tasks/preferences/fragments/TasksAccount.kt index fd72c2fb1..4597c542a 100644 --- a/app/src/main/java/org/tasks/preferences/fragments/TasksAccount.kt +++ b/app/src/main/java/org/tasks/preferences/fragments/TasksAccount.kt @@ -1,14 +1,20 @@ package org.tasks.preferences.fragments import android.app.Activity.RESULT_OK -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent +import android.content.* import android.net.Uri import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import androidx.core.content.ContextCompat +import androidx.core.content.ContextCompat.getSystemService import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels import androidx.lifecycle.LiveData import androidx.lifecycle.lifecycleScope +import androidx.preference.PreferenceCategory +import com.google.android.material.textfield.TextInputLayout +import com.todoroo.andlib.utility.DateUtilities import com.todoroo.astrid.service.TaskDeleter import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch @@ -23,8 +29,10 @@ import org.tasks.data.CaldavAccount import org.tasks.data.CaldavDao import org.tasks.injection.InjectingPreferenceFragment import org.tasks.jobs.WorkManager +import org.tasks.locale.Locale import org.tasks.preferences.IconPreference import org.tasks.ui.Toaster +import java.time.format.FormatStyle import javax.inject.Inject @AndroidEntryPoint @@ -37,6 +45,9 @@ class TasksAccount : InjectingPreferenceFragment() { @Inject lateinit var caldavDao: CaldavDao @Inject lateinit var workManager: WorkManager @Inject lateinit var toaster: Toaster + @Inject lateinit var locale: Locale + + private val viewModel: TasksAccountViewModel by viewModels() private lateinit var caldavAccountLiveData: LiveData @@ -63,6 +74,9 @@ class TasksAccount : InjectingPreferenceFragment() { caldavAccountLiveData = caldavDao.watchAccount( requireArguments().getParcelable(EXTRA_ACCOUNT)!!.id ) + if (savedInstanceState == null) { + viewModel.refreshPasswords(caldavAccount) + } findPreference(R.string.logout).setOnPreferenceClickListener { dialogBuilder @@ -99,8 +113,9 @@ class TasksAccount : InjectingPreferenceFragment() { findPreference(R.string.refresh_purchases).isVisible = false } - caldavAccountLiveData.observe(this) { account -> - account?.let { refreshUi(it) } + findPreference(R.string.generate_new_password).setOnPreferenceChangeListener { _, description -> + viewModel.requestNewPassword(caldavAccount, description as String) + false } } @@ -120,11 +135,50 @@ class TasksAccount : InjectingPreferenceFragment() { override fun onResume() { super.onResume() - + caldavAccountLiveData.observe(this) { account -> + account?.let { refreshUi(it) } + } + viewModel.appPasswords.observe(this) { passwords -> + passwords?.let { refreshPasswords(passwords) } + } + viewModel.newPassword.observe(this) { + it?.let { + val view = LayoutInflater.from(requireActivity()).inflate(R.layout.dialog_app_password, null) + setupTextField(view, R.id.url_layout, R.string.url, getString(R.string.tasks_caldav_url)) + setupTextField(view, R.id.user_layout, R.string.user, it.username) + setupTextField(view, R.id.password_layout, R.string.password, it.password) + dialogBuilder.newDialog() + .setView(view) + .setPositiveButton(R.string.ok) { _, _ -> + viewModel.clearNewPassword() + viewModel.refreshPasswords(caldavAccount) + } + .setCancelable(false) + .setNeutralButton(R.string.help) { _, _ -> + startActivity(Intent( + Intent.ACTION_VIEW, + Uri.parse(getString(R.string.url_app_passwords))) + ) + } + .show() + } + } localBroadcastManager.registerPurchaseReceiver(purchaseReceiver) localBroadcastManager.registerRefreshListReceiver(purchaseReceiver) } + private fun setupTextField(v: View, layout: Int, labelRes: Int, value: String?) { + with(v.findViewById(layout)) { + editText?.setText(value) + setEndIconOnClickListener { + val label = getString(labelRes) + getSystemService(requireContext(), ClipboardManager::class.java) + ?.setPrimaryClip(ClipData.newPlainText(label, value)) + toaster.toast(R.string.copied_to_clipboard, label) + } + } + } + override fun onPause() { super.onPause() @@ -228,6 +282,48 @@ class TasksAccount : InjectingPreferenceFragment() { findPreference(R.string.button_unsubscribe).isEnabled = inventory.subscription != null } + private fun refreshPasswords(passwords: List) { + findPreference(R.string.app_passwords_more_info).isVisible = passwords.isEmpty() + val category = findPreference(R.string.app_passwords) as PreferenceCategory + category.removeAll() + passwords.forEach { + val description = it.description ?: getString(R.string.app_password) + category.addPreference(IconPreference(context).apply { + layoutResource = R.layout.preference_icon + iconVisible = true + drawable = context?.getDrawable(R.drawable.ic_outline_delete_24px) + tint = ContextCompat.getColor(requireContext(), R.color.icon_tint_with_alpha) + title = description + iconClickListener = View.OnClickListener { _ -> + dialogBuilder.newDialog() + .setTitle(R.string.delete_tag_confirmation, description) + .setMessage(R.string.app_password_delete_confirmation) + .setPositiveButton(R.string.ok) { _, _ -> + viewModel.deletePassword(caldavAccount, it.id) + } + .setNegativeButton(R.string.cancel, null) + .show() + + } + summary = """ + ${getString(R.string.app_password_created_at, formatString(it.createdAt))} + ${getString(R.string.app_password_last_access, formatString(it.lastAccess) ?: getString(R.string.last_backup_never))} + """.trimIndent() + }) + } + } + + private fun formatString(date: Long?): String? = date?.let { + DateUtilities.getRelativeDay( + requireContext(), + date, + locale.locale, + FormatStyle.FULL, + false, + true + ) + } + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { if (requestCode == REQUEST_PURCHASE) { if (resultCode == RESULT_OK) { diff --git a/app/src/main/java/org/tasks/preferences/fragments/TasksAccountViewModel.kt b/app/src/main/java/org/tasks/preferences/fragments/TasksAccountViewModel.kt new file mode 100644 index 000000000..47e90b90d --- /dev/null +++ b/app/src/main/java/org/tasks/preferences/fragments/TasksAccountViewModel.kt @@ -0,0 +1,103 @@ +package org.tasks.preferences.fragments + +import androidx.hilt.lifecycle.ViewModelInject +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch +import org.json.JSONObject +import org.tasks.caldav.CaldavClientProvider +import org.tasks.data.CaldavAccount +import timber.log.Timber + +class TasksAccountViewModel @ViewModelInject constructor( + private val provider: CaldavClientProvider +) : ViewModel() { + val newPassword = MutableLiveData() + val appPasswords = MutableLiveData?>() + + private var inFlight = false + + fun refreshPasswords(account: CaldavAccount) = viewModelScope.launch { + try { + provider + .forTasksAccount(account) + .getAppPasswords() + ?.let { + val passwords = it.getJSONArray(PASSWORDS) + val result = ArrayList() + for (i in 0 until passwords.length()) { + with(passwords.getJSONObject(i)) { + result.add(AppPassword( + description = getStringOrNull(DESCRIPTION), + id = getInt(SESSION_ID), + createdAt = getLongOrNull(CREATED_AT), + lastAccess = getLongOrNull(LAST_ACCESS) + )) + } + } + appPasswords.value = result + } + } catch (e: Exception) { + Timber.e(e) + } + } + + fun requestNewPassword(account: CaldavAccount, description: String) = viewModelScope.launch { + if (inFlight) { + return@launch + } + inFlight = true + try { + provider + .forTasksAccount(account) + .generateNewPassword(description.takeIf { it.isNotBlank() }) + ?.let { + newPassword.value = + AppPassword( + username = it.getString(USERNAME), + password = it.getString(PASSWORD) + ) + } + } catch (e: Exception) { + Timber.e(e) + } + inFlight = false + } + + fun deletePassword(account: CaldavAccount, id: Int) = viewModelScope.launch { + try { + provider.forTasksAccount(account).deletePassword(id) + refreshPasswords(account) + } catch (e: Exception) { + Timber.e(e) + } + } + + fun clearNewPassword() { + newPassword.value = null + } + + data class AppPassword( + val username: String? = null, + val password: String? = null, + val description: String? = null, + val id: Int = -1, + val createdAt: Long? = null, + val lastAccess: Long? = null + ) + + companion object { + private const val PASSWORDS = "passwords" + private const val DESCRIPTION = "description" + private const val SESSION_ID = "session_id" + private const val CREATED_AT = "created_at" + private const val LAST_ACCESS = "last_access" + private const val PASSWORD = "password" + private const val USERNAME = "username" + + fun JSONObject.getStringOrNull(key: String) = if (isNull(key)) null else getString(key) + + fun JSONObject.getLongOrNull(key: String) = if (isNull(key)) null else getLong(key) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/tasks/ui/Toaster.java b/app/src/main/java/org/tasks/ui/Toaster.java index 84afa8577..8a30cb952 100644 --- a/app/src/main/java/org/tasks/ui/Toaster.java +++ b/app/src/main/java/org/tasks/ui/Toaster.java @@ -1,6 +1,7 @@ package org.tasks.ui; import static android.widget.Toast.LENGTH_LONG; +import static android.widget.Toast.LENGTH_SHORT; import static org.tasks.Strings.isNullOrEmpty; import android.content.Context; @@ -30,9 +31,11 @@ public class Toaster { } public void longToast(String text) { - if (!isNullOrEmpty(text)) { - Toast.makeText(context, text, LENGTH_LONG).show(); - } + toast(text, LENGTH_LONG); + } + + public void toast(@StringRes int resId, Object... args) { + toast(context.getString(resId, args), LENGTH_SHORT); } @SuppressWarnings("DeprecatedIsStillUsed") @@ -40,4 +43,10 @@ public class Toaster { public void longToastUnformatted(@StringRes int resId, int number) { Toast.makeText(context, context.getString(resId, number), LENGTH_LONG).show(); } + + private void toast(String text, int duration) { + if (!isNullOrEmpty(text)) { + Toast.makeText(context, text, duration).show(); + } + } } diff --git a/app/src/main/res/layout/dialog_app_password.xml b/app/src/main/res/layout/dialog_app_password.xml new file mode 100644 index 000000000..14bb0fb96 --- /dev/null +++ b/app/src/main/res/layout/dialog_app_password.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/keys.xml b/app/src/main/res/values/keys.xml index 917e706ed..b136fa771 100644 --- a/app/src/main/res/values/keys.xml +++ b/app/src/main/res/values/keys.xml @@ -28,14 +28,14 @@ https://tasks.org/donate https://reddit.com/r/tasks https://github.com/sponsors/abaker + https://tasks.org/passwords Tasks.org account Tasks.org account not included with \'Name your price\' subscriptions - Sync your data with Tasks.org + Sync your tasks and calendars with Tasks.org Sync with third-party apps and services - Tasks.org is based on open internet standards - Tasks.org does not show ads or sell personal information + Compatible with Outlook, Thunderbird, Apple Reminders, and more Many new features coming soon! Multiple Google Task accounts Unlock additional features @@ -44,6 +44,7 @@ Tasker plugins 7-day free trial for new subscribers Upgrade, downgrade, or cancel your subscription at any time + Your subscription supports open source software! Previous donors to receive one month credit for every $3 in past donations diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b62f67de7..5456d1909 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -685,4 +685,14 @@ File %1$s contained %2$s.\n\n Above average Save %d%% Sign in to Tasks.org + App password + App passwords + Synchronize your tasks and calendars with third-party desktop and mobile apps. Tap here for more info + Generate new password + Give your password a name (optional) + Created: %s + Last used: %s + Any app using this password will be signed out + Use these credentials to configure a third-party app. They grant complete access to your Tasks.org account, don\'t write them down or share them with anyone! + %s copied to clipboard diff --git a/app/src/main/res/xml/preferences_tasks.xml b/app/src/main/res/xml/preferences_tasks.xml index bdb4eb748..9ff357541 100644 --- a/app/src/main/res/xml/preferences_tasks.xml +++ b/app/src/main/res/xml/preferences_tasks.xml @@ -54,6 +54,26 @@ android:title="@string/background_sync_unmetered_only" /> + + + + + + + + +