Compare commits

...

5 Commits

Author SHA1 Message Date
f154f30e2d 优化布局样式,修改text的展示错误显示 2026-07-21 17:46:48 +08:00
7114e36165 初始化 2026-07-21 10:22:18 +08:00
195fb73620 优化res加密 2026-07-18 17:24:37 +08:00
655279a8a1 个人碎片和消息记录LoadingDialog 2026-07-17 15:35:36 +08:00
46385d5358 res加密 2026-07-15 15:26:00 +08:00
22 changed files with 918 additions and 60 deletions

View File

@@ -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() {

View File

@@ -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,//识别模式

View File

@@ -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 {

View File

@@ -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(

View File

@@ -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
}
}
}
}

View File

@@ -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()?: ""
}
}

View File

@@ -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 {
fetchUserList()
}
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()
}

View File

@@ -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 ?: ""

View File

@@ -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
}
}

View File

@@ -39,6 +39,25 @@ import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Query
// ┏┓   ┏┓
//┏┛┻━━━┛┻┓
//┃       ┃
//┃   ━   ┃
//┃ ┳┛ ┗┳ ┃
//┃       ┃
//┃   ┻   ┃
//┃       ┃
//┗━┓   ┏━┛
// ┃   ┃
// ┃   ┃
// ┃   ┗━━━┓
// ┃       ┣┓
// ┃       ┏┛
// ┗┓┓┏━┳┓┏┛
// ┃┫┫ ┃┫┫
// ┗┻┛ ┗┻┛
//神兽护体代码零Bug需求不改一次编译通过
interface ApiService {
/**
* 获取登录验证码

View File

@@ -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()
}
}

View File

@@ -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)
}
}

View File

@@ -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-keybody 是 {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嵌套加密 —— 响应是 JSONdata 字段是加密串
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)
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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"

View File

@@ -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,15 +38,13 @@
<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:textStyle="bold" />
android:textSize="20sp"
android:textStyle="bold" />
<ImageView
android:id="@+id/ivFamilyArrow"
@@ -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"

View File

@@ -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>

View File

@@ -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

View File

@@ -39,13 +39,23 @@
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvMessageList"
<com.ckkj.defense_device.view.PullRefreshLayout
android:id="@+id/swipeRefreshLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginStart="20dp"
android:layout_marginEnd="20dp"
tools:listitem="@layout/item_message_notice" />
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvMessageList"
android:layout_width="match_parent"
android:layout_height="match_parent"
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"

View File

@@ -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" />