Add support for microsoft organization accounts

pull/3552/head
Alex Baker 8 months ago
parent 68d7a02db8
commit c3e10bde94

@ -229,6 +229,9 @@ dependencies {
implementation(libs.etebase) implementation(libs.etebase)
implementation(libs.colorpicker) implementation(libs.colorpicker)
implementation(libs.appauth) implementation(libs.appauth)
implementation(libs.microsoft.authentication) {
exclude("com.microsoft.device.display", "display-mask")
}
implementation(libs.osmdroid) implementation(libs.osmdroid)
implementation(libs.androidx.recyclerview) implementation(libs.androidx.recyclerview)

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

@ -15,4 +15,5 @@
<string name="debug_force_restart">Restart app</string> <string name="debug_force_restart">Restart app</string>
<string name="debug_clear_hints">Clear hints</string> <string name="debug_clear_hints">Clear hints</string>
<string name="google_oauth_scheme">com.googleusercontent.apps.1006257750459-vf4mvft1b3rfda8b4c4bl4k4418abqlf</string> <string name="google_oauth_scheme">com.googleusercontent.apps.1006257750459-vf4mvft1b3rfda8b4c4bl4k4418abqlf</string>
<string name="microsoft_oauth_path">/8wnYBRqh5nnQgFzbIXfxXSs41xE=</string>
</resources> </resources>

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

@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="google_oauth_scheme">com.googleusercontent.apps.363426363175-jdrijf7hql9030klgjcjlpi6k5spviif</string> <string name="google_oauth_scheme">com.googleusercontent.apps.363426363175-jdrijf7hql9030klgjcjlpi6k5spviif</string>
<string name="microsoft_oauth_path">/sEe08kX5nGJi4miFX3VkNXICC/Y=</string>
</resources> </resources>

@ -178,14 +178,6 @@
<category android:name="android.intent.category.BROWSABLE"/> <category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="org.tasks.github.a50fdbf3e289a7fb2fc6" /> <data android:scheme="org.tasks.github.a50fdbf3e289a7fb2fc6" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data
android:host="${applicationId}"
android:scheme="msauth" />
</intent-filter>
</activity> </activity>
<activity <activity
@ -617,10 +609,6 @@
<receiver android:name="org.tasks.jobs.NotificationReceiver" /> <receiver android:name="org.tasks.jobs.NotificationReceiver" />
<activity
android:name=".auth.MicrosoftAuthenticationActivity"
android:theme="@style/TranslucentDialog"/>
<activity <activity
android:name="com.todoroo.astrid.activity.MainActivity" android:name="com.todoroo.astrid.activity.MainActivity"
android:exported="true" android:exported="true"
@ -1021,6 +1009,20 @@
<!-- Version >= 3.0. DeX Dual Mode support --> <!-- Version >= 3.0. DeX Dual Mode support -->
<meta-data android:name="com.samsung.android.multidisplay.keep_process_alive" android:value="true"/> <meta-data android:name="com.samsung.android.multidisplay.keep_process_alive" android:value="true"/>
<activity
android:name="com.microsoft.identity.client.BrowserTabActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="${applicationId}"
android:path="@string/microsoft_oauth_path"
android:scheme="msauth" />
</intent-filter>
</activity>
</application> </application>
</manifest> </manifest>

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

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

@ -3,6 +3,8 @@ package org.tasks.http
import android.content.Context import android.content.Context
import at.bitfire.cert4android.CustomCertManager import at.bitfire.cert4android.CustomCertManager
import at.bitfire.dav4jvm.BasicDigestAuthHandler import at.bitfire.dav4jvm.BasicDigestAuthHandler
import com.microsoft.identity.client.AcquireTokenSilentParameters
import com.microsoft.identity.client.PublicClientApplication
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.engine.android.Android 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.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import net.openid.appauth.AuthState
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.internal.tls.OkHostnameVerifier import okhttp3.internal.tls.OkHostnameVerifier
import org.tasks.BuildConfig import org.tasks.BuildConfig
import org.tasks.R
import org.tasks.caldav.TasksCookieJar import org.tasks.caldav.TasksCookieJar
import org.tasks.data.entity.CaldavAccount import org.tasks.data.entity.CaldavAccount
import org.tasks.extensions.Context.cookiePersistor import org.tasks.extensions.Context.cookiePersistor
import org.tasks.security.KeyStoreEncryption import org.tasks.security.KeyStoreEncryption
import org.tasks.sync.microsoft.MicrosoftService import org.tasks.sync.microsoft.MicrosoftService
import org.tasks.sync.microsoft.requestTokenRefresh import org.tasks.sync.microsoft.MicrosoftSignInViewModel
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import javax.net.ssl.SSLContext import javax.net.ssl.SSLContext
@ -85,18 +87,28 @@ class HttpClientFactory @Inject constructor(
} }
suspend fun getMicrosoftService(account: CaldavAccount): MicrosoftService { suspend fun getMicrosoftService(account: CaldavAccount): MicrosoftService {
val authState = encryption.decrypt(account.password)?.let { AuthState.jsonDeserialize(it) } val app = PublicClientApplication.createMultipleAccountPublicClientApplication(
?: throw RuntimeException("Missing credentials") context,
if (authState.needsTokenRefresh) { R.raw.microsoft_config
val (token, ex) = context.requestTokenRefresh(authState) )
authState.update(token, ex)
if (authState.isAuthorized) { val result = try {
account.password = encryption.encrypt(authState.jsonSerializeString()) val msalAccount = app.accounts.firstOrNull { it.username == account.username }
} ?: throw RuntimeException("No matching account found")
}
if (!authState.isAuthorized) { val parameters = AcquireTokenSilentParameters.Builder()
throw RuntimeException("Needs authentication") .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) { val client = HttpClient(Android) {
expectSuccess = true expectSuccess = true
@ -109,7 +121,7 @@ class HttpClientFactory @Inject constructor(
} }
defaultRequest { defaultRequest {
header("Authorization", "Bearer ${authState.accessToken}") header("Authorization", "Bearer ${result.accessToken}")
} }
install(HttpTimeout) { install(HttpTimeout) {

@ -1,18 +1,47 @@
package org.tasks.http package org.tasks.http
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.HttpClientPlugin import io.ktor.client.plugins.HttpClientPlugin
import io.ktor.client.plugins.HttpSend import io.ktor.client.plugins.HttpSend
import io.ktor.client.plugins.plugin import io.ktor.client.plugins.plugin
import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.HttpResponse
import io.ktor.http.isSuccess import io.ktor.http.isSuccess
import io.ktor.util.AttributeKey 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) @Serializable
class UnauthorizedException(cause: Throwable? = null) : NetworkException(cause) data class GraphErrorResponse(
class NotFoundException(cause: Throwable? = null) : NetworkException(cause) val error: GraphError
class ServiceUnavailableException(cause: Throwable? = null) : NetworkException(cause) )
class HttpException(val code: Int, override val message: String? = null) : NetworkException()
@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 HttpErrorHandler {
class Config { class Config {
@ -32,11 +61,33 @@ class HttpErrorHandler {
val originalCall = execute(request) val originalCall = execute(request)
if (!originalCall.response.status.isSuccess()) { if (!originalCall.response.status.isSuccess()) {
when (originalCall.response.status.value) { val errorResponse = try {
401 -> throw UnauthorizedException() originalCall.response.body<GraphErrorResponse>()
404 -> throw NotFoundException() } catch (e: Exception) {
in 500..599 -> throw ServiceUnavailableException() Timber.e(e)
else -> throw HttpException(originalCall.response.status.value) 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)
} }
} }

@ -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<TokenResponse?, AuthorizationException?> {
val authService = AuthorizationService(this)
return try {
suspendCoroutine { cont ->
authService.performTokenRequest(tokenRequest) { response, ex ->
cont.resumeWith(Result.success(Pair(response, ex)))
}
}
} finally {
authService.dispose()
}
}

@ -1,74 +1,94 @@
package org.tasks.sync.microsoft package org.tasks.sync.microsoft
import android.app.Activity import android.app.Activity
import android.app.PendingIntent
import android.content.Intent
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import net.openid.appauth.AppAuthConfiguration import org.tasks.R
import net.openid.appauth.AuthorizationRequest import org.tasks.analytics.Constants
import net.openid.appauth.AuthorizationService import org.tasks.analytics.Firebase
import net.openid.appauth.ResponseTypeValues import org.tasks.data.UUIDHelper
import net.openid.appauth.browser.AnyBrowserMatcher import org.tasks.data.dao.CaldavDao
import net.openid.appauth.connectivity.DefaultConnectionBuilder import org.tasks.data.entity.CaldavAccount
import org.tasks.BuildConfig import org.tasks.data.entity.CaldavAccount.Companion.TYPE_MICROSOFT
import org.tasks.auth.DebugConnectionBuilder import org.tasks.extensions.Context.toast
import org.tasks.auth.IdentityProvider import timber.log.Timber
import org.tasks.auth.MicrosoftAuthenticationActivity
import org.tasks.auth.MicrosoftAuthenticationActivity.Companion.EXTRA_SERVICE_DISCOVERY
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class MicrosoftSignInViewModel @Inject constructor( class MicrosoftSignInViewModel @Inject constructor(
private val debugConnectionBuilder: DebugConnectionBuilder, private val caldavDao: CaldavDao,
private val firebase: Firebase,
) : ViewModel() { ) : ViewModel() {
fun signIn(activity: Activity) { fun signIn(activity: Activity) {
viewModelScope.launch { viewModelScope.launch(Dispatchers.IO) {
val idp = IdentityProvider.MICROSOFT val app = PublicClientApplication.createMultipleAccountPublicClientApplication(
val serviceConfig = idp.retrieveConfig() activity,
val authRequest = AuthorizationRequest R.raw.microsoft_config
.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( val parameters = AcquireTokenParameters.Builder()
activity, .startAuthorizationFromActivity(activity)
AppAuthConfiguration.Builder() .withScopes(scopes)
.setBrowserMatcher(AnyBrowserMatcher.INSTANCE) .withPrompt(Prompt.SELECT_ACCOUNT)
.setConnectionBuilder( .withCallback(object : AuthenticationCallback {
if (BuildConfig.DEBUG) { override fun onSuccess(authenticationResult: IAuthenticationResult) {
debugConnectionBuilder val email = authenticationResult.account.claims?.get("preferred_username") as? String
} else { if (email == null) {
DefaultConnectionBuilder.INSTANCE Timber.e("No email found")
return
} }
Timber.d("Successfully signed in")
viewModelScope.launch {
caldavDao
.getAccount(TYPE_MICROSOFT, email)
?.let {
caldavDao.update(
it.copy(error = null)
) )
.build() }
?: caldavDao
.insert(
CaldavAccount(
uuid = UUIDHelper.newUUID(),
name = email,
username = email,
accountType = TYPE_MICROSOFT,
) )
authorizationService.performAuthorizationRequest( )
authRequest, .also {
PendingIntent.getActivity( firebase.logEvent(
activity, R.string.event_sync_add_account,
authRequest.hashCode(), R.string.param_type to Constants.SYNC_TYPE_MICROSOFT
intent,
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_CANCEL_CURRENT
),
authorizationService.createCustomTabsIntentBuilder()
.build()
) )
} }
} }
} }
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)
}
}
companion object {
val scopes = listOf("https://graph.microsoft.com/.default")
}
}

@ -64,10 +64,6 @@ class MicrosoftSynchronizer @Inject constructor(
Timber.d("Synchronizing $account") Timber.d("Synchronizing $account")
Thread.currentThread().contextClassLoader = context.classLoader Thread.currentThread().contextClassLoader = context.classLoader
if (isNullOrEmpty(account.password)) {
setError(account, ERROR_UNAUTHORIZED)
return
}
try { try {
synchronize(account) synchronize(account)
} catch (e: SocketTimeoutException) { } catch (e: SocketTimeoutException) {
@ -290,7 +286,7 @@ class MicrosoftSynchronizer @Inject constructor(
microsoft.paginateLists(nextPageToken) microsoft.paginateLists(nextPageToken)
} }
} catch (e: Exception) { } catch (e: Exception) {
val error = e.message ?: "Sync failed" val error = e.message ?: e.javaClass.simpleName
Timber.e(e) Timber.e(e)
setError(account, error) setError(account, error)
return null return null

@ -1413,6 +1413,85 @@
+| +--- androidx.core:core:1.1.0 -> 1.13.1 (*) +| +--- androidx.core:core:1.1.0 -> 1.13.1 (*)
+| +--- androidx.annotation:annotation:1.1.0 -> 1.9.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.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 ++--- org.osmdroid:osmdroid-android:6.1.20
++--- androidx.recyclerview:recyclerview:1.4.0 (*) ++--- androidx.recyclerview:recyclerview:1.4.0 (*)
++--- androidx.compose:compose-bom:2025.04.00 ++--- androidx.compose:compose-bom:2025.04.00

@ -1766,6 +1766,76 @@
+| +--- androidx.core:core:1.1.0 -> 1.13.1 (*) +| +--- androidx.core:core:1.1.0 -> 1.13.1 (*)
+| +--- androidx.annotation:annotation:1.1.0 -> 1.9.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.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 ++--- org.osmdroid:osmdroid-android:6.1.20
++--- androidx.recyclerview:recyclerview:1.4.0 (*) ++--- androidx.recyclerview:recyclerview:1.4.0 (*)
++--- androidx.compose:compose-bom:2025.04.00 (*) ++--- androidx.compose:compose-bom:2025.04.00 (*)

@ -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-tables = { module = "io.noties.markwon:ext-tables", version.ref = "markwon" }
markwon-tasklist = { module = "io.noties.markwon:ext-tasklist", version.ref = "markwon" } markwon-tasklist = { module = "io.noties.markwon:ext-tasklist", version.ref = "markwon" }
material = { module = "com.google.android.material:material", version.ref = "material" } 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-android = { module = "org.mockito:mockito-android", version.ref = "mockito" }
mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockito" } mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockito" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }

Loading…
Cancel
Save