diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 6330a36eca..0005b08937 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -460,6 +460,7 @@ dependencies {
implementation("org.jetbrains.kotlin:kotlin-stdlib:2.4.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion")
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:$coroutinesVersion")
implementation("com.github.zjupure:webpdecoder:$webpdecoderVersion")
implementation("com.github.bumptech.glide:glide:$glideVersion")
implementation("com.github.bumptech.glide:okhttp3-integration:$glideVersion")
diff --git a/app/src/main/java/one/mixin/android/extension/ContextExtension.kt b/app/src/main/java/one/mixin/android/extension/ContextExtension.kt
index 35a0eb409b..8da191c7dc 100644
--- a/app/src/main/java/one/mixin/android/extension/ContextExtension.kt
+++ b/app/src/main/java/one/mixin/android/extension/ContextExtension.kt
@@ -1343,11 +1343,18 @@ fun Activity.showPipPermissionNotification(
fun getStringDeviceId(resolver: ContentResolver): String {
var deviceId = Settings.Secure.getString(resolver, Settings.Secure.ANDROID_ID)
if (deviceId == null || deviceId == "9774d56d682e549c") {
- deviceId = FirebaseInstallations.getInstance().id.result
+ deviceId = getFirebaseInstallationIdIfReady() ?: Build.FINGERPRINT
}
return UUID.nameUUIDFromBytes(deviceId.toByteArray()).toString()
}
+private fun getFirebaseInstallationIdIfReady(): String? {
+ val task = FirebaseInstallations.getInstance().id
+ if (!task.isComplete) return null
+ task.exception?.let { Timber.w(it, "Firebase installation id unavailable for device id") }
+ return if (task.isSuccessful) task.result else null
+}
+
fun Context.getStringDeviceId(): String {
return getStringDeviceId(contentResolver)
}
diff --git a/app/src/main/java/one/mixin/android/ui/setting/LogAndDebugFragment.kt b/app/src/main/java/one/mixin/android/ui/setting/LogAndDebugFragment.kt
index a62f01d7e6..97c5af4581 100644
--- a/app/src/main/java/one/mixin/android/ui/setting/LogAndDebugFragment.kt
+++ b/app/src/main/java/one/mixin/android/ui/setting/LogAndDebugFragment.kt
@@ -24,6 +24,7 @@ import one.mixin.android.db.property.PropertyHelper.updateKeyValue
import one.mixin.android.extension.alertDialogBuilder
import one.mixin.android.extension.defaultSharedPreferences
import one.mixin.android.extension.indeterminateProgressDialog
+import one.mixin.android.extension.isGooglePlayServicesAvailable
import one.mixin.android.extension.navTo
import one.mixin.android.extension.putBoolean
import one.mixin.android.extension.toast
@@ -100,6 +101,9 @@ class LogAndDebugFragment : BaseFragment(R.layout.fragment_log_debug) {
logs.setOnClickListener {
shareLogsFile()
}
+ updateFcmToken.setOnClickListener {
+ updateFcmToken()
+ }
database.setOnClickListener {
navTo(
DatabaseDebugFragment.newInstance(),
@@ -187,6 +191,41 @@ class LogAndDebugFragment : BaseFragment(R.layout.fragment_log_debug) {
}
}
+ private fun updateFcmToken() {
+ val googlePlayServicesAvailable = requireContext().isGooglePlayServicesAvailable()
+ val progressDialog = indeterminateProgressDialog(message = R.string.Please_wait_a_bit).apply {
+ setCancelable(false)
+ }
+ viewLifecycleOwner.lifecycleScope.launch {
+ val result =
+ try {
+ if (googlePlayServicesAvailable) {
+ withContext(Dispatchers.IO) {
+ viewModel.updateFcmToken()
+ }
+ } else {
+ FcmTokenUpdateResult.Failure("Google Play services unavailable")
+ }
+ } finally {
+ progressDialog.dismiss()
+ }
+ when (result) {
+ FcmTokenUpdateResult.Success -> toast(R.string.FCM_Token_Updated)
+ is FcmTokenUpdateResult.Failure -> showFcmTokenUpdateError(result.message)
+ }
+ }
+ }
+
+ private fun showFcmTokenUpdateError(message: String) {
+ alertDialogBuilder()
+ .setTitle(R.string.Update_FCM_Token)
+ .setMessage(message)
+ .setPositiveButton(android.R.string.ok) { dialog, _ ->
+ dialog.dismiss()
+ }
+ .show()
+ }
+
override fun onDestroyView() {
captchaView?.release()
captchaView = null
diff --git a/app/src/main/java/one/mixin/android/ui/setting/LogAndDebugViewModel.kt b/app/src/main/java/one/mixin/android/ui/setting/LogAndDebugViewModel.kt
index d1d9cd2a8a..cea238af52 100644
--- a/app/src/main/java/one/mixin/android/ui/setting/LogAndDebugViewModel.kt
+++ b/app/src/main/java/one/mixin/android/ui/setting/LogAndDebugViewModel.kt
@@ -2,12 +2,19 @@ package one.mixin.android.ui.setting
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
+import one.mixin.android.api.request.SessionRequest
+import one.mixin.android.api.service.AccountService
import one.mixin.android.repository.TokenRepository
+import one.mixin.android.session.Session
+import one.mixin.android.util.reportFcmException
+import one.mixin.android.util.retrieveFirebaseMessagingToken
+import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
class LogAndDebugViewModel @Inject constructor(
- private val tokenRepository: TokenRepository
+ private val tokenRepository: TokenRepository,
+ private val accountService: AccountService,
) : ViewModel() {
suspend fun deleteAllWeb3Transactions() {
@@ -21,4 +28,56 @@ class LogAndDebugViewModel @Inject constructor(
suspend fun deleteAllOrders() {
tokenRepository.deleteAllOrders()
}
+
+ suspend fun updateFcmToken(): FcmTokenUpdateResult {
+ Timber.e("Debug FCM token update started")
+ if (Session.getAccount() == null) {
+ Timber.w("Debug FCM token update failed: No active account")
+ return FcmTokenUpdateResult.Failure("No active account")
+ }
+
+ val token = try {
+ retrieveFirebaseMessagingToken()
+ } catch (e: Exception) {
+ Timber.e(e, "Debug FCM token retrieval failed")
+ reportFcmException("Debug FCM token retrieval failed", e)
+ return FcmTokenUpdateResult.Failure("Failed to retrieve Firebase token: ${e.displayMessage()}")
+ }
+ if (token.isBlank()) {
+ val error = IllegalStateException("Debug FCM token is blank")
+ reportFcmException("Debug FCM token retrieval failed: blank token", error)
+ Timber.e("Debug FCM token retrieval failed: blank token")
+ return FcmTokenUpdateResult.Failure("Firebase token is blank")
+ }
+ Timber.e("Debug Firebase token retrieved: true")
+
+ return try {
+ val response = accountService.updateSession(SessionRequest(notificationToken = token))
+ if (response.isSuccess) {
+ Timber.e("Debug session updated successfully with Firebase token")
+ FcmTokenUpdateResult.Success
+ } else {
+ val message = buildString {
+ append("Session update failed with error code: ${response.errorCode}")
+ if (response.errorDescription.isNotBlank()) {
+ append('\n')
+ append(response.errorDescription)
+ }
+ }
+ Timber.e(message)
+ FcmTokenUpdateResult.Failure(message)
+ }
+ } catch (e: Exception) {
+ Timber.e(e, "Debug session update failed")
+ FcmTokenUpdateResult.Failure("Session update failed: ${e.displayMessage()}")
+ }
+ }
+
+ private fun Exception.displayMessage() = message ?: javaClass.simpleName
+}
+
+sealed interface FcmTokenUpdateResult {
+ object Success : FcmTokenUpdateResult
+
+ data class Failure(val message: String) : FcmTokenUpdateResult
}
diff --git a/app/src/main/java/one/mixin/android/util/CrashExceptionReport.kt b/app/src/main/java/one/mixin/android/util/CrashExceptionReport.kt
index 9c6d130a9d..07953bfc92 100644
--- a/app/src/main/java/one/mixin/android/util/CrashExceptionReport.kt
+++ b/app/src/main/java/one/mixin/android/util/CrashExceptionReport.kt
@@ -2,6 +2,7 @@ package one.mixin.android.util
import androidx.media3.common.PlaybackException
import androidx.media3.datasource.HttpDataSource
+import com.bugsnag.android.Bugsnag
import com.google.firebase.crashlytics.FirebaseCrashlytics
import one.mixin.android.extension.getStackTraceString
@@ -16,6 +17,18 @@ fun reportException(
FirebaseCrashlytics.getInstance().log(msg + e.getStackTraceString())
}
+fun reportFcmException(
+ msg: String,
+ e: Throwable,
+) {
+ FirebaseCrashlytics.getInstance().log(msg + e.getStackTraceString())
+ FirebaseCrashlytics.getInstance().recordException(e)
+ Bugsnag.notify(e) { report ->
+ report.addError(e.javaClass.name, msg)
+ true
+ }
+}
+
fun reportEvent(msg: String) {
FirebaseCrashlytics.getInstance().log(msg)
}
@@ -38,4 +51,4 @@ fun Throwable.msg(): String {
return this.javaClass.name
}
return message ?: "Unknown"
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/one/mixin/android/util/FirebaseTokenUtil.kt b/app/src/main/java/one/mixin/android/util/FirebaseTokenUtil.kt
new file mode 100644
index 0000000000..6264cbc896
--- /dev/null
+++ b/app/src/main/java/one/mixin/android/util/FirebaseTokenUtil.kt
@@ -0,0 +1,31 @@
+package one.mixin.android.util
+
+import com.google.firebase.installations.FirebaseInstallations
+import com.google.firebase.messaging.FirebaseMessaging
+import kotlinx.coroutines.tasks.await
+import timber.log.Timber
+
+private const val FIS_AUTH_ERROR = "FIS_AUTH_ERROR"
+
+suspend fun retrieveFirebaseMessagingToken(): String =
+ try {
+ FirebaseMessaging.getInstance().token.await()
+ } catch (e: Exception) {
+ if (!e.isFisAuthError()) throw e
+ Timber.w(e, "Firebase token retrieval failed with FIS_AUTH_ERROR")
+ try {
+ Timber.w("Deleting Firebase installation before retrying token retrieval")
+ FirebaseInstallations.getInstance().delete().await()
+ Timber.w("Firebase installation deleted, retrying token retrieval")
+ } catch (deleteError: Exception) {
+ Timber.e(deleteError, "Failed to delete Firebase installation after FIS_AUTH_ERROR")
+ reportException("Failed to delete Firebase installation after FIS_AUTH_ERROR", deleteError)
+ throw deleteError
+ }
+ FirebaseMessaging.getInstance().token.await()
+ }
+
+private fun Throwable.isFisAuthError(): Boolean =
+ generateSequence(this) { it.cause }.any { throwable ->
+ throwable.message?.contains(FIS_AUTH_ERROR, ignoreCase = true) == true
+ }
diff --git a/app/src/main/java/one/mixin/android/worker/SessionWorker.kt b/app/src/main/java/one/mixin/android/worker/SessionWorker.kt
index 2d603b93f4..635f50ff10 100644
--- a/app/src/main/java/one/mixin/android/worker/SessionWorker.kt
+++ b/app/src/main/java/one/mixin/android/worker/SessionWorker.kt
@@ -3,14 +3,15 @@ package one.mixin.android.worker
import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.WorkerParameters
-import com.google.android.gms.tasks.Tasks
-import com.google.firebase.messaging.FirebaseMessaging
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import one.mixin.android.api.request.SessionRequest
import one.mixin.android.api.service.AccountService
+import one.mixin.android.extension.isGooglePlayServicesAvailable
import one.mixin.android.session.Session
import one.mixin.android.util.ErrorHandler.Companion.SERVER
+import one.mixin.android.util.reportFcmException
+import one.mixin.android.util.retrieveFirebaseMessagingToken
import timber.log.Timber
@HiltWorker
@@ -28,11 +29,30 @@ class SessionWorker @AssistedInject constructor(
return Result.failure()
}
- val token = retrieveFirebaseToken()
- Timber.e("Firebase token retrieved: ${token != null}")
+ val token = if (applicationContext.isGooglePlayServicesAvailable()) {
+ try {
+ retrieveFirebaseToken()
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to retrieve Firebase token")
+ reportFcmException("SessionWorker failed to retrieve Firebase token", e)
+ null
+ }
+ } else {
+ Timber.w("Google Play services unavailable, skipping Firebase token retrieval")
+ null
+ }
+ val notificationToken = if (token != null && token.isBlank()) {
+ val error = IllegalStateException("SessionWorker retrieved blank Firebase token")
+ Timber.e(error, "Failed to retrieve Firebase token")
+ reportFcmException("SessionWorker retrieved blank Firebase token", error)
+ null
+ } else {
+ token
+ }
+ Timber.e("Firebase token retrieved: ${notificationToken != null}")
return try {
- val response = accountService.updateSession(SessionRequest(notificationToken = token))
+ val response = accountService.updateSession(SessionRequest(notificationToken = notificationToken))
if (response.isSuccess) {
Timber.e("Session updated successfully")
Result.success()
@@ -49,11 +69,5 @@ class SessionWorker @AssistedInject constructor(
}
}
- private fun retrieveFirebaseToken(): String? {
- return runCatching {
- Tasks.await(FirebaseMessaging.getInstance().token)
- }.onFailure { error ->
- Timber.e(error, "Failed to retrieve Firebase token")
- }.getOrDefault(null)
- }
-}
\ No newline at end of file
+ private suspend fun retrieveFirebaseToken(): String = retrieveFirebaseMessagingToken()
+}
diff --git a/app/src/main/res/layout/fragment_log_debug.xml b/app/src/main/res/layout/fragment_log_debug.xml
index 75bbc02edc..dae98a561d 100644
--- a/app/src/main/res/layout/fragment_log_debug.xml
+++ b/app/src/main/res/layout/fragment_log_debug.xml
@@ -50,6 +50,18 @@
android:foreground="?android:attr/selectableItemBackground"
android:textSize="16sp" />
+
+
分享出错
分享邀请链接
分享日志文件
+ 更新 FCM Token
+ FCM Token 已更新
你确定要发送来自%1$s的%2$s?
你确定要发送该%1$s?
分享二维码
diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml
index ef13d00848..887b322490 100644
--- a/app/src/main/res/values-zh-rTW/strings.xml
+++ b/app/src/main/res/values-zh-rTW/strings.xml
@@ -838,6 +838,8 @@
分享名片
分享出錯
分享邀請連結
+ 更新 FCM Token
+ FCM Token 已更新
你確定要傳送來自%1$s的%2$s?
你確定要傳送該%1$s?
分享二維碼
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 8909430af1..b719247a3b 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1057,6 +1057,8 @@
Share error.
Share Link
Share Logs
+ Update FCM Token
+ FCM token updated
Are you sure you want to send a %2$s from %1$s?
Are you sure you want to send the %1$s?
Share QR Code