diff --git a/app/build.gradle b/app/build.gradle index 31ef4acaf..07421f39c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -18,8 +18,8 @@ android { defaultConfig { applicationId "at.bitfire.davdroid" - versionCode 403080000 - versionName '4.3.8' + versionCode 403090000 + versionName '4.3.9-beta.1' buildConfigField "long", "buildTime", System.currentTimeMillis() + "L" diff --git a/app/proguard-rules-release.pro b/app/proguard-rules-release.pro index 9fac0cab2..eeaa9c7a1 100644 --- a/app/proguard-rules-release.pro +++ b/app/proguard-rules-release.pro @@ -30,9 +30,13 @@ # Additional rules which are now required since missing classes can't be ignored in R8 anymore. # [https://developer.android.com/build/releases/past-releases/agp-7-0-0-release-notes#r8-missing-class-warning] -dontwarn com.android.org.conscrypt.SSLParametersImpl +-dontwarn com.sun.jna.** # dnsjava -dontwarn groovy.** -dontwarn java.beans.Transient +-dontwarn javax.naming.NamingException # dnsjava +-dontwarn javax.naming.directory.** # dnsjava -dontwarn junit.textui.TestRunner +-dontwarn lombok.** # dnsjava -dontwarn org.apache.harmony.xnet.provider.jsse.SSLParametersImpl -dontwarn org.bouncycastle.jsse.** -dontwarn org.codehaus.groovy.** diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/AboutActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/AboutActivity.kt index a2079164a..92a6f3cac 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/AboutActivity.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/AboutActivity.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView import androidx.core.text.HtmlCompat +import androidx.core.view.MenuProvider import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentPagerAdapter @@ -81,14 +82,24 @@ class AboutActivity: AppCompatActivity() { binding.viewpager.adapter = TabsAdapter(supportFragmentManager) binding.tabs.setupWithViewPager(binding.viewpager, false) - } - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.activity_about, menu) - return true + addMenuProvider(object : MenuProvider { + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.activity_about, menu) + } + + override fun onMenuItemSelected(menuItem: MenuItem) = + when (menuItem.itemId) { + R.id.show_website -> { + showWebsite() + true + } + else -> false + } + }) } - fun showWebsite(item: MenuItem) { + fun showWebsite() { UiUtils.launchUri(this, App.homepageUrl(this)) } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountListFragment.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountListFragment.kt index 13d6288f2..462d5e006 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountListFragment.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountListFragment.kt @@ -20,10 +20,13 @@ import android.provider.Settings import android.view.LayoutInflater import android.view.Menu import android.view.MenuInflater +import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.annotation.AnyThread import androidx.core.content.ContextCompat +import androidx.core.view.MenuHost +import androidx.core.view.MenuProvider import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.AndroidViewModel @@ -58,8 +61,6 @@ class AccountListFragment: Fragment() { private var syncStatusSnackbar: Snackbar? = null override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - setHasOptionsMenu(true) - _binding = AccountListBinding.inflate(inflater, container, false) return binding.root } @@ -132,16 +133,28 @@ class AccountListFragment: Fragment() { accountAdapter.submitList(accounts) requireActivity().invalidateOptionsMenu() } - } - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) = - inflater.inflate(R.menu.activity_accounts, menu) + requireActivity().addMenuProvider(object : MenuProvider { + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.activity_accounts, menu) + } - override fun onPrepareOptionsMenu(menu: Menu) { - // Show "Sync all" only when there is at least one account - model.accounts.value?.let { accounts -> - menu.findItem(R.id.syncAll).setVisible(accounts.isNotEmpty()) - } + override fun onMenuItemSelected(menuItem: MenuItem) = + when (menuItem.itemId) { + R.id.syncAll -> { + (activity as AccountsActivity).syncAllAccounts() + true + } + else -> false + } + + override fun onPrepareMenu(menu: Menu) { + // Show "Sync all" only when there is at least one account + model.accounts.value?.let { accounts -> + menu.findItem(R.id.syncAll).setVisible(accounts.isNotEmpty()) + } + } + }) } override fun onResume() { 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 808db922d..8d3053328 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsActivity.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsActivity.kt @@ -54,11 +54,10 @@ class AccountsActivity: AppCompatActivity(), NavigationView.OnNavigationItemSele super.onCreate(savedInstanceState) if (savedInstanceState == null) { + // use a separate thread to check whether IntroActivity should be shown CoroutineScope(Dispatchers.Default).launch { - // use a separate thread to check whether IntroActivity should be shown - if (IntroActivity.shouldShowIntroActivity(this@AccountsActivity)) { + if (IntroActivity.shouldShowIntroActivity(this@AccountsActivity)) introActivityLauncher.launch(null) - } } } @@ -109,7 +108,7 @@ class AccountsActivity: AppCompatActivity(), NavigationView.OnNavigationItemSele private fun allAccounts() = AccountManager.get(this).getAccountsByType(getString(R.string.account_type)) - fun syncAllAccounts(item: MenuItem? = null) { + fun syncAllAccounts() { if (Build.VERSION.SDK_INT >= 25) getSystemService()?.reportShortcutUsed(UiUtils.SHORTCUT_SYNC_ALL) 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 8154948e7..59adc5c56 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 @@ -13,10 +13,12 @@ import android.os.Bundle import android.os.Handler import android.os.Looper import android.view.Menu +import android.view.MenuInflater import android.view.MenuItem import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.TooltipCompat +import androidx.core.view.MenuProvider import androidx.fragment.app.FragmentActivity import androidx.lifecycle.* import androidx.viewpager2.adapter.FragmentStateAdapter @@ -105,27 +107,45 @@ class AccountActivity: AppCompatActivity() { SyncWorker.enqueueAllAuthorities(this, model.account) } } - } - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.activity_account, menu) - return true + addMenuProvider(object : MenuProvider { + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.activity_account, menu) + } + + override fun onMenuItemSelected(menuItem: MenuItem) = + when (menuItem.itemId) { + R.id.settings -> { + openAccountSettings() + true + } + R.id.rename_account -> { + renameAccount() + true + } + R.id.delete_account -> { + deleteAccountDialog() + true + } + else -> false + } + }) } // menu actions - fun openAccountSettings(menuItem: MenuItem) { + fun openAccountSettings() { val intent = Intent(this, SettingsActivity::class.java) intent.putExtra(SettingsActivity.EXTRA_ACCOUNT, model.account) startActivity(intent, null) } - fun renameAccount(menuItem: MenuItem) { + fun renameAccount() { RenameAccountFragment.newInstance(model.account).show(supportFragmentManager, null) } - fun deleteAccount(menuItem: MenuItem) { + fun deleteAccountDialog() { MaterialAlertDialogBuilder(this) .setIcon(R.drawable.ic_error) .setTitle(R.string.account_delete_confirmation_title) diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AddressBooksFragment.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AddressBooksFragment.kt index 2a30b1304..67cda108b 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AddressBooksFragment.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AddressBooksFragment.kt @@ -5,7 +5,10 @@ package at.bitfire.davdroid.ui.account import android.content.Intent +import android.os.Bundle import android.view.* +import androidx.core.view.MenuHost +import androidx.core.view.MenuProvider import androidx.fragment.app.FragmentManager import at.bitfire.davdroid.util.PermissionUtils import at.bitfire.davdroid.R @@ -22,26 +25,39 @@ class AddressBooksFragment: CollectionsFragment() { override val noCollectionsStringId = R.string.account_no_address_books - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) = - inflater.inflate(R.menu.carddav_actions, menu) + private val menuProvider = object : CollectionsMenuProvider() { + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.carddav_actions, menu) + } - override fun onPrepareOptionsMenu(menu: Menu) { - menu.findItem(R.id.create_address_book).isVisible = model.hasWriteableCollections.value ?: false - super.onPrepareOptionsMenu(menu) - } + override fun onPrepareMenu(menu: Menu) { + super.onPrepareMenu(menu) + menu.findItem(R.id.create_address_book).isVisible = model.hasWriteableCollections.value ?: false + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + if (super.onMenuItemSelected(menuItem)) + return true - override fun onOptionsItemSelected(item: MenuItem): Boolean { - if (super.onOptionsItemSelected(item)) - return true + if (menuItem.itemId == R.id.create_address_book) { + val intent = Intent(requireActivity(), CreateAddressBookActivity::class.java) + intent.putExtra(CreateAddressBookActivity.EXTRA_ACCOUNT, accountModel.account) + startActivity(intent) + return true + } - if (item.itemId == R.id.create_address_book) { - val intent = Intent(requireActivity(), CreateAddressBookActivity::class.java) - intent.putExtra(CreateAddressBookActivity.EXTRA_ACCOUNT, accountModel.account) - startActivity(intent) - return true + return false } + } + + override fun onResume() { + super.onResume() + requireActivity().addMenuProvider(menuProvider) + } - return false + override fun onPause() { + super.onPause() + requireActivity().removeMenuProvider(menuProvider) } override fun checkPermissions() { diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CalendarsFragment.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CalendarsFragment.kt index cbd7916dc..9e58b0bf9 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CalendarsFragment.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CalendarsFragment.kt @@ -5,7 +5,10 @@ package at.bitfire.davdroid.ui.account import android.content.Intent +import android.os.Bundle import android.view.* +import androidx.core.view.MenuHost +import androidx.core.view.MenuProvider import androidx.fragment.app.FragmentManager import at.bitfire.davdroid.Constants import at.bitfire.davdroid.util.PermissionUtils @@ -18,26 +21,40 @@ class CalendarsFragment: CollectionsFragment() { override val noCollectionsStringId = R.string.account_no_calendars - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) = - inflater.inflate(R.menu.caldav_actions, menu) + private val menuProvider = object : CollectionsMenuProvider() { + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.caldav_actions, menu) + } - override fun onPrepareOptionsMenu(menu: Menu) { - menu.findItem(R.id.create_calendar).isVisible = model.hasWriteableCollections.value ?: false - super.onPrepareOptionsMenu(menu) - } + override fun onPrepareMenu(menu: Menu) { + super.onPrepareMenu(menu) + menu.findItem(R.id.create_calendar).isVisible = model.hasWriteableCollections.value ?: false + } - override fun onOptionsItemSelected(item: MenuItem): Boolean { - if (super.onOptionsItemSelected(item)) - return true + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + if (super.onMenuItemSelected(menuItem)) { + return true + } - if (item.itemId == R.id.create_calendar) { - val intent = Intent(requireActivity(), CreateCalendarActivity::class.java) - intent.putExtra(CreateCalendarActivity.EXTRA_ACCOUNT, accountModel.account) - startActivity(intent) - return true + if (menuItem.itemId == R.id.create_calendar) { + val intent = Intent(requireActivity(), CreateCalendarActivity::class.java) + intent.putExtra(CreateCalendarActivity.EXTRA_ACCOUNT, accountModel.account) + startActivity(intent) + return true + } + + return false } + } + + override fun onResume() { + super.onResume() + requireActivity().addMenuProvider(menuProvider) + } - return false + override fun onPause() { + super.onPause() + requireActivity().removeMenuProvider(menuProvider) } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CollectionsFragment.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CollectionsFragment.kt index e845ebc16..e94621f9c 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CollectionsFragment.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CollectionsFragment.kt @@ -13,6 +13,9 @@ import android.provider.CalendarContract import android.provider.ContactsContract import android.view.* import android.widget.PopupMenu +import androidx.annotation.CallSuper +import androidx.core.view.MenuHost +import androidx.core.view.MenuProvider import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.fragment.app.activityViewModels @@ -66,11 +69,6 @@ abstract class CollectionsFragment: Fragment(), SwipeRefreshLayout.OnRefreshList } } - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setHasOptionsMenu(true) - } - abstract val noCollectionsStringId: Int override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { @@ -86,17 +84,17 @@ abstract class CollectionsFragment: Fragment(), SwipeRefreshLayout.OnRefreshList override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - model.isRefreshing.observe(viewLifecycleOwner, Observer { nowRefreshing -> + model.isRefreshing.observe(viewLifecycleOwner) { nowRefreshing -> binding.swipeRefresh.isRefreshing = nowRefreshing - }) - model.hasWriteableCollections.observe(viewLifecycleOwner, Observer { + } + model.hasWriteableCollections.observe(viewLifecycleOwner) { requireActivity().invalidateOptionsMenu() - }) - model.collectionColors.observe(viewLifecycleOwner, Observer { colors: List -> + } + model.collectionColors.observe(viewLifecycleOwner) { colors: List -> val realColors = colors.filterNotNull() if (realColors.isNotEmpty()) binding.swipeRefresh.setColorSchemeColors(*realColors.toIntArray()) - }) + } binding.swipeRefresh.setOnRefreshListener(this) val updateProgress = Observer { @@ -128,11 +126,11 @@ abstract class CollectionsFragment: Fragment(), SwipeRefreshLayout.OnRefreshList val adapter = createAdapter() binding.list.layoutManager = LinearLayoutManager(requireActivity()) binding.list.adapter = adapter - model.collections.observe(viewLifecycleOwner, Observer { data -> + model.collections.observe(viewLifecycleOwner) { data -> lifecycleScope.launch { adapter.submitData(data) } - }) + } adapter.addLoadStateListener { loadStates -> if (loadStates.refresh is LoadState.NotLoading) { if (adapter.itemCount > 0) { @@ -148,31 +146,6 @@ abstract class CollectionsFragment: Fragment(), SwipeRefreshLayout.OnRefreshList binding.noCollections.setText(noCollectionsStringId) } - override fun onPrepareOptionsMenu(menu: Menu) { - menu.findItem(R.id.showOnlyPersonal).let { showOnlyPersonal -> - accountModel.showOnlyPersonal.value?.let { value -> - showOnlyPersonal.isChecked = value - } - accountModel.showOnlyPersonalWritable.value?.let { writable -> - showOnlyPersonal.isEnabled = writable - } - } - } - - override fun onOptionsItemSelected(item: MenuItem) = - when (item.itemId) { - R.id.refresh -> { - onRefresh() - true - } - R.id.showOnlyPersonal -> { - accountModel.toggleShowOnlyPersonal() - true - } - else -> - false - } - override fun onRefresh() { model.refresh() } @@ -223,6 +196,38 @@ abstract class CollectionsFragment: Fragment(), SwipeRefreshLayout.OnRefreshList } + abstract inner class CollectionsMenuProvider : MenuProvider { + abstract override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) + + @CallSuper + override fun onPrepareMenu(menu: Menu) { + menu.findItem(R.id.showOnlyPersonal).let { showOnlyPersonal -> + accountModel.showOnlyPersonal.value?.let { value -> + showOnlyPersonal.isChecked = value + } + accountModel.showOnlyPersonalWritable.value?.let { writable -> + showOnlyPersonal.isEnabled = writable + } + } + } + + @CallSuper + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + return when (menuItem.itemId) { + R.id.refresh -> { + onRefresh() + true + } + R.id.showOnlyPersonal -> { + accountModel.toggleShowOnlyPersonal() + true + } + else -> + false + } + } + } + class CollectionPopupListener( private val accountModel: AccountActivity.Model, private val item: Collection, diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/WebcalFragment.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/WebcalFragment.kt index 6e0105fd0..889d1bbd3 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/WebcalFragment.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/WebcalFragment.kt @@ -52,7 +52,7 @@ class WebcalFragment: CollectionsFragment() { override val noCollectionsStringId = R.string.account_no_webcals @Inject lateinit var webcalModelFactory: WebcalModel.Factory - val webcalModel by viewModels() { + private val webcalModel by viewModels { object : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class) = @@ -62,6 +62,17 @@ class WebcalFragment: CollectionsFragment() { } } + private val menuProvider = object : CollectionsMenuProvider() { + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.caldav_actions, menu) + } + + override fun onPrepareMenu(menu: Menu) { + super.onPrepareMenu(menu) + menu.findItem(R.id.create_calendar).isVisible = false + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -70,12 +81,14 @@ class WebcalFragment: CollectionsFragment() { }) } - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) = - inflater.inflate(R.menu.caldav_actions, menu) + override fun onResume() { + super.onResume() + requireActivity().addMenuProvider(menuProvider) + } - override fun onPrepareOptionsMenu(menu: Menu) { - super.onPrepareOptionsMenu(menu) - menu.findItem(R.id.create_calendar).isVisible = false + override fun onPause() { + super.onPause() + requireActivity().removeMenuProvider(menuProvider) } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/BatteryOptimizationsFragment.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/BatteryOptimizationsFragment.kt index 9a166c1ae..d9245b87c 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/BatteryOptimizationsFragment.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/BatteryOptimizationsFragment.kt @@ -15,13 +15,13 @@ import android.os.PowerManager import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.activity.result.contract.ActivityResultContract import androidx.core.content.getSystemService import androidx.databinding.ObservableBoolean import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel import at.bitfire.davdroid.App import at.bitfire.davdroid.BuildConfig import at.bitfire.davdroid.R @@ -37,7 +37,6 @@ import dagger.hilt.InstallIn import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.components.ActivityComponent import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext import dagger.multibindings.IntoSet import org.apache.commons.text.WordUtils import java.util.* @@ -46,12 +45,13 @@ import javax.inject.Inject @AndroidEntryPoint class BatteryOptimizationsFragment: Fragment() { - companion object { - const val REQUEST_IGNORE_BATTERY_OPTIMIZATIONS = 0 - } - val model by viewModels() + private val ignoreBatteryOptimizationsResultLauncher = + registerForActivityResult(IgnoreBatteryOptimizationsContract) { + model.checkWhitelisted() + } + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { val binding = IntroBatteryOptimizationsBinding.inflate(inflater, container, false) @@ -61,10 +61,7 @@ class BatteryOptimizationsFragment: Fragment() { model.shouldBeWhitelisted.observe(viewLifecycleOwner) { shouldBeWhitelisted -> @SuppressLint("BatteryLife") if (shouldBeWhitelisted && !model.isWhitelisted.value!!) - startActivityForResult(Intent( - android.provider.Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, - Uri.parse("package:" + BuildConfig.APPLICATION_ID) - ), REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) + ignoreBatteryOptimizationsResultLauncher.launch(BuildConfig.APPLICATION_ID) } binding.batteryText.text = getString(R.string.intro_battery_text, getString(R.string.app_name)) @@ -81,19 +78,14 @@ class BatteryOptimizationsFragment: Fragment() { return binding.root } - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - if (requestCode == REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) - model.checkWhitelisted() - } - override fun onResume() { super.onResume() model.checkWhitelisted() } - class Model( + @HiltViewModel + class Model @Inject constructor( application: Application, val settings: SettingsManager ): AndroidViewModel(application) { @@ -165,7 +157,7 @@ class BatteryOptimizationsFragment: Fragment() { } fun checkWhitelisted() { - val whitelisted = isWhitelisted(context) + val whitelisted = isWhitelisted(getApplication()) isWhitelisted.value = whitelisted shouldBeWhitelisted.value = whitelisted @@ -177,6 +169,21 @@ class BatteryOptimizationsFragment: Fragment() { } + @SuppressLint("BatteryLife") + object IgnoreBatteryOptimizationsContract: ActivityResultContract() { + override fun createIntent(context: Context, input: String): Intent { + return Intent( + android.provider.Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, + Uri.parse("package:$input") + ) + } + + override fun parseResult(resultCode: Int, intent: Intent?): Unit? { + return null + } + } + + @Module @InstallIn(ActivityComponent::class) abstract class BatteryOptimizationsFragmentModule { @@ -189,19 +196,19 @@ class BatteryOptimizationsFragment: Fragment() { ): IntroFragmentFactory { override fun getOrder(context: Context) = - // show fragment when: - // 1. DAVx5 is not whitelisted yet and "don't show anymore" has not been clicked, and/or - // 2a. evil manufacturer AND - // 2b. "don't show anymore" has not been clicked - if ( - (!Model.isWhitelisted(context) && settingsManager.getBooleanOrNull(HINT_BATTERY_OPTIMIZATIONS) != false) || - (Model.manufacturerWarning && settingsManager.getBooleanOrNull(HINT_AUTOSTART_PERMISSION) != false) - ) - 100 - else - IntroFragmentFactory.DONT_SHOW + // show fragment when: + // 1. DAVx5 is not whitelisted yet and "don't show anymore" has not been clicked, and/or + // 2a. evil manufacturer AND + // 2b. "don't show anymore" has not been clicked + if ( + (!Model.isWhitelisted(context) && settingsManager.getBooleanOrNull(HINT_BATTERY_OPTIMIZATIONS) != false) || + (Model.manufacturerWarning && settingsManager.getBooleanOrNull(HINT_AUTOSTART_PERMISSION) != false) + ) + 100 + else + IntroFragmentFactory.DONT_SHOW override fun create() = BatteryOptimizationsFragment() } -} +} \ No newline at end of file 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 914c3ac29..535bebb17 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 @@ -10,6 +10,7 @@ import android.content.Intent import android.os.Bundle import androidx.activity.result.contract.ActivityResultContract import androidx.activity.addCallback +import androidx.annotation.WorkerThread import androidx.core.content.res.ResourcesCompat import androidx.fragment.app.Fragment import at.bitfire.davdroid.R @@ -33,12 +34,11 @@ class IntroActivity: AppIntro2() { companion object { + @WorkerThread fun shouldShowIntroActivity(activity: Activity): Boolean { val factories = EntryPointAccessors.fromActivity(activity, IntroActivityEntryPoint::class.java).introFragmentFactories() return factories.any { - val order = it.getOrder(activity) - Logger.log.fine("Found intro fragment factory ${it::class.java} with order $order") - order > 0 + it.getOrder(activity) > 0 } } @@ -46,19 +46,24 @@ class IntroActivity: AppIntro2() { private var currentSlide = 0 - @Inject lateinit var introFragmentFactories: Set<@JvmSuppressWildcards IntroFragmentFactory> - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val factoriesWithOrder = introFragmentFactories - .associateBy { it.getOrder(this) } - .filterKeys { it != IntroFragmentFactory.DONT_SHOW } + 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.keys.any { it > 0 } + val anyPositiveOrder = factoriesWithOrder.values.any { it > 0 } if (anyPositiveOrder) { - for ((_, factory) in factoriesWithOrder.toSortedMap()) + val factoriesSortedByOrder = factoriesWithOrder + .toList() + .sortedBy { (_, v) -> v } // sort by value (= getOrder()) + for ((factory, _) in factoriesSortedByOrder) addSlide(factory.create()) } 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 3e6493ff3..3488f84de 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 @@ -22,7 +22,7 @@ interface IntroFragmentFactory { * @return Order with which an instance of this fragment type shall be created and shown. Possible values: * * * <0: only show the fragment when there is at least one other fragment with positive order (lower numbers are shown first) - * * 0: don't show the fragment + * * [DONT_SHOW] (0): don't show the fragment * * ≥0: show the fragment (lower numbers are shown first) */ fun getOrder(context: Context): Int diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/OpenSourceFragment.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/OpenSourceFragment.kt index f07c3f983..2e8b7eb32 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/OpenSourceFragment.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/OpenSourceFragment.kt @@ -23,7 +23,6 @@ import at.bitfire.davdroid.ui.UiUtils import at.bitfire.davdroid.ui.intro.OpenSourceFragment.Model.Companion.SETTING_NEXT_DONATION_POPUP import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject @AndroidEntryPoint @@ -77,7 +76,7 @@ class OpenSourceFragment: Fragment() { override fun getOrder(context: Context) = if (System.currentTimeMillis() > (settingsManager.getLongOrNull(SETTING_NEXT_DONATION_POPUP) ?: 0)) - 100 + 500 else 0 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 0588aefce..98df793fd 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 @@ -27,7 +27,10 @@ class PermissionsIntroFragment : Fragment() { override fun getOrder(context: Context): Int { // show PermissionsFragment as intro fragment when no permissions are granted - val permissions = CONTACT_PERMISSIONS + CALENDAR_PERMISSIONS + TaskProvider.PERMISSIONS_OPENTASKS + val permissions = CONTACT_PERMISSIONS + CALENDAR_PERMISSIONS + + TaskProvider.PERMISSIONS_JTX + + TaskProvider.PERMISSIONS_OPENTASKS + + TaskProvider.PERMISSIONS_TASKS_ORG return if (PermissionUtils.haveAnyPermission(context, permissions)) IntroFragmentFactory.DONT_SHOW else diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/AccountDetailsFragment.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/AccountDetailsFragment.kt index 79303a77b..45e8b74c2 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/AccountDetailsFragment.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/AccountDetailsFragment.kt @@ -20,7 +20,11 @@ import android.widget.ArrayAdapter import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels -import androidx.lifecycle.* +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import at.bitfire.davdroid.InvalidAccountException import at.bitfire.davdroid.R import at.bitfire.davdroid.databinding.LoginAccountDetailsBinding @@ -103,10 +107,10 @@ class AccountDetailsFragment : Fragment() { v.createAccount.visibility = View.GONE model.createAccount( - name, - loginModel.credentials, - config, - GroupMethod.valueOf(groupMethodName) + name, + loginModel.credentials, + config, + GroupMethod.valueOf(groupMethodName) ).observe(viewLifecycleOwner, Observer { success -> if (success) { // close Create account activity @@ -128,6 +132,7 @@ class AccountDetailsFragment : Fragment() { val forcedGroupMethod = settings.getString(AccountSettings.KEY_CONTACT_GROUP_METHOD)?.let { GroupMethod.valueOf(it) } if (forcedGroupMethod != null) { + // contact group type forced by settings v.contactGroupMethod.isEnabled = false for ((i, method) in resources.getStringArray(R.array.settings_contact_group_method_values).withIndex()) { if (method == forcedGroupMethod.name) { @@ -135,8 +140,17 @@ class AccountDetailsFragment : Fragment() { break } } - } else + } else { + // contact group type selectable v.contactGroupMethod.isEnabled = true + for ((i, method) in resources.getStringArray(R.array.settings_contact_group_method_values).withIndex()) { + // take suggestion from detection process into account + if (method == loginModel.suggestedGroupMethod.name) { + v.contactGroupMethod.setSelection(i) + break + } + } + } return v.root } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/DefaultLoginCredentialsFragment.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/DefaultLoginCredentialsFragment.kt index 27bca24a6..1f2b53382 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/DefaultLoginCredentialsFragment.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/DefaultLoginCredentialsFragment.kt @@ -71,10 +71,11 @@ class DefaultLoginCredentialsFragment : Fragment() { v.login.setOnClickListener { _ -> if (validate()) { val nextFragment = - if (model.loginGoogle.value == true) - GoogleLoginFragment() - else - DetectConfigurationFragment() + when { + model.loginGoogle.value == true -> GoogleLoginFragment() + model.loginNextcloud.value == true -> NextcloudLoginFlowFragment() + else -> DetectConfigurationFragment() + } parentFragmentManager.beginTransaction() .replace(android.R.id.content, nextFragment, null) @@ -204,7 +205,8 @@ class DefaultLoginCredentialsFragment : Fragment() { } } - model.loginGoogle.value == true -> { + // some login methods don't require further input → always valid + model.loginGoogle.value == true || model.loginNextcloud.value == true -> { valid = true } } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/DefaultLoginCredentialsModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/DefaultLoginCredentialsModel.kt index 30c9ed2d1..5edb235be 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/DefaultLoginCredentialsModel.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/DefaultLoginCredentialsModel.kt @@ -29,6 +29,7 @@ class DefaultLoginCredentialsModel(app: Application): AndroidViewModel(app) { val loginWithUrlAndUsername = MutableLiveData(false) val loginAdvanced = MutableLiveData(false) val loginGoogle = MutableLiveData(false) + val loginNextcloud = MutableLiveData(false) val baseUrl = MutableLiveData() val baseUrlError = MutableLiveData() 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 e03c39899..3925fcc13 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 @@ -6,8 +6,10 @@ package at.bitfire.davdroid.ui.setup import android.os.Bundle import android.view.Menu +import android.view.MenuInflater import android.view.MenuItem import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.MenuProvider import androidx.fragment.app.Fragment import at.bitfire.davdroid.App import at.bitfire.davdroid.R @@ -51,6 +53,22 @@ class LoginActivity: AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + addMenuProvider(object: MenuProvider { + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.activity_login, menu) + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + if (menuItem.itemId == R.id.help) { + UiUtils.launchUri(this@LoginActivity, + App.homepageUrl(this@LoginActivity).buildUpon().appendPath("tested-with").build()) + return true + } + + return false + } + }) + if (savedInstanceState == null) { // first call, add first login fragment val factories = loginFragmentFactories // get factories from hilt @@ -71,14 +89,4 @@ class LoginActivity: AppCompatActivity() { } } - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.activity_login, menu) - return true - } - - fun showHelp(item: MenuItem) { - UiUtils.launchUri(this, - App.homepageUrl(this).buildUpon().appendPath("tested-with").build()) - } - } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginModel.kt index 7aec2bfb2..976d30856 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginModel.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginModel.kt @@ -7,6 +7,7 @@ package at.bitfire.davdroid.ui.setup import androidx.lifecycle.ViewModel import at.bitfire.davdroid.db.Credentials import at.bitfire.davdroid.servicedetection.DavResourceFinder +import at.bitfire.vcard4android.GroupMethod import java.net.URI class LoginModel: ViewModel() { @@ -16,9 +17,10 @@ class LoginModel: ViewModel() { var configuration: DavResourceFinder.Configuration? = null - /** - * Account name that should be used as default account name when no email addresses have been found. - */ + /** account name that should be used as default account name when no email addresses have been found */ var suggestedAccountName: String? = null -} + /** group method that should be pre-selectedbr */ + var suggestedGroupMethod: GroupMethod = GroupMethod.GROUP_VCARDS + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/NextcloudLoginFlowFragment.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/NextcloudLoginFlowFragment.kt index 3e9a25403..4b2d3c721 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/NextcloudLoginFlowFragment.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/NextcloudLoginFlowFragment.kt @@ -7,16 +7,38 @@ package at.bitfire.davdroid.ui.setup import android.annotation.SuppressLint import android.app.Application import android.content.Intent -import android.net.Uri import android.os.Bundle import android.provider.Browser import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.UiThread import androidx.annotation.WorkerThread import androidx.browser.customtabs.CustomTabsIntent +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.Button +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +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.intl.Locale +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.core.os.bundleOf import androidx.fragment.app.Fragment @@ -24,14 +46,16 @@ import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope import at.bitfire.dav4jvm.exception.DavException import at.bitfire.dav4jvm.exception.HttpException import at.bitfire.davdroid.R import at.bitfire.davdroid.db.Credentials import at.bitfire.davdroid.log.Logger import at.bitfire.davdroid.network.HttpClient -import at.bitfire.davdroid.ui.DebugInfoActivity import at.bitfire.davdroid.ui.UiUtils.haveCustomTabs +import at.bitfire.vcard4android.GroupMethod +import com.google.accompanist.themeadapter.material.MdcTheme import com.google.android.material.snackbar.Snackbar import dagger.Binds import dagger.Module @@ -39,11 +63,11 @@ import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import dagger.multibindings.IntKey import dagger.multibindings.IntoMap -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.MediaType.Companion.toMediaType import okhttp3.Request import okhttp3.RequestBody @@ -51,14 +75,15 @@ import okhttp3.RequestBody.Companion.toRequestBody import org.json.JSONObject import java.net.HttpURLConnection import java.net.URI +import java.util.logging.Level import javax.inject.Inject class NextcloudLoginFlowFragment: Fragment() { companion object { - const val LOGIN_FLOW_V1_PATH = "/index.php/login/flow" - const val LOGIN_FLOW_V2_PATH = "/index.php/login/v2" + const val LOGIN_FLOW_V1_PATH = "index.php/login/flow" + val LOGIN_FLOW_V2_PATH = "index.php/login/v2" /** Set this to 1 to indicate that Login Flow shall be used. */ const val EXTRA_LOGIN_FLOW = "loginFlow" @@ -66,31 +91,47 @@ class NextcloudLoginFlowFragment: Fragment() { /** Path to DAV endpoint (e.g. `/remote.php/dav`). Will be appended to the * server URL returned by Login Flow without further processing. */ const val EXTRA_DAV_PATH = "davPath" - - const val REQUEST_BROWSER = 0 } val loginModel by activityViewModels() - val loginFlowModel by viewModels() + val model by viewModels() + + val checkResultCallback = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + val davPath = requireActivity().intent.getStringExtra(EXTRA_DAV_PATH) + model.checkResult(davPath) + } @SuppressLint("SetJavaScriptEnabled") override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - val view = View(requireActivity()) - - val entryUrl = requireActivity().intent.data ?: throw IllegalArgumentException("Intent data must be set to Login Flow URL") - Logger.log.info("Using Login Flow entry point: $entryUrl") + val entryUrl = requireActivity().intent.data?.toString()?.toHttpUrlOrNull() + + val view = ComposeView(requireActivity()).apply { + setContent { + MdcTheme { + NextcloudLoginComposable( + onStart = { url -> + model.start(url) + }, + entryUrl = entryUrl, + inProgress = model.inProgress.observeAsState(false), + error = model.error.observeAsState() + ) + } + } + } - loginFlowModel.loginUrl.observe(viewLifecycleOwner) { loginUrl -> + model.loginUrl.observe(viewLifecycleOwner) { loginUrl -> if (loginUrl == null) return@observe val loginUri = loginUrl.toUri() // reset URL so that the browser isn't shown another time - loginFlowModel.loginUrl.value = null + model.loginUrl.value = null if (haveCustomTabs(requireActivity())) { // Custom Tabs are available + @Suppress("DEPRECATION") val browser = CustomTabsIntent.Builder() .setToolbarColor(resources.getColor(R.color.primaryColor)) .build() @@ -99,70 +140,58 @@ class NextcloudLoginFlowFragment: Fragment() { Browser.EXTRA_HEADERS, bundleOf("Accept-Language" to Locale.current.toLanguageTag()) ) - startActivityForResult(browser.intent, REQUEST_BROWSER, browser.startAnimationBundle) - + checkResultCallback.launch(browser.intent) } else { // fallback: launch normal browser val browser = Intent(Intent.ACTION_VIEW, loginUri) browser.addCategory(Intent.CATEGORY_BROWSABLE) if (browser.resolveActivity(requireActivity().packageManager) != null) - startActivityForResult(browser, REQUEST_BROWSER) + checkResultCallback.launch(browser) else Snackbar.make(view, getString(R.string.install_browser), Snackbar.LENGTH_INDEFINITE).show() } } - loginFlowModel.error.observe(viewLifecycleOwner) { exception -> - Snackbar.make(requireView(), exception.toString(), Snackbar.LENGTH_INDEFINITE) - .setAction(R.string.exception_show_details) { - val intent = DebugInfoActivity.IntentBuilder(requireActivity()) - .withCause(exception) - .build() - startActivity(intent) - } - .show() - } + model.loginData.observe(viewLifecycleOwner) { loginData -> + if (loginData == null) + return@observe + val (baseUri, credentials) = loginData - loginFlowModel.loginData.observe(viewLifecycleOwner) { (baseUri, credentials) -> // continue to next fragment loginModel.baseURI = baseUri loginModel.credentials = credentials + loginModel.suggestedGroupMethod = GroupMethod.CATEGORIES parentFragmentManager.beginTransaction() .replace(android.R.id.content, DetectConfigurationFragment(), null) .addToBackStack(null) .commit() + + // reset loginData so that we can go back + model.loginData.value = null } - // start Login Flow - loginFlowModel.setUrl(entryUrl) + if (savedInstanceState == null && entryUrl != null) + model.start(entryUrl) return view } - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - if (requestCode != REQUEST_BROWSER) - return - - val davPath = requireActivity().intent.getStringExtra(EXTRA_DAV_PATH) - loginFlowModel.checkResult(davPath) - } - /** * Implements Login Flow v2. * * @see https://docs.nextcloud.com/server/20/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2 */ - class LoginFlowModel(app: Application): AndroidViewModel(app) { - - val error = MutableLiveData() + class Model(app: Application): AndroidViewModel(app) { val loginUrl = MutableLiveData() + val error = MutableLiveData() val httpClient by lazy { HttpClient.Builder(getApplication()) - .setForeground(true) - .build() + .setForeground(true) + .build() } + val inProgress = MutableLiveData(false) var pollUrl: HttpUrl? = null var token: String? = null @@ -174,20 +203,30 @@ class NextcloudLoginFlowFragment: Fragment() { } + /** + * Starts the Login Flow. + * + * @param entryUrl entryURL: either a Login Flow path (ending with [LOGIN_FLOW_V1_PATH] or [LOGIN_FLOW_V2_PATH]), + * or another URL which is treated as Nextcloud root URL. In this case, [LOGIN_FLOW_V2_PATH] is appended. + */ @UiThread - fun setUrl(entryUri: Uri) { - val entryUrl = entryUri.toString() - val v2Url = - if (entryUrl.endsWith(LOGIN_FLOW_V1_PATH)) - // got Login Flow v1 URL, rewrite to v2 - entryUrl.removeSuffix(LOGIN_FLOW_V1_PATH) + LOGIN_FLOW_V2_PATH - else - entryUrl + fun start(entryUrl: HttpUrl) { + inProgress.value = true + error.value = null + + var entryUrlStr = entryUrl.toString() + if (entryUrlStr.endsWith(LOGIN_FLOW_V1_PATH)) + // got Login Flow v1 URL, rewrite to v2 + entryUrlStr = entryUrlStr.removeSuffix(LOGIN_FLOW_V1_PATH) + + val v2Url = entryUrlStr.toHttpUrl().newBuilder() + .addPathSegments(LOGIN_FLOW_V2_PATH) + .build() // send POST request and process JSON reply - CoroutineScope(Dispatchers.IO).launch { + viewModelScope.launch(Dispatchers.IO) { try { - val json = postForJson(v2Url.toHttpUrl(), "".toRequestBody()) + val json = postForJson(v2Url, "".toRequestBody()) // login URL loginUrl.postValue(json.getString("login")) @@ -198,7 +237,10 @@ class NextcloudLoginFlowFragment: Fragment() { token = poll.getString("token") } } catch (e: Exception) { - error.postValue(e) + Logger.log.log(Level.WARNING, "Couldn't obtain login URL", e) + error.postValue(getApplication().getString(R.string.login_nextcloud_login_flow_no_login_url)) + } finally { + inProgress.postValue(false) } } } @@ -208,7 +250,7 @@ class NextcloudLoginFlowFragment: Fragment() { val pollUrl = pollUrl ?: return val token = token ?: return - CoroutineScope(Dispatchers.IO).launch { + viewModelScope.launch(Dispatchers.IO) { try { val json = postForJson(pollUrl, "token=$token".toRequestBody("application/x-www-form-urlencoded".toMediaType())) val serverUrl = json.getString("server") @@ -221,11 +263,12 @@ class NextcloudLoginFlowFragment: Fragment() { URI.create(serverUrl) loginData.postValue(Pair( - baseUri, - Credentials(loginName, appPassword) + baseUri, + Credentials(loginName, appPassword) )) } catch (e: Exception) { - error.postValue(e) + Logger.log.log(Level.WARNING, "Polling login URL failed", e) + error.postValue(getApplication().getString(R.string.login_nextcloud_login_flow_no_login_data)) } } } @@ -259,7 +302,7 @@ class NextcloudLoginFlowFragment: Fragment() { class Factory @Inject constructor(): LoginCredentialsFragmentFactory { override fun getFragment(intent: Intent) = - if (intent.hasExtra(EXTRA_LOGIN_FLOW)) + if (intent.hasExtra(EXTRA_LOGIN_FLOW) && intent.data != null) NextcloudLoginFlowFragment() else null @@ -275,4 +318,124 @@ class NextcloudLoginFlowFragment: Fragment() { abstract fun factory(impl: Factory): LoginCredentialsFragmentFactory } +} + + +@Composable +fun NextcloudLoginComposable( + entryUrl: HttpUrl?, + inProgress: State, + error: State, + onStart: (HttpUrl) -> Unit +) { + Column { + if (inProgress.value) + LinearProgressIndicator( + color = MaterialTheme.colors.secondary, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp) + ) + + Column(modifier = Modifier.padding(8.dp)) { + Text( + stringResource(R.string.login_nextcloud_login_with_nextcloud), + style = MaterialTheme.typography.h5 + ) + NextcloudLoginFlowComposable( + providedEntryUrl = entryUrl, + inProgress = inProgress, + error = error, + onStart = onStart + ) + } + } +} + + +@Composable +fun NextcloudLoginFlowComposable( + providedEntryUrl: HttpUrl?, + inProgress: State, + error: State, + onStart: ((HttpUrl) -> Unit) +) { + Column { + Text( + stringResource(R.string.login_nextcloud_login_flow), + style = MaterialTheme.typography.h6, + modifier = Modifier.padding(top = 16.dp) + ) + Text( + stringResource(R.string.login_nextcloud_login_flow_text), + modifier = Modifier.padding(vertical = 8.dp) + ) + + val entryUrlStr = remember { mutableStateOf(providedEntryUrl?.toString() ?: "") } + val entryUrl = remember { mutableStateOf(providedEntryUrl) } + OutlinedTextField(entryUrlStr.value, + onValueChange = { newUrlStr -> + entryUrlStr.value = newUrlStr + + entryUrl.value = try { + val withScheme = + if (!newUrlStr.startsWith("http://", true) && !newUrlStr.startsWith("https://", true)) + "https://$newUrlStr" + else + newUrlStr + withScheme.toHttpUrl() + } catch (e: IllegalArgumentException) { + null + } + }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + readOnly = inProgress.value, + label = { + Text(stringResource(R.string.login_nextcloud_login_flow_server_address)) + }, + placeholder = { + Text("cloud.example.com") + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Go + ), + keyboardActions = KeyboardActions( + onGo = { + entryUrl.value?.let(onStart) + } + ), + singleLine = true + ) + + Button( + onClick = { + entryUrl.value?.let(onStart) + }, + enabled = entryUrl.value != null && !inProgress.value + ) { + Text(stringResource(R.string.login_nextcloud_login_flow_sign_in)) + } + + error.value?.let { msg -> + Text( + msg, + color = MaterialTheme.colors.error, + modifier = Modifier.padding(vertical = 8.dp) + ) + } + } +} + +@Composable +@Preview +fun NextcloudLoginFlowComposable_PreviewWithError() { + NextcloudLoginFlowComposable( + providedEntryUrl = null, + inProgress = remember { mutableStateOf(true) }, + error = remember { mutableStateOf("Something wrong happened") }, + onStart = { } + ) } \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/AddWebdavMountActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/AddWebdavMountActivity.kt index 50474e5ae..c258a28a7 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/AddWebdavMountActivity.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/AddWebdavMountActivity.kt @@ -8,25 +8,27 @@ import android.app.Application import android.content.Context import android.os.Bundle import android.view.Menu +import android.view.MenuInflater import android.view.MenuItem import android.view.View import androidx.activity.viewModels import androidx.annotation.WorkerThread import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.AndroidViewModel +import androidx.core.view.MenuProvider import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.lifecycleScope import at.bitfire.dav4jvm.DavResource import at.bitfire.dav4jvm.UrlUtils import at.bitfire.davdroid.App -import at.bitfire.davdroid.network.HttpClient import at.bitfire.davdroid.R import at.bitfire.davdroid.databinding.ActivityAddWebdavMountBinding import at.bitfire.davdroid.db.AppDatabase import at.bitfire.davdroid.db.Credentials import at.bitfire.davdroid.db.WebDavMount import at.bitfire.davdroid.log.Logger +import at.bitfire.davdroid.network.HttpClient import at.bitfire.davdroid.ui.UiUtils import at.bitfire.davdroid.util.context import at.bitfire.davdroid.webdav.CredentialsStore @@ -69,14 +71,25 @@ class AddWebdavMountActivity: AppCompatActivity() { binding.addMount.setOnClickListener { validate() } - } - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.activity_add_webdav_mount, menu) - return true + addMenuProvider(object : MenuProvider { + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.activity_add_webdav_mount, menu) + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + return when (menuItem.itemId) { + R.id.help -> { + onShowHelp() + true + } + else -> false + } + } + }) } - fun onShowHelp(item: MenuItem) { + fun onShowHelp() { UiUtils.launchUri(this, App.homepageUrl(this).buildUpon().appendPath("tested-with").build()) } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/WebdavMountsActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/WebdavMountsActivity.kt index 394bcd8c2..8d3cf0202 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/WebdavMountsActivity.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/WebdavMountsActivity.kt @@ -16,6 +16,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.core.text.HtmlCompat +import androidx.core.view.MenuProvider import androidx.lifecycle.* import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.LinearLayoutManager @@ -90,14 +91,25 @@ class WebdavMountsActivity: AppCompatActivity() { binding.add.setOnClickListener { startActivity(Intent(this, AddWebdavMountActivity::class.java)) } - } - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.activity_webdav_mounts, menu) - return true + addMenuProvider(object : MenuProvider { + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.activity_webdav_mounts, menu) + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + return when (menuItem.itemId) { + R.id.help -> { + onShowHelp() + true + } + else -> false + } + } + }) } - fun onShowHelp(item: MenuItem) { + fun onShowHelp() { UiUtils.launchUri(this, helpUrl()) } diff --git a/app/src/main/res/layout/login_credentials_fragment.xml b/app/src/main/res/layout/login_credentials_fragment.xml index 8b7a93fb9..47fc82990 100644 --- a/app/src/main/res/layout/login_credentials_fragment.xml +++ b/app/src/main/res/layout/login_credentials_fragment.xml @@ -314,16 +314,31 @@ + + + + diff --git a/app/src/main/res/menu/activity_about.xml b/app/src/main/res/menu/activity_about.xml index 74d68b07e..9a6fa9530 100644 --- a/app/src/main/res/menu/activity_about.xml +++ b/app/src/main/res/menu/activity_about.xml @@ -3,9 +3,9 @@ xmlns:app="http://schemas.android.com/apk/res-auto"> + app:showAsAction="ifRoom" /> \ No newline at end of file diff --git a/app/src/main/res/menu/activity_account.xml b/app/src/main/res/menu/activity_account.xml index 3ad391ccb..c64e8cfd5 100644 --- a/app/src/main/res/menu/activity_account.xml +++ b/app/src/main/res/menu/activity_account.xml @@ -7,17 +7,14 @@ diff --git a/app/src/main/res/menu/activity_accounts.xml b/app/src/main/res/menu/activity_accounts.xml index f173669d3..05a0244f5 100644 --- a/app/src/main/res/menu/activity_accounts.xml +++ b/app/src/main/res/menu/activity_accounts.xml @@ -5,7 +5,6 @@ \ No newline at end of file diff --git a/app/src/main/res/menu/activity_add_webdav_mount.xml b/app/src/main/res/menu/activity_add_webdav_mount.xml index 520e126f0..5c1f92216 100644 --- a/app/src/main/res/menu/activity_add_webdav_mount.xml +++ b/app/src/main/res/menu/activity_add_webdav_mount.xml @@ -6,7 +6,6 @@ android:id="@+id/help" android:icon="@drawable/ic_help" android:title="@string/help" - app:showAsAction="always" - android:onClick="onShowHelp" /> + app:showAsAction="always" /> \ No newline at end of file diff --git a/app/src/main/res/menu/activity_login.xml b/app/src/main/res/menu/activity_login.xml index e889350ce..0649926fe 100644 --- a/app/src/main/res/menu/activity_login.xml +++ b/app/src/main/res/menu/activity_login.xml @@ -7,8 +7,6 @@ android:id="@+id/help" android:title="@string/help" android:icon="@drawable/ic_help" - app:showAsAction="always" - android:onClick="showHelp"> - - + app:showAsAction="always" /> + \ No newline at end of file diff --git a/app/src/main/res/menu/activity_webdav_mounts.xml b/app/src/main/res/menu/activity_webdav_mounts.xml index 520e126f0..5c1f92216 100644 --- a/app/src/main/res/menu/activity_webdav_mounts.xml +++ b/app/src/main/res/menu/activity_webdav_mounts.xml @@ -6,7 +6,6 @@ android:id="@+id/help" android:icon="@drawable/ic_help" android:title="@string/help" - app:showAsAction="always" - android:onClick="onShowHelp" /> + app:showAsAction="always" /> \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6ac45cec3..a64054e88 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -297,6 +297,14 @@ Privacy policy for details.]]> Google API Services User Data Policy, including the Limited Use requirements.]]> Couldn\'t obtain authorization code + Nextcloud + Login with Nextcloud + Login Flow + This will start the Nextcloud Login Flow in a Web browser. + Nextcloud server address + Sign in + Couldn\'t obtain login URL + Couldn\'t obtain login data Configuration detection Please wait, querying server…