Generate app passwords for Tasks.org

pull/1276/head
Alex Baker 4 years ago
parent 9dfdeaa582
commit 16ae98f9eb

@ -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)

@ -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,

@ -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)

@ -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"
}
}

@ -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();
}

@ -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

@ -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<CaldavAccount>
@ -63,6 +74,9 @@ class TasksAccount : InjectingPreferenceFragment() {
caldavAccountLiveData = caldavDao.watchAccount(
requireArguments().getParcelable<CaldavAccount>(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<TextInputLayout>(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<TasksAccountViewModel.AppPassword>) {
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) {

@ -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<AppPassword?>()
val appPasswords = MutableLiveData<List<AppPassword>?>()
private var inFlight = false
fun refreshPasswords(account: CaldavAccount) = viewModelScope.launch {
try {
provider
.forTasksAccount(account)
.getAppPasswords()
?.let {
val passwords = it.getJSONArray(PASSWORDS)
val result = ArrayList<AppPassword>()
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)
}
}

@ -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();
}
}
}

@ -0,0 +1,72 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto">
<LinearLayout
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="@dimen/keyline_first">
<TextView
style="@style/TextAppearance"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/app_password_save"
android:textAlignment="center"
android:paddingBottom="@dimen/keyline_first" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/url_layout"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingBottom="@dimen/keyline_first"
app:endIconMode="custom"
app:endIconDrawable="@drawable/ic_content_copy_24px">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/url"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:editable="false"
android:hint="@string/url" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/user_layout"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingBottom="@dimen/keyline_first"
app:endIconMode="custom"
app:endIconDrawable="@drawable/ic_content_copy_24px">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/username"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:editable="false"
android:hint="@string/user" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/password_layout"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:endIconMode="custom"
app:endIconDrawable="@drawable/ic_content_copy_24px">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:editable="false"
android:hint="@string/password" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
</ScrollView>

@ -28,14 +28,14 @@
<string name="url_donate">https://tasks.org/donate</string>
<string name="url_reddit">https://reddit.com/r/tasks</string>
<string name="url_sponsor">https://github.com/sponsors/abaker</string>
<string name="url_app_passwords">https://tasks.org/passwords</string>
<!-- Eventually these should be moved to strings.xml for translation -->
<string name="upgrade_tasks_account">Tasks.org account</string>
<string name="upgrade_tasks_no_account">Tasks.org account not included with \'Name your price\' subscriptions</string>
<string name="upgrade_sync_with_tasks">Sync your data with Tasks.org</string>
<string name="upgrade_sync_tasks">Sync your tasks and calendars with Tasks.org</string>
<string name="upgrade_sync_self_hosted">Sync with third-party apps and services</string>
<string name="upgrade_open_internet_standards">Tasks.org is based on open internet standards</string>
<string name="upgrade_privacy">Tasks.org does not show ads or sell personal information</string>
<string name="upgrade_third_party_apps">Compatible with Outlook, Thunderbird, Apple Reminders, and more</string>
<string name="upgrade_coming_soon">Many new features coming soon!</string>
<string name="upgrade_google_tasks">Multiple Google Task accounts</string>
<string name="upgrade_additional_features">Unlock additional features</string>
@ -44,6 +44,7 @@
<string name="upgrade_tasker">Tasker plugins</string>
<string name="upgrade_free_trial">7-day free trial for new subscribers</string>
<string name="upgrade_downgrade">Upgrade, downgrade, or cancel your subscription at any time</string>
<string name="upgrade_support_development">Your subscription supports open source software!</string>
<string name="upgrade_previous_donors">Previous donors</string>
<string name="upgrade_previous_donors_contact">to receive one month credit for every $3 in past donations</string>

@ -685,4 +685,14 @@ File %1$s contained %2$s.\n\n
<string name="above_average">Above average</string>
<string name="save_percent">Save %d%%</string>
<string name="sign_in_to_tasks">Sign in to Tasks.org</string>
<string name="app_password">App password</string>
<string name="app_passwords">App passwords</string>
<string name="app_passwords_more_info">Synchronize your tasks and calendars with third-party desktop and mobile apps. Tap here for more info</string>
<string name="generate_new_password">Generate new password</string>
<string name="app_password_enter_description">Give your password a name (optional)</string>
<string name="app_password_created_at">Created: %s</string>
<string name="app_password_last_access">Last used: %s</string>
<string name="app_password_delete_confirmation">Any app using this password will be signed out</string>
<string name="app_password_save">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!</string>
<string name="copied_to_clipboard">%s copied to clipboard</string>
</resources>

@ -54,6 +54,26 @@
android:title="@string/background_sync_unmetered_only" />
</PreferenceCategory>
<PreferenceCategory
android:key="@string/app_passwords"
android:title="@string/app_passwords">
</PreferenceCategory>
<Preference
android:icon="@drawable/ic_open_in_new_24px"
android:key="@string/app_passwords_more_info"
android:summary="@string/app_passwords_more_info">
<intent
android:action="android.intent.action.VIEW"
android:data="@string/url_app_passwords" />
</Preference>
<EditTextPreference
android:icon="@drawable/ic_outline_add_24px"
android:key="@string/generate_new_password"
android:title="@string/generate_new_password"
android:dialogTitle="@string/app_password_enter_description" />
<Preference
android:key="@string/logout"
android:title="@string/logout"

Loading…
Cancel
Save