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.colorpicker)
implementation(libs.appauth)
implementation(libs.microsoft.authentication) {
exclude("com.microsoft.device.display", "display-mask")
}
implementation(libs.osmdroid)
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_clear_hints">Clear hints</string>
<string name="google_oauth_scheme">com.googleusercontent.apps.1006257750459-vf4mvft1b3rfda8b4c4bl4k4418abqlf</string>
<string name="microsoft_oauth_path">/8wnYBRqh5nnQgFzbIXfxXSs41xE=</string>
</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"?>
<resources>
<string name="google_oauth_scheme">com.googleusercontent.apps.363426363175-jdrijf7hql9030klgjcjlpi6k5spviif</string>
<string name="microsoft_oauth_path">/sEe08kX5nGJi4miFX3VkNXICC/Y=</string>
</resources>

@ -178,14 +178,6 @@
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="org.tasks.github.a50fdbf3e289a7fb2fc6" />
</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
@ -617,10 +609,6 @@
<receiver android:name="org.tasks.jobs.NotificationReceiver" />
<activity
android:name=".auth.MicrosoftAuthenticationActivity"
android:theme="@style/TranslucentDialog"/>
<activity
android:name="com.todoroo.astrid.activity.MainActivity"
android:exported="true"
@ -1021,6 +1009,20 @@
<!-- Version >= 3.0. DeX Dual Mode support -->
<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>
</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 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) {

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

@ -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
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)
}
}
}
companion object {
val scopes = listOf("https://graph.microsoft.com/.default")
}
}

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

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

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

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

Loading…
Cancel
Save