Loading app/build.gradle +2 −1 Original line number Diff line number Diff line Loading @@ -106,9 +106,10 @@ dependencies { coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.0.10' implementation 'androidx.appcompat:appcompat:1.2.0' implementation 'androidx.browser:browser:1.2.0' implementation 'androidx.cardview:cardview:1.0.0' implementation 'androidx.constraintlayout:constraintlayout:2.0.1' implementation 'androidx.core:core-ktx:1.3.1' implementation 'androidx.core:core-ktx:1.3.2' implementation 'androidx.fragment:fragment-ktx:1.2.5' implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0' Loading app/src/main/AndroidManifest.xml +5 −0 Original line number Diff line number Diff line Loading @@ -242,6 +242,11 @@ <action android:name="*" /> <data android:scheme="content" android:host="com.android.calendar" /> </intent> <!-- Custom Tabs support (e.g. Nextcloud Login Flow) --> <intent> <action android:name="android.support.customtabs.action.CustomTabsService" /> </intent> </queries> </manifest> app/src/main/java/at/bitfire/davdroid/ui/UiUtils.kt +4 −1 Original line number Diff line number Diff line Loading @@ -43,13 +43,16 @@ object UiUtils { * installed), this method does nothing. * * @param toastInstallBrowser whether to show "Please install a browser" toast when * the Intent could not be resolved * the Intent could not be resolved; will also add [Intent.CATEGORY_BROWSABLE] to the Intent * * @return true on success, false if the Intent could not be resolved (for instance, because * there is no user agent installed) */ fun launchUri(context: Context, uri: Uri, action: String = Intent.ACTION_VIEW, toastInstallBrowser: Boolean = true): Boolean { val intent = Intent(action, uri) if (toastInstallBrowser) intent.addCategory(Intent.CATEGORY_BROWSABLE) if (intent.resolveActivity(context.packageManager) != null) { context.startActivity(intent) return true Loading app/src/main/java/at/bitfire/davdroid/ui/setup/NextcloudLoginFlowFragment.kt +194 −89 Original line number Diff line number Diff line package at.bitfire.davdroid.ui.setup import android.annotation.SuppressLint import android.annotation.TargetApi import android.app.Application import android.content.Intent import android.net.http.SslError import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.webkit.* import androidx.annotation.UiThread import androidx.annotation.WorkerThread import androidx.browser.customtabs.CustomTabsIntent import androidx.browser.customtabs.CustomTabsService.ACTION_CUSTOM_TABS_CONNECTION import androidx.core.net.toUri import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import at.bitfire.davdroid.BuildConfig import androidx.fragment.app.viewModels import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.MutableLiveData import at.bitfire.dav4jvm.exception.DavException import at.bitfire.dav4jvm.exception.HttpException import at.bitfire.davdroid.HttpClient import at.bitfire.davdroid.R import at.bitfire.davdroid.log.Logger import at.bitfire.davdroid.model.Credentials import at.bitfire.davdroid.ui.DebugInfoActivity import com.google.android.material.snackbar.Snackbar import kotlinx.android.synthetic.main.activity_webview.view.* import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.MediaType.Companion.toMediaType import okhttp3.Request import okhttp3.RequestBody import okhttp3.RequestBody.Companion.toRequestBody import org.json.JSONObject import java.net.HttpURLConnection import java.net.URI import java.net.URLDecoder import java.util.logging.Level class NextcloudLoginFlowFragment: Fragment() { companion object { /** * Format of the Nextcloud Login URL, see: * https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html * * The parameter values must be URL-decoded. For instance, Nextcloud passes the username * "test@example.com" as "user:test%40example.com". */ val LOGIN_URL_FORMAT = Regex("^nc://login/server:(.+)&user:(.+)&password:(.+)$") const val LOGIN_FLOW_V1_PATH = "/index.php/login/flow" const 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" Loading @@ -41,109 +53,202 @@ 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>() @SuppressLint("SetJavaScriptEnabled") override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { val view = inflater.inflate(R.layout.activity_webview, container, false) val progressBar = view.progress val view = View(requireActivity()) val loginUrl = requireActivity().intent.data ?: throw IllegalArgumentException("Intent data must be set to Login Flow URL") Logger.log.info("Using Login Flow URL: $loginUrl") 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 webView = view.browser webView.settings.apply { javaScriptEnabled = true userAgentString = BuildConfig.userAgent } webView.webViewClient = CustomWebViewClient() webView.webChromeClient = object: WebChromeClient() { override fun onProgressChanged(view: WebView, newProgress: Int) { progressBar.progress = newProgress progressBar.visibility = if (newProgress == 100) View.GONE else View.VISIBLE } } webView.loadUrl( loginUrl.toString(), // https://nextcloud.example.com/index.php/login/flow mapOf(Pair("OCS-APIREQUEST", "true")) ) return view } loginFlowModel.loginUrl.observe(viewLifecycleOwner) { loginUrl -> if (loginUrl == null) return@observe val loginUri = loginUrl.toUri() private fun onReceivedNcUrl(url: String) { val match = LOGIN_URL_FORMAT.find(url) if (match == null) { Logger.log.severe("Unknown format of nc URL: $url") return } // reset URL so that the browser isn't shown another time loginFlowModel.loginUrl.value = null // determine DAV URL from root URL try { val serverUrl = URLDecoder.decode(match.groupValues[1], Charsets.UTF_8.name()) val davPath = requireActivity().intent.getStringExtra(EXTRA_DAV_PATH) loginModel.baseURI = if (davPath != null) (serverUrl + davPath).toHttpUrl().toUri() if (haveCustomTabs(loginUri)) { // Custom Tabs are available val browser = CustomTabsIntent.Builder() .setToolbarColor(resources.getColor(R.color.primaryColor)) .build() browser.intent.data = loginUri startActivityForResult(browser.intent, REQUEST_BROWSER, browser.startAnimationBundle) } 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) else URI.create(serverUrl) Snackbar.make(view, getString(R.string.install_browser), Snackbar.LENGTH_INDEFINITE).show() } } loginModel.credentials = Credentials( userName = URLDecoder.decode(match.groupValues[2], Charsets.UTF_8.name()), password = URLDecoder.decode(match.groupValues[3], Charsets.UTF_8.name()) ) loginFlowModel.error.observe(viewLifecycleOwner) { exception -> Snackbar.make(requireView(), exception.toString(), Snackbar.LENGTH_INDEFINITE) .setAction(R.string.exception_show_details) { val intent = Intent(requireActivity(), DebugInfoActivity::class.java) intent.putExtra(DebugInfoActivity.EXTRA_CAUSE, exception) startActivity(intent) } .show() } loginFlowModel.loginData.observe(viewLifecycleOwner) { (baseUri, credentials) -> // continue to next fragment loginModel.baseURI = baseUri loginModel.credentials = credentials parentFragmentManager.beginTransaction() .replace(android.R.id.content, DetectConfigurationFragment(), null) .addToBackStack(null) .commit() } catch (e: IllegalArgumentException) { Logger.log.log(Level.SEVERE, "Couldn't parse server argument of nc URL: $url", e) } // start Login Flow loginFlowModel.setUrl(entryUrl) return view } private fun haveCustomTabs(uri: Uri): Boolean { val browserIntent = Intent() .setAction(Intent.ACTION_VIEW) .addCategory(Intent.CATEGORY_BROWSABLE) .setData(uri) val pm = requireActivity().packageManager val appsSupportingCustomTabs = pm.queryIntentActivities(browserIntent, 0) for (pkg in appsSupportingCustomTabs) { // check whether app resolves Custom Tabs service, too val serviceIntent = Intent(ACTION_CUSTOM_TABS_CONNECTION).apply { setPackage(pkg.activityInfo.packageName) } if (pm.resolveService(serviceIntent, 0) != null) return true } return false } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { if (requestCode != REQUEST_BROWSER) return inner class CustomWebViewClient: WebViewClient() { override fun shouldOverrideUrlLoading(view: WebView, url: String) = if (url.startsWith("nc://login")) { Logger.log.fine("Received nc URL: $url") onReceivedNcUrl(url) true } else { Logger.log.fine("Didn't handle $url") false val davPath = requireActivity().intent.getStringExtra(EXTRA_DAV_PATH) loginFlowModel.checkResult(davPath) } override fun onReceivedError(view: WebView, errorCode: Int, description: String, failingUrl: String) { Logger.log.warning("Received error (deprecated API) $errorCode $description on $failingUrl") showError(view, "$description ($errorCode)") /** * 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>() val loginUrl = MutableLiveData<String>() val httpClient by lazy { HttpClient.Builder(getApplication()) .setForeground(true) .build() } var pollUrl: HttpUrl? = null var token: String? = null val loginData = MutableLiveData<Pair<URI, Credentials>>() override fun onCleared() { httpClient.close() } @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 // send POST request and process JSON reply CoroutineScope(Dispatchers.IO).launch { try { val json = postForJson(v2Url.toHttpUrl(), "".toRequestBody()) // login URL loginUrl.postValue(json.getString("login")) // poll URL and token json.getJSONObject("poll").let { poll -> pollUrl = poll.getString("endpoint").toHttpUrl() token = poll.getString("token") } @TargetApi(23) override fun onReceivedError(view: WebView, request: WebResourceRequest, error: WebResourceError) { Logger.log.warning("Received error ${error.errorCode} on ${request.url}") if (request.isForMainFrame) showError(view, "${error.description} (${error.errorCode})") } catch (e: Exception) { error.postValue(e) } override fun onReceivedHttpError(view: WebView, request: WebResourceRequest, errorResponse: WebResourceResponse) { val message = "${errorResponse.statusCode} ${errorResponse.reasonPhrase}" Logger.log.warning("Received HTTP error $message on ${request.url}") if (request.isForMainFrame) showError(view, message) } override fun onReceivedSslError(view: WebView, handler: SslErrorHandler, error: SslError) { Logger.log.warning("Received TLS error ${error.primaryError} on ${error.url}") if (error.url == view.url) showError(view, getString(R.string.login_webview_tlserror, error.primaryError)) handler.cancel() } fun showError(view: WebView, message: CharSequence) { Snackbar.make(requireView(), message, Snackbar.LENGTH_INDEFINITE) .setAction(R.string.login_webview_retry) { view.reload() } .show() @UiThread fun checkResult(davPath: String?) { val pollUrl = pollUrl ?: return val token = token ?: return CoroutineScope(Dispatchers.IO).launch { val json = postForJson(pollUrl, "token=$token".toRequestBody("application/x-www-form-urlencoded".toMediaType())) val serverUrl = json.getString("server") val loginName = json.getString("loginName") val appPassword = json.getString("appPassword") val baseUri = if (davPath != null) URI.create(serverUrl + davPath) else URI.create(serverUrl) loginData.postValue(Pair( baseUri, Credentials(loginName, appPassword) )) } } @WorkerThread private fun postForJson(url: HttpUrl, requestBody: RequestBody): JSONObject { val postRq = Request.Builder() .url(url) .post(requestBody) .build() val response = httpClient.okHttpClient.newCall(postRq).execute() if (response.code != HttpURLConnection.HTTP_OK) throw HttpException(response) response.body?.use { body -> val mimeType = body.contentType() ?: throw DavException("Login Flow response without MIME type") if (mimeType.type != "application" || mimeType.subtype != "json") throw DavException("Invalid Login Flow response (not JSON)") // decode JSON return JSONObject(body.string()) } throw DavException("Invalid Login Flow response (no body)") } } Loading app/src/main/res/layout/activity_webview.xmldeleted 100644 → 0 +0 −22 Original line number Diff line number Diff line <?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" xmlns:app="http://schemas.android.com/apk/res-auto" android:orientation="vertical"> <WebView android:id="@+id/browser" android:layout_width="match_parent" android:layout_height="match_parent" app:layout_constraintTop_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent" /> <ProgressBar android:id="@+id/progress" android:layout_width="match_parent" android:layout_height="wrap_content" app:layout_constraintTop_toTopOf="parent" style="@style/Widget.AppCompat.ProgressBar.Horizontal"/> </androidx.constraintlayout.widget.ConstraintLayout> No newline at end of file Loading
app/build.gradle +2 −1 Original line number Diff line number Diff line Loading @@ -106,9 +106,10 @@ dependencies { coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.0.10' implementation 'androidx.appcompat:appcompat:1.2.0' implementation 'androidx.browser:browser:1.2.0' implementation 'androidx.cardview:cardview:1.0.0' implementation 'androidx.constraintlayout:constraintlayout:2.0.1' implementation 'androidx.core:core-ktx:1.3.1' implementation 'androidx.core:core-ktx:1.3.2' implementation 'androidx.fragment:fragment-ktx:1.2.5' implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0' Loading
app/src/main/AndroidManifest.xml +5 −0 Original line number Diff line number Diff line Loading @@ -242,6 +242,11 @@ <action android:name="*" /> <data android:scheme="content" android:host="com.android.calendar" /> </intent> <!-- Custom Tabs support (e.g. Nextcloud Login Flow) --> <intent> <action android:name="android.support.customtabs.action.CustomTabsService" /> </intent> </queries> </manifest>
app/src/main/java/at/bitfire/davdroid/ui/UiUtils.kt +4 −1 Original line number Diff line number Diff line Loading @@ -43,13 +43,16 @@ object UiUtils { * installed), this method does nothing. * * @param toastInstallBrowser whether to show "Please install a browser" toast when * the Intent could not be resolved * the Intent could not be resolved; will also add [Intent.CATEGORY_BROWSABLE] to the Intent * * @return true on success, false if the Intent could not be resolved (for instance, because * there is no user agent installed) */ fun launchUri(context: Context, uri: Uri, action: String = Intent.ACTION_VIEW, toastInstallBrowser: Boolean = true): Boolean { val intent = Intent(action, uri) if (toastInstallBrowser) intent.addCategory(Intent.CATEGORY_BROWSABLE) if (intent.resolveActivity(context.packageManager) != null) { context.startActivity(intent) return true Loading
app/src/main/java/at/bitfire/davdroid/ui/setup/NextcloudLoginFlowFragment.kt +194 −89 Original line number Diff line number Diff line package at.bitfire.davdroid.ui.setup import android.annotation.SuppressLint import android.annotation.TargetApi import android.app.Application import android.content.Intent import android.net.http.SslError import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.webkit.* import androidx.annotation.UiThread import androidx.annotation.WorkerThread import androidx.browser.customtabs.CustomTabsIntent import androidx.browser.customtabs.CustomTabsService.ACTION_CUSTOM_TABS_CONNECTION import androidx.core.net.toUri import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import at.bitfire.davdroid.BuildConfig import androidx.fragment.app.viewModels import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.MutableLiveData import at.bitfire.dav4jvm.exception.DavException import at.bitfire.dav4jvm.exception.HttpException import at.bitfire.davdroid.HttpClient import at.bitfire.davdroid.R import at.bitfire.davdroid.log.Logger import at.bitfire.davdroid.model.Credentials import at.bitfire.davdroid.ui.DebugInfoActivity import com.google.android.material.snackbar.Snackbar import kotlinx.android.synthetic.main.activity_webview.view.* import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.MediaType.Companion.toMediaType import okhttp3.Request import okhttp3.RequestBody import okhttp3.RequestBody.Companion.toRequestBody import org.json.JSONObject import java.net.HttpURLConnection import java.net.URI import java.net.URLDecoder import java.util.logging.Level class NextcloudLoginFlowFragment: Fragment() { companion object { /** * Format of the Nextcloud Login URL, see: * https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html * * The parameter values must be URL-decoded. For instance, Nextcloud passes the username * "test@example.com" as "user:test%40example.com". */ val LOGIN_URL_FORMAT = Regex("^nc://login/server:(.+)&user:(.+)&password:(.+)$") const val LOGIN_FLOW_V1_PATH = "/index.php/login/flow" const 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" Loading @@ -41,109 +53,202 @@ 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>() @SuppressLint("SetJavaScriptEnabled") override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { val view = inflater.inflate(R.layout.activity_webview, container, false) val progressBar = view.progress val view = View(requireActivity()) val loginUrl = requireActivity().intent.data ?: throw IllegalArgumentException("Intent data must be set to Login Flow URL") Logger.log.info("Using Login Flow URL: $loginUrl") 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 webView = view.browser webView.settings.apply { javaScriptEnabled = true userAgentString = BuildConfig.userAgent } webView.webViewClient = CustomWebViewClient() webView.webChromeClient = object: WebChromeClient() { override fun onProgressChanged(view: WebView, newProgress: Int) { progressBar.progress = newProgress progressBar.visibility = if (newProgress == 100) View.GONE else View.VISIBLE } } webView.loadUrl( loginUrl.toString(), // https://nextcloud.example.com/index.php/login/flow mapOf(Pair("OCS-APIREQUEST", "true")) ) return view } loginFlowModel.loginUrl.observe(viewLifecycleOwner) { loginUrl -> if (loginUrl == null) return@observe val loginUri = loginUrl.toUri() private fun onReceivedNcUrl(url: String) { val match = LOGIN_URL_FORMAT.find(url) if (match == null) { Logger.log.severe("Unknown format of nc URL: $url") return } // reset URL so that the browser isn't shown another time loginFlowModel.loginUrl.value = null // determine DAV URL from root URL try { val serverUrl = URLDecoder.decode(match.groupValues[1], Charsets.UTF_8.name()) val davPath = requireActivity().intent.getStringExtra(EXTRA_DAV_PATH) loginModel.baseURI = if (davPath != null) (serverUrl + davPath).toHttpUrl().toUri() if (haveCustomTabs(loginUri)) { // Custom Tabs are available val browser = CustomTabsIntent.Builder() .setToolbarColor(resources.getColor(R.color.primaryColor)) .build() browser.intent.data = loginUri startActivityForResult(browser.intent, REQUEST_BROWSER, browser.startAnimationBundle) } 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) else URI.create(serverUrl) Snackbar.make(view, getString(R.string.install_browser), Snackbar.LENGTH_INDEFINITE).show() } } loginModel.credentials = Credentials( userName = URLDecoder.decode(match.groupValues[2], Charsets.UTF_8.name()), password = URLDecoder.decode(match.groupValues[3], Charsets.UTF_8.name()) ) loginFlowModel.error.observe(viewLifecycleOwner) { exception -> Snackbar.make(requireView(), exception.toString(), Snackbar.LENGTH_INDEFINITE) .setAction(R.string.exception_show_details) { val intent = Intent(requireActivity(), DebugInfoActivity::class.java) intent.putExtra(DebugInfoActivity.EXTRA_CAUSE, exception) startActivity(intent) } .show() } loginFlowModel.loginData.observe(viewLifecycleOwner) { (baseUri, credentials) -> // continue to next fragment loginModel.baseURI = baseUri loginModel.credentials = credentials parentFragmentManager.beginTransaction() .replace(android.R.id.content, DetectConfigurationFragment(), null) .addToBackStack(null) .commit() } catch (e: IllegalArgumentException) { Logger.log.log(Level.SEVERE, "Couldn't parse server argument of nc URL: $url", e) } // start Login Flow loginFlowModel.setUrl(entryUrl) return view } private fun haveCustomTabs(uri: Uri): Boolean { val browserIntent = Intent() .setAction(Intent.ACTION_VIEW) .addCategory(Intent.CATEGORY_BROWSABLE) .setData(uri) val pm = requireActivity().packageManager val appsSupportingCustomTabs = pm.queryIntentActivities(browserIntent, 0) for (pkg in appsSupportingCustomTabs) { // check whether app resolves Custom Tabs service, too val serviceIntent = Intent(ACTION_CUSTOM_TABS_CONNECTION).apply { setPackage(pkg.activityInfo.packageName) } if (pm.resolveService(serviceIntent, 0) != null) return true } return false } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { if (requestCode != REQUEST_BROWSER) return inner class CustomWebViewClient: WebViewClient() { override fun shouldOverrideUrlLoading(view: WebView, url: String) = if (url.startsWith("nc://login")) { Logger.log.fine("Received nc URL: $url") onReceivedNcUrl(url) true } else { Logger.log.fine("Didn't handle $url") false val davPath = requireActivity().intent.getStringExtra(EXTRA_DAV_PATH) loginFlowModel.checkResult(davPath) } override fun onReceivedError(view: WebView, errorCode: Int, description: String, failingUrl: String) { Logger.log.warning("Received error (deprecated API) $errorCode $description on $failingUrl") showError(view, "$description ($errorCode)") /** * 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>() val loginUrl = MutableLiveData<String>() val httpClient by lazy { HttpClient.Builder(getApplication()) .setForeground(true) .build() } var pollUrl: HttpUrl? = null var token: String? = null val loginData = MutableLiveData<Pair<URI, Credentials>>() override fun onCleared() { httpClient.close() } @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 // send POST request and process JSON reply CoroutineScope(Dispatchers.IO).launch { try { val json = postForJson(v2Url.toHttpUrl(), "".toRequestBody()) // login URL loginUrl.postValue(json.getString("login")) // poll URL and token json.getJSONObject("poll").let { poll -> pollUrl = poll.getString("endpoint").toHttpUrl() token = poll.getString("token") } @TargetApi(23) override fun onReceivedError(view: WebView, request: WebResourceRequest, error: WebResourceError) { Logger.log.warning("Received error ${error.errorCode} on ${request.url}") if (request.isForMainFrame) showError(view, "${error.description} (${error.errorCode})") } catch (e: Exception) { error.postValue(e) } override fun onReceivedHttpError(view: WebView, request: WebResourceRequest, errorResponse: WebResourceResponse) { val message = "${errorResponse.statusCode} ${errorResponse.reasonPhrase}" Logger.log.warning("Received HTTP error $message on ${request.url}") if (request.isForMainFrame) showError(view, message) } override fun onReceivedSslError(view: WebView, handler: SslErrorHandler, error: SslError) { Logger.log.warning("Received TLS error ${error.primaryError} on ${error.url}") if (error.url == view.url) showError(view, getString(R.string.login_webview_tlserror, error.primaryError)) handler.cancel() } fun showError(view: WebView, message: CharSequence) { Snackbar.make(requireView(), message, Snackbar.LENGTH_INDEFINITE) .setAction(R.string.login_webview_retry) { view.reload() } .show() @UiThread fun checkResult(davPath: String?) { val pollUrl = pollUrl ?: return val token = token ?: return CoroutineScope(Dispatchers.IO).launch { val json = postForJson(pollUrl, "token=$token".toRequestBody("application/x-www-form-urlencoded".toMediaType())) val serverUrl = json.getString("server") val loginName = json.getString("loginName") val appPassword = json.getString("appPassword") val baseUri = if (davPath != null) URI.create(serverUrl + davPath) else URI.create(serverUrl) loginData.postValue(Pair( baseUri, Credentials(loginName, appPassword) )) } } @WorkerThread private fun postForJson(url: HttpUrl, requestBody: RequestBody): JSONObject { val postRq = Request.Builder() .url(url) .post(requestBody) .build() val response = httpClient.okHttpClient.newCall(postRq).execute() if (response.code != HttpURLConnection.HTTP_OK) throw HttpException(response) response.body?.use { body -> val mimeType = body.contentType() ?: throw DavException("Login Flow response without MIME type") if (mimeType.type != "application" || mimeType.subtype != "json") throw DavException("Invalid Login Flow response (not JSON)") // decode JSON return JSONObject(body.string()) } throw DavException("Invalid Login Flow response (no body)") } } Loading
app/src/main/res/layout/activity_webview.xmldeleted 100644 → 0 +0 −22 Original line number Diff line number Diff line <?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" xmlns:app="http://schemas.android.com/apk/res-auto" android:orientation="vertical"> <WebView android:id="@+id/browser" android:layout_width="match_parent" android:layout_height="match_parent" app:layout_constraintTop_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent" /> <ProgressBar android:id="@+id/progress" android:layout_width="match_parent" android:layout_height="wrap_content" app:layout_constraintTop_toTopOf="parent" style="@style/Widget.AppCompat.ProgressBar.Horizontal"/> </androidx.constraintlayout.widget.ConstraintLayout> No newline at end of file