mirror of
https://github.com/element-hq/element-android
synced 2024-11-28 05:31:21 +03:00
SSO login is now performed in the default browser (#1400) - WIP
Use ChromeCustomTabs to host the SSO web page
This commit is contained in:
parent
ae7a52cecf
commit
d70b19fa93
14 changed files with 228 additions and 58 deletions
|
@ -10,6 +10,7 @@ Improvements 🙌:
|
|||
- Hide "X made no changes" event by default in timeline (#1430)
|
||||
- Hide left rooms in breadcrumbs (#766)
|
||||
- Correctly handle SSO login redirection
|
||||
- SSO login is now performed in the default browser, or in Chrome Custom tab if available (#1400)
|
||||
|
||||
Bugfix 🐛:
|
||||
- Switch theme is not fully taken into account without restarting the app
|
||||
|
|
|
@ -332,6 +332,9 @@ dependencies {
|
|||
implementation 'com.google.android:flexbox:1.1.1'
|
||||
implementation "androidx.autofill:autofill:$autofill_version"
|
||||
|
||||
// Custom Tab
|
||||
implementation 'androidx.browser:browser:1.2.0'
|
||||
|
||||
// Passphrase strength helper
|
||||
implementation 'com.nulab-inc:zxcvbn:1.2.7'
|
||||
|
||||
|
|
|
@ -48,7 +48,19 @@
|
|||
<activity android:name=".features.home.HomeActivity" />
|
||||
<activity
|
||||
android:name=".features.login.LoginActivity"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
android:launchMode="singleTask"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- Add intent filter to handle redirection URL after SSO login in external browser -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="riotx" />
|
||||
<data android:host="riotx" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity android:name=".features.media.ImageMediaViewerActivity" />
|
||||
<activity android:name=".features.media.BigImageViewerActivity" />
|
||||
<activity
|
||||
|
|
|
@ -59,6 +59,7 @@ import im.vector.riotx.features.login.LoginResetPasswordSuccessFragment
|
|||
import im.vector.riotx.features.login.LoginServerSelectionFragment
|
||||
import im.vector.riotx.features.login.LoginServerUrlFormFragment
|
||||
import im.vector.riotx.features.login.LoginSignUpSignInSelectionFragment
|
||||
import im.vector.riotx.features.login.LoginSignUpSignInSsoFragment
|
||||
import im.vector.riotx.features.login.LoginSplashFragment
|
||||
import im.vector.riotx.features.login.LoginWaitForEmailFragment
|
||||
import im.vector.riotx.features.login.LoginWebFragment
|
||||
|
@ -217,6 +218,11 @@ interface FragmentModule {
|
|||
@FragmentKey(LoginSignUpSignInSelectionFragment::class)
|
||||
fun bindLoginSignUpSignInSelectionFragment(fragment: LoginSignUpSignInSelectionFragment): Fragment
|
||||
|
||||
@Binds
|
||||
@IntoMap
|
||||
@FragmentKey(LoginSignUpSignInSsoFragment::class)
|
||||
fun bindLoginSignUpSignInSsoFragment(fragment: LoginSignUpSignInSsoFragment): Fragment
|
||||
|
||||
@Binds
|
||||
@IntoMap
|
||||
@FragmentKey(LoginSplashFragment::class)
|
||||
|
|
|
@ -21,10 +21,14 @@ import android.content.ActivityNotFoundException
|
|||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Browser
|
||||
import android.provider.MediaStore
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import androidx.browser.customtabs.CustomTabsSession
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.fragment.app.Fragment
|
||||
import im.vector.riotx.BuildConfig
|
||||
|
@ -64,6 +68,32 @@ fun openUrlInExternalBrowser(context: Context, uri: Uri?) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open url in custom tab or, if not available, in the default browser
|
||||
*/
|
||||
fun openUrlInChromeCustomTab(context: Context, session: CustomTabsSession?, url: String) {
|
||||
try {
|
||||
CustomTabsIntent.Builder()
|
||||
.setToolbarColor(ContextCompat.getColor(context, R.color.riotx_background_light))
|
||||
.apply {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||
setNavigationBarColor(ContextCompat.getColor(context, R.color.riotx_header_panel_background_light))
|
||||
}
|
||||
}
|
||||
.setNavigationBarColor(ContextCompat.getColor(context, R.color.riotx_background_light))
|
||||
.setColorScheme(CustomTabsIntent.COLOR_SCHEME_LIGHT)
|
||||
// Note: setting close button icon does not work
|
||||
.setCloseButtonIcon(BitmapFactory.decodeResource(context.resources, R.drawable.ic_back_24dp))
|
||||
.setStartAnimations(context, R.anim.enter_fade_in, R.anim.exit_fade_out)
|
||||
.setExitAnimations(context, R.anim.enter_fade_in, R.anim.exit_fade_out)
|
||||
.apply { session?.let { setSession(it) } }
|
||||
.build()
|
||||
.launchUrl(context, Uri.parse(url))
|
||||
} catch (activityNotFoundException: ActivityNotFoundException) {
|
||||
context.toast(R.string.error_no_external_application_found)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open sound recorder external application
|
||||
*/
|
||||
|
|
|
@ -33,6 +33,7 @@ import com.airbnb.mvrx.viewModel
|
|||
import com.airbnb.mvrx.withState
|
||||
import im.vector.matrix.android.api.auth.registration.FlowResult
|
||||
import im.vector.matrix.android.api.auth.registration.Stage
|
||||
import im.vector.matrix.android.api.extensions.tryThis
|
||||
import im.vector.riotx.R
|
||||
import im.vector.riotx.core.di.ScreenComponent
|
||||
import im.vector.riotx.core.extensions.POP_BACK_STACK_EXCLUSIVE
|
||||
|
@ -155,7 +156,11 @@ open class LoginActivity : VectorBaseActivity(), ToolbarConfigurable {
|
|||
is LoginViewEvents.OnSignModeSelected -> onSignModeSelected()
|
||||
is LoginViewEvents.OnLoginFlowRetrieved ->
|
||||
addFragmentToBackstack(R.id.loginFragmentContainer,
|
||||
LoginSignUpSignInSelectionFragment::class.java,
|
||||
if (loginViewEvents.isSso) {
|
||||
LoginSignUpSignInSsoFragment::class.java
|
||||
} else {
|
||||
LoginSignUpSignInSelectionFragment::class.java
|
||||
},
|
||||
option = commonOption)
|
||||
is LoginViewEvents.OnWebLoginError -> onWebLoginError(loginViewEvents)
|
||||
is LoginViewEvents.OnForgetPasswordClicked ->
|
||||
|
@ -239,16 +244,14 @@ open class LoginActivity : VectorBaseActivity(), ToolbarConfigurable {
|
|||
SignMode.SignIn -> {
|
||||
// It depends on the LoginMode
|
||||
when (state.loginMode) {
|
||||
LoginMode.Unknown -> error("Developer error")
|
||||
LoginMode.Unknown,
|
||||
LoginMode.Sso -> error("Developer error")
|
||||
LoginMode.Password -> addFragmentToBackstack(R.id.loginFragmentContainer,
|
||||
LoginFragment::class.java,
|
||||
tag = FRAGMENT_LOGIN_TAG,
|
||||
option = commonOption)
|
||||
LoginMode.Sso -> addFragmentToBackstack(R.id.loginFragmentContainer,
|
||||
LoginWebFragment::class.java,
|
||||
option = commonOption)
|
||||
LoginMode.Unsupported -> onLoginModeNotSupported(state.loginModeSupportedTypes)
|
||||
}
|
||||
}.exhaustive
|
||||
}
|
||||
SignMode.SignInWithMatrixId -> addFragmentToBackstack(R.id.loginFragmentContainer,
|
||||
LoginFragment::class.java,
|
||||
|
@ -257,6 +260,17 @@ open class LoginActivity : VectorBaseActivity(), ToolbarConfigurable {
|
|||
}.exhaustive
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the SSO redirection here
|
||||
*/
|
||||
override fun onNewIntent(intent: Intent?) {
|
||||
super.onNewIntent(intent)
|
||||
|
||||
intent?.data
|
||||
?.let { tryThis { it.getQueryParameter("loginToken") } }
|
||||
?.let { loginViewModel.handle(LoginAction.LoginWithToken(it)) }
|
||||
}
|
||||
|
||||
private fun onRegistrationStageNotSupported() {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.app_name)
|
||||
|
|
|
@ -113,7 +113,7 @@ class LoginServerSelectionFragment @Inject constructor() : AbstractLoginFragment
|
|||
|
||||
if (state.loginMode != LoginMode.Unknown) {
|
||||
// LoginFlow for matrix.org has been retrieved
|
||||
loginViewModel.handle(LoginAction.PostViewEvent(LoginViewEvents.OnLoginFlowRetrieved))
|
||||
loginViewModel.handle(LoginAction.PostViewEvent(LoginViewEvents.OnLoginFlowRetrieved(state.loginMode == LoginMode.Sso)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -124,7 +124,7 @@ class LoginServerUrlFormFragment @Inject constructor() : AbstractLoginFragment()
|
|||
|
||||
if (state.loginMode != LoginMode.Unknown) {
|
||||
// The home server url is valid
|
||||
loginViewModel.handle(LoginAction.PostViewEvent(LoginViewEvents.OnLoginFlowRetrieved))
|
||||
loginViewModel.handle(LoginAction.PostViewEvent(LoginViewEvents.OnLoginFlowRetrieved(state.loginMode == LoginMode.Sso)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,13 +26,11 @@ import javax.inject.Inject
|
|||
/**
|
||||
* In this screen, the user is asked to sign up or to sign in to the homeserver
|
||||
*/
|
||||
class LoginSignUpSignInSelectionFragment @Inject constructor() : AbstractLoginFragment() {
|
||||
open class LoginSignUpSignInSelectionFragment @Inject constructor() : AbstractLoginFragment() {
|
||||
|
||||
override fun getLayoutResId() = R.layout.fragment_login_signup_signin_selection
|
||||
|
||||
private var isSsoSignIn: Boolean = false
|
||||
|
||||
private fun setupUi(state: LoginViewState) {
|
||||
protected fun setupUi(state: LoginViewState) {
|
||||
when (state.serverType) {
|
||||
ServerType.MatrixOrg -> {
|
||||
loginSignupSigninServerIcon.setImageResource(R.drawable.ic_logo_matrix_org)
|
||||
|
@ -54,25 +52,14 @@ class LoginSignUpSignInSelectionFragment @Inject constructor() : AbstractLoginFr
|
|||
}
|
||||
}
|
||||
|
||||
private fun setupButtons(state: LoginViewState) {
|
||||
isSsoSignIn = state.loginMode == LoginMode.Sso
|
||||
|
||||
if (isSsoSignIn) {
|
||||
loginSignupSigninSubmit.text = getString(R.string.login_signin_sso)
|
||||
loginSignupSigninSignIn.isVisible = false
|
||||
} else {
|
||||
loginSignupSigninSubmit.text = getString(R.string.login_signup)
|
||||
loginSignupSigninSignIn.isVisible = true
|
||||
}
|
||||
private fun setupButtons() {
|
||||
loginSignupSigninSubmit.text = getString(R.string.login_signup)
|
||||
loginSignupSigninSignIn.isVisible = true
|
||||
}
|
||||
|
||||
@OnClick(R.id.loginSignupSigninSubmit)
|
||||
fun signUp() {
|
||||
if (isSsoSignIn) {
|
||||
signIn()
|
||||
} else {
|
||||
loginViewModel.handle(LoginAction.UpdateSignMode(SignMode.SignUp))
|
||||
}
|
||||
open fun submit() {
|
||||
loginViewModel.handle(LoginAction.UpdateSignMode(SignMode.SignUp))
|
||||
}
|
||||
|
||||
@OnClick(R.id.loginSignupSigninSignIn)
|
||||
|
@ -86,6 +73,6 @@ class LoginSignUpSignInSelectionFragment @Inject constructor() : AbstractLoginFr
|
|||
|
||||
override fun updateWithState(state: LoginViewState) {
|
||||
setupUi(state)
|
||||
setupButtons(state)
|
||||
setupButtons()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,98 @@
|
|||
/*
|
||||
* Copyright (c) 2020 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package im.vector.riotx.features.login
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.net.Uri
|
||||
import androidx.browser.customtabs.CustomTabsClient
|
||||
import androidx.browser.customtabs.CustomTabsServiceConnection
|
||||
import androidx.browser.customtabs.CustomTabsSession
|
||||
import androidx.core.view.isVisible
|
||||
import im.vector.riotx.R
|
||||
import im.vector.riotx.core.utils.openUrlInChromeCustomTab
|
||||
import kotlinx.android.synthetic.main.fragment_login_signup_signin_selection.*
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* In this screen, the user is asked to sign up or to sign in using SSO
|
||||
* This Fragment binds a CustomTabsServiceConnection if available, then prefetch the SSO url, as it will be likely to be opened.
|
||||
*/
|
||||
open class LoginSignUpSignInSsoFragment @Inject constructor() : LoginSignUpSignInSelectionFragment() {
|
||||
|
||||
private var ssoUrl: String? = null
|
||||
private var customTabsServiceConnection: CustomTabsServiceConnection? = null
|
||||
private var customTabsClient: CustomTabsClient? = null
|
||||
private var customTabsSession: CustomTabsSession? = null
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
|
||||
val packageName = CustomTabsClient.getPackageName(requireContext(), null)
|
||||
|
||||
if (packageName != null) {
|
||||
customTabsServiceConnection = object : CustomTabsServiceConnection() {
|
||||
override fun onCustomTabsServiceConnected(name: ComponentName, client: CustomTabsClient) {
|
||||
customTabsClient = client
|
||||
.also { it.warmup(0L) }
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
}
|
||||
}
|
||||
.also {
|
||||
CustomTabsClient.bindCustomTabsService(
|
||||
requireContext(),
|
||||
// Despite the API, packageName cannot be null
|
||||
packageName,
|
||||
it
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun prefetchUrl(url: String) {
|
||||
if (ssoUrl != null) return
|
||||
|
||||
ssoUrl = url
|
||||
if (customTabsSession == null) {
|
||||
customTabsSession = customTabsClient?.newSession(null)
|
||||
}
|
||||
|
||||
customTabsSession?.mayLaunchUrl(Uri.parse(url), null, null)
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
customTabsServiceConnection?.let { requireContext().unbindService(it) }
|
||||
customTabsServiceConnection = null
|
||||
}
|
||||
|
||||
private fun setupButtons() {
|
||||
loginSignupSigninSubmit.text = getString(R.string.login_signin_sso)
|
||||
loginSignupSigninSignIn.isVisible = false
|
||||
}
|
||||
|
||||
override fun submit() {
|
||||
ssoUrl?.let { openUrlInChromeCustomTab(requireContext(), customTabsSession, it) }
|
||||
}
|
||||
|
||||
override fun updateWithState(state: LoginViewState) {
|
||||
setupUi(state)
|
||||
setupButtons()
|
||||
prefetchUrl(state.getSsoUrl())
|
||||
}
|
||||
}
|
|
@ -34,7 +34,7 @@ sealed class LoginViewEvents : VectorViewEvents {
|
|||
|
||||
object OpenServerSelection : LoginViewEvents()
|
||||
object OnServerSelectionDone : LoginViewEvents()
|
||||
object OnLoginFlowRetrieved : LoginViewEvents()
|
||||
data class OnLoginFlowRetrieved(val isSso: Boolean) : LoginViewEvents()
|
||||
object OnSignModeSelected : LoginViewEvents()
|
||||
object OnForgetPasswordClicked : LoginViewEvents()
|
||||
object OnResetPasswordSendThreePidDone : LoginViewEvents()
|
||||
|
|
|
@ -22,6 +22,9 @@ import com.airbnb.mvrx.MvRxState
|
|||
import com.airbnb.mvrx.PersistState
|
||||
import com.airbnb.mvrx.Success
|
||||
import com.airbnb.mvrx.Uninitialized
|
||||
import im.vector.matrix.android.api.auth.SSO_FALLBACK_PATH
|
||||
import im.vector.matrix.android.api.auth.SSO_REDIRECT_URL_PARAM
|
||||
import im.vector.riotx.core.extensions.appendParamToUrl
|
||||
|
||||
data class LoginViewState(
|
||||
val asyncLoginAction: Async<Unit> = Uninitialized,
|
||||
|
@ -64,4 +67,22 @@ data class LoginViewState(
|
|||
fun isUserLogged(): Boolean {
|
||||
return asyncLoginAction is Success
|
||||
}
|
||||
|
||||
fun getSsoUrl(): String {
|
||||
return buildString {
|
||||
append(homeServerUrl?.trim { it == '/' })
|
||||
append(SSO_FALLBACK_PATH)
|
||||
// Set a redirect url we will intercept later
|
||||
appendParamToUrl(SSO_REDIRECT_URL_PARAM, RIOTX_REDIRECT_URL)
|
||||
deviceId?.takeIf { it.isNotBlank() }?.let {
|
||||
// But https://github.com/matrix-org/synapse/issues/5755
|
||||
appendParamToUrl("device_id", it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
// Note that the domain can be displayed to the user for confirmation that he trusts it. So use a human readable string
|
||||
private const val RIOTX_REDIRECT_URL = "riotx://riotx"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,7 +21,6 @@ package im.vector.riotx.features.login
|
|||
import android.annotation.SuppressLint
|
||||
import android.content.DialogInterface
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.net.http.SslError
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
|
@ -34,10 +33,7 @@ import androidx.appcompat.app.AlertDialog
|
|||
import com.airbnb.mvrx.activityViewModel
|
||||
import im.vector.matrix.android.api.auth.LOGIN_FALLBACK_PATH
|
||||
import im.vector.matrix.android.api.auth.REGISTER_FALLBACK_PATH
|
||||
import im.vector.matrix.android.api.auth.SSO_FALLBACK_PATH
|
||||
import im.vector.matrix.android.api.auth.SSO_REDIRECT_URL_PARAM
|
||||
import im.vector.matrix.android.api.auth.data.Credentials
|
||||
import im.vector.matrix.android.api.extensions.tryThis
|
||||
import im.vector.matrix.android.internal.di.MoshiProvider
|
||||
import im.vector.riotx.R
|
||||
import im.vector.riotx.core.extensions.appendParamToUrl
|
||||
|
@ -50,18 +46,13 @@ import java.net.URLDecoder
|
|||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* This screen is displayed for SSO login and also when the application does not support login flow or registration flow
|
||||
* This screen is displayed when the application does not support login flow or registration flow
|
||||
* of the homeserver, as a fallback to login or to create an account
|
||||
*/
|
||||
class LoginWebFragment @Inject constructor(
|
||||
private val assetReader: AssetReader
|
||||
) : AbstractLoginFragment() {
|
||||
|
||||
companion object {
|
||||
// Note that the domain can be displayed to the user for confirmation that he trusts it. So use a human readable string
|
||||
private const val REDIRECT_URL = "riotx://riotx"
|
||||
}
|
||||
|
||||
override fun getLayoutResId() = R.layout.fragment_login_web
|
||||
|
||||
private var isWebViewLoaded = false
|
||||
|
@ -135,13 +126,7 @@ class LoginWebFragment @Inject constructor(
|
|||
val url = buildString {
|
||||
append(state.homeServerUrl?.trim { it == '/' })
|
||||
if (state.signMode == SignMode.SignIn) {
|
||||
if (state.loginMode == LoginMode.Sso) {
|
||||
append(SSO_FALLBACK_PATH)
|
||||
// Set a redirect url we will intercept later
|
||||
appendParamToUrl(SSO_REDIRECT_URL_PARAM, REDIRECT_URL)
|
||||
} else {
|
||||
append(LOGIN_FALLBACK_PATH)
|
||||
}
|
||||
append(LOGIN_FALLBACK_PATH)
|
||||
state.deviceId?.takeIf { it.isNotBlank() }?.let {
|
||||
// But https://github.com/matrix-org/synapse/issues/5755
|
||||
appendParamToUrl("device_id", it)
|
||||
|
@ -261,8 +246,6 @@ class LoginWebFragment @Inject constructor(
|
|||
}
|
||||
}
|
||||
return true
|
||||
} else if (url.startsWith(REDIRECT_URL)) {
|
||||
return handleSsoLoginSuccess(url)
|
||||
}
|
||||
|
||||
return super.shouldOverrideUrlLoading(view, url)
|
||||
|
@ -270,14 +253,6 @@ class LoginWebFragment @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun handleSsoLoginSuccess(url: String): Boolean {
|
||||
val uri = Uri.parse(url)
|
||||
val loginToken = tryThis { uri.getQueryParameter("loginToken") } ?: return false
|
||||
|
||||
loginViewModel.handle(LoginAction.LoginWithToken(loginToken))
|
||||
return true
|
||||
}
|
||||
|
||||
private fun notifyViewModel(credentials: Credentials) {
|
||||
if (isForSessionRecovery) {
|
||||
val softLogoutViewModel: SoftLogoutViewModel by activityViewModel()
|
||||
|
|
23
vector/src/main/res/drawable/ic_back_24dp.xml
Normal file
23
vector/src/main/res/drawable/ic_back_24dp.xml
Normal file
|
@ -0,0 +1,23 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M20,12H4"
|
||||
android:strokeWidth="2"
|
||||
android:strokeColor="#2E2F32"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
tools:strokeColor="#00F000" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M10,18L4,12L10,6"
|
||||
android:strokeWidth="2"
|
||||
android:strokeColor="#2E2F32"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
tools:strokeColor="#00F000" />
|
||||
</vector>
|
Loading…
Reference in a new issue