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 联动 ### 与 IM 联动

查看文件

@ -27,6 +27,7 @@ securityCrypto = "1.0.0"
hiltNavigationCompose = "1.2.0" hiltNavigationCompose = "1.2.0"
viewmodelCompose = "2.10.0" viewmodelCompose = "2.10.0"
material = "1.13.0" material = "1.13.0"
firebaseBom = "34.7.0"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } 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" } androidx-webkit = { group = "androidx.webkit", name = "webkit", version.ref = "webkit" }
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" } 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" } 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 = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", 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" } 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding 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.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Settings 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.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.Switch
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -31,6 +36,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp 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.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
@ -116,7 +124,8 @@ fun EnvironmentScreen(
.fillMaxSize() .fillMaxSize()
.padding(padding) .padding(padding)
.padding(24.dp) .padding(24.dp)
.imePadding(), .imePadding()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
Icon( Icon(
@ -187,6 +196,88 @@ fun EnvironmentScreen(
style = MaterialTheme.typography.bodySmall, 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 { dependencies {
api(project(":sdk-core")) 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 package com.xuqm.sdk.push
import android.content.Context 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.XuqmSDK
import com.xuqm.sdk.core.ServiceEndpointRegistry import com.xuqm.sdk.core.ServiceEndpointRegistry
import com.xuqm.sdk.network.ApiClient import com.xuqm.sdk.network.ApiClient
import com.xuqm.sdk.push.api.PushApi 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 com.xuqm.sdk.utils.DeviceUtils
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicReference import java.util.concurrent.atomic.AtomicReference
object PushSDK { object PushSDK {
private val api: PushApi get() = ApiClient.create(PushApi::class.java, ServiceEndpointRegistry.pushBaseUrl) 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) private val registeredUserId = AtomicReference<String?>(null)
fun registerDevice(context: Context, userId: String) { fun currentRegistration(context: Context): PushRegistrationSnapshot? {
XuqmSDK.requireInit() XuqmSDK.requireInit()
val vendor = DeviceUtils.getVendor()
val deviceId = DeviceUtils.getDeviceId(context) 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 { scope.launch {
runCatching { runCatching {
api.registerDevice( api.registerDevice(
appId = XuqmSDK.appId, appId = XuqmSDK.appId,
userId = userId, userId = userId,
vendor = vendor, vendor = vendor.name,
token = deviceId, token = pushToken,
) )
registeredUserId.set(userId) 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 { runCatching {
api.unregisterDevice(XuqmSDK.appId, userId) api.unregisterDevice(XuqmSDK.appId, userId)
registeredUserId.compareAndSet(userId, null) 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 val context = runCatching { XuqmSDK.appContext }.getOrNull() ?: return
if (registeredUserId.get() == session.userId) return if (registeredUserId.get() == session.userId) return
registerDevice(context, session.userId) bindImUser(context, session.userId)
} }
fun onSdkLogout() { fun onSdkLogout() {
val userId = registeredUserId.getAndSet(null) ?: return val userId = registeredUserId.getAndSet(null) ?: return
unregisterDevice(userId) 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 package com.xuqm.sdk.push.api
import retrofit2.http.Body
import retrofit2.http.DELETE import retrofit2.http.DELETE
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.Query 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 { interface PushApi {
@POST("api/push/register") @POST("api/push/register")
@ -29,4 +19,11 @@ interface PushApi {
@Query("appId") appId: String, @Query("appId") appId: String,
@Query("userId") userId: 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"
}
}