Fix push device registration

This commit is contained in:
sevenhill
2026-07-05 16:07:44 +03:00
parent bb92651714
commit 81dc918a50
5 changed files with 147 additions and 14 deletions
+2 -2
View File
@@ -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\"")
@@ -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<AttachmentDto>) {
@@ -494,6 +499,8 @@ class QMaxRepository(
}
}
private const val PushLogTag = "QMaxPush"
private data class SharedAttachment(
val attachment: AttachmentDto,
val file: File,
@@ -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<String, String>()
@@ -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
}
}
+47 -3
View File
@@ -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<PushController> 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<Guid?> 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;
}
}
+39
View File
@@ -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<IHostedService>();
});
});
using var client = factory.CreateClient();
await LoginAsync(client);
Guid currentUserId;
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
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<QMaxDbContext>();
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()
{