Add Jarvis Google Calendar OAuth broker

This commit is contained in:
zsolt
2026-08-13 13:22:40 +02:00
parent 94f09a3328
commit 7e433ead88
10 changed files with 567 additions and 236 deletions
+375 -2
View File
@@ -1,9 +1,12 @@
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Security.Claims; using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json; using System.Text.Json;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -151,6 +154,214 @@ app.MapGet("/api/app-config", async (HttpContext context, OtthonDbContext db) =>
!string.IsNullOrWhiteSpace(googleClientId))); !string.IsNullOrWhiteSpace(googleClientId)));
}); });
app.MapGet("/api/google/oauth/start", async (
HttpContext context,
OtthonDbContext db) =>
{
if (!HasApiToken(context))
{
return Results.Unauthorized();
}
var workspaceToken = GetWorkspaceToken(context);
if (string.IsNullOrWhiteSpace(workspaceToken))
{
return Results.BadRequest(new { message = "Missing X-Workspace-Token header." });
}
var clientId = await GetGoogleClientId(db);
var clientSecret = await GetGoogleClientSecret(db);
if (string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(clientSecret))
{
return Results.BadRequest(new { message = "Google OAuth client is not configured." });
}
var stateValue = RandomToken(32);
db.GoogleOAuthStates.Add(new GoogleOAuthState
{
State = stateValue,
WorkspaceToken = workspaceToken,
CreatedUtc = DateTimeOffset.UtcNow,
ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(15)
});
await db.SaveChangesAsync();
var redirectUri = GoogleOAuthRedirectUri();
var authUri = QueryHelpers.AddQueryString("https://accounts.google.com/o/oauth2/v2/auth", new Dictionary<string, string?>
{
["client_id"] = clientId,
["redirect_uri"] = redirectUri,
["response_type"] = "code",
["scope"] = "openid email https://www.googleapis.com/auth/calendar.readonly",
["access_type"] = "offline",
["prompt"] = "consent",
["state"] = stateValue
});
return Results.Ok(new GoogleOAuthStartResponse(authUri));
});
app.MapGet("/api/google/oauth/callback", async (
string? code,
string? state,
string? error,
OtthonDbContext db,
IHttpClientFactory httpClientFactory) =>
{
if (!string.IsNullOrWhiteSpace(error))
{
return Results.Content(GoogleOAuthResultPage(false, $"Google rejected the request: {Html(error)}"), "text/html; charset=utf-8");
}
if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(state))
{
return Results.Content(GoogleOAuthResultPage(false, "Missing OAuth code or state."), "text/html; charset=utf-8");
}
var savedState = await db.GoogleOAuthStates.FindAsync(state);
if (savedState is null || savedState.ExpiresUtc < DateTimeOffset.UtcNow)
{
return Results.Content(GoogleOAuthResultPage(false, "OAuth session expired. Start Google connection again from the tablet."), "text/html; charset=utf-8");
}
var clientId = await GetGoogleClientId(db);
var clientSecret = await GetGoogleClientSecret(db);
if (string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(clientSecret))
{
return Results.Content(GoogleOAuthResultPage(false, "Google OAuth client is not configured in JARVIS."), "text/html; charset=utf-8");
}
var http = httpClientFactory.CreateClient();
var tokenResponse = await http.PostAsync(
"https://oauth2.googleapis.com/token",
new FormUrlEncodedContent(new Dictionary<string, string>
{
["code"] = code,
["client_id"] = clientId,
["client_secret"] = clientSecret,
["redirect_uri"] = GoogleOAuthRedirectUri(),
["grant_type"] = "authorization_code"
}));
var tokenBody = await tokenResponse.Content.ReadAsStringAsync();
if (!tokenResponse.IsSuccessStatusCode)
{
return Results.Content(GoogleOAuthResultPage(false, $"Token exchange failed: {Html(tokenBody)}"), "text/html; charset=utf-8");
}
using var tokenJson = JsonDocument.Parse(tokenBody);
var accessToken = GetString(tokenJson.RootElement, "access_token");
var refreshToken = GetString(tokenJson.RootElement, "refresh_token");
var expiresIn = tokenJson.RootElement.TryGetProperty("expires_in", out var expiresElement)
? expiresElement.GetInt32()
: 3600;
if (string.IsNullOrWhiteSpace(accessToken))
{
return Results.Content(GoogleOAuthResultPage(false, "Google did not return an access token."), "text/html; charset=utf-8");
}
var email = await LoadGoogleEmail(http, accessToken);
var auth = await db.GoogleCalendarAuths.FindAsync(savedState.WorkspaceToken);
if (auth is null)
{
auth = new GoogleCalendarAuth
{
WorkspaceToken = savedState.WorkspaceToken,
AccessToken = accessToken,
RefreshToken = refreshToken ?? "",
Email = email,
ExpiresUtc = DateTimeOffset.UtcNow.AddSeconds(Math.Max(60, expiresIn - 60)),
UpdatedUtc = DateTimeOffset.UtcNow
};
db.GoogleCalendarAuths.Add(auth);
}
else
{
auth.AccessToken = accessToken;
if (!string.IsNullOrWhiteSpace(refreshToken))
{
auth.RefreshToken = refreshToken;
}
auth.Email = email ?? auth.Email;
auth.ExpiresUtc = DateTimeOffset.UtcNow.AddSeconds(Math.Max(60, expiresIn - 60));
auth.UpdatedUtc = DateTimeOffset.UtcNow;
}
db.GoogleOAuthStates.Remove(savedState);
await db.SaveChangesAsync();
return Results.Content(GoogleOAuthResultPage(true, $"Google Calendar connected for {Html(auth.Email ?? "the selected account")}."), "text/html; charset=utf-8");
});
app.MapGet("/api/google/oauth/status", async (HttpContext context, OtthonDbContext db) =>
{
if (!HasApiToken(context)) return Results.Unauthorized();
var workspaceToken = GetWorkspaceToken(context);
if (string.IsNullOrWhiteSpace(workspaceToken))
{
return Results.BadRequest(new { message = "Missing X-Workspace-Token header." });
}
var auth = await db.GoogleCalendarAuths.FindAsync(workspaceToken);
return Results.Ok(new GoogleOAuthStatusResponse(auth is not null, auth?.Email, auth?.UpdatedUtc));
});
app.MapGet("/api/google/calendars", async (
HttpContext context,
OtthonDbContext db,
IHttpClientFactory httpClientFactory) =>
{
if (!HasApiToken(context)) return Results.Unauthorized();
var workspaceToken = GetWorkspaceToken(context);
if (string.IsNullOrWhiteSpace(workspaceToken))
{
return Results.BadRequest(new { message = "Missing X-Workspace-Token header." });
}
var token = await GetGoogleAccessToken(db, httpClientFactory, workspaceToken);
if (token is null) return Results.BadRequest(new { message = "Google Calendar is not connected." });
var http = httpClientFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Get, "https://www.googleapis.com/calendar/v3/users/me/calendarList?minAccessRole=reader&showHidden=true");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await http.SendAsync(request);
var body = await response.Content.ReadAsStringAsync();
return Results.Content(body, "application/json; charset=utf-8", statusCode: (int)response.StatusCode);
});
app.MapGet("/api/google/events", async (
HttpContext context,
string calendarId,
string? timeMin,
string? timeMax,
OtthonDbContext db,
IHttpClientFactory httpClientFactory) =>
{
if (!HasApiToken(context)) return Results.Unauthorized();
var workspaceToken = GetWorkspaceToken(context);
if (string.IsNullOrWhiteSpace(workspaceToken))
{
return Results.BadRequest(new { message = "Missing X-Workspace-Token header." });
}
var token = await GetGoogleAccessToken(db, httpClientFactory, workspaceToken);
if (token is null) return Results.BadRequest(new { message = "Google Calendar is not connected." });
var query = new Dictionary<string, string?>
{
["singleEvents"] = "true",
["orderBy"] = "startTime",
["timeMin"] = timeMin,
["timeMax"] = timeMax,
["maxResults"] = "2500"
};
var uri = QueryHelpers.AddQueryString(
$"https://www.googleapis.com/calendar/v3/calendars/{Uri.EscapeDataString(calendarId)}/events",
query);
var http = httpClientFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Get, uri);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await http.SendAsync(request);
var body = await response.Content.ReadAsStringAsync();
return Results.Content(body, "application/json; charset=utf-8", statusCode: (int)response.StatusCode);
});
app.MapPost("/api/devices/register", async ( app.MapPost("/api/devices/register", async (
DeviceRegistrationRequest request, DeviceRegistrationRequest request,
HttpContext context, HttpContext context,
@@ -661,6 +872,88 @@ static async Task SetSetting(OtthonDbContext db, string key, string value)
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
static string RandomToken(int byteCount)
{
var bytes = RandomNumberGenerator.GetBytes(byteCount);
return Convert.ToBase64String(bytes)
.Replace("+", "-")
.Replace("/", "_")
.TrimEnd('=');
}
static string GoogleOAuthRedirectUri() =>
"https://api-test.a-web.hu/api/google/oauth/callback";
static string GoogleCalendarServerClientIdSettingKey() => "GoogleCalendarServerClientId";
static string GoogleCalendarClientSecretSettingKey() => "GoogleCalendarClientSecret";
static async Task<string?> GetGoogleClientId(OtthonDbContext db) =>
await GetSetting(db, GoogleCalendarServerClientIdSettingKey());
static async Task<string?> GetGoogleClientSecret(OtthonDbContext db) =>
await GetSetting(db, GoogleCalendarClientSecretSettingKey());
static async Task<string?> LoadGoogleEmail(HttpClient http, string accessToken)
{
try
{
var request = new HttpRequestMessage(HttpMethod.Get, "https://www.googleapis.com/oauth2/v2/userinfo");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var response = await http.SendAsync(request);
if (!response.IsSuccessStatusCode) return null;
var body = await response.Content.ReadAsStringAsync();
using var json = JsonDocument.Parse(body);
return GetString(json.RootElement, "email");
}
catch
{
return null;
}
}
static async Task<string?> GetGoogleAccessToken(
OtthonDbContext db,
IHttpClientFactory httpClientFactory,
string workspaceToken)
{
var auth = await db.GoogleCalendarAuths.FindAsync(workspaceToken);
if (auth is null) return null;
if (auth.ExpiresUtc > DateTimeOffset.UtcNow.AddMinutes(2))
{
return auth.AccessToken;
}
if (string.IsNullOrWhiteSpace(auth.RefreshToken)) return null;
var clientId = await GetGoogleClientId(db);
var clientSecret = await GetGoogleClientSecret(db);
if (string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(clientSecret)) return null;
var http = httpClientFactory.CreateClient();
var response = await http.PostAsync(
"https://oauth2.googleapis.com/token",
new FormUrlEncodedContent(new Dictionary<string, string>
{
["client_id"] = clientId,
["client_secret"] = clientSecret,
["refresh_token"] = auth.RefreshToken,
["grant_type"] = "refresh_token"
}));
var body = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode) return null;
using var json = JsonDocument.Parse(body);
var accessToken = GetString(json.RootElement, "access_token");
if (string.IsNullOrWhiteSpace(accessToken)) return null;
var expiresIn = json.RootElement.TryGetProperty("expires_in", out var expiresElement)
? expiresElement.GetInt32()
: 3600;
auth.AccessToken = accessToken;
auth.ExpiresUtc = DateTimeOffset.UtcNow.AddSeconds(Math.Max(60, expiresIn - 60));
auth.UpdatedUtc = DateTimeOffset.UtcNow;
await db.SaveChangesAsync();
return auth.AccessToken;
}
static void ApplyProductLookup(Product target, Product lookup, string source) static void ApplyProductLookup(Product target, Product lookup, string source)
{ {
target.Name = lookup.Name; target.Name = lookup.Name;
@@ -747,6 +1040,7 @@ static async Task EnsureDatabaseReady(OtthonDbContext db, ILogger logger)
await EnsureFoodLearningSchemaAsync(db); await EnsureFoodLearningSchemaAsync(db);
await EnsureWorkspaceSchemaAsync(db); await EnsureWorkspaceSchemaAsync(db);
await EnsureAppSettingsSchemaAsync(db); await EnsureAppSettingsSchemaAsync(db);
await EnsureGoogleCalendarSchemaAsync(db);
return; return;
} }
catch (Exception ex) when (attempt < maxAttempts) catch (Exception ex) when (attempt < maxAttempts)
@@ -765,6 +1059,7 @@ static async Task EnsureDatabaseReady(OtthonDbContext db, ILogger logger)
await EnsureFoodLearningSchemaAsync(db); await EnsureFoodLearningSchemaAsync(db);
await EnsureWorkspaceSchemaAsync(db); await EnsureWorkspaceSchemaAsync(db);
await EnsureAppSettingsSchemaAsync(db); await EnsureAppSettingsSchemaAsync(db);
await EnsureGoogleCalendarSchemaAsync(db);
} }
static async Task EnsureProductSchemaAsync(OtthonDbContext db) static async Task EnsureProductSchemaAsync(OtthonDbContext db)
@@ -815,6 +1110,33 @@ END
await db.Database.ExecuteSqlRawAsync(sql); await db.Database.ExecuteSqlRawAsync(sql);
} }
static async Task EnsureGoogleCalendarSchemaAsync(OtthonDbContext db)
{
var sql = """
IF OBJECT_ID('GoogleCalendarAuths', 'U') IS NULL
BEGIN
CREATE TABLE GoogleCalendarAuths (
WorkspaceToken nvarchar(160) NOT NULL CONSTRAINT PK_GoogleCalendarAuths PRIMARY KEY,
Email nvarchar(320) NULL,
AccessToken nvarchar(max) NOT NULL,
RefreshToken nvarchar(max) NOT NULL,
ExpiresUtc datetimeoffset NOT NULL,
UpdatedUtc datetimeoffset NOT NULL
);
END
IF OBJECT_ID('GoogleOAuthStates', 'U') IS NULL
BEGIN
CREATE TABLE GoogleOAuthStates (
[State] nvarchar(160) NOT NULL CONSTRAINT PK_GoogleOAuthStates PRIMARY KEY,
WorkspaceToken nvarchar(160) NOT NULL,
CreatedUtc datetimeoffset NOT NULL,
ExpiresUtc datetimeoffset NOT NULL
);
END
""";
await db.Database.ExecuteSqlRawAsync(sql);
}
static async Task EnsureFoodLearningSchemaAsync(OtthonDbContext db) static async Task EnsureFoodLearningSchemaAsync(OtthonDbContext db)
{ {
var sql = """ var sql = """
@@ -1047,9 +1369,27 @@ static string SettingsPage(string googleClientId) => $$"""
</html> </html>
"""; """;
static string Html(string? value) => System.Net.WebUtility.HtmlEncode(value ?? ""); static string GoogleOAuthResultPage(bool success, string message) => $$"""
<!doctype html>
<html lang="hu">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Google Calendar connection</title>
<style>{{AdminCss()}}</style>
</head>
<body>
<main class="card narrow">
<p class="eyebrow">JARVIS API</p>
<h1>{{(success ? "Google Calendar connected" : "Google Calendar error")}}</h1>
<p class="{{(success ? "hint" : "error")}}">{{message}}</p>
<p>You can close this browser window and return to Közös Tér.</p>
</main>
</body>
</html>
""";
static string GoogleCalendarServerClientIdSettingKey() => "GoogleCalendarServerClientId"; static string Html(string? value) => System.Net.WebUtility.HtmlEncode(value ?? "");
static string AdminCss() => """ static string AdminCss() => """
:root { color-scheme: light; font-family: Inter, Segoe UI, Arial, sans-serif; } :root { color-scheme: light; font-family: Inter, Segoe UI, Arial, sans-serif; }
@@ -1091,11 +1431,19 @@ public sealed class OtthonDbContext(DbContextOptions<OtthonDbContext> options)
public DbSet<FoodLearningEvent> FoodLearningEvents => Set<FoodLearningEvent>(); public DbSet<FoodLearningEvent> FoodLearningEvents => Set<FoodLearningEvent>();
public DbSet<WorkspaceState> Workspaces => Set<WorkspaceState>(); public DbSet<WorkspaceState> Workspaces => Set<WorkspaceState>();
public DbSet<AppSetting> AppSettings => Set<AppSetting>(); public DbSet<AppSetting> AppSettings => Set<AppSetting>();
public DbSet<GoogleCalendarAuth> GoogleCalendarAuths => Set<GoogleCalendarAuth>();
public DbSet<GoogleOAuthState> GoogleOAuthStates => Set<GoogleOAuthState>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
modelBuilder.Entity<AppSetting>().HasKey(setting => setting.Key); modelBuilder.Entity<AppSetting>().HasKey(setting => setting.Key);
modelBuilder.Entity<AppSetting>().Property(setting => setting.Key).HasMaxLength(160); modelBuilder.Entity<AppSetting>().Property(setting => setting.Key).HasMaxLength(160);
modelBuilder.Entity<GoogleCalendarAuth>().HasKey(auth => auth.WorkspaceToken);
modelBuilder.Entity<GoogleCalendarAuth>().Property(auth => auth.WorkspaceToken).HasMaxLength(160);
modelBuilder.Entity<GoogleCalendarAuth>().Property(auth => auth.Email).HasMaxLength(320);
modelBuilder.Entity<GoogleOAuthState>().HasKey(state => state.State);
modelBuilder.Entity<GoogleOAuthState>().Property(state => state.State).HasMaxLength(160);
modelBuilder.Entity<GoogleOAuthState>().Property(state => state.WorkspaceToken).HasMaxLength(160);
modelBuilder.Entity<WorkspaceState>().HasKey(workspace => workspace.Token); modelBuilder.Entity<WorkspaceState>().HasKey(workspace => workspace.Token);
modelBuilder.Entity<WorkspaceState>().Property(workspace => workspace.Token).HasMaxLength(160); modelBuilder.Entity<WorkspaceState>().Property(workspace => workspace.Token).HasMaxLength(160);
modelBuilder.Entity<Product>().HasKey(product => product.Barcode); modelBuilder.Entity<Product>().HasKey(product => product.Barcode);
@@ -1177,6 +1525,24 @@ public sealed class AppSetting
public DateTimeOffset UpdatedUtc { get; set; } public DateTimeOffset UpdatedUtc { get; set; }
} }
public sealed class GoogleCalendarAuth
{
public required string WorkspaceToken { get; set; }
public string? Email { get; set; }
public required string AccessToken { get; set; }
public required string RefreshToken { get; set; }
public DateTimeOffset ExpiresUtc { get; set; }
public DateTimeOffset UpdatedUtc { get; set; }
}
public sealed class GoogleOAuthState
{
public required string State { get; set; }
public required string WorkspaceToken { get; set; }
public DateTimeOffset CreatedUtc { get; set; }
public DateTimeOffset ExpiresUtc { get; set; }
}
public sealed record LoginForm(string Password); public sealed record LoginForm(string Password);
public sealed record GoogleCalendarSettingsForm(string? ServerClientId); public sealed record GoogleCalendarSettingsForm(string? ServerClientId);
public sealed record DeviceRegistrationRequest(string? DeviceName, string? Platform); public sealed record DeviceRegistrationRequest(string? DeviceName, string? Platform);
@@ -1252,6 +1618,13 @@ public sealed record AppConfigResponse(
string? GoogleCalendarServerClientId, string? GoogleCalendarServerClientId,
bool GoogleCalendarConfigured); bool GoogleCalendarConfigured);
public sealed record GoogleOAuthStartResponse(string AuthorizationUrl);
public sealed record GoogleOAuthStatusResponse(
bool Connected,
string? Email,
DateTimeOffset? UpdatedUtc);
public sealed record ProductResponse( public sealed record ProductResponse(
bool Found, bool Found,
string Barcode, string Barcode,
+115 -182
View File
@@ -25,15 +25,7 @@ class AccountsPage extends StatefulWidget {
} }
class _AccountsPageState extends State<AccountsPage> { class _AccountsPageState extends State<AccountsPage> {
static const calendarScopes = [
'https://www.googleapis.com/auth/calendar.readonly',
];
final googleSignIn = GoogleSignIn.instance;
List<GoogleCalendarInfo> googleCalendars = []; List<GoogleCalendarInfo> googleCalendars = [];
GoogleSignInAccount? signedInAccount;
String? runtimeServerClientId;
bool initialized = false;
bool loadingCalendars = false; bool loadingCalendars = false;
bool syncing = false; bool syncing = false;
String? statusMessage; String? statusMessage;
@@ -41,209 +33,156 @@ class _AccountsPageState extends State<AccountsPage> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadGoogleAppConfigAndInitialize(); _loadGoogleConnectionStatus();
} }
GoogleCalendarConnection? get connection => GoogleCalendarConnection? get connection =>
widget.connections.isEmpty ? null : widget.connections.first; widget.connections.isEmpty ? null : widget.connections.first;
Future<void> _loadGoogleAppConfigAndInitialize() async { String? get _workspaceToken {
setState(() { final token = widget.jarvisSettings.workspaceToken?.trim();
loadingCalendars = true; return token == null || token.isEmpty ? null : token;
statusMessage = 'Google napt\u00e1rkapcsolat el\u0151k\u00e9sz\u00edt\u00e9se...';
});
runtimeServerClientId = widget.serverClientId.trim().isNotEmpty
? widget.serverClientId.trim()
: await _loadGoogleServerClientId();
if (!mounted) return;
await _initializeGoogle();
} }
Future<String?> _loadGoogleServerClientId() async { Map<String, String> get _jarvisHeaders {
if (googleCalendarServerClientId.trim().isNotEmpty) { final headers = <String, String>{
return googleCalendarServerClientId.trim(); 'X-Otthon-Token': widget.jarvisSettings.apiToken.trim(),
};
final workspaceToken = _workspaceToken;
if (workspaceToken != null) {
headers['X-Workspace-Token'] = workspaceToken;
} }
return headers;
}
final apiToken = widget.jarvisSettings.apiToken.trim(); Uri _jarvisUri(String path, [Map<String, String?> query = const {}]) {
if (apiToken.isEmpty) { final baseUri = Uri.parse(normalizeJarvisUrl(widget.jarvisSettings.apiUrl));
return null; return baseUri.replace(path: path, queryParameters: query.isEmpty ? null : query);
}
String? _jarvisConfigError() {
if (widget.jarvisSettings.apiToken.trim().isEmpty) {
return 'Add meg a JARVIS API tokent a fogasker\u00e9k alatt.';
} }
if (_workspaceToken == null) {
return 'Add meg a k\u00f6z\u00f6s munkat\u00e9r tokent a fogasker\u00e9k alatt.';
}
return null;
}
Future<void> _loadGoogleConnectionStatus() async {
setState(() {
loadingCalendars = true;
statusMessage = 'JARVIS Google kapcsolat ellen\u0151rz\u00e9se...';
});
final configError = _jarvisConfigError();
if (configError != null) {
setState(() {
loadingCalendars = false;
statusMessage = configError;
});
return;
}
try { try {
final baseUri = Uri.parse(normalizeJarvisUrl(widget.jarvisSettings.apiUrl));
final uri = baseUri.replace(path: '/api/app-config', query: '');
final response = await http final response = await http
.get(uri, headers: {'X-Otthon-Token': apiToken}) .get(_jarvisUri('/api/google/oauth/status'), headers: _jarvisHeaders)
.timeout(const Duration(seconds: 10)); .timeout(const Duration(seconds: 10));
if (response.statusCode != 200) { if (response.statusCode != 200) {
return null; throw Exception('JARVIS status ${response.statusCode}: ${response.body}');
} }
final payload = jsonDecode(response.body); final payload = jsonDecode(response.body);
if (payload is! Map<String, dynamic>) { if (payload is! Map<String, dynamic>) {
return null; throw Exception('Hib\u00e1s JARVIS v\u00e1lasz.');
} }
return payload['googleCalendarServerClientId'] as String?; final connected = payload['connected'] == true;
} catch (_) { final email = payload['email'] as String?;
return null; if (connected) {
} final current = connection;
} widget.onChanged([
GoogleCalendarConnection(
Future<void> _initializeGoogle() async { id: current?.id ?? uniqueId(),
setState(() { accountEmail: email ?? current?.accountEmail ?? 'Google fi\u00f3k',
loadingCalendars = true; enabledCalendarIds: current?.enabledCalendarIds ?? const [],
statusMessage = 'Google kapcsolat el\u0151k\u00e9sz\u00edt\u00e9se...'; calendarPersonIds: current?.calendarPersonIds ?? const {},
}); lastSync: current?.lastSync,
),
try { ]);
await _ensureGoogleInitialized(); await _loadGoogleCalendars();
initialized = true;
final lightweight = googleSignIn.attemptLightweightAuthentication();
final account = lightweight == null ? null : await lightweight;
if (!mounted) return;
if (account != null) {
signedInAccount = account;
await _loadGoogleCalendars(promptIfNecessary: false);
} else { } else {
setState(() { setState(() {
loadingCalendars = false; loadingCalendars = false;
statusMessage = statusMessage =
'Jelentkezz be Google fi\u00f3kkal, majd v\u00e1laszd ki, mely napt\u00e1rak szinkroniz\u00e1ljanak.'; 'Google Calendar m\u00e9g nincs \u00f6sszek\u00f6tve. Ind\u00edtsd az \u00f6sszek\u00f6t\u00e9st JARVIS-on kereszt\u00fcl.';
}); });
} }
} catch (error) { } catch (error) {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
loadingCalendars = false; loadingCalendars = false;
statusMessage = _friendlyGoogleError( statusMessage = 'JARVIS Google kapcsolat nem el\u00e9rhet\u0151: $error';
'Google inicializ\u00e1l\u00e1s nem siker\u00fclt',
error,
);
}); });
} }
} }
Future<Map<String, String>> _googleHeaders({ Future<void> _startGoogleConnection() async {
bool promptIfNecessary = true,
}) async {
final account = signedInAccount;
final headers = account == null
? await googleSignIn.authorizationClient.authorizationHeaders(
calendarScopes,
promptIfNecessary: promptIfNecessary,
)
: await account.authorizationClient.authorizationHeaders(
calendarScopes,
promptIfNecessary: promptIfNecessary,
);
if (headers == null) {
throw Exception('A Google Calendar jogosults\u00e1g nincs megadva.');
}
return headers;
}
Future<void> _signInGoogle() async {
setState(() { setState(() {
loadingCalendars = true; loadingCalendars = true;
statusMessage = 'Google bejelentkez\u00e9s...'; statusMessage = 'Google \u00f6sszek\u00f6t\u00e9s ind\u00edt\u00e1sa JARVIS-on...';
}); });
try { final configError = _jarvisConfigError();
if (!initialized) { if (configError != null) {
runtimeServerClientId = widget.serverClientId.trim().isNotEmpty
? widget.serverClientId.trim()
: await _loadGoogleServerClientId();
await _ensureGoogleInitialized();
initialized = true;
}
final account = await googleSignIn.authenticate();
signedInAccount = account;
await account.authorizationClient.authorizeScopes(calendarScopes);
await _loadGoogleCalendars(promptIfNecessary: true);
} catch (error) {
if (!mounted) return;
final friendlyMessage = _friendlyGoogleError(
'Google bejelentkez\u00e9s nem siker\u00fclt',
error,
);
setState(() { setState(() {
loadingCalendars = false; loadingCalendars = false;
statusMessage = friendlyMessage; statusMessage = configError;
}); });
await _showGoogleErrorDialog(friendlyMessage);
}
}
String _friendlyGoogleError(String prefix, Object error) {
final raw = error.toString();
if (raw.contains('serverClientId must be provided') ||
raw.contains('CredentialManager requires a serverClientId')) {
return '$prefix: hi\u00e1nyzik az app Google konfigur\u00e1ci\u00f3ja. '
'A bel\u00e9p\u00e9si ablakos m\u0171k\u00f6d\u00e9shez tedd be az android/app/google-services.json f\u00e1jlt, '
'amely tartalmaz Web application OAuth client ID-t, majd buildeld \u00fajra az APK-t.';
}
if (raw.contains('Account reauth failed')) {
return '$prefix: a Google fi\u00f3k \u00fajrahiteles\u00edt\u00e9se nem siker\u00fclt. '
'Pr\u00f3b\u00e1ld meg a tableten a Google fi\u00f3kot friss\u00edteni/bejelentkeztetni a Be\u00e1ll\u00edt\u00e1sokban, '
'vagy t\u00f6r\u00f6ld az app Google hozz\u00e1f\u00e9r\u00e9s\u00e9t, majd jelentkezz be \u00fajra.';
}
return '$prefix: $raw';
}
Future<void> _showGoogleErrorDialog(String message) async {
if (!mounted) return;
await showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Google bel\u00e9p\u00e9s nem indult el'),
content: Text(
'$message\n\n'
'A v\u00e1rt m\u0171k\u00f6d\u00e9s az, hogy a Google saj\u00e1t bel\u00e9p\u00e9si/enged\u00e9lyez\u00e9si ablaka ny\u00edlik meg. '
'Ehhez az appnak el\u0151re regisztr\u00e1lt Google konfigur\u00e1ci\u00f3 kell, \u00e9s az eszk\u00f6z\u00f6n Google Play Services / Google fi\u00f3k is sz\u00fcks\u00e9ges.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Rendben'),
),
],
),
);
}
Future<void> _ensureGoogleInitialized() async {
final clientId = runtimeServerClientId;
if (clientId == null || clientId.isEmpty) {
await googleSignIn.initialize();
return; return;
} }
try {
await googleSignIn.initialize( final response = await http
serverClientId: clientId, .get(_jarvisUri('/api/google/oauth/start'), headers: _jarvisHeaders)
); .timeout(const Duration(seconds: 10));
if (response.statusCode != 200) {
throw Exception('JARVIS OAuth ${response.statusCode}: ${response.body}');
}
final payload = jsonDecode(response.body);
if (payload is! Map<String, dynamic>) throw Exception('Hib\u00e1s JARVIS v\u00e1lasz.');
final url = payload['authorizationUrl'] as String?;
if (url == null || url.isEmpty) throw Exception('Hi\u00e1nyzik az OAuth URL.');
final launched = await launchUrl(
Uri.parse(url),
mode: LaunchMode.externalApplication,
);
if (!launched) throw Exception('Nem siker\u00fclt megnyitni a b\u00f6ng\u00e9sz\u0151t.');
if (!mounted) return;
setState(() {
loadingCalendars = false;
statusMessage =
'A Google enged\u00e9lyez\u00e9s b\u00f6ng\u00e9sz\u0151ben megny\u00edlt. Ha k\u00e9sz, t\u00e9rj vissza ide \u00e9s friss\u00edtsd a napt\u00e1rlist\u00e1t.';
});
} catch (error) {
if (!mounted) return;
setState(() {
loadingCalendars = false;
statusMessage = 'Google \u00f6sszek\u00f6t\u00e9s nem indult el: $error';
});
}
} }
Future<void> _loadGoogleCalendars({bool promptIfNecessary = true}) async { Future<void> _loadGoogleCalendars() async {
setState(() { setState(() {
loadingCalendars = true; loadingCalendars = true;
statusMessage = 'Google napt\u00e1rak lek\u00e9r\u00e9se...'; statusMessage = 'Google napt\u00e1rak lek\u00e9r\u00e9se...';
}); });
try { try {
final headers = await _googleHeaders(
promptIfNecessary: promptIfNecessary,
);
final uri = Uri.https(
'www.googleapis.com',
'/calendar/v3/users/me/calendarList',
{'minAccessRole': 'reader', 'showHidden': 'true'},
);
final response = await http final response = await http
.get(uri, headers: headers) .get(_jarvisUri('/api/google/calendars'), headers: _jarvisHeaders)
.timeout(const Duration(seconds: 15)); .timeout(const Duration(seconds: 15));
if (response.statusCode != 200) { if (response.statusCode != 200) {
throw Exception( throw Exception(
'CalendarList API ${response.statusCode}: ${response.body}', 'JARVIS CalendarList ${response.statusCode}: ${response.body}',
); );
} }
@@ -263,16 +202,15 @@ class _AccountsPageState extends State<AccountsPage> {
return a.name.compareTo(b.name); return a.name.compareTo(b.name);
}); });
final account = signedInAccount;
final current = connection; final current = connection;
if (account != null) { if (current == null) {
widget.onChanged([ widget.onChanged([
GoogleCalendarConnection( GoogleCalendarConnection(
id: current?.id ?? uniqueId(), id: uniqueId(),
accountEmail: account.email, accountEmail: 'Google Calendar',
enabledCalendarIds: current?.enabledCalendarIds ?? const [], enabledCalendarIds: const [],
calendarPersonIds: current?.calendarPersonIds ?? const {}, calendarPersonIds: const {},
lastSync: current?.lastSync, lastSync: null,
), ),
]); ]);
} }
@@ -312,9 +250,8 @@ class _AccountsPageState extends State<AccountsPage> {
try { try {
if (googleCalendars.isEmpty) { if (googleCalendars.isEmpty) {
await _loadGoogleCalendars(promptIfNecessary: true); await _loadGoogleCalendars();
} }
final headers = await _googleHeaders(promptIfNecessary: true);
final selectedCalendars = googleCalendars final selectedCalendars = googleCalendars
.where((calendar) => current.enabledCalendarIds.contains(calendar.id)) .where((calendar) => current.enabledCalendarIds.contains(calendar.id))
.toList(); .toList();
@@ -329,19 +266,16 @@ class _AccountsPageState extends State<AccountsPage> {
final end = start.add(const Duration(days: 180)); final end = start.add(const Duration(days: 180));
for (final calendar in selectedCalendars) { for (final calendar in selectedCalendars) {
final uri = Uri.https( final uri = _jarvisUri(
'www.googleapis.com', '/api/google/events',
'/calendar/v3/calendars/${Uri.encodeComponent(calendar.id)}/events',
{ {
'singleEvents': 'true', 'calendarId': calendar.id,
'orderBy': 'startTime',
'timeMin': start.toUtc().toIso8601String(), 'timeMin': start.toUtc().toIso8601String(),
'timeMax': end.toUtc().toIso8601String(), 'timeMax': end.toUtc().toIso8601String(),
'maxResults': '2500',
}, },
); );
final response = await http final response = await http
.get(uri, headers: headers) .get(uri, headers: _jarvisHeaders)
.timeout(const Duration(seconds: 20)); .timeout(const Duration(seconds: 20));
if (response.statusCode != 200) { if (response.statusCode != 200) {
throw Exception('${calendar.name}: Events API ${response.statusCode}'); throw Exception('${calendar.name}: Events API ${response.statusCode}');
@@ -396,13 +330,13 @@ class _AccountsPageState extends State<AccountsPage> {
.length; .length;
return PageFrame( return PageFrame(
eyebrow: 'Google Calendar API szinkron', eyebrow: 'JARVIS Google Calendar szinkron',
title: 'Google napt\u00e1rak', title: 'Google napt\u00e1rak',
action: FilledButton.icon( action: FilledButton.icon(
onPressed: loadingCalendars ? null : _signInGoogle, onPressed: loadingCalendars ? null : _startGoogleConnection,
icon: const Icon(Icons.login), icon: const Icon(Icons.open_in_browser),
label: Text( label: Text(
hasConnection ? 'Fi\u00f3k v\u00e1lt\u00e1sa' : 'Google bel\u00e9p\u00e9s', hasConnection ? 'Google \u00f6sszek\u00f6t\u00e9s \u00fajranyit\u00e1sa' : 'Google \u00f6sszek\u00f6t\u00e9s',
), ),
), ),
child: ListView( child: ListView(
@@ -491,24 +425,23 @@ class _AccountsPageState extends State<AccountsPage> {
OutlinedButton.icon( OutlinedButton.icon(
onPressed: loadingCalendars onPressed: loadingCalendars
? null ? null
: () => _loadGoogleCalendars(promptIfNecessary: true), : _loadGoogleConnectionStatus,
icon: const Icon(Icons.refresh), icon: const Icon(Icons.refresh),
label: const Text('Google napt\u00e1rlista friss\u00edt\u00e9se'), label: const Text('Google napt\u00e1rlista friss\u00edt\u00e9se'),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
OutlinedButton.icon( OutlinedButton.icon(
onPressed: () async { onPressed: () async {
await googleSignIn.disconnect();
widget.onChanged([]); widget.onChanged([]);
widget.onSyncedEventsChanged([]); widget.onSyncedEventsChanged([]);
setState(() { setState(() {
signedInAccount = null;
googleCalendars = []; googleCalendars = [];
statusMessage = 'Google kapcsolat lev\u00e1lasztva.'; statusMessage =
'Helyi Google kapcsolat t\u00f6r\u00f6lve. A JARVIS token szerveren marad; \u00faj \u00f6sszek\u00f6t\u00e9ssel fel\u00fcl\u00edrhat\u00f3.';
}); });
}, },
icon: const Icon(Icons.link_off), icon: const Icon(Icons.link_off),
label: const Text('Google kapcsolat lev\u00e1laszt\u00e1sa'), label: const Text('Helyi Google cache t\u00f6rl\u00e9se'),
), ),
], ],
if (statusMessage != null) ...[ if (statusMessage != null) ...[
+1 -1
View File
@@ -7,11 +7,11 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:geocoding/geocoding.dart' as geocoding; import 'package:geocoding/geocoding.dart' as geocoding;
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:google_sign_in/google_sign_in.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:mobile_scanner/mobile_scanner.dart'; import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:timezone/data/latest.dart' as timezone_data; import 'package:timezone/data/latest.dart' as timezone_data;
import 'package:url_launcher/url_launcher.dart';
part 'data.dart'; part 'data.dart';
part 'app_shell.dart'; part 'app_shell.dart';
@@ -6,6 +6,10 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) { void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
} }
+1
View File
@@ -3,6 +3,7 @@
# #
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
url_launcher_linux
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST
@@ -7,14 +7,14 @@ import Foundation
import geocoding_darwin import geocoding_darwin
import geolocator_apple import geolocator_apple
import google_sign_in_ios
import mobile_scanner import mobile_scanner
import package_info_plus import package_info_plus
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
GeocodingDarwinPlugin.register(with: registry.registrar(forPlugin: "GeocodingDarwinPlugin")) GeocodingDarwinPlugin.register(with: registry.registrar(forPlugin: "GeocodingDarwinPlugin"))
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin"))
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
} }
+64 -48
View File
@@ -304,54 +304,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.3" version: "2.1.3"
google_identity_services_web:
dependency: transitive
description:
name: google_identity_services_web
sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454"
url: "https://pub.dev"
source: hosted
version: "0.3.3+1"
google_sign_in:
dependency: "direct main"
description:
name: google_sign_in
sha256: "521031b65853b4409b8213c0387d57edaad7e2a949ce6dea0d8b2afc9cb29763"
url: "https://pub.dev"
source: hosted
version: "7.2.0"
google_sign_in_android:
dependency: transitive
description:
name: google_sign_in_android
sha256: "57782125965ca87b03d42a147d40b046f1fd6a09141a58913e1b86671a5ef22e"
url: "https://pub.dev"
source: hosted
version: "7.2.16"
google_sign_in_ios:
dependency: transitive
description:
name: google_sign_in_ios
sha256: ac1e4c1205267cb7999d1d81333fccffdfda29e853f434bbaf71525498bb6950
url: "https://pub.dev"
source: hosted
version: "6.3.0"
google_sign_in_platform_interface:
dependency: transitive
description:
name: google_sign_in_platform_interface
sha256: "7f59208c42b415a3cca203571128d6f84f885fead2d5b53eb65a9e27f2965bb5"
url: "https://pub.dev"
source: hosted
version: "3.1.0"
google_sign_in_web:
dependency: transitive
description:
name: google_sign_in_web
sha256: d473003eeca892f96a01a64fc803378be765071cb0c265ee872c7f8683245d14
url: "https://pub.dev"
source: hosted
version: "1.1.3"
graphs: graphs:
dependency: transitive dependency: transitive
description: description:
@@ -685,6 +637,70 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.0"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.dev"
source: hosted
version: "6.3.2"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32
url: "https://pub.dev"
source: hosted
version: "6.3.32"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
url: "https://pub.dev"
source: hosted
version: "6.4.1"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
url: "https://pub.dev"
source: hosted
version: "3.2.2"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
url: "https://pub.dev"
source: hosted
version: "3.2.5"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
url: "https://pub.dev"
source: hosted
version: "2.4.3"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
url: "https://pub.dev"
source: hosted
version: "3.1.5"
uuid: uuid:
dependency: transitive dependency: transitive
description: description:
+1 -1
View File
@@ -40,7 +40,7 @@ dependencies:
geolocator: ^14.0.3 geolocator: ^14.0.3
geocoding: ^5.0.0 geocoding: ^5.0.0
mobile_scanner: ^7.4.0 mobile_scanner: ^7.4.0
google_sign_in: ^7.2.0 url_launcher: ^6.3.2
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@@ -7,8 +7,11 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <geolocator_windows/geolocator_windows.h> #include <geolocator_windows/geolocator_windows.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) { void RegisterPlugins(flutter::PluginRegistry* registry) {
GeolocatorWindowsRegisterWithRegistrar( GeolocatorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("GeolocatorWindows")); registry->GetRegistrarForPlugin("GeolocatorWindows"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
} }
+1
View File
@@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
geolocator_windows geolocator_windows
url_launcher_windows
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST