feat(push): use Firebase token auto registration

这个提交包含在:
XuqmGroup 2026-04-29 09:50:09 +08:00
父节点 4677717343
当前提交 65bdb352bf
共有 11 个文件被更改,包括 367 次插入19 次删除

查看文件

@ -230,7 +230,16 @@ data class ImMessage(
### 推送接入
`XuqmSDK.login()` 成功后,SDK 会自动完成厂商初始化、设备注册与 token 上传,不再要求业务侧单独调用注册接口。`logout()` 时会自动注销当前设备绑定。
`XuqmSDK.login()` 成功后,SDK 会自动完成当前系统推送 token 的注册与上传,不再要求业务侧手工传入 token。`logout()` 时会自动注销当前设备绑定。
还可以按用户设置接收开关:
```kotlin
PushSDK.setReceivePush(context, enabled = false)
PushSDK.setReceivePush(context, enabled = true)
```
如果项目接入了 Firebase Messaging,`sdk-push` 会通过 `FirebaseMessagingService.onNewToken()` 自动接收并上报 FCM token。对应服务已经随库注册,只要应用工程提供 Firebase 配置即可生效。
### 与 IM 联动

查看文件

@ -27,6 +27,7 @@ securityCrypto = "1.0.0"
hiltNavigationCompose = "1.2.0"
viewmodelCompose = "2.10.0"
material = "1.13.0"
firebaseBom = "34.7.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@ -53,6 +54,8 @@ kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-
androidx-webkit = { group = "androidx.webkit", name = "webkit", version.ref = "webkit" }
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" }
firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" }
firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging" }
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }

查看文件

@ -2,12 +2,15 @@ package com.xuqm.sdk.sample.ui.environment
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Settings
@ -15,11 +18,13 @@ import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.Switch
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@ -31,6 +36,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.xuqm.sdk.XuqmSDK
import com.xuqm.sdk.push.PushSDK
import com.xuqm.sdk.push.model.PushRegistrationSnapshot
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@ -116,7 +124,8 @@ fun EnvironmentScreen(
.fillMaxSize()
.padding(padding)
.padding(24.dp)
.imePadding(),
.imePadding()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Icon(
@ -187,6 +196,88 @@ fun EnvironmentScreen(
style = MaterialTheme.typography.bodySmall,
)
}
HorizontalDivider()
PushRegistrationSection()
}
}
}
@Composable
private fun PushRegistrationSection() {
val context = androidx.compose.ui.platform.LocalContext.current
val currentUserId = XuqmSDK.currentLoginSession?.userId ?: AppDependencies.authRepository.getCurrentUserId()
var statusMessage by remember { mutableStateOf<String?>(null) }
var snapshot by remember { mutableStateOf<PushRegistrationSnapshot?>(null) }
fun refresh(): PushRegistrationSnapshot? {
val current = runCatching { PushSDK.currentRegistration(context) }.getOrNull()
snapshot = current
return current
}
LaunchedEffect(Unit) {
refresh()
}
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = "推送注册",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Text(
text = if (currentUserId.isNullOrBlank()) {
"当前未登录,登录后会自动请求系统推送 token 并完成绑定。"
} else {
"当前登录用户:$currentUserId"
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline,
)
Text(
text = if (snapshot?.hasPushToken == true) {
"系统 token已就绪"
} else {
"系统 token等待 Firebase 回调"
},
style = MaterialTheme.typography.bodySmall,
)
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(
checked = snapshot?.receivePush ?: true,
onCheckedChange = { enabled ->
PushSDK.setReceivePush(context, currentUserId, enabled)
snapshot = snapshot?.copy(receivePush = enabled) ?: snapshot
statusMessage = if (enabled) "已允许接收推送" else "已关闭接收推送"
},
)
Spacer(modifier = Modifier.height(0.dp))
Text(
text = "接收推送",
style = MaterialTheme.typography.bodyMedium,
)
}
Text(
text = "系统 token 由 `FirebaseMessagingService.onNewToken()` 自动上报;登录后会自动完成绑定。",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline,
)
statusMessage?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
)
}
}
}

查看文件

@ -19,4 +19,6 @@ android {
dependencies {
api(project(":sdk-core"))
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.messaging)
}

查看文件

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<service
android:name="com.xuqm.sdk.push.fcm.XuqmFirebaseMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
</application>
</manifest>

查看文件

@ -1,35 +1,111 @@
package com.xuqm.sdk.push
import android.content.Context
import android.util.Log
import com.google.firebase.messaging.FirebaseMessaging
import com.xuqm.sdk.XuqmLoginSession
import com.xuqm.sdk.XuqmSDK
import com.xuqm.sdk.core.ServiceEndpointRegistry
import com.xuqm.sdk.network.ApiClient
import com.xuqm.sdk.push.api.PushApi
import com.xuqm.sdk.push.model.PushRegistrationSnapshot
import com.xuqm.sdk.push.model.PushVendor
import com.xuqm.sdk.push.storage.PushRegistrationStore
import com.xuqm.sdk.utils.DeviceUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicReference
object PushSDK {
private val api: PushApi get() = ApiClient.create(PushApi::class.java, ServiceEndpointRegistry.pushBaseUrl)
private val scope = CoroutineScope(Dispatchers.IO)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val registeredUserId = AtomicReference<String?>(null)
fun registerDevice(context: Context, userId: String) {
fun currentRegistration(context: Context): PushRegistrationSnapshot? {
XuqmSDK.requireInit()
val vendor = DeviceUtils.getVendor()
val deviceId = DeviceUtils.getDeviceId(context)
return store(context).load(deviceId = deviceId, fallbackVendor = detectVendor())
}
internal fun updateNativePushToken(
context: Context,
vendor: PushVendor,
pushToken: String,
) {
XuqmSDK.requireInit()
val normalizedToken = pushToken.trim()
require(normalizedToken.isNotBlank()) { "pushToken must not be blank" }
store(context).save(vendor, normalizedToken)
val sessionUserId = XuqmSDK.currentLoginSession?.userId
if (sessionUserId != null) {
bindImUser(context, sessionUserId)
}
}
fun bindImUser(context: Context, userId: String) {
if (!isReceivePushEnabled(context)) return
ensureNativePushToken(context)
registerDevice(context, userId)
}
fun unbindImUser(userId: String) {
unregisterDevice(userId)
}
fun setReceivePush(
context: Context,
userId: String? = XuqmSDK.currentLoginSession?.userId,
enabled: Boolean,
) {
XuqmSDK.requireInit()
store(context).setReceivePush(enabled)
val resolvedUserId = userId ?: registeredUserId.get()
if (resolvedUserId != null) {
scope.launch {
runCatching {
api.setReceivePush(
appId = XuqmSDK.appId,
userId = resolvedUserId,
enabled = enabled,
)
if (enabled) {
bindImUser(context, resolvedUserId)
}
}
}
}
}
fun registerDevice(
context: Context,
userId: String,
) {
XuqmSDK.requireInit()
val registration = currentRegistration(context)
val vendor = registration?.vendor ?: detectVendor()
val pushToken = registration?.pushToken?.takeIf { it.isNotBlank() }
if (pushToken.isNullOrBlank()) {
Log.w("XuqmPushSDK", "Native push token not ready yet, waiting for onNewToken()")
ensureNativePushToken(context)
return
}
scope.launch {
runCatching {
api.registerDevice(
appId = XuqmSDK.appId,
userId = userId,
vendor = vendor,
token = deviceId,
vendor = vendor.name,
token = pushToken,
)
registeredUserId.set(userId)
store(context).updateLastUserId(userId)
Log.i(
"XuqmPushSDK",
"Registered push device for userId=$userId vendor=${vendor.name}",
)
}
}
}
@ -40,18 +116,56 @@ object PushSDK {
runCatching {
api.unregisterDevice(XuqmSDK.appId, userId)
registeredUserId.compareAndSet(userId, null)
store(XuqmSDK.appContext).updateLastUserId(null)
}
}
}
fun onSdkLogin(session: com.xuqm.sdk.XuqmLoginSession) {
fun onSdkLogin(session: XuqmLoginSession) {
val context = runCatching { XuqmSDK.appContext }.getOrNull() ?: return
if (registeredUserId.get() == session.userId) return
registerDevice(context, session.userId)
bindImUser(context, session.userId)
}
fun onSdkLogout() {
val userId = registeredUserId.getAndSet(null) ?: return
unregisterDevice(userId)
}
private fun detectVendor(): PushVendor = PushVendor.FCM
private fun store(context: Context): PushRegistrationStore =
PushRegistrationStore(context.applicationContext)
private fun isReceivePushEnabled(context: Context): Boolean =
store(context).load(
deviceId = DeviceUtils.getDeviceId(context),
fallbackVendor = detectVendor(),
)?.receivePush ?: true
private fun ensureNativePushToken(context: Context) {
val vendor = detectVendor()
if (vendor != PushVendor.FCM) return
val registration = currentRegistration(context)
if (registration?.pushToken?.isNotBlank() == true) return
runCatching { FirebaseMessaging.getInstance() }
.onSuccess { messaging ->
messaging.token
.addOnSuccessListener { token ->
if (token.isNotBlank()) {
store(context).save(PushVendor.FCM, token)
val sessionUserId = XuqmSDK.currentLoginSession?.userId
if (sessionUserId != null) {
registerDevice(context, sessionUserId)
}
}
}
.addOnFailureListener { error ->
Log.w("XuqmPushSDK", "Unable to fetch FCM token: ${error.message}")
}
}
.onFailure { error ->
Log.w("XuqmPushSDK", "Firebase Messaging not available: ${error.message}")
}
}
}

查看文件

@ -1,19 +1,9 @@
package com.xuqm.sdk.push.api
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.POST
import retrofit2.http.Query
data class RegisterDeviceRequest(
val userId: String,
val appId: String,
val platform: String,
val vendor: String,
val pushToken: String,
val deviceId: String,
)
interface PushApi {
@POST("api/push/register")
@ -29,4 +19,11 @@ interface PushApi {
@Query("appId") appId: String,
@Query("userId") userId: String,
)
@POST("api/push/receive-push")
suspend fun setReceivePush(
@Query("appId") appId: String,
@Query("userId") userId: String,
@Query("enabled") enabled: Boolean,
)
}

查看文件

@ -0,0 +1,28 @@
package com.xuqm.sdk.push.fcm
import android.util.Log
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import com.xuqm.sdk.push.PushSDK
import com.xuqm.sdk.push.model.PushVendor
class XuqmFirebaseMessagingService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
super.onNewToken(token)
Log.d(TAG, "FCM token refreshed length=${token.length}")
PushSDK.updateNativePushToken(applicationContext, PushVendor.FCM, token)
}
override fun onMessageReceived(message: RemoteMessage) {
super.onMessageReceived(message)
Log.d(
TAG,
"FCM message from=${message.from.orEmpty()} dataKeys=${message.data.keys.joinToString(",")}",
)
}
companion object {
private const val TAG = "XuqmFcmService"
}
}

查看文件

@ -0,0 +1,11 @@
package com.xuqm.sdk.push.model
data class PushRegistrationSnapshot(
val vendor: PushVendor,
val pushToken: String,
val deviceId: String,
val receivePush: Boolean = true,
val lastUserId: String? = null,
) {
val hasPushToken: Boolean get() = pushToken.isNotBlank()
}

查看文件

@ -0,0 +1,11 @@
package com.xuqm.sdk.push.model
enum class PushVendor {
HUAWEI,
XIAOMI,
OPPO,
VIVO,
HONOR,
APNS,
FCM,
}

查看文件

@ -0,0 +1,70 @@
package com.xuqm.sdk.push.storage
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKeys
import com.xuqm.sdk.push.model.PushRegistrationSnapshot
import com.xuqm.sdk.push.model.PushVendor
internal class PushRegistrationStore(context: Context) {
private val prefs = EncryptedSharedPreferences.create(
PREFS_NAME,
MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC),
context.applicationContext,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
fun load(deviceId: String, fallbackVendor: PushVendor): PushRegistrationSnapshot? {
val vendorName = prefs.getString(KEY_VENDOR, null) ?: fallbackVendor.name
val pushToken = prefs.getString(KEY_PUSH_TOKEN, null).orEmpty()
val receivePush = prefs.getBoolean(KEY_RECEIVE_PUSH, true)
val lastUserId = prefs.getString(KEY_LAST_USER_ID, null)
val vendor = runCatching { PushVendor.valueOf(vendorName) }.getOrDefault(fallbackVendor)
if (pushToken.isBlank() && lastUserId.isNullOrBlank()) return null
return PushRegistrationSnapshot(
vendor = vendor,
pushToken = pushToken,
deviceId = deviceId,
receivePush = receivePush,
lastUserId = lastUserId,
)
}
fun save(vendor: PushVendor, pushToken: String) {
prefs.edit()
.putString(KEY_VENDOR, vendor.name)
.putString(KEY_PUSH_TOKEN, pushToken)
.apply()
}
fun setReceivePush(enabled: Boolean) {
prefs.edit()
.putBoolean(KEY_RECEIVE_PUSH, enabled)
.apply()
}
fun updateLastUserId(userId: String?) {
prefs.edit()
.apply {
if (userId.isNullOrBlank()) remove(KEY_LAST_USER_ID) else putString(KEY_LAST_USER_ID, userId)
}
.apply()
}
fun clearToken() {
prefs.edit()
.remove(KEY_VENDOR)
.remove(KEY_PUSH_TOKEN)
.apply()
}
companion object {
private const val PREFS_NAME = "xuqm_push_settings"
private const val KEY_VENDOR = "push_vendor"
private const val KEY_PUSH_TOKEN = "push_token"
private const val KEY_RECEIVE_PUSH = "receive_push"
private const val KEY_LAST_USER_ID = "last_user_id"
}
}