mirror of
https://github.com/bitfireAT/davx5-ose
synced 2024-07-23 19:50:18 +00:00
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
This commit is contained in:
parent
58d4a9f663
commit
52747e632f
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>()
|
||||
|
|
|
@ -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?.toString()?.toHttpUrlOrNull()
|
||||
|
||||
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 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,38 +163,33 @@ class NextcloudLoginFlowFragment: Fragment() {
|
|||
.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<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()
|
||||
.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))
|
||||
// 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 +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")
|
||||
|
@ -221,11 +261,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<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
|
||||
|
@ -275,4 +316,124 @@ class NextcloudLoginFlowFragment: Fragment() {
|
|||
abstract fun factory(impl: Factory): LoginCredentialsFragmentFactory
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@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 = { }
|
||||
)
|
||||
}
|
|
@ -314,14 +314,29 @@
|
|||
|
||||
</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: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.loginGoogle}"
|
||||
android:checked="@={model.loginNextcloud}"
|
||||
android:paddingStart="14dp"
|
||||
android:text="@string/login_type_google"
|
||||
android:text="@string/login_type_nextcloud"
|
||||
android:textAlignment="viewStart" />
|
||||
|
||||
</RadioGroup>
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in a new issue