Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Unverified Commit 52747e63 authored by Ricki Hirner's avatar Ricki Hirner
Browse files

LoginActivity: add Nextcloud Login Flow (bitfireAT/davx5#403)

* Replace onActivityResult by contract

* Add Nextcloud option to default login screen

* Decouple NextcloudLoginFlowComposable from model

* UI and model changes

* Single-line URL field

* Add progress indicator and other secondary UI
parent 58d4a9f6
Loading
Loading
Loading
Loading
+7 −5
Original line number Diff line number Diff line
@@ -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
            }
        }
+1 −0
Original line number Diff line number Diff line
@@ -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<String>()
    val baseUrlError = MutableLiveData<String>()
+220 −59
Original line number Diff line number Diff line
@@ -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,15 @@ 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 com.google.accompanist.themeadapter.material.MdcTheme
import com.google.android.material.snackbar.Snackbar
import dagger.Binds
import dagger.Module
@@ -39,11 +62,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 +74,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 +90,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<LoginModel>()
    val loginFlowModel by viewModels<LoginFlowModel>()
    val model by viewModels<Model>()

    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,31 +139,23 @@ 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
@@ -131,20 +163,15 @@ class NextcloudLoginFlowFragment: Fragment() {
                    .replace(android.R.id.content, DetectConfigurationFragment(), null)
                    .addToBackStack(null)
                    .commit()
        }

        // start Login Flow
        loginFlowModel.setUrl(entryUrl)

        return view
            // reset loginData so that we can go back
            model.loginData.value = null
        }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        if (requestCode != REQUEST_BROWSER)
            return
        if (savedInstanceState == null && entryUrl != null)
            model.start(entryUrl)

        val davPath = requireActivity().intent.getStringExtra(EXTRA_DAV_PATH)
        loginFlowModel.checkResult(davPath)
        return view
    }


@@ -153,16 +180,16 @@ class NextcloudLoginFlowFragment: Fragment() {
     *
     * @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<Exception>()
    class Model(app: Application): AndroidViewModel(app) {
        val loginUrl = MutableLiveData<String>()
        val error = MutableLiveData<String>()

        val httpClient by lazy {
            HttpClient.Builder(getApplication())
                .setForeground(true)
                .build()
        }
        val inProgress = MutableLiveData<Boolean>(false)

        var pollUrl: HttpUrl? = null
        var token: String? = null
@@ -174,20 +201,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))
        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
                        entryUrl.removeSuffix(LOGIN_FLOW_V1_PATH) + LOGIN_FLOW_V2_PATH
                    else
                        entryUrl
                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 +235,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<Application>().getString(R.string.login_nextcloud_login_flow_no_login_url))
                } finally {
                    inProgress.postValue(false)
                }
            }
        }
@@ -208,7 +248,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")
@@ -225,7 +265,8 @@ class NextcloudLoginFlowFragment: Fragment() {
                        Credentials(loginName, appPassword)
                    ))
                } catch (e: Exception) {
                    error.postValue(e)
                    Logger.log.log(Level.WARNING, "Polling login URL failed", e)
                    error.postValue(getApplication<Application>().getString(R.string.login_nextcloud_login_flow_no_login_data))
                }
            }
        }
@@ -259,7 +300,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
@@ -276,3 +317,123 @@ class NextcloudLoginFlowFragment: Fragment() {
    }

}


@Composable
fun NextcloudLoginComposable(
    entryUrl: HttpUrl?,
    inProgress: State<Boolean>,
    error: State<String?>,
    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<Boolean>,
    error: State<String?>,
    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<HttpUrl?>(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
+16 −1
Original line number Diff line number Diff line
@@ -314,16 +314,31 @@

                </LinearLayout>

                <com.google.android.material.divider.MaterialDivider
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:layout_marginTop="16dp"
                    android:layout_marginBottom="16dp" />

                <RadioButton
                    style="@style/TextAppearance.MaterialComponents.Body1"
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:layout_marginTop="16dp"
                    android:checked="@={model.loginGoogle}"
                    android:paddingStart="14dp"
                    android:text="@string/login_type_google"
                    android:textAlignment="viewStart" />

                <RadioButton
                    style="@style/TextAppearance.MaterialComponents.Body1"
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:layout_marginTop="16dp"
                    android:checked="@={model.loginNextcloud}"
                    android:paddingStart="14dp"
                    android:text="@string/login_type_nextcloud"
                    android:textAlignment="viewStart" />

            </RadioGroup>
        </ScrollView>

+8 −0
Original line number Diff line number Diff line
@@ -297,6 +297,14 @@
    <string name="login_google_client_privacy_policy"><![CDATA[%1$s transfers your Google Contacts and Calendar data solely for synchronization with this device. See our <a href="%2$s">Privacy policy</a> for details.]]></string>
    <string name="login_google_client_limited_use"><![CDATA[%1$s complies with the <a href="%2$s">Google API Services User Data Policy</a>, including the Limited Use requirements.]]></string>
    <string name="login_oauth_couldnt_obtain_auth_code">Couldn\'t obtain authorization code</string>
    <string name="login_type_nextcloud">Nextcloud</string>
    <string name="login_nextcloud_login_with_nextcloud">Login with Nextcloud</string>
    <string name="login_nextcloud_login_flow">Login Flow</string>
    <string name="login_nextcloud_login_flow_text">This will start the Nextcloud Login Flow in a Web browser.</string>
    <string name="login_nextcloud_login_flow_server_address">Nextcloud server address</string>
    <string name="login_nextcloud_login_flow_sign_in">Sign in</string>
    <string name="login_nextcloud_login_flow_no_login_url">Couldn\'t obtain login URL</string>
    <string name="login_nextcloud_login_flow_no_login_data">Couldn\'t obtain login data</string>

    <string name="login_configuration_detection">Configuration detection</string>
    <string name="login_querying_server">Please wait, querying server…</string>