Fix push device registration
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user