新增验证码

This commit is contained in:
2024-10-06 20:08:57 +08:00
parent 40bbb8a0a0
commit 9168884edb
8 changed files with 412 additions and 39 deletions

View File

@@ -3,6 +3,7 @@ package com.aiosman.riderpro.data
import com.aiosman.riderpro.AppState
import com.aiosman.riderpro.data.api.ApiClient
import com.aiosman.riderpro.data.api.AppConfig
import com.aiosman.riderpro.data.api.CaptchaInfo
import com.aiosman.riderpro.data.api.ChangePasswordRequestBody
import com.aiosman.riderpro.data.api.GoogleRegisterRequestBody
import com.aiosman.riderpro.data.api.LoginUserRequestBody
@@ -266,8 +267,13 @@ interface AccountService {
* 使用用户名密码登录
* @param loginName 用户名
* @param password 密码
* @param captchaInfo 验证码信息
*/
suspend fun loginUserWithPassword(loginName: String, password: String): UserAuth
suspend fun loginUserWithPassword(
loginName: String,
password: String,
captchaInfo: CaptchaInfo? = null
): UserAuth
/**
* 使用google登录
@@ -383,8 +389,16 @@ class AccountServiceImpl : AccountService {
return UserAuth(body.id)
}
override suspend fun loginUserWithPassword(loginName: String, password: String): UserAuth {
val resp = ApiClient.api.login(LoginUserRequestBody(loginName, password))
override suspend fun loginUserWithPassword(
loginName: String,
password: String,
captchaInfo: CaptchaInfo?
): UserAuth {
val resp = ApiClient.api.login(LoginUserRequestBody(
username = loginName,
password = password,
captcha = captchaInfo,
))
if (!resp.isSuccessful) {
parseErrorResponse(resp.errorBody())?.let {
throw it.toServiceException()

View File

@@ -0,0 +1,45 @@
package com.aiosman.riderpro.data
import com.aiosman.riderpro.data.api.ApiClient
import com.aiosman.riderpro.data.api.CaptchaRequestBody
import com.aiosman.riderpro.data.api.CaptchaResponseBody
import com.aiosman.riderpro.data.api.CheckLoginCaptchaRequestBody
import com.aiosman.riderpro.data.api.GenerateLoginCaptchaRequestBody
interface CaptchaService {
suspend fun generateCaptcha(source: String): CaptchaResponseBody
suspend fun checkLoginCaptcha(username: String): Boolean
suspend fun generateLoginCaptcha(username: String): CaptchaResponseBody
}
class CaptchaServiceImpl : CaptchaService {
override suspend fun generateCaptcha(source: String): CaptchaResponseBody {
val resp = ApiClient.api.generateCaptcha(
CaptchaRequestBody(source)
)
val data = resp.body() ?: throw Exception("Failed to generate captcha")
return data.data.copy(
masterBase64 = data.data.masterBase64.replace("data:image/jpeg;base64,", ""),
thumbBase64 = data.data.thumbBase64.replace("data:image/png;base64,", "")
)
}
override suspend fun checkLoginCaptcha(username: String): Boolean {
val resp = ApiClient.api.checkLoginCaptcha(
CheckLoginCaptchaRequestBody(username)
)
return resp.body()?.data ?: true
}
override suspend fun generateLoginCaptcha(username: String): CaptchaResponseBody {
val resp = ApiClient.api.generateLoginCaptcha(
GenerateLoginCaptchaRequestBody(username)
)
val data = resp.body() ?: throw Exception("Failed to generate captcha")
return data.data.copy(
masterBase64 = data.data.masterBase64.replace("data:image/jpeg;base64,", ""),
thumbBase64 = data.data.thumbBase64.replace("data:image/png;base64,", "")
)
}
}

View File

@@ -38,6 +38,8 @@ data class LoginUserRequestBody(
val password: String? = null,
@SerializedName("googleId")
val googleId: String? = null,
@SerializedName("captcha")
val captcha: CaptchaInfo? = null,
)
data class GoogleRegisterRequestBody(
@@ -128,6 +130,69 @@ data class DictItem(
val desc: String,
)
data class CaptchaRequestBody(
@SerializedName("source")
val source: String,
)
data class CaptchaResponseBody(
@SerializedName("id")
val id: Int,
@SerializedName("thumb_base64")
val thumbBase64: String,
@SerializedName("master_base64")
val masterBase64: String,
@SerializedName("count")
val count: Int,
)
data class CheckLoginCaptchaRequestBody(
@SerializedName("username")
val username: String,
)
data class GenerateLoginCaptchaRequestBody(
@SerializedName("username")
val username: String,
)
//{
// "id":48,
// "dot": [
// {
// "index": 0,
// "x": 76,
// "y": 165
// },
// {
// "index": 1,
// "x": 144,
// "y": 21
// },
// {
// "index": 2,
// "x": 220,
// "y": 42
// },
// {
// "index": 3,
// "x": 10,
// "y": 10
// }
// ]
//}
data class DotPosition(
@SerializedName("index")
val index: Int,
@SerializedName("x")
val x: Int,
@SerializedName("y")
val y: Int,
)
data class CaptchaInfo(
@SerializedName("id")
val id: Int,
@SerializedName("dot")
val dot: List<DotPosition>
)
interface RiderProAPI {
@POST("register")
suspend fun register(@Body body: RegisterRequestBody): Response<Unit>
@@ -336,4 +401,20 @@ interface RiderProAPI {
suspend fun getDict(
@Query("key") key: String
): Response<DataContainer<DictItem>>
@POST("captcha/generate")
suspend fun generateCaptcha(
@Body body: CaptchaRequestBody
): Response<DataContainer<CaptchaResponseBody>>
@POST("login/needCaptcha")
suspend fun checkLoginCaptcha(
@Body body: CheckLoginCaptchaRequestBody
): Response<DataContainer<Boolean>>
@POST("captcha/login/generate")
suspend fun generateLoginCaptcha(
@Body body: GenerateLoginCaptchaRequestBody
): Response<DataContainer<CaptchaResponseBody>>
}

View File

@@ -28,7 +28,7 @@ import com.aiosman.riderpro.ui.modifiers.noRippleClickable
@Composable
fun ActionButton(
modifier: Modifier,
modifier: Modifier = Modifier,
text: String,
color: Color = Color.Black,
@DrawableRes backgroundImage: Int? = null,

View File

@@ -0,0 +1,186 @@
package com.aiosman.riderpro.ui.composables
import android.graphics.BitmapFactory
import android.util.Base64
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material.AlertDialog
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.input.pointer.pointerInteropFilter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.aiosman.riderpro.R
import com.aiosman.riderpro.data.api.CaptchaResponseBody
import java.io.ByteArrayInputStream
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun ClickCaptchaView(
captchaData: CaptchaResponseBody,
onPositionClicked: (Offset) -> Unit
) {
var clickPositions by remember { mutableStateOf(listOf<Offset>()) }
val context = LocalContext.current
val imageBitmap = remember(captchaData.masterBase64) {
val decodedString = Base64.decode(captchaData.masterBase64, Base64.DEFAULT)
val inputStream = ByteArrayInputStream(decodedString)
BitmapFactory.decodeStream(inputStream).asImageBitmap()
}
val thumbnailBitmap = remember(captchaData.thumbBase64) {
val decodedString = Base64.decode(captchaData.thumbBase64, Base64.DEFAULT)
val inputStream = ByteArrayInputStream(decodedString)
BitmapFactory.decodeStream(inputStream).asImageBitmap()
}
var boxWidth by remember { mutableStateOf(0) }
var boxHeightInDp by remember { mutableStateOf(0.dp) }
var scale by remember { mutableStateOf(1f) }
val density = LocalDensity.current
Column(
modifier = Modifier
.fillMaxWidth()
) {
Text(stringResource(R.string.captcha_hint))
Spacer(modifier = Modifier.height(16.dp))
Image(
bitmap = thumbnailBitmap,
contentDescription = null,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.onGloballyPositioned {
boxWidth = it.size.width
scale = imageBitmap.width.toFloat() / boxWidth
boxHeightInDp = with(density) { (imageBitmap.height.toFloat() / scale).toDp() }
}
.background(Color.Gray)
) {
if (boxWidth != 0 && boxHeightInDp != 0.dp) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(boxHeightInDp)
) {
Image(
bitmap = imageBitmap,
contentDescription = null,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxSize()
.pointerInteropFilter { event ->
if (event.action == android.view.MotionEvent.ACTION_DOWN) {
val newPosition = Offset(event.x, event.y)
clickPositions = clickPositions + newPosition
// 计算出点击的位置在图片上的坐标
val imagePosition = Offset(
newPosition.x * scale,
newPosition.y * scale
)
onPositionClicked(imagePosition)
true
} else {
false
}
}
)
// Draw markers at click positions
clickPositions.forEachIndexed { index, position ->
Canvas(modifier = Modifier.fillMaxSize()) {
drawCircle(
color = Color(0xaada3832).copy(),
radius = 40f,
center = position
)
drawContext.canvas.nativeCanvas.apply {
drawText(
(index + 1).toString(),
position.x,
position.y + 15f, // Adjusting the y position to center the text
android.graphics.Paint().apply {
color = android.graphics.Color.WHITE
textSize = 50f
textAlign = android.graphics.Paint.Align.CENTER
}
)
}
}
}
}
}
}
}
}
@Composable
fun ClickCaptchaDialog(
captchaData: CaptchaResponseBody,
onLoadCaptcha: () -> Unit,
onDismissRequest: () -> Unit,
onPositionClicked: (Offset) -> Unit
) {
AlertDialog(
onDismissRequest = {
onDismissRequest()
},
title = {
Text("Captcha")
},
text = {
Column {
Box(
modifier = Modifier
.fillMaxWidth()
) {
ClickCaptchaView(
captchaData = captchaData!!,
onPositionClicked = onPositionClicked
)
}
Spacer(modifier = Modifier.height(16.dp))
ActionButton(
text = "Refresh",
modifier = Modifier
.fillMaxWidth(),
) {
onLoadCaptcha()
}
}
},
confirmButton = {
},
)
}

View File

@@ -1,11 +1,8 @@
package com.aiosman.riderpro.ui.login
import android.icu.util.Calendar
import android.icu.util.TimeZone
import android.widget.Toast
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@@ -16,7 +13,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -35,35 +31,41 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.aiosman.riderpro.AppState
import com.aiosman.riderpro.AppStore
import com.aiosman.riderpro.ErrorCode
import com.aiosman.riderpro.LocalNavController
import com.aiosman.riderpro.Messaging
import com.aiosman.riderpro.R
import com.aiosman.riderpro.data.AccountService
import com.aiosman.riderpro.data.AccountServiceImpl
import com.aiosman.riderpro.data.CaptchaService
import com.aiosman.riderpro.data.CaptchaServiceImpl
import com.aiosman.riderpro.data.ServiceException
import com.aiosman.riderpro.data.api.CaptchaInfo
import com.aiosman.riderpro.data.api.CaptchaResponseBody
import com.aiosman.riderpro.data.api.DotPosition
import com.aiosman.riderpro.ui.NavigationRoute
import com.aiosman.riderpro.ui.comment.NoticeScreenHeader
import com.aiosman.riderpro.ui.composables.ActionButton
import com.aiosman.riderpro.ui.composables.ClickCaptchaDialog
import com.aiosman.riderpro.ui.composables.StatusBarSpacer
import com.aiosman.riderpro.ui.composables.TextInputField
import com.aiosman.riderpro.ui.modifiers.noRippleClickable
import com.aiosman.riderpro.utils.GoogleLogin
import com.aiosman.riderpro.utils.Utils
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UserAuthScreen() {
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var email by remember { mutableStateOf("root") }
var password by remember { mutableStateOf("password") }
var rememberMe by remember { mutableStateOf(false) }
var accountService: AccountService = AccountServiceImpl()
val accountService: AccountService = AccountServiceImpl()
val captchaService: CaptchaService = CaptchaServiceImpl()
val scope = rememberCoroutineScope()
val navController = LocalNavController.current
val context = LocalContext.current
var emailError by remember { mutableStateOf<String?>(null) }
var passwordError by remember { mutableStateOf<String?>(null) }
var captchaInfo by remember { mutableStateOf<CaptchaInfo?>(null) }
fun validateForm(): Boolean {
emailError =
if (email.isEmpty()) context.getString(R.string.text_error_email_required) else null
@@ -72,13 +74,37 @@ fun UserAuthScreen() {
return emailError == null && passwordError == null
}
fun onLogin() {
var captchaData by remember { mutableStateOf<CaptchaResponseBody?>(null) }
fun loadLoginCaptcha() {
scope.launch {
try {
captchaData = captchaService.generateLoginCaptcha(email)
captchaData?.let {
captchaInfo = CaptchaInfo(
id = it.id,
dot = emptyList()
)
}
} catch (e: ServiceException) {
Toast.makeText(context, e.message, Toast.LENGTH_SHORT).show()
}
}
}
fun onLogin(captchaInfo: CaptchaInfo? = null) {
if (!validateForm()) {
return
}
scope.launch {
try {
val authResp = accountService.loginUserWithPassword(email, password)
// 检查是否需要验证码
if (captchaInfo == null && captchaService.checkLoginCaptcha(email)) {
loadLoginCaptcha()
return@launch
}
// 获取用户凭证
val authResp = accountService.loginUserWithPassword(email, password, captchaInfo)
if (authResp.token != null) {
AppStore.apply {
token = authResp.token
@@ -92,13 +118,25 @@ fun UserAuthScreen() {
}
} catch (e: ServiceException) {
// handle error
if (e.code == 12005) {
emailError = context.getString(R.string.error_invalidate_username_password)
passwordError = context.getString(R.string.error_invalidate_username_password)
} else if (e.code == ErrorCode.InvalidateCaptcha.code) {
loadLoginCaptcha()
Toast.makeText(
context,
"incorrect captcha,please try again",
Toast.LENGTH_SHORT
).show()
} else {
Toast.makeText(context, e.message, Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
Toast.makeText(context, e.message, Toast.LENGTH_SHORT).show()
} finally {
captchaData = null
}
}
@@ -132,10 +170,37 @@ fun UserAuthScreen() {
} catch (e: Exception) {
Toast.makeText(context, e.message, Toast.LENGTH_SHORT).show()
}
}
}
captchaData?.let {
ClickCaptchaDialog(
onDismissRequest = {
captchaData = null
},
captchaData = it,
onLoadCaptcha = {
loadLoginCaptcha()
},
onPositionClicked = { offset ->
captchaInfo?.let { info ->
val dots = info.dot.toMutableList()
val lastDotIndex = dots.size - 1
dots += DotPosition(
index = lastDotIndex + 1,
x = offset.x.toInt(),
y = offset.y.toInt()
)
captchaInfo = info.copy(dot = dots)
// 检查是否完成
if (dots.size == it.count) {
onLogin(captchaInfo)
}
}
}
)
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
@@ -204,26 +269,6 @@ fun UserAuthScreen() {
navController.navigate(NavigationRoute.ResetPassword.route)
}
)
// CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) {
// Checkbox(
// checked = rememberMe,
// onCheckedChange = {
// rememberMe = it
// },
// colors = CheckboxDefaults.colors(
// checkedColor = Color.Black
// ),
// )
// Text(
// stringResource(R.string.remember_me),
// modifier = Modifier.padding(start = 8.dp),
// fontSize = 12.sp
// )
// Spacer(modifier = Modifier.weight(1f))
// Text(stringResource(R.string.forgot_password), fontSize = 12.sp, modifier = Modifier.noRippleClickable {
// navController.navigate(NavigationRoute.ResetPassword.route)
// })
// }
}
Spacer(modifier = Modifier.height(64.dp))
@@ -251,7 +296,7 @@ fun UserAuthScreen() {
},
expandText = true,
contentPadding = PaddingValues(vertical = 8.dp, horizontal = 8.dp)
){
) {
googleLogin()
}
}

View File

@@ -81,4 +81,5 @@
<string name="like_your_comment">喜欢了你的评论</string>
<string name="resend">重新发送 %s</string>
<string name="error_40002_user_not_exist">用户不存在</string>
<string name="captcha_hint">请依次点击图片中的元素</string>
</resources>

View File

@@ -80,4 +80,5 @@
<string name="like_your_comment">Like your comment</string>
<string name="resend">Resend in %s</string>
<string name="error_40002_user_not_exist">user not exist</string>
<string name="captcha_hint">Please click on the dots in the image in the correct order.</string>
</resources>