Office365 Multi-Account (work + outlook.com), Cache pro Account, Tray-Icon mit Status-Punkt
This commit is contained in:
parent
22c2ac1b2d
commit
cd2cf5d1bb
11 changed files with 208 additions and 68 deletions
|
|
@ -47,8 +47,17 @@ public partial class App : System.Windows.Application
|
|||
return new ContactCache(options.ResolveCacheDatabasePath(), sp.GetRequiredService<ILogger<ContactCache>>());
|
||||
});
|
||||
|
||||
services.AddSingleton<Office365ContactProvider>();
|
||||
services.AddSingleton<IContactProvider>(sp => sp.GetRequiredService<Office365ContactProvider>());
|
||||
var contactsConfig = context.Configuration.GetSection(ContactsOptions.SectionName).Get<ContactsOptions>()
|
||||
?? new ContactsOptions();
|
||||
|
||||
foreach (var account in contactsConfig.Office365Accounts.Where(a => a.Enabled))
|
||||
{
|
||||
services.AddKeyedSingleton<Office365ContactProvider>(account.Name, (sp, _) =>
|
||||
new Office365ContactProvider(account, sp.GetRequiredService<ILogger<Office365ContactProvider>>()));
|
||||
services.AddSingleton<IContactProvider>(sp =>
|
||||
sp.GetRequiredKeyedService<Office365ContactProvider>(account.Name));
|
||||
}
|
||||
|
||||
services.AddSingleton<IContactProvider, GoogleContactProvider>();
|
||||
services.AddSingleton<IReverseLookupProvider, PublicDirectoryLookupProvider>();
|
||||
|
||||
|
|
@ -77,11 +86,14 @@ public partial class App : System.Windows.Application
|
|||
onExit: () => System.Windows.Application.Current.Shutdown());
|
||||
_trayIconManager.Show();
|
||||
|
||||
var office365Provider = _host.Services.GetRequiredService<Office365ContactProvider>();
|
||||
office365Provider.DeviceCodeRequired += message =>
|
||||
var office365Providers = _host.Services.GetServices<IContactProvider>().OfType<Office365ContactProvider>();
|
||||
foreach (var provider in office365Providers)
|
||||
{
|
||||
_trayIconManager?.ShowNotification("Office365-Anmeldung erforderlich", message);
|
||||
};
|
||||
provider.DeviceCodeRequired += message =>
|
||||
{
|
||||
_trayIconManager?.ShowNotification($"Office365-Anmeldung erforderlich ({provider.AccountName})", message);
|
||||
};
|
||||
}
|
||||
|
||||
sipMonitor.IncomingCall += async (_, args) =>
|
||||
{
|
||||
|
|
|
|||
55
src/Anrufmonitor.App/TrayIconFactory.cs
Normal file
55
src/Anrufmonitor.App/TrayIconFactory.cs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
using System.Drawing;
|
||||
using System.Drawing.Drawing2D;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Anrufmonitor.App;
|
||||
|
||||
/// <summary>
|
||||
/// Zeichnet zur Laufzeit ein einfaches Tray-Icon (Telefonhoerer) mit farbigem Status-Punkt
|
||||
/// (gruen = verbunden, rot = getrennt, grau = unbekannt/Start), statt eine binaere .ico-Datei
|
||||
/// mitzuliefern.
|
||||
/// </summary>
|
||||
public static class TrayIconFactory
|
||||
{
|
||||
public static readonly Color ConnectedColor = Color.FromArgb(255, 76, 175, 80);
|
||||
public static readonly Color DisconnectedColor = Color.FromArgb(255, 229, 57, 53);
|
||||
public static readonly Color UnknownColor = Color.FromArgb(255, 158, 158, 158);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern bool DestroyIcon(IntPtr handle);
|
||||
|
||||
public static Icon CreateStatusIcon(Color dotColor)
|
||||
{
|
||||
using var bitmap = new Bitmap(32, 32);
|
||||
using (var g = Graphics.FromImage(bitmap))
|
||||
{
|
||||
g.SmoothingMode = SmoothingMode.AntiAlias;
|
||||
g.Clear(Color.Transparent);
|
||||
|
||||
using var baseBrush = new SolidBrush(Color.FromArgb(255, 45, 55, 72));
|
||||
g.FillEllipse(baseBrush, 1, 1, 28, 28);
|
||||
|
||||
using var phonePen = new Pen(Color.White, 2.5f) { StartCap = LineCap.Round, EndCap = LineCap.Round };
|
||||
g.DrawArc(phonePen, 8, 8, 14, 14, 30, 220);
|
||||
|
||||
using var dotBrush = new SolidBrush(dotColor);
|
||||
using var dotBorder = new Pen(Color.White, 1.5f);
|
||||
g.FillEllipse(dotBrush, 19, 19, 11, 11);
|
||||
g.DrawEllipse(dotBorder, 19, 19, 11, 11);
|
||||
}
|
||||
|
||||
// GetHicon() liefert ein unverwaltetes Win32-Handle, fuer das WIR verantwortlich sind
|
||||
// (siehe .NET-Doku). Wir klonen es in ein eigenstaendiges Icon und geben das Handle
|
||||
// sofort wieder frei, damit hier kein GDI-Handle-Leak entsteht.
|
||||
var hIcon = bitmap.GetHicon();
|
||||
try
|
||||
{
|
||||
using var temp = Icon.FromHandle(hIcon);
|
||||
return (Icon)temp.Clone();
|
||||
}
|
||||
finally
|
||||
{
|
||||
DestroyIcon(hIcon);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,17 @@
|
|||
using System.Drawing;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace Anrufmonitor.App;
|
||||
|
||||
/// <summary>
|
||||
/// Verwaltet das Tray-Icon (System.Windows.Forms.NotifyIcon, da WPF selbst kein
|
||||
/// natives Tray-Icon anbietet) mit Kontextmenue.
|
||||
/// natives Tray-Icon anbietet) mit Kontextmenue und Status-Anzeige (farbiger Punkt:
|
||||
/// gruen = SIP verbunden, rot = getrennt, grau = unbekannt).
|
||||
/// </summary>
|
||||
public sealed class TrayIconManager : IDisposable
|
||||
{
|
||||
private readonly NotifyIcon _notifyIcon;
|
||||
private Icon? _currentIcon;
|
||||
|
||||
public TrayIconManager(Func<Task> onSyncNow, System.Action onExit)
|
||||
{
|
||||
|
|
@ -24,9 +27,11 @@ public sealed class TrayIconManager : IDisposable
|
|||
exitItem.Click += (_, _) => onExit();
|
||||
menu.Items.Add(exitItem);
|
||||
|
||||
_currentIcon = TrayIconFactory.CreateStatusIcon(TrayIconFactory.UnknownColor);
|
||||
|
||||
_notifyIcon = new NotifyIcon
|
||||
{
|
||||
Icon = System.Drawing.SystemIcons.Application,
|
||||
Icon = _currentIcon,
|
||||
Text = "Anrufmonitor",
|
||||
ContextMenuStrip = menu,
|
||||
Visible = false,
|
||||
|
|
@ -37,9 +42,19 @@ public sealed class TrayIconManager : IDisposable
|
|||
|
||||
public void SetSipStatus(bool registered)
|
||||
{
|
||||
_notifyIcon.Text = registered
|
||||
? "Anrufmonitor - verbunden"
|
||||
: "Anrufmonitor - SIP getrennt";
|
||||
SetIcon(registered ? TrayIconFactory.ConnectedColor : TrayIconFactory.DisconnectedColor);
|
||||
_notifyIcon.Text = registered ? "Anrufmonitor - verbunden" : "Anrufmonitor - SIP getrennt";
|
||||
}
|
||||
|
||||
private void SetIcon(Color dotColor)
|
||||
{
|
||||
var newIcon = TrayIconFactory.CreateStatusIcon(dotColor);
|
||||
var oldIcon = _currentIcon;
|
||||
|
||||
_notifyIcon.Icon = newIcon;
|
||||
_currentIcon = newIcon;
|
||||
|
||||
oldIcon?.Dispose();
|
||||
}
|
||||
|
||||
public void ShowNotification(string title, string message)
|
||||
|
|
@ -54,5 +69,6 @@ public sealed class TrayIconManager : IDisposable
|
|||
{
|
||||
_notifyIcon.Visible = false;
|
||||
_notifyIcon.Dispose();
|
||||
_currentIcon?.Dispose();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,10 +7,21 @@
|
|||
"Password": "geheim"
|
||||
},
|
||||
"Contacts": {
|
||||
"Office365": {
|
||||
"Enabled": true,
|
||||
"ClientId": "00000000-0000-0000-0000-000000000000"
|
||||
},
|
||||
"Office365Accounts": [
|
||||
{
|
||||
"Name": "work",
|
||||
"Enabled": true,
|
||||
"TenantId": "00000000-0000-0000-0000-000000000000",
|
||||
"ClientId": "00000000-0000-0000-0000-000000000000"
|
||||
},
|
||||
{
|
||||
"Name": "outlook",
|
||||
"Enabled": true,
|
||||
"TenantId": "common",
|
||||
"ClientId": "00000000-0000-0000-0000-000000000000",
|
||||
"_comment": "TenantId=common/consumers fuer outlook.com. App-Registration muss unter Authentifizierung -> Unterstuetzte Kontotypen auch persoenliche Microsoft-Konten erlauben."
|
||||
}
|
||||
],
|
||||
"Google": {
|
||||
"Enabled": true,
|
||||
"ClientId": "xxxxxxxx.apps.googleusercontent.com",
|
||||
|
|
|
|||
|
|
@ -13,11 +13,7 @@
|
|||
"CacheDatabasePath": "%LocalAppData%\\Anrufmonitor\\cache.db",
|
||||
"SyncInterval": "00:30:00",
|
||||
"ReverseLookupCacheTtl": "7.00:00:00",
|
||||
"Office365": {
|
||||
"Enabled": false,
|
||||
"TenantId": "common",
|
||||
"ClientId": ""
|
||||
},
|
||||
"Office365Accounts": [],
|
||||
"Google": {
|
||||
"Enabled": false,
|
||||
"ClientId": "",
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@ using Microsoft.Extensions.Logging;
|
|||
namespace Anrufmonitor.Contacts;
|
||||
|
||||
/// <summary>
|
||||
/// Lokaler SQLite-Cache: eine Zeile pro (normalisierter Rufnummer, Quelle).
|
||||
/// Bewusst denormalisiert (kein Join noetig), damit die Lookup-Anfrage beim
|
||||
/// eingehenden Anruf so schnell wie moeglich ist.
|
||||
/// Lokaler SQLite-Cache: eine Zeile pro (normalisierter Rufnummer, Provider-Instanz).
|
||||
/// Partitioniert nach <see cref="IContactProvider.ProviderKey"/> statt nur nach
|
||||
/// <see cref="ContactSource"/>, damit z. B. zwei Office365-Accounts sich beim Sync
|
||||
/// nicht gegenseitig ueberschreiben. Bewusst denormalisiert (kein Join noetig),
|
||||
/// damit die Lookup-Anfrage beim eingehenden Anruf so schnell wie moeglich ist.
|
||||
/// </summary>
|
||||
public sealed class ContactCache
|
||||
{
|
||||
|
|
@ -37,13 +39,14 @@ public sealed class ContactCache
|
|||
await ExecuteAsync(connection, transaction, """
|
||||
CREATE TABLE IF NOT EXISTS ContactNumbers (
|
||||
NormalizedNumber TEXT NOT NULL,
|
||||
ProviderKey TEXT NOT NULL,
|
||||
Source TEXT NOT NULL,
|
||||
ContactId TEXT NOT NULL,
|
||||
DisplayName TEXT NOT NULL,
|
||||
Company TEXT NULL,
|
||||
PhotoUrl TEXT NULL,
|
||||
SyncedAtUtc TEXT NOT NULL,
|
||||
PRIMARY KEY (NormalizedNumber, Source)
|
||||
PRIMARY KEY (NormalizedNumber, ProviderKey)
|
||||
);
|
||||
""", cancellationToken);
|
||||
|
||||
|
|
@ -61,10 +64,12 @@ public sealed class ContactCache
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ersetzt den kompletten Bestand einer Quelle in einer einzigen Transaktion
|
||||
/// (kein Zwischenzustand mit teilweise geloeschten/eingefuegten Daten).
|
||||
/// Ersetzt den kompletten Bestand einer Provider-Instanz (z. B. eines einzelnen
|
||||
/// Office365-Accounts) in einer einzigen Transaktion (kein Zwischenzustand mit
|
||||
/// teilweise geloeschten/eingefuegten Daten). Andere Provider/Accounts bleiben unberuehrt.
|
||||
/// </summary>
|
||||
public async Task ReplaceContactsForSourceAsync(
|
||||
public async Task ReplaceContactsForProviderAsync(
|
||||
string providerKey,
|
||||
ContactSource source,
|
||||
IReadOnlyList<ContactInfo> contacts,
|
||||
CancellationToken cancellationToken)
|
||||
|
|
@ -75,8 +80,8 @@ public sealed class ContactCache
|
|||
await using (var deleteCmd = connection.CreateCommand())
|
||||
{
|
||||
deleteCmd.Transaction = transaction;
|
||||
deleteCmd.CommandText = "DELETE FROM ContactNumbers WHERE Source = $source;";
|
||||
deleteCmd.Parameters.AddWithValue("$source", source.ToString());
|
||||
deleteCmd.CommandText = "DELETE FROM ContactNumbers WHERE ProviderKey = $providerKey;";
|
||||
deleteCmd.Parameters.AddWithValue("$providerKey", providerKey);
|
||||
await deleteCmd.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
|
|
@ -87,10 +92,11 @@ public sealed class ContactCache
|
|||
insertCmd.Transaction = transaction;
|
||||
insertCmd.CommandText = """
|
||||
INSERT INTO ContactNumbers
|
||||
(NormalizedNumber, Source, ContactId, DisplayName, Company, PhotoUrl, SyncedAtUtc)
|
||||
(NormalizedNumber, ProviderKey, Source, ContactId, DisplayName, Company, PhotoUrl, SyncedAtUtc)
|
||||
VALUES
|
||||
($number, $source, $contactId, $displayName, $company, $photoUrl, $syncedAt)
|
||||
ON CONFLICT(NormalizedNumber, Source) DO UPDATE SET
|
||||
($number, $providerKey, $source, $contactId, $displayName, $company, $photoUrl, $syncedAt)
|
||||
ON CONFLICT(NormalizedNumber, ProviderKey) DO UPDATE SET
|
||||
Source = excluded.Source,
|
||||
ContactId = excluded.ContactId,
|
||||
DisplayName = excluded.DisplayName,
|
||||
Company = excluded.Company,
|
||||
|
|
@ -99,6 +105,7 @@ public sealed class ContactCache
|
|||
""";
|
||||
|
||||
var pNumber = insertCmd.CreateParameter(); pNumber.ParameterName = "$number"; insertCmd.Parameters.Add(pNumber);
|
||||
var pProviderKey = insertCmd.CreateParameter(); pProviderKey.ParameterName = "$providerKey"; pProviderKey.Value = providerKey; insertCmd.Parameters.Add(pProviderKey);
|
||||
var pSource = insertCmd.CreateParameter(); pSource.ParameterName = "$source"; pSource.Value = source.ToString(); insertCmd.Parameters.Add(pSource);
|
||||
var pContactId = insertCmd.CreateParameter(); pContactId.ParameterName = "$contactId"; insertCmd.Parameters.Add(pContactId);
|
||||
var pDisplayName = insertCmd.CreateParameter(); pDisplayName.ParameterName = "$displayName"; insertCmd.Parameters.Add(pDisplayName);
|
||||
|
|
@ -128,7 +135,7 @@ public sealed class ContactCache
|
|||
}
|
||||
|
||||
transaction.Commit();
|
||||
_logger.LogInformation("Cache fuer {Source} aktualisiert: {Count} Kontakte", source, contacts.Count);
|
||||
_logger.LogInformation("Cache fuer {ProviderKey} aktualisiert: {Count} Kontakte", providerKey, contacts.Count);
|
||||
}
|
||||
|
||||
public async Task<ContactInfo?> TryFindByNumberAsync(string normalizedNumber, CancellationToken cancellationToken)
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ public sealed class ContactSyncService(
|
|||
try
|
||||
{
|
||||
var contacts = await provider.GetAllContactsAsync(cancellationToken);
|
||||
await cache.ReplaceContactsForSourceAsync(provider.Source, contacts, cancellationToken);
|
||||
await cache.ReplaceContactsForProviderAsync(provider.ProviderKey, provider.Source, contacts, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -14,7 +14,11 @@ public sealed class ContactsOptions
|
|||
/// <summary>Wie lange ein On-Demand-Reverse-Lookup-Ergebnis (oeffentliches Verzeichnis) im Cache bleibt.</summary>
|
||||
public TimeSpan ReverseLookupCacheTtl { get; set; } = TimeSpan.FromDays(7);
|
||||
|
||||
public Office365Options Office365 { get; set; } = new();
|
||||
/// <summary>
|
||||
/// Ein oder mehrere Office365/Outlook-Accounts (Firmen-Tenant UND/ODER outlook.com/persoenliche
|
||||
/// Microsoft-Accounts). Jeder Account bekommt einen eigenen Login und eigenen Token-Cache.
|
||||
/// </summary>
|
||||
public List<Office365AccountOptions> Office365Accounts { get; set; } = [];
|
||||
|
||||
public GoogleOptions Google { get; set; } = new();
|
||||
|
||||
|
|
@ -25,14 +29,24 @@ public sealed class ContactsOptions
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// App-Registration in Azure AD (Entra ID) mit Delegated Permission "Contacts.Read".
|
||||
/// Anmeldung per Device-Code-Flow (kein Client-Secret noetig, daher unbedenklich in appsettings.json,
|
||||
/// solange es sich um eine "Public Client"-Registrierung handelt).
|
||||
/// Ein einzelner Office365/Outlook-Account. App-Registration in Azure AD (Entra ID) mit
|
||||
/// Delegated Permission "Contacts.Read". Anmeldung per Device-Code-Flow (kein Client-Secret
|
||||
/// noetig, daher unbedenklich in appsettings.json, solange es sich um eine "Public Client"-
|
||||
/// Registrierung handelt).
|
||||
/// </summary>
|
||||
public sealed class Office365Options
|
||||
public sealed class Office365AccountOptions
|
||||
{
|
||||
/// <summary>Eindeutiger, frei waehlbarer Name (z. B. "work", "outlook") - bestimmt Cache-/Token-Trennung.</summary>
|
||||
public string Name { get; set; } = "default";
|
||||
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// "common" fuer Arbeits-/Schulkonten UND persoenliche Microsoft-Konten (outlook.com) gemischt,
|
||||
/// "consumers" nur fuer outlook.com/persoenliche Konten, oder eine konkrete Tenant-GUID fuer
|
||||
/// genau einen Firmen-Tenant. Die App-Registration muss den jeweiligen Kontotyp erlauben
|
||||
/// (Authentifizierung -> Unterstuetzte Kontotypen in Azure).
|
||||
/// </summary>
|
||||
public string TenantId { get; set; } = "common";
|
||||
|
||||
public string ClientId { get; set; } = string.Empty;
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ public sealed class GoogleContactProvider : IContactProvider
|
|||
|
||||
public ContactSource Source => ContactSource.Google;
|
||||
|
||||
public string ProviderKey => "google:default";
|
||||
|
||||
public bool IsEnabled =>
|
||||
_options.Google.Enabled
|
||||
&& !string.IsNullOrWhiteSpace(_options.Google.ClientId)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,13 @@ public interface IContactProvider
|
|||
{
|
||||
ContactSource Source { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Eindeutiger Schluessel dieser Provider-INSTANZ (z. B. "office365:work", "office365:outlook",
|
||||
/// "google:default"). Trennt den Cache pro Account, damit z. B. zwei Office365-Accounts sich
|
||||
/// nicht gegenseitig beim Sync ueberschreiben.
|
||||
/// </summary>
|
||||
string ProviderKey { get; }
|
||||
|
||||
/// <summary>Ob der Provider konfiguriert/aktiviert ist (z. B. Zugangsdaten vorhanden).</summary>
|
||||
bool IsEnabled { get; }
|
||||
|
||||
|
|
|
|||
|
|
@ -1,43 +1,52 @@
|
|||
using System.IO;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Graph;
|
||||
using Microsoft.Graph.Models;
|
||||
|
||||
namespace Anrufmonitor.Contacts;
|
||||
|
||||
/// <summary>
|
||||
/// Liest die Outlook/Office365-Kontakte des angemeldeten Benutzers ueber Microsoft Graph.
|
||||
/// Liest die Outlook/Office365-Kontakte EINES Accounts (Firmen-Tenant oder persoenliches
|
||||
/// outlook.com-Konto, siehe <see cref="Office365AccountOptions.TenantId"/>) ueber Microsoft Graph.
|
||||
/// Fuer mehrere Accounts wird pro Account eine eigene Instanz erzeugt (siehe App.xaml.cs) -
|
||||
/// jede Instanz hat ihren eigenen Token-Cache und ihren eigenen persistierten AuthenticationRecord,
|
||||
/// damit Logins der verschiedenen Accounts sich nicht gegenseitig ueberschreiben.
|
||||
/// Anmeldung per Device-Code-Flow. Nur beim allerersten Mal interaktiv: der MSAL-Token-Cache
|
||||
/// UND der <see cref="AuthenticationRecord"/> werden lokal persistiert (letzterer sagt MSAL,
|
||||
/// welcher Account beim naechsten Start automatisch/silent verwendet werden soll - ohne ihn
|
||||
/// wuerde trotz persistiertem Cache jedes Mal wieder interaktiv nachgefragt).
|
||||
/// Benoetigte App-Registration (Azure/Entra Portal): Public client, Delegated Permission "Contacts.Read".
|
||||
/// Fuer outlook.com/persoenliche Konten muss die App-Registration unter "Unterstuetzte Kontotypen"
|
||||
/// auch persoenliche Microsoft-Konten erlauben, und TenantId muss "common" oder "consumers" sein.
|
||||
/// </summary>
|
||||
public sealed class Office365ContactProvider : IContactProvider
|
||||
{
|
||||
private readonly ContactsOptions _options;
|
||||
private readonly Office365AccountOptions _account;
|
||||
private readonly ILogger<Office365ContactProvider> _logger;
|
||||
|
||||
public Office365ContactProvider(IOptions<ContactsOptions> options, ILogger<Office365ContactProvider> logger)
|
||||
public Office365ContactProvider(Office365AccountOptions account, ILogger<Office365ContactProvider> logger)
|
||||
{
|
||||
_options = options.Value;
|
||||
_account = account;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public ContactSource Source => ContactSource.Office365;
|
||||
|
||||
public bool IsEnabled => _options.Office365.Enabled && !string.IsNullOrWhiteSpace(_options.Office365.ClientId);
|
||||
public string ProviderKey => $"office365:{_account.Name}";
|
||||
|
||||
public string AccountName => _account.Name;
|
||||
|
||||
public bool IsEnabled => _account.Enabled && !string.IsNullOrWhiteSpace(_account.ClientId);
|
||||
|
||||
/// <summary>Feuert mit der fuer den Benutzer bestimmten Anmelde-Nachricht (Code + URL) des Device-Code-Flows.</summary>
|
||||
public event Action<string>? DeviceCodeRequired;
|
||||
|
||||
private static string AuthRecordPath =>
|
||||
private string AuthRecordPath =>
|
||||
Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"Anrufmonitor",
|
||||
"office365-auth-record.json");
|
||||
$"office365-auth-record-{_account.Name}.json");
|
||||
|
||||
public async Task<IReadOnlyList<ContactInfo>> GetAllContactsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
|
|
@ -72,7 +81,7 @@ public sealed class Office365ContactProvider : IContactProvider
|
|||
|
||||
await iterator.IterateAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("Office365: {Count} Kontakte gelesen", result.Count);
|
||||
_logger.LogInformation("Office365 ({Account}): {Count} Kontakte gelesen", _account.Name, result.Count);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
@ -89,28 +98,19 @@ public sealed class Office365ContactProvider : IContactProvider
|
|||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Gespeicherter Office365-AuthenticationRecord konnte nicht gelesen werden, melde erneut interaktiv an");
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Gespeicherter Office365-AuthenticationRecord ({Account}) konnte nicht gelesen werden, melde erneut interaktiv an",
|
||||
_account.Name);
|
||||
}
|
||||
}
|
||||
|
||||
var credential = new DeviceCodeCredential(new DeviceCodeCredentialOptions
|
||||
{
|
||||
ClientId = _options.Office365.ClientId,
|
||||
TenantId = _options.Office365.TenantId,
|
||||
TokenCachePersistenceOptions = new TokenCachePersistenceOptions
|
||||
{
|
||||
Name = "Anrufmonitor.Office365",
|
||||
},
|
||||
AuthenticationRecord = authRecord,
|
||||
DeviceCodeCallback = (info, ct) =>
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Office365-Anmeldung erforderlich: {Message}",
|
||||
info.Message);
|
||||
DeviceCodeRequired?.Invoke(info.Message);
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
});
|
||||
// WICHTIG: dasselbe Credential-Objekt wird unten sowohl fuer den (optionalen) initialen
|
||||
// AuthenticateAsync-Aufruf als auch fuer den eigentlichen Graph-Call wiederverwendet.
|
||||
// Ein zweites, separat konstruiertes Credential-Objekt landet MSAL-intern in einer
|
||||
// anderen Cache-Partition (CAE vs. NoCAE) und wuerde trotz gueltigem Cache erneut
|
||||
// interaktiv nachfragen.
|
||||
var credential = CreateDeviceCodeCredential(authRecord);
|
||||
|
||||
if (authRecord is null)
|
||||
{
|
||||
|
|
@ -124,12 +124,32 @@ public sealed class Office365ContactProvider : IContactProvider
|
|||
await using var writeStream = File.Create(AuthRecordPath);
|
||||
await newRecord.SerializeAsync(writeStream, cancellationToken);
|
||||
|
||||
_logger.LogInformation("Office365-Anmeldung gespeichert fuer {Account}", newRecord.Username);
|
||||
_logger.LogInformation("Office365-Anmeldung ({Account}) gespeichert fuer {Username}", _account.Name, newRecord.Username);
|
||||
}
|
||||
|
||||
return credential;
|
||||
}
|
||||
|
||||
private DeviceCodeCredential CreateDeviceCodeCredential(AuthenticationRecord? authenticationRecord) =>
|
||||
new(new DeviceCodeCredentialOptions
|
||||
{
|
||||
ClientId = _account.ClientId,
|
||||
TenantId = _account.TenantId,
|
||||
TokenCachePersistenceOptions = new TokenCachePersistenceOptions
|
||||
{
|
||||
Name = $"Anrufmonitor.Office365.{_account.Name}",
|
||||
},
|
||||
AuthenticationRecord = authenticationRecord,
|
||||
DeviceCodeCallback = (info, ct) =>
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Office365-Anmeldung ({Account}) erforderlich: {Message}",
|
||||
_account.Name, info.Message);
|
||||
DeviceCodeRequired?.Invoke(info.Message);
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
});
|
||||
|
||||
private static ContactInfo MapContact(Contact contact)
|
||||
{
|
||||
var numbers = new List<string>();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue