diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 3c79a2fd3..9ed26cc07 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -229,6 +229,9 @@ dependencies {
implementation(libs.etebase)
implementation(libs.colorpicker)
implementation(libs.appauth)
+ implementation(libs.microsoft.authentication) {
+ exclude("com.microsoft.device.display", "display-mask")
+ }
implementation(libs.osmdroid)
implementation(libs.androidx.recyclerview)
diff --git a/app/src/debug/res/raw/microsoft_config.json b/app/src/debug/res/raw/microsoft_config.json
new file mode 100644
index 000000000..3eea43cf9
--- /dev/null
+++ b/app/src/debug/res/raw/microsoft_config.json
@@ -0,0 +1,20 @@
+{
+ "client_id" : "9d4babd5-e7ba-4286-ba4b-17274495a901",
+ "authorization_user_agent" : "DEFAULT",
+ "redirect_uri" : "msauth://org.tasks/8wnYBRqh5nnQgFzbIXfxXSs41xE%3D",
+ "account_mode" : "MULTIPLE",
+ "authorities" : [
+ {
+ "type": "AAD",
+ "audience": {
+ "type": "AzureADandPersonalMicrosoftAccount",
+ "tenant_id": "common"
+ }
+ }
+ ],
+ "logging": {
+ "level": "verbose",
+ "logcat_enabled": true,
+ "pii_enabled": true
+ }
+}
\ No newline at end of file
diff --git a/app/src/debug/res/values/keys.xml b/app/src/debug/res/values/keys.xml
index 2cd776335..1e5efd11f 100644
--- a/app/src/debug/res/values/keys.xml
+++ b/app/src/debug/res/values/keys.xml
@@ -15,4 +15,5 @@
Restart app
Clear hints
com.googleusercontent.apps.1006257750459-vf4mvft1b3rfda8b4c4bl4k4418abqlf
+ /8wnYBRqh5nnQgFzbIXfxXSs41xE=
\ No newline at end of file
diff --git a/app/src/googleplayRelease/res/raw/microsoft_config.json b/app/src/googleplayRelease/res/raw/microsoft_config.json
new file mode 100644
index 000000000..500845d11
--- /dev/null
+++ b/app/src/googleplayRelease/res/raw/microsoft_config.json
@@ -0,0 +1,20 @@
+{
+ "client_id" : "9d4babd5-e7ba-4286-ba4b-17274495a901",
+ "authorization_user_agent" : "DEFAULT",
+ "redirect_uri" : "msauth://org.tasks/sEe08kX5nGJi4miFX3VkNXICC%2FY%3D",
+ "account_mode" : "MULTIPLE",
+ "authorities" : [
+ {
+ "type": "AAD",
+ "audience": {
+ "type": "AzureADandPersonalMicrosoftAccount",
+ "tenant_id": "common"
+ }
+ }
+ ],
+ "logging": {
+ "level": "info",
+ "logcat_enabled": true,
+ "pii_enabled": false
+ }
+}
\ No newline at end of file
diff --git a/app/src/googleplayRelease/res/values/keys.xml b/app/src/googleplayRelease/res/values/keys.xml
index ee090a016..0868853d4 100644
--- a/app/src/googleplayRelease/res/values/keys.xml
+++ b/app/src/googleplayRelease/res/values/keys.xml
@@ -1,4 +1,5 @@
com.googleusercontent.apps.363426363175-jdrijf7hql9030klgjcjlpi6k5spviif
+ /sEe08kX5nGJi4miFX3VkNXICC/Y=
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index c96c67637..96871d96c 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -178,14 +178,6 @@
-
-
-
-
-
-
-
-
= 3.0. DeX Dual Mode support -->
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/org/tasks/auth/IdentityProvider.kt b/app/src/main/java/org/tasks/auth/IdentityProvider.kt
deleted file mode 100644
index 2c2e05e6b..000000000
--- a/app/src/main/java/org/tasks/auth/IdentityProvider.kt
+++ /dev/null
@@ -1,22 +0,0 @@
-package org.tasks.auth
-
-import android.net.Uri
-import androidx.core.net.toUri
-
-data class IdentityProvider(
- val name: String,
- val discoveryEndpoint: Uri,
- val clientId: String,
- val redirectUri: Uri,
- val scope: String
-) {
- companion object {
- val MICROSOFT = IdentityProvider(
- "Microsoft",
- "https://login.microsoftonline.com/consumers/v2.0/.well-known/openid-configuration".toUri(),
- "9d4babd5-e7ba-4286-ba4b-17274495a901",
- "msauth://org.tasks/8wnYBRqh5nnQgFzbIXfxXSs41xE%3D".toUri(),
- "user.read Tasks.ReadWrite openid offline_access email"
- )
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/tasks/auth/MicrosoftAuthenticationActivity.kt b/app/src/main/java/org/tasks/auth/MicrosoftAuthenticationActivity.kt
deleted file mode 100644
index 5e93a9e2e..000000000
--- a/app/src/main/java/org/tasks/auth/MicrosoftAuthenticationActivity.kt
+++ /dev/null
@@ -1,152 +0,0 @@
-package org.tasks.auth
-
-import android.os.Bundle
-import android.widget.Toast
-import android.widget.Toast.LENGTH_LONG
-import androidx.activity.ComponentActivity
-import androidx.activity.compose.setContent
-import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.CircularProgressIndicator
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color.Companion.White
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.window.Dialog
-import androidx.compose.ui.window.DialogProperties
-import androidx.lifecycle.lifecycleScope
-import dagger.hilt.android.AndroidEntryPoint
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import net.openid.appauth.AuthState
-import net.openid.appauth.AuthorizationException
-import net.openid.appauth.AuthorizationResponse
-import net.openid.appauth.AuthorizationServiceDiscovery
-import okhttp3.Request
-import org.json.JSONObject
-import org.tasks.R
-import org.tasks.analytics.Constants
-import org.tasks.analytics.Firebase
-import org.tasks.data.UUIDHelper
-import org.tasks.data.dao.CaldavDao
-import org.tasks.data.entity.CaldavAccount
-import org.tasks.data.entity.CaldavAccount.Companion.TYPE_MICROSOFT
-import org.tasks.http.HttpClientFactory
-import org.tasks.preferences.fragments.TasksAccountViewModel.Companion.getStringOrNull
-import org.tasks.security.KeyStoreEncryption
-import org.tasks.sync.microsoft.requestTokenExchange
-import javax.inject.Inject
-
-@AndroidEntryPoint
-class MicrosoftAuthenticationActivity : ComponentActivity() {
-
- @Inject lateinit var caldavDao: CaldavDao
- @Inject lateinit var encryption: KeyStoreEncryption
- @Inject lateinit var httpClientFactory: HttpClientFactory
- @Inject lateinit var firebase: Firebase
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- val authState = AuthState(
- AuthorizationResponse.fromIntent(intent),
- AuthorizationException.fromIntent(intent)
- )
- authState.authorizationException?.let {
- error(it.message ?: "Authentication failed")
- return
- }
- lifecycleScope.launch {
- val (resp, ex) = requestTokenExchange(authState.lastAuthorizationResponse!!)
- authState.update(resp, ex)
- if (authState.isAuthorized) {
- val email = getEmail(authState.accessToken) ?: run {
- error("Failed to fetch profile")
- return@launch
- }
- caldavDao
- .getAccount(TYPE_MICROSOFT, email)
- ?.let {
- caldavDao.update(
- it.copy(password = encryption.encrypt(authState.jsonSerializeString()))
- )
- }
- ?: caldavDao
- .insert(
- CaldavAccount(
- uuid = UUIDHelper.newUUID(),
- name = email,
- username = email,
- password = encryption.encrypt(authState.jsonSerializeString()),
- accountType = TYPE_MICROSOFT,
- )
- )
- .also {
- firebase.logEvent(
- R.string.event_sync_add_account,
- R.string.param_type to Constants.SYNC_TYPE_MICROSOFT
- )
- }
- finish()
- } else {
- error(ex?.message ?: "Token exchange failed")
- }
- }
- setContent {
- var showDialog by remember { mutableStateOf(true) }
- if (showDialog) {
- Dialog(
- onDismissRequest = { showDialog = false },
- DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false)
- ) {
- Box(
- contentAlignment = Alignment.Center,
- modifier = Modifier
- .size(100.dp)
- .background(White, shape = RoundedCornerShape(8.dp))
- ) {
- CircularProgressIndicator()
- }
- }
- }
- }
- }
-
- private suspend fun getEmail(accessToken: String?): String? = withContext(Dispatchers.IO) {
- if (accessToken == null) {
- return@withContext null
- }
- val discovery = AuthorizationServiceDiscovery(
- JSONObject(
- intent.getStringExtra(EXTRA_SERVICE_DISCOVERY)!!
- )
- )
- val userInfo = httpClientFactory
- .newClient(foreground = false)
- .newCall(
- Request.Builder()
- .url(discovery.userinfoEndpoint!!.toString())
- .addHeader("Authorization", "Bearer $accessToken")
- .build()
- )
- .execute()
- val response = userInfo.body?.string() ?: return@withContext null
- JSONObject(response).getStringOrNull("email")
- }
-
- private fun error(message: String) {
- Toast.makeText(this@MicrosoftAuthenticationActivity, message, LENGTH_LONG).show()
- finish()
- }
-
- companion object {
- const val EXTRA_SERVICE_DISCOVERY = "extra_service_discovery"
- }
-}
-
diff --git a/app/src/main/java/org/tasks/http/HttpClientFactory.kt b/app/src/main/java/org/tasks/http/HttpClientFactory.kt
index 7c60c391a..0f696738c 100644
--- a/app/src/main/java/org/tasks/http/HttpClientFactory.kt
+++ b/app/src/main/java/org/tasks/http/HttpClientFactory.kt
@@ -3,6 +3,8 @@ package org.tasks.http
import android.content.Context
import at.bitfire.cert4android.CustomCertManager
import at.bitfire.dav4jvm.BasicDigestAuthHandler
+import com.microsoft.identity.client.AcquireTokenSilentParameters
+import com.microsoft.identity.client.PublicClientApplication
import dagger.hilt.android.qualifiers.ApplicationContext
import io.ktor.client.HttpClient
import io.ktor.client.engine.android.Android
@@ -19,16 +21,16 @@ import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
-import net.openid.appauth.AuthState
import okhttp3.OkHttpClient
import okhttp3.internal.tls.OkHostnameVerifier
import org.tasks.BuildConfig
+import org.tasks.R
import org.tasks.caldav.TasksCookieJar
import org.tasks.data.entity.CaldavAccount
import org.tasks.extensions.Context.cookiePersistor
import org.tasks.security.KeyStoreEncryption
import org.tasks.sync.microsoft.MicrosoftService
-import org.tasks.sync.microsoft.requestTokenRefresh
+import org.tasks.sync.microsoft.MicrosoftSignInViewModel
import timber.log.Timber
import javax.inject.Inject
import javax.net.ssl.SSLContext
@@ -85,18 +87,28 @@ class HttpClientFactory @Inject constructor(
}
suspend fun getMicrosoftService(account: CaldavAccount): MicrosoftService {
- val authState = encryption.decrypt(account.password)?.let { AuthState.jsonDeserialize(it) }
- ?: throw RuntimeException("Missing credentials")
- if (authState.needsTokenRefresh) {
- val (token, ex) = context.requestTokenRefresh(authState)
- authState.update(token, ex)
- if (authState.isAuthorized) {
- account.password = encryption.encrypt(authState.jsonSerializeString())
- }
- }
- if (!authState.isAuthorized) {
- throw RuntimeException("Needs authentication")
+ val app = PublicClientApplication.createMultipleAccountPublicClientApplication(
+ context,
+ R.raw.microsoft_config
+ )
+
+ val result = try {
+ val msalAccount = app.accounts.firstOrNull { it.username == account.username }
+ ?: throw RuntimeException("No matching account found")
+
+ val parameters = AcquireTokenSilentParameters.Builder()
+ .withScopes(MicrosoftSignInViewModel.scopes)
+ .forAccount(msalAccount)
+ .fromAuthority(msalAccount.authority)
+ .forceRefresh(true)
+ .build()
+
+ app.acquireTokenSilent(parameters)
+ } catch (e: Exception) {
+ Timber.e(e)
+ throw RuntimeException("Authentication failed: ${e.message}")
}
+
val client = HttpClient(Android) {
expectSuccess = true
@@ -109,7 +121,7 @@ class HttpClientFactory @Inject constructor(
}
defaultRequest {
- header("Authorization", "Bearer ${authState.accessToken}")
+ header("Authorization", "Bearer ${result.accessToken}")
}
install(HttpTimeout) {
diff --git a/app/src/main/java/org/tasks/http/HttpErrorHandler.kt b/app/src/main/java/org/tasks/http/HttpErrorHandler.kt
index 3c19b0a1a..01b566e6c 100644
--- a/app/src/main/java/org/tasks/http/HttpErrorHandler.kt
+++ b/app/src/main/java/org/tasks/http/HttpErrorHandler.kt
@@ -1,18 +1,47 @@
package org.tasks.http
import io.ktor.client.HttpClient
+import io.ktor.client.call.body
import io.ktor.client.plugins.HttpClientPlugin
import io.ktor.client.plugins.HttpSend
import io.ktor.client.plugins.plugin
import io.ktor.client.statement.HttpResponse
import io.ktor.http.isSuccess
import io.ktor.util.AttributeKey
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import timber.log.Timber
-open class NetworkException(cause: Throwable? = null) : Exception(cause)
-class UnauthorizedException(cause: Throwable? = null) : NetworkException(cause)
-class NotFoundException(cause: Throwable? = null) : NetworkException(cause)
-class ServiceUnavailableException(cause: Throwable? = null) : NetworkException(cause)
-class HttpException(val code: Int, override val message: String? = null) : NetworkException()
+@Serializable
+data class GraphErrorResponse(
+ val error: GraphError
+)
+
+@Serializable
+data class GraphError(
+ val code: String,
+ val message: String,
+ val innerError: GraphInnerError? = null
+) {
+ fun isTokenError() = code in listOf(
+ "InvalidAuthenticationToken",
+ "AuthenticationError",
+ "UnknownError"
+ )
+}
+
+@Serializable
+data class GraphInnerError(
+ val date: String,
+ @SerialName("request-id") val requestId: String,
+ @SerialName("client-request-id")val clientRequestId: String
+)
+
+open class NetworkException(message: String? = null, cause: Throwable? = null) : Exception(message, cause)
+class UnauthorizedException(message: String? = null, cause: Throwable? = null) : NetworkException(message, cause)
+class NotFoundException(message: String? = null, cause: Throwable? = null) : NetworkException(message, cause)
+class ServiceUnavailableException(message: String? = null, cause: Throwable? = null) : NetworkException(message, cause)
+class HttpException(val code: Int, override val message: String? = null) : NetworkException(message)
class HttpErrorHandler {
class Config {
@@ -32,11 +61,33 @@ class HttpErrorHandler {
val originalCall = execute(request)
if (!originalCall.response.status.isSuccess()) {
- when (originalCall.response.status.value) {
- 401 -> throw UnauthorizedException()
- 404 -> throw NotFoundException()
- in 500..599 -> throw ServiceUnavailableException()
- else -> throw HttpException(originalCall.response.status.value)
+ val errorResponse = try {
+ originalCall.response.body()
+ } catch (e: Exception) {
+ Timber.e(e)
+ null
+ }
+
+ val errorMessage = buildString {
+ append("HTTP ${originalCall.response.status.value}")
+ errorResponse?.error?.let { error ->
+ append(" - ${error.code}")
+ if (error.message.isNotBlank()) {
+ append(": ${error.message}")
+ }
+ error.innerError?.let { inner ->
+ append(" (Request ID: ${inner.requestId})")
+ }
+ }
+ }
+
+ Timber.e(errorMessage)
+
+ when {
+ errorResponse?.error?.isTokenError() == true -> throw UnauthorizedException(errorMessage)
+ originalCall.response.status.value == 404 -> throw NotFoundException(errorMessage)
+ originalCall.response.status.value in 500..599 -> throw ServiceUnavailableException(errorMessage)
+ else -> throw HttpException(originalCall.response.status.value, errorMessage)
}
}
diff --git a/app/src/main/java/org/tasks/sync/microsoft/AppAuthExtensions.kt b/app/src/main/java/org/tasks/sync/microsoft/AppAuthExtensions.kt
deleted file mode 100644
index c3d8a6ebc..000000000
--- a/app/src/main/java/org/tasks/sync/microsoft/AppAuthExtensions.kt
+++ /dev/null
@@ -1,39 +0,0 @@
-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.requestTokenRefresh(state: AuthState) =
- requestToken(state.createTokenRefreshRequest())
-
-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
index 8994ead4a..24ddfc058 100644
--- a/app/src/main/java/org/tasks/sync/microsoft/MicrosoftSignInViewModel.kt
+++ b/app/src/main/java/org/tasks/sync/microsoft/MicrosoftSignInViewModel.kt
@@ -1,74 +1,94 @@
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 com.microsoft.identity.client.AcquireTokenParameters
+import com.microsoft.identity.client.AuthenticationCallback
+import com.microsoft.identity.client.IAuthenticationResult
+import com.microsoft.identity.client.Prompt
+import com.microsoft.identity.client.PublicClientApplication
+import com.microsoft.identity.client.exception.MsalException
import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Dispatchers
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 org.tasks.R
+import org.tasks.analytics.Constants
+import org.tasks.analytics.Firebase
+import org.tasks.data.UUIDHelper
+import org.tasks.data.dao.CaldavDao
+import org.tasks.data.entity.CaldavAccount
+import org.tasks.data.entity.CaldavAccount.Companion.TYPE_MICROSOFT
+import org.tasks.extensions.Context.toast
+import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
class MicrosoftSignInViewModel @Inject constructor(
- private val debugConnectionBuilder: DebugConnectionBuilder,
+ private val caldavDao: CaldavDao,
+ private val firebase: Firebase,
) : 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()
+ viewModelScope.launch(Dispatchers.IO) {
+ val app = PublicClientApplication.createMultipleAccountPublicClientApplication(
+ activity,
+ R.raw.microsoft_config
)
- val authorizationService = AuthorizationService(
- activity,
- AppAuthConfiguration.Builder()
- .setBrowserMatcher(AnyBrowserMatcher.INSTANCE)
- .setConnectionBuilder(
- if (BuildConfig.DEBUG) {
- debugConnectionBuilder
- } else {
- DefaultConnectionBuilder.INSTANCE
+ val parameters = AcquireTokenParameters.Builder()
+ .startAuthorizationFromActivity(activity)
+ .withScopes(scopes)
+ .withPrompt(Prompt.SELECT_ACCOUNT)
+ .withCallback(object : AuthenticationCallback {
+ override fun onSuccess(authenticationResult: IAuthenticationResult) {
+ val email = authenticationResult.account.claims?.get("preferred_username") as? String
+ if (email == null) {
+ Timber.e("No email found")
+ return
}
- )
- .build()
- )
- authorizationService.performAuthorizationRequest(
- authRequest,
- PendingIntent.getActivity(
- activity,
- authRequest.hashCode(),
- intent,
- PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_CANCEL_CURRENT
- ),
- authorizationService.createCustomTabsIntentBuilder()
- .build()
- )
+ Timber.d("Successfully signed in")
+ viewModelScope.launch {
+ caldavDao
+ .getAccount(TYPE_MICROSOFT, email)
+ ?.let {
+ caldavDao.update(
+ it.copy(error = null)
+ )
+ }
+ ?: caldavDao
+ .insert(
+ CaldavAccount(
+ uuid = UUIDHelper.newUUID(),
+ name = email,
+ username = email,
+ accountType = TYPE_MICROSOFT,
+ )
+ )
+ .also {
+ firebase.logEvent(
+ R.string.event_sync_add_account,
+ R.string.param_type to Constants.SYNC_TYPE_MICROSOFT
+ )
+ }
+ }
+ }
+
+ override fun onError(exception: MsalException?) {
+ Timber.e(exception)
+ activity.toast(exception?.message ?: exception?.javaClass?.simpleName ?: "Sign in failed")
+ }
+
+ override fun onCancel() {
+ Timber.d("onCancel")
+ }
+ })
+ .build()
+
+ app.acquireToken(parameters)
}
}
-}
\ No newline at end of file
+
+ companion object {
+ val scopes = listOf("https://graph.microsoft.com/.default")
+ }
+}
diff --git a/app/src/main/java/org/tasks/sync/microsoft/MicrosoftSynchronizer.kt b/app/src/main/java/org/tasks/sync/microsoft/MicrosoftSynchronizer.kt
index 501c1ee73..d7cfa33ba 100644
--- a/app/src/main/java/org/tasks/sync/microsoft/MicrosoftSynchronizer.kt
+++ b/app/src/main/java/org/tasks/sync/microsoft/MicrosoftSynchronizer.kt
@@ -64,10 +64,6 @@ class MicrosoftSynchronizer @Inject constructor(
Timber.d("Synchronizing $account")
Thread.currentThread().contextClassLoader = context.classLoader
- if (isNullOrEmpty(account.password)) {
- setError(account, ERROR_UNAUTHORIZED)
- return
- }
try {
synchronize(account)
} catch (e: SocketTimeoutException) {
@@ -290,7 +286,7 @@ class MicrosoftSynchronizer @Inject constructor(
microsoft.paginateLists(nextPageToken)
}
} catch (e: Exception) {
- val error = e.message ?: "Sync failed"
+ val error = e.message ?: e.javaClass.simpleName
Timber.e(e)
setError(account, error)
return null
diff --git a/deps_fdroid.txt b/deps_fdroid.txt
index 6a397e01a..c7a653666 100644
--- a/deps_fdroid.txt
+++ b/deps_fdroid.txt
@@ -1413,6 +1413,85 @@
+| +--- androidx.core:core:1.1.0 -> 1.13.1 (*)
+| +--- androidx.annotation:annotation:1.1.0 -> 1.9.1 (*)
+| \--- com.google.guava:listenablefuture:1.0 -> 9999.0-empty-to-avoid-conflict-with-guava
+++--- com.microsoft.identity.client:msal:6.0.0
++| +--- com.microsoft.identity:common:21.0.0
++| | +--- com.microsoft.identity:common4j:21.0.0
++| | +--- org.jetbrains.kotlin:kotlin-stdlib:1.7.21 -> 2.1.20 (*)
++| | +--- org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4 -> 1.10.1 (*)
++| | +--- androidx.lifecycle:lifecycle-runtime-ktx:2.5.1 -> 2.8.7 (*)
++| | +--- androidx.datastore:datastore-preferences:1.0.0 -> 1.1.4 (*)
++| | +--- org.apache.httpcomponents.core5:httpcore5:5.3
++| | +--- com.nimbusds:nimbus-jose-jwt:9.37.3
++| | | \--- com.github.stephenc.jcip:jcip-annotations:1.0-1
++| | +--- androidx.appcompat:appcompat:1.1.0 -> 1.7.0 (*)
++| | +--- com.google.code.gson:gson:2.8.9 -> 2.12.1
++| | +--- com.squareup.moshi:moshi:1.14.0
++| | | +--- com.squareup.okio:okio:2.10.0 -> 3.9.0 (*)
++| | | \--- org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.0 -> 2.1.20 (*)
++| | +--- androidx.browser:browser:1.0.0 -> 1.3.0 (*)
++| | +--- androidx.constraintlayout:constraintlayout:1.1.3 -> 2.2.1 (*)
++| | +--- com.yubico.yubikit:android:2.5.0
++| | | +--- org.slf4j:slf4j-api:2.0.9 -> 2.0.16
++| | | \--- com.yubico.yubikit:core:2.5.0
++| | | \--- org.slf4j:slf4j-api:2.0.9 -> 2.0.16
++| | +--- com.yubico.yubikit:piv:2.5.0
++| | | +--- org.slf4j:slf4j-api:2.0.9 -> 2.0.16
++| | | \--- com.yubico.yubikit:core:2.5.0 (*)
++| | +--- androidx.credentials:credentials:1.2.2
++| | | +--- androidx.annotation:annotation:1.5.0 -> 1.9.1 (*)
++| | | +--- org.jetbrains.kotlin:kotlin-stdlib:1.8.22 -> 2.1.20 (*)
++| | | +--- org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1 -> 1.10.1 (*)
++| | | \--- androidx.credentials:credentials-play-services-auth:1.2.2 (c)
++| | +--- androidx.credentials:credentials-play-services-auth:1.2.2
++| | | +--- androidx.credentials:credentials:1.2.2 (*)
++| | | +--- com.google.android.gms:play-services-auth:20.7.0
++| | | | +--- androidx.fragment:fragment:1.0.0 -> 1.8.6 (*)
++| | | | +--- com.google.android.gms:play-services-auth-api-phone:18.0.1
++| | | | | +--- com.google.android.gms:play-services-base:18.0.1
++| | | | | | +--- androidx.collection:collection:1.0.0 -> 1.4.4 (*)
++| | | | | | +--- androidx.core:core:1.2.0 -> 1.13.1 (*)
++| | | | | | +--- androidx.fragment:fragment:1.0.0 -> 1.8.6 (*)
++| | | | | | +--- com.google.android.gms:play-services-basement:18.0.0 -> 18.2.0
++| | | | | | | +--- androidx.collection:collection:1.0.0 -> 1.4.4 (*)
++| | | | | | | +--- androidx.core:core:1.2.0 -> 1.13.1 (*)
++| | | | | | | \--- androidx.fragment:fragment:1.0.0 -> 1.8.6 (*)
++| | | | | | \--- com.google.android.gms:play-services-tasks:18.0.1
++| | | | | | \--- com.google.android.gms:play-services-basement:18.0.0 -> 18.2.0 (*)
++| | | | | +--- com.google.android.gms:play-services-basement:18.0.0 -> 18.2.0 (*)
++| | | | | \--- com.google.android.gms:play-services-tasks:18.0.1 (*)
++| | | | +--- com.google.android.gms:play-services-auth-base:18.0.4
++| | | | | +--- androidx.collection:collection:1.0.0 -> 1.4.4 (*)
++| | | | | +--- com.google.android.gms:play-services-base:18.0.1 (*)
++| | | | | +--- com.google.android.gms:play-services-basement:18.0.0 -> 18.2.0 (*)
++| | | | | \--- com.google.android.gms:play-services-tasks:18.0.1 (*)
++| | | | +--- com.google.android.gms:play-services-base:18.0.1 (*)
++| | | | +--- com.google.android.gms:play-services-basement:18.2.0 (*)
++| | | | +--- com.google.android.gms:play-services-fido:20.0.1 -> 20.1.0
++| | | | | +--- com.google.android.gms:play-services-base:18.0.1 (*)
++| | | | | +--- com.google.android.gms:play-services-basement:18.0.0 -> 18.2.0 (*)
++| | | | | \--- com.google.android.gms:play-services-tasks:18.0.1 (*)
++| | | | \--- com.google.android.gms:play-services-tasks:18.0.1 (*)
++| | | +--- com.google.android.gms:play-services-fido:20.1.0 (*)
++| | | +--- com.google.android.libraries.identity.googleid:googleid:1.1.0
++| | | | +--- androidx.credentials:credentials:1.0.0-alpha04 -> 1.2.2 (*)
++| | | | +--- org.jetbrains.kotlin:kotlin-stdlib:1.8.0 -> 2.1.20 (*)
++| | | | \--- org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.0 -> 2.1.20 (*)
++| | | +--- org.jetbrains.kotlin:kotlin-stdlib:1.8.22 -> 2.1.20 (*)
++| | | \--- androidx.credentials:credentials:1.2.2 (c)
++| | +--- com.google.android.gms:play-services-fido:20.1.0 (*)
++| | +--- com.google.android.libraries.identity.googleid:googleid:1.1.0 (*)
++| | +--- io.opentelemetry:opentelemetry-api:1.18.0
++| | | \--- io.opentelemetry:opentelemetry-context:1.18.0
++| | \--- androidx.fragment:fragment:1.3.2 -> 1.8.6 (*)
++| +--- org.jetbrains.kotlin:kotlin-stdlib:1.7.21 -> 2.1.20 (*)
++| +--- androidx.appcompat:appcompat:1.1.0 -> 1.7.0 (*)
++| +--- androidx.browser:browser:1.0.0 -> 1.3.0 (*)
++| +--- com.google.code.gson:gson:2.8.9 -> 2.12.1
++| +--- com.nimbusds:nimbus-jose-jwt:9.37.3 (*)
++| +--- org.apache.httpcomponents.core5:httpcore5:5.3
++| +--- androidx.constraintlayout:constraintlayout:1.1.3 -> 2.2.1 (*)
++| +--- org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4 -> 1.10.1 (*)
++| \--- io.opentelemetry:opentelemetry-api:1.18.0 (*)
++--- org.osmdroid:osmdroid-android:6.1.20
++--- androidx.recyclerview:recyclerview:1.4.0 (*)
++--- androidx.compose:compose-bom:2025.04.00
diff --git a/deps_googleplay.txt b/deps_googleplay.txt
index 628e5d913..880262d2b 100644
--- a/deps_googleplay.txt
+++ b/deps_googleplay.txt
@@ -1766,6 +1766,76 @@
+| +--- androidx.core:core:1.1.0 -> 1.13.1 (*)
+| +--- androidx.annotation:annotation:1.1.0 -> 1.9.1 (*)
+| \--- com.google.guava:listenablefuture:1.0 -> 9999.0-empty-to-avoid-conflict-with-guava
+++--- com.microsoft.identity.client:msal:6.0.0
++| +--- com.microsoft.identity:common:21.0.0
++| | +--- com.microsoft.identity:common4j:21.0.0
++| | +--- org.jetbrains.kotlin:kotlin-stdlib:1.7.21 -> 2.1.20 (*)
++| | +--- org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4 -> 1.10.1 (*)
++| | +--- androidx.lifecycle:lifecycle-runtime-ktx:2.5.1 -> 2.8.7 (*)
++| | +--- androidx.datastore:datastore-preferences:1.0.0 -> 1.1.4 (*)
++| | +--- org.apache.httpcomponents.core5:httpcore5:5.3
++| | +--- com.nimbusds:nimbus-jose-jwt:9.37.3
++| | | \--- com.github.stephenc.jcip:jcip-annotations:1.0-1
++| | +--- androidx.appcompat:appcompat:1.1.0 -> 1.7.0 (*)
++| | +--- com.google.code.gson:gson:2.8.9 -> 2.12.1
++| | +--- com.squareup.moshi:moshi:1.14.0
++| | | +--- com.squareup.okio:okio:2.10.0 -> 3.9.0 (*)
++| | | \--- org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.0 -> 2.1.20 (*)
++| | +--- androidx.browser:browser:1.0.0 -> 1.3.0 (*)
++| | +--- androidx.constraintlayout:constraintlayout:1.1.3 -> 2.2.1 (*)
++| | +--- com.yubico.yubikit:android:2.5.0
++| | | +--- org.slf4j:slf4j-api:2.0.9 -> 2.0.16
++| | | \--- com.yubico.yubikit:core:2.5.0
++| | | \--- org.slf4j:slf4j-api:2.0.9 -> 2.0.16
++| | +--- com.yubico.yubikit:piv:2.5.0
++| | | +--- org.slf4j:slf4j-api:2.0.9 -> 2.0.16
++| | | \--- com.yubico.yubikit:core:2.5.0 (*)
++| | +--- androidx.credentials:credentials:1.2.2
++| | | +--- androidx.annotation:annotation:1.5.0 -> 1.9.1 (*)
++| | | +--- org.jetbrains.kotlin:kotlin-stdlib:1.8.22 -> 2.1.20 (*)
++| | | +--- org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1 -> 1.10.1 (*)
++| | | \--- androidx.credentials:credentials-play-services-auth:1.2.2 (c)
++| | +--- androidx.credentials:credentials-play-services-auth:1.2.2
++| | | +--- androidx.credentials:credentials:1.2.2 (*)
++| | | +--- com.google.android.gms:play-services-auth:20.7.0
++| | | | +--- androidx.fragment:fragment:1.0.0 -> 1.8.6 (*)
++| | | | +--- com.google.android.gms:play-services-auth-api-phone:18.0.1
++| | | | | +--- com.google.android.gms:play-services-base:18.0.1 -> 18.5.0 (*)
++| | | | | +--- com.google.android.gms:play-services-basement:18.0.0 -> 18.5.0 (*)
++| | | | | \--- com.google.android.gms:play-services-tasks:18.0.1 -> 18.2.0 (*)
++| | | | +--- com.google.android.gms:play-services-auth-base:18.0.4
++| | | | | +--- androidx.collection:collection:1.0.0 -> 1.4.4 (*)
++| | | | | +--- com.google.android.gms:play-services-base:18.0.1 -> 18.5.0 (*)
++| | | | | +--- com.google.android.gms:play-services-basement:18.0.0 -> 18.5.0 (*)
++| | | | | \--- com.google.android.gms:play-services-tasks:18.0.1 -> 18.2.0 (*)
++| | | | +--- com.google.android.gms:play-services-base:18.0.1 -> 18.5.0 (*)
++| | | | +--- com.google.android.gms:play-services-basement:18.2.0 -> 18.5.0 (*)
++| | | | +--- com.google.android.gms:play-services-fido:20.0.1 -> 20.1.0
++| | | | | +--- com.google.android.gms:play-services-base:18.0.1 -> 18.5.0 (*)
++| | | | | +--- com.google.android.gms:play-services-basement:18.0.0 -> 18.5.0 (*)
++| | | | | \--- com.google.android.gms:play-services-tasks:18.0.1 -> 18.2.0 (*)
++| | | | \--- com.google.android.gms:play-services-tasks:18.0.1 -> 18.2.0 (*)
++| | | +--- com.google.android.gms:play-services-fido:20.1.0 (*)
++| | | +--- com.google.android.libraries.identity.googleid:googleid:1.1.0
++| | | | +--- androidx.credentials:credentials:1.0.0-alpha04 -> 1.2.2 (*)
++| | | | +--- org.jetbrains.kotlin:kotlin-stdlib:1.8.0 -> 2.1.20 (*)
++| | | | \--- org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.0 -> 2.1.20 (*)
++| | | +--- org.jetbrains.kotlin:kotlin-stdlib:1.8.22 -> 2.1.20 (*)
++| | | \--- androidx.credentials:credentials:1.2.2 (c)
++| | +--- com.google.android.gms:play-services-fido:20.1.0 (*)
++| | +--- com.google.android.libraries.identity.googleid:googleid:1.1.0 (*)
++| | +--- io.opentelemetry:opentelemetry-api:1.18.0
++| | | \--- io.opentelemetry:opentelemetry-context:1.18.0
++| | \--- androidx.fragment:fragment:1.3.2 -> 1.8.6 (*)
++| +--- org.jetbrains.kotlin:kotlin-stdlib:1.7.21 -> 2.1.20 (*)
++| +--- androidx.appcompat:appcompat:1.1.0 -> 1.7.0 (*)
++| +--- androidx.browser:browser:1.0.0 -> 1.3.0 (*)
++| +--- com.google.code.gson:gson:2.8.9 -> 2.12.1
++| +--- com.nimbusds:nimbus-jose-jwt:9.37.3 (*)
++| +--- org.apache.httpcomponents.core5:httpcore5:5.3
++| +--- androidx.constraintlayout:constraintlayout:1.1.3 -> 2.2.1 (*)
++| +--- org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4 -> 1.10.1 (*)
++| \--- io.opentelemetry:opentelemetry-api:1.18.0 (*)
++--- org.osmdroid:osmdroid-android:6.1.20
++--- androidx.recyclerview:recyclerview:1.4.0 (*)
++--- androidx.compose:compose-bom:2025.04.00 (*)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 44be88acb..719941448 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -159,6 +159,7 @@ markwon-strikethrough = { module = "io.noties.markwon:ext-strikethrough", versio
markwon-tables = { module = "io.noties.markwon:ext-tables", version.ref = "markwon" }
markwon-tasklist = { module = "io.noties.markwon:ext-tasklist", version.ref = "markwon" }
material = { module = "com.google.android.material:material", version.ref = "material" }
+microsoft-authentication = { module = "com.microsoft.identity.client:msal", version = "6.0.0" }
mockito-android = { module = "org.mockito:mockito-android", version.ref = "mockito" }
mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockito" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }