功能概述
在 Android 应用中实现自动获取本机手机号进行一键登录,同时支持手动登录和历史账号选择功能。这个功能大大提升了用户体验,减少了用户输入成本。
1. 权限配置
首先在 AndroidManifest.xml
中添加必要的权限:
<!-- 网络权限 -->
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/><!-- 读取手机状态权限 -->
<uses-permission android:name="android.permission.READ_PHONE_STATE"/>
<uses-permission android:name="android.permission.READ_PHONE_NUMBERS"/>
<uses-permission android:name="android.permission.READ_SMS"/><!-- 声明电话功能为可选,解决Chrome OS兼容性问题 -->
<uses-feature android:name="android.hardware.telephony" android:required="false"/>
2. 登录处理
主要功能包括通过系统权限获取本机手机号进行一键登录、支持手动输入账号密码登录、显示历史登录账号列表供用户快速选择。系统首先会动态申请读取手机状态的权限来获取本机号码,然后提供三种登录方式:一键登录使用获取到的本机号码自动登录,手动登录允许用户输入其他手机号和密码,历史账号功能展示之前登录过的账号供用户选择。无论采用哪种登录方式,系统都会在本地数据库和远程服务器之间同步用户信息,确保数据一致性,并在登录成功后跳转到主界面。
点击查看代码
package com.example.ui.screensimport android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.telephony.TelephonyManager
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.platform.LocalContext
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.example.MainActivity
import com.example.R
import com.example.model.User
import com.example.network.RetrofitClient
import com.example.ui.theme.MentalTheme
import com.example.util.DatabaseHelper
import coil.compose.AsyncImage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.RequestBody
import timber.log.Timber// 登录活动类,负责处理用户登录相关的所有功能
class LoginActivity : ComponentActivity() {// 权限请求码和必要的变量声明private val READ_PHONE_PERMISSION = 1001private lateinit var dbHelper: DatabaseHelperprivate var devicePhoneNumber: String? = nullprivate var allUserPhones: List<String> = emptyList()private val apiService = RetrofitClient.apiService// 活动创建时的初始化工作override fun onCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState)dbHelper = DatabaseHelper(this)// 从数据库获取所有历史登录用户的手机号allUserPhones = dbHelper.getAllUserPhones()// 检查是否有从其他界面传递过来的预填充手机号val prefilledPhone = intent.getStringExtra("PREFILLED_PHONE") ?: ""val isManualLogin = intent.getBooleanExtra("IS_MANUAL_LOGIN", false)// 检查并请求获取手机号的权限checkPhonePermission()// 设置Compose UI界面setContent {MentalTheme {LoginScreen(devicePhoneNumber = devicePhoneNumber,allUserPhones = allUserPhones,prefilledPhone = prefilledPhone,isManualLogin = isManualLogin,onOneKeyLogin = { phoneNumber ->handleOneKeyLogin(phoneNumber)},onManualLogin = { phone, password ->handleManualLogin(phone, password)},onHistoryPhoneSelected = { phone ->// 当用户选择历史账号时,重新启动登录界面并预填充选中的手机号startActivity(Intent(this, LoginActivity::class.java).apply {putExtra("PREFILLED_PHONE", phone)putExtra("IS_MANUAL_LOGIN", true)})finish()})}}}// 检查获取手机号所需的权限private fun checkPhonePermission() {// 定义需要的权限数组val permissions = arrayOf(android.Manifest.permission.READ_PHONE_STATE,android.Manifest.permission.READ_PHONE_NUMBERS,android.Manifest.permission.READ_SMS)// 筛选出尚未授予的权限val missingPermissions = permissions.filter {ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED}// 如果有未授权的权限,则请求授权if (missingPermissions.isNotEmpty()) {ActivityCompat.requestPermissions(this,missingPermissions.toTypedArray(),READ_PHONE_PERMISSION)} else {// 所有权限都已授予,直接获取手机号getDevicePhoneNumber()}}// 获取设备手机号码private fun getDevicePhoneNumber() {try {val telephonyManager = getSystemService(TELEPHONY_SERVICE) as TelephonyManagerif (ActivityCompat.checkSelfPermission(this,android.Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_GRANTED) {// 获取本机号码devicePhoneNumber = telephonyManager.line1Number// 处理国家代码,移除中国的+86前缀if (!devicePhoneNumber.isNullOrEmpty() && devicePhoneNumber!!.startsWith("+86")) {devicePhoneNumber = devicePhoneNumber!!.substring(3)}// 对手机号进行脱敏处理(实际函数中直接返回原始号码)devicePhoneNumber = maskPhoneNumber(devicePhoneNumber!!)}} catch (e: Exception) {Timber.e(e, "获取本机号码失败")}}// 处理权限请求结果override fun onRequestPermissionsResult(requestCode: Int,permissions: Array<out String>,grantResults: IntArray) {super.onRequestPermissionsResult(requestCode, permissions, grantResults)if (requestCode == READ_PHONE_PERMISSION) {// 检查是否有任何一个权限被授予val hasAnyPermission = grantResults.any { it == PackageManager.PERMISSION_GRANTED }if (hasAnyPermission) {// 至少有一个权限被授予,尝试获取手机号getDevicePhoneNumber()} else {// 所有权限都被拒绝,提示用户手动输入Toast.makeText(this, "无法获取本机号码,您可以手动输入或选择历史登录账号", Toast.LENGTH_SHORT).show()}}}// 处理一键登录逻辑private fun handleOneKeyLogin(phoneNumber: String) {// 首先根据手机号检查用户是否在本地数据库中存在val existingUser = dbHelper.getUserByPhone(phoneNumber)if (existingUser != null) {// 用户已存在,更新登录状态val updatedUser = existingUser.copy(isLogin = true)dbHelper.addOrUpdateUser(updatedUser)// 异步调用API更新服务器上的用户信息GlobalScope.launch(Dispatchers.IO) {try {// 准备用户信息JSON字符串val userJson = """{"username": "${updatedUser.username}","password": "${updatedUser.password}","phone": "${updatedUser.phone}","email": ${if (updatedUser.email != null) "\"${updatedUser.email}\"" else "null"},"nickname": ${if (updatedUser.nickname != null) "\"${updatedUser.nickname}\"" else "null"},"gender": "${updatedUser.gender}","age": ${if (updatedUser.age != null) updatedUser.age else "null"}}"""val userMediaType = "application/json".toMediaTypeOrNull()val userRequestBody = userMediaType?.let {RequestBody.create(it, userJson)} ?: throw IllegalStateException("Invalid media type")// 调用API更新用户信息val apiResponse = apiService.updateUser(phone = phoneNumber,user = userRequestBody)// 从响应中获取用户数据val responseUser = apiResponse.data ?: updatedUser// 处理头像URL,将localhost替换为实际IP地址val processedAvatarUrl = responseUser.avatarUrl?.replace("http://localhost:8080", "http://192.168.94.109:8080")// 更新本地用户信息val finalUser = responseUser.copy(isLogin = true,avatarUrl = processedAvatarUrl)dbHelper.addOrUpdateUser(finalUser)} catch (e: Exception) {Timber.e(e, "更新用户API调用失败")}}// 跳转到主界面navigateToMain()} else {// 用户不存在,创建新用户val maskedPhone = maskPhoneNumber(phoneNumber)val newUser = User(username = "用户" + (10000..99999).random(), // 生成随机用户名phone = phoneNumber,password = "", // 一键登录可以不设置密码isLogin = true)// 先保存到本地数据库dbHelper.addOrUpdateUser(newUser)// 然后异步调用API保存到远端服务器GlobalScope.launch(Dispatchers.IO) {try {// 准备用户信息JSON字符串val userJson = """{"username": "${newUser.username}","password": "${newUser.password}","phone": "${newUser.phone}","email": null,"nickname": null,"gender": "${newUser.gender}","age": null}""".trimIndent()val userMediaType = "application/json".toMediaTypeOrNull()val userRequestBody = userMediaType?.let {RequestBody.create(it, userJson)} ?: throw IllegalStateException("Invalid media type")// 调用API创建用户val apiResponse = apiService.createUser(user = userRequestBody)// 从响应中获取用户数据val responseUser = apiResponse.data ?: newUser// 更新本地用户信息val updatedLocalUser = responseUser.copy(isLogin = true)dbHelper.addOrUpdateUser(updatedLocalUser)} catch (e: Exception) {Timber.e(e, "创建用户API调用失败")}}navigateToMain()}}// 处理手动登录逻辑private fun handleManualLogin(phone: String, password: String) {// 检查本地数据库中是否存在匹配的用户val user = dbHelper.checkUser(phone, password)if (user != null) {// 用户存在,更新登录状态val updatedUser = user.copy(isLogin = true)dbHelper.addOrUpdateUser(updatedUser)// 异步调用API更新服务器上的用户信息GlobalScope.launch(Dispatchers.IO) {try {// 准备用户信息JSON字符串val userJson = """{"username": "${updatedUser.username}","password": "${updatedUser.password}","phone": "${updatedUser.phone}","email": ${if (updatedUser.email != null) "\"${updatedUser.email}\"" else "null"},"nickname": ${if (updatedUser.nickname != null) "\"${updatedUser.nickname}\"" else "null"},"gender": "${updatedUser.gender}","age": ${if (updatedUser.age != null) updatedUser.age else "null"}}""".trimIndent()val userMediaType = "application/json".toMediaTypeOrNull()val userRequestBody = userMediaType?.let {RequestBody.create(it, userJson)} ?: throw IllegalStateException("Invalid media type")// 调用API更新用户val apiResponse = apiService.updateUser(phone = phone,user = userRequestBody)// 从响应中获取用户数据val responseUser = apiResponse.data ?: updatedUser// 更新本地用户信息val finalUser = responseUser.copy(isLogin = true)dbHelper.addOrUpdateUser(finalUser)} catch (e: Exception) {Timber.e(e, "更新用户API调用失败")}}navigateToMain()} else {// 用户不存在,创建新用户val newUser = User(username = "用户" + (10000..99999).random(),phone = phone,password = password,isLogin = true)// 先保存到本地数据库dbHelper.addOrUpdateUser(newUser)// 然后异步调用API保存到远端服务器GlobalScope.launch(Dispatchers.IO) {try {// 准备用户信息JSON字符串val userJson = """{"username": "${newUser.username}","password": "${newUser.password}","phone": "${newUser.phone}","email": null,"nickname": null,"gender": "${newUser.gender}","age": null}""".trimIndent()val userMediaType = "application/json".toMediaTypeOrNull()val userRequestBody = userMediaType?.let {RequestBody.create(it, userJson)} ?: throw IllegalStateException("Invalid media type")// 调用API创建用户val apiResponse = apiService.createUser(user = userRequestBody)// 从响应中获取用户数据val responseUser = apiResponse.data ?: newUser// 更新本地用户信息val updatedLocalUser = responseUser.copy(isLogin = true)dbHelper.addOrUpdateUser(updatedLocalUser)} catch (e: Exception) {Timber.e(e, "创建用户API调用失败")}}navigateToMain()}}// 跳转到主界面private fun navigateToMain() {startActivity(Intent(this, MainActivity::class.java))finish()}
}// 手机号脱敏函数,当前实现直接返回原始手机号
private fun maskPhoneNumber(phoneNumber: String): String {return phoneNumber
}// Compose登录界面组件
@Composable
fun LoginScreen(devicePhoneNumber: String?, // 设备手机号allUserPhones: List<String>, // 所有历史登录手机号prefilledPhone: String = "", // 预填充的手机号isManualLogin: Boolean = false, // 是否手动登录模式onOneKeyLogin: (String) -> Unit, // 一键登录回调onManualLogin: (String, String) -> Unit, // 手动登录回调onHistoryPhoneSelected: (String) -> Unit // 历史账号选择回调
) {// 定义各种状态变量var phoneNumber by remember { mutableStateOf(prefilledPhone) }var password by remember { mutableStateOf("") }var isAgreed by remember { mutableStateOf(false) }var showManualLogin by remember { mutableStateOf(isManualLogin) }var showHistoryPhones by remember { mutableStateOf(false) }val context = LocalContext.currentvar showAgreementDialog by remember { mutableStateOf(false) } // 协议确认对话框显示状态// 显示的设备手机号,如果没有则为空字符串val displayedPhoneNumber = devicePhoneNumber ?: ""// 界面布局Box(modifier = Modifier.fillMaxSize().background(Color(0xFFF0F4FF)).padding(24.dp)) {Column(modifier = Modifier.fillMaxSize(),verticalArrangement = Arrangement.Center,horizontalAlignment = Alignment.CenterHorizontally) {// 应用图标Image(painter = painterResource(id = R.drawable.img),contentDescription = "应用图标",modifier = Modifier.size(100.dp).background(Color(0xFF5A67D8), RoundedCornerShape(20.dp)).padding(20.dp))Spacer(modifier = Modifier.height(40.dp))// 如果设备手机号不为空且不是手动登录模式,显示一键登录界面if (displayedPhoneNumber.isNotEmpty() && !isManualLogin) {Text(text = displayedPhoneNumber,fontSize = 20.sp,fontWeight = FontWeight.Bold,color = Color(0xFF333333))Spacer(modifier = Modifier.height(24.dp))// 本机号码一键登录按钮Button(onClick = {if (isAgreed) {onOneKeyLogin(displayedPhoneNumber)} else {// 未同意协议,显示提示对话框showAgreementDialog = true}},modifier = Modifier.fillMaxWidth().height(56.dp),shape = RoundedCornerShape(28.dp),enabled = true // 按钮始终可用) {Text(text = "本机号码一键登录",fontSize = 16.sp,fontWeight = FontWeight.Bold)}}// 手动登录表单(当设备手机号为空或用户选择手动登录时显示)if (displayedPhoneNumber.isEmpty() || showManualLogin) {Spacer(modifier = Modifier.height(8.dp))// 手机号输入框OutlinedTextField(value = phoneNumber,onValueChange = { phoneNumber = it },label = { Text("手机号") },modifier = Modifier.fillMaxWidth(),shape = RoundedCornerShape(12.dp))Spacer(modifier = Modifier.height(16.dp))// 密码输入框OutlinedTextField(value = password,onValueChange = { password = it },label = { Text("密码") },modifier = Modifier.fillMaxWidth(),visualTransformation = PasswordVisualTransformation(),shape = RoundedCornerShape(12.dp))Spacer(modifier = Modifier.height(24.dp))// 登录按钮Button(onClick = {if (isAgreed) {onManualLogin(phoneNumber, password)} else {// 未同意协议,显示提示对话框showAgreementDialog = true}},modifier = Modifier.fillMaxWidth().height(56.dp),shape = RoundedCornerShape(28.dp),enabled = phoneNumber.isNotEmpty() && password.isNotEmpty() // 手机号和密码不为空时启用) {Text(text = "登录",fontSize = 16.sp,fontWeight = FontWeight.Bold)}}Spacer(modifier = Modifier.height(16.dp))// 切换登录方式(仅在设备手机号不为空时显示)if (displayedPhoneNumber.isNotEmpty()) {Text(text = if (showManualLogin) "使用本机号码登录" else "其他手机号码登录",fontSize = 14.sp,color = Color(0xFF5A67D8),modifier = Modifier.clickable {showManualLogin = !showManualLogin})}// 显示历史登录手机号选项if (allUserPhones.isNotEmpty() && !showManualLogin && displayedPhoneNumber.isNotEmpty()) {Text(text = if (showHistoryPhones) "隐藏历史账号" else "选择历史账号",fontSize = 14.sp,color = Color(0xFF5A67D8),modifier = Modifier.clickable {showHistoryPhones = !showHistoryPhones})// 显示历史手机号列表if (showHistoryPhones) {Spacer(modifier = Modifier.height(8.dp))// 过滤掉当前显示的设备号码val filteredPhones = allUserPhones.filter { it != displayedPhoneNumber }if (filteredPhones.isNotEmpty()) {Column {filteredPhones.forEachIndexed { index, phone ->val maskedPhone = maskPhoneNumber(phone)val dbHelper = remember { DatabaseHelper(context) }val user = remember { dbHelper.getUserByPhone(phone) }// 历史账号项Row(modifier = Modifier.fillMaxWidth().clickable {onHistoryPhoneSelected(phone)}.padding(8.dp),verticalAlignment = Alignment.CenterVertically) {// 显示用户头像if (user?.avatarUrl != null && user.avatarUrl.isNotEmpty()) {AsyncImage(model = user.avatarUrl,contentDescription = "用户头像",modifier = Modifier.size(32.dp).padding(4.dp),placeholder = painterResource(id = R.drawable.img),error = painterResource(id = R.drawable.img))} else {Image(painter = painterResource(id = R.drawable.img),contentDescription = "用户头像",modifier = Modifier.size(32.dp).padding(4.dp))}Spacer(modifier = Modifier.width(8.dp))Text(text = maskedPhone,fontSize = 14.sp,color = Color(0xFF333333))}// 最后一个不显示分割线if (index < filteredPhones.size - 1) {Spacer(modifier = Modifier.height(4.dp))Box(modifier = Modifier.fillMaxWidth().height(1.dp).background(Color(0xFFEEEEEE)))Spacer(modifier = Modifier.height(4.dp))}}}} else {Text(text = "暂无其他历史账号",fontSize = 14.sp,color = Color(0xFF999999),modifier = Modifier.padding(8.dp))}}}Spacer(modifier = Modifier.weight(1f))// 协议同意复选框Row(verticalAlignment = Alignment.CenterVertically,modifier = Modifier.fillMaxWidth()) {Checkbox(checked = isAgreed,onCheckedChange = { isAgreed = it })Text(text = "同意《中国移动认证服务条款》和《用户协议》和《隐私政策》",fontSize = 12.sp,color = Color(0xFF666666))}// 协议确认对话框if (showAgreementDialog) {androidx.compose.material3.AlertDialog(onDismissRequest = { showAgreementDialog = false },title = { Text("提示") },text = { Text("请先同意《中国移动认证服务条款》和《用户协议》和《隐私政策》") },confirmButton = {Button(onClick = { showAgreementDialog = false }) {Text("确定")}})}}}
}