Merge pull request #4 from melod1n/2fa_direct_support

2fa direct support
This commit is contained in:
2021-08-31 15:52:30 +03:00
committed by GitHub
50 changed files with 789 additions and 481 deletions
+9
View File
@@ -1,3 +1,8 @@
import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
val login: String = gradleLocalProperties(rootDir).getProperty("vklogin")
val password: String = gradleLocalProperties(rootDir).getProperty("vkpassword")
plugins {
id("com.android.application")
id("kotlin-android")
@@ -26,6 +31,10 @@ android {
}
buildTypes {
getByName("debug") {
buildConfigField("String", "vkLogin", login)
buildConfigField("String", "vkPassword", password)
}
getByName("release") {
isMinifyEnabled = false
@@ -29,7 +29,6 @@ object UserConfig {
userId = -1
}
fun isLoggedIn(): Boolean {
return userId > 0 && !TextUtils.isEmpty(accessToken)
}
fun isLoggedIn() = userId > 0 && accessToken.isNotBlank()
}
@@ -6,7 +6,7 @@ import com.meloda.fast.concurrent.EventInfo
import com.meloda.fast.concurrent.TaskManager
import com.meloda.fast.api.VKApiKeys
import com.meloda.fast.api.model.VKMessage
import com.meloda.fast.api.util.VKUtil
import com.meloda.fast.api.VKUtil
import org.json.JSONArray
@Suppress("UNCHECKED_CAST")
@@ -1,6 +0,0 @@
package com.meloda.fast.api
sealed class Answer<out R> {
data class Success<out T>(val data: T) : Answer<T>()
data class Error(val errorString: String) : Answer<Nothing>()
}
@@ -1,2 +0,0 @@
package com.meloda.fast.api
@@ -1,25 +0,0 @@
package com.meloda.fast.api
data class Resource<out T> constructor(
val status: Status,
val responseData: T?,
val message: String?
) {
enum class Status {
SUCCESS,
ERROR,
LOADING
}
companion object {
fun <T> success(responseData: T?): Resource<T> =
Resource(Status.SUCCESS, responseData, null)
fun <T> error(message: String?, responseBody: T? = null): Resource<T> =
Resource(Status.ERROR, responseBody, message)
fun <T> loading(responseData: T? = null): Resource<T> =
Resource(Status.LOADING, responseData, null)
}
}
@@ -7,6 +7,7 @@ import com.meloda.fast.BuildConfig
import com.meloda.fast.api.method.MessageMethodSetter
import com.meloda.fast.api.method.MethodSetter
import com.meloda.fast.api.method.UserMethodSetter
import com.meloda.fast.api.network.ErrorCodes
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.asFlow
import org.json.JSONArray
@@ -55,13 +56,14 @@ object VKApi {
try {
checkError(json, url)
} catch (ex: VKException) {
if (ex.code == ErrorCodes.TOO_MANY_REQUESTS) {
Timer().schedule(object : TimerTask() {
override fun run() {
execute(url, cls)
}
}, 1000)
} else throw ex
throw ex
// if (ex.code == ErrorCodes.TOO_MANY_REQUESTS) {
// Timer().schedule(object : TimerTask() {
// override fun run() {
// execute(url, cls)
// }
// }, 1000)
// } else throw ex
}
when (cls) {
@@ -282,7 +284,7 @@ object VKApi {
val code = error.optInt("error_code", -1)
val message = error.optString("error_msg", "")
val e = VKException(url, message, code)
// val e = VKException(url, message, code)
//TODO: add checking invalid session
if (code == 5 && message.contains("invalid session")) {
@@ -291,16 +293,16 @@ object VKApi {
// })
}
if (code == ErrorCodes.CAPTCHA_NEEDED) {
e.captchaImg = error.optString("captcha_img")
e.captchaSid = error.optString("captcha_sid")
}
// if (code == ErrorCodes.CAPTCHA_NEEDED) {
// e.captchaImg = error.optString("captcha_img")
// e.captchaSid = error.optString("captcha_sid")
// }
//
// if (code == ErrorCodes.VALIDATION_REQUIRED) {
// e.redirectUri = error.optString("redirect_uri")
// }
if (code == ErrorCodes.VALIDATION_REQUIRED) {
e.redirectUri = error.optString("redirect_uri")
}
throw e
// throw e
}
}
@@ -3,14 +3,17 @@ package com.meloda.fast.api
import android.util.Log
import com.meloda.fast.BuildConfig
import com.meloda.fast.UserConfig
import com.meloda.fast.api.util.VKUtil
import java.net.URLEncoder
object VKAuth {
private const val TAG = "VKM.VKAuth"
private const val settings = "notify," +
object GrantType {
const val PASSWORD = "password"
}
const val scope = "notify," +
"friends," +
"photos," +
"audio," +
@@ -30,17 +33,25 @@ object VKAuth {
fun getDirectAuthUrl(
login: String,
password: String,
captchaSid: String? = null,
captchaKey: String? = null
) = "https://oauth.vk.com/token?grant_type=password&" +
twoFa: Boolean = false,
twoFaCode: String = "",
captcha: Pair<String, String>? = null
) = "https://oauth.vk.com/token?" +
"grant_type=password&" +
"client_id=${VKConstants.VK_APP_ID}&" +
"scope=$settings&" +
"client_secret=${VKConstants.VK_SECRET}&" +
"username=$login&" +
"password=$password" +
(if (captchaSid == null || captchaKey == null) "" else "&captcha_sid=$captchaSid&captcha_key=$captchaKey") +
"password=$password&" +
"scope=$scope&" +
"2fa_supported=1&" +
"force_sms=${if (twoFa) "1" else "0"}" +
(if (twoFa) "code=$twoFaCode" else "") +
(if (captcha == null) "" else "&captcha_sid=${captcha.first}&captcha_key=${captcha.second}") +
"&v=${URLEncoder.encode(VKApi.API_VERSION, "utf-8")}"
fun getSendSmsCodeUrl(sid: String) = "https://api.vk.com/method/auth.validatePhone?" +
"sid=$sid&" +
"&v=${URLEncoder.encode(VKApi.API_VERSION, "utf-8")}"
fun getOAuthUrl(settings: String) = "https://oauth.vk.com/authorize?" +
"client_id=${UserConfig.FAST_APP_ID}&" +
@@ -1,15 +1,17 @@
package com.meloda.fast.api
import org.json.JSONObject
import java.io.IOException
class VKException(var url: String = "", override var message: String = "", var code: Int) :
IOException(message) {
var captchaSid: String? = null
var captchaImg: String? = null
var redirectUri: String? = null
class VKException(var url: String = "", var description: String = "", var error: String) :
IOException(description) {
var captcha: Pair<String, String>? = null
var validationSid: String? = null
var json: JSONObject? = null
override fun toString(): String {
return "code: $code, message: $message"
return "error: $error; description: $description;"
}
}
@@ -1,17 +0,0 @@
package com.meloda.fast.api
import com.meloda.fast.api.model.response.GetConversationsResponse
import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded
import retrofit2.http.POST
interface VKRepo {
@FormUrlEncoded
@POST(VKUrls.getConversations)
suspend fun getAllChats(
@Field("user_id") chatId: Int,
@Field("token") token: String
): Answer<GetConversationsResponse>
}
@@ -1,7 +0,0 @@
package com.meloda.fast.api
object VKUrls {
const val getConversations = "messages.getConversations"
}
@@ -1,7 +1,8 @@
package com.meloda.fast.api.util
package com.meloda.fast.api
import androidx.annotation.WorkerThread
import com.meloda.fast.api.model.*
import com.meloda.fast.api.network.VKErrors
import org.json.JSONArray
import org.json.JSONObject
import java.text.SimpleDateFormat
@@ -12,6 +13,21 @@ object VKUtil {
private const val TAG = "VKUtil"
fun isValidationRequired(throwable: Throwable): Boolean {
if (throwable !is VKException) return false
return throwable.error == VKErrors.NEED_VALIDATION
}
fun isCaptchaRequired(throwable: Throwable): Boolean {
if (throwable !is VKException) return false
return throwable.error == VKErrors.NEED_CAPTCHA
}
fun extractValidationSid(throwable: Throwable): String? {
if (throwable !is VKException) return null
return throwable.json?.optString("validation_sid")
}
fun extractPattern(string: String, pattern: String): String? {
val p = Pattern.compile(pattern)
val m = p.matcher(string)
@@ -1,4 +0,0 @@
package com.meloda.fast.api.datasource
class MessagesDataSource constructor() {
}
@@ -1,97 +0,0 @@
package com.meloda.fast.api.datasource.base
import android.util.Log
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import com.google.gson.JsonSyntaxException
import com.meloda.fast.api.Resource
import com.meloda.fast.api.model.ApiResponse
import com.meloda.fast.api.ErrorCodes
import com.meloda.fast.api.VKException
import okhttp3.ResponseBody
import retrofit2.HttpException
class BaseDataSource {
private val TAG = BaseDataSource::class.simpleName
//TODO: move to resources
private val DEFAULT_ERROR = "Internal server error"
protected suspend fun <T> getResult(apiCall: suspend () -> ApiResponse<T>): Resource<T> {
try {
val response = apiCall()
return if (response.isSuccessful) {
Resource.success(response.response)
} else {
Log.d(TAG, "Server response unsuccessful")
if (response.error != null) {
Log.w(TAG, "Unsuccessful response with code 2XX")
Resource.error(response.error.message, response.response)
} else {
Log.e(TAG, "Unsuccessful result without error!")
Resource.error(DEFAULT_ERROR)
}
}
} catch (e: HttpException) {
Log.e(TAG, "Error while executing request ${e.message}")
val errorBody = e.response()?.errorBody() ?: return Resource.error(DEFAULT_ERROR)
val errorResponse = parseErrorBody<T>(errorBody) ?: return Resource.error(DEFAULT_ERROR)
return Resource.error(errorResponse.message)
} catch (e: Exception) {
Log.e(TAG, "Error while executing request ${e.message}")
return Resource.error(DEFAULT_ERROR)
}
}
private fun <T> parseErrorBody(responseBody: ResponseBody?): Exception? {
if (responseBody == null) return null
val jsonResponse: JsonObject?
try {
jsonResponse = JsonParser.parseString(responseBody.string()) as? JsonObject
if (jsonResponse == null) {
Log.d(TAG, "Response body is empty while parsing error body.")
return null
}
} catch (e: JsonSyntaxException) {
Log.e(TAG, "Error while parsing json ${e.message}")
return null
} catch (e: java.lang.Exception) {
Log.e(TAG, "Unknown error ${e.message}")
return null
}
if (jsonResponse.has("error")) {
val error = jsonResponse["error"].asJsonObject
val message = error["error_msg"].asString
val code = error["error_code"].asInt
val e = VKException("", message, code)
//TODO: add checking invalid session
if (code == 5 && message.contains("invalid session")) {
// context?.startActivity(Intent(context, DropUserDataActivity::class.java).apply {
// addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
// })
}
if (code == ErrorCodes.CAPTCHA_NEEDED) {
e.captchaImg = error["captcha_img"].asString
e.captchaSid = error["captcha_sid"].asString
}
if (code == ErrorCodes.VALIDATION_REQUIRED) {
e.redirectUri = error["redirect_uri"].asString
}
return e
}
return null
}
}
@@ -1,12 +0,0 @@
package com.meloda.fast.api.model
data class ApiResponse<T> constructor(
val isSuccessful: Boolean,
val error: Error?,
val response: T?
)
data class Error constructor(
val code: Long,
val message: String
)
@@ -1,7 +1,7 @@
package com.meloda.fast.api.model
import android.util.ArrayMap
import com.meloda.fast.api.util.VKUtil
import com.meloda.fast.api.VKUtil
import org.json.JSONObject
open class VKMessage() : VKModel() {
@@ -0,0 +1,16 @@
package com.meloda.fast.api.network
import com.meloda.fast.api.VKApi
import okhttp3.Interceptor
import okhttp3.Response
import java.net.URLEncoder
class AuthInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val builder = chain.request().url.newBuilder()
.addQueryParameter("v", URLEncoder.encode(VKApi.API_VERSION, "utf-8"))
return chain.proceed(chain.request().newBuilder().apply { url(builder.build()) }.build())
}
}
@@ -1,4 +1,4 @@
package com.meloda.fast.api
package com.meloda.fast.api.network
object ErrorCodes {
const val UNKNOWN_ERROR = 1
@@ -40,3 +40,12 @@ object ErrorCodes {
const val INVALID_DOC_TITLE = 1152
const val ACCESS_TO_DOC_DENIED = 1153
}
object VKErrors {
const val UNKNOWN = "unknown_error"
const val NEED_VALIDATION = "need_validation"
const val NEED_CAPTCHA = "need_captcha"
const val INVALID_REQUEST = "invalid_request"
}
@@ -0,0 +1,125 @@
package com.meloda.fast.api.network
import android.util.Log
import com.meloda.fast.api.VKException
import okhttp3.Request
import okio.IOException
import okio.Timeout
import org.json.JSONObject
import retrofit2.*
import java.lang.reflect.ParameterizedType
import java.lang.reflect.Type
class ResultCallFactory : CallAdapter.Factory() {
override fun get(
returnType: Type,
annotations: Array<out Annotation>,
retrofit: Retrofit
): CallAdapter<*, *>? {
val rawReturnType: Class<*> = getRawType(returnType)
if (rawReturnType == Call::class.java) {
if (returnType is ParameterizedType) {
val callInnerType: Type = getParameterUpperBound(0, returnType)
if (getRawType(callInnerType) == Answer::class.java) {
if (callInnerType is ParameterizedType) {
val resultInnerType = getParameterUpperBound(0, callInnerType)
return ResultCallAdapter<Any?>(resultInnerType)
}
return ResultCallAdapter<Nothing>(Nothing::class.java)
}
}
}
return null
}
}
internal abstract class CallDelegate<In, Out>(protected val proxy: Call<In>) : Call<Out> {
override fun execute(): Response<Out> = throw NotImplementedError()
final override fun enqueue(callback: Callback<Out>) = enqueueImpl(callback)
final override fun clone(): Call<Out> = cloneImpl()
override fun cancel() = proxy.cancel()
override fun request(): Request = proxy.request()
override fun isExecuted() = proxy.isExecuted
override fun isCanceled() = proxy.isCanceled
abstract fun enqueueImpl(callback: Callback<Out>)
abstract fun cloneImpl(): Call<Out>
}
private class ResultCallAdapter<R>(private val type: Type) : CallAdapter<R, Call<Answer<R>>> {
override fun responseType() = type
override fun adapt(call: Call<R>): Call<Answer<R>> = ResultCall(call)
}
internal class ResultCall<T>(proxy: Call<T>) : CallDelegate<T, Answer<T>>(proxy) {
override fun enqueueImpl(callback: Callback<Answer<T>>) {
proxy.enqueue(ResultCallback(this, callback))
}
override fun cloneImpl(): ResultCall<T> {
return ResultCall(proxy.clone())
}
private class ResultCallback<T>(
private val proxy: ResultCall<T>,
private val callback: Callback<Answer<T>>
) : Callback<T> {
// TODO: 8/31/2021 parse VK errors
override fun onResponse(call: Call<T>, response: Response<T>) {
val result: Answer<T> = if (response.isSuccessful)
Answer.Success(response.body() as T)
else Answer.Error(IOException(response.errorBody()?.string() ?: ""))
if (result is Answer.Error) if (checkErrors(call, result)) return
callback.onResponse(proxy, Response.success(result))
}
override fun onFailure(call: Call<T>, error: Throwable) {
callback.onResponse(
proxy,
Response.success(Answer.Error(throwable = error))
)
}
private fun checkErrors(call: Call<T>, result: Answer.Error): Boolean {
val json = JSONObject(result.throwable.message ?: "{}")
return if (json.has("error")) {
val error = json.optString("error", "")
val description = json.optString("error_description", "")
val exception = VKException(
error = error,
description = description,
).also { it.json = json }
onFailure(call, exception)
true
} else false
}
}
override fun timeout(): Timeout {
return proxy.timeout()
}
}
sealed class Answer<out R> {
data class Success<out T>(val data: T) : Answer<T>()
data class Error(val throwable: Throwable) : Answer<Nothing>()
}
@@ -0,0 +1,65 @@
package com.meloda.fast.api.network
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.meloda.fast.api.network.datasource.AuthDataSource
import com.meloda.fast.api.network.repo.AuthRepo
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
@Module
class VKModules {
@Singleton
@Provides
fun provideOkHttpClient(authInterceptor: AuthInterceptor): OkHttpClient = OkHttpClient.Builder()
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(20, TimeUnit.SECONDS)
.addInterceptor(authInterceptor)
.followRedirects(true)
.followSslRedirects(true)
.addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}).build()
@Singleton
@Provides
fun provideGson(): Gson = GsonBuilder()
.setLenient()
.create()
@Singleton
@Provides
fun provideRetrofit(
client: OkHttpClient,
gson: Gson
): Retrofit = Retrofit.Builder()
.baseUrl("https://api.vk.com/")
.addConverterFactory(GsonConverterFactory.create(gson))
.addCallAdapterFactory(ResultCallFactory())
.client(client)
.build()
@Provides
@Singleton
fun provideAuthInterceptor(): AuthInterceptor = AuthInterceptor()
@Provides
fun provideAuthRepo(retrofit: Retrofit): AuthRepo =
retrofit.create(AuthRepo::class.java)
@Provides
fun provideAuthDataSource(repo: AuthRepo): AuthDataSource =
AuthDataSource(repo)
}
@@ -0,0 +1,20 @@
package com.meloda.fast.api.network
object VKUrls {
const val OAUTH = "https://oauth.vk.com"
const val API = "https://api.vk.com/method"
object Auth {
const val directAuth = "$OAUTH/token"
const val sendSms = "$API/auth.validatePhone"
}
object Conversations {
const val get = "$API/messages.getConversations"
}
}
@@ -0,0 +1,12 @@
package com.meloda.fast.api.network.datasource
import com.meloda.fast.api.network.repo.AuthRepo
import javax.inject.Inject
class AuthDataSource @Inject constructor(
private val repo: AuthRepo
) : AuthRepo {
override suspend fun auth(param: Map<String, String?>) = repo.auth(param)
override suspend fun sendSms(validationSid: String) = repo.sendSms(validationSid)
}
@@ -0,0 +1,17 @@
package com.meloda.fast.api.network.repo
import com.meloda.fast.api.network.VKUrls
import com.meloda.fast.api.network.response.ResponseAuthDirect
import com.meloda.fast.api.network.Answer
import com.meloda.fast.api.network.response.ResponseSendSms
import retrofit2.http.*
interface AuthRepo {
@GET(VKUrls.Auth.directAuth)
suspend fun auth(@QueryMap param: Map<String, String?>): Answer<ResponseAuthDirect>
@GET(VKUrls.Auth.sendSms)
suspend fun sendSms(@Query("sid") validationSid: String): Answer<ResponseSendSms>
}
@@ -0,0 +1,18 @@
package com.meloda.fast.api.network.repo
import com.meloda.fast.api.network.Answer
import com.meloda.fast.api.network.VKUrls
import com.meloda.fast.api.network.response.GetConversationsResponse
import retrofit2.http.*
interface ConversationsRepo {
@FormUrlEncoded
@POST(VKUrls.Conversations.get)
suspend fun getAllChats(
@Field("user_id") chatId: Int,
@Field("token") token: String
): Answer<GetConversationsResponse>
}
@@ -0,0 +1,37 @@
package com.meloda.fast.api.network.request
import android.os.Parcelable
import com.google.gson.annotations.SerializedName
import kotlinx.parcelize.Parcelize
@Parcelize
data class RequestAuthDirect(
@SerializedName("grant_type") val grantType: String,
@SerializedName("client_id") val clientId: String,
@SerializedName("client_secret") val clientSecret: String,
@SerializedName("username") val username: String,
@SerializedName("password") val password: String,
@SerializedName("scope") val scope: String,
@SerializedName("2fa_supported") val twoFaSupported: Boolean = true,
@SerializedName("force_sms") val twoFaForceSms: Boolean = false,
@SerializedName("code") val twoFaCode: String? = null,
@SerializedName("captcha_sid") val captchaSid: String? = null,
@SerializedName("captcha_key") val captchaKey: String? = null,
) : Parcelable {
val map
get() = mutableMapOf(
"grant_type" to grantType,
"client_id" to clientId,
"client_secret" to clientSecret,
"username" to username,
"password" to password,
"scope" to scope,
"2fa_supported" to if (twoFaSupported) "1" else "0",
"force_sms" to if (twoFaForceSms) "1" else "0"
)
.apply {
twoFaCode?.let { this["code"] = it }
captchaSid?.let { this["captcha_sid"] = it }
captchaKey?.let { this["captcha_key"] = it }
}
}
@@ -0,0 +1 @@
package com.meloda.fast.api.network.request
@@ -1,4 +1,4 @@
package com.meloda.fast.api.model.request
package com.meloda.fast.api.network.request
import com.google.gson.annotations.SerializedName
@@ -0,0 +1,21 @@
package com.meloda.fast.api.network.response
import android.os.Parcelable
import com.google.gson.annotations.SerializedName
import kotlinx.parcelize.Parcelize
@Parcelize
data class ResponseAuthDirect(
@SerializedName("access_token") val accessToken: String? = null,
@SerializedName("user_id") val userId: Int? = null,
@SerializedName("trusted_hash") val twoFaHash: String? = null,
@SerializedName("validation_sid") val validationSid: String? = null
) : Parcelable
@Parcelize
data class ResponseSendSms(
@SerializedName("sid") val validationSid: String?,
@SerializedName("delay") val delay: Int?,
@SerializedName("validation_type") val validationType: String?,
@SerializedName("validation_resend") val validationResend: String?
) : Parcelable
@@ -1,4 +1,4 @@
package com.meloda.fast.api.model.response
package com.meloda.fast.api.network.response
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@@ -1,13 +0,0 @@
package com.meloda.fast.api.service
import com.meloda.fast.api.model.ApiResponse
import com.meloda.fast.api.model.request.RequestMessagesGetConversations
import retrofit2.http.GET
import retrofit2.http.QueryMap
interface MessagesService {
@GET("messages.getConversations")
suspend fun getConversations(@QueryMap params: RequestMessagesGetConversations): ApiResponse<Map<String, Any>>
}
@@ -4,12 +4,12 @@ import android.os.Bundle
import android.view.View
import androidx.annotation.LayoutRes
import androidx.lifecycle.lifecycleScope
import com.meloda.fast.base.viewmodel.BaseVM
import com.meloda.fast.base.viewmodel.BaseViewModel
import com.meloda.fast.base.viewmodel.VKEvent
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach
abstract class BaseVMFragment<VM : BaseVM> : BaseFragment {
abstract class BaseVMFragment<VM : BaseViewModel> : BaseFragment {
constructor() : super()
@@ -1,33 +0,0 @@
package com.meloda.fast.base.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.meloda.fast.api.Answer
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
abstract class BaseVM : ViewModel() {
protected val tasksEventChannel = Channel<VKEvent>()
val tasksEvent = tasksEventChannel.receiveAsFlow()
protected fun <T> makeJob(
job: suspend () -> Answer<T>,
onAnswer: suspend (T) -> Unit = {},
onStart: (suspend () -> Unit)? = null,
onEnd: (suspend () -> Unit)? = null,
onError: (suspend (String) -> Unit)? = null
) = viewModelScope.launch {
onStart?.invoke()
when (val response = job()) {
is Answer.Success -> onAnswer(response.data)
is Answer.Error -> onError?.invoke(response.errorString) ?: tasksEventChannel.send(
ShowDialogInfoEvent(null, response.errorString)
)
}
}.also { it.invokeOnCompletion { viewModelScope.launch { onEnd?.invoke() } } }
protected suspend fun <T : VKEvent> sendEvent(event: T) = tasksEventChannel.send(event)
}
@@ -0,0 +1,57 @@
package com.meloda.fast.base.viewmodel
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.meloda.fast.api.VKException
import com.meloda.fast.api.network.Answer
import com.meloda.fast.api.network.VKErrors
import com.meloda.fast.util.Utils
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
abstract class BaseViewModel : ViewModel() {
protected val tasksEventChannel = Channel<VKEvent>()
val tasksEvent = tasksEventChannel.receiveAsFlow()
protected fun <T> makeJob(
job: suspend () -> Answer<T>,
onAnswer: suspend (T) -> Unit = {},
onStart: (suspend () -> Unit)? = null,
onEnd: (suspend () -> Unit)? = null,
onError: (suspend (Throwable) -> Unit)? = null
) = viewModelScope.launch {
onStart?.invoke()
when (val response = job()) {
is Answer.Success -> onAnswer(response.data)
is Answer.Error -> onError?.invoke(response.throwable)
}
}.also { it.invokeOnCompletion { viewModelScope.launch { onEnd?.invoke() } } }
protected suspend fun <T : VKEvent> sendEvent(event: T) = tasksEventChannel.send(event)
protected suspend fun checkErrors(throwable: Throwable) {
// TODO: 8/31/2021 check illegal token
if (throwable is VKException) {
when (throwable.error) {
VKErrors.NEED_CAPTCHA -> {
throwable.captcha =
(throwable.json?.optString("captcha_sid")
?: "") to (throwable.json?.optString("captcha_img") ?: "")
return
}
VKErrors.NEED_VALIDATION -> {
throwable.validationSid = throwable.json?.optString("validation_sid")
return
}
}
}
tasksEventChannel.send(ShowDialogInfoEvent(null, Log.getStackTraceString(throwable)))
}
}
@@ -26,7 +26,7 @@ import com.meloda.fast.database.DatabaseKeys.UNREAD_COUNT
import com.meloda.fast.database.DatabaseUtils.TABLE_CHATS
import com.meloda.fast.database.base.Storage
import com.meloda.fast.api.model.VKConversation
import com.meloda.fast.api.util.VKUtil
import com.meloda.fast.api.VKUtil
import org.json.JSONObject
@WorkerThread
@@ -18,7 +18,7 @@ import com.meloda.fast.database.DatabaseKeys.TYPE
import com.meloda.fast.database.DatabaseUtils.TABLE_GROUPS
import com.meloda.fast.database.base.Storage
import com.meloda.fast.api.model.VKGroup
import com.meloda.fast.api.util.VKUtil
import com.meloda.fast.api.VKUtil
import org.json.JSONObject
class GroupsStorage : Storage<VKGroup>() {
@@ -26,7 +26,7 @@ import com.meloda.fast.database.DatabaseUtils.TABLE_USERS
import com.meloda.fast.database.QueryBuilder
import com.meloda.fast.database.base.Storage
import com.meloda.fast.api.model.VKUser
import com.meloda.fast.api.util.VKUtil
import com.meloda.fast.api.VKUtil
import org.json.JSONObject
@WorkerThread
@@ -1,15 +1,12 @@
package com.meloda.fast.screens.login
import android.annotation.SuppressLint
import android.graphics.Typeface
import android.os.Bundle
import android.view.KeyEvent
import android.view.View
import android.view.inputmethod.EditorInfo
import android.viewbinding.library.fragment.viewBinding
import android.webkit.CookieManager
import androidx.appcompat.app.AlertDialog
import androidx.core.os.bundleOf
import androidx.core.view.isVisible
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.setFragmentResultListener
@@ -20,12 +17,14 @@ import coil.load
import coil.transform.RoundedCornersTransformation
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.textfield.TextInputLayout
import com.meloda.fast.BuildConfig
import com.meloda.fast.R
import com.meloda.fast.base.BaseVMFragment
import com.meloda.fast.base.viewmodel.StartProgressEvent
import com.meloda.fast.base.viewmodel.StopProgressEvent
import com.meloda.fast.base.viewmodel.VKEvent
import com.meloda.fast.databinding.DialogCaptchaBinding
import com.meloda.fast.databinding.DialogValidationBinding
import com.meloda.fast.databinding.FragmentLoginBinding
import com.meloda.fast.screens.main.MainFragment
import com.meloda.fast.util.KeyboardUtils
@@ -37,16 +36,18 @@ import java.util.*
import kotlin.concurrent.schedule
@AndroidEntryPoint
class LoginFragment : BaseVMFragment<LoginVM>(R.layout.fragment_login) {
class LoginFragment : BaseVMFragment<LoginViewModel>(R.layout.fragment_login) {
override val viewModel: LoginVM by viewModels()
override val viewModel: LoginViewModel by viewModels()
private val binding: FragmentLoginBinding by viewBinding()
private var lastEmail: String = ""
private var lastLogin: String = ""
private var lastPassword: String = ""
private var errorTimer: Timer? = null
private var captchaInputLayout: TextInputLayout? = null
private var validationInputLayout: TextInputLayout? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
@@ -60,11 +61,6 @@ class LoginFragment : BaseVMFragment<LoginVM>(R.layout.fragment_login) {
setFragmentResultListener("validation") { _, bundle ->
lifecycleScope.launch { viewModel.getValidatedData(bundle) }
}
// showCaptchaDialog(
// "https://www.vets4pets.com/syssiteassets/species/cat/kitten/tiny-kitten-in-field.jpg?width=1040",
// ""
// )
}
override fun onEvent(event: VKEvent) {
@@ -72,9 +68,13 @@ class LoginFragment : BaseVMFragment<LoginVM>(R.layout.fragment_login) {
when (event) {
is ShowError -> showErrorSnackbar(event.errorDescription)
is ShowCaptchaDialog -> showCaptchaDialog(event.captchaImage, event.captchaSid)
is GoToValidationEvent -> goToValidation(event.redirectUrl)
is GoToMainEvent -> goToMain(event.haveAuthorized)
is CaptchaRequired -> showCaptchaDialog(event.captcha.first, event.captcha.second)
CodeSent -> showValidationDialog()
is ValidationRequired -> showValidationRequired()
is SuccessAuth -> goToMain(event.haveAuthorized)
StartProgressEvent -> onProgressStarted()
StopProgressEvent -> onProgressStopped()
}
@@ -95,28 +95,11 @@ class LoginFragment : BaseVMFragment<LoginVM>(R.layout.fragment_login) {
}
private fun prepareViews() {
prepareWebView()
prepareEmailEditText()
preparePasswordEditText()
prepareAuthButton()
}
@SuppressLint("SetJavaScriptEnabled")
private fun prepareWebView() {
with(binding.webView) {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.loadsImagesAutomatically = false
settings.userAgentString = "Chrome/41.0.2228.0 Safari/537.36"
clearCache(true)
}
val cookieManager = CookieManager.getInstance()
cookieManager.removeAllCookies(null)
cookieManager.flush()
cookieManager.setAcceptCookie(false)
}
private fun prepareEmailEditText() {
binding.loginInput.addTextChangedListener {
if (!binding.loginLayout.error.isNullOrBlank()) binding.loginLayout.error = ""
@@ -150,31 +133,38 @@ class LoginFragment : BaseVMFragment<LoginVM>(R.layout.fragment_login) {
}
private fun prepareAuthButton() {
binding.auth.setOnClickListener {
if (binding.progress.isVisible) return@setOnClickListener
binding.auth.setOnClickListener { validateDataAndAuth() }
binding.auth.setOnLongClickListener {
validateDataAndAuth(BuildConfig.vkLogin to BuildConfig.vkPassword)
true
}
}
val loginString = binding.loginInput.text.toString().trim()
val passwordString = binding.passwordInput.text.toString().trim()
private fun validateDataAndAuth(data: Pair<String, String>? = null) {
if (binding.progress.isVisible) return
val loginString = data?.first ?: binding.loginInput.text.toString().trim()
val passwordString = data?.second ?: binding.passwordInput.text.toString().trim()
if (!validateInputData(loginString, passwordString)) return@setOnClickListener
if (!validateInputData(loginString, passwordString)) return
lastLogin = loginString
lastPassword = passwordString
KeyboardUtils.hideKeyboardFrom(requireView().findFocus())
lifecycleScope.launch {
viewModel.login(
binding.webView,
loginString,
passwordString
login = loginString,
password = passwordString
)
}
}
}
// TODO: 7/27/2021 extract strings to resources
private fun validateInputData(
loginString: String?,
passwordString: String?,
captchaCode: String? = null
captchaCode: String? = null,
validationCode: String? = null
): Boolean {
var isValidated = true
@@ -193,6 +183,11 @@ class LoginFragment : BaseVMFragment<LoginVM>(R.layout.fragment_login) {
setError("Input code", captchaInputLayout!!)
}
if (validationCode?.isEmpty() == true && validationInputLayout != null) {
isValidated = false
setError("Input code", validationInputLayout!!)
}
return isValidated
}
@@ -220,7 +215,7 @@ class LoginFragment : BaseVMFragment<LoginVM>(R.layout.fragment_login) {
captchaInputLayout?.error = ""
}
private fun showCaptchaDialog(captchaImage: String, captchaSid: String) {
private fun showCaptchaDialog(captchaSid: String, captchaImage: String) {
val captchaBinding = DialogCaptchaBinding.inflate(layoutInflater, null, false)
captchaInputLayout = captchaBinding.captchaLayout
@@ -248,19 +243,52 @@ class LoginFragment : BaseVMFragment<LoginVM>(R.layout.fragment_login) {
dialog.dismiss()
lifecycleScope.launch {
viewModel.login(
webView = binding.webView,
email = lastEmail,
login = lastLogin,
password = lastPassword,
captchaSid = captchaSid,
captchaKey = captchaCode
captcha = captchaSid to captchaCode
)
}
}
captchaBinding.cancel.setOnClickListener { dialog.dismiss() }
}
private fun showValidationDialog() {
val validationBinding = DialogValidationBinding.inflate(layoutInflater, null, false)
validationInputLayout = validationBinding.codeLayout
val builder = AlertDialog.Builder(requireContext())
.setView(validationBinding.root)
.setCancelable(false)
.setTitle(R.string.input_validation_code)
val dialog = builder.show()
validationBinding.ok.setOnClickListener {
val validationCode = validationBinding.codeInput.text.toString().trim()
if (!validateInputData(
loginString = null,
passwordString = null,
validationCode = validationCode
)
) return@setOnClickListener
dialog.dismiss()
viewModel.login(
login = lastLogin,
password = lastPassword,
twoFaCode = validationCode
)
}
validationBinding.cancel.setOnClickListener { dialog.dismiss() }
}
// TODO: 8/31/2021 show snackbar
private fun showValidationRequired() {
}
private fun showErrorSnackbar(errorDescription: String) {
val snackbar = Snackbar.make(
requireView(),
@@ -272,19 +300,10 @@ class LoginFragment : BaseVMFragment<LoginVM>(R.layout.fragment_login) {
snackbar.show()
}
private fun goToValidation(redirectUrl: String) {
findNavController().navigate(
R.id.toValidation,
bundleOf("redirectUrl" to redirectUrl)
)
}
private fun goToMain(haveAuthorized: Boolean) {
lifecycleScope.launch {
private fun goToMain(haveAuthorized: Boolean) = lifecycleScope.launch {
if (haveAuthorized) delay(500)
findNavController().navigate(R.id.toMain)
}
}
}
@@ -1,127 +0,0 @@
package com.meloda.fast.screens.login
import android.os.Bundle
import android.util.Log
import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.lifecycle.viewModelScope
import com.meloda.fast.UserConfig
import com.meloda.fast.api.VKAuth
import com.meloda.fast.base.viewmodel.BaseVM
import com.meloda.fast.base.viewmodel.StartProgressEvent
import com.meloda.fast.base.viewmodel.StopProgressEvent
import com.meloda.fast.base.viewmodel.VKEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.json.JSONObject
import org.jsoup.Jsoup
class LoginVM : BaseVM() {
private var isWebViewPrepared = false
suspend fun login(
webView: WebView,
email: String,
password: String,
captchaSid: String? = null,
captchaKey: String? = null
) {
sendEvent(StartProgressEvent)
val urlToGo = VKAuth.getDirectAuthUrl(email, password, captchaSid, captchaKey)
if (!isWebViewPrepared) {
isWebViewPrepared = true
webView.addJavascriptInterface(WebViewHandlerInterface(), "HtmlHandler")
webView.webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
webView.loadUrl(
"javascript:window.HtmlHandler.handleHtml" +
"('<html>'+document.getElementsByTagName('html')[0].innerHTML+'</html>');"
)
}
}
}
webView.loadUrl(urlToGo)
}
@Suppress("MoveVariableDeclarationIntoWhen")
private fun checkResponse(response: JSONObject) {
viewModelScope.launch(Dispatchers.Default) {
if (response.has("error")) {
sendEvent(StopProgressEvent)
val errorString = response.optString("error")
val errorDescription = response.optString("error_description")
// TODO: 7/27/2021 use this with localized resources
// val errorType = response.optString("error_type")
when (errorString) {
"need_validation" -> {
val redirectUrl = response.optString("redirect_uri")
tasksEventChannel.send(GoToValidationEvent(redirectUrl))
}
"need_captcha" -> {
val captchaImage = response.optString("captcha_img")
val captchaSid = response.optString("captcha_sid")
Log.d("CAPTCHA", "captchaImage: $captchaImage")
tasksEventChannel.send(ShowCaptchaDialog(captchaImage, captchaSid))
}
else -> {
tasksEventChannel.send(ShowError(errorDescription))
}
}
} else {
delay(1500)
sendEvent(StopProgressEvent)
val userId = response.optInt("user_id", -1)
val accessToken = response.optString("access_token")
UserConfig.accessToken = accessToken
UserConfig.userId = userId
tasksEventChannel.send(GoToMainEvent())
}
}
}
suspend fun getValidatedData(bundle: Bundle) {
val accessToken = bundle.getString("token") ?: ""
val userId = bundle.getInt("userId")
UserConfig.accessToken = accessToken
UserConfig.userId = userId
tasksEventChannel.send(GoToMainEvent())
}
inner class WebViewHandlerInterface {
@JavascriptInterface
fun handleHtml(html: String) {
val doc = Jsoup.parse(html)
val responseString =
doc.select("pre[style=\"word-wrap: break-word; white-space: pre-wrap;\"]").first()
?.text() ?: ""
checkResponse(JSONObject(responseString))
}
}
}
data class ShowError(val errorDescription: String) : VKEvent()
data class ShowCaptchaDialog(val captchaImage: String, val captchaSid: String) : VKEvent()
data class GoToValidationEvent(val redirectUrl: String) : VKEvent()
data class GoToMainEvent(val haveAuthorized: Boolean = true) : VKEvent()
@@ -0,0 +1,111 @@
package com.meloda.fast.screens.login
import android.os.Bundle
import android.util.Log
import androidx.lifecycle.viewModelScope
import com.meloda.fast.UserConfig
import com.meloda.fast.api.VKAuth
import com.meloda.fast.api.VKConstants
import com.meloda.fast.api.VKException
import com.meloda.fast.api.VKUtil
import com.meloda.fast.api.network.repo.AuthRepo
import com.meloda.fast.api.network.request.RequestAuthDirect
import com.meloda.fast.base.viewmodel.BaseViewModel
import com.meloda.fast.base.viewmodel.StartProgressEvent
import com.meloda.fast.base.viewmodel.StopProgressEvent
import com.meloda.fast.base.viewmodel.VKEvent
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.json.JSONObject
import javax.inject.Inject
@HiltViewModel
class LoginViewModel @Inject constructor(
private val repo: AuthRepo
) : BaseViewModel() {
fun login(
login: String,
password: String,
twoFaCode: String? = null,
captcha: Pair<String, String>? = null
) = viewModelScope.launch {
makeJob(
{
repo.auth(
RequestAuthDirect(
grantType = VKAuth.GrantType.PASSWORD,
clientId = VKConstants.VK_APP_ID,
clientSecret = VKConstants.VK_SECRET,
username = login,
password = password,
scope = VKAuth.scope,
twoFaForceSms = true,
twoFaCode = twoFaCode,
captchaSid = captcha?.first,
captchaKey = captcha?.second
).map
)
},
onAnswer = {
// TODO: 8/31/2021 do something
if (it.userId == null || it.accessToken == null) {
return@makeJob
}
UserConfig.userId = it.userId
UserConfig.accessToken = it.accessToken
sendEvent(SuccessAuth(haveAuthorized = true))
},
onError = {
checkErrors(it)
if (it !is VKException) return@makeJob
if (VKUtil.isValidationRequired(it)) {
it.validationSid?.let { sid ->
sendEvent(ValidationRequired(validationSid = sid))
sendSms(sid)
}
} else if (VKUtil.isCaptchaRequired(it)) {
it.captcha?.let { captcha ->
sendEvent(CaptchaRequired(captcha.first to captcha.second))
}
}
},
onStart = { sendEvent(StartProgressEvent) },
onEnd = { sendEvent(StopProgressEvent) }
)
}
fun sendSms(validationSid: String) = viewModelScope.launch {
makeJob({ repo.sendSms(validationSid) },
onAnswer = { sendEvent(CodeSent) },
onError = {},
onStart = {},
onEnd = {})
}
suspend fun getValidatedData(bundle: Bundle) {
val accessToken = bundle.getString("token") ?: ""
val userId = bundle.getInt("userId")
UserConfig.accessToken = accessToken
UserConfig.userId = userId
tasksEventChannel.send(SuccessAuth())
}
}
data class ShowError(val errorDescription: String) : VKEvent()
data class ValidationRequired(val validationSid: String) : VKEvent()
data class CaptchaRequired(val captcha: Pair<String, String>) : VKEvent()
object CodeSent : VKEvent()
data class SuccessAuth(val haveAuthorized: Boolean = true) : VKEvent()
@@ -13,9 +13,9 @@ import com.meloda.fast.extensions.NavigationExtensions.setupWithNavController
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainFragment : BaseVMFragment<MainVM>(R.layout.fragment_main) {
class MainFragment : BaseVMFragment<MainViewModel>(R.layout.fragment_main) {
override val viewModel: MainVM by viewModels()
override val viewModel: MainViewModel by viewModels()
private val binding: FragmentMainBinding by viewBinding()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@@ -1,5 +0,0 @@
package com.meloda.fast.screens.main
import com.meloda.fast.base.viewmodel.BaseVM
class MainVM : BaseVM()
@@ -0,0 +1,5 @@
package com.meloda.fast.screens.main
import com.meloda.fast.base.viewmodel.BaseViewModel
class MainViewModel : BaseViewModel()
@@ -16,9 +16,9 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlin.math.roundToInt
@AndroidEntryPoint
class ConversationsFragment : BaseVMFragment<ConversationsVM>(R.layout.fragment_conversations) {
class ConversationsFragment : BaseVMFragment<ConversationsViewModel>(R.layout.fragment_conversations) {
override val viewModel: ConversationsVM by viewModels()
override val viewModel: ConversationsViewModel by viewModels()
private val binding: FragmentConversationsBinding by viewBinding()
private lateinit var adapter: ConversationsAdapter
@@ -1,11 +1,11 @@
package com.meloda.fast.screens.messages
import androidx.lifecycle.viewModelScope
import com.meloda.fast.base.viewmodel.BaseVM
import com.meloda.fast.base.viewmodel.BaseViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class ConversationsVM : BaseVM() {
class ConversationsViewModel : BaseViewModel() {
fun loadConversations() = viewModelScope.launch(Dispatchers.Default) {
@@ -13,7 +13,7 @@ import com.meloda.fast.R
import com.meloda.fast.api.model.*
import com.meloda.fast.common.AppGlobal
import com.meloda.fast.api.OnResponseListener
import com.meloda.fast.api.util.VKUtil
import com.meloda.fast.api.VKUtil
import java.text.SimpleDateFormat
import java.util.*
import kotlin.math.abs
@@ -11,7 +11,6 @@ import com.meloda.fast.extensions.ContextExtensions.color
import com.meloda.fast.R
import com.meloda.fast.widget.CircleImageView
import com.meloda.fast.api.model.VKUser
import com.meloda.fast.api.util.VKUtil
object ViewUtils {
+9 -3
View File
@@ -12,7 +12,8 @@
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/image"
android:layout_width="match_parent"
android:layout_height="50dp"
android:layout_height="100dp"
android:scaleType="fitCenter"
tools:src="@tools:sample/backgrounds/scenic" />
<LinearLayout
@@ -65,7 +66,9 @@
android:layout_marginEnd="@dimen/activity_horizontal_margin"
android:layout_weight="1"
android:text="@android:string/cancel"
app:elevation="0dp" />
android:textColor="?colorAction"
app:elevation="0dp"
app:rippleColor="?colorActionRipple" />
<com.google.android.material.button.MaterialButton
android:id="@+id/ok"
@@ -73,8 +76,11 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:backgroundTint="?colorAction"
android:text="@android:string/ok"
app:elevation="0dp" />
android:textColor="?colorActionContentPrimary"
app:elevation="0dp"
app:rippleColor="?colorActionRipple" />
</LinearLayout>
@@ -0,0 +1,83 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="@dimen/activity_horizontal_margin">
<LinearLayout
android:id="@+id/codeContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/activity_vertical_margin"
android:orientation="horizontal">
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/codeImage"
style="@style/AppTheme.Login.EditText.Icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:src="@drawable/ic_security"
app:tint="?colorAccent" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/codeLayout"
style="@style/Widget.TextInputLayout.NoError.Dense"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/codeInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/code_hint"
android:imeOptions="actionGo"
android:inputType="number"
android:lines="1"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="horizontal">
<com.google.android.material.button.MaterialButton
android:id="@+id/cancel"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/activity_horizontal_margin"
android:layout_weight="1"
android:text="@android:string/cancel"
android:textColor="?colorAction"
app:elevation="0dp"
app:rippleColor="?colorActionRipple" />
<com.google.android.material.button.MaterialButton
android:id="@+id/ok"
style="@style/Widget.MaterialComponents.Button.UnelevatedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:backgroundTint="?colorAction"
android:text="@android:string/ok"
android:textColor="?colorActionContentPrimary"
app:elevation="0dp"
app:rippleColor="?colorActionRipple" />
</LinearLayout>
</LinearLayout>
</layout>
@@ -9,12 +9,6 @@
android:layout_height="match_parent"
android:orientation="vertical">
<WebView
android:id="@+id/webView"
android:layout_width="0dp"
android:layout_height="0dp"
android:visibility="gone" />
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
+2
View File
@@ -156,5 +156,7 @@
<string name="login_hint">Login</string>
<string name="conversations">Conversations</string>
<string name="code_hint">Code</string>
<string name="input_validation_code">Input code from sms</string>
</resources>