diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 0822592c0..9913ed490 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -229,11 +229,6 @@ dependencies {
implementation(libs.etebase)
implementation(libs.colorpicker)
implementation(libs.appauth)
- implementation(libs.microsoft.authentication) {
- exclude("com.microsoft.device.display", "display-mask")
- exclude("com.google.android.gms")
- exclude("com.google.android.libraries.identity.googleid")
- }
implementation(libs.osmdroid)
implementation(libs.androidx.recyclerview)
@@ -273,6 +268,9 @@ dependencies {
googleplayImplementation(libs.horologist.datalayer.grpc)
googleplayImplementation(libs.horologist.datalayer.core)
googleplayImplementation(libs.play.services.wearable)
+ googleplayImplementation(libs.microsoft.authentication) {
+ exclude("com.microsoft.device.display", "display-mask")
+ }
googleplayImplementation(projects.wearDatalayer)
androidTestImplementation(libs.dagger.hilt.testing)
diff --git a/app/src/generic/AndroidManifest.xml b/app/src/generic/AndroidManifest.xml
index 2b327f7a7..e5499af2d 100644
--- a/app/src/generic/AndroidManifest.xml
+++ b/app/src/generic/AndroidManifest.xml
@@ -1,6 +1,24 @@
-
+
-
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/generic/java/org/tasks/auth/IdentityProvider.kt b/app/src/generic/java/org/tasks/auth/IdentityProvider.kt
new file mode 100644
index 000000000..a85663843
--- /dev/null
+++ b/app/src/generic/java/org/tasks/auth/IdentityProvider.kt
@@ -0,0 +1,38 @@
+package org.tasks.auth
+
+import android.net.Uri
+import androidx.core.net.toUri
+import net.openid.appauth.AuthorizationServiceConfiguration
+import kotlin.coroutines.suspendCoroutine
+
+data class IdentityProvider(
+ val name: String,
+ val discoveryEndpoint: Uri,
+ val clientId: String,
+ val redirectUri: Uri,
+ val scope: String
+) {
+ suspend fun 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())
+ }
+ )
+ }
+ }
+ }
+
+ 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/generic/java/org/tasks/auth/MicrosoftAuthenticationActivity.kt b/app/src/generic/java/org/tasks/auth/MicrosoftAuthenticationActivity.kt
new file mode 100644
index 000000000..365570e35
--- /dev/null
+++ b/app/src/generic/java/org/tasks/auth/MicrosoftAuthenticationActivity.kt
@@ -0,0 +1,157 @@
+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.jobs.WorkManager
+import org.tasks.preferences.fragments.TasksAccountViewModel.Companion.getStringOrNull
+import org.tasks.security.KeyStoreEncryption
+import org.tasks.sync.SyncAdapters
+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
+ @Inject lateinit var syncAdapters: SyncAdapters
+ @Inject lateinit var workManager: WorkManager
+
+ 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
+ )
+ }
+ syncAdapters.sync(true)
+ workManager.updateBackgroundSync()
+ 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/generic/java/org/tasks/sync/microsoft/AppAuthExtensions.kt b/app/src/generic/java/org/tasks/sync/microsoft/AppAuthExtensions.kt
new file mode 100644
index 000000000..7b2eae97e
--- /dev/null
+++ b/app/src/generic/java/org/tasks/sync/microsoft/AppAuthExtensions.kt
@@ -0,0 +1,29 @@
+package org.tasks.sync.microsoft
+
+import android.content.Context
+import net.openid.appauth.AuthState
+import net.openid.appauth.AuthorizationException
+import net.openid.appauth.AuthorizationResponse
+import net.openid.appauth.AuthorizationService
+import net.openid.appauth.TokenRequest
+import net.openid.appauth.TokenResponse
+import kotlin.coroutines.suspendCoroutine
+
+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/generic/java/org/tasks/sync/microsoft/MicrosoftSignInViewModel.kt b/app/src/generic/java/org/tasks/sync/microsoft/MicrosoftSignInViewModel.kt
new file mode 100644
index 000000000..8994ead4a
--- /dev/null
+++ b/app/src/generic/java/org/tasks/sync/microsoft/MicrosoftSignInViewModel.kt
@@ -0,0 +1,74 @@
+package org.tasks.sync.microsoft
+
+import android.app.Activity
+import android.app.PendingIntent
+import android.content.Intent
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.launch
+import net.openid.appauth.AppAuthConfiguration
+import net.openid.appauth.AuthorizationRequest
+import net.openid.appauth.AuthorizationService
+import net.openid.appauth.ResponseTypeValues
+import net.openid.appauth.browser.AnyBrowserMatcher
+import net.openid.appauth.connectivity.DefaultConnectionBuilder
+import org.tasks.BuildConfig
+import org.tasks.auth.DebugConnectionBuilder
+import org.tasks.auth.IdentityProvider
+import org.tasks.auth.MicrosoftAuthenticationActivity
+import org.tasks.auth.MicrosoftAuthenticationActivity.Companion.EXTRA_SERVICE_DISCOVERY
+import javax.inject.Inject
+
+@HiltViewModel
+class MicrosoftSignInViewModel @Inject constructor(
+ private val debugConnectionBuilder: DebugConnectionBuilder,
+) : ViewModel() {
+ fun signIn(activity: Activity) {
+ viewModelScope.launch {
+ val idp = IdentityProvider.MICROSOFT
+ val serviceConfig = idp.retrieveConfig()
+ val authRequest = AuthorizationRequest
+ .Builder(
+ serviceConfig,
+ idp.clientId,
+ ResponseTypeValues.CODE,
+ idp.redirectUri
+ )
+ .setScope(idp.scope)
+ .setPrompt(AuthorizationRequest.Prompt.SELECT_ACCOUNT)
+ .build()
+ val intent = Intent(activity, MicrosoftAuthenticationActivity::class.java)
+ intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
+ intent.putExtra(
+ EXTRA_SERVICE_DISCOVERY,
+ serviceConfig.discoveryDoc!!.docJson.toString()
+ )
+
+ val authorizationService = AuthorizationService(
+ activity,
+ AppAuthConfiguration.Builder()
+ .setBrowserMatcher(AnyBrowserMatcher.INSTANCE)
+ .setConnectionBuilder(
+ if (BuildConfig.DEBUG) {
+ debugConnectionBuilder
+ } else {
+ DefaultConnectionBuilder.INSTANCE
+ }
+ )
+ .build()
+ )
+ authorizationService.performAuthorizationRequest(
+ authRequest,
+ PendingIntent.getActivity(
+ activity,
+ authRequest.hashCode(),
+ intent,
+ PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_CANCEL_CURRENT
+ ),
+ authorizationService.createCustomTabsIntentBuilder()
+ .build()
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/generic/java/org/tasks/sync/microsoft/MicrosoftTokenProvider.kt b/app/src/generic/java/org/tasks/sync/microsoft/MicrosoftTokenProvider.kt
new file mode 100644
index 000000000..662c0882c
--- /dev/null
+++ b/app/src/generic/java/org/tasks/sync/microsoft/MicrosoftTokenProvider.kt
@@ -0,0 +1,29 @@
+package org.tasks.sync.microsoft
+
+import android.content.Context
+import dagger.hilt.android.qualifiers.ApplicationContext
+import net.openid.appauth.AuthState
+import org.tasks.data.entity.CaldavAccount
+import org.tasks.security.KeyStoreEncryption
+import javax.inject.Inject
+
+class MicrosoftTokenProvider @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val encryption: KeyStoreEncryption,
+) {
+ suspend fun getToken(account: CaldavAccount): String {
+ 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")
+ }
+ return authState.accessToken!!
+ }
+}
\ No newline at end of file
diff --git a/app/src/generic/res/values/keys.xml b/app/src/generic/res/values/keys.xml
index 9f5dcc50f..9a0a47489 100644
--- a/app/src/generic/res/values/keys.xml
+++ b/app/src/generic/res/values/keys.xml
@@ -5,5 +5,4 @@
%s
support@tasks.org
- /xVwQTvk42gGm0o6zNvelaYloFcs=
\ No newline at end of file
diff --git a/app/src/genericRelease/res/raw/microsoft_config.json b/app/src/genericRelease/res/raw/microsoft_config.json
deleted file mode 100644
index 51215e776..000000000
--- a/app/src/genericRelease/res/raw/microsoft_config.json
+++ /dev/null
@@ -1,20 +0,0 @@
-{
- "client_id" : "9d4babd5-e7ba-4286-ba4b-17274495a901",
- "authorization_user_agent" : "DEFAULT",
- "redirect_uri" : "msauth://org.tasks/xVwQTvk42gGm0o6zNvelaYloFcs%3D",
- "account_mode" : "MULTIPLE",
- "authorities" : [
- {
- "type": "AAD",
- "audience": {
- "type": "AzureADandPersonalMicrosoftAccount",
- "tenant_id": "common"
- }
- }
- ],
- "logging": {
- "level": "info",
- "logcat_enabled": true,
- "pii_enabled": false
- }
-}
diff --git a/app/src/googleplay/AndroidManifest.xml b/app/src/googleplay/AndroidManifest.xml
index 582361fe3..deb06d4a4 100644
--- a/app/src/googleplay/AndroidManifest.xml
+++ b/app/src/googleplay/AndroidManifest.xml
@@ -53,6 +53,20 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/org/tasks/sync/microsoft/MicrosoftSignInViewModel.kt b/app/src/googleplay/java/org/tasks/sync/microsoft/MicrosoftSignInViewModel.kt
similarity index 100%
rename from app/src/main/java/org/tasks/sync/microsoft/MicrosoftSignInViewModel.kt
rename to app/src/googleplay/java/org/tasks/sync/microsoft/MicrosoftSignInViewModel.kt
diff --git a/app/src/googleplay/java/org/tasks/sync/microsoft/MicrosoftTokenProvider.kt b/app/src/googleplay/java/org/tasks/sync/microsoft/MicrosoftTokenProvider.kt
new file mode 100644
index 000000000..13f617c8a
--- /dev/null
+++ b/app/src/googleplay/java/org/tasks/sync/microsoft/MicrosoftTokenProvider.kt
@@ -0,0 +1,39 @@
+package org.tasks.sync.microsoft
+
+import android.content.Context
+import com.microsoft.identity.client.AcquireTokenSilentParameters
+import com.microsoft.identity.client.PublicClientApplication
+import dagger.hilt.android.qualifiers.ApplicationContext
+import org.tasks.R
+import org.tasks.data.entity.CaldavAccount
+import timber.log.Timber
+import javax.inject.Inject
+
+class MicrosoftTokenProvider @Inject constructor(
+ @ApplicationContext private val context: Context,
+) {
+ fun getToken(account: CaldavAccount): String {
+ 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}")
+ }
+ return result.accessToken
+ }
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 954258193..b6e4de5cf 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1013,20 +1013,6 @@
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/java/org/tasks/http/HttpClientFactory.kt b/app/src/main/java/org/tasks/http/HttpClientFactory.kt
index 46acf1aa8..a42fd6a34 100644
--- a/app/src/main/java/org/tasks/http/HttpClientFactory.kt
+++ b/app/src/main/java/org/tasks/http/HttpClientFactory.kt
@@ -3,8 +3,6 @@ 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
@@ -24,13 +22,12 @@ import kotlinx.serialization.json.Json
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.MicrosoftSignInViewModel
+import org.tasks.sync.microsoft.MicrosoftTokenProvider
import timber.log.Timber
import javax.inject.Inject
import javax.net.ssl.SSLContext
@@ -38,6 +35,7 @@ import javax.net.ssl.SSLContext
class HttpClientFactory @Inject constructor(
@ApplicationContext private val context: Context,
private val encryption: KeyStoreEncryption,
+ private val microsoftTokenProvider: MicrosoftTokenProvider,
) {
suspend fun newClient(foreground: Boolean) = newClient(
foreground = foreground,
@@ -87,28 +85,7 @@ class HttpClientFactory @Inject constructor(
}
suspend fun getMicrosoftService(account: CaldavAccount): MicrosoftService = withContext(Dispatchers.IO) {
- 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 token = microsoftTokenProvider.getToken(account)
val client = HttpClient(Android) {
expectSuccess = true
@@ -121,7 +98,7 @@ class HttpClientFactory @Inject constructor(
}
defaultRequest {
- header("Authorization", "Bearer ${result.accessToken}")
+ header("Authorization", "Bearer $token")
}
install(HttpTimeout) {
diff --git a/deps_fdroid.txt b/deps_fdroid.txt
index 8c4309ed3..6fa4dfbe3 100644
--- a/deps_fdroid.txt
+++ b/deps_fdroid.txt
@@ -1460,51 +1460,6 @@
+| +--- 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.1
-+| +--- com.microsoft.identity:common:21.1.0
-+| | +--- com.microsoft.identity:common4j:21.1.0
-+| | +--- org.jetbrains.kotlin:kotlin-stdlib:1.7.21 -> 2.1.20 (*)
-+| | +--- org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4 -> 1.10.2 (*)
-+| | +--- androidx.lifecycle:lifecycle-runtime-ktx:2.5.1 -> 2.9.0 (*)
-+| | +--- 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.2 (*)
-+| | | \--- 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 (*)
-+| | | +--- org.jetbrains.kotlin:kotlin-stdlib:1.8.22 -> 2.1.20 (*)
-+| | | \--- androidx.credentials:credentials:1.2.2 (c)
-+| | +--- 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.2 (*)
-+| \--- 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 ea17955c0..1fb6b8ac6 100644
--- a/deps_googleplay.txt
+++ b/deps_googleplay.txt
@@ -806,6 +806,87 @@
+| \--- org.jetbrains.kotlin:kotlin-stdlib:1.9.24 -> 2.1.20 (*)
++--- com.google.android.horologist:horologist-datalayer:0.6.23 (*)
++--- com.google.android.gms:play-services-wearable:19.0.0 (*)
+++--- com.microsoft.identity.client:msal:6.0.1
++| +--- com.microsoft.identity:common:21.1.0
++| | +--- com.microsoft.identity:common4j:21.1.0
++| | +--- org.jetbrains.kotlin:kotlin-stdlib:1.7.21 -> 2.1.20 (*)
++| | +--- org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4 -> 1.10.2 (*)
++| | +--- androidx.lifecycle:lifecycle-runtime-ktx:2.5.1 -> 2.9.0 (*)
++| | +--- 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.collection:collection:1.1.0 -> 1.4.5 (*)
++| | | +--- androidx.concurrent:concurrent-futures:1.0.0 -> 1.1.0 (*)
++| | | +--- androidx.interpolator:interpolator:1.0.0 (*)
++| | | +--- 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
++| | +--- androidx.constraintlayout:constraintlayout:1.1.3 -> 2.2.1
++| | | +--- androidx.appcompat:appcompat:1.2.0 -> 1.7.0 (*)
++| | | +--- androidx.constraintlayout:constraintlayout-core:1.1.1
++| | | | \--- androidx.annotation:annotation:1.8.1 -> 1.9.1 (*)
++| | | +--- androidx.core:core:1.3.2 -> 1.13.1 (*)
++| | | \--- androidx.profileinstaller:profileinstaller:1.4.0 (*)
++| | +--- 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.2 (*)
++| | | \--- 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.5 (*)
++| | | | | +--- 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.2 (*)
++| \--- io.opentelemetry:opentelemetry-api:1.18.0 (*)
++--- project :wear-datalayer
+| +--- io.grpc:grpc-kotlin-stub:1.4.3 (*)
+| +--- io.grpc:grpc-protobuf-lite:1.72.0 (*)
@@ -1574,12 +1655,7 @@
+| | | +--- androidx.core:core:1.1.0 -> 1.13.1 (*)
+| | | +--- androidx.customview:customview:1.0.0 -> 1.1.0 (*)
+| | | \--- androidx.collection:collection:1.0.0 -> 1.4.5 (*)
-+| | +--- androidx.constraintlayout:constraintlayout:2.0.1 -> 2.2.1
-+| | | +--- androidx.appcompat:appcompat:1.2.0 -> 1.7.0 (*)
-+| | | +--- androidx.constraintlayout:constraintlayout-core:1.1.1
-+| | | | \--- androidx.annotation:annotation:1.8.1 -> 1.9.1 (*)
-+| | | +--- androidx.core:core:1.3.2 -> 1.13.1 (*)
-+| | | \--- androidx.profileinstaller:profileinstaller:1.4.0 (*)
++| | +--- androidx.constraintlayout:constraintlayout:2.0.1 -> 2.2.1 (*)
+| | +--- androidx.core:core:1.6.0 -> 1.13.1 (*)
+| | +--- androidx.drawerlayout:drawerlayout:1.1.1 (*)
+| | +--- androidx.dynamicanimation:dynamicanimation:1.0.0
@@ -1806,58 +1882,7 @@
++--- net.openid:appauth:0.11.1
+| +--- androidx.annotation:annotation:1.2.0 -> 1.9.1 (*)
+| +--- androidx.appcompat:appcompat:1.3.0 -> 1.7.0 (*)
-+| \--- androidx.browser:browser:1.3.0
-+| +--- androidx.collection:collection:1.1.0 -> 1.4.5 (*)
-+| +--- androidx.concurrent:concurrent-futures:1.0.0 -> 1.1.0 (*)
-+| +--- androidx.interpolator:interpolator:1.0.0 (*)
-+| +--- 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.1
-+| +--- com.microsoft.identity:common:21.1.0
-+| | +--- com.microsoft.identity:common4j:21.1.0
-+| | +--- org.jetbrains.kotlin:kotlin-stdlib:1.7.21 -> 2.1.20 (*)
-+| | +--- org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4 -> 1.10.2 (*)
-+| | +--- androidx.lifecycle:lifecycle-runtime-ktx:2.5.1 -> 2.9.0 (*)
-+| | +--- 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.2 (*)
-+| | | \--- 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 (*)
-+| | | +--- org.jetbrains.kotlin:kotlin-stdlib:1.8.22 -> 2.1.20 (*)
-+| | | \--- androidx.credentials:credentials:1.2.2 (c)
-+| | +--- 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.2 (*)
-+| \--- io.opentelemetry:opentelemetry-api:1.18.0 (*)
++| \--- androidx.browser:browser:1.3.0 (*)
++--- org.osmdroid:osmdroid-android:6.1.20
++--- androidx.recyclerview:recyclerview:1.4.0 (*)
++--- androidx.compose:compose-bom:2025.04.00 (*)