diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 05eb9bb..2d5c25d 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -47,6 +47,9 @@ android {
// Set appkey from local.properties
buildConfigField("String", "APPKEY", "\"${properties.getProperty("APPKEY")}\"")
+ //请求验证码秘钥,这里是环信公司的,开发者应该用自己的
+ buildConfigField("String", "SECRET_KEY", "\"${properties.getProperty("SECRET_KEY")}\"")
+
// Set push info from local.properties
buildConfigField("String", "MEIZU_PUSH_APPKEY", "\"${properties.getProperty("MEIZU_PUSH_APPKEY")}\"")
buildConfigField("String", "MEIZU_PUSH_APPID", "\"${properties.getProperty("MEIZU_PUSH_APPID")}\"")
@@ -155,8 +158,8 @@ dependencies {
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
implementation("androidx.core:core-ktx:1.10.1")
implementation("androidx.appcompat:appcompat:1.7.0")
- implementation("com.google.android.material:material:1.9.0")
- implementation("androidx.constraintlayout:constraintlayout:2.1.4")
+ implementation("com.google.android.material:material:1.12.0")
+ implementation("androidx.constraintlayout:constraintlayout:2.2.1")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index b782325..e7871c9 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -95,6 +95,8 @@
+
+
- getVerificationCodeFromServer(
- phoneNumber,
- onSuccess = {
- continuation.resume(ChatError.EM_NO_ERROR)
- },
- onError = { code, error ->
- continuation.resumeWithException(ChatException(code, error))
- }
- )
- }
- }
-
- private fun getVerificationCodeFromServer(phoneNumber: String?, onSuccess: OnSuccess, onError: OnError) {
- if (phoneNumber.isNullOrEmpty()) {
- onError(ChatError.INVALID_PARAM, getContext().getString(R.string.em_login_phone_empty))
- return
- }
- try {
- val headers: MutableMap = java.util.HashMap()
- headers["Content-Type"] = "application/json"
- val url = "$SEND_SMS_URL/$phoneNumber/"
- EMLog.d("getVerificationCodeFromServe url : ", url)
- val response =
- HttpClientManager.httpExecute(url, headers, null, HttpClientManager.Method_POST)
- val code = response.code
- val responseInfo = response.content
- if (code == 200) {
- onSuccess()
- } else {
- if (responseInfo != null && responseInfo.isNotEmpty()) {
- var errorInfo: String? = null
- try {
- val responseObject = JSONObject(responseInfo)
- errorInfo = responseObject.getString("errorInfo")
- if (errorInfo.contains("wait a moment while trying to send")) {
- errorInfo =
- getContext().getString(R.string.em_login_error_send_code_later)
- } else if (errorInfo.contains("exceed the limit of")) {
- errorInfo =
- getContext().getString(R.string.em_login_error_send_code_limit)
- }
- } catch (e: JSONException) {
- e.printStackTrace()
- errorInfo = responseInfo
- }
- onError(code, errorInfo)
- } else {
- onError(code, responseInfo)
- }
- }
- } catch (e: java.lang.Exception) {
- onError(ChatError.NETWORK_ERROR, e.message)
- }
- }
-
/**
* 注销账户
* @return
diff --git a/app/src/main/kotlin/com/hyphenate/chatdemo/ui/login/LoginFragment.kt b/app/src/main/kotlin/com/hyphenate/chatdemo/ui/login/LoginFragment.kt
index b6c2759..2f32119 100644
--- a/app/src/main/kotlin/com/hyphenate/chatdemo/ui/login/LoginFragment.kt
+++ b/app/src/main/kotlin/com/hyphenate/chatdemo/ui/login/LoginFragment.kt
@@ -2,8 +2,10 @@ package com.hyphenate.chatdemo.ui.login
import android.content.Intent
import android.graphics.Color
+import android.graphics.Outline
import android.graphics.drawable.Drawable
import android.net.Uri
+import android.os.Build
import android.os.Bundle
import android.os.SystemClock
import android.text.Editable
@@ -16,18 +18,32 @@ import android.text.TextWatcher
import android.text.method.LinkMovementMethod
import android.text.style.ClickableSpan
import android.text.style.ForegroundColorSpan
+import android.util.Base64
+import android.util.Log
+import android.util.TypedValue
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.View
+import android.view.View.VISIBLE
import android.view.ViewGroup
+import android.view.ViewOutlineProvider
import android.view.inputmethod.EditorInfo
+import android.webkit.JavascriptInterface
+import android.webkit.WebResourceError
+import android.webkit.WebResourceRequest
+import android.webkit.WebSettings
+import android.webkit.WebView
+import android.webkit.WebViewClient
import android.widget.CompoundButton
import android.widget.TextView
+import android.widget.TextView.GONE
import android.widget.TextView.OnEditorActionListener
+import androidx.annotation.RequiresApi
import androidx.core.content.ContextCompat
import androidx.core.text.clearSpans
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
+import com.hyphenate.chatdemo.BuildConfig
import com.hyphenate.chatdemo.DemoHelper
import com.hyphenate.chatdemo.MainActivity
import com.hyphenate.chatdemo.R
@@ -39,6 +55,7 @@ import com.hyphenate.chatdemo.common.extensions.internal.changePwdDrawable
import com.hyphenate.chatdemo.common.extensions.internal.clearEditTextListener
import com.hyphenate.chatdemo.common.extensions.internal.showRightDrawable
import com.hyphenate.chatdemo.databinding.DemoFragmentLoginBinding
+import com.hyphenate.chatdemo.utils.AESEncryptor
import com.hyphenate.chatdemo.utils.PhoneNumberUtils
import com.hyphenate.chatdemo.utils.ToastUtils.showToast
import com.hyphenate.chatdemo.viewmodel.LoginFragmentViewModel
@@ -48,14 +65,18 @@ import com.hyphenate.easeui.common.ChatError
import com.hyphenate.easeui.common.bus.ChatUIKitFlowBus
import com.hyphenate.easeui.common.extensions.catchChatException
import com.hyphenate.easeui.common.extensions.hideSoftKeyboard
+import com.hyphenate.util.EMLog
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
+import org.json.JSONObject
import java.util.Locale
+import javax.crypto.spec.SecretKeySpec
-class LoginFragment : ChatUIKitBaseFragment(), View.OnClickListener, TextWatcher,
+class LoginFragment : ChatUIKitBaseFragment(), View.OnClickListener,
+ TextWatcher,
CompoundButton.OnCheckedChangeListener, OnEditorActionListener {
private var mUserPhone: String? = null
private var mCode: String? = null
@@ -67,6 +88,7 @@ class LoginFragment : ChatUIKitBaseFragment(), View.On
private var isDeveloperMode = false
private var isShowingDialog = false
private var countDownTimer: CustomCountDownTimer? = null
+ private val VERIFY_CODE_URL = "https://downloadsdk.easemob.com/downloads/IMDemo/sms/index.html"
override fun getViewBinding(
inflater: LayoutInflater,
@@ -89,6 +111,7 @@ class LoginFragment : ChatUIKitBaseFragment(), View.On
etLoginPhone.clearEditTextListener()
root.setOnClickListener {
mContext.hideSoftKeyboard()
+ makeVerifyCodeWebViewVisible(false)
}
}
}
@@ -99,6 +122,11 @@ class LoginFragment : ChatUIKitBaseFragment(), View.On
mFragmentViewModel = ViewModelProvider(this)[LoginFragmentViewModel::class.java]
}
+ override fun initView(savedInstanceState: Bundle?) {
+ super.initView(savedInstanceState)
+ setupWebView()
+ }
+
override fun initData() {
super.initData()
binding?.run {
@@ -137,7 +165,8 @@ class LoginFragment : ChatUIKitBaseFragment(), View.On
}
R.id.tv_login_developer -> {
- ChatUIKitFlowBus.with(DemoConstant.SKIP_DEVELOPER_CONFIG).post(lifecycleScope, LoginFragment::class.java.simpleName)
+ ChatUIKitFlowBus.with(DemoConstant.SKIP_DEVELOPER_CONFIG)
+ .post(lifecycleScope, LoginFragment::class.java.simpleName)
}
}
}
@@ -163,7 +192,11 @@ class LoginFragment : ChatUIKitBaseFragment(), View.On
showToast(e.description)
}
}
- .stateIn(lifecycleScope, SharingStarted.WhileSubscribed(stopTimeoutMillis), null)
+ .stateIn(
+ lifecycleScope,
+ SharingStarted.WhileSubscribed(stopTimeoutMillis),
+ null
+ )
.collect {
if (it != null) {
DemoHelper.getInstance().getDataModel().initDb()
@@ -178,7 +211,7 @@ class LoginFragment : ChatUIKitBaseFragment(), View.On
return
}
if (!PhoneNumberUtils.isPhoneNumber(mUserPhone)) {
- showToast(mContext!!.getString(R.string.em_login_phone_illegal))
+ showToast(mContext.getString(R.string.em_login_phone_illegal))
return
}
if (mCode.isNullOrEmpty()) {
@@ -208,7 +241,11 @@ class LoginFragment : ChatUIKitBaseFragment(), View.On
showToast(e.description)
}
}
- .stateIn(lifecycleScope, SharingStarted.WhileSubscribed(stopTimeoutMillis), null)
+ .stateIn(
+ lifecycleScope,
+ SharingStarted.WhileSubscribed(stopTimeoutMillis),
+ null
+ )
.collect {
if (it != null) {
DemoHelper.getInstance().getDataModel().initDb()
@@ -237,19 +274,8 @@ class LoginFragment : ChatUIKitBaseFragment(), View.On
1000
)
}
- lifecycleScope.launch {
- mFragmentViewModel.getVerificationCode(mUserPhone!!)
- .catchChatException { e ->
- showToast(e.description)
- }
- .stateIn(lifecycleScope, SharingStarted.WhileSubscribed(stopTimeoutMillis), null)
- .collect {
- if (it != null) {
- countDownTimer?.start()
- showToast(R.string.em_login_post_code)
- }
- }
- }
+ mContext.hideSoftKeyboard()
+ showVerifyCodeWebView(mUserPhone)
}
private fun showOpenDeveloperDialog() {
@@ -259,7 +285,7 @@ class LoginFragment : ChatUIKitBaseFragment(), View.On
R.string.server_open_develop_mode
)
)
- .setPositiveButton{
+ .setPositiveButton {
isDeveloperMode = !isDeveloperMode
DemoHelper.getInstance().getDataModel()?.setDeveloperMode(isDeveloperMode)
binding?.etLoginPhone?.setText("")
@@ -282,6 +308,10 @@ class LoginFragment : ChatUIKitBaseFragment(), View.On
etLoginPhone.showRightDrawable(clear)
if (isDeveloperMode) {
etLoginCode.showRightDrawable(eyeClose)
+ } else {
+ if (!PhoneNumberUtils.isPhoneNumber(mUserPhone)) {
+ makeVerifyCodeWebViewVisible(false)
+ }
}
setButtonEnable(!TextUtils.isEmpty(mUserPhone) && !TextUtils.isEmpty(mCode))
}
@@ -301,6 +331,7 @@ class LoginFragment : ChatUIKitBaseFragment(), View.On
binding?.run {
btnLogin.isEnabled = enable
if (etLoginCode.hasFocus()) {
+ makeVerifyCodeWebViewVisible(false)
etLoginCode.imeOptions =
if (enable) EditorInfo.IME_ACTION_DONE else EditorInfo.IME_ACTION_PREVIOUS
} else if (etLoginPhone.hasFocus()) {
@@ -319,7 +350,8 @@ class LoginFragment : ChatUIKitBaseFragment(), View.On
}
private fun createSpannable(): SpannableString {
- val language = PreferenceManager.getValue(DemoConstant.APP_LANGUAGE,Locale.getDefault().language)
+ val language =
+ PreferenceManager.getValue(DemoConstant.APP_LANGUAGE, Locale.getDefault().language)
val isZh = language.startsWith("zh")
val spanStr = SpannableString(getString(R.string.em_login_agreement))
var start1 = 29
@@ -353,7 +385,12 @@ class LoginFragment : ChatUIKitBaseFragment(), View.On
}
}, start2, end2, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
spanStr.setSpan(
- ForegroundColorSpan(ContextCompat.getColor(mContext, com.hyphenate.easeui.R.color.ease_color_primary)),
+ ForegroundColorSpan(
+ ContextCompat.getColor(
+ mContext,
+ com.hyphenate.easeui.R.color.ease_color_primary
+ )
+ ),
start2,
end2,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
@@ -419,6 +456,130 @@ class LoginFragment : ChatUIKitBaseFragment(), View.On
startActivity(it)
}
+ private fun setupWebView() {
+ binding?.webView?.settings?.apply {
+ javaScriptEnabled = true
+ domStorageEnabled = true
+ useWideViewPort = true
+ loadWithOverviewMode = true
+ domStorageEnabled = true
+ cacheMode = WebSettings.LOAD_NO_CACHE
+ }
+
+ // Kotlin代码动态设置
+ binding?.webView?.apply {
+ addJavascriptInterface(CaptchaJsInterface(), "android")
+ isVerticalScrollBarEnabled = false
+ isHorizontalScrollBarEnabled = false
+ outlineProvider = object : ViewOutlineProvider() {
+ override fun getOutline(view: View, outline: Outline) {
+ outline.setRoundRect(0, 0, view.width, view.height, 4f.dp)
+ }
+ }
+ clipToOutline = true
+ }
+
+ binding?.webView?.webViewClient = object : WebViewClient() {
+ override fun onPageFinished(view: WebView?, url: String?) {
+ binding?.progressBar?.visibility = GONE
+ }
+
+ @RequiresApi(Build.VERSION_CODES.M)
+ override fun onReceivedError(
+ view: WebView?,
+ request: WebResourceRequest?,
+ error: WebResourceError?
+ ) {
+ handleError(errorInfo = "WEBVIEW_ERROR: ${error?.description}")
+ }
+
+ override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean {
+ url?.let {
+ view?.loadUrl(url)
+ return true
+ }
+ return super.shouldOverrideUrlLoading(view, url)
+ }
+
+ }
+ }
+
+ private fun showVerifyCodeWebView(mUserPhone: String?) {
+ makeVerifyCodeWebViewVisible(true)
+ binding?.webView?.loadUrl("$VERIFY_CODE_URL?telephone=$mUserPhone")
+ }
+
+ private fun makeVerifyCodeWebViewVisible(visible: Boolean) {
+ binding?.run {
+ webView.clearHistory()
+ webView.removeAllViews()
+ if (visible){
+ progressBar.visibility = VISIBLE
+ webView.visibility = VISIBLE
+ }else{
+ progressBar.visibility = GONE
+ webView.visibility = GONE
+ }
+ }
+ }
+
+ inner class CaptchaJsInterface {
+
+ @JavascriptInterface
+ fun encryptData(param: String) {
+ // 1. 准备密钥
+ val keyBytes = Base64.decode(BuildConfig.SECRET_KEY, Base64.DEFAULT)
+ val secretKey = SecretKeySpec(keyBytes, "AES")
+
+ val encryptedData = AESEncryptor.encrypt(param,secretKey)
+
+ // 2. 切换到主线程回调JS
+ binding?.webView?.post {
+ val jsCallback = "window.encryptCallback('${encryptedData}')"
+ binding?.webView?.evaluateJavascript(jsCallback, null)
+ Log.d(TAG, "encryptData: $jsCallback")
+ }
+ }
+
+ @JavascriptInterface
+ fun getVerifyResult(verifyResult: String) {
+ binding?.progressBar?.post {
+ makeVerifyCodeWebViewVisible(false)
+ }
+ EMLog.d(TAG, "verifyResult = " + verifyResult)
+ var code = -1;
+ try {
+ val json = JSONObject(verifyResult)
+ code = json.getInt("code")
+ if (code == 200) {
+ handleSuccess()
+ } else {
+ val errorInfo = json.getString("errorInfo")
+ handleError(code, errorInfo)
+ }
+
+ } catch (e: Exception) {
+ handleError(code, "PARSE_ERROR: ${e.message}")
+ }
+ }
+ }
+
+ private fun handleSuccess() {
+ binding?.webView?.post {
+ countDownTimer?.start()
+ showToast(R.string.em_login_post_code)
+ }
+ }
+
+
+ private fun handleError(code: Int = -1, errorInfo: String = "") {
+ EMLog.e(TAG, "code = $code" + ",errorInfo = " + errorInfo)
+ binding?.webView?.post {
+ showToast(errorInfo)
+ }
+ }
+
+
private abstract inner class MyClickableSpan : ClickableSpan() {
override fun updateDrawState(ds: TextPaint) {
super.updateDrawState(ds)
@@ -430,9 +591,20 @@ class LoginFragment : ChatUIKitBaseFragment(), View.On
override fun onDestroyView() {
spannable?.clearSpans()
spannable = null
+ binding?.webView?.apply {
+ stopLoading()
+ destroy()
+ }
super.onDestroyView()
}
+ val Float.dp: Float
+ get() = TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP,
+ this,
+ resources.displayMetrics
+ )
+
companion object {
private const val TAG = "LoginFragment"
private const val COUNT: Int = 5
diff --git a/app/src/main/kotlin/com/hyphenate/chatdemo/utils/AESEncryptor.kt b/app/src/main/kotlin/com/hyphenate/chatdemo/utils/AESEncryptor.kt
new file mode 100644
index 0000000..d0893c8
--- /dev/null
+++ b/app/src/main/kotlin/com/hyphenate/chatdemo/utils/AESEncryptor.kt
@@ -0,0 +1,72 @@
+package com.hyphenate.chatdemo.utils
+
+import android.util.Base64
+import java.security.SecureRandom
+import javax.crypto.Cipher
+import javax.crypto.SecretKey
+import javax.crypto.spec.GCMParameterSpec
+import javax.crypto.spec.SecretKeySpec
+
+
+object AESEncryptor {
+
+ // GCM建议的IV长度是12字节(96位)
+ private const val IV_LENGTH: Int = 12
+ // 认证标签长度,通常为128位
+ private const val AUTH_TAG_LENGTH: Int = 128
+
+ // 加密方法(与iOS的encryptWithAES功能一致)
+ fun encrypt(data: String,secretKey: SecretKeySpec): String {
+ return try {
+
+ // 2. 初始化加密器(使用相同的AES/GCM/NoPadding模式)
+ val cipher = Cipher.getInstance("AES/GCM/NoPadding")
+
+ // 3. 使用12字节IV(与iOS默认行为一致)
+ // 注意:iOS的sealedBox会自动生成IV,我们需要在Android端模拟相同行为
+ val iv = ByteArray(12).also { SecureRandom().nextBytes(it) }
+
+ // 4. 使用128位认证标签(与iOS一致)
+ val parameterSpec = GCMParameterSpec(128, iv)
+ cipher.init(Cipher.ENCRYPT_MODE, secretKey, parameterSpec)
+
+ // 5. 加密数据
+ val encryptedBytes = cipher.doFinal(data.toByteArray(Charsets.UTF_8))
+
+ // 6. 组合IV + 加密数据 + 认证标签(与iOS的sealedBox.combined结构一致)
+ // 结构:IV(12) + 加密数据 + 认证标签(16)
+ val combined = iv + encryptedBytes
+
+ // 7. Base64编码返回(与iOS一致)
+ Base64.encodeToString(combined,Base64.NO_WRAP)
+ } catch (e: Exception) {
+ e.printStackTrace()
+ ""
+ }
+ }
+
+ //解密
+ fun decrypt(encryptedText: String?, secretKey: SecretKeySpec): String {
+ return try {
+ val combined: ByteArray = Base64.decode(encryptedText,Base64.DEFAULT)
+ // 提取IV
+ val iv = ByteArray(IV_LENGTH)
+ val cipherText = ByteArray(combined.size - IV_LENGTH)
+
+ System.arraycopy(combined, 0, iv, 0, IV_LENGTH)
+ System.arraycopy(combined, IV_LENGTH, cipherText, 0, cipherText.size)
+
+ val cipher = Cipher.getInstance("AES/GCM/NoPadding")
+ val spec = GCMParameterSpec(AUTH_TAG_LENGTH, iv)
+
+ cipher.init(Cipher.DECRYPT_MODE, secretKey, spec)
+
+ val decryptedData = cipher.doFinal(cipherText)
+ String(decryptedData)
+ } catch (e: Exception) {
+ e.printStackTrace()
+ ""
+ }
+
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/hyphenate/chatdemo/viewmodel/LoginFragmentViewModel.kt b/app/src/main/kotlin/com/hyphenate/chatdemo/viewmodel/LoginFragmentViewModel.kt
index 14c3772..4a185cc 100644
--- a/app/src/main/kotlin/com/hyphenate/chatdemo/viewmodel/LoginFragmentViewModel.kt
+++ b/app/src/main/kotlin/com/hyphenate/chatdemo/viewmodel/LoginFragmentViewModel.kt
@@ -33,12 +33,4 @@ class LoginFragmentViewModel(application: Application) : AndroidViewModel(applic
flow { emit(mRepository.loginToServer(result?.username!!, result.token!!, true)) }
}
- /**
- * Get verification code.
- */
- fun getVerificationCode(phoneNumber: String?) =
- flow {
- emit(mRepository.getVerificationCode(phoneNumber))
- }
-
}
diff --git a/app/src/main/res/layout/demo_fragment_login.xml b/app/src/main/res/layout/demo_fragment_login.xml
index 2403049..47e24fe 100644
--- a/app/src/main/res/layout/demo_fragment_login.xml
+++ b/app/src/main/res/layout/demo_fragment_login.xml
@@ -1,133 +1,158 @@
+ xmlns:tools="http://schemas.android.com/tools"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+ android:textColor="@color/ease_color_primary"
+ android:textSize="24sp"
+ app:layout_constraintBottom_toTopOf="@id/et_login_phone"
+ app:layout_constraintStart_toStartOf="@id/et_login_phone" />
+ android:textColor="@color/ease_color_text_primary"
+ app:layout_constraintEnd_toEndOf="@id/et_login_phone"
+ app:layout_constraintTop_toTopOf="@id/tv_login_im"
+ tools:text="V4.1.0" />
+ app:layout_constraintVertical_bias="0.3" />
+ app:layout_constraintBottom_toTopOf="@id/btn_login"
+ app:layout_constraintLeft_toLeftOf="parent"
+ app:layout_constraintRight_toRightOf="parent"
+ app:layout_constraintTop_toBottomOf="@id/et_login_phone">
+ android:textSize="@dimen/em_login_text_size" />
+ android:textColor="@color/ease_color_primary" />
+ app:layout_constraintTop_toBottomOf="@id/ll_login_code" />
+ android:textSize="12sp"
+ app:layout_constraintEnd_toStartOf="@id/tv_agreement"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@id/btn_login" />
+ android:textAppearance="@style/Ease.TextAppearance.Body.Small"
+ android:textColor="@color/ease_color_on_background"
+ android:textColorHighlight="@color/transparent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toEndOf="@id/cb_select"
+ app:layout_constraintTop_toBottomOf="@id/btn_login" />
+
+
+ app:layout_constraintLeft_toLeftOf="parent"
+ app:layout_constraintRight_toRightOf="parent"
+ app:layout_constraintTop_toBottomOf="@id/ll_login_code" />
+
+
+
\ No newline at end of file