From 2a8484c8e6b5151e61262f89a99bb13b213731c9 Mon Sep 17 00:00:00 2001 From: Jonathan Klee Date: Fri, 24 Apr 2026 10:03:05 +0200 Subject: [PATCH 1/4] feat: remove dependency to e browser --- app/src/main/AndroidManifest.xml | 2 ++ .../kotlin/at/bitfire/davdroid/Constants.kt | 1 - .../bitfire/davdroid/network/OAuthModule.kt | 27 ++++++++++++++++--- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index be9978293..be2a2ce0f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -819,6 +819,8 @@ + + diff --git a/app/src/main/kotlin/at/bitfire/davdroid/Constants.kt b/app/src/main/kotlin/at/bitfire/davdroid/Constants.kt index 40f23a5a3..0ddf36488 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/Constants.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/Constants.kt @@ -35,5 +35,4 @@ object Constants { const val MURENA_DAV_URL = "https://murena.io/remote.php/dav" - const val E_BROWSER_PACKAGE_NAME = "foundation.e.browser" } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/network/OAuthModule.kt b/app/src/main/kotlin/at/bitfire/davdroid/network/OAuthModule.kt index 90c3aa7ec..6b4210bac 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/network/OAuthModule.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/network/OAuthModule.kt @@ -5,7 +5,7 @@ package at.bitfire.davdroid.network import android.content.Context -import at.bitfire.davdroid.Constants +import android.content.pm.PackageManager import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -20,9 +20,13 @@ import java.net.URL @InstallIn(SingletonComponent::class) object OAuthModule { + private const val E_BROWSER_PACKAGE_NAME = "foundation.e.browser" + @Provides - fun authorizationService(@ApplicationContext context: Context): AuthorizationService = - AuthorizationService(context, + fun authorizationService(@ApplicationContext context: Context): AuthorizationService { + val isEBrowserInstalled = isInstalled(context, E_BROWSER_PACKAGE_NAME) + return AuthorizationService( + context, AppAuthConfiguration.Builder() .setConnectionBuilder { uri -> val url = URL(uri.toString()) @@ -30,7 +34,22 @@ object OAuthModule { setRequestProperty("User-Agent", HttpClient.UserAgentInterceptor.userAgent) } } - .setBrowserMatcher { it.packageName == Constants.E_BROWSER_PACKAGE_NAME } + .setBrowserMatcher { browser -> + if (isEBrowserInstalled) { + browser.packageName == E_BROWSER_PACKAGE_NAME + } else { + true + } + } .build() ) + } + + private fun isInstalled(context: Context, packageName: String): Boolean = + try { + context.packageManager.getPackageInfo(packageName, 0) + true + } catch (_: PackageManager.NameNotFoundException) { + false + } } -- GitLab From 94f2bdf3aa7d141facc0cad6a3f662eb11741a0d Mon Sep 17 00:00:00 2001 From: Jonathan Klee Date: Fri, 24 Apr 2026 10:04:56 +0200 Subject: [PATCH 2/4] feat: display toast error if browser is not present --- .../setup/OpenIdAuthenticationBaseFragment.kt | 11 ++++++++++- .../ui/setup/OpenIdAuthenticationViewModel.kt | 19 +++++++++++++------ app/src/main/res/values-de/strings.xml | 3 ++- app/src/main/res/values-es/strings.xml | 3 ++- app/src/main/res/values-fr/strings.xml | 3 ++- app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 7 files changed, 31 insertions(+), 10 deletions(-) diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/OpenIdAuthenticationBaseFragment.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/OpenIdAuthenticationBaseFragment.kt index 5a417b264..11854ef58 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/OpenIdAuthenticationBaseFragment.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/OpenIdAuthenticationBaseFragment.kt @@ -123,7 +123,11 @@ abstract class OpenIdAuthenticationBaseFragment(private val identityProvider: Id return } - viewModel.requestAuthCode(serviceConfiguration, requireActivity().intent) + val isAuthorizationLaunched = viewModel.requestAuthCode(serviceConfiguration, requireActivity().intent) + if (!isAuthorizationLaunched) { + showAuthorizationToast() + return + } requireActivity().setResult(Activity.RESULT_OK) requireActivity().finish() } @@ -204,6 +208,11 @@ abstract class OpenIdAuthenticationBaseFragment(private val identityProvider: Id finishActivity() } + private fun showAuthorizationToast() { + Toast.makeText(requireContext(), R.string.login_oauth_couldnt_open_authorization, Toast.LENGTH_LONG).show() + finishActivity() + } + protected fun getAuthState(): AuthState = viewModel.getAuthState() protected fun proceedNext(userName: String, baseUrl: String, cardDavUrl: String? = null) { diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/OpenIdAuthenticationViewModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/OpenIdAuthenticationViewModel.kt index a0d2d1e20..9783966e3 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/OpenIdAuthenticationViewModel.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/OpenIdAuthenticationViewModel.kt @@ -18,6 +18,7 @@ package at.bitfire.davdroid.ui.setup import android.app.Application import android.app.PendingIntent +import android.content.ActivityNotFoundException import android.content.Intent import android.os.Build import androidx.annotation.WorkerThread @@ -93,7 +94,7 @@ class OpenIdAuthenticationViewModel @Inject constructor( fun requestAuthCode( serviceConfiguration: AuthorizationServiceConfiguration, intent: Intent - ) { + ): Boolean { authState = AuthState(serviceConfiguration) val provider = requireNotNull(identityProvider) { "identityProvider must be set before requestAuthCode()" } @@ -117,11 +118,17 @@ class OpenIdAuthenticationViewModel @Inject constructor( .setLoginHint(sanitizeHint(loginHint)) .build() - authorizationService.performAuthorizationRequest( - authRequest, - createPostAuthorizationIntent(authRequest, intent), - authorizationService.createCustomTabsIntentBuilder().build() - ) + return try { + authorizationService.performAuthorizationRequest( + authRequest, + createPostAuthorizationIntent(authRequest, intent), + authorizationService.createCustomTabsIntentBuilder().build() + ) + true + } catch (e: ActivityNotFoundException) { + Logger.log.log(Level.WARNING, "Couldn't start OpenID authorization intent", e) + false + } } private fun sanitizeHint(hint: String?): String? { diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 057d8f613..12910ff57 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -498,6 +498,7 @@ Datenschutzrichtliniefür mehr Informationen.]]> Google API Services Nutzerdaten-Richtlinie, inklusive der eingeschränkten Nutzungsbedingungen.]]> Authentifizierungscode konnte nicht abgerufen werden + Autorisierungsseite konnte nicht geöffnet werden Wieder anmelden Erneut mit OAuth anmelden Benutzername @@ -517,4 +518,4 @@ VPN ohne zugrundeliegende überprüfte Internetverbindung reicht für Synchronisierung nicht aus (empfohlen) Anmeldung erforderlich Melden Sie sich an, um auf Murena Workspace online in /e/OS zuzugreifen. - \ No newline at end of file + diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index e8aaf30fd..c7100a28b 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -492,6 +492,7 @@ Cuenta Google OK No se pudo obtener el código de autorización + No se pudo abrir la página de autorización Error al iniciar sesión, inténtalo más tarde Puede que recibas advertencias inesperadas y/o tengas que crear tu propio ID de cliente. Esto iniciará el flujo de inicio de sesión de Nextcloud en un navegador web. @@ -516,4 +517,4 @@ Error de autenticación. Por favor, usar credenciales válidas Inicio de sesión requerido Inicia sesión para acceder a Murena Workspace en línea en /e/OS. - \ No newline at end of file + diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 2bf55040e..da24185d6 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -494,6 +494,7 @@ Compte Google Se connecter avec Google N\'a pas pu obtenir le code d\'autorisation + Impossible d\'ouvrir la page d\'autorisation nom d\'utilisateur Dernièrement synchronisé : Jamais synchronisé @@ -524,4 +525,4 @@ Plus tard Connexion requise Connectez-vous pour accéder à Murena Workspace en ligne dans /e/OS. - \ No newline at end of file + diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 25d39a8bf..73d8e7432 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -448,6 +448,7 @@ Per impostare un account protetto con l\'autenticazione a due fattori all\'interno di /e/OS, è necessario creare una application password dedicata nelle impostazioni dell\'account su murena.io. Autenticazione a Due Fattori Nome utente (indirizzo e-mail) / password errati\? + Impossibile aprire la pagina di autorizzazione Installa un certificato Non è stato trovato alcun certificato Usa il certificato client diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 61dafd445..79e157dfc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -326,6 +326,7 @@ Privacy policy for details.]]> Google API Services User Data Policy, including the Limited Use requirements.]]> Couldn\'t obtain authorization code + Couldn\'t open authorization page Nextcloud Login with Nextcloud Login Flow -- GitLab From 3bbc08f2b2176e372eac8ca3f3befdf325057e49 Mon Sep 17 00:00:00 2001 From: Jonathan Klee Date: Mon, 27 Apr 2026 15:34:55 +0200 Subject: [PATCH 3/4] feat: introduce SyncAdapterComponentManager --- .../main/kotlin/at/bitfire/davdroid/App.kt | 3 + .../receiver/BootCompletedReceiver.kt | 3 + .../SyncAdapterComponentManager.kt | 79 +++++++++++++++++++ 3 files changed, 85 insertions(+) create mode 100644 app/src/main/kotlin/at/bitfire/davdroid/syncadapter/SyncAdapterComponentManager.kt diff --git a/app/src/main/kotlin/at/bitfire/davdroid/App.kt b/app/src/main/kotlin/at/bitfire/davdroid/App.kt index 1f9f8ac9b..833c6d5fe 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/App.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/App.kt @@ -14,6 +14,7 @@ import androidx.hilt.work.HiltWorkerFactory import androidx.work.Configuration import at.bitfire.davdroid.log.Logger import at.bitfire.davdroid.syncadapter.AccountsUpdatedListener +import at.bitfire.davdroid.syncadapter.SyncAdapterComponentManager import at.bitfire.davdroid.syncadapter.SyncUtils import at.bitfire.davdroid.ui.DebugInfoActivity import at.bitfire.davdroid.ui.NotificationUtils @@ -100,6 +101,8 @@ class App: Application(), Thread.UncaughtExceptionHandler, Configuration.Provide // don't block UI for some background checks thread { + SyncAdapterComponentManager.updateComponents(this) + // watch for account changes/deletions accountsUpdatedListener.listen() diff --git a/app/src/main/kotlin/at/bitfire/davdroid/receiver/BootCompletedReceiver.kt b/app/src/main/kotlin/at/bitfire/davdroid/receiver/BootCompletedReceiver.kt index d8578412f..33a781c01 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/receiver/BootCompletedReceiver.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/receiver/BootCompletedReceiver.kt @@ -20,6 +20,7 @@ import at.bitfire.davdroid.murenasso.MurenaSsoMigrationService import at.bitfire.davdroid.murenasso.MurenaSsoMigrationService.Companion.NOTIFICATION_TAG_MURENA_SSO import at.bitfire.davdroid.settings.AccountSettings import at.bitfire.davdroid.syncadapter.AccountUtils +import at.bitfire.davdroid.syncadapter.SyncAdapterComponentManager import at.bitfire.davdroid.ui.NotificationUtils import at.bitfire.davdroid.ui.NotificationUtils.CHANNEL_GENERAL import at.bitfire.davdroid.ui.NotificationUtils.NOTIFY_MIGRATE_TO_MURENA_SSO @@ -55,6 +56,8 @@ class BootCompletedReceiver: BroadcastReceiver() { } private fun initializeSync(context: Context) { + SyncAdapterComponentManager.updateComponents(context) + AccountUtils.getMainAccounts(context) .forEach { // sync intervals are checked in App.onCreate() diff --git a/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/SyncAdapterComponentManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/SyncAdapterComponentManager.kt new file mode 100644 index 000000000..2efc34563 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/SyncAdapterComponentManager.kt @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2026 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 at.bitfire.davdroid.syncadapter + +import android.content.ComponentName +import android.content.Context +import android.content.pm.PackageManager +import at.bitfire.davdroid.R +import at.bitfire.davdroid.log.Logger +import java.util.logging.Level + +object SyncAdapterComponentManager { + + private data class SyncServiceSpec( + val serviceClass: Class<*>, + val authorityResId: Int + ) + + private val optionalSyncServices = listOf( + SyncServiceSpec(MurenaTasksSyncAdapterService::class.java, R.string.task_authority), + SyncServiceSpec(MurenaNotesSyncAdapterService::class.java, R.string.notes_authority), + SyncServiceSpec(MurenaEmailSyncAdapterService::class.java, R.string.email_authority), + SyncServiceSpec(MurenaMediaSyncAdapterService::class.java, R.string.media_authority), + SyncServiceSpec(MurenaAppDataSyncAdapterService::class.java, R.string.app_data_authority), + SyncServiceSpec(MurenaMeteredEdriveSyncAdapterService::class.java, R.string.metered_edrive_authority), + SyncServiceSpec(MurenaPasswordSyncAdapterService::class.java, R.string.password_authority), + SyncServiceSpec(GoogleTasksSyncAdapterService::class.java, R.string.task_authority), + SyncServiceSpec(GoogleEmailSyncAdapterService::class.java, R.string.email_authority), + SyncServiceSpec(YahooTasksSyncAdapterService::class.java, R.string.task_authority), + SyncServiceSpec(YahooEmailSyncAdapterService::class.java, R.string.email_authority) + ) + + fun updateComponents(context: Context) { + val packageManager = context.packageManager + + for (syncService in optionalSyncServices) { + val authority = context.getString(syncService.authorityResId) + val isProviderAvailable = hasProvider(packageManager, authority) + setComponentEnabled(packageManager, context, syncService.serviceClass, isProviderAvailable, authority) + } + } + + private fun hasProvider(packageManager: PackageManager, authority: String): Boolean = + packageManager.resolveContentProvider(authority, 0) != null + + private fun setComponentEnabled( + packageManager: PackageManager, + context: Context, + serviceClass: Class<*>, + enabled: Boolean, + authority: String + ) { + val component = ComponentName(context, serviceClass) + val desiredState = if (enabled) PackageManager.COMPONENT_ENABLED_STATE_ENABLED else PackageManager.COMPONENT_ENABLED_STATE_DISABLED + + if (packageManager.getComponentEnabledSetting(component) == desiredState) { + return + } + + packageManager.setComponentEnabledSetting(component, desiredState, PackageManager.DONT_KILL_APP) + Logger.log.log(Level.INFO, "Set ${serviceClass.simpleName} to ${if (enabled) "ENABLED" else "DISABLED"} (authority=$authority)") + } +} -- GitLab From 027e67a8e8ab499074b4801db8385b2f294c5d61 Mon Sep 17 00:00:00 2001 From: Jonathan Klee Date: Mon, 27 Apr 2026 15:57:57 +0200 Subject: [PATCH 4/4] feat: ask required runtime permission when adding new account --- .../bitfire/davdroid/ui/AccountsActivity.kt | 7 +++ .../davdroid/ui/PermissionsActivity.kt | 9 ++-- .../davdroid/ui/PermissionsFragment.kt | 43 ++++++++++++----- .../davdroid/ui/intro/IntroActivity.kt | 47 ++++++++++++------- .../davdroid/ui/intro/IntroFragmentFactory.kt | 2 + .../ui/intro/PermissionsIntroFragment.kt | 15 +++--- .../davdroid/ui/setup/LoginActivity.kt | 24 ++++++++++ .../bitfire/davdroid/util/PermissionUtils.kt | 11 +++++ 8 files changed, 117 insertions(+), 41 deletions(-) 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 a3d6e1b73..c79d58288 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsActivity.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsActivity.kt @@ -21,6 +21,7 @@ import at.bitfire.davdroid.settings.SettingsManager import at.bitfire.davdroid.syncadapter.AccountUtils import at.bitfire.davdroid.syncadapter.SyncWorker import at.bitfire.davdroid.ui.setup.LoginActivity +import at.bitfire.davdroid.util.PermissionUtils import com.google.android.material.navigation.NavigationView import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint @@ -35,6 +36,7 @@ class AccountsActivity : AppCompatActivity(), NavigationView.OnNavigationItemSel private lateinit var binding: ActivityAccountsBinding val model by viewModels() + private var hasAskedRequiredPermissions = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -79,6 +81,11 @@ class AccountsActivity : AppCompatActivity(), NavigationView.OnNavigationItemSel override fun onResume() { super.onResume() accountsDrawerHandler.initMenu(this, binding.navView.menu) + + if (!hasAskedRequiredPermissions && PermissionUtils.collectMissingRequiredPermissions(this).isNotEmpty()) { + hasAskedRequiredPermissions = true + startActivity(Intent(this, PermissionsActivity::class.java)) + } } override fun onNavigationItemSelected(item: MenuItem): Boolean { diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/PermissionsActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/PermissionsActivity.kt index 659538a08..f616c5a67 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/PermissionsActivity.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/PermissionsActivity.kt @@ -12,10 +12,11 @@ class PermissionsActivity: AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - if (savedInstanceState == null) + if (savedInstanceState == null) { supportFragmentManager.beginTransaction() - .add(android.R.id.content, PermissionsFragment()) - .commit() + .add(android.R.id.content, PermissionsFragment()) + .commit() + } } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/PermissionsFragment.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/PermissionsFragment.kt index 6a75b1e91..dafa126c7 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/PermissionsFragment.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/PermissionsFragment.kt @@ -16,6 +16,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.Toast +import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.MainThread import androidx.core.content.ContextCompat @@ -25,31 +26,43 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.MutableLiveData import at.bitfire.davdroid.BuildConfig import at.bitfire.davdroid.PackageChangedReceiver +import at.bitfire.davdroid.R +import at.bitfire.davdroid.databinding.ActivityPermissionsBinding import at.bitfire.davdroid.util.PermissionUtils import at.bitfire.davdroid.util.PermissionUtils.CALENDAR_PERMISSIONS import at.bitfire.davdroid.util.PermissionUtils.CONTACT_PERMISSIONS import at.bitfire.davdroid.util.PermissionUtils.havePermissions -import at.bitfire.davdroid.R -import at.bitfire.davdroid.databinding.ActivityPermissionsBinding import at.bitfire.ical4android.TaskProvider import at.bitfire.ical4android.TaskProvider.ProviderName class PermissionsFragment: Fragment() { val model by viewModels() - + private lateinit var requestPermission: ActivityResultLauncher> + private var hasAskedRequiredPermissions = false override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { val binding = ActivityPermissionsBinding.inflate(inflater, container, false) binding.lifecycleOwner = viewLifecycleOwner binding.model = model - binding.text.text = getString(R.string.permissions_text, getString(R.string.app_name)) - val requestPermission = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { + initPermissionLauncher() + observePermissionToggles() + binding.appSettings.setOnClickListener { + PermissionUtils.showAppSettings(requireActivity()) + } + + return binding.root + } + + private fun initPermissionLauncher() { + requestPermission = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { model.checkPermissions() } + } + private fun observePermissionToggles() { model.needAutoResetPermission.observe(viewLifecycleOwner) { keepPermissions -> if (keepPermissions == true && model.haveAutoResetPermission.value == false) { Toast.makeText(requireActivity(), R.string.permissions_autoreset_instruction, Toast.LENGTH_LONG).show() @@ -95,17 +108,25 @@ class PermissionsFragment: Fragment() { requestPermission.launch(all.toTypedArray()) } } - - binding.appSettings.setOnClickListener { - PermissionUtils.showAppSettings(requireActivity()) - } - - return binding.root } override fun onResume() { super.onResume() model.checkPermissions() + requestMissingPermissions() + } + + private fun requestMissingPermissions() { + if (hasAskedRequiredPermissions) { + return + } + + val missing = PermissionUtils.collectMissingRequiredPermissions(requireContext()) + + if (missing.isNotEmpty()) { + hasAskedRequiredPermissions = true + requestPermission.launch(missing.toTypedArray()) + } } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/IntroActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/IntroActivity.kt index 626f9f4b0..920d69654 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/IntroActivity.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/IntroActivity.kt @@ -13,6 +13,11 @@ import androidx.activity.addCallback import androidx.annotation.WorkerThread import androidx.core.content.res.ResourcesCompat import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import at.bitfire.davdroid.R import at.bitfire.davdroid.log.Logger import com.github.appintro.AppIntro2 @@ -50,23 +55,6 @@ class IntroActivity: AppIntro2() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val factories = EntryPointAccessors.fromActivity(this, IntroActivityEntryPoint::class.java).introFragmentFactories() - for (factory in factories) - Logger.log.fine("Found intro fragment factory ${factory::class.java} with order ${factory.getOrder(this)}") - - val factoriesWithOrder = factories - .associateWith { it.getOrder(this) } - .filterValues { it != IntroFragmentFactory.DONT_SHOW } - - val anyPositiveOrder = factoriesWithOrder.values.any { it > 0 } - if (anyPositiveOrder) { - val factoriesSortedByOrder = factoriesWithOrder - .toList() - .sortedBy { (_, v) -> v } // sort by value (= getOrder()) - for ((factory, _) in factoriesSortedByOrder) - addSlide(factory.create()) - } - setBarColor(ResourcesCompat.getColor(resources, R.color.accentColor, null)) isSkipButtonEnabled = false @@ -78,6 +66,31 @@ class IntroActivity: AppIntro2() { goToPreviousSlide() } } + + val factories = EntryPointAccessors.fromActivity(this, IntroActivityEntryPoint::class.java).introFragmentFactories() + + lifecycleScope.launch { + val factoriesWithOrder = withContext(Dispatchers.IO) { + factories.associateWith { it.getOrder(this@IntroActivity) } + .filterValues { it != IntroFragmentFactory.DONT_SHOW } + } + + for (factory in factories) { + Logger.log.fine("Found intro fragment factory ${factory::class.java} with order ${factoriesWithOrder[factory]}") + } + + if (!lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { + return@launch + } + + val anyPositiveOrder = factoriesWithOrder.values.any { it > 0 } + if (anyPositiveOrder) { + factoriesWithOrder + .toList() + .sortedBy { (_, v) -> v } + .forEach { (factory, _) -> addSlide(factory.create()) } + } + } } override fun onPageSelected(position: Int) { diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/IntroFragmentFactory.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/IntroFragmentFactory.kt index 3488f84de..11def1967 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/IntroFragmentFactory.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/IntroFragmentFactory.kt @@ -5,6 +5,7 @@ package at.bitfire.davdroid.ui.intro import android.content.Context +import androidx.annotation.WorkerThread import androidx.fragment.app.Fragment interface IntroFragmentFactory { @@ -25,6 +26,7 @@ interface IntroFragmentFactory { * * [DONT_SHOW] (0): don't show the fragment * * ≥0: show the fragment (lower numbers are shown first) */ + @WorkerThread fun getOrder(context: Context): Int /** diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/PermissionsIntroFragment.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/PermissionsIntroFragment.kt index 98df793fd..dbf9ebefe 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/PermissionsIntroFragment.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/PermissionsIntroFragment.kt @@ -10,11 +10,10 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment +import at.bitfire.davdroid.R import at.bitfire.davdroid.util.PermissionUtils import at.bitfire.davdroid.util.PermissionUtils.CALENDAR_PERMISSIONS import at.bitfire.davdroid.util.PermissionUtils.CONTACT_PERMISSIONS -import at.bitfire.davdroid.R -import at.bitfire.ical4android.TaskProvider import javax.inject.Inject class PermissionsIntroFragment : Fragment() { @@ -26,19 +25,17 @@ class PermissionsIntroFragment : Fragment() { class Factory @Inject constructor(): IntroFragmentFactory { override fun getOrder(context: Context): Int { - // show PermissionsFragment as intro fragment when no permissions are granted - val permissions = CONTACT_PERMISSIONS + CALENDAR_PERMISSIONS + - TaskProvider.PERMISSIONS_JTX + - TaskProvider.PERMISSIONS_OPENTASKS + - TaskProvider.PERMISSIONS_TASKS_ORG - return if (PermissionUtils.haveAnyPermission(context, permissions)) + val permissions = requiredPermissions() + return if (PermissionUtils.havePermissions(context, permissions)) IntroFragmentFactory.DONT_SHOW else 50 } + private fun requiredPermissions(): Array = CONTACT_PERMISSIONS + CALENDAR_PERMISSIONS + override fun create() = PermissionsIntroFragment() } -} \ No newline at end of file +} 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 a7dfc9976..1552c7624 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 @@ -5,9 +5,11 @@ package at.bitfire.davdroid.ui.setup import android.os.Bundle +import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.Fragment import at.bitfire.davdroid.log.Logger +import at.bitfire.davdroid.util.PermissionUtils import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject @@ -54,6 +56,10 @@ class LoginActivity : AppCompatActivity() { @Inject lateinit var loginFragmentFactories: Map + private var hasAskedRequiredPermissions = false + private val requestRequiredPermissions = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -79,4 +85,22 @@ class LoginActivity : AppCompatActivity() { } } } + + override fun onStart() { + super.onStart() + requestMissingPermissions() + } + + private fun requestMissingPermissions() { + if (hasAskedRequiredPermissions) { + return + } + + val missing = PermissionUtils.collectMissingRequiredPermissions(this) + + if (missing.isNotEmpty()) { + hasAskedRequiredPermissions = true + requestRequiredPermissions.launch(missing.toTypedArray()) + } + } } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/util/PermissionUtils.kt b/app/src/main/kotlin/at/bitfire/davdroid/util/PermissionUtils.kt index d23070485..ac793f476 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/util/PermissionUtils.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/util/PermissionUtils.kt @@ -104,6 +104,17 @@ object PermissionUtils { fun havePermissions(context: Context, permissions: Array) = permissions.all { ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED } + fun collectMissingRequiredPermissions(context: Context): List { + val required = CONTACT_PERMISSIONS + CALENDAR_PERMISSIONS + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + arrayOf(Manifest.permission.POST_NOTIFICATIONS) + else + emptyArray() + return required.filter { + ContextCompat.checkSelfPermission(context, it) != PackageManager.PERMISSION_GRANTED + } + } + /** * Shows a notification about missing permissions. * -- GitLab