diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5fdffa5d034c03db2f5b0cbe8d075cabee8e3c27..c924847a55aba7261da83882007dd6d1eb07f188 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -5,6 +5,11 @@ stages: - build before_script: + - echo MURENA_CLIENT_ID=$MURENA_CLIENT_ID >> local.properties + - echo MURENA_REDIRECT_URI=$MURENA_REDIRECT_URI >> local.properties + - echo MURENA_LOGOUT_REDIRECT_URI=$MURENA_LOGOUT_REDIRECT_URI >> local.properties + - echo MURENA_BASE_URL=$MURENA_BASE_URL_STAGING >> local.properties + - echo MURENA_DISCOVERY_END_POINT=$MURENA_DISCOVERY_END_POINT_STAGING >> local.properties - export JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64 - export GRADLE_USER_HOME=$(pwd)/.gradle - chmod +x ./gradlew diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 00aaf4765e5a3912efb6656a67ecf37648377029..208a90be9c2e5583757a516907a6342eaec62670 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -69,6 +69,19 @@ android { if (versionName != null) { setProperty("archivesBaseName", "AccountManager-$versionName") } + + buildConfigField("String", "MURENA_CLIENT_ID", "\"${retrieveKey("MURENA_CLIENT_ID")}\"") + buildConfigField("String", "MURENA_REDIRECT_URI", "\"${retrieveKey("MURENA_REDIRECT_URI")}\"") + buildConfigField("String", "MURENA_LOGOUT_REDIRECT_URI", "\"${retrieveKey("MURENA_LOGOUT_REDIRECT_URI")}\"") + buildConfigField("String", "MURENA_BASE_URL", "\"${retrieveKey("MURENA_BASE_URL")}\"") + buildConfigField("String", "MURENA_DISCOVERY_END_POINT", "\"${retrieveKey("MURENA_DISCOVERY_END_POINT")}\"") + + manifestPlaceholders.putAll( + mapOf( + "murenaAuthRedirectScheme" to retrieveKey("MURENA_REDIRECT_URI"), + "murenaAuthLogoutRedirectScheme" to retrieveKey("MURENA_LOGOUT_REDIRECT_URI") + ) + ) } } @@ -168,6 +181,15 @@ android { } } +fun retrieveKey(keyName: String): String { + val properties = Properties().apply { + load(rootProject.file("local.properties").inputStream()) + } + + return properties.getProperty(keyName) + ?: throw GradleException("$keyName property not found in local.properties file") +} + ksp { arg("room.schemaLocation", "$projectDir/schemas") } @@ -257,6 +279,7 @@ dependencies { implementation(libs.commons.lang) // e-Specific dependencies + implementation(libs.androidx.runtime.livedata) implementation(libs.elib) implementation(libs.ez.vcard) implementation(libs.synctools) { 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 49e8a87a3621e3c1fff658e601efc13d48332d3e..282a3baba2f3ff9336dc75f08be6f637d0712a6c 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/repository/AccountRepository.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/repository/AccountRepository.kt @@ -28,6 +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.utils.AccountHelper import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.awaitClose @@ -75,6 +76,7 @@ class AccountRepository @Inject constructor( // create Android account val userData = AccountSettings.initialUserData(credentials) + AuthStatePrefUtils.saveAuthState(context, account, credentials?.authState?.jsonSerializeString()) 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/settings/AccountSettings.kt b/app/src/main/kotlin/at/bitfire/davdroid/settings/AccountSettings.kt index 9021818aa45953ee86355f3ef2f55e58fcbd2c0b..d26e1a1a73e365ecd281eb96cb0ea6f418907050 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/settings/AccountSettings.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/settings/AccountSettings.kt @@ -24,6 +24,7 @@ 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 net.openid.appauth.AuthState import java.util.Collections import java.util.logging.Level @@ -72,7 +73,7 @@ class AccountSettings @AssistedInject constructor( *AccountTypes.getAccountTypes().toTypedArray(), "at.bitfire.davdroid.test" // R.strings.account_type_test in androidTest ) - if (!allowedAccountTypes.contains(account.type)) + if (!allowedAccountTypes.any { it == account.type }) throw IllegalArgumentException("Invalid account type for AccountSettings(): ${account.type}") // synchronize because account migration must only be run one time @@ -131,6 +132,7 @@ class AccountSettings @AssistedInject constructor( fun updateAuthState(authState: AuthState) { accountManager.setAndVerifyUserData(account, KEY_AUTH_STATE, authState.jsonSerializeString()) + AuthStatePrefUtils.saveAuthState(context, account, authState.jsonSerializeString()) } /** @@ -156,6 +158,7 @@ class AccountSettings @AssistedInject constructor( SyncDataType.CONTACTS -> KEY_SYNC_INTERVAL_ADDRESSBOOKS SyncDataType.EVENTS -> KEY_SYNC_INTERVAL_CALENDARS SyncDataType.TASKS -> KEY_SYNC_INTERVAL_TASKS + else -> KEY_SYNC_INTERVAL_DEFAULT } val seconds = accountManager.getUserData(account, key)?.toLong() return when (seconds) { @@ -176,6 +179,7 @@ class AccountSettings @AssistedInject constructor( SyncDataType.CONTACTS -> KEY_SYNC_INTERVAL_ADDRESSBOOKS SyncDataType.EVENTS -> KEY_SYNC_INTERVAL_CALENDARS SyncDataType.TASKS -> KEY_SYNC_INTERVAL_TASKS + else -> KEY_SYNC_INTERVAL_DEFAULT } val newValue = seconds ?: SYNC_INTERVAL_MANUALLY accountManager.setAndVerifyUserData(account, key, newValue.toString()) @@ -362,6 +366,7 @@ class AccountSettings @AssistedInject constructor( /** Stores the tasks sync interval (in seconds) so that it can be set again when the provider is switched */ const val KEY_SYNC_INTERVAL_TASKS = "sync_interval_tasks" + const val KEY_SYNC_INTERVAL_DEFAULT = "sync_interval_default" const val KEY_USERNAME = "user_name" const val KEY_CERTIFICATE_ALIAS = "certificate_alias" diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/AutomaticSyncManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/AutomaticSyncManager.kt index 73556d0d8f6788f37f4df00bc8189fe9b5485dc2..6142102e42a790456536c123a8ce32c3d14f53be 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/AutomaticSyncManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/AutomaticSyncManager.kt @@ -13,6 +13,7 @@ import at.bitfire.davdroid.resource.LocalAddressBookStore import at.bitfire.davdroid.settings.AccountSettings import at.bitfire.davdroid.sync.adapter.SyncFrameworkIntegration import at.bitfire.davdroid.sync.worker.SyncWorkerManager +import foundation.e.accountmanager.Authority import kotlinx.coroutines.runBlocking import javax.inject.Inject import javax.inject.Provider @@ -92,6 +93,11 @@ class AutomaticSyncManager @Inject constructor( SyncDataType.CONTACTS -> throw IllegalStateException() // handled above SyncDataType.EVENTS -> CalendarContract.AUTHORITY SyncDataType.TASKS -> tasksAppManager.get().currentProvider()?.authority + SyncDataType.EMAIL -> Authority.Email.authority + SyncDataType.MEDIA -> Authority.Media.authority + SyncDataType.NOTES -> Authority.Notes.authority + SyncDataType.APP_DATA -> Authority.AppData.authority + SyncDataType.E_DRIVE -> Authority.MeteredEDrive.authority } if (authority != null && syncInterval != null) { // enable given authority, but completely disable all other possible authorities @@ -132,7 +138,12 @@ class AutomaticSyncManager @Inject constructor( val serviceType = when (dataType) { SyncDataType.CONTACTS -> Service.TYPE_CARDDAV SyncDataType.EVENTS, - SyncDataType.TASKS -> Service.TYPE_CALDAV + SyncDataType.TASKS, + SyncDataType.EMAIL, + SyncDataType.MEDIA, + SyncDataType.NOTES, + SyncDataType.APP_DATA, + SyncDataType.E_DRIVE -> Service.TYPE_CALDAV } val hasService = runBlocking { serviceRepository.getByAccountAndType(account.name, serviceType) != null } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncDataType.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncDataType.kt index 9eb68efc8e5d0062732fce1b9b0177b73bc7fff0..cb2e55cf2896d0dd0341624649d0dc2235586749 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncDataType.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncDataType.kt @@ -10,11 +10,17 @@ import at.bitfire.ical4android.TaskProvider import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import foundation.e.accountmanager.Authority enum class SyncDataType { CONTACTS, EVENTS, + EMAIL, + MEDIA, + NOTES, + APP_DATA, + E_DRIVE, TASKS; @EntryPoint @@ -34,6 +40,11 @@ enum class SyncDataType { ) TASKS -> TaskProvider.ProviderName.entries.map { it.authority } + EMAIL -> listOf(Authority.Email.authority) + MEDIA -> listOf(Authority.Media.authority) + NOTES -> listOf(Authority.Notes.authority) + APP_DATA -> listOf(Authority.AppData.authority) + E_DRIVE -> listOf(Authority.MeteredEDrive.authority) } companion object { @@ -44,6 +55,11 @@ enum class SyncDataType { CONTACTS CalendarContract.AUTHORITY -> EVENTS + Authority.Notes.authority -> NOTES + Authority.Email.authority -> EMAIL + Authority.Media.authority -> MEDIA + Authority.AppData.authority -> APP_DATA + Authority.MeteredEDrive.authority -> E_DRIVE TaskProvider.ProviderName.JtxBoard.authority, TaskProvider.ProviderName.TasksOrg.authority, TaskProvider.ProviderName.OpenTasks.authority, diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/adapter/SyncAdapterServices.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/adapter/SyncAdapterServices.kt index 0d2f3f57dfd952f1e87084435b60ac5f3853dd3b..9fe22a14ecf9c181deba022993dae875e6787810 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/adapter/SyncAdapterServices.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/adapter/SyncAdapterServices.kt @@ -33,6 +33,19 @@ abstract class SyncAdapterService: Service() { } +class MurenaCalendarsSyncAdapterService: SyncAdapterService() +class MurenaContactsSyncAdapterService: SyncAdapterService() +class MurenaJtxSyncAdapterService: SyncAdapterService() +class MurenaOpenTasksSyncAdapterService: SyncAdapterService() +class MurenaTasksOrgSyncAdapterService: SyncAdapterService() + +class MurenaEmailSyncAdapterService: SyncAdapterService() +class MurenaEOpenTasksSyncAdapterService: SyncAdapterService() +class MurenaMediaSyncAdapterService: SyncAdapterService() +class MurenaAppDataSyncAdapterService:SyncAdapterService() +class MurenaMeteredEdriveSyncAdapterService: SyncAdapterService() +class MurenaNotesSyncAdapterService: SyncAdapterService() + // exported sync adapter services; we need a separate class for each authority class CalendarsSyncAdapterService: SyncAdapterService() class ContactsSyncAdapterService: SyncAdapterService() 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 87edf986f7945549b418f55c61e4d52233d960d0..51d0ca2aa35ff2e109a341c5d38dba2f12ec4ca2 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 @@ -35,6 +35,7 @@ import at.bitfire.davdroid.sync.worker.BaseSyncWorker.Companion.commonTag import at.bitfire.davdroid.ui.NotificationRegistry import at.bitfire.ical4android.TaskProvider import dagger.Lazy +import foundation.e.accountmanager.utils.AccountHelper import kotlinx.coroutines.delay import java.util.Collections import java.util.logging.Level @@ -173,6 +174,14 @@ abstract class BaseSyncWorker( } } } + SyncDataType.EMAIL -> { + AccountHelper.syncMailAccounts(applicationContext) + return Result.success() + } + SyncDataType.MEDIA, + SyncDataType.NOTES, + SyncDataType.E_DRIVE, + SyncDataType.APP_DATA -> return Result.success() } // Start syncing diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsActivity.kt index ad6752eb26adb8f8fa9c134fe2bd392753d3d937..3cc9bd28673051dcdb643b113377a41818d22981 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsActivity.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsActivity.kt @@ -12,6 +12,7 @@ import at.bitfire.davdroid.ui.account.AccountActivity import at.bitfire.davdroid.ui.intro.IntroActivity import at.bitfire.davdroid.ui.setup.LoginActivity import dagger.hilt.android.AndroidEntryPoint +import foundation.e.accountmanager.AccountTypes import javax.inject.Inject @@ -46,6 +47,7 @@ class AccountsActivity: AppCompatActivity() { onShowAccount = { account -> val intent = Intent(this, AccountActivity::class.java) intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account) + intent.putExtra(AccountActivity.EXTRA_MURENA_ACCOUNT, account.type == AccountTypes.Murena.accountType) startActivity(intent) }, onManagePermissions = { diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountActivity.kt index d60f8bd5cb58568877c040a65c523e7b5cad29be..5a21882adb41d2423f74aa4472a75c3b8dbce41f 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountActivity.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountActivity.kt @@ -30,7 +30,11 @@ class AccountActivity : AppCompatActivity() { val account = IntentCompat.getParcelableExtra(intent, EXTRA_ACCOUNT, Account::class.java) ?: intent.getStringExtra(EXTRA_ACCOUNT)?.let { - Account(it, AccountTypes.Default.accountType) + if (intent.getBooleanExtra(EXTRA_MURENA_ACCOUNT, false)) { + Account(it, AccountTypes.Murena.accountType) + } else { + Account(it, AccountTypes.Default.accountType) + } } // If account is not passed, log warning and redirect to accounts overview @@ -79,6 +83,7 @@ class AccountActivity : AppCompatActivity() { companion object { const val EXTRA_ACCOUNT = "account" + const val EXTRA_MURENA_ACCOUNT = "murena_account" } } \ No newline at end of file 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 7d2344e02703b118cb835a7a86956400ad2d68ce..539d202d69d54e63a2016dc9befb768a6d812f34 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 @@ -69,6 +69,8 @@ class AccountSettingsModel @AssistedInject constructor( val syncIntervalCalendars: Long? = null, val hasTasksSync: Boolean = false, val syncIntervalTasks: Long? = null, + val hasDefaultSync: Boolean = false, + val syncIntervalDefault: Long? = null, val syncWifiOnly: Boolean = false, val syncWifiOnlySSIDs: List? = null, @@ -128,6 +130,8 @@ class AccountSettingsModel @AssistedInject constructor( syncIntervalCalendars = accountSettings.getSyncInterval(SyncDataType.EVENTS), hasTasksSync = hasTasksSync, syncIntervalTasks = accountSettings.getSyncInterval(SyncDataType.TASKS), + hasDefaultSync = true, + syncIntervalDefault = accountSettings.getSyncInterval(SyncDataType.APP_DATA), // We set same for all syncWifiOnly = accountSettings.getSyncWifiOnly(), syncWifiOnlySSIDs = accountSettings.getSyncWifiOnlySSIDs(), @@ -167,6 +171,21 @@ class AccountSettingsModel @AssistedInject constructor( } } + fun updateDefaultSyncInterval(syncInterval: Long) { + CoroutineScope(defaultDispatcher).launch { + listOf( + SyncDataType.EMAIL, + SyncDataType.MEDIA, + SyncDataType.NOTES, + SyncDataType.APP_DATA, + SyncDataType.E_DRIVE + ).forEach { dataType -> + accountSettings.setSyncInterval(dataType, syncInterval.takeUnless { it == -1L }) + } + reload() + } + } + fun updateSyncWifiOnly(wifiOnly: Boolean) = CoroutineScope(defaultDispatcher).launch { accountSettings.setSyncWiFiOnly(wifiOnly) reload() diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountSettingsScreen.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountSettingsScreen.kt index 7b8ba226966f9d17054a423b26c3ba70b82de961..70f7f9278ac21f75c2ba5e317b3453ceec7e4a59 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountSettingsScreen.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountSettingsScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Password import androidx.compose.material.icons.filled.SyncProblem import androidx.compose.material.icons.filled.Wifi +import androidx.compose.material.icons.outlined.Apps import androidx.compose.material.icons.outlined.Task import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -108,6 +109,9 @@ fun AccountSettingsScreen( hasTasksSync = uiState.hasTasksSync, tasksSyncInterval = uiState.syncIntervalTasks, onUpdateTasksSyncInterval = model::updateTasksSyncInterval, + hasDefaultSync = uiState.hasDefaultSync, + defaultSyncInterval = uiState.syncIntervalDefault, + onUpdateDefaultSyncInterval = model::updateDefaultSyncInterval, syncOnlyOnWifi = uiState.syncWifiOnly, onUpdateSyncOnlyOnWifi = model::updateSyncWifiOnly, onlyOnSsids = uiState.syncWifiOnlySSIDs, @@ -161,6 +165,9 @@ fun AccountSettingsScreen( hasTasksSync: Boolean, tasksSyncInterval: Long?, onUpdateTasksSyncInterval: ((Long) -> Unit) = {}, + hasDefaultSync: Boolean, + defaultSyncInterval: Long?, + onUpdateDefaultSyncInterval: ((Long) -> Unit) = {}, syncOnlyOnWifi: Boolean, onUpdateSyncOnlyOnWifi: (Boolean) -> Unit = {}, onlyOnSsids: List?, @@ -243,6 +250,9 @@ fun AccountSettingsScreen( hasTasksSync = hasTasksSync, taskSyncInterval = tasksSyncInterval, onUpdateTaskSyncInterval = onUpdateTasksSyncInterval, + hasDefaultSync = hasDefaultSync, + defaultSyncInterval = defaultSyncInterval, + onUpdateDefaultSyncInterval = onUpdateDefaultSyncInterval, syncOnlyOnWifi = syncOnlyOnWifi, onUpdateSyncOnlyOnWifi = onUpdateSyncOnlyOnWifi, onlyOnSsids = onlyOnSsids, @@ -290,6 +300,9 @@ fun AccountSettings_FromModel( hasTasksSync: Boolean, taskSyncInterval: Long?, onUpdateTaskSyncInterval: ((Long) -> Unit) = {}, + hasDefaultSync: Boolean, + defaultSyncInterval: Long?, + onUpdateDefaultSyncInterval: ((Long) -> Unit) = {}, syncOnlyOnWifi: Boolean, onUpdateSyncOnlyOnWifi: (Boolean) -> Unit = {}, onlyOnSsids: List?, @@ -330,6 +343,9 @@ fun AccountSettings_FromModel( hasTasksSync = hasTasksSync, taskSyncInterval = taskSyncInterval, onUpdateTaskSyncInterval = onUpdateTaskSyncInterval, + hasDefaultSync = hasDefaultSync, + defaultSyncInterval = defaultSyncInterval, + onUpdateDefaultSyncInterval = onUpdateDefaultSyncInterval, syncOnlyOnWifi = syncOnlyOnWifi, onUpdateSyncOnlyOnWifi = onUpdateSyncOnlyOnWifi, onlyOnSsids = onlyOnSsids, @@ -379,6 +395,9 @@ fun SyncSettings( hasTasksSync: Boolean, taskSyncInterval: Long?, onUpdateTaskSyncInterval: ((Long) -> Unit) = {}, + hasDefaultSync: Boolean, + defaultSyncInterval: Long?, + onUpdateDefaultSyncInterval: ((Long) -> Unit) = {}, syncOnlyOnWifi: Boolean, onUpdateSyncOnlyOnWifi: (Boolean) -> Unit = {}, onlyOnSsids: List?, @@ -412,6 +431,13 @@ fun SyncSettings( syncInterval = taskSyncInterval, onUpdateSyncInterval = onUpdateTaskSyncInterval ) + if (hasDefaultSync) + SyncIntervalSetting( + icon = Icons.Outlined.Apps, + name = R.string.settings_sync_interval_default, + syncInterval = defaultSyncInterval, + onUpdateSyncInterval = onUpdateDefaultSyncInterval + ) SwitchSetting( icon = Icons.Default.Wifi, @@ -773,6 +799,9 @@ fun AccountSettingsScreen_Preview() { hasTasksSync = true, tasksSyncInterval = 900000L, onUpdateTasksSyncInterval = {}, + hasDefaultSync = true, + defaultSyncInterval = 900000L, + onUpdateDefaultSyncInterval = {}, syncOnlyOnWifi = true, onUpdateSyncOnlyOnWifi = {}, onlyOnSsids = listOf("HeyWifi", "Another"), diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/AccountDetailsPage.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/AccountDetailsPage.kt index bb2c319a1ad5a8c8901322fa3baba5a20940a7e3..8b7858cc7c7b8e0e7fc47269a2fb49b1d6a766a2 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/AccountDetailsPage.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/AccountDetailsPage.kt @@ -46,6 +46,8 @@ import androidx.lifecycle.viewmodel.compose.viewModel import at.bitfire.davdroid.R import at.bitfire.davdroid.ui.composable.Assistant import at.bitfire.vcard4android.GroupMethod +import foundation.e.accountmanager.AccountTypes +import foundation.e.accountmanager.ui.setup.MurenaAccountDetailsPageContent @Composable fun AccountDetailsPage( @@ -64,18 +66,31 @@ fun AccountDetailsPage( } } - AccountDetailsPageContent( - accountName = uiState.accountName, - suggestedAccountNames = uiState.suggestedAccountNames, - accountNameAlreadyExists = uiState.accountNameExists, - onUpdateAccountName = { model.updateAccountName(it, uiState.accountType) }, - showApostropheWarning = uiState.showApostropheWarning, - groupMethod = uiState.groupMethod, - groupMethodReadOnly = uiState.groupMethodReadOnly, - onUpdateGroupMethod = { model.updateGroupMethod(it) }, - onCreateAccount = { model.createAccount() }, - creatingAccount = uiState.creatingAccount - ) + when (uiState.accountType) { + AccountTypes.Murena.accountType -> { + MurenaAccountDetailsPageContent( + uiState = uiState, + onUpdateAccountName = { model.updateAccountName(it, uiState.accountType) }, + onUpdateGroupMethod = model::updateGroupMethod, + onCreateAccount = model::createAccount + ) + } + + else -> { + AccountDetailsPageContent( + accountName = uiState.accountName, + suggestedAccountNames = uiState.suggestedAccountNames, + accountNameAlreadyExists = uiState.accountNameExists, + onUpdateAccountName = { model.updateAccountName(it, uiState.accountType) }, + showApostropheWarning = uiState.showApostropheWarning, + groupMethod = uiState.groupMethod, + groupMethodReadOnly = uiState.groupMethodReadOnly, + onUpdateGroupMethod = model::updateGroupMethod, + onCreateAccount = model::createAccount, + creatingAccount = uiState.creatingAccount + ) + } + } } @OptIn(ExperimentalMaterial3Api::class) diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginActivity.kt index ef368647bdcf543034723172fcff0fe564726a17..6876acfd60618169e2b9fc324d20837c7b0276eb 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginActivity.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginActivity.kt @@ -11,6 +11,7 @@ import androidx.appcompat.app.AppCompatActivity import at.bitfire.davdroid.settings.Credentials import at.bitfire.davdroid.ui.account.AccountActivity import dagger.hilt.android.AndroidEntryPoint +import foundation.e.accountmanager.AccountTypes import java.net.URI import java.net.URISyntaxException import java.util.logging.Logger @@ -30,6 +31,8 @@ class LoginActivity @Inject constructor(): AppCompatActivity() { val (initialLoginType, skipLoginTypePage) = loginTypesProvider.intentToInitialLoginType(intent) + val isMurenaLogin = intent.getBooleanExtra(EXTRA_MURENA_LOGIN_FLOW, false) + setContent { LoginScreen( initialLoginType = initialLoginType, @@ -42,9 +45,11 @@ class LoginActivity @Inject constructor(): AppCompatActivity() { newAccount?.let { newAccount -> val intent = Intent(this, AccountActivity::class.java) intent.putExtra(AccountActivity.EXTRA_ACCOUNT, newAccount) + intent.putExtra(AccountActivity.EXTRA_MURENA_ACCOUNT, newAccount.type == AccountTypes.Murena.accountType) startActivity(intent) } - } + }, + isMurenaLogin = isMurenaLogin ) } } @@ -73,6 +78,10 @@ class LoginActivity @Inject constructor(): AppCompatActivity() { */ const val EXTRA_LOGIN_FLOW = "loginFlow" + /** + * When set, Murena OAuth Login Flow will be used. + */ + const val EXTRA_MURENA_LOGIN_FLOW = "murenaLoginFlow" /** * Extracts login information from given intent, validates it and returns it in [LoginInfo]. diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginScreen.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginScreen.kt index c0c32e110246da557c24e6dcdf70d0ac542eb593..4c7ee889c3c3a51c001f461f616d72ca14060e16 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginScreen.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginScreen.kt @@ -7,6 +7,7 @@ package at.bitfire.davdroid.ui.setup import android.accounts.Account import android.net.Uri import androidx.activity.compose.BackHandler +import androidx.activity.compose.LocalActivity import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -38,15 +39,21 @@ fun LoginScreen( skipLoginTypePage: Boolean = false, initialLoginType: LoginType = UrlLogin, onNavUp: () -> Unit, - onFinish: (Account?) -> Unit + onFinish: (Account?) -> Unit, + isMurenaLogin: Boolean = false ) { + val activity = LocalActivity.current val model: LoginScreenModel = hiltViewModel { factory: LoginScreenModel.Factory -> factory.create(initialLoginType, skipLoginTypePage, initialLoginInfo) } // handle back/up navigation BackHandler { - model.navBack() + if (isMurenaLogin) { + activity?.finish() + } else { + model.navBack() + } } if (model.finish) { onFinish(null) diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginScreenModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginScreenModel.kt index fc3a2690a5f9a46d7508e8a9d54afb3d2f79f8cf..11b81eeb537250ffe299050d573fa5f814fd9c47 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginScreenModel.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginScreenModel.kt @@ -297,7 +297,7 @@ class LoginScreenModel @AssistedInject constructor( } } - fun createAccount() { + fun createAccount(finishActivity: Boolean = false) { _accountDetailsUiState.update { currentState -> currentState.copy(creatingAccount = true) } @@ -322,6 +322,10 @@ class LoginScreenModel @AssistedInject constructor( couldNotCreateAccount = true ) } + + if (finishActivity) { + finish = true + } } } diff --git a/app/src/main/kotlin/foundation/e/accountmanager/AccountTypes.kt b/app/src/main/kotlin/foundation/e/accountmanager/AccountTypes.kt index c75787ca4192dc859f4f2c65b81ec6d602cc55e3..50e1ec65eb6956d2d6bd0fbcd9f49ecaf203a25b 100644 --- a/app/src/main/kotlin/foundation/e/accountmanager/AccountTypes.kt +++ b/app/src/main/kotlin/foundation/e/accountmanager/AccountTypes.kt @@ -27,8 +27,14 @@ enum class AccountTypes( // Duplicated in res/values/strings.xml because we can’t access Context in enums. // Update the values in res/values/strings.xml as well. Default( - accountType = "bitfire.at.davdroid", // R.string.account_type - addressBookType = "at.bitfire.davdroid.address_book", // R.string.account_type_address_book + accountType = "e.foundation.webdav", // R.string.account_type + addressBookType = "foundation.e.accountmanager.address_book", // R.string.account_type_address_book + ), + // Update the values in res/values/e_strings.xml as well. + Murena( + accountType = "e.foundation.webdav.eelo", // R.string.eelo_account_type + addressBookType = "foundation.e.accountmanager.eelo.address_book", // R.string.eelo_account_type_address_book + whitelistedDomains = listOf("murena.io", "eelo.one") ); companion object { diff --git a/app/src/main/kotlin/foundation/e/accountmanager/Authority.kt b/app/src/main/kotlin/foundation/e/accountmanager/Authority.kt new file mode 100644 index 0000000000000000000000000000000000000000..71ba6de4caffc6e403d57867b7a1c98503d302d1 --- /dev/null +++ b/app/src/main/kotlin/foundation/e/accountmanager/Authority.kt @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2025 eFoundation + * + * 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 + +// Make sure update in app/src/main/res/values/e_strings.xml as well +enum class Authority(val authority: String) { + Notes("foundation.e.notes.android.providers.AppContentProvider"), // R.string.notes_authority + Tasks("foundation.e.tasks"), // R.string.task_authority + Email("foundation.e.mail.provider.AppContentProvider"), // R.string.email_authority + Media("foundation.e.drive.providers.MediasSyncProvider"), // R.string.media_authority + AppData("foundation.e.drive.providers.SettingsSyncProvider"), // R.string.app_data_authority + MeteredEDrive("foundation.e.drive.providers.MeteredConnectionAllowedProvider"), // R.string.metered_edrive_authority +} diff --git a/app/src/main/kotlin/foundation/e/accountmanager/auth/AccountReceiver.kt b/app/src/main/kotlin/foundation/e/accountmanager/auth/AccountReceiver.kt new file mode 100644 index 0000000000000000000000000000000000000000..d212a690853fb3da998ddf47a5df361709e09a00 --- /dev/null +++ b/app/src/main/kotlin/foundation/e/accountmanager/auth/AccountReceiver.kt @@ -0,0 +1,107 @@ +/* + * Copyright (C) 2025 eFoundation + * + * 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.auth + +import android.accounts.AccountManager +import android.app.PendingIntent +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 +import foundation.e.accountmanager.utils.AccountHelper +import foundation.e.accountmanager.utils.SystemUtils.sendWithBackgroundLaunchAllowed +import java.util.logging.Level +import java.util.logging.Logger + +class AccountReceiver : BroadcastReceiver() { + val logger: Logger = Logger.getLogger(this.javaClass.name) + + override fun onReceive(context: Context?, intent: Intent?) { + if (context == null || intent == null) return + + when (intent.action) { + AccountManager.LOGIN_ACCOUNTS_CHANGED_ACTION -> handleAccountChange(context) + AccountManager.ACTION_ACCOUNT_REMOVED -> handleAccountRemoval(context, intent) + else -> logger.log(Level.WARNING, "Unhandled action: ${intent.action}") + } + } + + private fun handleAccountChange(context: Context) { + val accountManager = AccountManager.get(context) + val murenaAccounts = AccountHelper.getAllAccounts(accountManager) + .filter { it.type == AccountTypes.Murena.accountType } + + murenaAccounts.forEach { account -> + logger.log(Level.INFO, "Account change detected for ${account.name}") + val authState = accountManager.getUserData(account, KEY_AUTH_STATE) + AuthStatePrefUtils.saveAuthState(context, account, authState) + } + } + + private fun handleAccountRemoval(context: Context, intent: Intent) { + val accountType = intent.getStringExtra(AccountManager.KEY_ACCOUNT_TYPE) ?: return + val accountName = intent.getStringExtra(AccountManager.KEY_ACCOUNT_NAME) ?: return + + logger.log(Level.INFO, "Account removed - Type: $accountType, Name: $accountName") + + if (accountType != AccountTypes.Murena.accountType) { + logger.log(Level.INFO, "Unrecognized account type $accountType, ignoring") + return + } + + clearOidcSession(context, accountName, accountType) + + val accountManager = AccountManager.get(context) + val addressBooks = accountManager.getAccountsByType(AccountTypes.Murena.addressBookType) + addressBooks.forEach { + accountManager.removeAccountExplicitly(it) + } + } + + private fun clearOidcSession(context: Context, accountName: String, accountType: String) { + val authState = AuthStatePrefUtils.loadAuthState(context, accountName, accountType) + if (authState == null) { + logger.log(Level.SEVERE, "AuthState is null, cannot initiate end session for $accountName and $accountType") + return + } + + AuthStatePrefUtils.removeAuthState(context, accountName, accountType) + startOidcEndSessionActivity(context, authState, accountType) + } + + private fun startOidcEndSessionActivity(context: Context, authState: String, accountType: String) { + val intent = Intent(context, MurenaLogoutActivity::class.java).apply { + putExtra(AccountSettings.KEY_AUTH_STATE, authState) + putExtra(AccountManager.KEY_ACCOUNT_TYPE, accountType) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + val pendingIntent = PendingIntent.getActivity( + context, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + pendingIntent.sendWithBackgroundLaunchAllowed() + } +} diff --git a/app/src/main/kotlin/foundation/e/accountmanager/network/OAuthMurena.kt b/app/src/main/kotlin/foundation/e/accountmanager/network/OAuthMurena.kt new file mode 100644 index 0000000000000000000000000000000000000000..e053bb7dd0286502c9bf8c633f4266ad6c010f10 --- /dev/null +++ b/app/src/main/kotlin/foundation/e/accountmanager/network/OAuthMurena.kt @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2025 eFoundation + * + * 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.net.Uri +import androidx.core.net.toUri +import at.bitfire.davdroid.BuildConfig +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 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 val DOMAIN: String by lazy { URI(BuildConfig.MURENA_BASE_URL).host } + + val baseUri = "https://$DOMAIN" + val redirectUri = "${BuildConfig.MURENA_REDIRECT_URI}:/redirect".toUri() + val logoutRedirectUri = "${BuildConfig.MURENA_LOGOUT_REDIRECT_URI}:/redirect".toUri() + val discoveryUri = BuildConfig.MURENA_DISCOVERY_END_POINT.toUri() + + suspend fun fetchOAuthConfigSuspend(discoveryUrl: Uri): AuthorizationServiceConfiguration = + suspendCoroutine { cont -> + AuthorizationServiceConfiguration.fetchFromUrl(discoveryUrl) { config, ex -> + if (config != null) cont.resume(config) + else cont.resumeWithException(ex ?: Exception("Unknown error")) + } + } + + fun signIn(email: String?, locale: String?, config: AuthorizationServiceConfiguration): AuthorizationRequest { + val builder = AuthorizationRequest.Builder( + config, + CLIENT_ID, + ResponseTypeValues.CODE, + redirectUri + ) + return builder + .setScopes(*SCOPES) + .setLoginHint(email) + .setUiLocales(locale) + .build() + } + + fun signOut(authState: AuthState, config: AuthorizationServiceConfiguration): EndSessionRequest { + return EndSessionRequest.Builder(config) + .setPostLogoutRedirectUri(logoutRedirectUri) + .setAdditionalParameters(mapOf("client_id" to CLIENT_ID)) + .apply { + authState.idToken?.let { setIdTokenHint(it) } + }.build() + } +} diff --git a/app/src/main/kotlin/foundation/e/accountmanager/pref/AuthStatePrefUtils.kt b/app/src/main/kotlin/foundation/e/accountmanager/pref/AuthStatePrefUtils.kt new file mode 100644 index 0000000000000000000000000000000000000000..db5f3c71dfeab6d714747d4e3e8eaf10d0c5cb8a --- /dev/null +++ b/app/src/main/kotlin/foundation/e/accountmanager/pref/AuthStatePrefUtils.kt @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2025 eFoundation + * + * 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.pref + +import android.accounts.Account +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import foundation.e.accountmanager.AccountTypes + +object AuthStatePrefUtils { + + private const val PREF_NAME = "authStateShared_Pref" + + fun saveAuthState(context: Context, account: Account, value: String?) { + if (account.type != AccountTypes.Murena.accountType) return + + val key = buildKey(account.name, account.type) + getSharedPref(context) + .edit { + putString(key, value) + } + } + + fun loadAuthState(context: Context, name: String, type: String): String? { + val key = buildKey(name, type) + return getSharedPref(context).getString(key, null) + } + + fun removeAuthState(context: Context, name: String, type: String) { + val key = buildKey(name, type) + getSharedPref(context) + .edit { + remove(key) + } + } + + private fun getSharedPref(context: Context): SharedPreferences { + return context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) + } + + private fun buildKey(name: String, type: String): String { + return "$name==$type" + } +} diff --git a/app/src/main/kotlin/foundation/e/accountmanager/sync/account/MurenaAccountAuthenticatorService.kt b/app/src/main/kotlin/foundation/e/accountmanager/sync/account/MurenaAccountAuthenticatorService.kt new file mode 100644 index 0000000000000000000000000000000000000000..731b8eaaacfdb4b60c0c136ca2469df219ea4d92 --- /dev/null +++ b/app/src/main/kotlin/foundation/e/accountmanager/sync/account/MurenaAccountAuthenticatorService.kt @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2025 eFoundation + * + * 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.sync.account + +import android.accounts.AbstractAccountAuthenticator +import android.accounts.Account +import android.accounts.AccountAuthenticatorResponse +import android.accounts.AccountManager +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.core.os.bundleOf +import at.bitfire.davdroid.ui.setup.LoginActivity + +class MurenaAccountAuthenticatorService: Service() { + + private lateinit var accountAuthenticator: AccountAuthenticator + + override fun onCreate() { + accountAuthenticator = AccountAuthenticator(this) + } + + override fun onBind(intent: Intent?) = + accountAuthenticator.iBinder.takeIf { intent?.action == AccountManager.ACTION_AUTHENTICATOR_INTENT } + + private class AccountAuthenticator( + val context: Context + ): AbstractAccountAuthenticator(context) { + + override fun addAccount( + response: AccountAuthenticatorResponse?, + accountType: String?, + authTokenType: String?, + requiredFeatures: Array?, + options: Bundle? + ): Bundle { + val intent = Intent(context, LoginActivity::class.java) + intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response) + intent.putExtra(LoginActivity.EXTRA_MURENA_LOGIN_FLOW, true) + return bundleOf(AccountManager.KEY_INTENT to intent) + } + + override fun editProperties(response: AccountAuthenticatorResponse?, accountType: String?) = null + override fun getAuthTokenLabel(p0: String?) = null + override fun confirmCredentials(p0: AccountAuthenticatorResponse?, p1: Account?, p2: Bundle?) = null + override fun updateCredentials(p0: AccountAuthenticatorResponse?, p1: Account?, p2: String?, p3: Bundle?) = null + override fun getAuthToken(p0: AccountAuthenticatorResponse?, p1: Account?, p2: String?, p3: Bundle?) = null + override fun hasFeatures(p0: AccountAuthenticatorResponse?, p1: Account?, p2: Array?) = null + + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/foundation/e/accountmanager/sync/account/MurenaAddressBookAuthenticatorService.kt b/app/src/main/kotlin/foundation/e/accountmanager/sync/account/MurenaAddressBookAuthenticatorService.kt new file mode 100644 index 0000000000000000000000000000000000000000..3527e4d3eeb272716cac5fb28ca2d292990468f6 --- /dev/null +++ b/app/src/main/kotlin/foundation/e/accountmanager/sync/account/MurenaAddressBookAuthenticatorService.kt @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2025 eFoundation + * + * 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.sync.account + +import android.accounts.AbstractAccountAuthenticator +import android.accounts.Account +import android.accounts.AccountAuthenticatorResponse +import android.accounts.AccountManager +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.core.os.bundleOf +import at.bitfire.davdroid.ui.AccountsActivity + +class MurenaAddressBookAuthenticatorService: Service() { + + private lateinit var accountAuthenticator: AccountAuthenticator + + override fun onCreate() { + accountAuthenticator = AccountAuthenticator(this) + } + + override fun onBind(intent: Intent?) = + accountAuthenticator.iBinder.takeIf { intent?.action == AccountManager.ACTION_AUTHENTICATOR_INTENT } + + private class AccountAuthenticator( + val context: Context + ): AbstractAccountAuthenticator(context) { + + override fun addAccount(response: AccountAuthenticatorResponse?, accountType: String?, authTokenType: String?, requiredFeatures: Array?, options: Bundle?): Bundle { + val intent = Intent(context, AccountsActivity::class.java) + intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response) + return bundleOf(AccountManager.KEY_INTENT to intent) + } + + override fun editProperties(response: AccountAuthenticatorResponse?, accountType: String?) = null + override fun getAuthTokenLabel(p0: String?) = null + override fun confirmCredentials(p0: AccountAuthenticatorResponse?, p1: Account?, p2: Bundle?) = null + override fun updateCredentials(p0: AccountAuthenticatorResponse?, p1: Account?, p2: String?, p3: Bundle?) = null + override fun getAuthToken(p0: AccountAuthenticatorResponse?, p1: Account?, p2: String?, p3: Bundle?) = null + override fun hasFeatures(p0: AccountAuthenticatorResponse?, p1: Account?, p2: Array?) = null + + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/foundation/e/accountmanager/ui/setup/MurenaAccountDetailsPageContent.kt b/app/src/main/kotlin/foundation/e/accountmanager/ui/setup/MurenaAccountDetailsPageContent.kt new file mode 100644 index 0000000000000000000000000000000000000000..11726a069180a21ca5ed70ac7a751cb30bebc9c8 --- /dev/null +++ b/app/src/main/kotlin/foundation/e/accountmanager/ui/setup/MurenaAccountDetailsPageContent.kt @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2025 eFoundation + * + * 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.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringArrayResource +import at.bitfire.davdroid.R +import at.bitfire.davdroid.ui.setup.LoginScreenModel +import at.bitfire.vcard4android.GroupMethod + +@Composable +fun MurenaAccountDetailsPageContent( + uiState: LoginScreenModel.AccountDetailsUiState, + onUpdateAccountName: (String) -> Unit, + onUpdateGroupMethod: (GroupMethod) -> Unit, + onCreateAccount: (Boolean) -> Unit +) { + val firstSuggestedName = uiState.suggestedAccountNames.firstOrNull() + val groupMethodValues = stringArrayResource(R.array.settings_contact_group_method_values) + .map { GroupMethod.valueOf(it) } + val firstGroupMethod = groupMethodValues.lastOrNull() ?: groupMethodValues.firstOrNull() + + // Set default values + LaunchedEffect(Unit) { + if (uiState.accountName.isBlank() && firstSuggestedName != null) { + onUpdateAccountName(firstSuggestedName) + } + if (!uiState.groupMethodReadOnly && firstGroupMethod != null && uiState.groupMethod != firstGroupMethod) { + onUpdateGroupMethod(firstGroupMethod) + } + } + + // Trigger account creation automatically + LaunchedEffect(uiState.accountName, uiState.accountNameExists, uiState.creatingAccount) { + if (!uiState.creatingAccount && + uiState.accountName.isNotBlank() && + !uiState.accountNameExists + ) { + onCreateAccount(true) + } + } + + // Show loading screen + if (uiState.creatingAccount) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } +} 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 new file mode 100644 index 0000000000000000000000000000000000000000..56ceec360ff613f1e550ff6a7c182665ee476e1f --- /dev/null +++ b/app/src/main/kotlin/foundation/e/accountmanager/ui/setup/MurenaLogin.kt @@ -0,0 +1,392 @@ +/* + * Copyright (C) 2025 eFoundation + * + * 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.AccountManager +import android.content.Context +import android.net.Uri +import androidx.activity.compose.LocalActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +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.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +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.setup.LoginInfo +import at.bitfire.davdroid.ui.setup.LoginType +import foundation.e.accountmanager.AccountTypes +import foundation.e.accountmanager.network.OAuthMurena +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.util.logging.Level +import java.util.logging.Logger + +object MurenaLogin : LoginType { + override val title: Int + get() = R.string.eelo_account_name + + override val helpUrl: Uri + get() = "https://doc.e.foundation/support-topics".toUri() + + 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, + initialLoginInfo: LoginInfo, + onLogin: (LoginInfo) -> Unit + ) { + val context = LocalContext.current + var showAccountDialog by remember { mutableStateOf(false) } + + if (alreadyHasAccount(context)) { + showAccountDialog = true + } + + if (showAccountDialog) { + MultipleECloudAccountNotAcceptedDialog { + showAccountDialog = false + } + return + } + + val model: MurenaLoginModel = hiltViewModel( + creationCallback = { factory: MurenaLoginModel.Factory -> + factory.create(loginInfo = initialLoginInfo) + } + ) + + // continue to resource detection when result is set in model + val uiState = model.uiState + LaunchedEffect(uiState.result) { + if (uiState.result != null) { + onLogin(uiState.result) + model.resetResult() + } + } + + // contract to open the browser for authentication + val authRequestContract = rememberLauncherForActivityResult(model.authorizationContract()) { authResponse -> + if (authResponse != null) + model.authenticate(authResponse) + else + model.authCodeFailed() + } + + LaunchedEffect(uiState.error) { + if (uiState.error != null) { + snackbarHostState.showSnackbar(uiState.error) + } + } + + val isLoading by model.isLoading.observeAsState(false) + if (isLoading) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + return + } + + MurenaLoginScreen( + email = uiState.email, + serverUri = uiState.baseUri, + discoveryEndPoint = uiState.discoveryEndPoint, + onSetEmail = model::setEmail, + onSetServerUrl = model::setCustomBareUri, + onSetDiscoveryEndPoint = model::setDiscoveryEndPoint, + canContinue = uiState.canContinue, + onLogin = { + if (uiState.canContinue) { + CoroutineScope(Dispatchers.Main).launch { + try { + val authRequest = model.signIn() + authRequestContract.launch(authRequest) + } catch (e: Exception) { + Logger.getGlobal().log(Level.WARNING, "Couldn't start OAuth intent", e) + model.signInFailed() + } + } + } + } + ) + } +} + +@Composable +fun MurenaLoginScreen( + email: String, + serverUri: String, + discoveryEndPoint: String, + onSetEmail: (String) -> Unit = {}, + onSetServerUrl: (String) -> Unit = {}, + onSetDiscoveryEndPoint: (String) -> Unit = {}, + canContinue: Boolean, + onLogin: () -> Unit = {}, +) { + var showServerField by remember { mutableStateOf(false) } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp) + .verticalScroll(rememberScrollState()), + 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)) + + val focusRequester = remember { FocusRequester() } + OutlinedTextField( + value = email, + onValueChange = onSetEmail, + singleLine = true, + label = { Text(stringResource(R.string.login_user_id)) }, + placeholder = { Text("example@murena.io") }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Done + ), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + ) + + // Suggestion buttons to add domain suffixes + if (!email.contains("@")) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + ) { + OutlinedButton( + onClick = { onSetEmail("$email@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 = { onSetEmail("$email@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 + ) + } + } + } + + if (showServerField) { + OutlinedTextField( + value = serverUri, + onValueChange = onSetServerUrl, + singleLine = true, + label = { Text(stringResource(R.string.login_server_url)) }, + placeholder = { Text(OAuthMurena.baseUri) }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Done + ), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + ) + OutlinedTextField( + value = discoveryEndPoint, + onValueChange = onSetDiscoveryEndPoint, + label = { Text(stringResource(R.string.login_discovery_endpoint)) }, + placeholder = { Text(OAuthMurena.discoveryUri.toString()) }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Done + ), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + ) + } else { + onSetServerUrl("") + onSetDiscoveryEndPoint("") + } + + LaunchedEffect(Unit) { + if (email.isEmpty()) focusRequester.requestFocus() + } + + Spacer(modifier = Modifier.height(14.dp)) + + Button( + onClick = onLogin, + enabled = canContinue, + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + ) { + Text(stringResource(R.string.login_nextcloud_login_flow_sign_in)) + } + + Spacer(modifier = Modifier.height(16.dp)) + + Column ( + modifier = Modifier + .clickable { showServerField = !showServerField } + .padding(vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(R.string.login_eelo_server_uri_title), + color = Color.Gray, + style = MaterialTheme.typography.bodyMedium + ) + + Spacer(modifier = Modifier.width(4.dp)) + + Icon( + imageVector = if (showServerField) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = if (showServerField) stringResource(R.string.collapse) else stringResource(R.string.expand), + tint = Color.Gray + ) + } + } +} + +@Composable +@Preview(showBackground = true) +fun MurenaLoginScreen_Preview_Empty() { + MurenaLoginScreen( + email = "", + serverUri = "", + discoveryEndPoint = "", + canContinue = false, + ) +} + +@Composable +@Preview(showBackground = true) +fun MurenaLoginScreen_Preview_WithDefaultEmail() { + MurenaLoginScreen( + email = "example@murena.io", + serverUri = OAuthMurena.baseUri, + discoveryEndPoint = OAuthMurena.discoveryUri.toString(), + canContinue = true, + ) +} diff --git a/app/src/main/kotlin/foundation/e/accountmanager/ui/setup/MurenaLoginModel.kt b/app/src/main/kotlin/foundation/e/accountmanager/ui/setup/MurenaLoginModel.kt new file mode 100644 index 0000000000000000000000000000000000000000..dbe8ea322df2386659b394dc07449827a130ae39 --- /dev/null +++ b/app/src/main/kotlin/foundation/e/accountmanager/ui/setup/MurenaLoginModel.kt @@ -0,0 +1,160 @@ +/* + * Copyright (C) 2025 eFoundation + * + * 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.content.Context +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.core.net.toUri +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import at.bitfire.davdroid.R +import at.bitfire.davdroid.network.OAuthIntegration +import at.bitfire.davdroid.settings.Credentials +import at.bitfire.davdroid.ui.setup.LoginInfo +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import foundation.e.accountmanager.network.OAuthMurena +import kotlinx.coroutines.launch +import net.openid.appauth.AuthorizationRequest +import net.openid.appauth.AuthorizationResponse +import net.openid.appauth.AuthorizationService +import java.net.URI +import java.util.Locale +import java.util.logging.Level +import java.util.logging.Logger + +@HiltViewModel(assistedFactory = MurenaLoginModel.Factory::class) +class MurenaLoginModel @AssistedInject constructor( + @Assisted val initialLoginInfo: LoginInfo, + @ApplicationContext val context: Context, + private val authService: AuthorizationService, + private val logger: Logger, +): ViewModel() { + + private val _isLoading = MutableLiveData(false) + val isLoading: LiveData get() = _isLoading + + @AssistedFactory + interface Factory { + fun create(loginInfo: LoginInfo): MurenaLoginModel + } + + override fun onCleared() { + authService.dispose() + } + + data class UiState( + val email: String = "", + val baseUri: String = OAuthMurena.baseUri, + val discoveryEndPoint: String = OAuthMurena.discoveryUri.toString(), + val error: String? = null, + + /** login info (set after successful login) */ + val result: LoginInfo? = null + ) { + val canContinue = email.isNotEmpty() && baseUri.startsWith("https", ignoreCase = true) + && (email.endsWith("@murena.io", ignoreCase = true) || email.endsWith("@e.email", ignoreCase = true)) + val emailWithDomain = if (email.contains("@")) email else "$email@murena.io" + } + + var uiState by mutableStateOf(UiState()) + private set + + init { + uiState = uiState.copy( + email = initialLoginInfo.credentials?.username ?: "", + error = null, + result = null + ) + } + + fun setEmail(email: String) { + uiState = uiState.copy(email = email) + } + + fun setCustomBareUri(uri: String) { + uiState = if (uri.isEmpty()) { + uiState.copy(baseUri = OAuthMurena.baseUri) + } else { + uiState.copy(baseUri = uri) + } + } + + fun setDiscoveryEndPoint(uri: String) { + uiState = if (uri.isEmpty()) { + uiState.copy(discoveryEndPoint = OAuthMurena.discoveryUri.toString()) + } else { + uiState.copy(discoveryEndPoint = uri) + } + } + + fun authorizationContract() = OAuthIntegration.AuthorizationContract(authService) + + suspend fun signIn(): AuthorizationRequest { + _isLoading.postValue(true) + try { + val config = OAuthMurena.fetchOAuthConfigSuspend(uiState.discoveryEndPoint.toUri()) + return OAuthMurena.signIn( + email = uiState.emailWithDomain, + locale = Locale.getDefault().toLanguageTag(), + config = config + ) + } finally { + _isLoading.postValue(false) + } + } + + fun signInFailed() { + uiState = uiState.copy(error = context.getString(R.string.install_browser)) + } + + fun authenticate(authResponse: AuthorizationResponse) { + viewModelScope.launch { + try { + val credentials = Credentials(authState = OAuthIntegration.authenticate(authService, authResponse)) + + // success, provide login info to continue + uiState = uiState.copy( + result = LoginInfo( + baseUri = URI(uiState.baseUri), + credentials = credentials, + suggestedAccountName = uiState.emailWithDomain + ) + ) + } catch (e: Exception) { + logger.log(Level.WARNING, "Murena authentication failed", e) + uiState = uiState.copy(error = e.message) + } + } + } + + fun authCodeFailed() { + uiState = uiState.copy(error = context.getString(R.string.login_oauth_couldnt_obtain_auth_code)) + } + + fun resetResult() { + uiState = uiState.copy(result = null) + } +} diff --git a/app/src/main/kotlin/foundation/e/accountmanager/ui/setup/MurenaLogoutActivity.kt b/app/src/main/kotlin/foundation/e/accountmanager/ui/setup/MurenaLogoutActivity.kt new file mode 100644 index 0000000000000000000000000000000000000000..5d37d606b40a03632e159f09704ec479e8438aa4 --- /dev/null +++ b/app/src/main/kotlin/foundation/e/accountmanager/ui/setup/MurenaLogoutActivity.kt @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2025 eFoundation + * + * 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.AccountManager +import android.os.Bundle +import androidx.activity.ComponentActivity +import at.bitfire.davdroid.settings.AccountSettings +import dagger.hilt.android.AndroidEntryPoint +import foundation.e.accountmanager.network.OAuthMurena +import net.openid.appauth.AuthState +import net.openid.appauth.AuthorizationService +import java.util.logging.Level +import java.util.logging.Logger +import javax.inject.Inject +import javax.inject.Provider + +@AndroidEntryPoint +class MurenaLogoutActivity : ComponentActivity() { + + private val logger: Logger = Logger.getLogger(this::class.java.name) + + @Inject + lateinit var authServiceProvider: Provider + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val authStateString = intent.getStringExtra(AccountSettings.KEY_AUTH_STATE) + val accountType = intent.getStringExtra(AccountManager.KEY_ACCOUNT_TYPE) + if (authStateString.isNullOrBlank() || accountType.isNullOrBlank()) { + logger.severe("Missing authState or accountType") + finish() + return + } + + val authState = try { + AuthState.jsonDeserialize(authStateString) + } catch (e: Exception) { + logger.log(Level.SEVERE, "Failed to deserialize AuthState", e) + finish() + return + } + + val config = authState.authorizationServiceConfiguration + val authService = authServiceProvider.get() + if (config != null) { + val intent = authService.getEndSessionRequestIntent(OAuthMurena.signOut(authState, config)) + intent?.let { startActivity(it) } + ?: logger.severe("Failed to create end session intent") + } + + finish() + } +} 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 ba108be59e9f05baedb292dad79fc25ff425916b..9f93a6eb79853f955aba3fb750d3a87580f40299 100644 --- a/app/src/main/kotlin/foundation/e/accountmanager/utils/AccountHelper.kt +++ b/app/src/main/kotlin/foundation/e/accountmanager/utils/AccountHelper.kt @@ -19,9 +19,16 @@ package foundation.e.accountmanager.utils import android.accounts.Account import android.accounts.AccountManager +import android.content.ComponentName +import android.content.Context +import android.content.Intent import foundation.e.accountmanager.AccountTypes 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." + fun getAllAccounts(accountManager: AccountManager): Array { val allAccounts = mutableListOf() for (type in AccountTypes.getAccountTypes()) { @@ -37,4 +44,12 @@ object AccountHelper { } return allAccounts.toTypedArray() } + + fun syncMailAccounts(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" + context.sendBroadcast(intent) + } } diff --git a/app/src/main/kotlin/foundation/e/accountmanager/utils/SystemUtils.kt b/app/src/main/kotlin/foundation/e/accountmanager/utils/SystemUtils.kt index 89e31b10c5fd351b700e1adf965954a88ae131aa..1739bf2bfb727c57fb181781c7f6f43b88c1f9fd 100644 --- a/app/src/main/kotlin/foundation/e/accountmanager/utils/SystemUtils.kt +++ b/app/src/main/kotlin/foundation/e/accountmanager/utils/SystemUtils.kt @@ -17,12 +17,13 @@ */ package foundation.e.accountmanager.utils +import android.app.ActivityOptions +import android.app.PendingIntent import android.content.Context import android.net.ConnectivityManager import android.net.Network import android.net.NetworkCapabilities import android.os.Build -import androidx.annotation.RequiresApi object SystemUtils { @@ -43,4 +44,20 @@ object SystemUtils { return false } -} \ No newline at end of file + fun PendingIntent.sendWithBackgroundLaunchAllowed() = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) { + send( + ActivityOptions.makeBasic().setPendingIntentCreatorBackgroundActivityStartMode( + ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOW_ALWAYS + ).toBundle() + ) + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + send( + ActivityOptions.makeBasic().setPendingIntentBackgroundActivityStartMode( + ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED + ).toBundle() + ) + } else { + send() + } +} diff --git a/app/src/main/res/values/e_strings.xml b/app/src/main/res/values/e_strings.xml index 072674a3efc47edeecdf14abc2c30e243c609036..1356803cbb23f1ba96265b65ed9607d8fc00418a 100644 --- a/app/src/main/res/values/e_strings.xml +++ b/app/src/main/res/values/e_strings.xml @@ -2,7 +2,31 @@ Account Manager Welcome to Account Manager! + + e.foundation.webdav.eelo + foundation.e.accountmanager.eelo.address_book + Murena.io Manage accounts + My Account + Murena.io Account overview + Murena.io Address book + + foundation.e.notes.android.providers.AppContentProvider + foundation.e.tasks + foundation.e.mail.provider.AppContentProvider + foundation.e.drive.providers.MediasSyncProvider + foundation.e.drive.providers.SettingsSyncProvider + foundation.e.drive.providers.MeteredConnectionAllowedProvider + Tasks /e/OS Tasks app. + Default apps sync. interval + You can configure only one Murena.io account on your device + Use your Murena ID to sign in (for example @e.email or @murena.io): + User ID + Server URL + Discovery Endpoint + Use a specific server + Collapse + Expand diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d2f27833d938f8685b9c507c3dd8e1b87cf94224..6a07e00f1d059d2cb2495f6a1d9c1602bb52e6a0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8,8 +8,8 @@ DAVx⁵ Account does not exist (anymore) - bitfire.at.davdroid - at.bitfire.davdroid.address_book + e.foundation.webdav + foundation.e.accountmanager.address_book DAVx⁵ Address book Don\'t change the account here! Directly use the app to manage accounts instead. Delete @@ -485,7 +485,7 @@ Show details - at.bitfire.davdroid.webdav + foundation.e.accountmanager.webdav WebDAV mounts Quota used: %1$s / available: %2$s Share content diff --git a/app/src/main/res/xml/e_sync_app_data.xml b/app/src/main/res/xml/e_sync_app_data.xml new file mode 100644 index 0000000000000000000000000000000000000000..b9260adc5362f3edb182d2c66a18c0a2eab10ee7 --- /dev/null +++ b/app/src/main/res/xml/e_sync_app_data.xml @@ -0,0 +1,5 @@ + diff --git a/app/src/main/res/xml/e_sync_calendars.xml b/app/src/main/res/xml/e_sync_calendars.xml new file mode 100644 index 0000000000000000000000000000000000000000..15676ce891ef4fa1780d2e72692adc595d85ca9d --- /dev/null +++ b/app/src/main/res/xml/e_sync_calendars.xml @@ -0,0 +1,7 @@ + diff --git a/app/src/main/res/xml/e_sync_contacts.xml b/app/src/main/res/xml/e_sync_contacts.xml new file mode 100644 index 0000000000000000000000000000000000000000..516960919a4683ee2ff8ea478664a2073078ceef --- /dev/null +++ b/app/src/main/res/xml/e_sync_contacts.xml @@ -0,0 +1,6 @@ + diff --git a/app/src/main/res/xml/e_sync_e_notes.xml b/app/src/main/res/xml/e_sync_e_notes.xml new file mode 100644 index 0000000000000000000000000000000000000000..8d7d768045231b5453ff101b724cfa94c6ed5696 --- /dev/null +++ b/app/src/main/res/xml/e_sync_e_notes.xml @@ -0,0 +1,6 @@ + diff --git a/app/src/main/res/xml/e_sync_e_opentasks.xml b/app/src/main/res/xml/e_sync_e_opentasks.xml new file mode 100644 index 0000000000000000000000000000000000000000..28478f03e4b8af603ef258d12dec2137cb46f8ee --- /dev/null +++ b/app/src/main/res/xml/e_sync_e_opentasks.xml @@ -0,0 +1,6 @@ + diff --git a/app/src/main/res/xml/e_sync_email.xml b/app/src/main/res/xml/e_sync_email.xml new file mode 100644 index 0000000000000000000000000000000000000000..4213c8e970b012546bcaf62c355335ead8ef5682 --- /dev/null +++ b/app/src/main/res/xml/e_sync_email.xml @@ -0,0 +1,5 @@ + diff --git a/app/src/main/res/xml/e_sync_media.xml b/app/src/main/res/xml/e_sync_media.xml new file mode 100644 index 0000000000000000000000000000000000000000..5a05bbd9ae68990b15d89bf06b8893cb90b6921b --- /dev/null +++ b/app/src/main/res/xml/e_sync_media.xml @@ -0,0 +1,5 @@ + diff --git a/app/src/main/res/xml/e_sync_metered_edrive.xml b/app/src/main/res/xml/e_sync_metered_edrive.xml new file mode 100644 index 0000000000000000000000000000000000000000..efb8125b7102794ec45f5d057a8a541cba227015 --- /dev/null +++ b/app/src/main/res/xml/e_sync_metered_edrive.xml @@ -0,0 +1,5 @@ + diff --git a/app/src/main/res/xml/e_sync_notes.xml b/app/src/main/res/xml/e_sync_notes.xml new file mode 100644 index 0000000000000000000000000000000000000000..a314d84b6f6118a677dbd48df2b30e21543394f3 --- /dev/null +++ b/app/src/main/res/xml/e_sync_notes.xml @@ -0,0 +1,7 @@ + diff --git a/app/src/main/res/xml/e_sync_opentasks.xml b/app/src/main/res/xml/e_sync_opentasks.xml new file mode 100644 index 0000000000000000000000000000000000000000..76b3f89c52b385091e23dd021db78151c4caa128 --- /dev/null +++ b/app/src/main/res/xml/e_sync_opentasks.xml @@ -0,0 +1,7 @@ + diff --git a/app/src/main/res/xml/e_sync_prefs.xml b/app/src/main/res/xml/e_sync_prefs.xml new file mode 100644 index 0000000000000000000000000000000000000000..07022ca49a4fa7ab0573d69872b3939f29493580 --- /dev/null +++ b/app/src/main/res/xml/e_sync_prefs.xml @@ -0,0 +1,17 @@ + + + + + + + + diff --git a/app/src/main/res/xml/e_sync_tasks_org.xml b/app/src/main/res/xml/e_sync_tasks_org.xml new file mode 100644 index 0000000000000000000000000000000000000000..bb574a4a2e672cbd42980e842e7d2924dcf31655 --- /dev/null +++ b/app/src/main/res/xml/e_sync_tasks_org.xml @@ -0,0 +1,7 @@ + diff --git a/app/src/main/res/xml/murena_account_authenticator.xml b/app/src/main/res/xml/murena_account_authenticator.xml new file mode 100644 index 0000000000000000000000000000000000000000..19f93051160293ddbc4c2b74c7a562273087f651 --- /dev/null +++ b/app/src/main/res/xml/murena_account_authenticator.xml @@ -0,0 +1,6 @@ + diff --git a/app/src/main/res/xml/murena_account_authenticator_address_book.xml b/app/src/main/res/xml/murena_account_authenticator_address_book.xml new file mode 100644 index 0000000000000000000000000000000000000000..f6ee68b687ec55e90bbce421d8c9310478bef3de --- /dev/null +++ b/app/src/main/res/xml/murena_account_authenticator_address_book.xml @@ -0,0 +1,6 @@ + diff --git a/app/src/ose/AndroidManifest.xml b/app/src/ose/AndroidManifest.xml index 6c637e19b42ca3da18315e7029c1d38c4d55860d..f645872f4340bd3dc30d04ad328f0b0727dc63c1 100644 --- a/app/src/ose/AndroidManifest.xml +++ b/app/src/ose/AndroidManifest.xml @@ -5,6 +5,14 @@ + + + + + + + + @@ -19,9 +27,208 @@ tools:ignore="AppLinkUrlError" android:scheme="${applicationId}" android:path="/oauth2/redirect"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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 1642a6117b341481a914ba1bd6e48a9633273e2d..0359c00730050e7d1153de8d0452136873dbc037 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 @@ -8,6 +8,7 @@ import android.content.Intent import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import at.bitfire.davdroid.ui.setup.LoginTypesProvider.LoginAction +import foundation.e.accountmanager.ui.setup.MurenaLogin import java.util.logging.Logger import javax.inject.Inject @@ -23,6 +24,7 @@ class StandardLoginTypesProvider @Inject constructor( ) val specificLoginTypes = listOf( + MurenaLogin, FastmailLogin, GoogleLogin, NextcloudLogin @@ -36,6 +38,9 @@ class StandardLoginTypesProvider @Inject constructor( when { intent.hasExtra(LoginActivity.EXTRA_LOGIN_FLOW) -> LoginAction(NextcloudLogin, true) + intent.hasExtra(LoginActivity.EXTRA_MURENA_LOGIN_FLOW) -> { + LoginAction(MurenaLogin, true) + } uri?.scheme == "mailto" -> LoginAction(EmailLogin, true) listOf("caldavs", "carddavs", "davx5", "http", "https").any { uri?.scheme == it } -> diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 977295c25ce10f369bf9fc812518aba39567d69b..176addc3c5f571b25220dfa99e8b7cde401ebd78 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -52,6 +52,7 @@ elib = "0.0.1-alpha11" ezVcard = "0.12.1" ical4j = "3.2.19" synctools = "58bc6752" +runtimeLivedata = "1.8.3" [libraries] android-desugaring = { module = "com.android.tools:desugar_jdk_libs_nio", version.ref = "android-desugaring" } @@ -119,6 +120,7 @@ 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" } [plugins] android-application = { id = "com.android.application", version.ref = "android-agp" }