From 81dc918a508235d5bbc1e69208bce4b3616c9004 Mon Sep 17 00:00:00 2001 From: sevenhill Date: Sun, 5 Jul 2026 16:07:44 +0300 Subject: [PATCH] Fix push device registration --- android/app/build.gradle.kts | 4 +- .../xyz/kusoft/qmax/core/QMaxRepository.kt | 17 +++++-- .../java/xyz/kusoft/qmax/ui/QMaxViewModel.kt | 51 +++++++++++++++++-- server/QMax.Api/Controllers/PushController.cs | 50 ++++++++++++++++-- tests/QMax.Tests/ApiSmokeTests.cs | 39 ++++++++++++++ 5 files changed, 147 insertions(+), 14 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index eeaaa01..4641fe4 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -22,8 +22,8 @@ android { applicationId = "xyz.kusoft.qmax" minSdk = 26 targetSdk = 36 - versionCode = 48 - versionName = "0.1.47" + versionCode = 49 + versionName = "0.1.48" buildConfigField("String", "QMAX_DEFAULT_SERVER_URL", "\"https://qmax.kusoft.xyz\"") buildConfigField("String", "QMAX_DEFAULT_PAIRING_CODE", "\"qmax-MxRq4h2HQBEIFs6k\"") diff --git a/android/app/src/main/java/xyz/kusoft/qmax/core/QMaxRepository.kt b/android/app/src/main/java/xyz/kusoft/qmax/core/QMaxRepository.kt index 5bdb393..9bb04ee 100644 --- a/android/app/src/main/java/xyz/kusoft/qmax/core/QMaxRepository.kt +++ b/android/app/src/main/java/xyz/kusoft/qmax/core/QMaxRepository.kt @@ -6,6 +6,7 @@ import android.content.ClipData import android.content.ActivityNotFoundException import android.net.Uri import android.provider.OpenableColumns +import android.util.Log import androidx.core.content.FileProvider import com.google.firebase.messaging.FirebaseMessaging import kotlinx.coroutines.flow.Flow @@ -268,23 +269,27 @@ class QMaxRepository( return withFreshSession(session) { api.submitMaxCode(it.serverUrl, it.accessToken, code) } } - suspend fun registerCurrentPushDevice(session: QMaxSession) { + suspend fun registerCurrentPushDevice(session: QMaxSession): Boolean { if (!FirebaseBootstrap.ensureInitialized(context)) { - return + Log.w(PushLogTag, "Firebase is not initialized; push device registration skipped") + return false } val token = FirebaseMessaging.getInstance().token.await() - registerPushDevice(session, token) + return registerPushDevice(session, token) } - suspend fun registerPushDevice(session: QMaxSession, firebaseToken: String) { + suspend fun registerPushDevice(session: QMaxSession, firebaseToken: String): Boolean { if (firebaseToken.isBlank()) { - return + Log.w(PushLogTag, "Firebase returned a blank FCM token") + return false } withFreshSession(session) { api.registerPushDevice(it.serverUrl, it.accessToken, firebaseToken) } + Log.i(PushLogTag, "Push device token registered with QMAX API") + return true } private suspend fun sharePayload(session: QMaxSession, text: String?, attachments: List) { @@ -494,6 +499,8 @@ class QMaxRepository( } } +private const val PushLogTag = "QMaxPush" + private data class SharedAttachment( val attachment: AttachmentDto, val file: File, diff --git a/android/app/src/main/java/xyz/kusoft/qmax/ui/QMaxViewModel.kt b/android/app/src/main/java/xyz/kusoft/qmax/ui/QMaxViewModel.kt index c2c3eaa..97be422 100644 --- a/android/app/src/main/java/xyz/kusoft/qmax/ui/QMaxViewModel.kt +++ b/android/app/src/main/java/xyz/kusoft/qmax/ui/QMaxViewModel.kt @@ -70,6 +70,8 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() { private var chatSearchJob: Job? = null private var autoLoginJob: Job? = null private var pushRegisteredForToken: String? = null + private var pushRegistrationJob: Job? = null + private var pushRegistrationInFlightForToken: String? = null private var autoLoginAttempted = false private var pendingOpenChatId: String? = null private val locallyReadChatFingerprints = mutableMapOf() @@ -97,6 +99,9 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() { realtimeConnectJob?.cancel() messagePollJob?.cancel() presencePollJob?.cancel() + pushRegistrationJob?.cancel() + pushRegistrationInFlightForToken = null + pushRegisteredForToken = null realtime.disconnect() if (!autoLoginAttempted && state.value.pairingCode.isNotBlank()) { autoLoginAttempted = true @@ -843,11 +848,46 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() { if (pushRegisteredForToken == registrationKey) { return } + if (pushRegistrationJob?.isActive == true && pushRegistrationInFlightForToken == registrationKey) { + return + } - pushRegisteredForToken = registrationKey - viewModelScope.launch { - runCatching { - repository.registerCurrentPushDevice(session) + pushRegistrationJob?.cancel() + pushRegistrationInFlightForToken = registrationKey + pushRegistrationJob = viewModelScope.launch { + try { + var retryDelayMs = 0L + for (attempt in 1..PushRegistrationAttempts) { + if (retryDelayMs > 0) { + delay(retryDelayMs) + } + + val registered = try { + repository.registerCurrentPushDevice(session) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + Log.w(LogTag, "push registration failed, attempt=$attempt", error) + false + } + + if (registered) { + pushRegisteredForToken = registrationKey + Log.i(LogTag, "push registration completed") + return@launch + } + + Log.w(LogTag, "push registration was not completed, attempt=$attempt") + retryDelayMs = if (retryDelayMs == 0L) { + InitialPushRegistrationRetryMs + } else { + (retryDelayMs * 2).coerceAtMost(MaxPushRegistrationRetryMs) + } + } + } finally { + if (pushRegistrationInFlightForToken == registrationKey) { + pushRegistrationInFlightForToken = null + } } } } @@ -1036,5 +1076,8 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() { const val ChatListTimeoutMs = 45_000L const val MessageSyncTimeoutMs = 120_000L const val AutoLoginDelayMs = 1_000L + const val PushRegistrationAttempts = 6 + const val InitialPushRegistrationRetryMs = 15_000L + const val MaxPushRegistrationRetryMs = 120_000L } } diff --git a/server/QMax.Api/Controllers/PushController.cs b/server/QMax.Api/Controllers/PushController.cs index 0bf561c..75b8135 100644 --- a/server/QMax.Api/Controllers/PushController.cs +++ b/server/QMax.Api/Controllers/PushController.cs @@ -10,7 +10,7 @@ namespace QMax.Api.Controllers; [ApiController] [Authorize] [Route("api/push")] -public sealed class PushController(QMaxDbContext db) : ControllerBase +public sealed class PushController(QMaxDbContext db, ILogger logger) : ControllerBase { public sealed record RegisterPushDeviceRequest(string FirebaseToken, string Platform); @@ -22,7 +22,12 @@ public sealed class PushController(QMaxDbContext db) : ControllerBase return BadRequest("firebaseToken is required."); } - var userId = User.GetUserId(); + var userId = await ResolvePushUserIdAsync(User.GetUserId(), cancellationToken); + if (userId is null) + { + return Unauthorized(); + } + var device = await db.PushDevices.FirstOrDefaultAsync(x => x.FirebaseToken == request.FirebaseToken, cancellationToken); if (device is null) { @@ -30,7 +35,7 @@ public sealed class PushController(QMaxDbContext db) : ControllerBase db.PushDevices.Add(device); } - device.UserId = userId; + device.UserId = userId.Value; device.Platform = string.IsNullOrWhiteSpace(request.Platform) ? "android" : request.Platform; device.UpdatedAt = DateTimeOffset.UtcNow; await db.SaveChangesAsync(cancellationToken); @@ -51,4 +56,43 @@ public sealed class PushController(QMaxDbContext db) : ControllerBase await db.SaveChangesAsync(cancellationToken); return NoContent(); } + + private async Task ResolvePushUserIdAsync(Guid authenticatedUserId, CancellationToken cancellationToken) + { + if (await db.Users.AnyAsync(x => x.Id == authenticatedUserId, cancellationToken)) + { + return authenticatedUserId; + } + + var existingUserIds = await db.Users + .Select(x => x.Id) + .Take(2) + .ToArrayAsync(cancellationToken); + + if (existingUserIds.Length == 1) + { + logger.LogWarning( + "Push registration used stale user {AuthenticatedUserId}; attaching device to single existing user {UserId}.", + authenticatedUserId, + existingUserIds[0]); + return existingUserIds[0]; + } + + if (existingUserIds.Length > 1) + { + logger.LogWarning( + "Push registration used missing user {AuthenticatedUserId}, but database has multiple users.", + authenticatedUserId); + return null; + } + + db.Users.Add(new User + { + Id = authenticatedUserId, + DisplayName = User.Identity?.Name?.Trim() is { Length: > 0 } displayName + ? displayName + : "QMAX Owner" + }); + return authenticatedUserId; + } } diff --git a/tests/QMax.Tests/ApiSmokeTests.cs b/tests/QMax.Tests/ApiSmokeTests.cs index 8a9adec..b5d7917 100644 --- a/tests/QMax.Tests/ApiSmokeTests.cs +++ b/tests/QMax.Tests/ApiSmokeTests.cs @@ -158,6 +158,45 @@ public sealed class ApiSmokeTests : IDisposable Assert.Contains(chats, x => x.Title == "\u0410\u043d\u044e\u0442\u0430"); } + [Fact] + public async Task PushRegistrationUsesSingleExistingUserWhenTokenUserIsStale() + { + using var factory = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.RemoveAll(); + }); + }); + using var client = factory.CreateClient(); + await LoginAsync(client); + + Guid currentUserId; + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.UserSessions.RemoveRange(db.UserSessions); + db.Users.RemoveRange(db.Users); + var currentUser = new User { DisplayName = "QMAX Owner" }; + db.Users.Add(currentUser); + await db.SaveChangesAsync(); + currentUserId = currentUser.Id; + } + + var response = await client.PostAsJsonAsync( + "/api/push/devices", + new { FirebaseToken = "stale-token-user-fcm-token", Platform = "android" }); + + await AssertStatusAsync(HttpStatusCode.NoContent, response); + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var device = await db.PushDevices.SingleAsync(); + Assert.Equal(currentUserId, device.UserId); + Assert.Equal("stale-token-user-fcm-token", device.FirebaseToken); + } + } + [Fact] public async Task SyncMergesLateDiscoveredAttachmentsIntoExistingMessage() {