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