diff --git a/README.md b/README.md index e364e6f..438e746 100644 --- a/README.md +++ b/README.md @@ -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 联动 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e49bb34..6ab1f15 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/environment/EnvironmentScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/environment/EnvironmentScreen.kt index cbcff6a..3d9b06e 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/environment/EnvironmentScreen.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/environment/EnvironmentScreen.kt @@ -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(null) } + var snapshot by remember { mutableStateOf(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, + ) } } } diff --git a/sdk-push/build.gradle.kts b/sdk-push/build.gradle.kts index 9d0b210..ed5e8e6 100644 --- a/sdk-push/build.gradle.kts +++ b/sdk-push/build.gradle.kts @@ -19,4 +19,6 @@ android { dependencies { api(project(":sdk-core")) + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.messaging) } diff --git a/sdk-push/src/main/AndroidManifest.xml b/sdk-push/src/main/AndroidManifest.xml new file mode 100644 index 0000000..b07524d --- /dev/null +++ b/sdk-push/src/main/AndroidManifest.xml @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/sdk-push/src/main/java/com/xuqm/sdk/push/PushSDK.kt b/sdk-push/src/main/java/com/xuqm/sdk/push/PushSDK.kt index 5bfd4e3..2111303 100644 --- a/sdk-push/src/main/java/com/xuqm/sdk/push/PushSDK.kt +++ b/sdk-push/src/main/java/com/xuqm/sdk/push/PushSDK.kt @@ -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(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}") + } + } } diff --git a/sdk-push/src/main/java/com/xuqm/sdk/push/api/PushApi.kt b/sdk-push/src/main/java/com/xuqm/sdk/push/api/PushApi.kt index 2861c59..07dd1b9 100644 --- a/sdk-push/src/main/java/com/xuqm/sdk/push/api/PushApi.kt +++ b/sdk-push/src/main/java/com/xuqm/sdk/push/api/PushApi.kt @@ -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, + ) } diff --git a/sdk-push/src/main/java/com/xuqm/sdk/push/fcm/XuqmFirebaseMessagingService.kt b/sdk-push/src/main/java/com/xuqm/sdk/push/fcm/XuqmFirebaseMessagingService.kt new file mode 100644 index 0000000..5eea808 --- /dev/null +++ b/sdk-push/src/main/java/com/xuqm/sdk/push/fcm/XuqmFirebaseMessagingService.kt @@ -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" + } +} diff --git a/sdk-push/src/main/java/com/xuqm/sdk/push/model/PushRegistrationSnapshot.kt b/sdk-push/src/main/java/com/xuqm/sdk/push/model/PushRegistrationSnapshot.kt new file mode 100644 index 0000000..854f18f --- /dev/null +++ b/sdk-push/src/main/java/com/xuqm/sdk/push/model/PushRegistrationSnapshot.kt @@ -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() +} diff --git a/sdk-push/src/main/java/com/xuqm/sdk/push/model/PushVendor.kt b/sdk-push/src/main/java/com/xuqm/sdk/push/model/PushVendor.kt new file mode 100644 index 0000000..e232e3f --- /dev/null +++ b/sdk-push/src/main/java/com/xuqm/sdk/push/model/PushVendor.kt @@ -0,0 +1,11 @@ +package com.xuqm.sdk.push.model + +enum class PushVendor { + HUAWEI, + XIAOMI, + OPPO, + VIVO, + HONOR, + APNS, + FCM, +} diff --git a/sdk-push/src/main/java/com/xuqm/sdk/push/storage/PushRegistrationStore.kt b/sdk-push/src/main/java/com/xuqm/sdk/push/storage/PushRegistrationStore.kt new file mode 100644 index 0000000..81bde02 --- /dev/null +++ b/sdk-push/src/main/java/com/xuqm/sdk/push/storage/PushRegistrationStore.kt @@ -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" + } +}