From 03b4510795fd8487f4d664de17afe72a41563af0 Mon Sep 17 00:00:00 2001 From: StudyOS Org Date: Fri, 17 Jul 2026 18:26:54 +0000 Subject: [PATCH 1/8] feat(agent): add local study task tools --- flutter_app/README.md | 8 + flutter_app/lib/src/agent_llm_provider.dart | 4 + flutter_app/lib/src/agent_message_sender.dart | 3 + flutter_app/lib/src/agent_request_runner.dart | 5 + flutter_app/lib/src/app_shell_controller.dart | 8 + flutter_app/lib/src/capability_result.dart | 31 +- .../lib/src/cloud_tool_definitions.dart | 4 +- flutter_app/lib/src/portal_http_session.dart | 212 +++++++++++ .../lib/src/private_study_capabilities.dart | 274 ++++++++++++++ .../lib/src/private_study_clients.dart | 184 +++++++++ flutter_app/lib/src/private_study_models.dart | 126 +++++++ .../lib/src/private_study_parsers.dart | 220 +++++++++++ flutter_app/lib/src/private_study_tools.dart | 108 ++++++ flutter_app/lib/src/studyos_tool_catalog.dart | 60 +++ .../lib/src/studyos_tool_executor.dart | 23 ++ flutter_app/test/cloud_agent_client_test.dart | 2 + .../test/private_study_tools_test.dart | 352 ++++++++++++++++++ .../test/studyos_tool_executor_test.dart | 34 ++ 18 files changed, 1654 insertions(+), 4 deletions(-) create mode 100644 flutter_app/lib/src/portal_http_session.dart create mode 100644 flutter_app/lib/src/private_study_capabilities.dart create mode 100644 flutter_app/lib/src/private_study_clients.dart create mode 100644 flutter_app/lib/src/private_study_models.dart create mode 100644 flutter_app/lib/src/private_study_parsers.dart create mode 100644 flutter_app/lib/src/private_study_tools.dart create mode 100644 flutter_app/test/private_study_tools_test.dart diff --git a/flutter_app/README.md b/flutter_app/README.md index f627c0a..6f32877 100644 --- a/flutter_app/README.md +++ b/flutter_app/README.md @@ -60,3 +60,11 @@ when the current SDK/device supports the `FoundationModels` framework. Unsupported native features should return explicit platform errors rather than mock responses. + +## Assistant Tool Privacy + +The shared Dart catalog exposes public Mensa and Tübingen location tools to +both local and cloud assistants. Authenticated `get_tasks` and `get_deadlines` +tools use ephemeral on-device ILIAS/Moodle sessions and are advertised only to +the local assistant. Passwords, cookies, SAML fields, Moodle session keys, and +raw portal HTML must never enter prompts, tool results, traces, or caches. diff --git a/flutter_app/lib/src/agent_llm_provider.dart b/flutter_app/lib/src/agent_llm_provider.dart index adaa77b..5a6d41f 100644 --- a/flutter_app/lib/src/agent_llm_provider.dart +++ b/flutter_app/lib/src/agent_llm_provider.dart @@ -8,6 +8,7 @@ import 'models.dart'; import 'native_bridge.dart'; import 'native_tool_router.dart'; import 'prompt_context.dart'; +import 'private_study_tools.dart'; import 'public_study_tools.dart'; import 'studyos_tool_catalog.dart'; import 'studyos_tool_executor.dart'; @@ -33,6 +34,7 @@ class AgentLlmRequest { required this.mailTools, required this.onToolTrace, this.publicStudyTools, + this.privateStudyTools, this.onDelta, this.cancelToken, }); @@ -50,6 +52,7 @@ class AgentLlmRequest { final Future Function(String query, int limit) searchTalks; final MailToolRunner mailTools; final PublicStudyToolRunner? publicStudyTools; + final PrivateStudyToolRunner? privateStudyTools; final void Function(ToolTrace trace) onToolTrace; /// Optional sink for streamed reply fragments. Cloud uses it for SSE; the @@ -154,6 +157,7 @@ class LocalNativeLlmProvider implements AgentLlmProvider { mailTools: request.mailTools, nativeTools: nativeTools, publicStudyTools: request.publicStudyTools, + privateStudyTools: request.privateStudyTools, ); for (var round = 0; round < _maxToolRounds; round += 1) { diff --git a/flutter_app/lib/src/agent_message_sender.dart b/flutter_app/lib/src/agent_message_sender.dart index 2c39d5e..7f6a2aa 100644 --- a/flutter_app/lib/src/agent_message_sender.dart +++ b/flutter_app/lib/src/agent_message_sender.dart @@ -5,6 +5,7 @@ import 'memory_store.dart'; import 'models.dart'; import 'native_bridge.dart'; import 'prompt_context.dart'; +import 'private_study_tools.dart'; import 'public_study_tools.dart'; Future _unavailableTalks(String query, int limit) async => @@ -29,6 +30,7 @@ Future sendAgentMessage({ _unavailableTalks, required MailToolRunner mailTools, required PublicStudyToolRunner publicStudyTools, + required PrivateStudyToolRunner privateStudyTools, required void Function(ToolTrace trace) onToolTrace, AgentStreamSink? onDelta, AgentCancelToken? cancelToken, @@ -57,6 +59,7 @@ Future sendAgentMessage({ searchTalks: searchTalks, mailTools: mailTools, publicStudyTools: publicStudyTools, + privateStudyTools: privateStudyTools, onDelta: onDelta, cancelToken: cancelToken, ); diff --git a/flutter_app/lib/src/agent_request_runner.dart b/flutter_app/lib/src/agent_request_runner.dart index b600dcb..913fa93 100644 --- a/flutter_app/lib/src/agent_request_runner.dart +++ b/flutter_app/lib/src/agent_request_runner.dart @@ -7,6 +7,7 @@ import 'models.dart'; import 'native_bridge.dart'; import 'native_tool_router.dart'; import 'prompt_context.dart'; +import 'private_study_tools.dart'; import 'public_study_tools.dart'; Future _unavailableAcademicStatus() async => @@ -55,6 +56,7 @@ class AgentRequestRunner { _unavailableTalks, required MailToolRunner mailTools, PublicStudyToolRunner? publicStudyTools, + PrivateStudyToolRunner? privateStudyTools, AgentStreamSink? onDelta, AgentCancelToken? cancelToken, }) async { @@ -74,6 +76,9 @@ class AgentRequestRunner { searchTalks: searchTalks, mailTools: mailTools, publicStudyTools: publicStudyTools, + privateStudyTools: config.provider == AgentProvider.local + ? privateStudyTools + : null, onToolTrace: onToolTrace, onDelta: onDelta, cancelToken: cancelToken, diff --git a/flutter_app/lib/src/app_shell_controller.dart b/flutter_app/lib/src/app_shell_controller.dart index 334ecda..ec11083 100644 --- a/flutter_app/lib/src/app_shell_controller.dart +++ b/flutter_app/lib/src/app_shell_controller.dart @@ -18,6 +18,8 @@ import 'native_bridge.dart'; import 'official_document_models.dart'; import 'official_documents_repository.dart'; import 'profile_context.dart'; +import 'private_study_capabilities.dart'; +import 'private_study_tools.dart'; import 'public_study_tools.dart'; import 'send_error_message.dart'; import 'session_store.dart'; @@ -59,6 +61,9 @@ class AppShellController extends ChangeNotifier { _profile = initialProfile, _onLogout = initialOnLogout, _onSaveProfile = initialOnSaveProfile { + _privateStudyTools = LivePrivateStudyToolRunner( + PrivateStudyCapability(profileProvider: () => _profile), + ); this.calendarOverviewSource = calendarOverviewSource ?? CalendarOverviewRepository(bridge, this.talksRepository); @@ -77,6 +82,7 @@ class AppShellController extends ChangeNotifier { final OfficialDocumentsRepository _documentsRepository = OfficialDocumentsRepository(); final PublicStudyToolRunner _publicStudyTools = LivePublicStudyToolRunner(); + late final PrivateStudyToolRunner _privateStudyTools; final TextEditingController inputController = TextEditingController(); final ScrollController messageScrollController = ScrollController(); @@ -185,6 +191,7 @@ class AppShellController extends ChangeNotifier { return; } _profile = profile; + _privateStudyTools.invalidate(); _onLogout = onLogout; _onSaveProfile = onSaveProfile; _worldState = withProfileContext( @@ -478,6 +485,7 @@ class AppShellController extends ChangeNotifier { profile: _profile, ), publicStudyTools: _publicStudyTools, + privateStudyTools: _privateStudyTools, onToolTrace: addToolTrace, onDelta: _handleStreamDelta, cancelToken: cancelToken, diff --git a/flutter_app/lib/src/capability_result.dart b/flutter_app/lib/src/capability_result.dart index c45703c..723961d 100644 --- a/flutter_app/lib/src/capability_result.dart +++ b/flutter_app/lib/src/capability_result.dart @@ -1,4 +1,12 @@ -enum CapabilityState { fresh, stale, empty, unavailable, failed } +enum CapabilityState { + fresh, + stale, + empty, + unavailable, + permissionDenied, + authenticationRequired, + failed, +} enum CapabilityPrivacy { publicExternal, privateLocal } @@ -12,6 +20,11 @@ class CapabilityPolicy { effect: CapabilityEffect.readOnly, ); + static const privateRead = CapabilityPolicy( + privacy: CapabilityPrivacy.privateLocal, + effect: CapabilityEffect.readOnly, + ); + final CapabilityPrivacy privacy; final CapabilityEffect effect; @@ -21,6 +34,18 @@ class CapabilityPolicy { }; } +class CapabilityFailure { + const CapabilityFailure({required this.source, required this.message}); + + final String source; + final String message; + + Map toJson() => { + 'source': source, + 'message': message, + }; +} + class CapabilitySource { const CapabilitySource({ required this.id, @@ -51,6 +76,7 @@ class CapabilityResult { this.expiresAt, this.data, this.message, + this.failures = const [], }); final CapabilityState state; @@ -60,6 +86,7 @@ class CapabilityResult { final DateTime? expiresAt; final T? data; final String? message; + final List failures; Map toJson( Object? Function(T value) encodeData, @@ -71,6 +98,8 @@ class CapabilityResult { if (expiresAt != null) 'expires_at': expiresAt!.toUtc().toIso8601String(), if (data != null) 'data': encodeData(data as T), if (message != null) 'message': message, + if (failures.isNotEmpty) + 'failures': failures.map((failure) => failure.toJson()).toList(), }; } diff --git a/flutter_app/lib/src/cloud_tool_definitions.dart b/flutter_app/lib/src/cloud_tool_definitions.dart index 42a4015..9801999 100644 --- a/flutter_app/lib/src/cloud_tool_definitions.dart +++ b/flutter_app/lib/src/cloud_tool_definitions.dart @@ -3,9 +3,7 @@ import 'studyos_tool_catalog.dart'; List> cloudToolDefinitions({ Set supportedNativeToolNames = const {}, }) { - return studyOsToolsForNativeSupport( - supportedNativeToolNames, - ).map(_tool).toList(); + return cloudStudyOsTools(supportedNativeToolNames).map(_tool).toList(); } Map _tool(StudyOsToolSpec spec) { diff --git a/flutter_app/lib/src/portal_http_session.dart b/flutter_app/lib/src/portal_http_session.dart new file mode 100644 index 0000000..e4f75a7 --- /dev/null +++ b/flutter_app/lib/src/portal_http_session.dart @@ -0,0 +1,212 @@ +import 'dart:convert'; + +import 'package:html/dom.dart'; +import 'package:html/parser.dart' as html_parser; +import 'package:http/http.dart' as http; + +import 'private_study_models.dart'; + +class PortalResponse { + const PortalResponse({required this.response, required this.url}); + + final http.Response response; + final Uri url; + String get body => response.body; +} + +class PortalForm { + const PortalForm({required this.action, required this.payload}); + + final Uri action; + final Map payload; +} + +class PortalHttpSession { + PortalHttpSession({ + http.Client? client, + this.timeout = const Duration(seconds: 18), + this.allowedHostSuffix = 'uni-tuebingen.de', + }) : _client = client ?? http.Client(), + _ownsClient = client == null; + + final http.Client _client; + final bool _ownsClient; + final Duration timeout; + final String allowedHostSuffix; + final Map> _cookiesByHost = {}; + + Future get(Uri url) => _send('GET', url); + + Future postForm(PortalForm form) => + _send('POST', form.action, form: form.payload); + + Future postJson(Uri url, Object body, {Uri? referer}) => + _send('POST', url, jsonBody: jsonEncode(body), referer: referer); + + Future _send( + String method, + Uri url, { + Map? form, + String? jsonBody, + Uri? referer, + }) async { + var nextMethod = method; + var nextUrl = url; + var nextForm = form; + var nextJson = jsonBody; + for (var redirects = 0; redirects < 10; redirects += 1) { + _validateUrl(nextUrl); + final cookies = _cookiesByHost[nextUrl.host]; + final request = http.Request(nextMethod, nextUrl) + ..followRedirects = false + ..headers.addAll({ + 'Accept': 'text/html,application/json,*/*;q=0.8', + 'User-Agent': + 'StudyOS/1.0 (+https://github.com/Tue-StudyOS/StudyOS_Agent)', + if (cookies?.isNotEmpty == true) + 'Cookie': cookies!.entries + .map((entry) => '${entry.key}=${entry.value}') + .join('; '), + if (referer != null) 'Referer': referer.toString(), + }); + if (nextForm != null) request.bodyFields = nextForm; + if (nextJson != null) { + request.headers['Content-Type'] = 'application/json'; + request.body = nextJson; + } + final streamed = await _client.send(request).timeout(timeout); + final response = await http.Response.fromStream( + streamed, + ).timeout(timeout); + _captureCookies(response, nextUrl.host); + if (response.statusCode < 200 || response.statusCode >= 400) { + throw PortalException('Portal returned HTTP ${response.statusCode}.'); + } + final location = response.headers['location']; + if (!_isRedirect(response.statusCode) || location == null) { + return PortalResponse(response: response, url: nextUrl); + } + nextUrl = nextUrl.resolve(location); + if (response.statusCode == 303 || + (nextMethod == 'POST' && response.statusCode < 303)) { + nextMethod = 'GET'; + nextForm = null; + nextJson = null; + } + } + throw const PortalException('Portal redirected too many times.'); + } + + void close() { + _cookiesByHost.clear(); + if (_ownsClient) _client.close(); + } + + void _captureCookies(http.Response response, String host) { + final header = response.headers['set-cookie']; + if (header == null) return; + for (final raw in header.split(RegExp(r',(?=\s*[^;,]+=)'))) { + final pair = raw.split(';').first.trim(); + final separator = pair.indexOf('='); + if (separator > 0) { + (_cookiesByHost[host] ??= {})[pair.substring( + 0, + separator, + )] = pair.substring( + separator + 1, + ); + } + } + } + + void _validateUrl(Uri url) { + final hostAllowed = + url.host == allowedHostSuffix || + url.host.endsWith('.$allowedHostSuffix'); + if (url.scheme != 'https' || !hostAllowed) { + throw const PortalException( + 'Portal redirected to an untrusted destination.', + ); + } + } +} + +Uri portalLink(String html, Uri pageUrl, String marker) { + final document = html_parser.parse(html); + for (final link in document.querySelectorAll('a[href]')) { + final href = link.attributes['href']; + if (href != null && href.contains(marker)) return pageUrl.resolve(href); + } + throw const PortalException('Could not find the portal login link.'); +} + +PortalForm portalForm( + String html, + Uri pageUrl, { + required Set requiredFields, +}) { + final document = html_parser.parse(html); + for (final form in document.querySelectorAll('form')) { + final payload = _formPayload(form); + if (requiredFields.every(payload.containsKey)) { + return PortalForm( + action: pageUrl.resolve(form.attributes['action'] ?? ''), + payload: payload, + ); + } + } + throw const PortalException('Could not find the expected portal form.'); +} + +Map _formPayload(Element form) { + final result = {}; + for (final input in form.querySelectorAll('input[name]')) { + if (input.attributes['type']?.toLowerCase() == 'checkbox') continue; + result[input.attributes['name']!] = input.attributes['value'] ?? ''; + } + return result; +} + +Future completeSaml( + PortalResponse initial, + PortalHttpSession session, { + required bool Function(PortalResponse response) isAuthenticated, +}) async { + var current = initial; + for (var step = 0; step < 6; step += 1) { + if (isAuthenticated(current)) return current; + if (current.body.contains('SAMLResponse') && + current.body.contains('RelayState')) { + current = await session.postForm( + portalForm( + current.body, + current.url, + requiredFields: const {'SAMLResponse', 'RelayState'}, + ), + ); + continue; + } + if (current.url.host == 'idp.uni-tuebingen.de' && + current.body.contains('_eventId_proceed')) { + current = await session.postForm( + portalForm( + current.body, + current.url, + requiredFields: const {'_eventId_proceed'}, + ), + ); + continue; + } + break; + } + throw const PortalAuthenticationException( + 'Could not complete the university SAML handoff.', + ); +} + +bool _isRedirect(int status) => + status == 301 || + status == 302 || + status == 303 || + status == 307 || + status == 308; diff --git a/flutter_app/lib/src/private_study_capabilities.dart b/flutter_app/lib/src/private_study_capabilities.dart new file mode 100644 index 0000000..b26503e --- /dev/null +++ b/flutter_app/lib/src/private_study_capabilities.dart @@ -0,0 +1,274 @@ +import 'capability_result.dart'; +import 'private_study_clients.dart'; +import 'private_study_models.dart'; +import 'profile_store.dart'; +import 'student_profile.dart'; + +typedef IliasSourceFactory = + IliasStudySource Function(String username, String password); +typedef MoodleSourceFactory = + MoodleStudySource Function(String username, String password); +typedef PortalCredentialsProvider = Future Function(); + +class PortalCredentials { + const PortalCredentials(this.username, this.password); + final String username; + final String password; +} + +class PrivateStudyCapability { + PrivateStudyCapability({ + required this.profileProvider, + this.profileStore, + IliasSourceFactory? iliasFactory, + MoodleSourceFactory? moodleFactory, + this.credentialsProvider, + DateTime Function()? clock, + this.ttl = const Duration(minutes: 10), + this.sourceTimeout = const Duration(seconds: 45), + }) : _iliasFactory = + iliasFactory ?? + ((username, password) => + IliasPortalClient(username: username, password: password)), + _moodleFactory = + moodleFactory ?? + ((username, password) => + MoodlePortalClient(username: username, password: password)), + _clock = clock ?? DateTime.now; + + static final source = CapabilitySource( + id: 'local_university_portals', + label: 'Local ILIAS and Moodle sessions', + url: 'https://uni-tuebingen.de/', + reliability: 'authenticated_live', + ); + + final OnboardingProfile? Function() profileProvider; + final ProfileStore? profileStore; + final IliasSourceFactory _iliasFactory; + final MoodleSourceFactory _moodleFactory; + final PortalCredentialsProvider? credentialsProvider; + final DateTime Function() _clock; + final Duration ttl; + final Duration sourceTimeout; + final Map> _cache = {}; + + Future>> tasks({ + required Set sources, + required int limit, + }) async { + final key = 'tasks:${_sourceKey(sources)}:$limit'; + final cached = _cached>(key); + if (cached != null) return cached; + final credentials = await _credentials(); + if (credentials == null) return _authenticationRequired>(); + + final data = []; + final failures = []; + if (sources.contains(StudyPortalSource.ilias)) { + final client = _iliasFactory(credentials.username, credentials.password); + try { + data.addAll(await client.fetchTasks(limit: 50).timeout(sourceTimeout)); + } on Object catch (error) { + failures.add(_failure(StudyPortalSource.ilias, error)); + } finally { + client.close(); + } + } + if (sources.contains(StudyPortalSource.moodle)) { + final client = _moodleFactory(credentials.username, credentials.password); + try { + data.addAll( + await client.fetchEvents(days: 180, limit: 50).timeout(sourceTimeout), + ); + } on Object catch (error) { + failures.add(_failure(StudyPortalSource.moodle, error)); + } finally { + client.close(); + } + } + data.sort(_compareTasks); + final result = _result(data.take(limit).toList(growable: false), failures); + return _store(key, _staleIfFailed(key, result) ?? result); + } + + Future>> deadlines({ + required Set sources, + required int days, + required int limit, + }) async { + final key = 'deadlines:${_sourceKey(sources)}:$days:$limit'; + final cached = _cached>(key); + if (cached != null) return cached; + final credentials = await _credentials(); + if (credentials == null) { + return _authenticationRequired>(); + } + final now = _clock(); + final end = now.add(Duration(days: days)); + final data = []; + final failures = []; + if (sources.contains(StudyPortalSource.ilias)) { + final client = _iliasFactory(credentials.username, credentials.password); + try { + data.addAll( + await client.fetchDeadlines(scanLimit: 50).timeout(sourceTimeout), + ); + } on Object catch (error) { + failures.add(_failure(StudyPortalSource.ilias, error)); + } finally { + client.close(); + } + } + if (sources.contains(StudyPortalSource.moodle)) { + final client = _moodleFactory(credentials.username, credentials.password); + try { + final events = await client + .fetchEvents(days: days, limit: limit) + .timeout(sourceTimeout); + data.addAll( + events + .where((event) => event.dueAt != null) + .map( + (event) => PortalDeadline( + source: event.source, + id: event.id, + title: event.title, + dueAt: event.dueAt!, + url: event.url, + courseTitle: event.courseTitle, + dueHint: event.rawDueHint, + status: event.status, + ), + ), + ); + } on Object catch (error) { + failures.add(_failure(StudyPortalSource.moodle, error)); + } finally { + client.close(); + } + } + final deduplicated = {}; + for (final item in data) { + if (item.dueAt.isBefore(now) || item.dueAt.isAfter(end)) continue; + deduplicated[item.deduplicationKey] = item; + } + final bounded = deduplicated.values.toList() + ..sort((first, second) => first.dueAt.compareTo(second.dueAt)); + final result = _result( + bounded.take(limit).toList(growable: false), + failures, + ); + return _store(key, _staleIfFailed(key, result) ?? result); + } + + void invalidate() => _cache.clear(); + + CapabilityResult? _cached(String key) { + final entry = _cache[key]; + if (entry == null || !_clock().isBefore(entry.expiresAt)) return null; + return entry.result as CapabilityResult; + } + + CapabilityResult _store(String key, CapabilityResult result) { + _cache[key] = _PrivateCacheEntry( + result as CapabilityResult, + expiresAt: result.expiresAt ?? _clock().add(ttl), + ); + return result; + } + + CapabilityResult? _staleIfFailed( + String key, + CapabilityResult result, + ) { + if (result.state != CapabilityState.failed) return null; + final previous = _cache[key]?.result; + if (previous == null) return null; + final cached = previous as CapabilityResult; + return CapabilityResult( + state: CapabilityState.stale, + policy: cached.policy, + source: cached.source, + fetchedAt: cached.fetchedAt, + expiresAt: cached.expiresAt, + data: cached.data, + message: 'Live refresh failed; returning locally cached portal data.', + failures: result.failures, + ); + } + + CapabilityResult> _result( + List data, + List failures, + ) { + final now = _clock(); + final allFailed = failures.isNotEmpty && data.isEmpty; + final authenticationFailed = + failures.isNotEmpty && + failures.every((failure) => failure.message.contains('authentication')); + return CapabilityResult>( + state: authenticationFailed + ? CapabilityState.authenticationRequired + : allFailed + ? CapabilityState.failed + : data.isEmpty + ? CapabilityState.empty + : CapabilityState.fresh, + policy: CapabilityPolicy.privateRead, + source: source, + fetchedAt: now, + expiresAt: now.add(ttl), + data: data, + failures: failures, + message: failures.isEmpty + ? null + : 'Some requested portal sources failed.', + ); + } + + CapabilityResult _authenticationRequired() => CapabilityResult( + state: CapabilityState.authenticationRequired, + policy: CapabilityPolicy.privateRead, + source: source, + fetchedAt: _clock(), + message: 'Sign in locally to access university portal data.', + ); + + Future _credentials() async { + final injected = credentialsProvider; + if (injected != null) return injected(); + final profile = profileProvider(); + if (profile == null || profile.username.trim().isEmpty) return null; + final password = await (profileStore ?? ProfileStore()).readPassword(); + if (password == null || password.isEmpty) return null; + return PortalCredentials(profile.username, password); + } +} + +class _PrivateCacheEntry { + const _PrivateCacheEntry(this.result, {required this.expiresAt}); + final CapabilityResult result; + final DateTime expiresAt; +} + +CapabilityFailure _failure(StudyPortalSource source, Object error) => + CapabilityFailure( + source: source.name, + message: error is PortalAuthenticationException + ? 'authentication required' + : boundedCapabilityMessage(error), + ); + +String _sourceKey(Set sources) => + (sources.toList()..sort((a, b) => a.index.compareTo(b.index))) + .map((source) => source.name) + .join(','); + +int _compareTasks(PortalTask first, PortalTask second) { + if (first.dueAt == null && second.dueAt != null) return 1; + if (first.dueAt != null && second.dueAt == null) return -1; + return (first.dueAt ?? DateTime(9999)).compareTo( + second.dueAt ?? DateTime(9999), + ); +} diff --git a/flutter_app/lib/src/private_study_clients.dart b/flutter_app/lib/src/private_study_clients.dart new file mode 100644 index 0000000..6c2c942 --- /dev/null +++ b/flutter_app/lib/src/private_study_clients.dart @@ -0,0 +1,184 @@ +import 'portal_http_session.dart'; +import 'private_study_models.dart'; +import 'private_study_parsers.dart'; + +abstract interface class IliasStudySource { + Future> fetchTasks({required int limit}); + Future> fetchDeadlines({required int scanLimit}); + void close(); +} + +abstract interface class MoodleStudySource { + Future> fetchEvents({required int days, required int limit}); + void close(); +} + +class IliasPortalClient implements IliasStudySource { + IliasPortalClient({ + required this.username, + required this.password, + PortalHttpSession? session, + }) : _session = session ?? PortalHttpSession(); + + static final loginUri = Uri.parse( + 'https://ovidius.uni-tuebingen.de/login.php?cmd=force_login', + ); + static final tasksUri = Uri.parse( + 'https://ovidius.uni-tuebingen.de/ilias.php?baseClass=ilderivedtasksgui', + ); + + final String username; + final String password; + final PortalHttpSession _session; + bool _authenticated = false; + + @override + Future> fetchTasks({required int limit}) async { + await _authenticate(); + final response = await _session.get(tasksUri); + return parseIliasTasks( + response.body, + response.url, + ).take(limit).toList(growable: false); + } + + @override + Future> fetchDeadlines({required int scanLimit}) async { + final tasks = await fetchTasks(limit: scanLimit); + final deadlines = []; + final targets = tasks.map((task) => task.url).toSet().take(12); + for (final target in targets) { + final response = await _session.get(Uri.parse(target)); + deadlines.addAll(parseIliasAssignments(response.body, response.url)); + } + return deadlines; + } + + Future _authenticate() async { + if (_authenticated) return; + final login = await _session.get(loginUri); + final shibboleth = portalLink(login.body, login.url, 'shib_login.php'); + final idp = await _session.get(shibboleth); + final form = portalForm( + idp.body, + idp.url, + requiredFields: const {'j_username', 'j_password'}, + ); + final submitted = await _session.postForm( + PortalForm( + action: form.action, + payload: Map.from(form.payload) + ..['j_username'] = username + ..['j_password'] = password + ..putIfAbsent('_eventId_proceed', () => ''), + ), + ); + await completeSaml( + submitted, + _session, + isAuthenticated: (response) => + response.url.host == 'ovidius.uni-tuebingen.de' && + !response.body.contains('j_password') && + (response.body.contains('logout.php') || + response.body.contains('il-mainbar-entries') || + response.body.contains('baseClass=ilderivedtasksgui')), + ); + _authenticated = true; + } + + @override + void close() => _session.close(); +} + +class MoodlePortalClient implements MoodleStudySource { + MoodlePortalClient({ + required this.username, + required this.password, + PortalHttpSession? session, + DateTime Function()? clock, + }) : _session = session ?? PortalHttpSession(), + _clock = clock ?? DateTime.now; + + static final baseUri = Uri.parse('https://moodle.zdv.uni-tuebingen.de/'); + + final String username; + final String password; + final PortalHttpSession _session; + final DateTime Function() _clock; + bool _authenticated = false; + + @override + Future> fetchEvents({ + required int days, + required int limit, + }) async { + await _authenticate(); + final dashboard = await _session.get(baseUri.resolve('my/')); + if (dashboard.url.path.contains('/login/')) { + throw const PortalAuthenticationException(); + } + final sesskey = parseMoodleSesskey(dashboard.body); + final now = _clock().toUtc(); + final payload = [ + { + 'index': 0, + 'methodname': 'core_calendar_get_action_events_by_timesort', + 'args': { + 'limitnum': limit, + 'timesortfrom': now.millisecondsSinceEpoch ~/ 1000, + 'timesortto': + now.add(Duration(days: days)).millisecondsSinceEpoch ~/ 1000, + 'limittononsuspendedevents': true, + }, + }, + ]; + final response = await _session.postJson( + baseUri.resolve( + 'lib/ajax/service.php?sesskey=${Uri.encodeQueryComponent(sesskey)}&info=core_calendar_get_action_events_by_timesort', + ), + payload, + referer: dashboard.url, + ); + return parseMoodleEvents( + response.body, + baseUri, + ).take(limit).toList(growable: false); + } + + Future _authenticate() async { + if (_authenticated) return; + final login = await _session.get(baseUri.resolve('login/index.php')); + final shibboleth = portalLink( + login.body, + login.url, + '/auth/shibboleth/index.php', + ); + final idp = await _session.get(shibboleth); + final form = portalForm( + idp.body, + idp.url, + requiredFields: const {'j_username', 'j_password'}, + ); + final submitted = await _session.postForm( + PortalForm( + action: form.action, + payload: Map.from(form.payload) + ..['j_username'] = username + ..['j_password'] = password + ..putIfAbsent('_eventId_proceed', () => ''), + ), + ); + await completeSaml( + submitted, + _session, + isAuthenticated: (response) => + response.url.host == baseUri.host && + !response.url.path.contains('/login/') && + !response.url.path.contains('/auth/shibboleth/'), + ); + _authenticated = true; + } + + @override + void close() => _session.close(); +} diff --git a/flutter_app/lib/src/private_study_models.dart b/flutter_app/lib/src/private_study_models.dart new file mode 100644 index 0000000..f83b281 --- /dev/null +++ b/flutter_app/lib/src/private_study_models.dart @@ -0,0 +1,126 @@ +enum StudyPortalSource { ilias, moodle } + +class PortalTask { + const PortalTask({ + required this.source, + required this.id, + required this.title, + required this.url, + this.courseTitle, + this.itemType, + this.startAt, + this.dueAt, + this.rawStartHint, + this.rawDueHint, + this.status, + this.actionable = true, + }); + + final StudyPortalSource source; + final String id; + final String title; + final String url; + final String? courseTitle; + final String? itemType; + final DateTime? startAt; + final DateTime? dueAt; + final String? rawStartHint; + final String? rawDueHint; + final String? status; + final bool actionable; + + Map toJson() => { + 'id': '${source.name}:$id', + 'source': source.name, + 'title': title, + if (courseTitle != null) 'courseTitle': courseTitle, + if (itemType != null) 'itemType': itemType, + if (startAt != null) 'startAt': startAt!.toUtc().toIso8601String(), + if (dueAt != null) 'dueAt': dueAt!.toUtc().toIso8601String(), + if (rawStartHint != null) 'rawStartHint': rawStartHint, + if (rawDueHint != null) 'rawDueHint': rawDueHint, + if (status != null) 'status': status, + 'actionable': actionable, + 'target': safePortalTarget(url), + }; +} + +class PortalDeadline { + const PortalDeadline({ + required this.source, + required this.id, + required this.title, + required this.dueAt, + required this.url, + this.courseTitle, + this.dueHint, + this.requirement, + this.status, + }); + + final StudyPortalSource source; + final String id; + final String title; + final DateTime dueAt; + final String url; + final String? courseTitle; + final String? dueHint; + final String? requirement; + final String? status; + + String get deduplicationKey => + '${source.name}|$url|${title.toLowerCase()}|${dueAt.toUtc().toIso8601String()}'; + + Map toJson() => { + 'id': '${source.name}:$id', + 'source': source.name, + 'title': title, + if (courseTitle != null) 'courseTitle': courseTitle, + 'dueAt': dueAt.toUtc().toIso8601String(), + if (dueHint != null) 'dueHint': dueHint, + if (requirement != null) 'requirement': requirement, + if (status != null) 'status': status, + 'target': safePortalTarget(url), + }; +} + +String safePortalTarget(String value) { + final uri = Uri.tryParse(value); + if (uri == null) return ''; + const sensitiveKeys = { + 'sesskey', + 'token', + 'access_token', + 'samlresponse', + 'relaystate', + }; + final filtered = {}; + for (final entry in uri.queryParameters.entries) { + if (!sensitiveKeys.contains(entry.key.toLowerCase())) { + filtered[entry.key] = entry.value; + } + } + return uri + .replace(queryParameters: filtered.isEmpty ? null : filtered) + .toString(); +} + +class PortalAuthenticationException implements Exception { + const PortalAuthenticationException([ + this.message = 'University portal authentication is required.', + ]); + + final String message; + + @override + String toString() => message; +} + +class PortalException implements Exception { + const PortalException(this.message); + + final String message; + + @override + String toString() => message; +} diff --git a/flutter_app/lib/src/private_study_parsers.dart b/flutter_app/lib/src/private_study_parsers.dart new file mode 100644 index 0000000..2a8709f --- /dev/null +++ b/flutter_app/lib/src/private_study_parsers.dart @@ -0,0 +1,220 @@ +import 'dart:convert'; + +import 'package:html/dom.dart'; +import 'package:html/parser.dart' as html_parser; + +import 'private_study_models.dart'; + +List parseIliasTasks(String html, Uri pageUrl) { + final document = html_parser.parse(html); + final tasks = []; + for (final item in document.querySelectorAll('.il-item.il-std-item')) { + final action = item.querySelector( + '.il-item-title a[href], .il-item-title button[data-action]', + ); + final rawUrl = + action?.attributes['href'] ?? action?.attributes['data-action']; + final title = _text(action?.text); + if (rawUrl == null || title == null) { + continue; + } + final properties = _properties(item); + final url = pageUrl.resolve(rawUrl).toString(); + tasks.add( + PortalTask( + source: StudyPortalSource.ilias, + id: _stableId(url), + title: title, + url: url, + courseTitle: properties['Kurs'], + itemType: properties['Übung'] ?? properties['Typ'], + startAt: parsePortalDate(properties['Beginn']), + dueAt: parsePortalDate(properties['Ende']), + rawStartHint: properties['Beginn'], + rawDueHint: properties['Ende'], + ), + ); + } + if (tasks.isEmpty && _looksUnauthenticated(html)) { + throw const PortalAuthenticationException(); + } + return tasks; +} + +List parseIliasAssignments(String html, Uri pageUrl) { + final document = html_parser.parse(html); + final deadlines = []; + for (final item in document.querySelectorAll('.il-item.il-std-item')) { + final link = item.querySelector('.il-item-title a[href]'); + final rawUrl = link?.attributes['href']; + final title = _text(link?.text); + if (rawUrl == null || title == null) { + continue; + } + final properties = _properties(item); + final dueHint = + properties['Abgabetermin'] ?? + _text(item.querySelector('.col-sm-3')?.text); + final dueAt = parsePortalDate(dueHint); + if (dueAt == null) continue; + final url = pageUrl.resolve(rawUrl).toString(); + deadlines.add( + PortalDeadline( + source: StudyPortalSource.ilias, + id: _stableId(url), + title: title, + dueAt: dueAt, + url: url, + dueHint: dueHint, + requirement: properties['Anforderung'], + status: properties['Status'], + ), + ); + } + if (deadlines.isEmpty && _looksUnauthenticated(html)) { + throw const PortalAuthenticationException(); + } + return deadlines; +} + +String parseMoodleSesskey(String html) { + final match = RegExp( + r'M\.cfg\s*=\s*(\{.*?\});', + dotAll: true, + ).firstMatch(html); + if (match == null) { + throw const PortalException('Could not find Moodle sesskey.'); + } + final value = jsonDecode(match.group(1)!); + if (value is! Map || value['sesskey']?.toString().isEmpty != false) { + throw const PortalException('Could not find Moodle sesskey.'); + } + return value['sesskey'].toString(); +} + +List parseMoodleEvents(String body, Uri baseUrl) { + Object? payload = jsonDecode(body); + if (payload is List && payload.isNotEmpty && payload.first is Map) { + final envelope = Map.from(payload.first as Map); + if (_truthy(envelope['error'])) { + throw PortalException( + envelope['exception']?.toString() ?? + envelope['message']?.toString() ?? + 'Moodle request failed.', + ); + } + payload = envelope['data']; + } + final events = payload is Map ? payload['events'] : payload; + if (events is! List) return const []; + return events + .whereType() + .map((raw) { + final item = Map.from(raw); + final course = item['course'] is Map + ? Map.from(item['course'] as Map) + : const {}; + final action = item['action'] is Map + ? Map.from(item['action'] as Map) + : const {}; + final rawUrl = action['url'] ?? item['url'] ?? item['viewurl']; + final url = rawUrl == null + ? baseUrl + : baseUrl.resolve(rawUrl.toString()); + final timestamp = _integer(item['timesort']); + return PortalTask( + source: StudyPortalSource.moodle, + id: item['id']?.toString() ?? _stableId(url.toString()), + title: + _text(item['name']?.toString()) ?? + _text(item['title']?.toString()) ?? + 'Untitled event', + url: url.toString(), + courseTitle: + _text(course['fullname']?.toString()) ?? + _text(course['shortname']?.toString()), + itemType: 'calendar_event', + dueAt: timestamp == null + ? null + : DateTime.fromMillisecondsSinceEpoch( + timestamp * 1000, + isUtc: true, + ), + rawDueHint: _text(item['formattedtime']?.toString()), + actionable: rawUrl != null, + ); + }) + .toList(growable: false); +} + +DateTime? parsePortalDate(String? value) { + final text = _text(value); + if (text == null) return null; + final iso = DateTime.tryParse(text); + if (iso != null) return iso; + final match = RegExp( + r'\b(\d{1,2})\.(\d{1,2})\.(\d{4})(?:[, ]+\s*(\d{1,2}):(\d{2}))?', + ).firstMatch(text); + if (match == null) return null; + final year = int.parse(match.group(3)!); + final month = int.parse(match.group(2)!); + final day = int.parse(match.group(1)!); + final hour = int.tryParse(match.group(4) ?? '') ?? 0; + final minute = int.tryParse(match.group(5) ?? '') ?? 0; + final offset = _berlinUtcOffset(year, month, day, hour); + return DateTime.utc(year, month, day, hour - offset, minute); +} + +int _berlinUtcOffset(int year, int month, int day, int hour) { + if (month < 3 || month > 10) return 1; + if (month > 3 && month < 10) return 2; + final current = DateTime.utc(year, month, day, hour); + final transitionDay = _lastSunday(year, month); + final transitionHour = month == 3 ? 2 : 3; + final transition = DateTime.utc(year, month, transitionDay, transitionHour); + return month == 3 + ? (current.isBefore(transition) ? 1 : 2) + : (current.isBefore(transition) ? 2 : 1); +} + +int _lastSunday(int year, int month) { + final last = DateTime.utc(year, month + 1, 0); + return last.day - (last.weekday % 7); +} + +Map _properties(Element item) { + final result = {}; + for (final name in item.querySelectorAll('.il-item-property-name')) { + final value = name.nextElementSibling; + if (value?.classes.contains('il-item-property-value') != true) continue; + final key = _text(name.text); + final content = _text(value!.text); + if (key != null && content != null) result[key] = content; + } + return result; +} + +String? _text(String? value) { + final cleaned = value?.replaceAll(RegExp(r'\s+'), ' ').trim() ?? ''; + return cleaned.isEmpty ? null : cleaned; +} + +int? _integer(Object? value) => + value is num ? value.toInt() : int.tryParse(value?.toString() ?? ''); + +bool _truthy(Object? value) => + value == true || value == 1 || value?.toString().toLowerCase() == 'true'; + +bool _looksUnauthenticated(String html) => + html.contains('SAMLResponse') || + html.contains('j_username') || + html.contains('j_password') || + html.contains('shib_login.php'); + +String _stableId(String value) { + var hash = 0x811c9dc5; + for (final byte in utf8.encode(value)) { + hash = ((hash ^ byte) * 0x01000193) & 0xffffffff; + } + return hash.toRadixString(16).padLeft(8, '0'); +} diff --git a/flutter_app/lib/src/private_study_tools.dart b/flutter_app/lib/src/private_study_tools.dart new file mode 100644 index 0000000..c84efd5 --- /dev/null +++ b/flutter_app/lib/src/private_study_tools.dart @@ -0,0 +1,108 @@ +import 'dart:convert'; + +import 'capability_result.dart'; +import 'private_study_capabilities.dart'; +import 'private_study_models.dart'; + +const getTasksToolName = 'get_tasks'; +const getDeadlinesToolName = 'get_deadlines'; + +abstract interface class PrivateStudyToolRunner { + Future execute(String toolName, String arguments); + void invalidate(); +} + +class LivePrivateStudyToolRunner implements PrivateStudyToolRunner { + LivePrivateStudyToolRunner(this._capability); + + final PrivateStudyCapability _capability; + + @override + Future execute(String toolName, String arguments) async { + final Map args; + try { + final decoded = arguments.trim().isEmpty + ? {} + : jsonDecode(arguments); + if (decoded is! Map) throw const FormatException(); + args = Map.from(decoded); + } on Object { + return _failure('Tool arguments must be a JSON object.'); + } + final sources = _sources(args['sources']); + if (sources == null) { + return _failure('Sources must be a subset of ilias and moodle.'); + } + return switch (toolName) { + getTasksToolName => _tasks(args, sources), + getDeadlinesToolName => _deadlines(args, sources), + _ => _failure('Private study tool is not available.'), + }; + } + + Future _tasks( + Map args, + Set sources, + ) async { + final result = await _capability.tasks( + sources: sources, + limit: _boundedInt(args['limit'], fallback: 20, max: 50), + ); + return jsonEncode( + result.toJson( + (items) => items.map((item) => item.toJson()).toList(growable: false), + ), + ); + } + + Future _deadlines( + Map args, + Set sources, + ) async { + final result = await _capability.deadlines( + sources: sources, + days: _boundedInt(args['days'], fallback: 30, max: 180), + limit: _boundedInt(args['limit'], fallback: 30, max: 100), + ); + return jsonEncode( + result.toJson( + (items) => items.map((item) => item.toJson()).toList(growable: false), + ), + ); + } + + String _failure(String message) => jsonEncode( + CapabilityResult>( + state: CapabilityState.failed, + policy: CapabilityPolicy.privateRead, + source: PrivateStudyCapability.source, + fetchedAt: DateTime.now(), + message: message, + ).toJson((items) => items), + ); + + @override + void invalidate() => _capability.invalidate(); +} + +Set? _sources(Object? value) { + if (value == null) return StudyPortalSource.values.toSet(); + if (value is! List || value.isEmpty) return null; + final result = {}; + for (final item in value) { + final normalized = item.toString().trim().toLowerCase(); + final source = StudyPortalSource.values + .where((candidate) => candidate.name == normalized) + .firstOrNull; + if (source == null) return null; + result.add(source); + } + return result; +} + +int _boundedInt(Object? value, {required int fallback, required int max}) { + final parsed = value is num + ? value.toInt() + : int.tryParse(value?.toString() ?? ''); + return (parsed ?? fallback).clamp(1, max).toInt(); +} diff --git a/flutter_app/lib/src/studyos_tool_catalog.dart b/flutter_app/lib/src/studyos_tool_catalog.dart index 049b939..d04e642 100644 --- a/flutter_app/lib/src/studyos_tool_catalog.dart +++ b/flutter_app/lib/src/studyos_tool_catalog.dart @@ -1,4 +1,5 @@ import 'native_tool_router.dart'; +import 'private_study_tools.dart'; import 'public_study_tools.dart'; class StudyOsToolSpec { @@ -8,6 +9,7 @@ class StudyOsToolSpec { required this.traceSummary, required this.properties, required this.required, + this.cloudAllowed = true, }); final String name; @@ -15,6 +17,7 @@ class StudyOsToolSpec { final String traceSummary; final Map properties; final List required; + final bool cloudAllowed; } const appendMemoryTool = StudyOsToolSpec( @@ -126,6 +129,56 @@ const searchCampusLocationsTool = StudyOsToolSpec( required: ['query'], ); +const getTasksTool = StudyOsToolSpec( + name: getTasksToolName, + description: + 'Read private ILIAS and Moodle tasks locally. Results never leave the device through cloud tools.', + traceSummary: 'Loading local ILIAS and Moodle tasks.', + properties: { + 'sources': { + 'type': 'array', + 'items': { + 'type': 'string', + 'enum': ['ilias', 'moodle'], + }, + 'description': 'Optional portal subset; defaults to both.', + }, + 'limit': { + 'type': 'integer', + 'description': 'Maximum tasks to return. Capped at 50.', + }, + }, + required: [], + cloudAllowed: false, +); + +const getDeadlinesTool = StudyOsToolSpec( + name: getDeadlinesToolName, + description: + 'Read confirmed private ILIAS and Moodle deadlines locally within a bounded window.', + traceSummary: 'Loading local ILIAS and Moodle deadlines.', + properties: { + 'days': { + 'type': 'integer', + 'description': 'Upcoming window in days, from 1 to 180. Defaults to 30.', + }, + 'sources': { + 'type': 'array', + 'items': { + 'type': 'string', + 'enum': ['ilias', 'moodle'], + }, + 'description': 'Optional portal subset; defaults to both.', + }, + 'limit': { + 'type': 'integer', + 'description': 'Maximum deadlines to return. Capped at 100.', + }, + }, + required: [], + cloudAllowed: false, +); + const listMailboxesTool = StudyOsToolSpec( name: 'list_mailboxes', description: 'List local university mail folders and unread counts.', @@ -386,6 +439,8 @@ const studyOsTools = [ searchTalksTool, getMensaOptionsTool, searchCampusLocationsTool, + getTasksTool, + getDeadlinesTool, listMailboxesTool, getRecentMailTool, searchMailTool, @@ -413,6 +468,11 @@ List studyOsToolsForNativeSupport( .toList(); } +List cloudStudyOsTools(Set supportedNativeToolNames) => + studyOsToolsForNativeSupport( + supportedNativeToolNames, + ).where((tool) => tool.cloudAllowed).toList(growable: false); + StudyOsToolSpec? studyOsToolByName(String name) { for (final tool in studyOsTools) { if (tool.name == name) return tool; diff --git a/flutter_app/lib/src/studyos_tool_executor.dart b/flutter_app/lib/src/studyos_tool_executor.dart index c706892..b5448f2 100644 --- a/flutter_app/lib/src/studyos_tool_executor.dart +++ b/flutter_app/lib/src/studyos_tool_executor.dart @@ -4,6 +4,7 @@ import 'mail_tools.dart'; import 'memory_store.dart'; import 'native_tool_router.dart'; import 'prompt_context.dart'; +import 'private_study_tools.dart'; import 'public_study_tools.dart'; Future _unavailableAcademicStatus() async => @@ -22,6 +23,7 @@ class StudyOsToolContext { required this.mailTools, required this.nativeTools, this.publicStudyTools, + this.privateStudyTools, }); final PromptContext promptContext; @@ -33,6 +35,7 @@ class StudyOsToolContext { final MailToolRunner mailTools; final NativeToolRunner? nativeTools; final PublicStudyToolRunner? publicStudyTools; + final PrivateStudyToolRunner? privateStudyTools; } class StudyOsToolExecutor { @@ -52,6 +55,11 @@ class StudyOsToolExecutor { 'search_talks' => _searchTalks(arguments, context.searchTalks), getMensaOptionsToolName || searchCampusLocationsToolName => _executePublicStudyTool(toolName, arguments, context.publicStudyTools), + getTasksToolName || getDeadlinesToolName => _executePrivateStudyTool( + toolName, + arguments, + context.privateStudyTools, + ), 'list_mailboxes' || 'get_recent_mail' || 'search_mail' || @@ -66,6 +74,19 @@ class StudyOsToolExecutor { }; } + Future _executePrivateStudyTool( + String toolName, + String arguments, + PrivateStudyToolRunner? privateStudyTools, + ) { + if (privateStudyTools == null) { + return Future.value( + 'Private study tool is not available in this local runtime: $toolName', + ); + } + return privateStudyTools.execute(toolName, arguments); + } + Future _executePublicStudyTool( String toolName, String arguments, @@ -145,6 +166,7 @@ StudyOsToolContext studyOsToolContext({ required MailToolRunner mailTools, NativeToolRunner? nativeTools, PublicStudyToolRunner? publicStudyTools, + PrivateStudyToolRunner? privateStudyTools, }) { return StudyOsToolContext( promptContext: promptContext, @@ -156,5 +178,6 @@ StudyOsToolContext studyOsToolContext({ mailTools: mailTools, nativeTools: nativeTools, publicStudyTools: publicStudyTools, + privateStudyTools: privateStudyTools, ); } diff --git a/flutter_app/test/cloud_agent_client_test.dart b/flutter_app/test/cloud_agent_client_test.dart index 0c31c7d..91ebff8 100644 --- a/flutter_app/test/cloud_agent_client_test.dart +++ b/flutter_app/test/cloud_agent_client_test.dart @@ -37,6 +37,8 @@ void main() { 'find_mail_deadlines', ]), ); + expect(toolNames, isNot(contains('get_tasks'))); + expect(toolNames, isNot(contains('get_deadlines'))); expect(toolNames, isNot(contains(nativeDeviceStatusToolName))); }); diff --git a/flutter_app/test/private_study_tools_test.dart b/flutter_app/test/private_study_tools_test.dart new file mode 100644 index 0000000..3504043 --- /dev/null +++ b/flutter_app/test/private_study_tools_test.dart @@ -0,0 +1,352 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:studyos_agent/src/portal_http_session.dart'; +import 'package:studyos_agent/src/private_study_capabilities.dart'; +import 'package:studyos_agent/src/private_study_clients.dart'; +import 'package:studyos_agent/src/private_study_models.dart'; +import 'package:studyos_agent/src/private_study_parsers.dart'; +import 'package:studyos_agent/src/private_study_tools.dart'; + +void main() { + test('ILIAS parser preserves hints and normalizes reliable dates', () { + final tasks = parseIliasTasks( + _iliasTasksHtml, + Uri.parse('https://ilias.test/tasks'), + ); + + expect(tasks, hasLength(2)); + expect(tasks.first.title, 'Exercise sheet 4'); + expect(tasks.first.courseTitle, 'Machine Learning'); + expect(tasks.first.dueAt, DateTime.utc(2026, 7, 20, 21, 59)); + expect(tasks.first.url, 'https://ilias.test/exercise/4'); + expect(tasks.last.dueAt, isNull); + expect(tasks.last.rawDueHint, 'next tutorial'); + }); + + test('Moodle parser normalizes actionable calendar events', () { + final tasks = parseMoodleEvents( + jsonEncode([ + { + 'error': false, + 'data': { + 'events': [ + { + 'id': 42, + 'name': 'Submit report', + 'timesort': 1784584740, + 'formattedtime': 'Monday, 23:59', + 'course': {'fullname': 'Agentic Systems'}, + 'action': { + 'url': '/mod/assign/view.php?id=42', + }, + }, + ], + }, + }, + ]), + Uri.parse('https://moodle.test/'), + ); + + expect(tasks, hasLength(1)); + expect(tasks.single.id, '42'); + expect(tasks.single.courseTitle, 'Agentic Systems'); + expect(tasks.single.dueAt, isNotNull); + expect(tasks.single.actionable, isTrue); + expect(tasks.single.url, 'https://moodle.test/mod/assign/view.php?id=42'); + }); + + test('ILIAS assignment parser includes only confirmed dated entries', () { + final deadlines = parseIliasAssignments( + _iliasAssignmentHtml, + Uri.parse('https://ilias.test/exercise'), + ); + + expect(deadlines, hasLength(1)); + expect(deadlines.single.title, 'Final report'); + expect(deadlines.single.dueAt, DateTime.utc(2026, 7, 25, 21, 59)); + expect(deadlines.single.requirement, 'Mandatory'); + }); + + test( + 'get_tasks returns healthy source with bounded partial failure', + () async { + var moodleCalls = 0; + final capability = PrivateStudyCapability( + profileProvider: () => null, + credentialsProvider: () async => + const PortalCredentials('student', 'secret'), + iliasFactory: (_, _) => + _FakeIlias(tasksError: const PortalException('offline')), + moodleFactory: (_, _) => _FakeMoodle( + onFetch: () { + moodleCalls += 1; + return [ + _task( + StudyPortalSource.moodle, + 'later', + DateTime.utc(2026, 7, 22), + ), + _task( + StudyPortalSource.moodle, + 'soon', + DateTime.utc(2026, 7, 20), + ), + ]; + }, + ), + clock: () => DateTime.utc(2026, 7, 17), + ); + final runner = LivePrivateStudyToolRunner(capability); + + final first = _json( + await runner.execute(getTasksToolName, '{"limit":1}'), + ); + final second = _json( + await runner.execute(getTasksToolName, '{"limit":1}'), + ); + + expect(first['state'], 'fresh'); + expect(_policy(first)['privacy'], 'private_local'); + expect(_data(first), hasLength(1)); + expect(_data(first).single['title'], 'soon'); + expect( + (first['failures'] as List).single, + containsPair('source', 'ilias'), + ); + expect(jsonEncode(first), isNot(contains('secret'))); + expect( + moodleCalls, + 1, + reason: 'the second call should use the TTL cache', + ); + expect(second, first); + }, + ); + + test( + 'get_deadlines filters window and deduplicates confirmed dates', + () async { + final inside = PortalDeadline( + source: StudyPortalSource.ilias, + id: 'one', + title: 'Sheet', + dueAt: DateTime.utc(2026, 7, 20), + url: 'https://ilias.test/sheet', + dueHint: '20.07.2026', + ); + final capability = PrivateStudyCapability( + profileProvider: () => null, + credentialsProvider: () async => + const PortalCredentials('student', 'secret'), + iliasFactory: (_, _) => _FakeIlias( + deadlines: [ + inside, + inside, + PortalDeadline( + source: StudyPortalSource.ilias, + id: 'outside', + title: 'Old sheet', + dueAt: DateTime.utc(2026, 6, 1), + url: 'https://ilias.test/old', + ), + ], + ), + moodleFactory: (_, _) => + _FakeMoodle(onFetch: () => const []), + clock: () => DateTime.utc(2026, 7, 17), + ); + final result = _json( + await LivePrivateStudyToolRunner( + capability, + ).execute(getDeadlinesToolName, '{"days":30,"sources":["ilias"]}'), + ); + + expect(result['state'], 'fresh'); + expect(_data(result), hasLength(1)); + expect(_data(result).single['dueHint'], '20.07.2026'); + }, + ); + + test('private tools distinguish missing local authentication', () async { + final capability = PrivateStudyCapability( + profileProvider: () => null, + credentialsProvider: () async => null, + ); + final result = _json( + await LivePrivateStudyToolRunner( + capability, + ).execute(getTasksToolName, '{}'), + ); + + expect(result['state'], 'authenticationRequired'); + expect(result['message'], contains('Sign in locally')); + }); + + test('private tools serve stale local data after refresh failure', () async { + var now = DateTime.utc(2026, 7, 17); + var calls = 0; + final capability = PrivateStudyCapability( + profileProvider: () => null, + credentialsProvider: () async => + const PortalCredentials('student', 'secret'), + iliasFactory: (_, _) => _FakeIlias( + onTasks: () { + calls += 1; + if (calls > 1) throw const PortalException('offline'); + return [ + _task(StudyPortalSource.ilias, 'cached', DateTime.utc(2026, 7, 20)), + ]; + }, + ), + moodleFactory: (_, _) => _FakeMoodle(onFetch: () => const []), + clock: () => now, + ttl: const Duration(minutes: 1), + ); + final runner = LivePrivateStudyToolRunner(capability); + + await runner.execute(getTasksToolName, '{"sources":["ilias"],"limit":10}'); + now = now.add(const Duration(minutes: 2)); + final stale = _json( + await runner.execute( + getTasksToolName, + '{"sources":["ilias"],"limit":10}', + ), + ); + + expect(stale['state'], 'stale'); + expect(_data(stale).single['title'], 'cached'); + expect((stale['failures'] as List).single, containsPair('source', 'ilias')); + }); + + test('tool output strips session-shaped URL parameters', () { + expect( + safePortalTarget( + 'https://moodle.test/mod/assign?id=42&sesskey=secret&token=hidden', + ), + 'https://moodle.test/mod/assign?id=42', + ); + }); + + test('portal session scopes cookies to the issuing host', () async { + final requests = []; + final session = PortalHttpSession( + allowedHostSuffix: 'test', + client: MockClient((request) async { + requests.add(request); + return http.Response( + 'ok', + 200, + headers: request.url.host == 'idp.test' + ? {'set-cookie': 'idp_session=private; Secure'} + : const {}, + request: request, + ); + }), + ); + + await session.get(Uri.parse('https://idp.test/login')); + await session.get(Uri.parse('https://moodle.test/home')); + + expect(requests.first.headers['Cookie'], isNull); + expect(requests.last.headers['Cookie'], isNull); + session.close(); + }); + + test('portal session rejects untrusted redirects before sending', () async { + final session = PortalHttpSession( + client: MockClient((_) async => http.Response('unexpected', 200)), + ); + + await expectLater( + session.get(Uri.parse('https://evil.example/login')), + throwsA(isA()), + ); + session.close(); + }); +} + +class _FakeIlias implements IliasStudySource { + _FakeIlias({ + this.deadlines = const [], + this.tasksError, + this.onTasks, + }); + + final List deadlines; + final Object? tasksError; + final List Function()? onTasks; + + @override + Future> fetchTasks({required int limit}) async { + if (tasksError case final error?) throw error; + return (onTasks?.call() ?? const []).take(limit).toList(); + } + + @override + Future> fetchDeadlines({required int scanLimit}) async => + deadlines; + + @override + void close() {} +} + +class _FakeMoodle implements MoodleStudySource { + _FakeMoodle({required this.onFetch}); + final List Function() onFetch; + + @override + Future> fetchEvents({ + required int days, + required int limit, + }) async => onFetch().take(limit).toList(); + + @override + void close() {} +} + +PortalTask _task(StudyPortalSource source, String title, DateTime dueAt) => + PortalTask( + source: source, + id: title, + title: title, + url: 'https://${source.name}.test/$title', + dueAt: dueAt, + ); + +Map _json(String value) => + Map.from(jsonDecode(value) as Map); + +Map _policy(Map result) => + Map.from(result['policy'] as Map); + +List> _data(Map result) => + (result['data'] as List) + .map((item) => Map.from(item as Map)) + .toList(); + +const _iliasTasksHtml = ''' +
+ + KursMachine Learning + Beginn17.07.2026, 08:00 + Ende20.07.2026, 23:59 +
+
+ + Endenext tutorial +
+'''; + +const _iliasAssignmentHtml = ''' +
+ + Abgabetermin25.07.2026, 23:59 + AnforderungMandatory +
+ +'''; diff --git a/flutter_app/test/studyos_tool_executor_test.dart b/flutter_app/test/studyos_tool_executor_test.dart index 32f4089..4021a55 100644 --- a/flutter_app/test/studyos_tool_executor_test.dart +++ b/flutter_app/test/studyos_tool_executor_test.dart @@ -3,6 +3,7 @@ import 'package:studyos_agent/src/mail_repository.dart'; import 'package:studyos_agent/src/mail_tools.dart'; import 'package:studyos_agent/src/native_tool_router.dart'; import 'package:studyos_agent/src/prompt_context.dart'; +import 'package:studyos_agent/src/private_study_tools.dart'; import 'package:studyos_agent/src/public_study_tools.dart'; import 'package:studyos_agent/src/studyos_tool_catalog.dart'; import 'package:studyos_agent/src/studyos_tool_executor.dart'; @@ -74,6 +75,19 @@ void main() { expect(publicTools.arguments, ['{"preference":"vegan"}']); }); + test('StudyOsToolExecutor routes private study tools locally', () async { + final privateTools = _FakePrivateStudyToolRunner('Private results'); + + final response = await const StudyOsToolExecutor().execute( + getTasksToolName, + '{"sources":["ilias"]}', + _context(privateStudyTools: privateTools), + ); + + expect(response, 'Private results'); + expect(privateTools.calls, [getTasksToolName]); + }); + test( 'StudyOsToolExecutor returns explicit errors for bad tool input', () async { @@ -97,6 +111,8 @@ void main() { expect(toolNames, contains('search_talks')); expect(toolNames, contains(getMensaOptionsToolName)); expect(toolNames, contains(searchCampusLocationsToolName)); + expect(toolNames, contains(getTasksToolName)); + expect(toolNames, contains(getDeadlinesToolName)); expect(toolNames, contains(nativeDeviceStatusToolName)); expect(toolNames, contains(nativeSetFlashlightToolName)); expect(toolNames, contains(nativeOpenInstalledAppToolName)); @@ -159,6 +175,7 @@ StudyOsToolContext _context({ Future Function(String query, int limit)? searchTalks, NativeToolRunner? nativeTools, PublicStudyToolRunner? publicStudyTools, + PrivateStudyToolRunner? privateStudyTools, }) { return StudyOsToolContext( promptContext: const PromptContext( @@ -173,9 +190,26 @@ StudyOsToolContext _context({ mailTools: MailToolRunner(repository: MailRepository(), profile: null), nativeTools: nativeTools, publicStudyTools: publicStudyTools, + privateStudyTools: privateStudyTools, ); } +class _FakePrivateStudyToolRunner implements PrivateStudyToolRunner { + _FakePrivateStudyToolRunner(this.response); + + final String response; + final calls = []; + + @override + Future execute(String toolName, String arguments) async { + calls.add(toolName); + return response; + } + + @override + void invalidate() {} +} + class _FakePublicStudyToolRunner implements PublicStudyToolRunner { _FakePublicStudyToolRunner(this.response); From 92e1e03a1b650d0be752452b268e76e195499a1d Mon Sep 17 00:00:00 2001 From: StudyOS Org Date: Fri, 17 Jul 2026 19:42:29 +0000 Subject: [PATCH 2/8] feat(agent): expose study tasks to cloud models --- README.md | 10 +++ flutter_app/lib/src/agent_llm_provider.dart | 1 + flutter_app/lib/src/agent_request_runner.dart | 4 +- flutter_app/lib/src/cloud_agent_client.dart | 3 + flutter_app/lib/src/studyos_tool_catalog.dart | 10 +-- .../lib/src/studyos_tool_executor.dart | 2 +- flutter_app/test/agent_llm_provider_test.dart | 12 ++++ flutter_app/test/cloud_agent_client_test.dart | 71 ++++++++++++++++++- 8 files changed, 99 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 8de804c..04d586a 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,16 @@ flutter run -d chrome \ --dart-define=STUDYOS_DEMO_OPENROUTER_API_KEY="$OPENROUTER_API_KEY" ``` +### Assistant tool privacy + +StudyOS executes university tools in the app for both on-device and cloud +models. Portal credentials, cookies, SAML fields, session keys, and raw HTML +remain on the device. When a cloud model requests `get_tasks` or +`get_deadlines`, the app sends only the sanitized tool result (such as titles, +courses, dates, and source links) back to the configured AI service so it can +answer the question. Use the on-device model when those results must not leave +the device. + ## Migration Notes - Flutter owns the main chat UI, input bar, status display, navigation, and diff --git a/flutter_app/lib/src/agent_llm_provider.dart b/flutter_app/lib/src/agent_llm_provider.dart index 5a6d41f..c8a8ad6 100644 --- a/flutter_app/lib/src/agent_llm_provider.dart +++ b/flutter_app/lib/src/agent_llm_provider.dart @@ -346,6 +346,7 @@ class CloudLlmProvider implements AgentLlmProvider { searchTalks: request.searchTalks, mailTools: request.mailTools, publicStudyTools: request.publicStudyTools, + privateStudyTools: request.privateStudyTools, onToolTrace: request.onToolTrace, onDelta: request.onDelta, cancelToken: request.cancelToken, diff --git a/flutter_app/lib/src/agent_request_runner.dart b/flutter_app/lib/src/agent_request_runner.dart index 913fa93..3e96f6b 100644 --- a/flutter_app/lib/src/agent_request_runner.dart +++ b/flutter_app/lib/src/agent_request_runner.dart @@ -76,9 +76,7 @@ class AgentRequestRunner { searchTalks: searchTalks, mailTools: mailTools, publicStudyTools: publicStudyTools, - privateStudyTools: config.provider == AgentProvider.local - ? privateStudyTools - : null, + privateStudyTools: privateStudyTools, onToolTrace: onToolTrace, onDelta: onDelta, cancelToken: cancelToken, diff --git a/flutter_app/lib/src/cloud_agent_client.dart b/flutter_app/lib/src/cloud_agent_client.dart index aa2463c..de83f74 100644 --- a/flutter_app/lib/src/cloud_agent_client.dart +++ b/flutter_app/lib/src/cloud_agent_client.dart @@ -9,6 +9,7 @@ import 'mail_tools.dart'; import 'models.dart'; import 'native_tool_router.dart'; import 'prompt_context.dart'; +import 'private_study_tools.dart'; import 'public_study_tools.dart'; import 'studyos_tool_catalog.dart'; import 'studyos_tool_executor.dart'; @@ -48,6 +49,7 @@ class CloudAgentClient { _unavailableTalks, required MailToolRunner mailTools, PublicStudyToolRunner? publicStudyTools, + PrivateStudyToolRunner? privateStudyTools, void Function(ToolTrace trace)? onToolTrace, AgentStreamSink? onDelta, AgentCancelToken? cancelToken, @@ -86,6 +88,7 @@ class CloudAgentClient { mailTools: mailTools, nativeTools: _nativeTools, publicStudyTools: publicStudyTools, + privateStudyTools: privateStudyTools, ); for (var round = 0; ; round += 1) { final message = await _fetchTurn( diff --git a/flutter_app/lib/src/studyos_tool_catalog.dart b/flutter_app/lib/src/studyos_tool_catalog.dart index d04e642..3043631 100644 --- a/flutter_app/lib/src/studyos_tool_catalog.dart +++ b/flutter_app/lib/src/studyos_tool_catalog.dart @@ -9,7 +9,6 @@ class StudyOsToolSpec { required this.traceSummary, required this.properties, required this.required, - this.cloudAllowed = true, }); final String name; @@ -17,7 +16,6 @@ class StudyOsToolSpec { final String traceSummary; final Map properties; final List required; - final bool cloudAllowed; } const appendMemoryTool = StudyOsToolSpec( @@ -132,7 +130,7 @@ const searchCampusLocationsTool = StudyOsToolSpec( const getTasksTool = StudyOsToolSpec( name: getTasksToolName, description: - 'Read private ILIAS and Moodle tasks locally. Results never leave the device through cloud tools.', + 'Read private ILIAS and Moodle tasks through the on-device portal client.', traceSummary: 'Loading local ILIAS and Moodle tasks.', properties: { 'sources': { @@ -149,7 +147,6 @@ const getTasksTool = StudyOsToolSpec( }, }, required: [], - cloudAllowed: false, ); const getDeadlinesTool = StudyOsToolSpec( @@ -176,7 +173,6 @@ const getDeadlinesTool = StudyOsToolSpec( }, }, required: [], - cloudAllowed: false, ); const listMailboxesTool = StudyOsToolSpec( @@ -469,9 +465,7 @@ List studyOsToolsForNativeSupport( } List cloudStudyOsTools(Set supportedNativeToolNames) => - studyOsToolsForNativeSupport( - supportedNativeToolNames, - ).where((tool) => tool.cloudAllowed).toList(growable: false); + studyOsToolsForNativeSupport(supportedNativeToolNames); StudyOsToolSpec? studyOsToolByName(String name) { for (final tool in studyOsTools) { diff --git a/flutter_app/lib/src/studyos_tool_executor.dart b/flutter_app/lib/src/studyos_tool_executor.dart index b5448f2..499198a 100644 --- a/flutter_app/lib/src/studyos_tool_executor.dart +++ b/flutter_app/lib/src/studyos_tool_executor.dart @@ -81,7 +81,7 @@ class StudyOsToolExecutor { ) { if (privateStudyTools == null) { return Future.value( - 'Private study tool is not available in this local runtime: $toolName', + 'Private study tool is not available in this app runtime: $toolName', ); } return privateStudyTools.execute(toolName, arguments); diff --git a/flutter_app/test/agent_llm_provider_test.dart b/flutter_app/test/agent_llm_provider_test.dart index 32231fb..f6861a6 100644 --- a/flutter_app/test/agent_llm_provider_test.dart +++ b/flutter_app/test/agent_llm_provider_test.dart @@ -10,6 +10,7 @@ import 'package:studyos_agent/src/models.dart'; import 'package:studyos_agent/src/native_bridge.dart'; import 'package:studyos_agent/src/native_tool_router.dart'; import 'package:studyos_agent/src/prompt_context.dart'; +import 'package:studyos_agent/src/private_study_tools.dart'; void main() { test('AgentLlmProviderRegistry resolves registered providers', () { @@ -27,6 +28,7 @@ void main() { 'AgentRequestRunner delegates requests to the selected provider', () async { final provider = _FakeLlmProvider(provider: AgentProvider.cloud); + final privateTools = _FakePrivateStudyTools(); final runner = AgentRequestRunner( bridge: NativeBridge(), configStore: AgentConfigStore(), @@ -58,11 +60,13 @@ void main() { memoryText: 'Saved context', readSchedule: () async => 'No schedule.', mailTools: MailToolRunner(repository: MailRepository(), profile: null), + privateStudyTools: privateTools, ); expect(response, 'fake response'); expect(provider.lastRequest?.userText, 'Hello'); expect(provider.lastRequest?.memoryText, 'Saved context'); + expect(provider.lastRequest?.privateStudyTools, same(privateTools)); }, ); @@ -270,6 +274,14 @@ class _FakeLlmProvider implements AgentLlmProvider { } } +class _FakePrivateStudyTools implements PrivateStudyToolRunner { + @override + Future execute(String toolName, String arguments) async => '{}'; + + @override + void invalidate() {} +} + class _FakeNativeBridge extends NativeBridge { _FakeNativeBridge( this.response, { diff --git a/flutter_app/test/cloud_agent_client_test.dart b/flutter_app/test/cloud_agent_client_test.dart index 91ebff8..af58876 100644 --- a/flutter_app/test/cloud_agent_client_test.dart +++ b/flutter_app/test/cloud_agent_client_test.dart @@ -12,6 +12,7 @@ import 'package:studyos_agent/src/mail_tools.dart'; import 'package:studyos_agent/src/models.dart'; import 'package:studyos_agent/src/native_tool_router.dart'; import 'package:studyos_agent/src/prompt_context.dart'; +import 'package:studyos_agent/src/private_study_tools.dart'; void main() { test('cloud tools are built from the StudyOS catalog', () { @@ -37,11 +38,64 @@ void main() { 'find_mail_deadlines', ]), ); - expect(toolNames, isNot(contains('get_tasks'))); - expect(toolNames, isNot(contains('get_deadlines'))); + expect(toolNames, contains('get_tasks')); + expect(toolNames, contains('get_deadlines')); expect(toolNames, isNot(contains(nativeDeviceStatusToolName))); }); + test('executes private study tools locally for cloud models', () async { + var requestCount = 0; + final bodies = >[]; + final privateTools = _FakePrivateStudyTools(); + final client = CloudAgentClient( + httpClient: MockClient((request) async { + requestCount += 1; + bodies.add(jsonDecode(request.body) as Map); + if (requestCount == 1) { + return _toolCallResponse( + request, + id: 'tasks_1', + name: getTasksToolName, + arguments: '{}', + ); + } + return _contentResponse(request, 'You have one task.'); + }), + ); + + final response = await client.sendMessage( + config: const AgentConfig( + provider: AgentProvider.cloud, + cloudEndpoint: 'https://openrouter.ai/api/v1/chat/completions', + cloudModel: 'openai/gpt-4.1-mini', + hasApiKey: true, + localModelId: 'test-local', + localModelPath: '', + ), + apiKey: 'secret', + history: const [], + userText: 'What tasks do I have?', + context: const PromptContext( + profile: null, + memory: '', + worldState: {}, + ), + appendMemory: (_) async {}, + readMemory: () async => '', + readSchedule: () async => '', + mailTools: _fakeMailTools(), + privateStudyTools: privateTools, + ); + + expect(response, 'You have one task.'); + expect(privateTools.calls, [getTasksToolName]); + final followUpMessages = bodies.last['messages'] as List; + expect( + followUpMessages.whereType().last['content'], + contains('Linear Algebra sheet'), + ); + }); + test('cloud tools include only supported native tools', () { final toolNames = cloudToolDefinitions( @@ -598,6 +652,19 @@ MailToolRunner _fakeMailTools() { return MailToolRunner(repository: _FakeMailRepository(), profile: null); } +class _FakePrivateStudyTools implements PrivateStudyToolRunner { + final List calls = []; + + @override + Future execute(String toolName, String arguments) async { + calls.add(toolName); + return '{"state":"fresh","data":[{"title":"Linear Algebra sheet"}]}'; + } + + @override + void invalidate() {} +} + class _FakeMailRepository extends MailRepository { _FakeMailRepository() : super.test(); From 7f768d3e3058dee0ec1841b8ca1f6fe419de64e2 Mon Sep 17 00:00:00 2001 From: StudyOS Org Date: Fri, 17 Jul 2026 21:31:47 +0000 Subject: [PATCH 3/8] fix(agent): recognize current ILIAS login shell --- .../lib/src/private_study_capabilities.dart | 16 +++--- .../lib/src/private_study_clients.dart | 28 +++++++--- .../test/private_study_tools_test.dart | 53 +++++++++++++++++++ 3 files changed, 84 insertions(+), 13 deletions(-) diff --git a/flutter_app/lib/src/private_study_capabilities.dart b/flutter_app/lib/src/private_study_capabilities.dart index b26503e..a6005b1 100644 --- a/flutter_app/lib/src/private_study_capabilities.dart +++ b/flutter_app/lib/src/private_study_capabilities.dart @@ -252,13 +252,15 @@ class _PrivateCacheEntry { final DateTime expiresAt; } -CapabilityFailure _failure(StudyPortalSource source, Object error) => - CapabilityFailure( - source: source.name, - message: error is PortalAuthenticationException - ? 'authentication required' - : boundedCapabilityMessage(error), - ); +CapabilityFailure _failure( + StudyPortalSource source, + Object error, +) => CapabilityFailure( + source: source.name, + message: error is PortalAuthenticationException + ? 'authentication required: ${boundedCapabilityMessage(error.message)}' + : boundedCapabilityMessage(error), +); String _sourceKey(Set sources) => (sources.toList()..sort((a, b) => a.index.compareTo(b.index))) diff --git a/flutter_app/lib/src/private_study_clients.dart b/flutter_app/lib/src/private_study_clients.dart index 6c2c942..0e29ed5 100644 --- a/flutter_app/lib/src/private_study_clients.dart +++ b/flutter_app/lib/src/private_study_clients.dart @@ -76,12 +76,7 @@ class IliasPortalClient implements IliasStudySource { await completeSaml( submitted, _session, - isAuthenticated: (response) => - response.url.host == 'ovidius.uni-tuebingen.de' && - !response.body.contains('j_password') && - (response.body.contains('logout.php') || - response.body.contains('il-mainbar-entries') || - response.body.contains('baseClass=ilderivedtasksgui')), + isAuthenticated: isAuthenticatedIliasPage, ); _authenticated = true; } @@ -90,6 +85,27 @@ class IliasPortalClient implements IliasStudySource { void close() => _session.close(); } +bool isAuthenticatedIliasPage(PortalResponse response) { + if (response.url.host != 'ovidius.uni-tuebingen.de') return false; + const loginOrHandoffMarkers = [ + 'SAMLResponse', + 'j_username', + 'j_password', + 'Login mit zentraler Universitäts-Kennung', + ]; + if (loginOrHandoffMarkers.any(response.body.contains)) return false; + const authenticatedMarkers = [ + 'ILIAS Universität Tübingen', + 'logout.php', + 'il-mainbar-entries', + 'il-maincontrols-metabar', + 'baseClass=ilDashboardGUI', + 'baseClass=ilmembershipoverviewgui', + 'baseClass=ilderivedtasksgui', + ]; + return authenticatedMarkers.any(response.body.contains); +} + class MoodlePortalClient implements MoodleStudySource { MoodlePortalClient({ required this.username, diff --git a/flutter_app/test/private_study_tools_test.dart b/flutter_app/test/private_study_tools_test.dart index 3504043..657ac4d 100644 --- a/flutter_app/test/private_study_tools_test.dart +++ b/flutter_app/test/private_study_tools_test.dart @@ -11,6 +11,30 @@ import 'package:studyos_agent/src/private_study_parsers.dart'; import 'package:studyos_agent/src/private_study_tools.dart'; void main() { + test('recognizes the current authenticated ILIAS shell', () { + final response = PortalResponse( + response: http.Response( + '', + 200, + ), + url: Uri.parse('https://ovidius.uni-tuebingen.de/ilias.php'), + ); + + expect(isAuthenticatedIliasPage(response), isTrue); + }); + + test('does not accept an ILIAS login page as authenticated', () { + final response = PortalResponse( + response: http.Response( + 'Login mit zentraler Universitäts-Kennung', + 200, + ), + url: Uri.parse('https://ovidius.uni-tuebingen.de/login.php'), + ); + + expect(isAuthenticatedIliasPage(response), isFalse); + }); + test('ILIAS parser preserves hints and normalizes reliable dates', () { final tasks = parseIliasTasks( _iliasTasksHtml, @@ -185,6 +209,35 @@ void main() { expect(result['message'], contains('Sign in locally')); }); + test('private tools preserve bounded SAML failure details', () async { + final capability = PrivateStudyCapability( + profileProvider: () => null, + credentialsProvider: () async => + const PortalCredentials('student', 'secret'), + iliasFactory: (_, _) => _FakeIlias( + tasksError: const PortalAuthenticationException( + 'Could not complete the university SAML handoff.', + ), + ), + moodleFactory: (_, _) => _FakeMoodle(onFetch: () => const []), + ); + final result = _json( + await LivePrivateStudyToolRunner( + capability, + ).execute(getTasksToolName, '{"sources":["ilias"]}'), + ); + + expect(result['state'], 'authenticationRequired'); + expect( + (result['failures'] as List).single, + containsPair( + 'message', + 'authentication required: Could not complete the university SAML handoff.', + ), + ); + expect(jsonEncode(result), isNot(contains('secret'))); + }); + test('private tools serve stale local data after refresh failure', () async { var now = DateTime.utc(2026, 7, 17); var calls = 0; From c44405b2fa2be9b94a6cd23d64acccf2f9abd207 Mon Sep 17 00:00:00 2001 From: StudyOS Org Date: Fri, 17 Jul 2026 21:52:25 +0000 Subject: [PATCH 4/8] fix(agent): handle current IdP proceed forms --- flutter_app/lib/src/portal_http_session.dart | 18 ++++-- .../test/private_study_tools_test.dart | 60 +++++++++++++++++++ 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/flutter_app/lib/src/portal_http_session.dart b/flutter_app/lib/src/portal_http_session.dart index e4f75a7..80c2c04 100644 --- a/flutter_app/lib/src/portal_http_session.dart +++ b/flutter_app/lib/src/portal_http_session.dart @@ -147,7 +147,7 @@ PortalForm portalForm( }) { final document = html_parser.parse(html); for (final form in document.querySelectorAll('form')) { - final payload = _formPayload(form); + final payload = _formPayload(form, requiredFields); if (requiredFields.every(payload.containsKey)) { return PortalForm( action: pageUrl.resolve(form.attributes['action'] ?? ''), @@ -158,12 +158,19 @@ PortalForm portalForm( throw const PortalException('Could not find the expected portal form.'); } -Map _formPayload(Element form) { +Map _formPayload(Element form, Set requiredFields) { final result = {}; for (final input in form.querySelectorAll('input[name]')) { if (input.attributes['type']?.toLowerCase() == 'checkbox') continue; result[input.attributes['name']!] = input.attributes['value'] ?? ''; } + for (final button in form.querySelectorAll('button[name]')) { + final name = button.attributes['name']!; + final type = button.attributes['type']?.toLowerCase() ?? 'submit'; + if (type == 'submit' && requiredFields.contains(name)) { + result[name] = button.attributes['value'] ?? ''; + } + } return result; } @@ -199,9 +206,10 @@ Future completeSaml( } break; } - throw const PortalAuthenticationException( - 'Could not complete the university SAML handoff.', - ); + final stage = current.url.host == 'idp.uni-tuebingen.de' + ? 'The university identity provider requires an unsupported interactive step, such as MFA or consent.' + : 'The university portal did not confirm the SAML login at ${current.url.host}${current.url.path}.'; + throw PortalAuthenticationException(stage); } bool _isRedirect(int status) => diff --git a/flutter_app/test/private_study_tools_test.dart b/flutter_app/test/private_study_tools_test.dart index 657ac4d..fc4e0dd 100644 --- a/flutter_app/test/private_study_tools_test.dart +++ b/flutter_app/test/private_study_tools_test.dart @@ -35,6 +35,66 @@ void main() { expect(isAuthenticatedIliasPage(response), isFalse); }); + test('portal form includes a required submit button', () { + final form = portalForm( + ''' +
+ + + +
+ ''', + Uri.parse('https://idp.uni-tuebingen.de/flow'), + requiredFields: const {'_eventId_proceed'}, + ); + + expect(form.action.path, '/continue'); + expect(form.payload['_eventId_proceed'], 'continue'); + expect(form.payload['_eventId_cancel'], isNull); + expect(form.payload['csrf'], 'safe'); + }); + + test('SAML handoff submits an IdP proceed button', () async { + final requests = []; + final session = PortalHttpSession( + client: MockClient((request) async { + requests.add(request); + if (request.url.host == 'idp.uni-tuebingen.de') { + return http.Response( + '', + 302, + headers: { + 'location': 'https://ovidius.uni-tuebingen.de/ilias.php', + }, + request: request, + ); + } + return http.Response( + '', + 200, + request: request, + ); + }), + ); + final response = await completeSaml( + PortalResponse( + response: http.Response(''' +
+ + +
+ ''', 200), + url: Uri.parse('https://idp.uni-tuebingen.de/flow'), + ), + session, + isAuthenticated: isAuthenticatedIliasPage, + ); + + expect(response.url.host, 'ovidius.uni-tuebingen.de'); + expect(requests.first.body, contains('_eventId_proceed=')); + session.close(); + }); + test('ILIAS parser preserves hints and normalizes reliable dates', () { final tasks = parseIliasTasks( _iliasTasksHtml, From 38fc636fea23d427a2f2670efd1cdbf808b9d68f Mon Sep 17 00:00:00 2001 From: StudyOS Org Date: Fri, 17 Jul 2026 22:00:10 +0000 Subject: [PATCH 5/8] fix(agent): surface portal failure reasons --- .../lib/src/private_study_capabilities.dart | 14 +++++++++++--- flutter_app/test/private_study_tools_test.dart | 1 + 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/flutter_app/lib/src/private_study_capabilities.dart b/flutter_app/lib/src/private_study_capabilities.dart index a6005b1..ba4439d 100644 --- a/flutter_app/lib/src/private_study_capabilities.dart +++ b/flutter_app/lib/src/private_study_capabilities.dart @@ -221,9 +221,7 @@ class PrivateStudyCapability { expiresAt: now.add(ttl), data: data, failures: failures, - message: failures.isEmpty - ? null - : 'Some requested portal sources failed.', + message: failures.isEmpty ? null : _failureSummary(failures), ); } @@ -262,6 +260,16 @@ CapabilityFailure _failure( : boundedCapabilityMessage(error), ); +String _failureSummary(List failures) { + final details = failures + .map((failure) => '${failure.source}: ${failure.message}') + .join('; '); + return boundedCapabilityMessage( + 'Some requested portal sources failed: $details', + maxLength: 520, + ); +} + String _sourceKey(Set sources) => (sources.toList()..sort((a, b) => a.index.compareTo(b.index))) .map((source) => source.name) diff --git a/flutter_app/test/private_study_tools_test.dart b/flutter_app/test/private_study_tools_test.dart index fc4e0dd..34fb488 100644 --- a/flutter_app/test/private_study_tools_test.dart +++ b/flutter_app/test/private_study_tools_test.dart @@ -200,6 +200,7 @@ void main() { (first['failures'] as List).single, containsPair('source', 'ilias'), ); + expect(first['message'], contains('ilias: offline')); expect(jsonEncode(first), isNot(contains('secret'))); expect( moodleCalls, From 5c9e31cea0e3b36b1d43548ef6303db81530e2d8 Mon Sep 17 00:00:00 2001 From: linuscooper Date: Sat, 18 Jul 2026 00:46:24 +0200 Subject: [PATCH 6/8] Fix shibboleth login form format --- flutter_app/lib/src/portal_http_session.dart | 149 ++++++++++++++++-- .../test/private_study_tools_test.dart | 98 ++++++++++++ 2 files changed, 235 insertions(+), 12 deletions(-) diff --git a/flutter_app/lib/src/portal_http_session.dart b/flutter_app/lib/src/portal_http_session.dart index 80c2c04..86c3c27 100644 --- a/flutter_app/lib/src/portal_http_session.dart +++ b/flutter_app/lib/src/portal_http_session.dart @@ -43,16 +43,28 @@ class PortalHttpSession { Future postJson(Uri url, Object body, {Uri? referer}) => _send('POST', url, jsonBody: jsonEncode(body), referer: referer); + /// Posts a form that may legitimately repeat a field name (for example the + /// Shibboleth attribute-release consent page, which renders one + /// `_shib_idp_consentIds` checkbox per attribute). A [Map] payload cannot + /// represent repeated keys, so these are encoded as an ordered list. + Future postFormFields( + Uri action, + List> fields, { + Uri? referer, + }) => _send('POST', action, formBody: _encodeFields(fields), referer: referer); + Future _send( String method, Uri url, { Map? form, + String? formBody, String? jsonBody, Uri? referer, }) async { var nextMethod = method; var nextUrl = url; var nextForm = form; + var nextFormBody = formBody; var nextJson = jsonBody; for (var redirects = 0; redirects < 10; redirects += 1) { _validateUrl(nextUrl); @@ -70,6 +82,11 @@ class PortalHttpSession { if (referer != null) 'Referer': referer.toString(), }); if (nextForm != null) request.bodyFields = nextForm; + if (nextFormBody != null) { + request.headers['Content-Type'] = + 'application/x-www-form-urlencoded; charset=utf-8'; + request.body = nextFormBody; + } if (nextJson != null) { request.headers['Content-Type'] = 'application/json'; request.body = nextJson; @@ -91,6 +108,7 @@ class PortalHttpSession { (nextMethod == 'POST' && response.statusCode < 303)) { nextMethod = 'GET'; nextForm = null; + nextFormBody = null; nextJson = null; } } @@ -102,6 +120,14 @@ class PortalHttpSession { if (_ownsClient) _client.close(); } + static String _encodeFields(List> fields) => fields + .map( + (field) => + '${Uri.encodeQueryComponent(field.key)}=' + '${Uri.encodeQueryComponent(field.value)}', + ) + .join('&'); + void _captureCookies(http.Response response, String host) { final header = response.headers['set-cookie']; if (header == null) return; @@ -180,7 +206,7 @@ Future completeSaml( required bool Function(PortalResponse response) isAuthenticated, }) async { var current = initial; - for (var step = 0; step < 6; step += 1) { + for (var step = 0; step < 8; step += 1) { if (isAuthenticated(current)) return current; if (current.body.contains('SAMLResponse') && current.body.contains('RelayState')) { @@ -193,25 +219,124 @@ Future completeSaml( ); continue; } - if (current.url.host == 'idp.uni-tuebingen.de' && - current.body.contains('_eventId_proceed')) { - current = await session.postForm( - portalForm( - current.body, - current.url, - requiredFields: const {'_eventId_proceed'}, - ), - ); - continue; + if (current.url.host == 'idp.uni-tuebingen.de') { + // The attribute-release consent page must be handled before the generic + // proceed step: it also carries `_eventId_proceed`, but proceeding + // without echoing the requested `_shib_idp_consentIds` attributes just + // re-serves the same page. + final consent = portalConsentForm(current.body, current.url); + if (consent != null) { + current = await session.postFormFields( + consent.action, + consent.fields, + referer: current.url, + ); + continue; + } + if (current.body.contains('_eventId_proceed')) { + current = await session.postForm( + portalForm( + current.body, + current.url, + requiredFields: const {'_eventId_proceed'}, + ), + ); + continue; + } } break; } final stage = current.url.host == 'idp.uni-tuebingen.de' - ? 'The university identity provider requires an unsupported interactive step, such as MFA or consent.' + ? 'The university identity provider requires an unsupported interactive ' + 'step, such as MFA or consent (${_idpDiagnostics(current.body)}).' : 'The university portal did not confirm the SAML login at ${current.url.host}${current.url.path}.'; throw PortalAuthenticationException(stage); } +/// Builds the submission for a Shibboleth attribute-release consent page, or +/// returns null when [html] is not such a page. Releases exactly the +/// attributes the service requested and, when offered, remembers the choice +/// for this service so the page is a one-time step rather than a per-login one. +({Uri action, List> fields})? portalConsentForm( + String html, + Uri pageUrl, +) { + final document = html_parser.parse(html); + for (final form in document.querySelectorAll('form')) { + final consentBoxes = form.querySelectorAll( + 'input[name="_shib_idp_consentIds"]', + ); + if (consentBoxes.isEmpty) continue; + final fields = >[]; + for (final box in consentBoxes) { + // Mirror a browser's default "Accept": disabled inputs are never sent, + // and an unchecked checkbox is omitted. Tübingen renders these as hidden + // inputs (always submitted); other templates use pre-checked checkboxes. + if (box.attributes.containsKey('disabled')) continue; + final type = box.attributes['type']?.toLowerCase(); + if (type == 'checkbox' && !box.attributes.containsKey('checked')) continue; + final value = box.attributes['value']; + if (value != null && value.isNotEmpty) { + fields.add(MapEntry('_shib_idp_consentIds', value)); + } + } + // Carry hidden fields (e.g. a CSRF token) but never a form control: this + // page ships both an Accept and a Reject submit button, and echoing the + // reject event alongside proceed is what the IdP treats as a denied + // release. Only the explicit proceed button below is submitted. + const skipTypes = { + 'checkbox', + 'radio', + 'submit', + 'reset', + 'button', + 'image', + }; + for (final input in form.querySelectorAll('input[name]')) { + final name = input.attributes['name']!; + final type = input.attributes['type']?.toLowerCase(); + if (name == '_shib_idp_consentIds') continue; + if (skipTypes.contains(type)) continue; + fields.add(MapEntry(name, input.attributes['value'] ?? '')); + } + if (form.querySelector('[name="_shib_idp_consentOptions"]') != null) { + fields.add( + const MapEntry('_shib_idp_consentOptions', '_shib_idp_rememberConsent'), + ); + } + final proceed = + form.querySelector('button[name="_eventId_proceed"]') ?? + form.querySelector('input[name="_eventId_proceed"]'); + fields.add( + MapEntry('_eventId_proceed', proceed?.attributes['value'] ?? ''), + ); + return ( + action: pageUrl.resolve(form.attributes['action'] ?? ''), + fields: fields, + ); + } + return null; +} + +/// Names the blocking IdP page (title and detected field names, never values) +/// so a failed login report can distinguish a consent gate from an MFA gate. +String _idpDiagnostics(String html) { + final document = html_parser.parse(html); + final title = document.querySelector('title')?.text.trim(); + final fields = {}; + for (final node in document.querySelectorAll( + 'input[name], select[name], button[name]', + )) { + final name = node.attributes['name']; + if (name != null && name.isNotEmpty) fields.add(name); + } + final parts = [ + if (title != null && title.isNotEmpty) 'page "$title"', + if (fields.isNotEmpty) 'fields: ${fields.take(12).join(', ')}', + ]; + return parts.isEmpty ? 'no recognizable form' : parts.join('; '); +} + bool _isRedirect(int status) => status == 301 || status == 302 || diff --git a/flutter_app/test/private_study_tools_test.dart b/flutter_app/test/private_study_tools_test.dart index 34fb488..9006248 100644 --- a/flutter_app/test/private_study_tools_test.dart +++ b/flutter_app/test/private_study_tools_test.dart @@ -95,6 +95,104 @@ void main() { session.close(); }); + test('SAML flow auto-accepts the attribute-release consent page', () async { + final requests = []; + final session = PortalHttpSession( + client: MockClient((request) async { + requests.add(request); + if (request.url.host == 'idp.uni-tuebingen.de') { + return http.Response( + '', + 302, + headers: { + 'location': 'https://ovidius.uni-tuebingen.de/ilias.php', + }, + request: request, + ); + } + return http.Response( + '', + 200, + request: request, + ); + }), + ); + + final response = await completeSaml( + PortalResponse( + // Mirrors the live Tübingen IdP: attributes are hidden inputs, and the + // form ships both an Accept and a Reject submit button. + response: http.Response(''' + Information to be Provided to Service +
+ + + + + + +
+ ''', 200), + url: Uri.parse( + 'https://idp.uni-tuebingen.de/idp/profile/SAML2/Redirect/SSO?execution=e1s2', + ), + ), + session, + isAuthenticated: isAuthenticatedIliasPage, + ); + + expect(response.url.host, 'ovidius.uni-tuebingen.de'); + final consentPost = requests.first; + expect(consentPost.url.path, '/idp/profile/SAML2/Redirect/SSO'); + expect(consentPost.url.query, contains('execution=e1s2')); + expect(consentPost.body, contains('_shib_idp_consentIds=mail')); + expect(consentPost.body, contains('_shib_idp_consentIds=uid')); + expect( + consentPost.body, + contains('_shib_idp_consentOptions=_shib_idp_rememberConsent'), + ); + expect(consentPost.body, contains('_eventId_proceed=Accept')); + // The reject event must never ride along with proceed, or the IdP blocks + // the release ("release of information prevented"). + expect(consentPost.body, isNot(contains('_eventId_AttributeReleaseRejected'))); + session.close(); + }); + + test('SAML failure names the blocking IdP page to distinguish MFA', () async { + final session = PortalHttpSession( + client: MockClient((_) async => http.Response('', 200)), + ); + + await expectLater( + completeSaml( + PortalResponse( + response: http.Response(''' + Two-step verification +
+ + +
+ ''', 200), + url: Uri.parse('https://idp.uni-tuebingen.de/mfa'), + ), + session, + isAuthenticated: isAuthenticatedIliasPage, + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + allOf( + contains('Two-step verification'), + contains('otp'), + contains('_eventId_verify'), + ), + ), + ), + ); + session.close(); + }); + test('ILIAS parser preserves hints and normalizes reliable dates', () { final tasks = parseIliasTasks( _iliasTasksHtml, From ec9fc99c6716f593c26202b86db5003249ef8cdd Mon Sep 17 00:00:00 2001 From: StudyOS Org Date: Fri, 17 Jul 2026 22:57:13 +0000 Subject: [PATCH 7/8] style(agent): format shibboleth flow --- flutter_app/lib/src/portal_http_session.dart | 6 ++++-- flutter_app/test/private_study_tools_test.dart | 5 ++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/flutter_app/lib/src/portal_http_session.dart b/flutter_app/lib/src/portal_http_session.dart index 86c3c27..cf65ff1 100644 --- a/flutter_app/lib/src/portal_http_session.dart +++ b/flutter_app/lib/src/portal_http_session.dart @@ -51,7 +51,8 @@ class PortalHttpSession { Uri action, List> fields, { Uri? referer, - }) => _send('POST', action, formBody: _encodeFields(fields), referer: referer); + }) => + _send('POST', action, formBody: _encodeFields(fields), referer: referer); Future _send( String method, @@ -274,7 +275,8 @@ Future completeSaml( // inputs (always submitted); other templates use pre-checked checkboxes. if (box.attributes.containsKey('disabled')) continue; final type = box.attributes['type']?.toLowerCase(); - if (type == 'checkbox' && !box.attributes.containsKey('checked')) continue; + if (type == 'checkbox' && !box.attributes.containsKey('checked')) + continue; final value = box.attributes['value']; if (value != null && value.isNotEmpty) { fields.add(MapEntry('_shib_idp_consentIds', value)); diff --git a/flutter_app/test/private_study_tools_test.dart b/flutter_app/test/private_study_tools_test.dart index 9006248..030426d 100644 --- a/flutter_app/test/private_study_tools_test.dart +++ b/flutter_app/test/private_study_tools_test.dart @@ -154,7 +154,10 @@ void main() { expect(consentPost.body, contains('_eventId_proceed=Accept')); // The reject event must never ride along with proceed, or the IdP blocks // the release ("release of information prevented"). - expect(consentPost.body, isNot(contains('_eventId_AttributeReleaseRejected'))); + expect( + consentPost.body, + isNot(contains('_eventId_AttributeReleaseRejected')), + ); session.close(); }); From d3ca7fba646b5af86ca46b0b5d5a073a2b68804b Mon Sep 17 00:00:00 2001 From: linuscooper Date: Tue, 21 Jul 2026 23:17:44 +0200 Subject: [PATCH 8/8] Add study planner capabilities as tool for agent --- flutter_app/lib/src/academic_models.dart | 22 ++ flutter_app/lib/src/alma_academic_client.dart | 177 ++++++++- .../lib/src/alma_study_capability.dart | 165 ++++++++ .../lib/src/alma_study_planner_client.dart | 353 ++++++++++++++++++ .../lib/src/alma_study_planner_models.dart | 103 +++++ flutter_app/lib/src/alma_study_tools.dart | 54 +++ flutter_app/lib/src/alma_web_session.dart | 7 +- flutter_app/lib/src/app_shell_controller.dart | 21 +- flutter_app/lib/src/portal_http_session.dart | 3 +- flutter_app/lib/src/studyos_tool_catalog.dart | 12 + .../lib/src/studyos_tool_executor.dart | 5 +- .../test/alma_academic_status_test.dart | 102 +++++ flutter_app/test/alma_study_planner_test.dart | 119 ++++++ .../test/studyos_tool_executor_test.dart | 38 ++ 14 files changed, 1158 insertions(+), 23 deletions(-) create mode 100644 flutter_app/lib/src/alma_study_capability.dart create mode 100644 flutter_app/lib/src/alma_study_planner_client.dart create mode 100644 flutter_app/lib/src/alma_study_planner_models.dart create mode 100644 flutter_app/lib/src/alma_study_tools.dart create mode 100644 flutter_app/test/alma_academic_status_test.dart create mode 100644 flutter_app/test/alma_study_planner_test.dart diff --git a/flutter_app/lib/src/academic_models.dart b/flutter_app/lib/src/academic_models.dart index e32f528..7ae6dee 100644 --- a/flutter_app/lib/src/academic_models.dart +++ b/flutter_app/lib/src/academic_models.dart @@ -1,21 +1,41 @@ +import 'private_study_models.dart' show safePortalTarget; + class AcademicEntry { const AcademicEntry({ required this.category, required this.title, this.status, this.detail, + this.eventType, + this.number, + this.semester, + this.scheduleText, + this.detailUrl, + this.attempt, }); final String category; final String title; final String? status; final String? detail; + final String? eventType; + final String? number; + final String? semester; + final String? scheduleText; + final String? detailUrl; + final String? attempt; Map toJson() => { 'category': category, 'title': title, if (status != null) 'status': status, if (detail != null) 'detail': detail, + if (eventType != null) 'eventType': eventType, + if (number != null) 'number': number, + if (semester != null) 'semester': semester, + if (scheduleText != null) 'scheduleText': scheduleText, + if (detailUrl != null) 'detailUrl': safePortalTarget(detailUrl!), + if (attempt != null) 'attempt': attempt, }; } @@ -25,10 +45,12 @@ class AcademicStatusSnapshot { required this.entries, required this.refreshedAt, this.notice, + this.availableTerms = const [], }); final String? term; final List entries; final DateTime refreshedAt; final String? notice; + final List availableTerms; } diff --git a/flutter_app/lib/src/alma_academic_client.dart b/flutter_app/lib/src/alma_academic_client.dart index 1e696b0..ff0242b 100644 --- a/flutter_app/lib/src/alma_academic_client.dart +++ b/flutter_app/lib/src/alma_academic_client.dart @@ -113,34 +113,86 @@ class AlmaAcademicClient { } } +final _headingPattern = RegExp(r'^(Veranstaltung|Prüfung):\s*(.+)$'); +final _codePattern = RegExp( + r'^([A-ZÄÖÜ]+[A-ZÄÖÜ0-9-]*\d+[A-Z]*|GTCNEURO)\s+(.+)$', +); +final _embeddedCodePattern = RegExp( + r'([A-ZÄÖÜ]+[A-ZÄÖÜ0-9-]*\d+[A-Z]*|GTCNEURO)\s+(.+)$', +); +final _scheduleNoisePattern = RegExp( + r'\b(?:Status|Aktionen|Details anzeigen|Informationen zu Belegzeiträumen|' + r'Ab-/Ummelden|Raumdetails für .+? anzeigen)\b', +); + AcademicStatusSnapshot parseAcademicStatus( String html, { required DateTime now, }) { final document = html_parser.parse(html); - final term = document - .querySelector('select[name*="termPeriodDropDownList"] option[selected]') - ?.text - .trim(); + final scope = + document.getElementById('studentOverviewForm') ?? + document.documentElement; + + final availableTerms = []; + String? selectedTerm; + final select = scope?.querySelector('select[name*="termPeriodDropDownList"]'); + if (select != null) { + for (final option in select.querySelectorAll('option')) { + final label = _clean(option.text); + final value = (option.attributes['value'] ?? '').trim(); + if (label.isEmpty || value.isEmpty) continue; + availableTerms.add(label); + if (option.attributes.containsKey('selected')) selectedTerm = label; + } + } + + final preorder = []; + void collect(Element element) { + preorder.add(element); + for (final child in element.children) { + collect(child); + } + } + + if (scope != null) collect(scope); + final entries = []; - for (final heading in document.querySelectorAll('h2')) { - final text = _clean(heading.text); - final match = RegExp(r'^(Veranstaltung|Prüfung):\s*(.+)$').firstMatch(text); + for (var index = 0; index < preorder.length; index++) { + final element = preorder[index]; + if (element.localName != 'h2') continue; + final match = _headingPattern.firstMatch(_clean(element.text)); if (match == null) continue; - final tableText = _clean( - heading.parent?.querySelector('table')?.text ?? '', + final category = match.group(1)!; + final table = _followingTable(preorder, index); + if (table == null) continue; + final scheduleText = _scheduleText(table); + final (eventType, number, title) = _entryIdentity( + category, + match.group(2)!, + scheduleText, ); + final statusText = _statusText(table); + final semester = _afterLabel(statusText, 'Semester der Leistung'); entries.add( AcademicEntry( - category: match.group(1)!, - title: match.group(2)!, - status: _afterLabel(tableText, 'Ihr aktueller Status'), - detail: _afterLabel(tableText, 'Semester der Leistung'), + category: category, + title: title, + eventType: eventType, + number: number, + status: _afterLabel(statusText, 'Ihr aktueller Status'), + semester: semester, + detail: semester, + scheduleText: scheduleText, + detailUrl: _detailUrl(table), + attempt: _afterLabel(statusText, 'Versuch (gilt nur für Prüfungen)'), ), ); } + return AcademicStatusSnapshot( - term: term, + term: selectedTerm, + availableTerms: availableTerms, entries: entries, refreshedAt: now, notice: entries.isEmpty @@ -149,6 +201,103 @@ AcademicStatusSnapshot parseAcademicStatus( ); } +Element? _followingTable(List preorder, int fromIndex) { + for (var index = fromIndex + 1; index < preorder.length; index++) { + if (preorder[index].localName == 'table') return preorder[index]; + } + return null; +} + +(String?, String?, String) _entryIdentity( + String category, + String headingTitle, + String? scheduleText, +) { + if (category == 'Prüfung') { + final (number, fallbackTitle) = _splitCode(headingTitle); + return (category, number, _examTitle(scheduleText) ?? fallbackTitle); + } + final cleaned = _clean(headingTitle); + final embedded = _embeddedCodePattern.firstMatch(cleaned); + if (embedded == null) { + final (number, title) = _splitCode(headingTitle); + return (category, number, title); + } + final eventType = _clean(cleaned.substring(0, embedded.start)); + return ( + eventType.isEmpty ? category : eventType, + embedded.group(1), + embedded.group(2)!, + ); +} + +(String?, String) _splitCode(String value) { + final cleaned = _clean(value); + final match = _codePattern.firstMatch(cleaned); + if (match == null) return (null, cleaned); + return (match.group(1), match.group(2)!); +} + +String? _examTitle(String? scheduleText) { + if (scheduleText == null || scheduleText.isEmpty) return null; + var value = scheduleText.replaceFirst( + RegExp(r'^\d+\.\s*Parallelgruppe\s+'), + '', + ); + value = value + .split( + RegExp( + r'\s+(?:Montag|Dienstag|Mittwoch|Donnerstag|Freitag|Samstag|Sonntag)' + r'\s+\d{2}\.\d{2}\.\d{2}\b', + ), + ) + .first; + value = value + .split( + RegExp(r'\s+(?:Keine Uhrzeit festgelegt|Prüfungsform:|Prüfer/-in:)'), + ) + .first; + final cleaned = _clean(value); + return cleaned.isEmpty ? null : cleaned; +} + +String? _scheduleText(Element table) { + final candidates = table + .querySelectorAll('td') + .map((cell) => _clean(cell.text)) + .toList(); + if (candidates.isEmpty) return null; + final datePattern = RegExp(r'\b\d{2}\.\d{2}\.\d{2}\b'); + final value = candidates.firstWhere( + (candidate) => + !candidate.contains('Ihr aktueller Status:') && + (candidate.contains('Parallelgruppe') || + candidate.contains('Prüfungsform:') || + datePattern.hasMatch(candidate)), + orElse: () => candidates.first, + ); + final cleaned = _clean(value.replaceAll(_scheduleNoisePattern, ' ')); + return cleaned.isEmpty ? null : cleaned; +} + +String _statusText(Element table) { + for (final cell in table.querySelectorAll('td')) { + final value = _clean(cell.text); + if (value.contains('Ihr aktueller Status:')) return value; + } + return _clean(table.text); +} + +String? _detailUrl(Element table) { + for (final anchor in table.querySelectorAll('a')) { + final href = anchor.attributes['href']; + if (href != null && href.contains('_flowId=detailView-flow')) { + return Uri.parse('${AlmaWebSession.baseUrl}/').resolve(href).toString(); + } + } + return null; +} + AcademicStatusSnapshot parseAcademicReport( String text, { required DateTime now, diff --git a/flutter_app/lib/src/alma_study_capability.dart b/flutter_app/lib/src/alma_study_capability.dart new file mode 100644 index 0000000..6557803 --- /dev/null +++ b/flutter_app/lib/src/alma_study_capability.dart @@ -0,0 +1,165 @@ +import 'alma_study_planner_client.dart'; +import 'alma_study_planner_models.dart'; +import 'alma_web_session.dart'; +import 'capability_result.dart'; +import 'private_study_capabilities.dart' show PortalCredentials; +import 'profile_store.dart'; +import 'student_profile.dart'; + +typedef StudyPlannerFetcher = + Future Function(String username, String password); +typedef AlmaCredentialsProvider = Future Function(); + +class AlmaStudyCapability { + AlmaStudyCapability({ + required this.profileProvider, + this.profileStore, + this.credentialsProvider, + StudyPlannerFetcher? plannerFetcher, + DateTime Function()? clock, + this.ttl = const Duration(minutes: 30), + this.timeout = const Duration(seconds: 45), + }) : _plannerFetcher = plannerFetcher ?? _defaultPlannerFetcher, + _clock = clock ?? DateTime.now; + + static final source = CapabilitySource( + id: 'local_alma_session', + label: 'Local ALMA study planner', + url: 'https://alma.uni-tuebingen.de/', + reliability: 'authenticated_live', + ); + + final OnboardingProfile? Function() profileProvider; + final ProfileStore? profileStore; + final AlmaCredentialsProvider? credentialsProvider; + final StudyPlannerFetcher _plannerFetcher; + final DateTime Function() _clock; + final Duration ttl; + final Duration timeout; + CapabilityResult? _cache; + + Future> studyPlanner() async { + final cached = _cache; + if (cached != null && + cached.expiresAt != null && + _clock().isBefore(cached.expiresAt!)) { + return cached; + } + final credentials = await _credentials(); + if (credentials == null) return _authenticationRequired(); + try { + final page = await _plannerFetcher( + credentials.username, + credentials.password, + ).timeout(timeout); + return _store(_fresh(page)); + } on Object catch (error) { + if (_looksLikeAuthFailure(error)) { + return _authenticationRequired( + message: boundedCapabilityMessage(error), + ); + } + final stale = _staleIfPossible(error); + if (stale != null) return _store(stale); + return _failed(error); + } + } + + void invalidate() => _cache = null; + + CapabilityResult _fresh(AlmaStudyPlannerPage page) { + final now = _clock(); + return CapabilityResult( + state: page.modules.isEmpty && page.semesters.isEmpty + ? CapabilityState.empty + : CapabilityState.fresh, + policy: CapabilityPolicy.privateRead, + source: source, + fetchedAt: now, + expiresAt: now.add(ttl), + data: page, + ); + } + + CapabilityResult _store( + CapabilityResult result, + ) { + _cache = result; + return result; + } + + CapabilityResult? _staleIfPossible(Object error) { + final previous = _cache; + if (previous == null || previous.data == null) return null; + return CapabilityResult( + state: CapabilityState.stale, + policy: CapabilityPolicy.privateRead, + source: source, + fetchedAt: previous.fetchedAt, + expiresAt: previous.expiresAt, + data: previous.data, + message: 'Live refresh failed; returning the cached study planner.', + failures: [ + CapabilityFailure( + source: source.id, + message: boundedCapabilityMessage(error), + ), + ], + ); + } + + CapabilityResult _failed(Object error) => + CapabilityResult( + state: CapabilityState.failed, + policy: CapabilityPolicy.privateRead, + source: source, + fetchedAt: _clock(), + message: boundedCapabilityMessage(error), + failures: [ + CapabilityFailure( + source: source.id, + message: boundedCapabilityMessage(error), + ), + ], + ); + + CapabilityResult _authenticationRequired({ + String? message, + }) => CapabilityResult( + state: CapabilityState.authenticationRequired, + policy: CapabilityPolicy.privateRead, + source: source, + fetchedAt: _clock(), + message: message ?? 'Sign in locally to access your ALMA study planner.', + ); + + Future _credentials() async { + final injected = credentialsProvider; + if (injected != null) return injected(); + final profile = profileProvider(); + if (profile == null || profile.username.trim().isEmpty) return null; + final password = await (profileStore ?? ProfileStore()).readPassword(); + if (password == null || password.isEmpty) return null; + return PortalCredentials(profile.username, password); + } +} + +bool _looksLikeAuthFailure(Object error) { + if (error is! AlmaWebException) return false; + final message = error.message.toLowerCase(); + return message.contains('login') || + message.contains('session expired') || + message.contains('authenticat'); +} + +Future _defaultPlannerFetcher( + String username, + String password, +) async { + final client = AlmaStudyPlannerClient(); + try { + return await client.fetch(username: username, password: password); + } finally { + client.close(); + } +} diff --git a/flutter_app/lib/src/alma_study_planner_client.dart b/flutter_app/lib/src/alma_study_planner_client.dart new file mode 100644 index 0000000..4f0f2fb --- /dev/null +++ b/flutter_app/lib/src/alma_study_planner_client.dart @@ -0,0 +1,353 @@ +import 'package:html/dom.dart'; +import 'package:html/parser.dart' as html_parser; +import 'package:http/http.dart' as http; + +import 'alma_study_planner_models.dart'; +import 'alma_web_session.dart'; + +const studyPlannerPath = + '/alma/pages/startFlow.xhtml' + '?_flowId=studyPlanner-flow' + '&navigationPosition=hisinoneMeinStudium,hisinoneStudyPlanner' + '&recordRequest=true'; + +class AlmaStudyPlannerClient { + AlmaStudyPlannerClient({http.Client? httpClient}) + : _session = AlmaWebSession(httpClient: httpClient); + + final AlmaWebSession _session; + + Future fetch({ + required String username, + required String password, + }) async { + await _session.login(username: username, password: password); + var response = await _session.getPath(studyPlannerPath); + if (_session.looksLoggedOut(response.body)) { + throw const AlmaStudyPlannerException( + 'ALMA session expired before the study planner loaded.', + ); + } + // The planner is a stateful JSF flow: the first GET can land on a + // course-of-study selection page, then on the examination-structure tree + // view. The credit/semester grid the parser reads only renders in the + // "Modulplan" view, so advance through both steps before parsing. + response = await _selectCourseIfNeeded(response); + response = await _switchToModulplanIfNeeded(response); + + final pageUrl = + response.request?.url.toString() ?? + '${AlmaWebSession.baseUrl}$studyPlannerPath'; + return parseStudyPlannerPage(response.body, pageUrl); + } + + Future _selectCourseIfNeeded(http.Response response) async { + final document = html_parser.parse(response.body); + final trigger = _triggerIdEndingWith( + document, + ':doChangeDepp', + containing: 'studentCourseOfStudySelection', + ); + final form = document.getElementById('studyPlanner'); + if (trigger == null || form == null) return response; + final action = form.attributes['action']; + if (action == null || action.isEmpty) return response; + final formId = form.id.isEmpty ? 'studyPlanner' : form.id; + final target = _pageUrl(response).resolve(action); + // `doChangeDepp` is a JSF AJAX action, and Spring Web Flow only advances the + // flow on a partial/ajax request, answering with a to the next + // view-state (the URL the browser navigates to). Replay that request, then + // follow the redirect to load the selected planner. + final payload = _session.formPayload(form) + ..[formId] = formId + ..[trigger] = trigger + ..['javax.faces.partial.ajax'] = 'true' + ..['javax.faces.source'] = trigger + ..['javax.faces.partial.execute'] = '@all' + ..['javax.faces.partial.render'] = + '$formId contentFrame $formId:messages-infobox' + ..['javax.faces.behavior.event'] = 'action'; + final ajax = await _session.post( + target, + payload, + extraHeaders: const { + 'Faces-Request': 'partial/ajax', + 'X-Requested-With': 'XMLHttpRequest', + }, + ); + final redirect = _partialResponseRedirect(ajax.body); + if (redirect != null) return _session.get(target.resolve(redirect)); + return ajax; + } + + Future _switchToModulplanIfNeeded( + http.Response response, + ) async { + final document = html_parser.parse(response.body); + if (_hasModulplanTable(document)) return response; + final trigger = _triggerIdEndingWith(document, ':switchView'); + final form = document.getElementById('enrollTree'); + if (trigger == null || form == null) return response; + final action = form.attributes['action']; + if (action == null || action.isEmpty) return response; + // The "Modulplan anzeigen" button is a plain (non-AJAX) form submit via + // `myfaces.oam.submitForm`, which sets a hidden field named after the + // button and the `activeView=mplan` parameter, then submits the form. + final payload = _session.formPayload(form) + ..['activePageElementId'] = trigger + ..['refreshButtonClickedId'] = '' + ..[trigger] = trigger + ..['activeView'] = 'mplan'; + return _session.post(_pageUrl(response).resolve(action), payload); + } + + Uri _pageUrl(http.Response response) => + response.request?.url ?? + Uri.parse('${AlmaWebSession.baseUrl}$studyPlannerPath'); + + void close() => _session.close(); +} + +String? _partialResponseRedirect(String body) { + final match = RegExp(r' document + .querySelectorAll('table') + .any((element) => element.id.endsWith('modulAnchorsTable')); + +String? _triggerIdEndingWith( + Document document, + String suffix, { + String? containing, +}) { + for (final element in document.querySelectorAll('a, button')) { + final id = element.id; + if (id.isEmpty || !id.endsWith(suffix)) continue; + if (containing != null && !id.contains(containing)) continue; + return id; + } + return null; +} + +AlmaStudyPlannerPage parseStudyPlannerPage(String html, String pageUrl) { + final document = html_parser.parse(html); + final titleNode = document.querySelector('title'); + final title = titleNode != null ? _clean(titleNode.text) : 'Study planner'; + + final table = _firstOrNull( + document + .querySelectorAll('table') + .where((element) => element.id.endsWith('modulAnchorsTable')), + ); + if (table == null) { + final tableIds = document + .querySelectorAll('table') + .map((element) => element.id) + .where((id) => id.isNotEmpty) + .take(12) + .toList(); + throw AlmaStudyPlannerException( + 'Could not find the Alma study planner table. ' + 'title="$title"; ' + 'bytes=${html.length}; ' + 'looksLoggedOut=${html.contains('loginForm')}; ' + 'stage=${_plannerStage(html)}; ' + 'tableIds=${tableIds.isEmpty ? 'none' : tableIds.join('|')}', + ); + } + + final semesters = []; + final headers = table + .querySelectorAll('thead th div') + .where((div) => div.attributes['title'] == 'Studiensemester') + .toList(); + var headerIndex = 0; + for (final header in headers) { + headerIndex++; + final parts = _strippedStrings(header); + if (parts.isEmpty) continue; + semesters.add( + AlmaStudyPlannerSemester( + index: headerIndex, + label: parts[0], + termLabel: parts.length > 1 ? parts[1] : null, + ), + ); + } + + final modules = []; + var bodyRows = table.querySelectorAll('tbody tr'); + if (bodyRows.isEmpty) { + final allRows = table.querySelectorAll('tr'); + bodyRows = allRows.length > 1 ? allRows.sublist(1) : const []; + } + var rowIndex = 0; + for (final row in bodyRows) { + rowIndex++; + var columnStart = 1; + for (final cell in row.children.where((e) => e.localName == 'td')) { + final span = int.tryParse(cell.attributes['colspan'] ?? '1') ?? 1; + final popup = cell.querySelector('.mouseMoveTitle .mouseMove'); + final summary = cell.querySelector( + '.headerModulePlan .popupDismissable [title]', + ); + final heading = _clean( + popup != null + ? popup.text + : (summary != null ? (summary.attributes['title'] ?? '') : ''), + ); + if (heading.isEmpty) { + columnStart += span; + continue; + } + final (number, moduleTitle) = _splitModuleHeading(heading); + final detailLink = _firstOrNull( + cell + .querySelectorAll('a') + .where( + (a) => (a.attributes['href'] ?? '').contains( + '_flowId=detailView-flow', + ), + ), + ); + final creditsElement = _firstOrNull( + cell + .querySelectorAll('span') + .where((span) => span.attributes['title'] == 'CP erworben/soll'), + ); + final creditsSummary = creditsElement != null + ? _clean(creditsElement.text) + : null; + final (earned, required) = _parseCreditProgress(creditsSummary); + final detailHref = detailLink?.attributes['href']; + modules.add( + AlmaStudyPlannerModule( + rowIndex: rowIndex, + columnStart: columnStart, + columnSpan: span, + title: moduleTitle, + number: number, + creditsSummary: creditsSummary, + creditsEarned: earned, + creditsRequired: required, + progressPercent: _progressPercent(earned, required), + detailUrl: detailHref != null ? _resolve(pageUrl, detailHref) : null, + isExpandable: cell + .querySelectorAll('button') + .any( + (b) => (b.attributes['name'] ?? '').endsWith(':explodeModule'), + ), + ), + ); + columnStart += span; + } + } + + bool buttonEnabled(String suffix) { + final button = _firstOrNull( + document + .querySelectorAll('button') + .where((b) => (b.attributes['name'] ?? '').endsWith(suffix)), + ); + if (button == null) return false; + return (button.attributes['class'] ?? '') + .split(RegExp(r'\s+')) + .contains('submit_checkbox_tick'); + } + + if (semesters.isEmpty && !html.contains('Studienplaner')) { + throw const AlmaStudyPlannerException( + 'The response did not look like an Alma study planner page.', + ); + } + + return AlmaStudyPlannerPage( + title: title, + pageUrl: pageUrl, + semesters: semesters, + modules: modules, + viewState: AlmaStudyPlannerViewState( + showRecommendedPlan: buttonEnabled(':switchMusterplan'), + showMyModules: buttonEnabled(':switchMeineModule'), + showAlternativeSemesters: buttonEnabled(':switchAlternativeFachsemester'), + ), + ); +} + +(String?, String) _splitModuleHeading(String value) { + final heading = _clean(value); + final separator = heading.indexOf(' - '); + if (separator < 0) return (null, heading); + final number = heading.substring(0, separator).trim(); + final title = heading.substring(separator + 3).trim(); + return (number.isEmpty ? null : number, title.isEmpty ? heading : title); +} + +(double?, double?) _parseCreditProgress(String? value) { + if (value == null || !value.contains('/')) return (null, null); + final separator = value.indexOf('/'); + return ( + _parseCreditValue(value.substring(0, separator)), + _parseCreditValue(value.substring(separator + 1)), + ); +} + +double? _parseCreditValue(String value) { + final normalized = value.trim().replaceAll(',', '.'); + if (normalized.isEmpty) return null; + if (normalized == '-') return 0.0; + return double.tryParse(normalized); +} + +double? _progressPercent(double? earned, double? required) { + if (earned == null || required == null || required <= 0) return null; + final raw = (earned / required * 100).clamp(0.0, 100.0); + return double.parse(raw.toStringAsFixed(1)); +} + +List _strippedStrings(Element element) { + final result = []; + void walk(Node node) { + for (final child in node.nodes) { + if (child is Text) { + final cleaned = _clean(child.text); + if (cleaned.isNotEmpty) result.add(cleaned); + } else if (child is Element) { + walk(child); + } + } + } + + walk(element); + return result; +} + +/// Names which stage of the JSF planner flow a page represents, for +/// on-device diagnostics when the module grid is not found. +String _plannerStage(String html) { + if (html.contains('studentCourseOfStudySelection')) return 'course_selection'; + if (html.contains('enrollTree:EnrollmentTree') && + !html.contains('modulAnchorsTable')) { + return 'structure_tree'; + } + if (!html.contains('Studienplaner')) return 'unknown_page'; + return 'planner_no_grid'; +} + +String _resolve(String base, String href) => + Uri.parse(base).resolve(href).toString(); + +String _clean(String value) => + value.split(RegExp(r'\s+')).where((part) => part.isNotEmpty).join(' '); + +T? _firstOrNull(Iterable items) { + final iterator = items.iterator; + return iterator.moveNext() ? iterator.current : null; +} + +class AlmaStudyPlannerException extends AlmaWebException { + const AlmaStudyPlannerException(super.message); +} diff --git a/flutter_app/lib/src/alma_study_planner_models.dart b/flutter_app/lib/src/alma_study_planner_models.dart new file mode 100644 index 0000000..d97b560 --- /dev/null +++ b/flutter_app/lib/src/alma_study_planner_models.dart @@ -0,0 +1,103 @@ +import 'private_study_models.dart' show safePortalTarget; + +class AlmaStudyPlannerSemester { + const AlmaStudyPlannerSemester({ + required this.index, + required this.label, + this.termLabel, + }); + + final int index; + final String label; + final String? termLabel; + + Map toJson() => { + 'index': index, + 'label': label, + if (termLabel != null) 'termLabel': termLabel, + }; +} + +class AlmaStudyPlannerModule { + const AlmaStudyPlannerModule({ + required this.rowIndex, + required this.columnStart, + required this.columnSpan, + required this.title, + this.number, + this.creditsSummary, + this.creditsEarned, + this.creditsRequired, + this.progressPercent, + this.detailUrl, + this.isExpandable = false, + }); + + final int rowIndex; + final int columnStart; + final int columnSpan; + final String title; + final String? number; + final String? creditsSummary; + final double? creditsEarned; + final double? creditsRequired; + final double? progressPercent; + final String? detailUrl; + final bool isExpandable; + + Map toJson() => { + 'rowIndex': rowIndex, + 'columnStart': columnStart, + 'columnSpan': columnSpan, + 'title': title, + if (number != null) 'number': number, + if (creditsSummary != null) 'creditsSummary': creditsSummary, + if (creditsEarned != null) 'creditsEarned': creditsEarned, + if (creditsRequired != null) 'creditsRequired': creditsRequired, + if (progressPercent != null) 'progressPercent': progressPercent, + if (detailUrl != null) 'detailUrl': safePortalTarget(detailUrl!), + 'isExpandable': isExpandable, + }; +} + +class AlmaStudyPlannerViewState { + const AlmaStudyPlannerViewState({ + required this.showRecommendedPlan, + required this.showMyModules, + required this.showAlternativeSemesters, + }); + + final bool showRecommendedPlan; + final bool showMyModules; + final bool showAlternativeSemesters; + + Map toJson() => { + 'showRecommendedPlan': showRecommendedPlan, + 'showMyModules': showMyModules, + 'showAlternativeSemesters': showAlternativeSemesters, + }; +} + +class AlmaStudyPlannerPage { + const AlmaStudyPlannerPage({ + required this.title, + required this.pageUrl, + required this.semesters, + required this.modules, + required this.viewState, + }); + + final String title; + final String pageUrl; + final List semesters; + final List modules; + final AlmaStudyPlannerViewState viewState; + + Map toJson() => { + 'title': title, + 'pageUrl': safePortalTarget(pageUrl), + 'semesters': semesters.map((semester) => semester.toJson()).toList(), + 'modules': modules.map((module) => module.toJson()).toList(), + 'viewState': viewState.toJson(), + }; +} diff --git a/flutter_app/lib/src/alma_study_tools.dart b/flutter_app/lib/src/alma_study_tools.dart new file mode 100644 index 0000000..34b18d2 --- /dev/null +++ b/flutter_app/lib/src/alma_study_tools.dart @@ -0,0 +1,54 @@ +import 'dart:convert'; + +import 'alma_study_capability.dart'; +import 'private_study_tools.dart'; + +const getStudyPlannerToolName = 'get_study_planner'; + +/// Serves the read-only ALMA study planner through the shared private-study +/// tool interface so it routes exactly like `get_tasks`/`get_deadlines`. +class LiveAlmaStudyToolRunner implements PrivateStudyToolRunner { + LiveAlmaStudyToolRunner(this._capability); + + final AlmaStudyCapability _capability; + + @override + Future execute(String toolName, String arguments) async { + if (toolName != getStudyPlannerToolName) { + return jsonEncode({ + 'error': 'ALMA study tool is not available: $toolName', + }); + } + final result = await _capability.studyPlanner(); + return jsonEncode(result.toJson((page) => page.toJson())); + } + + @override + void invalidate() => _capability.invalidate(); +} + +/// Routes private study tools to the portal (ILIAS/Moodle) runner and the ALMA +/// study planner to its own runner behind one [PrivateStudyToolRunner]. +class CombinedPrivateStudyToolRunner implements PrivateStudyToolRunner { + const CombinedPrivateStudyToolRunner({ + required this.portal, + required this.alma, + }); + + final PrivateStudyToolRunner portal; + final PrivateStudyToolRunner alma; + + @override + Future execute(String toolName, String arguments) { + if (toolName == getStudyPlannerToolName) { + return alma.execute(toolName, arguments); + } + return portal.execute(toolName, arguments); + } + + @override + void invalidate() { + portal.invalidate(); + alma.invalidate(); + } +} diff --git a/flutter_app/lib/src/alma_web_session.dart b/flutter_app/lib/src/alma_web_session.dart index efbf138..632afdd 100644 --- a/flutter_app/lib/src/alma_web_session.dart +++ b/flutter_app/lib/src/alma_web_session.dart @@ -46,12 +46,17 @@ class AlmaWebSession { return response; } - Future post(Uri url, Map body) async { + Future post( + Uri url, + Map body, { + Map extraHeaders = const {}, + }) async { final response = await _http.post( url, headers: { ..._headers(), 'Content-Type': 'application/x-www-form-urlencoded', + ...extraHeaders, }, body: body, ); diff --git a/flutter_app/lib/src/app_shell_controller.dart b/flutter_app/lib/src/app_shell_controller.dart index ec11083..877b95f 100644 --- a/flutter_app/lib/src/app_shell_controller.dart +++ b/flutter_app/lib/src/app_shell_controller.dart @@ -18,6 +18,8 @@ import 'native_bridge.dart'; import 'official_document_models.dart'; import 'official_documents_repository.dart'; import 'profile_context.dart'; +import 'alma_study_capability.dart'; +import 'alma_study_tools.dart'; import 'private_study_capabilities.dart'; import 'private_study_tools.dart'; import 'public_study_tools.dart'; @@ -61,8 +63,13 @@ class AppShellController extends ChangeNotifier { _profile = initialProfile, _onLogout = initialOnLogout, _onSaveProfile = initialOnSaveProfile { - _privateStudyTools = LivePrivateStudyToolRunner( - PrivateStudyCapability(profileProvider: () => _profile), + _privateStudyTools = CombinedPrivateStudyToolRunner( + portal: LivePrivateStudyToolRunner( + PrivateStudyCapability(profileProvider: () => _profile), + ), + alma: LiveAlmaStudyToolRunner( + AlmaStudyCapability(profileProvider: () => _profile), + ), ); this.calendarOverviewSource = calendarOverviewSource ?? @@ -313,10 +320,11 @@ class AppShellController extends ChangeNotifier { _academicStatusError = null; _notify(); try { - _academicStatus = await _academicRepository.refresh( - profile, - extractPdfText: bridge.extractPdfText, - ); + // Use the term-aware ALMA enrollment overview for the status snapshot. + // The official registration report (PDF, native `extractPdfText`) stays + // behind the explicit report-download action so this works on every + // platform, not only where the native PDF extractor is implemented. + _academicStatus = await _academicRepository.refresh(profile); } on Object catch (error) { _academicStatusError = error.toString(); } finally { @@ -402,6 +410,7 @@ class AppShellController extends ChangeNotifier { } return jsonEncode({ 'term': resolved.term, + 'available_terms': resolved.availableTerms, 'refreshed_at': resolved.refreshedAt.toIso8601String(), 'notice': resolved.notice, 'entries': resolved.entries.map((entry) => entry.toJson()).toList(), diff --git a/flutter_app/lib/src/portal_http_session.dart b/flutter_app/lib/src/portal_http_session.dart index cf65ff1..9ecfde5 100644 --- a/flutter_app/lib/src/portal_http_session.dart +++ b/flutter_app/lib/src/portal_http_session.dart @@ -275,8 +275,9 @@ Future completeSaml( // inputs (always submitted); other templates use pre-checked checkboxes. if (box.attributes.containsKey('disabled')) continue; final type = box.attributes['type']?.toLowerCase(); - if (type == 'checkbox' && !box.attributes.containsKey('checked')) + if (type == 'checkbox' && !box.attributes.containsKey('checked')) { continue; + } final value = box.attributes['value']; if (value != null && value.isNotEmpty) { fields.add(MapEntry('_shib_idp_consentIds', value)); diff --git a/flutter_app/lib/src/studyos_tool_catalog.dart b/flutter_app/lib/src/studyos_tool_catalog.dart index 3043631..ee6e5ea 100644 --- a/flutter_app/lib/src/studyos_tool_catalog.dart +++ b/flutter_app/lib/src/studyos_tool_catalog.dart @@ -1,3 +1,4 @@ +import 'alma_study_tools.dart'; import 'native_tool_router.dart'; import 'private_study_tools.dart'; import 'public_study_tools.dart'; @@ -175,6 +176,16 @@ const getDeadlinesTool = StudyOsToolSpec( required: [], ); +const getStudyPlannerTool = StudyOsToolSpec( + name: getStudyPlannerToolName, + description: + 'Read the on-device ALMA study planner: semesters, modules, and earned ' + 'versus required credits with progress. Read-only and executed locally.', + traceSummary: 'Loading your local ALMA study planner.', + properties: {}, + required: [], +); + const listMailboxesTool = StudyOsToolSpec( name: 'list_mailboxes', description: 'List local university mail folders and unread counts.', @@ -437,6 +448,7 @@ const studyOsTools = [ searchCampusLocationsTool, getTasksTool, getDeadlinesTool, + getStudyPlannerTool, listMailboxesTool, getRecentMailTool, searchMailTool, diff --git a/flutter_app/lib/src/studyos_tool_executor.dart b/flutter_app/lib/src/studyos_tool_executor.dart index 499198a..512f2ad 100644 --- a/flutter_app/lib/src/studyos_tool_executor.dart +++ b/flutter_app/lib/src/studyos_tool_executor.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'alma_study_tools.dart'; import 'mail_tools.dart'; import 'memory_store.dart'; import 'native_tool_router.dart'; @@ -55,7 +56,9 @@ class StudyOsToolExecutor { 'search_talks' => _searchTalks(arguments, context.searchTalks), getMensaOptionsToolName || searchCampusLocationsToolName => _executePublicStudyTool(toolName, arguments, context.publicStudyTools), - getTasksToolName || getDeadlinesToolName => _executePrivateStudyTool( + getTasksToolName || + getDeadlinesToolName || + getStudyPlannerToolName => _executePrivateStudyTool( toolName, arguments, context.privateStudyTools, diff --git a/flutter_app/test/alma_academic_status_test.dart b/flutter_app/test/alma_academic_status_test.dart new file mode 100644 index 0000000..1314d6e --- /dev/null +++ b/flutter_app/test/alma_academic_status_test.dart @@ -0,0 +1,102 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:studyos_agent/src/alma_academic_client.dart'; + +const _courseHtml = ''' +
+ +

Veranstaltung: Vorlesung/Übung GTCNEURO Neural Data Science

+ + + + + + +
+
1. Parallelgruppe Neural Data Science
+
jeden Mittwoch (15.04.26 bis 22.07.26) von 10:15 bis 11:45 wöchentlich
+
Status Aktionen Details anzeigen Informationen zu Belegzeiträumen
+ Raumdetails für Hörsaal A2 anzeigen +
+
Status
+
Ihr aktueller Status: storniert
+
Semester der Leistung: SoSe 2026
+
+ Details anzeigen +
+
+'''; + +const _examHtml = ''' +
+ +

Prüfung: INFO-THEO-1-9CP THEO

+ + + + + + + +
Termine und Räume Status Aktionen1. Parallelgruppe Probabilistic Machine Learning Donnerstag 23.07.26 Keine Uhrzeit festgelegt Prüfungsform: Schriftlich oder mündlich Prüfer/-in: Prof. Dr. MackeIhr aktueller Status: zugelassen Semester der Leistung: SoSe 2026 Versuch (gilt nur für Prüfungen): 1Details anzeigen
+
+'''; + +void main() { + test('parseAcademicStatus extracts term-aware course enrollment', () { + final snapshot = parseAcademicStatus( + _courseHtml, + now: DateTime(2026, 7, 10), + ); + + expect(snapshot.term, 'Sommersemester 2026'); + expect(snapshot.availableTerms, contains('Sommersemester 2026')); + + final entry = snapshot.entries.single; + expect(entry.category, 'Veranstaltung'); + expect(entry.eventType, 'Vorlesung/Übung'); + expect(entry.number, 'GTCNEURO'); + expect(entry.title, 'Neural Data Science'); + expect(entry.status, 'storniert'); + expect(entry.semester, 'SoSe 2026'); + expect(entry.scheduleText, contains('jeden Mittwoch')); + expect(entry.scheduleText, isNot(contains('Details anzeigen'))); + expect( + entry.scheduleText, + isNot(contains('Informationen zu Belegzeiträumen')), + ); + expect(entry.detailUrl, endsWith('unitId=42&periodId=229')); + }); + + test('parseAcademicStatus distinguishes exam registrations', () { + final snapshot = parseAcademicStatus(_examHtml, now: DateTime(2026, 7, 10)); + + final entry = snapshot.entries.single; + expect(entry.category, 'Prüfung'); + expect(entry.eventType, 'Prüfung'); + expect(entry.number, 'INFO-THEO-1-9CP'); + expect(entry.title, 'Probabilistic Machine Learning'); + expect(entry.status, 'zugelassen'); + expect(entry.semester, 'SoSe 2026'); + expect(entry.attempt, '1'); + expect(entry.scheduleText, contains('Donnerstag 23.07.26')); + expect( + entry.scheduleText, + contains('Prüfungsform: Schriftlich oder mündlich'), + ); + }); + + test('parseAcademicStatus preserves the original status without inference', () { + final snapshot = parseAcademicStatus( + _courseHtml, + now: DateTime(2026, 7, 10), + ); + + // "storniert" must survive verbatim; we never normalize to passed/cancelled. + expect(snapshot.entries.single.status, 'storniert'); + expect(snapshot.notice, isNull); + }); +} diff --git a/flutter_app/test/alma_study_planner_test.dart b/flutter_app/test/alma_study_planner_test.dart new file mode 100644 index 0000000..90bc7ea --- /dev/null +++ b/flutter_app/test/alma_study_planner_test.dart @@ -0,0 +1,119 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:studyos_agent/src/alma_study_capability.dart'; +import 'package:studyos_agent/src/alma_study_planner_client.dart'; +import 'package:studyos_agent/src/alma_study_planner_models.dart'; +import 'package:studyos_agent/src/alma_study_tools.dart'; +import 'package:studyos_agent/src/capability_result.dart'; +import 'package:studyos_agent/src/private_study_capabilities.dart'; + +const _plannerHtml = ''' +Studienplaner Master Informatik + + + + + + + + + +
1. Semester
WiSe 2025/26
+
+
Info Fokus
+ 6/18 +
+ Details + +
+ + + +'''; + +void main() { + test('parseStudyPlannerPage extracts semesters, modules, and progress', () { + final page = parseStudyPlannerPage( + _plannerHtml, + 'https://alma.example/planner', + ); + + expect(page.title, 'Studienplaner Master Informatik'); + expect(page.semesters, hasLength(1)); + expect(page.semesters[0].label, '1. Semester'); + expect(page.semesters[0].termLabel, 'WiSe 2025/26'); + + final module = page.modules.single; + expect(module.number, 'INFO-FOKUS'); + expect(module.title, 'Studienbereich Info Fokus'); + expect(module.creditsEarned, 6.0); + expect(module.creditsRequired, 18.0); + expect(module.progressPercent, 33.3); + expect(module.columnStart, 1); + expect(module.columnSpan, 1); + expect(module.isExpandable, isTrue); + expect(module.detailUrl, endsWith('moduleId=7')); + + expect(page.viewState.showRecommendedPlan, isTrue); + expect(page.viewState.showMyModules, isFalse); + expect(page.viewState.showAlternativeSemesters, isFalse); + }); + + test('planner serialization sanitizes URLs and omits nothing sensitive', () { + final page = parseStudyPlannerPage( + _plannerHtml, + 'https://alma.example/planner', + ); + final json = jsonEncode(page.toJson()); + + expect(json, contains('"progressPercent":33.3')); + expect(json, isNot(contains('password'))); + expect(json, isNot(contains('Cookie'))); + }); + + test( + 'AlmaStudyCapability returns authenticationRequired without login', + () async { + final capability = AlmaStudyCapability( + profileProvider: () => null, + credentialsProvider: () async => null, + ); + + final result = await capability.studyPlanner(); + + expect(result.state, CapabilityState.authenticationRequired); + expect(result.data, isNull); + }, + ); + + test( + 'AlmaStudyCapability serves a fresh planner from injected credentials', + () async { + final capability = AlmaStudyCapability( + profileProvider: () => null, + credentialsProvider: () async => + const PortalCredentials('zxy123', 'secret'), + plannerFetcher: (username, password) async => + parseStudyPlannerPage(_plannerHtml, 'https://alma.example/planner'), + ); + + final result = await capability.studyPlanner(); + + expect(result.state, CapabilityState.fresh); + expect(result.policy.privacy, CapabilityPrivacy.privateLocal); + expect(result.data, isA()); + expect(result.data!.modules.single.number, 'INFO-FOKUS'); + }, + ); + + test('LiveAlmaStudyToolRunner rejects foreign tool names', () async { + final runner = LiveAlmaStudyToolRunner( + AlmaStudyCapability(profileProvider: () => null), + ); + + final response = await runner.execute('get_tasks', '{}'); + + expect(response, contains('not available')); + }); +} diff --git a/flutter_app/test/studyos_tool_executor_test.dart b/flutter_app/test/studyos_tool_executor_test.dart index 4021a55..daea932 100644 --- a/flutter_app/test/studyos_tool_executor_test.dart +++ b/flutter_app/test/studyos_tool_executor_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:studyos_agent/src/alma_study_tools.dart'; import 'package:studyos_agent/src/mail_repository.dart'; import 'package:studyos_agent/src/mail_tools.dart'; import 'package:studyos_agent/src/native_tool_router.dart'; @@ -88,6 +89,43 @@ void main() { expect(privateTools.calls, [getTasksToolName]); }); + test('StudyOsToolExecutor routes the study planner locally', () async { + final privateTools = _FakePrivateStudyToolRunner('Planner results'); + + final response = await const StudyOsToolExecutor().execute( + getStudyPlannerToolName, + '{}', + _context(privateStudyTools: privateTools), + ); + + expect(response, 'Planner results'); + expect(privateTools.calls, [getStudyPlannerToolName]); + }); + + test('CombinedPrivateStudyToolRunner dispatches by tool name', () async { + final portal = _FakePrivateStudyToolRunner('Portal results'); + final alma = _FakePrivateStudyToolRunner('Planner results'); + final runner = CombinedPrivateStudyToolRunner(portal: portal, alma: alma); + + expect(await runner.execute(getTasksToolName, '{}'), 'Portal results'); + expect( + await runner.execute(getStudyPlannerToolName, '{}'), + 'Planner results', + ); + expect(portal.calls, [getTasksToolName]); + expect(alma.calls, [getStudyPlannerToolName]); + }); + + test('Study planner tool is advertised in cloud and local catalogs', () { + final localNames = studyOsTools.map((tool) => tool.name).toSet(); + final cloudNames = cloudStudyOsTools( + const {}, + ).map((tool) => tool.name).toSet(); + + expect(localNames, contains(getStudyPlannerToolName)); + expect(cloudNames, contains(getStudyPlannerToolName)); + }); + test( 'StudyOsToolExecutor returns explicit errors for bad tool input', () async {