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