diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index b26af0d56..14e4aa28e 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -177,6 +177,12 @@
+
+
+
+
+
+
+
+
= when(accountType) {
TYPE_LOCAL -> LocalListSettingsActivity::class.java
TYPE_ETESYNC, TYPE_OPENTASKS -> OpenTasksListSettingsActivity::class.java
@@ -202,6 +205,7 @@ class CaldavAccount : Parcelable {
isEteSyncAccount -> R.string.etesync_v1
uuid.isDavx5() -> R.string.davx5
uuid.isDecSync() -> R.string.decsync
+ isMicrosoft -> R.string.microsoft
else -> 0
}
@@ -212,6 +216,7 @@ class CaldavAccount : Parcelable {
isEtebaseAccount || isEteSyncAccount || uuid.isEteSync() -> R.drawable.ic_etesync
uuid.isDavx5() -> R.drawable.ic_davx5_icon_green_bg
uuid.isDecSync() -> R.drawable.ic_decsync
+ isMicrosoft -> R.drawable.ic_microsoft_tasks
else -> 0
}
@@ -225,6 +230,7 @@ class CaldavAccount : Parcelable {
const val TYPE_OPENTASKS = 3
const val TYPE_TASKS = 4
const val TYPE_ETEBASE = 5
+ const val TYPE_MICROSOFT = 6
const val SERVER_UNKNOWN = -1
const val SERVER_TASKS = 0
diff --git a/app/src/main/java/org/tasks/preferences/fragments/MainSettingsFragment.kt b/app/src/main/java/org/tasks/preferences/fragments/MainSettingsFragment.kt
index ad60dd605..dacc6b604 100644
--- a/app/src/main/java/org/tasks/preferences/fragments/MainSettingsFragment.kt
+++ b/app/src/main/java/org/tasks/preferences/fragments/MainSettingsFragment.kt
@@ -4,6 +4,7 @@ import android.content.Intent
import android.os.Bundle
import androidx.core.content.ContextCompat
import androidx.fragment.app.activityViewModels
+import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.preference.Preference
import androidx.preference.PreferenceScreen
@@ -31,11 +32,13 @@ import org.tasks.preferences.MainPreferences
import org.tasks.preferences.Preferences
import org.tasks.preferences.PreferencesViewModel
import org.tasks.preferences.fragments.GoogleTasksAccount.Companion.newGoogleTasksAccountPreference
+import org.tasks.preferences.fragments.MicrosoftAccount.Companion.newMicrosoftAccountPreference
import org.tasks.preferences.fragments.TasksAccount.Companion.newTasksAccountPreference
import org.tasks.sync.AddAccountDialog
import org.tasks.sync.AddAccountDialog.Companion.EXTRA_SELECTED
import org.tasks.sync.AddAccountDialog.Companion.newAccountDialog
import org.tasks.sync.AddAccountDialog.Platform
+import org.tasks.sync.microsoft.MicrosoftSignInViewModel
import org.tasks.widget.AppWidgetManager
import javax.inject.Inject
@@ -49,6 +52,7 @@ class MainSettingsFragment : InjectingPreferenceFragment() {
@Inject lateinit var billingClient: BillingClient
private val viewModel: PreferencesViewModel by activityViewModels()
+ private val microsoftVM: MicrosoftSignInViewModel by viewModels()
override fun getPreferenceXml() = R.xml.preferences
@@ -109,9 +113,8 @@ class MainSettingsFragment : InjectingPreferenceFragment() {
Intent(requireContext(), GtasksLoginActivity::class.java),
REQUEST_GOOGLE_TASKS
)
- Platform.MICROSOFT -> {
-
- }
+ Platform.MICROSOFT ->
+ microsoftVM.signIn(requireActivity())
Platform.DAVX5 ->
context?.openUri(R.string.url_davx5)
Platform.CALDAV ->
@@ -203,9 +206,15 @@ class MainSettingsFragment : InjectingPreferenceFragment() {
pref.setOnPreferenceClickListener {
if (account.isTasksOrg) {
(activity as MainPreferences).startPreference(
- this,
- newTasksAccountPreference(account),
- getString(R.string.tasks_org)
+ this,
+ newTasksAccountPreference(account),
+ getString(R.string.tasks_org)
+ )
+ } else if (account.isMicrosoft) {
+ (activity as MainPreferences).startPreference(
+ this,
+ newMicrosoftAccountPreference(account),
+ getString(R.string.microsoft)
)
} else {
val intent = Intent(context, account.accountSettingsClass).apply {
diff --git a/app/src/main/java/org/tasks/preferences/fragments/MicrosoftAccount.kt b/app/src/main/java/org/tasks/preferences/fragments/MicrosoftAccount.kt
new file mode 100644
index 000000000..ce0d37f47
--- /dev/null
+++ b/app/src/main/java/org/tasks/preferences/fragments/MicrosoftAccount.kt
@@ -0,0 +1,132 @@
+package org.tasks.preferences.fragments
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.fragment.app.viewModels
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.lifecycleScope
+import com.todoroo.astrid.service.TaskDeleter
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.launch
+import org.tasks.LocalBroadcastManager
+import org.tasks.R
+import org.tasks.billing.Inventory
+import org.tasks.data.CaldavAccount
+import org.tasks.data.CaldavAccount.Companion.isPaymentRequired
+import org.tasks.data.CaldavDao
+import org.tasks.preferences.IconPreference
+import org.tasks.sync.microsoft.MicrosoftSignInViewModel
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class MicrosoftAccount : BaseAccountPreference() {
+
+ @Inject lateinit var taskDeleter: TaskDeleter
+ @Inject lateinit var inventory: Inventory
+ @Inject lateinit var localBroadcastManager: LocalBroadcastManager
+ @Inject lateinit var caldavDao: CaldavDao
+
+ private val microsoftVM: MicrosoftSignInViewModel by viewModels()
+ private lateinit var microsoftAccountLiveData: LiveData
+
+ val microsoftAccount: CaldavAccount
+ get() = microsoftAccountLiveData.value ?: requireArguments().getParcelable(EXTRA_ACCOUNT)!!
+
+ private val purchaseReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ lifecycleScope.launch {
+ microsoftAccount.let {
+ if (inventory.subscription.value != null && it.error.isPaymentRequired()) {
+ it.error = null
+ caldavDao.update(it)
+ }
+ refreshUi(it)
+ }
+ }
+ }
+ }
+
+ override fun getPreferenceXml() = R.xml.preferences_google_tasks
+
+ override suspend fun setupPreferences(savedInstanceState: Bundle?) {
+ super.setupPreferences(savedInstanceState)
+
+ microsoftAccountLiveData = caldavDao.watchAccount(
+ arguments?.getParcelable(EXTRA_ACCOUNT)?.id ?: 0
+ )
+ microsoftAccountLiveData.observe(this) { refreshUi(it) }
+
+ findPreference(R.string.reinitialize_account)
+ .setOnPreferenceClickListener { requestLogin() }
+ }
+
+ override suspend fun removeAccount() {
+ taskDeleter.delete(microsoftAccount)
+ }
+
+ override fun onResume() {
+ super.onResume()
+ localBroadcastManager.registerPurchaseReceiver(purchaseReceiver)
+ localBroadcastManager.registerRefreshListReceiver(purchaseReceiver)
+ }
+
+ override fun onPause() {
+ super.onPause()
+
+ localBroadcastManager.unregisterReceiver(purchaseReceiver)
+ }
+
+ private fun refreshUi(account: CaldavAccount?) {
+ if (account == null) {
+ return
+ }
+ (findPreference(R.string.sign_in_with_google) as IconPreference).apply {
+ if (account.error.isNullOrBlank()) {
+ isVisible = false
+ return@apply
+ }
+ isVisible = true
+ when {
+ account.error.isPaymentRequired() -> {
+ setOnPreferenceClickListener { showPurchaseDialog() }
+ setTitle(R.string.name_your_price)
+ setSummary(R.string.requires_pro_subscription)
+ }
+ account.error.isUnauthorized() -> {
+ setTitle(R.string.sign_in)
+ setSummary(R.string.authentication_required)
+ setOnPreferenceClickListener { requestLogin() }
+ }
+ else -> {
+ this.title = null
+ this.summary = account.error
+ this.onPreferenceClickListener = null
+ }
+ }
+ iconVisible = true
+ }
+ }
+
+ private fun requestLogin(): Boolean {
+ microsoftAccount.username?.let {
+ microsoftVM.signIn(requireActivity()) // should force a specific account
+ }
+ return false
+ }
+
+ companion object {
+ private const val EXTRA_ACCOUNT = "extra_account"
+
+ fun String?.isUnauthorized(): Boolean =
+ this?.startsWith("401 Unauthorized", ignoreCase = true) == true
+
+ fun newMicrosoftAccountPreference(account: CaldavAccount) =
+ MicrosoftAccount().apply {
+ arguments = Bundle().apply {
+ putParcelable(EXTRA_ACCOUNT, account)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/tasks/sync/microsoft/AppAuthExtensions.kt b/app/src/main/java/org/tasks/sync/microsoft/AppAuthExtensions.kt
new file mode 100644
index 000000000..ba274fe5a
--- /dev/null
+++ b/app/src/main/java/org/tasks/sync/microsoft/AppAuthExtensions.kt
@@ -0,0 +1,36 @@
+package org.tasks.sync.microsoft
+
+import android.content.Context
+import net.openid.appauth.*
+import org.tasks.auth.IdentityProvider
+import kotlin.coroutines.suspendCoroutine
+
+suspend fun IdentityProvider.retrieveConfig(): AuthorizationServiceConfiguration {
+ return suspendCoroutine { cont ->
+ AuthorizationServiceConfiguration.fetchFromUrl(discoveryEndpoint) { serviceConfiguration, ex ->
+ cont.resumeWith(
+ when {
+ ex != null -> Result.failure(ex)
+ serviceConfiguration != null -> Result.success(serviceConfiguration)
+ else -> Result.failure(IllegalStateException())
+ }
+ )
+ }
+ }
+}
+
+suspend fun Context.requestTokenExchange(response: AuthorizationResponse) =
+ requestToken(response.createTokenExchangeRequest())
+
+private suspend fun Context.requestToken(tokenRequest: TokenRequest): Pair {
+ val authService = AuthorizationService(this)
+ return try {
+ suspendCoroutine { cont ->
+ authService.performTokenRequest(tokenRequest) { response, ex ->
+ cont.resumeWith(Result.success(Pair(response, ex)))
+ }
+ }
+ } finally {
+ authService.dispose()
+ }
+}
diff --git a/app/src/main/java/org/tasks/sync/microsoft/MicrosoftSignInViewModel.kt b/app/src/main/java/org/tasks/sync/microsoft/MicrosoftSignInViewModel.kt
new file mode 100644
index 000000000..8994ead4a
--- /dev/null
+++ b/app/src/main/java/org/tasks/sync/microsoft/MicrosoftSignInViewModel.kt
@@ -0,0 +1,74 @@
+package org.tasks.sync.microsoft
+
+import android.app.Activity
+import android.app.PendingIntent
+import android.content.Intent
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.launch
+import net.openid.appauth.AppAuthConfiguration
+import net.openid.appauth.AuthorizationRequest
+import net.openid.appauth.AuthorizationService
+import net.openid.appauth.ResponseTypeValues
+import net.openid.appauth.browser.AnyBrowserMatcher
+import net.openid.appauth.connectivity.DefaultConnectionBuilder
+import org.tasks.BuildConfig
+import org.tasks.auth.DebugConnectionBuilder
+import org.tasks.auth.IdentityProvider
+import org.tasks.auth.MicrosoftAuthenticationActivity
+import org.tasks.auth.MicrosoftAuthenticationActivity.Companion.EXTRA_SERVICE_DISCOVERY
+import javax.inject.Inject
+
+@HiltViewModel
+class MicrosoftSignInViewModel @Inject constructor(
+ private val debugConnectionBuilder: DebugConnectionBuilder,
+) : ViewModel() {
+ fun signIn(activity: Activity) {
+ viewModelScope.launch {
+ val idp = IdentityProvider.MICROSOFT
+ val serviceConfig = idp.retrieveConfig()
+ val authRequest = AuthorizationRequest
+ .Builder(
+ serviceConfig,
+ idp.clientId,
+ ResponseTypeValues.CODE,
+ idp.redirectUri
+ )
+ .setScope(idp.scope)
+ .setPrompt(AuthorizationRequest.Prompt.SELECT_ACCOUNT)
+ .build()
+ val intent = Intent(activity, MicrosoftAuthenticationActivity::class.java)
+ intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
+ intent.putExtra(
+ EXTRA_SERVICE_DISCOVERY,
+ serviceConfig.discoveryDoc!!.docJson.toString()
+ )
+
+ val authorizationService = AuthorizationService(
+ activity,
+ AppAuthConfiguration.Builder()
+ .setBrowserMatcher(AnyBrowserMatcher.INSTANCE)
+ .setConnectionBuilder(
+ if (BuildConfig.DEBUG) {
+ debugConnectionBuilder
+ } else {
+ DefaultConnectionBuilder.INSTANCE
+ }
+ )
+ .build()
+ )
+ authorizationService.performAuthorizationRequest(
+ authRequest,
+ PendingIntent.getActivity(
+ activity,
+ authRequest.hashCode(),
+ intent,
+ PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_CANCEL_CURRENT
+ ),
+ authorizationService.createCustomTabsIntentBuilder()
+ .build()
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index e2ef1d12e..a5ae3fa40 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -735,4 +735,7 @@ File %1$s contained %2$s.\n\n
You can customize this screen by rearranging or removing fields
Enable reminders
Reminders are disabled in Android Settings
+ Sign in
+ Sign in with a personal Microsoft account
+ Email or phone number