diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 70193ab13adfe9c04b1565cf001eccde5620b8b0..cf9ae86d135d0fa899d5950e475782ab82db8e15 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -274,7 +274,6 @@ dependencies { implementation(libs.okhttp.base) implementation(libs.okhttp.brotli) implementation(libs.okhttp.logging) - implementation(libs.openid.appauth) implementation(libs.unifiedpush) { // UnifiedPush connector seems to be using a workaround by importing this library. // Will be removed after https://github.com/tink-crypto/tink-java-apps/pull/5 is merged. @@ -288,9 +287,12 @@ dependencies { implementation(libs.commons.lang) // e-Specific dependencies + implementation(libs.appauth) + implementation(libs.android.singlesignon) implementation(libs.androidx.runtime.livedata) implementation(libs.elib) implementation(libs.ez.vcard) + implementation(libs.jackrabbit.webdav) implementation(libs.synctools) { exclude(group="androidx.test") exclude(group = "junit") @@ -298,6 +300,13 @@ dependencies { implementation(libs.ical4j) { exclude(group = "commons-logging", module = "commons-logging") } + implementation(libs.nextcloud.library) { + exclude(group = "org.ogce", module = "xpp3") + } + implementation(libs.commons.httpclient) { + exclude(group = "commons-logging", module = "commons-logging") + } + implementation(libs.okhttp.urlconnection) // for tests androidTestImplementation(libs.androidx.arch.core.testing) diff --git a/app/proguard-rules-release.pro b/app/proguard-rules-release.pro index 0288d08ee55af7e4abac118ae40796faea712462..d392e5af3dad0a46ce69b7de243e206726a71858 100644 --- a/app/proguard-rules-release.pro +++ b/app/proguard-rules-release.pro @@ -24,3 +24,10 @@ -dontwarn sun.net.spi.nameservice.NameService -dontwarn sun.net.spi.nameservice.NameServiceDescriptor -dontwarn org.xbill.DNS.spi.DnsjavaInetAddressResolverProvider + +-dontwarn edu.umd.cs.findbugs.annotations.SuppressFBWarnings + +-keep class com.nextcloud.android.sso.** { *; } +-keep interface com.nextcloud.android.sso.** { *; } +-keep class org.apache.commons.httpclient.** { *; } +-keep interface org.apache.commons.httpclient.** { *; } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/db/ServiceDao.kt b/app/src/main/kotlin/at/bitfire/davdroid/db/ServiceDao.kt index c4938e77b41f23eec3296aa7e5fdaae4877c428b..23d5214af31253cc829c18eb3af6fe1d0976e679 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/db/ServiceDao.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/db/ServiceDao.kt @@ -16,6 +16,9 @@ interface ServiceDao { @Query("SELECT * FROM service WHERE accountName=:accountName AND type=:type") suspend fun getByAccountAndType(accountName: String, @ServiceType type: String): Service? + @Query("SELECT * FROM service WHERE accountName=:accountName") + suspend fun getByAccountName(accountName: String): List + @Query("SELECT * FROM service WHERE accountName=:accountName AND type=:type") fun getByAccountAndTypeFlow(accountName: String, @ServiceType type: String): Flow diff --git a/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClient.kt b/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClient.kt index 71d5057f41f759dd26dbebe827a163a07b3871b7..cd091450b84a03b20a65e6445c0126ccffbf7aa8 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClient.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClient.kt @@ -19,6 +19,8 @@ import at.bitfire.davdroid.settings.SettingsManager import at.bitfire.davdroid.ui.ForegroundTracker import com.google.common.net.HttpHeaders import dagger.hilt.android.qualifiers.ApplicationContext +import foundation.e.accountmanager.network.CookieParser +import foundation.e.accountmanager.network.PersistentCookieStore import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext import net.openid.appauth.AuthState @@ -173,9 +175,12 @@ class HttpClient( accountSettings.credentials() }, updateAuthState = { authState -> - accountSettings.updateAuthState(authState) + accountSettings.updateAuthState(authState, cookieStore) } ) + + cookieStore = PersistentCookieStore.create(context, account, cookieStore) + return this } @@ -312,4 +317,12 @@ class HttpClient( } + fun getCookieAsString(): String { + val cookieJar = okHttpClient.cookieJar + if (cookieJar is CookieParser) { + return cookieJar.cookiesAsString() + } + + return "" + } } \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/network/MemoryCookieStore.kt b/app/src/main/kotlin/at/bitfire/davdroid/network/MemoryCookieStore.kt index 14f9aa6fb0c6e6d086a3bb838ac41625bc7796a6..a58a9f7547de025d13fa48eb102ceb1b75250b9d 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/network/MemoryCookieStore.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/network/MemoryCookieStore.kt @@ -4,7 +4,8 @@ package at.bitfire.davdroid.network -import androidx.annotation.VisibleForTesting +import com.owncloud.android.lib.common.accounts.AccountUtils +import foundation.e.accountmanager.network.CookieParser import okhttp3.Cookie import okhttp3.CookieJar import okhttp3.HttpUrl @@ -14,7 +15,7 @@ import java.util.LinkedList * Primitive cookie store that stores cookies in a (volatile) hash map. * Will be sufficient for session cookies. */ -class MemoryCookieStore : CookieJar { +class MemoryCookieStore : CookieJar, CookieParser { data class StorageKey( val domain: String, @@ -78,4 +79,12 @@ class MemoryCookieStore : CookieJar { return cookies } + override fun cookiesAsString(): String { + if (storage.isEmpty()) { + return "" + } + + return storage.values.joinToString(separator = AccountUtils.Constants.OKHTTP_COOKIE_SEPARATOR) + } + } \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/network/OAuthInterceptor.kt b/app/src/main/kotlin/at/bitfire/davdroid/network/OAuthInterceptor.kt index 7cb1dd64239a89e8eb2760520ebfda5e227dfecb..cd57286ef1a6310f4349c316e5411d5feec9d7b4 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/network/OAuthInterceptor.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/network/OAuthInterceptor.kt @@ -8,6 +8,8 @@ import at.bitfire.davdroid.BuildConfig import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import foundation.e.accountmanager.utils.AccountHelper +import kotlinx.coroutines.runBlocking import net.openid.appauth.AuthState import net.openid.appauth.AuthorizationException import net.openid.appauth.AuthorizationService @@ -69,7 +71,11 @@ class OAuthInterceptor @AssistedInject constructor( // if possible, use cached access token val authState = readAuthState() ?: return null - if (authState.isAuthorized && authState.accessToken != null && !authState.needsTokenRefresh) { + val isValidAccessToken = runBlocking { + !authState.needsTokenRefresh || AccountHelper.isValidAccessToken(authState) + } + + if (authState.isAuthorized && authState.accessToken != null && isValidAccessToken) { if (BuildConfig.DEBUG) // log sensitive information (refresh/access token) only in debug builds logger.log(Level.FINEST, "Using cached AuthState", authState.jsonSerializeString()) return authState.accessToken diff --git a/app/src/main/kotlin/at/bitfire/davdroid/network/UserAgentInterceptor.kt b/app/src/main/kotlin/at/bitfire/davdroid/network/UserAgentInterceptor.kt index 081ed27985c5a59bcfdeec9605842fbe3598034b..ca9ffd467a8ec9c5bf42098e6fe85d793d86b20f 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/network/UserAgentInterceptor.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/network/UserAgentInterceptor.kt @@ -6,6 +6,7 @@ package at.bitfire.davdroid.network import android.os.Build import at.bitfire.davdroid.BuildConfig +import com.owncloud.android.lib.common.OwnCloudClientManagerFactory import okhttp3.Interceptor import okhttp3.OkHttp import okhttp3.Response @@ -18,7 +19,7 @@ object UserAgentInterceptor: Interceptor { "okhttp/${OkHttp.VERSION}) Android/${Build.VERSION.RELEASE}" init { - userAgent = "/e/OS v2 (Android) Nextcloud-android" + userAgent = OwnCloudClientManagerFactory.getNextCloudUserAgent() Logger.getGlobal().info("Will set User-Agent: $userAgent") } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/repository/AccountRepository.kt b/app/src/main/kotlin/at/bitfire/davdroid/repository/AccountRepository.kt index 0420512b99a9f99c861ee90d4d8dfc35ed5fbe66..774fd14b4519776fbc865e7654b667b01fd20702 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/repository/AccountRepository.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/repository/AccountRepository.kt @@ -28,7 +28,7 @@ import at.bitfire.vcard4android.GroupMethod import dagger.Lazy import dagger.hilt.android.qualifiers.ApplicationContext import foundation.e.accountmanager.AccountTypes -import foundation.e.accountmanager.pref.AuthStatePrefUtils +import foundation.e.accountmanager.network.OAuthMurena import foundation.e.accountmanager.utils.AccountHelper import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.awaitClose @@ -75,8 +75,8 @@ class AccountRepository @Inject constructor( val account = fromName(accountName, accountType) // create Android account - val userData = AccountSettings.initialUserData(credentials) - AuthStatePrefUtils.saveAuthState(context, account, credentials?.authState?.jsonSerializeString()) + val initialUserData = AccountSettings.initialUserData(credentials) + val userData = OAuthMurena.onCreateAccount(context, initialUserData, account, credentials, config) logger.log(Level.INFO, "Creating Android account with initial config", arrayOf(account, userData)) if (!SystemAccountUtils.createAccount(context, account, userData, credentials?.password)) diff --git a/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/DavResourceFinder.kt b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/DavResourceFinder.kt index 494d9c11534422a5693417fe023a85be29d704af..74ee7552b9a7a1ecbdf0bce9772bd90ca3eeea33 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/DavResourceFinder.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/DavResourceFinder.kt @@ -148,7 +148,8 @@ class DavResourceFinder @AssistedInject constructor( cardDAV = cardDavConfig, calDAV = calDavConfig, encountered401 = encountered401, - logs = logBuffer.toString() + logs = logBuffer.toString(), + cookies = httpClient.getCookieAsString() ) } @@ -487,7 +488,8 @@ class DavResourceFinder @AssistedInject constructor( val calDAV: ServiceInfo?, val encountered401: Boolean, - val logs: String + val logs: String, + val cookies: String? = null ) { data class ServiceInfo( diff --git a/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/RefreshCollectionsWorker.kt b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/RefreshCollectionsWorker.kt index 1cf93cea8f03021f6e343b5cd341c98050049d3f..267b829c2f602503948aacd2ce44df63962e6a6e 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/RefreshCollectionsWorker.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/RefreshCollectionsWorker.kt @@ -35,10 +35,11 @@ import at.bitfire.davdroid.ui.account.AccountSettingsActivity import dagger.assisted.Assisted import dagger.assisted.AssistedInject import foundation.e.accountmanager.AccountTypes +import foundation.e.accountmanager.ui.setup.ReOAuthActivity +import foundation.e.accountmanager.utils.AccountHelper import kotlinx.coroutines.flow.map import kotlinx.coroutines.runInterruptible import java.io.IOException -import java.io.InterruptedIOException import java.util.logging.Level import java.util.logging.Logger import kotlin.coroutines.cancellation.CancellationException @@ -191,10 +192,17 @@ class RefreshCollectionsWorker @AssistedInject constructor( } catch (e: UnauthorizedException) { logger.log(Level.SEVERE, "Not authorized (anymore)", e) // notify that we need to re-authenticate in the account settings - val settingsIntent = Intent(applicationContext, AccountSettingsActivity::class.java) - .putExtra(AccountSettingsActivity.EXTRA_ACCOUNT, account) + val isOidcAccount = AccountHelper.isOidcAccount(applicationContext, account) + val (settingsIntent, notificationMessage) = if (isOidcAccount) { + Intent(applicationContext, ReOAuthActivity::class.java) to + applicationContext.getString(R.string.sync_error_authentication_oauth) + } else { + Intent(applicationContext, AccountSettingsActivity::class.java) to + applicationContext.getString(R.string.sync_error_authentication_failed) + } + settingsIntent.putExtra(AccountSettingsActivity.EXTRA_ACCOUNT, account) notifyRefreshError( - applicationContext.getString(R.string.sync_error_authentication_failed), + notificationMessage, settingsIntent ) return Result.failure() diff --git a/app/src/main/kotlin/at/bitfire/davdroid/settings/AccountSettings.kt b/app/src/main/kotlin/at/bitfire/davdroid/settings/AccountSettings.kt index b114c024bdcf204a3a0b009572e0b6f3a9e66914..427cf1088be3b073cba62c985e617042523a6af0 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/settings/AccountSettings.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/settings/AccountSettings.kt @@ -19,13 +19,16 @@ import at.bitfire.davdroid.sync.account.InvalidAccountException import at.bitfire.davdroid.sync.account.setAndVerifyUserData import at.bitfire.davdroid.util.trimToNull import at.bitfire.vcard4android.GroupMethod +import com.owncloud.android.lib.common.accounts.AccountUtils import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.qualifiers.ApplicationContext import foundation.e.accountmanager.AccountTypes -import foundation.e.accountmanager.pref.AuthStatePrefUtils +import foundation.e.accountmanager.network.CookieParser +import foundation.e.accountmanager.network.OAuthMurena import net.openid.appauth.AuthState +import okhttp3.CookieJar import java.util.Collections import java.util.logging.Level import java.util.logging.Logger @@ -128,11 +131,39 @@ class AccountSettings @AssistedInject constructor( credentials.authState?.let { authState -> updateAuthState(authState) } + + OAuthMurena.onAccountUpdate(accountManager, account) } - fun updateAuthState(authState: AuthState) { + fun updateAuthState(authState: AuthState, cookie: CookieJar? = null) { accountManager.setAndVerifyUserData(account, KEY_AUTH_STATE, authState.jsonSerializeString()) - AuthStatePrefUtils.saveAuthState(context, account, authState.jsonSerializeString()) + if (cookie is CookieParser) { + OAuthMurena.saveAuthState(context, account, authState, cookie.cookiesAsString()) + } + } + + fun containsPersistentCookie(): Boolean { + return !accountManager.getUserData(account, AccountUtils.Constants.KEY_OKHTTP_COOKIES).isNullOrBlank() + } + + fun noAuthExceptionDetected(): Boolean { + return accountManager.getUserData( + account, + AUTH_EXCEPTION_DETECTED + ).isNullOrBlank() + } + + fun updateAuthExceptionDetectedStatus(detected: Boolean) { + accountManager.setUserData( + account, + AUTH_EXCEPTION_DETECTED, + if (detected) true.toString() else null + ) + } + + fun clearCookie() { + accountManager.setUserData(account, AccountUtils.Constants.KEY_OKHTTP_COOKIES, null) + accountManager.setUserData(account, AccountUtils.Constants.KEY_COOKIES, null) } /** @@ -422,6 +453,8 @@ class AccountSettings @AssistedInject constructor( internal const val SYNC_INTERVAL_MANUALLY = -1L + const val AUTH_EXCEPTION_DETECTED = "auth_exception_detected" + /** Static property to remember which AccountSettings updates/migrations are currently running */ val currentlyUpdating = Collections.synchronizedSet(mutableSetOf()) diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncManager.kt index 722f955a4d3191b82350b91d2f22ed363512d586..2367436a718e80663a695e0fa29756a689b7f869 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncManager.kt @@ -37,9 +37,11 @@ import at.bitfire.davdroid.repository.DavSyncStatsRepository import at.bitfire.davdroid.resource.LocalCollection import at.bitfire.davdroid.resource.LocalResource import at.bitfire.davdroid.resource.SyncState +import at.bitfire.davdroid.settings.AccountSettings import at.bitfire.davdroid.sync.account.InvalidAccountException import at.bitfire.synctools.storage.LocalStorageException import dagger.hilt.android.qualifiers.ApplicationContext +import foundation.e.accountmanager.utils.AccountHelper import foundation.e.accountmanager.utils.SystemUtils import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.coroutineScope @@ -94,6 +96,9 @@ abstract class SyncManager, out CollectionType: L @Inject lateinit var accountRepository: AccountRepository + @Inject + lateinit var accountSettingsFactory: AccountSettings.Factory + @Inject lateinit var collectionRepository: DavCollectionRepository @@ -735,6 +740,8 @@ abstract class SyncManager, out CollectionType: L private fun handleException(e: Throwable, local: LocalResource<*>?, remote: HttpUrl?) { var message: String val isNetworkAvailable = SystemUtils.isNetworkAvailable(context) + val accountSettings = accountSettingsFactory.create(account) + when (e) { is IOException -> { logger.log(Level.WARNING, "I/O error", e) @@ -749,7 +756,17 @@ abstract class SyncManager, out CollectionType: L if (isNetworkAvailable) { syncResult.numAuthExceptions++ } - message = context.getString(R.string.sync_error_authentication_failed) + message = if (AccountHelper.isOidcAccount(context, account)) { + context.getString(R.string.sync_error_authentication_oauth) + } else { + context.getString(R.string.sync_error_authentication_failed) + } + + // persistent session cookie is present. Probably the session is outDated. no need to show the notification + if (accountSettings.containsPersistentCookie() && accountSettings.noAuthExceptionDetected()) { + logger.log(Level.FINE, "Authorization error. Session outDated") + return + } } is HttpException, is DavException -> { diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncNotificationManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncNotificationManager.kt index 398a660aa956f798965f8028226f1796caac1960..dabbb37af65a899a263138840659ac780e0d24fb 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncNotificationManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncNotificationManager.kt @@ -33,6 +33,8 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.qualifiers.ApplicationContext +import foundation.e.accountmanager.ui.setup.ReOAuthActivity +import foundation.e.accountmanager.utils.AccountHelper import okhttp3.HttpUrl import org.dmfs.tasks.contract.TaskContract import java.io.IOException @@ -124,7 +126,11 @@ class SyncNotificationManager @AssistedInject constructor( val contentIntent: Intent var viewItemAction: NotificationCompat.Action? = null if (e is UnauthorizedException) { - contentIntent = Intent(context, AccountSettingsActivity::class.java) + contentIntent = if (AccountHelper.isOidcAccount(context, account)) { + Intent(context, ReOAuthActivity::class.java) + } else { + Intent(context, AccountSettingsActivity::class.java) + } contentIntent.putExtra( AccountSettingsActivity.EXTRA_ACCOUNT, account diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/worker/BaseSyncWorker.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/worker/BaseSyncWorker.kt index 51d0ca2aa35ff2e109a341c5d38dba2f12ec4ca2..86faa32c305aa1df79caa76d9318891401675704 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/worker/BaseSyncWorker.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/worker/BaseSyncWorker.kt @@ -16,6 +16,7 @@ import androidx.work.WorkInfo import androidx.work.WorkManager import androidx.work.WorkerParameters import at.bitfire.davdroid.R +import at.bitfire.davdroid.network.HttpClient import at.bitfire.davdroid.push.PushNotificationManager import at.bitfire.davdroid.settings.AccountSettings import at.bitfire.davdroid.sync.AddressBookSyncer @@ -37,10 +38,18 @@ import at.bitfire.ical4android.TaskProvider import dagger.Lazy import foundation.e.accountmanager.utils.AccountHelper import kotlinx.coroutines.delay +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.HttpUrl +import okhttp3.JavaNetCookieJar +import okhttp3.OkHttpClient +import java.net.CookieManager +import java.net.CookiePolicy import java.util.Collections import java.util.logging.Level import java.util.logging.Logger import javax.inject.Inject +import javax.inject.Provider abstract class BaseSyncWorker( context: Context, @@ -77,6 +86,8 @@ abstract class BaseSyncWorker( @Inject lateinit var taskSyncer: TaskSyncer.Factory + @Inject + lateinit var httpClientBuilder: Provider override suspend fun doWork(): Result { // ensure we got the required arguments @@ -153,6 +164,9 @@ abstract class BaseSyncWorker( val syncResult = SyncResult() + val accountSettings = accountSettingsFactory.create(account) + val isCookiePresent = accountSettings.containsPersistentCookie() + // What are we going to sync? Select syncer based on authority val syncer = when (dataType) { SyncDataType.CONTACTS -> @@ -175,7 +189,7 @@ abstract class BaseSyncWorker( } } SyncDataType.EMAIL -> { - AccountHelper.syncMailAccounts(applicationContext) + AccountHelper.notifyMailAccountAdded(applicationContext) return Result.success() } SyncDataType.MEDIA, @@ -195,6 +209,17 @@ abstract class BaseSyncWorker( if (syncResult.hasError()) { val softErrorNotificationTag = "${account.type}-${account.name}-$dataType" + if (isCookiePresent && syncResult.numAuthExceptions > 0) { + // probably the session is outDated. retry without the sessionCookie + val emptyCookie = JavaNetCookieJar(CookieManager().apply { + setCookiePolicy(CookiePolicy.ACCEPT_ALL) + }) + httpClientBuilder.get().setCookieStore(emptyCookie) + accountSettings.clearCookie() + accountSettings.updateAuthExceptionDetectedStatus(detected = true) + return Result.retry() + } + // On soft errors the sync is retried a few times before considered failed if (syncResult.hasSoftError()) { logger.log(Level.WARNING, "Soft error while syncing", syncResult) @@ -243,6 +268,7 @@ abstract class BaseSyncWorker( } } + accountSettings.updateAuthExceptionDetectedStatus(detected = false) logger.log(Level.INFO, "Sync worker succeeded", syncResult) return Result.success(output.build()) } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountSettingsModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountSettingsModel.kt index 84146d0c8c78c6d08d9e5ac32cab3e7a0f2a837e..154697149d2c1f817821061f06f466fcf46d708b 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountSettingsModel.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountSettingsModel.kt @@ -235,11 +235,10 @@ class AccountSettingsModel @AssistedInject constructor( fun updateCredentials(credentials: Credentials) = CoroutineScope(defaultDispatcher).launch { accountSettings.credentials(credentials) - AccountHelper.syncMailAccounts(context) + AccountHelper.notifyMailAccountAdded(context) reload() } - fun updateTimeRangePastDays(days: Int?) = CoroutineScope(defaultDispatcher).launch { accountSettings.setTimeRangePastDays(days) reload() diff --git a/app/src/main/kotlin/com/nextcloud/android/sso/BinderDependencies.kt b/app/src/main/kotlin/com/nextcloud/android/sso/BinderDependencies.kt new file mode 100644 index 0000000000000000000000000000000000000000..b1f5e057ff5f5dfaf9b2278c34fc1125f855c547 --- /dev/null +++ b/app/src/main/kotlin/com/nextcloud/android/sso/BinderDependencies.kt @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2025 e Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package com.nextcloud.android.sso + +import at.bitfire.davdroid.network.OAuthInterceptor +import at.bitfire.davdroid.settings.AccountSettings +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@EntryPoint +@InstallIn(SingletonComponent::class) +interface BinderDependencies { + fun accountSettingsFactory(): AccountSettings.Factory + fun oAuthInterceptorFactory(): OAuthInterceptor.Factory +} diff --git a/app/src/main/kotlin/com/nextcloud/android/sso/InputStreamBinder.kt b/app/src/main/kotlin/com/nextcloud/android/sso/InputStreamBinder.kt new file mode 100644 index 0000000000000000000000000000000000000000..a2e6d7a64bde884c653fbb6e7bbbd826d6d55056 --- /dev/null +++ b/app/src/main/kotlin/com/nextcloud/android/sso/InputStreamBinder.kt @@ -0,0 +1,426 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 David Luhmer + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + * + * More information here: https://github.com/abeluck/android-streams-ipc + */ +package com.nextcloud.android.sso + +import android.accounts.Account +import android.accounts.AccountManager +import android.content.Context +import android.net.Uri +import android.os.ParcelFileDescriptor +import android.text.TextUtils +import at.bitfire.davdroid.network.OAuthInterceptor +import at.bitfire.davdroid.settings.AccountSettings +import com.nextcloud.android.sso.aidl.IInputStreamService +import com.nextcloud.android.sso.aidl.NextcloudRequest +import com.nextcloud.android.sso.aidl.ParcelFileDescriptorUtil +import com.nextcloud.android.utils.EncryptionUtils.generateSHA512 +import com.owncloud.android.lib.common.OwnCloudAccount +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.OwnCloudClientManagerFactory +import com.owncloud.android.lib.common.operations.RemoteOperation +import dagger.hilt.android.EntryPointAccessors +import foundation.e.accountmanager.AccountTypes +import foundation.e.accountmanager.utils.AccountHelper +import kotlinx.coroutines.runBlocking +import org.apache.commons.httpclient.HttpConnection +import org.apache.commons.httpclient.HttpMethodBase +import org.apache.commons.httpclient.HttpState +import org.apache.commons.httpclient.NameValuePair +import org.apache.commons.httpclient.methods.DeleteMethod +import org.apache.commons.httpclient.methods.GetMethod +import org.apache.commons.httpclient.methods.HeadMethod +import org.apache.commons.httpclient.methods.InputStreamRequestEntity +import org.apache.commons.httpclient.methods.PostMethod +import org.apache.commons.httpclient.methods.PutMethod +import org.apache.commons.httpclient.methods.RequestEntity +import org.apache.commons.httpclient.methods.StringRequestEntity +import org.apache.jackrabbit.webdav.DavConstants +import org.apache.jackrabbit.webdav.client.methods.MkColMethod +import org.apache.jackrabbit.webdav.client.methods.PropFindMethod +import org.apache.jackrabbit.webdav.property.DavPropertyNameSet +import org.jetbrains.annotations.Blocking +import java.io.BufferedReader +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.InputStream +import java.io.InputStreamReader +import java.io.ObjectInputStream +import java.io.ObjectOutputStream +import java.io.SequenceInputStream +import java.io.Serializable +import java.util.logging.Level +import java.util.logging.Logger + + +/** + * Stream binder to pass usable InputStreams across the process boundary in Android. + */ +class InputStreamBinder(private val context: Context) : IInputStreamService.Stub() { + private val logger: Logger = Logger.getLogger(this.javaClass.name) + + private val accountSettingsFactory: AccountSettings.Factory by lazy { + EntryPointAccessors.fromApplication( + context.applicationContext, + BinderDependencies::class.java + ).accountSettingsFactory() + } + + private val oAuthInterceptorFactory: OAuthInterceptor.Factory by lazy { + EntryPointAccessors.fromApplication( + context.applicationContext, + BinderDependencies::class.java + ).oAuthInterceptorFactory() + } + + @Blocking + fun refreshToken(account: Account) = runBlocking { + val accountSettings = accountSettingsFactory.create(account) + + val credentials = accountSettings.credentials() + credentials.authState ?: run { + logger.log(Level.FINE, "No AuthState for $account") + return@runBlocking + } + + val interceptor = oAuthInterceptorFactory.create( + readAuthState = { credentials.authState }, + writeAuthState = { newAuthState -> + accountSettings.credentials(credentials.copy(authState = newAuthState)) + } + ) + + try { + val token = interceptor.provideAccessToken() + logger.log(Level.FINE, "Token refreshed: $token") + } catch (t: Throwable) { + logger.warning("Couldn't update AuthState for account: $account — ${t.message}") + } + } + + override fun performNextcloudRequestV2(input: ParcelFileDescriptor?): ParcelFileDescriptor? { + return performNextcloudRequestAndBodyStreamV2(input, null) + } + + override fun performNextcloudRequestAndBodyStreamV2( + input: ParcelFileDescriptor?, + requestBodyParcelFileDescriptor: ParcelFileDescriptor? + ): ParcelFileDescriptor? { + // read the input + val `is`: InputStream = ParcelFileDescriptor.AutoCloseInputStream(input) + + val requestBodyInputStream: InputStream? = if (requestBodyParcelFileDescriptor != null) ParcelFileDescriptor.AutoCloseInputStream(requestBodyParcelFileDescriptor) else null + var exception: Exception? = null + var response = Response() + + try { + // Start request and catch exceptions + val request = deserializeObjectAndCloseStream(`is`) + response = processRequestV2(request, requestBodyInputStream) + } catch (e: Exception) { + logger.log(Level.SEVERE, "Error during Nextcloud request", e) + exception = e + } + + try { + // Write exception to the stream followed by the actual network stream + val exceptionStream: InputStream = serializeObjectToInputStreamV2(exception, response.plainHeadersString()) + val resultStream: InputStream = SequenceInputStream(exceptionStream, response.body()) + + return ParcelFileDescriptorUtil.pipeFrom( + resultStream + ) { thread: Thread? -> logger.log(Level.WARNING, "Done sending result") } + } catch (e: IOException) { + logger.log(Level.SEVERE, "Error while sending response back to client app", e) + } + return null + } + + override fun performNextcloudRequest(input: ParcelFileDescriptor?): ParcelFileDescriptor? { + return performNextcloudRequestAndBodyStreamV2(input, null) + } + + override fun performNextcloudRequestAndBodyStream( + input: ParcelFileDescriptor?, + requestBodyParcelFileDescriptor: ParcelFileDescriptor? + ): ParcelFileDescriptor? { + return performNextcloudRequestAndBodyStreamV2(input, requestBodyParcelFileDescriptor) + } + + private fun serializeObjectToInputStreamV2(exception: Exception?, headers: String?): ByteArrayInputStream { + var baosByteArray = ByteArray(0) + try { + ByteArrayOutputStream().use { baos -> + ObjectOutputStream(baos).use { oos -> + oos.writeObject(exception) + oos.writeObject(headers) + oos.flush() + oos.close() + baosByteArray = baos.toByteArray() + } + } + } catch (e: IOException) { + logger.log(Level.SEVERE, "Error while sending response back to client app", e) + } + + return ByteArrayInputStream(baosByteArray) + } + + private fun deserializeObjectAndCloseStream(isInput: InputStream): T { + ObjectInputStream(isInput).use { ois -> + @Suppress("UNCHECKED_CAST") + return ois.readObject() as T + } + } + + class NCPropFindMethod internal constructor(uri: String?, propfindType: Int, depth: Int) : PropFindMethod(uri, propfindType, DavPropertyNameSet(), depth) { + override fun processResponseBody(httpState: HttpState?, httpConnection: HttpConnection?) { + // Do not process the response body here. Instead pass it on to client app. + } + } + + private fun buildMethod(request: NextcloudRequest, baseUri: Uri?, requestBodyInputStream: InputStream?): HttpMethodBase { + val requestUrl = baseUri.toString() + request.url + val method: HttpMethodBase + when (request.method) { + "GET" -> method = GetMethod(requestUrl) + "POST" -> { + method = PostMethod(requestUrl) + if (requestBodyInputStream != null) { + val requestEntity: RequestEntity = InputStreamRequestEntity(requestBodyInputStream) + method.setRequestEntity(requestEntity) + } else if (request.requestBody != null) { + val requestEntity = StringRequestEntity( + request.requestBody, + CONTENT_TYPE_APPLICATION_JSON, + CHARSET_UTF8 + ) + method.setRequestEntity(requestEntity) + } + } + + "PATCH" -> { + method = PatchMethod(requestUrl) + if (requestBodyInputStream != null) { + val requestEntity: RequestEntity = InputStreamRequestEntity(requestBodyInputStream) + method.setRequestEntity(requestEntity) + } else if (request.requestBody != null) { + val requestEntity = StringRequestEntity( + request.requestBody, + CONTENT_TYPE_APPLICATION_JSON, + CHARSET_UTF8 + ) + method.setRequestEntity(requestEntity) + } + } + + "PUT" -> { + method = PutMethod(requestUrl) + if (requestBodyInputStream != null) { + val requestEntity: RequestEntity = InputStreamRequestEntity(requestBodyInputStream) + method.setRequestEntity(requestEntity) + } else if (request.requestBody != null) { + val requestEntity = StringRequestEntity( + request.requestBody, + CONTENT_TYPE_APPLICATION_JSON, + CHARSET_UTF8 + ) + method.setRequestEntity(requestEntity) + } + } + + "DELETE" -> method = DeleteMethod(requestUrl) + "PROPFIND" -> { + method = NCPropFindMethod(requestUrl, DavConstants.PROPFIND_ALL_PROP, DavConstants.DEPTH_1) + if (request.requestBody != null) { + //text/xml; charset=UTF-8 is taken from XmlRequestEntity... Should be application/xml + val requestEntity = StringRequestEntity( + request.requestBody, + "text/xml; charset=UTF-8", + CHARSET_UTF8 + ) + (method as PropFindMethod).setRequestEntity(requestEntity) + } + } + + "MKCOL" -> method = MkColMethod(requestUrl) + "HEAD" -> method = HeadMethod(requestUrl) + else -> throw UnsupportedOperationException(Constants.EXCEPTION_UNSUPPORTED_METHOD) + + } + return method + } + + /* + * for non ocs/dav requests (nextcloud app: ex: notes app), when OIDC is used, we need to pass an special header. + * We should not pass this header for ocs/dav requests as it can cause session cookie not being used for those request. + * + * These nextcloud app request paths contain `/index.php/apps/` on them. + */ + private fun shouldAddHeaderForOidcLogin(account: Account, path: String): Boolean { + if (!AccountHelper.isOidcAccount(context, account)) { + return false + } + + return path.contains("/index.php/apps/") + } + + private fun processRequestV2(request: NextcloudRequest, requestBodyInputStream: InputStream?): Response { + val account = AccountHelper.getAccountByName(context, request.accountName) + checkNotNull(account) { Constants.EXCEPTION_ACCOUNT_NOT_FOUND } + + // Validate token + check(isValid(request)) { Constants.EXCEPTION_INVALID_TOKEN } + + // Validate URL + if (request.url.isEmpty() || request.url.firstOrNull() != PATH_SEPARATOR) { + throw IllegalStateException( + Constants.EXCEPTION_INVALID_REQUEST_URL, + IllegalStateException("URL need to start with a /") + ) + } + + if (AccountHelper.isOidcAccount(context, account)) { + // Blocking call + refreshToken(account) + } + + val ownCloudClientManager = OwnCloudClientManagerFactory.getDefaultSingleton() + val ocAccount = OwnCloudAccount(account, context) + val client = ownCloudClientManager.getClientFor(ocAccount, context, OwnCloudClient.DONT_USE_COOKIES) + client.connectionTimeout = 60000 + + val method = buildMethod(request, client.baseUri, requestBodyInputStream) + method.setQueryString(convertListToNVP(request.parameterV2)) + + for (header in request.header.entries) { + // https://stackoverflow.com/a/3097052 + method.addRequestHeader(header.key, TextUtils.join(",", header.value)) + } + + method.setRequestHeader( + RemoteOperation.OCS_API_HEADER, + RemoteOperation.OCS_API_HEADER_VALUE + ) + + if (shouldAddHeaderForOidcLogin(account, request.url)) { + method.setRequestHeader( + RemoteOperation.OIDC_LOGIN_WITH_TOKEN, + RemoteOperation.OIDC_LOGIN_WITH_TOKEN_VALUE + ) + } + + client.isFollowRedirects = request.isFollowRedirects + val status = client.executeMethod(method) + + ownCloudClientManager.saveAllClients(context, account.type) + + // Check if status code is 2xx --> https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#2xx_Success + if (status >= HTTP_STATUS_CODE_OK && status < HTTP_STATUS_CODE_MULTIPLE_CHOICES) { + return Response(method) + } else { + val inputStream = method.getResponseBodyAsStream() + var total: String? = "No response body" + + // If response body is available + if (inputStream != null) { + total = inputStreamToString(inputStream) + logger.log(Level.WARNING, total) + } + + method.releaseConnection() + throw IllegalStateException( + Constants.EXCEPTION_HTTP_REQUEST_FAILED, + IllegalStateException( + status.toString(), + IllegalStateException(total) + ) + ) + } + } + + private fun isValid(request: NextcloudRequest): Boolean { + val callingPackageNames = context.packageManager.getPackagesForUid(getCallingUid()) + + val sharedPreferences = context.getSharedPreferences( + Constants.SSO_SHARED_PREFERENCE, + Context.MODE_PRIVATE + ) + checkNotNull(callingPackageNames) + for (callingPackageName in callingPackageNames) { + val hash: String = sharedPreferences.getString(callingPackageName + DELIMITER + request.accountName, "")!! + if (hash.isEmpty()) continue + if (validateToken(hash, request.token)) { + return true + } + } + return false + } + + private fun validateToken(hash: String, token: String): Boolean { + check(hash.contains("$")) { Constants.EXCEPTION_INVALID_TOKEN } + + val salt = hash.split("\\$".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()[1] // TODO extract "$" + + val newHash = generateSHA512(token, salt) + + // As discussed with Lukas R. at the Nextcloud Conf 2018, always compare whole strings + // and don't exit prematurely if the string does not match anymore to prevent timing-attacks + return isEqual(hash.toByteArray(), newHash.toByteArray()) + } + + // Taken from http://codahale.com/a-lesson-in-timing-attacks/ + private fun isEqual(a: ByteArray, b: ByteArray): Boolean { + if (a.size != b.size) { + return false + } + + var result = 0 + for (i in a.indices) { + result = result or (a[i].toInt() xor b[i].toInt()) + } + return result == 0 + } + + private fun inputStreamToString(inputStream: InputStream?): String? { + try { + val total = StringBuilder() + val reader = BufferedReader(InputStreamReader(inputStream)) + var line = reader.readLine() + while (line != null) { + total.append(line).append('\n') + line = reader.readLine() + } + return total.toString() + } catch (e: Exception) { + return e.message + } + } + + fun convertListToNVP(list: MutableCollection): Array { + val nvp = arrayOfNulls(list.size) + var i = 0 + for (pair in list) { + nvp[i] = NameValuePair(pair.key, pair.value) + i++ + } + return nvp + } + + companion object { + private const val CONTENT_TYPE_APPLICATION_JSON = "application/json" + private const val CHARSET_UTF8 = "UTF-8" + + private const val HTTP_STATUS_CODE_OK = 200 + private const val HTTP_STATUS_CODE_MULTIPLE_CHOICES = 300 + + private const val PATH_SEPARATOR = '/' + const val DELIMITER: String = "_" + } +} diff --git a/app/src/main/kotlin/com/nextcloud/android/sso/PatchMethod.kt b/app/src/main/kotlin/com/nextcloud/android/sso/PatchMethod.kt new file mode 100644 index 0000000000000000000000000000000000000000..b8a8903493242713d03faf3f22501000fb16e4a9 --- /dev/null +++ b/app/src/main/kotlin/com/nextcloud/android/sso/PatchMethod.kt @@ -0,0 +1,49 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2021 Timo Triebensky + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + * + * More information here: https://github.com/abeluck/android-streams-ipc + */ +package com.nextcloud.android.sso + +import org.apache.commons.httpclient.methods.ByteArrayRequestEntity +import org.apache.commons.httpclient.methods.PostMethod +import org.apache.commons.httpclient.methods.RequestEntity +import org.apache.commons.httpclient.util.EncodingUtil +import java.util.Vector + +class PatchMethod : PostMethod { + private val params = Vector() + + constructor() : super() + constructor(uri: String?) : super(uri) + + /** Always returns "PATCH". */ + override fun getName(): String = "PATCH" + + /** True if request body exists. */ + override fun hasRequestContent(): Boolean = + params.isNotEmpty() || super.hasRequestContent() + + /** Clears request body. */ + override fun clearRequestBody() { + params.clear() + super.clearRequestBody() + } + + /** Creates request entity from params or defaults to super. */ + override fun generateRequestEntity(): RequestEntity? { + return if (params.isNotEmpty()) { + val content = EncodingUtil.formUrlEncode(getParameters(), requestCharSet) + ByteArrayRequestEntity( + EncodingUtil.getAsciiBytes(content), + FORM_URL_ENCODED_CONTENT_TYPE + ) + } else { + super.generateRequestEntity() + } + } +} diff --git a/app/src/main/kotlin/com/nextcloud/android/sso/Response.kt b/app/src/main/kotlin/com/nextcloud/android/sso/Response.kt new file mode 100644 index 0000000000000000000000000000000000000000..7569d888c9dcae1c37d64f1ff75be92313a4a3d6 --- /dev/null +++ b/app/src/main/kotlin/com/nextcloud/android/sso/Response.kt @@ -0,0 +1,44 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2019 Tobias Kaminsky + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.nextcloud.android.sso + +import org.apache.commons.httpclient.Header +import org.apache.commons.httpclient.HttpMethodBase +import org.json.JSONArray +import org.json.JSONObject +import java.io.ByteArrayInputStream +import java.io.InputStream + +class Response { + + private val body: InputStream + private val headers: Array
+ private val method: HttpMethodBase? + + constructor() { + headers = emptyArray() + body = ByteArrayInputStream(ByteArray(0)) // empty stream + method = null + } + + constructor(methodBase: HttpMethodBase) { + method = methodBase + body = methodBase.responseBodyAsStream ?: ByteArrayInputStream(ByteArray(0)) + headers = methodBase.responseHeaders + } + + /** Returns headers as JSON string, or "[]" if invalid. */ + fun plainHeadersString(): String { + if (headers.any { it.name == null || it.value == null }) return "[]" + return JSONArray(headers.map { JSONObject().put("name", it.name).put("value", it.value) }) + .toString() + } + + fun body(): InputStream = body + fun method(): HttpMethodBase? = method +} diff --git a/app/src/main/kotlin/com/nextcloud/android/utils/EncryptionUtils.kt b/app/src/main/kotlin/com/nextcloud/android/utils/EncryptionUtils.kt new file mode 100644 index 0000000000000000000000000000000000000000..d75564f084eb572e1f605d2bd34c5edf0a55745f --- /dev/null +++ b/app/src/main/kotlin/com/nextcloud/android/utils/EncryptionUtils.kt @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2025 e Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package com.nextcloud.android.utils + +import android.util.Base64 +import java.security.MessageDigest +import java.security.NoSuchAlgorithmException +import java.security.SecureRandom +import java.util.logging.Level +import java.util.logging.Logger + +object EncryptionUtils { + const val SALT_LENGTH: Int = 40 + + private const val HASH_DELIMITER = "$" + + private val logger: Logger = Logger.getLogger(EncryptionUtils::class.java.getName()) + + /** + * Generate a SHA512 with appended salt + * + * @param token token to be hashed + * @return SHA512 with appended salt, delimiter HASH_DELIMITER + */ + fun generateSHA512(token: String): String { + val salt = encodeBytesToBase64String(randomBytes(SALT_LENGTH)) + + return generateSHA512(token, salt) + } + + /** + * Generate a SHA512 with appended salt + * + * @param token token to be hashed + * @return SHA512 with appended salt, delimiter HASH_DELIMITER + */ + @JvmStatic + fun generateSHA512(token: String, salt: String): String { + val digest: MessageDigest? + var hashedToken = "" + val hash: ByteArray? + try { + digest = MessageDigest.getInstance("SHA-512") + digest.update(salt.toByteArray()) + hash = digest.digest(token.toByteArray()) + + val stringBuilder = StringBuilder() + for (hashByte in hash) { + stringBuilder.append(((hashByte.toInt() and 0xff) + 0x100).toString(16).substring(1)) + } + + stringBuilder.append(HASH_DELIMITER).append(salt) + + hashedToken = stringBuilder.toString() + } catch (e: NoSuchAlgorithmException) { + logger.log(Level.SEVERE, "Generating SHA512 failed", e) + } + + return hashedToken + } + + fun encodeBytesToBase64String(bytes: ByteArray?): String { + return Base64.encodeToString(bytes, Base64.NO_WRAP) + } + + fun randomBytes(size: Int): ByteArray { + val random = SecureRandom() + val iv = ByteArray(size) + random.nextBytes(iv) + + return iv + } +} diff --git a/app/src/main/kotlin/com/owncloud/android/services/AccountManagerService.kt b/app/src/main/kotlin/com/owncloud/android/services/AccountManagerService.kt new file mode 100644 index 0000000000000000000000000000000000000000..1d423e0714846d20d82eb4ffbae557e5d86b6c11 --- /dev/null +++ b/app/src/main/kotlin/com/owncloud/android/services/AccountManagerService.kt @@ -0,0 +1,35 @@ +/* + * Copyright MURENA SAS 2024 + * Copyright (C) 2025 e Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package com.owncloud.android.services + +import android.app.Service +import android.content.Intent +import android.os.IBinder +import com.nextcloud.android.sso.InputStreamBinder + +class AccountManagerService : Service() { + private var binder: InputStreamBinder? = null + + override fun onBind(intent: Intent?): IBinder? { + if (binder == null) { + binder = InputStreamBinder(applicationContext) + } + return binder + } +} diff --git a/app/src/main/kotlin/com/owncloud/android/ui/activity/SsoGrantPermissionActivity.kt b/app/src/main/kotlin/com/owncloud/android/ui/activity/SsoGrantPermissionActivity.kt new file mode 100644 index 0000000000000000000000000000000000000000..07f4f4b7f48c48ff76ca095c80b3b60035740219 --- /dev/null +++ b/app/src/main/kotlin/com/owncloud/android/ui/activity/SsoGrantPermissionActivity.kt @@ -0,0 +1,113 @@ +/* + * Copyright MURENA SAS 2024 + * Copyright (C) 2025 e Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package com.owncloud.android.ui.activity + +import android.accounts.Account +import android.content.Intent +import android.os.Build +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.lifecycle.flowWithLifecycle +import androidx.lifecycle.lifecycleScope +import com.nextcloud.android.sso.Constants +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +@AndroidEntryPoint +class SsoGrantPermissionActivity : AppCompatActivity() { + + private val viewModel: SsoGrantPermissionViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.White), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + + observePermissionEvents() + validateAccount() + } + + private fun observePermissionEvents() { + lifecycleScope.launch { + viewModel.permissionEvent + .flowWithLifecycle( + lifecycle = lifecycle, + minActiveState = androidx.lifecycle.Lifecycle.State.CREATED + ) + .collectLatest { event -> + when (event) { + is SsoGrantPermissionViewModel.SsoGrantPermissionEvent.PermissionGranted -> { + setSuccessResult(event.bundle) + } + is SsoGrantPermissionViewModel.SsoGrantPermissionEvent.PermissionDenied -> { + setCanceledResult(event.errorMessage) + } + } + } + } + } + + private fun validateAccount() { + val account: Account? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(Constants.NEXTCLOUD_FILES_ACCOUNT, Account::class.java) + } else { + intent.getParcelableExtra(Constants.NEXTCLOUD_FILES_ACCOUNT) + } + + viewModel.initValidation( + callingActivity = callingActivity, + account = account + ) + } + + private fun setCanceledResult(exception: String) { + val data = Intent().apply { + putExtra(Constants.NEXTCLOUD_SSO_EXCEPTION, exception) + } + setResult(RESULT_CANCELED, data) + finish() + } + + private fun setSuccessResult(result: Bundle) { + val data = Intent().apply { + putExtra(Constants.NEXTCLOUD_SSO, result) + } + setResult(RESULT_OK, data) + finish() + } +} diff --git a/app/src/main/kotlin/com/owncloud/android/ui/activity/SsoGrantPermissionViewModel.kt b/app/src/main/kotlin/com/owncloud/android/ui/activity/SsoGrantPermissionViewModel.kt new file mode 100644 index 0000000000000000000000000000000000000000..940ef1cc415fdb51a5d5667bd7dcb70ca0335121 --- /dev/null +++ b/app/src/main/kotlin/com/owncloud/android/ui/activity/SsoGrantPermissionViewModel.kt @@ -0,0 +1,161 @@ +/* + * Copyright MURENA SAS 2024 + * Copyright (C) 2025 e Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package com.owncloud.android.ui.activity + +import android.accounts.Account +import android.accounts.AccountManager +import android.content.ComponentName +import android.content.Context +import android.os.Bundle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import at.bitfire.davdroid.db.AppDatabase +import com.nextcloud.android.utils.EncryptionUtils +import com.owncloud.android.lib.common.OwnCloudAccount +import com.owncloud.android.lib.common.accounts.AccountUtils +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import java.util.UUID +import java.util.logging.Level +import java.util.logging.Logger +import javax.inject.Inject +import androidx.core.content.edit +import com.nextcloud.android.sso.Constants +import com.nextcloud.android.sso.InputStreamBinder +import foundation.e.accountmanager.AccountTypes +import foundation.e.accountmanager.utils.UserIdFetcher + +@HiltViewModel +class SsoGrantPermissionViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val database: AppDatabase, + private val logger: Logger +) : ViewModel() { + + sealed class SsoGrantPermissionEvent { + data class PermissionGranted(val bundle: Bundle) : SsoGrantPermissionEvent() + data class PermissionDenied(val errorMessage: String) : SsoGrantPermissionEvent() + } + + private val acceptedAccountTypes = listOf(AccountTypes.Murena.accountType) + private val acceptedPackages = listOf("foundation.e.notes") + + private val _permissionEvent = MutableSharedFlow() + val permissionEvent = _permissionEvent.asSharedFlow() + + fun initValidation(callingActivity: ComponentName?, account: Account?) { + viewModelScope.launch(Dispatchers.IO) { + val packageName = getCallingPackageName(callingActivity) ?: return@launch + validate(packageName, account) + } + } + + private suspend fun emitPermissionDeniedEvent(message: String) { + _permissionEvent.emit(SsoGrantPermissionEvent.PermissionDenied(message)) + } + + private suspend fun getCallingPackageName(callingActivity: ComponentName?): String? { + return callingActivity?.packageName ?: run { + logger.log(Level.SEVERE, "SsoGrantPermissionViewModel: Calling Package is null") + emitPermissionDeniedEvent(Constants.EXCEPTION_ACCOUNT_ACCESS_DECLINED) + null + } + } + + private suspend fun validate(packageName: String?, account: Account?) { + if (!isValidRequest(packageName, account)) { + logger.log(Level.SEVERE, "SsoGrantPermissionViewModel: Invalid request") + emitPermissionDeniedEvent(Constants.EXCEPTION_ACCOUNT_ACCESS_DECLINED) + return + } + + val serverUrl = getServerUrl(account!!) ?: return + + val token = UUID.randomUUID().toString().replace("-", "") + val userId = getUserId(account) + + saveToken(token, account.name, packageName!!) + passSuccessfulData(account, token, userId, serverUrl) + } + + private fun isValidRequest(packageName: String?, account: Account?): Boolean { + return packageName != null && account != null && + acceptedPackages.contains(packageName) && + acceptedAccountTypes.contains(account.type) + } + + private suspend fun getServerUrl(account: Account): String? { + return try { + val ocAccount = OwnCloudAccount(account, context) + ocAccount.baseUri.toString() + } catch (e: AccountUtils.AccountNotFoundException) { + logger.log(Level.SEVERE, "SsoGrantPermissionViewModel: Account not found", e) + emitPermissionDeniedEvent(Constants.EXCEPTION_ACCOUNT_NOT_FOUND) + null + } + } + + private suspend fun getUserId(account: Account): String { + val accountManager = AccountManager.get(context) + accountManager.getUserData(account, AccountUtils.Constants.KEY_USER_ID) + ?.takeIf { it.isNotBlank() } + ?.let { return it } + + val principalUrl = database.serviceDao() + .getByAccountName(account.name) + .firstOrNull() + ?.principal + ?.toString() + + return principalUrl?.let { UserIdFetcher.fetch(it) } ?: account.name + } + + private fun saveToken(token: String, accountName: String, packageName: String) { + val hashedToken = EncryptionUtils.generateSHA512(token) + val sharedPreferences = context.getSharedPreferences( + Constants.SSO_SHARED_PREFERENCE, + Context.MODE_PRIVATE + ) + sharedPreferences.edit { + putString("$packageName${InputStreamBinder.DELIMITER}$accountName", hashedToken) + } + } + + private suspend fun passSuccessfulData( + account: Account, + token: String, + userId: String, + serverUrl: String + ) { + val result = Bundle().apply { + putString(AccountManager.KEY_ACCOUNT_NAME, account.name) + putString(AccountManager.KEY_ACCOUNT_TYPE, account.type) + putString(AccountManager.KEY_AUTHTOKEN, Constants.NEXTCLOUD_SSO) + putString(Constants.SSO_USER_ID, userId) + putString(Constants.SSO_TOKEN, token) + putString(Constants.SSO_SERVER_URL, serverUrl) + } + + _permissionEvent.emit(SsoGrantPermissionEvent.PermissionGranted(result)) + } +} diff --git a/app/src/main/kotlin/foundation/e/accountmanager/auth/AccountReceiver.kt b/app/src/main/kotlin/foundation/e/accountmanager/auth/AccountReceiver.kt index 8603d6117617a6e81911733ba3ee9264130a6882..b5f8c6682112985b5e97b54ff52e1d3eb65d8f40 100644 --- a/app/src/main/kotlin/foundation/e/accountmanager/auth/AccountReceiver.kt +++ b/app/src/main/kotlin/foundation/e/accountmanager/auth/AccountReceiver.kt @@ -23,7 +23,6 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import at.bitfire.davdroid.settings.AccountSettings -import at.bitfire.davdroid.settings.AccountSettings.Companion.KEY_AUTH_STATE import foundation.e.accountmanager.AccountTypes import foundation.e.accountmanager.pref.AuthStatePrefUtils import foundation.e.accountmanager.ui.setup.MurenaLogoutActivity @@ -52,8 +51,9 @@ class AccountReceiver : BroadcastReceiver() { murenaAccounts.forEach { account -> logger.log(Level.INFO, "Account change detected for ${account.name}") - val authState = accountManager.getUserData(account, KEY_AUTH_STATE) + val authState = accountManager.getUserData(account, AccountSettings.KEY_AUTH_STATE) AuthStatePrefUtils.saveAuthState(context, account, authState) + AccountHelper.notifyEApps(context, account.name) } } @@ -71,8 +71,7 @@ class AccountReceiver : BroadcastReceiver() { clearOidcSession(context, accountName, accountType) val accountManager = AccountManager.get(context) - val addressBooks = accountManager.getAccountsByType(AccountTypes.Murena.addressBookType) - addressBooks.forEach { + accountManager.getAccountsByType(AccountTypes.Murena.addressBookType).forEach { accountManager.removeAccountExplicitly(it) } } @@ -102,6 +101,10 @@ class AccountReceiver : BroadcastReceiver() { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) - pendingIntent.sendWithBackgroundLaunchAllowed() + try { + pendingIntent.sendWithBackgroundLaunchAllowed() + } catch (e: PendingIntent.CanceledException) { + logger.log(Level.SEVERE, "Failed to start logout activity", e) + } } } diff --git a/app/src/main/kotlin/foundation/e/accountmanager/network/CookieParser.kt b/app/src/main/kotlin/foundation/e/accountmanager/network/CookieParser.kt new file mode 100644 index 0000000000000000000000000000000000000000..8bb9905231405a137efa6f06a902633e9b0744cd --- /dev/null +++ b/app/src/main/kotlin/foundation/e/accountmanager/network/CookieParser.kt @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2025 e Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package foundation.e.accountmanager.network + +interface CookieParser { + fun cookiesAsString(): String +} diff --git a/app/src/main/kotlin/foundation/e/accountmanager/network/OAuthMurena.kt b/app/src/main/kotlin/foundation/e/accountmanager/network/OAuthMurena.kt index eb6273e1babcddb2b6574f152699b19f3188f76d..a42de3b2f413d4802b135ed5c97859d0c1f911fd 100644 --- a/app/src/main/kotlin/foundation/e/accountmanager/network/OAuthMurena.kt +++ b/app/src/main/kotlin/foundation/e/accountmanager/network/OAuthMurena.kt @@ -17,22 +17,34 @@ */ package foundation.e.accountmanager.network +import android.accounts.Account +import android.accounts.AccountManager +import android.content.Context import android.net.Uri +import android.os.Bundle import androidx.core.net.toUri import at.bitfire.davdroid.BuildConfig +import at.bitfire.davdroid.servicedetection.DavResourceFinder +import at.bitfire.davdroid.settings.Credentials +import at.bitfire.davdroid.sync.account.setAndVerifyUserData +import com.owncloud.android.lib.common.accounts.AccountUtils +import foundation.e.accountmanager.AccountTypes +import foundation.e.accountmanager.pref.AuthStatePrefUtils import net.openid.appauth.AuthState import net.openid.appauth.AuthorizationRequest import net.openid.appauth.AuthorizationServiceConfiguration import net.openid.appauth.EndSessionRequest import net.openid.appauth.ResponseTypeValues import java.net.URI +import java.util.logging.Level +import java.util.logging.Logger import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine object OAuthMurena { private val SCOPES = arrayOf("openid", "profile", "email", "offline_access") - const val CLIENT_ID = BuildConfig.MURENA_CLIENT_ID + private const val CLIENT_ID = BuildConfig.MURENA_CLIENT_ID private val DOMAIN: String by lazy { URI(BuildConfig.MURENA_BASE_URL).host } val baseUri = "https://$DOMAIN" @@ -40,6 +52,8 @@ object OAuthMurena { val logoutRedirectUri = "${BuildConfig.MURENA_LOGOUT_REDIRECT_URI}:/redirect".toUri() val discoveryUri = BuildConfig.MURENA_DISCOVERY_END_POINT.toUri() + val logger: Logger = Logger.getLogger(this.javaClass.name) + suspend fun fetchOAuthConfigSuspend(discoveryUrl: Uri): AuthorizationServiceConfiguration = suspendCoroutine { cont -> AuthorizationServiceConfiguration.fetchFromUrl(discoveryUrl) { config, ex -> @@ -70,4 +84,37 @@ object OAuthMurena { authState.idToken?.let { setIdTokenHint(it) } }.build() } + + fun onCreateAccount(context: Context, userData: Bundle, account: Account, credentials: Credentials?, config: DavResourceFinder.Configuration): Bundle { + if (account.type != AccountTypes.Murena.accountType) return userData + + saveAuthState(context, account, credentials?.authState, config.cookies) + userData.putString(AccountUtils.Constants.KEY_OC_BASE_URL, baseUri) + userData.putString(AccountUtils.Constants.KEY_DISPLAY_NAME, account.name.substringBefore('@')) + config.cookies?.takeIf { it.isNotEmpty() }?.let { cookies -> + userData.putString(AccountUtils.Constants.KEY_OKHTTP_COOKIES, cookies) + } + + return userData + } + + fun onAccountUpdate(accountManager: AccountManager, account: Account) { + if (account.type != AccountTypes.Murena.accountType) return + + accountManager.setAndVerifyUserData(account, AccountUtils.Constants.KEY_OC_BASE_URL, baseUri) + accountManager.setAndVerifyUserData(account, + AccountUtils.Constants.KEY_DISPLAY_NAME, account.name.substringBefore('@')) + } + + fun saveAuthState(context: Context, account: Account, authState: AuthState?, cookie: String? = null) { + val stateJson = authState?.jsonSerializeString() + AuthStatePrefUtils.saveAuthState(context, account, stateJson) + if (BuildConfig.DEBUG) { + logger.log(Level.INFO, "Saved new authState $stateJson") + } + val accountManager = AccountManager.get(context) + if (cookie != null) { + accountManager.setAndVerifyUserData(account, AccountUtils.Constants.KEY_OKHTTP_COOKIES, cookie) + } + } } diff --git a/app/src/main/kotlin/foundation/e/accountmanager/network/PersistentCookieStore.kt b/app/src/main/kotlin/foundation/e/accountmanager/network/PersistentCookieStore.kt new file mode 100644 index 0000000000000000000000000000000000000000..021281ae3579594c6891c9b0f81c460c0c8900f2 --- /dev/null +++ b/app/src/main/kotlin/foundation/e/accountmanager/network/PersistentCookieStore.kt @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2025 e Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package foundation.e.accountmanager.network + +import android.accounts.Account +import android.accounts.AccountManager +import android.content.Context +import at.bitfire.davdroid.sync.account.setAndVerifyUserData +import com.owncloud.android.lib.common.accounts.AccountUtils +import foundation.e.accountmanager.AccountTypes +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.HttpUrl + +/** + * Cookie store that persists only cookies for a specific domain. + * Other cookies are kept in memory temporarily for the app session. + */ +class PersistentCookieStore( + context: Context, + private val account: Account +) : CookieJar { + private val accountManager = AccountManager.get(context.applicationContext) + + override fun loadForRequest(url: HttpUrl): List = + getCookies(url).filter { it.matches(url) } + + override fun saveFromResponse(url: HttpUrl, cookies: List) { + if (url.host !in AccountTypes.Murena.whitelistedDomains) return + + val validCookies = cookies.filter { it.expiresAt > System.currentTimeMillis() } + if (validCookies.isEmpty()) return + + val cookieMap = getCookies(url).associateBy { it.name }.toMutableMap() + validCookies.forEach { cookieMap[it.name] = it } + + val serialized = cookieMap.values.joinToString(AccountUtils.Constants.OKHTTP_COOKIE_SEPARATOR) + accountManager.setAndVerifyUserData(account, AccountUtils.Constants.KEY_OKHTTP_COOKIES, serialized) + } + + private fun getCookies(url: HttpUrl): List { + val cookiesString = accountManager.getUserData(account, AccountUtils.Constants.KEY_OKHTTP_COOKIES) ?: return emptyList() + + return cookiesString + .split(AccountUtils.Constants.OKHTTP_COOKIE_SEPARATOR) + .mapNotNull { Cookie.parse(url, it) } + .filter { it.expiresAt > System.currentTimeMillis() } + } + + companion object { + val allowedAccountTypes = listOf(AccountTypes.Murena.accountType, AccountTypes.Murena.addressBookType) + + fun create( + context: Context, + account: Account?, + defaultJar: CookieJar + ): CookieJar { + if (account == null || account.type !in allowedAccountTypes) { + return defaultJar + } + + return PersistentCookieStore(context, account) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/foundation/e/accountmanager/ui/setup/MurenaLegacyLogin.kt b/app/src/main/kotlin/foundation/e/accountmanager/ui/setup/MurenaLegacyLogin.kt new file mode 100644 index 0000000000000000000000000000000000000000..70f5fed8217cff42db86086ea5ff1a5b13aee372 --- /dev/null +++ b/app/src/main/kotlin/foundation/e/accountmanager/ui/setup/MurenaLegacyLogin.kt @@ -0,0 +1,269 @@ +/* + * Copyright (C) 2025 e Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package foundation.e.accountmanager.ui.setup + +import android.net.Uri +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Password +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.hilt.navigation.compose.hiltViewModel +import at.bitfire.davdroid.R +import at.bitfire.davdroid.ui.composable.Assistant +import at.bitfire.davdroid.ui.composable.PasswordTextField +import at.bitfire.davdroid.ui.setup.LoginInfo +import at.bitfire.davdroid.ui.setup.LoginType +import foundation.e.accountmanager.AccountTypes +import foundation.e.accountmanager.utils.AccountHelper + +object MurenaLegacyLogin : LoginType { + + override val title = R.string.legacy_murena_login + + override val helpUrl: Uri + get() = "https://doc.e.foundation/support-topics".toUri() + + override val accountType: String + get() = AccountTypes.Murena.accountType + + @Composable + override fun LoginScreen( + snackbarHostState: SnackbarHostState, + initialLoginInfo: LoginInfo, + onLogin: (LoginInfo) -> Unit + ) { + val context = LocalContext.current + var showAccountDialog by remember { mutableStateOf(false) } + + if (AccountHelper.alreadyHasAccount(context)) { + showAccountDialog = true + } + + if (showAccountDialog) { + MultipleECloudAccountNotAcceptedDialog { + showAccountDialog = false + } + return + } + + val model: MurenaLegacyLoginModel = hiltViewModel( + creationCallback = { factory: MurenaLegacyLoginModel.Factory -> + factory.create(loginInfo = initialLoginInfo) + } + ) + + val uiState = model.uiState + MurenaLegacyLoginScreen( + url = uiState.url, + onSetUrl = model::setUrl, + username = uiState.username, + onSetUsername = model::setUsername, + password = uiState.password, + onSetPassword = model::setPassword, + canContinue = uiState.canContinue, + onLogin = { + if (uiState.canContinue) + onLogin(uiState.asLoginInfo()) + } + ) + } +} + +@Composable +fun MurenaLegacyLoginScreen( + url: String, + onSetUrl: (String) -> Unit = {}, + username: String, + onSetUsername: (String) -> Unit = {}, + password: String, + onSetPassword: (String) -> Unit = {}, + canContinue: Boolean, + onLogin: () -> Unit = {} +) { + val focusRequester = remember { FocusRequester() } + + Assistant( + nextLabel = stringResource(R.string.login_login), + nextEnabled = canContinue, + onNext = onLogin + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally) { + Spacer(modifier = Modifier.height(40.dp)) + // Murena + e logo (replace with actual logos if available) + Icon( + painter = painterResource(id = R.drawable.ic_murena_logo), + contentDescription = stringResource(R.string.eelo_account_name), + tint = Color.Unspecified // To display original logo colors + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = stringResource(R.string.login_eelo_title), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium + ) + + Spacer(modifier = Modifier.height(24.dp)) + + OutlinedTextField( + value = url, + onValueChange = onSetUrl, + label = { Text(stringResource(R.string.login_base_url)) }, + placeholder = { Text("murena.io") }, + singleLine = true, + leadingIcon = { + Icon(Icons.Default.Folder, null) + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Next + ), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + ) + + OutlinedTextField( + value = username, + onValueChange = onSetUsername, + label = { Text(stringResource(R.string.login_user_id)) }, + singleLine = true, + leadingIcon = { + Icon(Icons.Default.AccountCircle, null) + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next + ), + modifier = Modifier.fillMaxWidth() + ) + + // Suggestion buttons to add domain suffixes + if (!username.contains("@")) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + ) { + OutlinedButton( + onClick = { onSetUsername("$username@murena.io") }, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp), + shape = RoundedCornerShape(12.dp), + modifier = Modifier + .defaultMinSize(minHeight = 32.dp) + ) { + Text( + text = "@murena.io", + style = MaterialTheme.typography.bodySmall + ) + } + + OutlinedButton( + onClick = { onSetUsername("$username@e.email") }, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp), + shape = RoundedCornerShape(12.dp), + modifier = Modifier + .defaultMinSize(minHeight = 32.dp) + ) { + Text( + text = "@e.email", + style = MaterialTheme.typography.bodySmall + ) + } + } + } + + PasswordTextField( + password = password, + onPasswordChange = onSetPassword, + labelText = stringResource(R.string.login_password), + leadingIcon = { + Icon(Icons.Default.Password, null) + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions { + if (canContinue) onLogin() + }, + modifier = Modifier.fillMaxWidth() + ) + } + } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } +} + +@Composable +@Preview +fun MurenaLegacyLoginScreen_Preview() { + MurenaLegacyLoginScreen( + url = "", + username = "user", + password = "", + canContinue = false + ) +} diff --git a/app/src/main/kotlin/foundation/e/accountmanager/ui/setup/MurenaLegacyLoginModel.kt b/app/src/main/kotlin/foundation/e/accountmanager/ui/setup/MurenaLegacyLoginModel.kt new file mode 100644 index 0000000000000000000000000000000000000000..a0ad0839c45156c95338809c5a8da75fb89754ec --- /dev/null +++ b/app/src/main/kotlin/foundation/e/accountmanager/ui/setup/MurenaLegacyLoginModel.kt @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2025 e Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package foundation.e.accountmanager.ui.setup + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import at.bitfire.davdroid.BuildConfig +import at.bitfire.davdroid.settings.Credentials +import at.bitfire.davdroid.ui.setup.LoginInfo +import at.bitfire.davdroid.util.DavUtils.toURIorNull +import at.bitfire.davdroid.util.trimToNull +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import java.net.URI + +@HiltViewModel(assistedFactory = MurenaLegacyLoginModel.Factory::class) +class MurenaLegacyLoginModel @AssistedInject constructor( + @Assisted val initialLoginInfo: LoginInfo +): ViewModel() { + + @AssistedFactory + interface Factory { + fun create(loginInfo: LoginInfo): MurenaLegacyLoginModel + } + + val defaultUrl = "${URI(BuildConfig.MURENA_BASE_URL).host}" + + data class UiState( + val url: String = "", + val username: String = "", + val password: String = "" + ) { + + val urlWithPrefix = + if (url.startsWith("http://") || url.startsWith("https://")) + url + else + "https://$url" + val uri = urlWithPrefix.trim().toURIorNull() + + val canContinue = uri != null && username.isNotEmpty() && password.isNotEmpty() + + fun asLoginInfo(): LoginInfo = + LoginInfo( + baseUri = uri, + credentials = Credentials( + username = username.trimToNull(), + password = password.trimToNull()?.toCharArray() + ) + ) + + } + + var uiState by mutableStateOf(UiState()) + private set + + init { + uiState = UiState( + url = initialLoginInfo.baseUri?.toString()?.removePrefix("https://") ?: defaultUrl, + username = initialLoginInfo.credentials?.username ?: "", + password = initialLoginInfo.credentials?.password?.concatToString() ?: "" + ) + } + + fun setUrl(url: String) { + uiState = uiState.copy(url = url) + } + + fun setUsername(username: String) { + uiState = uiState.copy(username = username) + } + + fun setPassword(password: String) { + uiState = uiState.copy(password = password) + } + +} diff --git a/app/src/main/kotlin/foundation/e/accountmanager/ui/setup/MurenaLogin.kt b/app/src/main/kotlin/foundation/e/accountmanager/ui/setup/MurenaLogin.kt index 651d9a2fb383e6e4b998e54045b9208f9d54f02c..56a57c8eb180aa58233541acb4ab65ee01e8e5c8 100644 --- a/app/src/main/kotlin/foundation/e/accountmanager/ui/setup/MurenaLogin.kt +++ b/app/src/main/kotlin/foundation/e/accountmanager/ui/setup/MurenaLogin.kt @@ -79,6 +79,7 @@ import at.bitfire.davdroid.ui.setup.LoginInfo import at.bitfire.davdroid.ui.setup.LoginType import foundation.e.accountmanager.AccountTypes import foundation.e.accountmanager.network.OAuthMurena +import foundation.e.accountmanager.utils.AccountHelper import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -95,33 +96,6 @@ object MurenaLogin : LoginType { override val accountType: String get() = AccountTypes.Murena.accountType - @Composable - fun MultipleECloudAccountNotAcceptedDialog(onDismiss: () -> Unit) { - val activity = LocalActivity.current - - AlertDialog( - onDismissRequest = {}, - confirmButton = { - TextButton(onClick = { - activity?.finish() - onDismiss() - }) { - Text(stringResource(id = android.R.string.ok)) - } - }, - text = { - Text(text = stringResource(R.string.multiple_ecloud_account_not_permitted_message)) - }, - tonalElevation = 8.dp - ) - } - - fun alreadyHasAccount(context: Context): Boolean { - val accountManager = AccountManager.get(context) - val accounts = accountManager.getAccountsByType(accountType) - return accounts.isNotEmpty() - } - @Composable override fun LoginScreen( snackbarHostState: SnackbarHostState, @@ -131,7 +105,7 @@ object MurenaLogin : LoginType { val context = LocalContext.current var showAccountDialog by remember { mutableStateOf(false) } - if (alreadyHasAccount(context)) { + if (AccountHelper.alreadyHasAccount(context)) { showAccountDialog = true } @@ -204,6 +178,27 @@ object MurenaLogin : LoginType { } } +@Composable +fun MultipleECloudAccountNotAcceptedDialog(onDismiss: () -> Unit) { + val activity = LocalActivity.current + + AlertDialog( + onDismissRequest = {}, + confirmButton = { + TextButton(onClick = { + activity?.finish() + onDismiss() + }) { + Text(stringResource(id = android.R.string.ok)) + } + }, + text = { + Text(text = stringResource(R.string.multiple_ecloud_account_not_permitted_message)) + }, + tonalElevation = 8.dp + ) +} + @Composable fun MurenaLoginScreen( email: String, diff --git a/app/src/main/kotlin/foundation/e/accountmanager/ui/setup/ReOAuthActivity.kt b/app/src/main/kotlin/foundation/e/accountmanager/ui/setup/ReOAuthActivity.kt new file mode 100644 index 0000000000000000000000000000000000000000..5e3a30688e05b9a6d5bf1d30e6011358878be684 --- /dev/null +++ b/app/src/main/kotlin/foundation/e/accountmanager/ui/setup/ReOAuthActivity.kt @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2025 e Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package foundation.e.accountmanager.ui.setup + +import android.accounts.Account +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.setContent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.glance.LocalContext +import androidx.hilt.navigation.compose.hiltViewModel +import at.bitfire.davdroid.ui.AppTheme +import at.bitfire.davdroid.ui.account.AccountSettingsActivity +import at.bitfire.davdroid.ui.account.AccountSettingsModel +import dagger.hilt.android.AndroidEntryPoint +import foundation.e.accountmanager.utils.AccountHelper + +@AndroidEntryPoint +class ReOAuthActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Retrieve the Account from the Intent + val account: Account? = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(AccountSettingsActivity.EXTRA_ACCOUNT, Account::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(AccountSettingsActivity.EXTRA_ACCOUNT) + } + + setContent { + AppTheme { + if (account != null) { + OAuthHandlerScreen( + account = account, + onFinished = { finish() } + ) + } else { + finish() + } + } + } + } +} + +@Composable +fun OAuthHandlerScreen( + account: Account, + onFinished: () -> Unit, +) { + val context = LocalContext.current + val model = hiltViewModel { factory: AccountSettingsModel.Factory -> + factory.create(account) + } + + val authRequestContract = rememberLauncherForActivityResult(model.authorizationContract()) { authResponse -> + if (authResponse != null) { + model.authenticate(authResponse) + + // Sync after authenticated + AccountHelper.scheduleSyncWithDelay(context) + } else { + model.authCodeFailed() + } + onFinished() + } + + // Auto-launch immediately, no UI shown + LaunchedEffect(Unit) { + val request = model.newAuthorizationRequest() + if (request != null) { + authRequestContract.launch(request) + } else { + onFinished() + } + } +} diff --git a/app/src/main/kotlin/foundation/e/accountmanager/utils/AccountHelper.kt b/app/src/main/kotlin/foundation/e/accountmanager/utils/AccountHelper.kt index 9ae52d232c96bb04cc2a78ab4d7830a93220e1c8..68d168655516291eef978d05b5dab2998dc8ebc7 100644 --- a/app/src/main/kotlin/foundation/e/accountmanager/utils/AccountHelper.kt +++ b/app/src/main/kotlin/foundation/e/accountmanager/utils/AccountHelper.kt @@ -24,15 +24,28 @@ import android.app.PendingIntent import android.content.ComponentName import android.content.Context import android.content.Intent -import android.os.Build +import at.bitfire.davdroid.settings.AccountSettings import foundation.e.accountmanager.AccountTypes import foundation.e.accountmanager.sync.SyncBroadcastReceiver import java.util.concurrent.TimeUnit +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import net.openid.appauth.AuthState +import java.net.HttpURLConnection +import java.net.URL +import java.util.logging.Level +import java.util.logging.Logger object AccountHelper { private const val MAIL_PACKAGE = "foundation.e.mail" private const val MAIL_RECEIVER_CLASS = "com.fsck.k9.account.AccountSyncReceiver" - private const val ACTION_PREFIX = "foundation.e.accountmanager.account." + private const val MAIL_ACTION_PREFIX = "foundation.e.accountmanager.account." + + private const val DRIVE_PACKAGE_NAME = "foundation.e.drive" + private const val DRIVE_ACTION_ADD_ACCOUNT = "$DRIVE_PACKAGE_NAME.action.ADD_ACCOUNT" + private const val DRIVE_RECEIVER_CLASS = "$DRIVE_PACKAGE_NAME.account.receivers.AccountAddedReceiver" + + val logger: Logger = Logger.getLogger(this.javaClass.name) fun getAllAccounts(accountManager: AccountManager): Array { val allAccounts = mutableListOf() @@ -50,11 +63,54 @@ object AccountHelper { return allAccounts.toTypedArray() } - fun syncMailAccounts(context: Context) { + suspend fun isValidAccessToken(authState: AuthState): Boolean = withContext(Dispatchers.IO) { + try { + val endpoint = authState.authorizationServiceConfiguration + ?.discoveryDoc + ?.userinfoEndpoint + ?.toString() + ?: return@withContext false + logger.fine("Checking current access token") + (URL(endpoint).openConnection() as HttpURLConnection).run { + setRequestProperty("Authorization", "Bearer ${authState.accessToken}") + instanceFollowRedirects = false + val valid = responseCode == HttpURLConnection.HTTP_OK + disconnect() + valid + } + } catch (ex: Exception) { + logger.log(Level.SEVERE, "Failed to access userInfo endpoint", ex) + false + } + } + + fun alreadyHasAccount(context: Context): Boolean { + val accountManager = AccountManager.get(context) + val accounts = accountManager.getAccountsByType(AccountTypes.Murena.accountType) + return accounts.isNotEmpty() + } + + fun isOidcAccount(context: Context, account: Account): Boolean { + val accountManager = AccountManager.get(context) + val authState = accountManager.getUserData(account, AccountSettings.KEY_AUTH_STATE) + return !authState.isNullOrBlank() + } + + fun getAccountByName(context: Context, name: String?): Account? { + val accountManager = AccountManager.get(context) + for (account in accountManager.getAccountsByType(AccountTypes.Murena.accountType)) { + if (account.name == name) { + return account + } + } + return null + } + + fun notifyMailAccountAdded(context: Context) { val intent = Intent() intent.addFlags(Intent.FLAG_INCLUDE_STOPPED_PACKAGES) intent.component = ComponentName(MAIL_PACKAGE, MAIL_RECEIVER_CLASS) - intent.action = ACTION_PREFIX + "create" + intent.action = MAIL_ACTION_PREFIX + "create" context.sendBroadcast(intent) } @@ -74,4 +130,19 @@ object AccountHelper { alarmManager.setExact(AlarmManager.RTC_WAKEUP, triggerTime, pendingIntent) } } + + private fun notifyEDriveAccountAdded(context: Context, name: String) { + val intent = Intent(DRIVE_ACTION_ADD_ACCOUNT).apply { + addFlags(Intent.FLAG_INCLUDE_STOPPED_PACKAGES) + component = ComponentName(DRIVE_PACKAGE_NAME, DRIVE_RECEIVER_CLASS) + putExtra(AccountManager.KEY_ACCOUNT_NAME, name) + putExtra(AccountManager.KEY_ACCOUNT_TYPE, AccountTypes.Murena.accountType) + } + context.sendBroadcast(intent) + } + + fun notifyEApps(context: Context, name: String) { + notifyEDriveAccountAdded(context, name) + notifyMailAccountAdded(context) + } } diff --git a/app/src/main/kotlin/foundation/e/accountmanager/utils/UserIdFetcher.kt b/app/src/main/kotlin/foundation/e/accountmanager/utils/UserIdFetcher.kt new file mode 100644 index 0000000000000000000000000000000000000000..fbc9224d7e80baa222a4e2dab7f63c5044d87870 --- /dev/null +++ b/app/src/main/kotlin/foundation/e/accountmanager/utils/UserIdFetcher.kt @@ -0,0 +1,47 @@ +/* + * Copyright MURENA SAS 2024 + * Copyright (C) 2025 e Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package foundation.e.accountmanager.utils + +object UserIdFetcher { + + /** + * retrieve the userId from caldav/carddav nextcloud principal url. + * example: if the url is: https://abc.com/remote.php/dav/principals/users/xyz/, then this function will return xyz. + * + * this function will return null, if + * - `/users/` part is missing + */ + fun fetch(principalUrl: String): String? { + val usersPart = "/users/" + + var userId: String? = null + if (principalUrl.contains(usersPart, ignoreCase = true)) { + userId = principalUrl.split(usersPart, ignoreCase = true)[1] + if (userId.endsWith("/")) { + userId = userId.dropLast(1) + } + + if (userId.isBlank()) { + userId = null + } + } + + return userId + } +} diff --git a/app/src/main/res/values/e_strings.xml b/app/src/main/res/values/e_strings.xml index 5c277d71731b2617486d897f95bba969d6b087fc..8e8adc9808ad89e8242c4ae5d23b033d4e3882e2 100644 --- a/app/src/main/res/values/e_strings.xml +++ b/app/src/main/res/values/e_strings.xml @@ -33,4 +33,7 @@ "Account Manager's Privacy Policy" "Privacy Policy" Web Calendar Manager + Legacy Murena.io + + Authentication issue. Tap to sign in again diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6a07e00f1d059d2cb2495f6a1d9c1602bb52e6a0..94d5919ce4e6e31951ea79a66e9280a0dbe4c891 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -238,7 +238,7 @@ Push messages are always encrypted. - Account doesn\'t exist + Account has been removed CardDAV CalDAV Webcal diff --git a/app/src/ose/AndroidManifest.xml b/app/src/ose/AndroidManifest.xml index 4289641faa63a547d0da7b6c9e62a3a48431f1cd..f6583537c7ba9d60ebf9be7ac8880c45d85b1855 100644 --- a/app/src/ose/AndroidManifest.xml +++ b/app/src/ose/AndroidManifest.xml @@ -14,6 +14,11 @@ + + + + @@ -254,8 +259,22 @@ + + + + + + - \ No newline at end of file + diff --git a/app/src/ose/kotlin/at/bitfire/davdroid/ui/setup/StandardLoginTypesProvider.kt b/app/src/ose/kotlin/at/bitfire/davdroid/ui/setup/StandardLoginTypesProvider.kt index 0359c00730050e7d1153de8d0452136873dbc037..87d05f15eb83a64eb0074e2ea7cdf1e834678e59 100644 --- a/app/src/ose/kotlin/at/bitfire/davdroid/ui/setup/StandardLoginTypesProvider.kt +++ b/app/src/ose/kotlin/at/bitfire/davdroid/ui/setup/StandardLoginTypesProvider.kt @@ -7,7 +7,9 @@ package at.bitfire.davdroid.ui.setup import android.content.Intent import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable +import at.bitfire.davdroid.BuildConfig import at.bitfire.davdroid.ui.setup.LoginTypesProvider.LoginAction +import foundation.e.accountmanager.ui.setup.MurenaLegacyLogin import foundation.e.accountmanager.ui.setup.MurenaLogin import java.util.logging.Logger import javax.inject.Inject @@ -17,6 +19,13 @@ class StandardLoginTypesProvider @Inject constructor( ) : LoginTypesProvider { companion object { + private val testLoginTypes = + if (BuildConfig.DEBUG) { + listOf( + MurenaLegacyLogin + ) + } else emptyList() + val genericLoginTypes = listOf( UrlLogin, EmailLogin, @@ -28,7 +37,7 @@ class StandardLoginTypesProvider @Inject constructor( FastmailLogin, GoogleLogin, NextcloudLogin - ) + ) + testLoginTypes } override val defaultLoginType = UrlLogin diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 176addc3c5f571b25220dfa99e8b7cde401ebd78..b79cd0edc772a3ada07e59255398912116ef37c0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -48,11 +48,16 @@ commons-codec = { strictly = "1.17.1" } commons-lang = { strictly = "3.15.0" } # --- e-Specific dependencies --- +appauth = "e8ca08e3" elib = "0.0.1-alpha11" ezVcard = "0.12.1" ical4j = "3.2.19" synctools = "58bc6752" runtimeLivedata = "1.8.3" +jackrabbitWebdav = "2.13.5" +commonsHttpclient = "3.1" +androidSinglesignon = "dff59d8d" +nextcloudLibrary = "2303db7b" [libraries] android-desugaring = { module = "com.android.tools:desugar_jdk_libs_nio", version.ref = "android-desugaring" } @@ -116,11 +121,17 @@ unifiedpush = { module = "org.unifiedpush.android:connector", version.ref = "uni unifiedpush-fcm = { module = "org.unifiedpush.android:embedded-fcm-distributor", version.ref = "unifiedpush-fcm" } # --- e-Specific dependencies --- +appauth = { module = "foundation.e:appauth", version.ref = "appauth" } +android-singlesignon = { module = "foundation.e:Android-SingleSignOn", version.ref = "androidSinglesignon" } elib = { module = "foundation.e:elib", version.ref = "elib" } ez-vcard = { module = "com.googlecode.ez-vcard:ez-vcard", version.ref = "ezVcard" } ical4j = { module = "org.mnode.ical4j:ical4j", version.ref = "ical4j" } synctools = { module = "foundation.e:synctools", version.ref = "synctools" } androidx-runtime-livedata = { module = "androidx.compose.runtime:runtime-livedata", version.ref = "runtimeLivedata" } +commons-httpclient = { module = "commons-httpclient:commons-httpclient", version.ref = "commonsHttpclient" } +jackrabbit-webdav = { module = "org.apache.jackrabbit:jackrabbit-webdav", version.ref = "jackrabbitWebdav" } +nextcloud-library = { module = "foundation.e:nextcloud-library", version.ref = "nextcloudLibrary" } +okhttp-urlconnection = { module = "com.squareup.okhttp3:okhttp-urlconnection", version.ref = "okhttp" } [plugins] android-application = { id = "com.android.application", version.ref = "android-agp" }