Fix push device registration
This commit is contained in:
@@ -22,8 +22,8 @@ android {
|
|||||||
applicationId = "xyz.kusoft.qmax"
|
applicationId = "xyz.kusoft.qmax"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 48
|
versionCode = 49
|
||||||
versionName = "0.1.47"
|
versionName = "0.1.48"
|
||||||
|
|
||||||
buildConfigField("String", "QMAX_DEFAULT_SERVER_URL", "\"https://qmax.kusoft.xyz\"")
|
buildConfigField("String", "QMAX_DEFAULT_SERVER_URL", "\"https://qmax.kusoft.xyz\"")
|
||||||
buildConfigField("String", "QMAX_DEFAULT_PAIRING_CODE", "\"qmax-MxRq4h2HQBEIFs6k\"")
|
buildConfigField("String", "QMAX_DEFAULT_PAIRING_CODE", "\"qmax-MxRq4h2HQBEIFs6k\"")
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import android.content.ClipData
|
|||||||
import android.content.ActivityNotFoundException
|
import android.content.ActivityNotFoundException
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.provider.OpenableColumns
|
import android.provider.OpenableColumns
|
||||||
|
import android.util.Log
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import com.google.firebase.messaging.FirebaseMessaging
|
import com.google.firebase.messaging.FirebaseMessaging
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -268,23 +269,27 @@ class QMaxRepository(
|
|||||||
return withFreshSession(session) { api.submitMaxCode(it.serverUrl, it.accessToken, code) }
|
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)) {
|
if (!FirebaseBootstrap.ensureInitialized(context)) {
|
||||||
return
|
Log.w(PushLogTag, "Firebase is not initialized; push device registration skipped")
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
val token = FirebaseMessaging.getInstance().token.await()
|
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()) {
|
if (firebaseToken.isBlank()) {
|
||||||
return
|
Log.w(PushLogTag, "Firebase returned a blank FCM token")
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
withFreshSession(session) {
|
withFreshSession(session) {
|
||||||
api.registerPushDevice(it.serverUrl, it.accessToken, firebaseToken)
|
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>) {
|
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(
|
private data class SharedAttachment(
|
||||||
val attachment: AttachmentDto,
|
val attachment: AttachmentDto,
|
||||||
val file: File,
|
val file: File,
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
|
|||||||
private var chatSearchJob: Job? = null
|
private var chatSearchJob: Job? = null
|
||||||
private var autoLoginJob: Job? = null
|
private var autoLoginJob: Job? = null
|
||||||
private var pushRegisteredForToken: String? = null
|
private var pushRegisteredForToken: String? = null
|
||||||
|
private var pushRegistrationJob: Job? = null
|
||||||
|
private var pushRegistrationInFlightForToken: String? = null
|
||||||
private var autoLoginAttempted = false
|
private var autoLoginAttempted = false
|
||||||
private var pendingOpenChatId: String? = null
|
private var pendingOpenChatId: String? = null
|
||||||
private val locallyReadChatFingerprints = mutableMapOf<String, String>()
|
private val locallyReadChatFingerprints = mutableMapOf<String, String>()
|
||||||
@@ -97,6 +99,9 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
|
|||||||
realtimeConnectJob?.cancel()
|
realtimeConnectJob?.cancel()
|
||||||
messagePollJob?.cancel()
|
messagePollJob?.cancel()
|
||||||
presencePollJob?.cancel()
|
presencePollJob?.cancel()
|
||||||
|
pushRegistrationJob?.cancel()
|
||||||
|
pushRegistrationInFlightForToken = null
|
||||||
|
pushRegisteredForToken = null
|
||||||
realtime.disconnect()
|
realtime.disconnect()
|
||||||
if (!autoLoginAttempted && state.value.pairingCode.isNotBlank()) {
|
if (!autoLoginAttempted && state.value.pairingCode.isNotBlank()) {
|
||||||
autoLoginAttempted = true
|
autoLoginAttempted = true
|
||||||
@@ -843,11 +848,46 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
|
|||||||
if (pushRegisteredForToken == registrationKey) {
|
if (pushRegisteredForToken == registrationKey) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (pushRegistrationJob?.isActive == true && pushRegistrationInFlightForToken == registrationKey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
pushRegisteredForToken = registrationKey
|
pushRegistrationJob?.cancel()
|
||||||
viewModelScope.launch {
|
pushRegistrationInFlightForToken = registrationKey
|
||||||
runCatching {
|
pushRegistrationJob = viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
var retryDelayMs = 0L
|
||||||
|
for (attempt in 1..PushRegistrationAttempts) {
|
||||||
|
if (retryDelayMs > 0) {
|
||||||
|
delay(retryDelayMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
val registered = try {
|
||||||
repository.registerCurrentPushDevice(session)
|
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 ChatListTimeoutMs = 45_000L
|
||||||
const val MessageSyncTimeoutMs = 120_000L
|
const val MessageSyncTimeoutMs = 120_000L
|
||||||
const val AutoLoginDelayMs = 1_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]
|
[ApiController]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
[Route("api/push")]
|
[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);
|
public sealed record RegisterPushDeviceRequest(string FirebaseToken, string Platform);
|
||||||
|
|
||||||
@@ -22,7 +22,12 @@ public sealed class PushController(QMaxDbContext db) : ControllerBase
|
|||||||
return BadRequest("firebaseToken is required.");
|
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);
|
var device = await db.PushDevices.FirstOrDefaultAsync(x => x.FirebaseToken == request.FirebaseToken, cancellationToken);
|
||||||
if (device is null)
|
if (device is null)
|
||||||
{
|
{
|
||||||
@@ -30,7 +35,7 @@ public sealed class PushController(QMaxDbContext db) : ControllerBase
|
|||||||
db.PushDevices.Add(device);
|
db.PushDevices.Add(device);
|
||||||
}
|
}
|
||||||
|
|
||||||
device.UserId = userId;
|
device.UserId = userId.Value;
|
||||||
device.Platform = string.IsNullOrWhiteSpace(request.Platform) ? "android" : request.Platform;
|
device.Platform = string.IsNullOrWhiteSpace(request.Platform) ? "android" : request.Platform;
|
||||||
device.UpdatedAt = DateTimeOffset.UtcNow;
|
device.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
@@ -51,4 +56,43 @@ public sealed class PushController(QMaxDbContext db) : ControllerBase
|
|||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
return NoContent();
|
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");
|
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]
|
[Fact]
|
||||||
public async Task SyncMergesLateDiscoveredAttachmentsIntoExistingMessage()
|
public async Task SyncMergesLateDiscoveredAttachmentsIntoExistingMessage()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user