feat(postit): keep Android SignalR chat in background without ongoing notification
Share the chat transport between UI, calls and an opt-in background service. Restore account rooms and attempt boot restart, keeping event alerts independent from connection permissions. Document Android limits and add lifecycle regressions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
a39c3912ea
commit
311d55dcc6
32 changed files with 2210 additions and 143 deletions
|
|
@ -0,0 +1,144 @@
|
|||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Android.App;
|
||||
using Android.Content;
|
||||
using Android.Util;
|
||||
using PostIt.Models;
|
||||
using PostIt.Services;
|
||||
|
||||
namespace PostIt.Android.Services;
|
||||
|
||||
internal enum BackgroundChatFailure { SessionRequired, StartRejected, ConnectionFailed }
|
||||
|
||||
internal sealed class BackgroundChatException(BackgroundChatFailure failure, string message, Exception? inner = null)
|
||||
: InvalidOperationException(message, inner)
|
||||
{
|
||||
internal BackgroundChatFailure Failure { get; } = failure;
|
||||
}
|
||||
|
||||
internal sealed class AndroidBackgroundChatService : IBackgroundChatService
|
||||
{
|
||||
internal static readonly AndroidBackgroundChatService Instance = new();
|
||||
internal static readonly ChatServiceEpoch Epoch = new();
|
||||
internal const string EpochExtra = "postit-chat-epoch";
|
||||
internal const string IdentityExtra = "postit-chat-identity";
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
|
||||
public async Task SynchronizeAsync(YavscApiClient api, bool enabled, CancellationToken ct = default)
|
||||
{
|
||||
// This runs before the first await: cancellation cannot queue a stop
|
||||
// behind an in-flight permission dialog or a native start.
|
||||
if (!enabled || !api.Settings.AndroidKeepChatConnected)
|
||||
{
|
||||
Invalidate();
|
||||
return;
|
||||
}
|
||||
ct.ThrowIfCancellationRequested();
|
||||
if (api.IsSigningOut)
|
||||
{
|
||||
Invalidate();
|
||||
throw new BackgroundChatException(BackgroundChatFailure.SessionRequired, "Deconnexion en cours.");
|
||||
}
|
||||
var epoch = Epoch.Begin();
|
||||
var identity = ChatBootState.Identity(api);
|
||||
var entered = false;
|
||||
try
|
||||
{
|
||||
await _gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
entered = true;
|
||||
Validate(api, epoch, identity, ct);
|
||||
if (!Epoch.Run(epoch, () => ChatBootState.Save(api))) throw new OperationCanceledException(ct);
|
||||
if (!ChatBootState.CanStart(api))
|
||||
throw new BackgroundChatException(BackgroundChatFailure.SessionRequired, "Connectez-vous avant d'activer la connexion de fond.");
|
||||
var context = global::Android.App.Application.Context;
|
||||
await ChatConnectionService.StartAndWaitAsync(context, epoch, identity!, ct).ConfigureAwait(false);
|
||||
Validate(api, epoch, identity, ct);
|
||||
if (!NotificationPermission.IsGranted(context))
|
||||
{
|
||||
if (MainActivity.Current is { } activity && MainActivity.IsForeground)
|
||||
{
|
||||
try
|
||||
{
|
||||
var granted = await NotificationPermission.RequestAsync(activity, ct).ConfigureAwait(false);
|
||||
Validate(api, epoch, identity, ct);
|
||||
await AndroidSettings.UpdateAsync(api.Settings, s => s.SetActionStatus(granted
|
||||
? "Connexion de fond active sans notification permanente. Android peut l'interrompre."
|
||||
: "Notifications refusees : SignalR reste actif, mais les alertes ne seront pas affichees.",
|
||||
granted ? StatusSeverity.Info : StatusSeverity.Warning)).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("PostIt.Chat", "Notification permission request failed: " + ex);
|
||||
AndroidSettings.ReportError(api.Settings,
|
||||
"SignalR reste actif ; demande d'autorisation des alertes impossible : " + ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
Validate(api, epoch, identity, ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Cancel(epoch);
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Cancel(epoch);
|
||||
Log.Error("PostIt.Chat", ex.ToString());
|
||||
try
|
||||
{
|
||||
await AndroidSettings.UpdateAsync(api.Settings, s => s.SetActionStatus(ex.Message, StatusSeverity.Error))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception statusError) { Log.Error("PostIt.Chat", statusError.ToString()); }
|
||||
if (ex is BackgroundChatException) throw;
|
||||
throw new BackgroundChatException(BackgroundChatFailure.StartRejected,
|
||||
"Android n'a pas pu demarrer le service de messagerie : " + ex.Message, ex);
|
||||
}
|
||||
finally { if (entered) _gate.Release(); }
|
||||
}
|
||||
|
||||
public Task DisableAsync(YavscApiClient api, CancellationToken ct = default)
|
||||
{
|
||||
Invalidate();
|
||||
// Logout calls this before deleting tokens. Never stop the shared
|
||||
// socket here: the process-owned messaging session manages it.
|
||||
try { ChatBootState.MarkSignedOut(); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("PostIt.Chat", ex.ToString());
|
||||
AndroidSettings.ReportError(api.Settings, ex.Message);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static void Validate(YavscApiClient api, string epoch, string? identity, CancellationToken ct)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
if (!Epoch.IsCurrent(epoch) || api.IsSigningOut || !api.Settings.AndroidKeepChatConnected
|
||||
|| !api.HasValidSession || identity is null || identity != ChatBootState.Identity(api))
|
||||
throw new OperationCanceledException("Demarrage de messagerie obsolete.", ct);
|
||||
}
|
||||
|
||||
internal static bool StartNative(Context context, string epoch, string identity) =>
|
||||
Epoch.Run(epoch, () =>
|
||||
{
|
||||
var intent = new Intent(context, typeof(ChatConnectionService))
|
||||
.PutExtra(EpochExtra, epoch).PutExtra(IdentityExtra, identity);
|
||||
context.StartService(intent);
|
||||
});
|
||||
|
||||
internal static void Invalidate() => Epoch.Invalidate(Stop);
|
||||
internal static void Cancel(string epoch) => Epoch.Cancel(epoch, Stop);
|
||||
|
||||
internal static bool IsCurrent(string? epoch) => Epoch.IsCurrent(epoch);
|
||||
|
||||
private static void Stop()
|
||||
{
|
||||
ChatConnectionService.Suspend();
|
||||
global::Android.App.Application.Context.StopService(
|
||||
new Intent(global::Android.App.Application.Context, typeof(ChatConnectionService)));
|
||||
}
|
||||
}
|
||||
34
src/PostIt/PostIt.Android/Services/AndroidServiceProvider.cs
Normal file
34
src/PostIt/PostIt.Android/Services/AndroidServiceProvider.cs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
using System;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using PostIt.Helpers;
|
||||
using PostIt.Services;
|
||||
|
||||
namespace PostIt.Android.Services;
|
||||
|
||||
internal static class AndroidServiceProvider
|
||||
{
|
||||
private static readonly Lazy<IServiceProvider> Provider = new(() =>
|
||||
{
|
||||
var provider = new ServiceCollection().BuildPostItServices();
|
||||
var api = provider.GetRequiredService<YavscApiClient>();
|
||||
api.Settings.Saved += (_, _) =>
|
||||
{
|
||||
if (!api.Settings.AndroidKeepChatConnected) AndroidBackgroundChatService.Invalidate();
|
||||
ChatBootState.Save(api);
|
||||
};
|
||||
api.SigningOut += () =>
|
||||
{
|
||||
AndroidBackgroundChatService.Invalidate();
|
||||
try { ChatBootState.MarkSignedOut(); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
global::Android.Util.Log.Error("PostIt.Chat", ex.ToString());
|
||||
AndroidSettings.ReportError(api.Settings, ex.Message);
|
||||
}
|
||||
return System.Threading.Tasks.Task.CompletedTask;
|
||||
};
|
||||
return provider;
|
||||
});
|
||||
|
||||
internal static IServiceProvider GetOrCreate() => Provider.Value;
|
||||
}
|
||||
38
src/PostIt/PostIt.Android/Services/AndroidSettings.cs
Normal file
38
src/PostIt/PostIt.Android/Services/AndroidSettings.cs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using PostIt.ViewModels;
|
||||
|
||||
namespace PostIt.Android.Services;
|
||||
|
||||
internal static class AndroidSettings
|
||||
{
|
||||
internal static Task UpdateAsync(Settings settings, Action<Settings> update)
|
||||
{
|
||||
if (MainActivity.Current is not { IsDestroyed: false, IsFinishing: false } activity)
|
||||
{
|
||||
update(settings);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
activity.RunOnUiThread(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
update(settings);
|
||||
completion.TrySetResult();
|
||||
}
|
||||
catch (Exception ex) { completion.TrySetException(ex); }
|
||||
});
|
||||
return completion.Task;
|
||||
}
|
||||
|
||||
internal static void ReportError(Settings settings, string message)
|
||||
{
|
||||
_ = ReportAsync();
|
||||
async Task ReportAsync()
|
||||
{
|
||||
try { await UpdateAsync(settings, s => s.SetActionStatus(message, PostIt.Models.StatusSeverity.Error)); }
|
||||
catch (Exception ex) { global::Android.Util.Log.Error("PostIt.Chat", ex.ToString()); }
|
||||
}
|
||||
}
|
||||
}
|
||||
43
src/PostIt/PostIt.Android/Services/ChatBootReceiver.cs
Normal file
43
src/PostIt/PostIt.Android/Services/ChatBootReceiver.cs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Android.App;
|
||||
using Android.Content;
|
||||
using Android.Util;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using PostIt.Models;
|
||||
using PostIt.Services;
|
||||
|
||||
namespace PostIt.Android.Services;
|
||||
|
||||
[BroadcastReceiver(Name = "fr.pschneider.postit.ChatBootReceiver", Enabled = true, Exported = true)]
|
||||
[IntentFilter([Intent.ActionBootCompleted])]
|
||||
public sealed class ChatBootReceiver : BroadcastReceiver
|
||||
{
|
||||
public override void OnReceive(Context? context, Intent? intent)
|
||||
{
|
||||
if (context is null || intent?.Action != Intent.ActionBootCompleted || !ChatBootState.OptedIn) return;
|
||||
var epoch = AndroidBackgroundChatService.Epoch.Restore();
|
||||
if (epoch is null) return;
|
||||
var pending = GoAsync();
|
||||
_ = Task.Run(() =>
|
||||
{
|
||||
YavscApiClient? api = null;
|
||||
try
|
||||
{
|
||||
api = AndroidServiceProvider.GetOrCreate().GetRequiredService<YavscApiClient>();
|
||||
if (!ChatBootState.CanStart(api)) return;
|
||||
var identity = ChatBootState.Identity(api);
|
||||
if (identity is null || !AndroidBackgroundChatService.StartNative(context, epoch, identity)) return;
|
||||
Log.Info("PostIt.Chat", "Best-effort chat restart requested after boot.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("PostIt.Chat", "Boot restart rejected: " + ex);
|
||||
if (api is not null)
|
||||
AndroidSettings.ReportError(api.Settings,
|
||||
"Android a refuse le redemarrage de la messagerie : " + ex.Message);
|
||||
}
|
||||
finally { pending?.Finish(); }
|
||||
});
|
||||
}
|
||||
}
|
||||
59
src/PostIt/PostIt.Android/Services/ChatBootState.cs
Normal file
59
src/PostIt/PostIt.Android/Services/ChatBootState.cs
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Android.Content;
|
||||
using PostIt.Services;
|
||||
|
||||
namespace PostIt.Android.Services;
|
||||
|
||||
internal static class ChatBootState
|
||||
{
|
||||
private static ISharedPreferences Preferences =>
|
||||
global::Android.App.Application.Context.GetSharedPreferences("postit-chat-service", FileCreationMode.Private)!;
|
||||
|
||||
internal static bool OptedIn => Preferences.GetBoolean("enabled", false);
|
||||
|
||||
internal static void Save(YavscApiClient api)
|
||||
{
|
||||
var identity = Identity(api);
|
||||
var saved = Preferences.Edit()!
|
||||
.PutBoolean("enabled", api.Settings.AndroidKeepChatConnected)!
|
||||
.PutBoolean("signed-in", !api.IsSigningOut && api.HasValidSession && identity is not null)!
|
||||
.PutString("identity", identity)!.Commit();
|
||||
if (!saved) throw new IOException("Impossible d'enregistrer l'etat Android de la messagerie.");
|
||||
}
|
||||
|
||||
internal static void MarkSignedOut()
|
||||
{
|
||||
if (!Preferences.Edit()!.PutBoolean("signed-in", false)!.Commit())
|
||||
throw new IOException("Impossible d'enregistrer la deconnexion Android.");
|
||||
}
|
||||
|
||||
internal static bool CanStart(YavscApiClient api) =>
|
||||
OptedIn && Preferences.GetBoolean("signed-in", false)
|
||||
&& api.Settings.AndroidKeepChatConnected && !api.IsSigningOut && api.HasValidSession
|
||||
&& Identity(api) is { } identity && identity == Preferences.GetString("identity", null);
|
||||
|
||||
internal static string? Identity(YavscApiClient api)
|
||||
{
|
||||
var token = api.CurrentIdToken ?? api.CurrentAccessToken;
|
||||
if (string.IsNullOrEmpty(token)) return null;
|
||||
try
|
||||
{
|
||||
var parts = token.Split('.');
|
||||
if (parts.Length != 3) return null;
|
||||
var payload = parts[1].Replace('-', '+').Replace('_', '/');
|
||||
payload = payload.PadRight((payload.Length + 3) / 4 * 4, '=');
|
||||
using var json = JsonDocument.Parse(Encoding.UTF8.GetString(Convert.FromBase64String(payload)));
|
||||
if (!json.RootElement.TryGetProperty("sub", out var subject)
|
||||
|| string.IsNullOrEmpty(subject.GetString())) return null;
|
||||
return $"{api.Settings.ApiUrl.TrimEnd('/')}\n{api.Settings.Authentication.Authority.TrimEnd('/')}\n{subject.GetString()}";
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or JsonException or InvalidOperationException)
|
||||
{
|
||||
global::Android.Util.Log.Warn("PostIt.Chat", "Session identity unavailable for boot restart.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
294
src/PostIt/PostIt.Android/Services/ChatConnectionService.cs
Normal file
294
src/PostIt/PostIt.Android/Services/ChatConnectionService.cs
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Android.App;
|
||||
using Android.Content;
|
||||
using Android.OS;
|
||||
using Android.Util;
|
||||
using AndroidX.Core.App;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using PostIt.Models;
|
||||
using PostIt.Services;
|
||||
using PostIt.ViewModels.Chat;
|
||||
using OperationCanceledException = System.OperationCanceledException;
|
||||
|
||||
namespace PostIt.Android.Services;
|
||||
|
||||
[Service(Name = "fr.pschneider.postit.ChatConnectionService", Exported = false)]
|
||||
public sealed class ChatConnectionService : Service
|
||||
{
|
||||
private const string MessageChannel = "postit-chat-messages";
|
||||
private const int MessageNotificationId = 4053;
|
||||
private static readonly ConcurrentDictionary<string, TaskCompletionSource<bool>> Startups = new();
|
||||
private static volatile ChatConnectionService? _current;
|
||||
private CancellationTokenSource _stop = new();
|
||||
private string? _epoch;
|
||||
private readonly List<IDisposable> _subscriptions = [];
|
||||
private readonly object _observerGate = new();
|
||||
private YavscApiClient? _api;
|
||||
private ChatHubConnection? _transport;
|
||||
private HubConnection? _observedConnection;
|
||||
private Task? _loop;
|
||||
private volatile bool _destroyed;
|
||||
private bool _wasConnected;
|
||||
|
||||
internal static async Task StartAndWaitAsync(Context context, string epoch, string identity, CancellationToken ct)
|
||||
{
|
||||
var pending = Startups.GetOrAdd(epoch,
|
||||
_ => new(TaskCreationOptions.RunContinuationsAsynchronously));
|
||||
try
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
if (!AndroidBackgroundChatService.StartNative(context, epoch, identity))
|
||||
throw new OperationCanceledException("Demarrage de messagerie obsolete.", ct);
|
||||
await pending.Task.WaitAsync(TimeSpan.FromSeconds(30), ct).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Cancel only the owner of this attempt, never a later re-enable.
|
||||
AndroidBackgroundChatService.Cancel(epoch);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Startups.TryRemove(epoch, out _);
|
||||
}
|
||||
}
|
||||
|
||||
internal static void Suspend()
|
||||
{
|
||||
foreach (var startup in Startups)
|
||||
if (!AndroidBackgroundChatService.IsCurrent(startup.Key))
|
||||
startup.Value.TrySetException(new OperationCanceledException("Demarrage de messagerie annule."));
|
||||
if (_current is not { } current) return;
|
||||
current._stop.Cancel();
|
||||
current.FailStartup(new OperationCanceledException("Demarrage de messagerie annule."));
|
||||
}
|
||||
|
||||
public override StartCommandResult OnStartCommand(Intent? intent, StartCommandFlags flags, int startId)
|
||||
{
|
||||
var epoch = intent is null
|
||||
? AndroidBackgroundChatService.Epoch.Restore()
|
||||
: intent.GetStringExtra(AndroidBackgroundChatService.EpochExtra);
|
||||
try
|
||||
{
|
||||
if (!AndroidBackgroundChatService.IsCurrent(epoch))
|
||||
{
|
||||
if (_current == this && AndroidBackgroundChatService.IsCurrent(_epoch))
|
||||
return StartCommandResult.Sticky;
|
||||
StopSelfResult(startId);
|
||||
return StartCommandResult.NotSticky;
|
||||
}
|
||||
EnsureChannels();
|
||||
var provider = AndroidServiceProvider.GetOrCreate();
|
||||
_api = provider.GetRequiredService<YavscApiClient>();
|
||||
_transport = provider.GetRequiredService<ChatHubConnection>();
|
||||
var identity = intent is null ? ChatBootState.Identity(_api)
|
||||
: intent.GetStringExtra(AndroidBackgroundChatService.IdentityExtra);
|
||||
if (!ChatBootState.CanStart(_api) || identity is null || identity != ChatBootState.Identity(_api))
|
||||
throw new BackgroundChatException(BackgroundChatFailure.SessionRequired,
|
||||
"Connexion de fond inactive : option ou session absente.");
|
||||
if (!AndroidBackgroundChatService.Epoch.Run(epoch, () =>
|
||||
{
|
||||
if (_epoch == epoch && _loop is { IsCompleted: false } && !_stop.IsCancellationRequested)
|
||||
{
|
||||
if (_transport.Connection.State == HubConnectionState.Connected) CompleteStartup(epoch!);
|
||||
return;
|
||||
}
|
||||
_stop.Cancel();
|
||||
_stop = new CancellationTokenSource();
|
||||
_epoch = epoch;
|
||||
_current = this;
|
||||
_wasConnected = false;
|
||||
_loop = RunAsync(epoch!, _stop.Token);
|
||||
}))
|
||||
{
|
||||
StopSelfResult(startId);
|
||||
return StartCommandResult.NotSticky;
|
||||
}
|
||||
return StartCommandResult.Sticky;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ReportError(ex);
|
||||
if (epoch is not null)
|
||||
{
|
||||
if (Startups.TryGetValue(epoch, out var pending)) pending.TrySetException(ex);
|
||||
AndroidBackgroundChatService.Cancel(epoch);
|
||||
}
|
||||
if (!AndroidBackgroundChatService.IsCurrent(_epoch)) StopSelfResult(startId);
|
||||
return StartCommandResult.NotSticky;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunAsync(string epoch, CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested && AndroidBackgroundChatService.IsCurrent(epoch))
|
||||
{
|
||||
if (_api is null || _transport is null || !ChatBootState.CanStart(_api))
|
||||
{
|
||||
FailStartup(new BackgroundChatException(BackgroundChatFailure.SessionRequired,
|
||||
"La connexion de fond necessite une session active."), epoch);
|
||||
AndroidBackgroundChatService.Cancel(epoch);
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
var connection = await _transport.GetConnectionAsync().ConfigureAwait(false);
|
||||
if (ct.IsCancellationRequested || !AndroidBackgroundChatService.IsCurrent(epoch)) return;
|
||||
Observe(connection);
|
||||
if (connection.State == HubConnectionState.Disconnected)
|
||||
{
|
||||
using var attempt = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
attempt.CancelAfter(TimeSpan.FromSeconds(20));
|
||||
await _transport.StartAsync(attempt.Token).ConfigureAwait(false);
|
||||
}
|
||||
if (connection.State == HubConnectionState.Connected)
|
||||
{
|
||||
if (!_wasConnected)
|
||||
await AndroidSettings.UpdateAsync(_api.Settings,
|
||||
s =>
|
||||
{
|
||||
if (!_destroyed && AndroidBackgroundChatService.IsCurrent(epoch)
|
||||
&& ChatBootState.CanStart(_api))
|
||||
s.SetActionStatus(NotificationPermission.IsGranted(this)
|
||||
? "Connexion de fond active sans notification permanente. Android peut l'interrompre."
|
||||
: "SignalR actif sans notification permanente. Alertes desactivees dans Android.",
|
||||
NotificationPermission.IsGranted(this) ? StatusSeverity.Info : StatusSeverity.Warning);
|
||||
})
|
||||
.ConfigureAwait(false);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
if (!AndroidBackgroundChatService.IsCurrent(epoch)) return;
|
||||
if (!ChatBootState.CanStart(_api)) { AndroidBackgroundChatService.Cancel(epoch); return; }
|
||||
_wasConnected = true;
|
||||
CompleteStartup(epoch);
|
||||
}
|
||||
else
|
||||
{
|
||||
_wasConnected = false;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!AndroidBackgroundChatService.IsCurrent(epoch)) return;
|
||||
_wasConnected = false;
|
||||
var failure = new BackgroundChatException(BackgroundChatFailure.ConnectionFailed,
|
||||
"Connexion de fond indisponible ; nouvelle tentative automatique. " + ex.Message, ex);
|
||||
ReportError(failure);
|
||||
FailStartup(failure, epoch);
|
||||
}
|
||||
try { await Task.Delay(TimeSpan.FromSeconds(5), ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
|
||||
}
|
||||
}
|
||||
|
||||
private void Observe(HubConnection connection)
|
||||
{
|
||||
lock (_observerGate)
|
||||
{
|
||||
if (_destroyed || ReferenceEquals(connection, _observedConnection)) return;
|
||||
ClearSubscriptions();
|
||||
_observedConnection = connection;
|
||||
_subscriptions.Add(connection.On<string, string, string>("notifyUser", (type, _, _) =>
|
||||
{
|
||||
if (type is "pv" or "private" or "private_message" or "PrivateMessage")
|
||||
NotifyMessage("Nouveau message prive. Ouvrez PostIt.");
|
||||
}));
|
||||
// The current Yavsc hub delivers private chat under addPV.
|
||||
_subscriptions.Add(connection.On<string, string>("addPV", (_, _) =>
|
||||
NotifyMessage("Nouveau message prive. Ouvrez PostIt.")));
|
||||
_subscriptions.Add(connection.On<ChatMessage>("ReceiveMessage", _ =>
|
||||
NotifyMessage("Nouveau message dans un salon. Ouvrez PostIt.")));
|
||||
_subscriptions.Add(connection.On<string, string>("push", (_, _) =>
|
||||
NotifyMessage("Nouvelle notification d'activite. Ouvrez PostIt.")));
|
||||
}
|
||||
}
|
||||
|
||||
private void NotifyMessage(string text)
|
||||
{
|
||||
if (_destroyed || _stop.IsCancellationRequested || !AndroidBackgroundChatService.IsCurrent(_epoch)
|
||||
|| MainActivity.IsForeground
|
||||
|| _api is null || !ChatBootState.CanStart(_api)
|
||||
|| !NotificationPermission.IsGranted(this)) return;
|
||||
try
|
||||
{
|
||||
var builder = new NotificationCompat.Builder(this, MessageChannel);
|
||||
builder.SetSmallIcon(Yavsc.Resource.Drawable.ic_stat_postit);
|
||||
builder.SetContentTitle("PostIt");
|
||||
builder.SetContentText(text);
|
||||
builder.SetVisibility(NotificationCompat.VisibilityPrivate);
|
||||
builder.SetContentIntent(OpenApp());
|
||||
builder.SetAutoCancel(true);
|
||||
builder.SetOnlyAlertOnce(false);
|
||||
GetNotificationManager().Notify(MessageNotificationId, builder.Build());
|
||||
}
|
||||
catch (Exception ex) { ReportError(ex); }
|
||||
}
|
||||
|
||||
private void EnsureChannels()
|
||||
{
|
||||
if (!OperatingSystem.IsAndroidVersionAtLeast(26)) return;
|
||||
var manager = (NotificationManager?)GetSystemService(NotificationService)
|
||||
?? throw new InvalidOperationException("Notifications Android indisponibles.");
|
||||
manager.CreateNotificationChannel(new NotificationChannel(MessageChannel,
|
||||
"Messages et activite", NotificationImportance.Default));
|
||||
}
|
||||
|
||||
private PendingIntent? OpenApp()
|
||||
{
|
||||
var launch = PackageManager?.GetLaunchIntentForPackage(PackageName!);
|
||||
return launch is null ? null : PendingIntent.GetActivity(this, 4053, launch,
|
||||
PendingIntentFlags.UpdateCurrent | PendingIntentFlags.Immutable);
|
||||
}
|
||||
|
||||
private NotificationManagerCompat GetNotificationManager() =>
|
||||
NotificationManagerCompat.From(this)
|
||||
?? throw new InvalidOperationException("Notifications Android indisponibles.");
|
||||
|
||||
private void ReportError(Exception ex)
|
||||
{
|
||||
Log.Error("PostIt.Chat", ex.ToString());
|
||||
if (_api is not null) AndroidSettings.ReportError(_api.Settings, ex.Message);
|
||||
}
|
||||
|
||||
private static void CompleteStartup(string epoch)
|
||||
{
|
||||
if (Startups.TryGetValue(epoch, out var pending)) pending.TrySetResult(true);
|
||||
}
|
||||
|
||||
private void FailStartup(Exception ex, string? epoch = null)
|
||||
{
|
||||
epoch ??= _epoch;
|
||||
if (epoch is not null && Startups.TryGetValue(epoch, out var pending)) pending.TrySetException(ex);
|
||||
}
|
||||
|
||||
private void ClearSubscriptions()
|
||||
{
|
||||
lock (_observerGate)
|
||||
{
|
||||
foreach (var subscription in _subscriptions) subscription.Dispose();
|
||||
_subscriptions.Clear();
|
||||
_observedConnection = null;
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnDestroy()
|
||||
{
|
||||
_destroyed = true;
|
||||
_stop.Cancel();
|
||||
ClearSubscriptions();
|
||||
if (ReferenceEquals(_current, this))
|
||||
{
|
||||
_current = null;
|
||||
FailStartup(new OperationCanceledException("Service de messagerie arrete."));
|
||||
}
|
||||
// The socket belongs to the shared process session, not this service.
|
||||
base.OnDestroy();
|
||||
}
|
||||
|
||||
public override IBinder? OnBind(Intent? intent) => null;
|
||||
}
|
||||
70
src/PostIt/PostIt.Android/Services/ChatServiceEpoch.cs
Normal file
70
src/PostIt/PostIt.Android/Services/ChatServiceEpoch.cs
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
using System;
|
||||
|
||||
namespace PostIt.Android.Services;
|
||||
|
||||
/// <summary>Serializes native launch ownership, including intents delivered after cancellation.</summary>
|
||||
internal sealed class ChatServiceEpoch
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly string _process = Guid.NewGuid().ToString("N");
|
||||
private long _epoch;
|
||||
private bool _enabled;
|
||||
|
||||
internal string Begin()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_enabled) { ++_epoch; _enabled = true; }
|
||||
return Current;
|
||||
}
|
||||
}
|
||||
|
||||
internal string? Restore()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
// Only a genuinely fresh process may restore without a new user request.
|
||||
if (_epoch == 0) { ++_epoch; _enabled = true; }
|
||||
return _enabled ? Current : null;
|
||||
}
|
||||
}
|
||||
|
||||
internal bool IsCurrent(string? epoch)
|
||||
{
|
||||
lock (_gate) return _enabled && epoch == Current;
|
||||
}
|
||||
|
||||
internal bool Run(string? epoch, Action action)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_enabled || epoch != Current) return false;
|
||||
action();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
internal void Invalidate(Action stop)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
++_epoch;
|
||||
_enabled = false;
|
||||
stop();
|
||||
}
|
||||
}
|
||||
|
||||
internal bool Cancel(string epoch, Action stop)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_enabled || epoch != Current) return false;
|
||||
++_epoch;
|
||||
_enabled = false;
|
||||
stop();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private string Current => $"{_process}:{_epoch}";
|
||||
}
|
||||
85
src/PostIt/PostIt.Android/Services/NotificationPermission.cs
Normal file
85
src/PostIt/PostIt.Android/Services/NotificationPermission.cs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Android.App;
|
||||
using Android.Content;
|
||||
using Android.Content.PM;
|
||||
using AndroidX.Core.App;
|
||||
using AndroidX.Core.Content;
|
||||
|
||||
namespace PostIt.Android.Services;
|
||||
|
||||
/// <summary>Runtime POST_NOTIFICATIONS request (Android 13+), routed through MainActivity.</summary>
|
||||
internal static class NotificationPermission
|
||||
{
|
||||
private const int RequestCode = 4051;
|
||||
private const string AskedKey = "notification-permission-asked";
|
||||
private const string PermissionName = "android.permission.POST_NOTIFICATIONS";
|
||||
private static TaskCompletionSource<bool>? _pending;
|
||||
|
||||
internal static bool IsGranted(Context context)
|
||||
=> (!OperatingSystem.IsAndroidVersionAtLeast(33)
|
||||
|| ContextCompat.CheckSelfPermission(context, PermissionName) == Permission.Granted)
|
||||
&& NotificationManagerCompat.From(context)?.AreNotificationsEnabled() == true;
|
||||
|
||||
internal static async Task<bool> RequestAsync(Activity activity, CancellationToken ct)
|
||||
{
|
||||
if (IsGranted(activity)) return true;
|
||||
if (!OperatingSystem.IsAndroidVersionAtLeast(33))
|
||||
return false; // Disabled from system settings: no runtime prompt exists.
|
||||
|
||||
if (!MainActivity.IsForeground || activity.IsFinishing || activity.IsDestroyed)
|
||||
return false;
|
||||
ct.ThrowIfCancellationRequested();
|
||||
if (_pending is { } existing)
|
||||
return await existing.Task.WaitAsync(ct).ConfigureAwait(false);
|
||||
var preferences = activity.GetSharedPreferences("postit-chat-service", FileCreationMode.Private)!;
|
||||
if (preferences.GetBoolean(AskedKey, false))
|
||||
return false; // Subsequent retries must use Android notification settings.
|
||||
|
||||
var pending = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
if (Interlocked.CompareExchange(ref _pending, pending, null) is { } inFlight)
|
||||
return await inFlight.Task.WaitAsync(ct).ConfigureAwait(false);
|
||||
|
||||
activity.RunOnUiThread(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!MainActivity.IsForeground || activity.IsFinishing || activity.IsDestroyed)
|
||||
{
|
||||
Interlocked.CompareExchange(ref _pending, null, pending);
|
||||
pending.TrySetResult(false);
|
||||
return;
|
||||
}
|
||||
if (!preferences.Edit()!.PutBoolean(AskedKey, true)!.Commit())
|
||||
throw new System.IO.IOException("Impossible d'enregistrer la demande d'autorisation Android.");
|
||||
ActivityCompat.RequestPermissions(activity, [PermissionName], RequestCode);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Interlocked.CompareExchange(ref _pending, null, pending);
|
||||
pending.TrySetException(ex);
|
||||
}
|
||||
});
|
||||
|
||||
// Cancellation cancels only this waiter, not the OS request. Retain the
|
||||
// request until its callback so retries cannot open a second dialog.
|
||||
return await pending.Task.WaitAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
internal static bool HandleResult(int requestCode, string[]? permissions, Permission[]? results)
|
||||
{
|
||||
if (requestCode != RequestCode) return false;
|
||||
var granted = false;
|
||||
if (permissions is not null && results is not null)
|
||||
{
|
||||
var index = Array.IndexOf(permissions, PermissionName);
|
||||
granted = index >= 0 && index < results.Length && results[index] == Permission.Granted;
|
||||
}
|
||||
Interlocked.Exchange(ref _pending, null)?.TrySetResult(granted);
|
||||
return true;
|
||||
}
|
||||
|
||||
internal static void OnActivityDestroyed() =>
|
||||
Interlocked.Exchange(ref _pending, null)?.TrySetResult(false);
|
||||
}
|
||||
Loading…
Reference in a new issue