From 7e433ead88c231306edc7c3a9854cdafa1470ac5 Mon Sep 17 00:00:00 2001 From: zsolt Date: Thu, 13 Aug 2026 13:22:40 +0200 Subject: [PATCH] Add Jarvis Google Calendar OAuth broker --- api/OtthonApi/Program.cs | 377 +++++++++++++++++- lib/google_calendar_page.dart | 297 ++++++-------- lib/main.dart | 2 +- linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 4 +- pubspec.lock | 112 +++--- pubspec.yaml | 2 +- .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 10 files changed, 567 insertions(+), 236 deletions(-) diff --git a/api/OtthonApi/Program.cs b/api/OtthonApi/Program.cs index b6a0dfb..c366769 100644 --- a/api/OtthonApi/Program.cs +++ b/api/OtthonApi/Program.cs @@ -1,9 +1,12 @@ using System.Net.Http.Headers; using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.WebUtilities; using Microsoft.EntityFrameworkCore; var builder = WebApplication.CreateBuilder(args); @@ -151,6 +154,214 @@ app.MapGet("/api/app-config", async (HttpContext context, OtthonDbContext db) => !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 + { + ["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 + { + ["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 + { + ["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 ( DeviceRegistrationRequest request, HttpContext context, @@ -661,6 +872,88 @@ static async Task SetSetting(OtthonDbContext db, string key, string value) 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 GetGoogleClientId(OtthonDbContext db) => + await GetSetting(db, GoogleCalendarServerClientIdSettingKey()); + +static async Task GetGoogleClientSecret(OtthonDbContext db) => + await GetSetting(db, GoogleCalendarClientSecretSettingKey()); + +static async Task 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 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 + { + ["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) { target.Name = lookup.Name; @@ -747,6 +1040,7 @@ static async Task EnsureDatabaseReady(OtthonDbContext db, ILogger logger) await EnsureFoodLearningSchemaAsync(db); await EnsureWorkspaceSchemaAsync(db); await EnsureAppSettingsSchemaAsync(db); + await EnsureGoogleCalendarSchemaAsync(db); return; } catch (Exception ex) when (attempt < maxAttempts) @@ -765,6 +1059,7 @@ static async Task EnsureDatabaseReady(OtthonDbContext db, ILogger logger) await EnsureFoodLearningSchemaAsync(db); await EnsureWorkspaceSchemaAsync(db); await EnsureAppSettingsSchemaAsync(db); + await EnsureGoogleCalendarSchemaAsync(db); } static async Task EnsureProductSchemaAsync(OtthonDbContext db) @@ -815,6 +1110,33 @@ END 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) { var sql = """ @@ -1047,9 +1369,27 @@ static string SettingsPage(string googleClientId) => $$""" """; -static string Html(string? value) => System.Net.WebUtility.HtmlEncode(value ?? ""); +static string GoogleOAuthResultPage(bool success, string message) => $$""" + + + + + + Google Calendar connection + + + +
+

JARVIS API

+

{{(success ? "Google Calendar connected" : "Google Calendar error")}}

+

{{message}}

+

You can close this browser window and return to Közös Tér.

+
+ + +"""; -static string GoogleCalendarServerClientIdSettingKey() => "GoogleCalendarServerClientId"; +static string Html(string? value) => System.Net.WebUtility.HtmlEncode(value ?? ""); static string AdminCss() => """ :root { color-scheme: light; font-family: Inter, Segoe UI, Arial, sans-serif; } @@ -1091,11 +1431,19 @@ public sealed class OtthonDbContext(DbContextOptions options) public DbSet FoodLearningEvents => Set(); public DbSet Workspaces => Set(); public DbSet AppSettings => Set(); + public DbSet GoogleCalendarAuths => Set(); + public DbSet GoogleOAuthStates => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity().HasKey(setting => setting.Key); modelBuilder.Entity().Property(setting => setting.Key).HasMaxLength(160); + modelBuilder.Entity().HasKey(auth => auth.WorkspaceToken); + modelBuilder.Entity().Property(auth => auth.WorkspaceToken).HasMaxLength(160); + modelBuilder.Entity().Property(auth => auth.Email).HasMaxLength(320); + modelBuilder.Entity().HasKey(state => state.State); + modelBuilder.Entity().Property(state => state.State).HasMaxLength(160); + modelBuilder.Entity().Property(state => state.WorkspaceToken).HasMaxLength(160); modelBuilder.Entity().HasKey(workspace => workspace.Token); modelBuilder.Entity().Property(workspace => workspace.Token).HasMaxLength(160); modelBuilder.Entity().HasKey(product => product.Barcode); @@ -1177,6 +1525,24 @@ public sealed class AppSetting 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 GoogleCalendarSettingsForm(string? ServerClientId); public sealed record DeviceRegistrationRequest(string? DeviceName, string? Platform); @@ -1252,6 +1618,13 @@ public sealed record AppConfigResponse( string? GoogleCalendarServerClientId, bool GoogleCalendarConfigured); +public sealed record GoogleOAuthStartResponse(string AuthorizationUrl); + +public sealed record GoogleOAuthStatusResponse( + bool Connected, + string? Email, + DateTimeOffset? UpdatedUtc); + public sealed record ProductResponse( bool Found, string Barcode, diff --git a/lib/google_calendar_page.dart b/lib/google_calendar_page.dart index a6bf855..613a50c 100644 --- a/lib/google_calendar_page.dart +++ b/lib/google_calendar_page.dart @@ -25,15 +25,7 @@ class AccountsPage extends StatefulWidget { } class _AccountsPageState extends State { - static const calendarScopes = [ - 'https://www.googleapis.com/auth/calendar.readonly', - ]; - - final googleSignIn = GoogleSignIn.instance; List googleCalendars = []; - GoogleSignInAccount? signedInAccount; - String? runtimeServerClientId; - bool initialized = false; bool loadingCalendars = false; bool syncing = false; String? statusMessage; @@ -41,209 +33,156 @@ class _AccountsPageState extends State { @override void initState() { super.initState(); - _loadGoogleAppConfigAndInitialize(); + _loadGoogleConnectionStatus(); } GoogleCalendarConnection? get connection => widget.connections.isEmpty ? null : widget.connections.first; - Future _loadGoogleAppConfigAndInitialize() async { - setState(() { - loadingCalendars = true; - statusMessage = 'Google napt\u00e1rkapcsolat el\u0151k\u00e9sz\u00edt\u00e9se...'; - }); - - runtimeServerClientId = widget.serverClientId.trim().isNotEmpty - ? widget.serverClientId.trim() - : await _loadGoogleServerClientId(); - if (!mounted) return; - await _initializeGoogle(); + String? get _workspaceToken { + final token = widget.jarvisSettings.workspaceToken?.trim(); + return token == null || token.isEmpty ? null : token; } - Future _loadGoogleServerClientId() async { - if (googleCalendarServerClientId.trim().isNotEmpty) { - return googleCalendarServerClientId.trim(); + Map get _jarvisHeaders { + final headers = { + '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(); - if (apiToken.isEmpty) { - return null; + Uri _jarvisUri(String path, [Map query = const {}]) { + final baseUri = Uri.parse(normalizeJarvisUrl(widget.jarvisSettings.apiUrl)); + 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 _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 { - final baseUri = Uri.parse(normalizeJarvisUrl(widget.jarvisSettings.apiUrl)); - final uri = baseUri.replace(path: '/api/app-config', query: ''); final response = await http - .get(uri, headers: {'X-Otthon-Token': apiToken}) + .get(_jarvisUri('/api/google/oauth/status'), headers: _jarvisHeaders) .timeout(const Duration(seconds: 10)); if (response.statusCode != 200) { - return null; + throw Exception('JARVIS status ${response.statusCode}: ${response.body}'); } final payload = jsonDecode(response.body); if (payload is! Map) { - return null; + throw Exception('Hib\u00e1s JARVIS v\u00e1lasz.'); } - return payload['googleCalendarServerClientId'] as String?; - } catch (_) { - return null; - } - } - - Future _initializeGoogle() async { - setState(() { - loadingCalendars = true; - statusMessage = 'Google kapcsolat el\u0151k\u00e9sz\u00edt\u00e9se...'; - }); - - try { - await _ensureGoogleInitialized(); - 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); + final connected = payload['connected'] == true; + final email = payload['email'] as String?; + if (connected) { + final current = connection; + widget.onChanged([ + GoogleCalendarConnection( + id: current?.id ?? uniqueId(), + accountEmail: email ?? current?.accountEmail ?? 'Google fi\u00f3k', + enabledCalendarIds: current?.enabledCalendarIds ?? const [], + calendarPersonIds: current?.calendarPersonIds ?? const {}, + lastSync: current?.lastSync, + ), + ]); + await _loadGoogleCalendars(); } else { setState(() { loadingCalendars = false; 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) { if (!mounted) return; setState(() { loadingCalendars = false; - statusMessage = _friendlyGoogleError( - 'Google inicializ\u00e1l\u00e1s nem siker\u00fclt', - error, - ); + statusMessage = 'JARVIS Google kapcsolat nem el\u00e9rhet\u0151: $error'; }); } } - Future> _googleHeaders({ - 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 _signInGoogle() async { + Future _startGoogleConnection() async { setState(() { loadingCalendars = true; - statusMessage = 'Google bejelentkez\u00e9s...'; + statusMessage = 'Google \u00f6sszek\u00f6t\u00e9s ind\u00edt\u00e1sa JARVIS-on...'; }); - try { - if (!initialized) { - 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, - ); + final configError = _jarvisConfigError(); + if (configError != null) { setState(() { 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 _showGoogleErrorDialog(String message) async { - if (!mounted) return; - await showDialog( - 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 _ensureGoogleInitialized() async { - final clientId = runtimeServerClientId; - if (clientId == null || clientId.isEmpty) { - await googleSignIn.initialize(); return; } - - await googleSignIn.initialize( - serverClientId: clientId, - ); + try { + final response = await http + .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) 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 _loadGoogleCalendars({bool promptIfNecessary = true}) async { + Future _loadGoogleCalendars() async { setState(() { loadingCalendars = true; statusMessage = 'Google napt\u00e1rak lek\u00e9r\u00e9se...'; }); 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 - .get(uri, headers: headers) + .get(_jarvisUri('/api/google/calendars'), headers: _jarvisHeaders) .timeout(const Duration(seconds: 15)); if (response.statusCode != 200) { throw Exception( - 'CalendarList API ${response.statusCode}: ${response.body}', + 'JARVIS CalendarList ${response.statusCode}: ${response.body}', ); } @@ -263,16 +202,15 @@ class _AccountsPageState extends State { return a.name.compareTo(b.name); }); - final account = signedInAccount; final current = connection; - if (account != null) { + if (current == null) { widget.onChanged([ GoogleCalendarConnection( - id: current?.id ?? uniqueId(), - accountEmail: account.email, - enabledCalendarIds: current?.enabledCalendarIds ?? const [], - calendarPersonIds: current?.calendarPersonIds ?? const {}, - lastSync: current?.lastSync, + id: uniqueId(), + accountEmail: 'Google Calendar', + enabledCalendarIds: const [], + calendarPersonIds: const {}, + lastSync: null, ), ]); } @@ -312,9 +250,8 @@ class _AccountsPageState extends State { try { if (googleCalendars.isEmpty) { - await _loadGoogleCalendars(promptIfNecessary: true); + await _loadGoogleCalendars(); } - final headers = await _googleHeaders(promptIfNecessary: true); final selectedCalendars = googleCalendars .where((calendar) => current.enabledCalendarIds.contains(calendar.id)) .toList(); @@ -329,19 +266,16 @@ class _AccountsPageState extends State { final end = start.add(const Duration(days: 180)); for (final calendar in selectedCalendars) { - final uri = Uri.https( - 'www.googleapis.com', - '/calendar/v3/calendars/${Uri.encodeComponent(calendar.id)}/events', + final uri = _jarvisUri( + '/api/google/events', { - 'singleEvents': 'true', - 'orderBy': 'startTime', + 'calendarId': calendar.id, 'timeMin': start.toUtc().toIso8601String(), 'timeMax': end.toUtc().toIso8601String(), - 'maxResults': '2500', }, ); final response = await http - .get(uri, headers: headers) + .get(uri, headers: _jarvisHeaders) .timeout(const Duration(seconds: 20)); if (response.statusCode != 200) { throw Exception('${calendar.name}: Events API ${response.statusCode}'); @@ -396,13 +330,13 @@ class _AccountsPageState extends State { .length; return PageFrame( - eyebrow: 'Google Calendar API szinkron', + eyebrow: 'JARVIS Google Calendar szinkron', title: 'Google napt\u00e1rak', action: FilledButton.icon( - onPressed: loadingCalendars ? null : _signInGoogle, - icon: const Icon(Icons.login), + onPressed: loadingCalendars ? null : _startGoogleConnection, + icon: const Icon(Icons.open_in_browser), 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( @@ -491,24 +425,23 @@ class _AccountsPageState extends State { OutlinedButton.icon( onPressed: loadingCalendars ? null - : () => _loadGoogleCalendars(promptIfNecessary: true), + : _loadGoogleConnectionStatus, icon: const Icon(Icons.refresh), label: const Text('Google napt\u00e1rlista friss\u00edt\u00e9se'), ), const SizedBox(height: 8), OutlinedButton.icon( onPressed: () async { - await googleSignIn.disconnect(); widget.onChanged([]); widget.onSyncedEventsChanged([]); setState(() { - signedInAccount = null; 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), - label: const Text('Google kapcsolat lev\u00e1laszt\u00e1sa'), + label: const Text('Helyi Google cache t\u00f6rl\u00e9se'), ), ], if (statusMessage != null) ...[ diff --git a/lib/main.dart b/lib/main.dart index 1e8c85e..7b831f3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,11 +7,11 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:geocoding/geocoding.dart' as geocoding; import 'package:geolocator/geolocator.dart'; -import 'package:google_sign_in/google_sign_in.dart'; import 'package:http/http.dart' as http; import 'package:mobile_scanner/mobile_scanner.dart'; import 'package:path_provider/path_provider.dart'; import 'package:timezone/data/latest.dart' as timezone_data; +import 'package:url_launcher/url_launcher.dart'; part 'data.dart'; part 'app_shell.dart'; diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index e71a16d..f6f23bf 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,10 @@ #include "generated_plugin_registrant.h" +#include 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); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index be1ee3e..df8d2f7 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 4c877b2..1bac2d0 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,14 +7,14 @@ import Foundation import geocoding_darwin import geolocator_apple -import google_sign_in_ios import mobile_scanner import package_info_plus +import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { GeocodingDarwinPlugin.register(with: registry.registrar(forPlugin: "GeocodingDarwinPlugin")) GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) - FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin")) MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index a2ce2fc..67688c1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -304,54 +304,6 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -685,6 +637,70 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 19ebba7..d22cb56 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,7 +40,7 @@ dependencies: geolocator: ^14.0.3 geocoding: ^5.0.0 mobile_scanner: ^7.4.0 - google_sign_in: ^7.2.0 + url_launcher: ^6.3.2 dev_dependencies: flutter_test: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 1ece8f2..94586cc 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,8 +7,11 @@ #include "generated_plugin_registrant.h" #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { GeolocatorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("GeolocatorWindows")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index a49b9f6..1f8739d 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST geolocator_windows + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST