From 9168884edb0a5eab78245843f4a9acd82a36ac7a Mon Sep 17 00:00:00 2001 From: AllenTom Date: Sun, 6 Oct 2024 20:08:57 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E9=AA=8C=E8=AF=81=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../aiosman/riderpro/data/AccountService.kt | 20 +- .../aiosman/riderpro/data/CaptchaService.kt | 45 +++++ .../aiosman/riderpro/data/api/RiderProAPI.kt | 81 ++++++++ .../riderpro/ui/composables/ActionButton.kt | 2 +- .../ui/composables/ClickCaptchaView.kt | 186 ++++++++++++++++++ .../com/aiosman/riderpro/ui/login/userauth.kt | 115 +++++++---- app/src/main/res/values-zh/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 8 files changed, 412 insertions(+), 39 deletions(-) create mode 100644 app/src/main/java/com/aiosman/riderpro/data/CaptchaService.kt create mode 100644 app/src/main/java/com/aiosman/riderpro/ui/composables/ClickCaptchaView.kt diff --git a/app/src/main/java/com/aiosman/riderpro/data/AccountService.kt b/app/src/main/java/com/aiosman/riderpro/data/AccountService.kt index 22126c3..88cbc8b 100644 --- a/app/src/main/java/com/aiosman/riderpro/data/AccountService.kt +++ b/app/src/main/java/com/aiosman/riderpro/data/AccountService.kt @@ -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() diff --git a/app/src/main/java/com/aiosman/riderpro/data/CaptchaService.kt b/app/src/main/java/com/aiosman/riderpro/data/CaptchaService.kt new file mode 100644 index 0000000..7396664 --- /dev/null +++ b/app/src/main/java/com/aiosman/riderpro/data/CaptchaService.kt @@ -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,", "") + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/riderpro/data/api/RiderProAPI.kt b/app/src/main/java/com/aiosman/riderpro/data/api/RiderProAPI.kt index a63e7bc..2fdfa2e 100644 --- a/app/src/main/java/com/aiosman/riderpro/data/api/RiderProAPI.kt +++ b/app/src/main/java/com/aiosman/riderpro/data/api/RiderProAPI.kt @@ -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 +) interface RiderProAPI { @POST("register") suspend fun register(@Body body: RegisterRequestBody): Response @@ -336,4 +401,20 @@ interface RiderProAPI { suspend fun getDict( @Query("key") key: String ): Response> + + @POST("captcha/generate") + suspend fun generateCaptcha( + @Body body: CaptchaRequestBody + ): Response> + + @POST("login/needCaptcha") + suspend fun checkLoginCaptcha( + @Body body: CheckLoginCaptchaRequestBody + ): Response> + + @POST("captcha/login/generate") + suspend fun generateLoginCaptcha( + @Body body: GenerateLoginCaptchaRequestBody + ): Response> + } \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/riderpro/ui/composables/ActionButton.kt b/app/src/main/java/com/aiosman/riderpro/ui/composables/ActionButton.kt index 5c79eff..7f614a8 100644 --- a/app/src/main/java/com/aiosman/riderpro/ui/composables/ActionButton.kt +++ b/app/src/main/java/com/aiosman/riderpro/ui/composables/ActionButton.kt @@ -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, diff --git a/app/src/main/java/com/aiosman/riderpro/ui/composables/ClickCaptchaView.kt b/app/src/main/java/com/aiosman/riderpro/ui/composables/ClickCaptchaView.kt new file mode 100644 index 0000000..2a3c047 --- /dev/null +++ b/app/src/main/java/com/aiosman/riderpro/ui/composables/ClickCaptchaView.kt @@ -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()) } + + 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 = { + + }, + ) +} + diff --git a/app/src/main/java/com/aiosman/riderpro/ui/login/userauth.kt b/app/src/main/java/com/aiosman/riderpro/ui/login/userauth.kt index 246ce80..ddfae2f 100644 --- a/app/src/main/java/com/aiosman/riderpro/ui/login/userauth.kt +++ b/app/src/main/java/com/aiosman/riderpro/ui/login/userauth.kt @@ -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(null) } var passwordError by remember { mutableStateOf(null) } + var captchaInfo by remember { mutableStateOf(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(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() } } diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 6f569c8..acb0daa 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -81,4 +81,5 @@ 喜欢了你的评论 重新发送 %s 用户不存在 + 请依次点击图片中的元素 \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0a98c01..edf2438 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -80,4 +80,5 @@ Like your comment Resend in %s user not exist + Please click on the dots in the image in the correct order. \ No newline at end of file