Office365 Multi-Account (work + outlook.com), Cache pro Account, Tray-Icon mit Status-Punkt

This commit is contained in:
dimedtec 2026-08-28 18:11:08 +02:00
parent 22c2ac1b2d
commit cd2cf5d1bb
11 changed files with 208 additions and 68 deletions

View file

@ -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) =>
{

View 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);
}
}
}

View file

@ -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();
}
}

View file

@ -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",

View file

@ -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": "",

View file

@ -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)

View file

@ -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)
{

View file

@ -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;

View file

@ -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)

View file

@ -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; }

View file

@ -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>();