Compare commits
5 Commits
ab0dd2fbf6
...
f154f30e2d
| Author | SHA1 | Date | |
|---|---|---|---|
| f154f30e2d | |||
| 7114e36165 | |||
| 195fb73620 | |||
| 655279a8a1 | |||
| 46385d5358 |
@@ -4,6 +4,26 @@ import android.app.Application
|
||||
import android.content.Context
|
||||
import com.blankj.utilcode.util.Utils
|
||||
import com.ckkj.defense_device.utils.LanguageUtil
|
||||
// _ooOoo_
|
||||
// o8888888o
|
||||
// 88" . "88
|
||||
// (| -_- |)
|
||||
// O\ = /O
|
||||
// ____/`---'\____
|
||||
// . ' \\| |// `.
|
||||
// / \\||| : |||// \
|
||||
// / _||||| -:- |||||- \
|
||||
// | | \\\ - /// | |
|
||||
// | \_| ''\---/'' | |
|
||||
// \ .-\__ `-` ___/-. /
|
||||
// ___`. .' /--.--\ `. . __
|
||||
// ."" '< `.___\_<|>_/___.' >'"".
|
||||
// | | : `- \`.;`\ _ /`;.`/ - ` : | |
|
||||
// \ \ `-. \_ __\ /__ _/ .-` / /
|
||||
// ======`-.____`-.___\_____/___.-`____.-'======
|
||||
// `=---='
|
||||
//
|
||||
// 佛祖保佑 永无BUG
|
||||
|
||||
class MyApplication : Application() {
|
||||
|
||||
|
||||
@@ -9,11 +9,11 @@ data class DeviceBean(
|
||||
val deviceFamilyId: String? = null,
|
||||
val deviceImg: String? = null,
|
||||
val deviceName: String? = null,//设备名称
|
||||
val deviceNo: String? = null,//设备编号
|
||||
val deviceSn: String? = null,//设备序列号
|
||||
val deviceNo: String = "",//设备编号
|
||||
val deviceSn: String = "",//设备序列号
|
||||
val endTime: String? = null,//自动布放结束时间
|
||||
val expirationTime: String? = null,
|
||||
val fwVer: String? = null,//固件版本
|
||||
val fwVer: String= "",//固件版本
|
||||
val id: String? = null,
|
||||
val light: String? = null,//强光开启
|
||||
val patternPerception: String? = null,//识别模式
|
||||
|
||||
@@ -21,6 +21,7 @@ import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.alibaba.sdk.android.emas.g
|
||||
import com.ckkj.defense_device.R
|
||||
import com.ckkj.defense_device.adapter.DeviceAdapter
|
||||
import com.ckkj.defense_device.adapter.FamilyAdapter
|
||||
@@ -465,7 +466,7 @@ class HomeMenuFragment : Fragment() {
|
||||
|
||||
isLoading = true
|
||||
|
||||
loadingDialog = if (showLoading) LoadingDialog(requireActivity()).setMessage("加载中...").apply { show() } else null
|
||||
loadingDialog = if (showLoading) LoadingDialog(requireActivity()).setMessage(getString(R.string.loading)).apply { show() } else null
|
||||
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
|
||||
@@ -5,11 +5,14 @@ import android.view.View
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.ckkj.defense_device.R
|
||||
import com.ckkj.defense_device.adapter.MessageListAdapter
|
||||
import com.ckkj.defense_device.adapter.MessageListItem
|
||||
import com.ckkj.defense_device.databinding.MessageActivityBinding
|
||||
import com.ckkj.defense_device.utils.MessageStatusManager
|
||||
import com.ckkj.defense_device.utils.RetrofitNetwork
|
||||
import com.ckkj.defense_device.view.LoadingDialog
|
||||
import com.ckkj.defense_device.view.PullRefreshLayout
|
||||
import com.gyf.immersionbar.ImmersionBar
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -19,13 +22,13 @@ import kotlinx.coroutines.withContext
|
||||
class MessageActivity : AppCompatActivity() {
|
||||
private lateinit var binding: MessageActivityBinding
|
||||
private val messageAdapter by lazy { MessageListAdapter() }
|
||||
private lateinit var loadingDialog: LoadingDialog
|
||||
|
||||
private var currentPage = 1
|
||||
private val pageSize = 10
|
||||
private var isLoading = false
|
||||
private var hasMoreData = true
|
||||
private val allMessages = mutableListOf<com.ckkj.defense_device.bean.MessageBean>()
|
||||
private var lastLoadedCount = 0
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -41,10 +44,19 @@ class MessageActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
private fun initView() {
|
||||
loadingDialog = LoadingDialog(this).setMessage(getString(R.string.loading))
|
||||
binding.ivBack.setOnClickListener { finish() }
|
||||
binding.rvMessageList.layoutManager = LinearLayoutManager(this)
|
||||
binding.rvMessageList.adapter = messageAdapter
|
||||
|
||||
binding.swipeRefreshLayout.setOnRefreshListener(object : PullRefreshLayout.OnRefreshListener {
|
||||
override fun onRefresh() {
|
||||
currentPage = 1
|
||||
hasMoreData = true
|
||||
loadMessageList()
|
||||
}
|
||||
})
|
||||
|
||||
binding.rvMessageList.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
||||
super.onScrolled(recyclerView, dx, dy)
|
||||
@@ -70,20 +82,21 @@ class MessageActivity : AppCompatActivity() {
|
||||
if (isLoading) return
|
||||
|
||||
isLoading = true
|
||||
|
||||
if (currentPage == 1) {
|
||||
showLoading()
|
||||
}
|
||||
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
val messageResponse = RetrofitNetwork.getNetworkService().getMessage(
|
||||
pageNum = currentPage,
|
||||
pageSize = pageSize
|
||||
)
|
||||
println("total: ${messageResponse.total}")
|
||||
println("rows size: ${messageResponse.rows.size}")
|
||||
println("当前页:$currentPage, 加载条数:${messageResponse.rows.size}")
|
||||
|
||||
val newMessages = messageResponse.rows
|
||||
val loadedCount = newMessages.size
|
||||
|
||||
if (loadedCount < pageSize) {
|
||||
if (newMessages.size < pageSize) {
|
||||
hasMoreData = false
|
||||
}
|
||||
|
||||
@@ -98,11 +111,11 @@ class MessageActivity : AppCompatActivity() {
|
||||
allMessages.clear()
|
||||
}
|
||||
allMessages.addAll(newMessages)
|
||||
lastLoadedCount = loadedCount
|
||||
|
||||
val items = convertToMessageListItems(allMessages)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
hideLoading()
|
||||
hideEmptyState()
|
||||
messageAdapter.submitList(items)
|
||||
|
||||
@@ -116,12 +129,10 @@ class MessageActivity : AppCompatActivity() {
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
if (allMessages.isEmpty()) {
|
||||
println("items is empty, showing empty state")
|
||||
showEmptyState()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("Exception: ${e.message}")
|
||||
e.printStackTrace()
|
||||
withContext(Dispatchers.Main) {
|
||||
if (allMessages.isEmpty()) {
|
||||
@@ -130,6 +141,12 @@ class MessageActivity : AppCompatActivity() {
|
||||
}
|
||||
} finally {
|
||||
isLoading = false
|
||||
if (currentPage == 1) {
|
||||
withContext(Dispatchers.Main) {
|
||||
hideLoading()
|
||||
binding.swipeRefreshLayout.finishRefresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,19 +158,31 @@ class MessageActivity : AppCompatActivity() {
|
||||
loadMessageList()
|
||||
}
|
||||
|
||||
private fun showLoading() {
|
||||
binding.swipeRefreshLayout.visibility = View.GONE
|
||||
binding.emptyLayout.visibility = View.GONE
|
||||
binding.layoutNoNetwork.visibility = View.GONE
|
||||
loadingDialog.show()
|
||||
}
|
||||
|
||||
private fun hideLoading() {
|
||||
binding.swipeRefreshLayout.visibility = View.VISIBLE
|
||||
loadingDialog.dismiss()
|
||||
}
|
||||
|
||||
private fun showEmptyState() {
|
||||
binding.rvMessageList.visibility = View.GONE
|
||||
binding.swipeRefreshLayout.visibility = View.GONE
|
||||
binding.emptyLayout.visibility = View.VISIBLE
|
||||
binding.tvNoMoreData.visibility = View.GONE
|
||||
}
|
||||
|
||||
private fun hideEmptyState() {
|
||||
binding.rvMessageList.visibility = View.VISIBLE
|
||||
binding.swipeRefreshLayout.visibility = View.VISIBLE
|
||||
binding.emptyLayout.visibility = View.GONE
|
||||
}
|
||||
|
||||
private fun noNetwork() {
|
||||
binding.rvMessageList.visibility = View.GONE
|
||||
binding.swipeRefreshLayout.visibility = View.GONE
|
||||
binding.layoutNoNetwork.visibility = View.VISIBLE
|
||||
binding.tvNoMoreData.visibility = View.GONE
|
||||
}
|
||||
@@ -171,9 +200,6 @@ class MessageActivity : AppCompatActivity() {
|
||||
val groupedMessages = messages.groupBy { extractDate(it.createTime ?: "") }
|
||||
groupedMessages.forEach { (date, messageList) ->
|
||||
val headerTitle = formatDate(date)
|
||||
messageList.forEachIndexed { index, message ->
|
||||
println(" 消息${index + 1}: id=${message.id}, categoryName=${message.categoryName}, createTime=${message.createTime}")
|
||||
}
|
||||
items.add(MessageListItem.Header(headerTitle))
|
||||
messageList.forEach { message ->
|
||||
items.add(
|
||||
|
||||
@@ -25,6 +25,7 @@ import com.ckkj.defense_device.utils.ActivityManager
|
||||
import com.ckkj.defense_device.view.CustomDialogFragmentText
|
||||
import com.ckkj.defense_device.view.FamilySwitchDialog
|
||||
import com.ckkj.defense_device.view.LanguageDialog
|
||||
import com.ckkj.defense_device.view.LoadingDialog
|
||||
import com.gyf.immersionbar.ImmersionBar
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -35,6 +36,7 @@ class PersonalFragment : Fragment() {
|
||||
// ViewBinding
|
||||
private var _binding: PersonalFragmentBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
private var loadingDialog: LoadingDialog? = null
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
fetchUserInfo()
|
||||
@@ -234,6 +236,7 @@ class PersonalFragment : Fragment() {
|
||||
|
||||
|
||||
private fun fetchUserInfo() {
|
||||
loadingDialog = LoadingDialog(requireContext()).setMessage(getString(R.string.loading)).apply { show() }
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
val response = RetrofitNetwork.getNetworkService().getUserInfo()
|
||||
@@ -244,6 +247,11 @@ class PersonalFragment : Fragment() {
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
} finally {
|
||||
withContext(Dispatchers.Main) {
|
||||
loadingDialog?.dismiss()
|
||||
loadingDialog = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,8 +55,8 @@ class DeviceInfoActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
private fun updateDeviceInfo(device: com.ckkj.defense_device.bean.DeviceBean) {
|
||||
binding.tvDeviceModelValue.text = device.deviceNo.toString()
|
||||
binding.tvDeviceNoValue.text = device.deviceSn.toString()
|
||||
binding.tvFwVersionValue.text = device.fwVer.toString()
|
||||
binding.tvDeviceModelValue.text = device.deviceNo?.toString()?: ""
|
||||
binding.tvDeviceNoValue.text = device.deviceSn?.toString() ?: ""
|
||||
binding.tvFwVersionValue.text = device.fwVer?.toString()?: ""
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import com.ckkj.defense_device.utils.DeviceIdEvent
|
||||
import com.ckkj.defense_device.utils.DeviceUserUpdateEvent
|
||||
import com.ckkj.defense_device.utils.RetrofitNetwork
|
||||
import com.ckkj.defense_device.view.CustomDialogFragmentText
|
||||
import com.ckkj.defense_device.view.PullRefreshLayout
|
||||
import com.gyf.immersionbar.ImmersionBar
|
||||
import com.yanzhenjie.recyclerview.SwipeMenuItem
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -95,9 +96,11 @@ class LinkedUserActivity : AppCompatActivity() {
|
||||
|
||||
(binding.rvLinkedUser as RecyclerView).layoutManager = LinearLayoutManager(this)
|
||||
|
||||
binding.swipeRefresh.setOnRefreshListener {
|
||||
binding.swipeRefresh.setOnRefreshListener(object : PullRefreshLayout.OnRefreshListener {
|
||||
override fun onRefresh() {
|
||||
fetchUserList()
|
||||
}
|
||||
})
|
||||
|
||||
binding.rvLinkedUser.setSwipeMenuCreator { leftMenu, rightMenu, position ->
|
||||
if (mode == MODE_SETTINGS) {
|
||||
@@ -229,7 +232,6 @@ class LinkedUserActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
private fun fetchUserList() {
|
||||
binding.swipeRefresh.isRefreshing = true
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
var deviceId: String? = null
|
||||
@@ -238,7 +240,7 @@ class LinkedUserActivity : AppCompatActivity() {
|
||||
if (deviceId.isNullOrEmpty()) {
|
||||
Toast.makeText(this@LinkedUserActivity, "设备 ID 为空", Toast.LENGTH_SHORT).show()
|
||||
withContext(Dispatchers.Main) {
|
||||
binding.swipeRefresh.isRefreshing = false
|
||||
binding.swipeRefresh.finishRefresh()
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
@@ -249,7 +251,7 @@ class LinkedUserActivity : AppCompatActivity() {
|
||||
RetrofitNetwork.getNetworkService().getFamilyMembers()
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
binding.swipeRefresh.isRefreshing = false
|
||||
binding.swipeRefresh.finishRefresh()
|
||||
if (response.code == 200 && response.data != null) {
|
||||
if (mode == MODE_SETTINGS) {
|
||||
updateDeviceUserList(response.data as List<DeviceUserBean>)
|
||||
@@ -264,7 +266,7 @@ class LinkedUserActivity : AppCompatActivity() {
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
withContext(Dispatchers.Main) {
|
||||
binding.swipeRefresh.isRefreshing = false
|
||||
binding.swipeRefresh.finishRefresh()
|
||||
noNetwork()
|
||||
Toast.makeText(this@LinkedUserActivity, R.string.network_request_failed, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ class UserDetailsActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
private fun updateUI(data: com.ckkj.defense_device.bean.ObtainInviterInformationBean) {
|
||||
binding.tvContactName.text = data.nickName ?: "未知用户"
|
||||
binding.tvContactName.text = data.nickName ?: ""
|
||||
binding.tvPhone.text = data.phonenumber ?: ""
|
||||
binding.tvIdCard.text = data.idCard ?: ""
|
||||
binding.tvMacAddress.text = data.mac ?: ""
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.ckkj.defense_device.databinding.SettingsMenusActivityBinding
|
||||
import com.gyf.immersionbar.ImmersionBar
|
||||
import java.io.File
|
||||
|
||||
class SettingsMenusActivity : AppCompatActivity() {
|
||||
private lateinit var binding: SettingsMenusActivityBinding
|
||||
@@ -25,6 +26,8 @@ class SettingsMenusActivity : AppCompatActivity() {
|
||||
|
||||
val versionName = packageManager.getPackageInfo(packageName, 0).versionName
|
||||
binding.tvVersion.text = versionName
|
||||
// 显示缓存大小
|
||||
displayCacheSize()
|
||||
|
||||
setupClickListeners()
|
||||
}
|
||||
@@ -42,8 +45,9 @@ class SettingsMenusActivity : AppCompatActivity() {
|
||||
|
||||
// 清除缓存
|
||||
binding.itemClearCache.setOnClickListener {
|
||||
clearCache()
|
||||
displayCacheSize() // 更新缓存大小显示
|
||||
Toast.makeText(this, "缓存已清除", Toast.LENGTH_SHORT).show()
|
||||
binding.tvCacheSize.text = "0 KB"
|
||||
}
|
||||
|
||||
// 用户协议
|
||||
@@ -62,4 +66,46 @@ class SettingsMenusActivity : AppCompatActivity() {
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
//缓存方法
|
||||
private fun displayCacheSize() {
|
||||
val cacheSize = getCacheSize()
|
||||
val sizeString = when {
|
||||
cacheSize >= 1024 * 1024 -> String.format("%.2f MB", cacheSize / (1024.0 * 1024.0))
|
||||
else -> String.format("%d KB", cacheSize / 1024)
|
||||
}
|
||||
binding.tvCacheSize.text = sizeString
|
||||
}
|
||||
|
||||
private fun getCacheSize(): Long {
|
||||
val cacheDir = cacheDir
|
||||
return getFolderSize(cacheDir)
|
||||
}
|
||||
|
||||
private fun getFolderSize(dir: File?): Long {
|
||||
var size = 0L
|
||||
if (dir != null && dir.isDirectory) {
|
||||
dir.listFiles()?.forEach { file ->
|
||||
size += if (file.isFile) {
|
||||
file.length()
|
||||
} else {
|
||||
getFolderSize(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
private fun clearCache() {
|
||||
val cacheDir = cacheDir
|
||||
deleteDir(cacheDir)
|
||||
}
|
||||
|
||||
private fun deleteDir(dir: File?): Boolean {
|
||||
if (dir != null && dir.isDirectory) {
|
||||
dir.listFiles()?.forEach { file ->
|
||||
deleteDir(file)
|
||||
}
|
||||
}
|
||||
return dir?.delete() ?: false
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,25 @@ import retrofit2.http.POST
|
||||
import retrofit2.http.PUT
|
||||
import retrofit2.http.Query
|
||||
|
||||
// ┏┓ ┏┓
|
||||
//┏┛┻━━━┛┻┓
|
||||
//┃ ┃
|
||||
//┃ ━ ┃
|
||||
//┃ ┳┛ ┗┳ ┃
|
||||
//┃ ┃
|
||||
//┃ ┻ ┃
|
||||
//┃ ┃
|
||||
//┗━┓ ┏━┛
|
||||
// ┃ ┃
|
||||
// ┃ ┃
|
||||
// ┃ ┗━━━┓
|
||||
// ┃ ┣┓
|
||||
// ┃ ┏┛
|
||||
// ┗┓┓┏━┳┓┏┛
|
||||
// ┃┫┫ ┃┫┫
|
||||
// ┗┻┛ ┗┻┛
|
||||
//神兽护体,代码零Bug,需求不改,一次编译通过
|
||||
|
||||
interface ApiService {
|
||||
/**
|
||||
* 获取登录验证码
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.ckkj.defense_device.utils
|
||||
|
||||
import com.ckkj.defense_device.utils.crypto.CryptoInterceptor
|
||||
import com.ckkj.defense_device.utils.crypto.CryptoKeyManager
|
||||
import com.google.gson.Gson
|
||||
import okhttp3.OkHttpClient
|
||||
import retrofit2.Retrofit
|
||||
@@ -65,12 +67,39 @@ object RetrofitNetwork {
|
||||
.readTimeout(DEFAULT_TIMEOUT, TimeUnit.SECONDS)
|
||||
.writeTimeout(DEFAULT_TIMEOUT, TimeUnit.SECONDS)
|
||||
.addInterceptor(ApiConfig.createHeadersInterceptor())
|
||||
.addInterceptor(ApiConfig.getUnifiedInterceptor()) // 使用 ApiConfig 中的统一拦截器
|
||||
.addInterceptor(ApiConfig.getUnifiedInterceptor())
|
||||
|
||||
// 加密拦截器(密钥就绪即启用,按开关自动判断)
|
||||
if (CryptoKeyManager.isCryptoEnabled && CryptoKeyManager.isKeyConfigured()) {
|
||||
builder.addInterceptor(getCryptoInterceptor())
|
||||
}
|
||||
|
||||
if (enableLogging) {
|
||||
builder.addInterceptor(ApiConfig.getLoggingInterceptor()) // 使用 ApiConfig 中的日志拦截器
|
||||
builder.addInterceptor(ApiConfig.getLoggingInterceptor())
|
||||
}
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取加密拦截器实例
|
||||
* 每次都重新创建,确保使用最新密钥
|
||||
*/
|
||||
private fun getCryptoInterceptor(): CryptoInterceptor {
|
||||
// 清除旧实例,确保使用最新密钥
|
||||
CryptoInterceptor.clearInstance()
|
||||
return CryptoInterceptor.getInstance(
|
||||
publicKey = CryptoKeyManager.requestPublicKey,
|
||||
privateKey = CryptoKeyManager.responsePrivateKey
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新初始化网络客户端(密钥更新后调用)
|
||||
*/
|
||||
fun resetClient() {
|
||||
retrofit = null
|
||||
// 强制重新创建 Retrofit
|
||||
getNetworkService()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.ckkj.defense_device.utils.crypto
|
||||
|
||||
import android.util.Base64
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
/**
|
||||
* AES 加密工具类
|
||||
*
|
||||
* 算法:AES/ECB/PKCS5Padding (Android) 与 AES/ECB/PKCS7Padding (iOS) 兼容
|
||||
*/
|
||||
object AESUtil {
|
||||
|
||||
private const val ALGORITHM = "AES"
|
||||
private const val TRANSFORMATION = "AES/ECB/PKCS5Padding"
|
||||
|
||||
/**
|
||||
* AES ECB 模式加密
|
||||
*
|
||||
* @param key 32字节 ASCII 字符串密钥
|
||||
* @param data UTF-8 编码的明文字节数组
|
||||
* @return Base64 编码的密文字节数组
|
||||
*/
|
||||
fun encrypt(key: String, data: ByteArray): ByteArray {
|
||||
val keySpec = SecretKeySpec(key.toByteArray(Charsets.UTF_8), ALGORITHM)
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, keySpec)
|
||||
return cipher.doFinal(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* AES ECB 模式加密,返回 Base64 字符串
|
||||
*
|
||||
* @param key 32字节 ASCII 字符串密钥
|
||||
* @param plainText UTF-8 明文字符串
|
||||
* @return Base64 编码的密文字符串
|
||||
*/
|
||||
fun encrypt(key: String, plainText: String): String {
|
||||
val encrypted = encrypt(key, plainText.toByteArray(Charsets.UTF_8))
|
||||
return Base64.encodeToString(encrypted, Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
/**
|
||||
* AES ECB 模式解密
|
||||
*
|
||||
* @param key 32字节 ASCII 字符串密钥
|
||||
* @param encryptedData Base64 编码的密文字节数组
|
||||
* @return UTF-8 解密后的明文字节数组
|
||||
*/
|
||||
fun decrypt(key: String, encryptedData: ByteArray): ByteArray {
|
||||
val keySpec = SecretKeySpec(key.toByteArray(Charsets.UTF_8), ALGORITHM)
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(Cipher.DECRYPT_MODE, keySpec)
|
||||
return cipher.doFinal(encryptedData)
|
||||
}
|
||||
|
||||
/**
|
||||
* AES ECB 模式解密,返回 UTF-8 字符串
|
||||
*
|
||||
* @param key 32字节 ASCII 字符串密钥
|
||||
* @param encryptedText Base64 编码的密文字符串
|
||||
* @return UTF-8 解密后的明文字符串
|
||||
*/
|
||||
fun decrypt(key: String, encryptedText: String): String {
|
||||
val encryptedData = Base64.decode(encryptedText, Base64.NO_WRAP)
|
||||
val decrypted = decrypt(key, encryptedData)
|
||||
return String(decrypted, Charsets.UTF_8)
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机 AES 密钥
|
||||
*
|
||||
* @param length 密钥长度,默认 32 字节
|
||||
* @return 随机 ASCII 字符串密钥
|
||||
*/
|
||||
fun generateKey(length: Int = 32): String {
|
||||
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
return (1..length)
|
||||
.map { chars.random() }
|
||||
.joinToString("")
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 编码
|
||||
*/
|
||||
fun base64Encode(data: ByteArray): String {
|
||||
return Base64.encodeToString(data, Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 解码
|
||||
*/
|
||||
fun base64Decode(encoded: String): ByteArray {
|
||||
return Base64.decode(encoded, Base64.NO_WRAP)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
package com.ckkj.defense_device.utils.crypto
|
||||
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import com.google.gson.JsonParser
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import java.nio.charset.Charset
|
||||
|
||||
/**
|
||||
* 加密拦截器
|
||||
*
|
||||
* 实现 App 请求加密和服务端响应解密
|
||||
*
|
||||
* 加密流程:
|
||||
* 1. 生成随机 AES 密钥 (32字节 ASCII)
|
||||
* 2. 用 RSA 公钥加密 AES 密钥
|
||||
* 3. 用 AES 加密请求体
|
||||
* 4. 设置请求头 encrypt-key 为 RSA 加密后的 AES 密钥
|
||||
* 5. 请求体为 AES 加密后的 Base64 字符串
|
||||
*
|
||||
* 解密流程(仅需要对响应解密的接口):
|
||||
* 1. 从响应头获取 encrypt-key
|
||||
* 2. 用 RSA 私钥解密得到 AES 密钥
|
||||
* 3. 用 AES 解密响应体
|
||||
*/
|
||||
class CryptoInterceptor private constructor(
|
||||
private val publicKey: String,
|
||||
private val privateKey: String
|
||||
) : Interceptor {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "CryptoInterceptor"
|
||||
private const val HEADER_ENCRYPT_KEY = "encrypt-key"
|
||||
private const val HEADER_IS_ENCRYPT = "isEncrypt"
|
||||
private const val CONTENT_TYPE_JSON = "application/json; charset=utf-8"
|
||||
|
||||
// 需要加密的接口列表(POST/PUT 请求)
|
||||
private val ENCRYPT_ENDPOINTS = setOf(
|
||||
"app/login",
|
||||
"app/register",
|
||||
"app/forgot",
|
||||
"app/switchFamily",
|
||||
"app/addDevice",
|
||||
"app/updateDeviceInfo",
|
||||
"app/deleteDevice",
|
||||
"app/addDeviceUser",
|
||||
"app/updateUserInfo",
|
||||
"app/forgotPassword",
|
||||
"app/retrievePassword",
|
||||
"app/verifyOptionPassword",
|
||||
"app/obtainInviterInformation",
|
||||
"app/selectRecommendationCode",
|
||||
"app/generateRecommendationCode",
|
||||
"app/revokeInvitation",
|
||||
"app/updateFamilyUser",
|
||||
"app/selectInviterInfo",
|
||||
"app/joinTheFamily",
|
||||
"app/stream/start"
|
||||
)
|
||||
|
||||
// 需要解密响应的接口列表(默认与加密列表对称 —— 加密请求的接口响应也会被加密)
|
||||
// 额外加上 GET 接口(GET 无 body 不加密请求,但后端仍可能加密响应)
|
||||
private val DECRYPT_RESPONSE_ENDPOINTS: Set<String> = ENCRYPT_ENDPOINTS + setOf(
|
||||
"app/selectFamily", // 获取家庭列表(GET)
|
||||
"app/getdeviceList", // 获取设备列表(GET)
|
||||
"app/deviceInfo", // 获取设备信息(GET)
|
||||
"app/appMessage/list", // 消息列表(GET)
|
||||
"app/appMessageInfo", // 告警消息详情(GET)
|
||||
"app/appMessage/deviceMeslist", // 设备告警日志(GET)
|
||||
"app/familyMembers/list", // 家庭成员列表(GET)
|
||||
"app/deviceUser/list", // 设备关联用户列表(GET)
|
||||
"app/userInfo", // 当前用户信息(GET)
|
||||
"app/deleteMessage" // 删除消息(DELETE)
|
||||
)
|
||||
|
||||
@Volatile
|
||||
private var instance: CryptoInterceptor? = null
|
||||
|
||||
fun getInstance(publicKey: String, privateKey: String): CryptoInterceptor {
|
||||
return instance ?: synchronized(this) {
|
||||
instance ?: CryptoInterceptor(publicKey, privateKey).also {
|
||||
instance = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearInstance() {
|
||||
instance = null
|
||||
}
|
||||
|
||||
fun isEncryptEndpoint(url: String): Boolean {
|
||||
return ENCRYPT_ENDPOINTS.any { url.contains(it, ignoreCase = true) }
|
||||
}
|
||||
|
||||
fun isDecryptEndpoint(url: String): Boolean {
|
||||
return DECRYPT_RESPONSE_ENDPOINTS.any { url.contains(it, ignoreCase = true) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val url = request.url.toString()
|
||||
|
||||
// 检查是否需要加密
|
||||
val needsEncrypt = isEncryptEndpoint(url)
|
||||
val needsDecrypt = isDecryptEndpoint(url)
|
||||
|
||||
if (!needsEncrypt && !needsDecrypt) {
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
// 调试日志
|
||||
Log.d(TAG, "=== CryptoInterceptor Debug ===")
|
||||
Log.d(TAG, "URL: $url")
|
||||
Log.d(TAG, "needsEncrypt: $needsEncrypt, needsDecrypt: $needsDecrypt")
|
||||
Log.d(TAG, "PublicKey available: ${publicKey.isNotBlank()}, length: ${publicKey.length}")
|
||||
Log.d(TAG, "PrivateKey available: ${privateKey.isNotBlank()}, length: ${privateKey.length}")
|
||||
|
||||
// 加密请求
|
||||
val encryptedRequest = if (needsEncrypt) {
|
||||
encryptRequest(request)
|
||||
} else {
|
||||
request
|
||||
}
|
||||
|
||||
// 发起请求
|
||||
val response = try {
|
||||
chain.proceed(encryptedRequest)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Request failed", e)
|
||||
throw e
|
||||
}
|
||||
|
||||
// 解密响应
|
||||
return if (needsDecrypt) {
|
||||
decryptResponse(response, request)
|
||||
} else {
|
||||
response
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密请求
|
||||
*/
|
||||
private fun encryptRequest(request: Request): Request {
|
||||
val requestBody = request.body ?: return request
|
||||
|
||||
// 只处理 JSON 请求
|
||||
val contentType = requestBody.contentType()?.toString() ?: ""
|
||||
if (!contentType.contains("application/json")) {
|
||||
return request
|
||||
}
|
||||
|
||||
// 只处理 POST 和 PUT
|
||||
val method = request.method
|
||||
if (method != "POST" && method != "PUT") {
|
||||
return request
|
||||
}
|
||||
|
||||
// 读取原始请求体
|
||||
val buffer = okio.Buffer()
|
||||
requestBody.writeTo(buffer)
|
||||
val originalBody = buffer.readString(Charset.defaultCharset())
|
||||
Log.d(TAG, "Original request body: $originalBody")
|
||||
|
||||
// 生成随机 AES 密钥
|
||||
val aesKey = AESUtil.generateKey(32)
|
||||
Log.d(TAG, "Generated AES key: $aesKey")
|
||||
|
||||
// 1. keyBase64 = Base64(UTF8(aesKey))
|
||||
val keyBase64 = AESUtil.base64Encode(aesKey.toByteArray(Charsets.UTF_8))
|
||||
Log.d(TAG, "keyBase64: $keyBase64")
|
||||
|
||||
// 2. encrypt-key = RSA-PKCS1-Encrypt(请求公钥, UTF8(keyBase64))
|
||||
// encryptByPublicKey(String, String) 已返回 Base64 编码结果
|
||||
val encryptedKeyBase64 = RSAUtil.encryptByPublicKey(publicKey, keyBase64)
|
||||
Log.d(TAG, "Encrypted key (Base64): $encryptedKeyBase64")
|
||||
|
||||
// 3. body = AES-ECB-PKCS5-Encrypt(aesKey, UTF8(JSON))
|
||||
// encrypt(String, String) 已返回 Base64 编码结果
|
||||
val encryptedBodyBase64 = AESUtil.encrypt(aesKey, originalBody)
|
||||
Log.d(TAG, "Encrypted body (Base64): $encryptedBodyBase64")
|
||||
|
||||
// 构建新请求(根据原方法选择 POST 或 PUT)
|
||||
val newRequestBuilder = request.newBuilder()
|
||||
.header(HEADER_ENCRYPT_KEY, encryptedKeyBase64)
|
||||
.header(HEADER_IS_ENCRYPT, "true")
|
||||
|
||||
return if (method == "PUT") {
|
||||
newRequestBuilder.put(encryptedBodyBase64.toRequestBody(CONTENT_TYPE_JSON.toMediaTypeOrNull()))
|
||||
} else {
|
||||
newRequestBuilder.post(encryptedBodyBase64.toRequestBody(CONTENT_TYPE_JSON.toMediaTypeOrNull()))
|
||||
}.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密响应
|
||||
*
|
||||
* 兼容三种后端响应形态:
|
||||
* 1. 普通 JSON(无 encrypt-key 头)→ 原样放行
|
||||
* 2. 整 body 加密(响应头有 encrypt-key 且 body 不是合法 JSON)→ 整体 AES 解密
|
||||
* 3. 嵌套加密(响应头有 encrypt-key,body 是 {code,msg,data:"加密串"})
|
||||
* → 只解 data 字段,保持外层 JSON 结构(与 iOS 端一致)
|
||||
*/
|
||||
private fun decryptResponse(response: Response, originalRequest: Request): Response {
|
||||
val responseBody = response.body ?: return response
|
||||
|
||||
val encryptedKeyBase64 = response.header(HEADER_ENCRYPT_KEY)
|
||||
if (encryptedKeyBase64.isNullOrBlank()) {
|
||||
return response
|
||||
}
|
||||
|
||||
val rawBody: String = try {
|
||||
responseBody.source().apply { request(Long.MAX_VALUE) }
|
||||
.buffer.clone().readString(Charset.defaultCharset())
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Read response body failed", e)
|
||||
return response
|
||||
}
|
||||
|
||||
// 先拿到 AES 密钥(RSA 私钥解密 encrypt-key 头)
|
||||
val aesKey = try {
|
||||
val aesKeyBase64 = RSAUtil.decryptByPrivateKeyBase64(privateKey, encryptedKeyBase64)
|
||||
String(Base64.decode(aesKeyBase64, Base64.NO_WRAP), Charsets.UTF_8)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "RSA decrypt encrypt-key failed", e)
|
||||
return response
|
||||
}
|
||||
|
||||
// 形态 3:嵌套加密 —— 响应是 JSON,data 字段是加密串
|
||||
val trimmed = rawBody.trim()
|
||||
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
|
||||
try {
|
||||
val jsonElement = JsonParser().parse(trimmed)
|
||||
if (jsonElement.isJsonObject) {
|
||||
val obj = jsonElement.asJsonObject
|
||||
if (obj.has("data") && obj.get("data").isJsonPrimitive
|
||||
&& obj.get("data").asJsonPrimitive.isString
|
||||
) {
|
||||
val encryptedData = obj.get("data").asString
|
||||
val decryptedData = AESUtil.decrypt(aesKey, encryptedData)
|
||||
// 把 data 字段替换为解密后的 JSON 字符串(让 Gson 当成对象继续解析)
|
||||
obj.add("data", parseDataAsJsonElement(decryptedData))
|
||||
val newBody = obj.toString().toResponseBody(CONTENT_TYPE_JSON.toMediaTypeOrNull())
|
||||
return response.newBuilder()
|
||||
.body(newBody)
|
||||
.removeHeader(HEADER_ENCRYPT_KEY)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Nested decrypt failed, fallback to whole-body decrypt", e)
|
||||
}
|
||||
}
|
||||
|
||||
// 形态 2:整 body 加密
|
||||
return try {
|
||||
val decryptedBody = AESUtil.decrypt(aesKey, rawBody)
|
||||
response.newBuilder()
|
||||
.body(decryptedBody.toResponseBody(CONTENT_TYPE_JSON.toMediaTypeOrNull()))
|
||||
.removeHeader(HEADER_ENCRYPT_KEY)
|
||||
.build()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Whole-body decrypt failed, return raw response", e)
|
||||
response
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把解密后的字符串解析成 JsonElement(如果是合法 JSON);否则当成普通字符串返回
|
||||
*/
|
||||
private fun parseDataAsJsonElement(text: String): com.google.gson.JsonElement {
|
||||
val s = text.trim()
|
||||
return try {
|
||||
if ((s.startsWith("{") && s.endsWith("}")) ||
|
||||
(s.startsWith("[") && s.endsWith("]"))
|
||||
) {
|
||||
JsonParser().parse(s)
|
||||
} else {
|
||||
com.google.gson.JsonPrimitive(s)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
com.google.gson.JsonPrimitive(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 扩展函数:String 转 RequestBody
|
||||
private fun String.toRequestBody(mediaType: okhttp3.MediaType?): okhttp3.RequestBody {
|
||||
return okhttp3.RequestBody.create(mediaType, this)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package com.ckkj.defense_device.utils.crypto
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.blankj.utilcode.util.SPUtils
|
||||
import com.ckkj.defense_device.utils.UserinfoUtil
|
||||
|
||||
/**
|
||||
* 密钥管理类
|
||||
*
|
||||
* 管理 RSA 加密所需的公钥和私钥
|
||||
*
|
||||
* 密钥关系:
|
||||
* ┌─────────────┬──────────────────┬──────────────────┐
|
||||
* │ 方向 │ App 持有 │ 服务端持有 │
|
||||
* ├─────────────┼──────────────────┼──────────────────┤
|
||||
* │ App请求服务端 │ 请求公钥 (RSA-P) │ 请求私钥 (RSA-S) │
|
||||
* ├─────────────┼──────────────────┼──────────────────┤
|
||||
* │ 服务端响应App │ 响应私钥 (RSA-S) │ 响应公钥 (RSA-P) │
|
||||
* └─────────────┴──────────────────┴──────────────────┘
|
||||
*
|
||||
* 需要生成两套独立 RSA 2048/3072 密钥对
|
||||
*/
|
||||
object CryptoKeyManager {
|
||||
|
||||
private const val TAG = "CryptoKeyManager"
|
||||
|
||||
// SPUtils 用于持久化密钥配置
|
||||
private val spUtils: SPUtils by lazy {
|
||||
SPUtils.getInstance("crypto_keys")
|
||||
}
|
||||
|
||||
// 配置项 Keys
|
||||
private const val KEY_REQUEST_PUBLIC_KEY = "request_public_key"
|
||||
private const val KEY_RESPONSE_PRIVATE_KEY = "response_private_key"
|
||||
private const val KEY_CRYPTO_ENABLED = "crypto_enabled"
|
||||
|
||||
// 配置项默认值
|
||||
// 注意:这些密钥必须与后端配置配对使用
|
||||
// App端公钥 = 后端私钥(App加密请求,后端解密)
|
||||
// App端私钥 = 后端公钥(后端加密响应,App解密)
|
||||
private const val DEFAULT_PUBLIC_KEY = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtVIQm+1AWMfkT1uVOyugKQwBeHtgpulABOrVfaL7Xzc1/rKSdLdjYZLZnnOU+rdE1jqz20MXDBozK22spH/j53d9nFLftHkMCRUlfc0MWEqtSla+2MdzElIHEbq4CSizZOcT8QXCRw7V2OuDqnC7XRQmnpA5Ks1WaKH5V0aoKWztjsqZV6vVWPqyDC94mvu/Q/9e6KVfkqAzR+afuo9OoBFewDGQ6jfqyu1TxoGZrfSr5lXOVeADf0CVfWCDze5y4f7er4JjPHWA2+yv8Scd9bqVJ4Q+/yR2Gd516Mi11OgBKc8GdkuA4/kfmXc7n91SMjgQP6DlTVuPc/6KnB/pyQIDAQAB"
|
||||
private const val DEFAULT_PRIVATE_KEY = "MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDYg1dC9XDMRtKJ5IFj+Hd3bq7+r2YL1y8FM9HD111PN/YVy4K6O67cRA/TLG7+tl6eYiA73f5lzPgv1riOwBPSCmvVopxq2KiJs30uEnug0qGp6l2cwQOmjitFAQF8dOtAl4m6EM/jdXCZYbQrthJ6n3tR+vGj2IrGSJEjPrj3VRljK9I7Zw0Be0rhJn/vZw2CA4GoVfE/3gqa5bpl+3SFxMP7TwF5uZueCNqLaPnd7jK5jB1SUghI7+pq897Ng4EG6kdOGsiQe5kbHWkSPTmPEObMwgLJ18i/XCVg+LUkTDJOC/MAfB+WGcKryNHrhcbhB6+XstpoGT8JlOPOgh0BAgMBAAECggEBALZG7MH8bDguL7XTYHNPjRRJZJ4aAGlbgcR5edHMkEvPKyfyK16qPG5IBqKcN93Mnx/sMIL2Q+RkiVWNCdd0MbuU9m/m9JSnTkyPhYWyHc6pRV0NaD496Nrhud/gFuY2cI/yhArXeI1gI6mdrddW83u0pFfCUojEFyETmsz3UzU1LMkVbwCXNbZ1b3GfcVnONMn8T/7CMlRPlKyOuE0OPnTut4wddAybe5BKDQYnGzuosUdO+Q+2K3hqaTD6Bb33D4ATHjvR3Vt9itE07BiaBFjZkE2odP4x3fSsnc7Cw3A7zHQivpfgqcoBcGUaMcN1FdgpaF2mbe9/eHn/lYCpevkCgYEA4FoKq3fPOITAtA6bjwCHXvIQUjv4vQ0BZtt7OX7zwpRwqOhJfN0ZRkd7NMUqYnB10N9NgPRz0kKC6qff5duN+zkYIzjHrlm5lzdIEAouovRlrGXST4n8gAYteh5D5Or9wRXj7USQoaNyiWKB9A/07ortJvul09sPjJU7IVfzzXMCgYEA9w45Gh2bAvYvm2bWieKZRhcV6Pwy9R8587qmKLC7V/0gfVojBHx7xTmTEfKCGrt3fK4jB5v/2Dar8gDF+RM7g01u0YuFJt17jeFr/4o5KMimfXhoEulDbgjfvhxQOrtkrF5iHpKxiEXqIPiH+B7155PAnE43011AAJCBSylmTrsCgYEAkxr3PA9HFKwXHvklDtMt8BeQlBs2sd9BOAxZ9A1GECP86wPEi9b9p5NfOe6+J+XNmrOQwimHeCqcZPjGWpVnt35sUUv9wlia1Igu/DVw9vCBalUpXXYA1oE2eIg3xHZBBMYxuXXnz9S4WVT6GOoNlAwMDC+dQBi3TVrcdrSQ6/kCgYBA254ICh+ovmKvJGdMGY3thZ/94z+pdEIthyGZ6xOzvMMrxV8ODXQcycmfW4/mXrK2q6yMkdqvs2KejK9sfS0RgmGGZ19UXa7TB4vnsSziVRLIO7TuyggmufOrIBm74XhDfB+8MPykbt5RO43OiKo72mElZ69mMMPdohIfXkX6gwKBgH4L9qJRhNunWSfHO4/8dST+2meSV+Cpqf0b/9cV6v5FMS22m+szzW9XoBXnI9dbAqiEDlCHCLw34pRlC3OzBNlbT6HzNmYXLko2Ttg9thiDq7WS5tV+EHJRP6kLabYGq0Aghtq+6LIwwogbHzhRi8Q52o3CNmM9lThNWZIkzaBe"
|
||||
/**
|
||||
* 是否启用加密
|
||||
*/
|
||||
var isCryptoEnabled: Boolean
|
||||
get() {
|
||||
val stored = spUtils.getBoolean(KEY_CRYPTO_ENABLED, false)
|
||||
// 如果存储值为 false 但密钥已配置,也返回 true
|
||||
return stored || (DEFAULT_PUBLIC_KEY.isNotBlank() && DEFAULT_PRIVATE_KEY.isNotBlank())
|
||||
}
|
||||
set(value) {
|
||||
spUtils.put(KEY_CRYPTO_ENABLED, value)
|
||||
Log.d(TAG, "Crypto enabled: $value")
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求公钥(用于 App 加密请求)
|
||||
* App 使用此公钥加密 AES 密钥,服务端使用对应的私钥解密
|
||||
*/
|
||||
var requestPublicKey: String
|
||||
get() {
|
||||
val stored = spUtils.getString(KEY_REQUEST_PUBLIC_KEY, "")
|
||||
return if (stored.isNotBlank()) stored else DEFAULT_PUBLIC_KEY
|
||||
}
|
||||
set(value) {
|
||||
spUtils.put(KEY_REQUEST_PUBLIC_KEY, value)
|
||||
Log.d(TAG, "Request public key updated: ${value.take(20)}...")
|
||||
}
|
||||
|
||||
/**
|
||||
* 响应私钥(用于 App 解密响应)
|
||||
* 服务端使用对应的响应公钥加密响应,App 使用此私钥解密
|
||||
*/
|
||||
var responsePrivateKey: String
|
||||
get() {
|
||||
val stored = spUtils.getString(KEY_RESPONSE_PRIVATE_KEY, "")
|
||||
return if (stored.isNotBlank()) stored else DEFAULT_PRIVATE_KEY
|
||||
}
|
||||
set(value) {
|
||||
spUtils.put(KEY_RESPONSE_PRIVATE_KEY, value)
|
||||
Log.d(TAG, "Response private key updated: ${value.take(20)}...")
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否已配置密钥
|
||||
*/
|
||||
fun isKeyConfigured(): Boolean {
|
||||
// 检查密钥是否有内容(默认值或用户配置)
|
||||
val hasPublicKey = requestPublicKey.isNotBlank()
|
||||
val hasPrivateKey = responsePrivateKey.isNotBlank()
|
||||
Log.d(TAG, "isKeyConfigured: publicKey=$hasPublicKey (length=${requestPublicKey.length}), privateKey=$hasPrivateKey (length=${responsePrivateKey.length})")
|
||||
return hasPublicKey && hasPrivateKey
|
||||
}
|
||||
|
||||
/**
|
||||
* 从服务端配置初始化密钥
|
||||
*
|
||||
* 配置格式(可从服务端接口或配置中心获取):
|
||||
* api-decrypt:
|
||||
* enabled: true
|
||||
* headerFlag: encrypt-key
|
||||
* publicKey: ${APP_RESPONSE_PUBLIC_KEY}
|
||||
* privateKey: ${APP_REQUEST_PRIVATE_KEY}
|
||||
*
|
||||
* @param enabled 是否启用加密
|
||||
* @param publicKey 服务端响应公钥(用于 App 加密请求)
|
||||
* @param privateKey 服务端请求私钥(用于 App 解密响应)
|
||||
*/
|
||||
fun initialize(enabled: Boolean, publicKey: String, privateKey: String) {
|
||||
isCryptoEnabled = enabled
|
||||
requestPublicKey = publicKey
|
||||
responsePrivateKey = privateKey
|
||||
|
||||
Log.i(TAG, "CryptoKeyManager initialized: enabled=$enabled, keyConfigured=${isKeyConfigured()}")
|
||||
}
|
||||
|
||||
/**
|
||||
* 从用户登录信息初始化密钥
|
||||
* 适用于密钥存储在用户账户中的场景
|
||||
*
|
||||
* @param publicKey 公钥
|
||||
* @param privateKey 私钥
|
||||
*/
|
||||
fun initializeFromLogin(publicKey: String, privateKey: String) {
|
||||
initialize(
|
||||
enabled = publicKey.isNotBlank() && privateKey.isNotBlank(),
|
||||
publicKey = publicKey,
|
||||
privateKey = privateKey
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除密钥配置
|
||||
*/
|
||||
fun clear() {
|
||||
spUtils.clear()
|
||||
Log.i(TAG, "CryptoKeyManager cleared")
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取密钥信息摘要(用于调试)
|
||||
*/
|
||||
fun getKeySummary(): String {
|
||||
return buildString {
|
||||
appendLine("CryptoKeyManager Summary:")
|
||||
appendLine(" Enabled: $isCryptoEnabled")
|
||||
appendLine(" RequestPublicKey: ${if (requestPublicKey.isNotBlank()) "${requestPublicKey.take(20)}..." else "NOT SET"}")
|
||||
appendLine(" ResponsePrivateKey: ${if (responsePrivateKey.isNotBlank()) "${responsePrivateKey.take(20)}..." else "NOT SET"}")
|
||||
appendLine(" Configured: ${isKeyConfigured()}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证密钥格式
|
||||
*
|
||||
* @return true 如果密钥是有效的 Base64 编码
|
||||
*/
|
||||
fun validateKeys(): Pair<Boolean, String> {
|
||||
val publicKeyValid = validateBase64Key(requestPublicKey)
|
||||
val privateKeyValid = validateBase64Key(responsePrivateKey)
|
||||
|
||||
return when {
|
||||
!publicKeyValid && !privateKeyValid -> false to "Both keys are invalid"
|
||||
!publicKeyValid -> false to "Request public key is invalid"
|
||||
!privateKeyValid -> false to "Response private key is invalid"
|
||||
else -> true to "Keys are valid"
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateBase64Key(key: String): Boolean {
|
||||
if (key.isBlank()) return false
|
||||
return try {
|
||||
android.util.Base64.decode(key, android.util.Base64.NO_WRAP)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package com.ckkj.defense_device.utils.crypto
|
||||
|
||||
import android.util.Base64
|
||||
import java.security.KeyFactory
|
||||
import java.security.PublicKey
|
||||
import java.security.spec.PKCS8EncodedKeySpec
|
||||
import java.security.spec.RSAPrivateKeySpec
|
||||
import java.security.spec.X509EncodedKeySpec
|
||||
import javax.crypto.Cipher
|
||||
|
||||
/**
|
||||
* RSA 加密工具类
|
||||
*
|
||||
* 算法:RSA/ECB/PKCS1Padding
|
||||
* 支持 RSA 2048/3072 位密钥
|
||||
*/
|
||||
object RSAUtil {
|
||||
|
||||
private const val ALGORITHM = "RSA"
|
||||
private const val TRANSFORMATION = "RSA/ECB/PKCS1Padding"
|
||||
|
||||
/**
|
||||
* 使用公钥加密(用于 App 加密请求)
|
||||
*
|
||||
* @param publicKeyStr Base64 编码的公钥字符串
|
||||
* @param data UTF-8 编码的明文字节数组
|
||||
* @return 密文字节数组
|
||||
*/
|
||||
fun encryptByPublicKey(publicKeyStr: String, data: ByteArray): ByteArray {
|
||||
val publicKey = parsePublicKey(publicKeyStr)
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, publicKey)
|
||||
return cipher.doFinal(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用公钥加密,返回 Base64 字符串
|
||||
*
|
||||
* @param publicKeyStr Base64 编码的公钥字符串
|
||||
* @param plainText UTF-8 明文字符串
|
||||
* @return Base64 编码的密文字符串
|
||||
*/
|
||||
fun encryptByPublicKey(publicKeyStr: String, plainText: String): String {
|
||||
val encrypted = encryptByPublicKey(publicKeyStr, plainText.toByteArray(Charsets.UTF_8))
|
||||
return Base64.encodeToString(encrypted, Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用私钥解密(用于 App 解密响应)
|
||||
*
|
||||
* @param privateKeySpec RSA 私钥规格
|
||||
* @param encryptedData 密文字节数组
|
||||
* @return UTF-8 解密后的明文字节数组
|
||||
*/
|
||||
fun decryptByPrivateKey(privateKeySpec: RSAPrivateKeySpec, encryptedData: ByteArray): ByteArray {
|
||||
val keyFactory = KeyFactory.getInstance(ALGORITHM)
|
||||
val privateKey = keyFactory.generatePrivate(privateKeySpec)
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(Cipher.DECRYPT_MODE, privateKey)
|
||||
return cipher.doFinal(encryptedData)
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用私钥解密,返回 UTF-8 字符串
|
||||
*
|
||||
* @param privateKeySpec RSA 私钥规格
|
||||
* @param encryptedText Base64 编码的密文字符串
|
||||
* @return UTF-8 解密后的明文字符串
|
||||
*/
|
||||
fun decryptByPrivateKey(privateKeySpec: RSAPrivateKeySpec, encryptedText: String): String {
|
||||
val encryptedData = Base64.decode(encryptedText, Base64.NO_WRAP)
|
||||
val decrypted = decryptByPrivateKey(privateKeySpec, encryptedData)
|
||||
return String(decrypted, Charsets.UTF_8)
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 Base64 编码的 PKCS#8 私钥字符串解密
|
||||
*
|
||||
* @param privateKeyBase64 Base64 编码的 PKCS#8 私钥
|
||||
* @param encryptedText Base64 编码的密文字符串
|
||||
* @return UTF-8 解密后的明文字符串
|
||||
*/
|
||||
fun decryptByPrivateKeyBase64(privateKeyBase64: String, encryptedText: String): String {
|
||||
val encryptedData = Base64.decode(encryptedText, Base64.NO_WRAP)
|
||||
val keyFactory = KeyFactory.getInstance(ALGORITHM)
|
||||
val privateKeySpec = PKCS8EncodedKeySpec(Base64.decode(privateKeyBase64, Base64.NO_WRAP))
|
||||
val privateKey = keyFactory.generatePrivate(privateKeySpec)
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(Cipher.DECRYPT_MODE, privateKey)
|
||||
val decrypted = cipher.doFinal(encryptedData)
|
||||
return String(decrypted, Charsets.UTF_8)
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析公钥
|
||||
*
|
||||
* @param publicKeyStr Base64 编码的 X.509 公钥
|
||||
* @return PublicKey 对象
|
||||
*/
|
||||
fun parsePublicKey(publicKeyStr: String): PublicKey {
|
||||
val keyBytes = Base64.decode(publicKeyStr, Base64.NO_WRAP)
|
||||
val keySpec = X509EncodedKeySpec(keyBytes)
|
||||
val keyFactory = KeyFactory.getInstance(ALGORITHM)
|
||||
return keyFactory.generatePublic(keySpec)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Base64 编码的 PKCS#8 私钥创建私钥规格
|
||||
*
|
||||
* @param privateKeyBase64 Base64 编码的 PKCS#8 私钥
|
||||
* @return RSAPrivateKeySpec
|
||||
*/
|
||||
fun createPrivateKeySpecFromBase64(privateKeyBase64: String): RSAPrivateKeySpec {
|
||||
val keyBytes = Base64.decode(privateKeyBase64, Base64.NO_WRAP)
|
||||
val keyFactory = KeyFactory.getInstance(ALGORITHM)
|
||||
val keySpec = keyFactory.getKeySpec(
|
||||
keyFactory.generatePrivate(PKCS8EncodedKeySpec(keyBytes)),
|
||||
RSAPrivateKeySpec::class.java
|
||||
)
|
||||
return keySpec
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,7 @@
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
<!-- 列表区域 -->
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
<com.ckkj.defense_device.view.PullRefreshLayout
|
||||
android:id="@+id/swipeRefresh"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
@@ -84,8 +84,10 @@
|
||||
android:layout_height="match_parent"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="16dp"/>
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
android:paddingBottom="16dp"
|
||||
android:clipToPadding="false"
|
||||
android:overScrollMode="never"/>
|
||||
</com.ckkj.defense_device.view.PullRefreshLayout>
|
||||
|
||||
<!-- 底部按钮 -->
|
||||
<LinearLayout
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_marginStart="30dp"
|
||||
android:layout_marginEnd="30dp"
|
||||
android:layout_marginTop="20dp"
|
||||
android:layout_marginStart="20dp"
|
||||
android:layout_marginEnd="20dp"
|
||||
android:layout_marginTop="10dp"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="40dp"
|
||||
android:layout_height="30dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
@@ -21,6 +21,7 @@
|
||||
android:text="@string/cancel"
|
||||
android:textColor="@color/gray"
|
||||
android:textSize="14dp"
|
||||
android:textStyle="bold"
|
||||
android:clickable="true"
|
||||
android:focusable="true" />
|
||||
|
||||
@@ -37,6 +38,7 @@
|
||||
android:text="@string/confirm"
|
||||
android:textColor="@color/green"
|
||||
android:textSize="14dp"
|
||||
android:textStyle="bold"
|
||||
android:clickable="true"
|
||||
android:focusable="true" />
|
||||
</LinearLayout>
|
||||
@@ -45,6 +47,7 @@
|
||||
android:id="@+id/rvMessageList"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:maxHeight="150dp"
|
||||
android:clipToPadding="false"
|
||||
android:paddingStart="14dp"
|
||||
android:paddingTop="12dp"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:background="@mipmap/home_back"
|
||||
tools:context=".home.HomeMenuFragment">
|
||||
|
||||
@@ -37,14 +38,12 @@
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvCurrentFamily"
|
||||
android:layout_width="0dp"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/my_family_home"
|
||||
android:textColor="@color/black"
|
||||
android:textSize="20dp"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<ImageView
|
||||
@@ -55,11 +54,6 @@
|
||||
android:src="@mipmap/triangle" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"/>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
|
||||
@@ -3,16 +3,17 @@
|
||||
android:id="@+id/itemFamily1"
|
||||
android:layout_width="match_parent"
|
||||
android:gravity="center"
|
||||
android:layout_height="30dp">
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<TextView
|
||||
android:paddingVertical="10dp"
|
||||
android:id="@+id/tvFamilyName"
|
||||
android:gravity="center"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/my_family"
|
||||
android:textColor="@color/black"
|
||||
android:textSize="16dp" />
|
||||
android:textSize="14dp" />
|
||||
|
||||
|
||||
</LinearLayout>
|
||||
@@ -85,7 +85,7 @@
|
||||
android:layout_weight="1">
|
||||
|
||||
<!-- 列表 -->
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
<com.ckkj.defense_device.view.PullRefreshLayout
|
||||
android:id="@+id/swipeRefresh"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
@@ -93,8 +93,11 @@
|
||||
<com.yanzhenjie.recyclerview.SwipeRecyclerView
|
||||
android:id="@+id/rvLinkedUser"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"/>
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
android:layout_height="match_parent"
|
||||
android:clipToPadding="false"
|
||||
android:overScrollMode="never"
|
||||
android:paddingBottom="16dp"/>
|
||||
</com.ckkj.defense_device.view.PullRefreshLayout>
|
||||
|
||||
<!-- 空页面 -->
|
||||
<LinearLayout
|
||||
|
||||
@@ -39,14 +39,24 @@
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
<com.ckkj.defense_device.view.PullRefreshLayout
|
||||
android:id="@+id/swipeRefreshLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_marginStart="20dp"
|
||||
android:layout_marginEnd="20dp"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/rvMessageList"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginStart="20dp"
|
||||
android:layout_marginEnd="20dp"
|
||||
android:clipToPadding="false"
|
||||
android:overScrollMode="never"
|
||||
android:paddingBottom="16dp"
|
||||
tools:listitem="@layout/item_message_notice" />
|
||||
|
||||
</com.ckkj.defense_device.view.PullRefreshLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/emptyLayout"
|
||||
android:layout_width="match_parent"
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
android:id="@+id/tv_cache_size"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="11Kb"
|
||||
android:text=""
|
||||
android:textColor="#999999"
|
||||
android:textSize="13sp" />
|
||||
|
||||
|
||||
Reference in New Issue
Block a user