You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Kotlin Compose短信验证码发送方案及Google Play合规问题咨询

Kotlin Compose 验证码发送与验证方案优化

问题背景

开发Kotlin Compose应用时,需向用户输入的手机号发送4位随机验证码完成归属验证。当前使用SmsManager实现短信发送,但存在两个核心问题:

  • Google Play合规风险:多数申请SEND_SMS权限的应用已被下架,担心后续上架时因权限合规被处理;
  • 权限请求体验差:当前权限请求仅触发一次,用户拒绝后反复请求,影响体验。

已调研SMS User Consent API和SMS Retriever API,但二者仅用于短信接收相关场景,无法满足发送需求,且不使用Firebase。

当前实现代码

注册页面路由与UI代码

@Composable
fun UserFormScreenRoute(
    navHostController: NavHostController,
    sharedViewModel: SharedViewModel,
    viewModel: UserFormViewModel = hiltViewModel()
) {
    val state by viewModel.state.collectAsState()

    UserFormScreen(
        navHostController = navHostController,
        sharedViewModel = sharedViewModel,
        state = state,
        sendMessage = viewModel::sendMessage,
        requestSmsPermission = viewModel::requestSmsPermission,
    )
}

@Composable
fun UserFormScreen(
    navHostController: NavHostController,
    sharedViewModel: SharedViewModel,
    state: UserFormScreenState,
    sendMessage: (String, String, Int) -> String,
    requestSmsPermission: (Activity, Int) -> Boolean
) {
    val context = LocalContext.current
    val activity = context as Activity

    val result = requestSmsPermission(activity, 123)
    if (result == false) {
        requestSmsPermission(activity, 123)
    }

    Scaffold(
        topBar = {
            BackPopUp(
                navController = navHostController,
                route = RegisterScreen.SignUpHealthyLifeStyleGoalsScreen.route
            )
        },
        backgroundColor = Color.Transparent
    ) {
        Column(
            modifier = Modifier.fillMaxSize(),
            verticalArrangement = Arrangement.SpaceBetween,
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            DYTLoginAndContinueButton(
                modifier = Modifier.fillMaxWidth().padding(16.dp),
                text = stringResource(id = R.string.devam),
                navController = navHostController,
                route = RegisterScreen.SignUpVerificationPhoneCodeScreen.route,
                enabled = buttonEnabled(state)
            ) {
                val smsCode = (1000..9999).random()
                startSMSRetrieverClient(context)

                val result = requestSmsPermission(activity, 123)
                if (result)
                    sendMessage(getFullPhoneNumber(), "asdasd", smsCode)
            }
        }
    }
}

注册页面ViewModel代码

@HiltViewModel
class UserFormViewModel @Inject constructor() : ViewModel() {

    fun requestSmsPermission(activity: Activity, requestCode: Int): Boolean {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            return if (activity.checkSelfPermission(Manifest.permission.SEND_SMS) == PackageManager.PERMISSION_GRANTED) {
                true
            } else {
                activity.requestPermissions(arrayOf(Manifest.permission.SEND_SMS), requestCode)
                false
            }
        } else {
            true
        }
    }

    fun sendMessage(phoneNumber: String, message: String, smsCode: Int): String {
        return try {
            val smsManager: SmsManager = SmsManager.getDefault()
            smsManager.sendTextMessage(phoneNumber, null, "$smsCode $message", null, null)
            "success"
        } catch (e: Exception) {
            e.localizedMessage ?: "发送失败"
        }
    }
}

验证码验证页面代码

@Composable
fun VerificationPhoneCodeRoute(
    navHostController: NavHostController,
    viewModel: VerificationPhoneCodeViewModel = hiltViewModel()) {

    val state by viewModel.state.collectAsState()

    VerificationPhoneCodeScreen(
        navHostController = navHostController,
        state = state,
        onChangeOtpValue = viewModel::onChangeOtpValue)
}

@Composable
fun VerificationPhoneCodeScreen(
    navHostController: NavHostController,
    state: VerificationPhoneCodeScreenState,
    onChangeOtpValue: (String) -> Unit
) {
    Scaffold(
        topBar = {
            BackPopUp(navController = navHostController, route = null)
        },
        backgroundColor = Color.Transparent
    ) {
        Column(
            modifier = Modifier.fillMaxSize(),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.Top
        ) {
            Text(
                textAlign = TextAlign.Center,
                modifier = Modifier.padding(10.dp),
                text = stringResource(id = R.string.telefonuna_gelen_kodu_asagidaki_alana_giriniz),
                style = MaterialTheme.typography.subtitle1
            )

            OtpTextField(
                otpText = state.otpValue,
                onOtpTextChange = { value, otpInputFilled ->
                    onChangeOtpValue(value)
                }
            )
        }
    }
}

OTP输入框代码

@Composable
fun OtpTextField(
    modifier: Modifier = Modifier,
    otpText: String,
    otpCount: Int = 4,
    onOtpTextChange: (String, Boolean) -> Unit
) {
    LaunchedEffect(Unit) {
        if (otpText.length > otpCount) {
            throw IllegalArgumentException("Otp text value must not have more than otpCount: $otpCount characters")
        }
    }

    BasicTextField(
        modifier = modifier,
        value = TextFieldValue(otpText, selection = TextRange(otpText.length)),
        onValueChange = {
            if (it.text.length <= otpCount) {
                onOtpTextChange.invoke(it.text, it.text.length == otpCount)
            }
        },
        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
        decorationBox = {
            Row(
                modifier = Modifier.fillMaxWidth().padding(15.dp),
                horizontalArrangement = Arrangement.Center,
                verticalAlignment = Alignment.CenterVertically
            ) {
                repeat(otpCount) { index ->
                    CharView(
                        index = index,
                        text = otpText
                    )
                    Spacer(modifier = Modifier.width(8.dp))
                }
            }
        }
    )
}

@Composable
private fun CharView(
    index: Int,
    text: String
) {
    val isFocused = text.length == index
    val char = when {
        index == text.length -> "0"
        index > text.length -> ""
        else -> text[index].toString()
    }
    Text(
        modifier = Modifier
            .size(50.dp, 60.dp)
            .border(
                2.dp, when {
                    isFocused -> MaterialTheme.colors.DYTThemeColor
                    else -> MaterialTheme.colors.grayColor
                }, RoundedCornerShape(8.dp)
            )
            .padding(2.dp)
            .offset(y = 15.dp),
        text = char,
        style = MaterialTheme.typography.h4,
        color = MaterialTheme.colors.DYTThemeColor,
        textAlign = TextAlign.Center
    )
}

优化方案

1. 彻底解决Google Play合规问题:改用服务端发送验证码

SEND_SMS属于Google Play的高风险权限,仅允许短信类核心功能的应用申请(如短信群发、短信备份工具),普通应用申请该权限几乎无法通过审核,甚至会被下架。最优解决方案是:

  • 客户端将用户输入的手机号发送到自己的后端服务;
  • 后端调用第三方短信服务商API(如阿里云短信、腾讯云短信等)发送验证码;
  • 客户端仅负责输入手机号、接收短信(可配合SMS Retriever API自动填充)、输入验证码并提交验证。

这种方式完全不需要申请SEND_SMS权限,从根源上解决合规问题,同时也避免了客户端发送短信的各种限制(如运营商拦截、设备权限限制)。

2. 权限请求逻辑优化(若坚持客户端发送)

如果因特殊需求必须在客户端发送短信,需优化权限请求逻辑:

(1)使用Activity Result API替代旧权限请求方式

Compose中推荐使用rememberLauncherForActivityResult处理权限请求,避免直接操作Activity:

@Composable
fun UserFormScreen(...) {
    val context = LocalContext.current
    val smsPermissionLauncher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.RequestPermission(),
        onResult = { isGranted ->
            if (isGranted) {
                // 权限已授予,执行发送短信逻辑
                val smsCode = (1000..9999).random()
                sendMessage(getFullPhoneNumber(), "asdasd", smsCode)
            } else {
                // 权限被拒绝,显示弹窗引导用户去设置开启
                AlertDialog(
                    onDismissRequest = {},
                    title = { Text("需要短信权限") },
                    text = { Text("发送验证码需要短信权限,请在设置中开启") },
                    confirmButton = {
                        TextButton(onClick = {
                            // 跳转到应用设置页面
                            val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
                                data = Uri.fromParts("package", context.packageName, null)
                            }
                            context.startActivity(intent)
                        }) {
                            Text("去设置")
                        }
                    },
                    dismissButton = {
                        TextButton(onClick = {}) {
                            Text("取消")
                        }
                    }
                )
            }
        }
    )

    DYTLoginAndContinueButton(...) {
        when {
            ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) == PackageManager.PERMISSION_GRANTED -> {
                // 权限已授予,直接发送
                val smsCode = (1000..9999).random()
                sendMessage(getFullPhoneNumber(), "asdasd", smsCode)
            }
            shouldShowRequestPermissionRationale(context as Activity, Manifest.permission.SEND_SMS) -> {
                // 用户之前拒绝过权限,显示权限说明弹窗
                AlertDialog(
                    onDismissRequest = {},
                    title = { Text("需要短信权限") },
                    text = { Text("我们需要短信权限来发送验证码,验证您的手机号") },
                    confirmButton = {
                        TextButton(onClick = {
                            smsPermissionLauncher.launch(Manifest.permission.SEND_SMS)
                        }) {
                            Text("同意")
                        }
                    }
                )
            }
            else -> {
                // 首次请求权限
                smsPermissionLauncher.launch(Manifest.permission.SEND_SMS)
            }
        }
    }
}

(2)优化权限请求时机

不要在页面加载时就请求权限,应该在用户点击“发送验证码”按钮时再触发,符合用户操作预期,减少不必要的权限干扰。

(3)处理权限拒绝后的引导

当用户拒绝权限后,不要反复弹出请求框,而是显示清晰的说明,告知用户权限的用途,并引导用户到设置页面手动开启。

3. 验证码验证逻辑补充

当前验证码验证页面仅接收用户输入,需补充:

  • 将用户输入的验证码与后端(或客户端生成后存储的验证码)进行比对;
  • 添加验证按钮,点击后执行验证逻辑,验证通过后跳转至下一页面;
  • 处理验证码过期逻辑(如设置5分钟有效期)。

内容的提问来源于stack exchange,提问作者NewPartizal

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.23 00:49:57