diff --git a/flutter_app/android/app/src/main/kotlin/com/studyos/studyos_agent/AndroidLocalModelStore.kt b/flutter_app/android/app/src/main/kotlin/com/studyos/studyos_agent/AndroidLocalModelStore.kt index 1a07547..45ade5b 100644 --- a/flutter_app/android/app/src/main/kotlin/com/studyos/studyos_agent/AndroidLocalModelStore.kt +++ b/flutter_app/android/app/src/main/kotlin/com/studyos/studyos_agent/AndroidLocalModelStore.kt @@ -5,6 +5,7 @@ import org.json.JSONObject import java.io.File import java.net.HttpURLConnection import java.net.URL +import java.security.MessageDigest class AndroidLocalModelStore(context: Context) { private val appContext = context.applicationContext @@ -33,6 +34,8 @@ class AndroidLocalModelStore(context: Context) { label: String, fileName: String, url: String, + expectedSizeBytes: Long = -1L, + expectedSha256: String = "", onProgress: (Long, Long) -> Unit = { _, _ -> }, ): Map { require(id.isNotBlank()) { "Model id is required." } @@ -46,7 +49,13 @@ class AndroidLocalModelStore(context: Context) { val target = File(modelDir, safeFileName) val partial = File(modelDir, "$safeFileName.download") cancelRequested = false - downloadToFile(url, partial, onProgress) + val verified = downloadToFile( + url = url, + target = partial, + expectedSizeBytes = expectedSizeBytes, + expectedSha256 = expectedSha256, + onProgress = onProgress, + ) if (target.exists()) { target.delete() } @@ -59,6 +68,10 @@ class AndroidLocalModelStore(context: Context) { .put("label", label) .put("fileName", safeFileName) .put("url", url) + .put("sizeBytes", verified.sizeBytes) + .put("sha256", verified.sha256) + .put("expectedSizeBytes", expectedSizeBytes.takeIf { it > 0 }) + .put("expectedSha256", expectedSha256.ifBlank { null }) .put("downloadedAt", System.currentTimeMillis()), ) writeMetadata(metadata) @@ -94,14 +107,22 @@ class AndroidLocalModelStore(context: Context) { "downloadedAt" to item.optLong("downloadedAt"), "exists" to file.exists(), "sizeBytes" to if (file.exists()) file.length() else 0L, + "sha256" to item.optString("sha256"), + "expectedSizeBytes" to item.optLong("expectedSizeBytes").takeIf { it > 0 }, + "expectedSha256" to item.optString("expectedSha256").takeIf { it.isNotBlank() }, ) } private fun downloadToFile( url: String, target: File, + expectedSizeBytes: Long, + expectedSha256: String, onProgress: (Long, Long) -> Unit, - ) { + ): DownloadVerification { + if (target.exists()) { + target.delete() + } val connection = URL(url).openConnection() as HttpURLConnection connection.connectTimeout = 15_000 connection.readTimeout = 60_000 @@ -113,7 +134,14 @@ class AndroidLocalModelStore(context: Context) { throw IllegalStateException("Download failed with HTTP ${connection.responseCode}.") } val totalBytes = connection.contentLengthLong.takeIf { it > 0 } ?: -1L + if (expectedSizeBytes > 0 && totalBytes > 0 && totalBytes != expectedSizeBytes) { + throw IllegalStateException( + "Download size mismatch before transfer: expected " + + "$expectedSizeBytes bytes, got $totalBytes.", + ) + } var receivedBytes = 0L + val digest = MessageDigest.getInstance("SHA-256") try { connection.inputStream.use { input -> target.outputStream().use { output -> @@ -126,18 +154,34 @@ class AndroidLocalModelStore(context: Context) { val read = input.read(buffer) if (read < 0) break output.write(buffer, 0, read) + digest.update(buffer, 0, read) receivedBytes += read onProgress(receivedBytes, totalBytes) } } } } catch (error: Exception) { + target.delete() if (cancelRequested) { - target.delete() throw InterruptedException("Download cancelled.") } throw error } + if (expectedSizeBytes > 0 && receivedBytes != expectedSizeBytes) { + target.delete() + throw IllegalStateException( + "Download size mismatch: expected $expectedSizeBytes " + + "bytes, got $receivedBytes.", + ) + } + val sha256 = digest.digest().joinToString("") { + "%02x".format(it.toInt() and 0xff) + } + if (expectedSha256.isNotBlank() && !sha256.equals(expectedSha256, ignoreCase = true)) { + target.delete() + throw IllegalStateException("Download checksum mismatch.") + } + return DownloadVerification(receivedBytes, sha256) } finally { activeConnection = null connection.disconnect() @@ -166,4 +210,9 @@ class AndroidLocalModelStore(context: Context) { val cleaned = value.trim().replace(Regex("[^A-Za-z0-9._-]"), "-") return cleaned.ifBlank { "local-model.task" } } + + private data class DownloadVerification( + val sizeBytes: Long, + val sha256: String, + ) } diff --git a/flutter_app/android/app/src/main/kotlin/com/studyos/studyos_agent/MainActivity.kt b/flutter_app/android/app/src/main/kotlin/com/studyos/studyos_agent/MainActivity.kt index b6056b9..501e717 100644 --- a/flutter_app/android/app/src/main/kotlin/com/studyos/studyos_agent/MainActivity.kt +++ b/flutter_app/android/app/src/main/kotlin/com/studyos/studyos_agent/MainActivity.kt @@ -337,6 +337,9 @@ class MainActivity : FlutterActivity() { val label = call.argument("label").orEmpty() val fileName = call.argument("fileName").orEmpty() val url = call.argument("url").orEmpty() + val expectedSizeBytes = + (call.argument("expectedSizeBytes") as? Number)?.toLong() ?: -1L + val expectedSha256 = call.argument("expectedSha256").orEmpty() Thread { try { val model = localModelStore().downloadModel( @@ -344,6 +347,8 @@ class MainActivity : FlutterActivity() { label = label, fileName = fileName, url = url, + expectedSizeBytes = expectedSizeBytes, + expectedSha256 = expectedSha256, onProgress = { receivedBytes, totalBytes -> emitDownloadProgress(id, label, receivedBytes, totalBytes) }, diff --git a/flutter_app/lib/src/campus_client.dart b/flutter_app/lib/src/campus_client.dart index f1d101f..3dab2fd 100644 --- a/flutter_app/lib/src/campus_client.dart +++ b/flutter_app/lib/src/campus_client.dart @@ -5,8 +5,10 @@ import 'package:http/http.dart' as http; import 'campus_models.dart'; class CampusClient { - CampusClient({http.Client? httpClient}) - : _httpClient = httpClient ?? http.Client(); + CampusClient({ + http.Client? httpClient, + this.cacheTtl = const Duration(hours: 6), + }) : _httpClient = httpClient ?? http.Client(); static const _mealplanUrl = 'https://www.my-stuwe.de/wp-json/mealplans/v1/canteens?lang=de'; @@ -19,8 +21,19 @@ class CampusClient { }; final http.Client _httpClient; + final Duration cacheTtl; + static List? _cachedCanteens; + static DateTime? _cachedAt; - Future> fetchTuebingenCanteens() async { + Future> fetchTuebingenCanteens({ + bool forceRefresh = false, + }) async { + final cached = _cachedCanteens; + final cachedAt = _cachedAt; + if (!forceRefresh && cached != null && cachedAt != null) { + final age = DateTime.now().difference(cachedAt); + if (age <= cacheTtl) return cached; + } final response = await _httpClient.get(Uri.parse(_mealplanUrl)); if (response.statusCode < 200 || response.statusCode >= 300) { throw CampusException('Mensa data returned HTTP ${response.statusCode}.'); @@ -29,11 +42,14 @@ class CampusClient { if (decoded is! Map) { throw const CampusException('Mensa data has an unexpected format.'); } - return _tuebingenCanteenIds + final canteens = _tuebingenCanteenIds .where(decoded.containsKey) .map((id) => _canteenFromJson(id, decoded[id])) .whereType() .toList(); + _cachedCanteens = canteens; + _cachedAt = DateTime.now(); + return canteens; } void close() => _httpClient.close(); diff --git a/flutter_app/lib/src/local_model_catalog.dart b/flutter_app/lib/src/local_model_catalog.dart index 915d336..4f67339 100644 --- a/flutter_app/lib/src/local_model_catalog.dart +++ b/flutter_app/lib/src/local_model_catalog.dart @@ -5,6 +5,8 @@ class LocalModelOption { required this.fileName, required this.description, this.downloadUrl = '', + this.expectedSizeBytes, + this.expectedSha256, }); final String id; @@ -12,8 +14,12 @@ class LocalModelOption { final String fileName; final String description; final String downloadUrl; + final int? expectedSizeBytes; + final String? expectedSha256; bool get needsCustomUrl => downloadUrl.isEmpty; + bool get hasIntegrityCheck => + expectedSizeBytes != null || expectedSha256?.isNotEmpty == true; } const List localModelCatalog = [ @@ -22,18 +28,23 @@ const List localModelCatalog = [ label: 'Gemma 4 E2B Instruct', fileName: 'gemma-4-e2b-it.litertlm', description: 'Balanced default for mid-range Android phones.', + downloadUrl: + 'https://huggingface.co/litert-community/' + 'gemma-4-E2B-it-litert-lm/resolve/main/gemma-4-E2B-it.litertlm', + // No integrity check for now: the resolver did not expose a stable + // SHA-256, and pinning the advertised size is too brittle (a re-upload + // would break every download). The native download path still verifies + // expectedSizeBytes/expectedSha256 when a future entry supplies them. ), LocalModelOption( id: 'qwen3-1-7b', label: 'Qwen3 1.7B', - fileName: 'qwen3-1-7b.task', - description: 'Small agentic model candidate for tool routing and RAG.', - ), - LocalModelOption( - id: 'lfm2-5-1-2b', - label: 'LFM2.5 1.2B', - fileName: 'lfm2-5-1-2b.task', - description: 'Fast low-memory candidate for smaller devices.', + fileName: 'qwen3-1-7b.litertlm', + description: 'Small agentic model for tool routing and RAG.', + downloadUrl: + 'https://huggingface.co/litert-community/' + 'Qwen3-1.7B/resolve/main/Qwen3_1.7B.litertlm', + // No integrity check for now (see gemma entry above). ), LocalModelOption( id: 'custom', diff --git a/flutter_app/lib/src/mail_client.dart b/flutter_app/lib/src/mail_client.dart index 5af2d09..629f545 100644 --- a/flutter_app/lib/src/mail_client.dart +++ b/flutter_app/lib/src/mail_client.dart @@ -11,6 +11,9 @@ class MailClient { this.timeout = const Duration(seconds: 20), }); + static const int summaryPreviewBytes = 4096; + static const int detailBodyBytes = 65536; + final String host; final int port; final Duration timeout; @@ -122,12 +125,41 @@ class MailClient { } await _requireConnection().command('EXAMINE ${_quote(mailbox)}'); final unreadIds = (await _searchUids('UNSEEN')).toSet(); - final raw = await _fetchRawMessage(uid); + final rawHeaders = await _fetchLiteral( + 'UID FETCH $uid (BODY.PEEK[HEADER])', + ); + final structure = await _fetchBodyStructure(uid); + final section = structure?.textSection; + if (section != null) { + final rawBody = await _fetchLiteral( + 'UID FETCH $uid (BODY.PEEK[${section.section}]<0.$detailBodyBytes>)', + allowEmpty: true, + ); + return parseMailDetail( + buildTextPartMessage( + rawHeaders: rawHeaders, + rawPartBody: rawBody, + section: section, + ), + uid: uid, + mailbox: mailbox, + isUnread: unreadIds.contains(uid), + attachmentNames: structure!.attachmentNames, + ); + } + final rawBody = await _fetchLiteral( + 'UID FETCH $uid (BODY.PEEK[TEXT]<0.$detailBodyBytes>)', + allowEmpty: true, + ); + final attachmentNames = structure?.attachmentNames; return parseMailDetail( - raw, + combineMailHeaderAndBodyPreview(rawHeaders, rawBody), uid: uid, mailbox: mailbox, isUnread: unreadIds.contains(uid), + attachmentNames: attachmentNames != null && attachmentNames.isNotEmpty + ? attachmentNames + : null, ); } @@ -165,17 +197,58 @@ class MailClient { String mailbox, bool isUnread, ) async { - final raw = await _fetchRawMessage(uid); - return parseMailSummary(raw, uid: uid, isUnread: isUnread); + final rawHeaders = await _fetchLiteral( + 'UID FETCH $uid (BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE CONTENT-TYPE)])', + ); + final structure = await _fetchBodyStructure(uid); + final section = structure?.textSection; + if (section != null) { + final rawPart = await _fetchLiteral( + 'UID FETCH $uid (BODY.PEEK[${section.section}]<0.$summaryPreviewBytes>)', + allowEmpty: true, + ); + return parseMailSummary( + buildTextPartMessage( + rawHeaders: rawHeaders, + rawPartBody: rawPart, + section: section, + ), + uid: uid, + isUnread: isUnread, + ); + } + final rawPreview = await _fetchLiteral( + 'UID FETCH $uid (BODY.PEEK[TEXT]<0.$summaryPreviewBytes>)', + allowEmpty: true, + ); + return parseMailSummaryPreview( + rawHeaders: rawHeaders, + rawPreview: rawPreview, + uid: uid, + isUnread: isUnread, + ); } - Future> _fetchRawMessage(String uid) async { - final response = await _requireConnection().command( - 'UID FETCH $uid (BODY.PEEK[])', - ); + Future _fetchBodyStructure(String uid) async { + try { + final response = await _requireConnection().command( + 'UID FETCH $uid (BODYSTRUCTURE)', + ); + return parseBodyStructure(response.text); + } on Object { + return null; + } + } + + Future> _fetchLiteral( + String command, { + bool allowEmpty = false, + }) async { + final response = await _requireConnection().command(command); final literal = response.firstLiteral; if (literal == null) { - throw MailException('IMAP fetch returned no message body for UID $uid.'); + if (allowEmpty) return const []; + throw const MailException('IMAP fetch returned no message body.'); } return literal; } diff --git a/flutter_app/lib/src/mail_parsing.dart b/flutter_app/lib/src/mail_parsing.dart index 97c0304..96783b0 100644 --- a/flutter_app/lib/src/mail_parsing.dart +++ b/flutter_app/lib/src/mail_parsing.dart @@ -37,11 +37,34 @@ MailMessageSummary parseMailSummary( ); } +MailMessageSummary parseMailSummaryPreview({ + required List rawHeaders, + required List rawPreview, + required String uid, + required bool isUnread, +}) { + final parsed = ParsedMail.fromBytes(_messageBytes(rawHeaders, rawPreview)); + final body = parsed.bodyText; + final cleanedBody = stripBroadcastBoilerplate(body); + final sender = _parseAddress(parsed.header('from')); + return MailMessageSummary( + uid: uid, + subject: decodeMimeHeader(parsed.header('subject')) ?? '(No subject)', + fromName: sender.name, + fromAddress: sender.address, + receivedAt: parsed.header('date'), + preview: previewFromText(cleanedBody), + isUnread: isUnread, + approvalNotice: hasBroadcastApproval(body) ? approvedBroadcastNotice : null, + ); +} + MailMessageDetail parseMailDetail( List rawMessage, { required String uid, required String mailbox, required bool isUnread, + List? attachmentNames, }) { final parsed = ParsedMail.fromBytes(rawMessage); final body = parsed.bodyText; @@ -58,12 +81,340 @@ MailMessageDetail parseMailDetail( receivedAt: parsed.header('date'), preview: previewFromText(cleanedBody), bodyText: cleanedBody, - attachmentNames: parsed.attachmentNames, + attachmentNames: attachmentNames ?? parsed.attachmentNames, isUnread: isUnread, approvalNotice: hasBroadcastApproval(body) ? approvedBroadcastNotice : null, ); } +List combineMailHeaderAndBodyPreview( + List rawHeaders, + List rawBody, +) { + return _messageBytes(rawHeaders, rawBody); +} + +/// A single MIME leaf selected from an IMAP BODYSTRUCTURE, describing how to +/// fetch and decode just that part. +class MailTextSection { + const MailTextSection({ + required this.section, + required this.mimeType, + required this.charset, + required this.encoding, + }); + + /// IMAP section spec used in `BODY[
]`, e.g. `1` or `1.2`. + final String section; + + /// e.g. `text/plain` or `text/html`. + final String mimeType; + + /// e.g. `utf-8`. + final String charset; + + /// Content transfer encoding, e.g. `base64`, `quoted-printable`, `7bit`. + final String encoding; +} + +/// Parsed view of an IMAP BODYSTRUCTURE response: which body part carries the +/// readable text and which parts are attachments. Used to fetch only the text +/// part instead of downloading full messages (attachments included). +class MailBodyStructure { + const MailBodyStructure({ + required this.isMultipart, + required this.textSection, + required this.attachmentNames, + }); + + final bool isMultipart; + + /// The best text part to fetch, or null when the message is a single part + /// (fetch `BODY[TEXT]` instead) or no text part exists. + final MailTextSection? textSection; + + final List attachmentNames; +} + +/// Parses a raw IMAP FETCH response containing a BODYSTRUCTURE. Returns null if +/// no structure can be recovered, so callers can fall back to a bounded +/// `BODY[TEXT]` fetch. +MailBodyStructure? parseBodyStructure(String rawResponse) { + final group = _extractStructureGroup(rawResponse); + if (group == null) return null; + try { + final cursor = _StructCursor(group, 0); + final root = _readStructToken(group, cursor); + if (root is! List) return null; + final leaves = <_StructLeaf>[]; + _collectLeaves(root, '', leaves); + final isMultipart = _isMultipartNode(root); + final textLeaves = leaves + .where((leaf) => !leaf.isAttachment && leaf.type == 'TEXT') + .toList(); + _StructLeaf? chosen; + for (final leaf in textLeaves) { + if (leaf.subtype == 'PLAIN') { + chosen = leaf; + break; + } + } + chosen ??= textLeaves.isNotEmpty ? textLeaves.first : null; + final attachments = leaves + .where((leaf) => leaf.isAttachment) + .map((leaf) => leaf.filename) + .whereType() + .toList(); + final textSection = (chosen == null || !isMultipart) + ? null + : MailTextSection( + section: chosen.section, + mimeType: + '${chosen.type.toLowerCase()}/${chosen.subtype.toLowerCase()}', + charset: chosen.charset, + encoding: chosen.encoding, + ); + return MailBodyStructure( + isMultipart: isMultipart, + textSection: textSection, + attachmentNames: attachments, + ); + } on Object { + return null; + } +} + +/// Builds a synthetic single-part message (top-level headers + the selected +/// part's content headers + its still-encoded body) so [parseMailSummary] and +/// [parseMailDetail] can decode a single fetched text part with the existing +/// pipeline. The injected content headers are appended last so they win over +/// any multipart Content-Type carried in [rawHeaders]. +List buildTextPartMessage({ + required List rawHeaders, + required List rawPartBody, + required MailTextSection section, +}) { + final headers = latin1.decode(rawHeaders, allowInvalid: true).trim(); + final injected = + '$headers\r\n' + 'Content-Type: ${section.mimeType}; charset=${section.charset}\r\n' + 'Content-Transfer-Encoding: ${section.encoding}'; + final body = latin1.decode(rawPartBody, allowInvalid: true); + return latin1.encode('$injected\r\n\r\n$body'); +} + +class _StructLeaf { + const _StructLeaf({ + required this.section, + required this.type, + required this.subtype, + required this.charset, + required this.encoding, + required this.isAttachment, + required this.filename, + }); + + final String section; + final String type; + final String subtype; + final String charset; + final String encoding; + final bool isAttachment; + final String? filename; +} + +String? _extractStructureGroup(String raw) { + final marker = RegExp('BODYSTRUCTURE', caseSensitive: false).firstMatch(raw); + if (marker == null) return null; + final start = raw.indexOf('(', marker.end); + if (start < 0) return null; + var depth = 0; + var inQuote = false; + for (var i = start; i < raw.length; i++) { + final ch = raw[i]; + if (inQuote) { + if (ch == r'\') { + i++; + continue; + } + if (ch == '"') inQuote = false; + continue; + } + if (ch == '"') { + inQuote = true; + } else if (ch == '(') { + depth++; + } else if (ch == ')') { + depth--; + if (depth == 0) return raw.substring(start, i + 1); + } + } + return null; +} + +void _collectLeaves(List node, String prefix, List<_StructLeaf> out) { + if (_isMultipartNode(node)) { + var index = 0; + for (final child in node) { + if (child is! List) break; + index += 1; + final section = prefix.isEmpty ? '$index' : '$prefix.$index'; + _collectLeaves(child, section, out); + } + return; + } + out.add(_leafFromNode(node, prefix.isEmpty ? '1' : prefix)); +} + +bool _isMultipartNode(List node) => node.isNotEmpty && node.first is List; + +_StructLeaf _leafFromNode(List node, String section) { + final type = _asString(node.isNotEmpty ? node[0] : '').toUpperCase(); + final subtype = _asString(node.length > 1 ? node[1] : '').toUpperCase(); + final params = node.length > 2 && node[2] is List + ? node[2] as List + : const []; + final charset = (_paramValue(params, 'CHARSET') ?? 'utf-8').toLowerCase(); + final encoding = (node.length > 5 ? _asString(node[5]) : '7bit') + .toLowerCase(); + final disposition = _dispositionOf(node); + final filename = _filenameOf(node); + final isAttachment = + disposition == 'ATTACHMENT' || (type != 'TEXT' && filename != null); + return _StructLeaf( + section: section, + type: type, + subtype: subtype, + charset: charset, + encoding: encoding.isEmpty ? '7bit' : encoding, + isAttachment: isAttachment, + filename: filename, + ); +} + +String? _dispositionOf(List node) { + for (final element in node) { + if (element is List && element.isNotEmpty && element.first is String) { + final value = (element.first as String).toUpperCase(); + if (value == 'ATTACHMENT' || value == 'INLINE') return value; + } + } + return null; +} + +String? _filenameOf(List node) { + for (final element in node) { + if (element is List && element.isNotEmpty && element.first is String) { + final value = (element.first as String).toUpperCase(); + if ((value == 'ATTACHMENT' || value == 'INLINE') && + element.length > 1 && + element[1] is List) { + final name = _paramValue(element[1] as List, 'FILENAME'); + if (name != null && name.isNotEmpty) { + return decodeMimeHeader(name) ?? name; + } + } + } + } + final params = node.length > 2 && node[2] is List + ? node[2] as List + : const []; + final name = _paramValue(params, 'NAME'); + if (name != null && name.isNotEmpty) return decodeMimeHeader(name) ?? name; + return null; +} + +String? _paramValue(List params, String key) { + for (var i = 0; i + 1 < params.length; i += 2) { + if (_asString(params[i]).toUpperCase() == key.toUpperCase()) { + final value = params[i + 1]; + if (value is String && value.toUpperCase() != 'NIL') return value; + } + } + return null; +} + +String _asString(Object? value) => value is String ? value : ''; + +class _StructCursor { + _StructCursor(this.text, this.index); + + final String text; + int index; +} + +Object? _readStructToken(String source, _StructCursor cursor) { + _skipStructSpaces(source, cursor); + if (cursor.index >= source.length) return null; + final ch = source[cursor.index]; + if (ch == '(') return _readStructList(source, cursor); + if (ch == '"') return _readStructQuoted(source, cursor); + return _readStructAtom(source, cursor); +} + +List _readStructList(String source, _StructCursor cursor) { + cursor.index += 1; // consume '(' + final items = []; + while (cursor.index < source.length) { + _skipStructSpaces(source, cursor); + if (cursor.index >= source.length) break; + if (source[cursor.index] == ')') { + cursor.index += 1; + break; + } + final token = _readStructToken(source, cursor); + if (token == null) break; + items.add(token); + } + return items; +} + +String _readStructQuoted(String source, _StructCursor cursor) { + cursor.index += 1; // consume opening quote + final buffer = StringBuffer(); + while (cursor.index < source.length) { + final ch = source[cursor.index]; + if (ch == r'\' && cursor.index + 1 < source.length) { + buffer.write(source[cursor.index + 1]); + cursor.index += 2; + continue; + } + if (ch == '"') { + cursor.index += 1; + break; + } + buffer.write(ch); + cursor.index += 1; + } + return buffer.toString(); +} + +String _readStructAtom(String source, _StructCursor cursor) { + final buffer = StringBuffer(); + while (cursor.index < source.length) { + final ch = source[cursor.index]; + if (ch == ' ' || + ch == '\r' || + ch == '\n' || + ch == '(' || + ch == ')' || + ch == '"') { + break; + } + buffer.write(ch); + cursor.index += 1; + } + return buffer.toString(); +} + +void _skipStructSpaces(String source, _StructCursor cursor) { + while (cursor.index < source.length) { + final ch = source[cursor.index]; + if (ch != ' ' && ch != '\r' && ch != '\n') break; + cursor.index += 1; + } +} + bool hasBroadcastApproval(String? value) { return value != null && _approvalPattern.hasMatch(value); } @@ -331,3 +682,11 @@ class _MailAddress { final String? name; final String? address; } + +List _messageBytes(List rawHeaders, List rawBody) { + final normalizedHeaders = latin1 + .decode(rawHeaders, allowInvalid: true) + .trim(); + final normalizedBody = latin1.decode(rawBody, allowInvalid: true); + return latin1.encode('$normalizedHeaders\r\n\r\n$normalizedBody'); +} diff --git a/flutter_app/lib/src/mail_repository.dart b/flutter_app/lib/src/mail_repository.dart index 90a9bbc..e4566e8 100644 --- a/flutter_app/lib/src/mail_repository.dart +++ b/flutter_app/lib/src/mail_repository.dart @@ -6,27 +6,91 @@ class MailRepository { factory MailRepository({ ProfileStore? profileStore, MailClient Function()? clientFactory, + Duration cacheTtl = const Duration(minutes: 2), }) { - return MailRepository._(profileStore, clientFactory ?? MailClient.new); + return MailRepository._( + profileStore, + clientFactory ?? MailClient.new, + cacheTtl, + ); } MailRepository.test({ ProfileStore? profileStore, MailClient Function()? clientFactory, - }) : this._(profileStore, clientFactory ?? MailClient.new); + Duration cacheTtl = const Duration(minutes: 2), + }) : this._(profileStore, clientFactory ?? MailClient.new, cacheTtl); - MailRepository._(this._profileStore, this._clientFactory); + MailRepository._(this._profileStore, this._clientFactory, this.cacheTtl); final ProfileStore? _profileStore; final MailClient Function() _clientFactory; + final Duration cacheTtl; + final Map> _cache = + >{}; - Future> listMailboxes(OnboardingProfile? profile) async { - final client = await _authenticatedClient(profile); - try { - return await client.listMailboxes(); - } finally { - client.close(); - } + Future> listMailboxes( + OnboardingProfile? profile, { + bool forceRefresh = false, + }) async { + await _ensureMailAccessAllowed(profile); + final account = _accountKey(profile); + return _cached>( + 'mailboxes|$account', + forceRefresh: forceRefresh, + loader: () async { + final client = await _authenticatedClient(profile); + try { + return await client.listMailboxes(); + } finally { + client.close(); + } + }, + ); + } + + Future> _fetchMailboxesWithoutCache( + MailClient client, + String account, + ) async { + final mailboxes = await client.listMailboxes(); + _cache['mailboxes|$account'] = _MailCacheEntry(mailboxes); + return mailboxes; + } + + Future _fetchSummaryWithoutCache( + MailClient client, + String account, { + required String mailbox, + required int limit, + required bool unreadOnly, + required String query, + required String sender, + required String since, + required int scanLimit, + }) async { + final summary = await client.fetchMailboxSummary( + mailbox: mailbox, + limit: limit, + unreadOnly: unreadOnly, + query: query, + sender: sender, + since: since, + scanLimit: scanLimit, + ); + _cache[_summaryKey( + account: account, + mailbox: mailbox, + limit: limit, + unreadOnly: unreadOnly, + query: query, + sender: sender, + since: since, + scanLimit: scanLimit, + )] = _MailCacheEntry( + summary, + ); + return summary; } Future fetchMailboxSummary( @@ -38,21 +102,40 @@ class MailRepository { String sender = '', String since = '', int scanLimit = 200, + bool forceRefresh = false, }) async { - final client = await _authenticatedClient(profile); - try { - return await client.fetchMailboxSummary( - mailbox: mailbox, - limit: limit, - unreadOnly: unreadOnly, - query: query, - sender: sender, - since: since, - scanLimit: scanLimit, - ); - } finally { - client.close(); - } + await _ensureMailAccessAllowed(profile); + final account = _accountKey(profile); + final key = _summaryKey( + account: account, + mailbox: mailbox, + limit: limit, + unreadOnly: unreadOnly, + query: query, + sender: sender, + since: since, + scanLimit: scanLimit, + ); + return _cached( + key, + forceRefresh: forceRefresh, + loader: () async { + final client = await _authenticatedClient(profile); + try { + return await client.fetchMailboxSummary( + mailbox: mailbox, + limit: limit, + unreadOnly: unreadOnly, + query: query, + sender: sender, + since: since, + scanLimit: scanLimit, + ); + } finally { + client.close(); + } + }, + ); } Future fetchMailboxSnapshot( @@ -60,35 +143,72 @@ class MailRepository { String mailbox = 'INBOX', int limit = 12, bool unreadOnly = false, + bool forceRefresh = false, }) async { - final client = await _authenticatedClient(profile); - try { - final mailboxes = await client.listMailboxes(); - final inbox = await client.fetchMailboxSummary( - mailbox: mailbox, - limit: limit, - unreadOnly: unreadOnly, - ); - return MailMailboxSnapshot(mailboxes: mailboxes, inbox: inbox); - } finally { - client.close(); - } + await _ensureMailAccessAllowed(profile); + final account = _accountKey(profile); + final key = 'snapshot|$account|$mailbox|$limit|$unreadOnly'; + return _cached( + key, + forceRefresh: forceRefresh, + loader: () async { + final client = await _authenticatedClient(profile); + try { + final mailboxes = await _fetchMailboxesWithoutCache(client, account); + final inbox = await _fetchSummaryWithoutCache( + client, + account, + mailbox: mailbox, + limit: limit, + unreadOnly: unreadOnly, + query: '', + sender: '', + since: '', + scanLimit: 200, + ); + return MailMailboxSnapshot(mailboxes: mailboxes, inbox: inbox); + } finally { + client.close(); + } + }, + ); } Future fetchMessageDetail( OnboardingProfile? profile, { required String uid, String mailbox = 'INBOX', + bool forceRefresh = false, }) async { - final client = await _authenticatedClient(profile); - try { - return await client.fetchMessageDetail(uid, mailbox: mailbox); - } finally { - client.close(); - } + await _ensureMailAccessAllowed(profile); + final account = _accountKey(profile); + return _cached( + 'detail|$account|$mailbox|$uid', + forceRefresh: forceRefresh, + loader: () async { + final client = await _authenticatedClient(profile); + try { + return await client.fetchMessageDetail(uid, mailbox: mailbox); + } finally { + client.close(); + } + }, + ); + } + + void clearCache() { + _cache.clear(); } Future _authenticatedClient(OnboardingProfile? profile) async { + await _ensureMailAccessAllowed(profile); + final password = await (_profileStore ?? ProfileStore()).readPassword(); + final client = _clientFactory(); + await client.login(profile!.username, password!); + return client; + } + + Future _ensureMailAccessAllowed(OnboardingProfile? profile) async { if (profile == null) { throw const MailException('Sign in again to access university mail.'); } @@ -96,8 +216,57 @@ class MailRepository { if (password == null || password.isEmpty) { throw const MailException('Sign in again to access university mail.'); } - final client = _clientFactory(); - await client.login(profile.username, password); - return client; } + + String _accountKey(OnboardingProfile? profile) { + return profile?.username.trim().toLowerCase() ?? 'anonymous'; + } + + String _summaryKey({ + required String account, + required String mailbox, + required int limit, + required bool unreadOnly, + required String query, + required String sender, + required String since, + required int scanLimit, + }) { + return [ + 'summary', + account, + mailbox, + limit, + unreadOnly, + query.trim().toLowerCase(), + sender.trim().toLowerCase(), + since.trim(), + scanLimit, + ].join('|'); + } + + Future _cached( + String key, { + required bool forceRefresh, + required Future Function() loader, + }) async { + if (!forceRefresh) { + final cached = _cache[key]; + if (cached != null && !cached.isExpired(cacheTtl)) { + return cached.value as T; + } + } + final value = await loader(); + _cache[key] = _MailCacheEntry(value); + return value; + } +} + +class _MailCacheEntry { + _MailCacheEntry(this.value) : createdAt = DateTime.now(); + + final T value; + final DateTime createdAt; + + bool isExpired(Duration ttl) => DateTime.now().difference(createdAt) > ttl; } diff --git a/flutter_app/lib/src/native_bridge.dart b/flutter_app/lib/src/native_bridge.dart index 7ad89a0..7078f39 100644 --- a/flutter_app/lib/src/native_bridge.dart +++ b/flutter_app/lib/src/native_bridge.dart @@ -73,6 +73,8 @@ class NativeBridge { required String label, required String fileName, required String url, + int? expectedSizeBytes, + String? expectedSha256, }) async { final result = await _methods.invokeMapMethod( 'downloadLocalModel', @@ -81,6 +83,9 @@ class NativeBridge { 'label': label, 'fileName': fileName, 'url': url, + 'expectedSizeBytes': ?expectedSizeBytes, + if (expectedSha256 != null && expectedSha256.isNotEmpty) + 'expectedSha256': expectedSha256, }, ); return result ?? const {}; diff --git a/flutter_app/lib/src/views/campus_view.dart b/flutter_app/lib/src/views/campus_view.dart index 2d2d2a3..e0bf9eb 100644 --- a/flutter_app/lib/src/views/campus_view.dart +++ b/flutter_app/lib/src/views/campus_view.dart @@ -30,10 +30,12 @@ class _CampusViewState extends State { _canteens = _fetch(); } - Future> _fetch() async { + Future> _fetch({bool forceRefresh = false}) async { final client = widget.client ?? CampusClient(); try { - final canteens = await client.fetchTuebingenCanteens(); + final canteens = await client.fetchTuebingenCanteens( + forceRefresh: forceRefresh, + ); return canteens .map((canteen) => canteen.filteredFor(_preference)) .map((canteen) => canteen.forWeek(_today)) @@ -45,7 +47,7 @@ class _CampusViewState extends State { } void _refresh() { - setState(() => _canteens = _fetch()); + setState(() => _canteens = _fetch(forceRefresh: true)); } @override diff --git a/flutter_app/lib/src/views/mail_view.dart b/flutter_app/lib/src/views/mail_view.dart index 9c64dec..120c07a 100644 --- a/flutter_app/lib/src/views/mail_view.dart +++ b/flutter_app/lib/src/views/mail_view.dart @@ -37,16 +37,17 @@ class _MailViewState extends State { _selectedMessage = null; _openingMessageUid = null; _messageError = null; - _state = _load(); + _state = _load(forceRefresh: true); }); } - Future _load() { + Future _load({bool forceRefresh = false}) { return _repository.fetchMailboxSnapshot( widget.profile, mailbox: _mailbox, limit: 20, unreadOnly: _unreadOnly, + forceRefresh: forceRefresh, ); } diff --git a/flutter_app/lib/src/widgets/local_model_settings_card.dart b/flutter_app/lib/src/widgets/local_model_settings_card.dart index 32613fa..beaa6ab 100644 --- a/flutter_app/lib/src/widgets/local_model_settings_card.dart +++ b/flutter_app/lib/src/widgets/local_model_settings_card.dart @@ -244,6 +244,8 @@ class _LocalModelSettingsCardState extends State { label: option.label, fileName: option.fileName, url: url, + expectedSizeBytes: option.expectedSizeBytes, + expectedSha256: option.expectedSha256, ); await _loadInstalledModels(); await widget.onSaveAgentConfig( diff --git a/flutter_app/test/campus_client_test.dart b/flutter_app/test/campus_client_test.dart index 2ae102e..d4dde17 100644 --- a/flutter_app/test/campus_client_test.dart +++ b/flutter_app/test/campus_client_test.dart @@ -35,11 +35,46 @@ void main() { }), ); - final canteens = await client.fetchTuebingenCanteens(); + final canteens = await client.fetchTuebingenCanteens(forceRefresh: true); final filtered = canteens.single.filteredFor(FoodPreference.vegan); expect(filtered.name, 'Mensa Wilhelmstraße'); expect(filtered.menus.single.items, contains('Cous-Cous')); expect(filtered.menus.single.studentPrice, '3,70'); }); + + test('campus client reuses cached menus until force refreshed', () async { + var requestCount = 0; + final client = CampusClient( + httpClient: MockClient((request) async { + requestCount += 1; + return http.Response( + jsonEncode({ + '611': { + 'canteenId': '611', + 'canteen': 'Mensa Wilhelmstraße', + 'menus': [ + { + 'id': '$requestCount', + 'menuLine': 'Tagesmenü', + 'menuDate': '2026-06-17', + 'menu': ['Meal $requestCount'], + }, + ], + }, + }), + 200, + request: request, + ); + }), + ); + + final first = await client.fetchTuebingenCanteens(forceRefresh: true); + final second = await client.fetchTuebingenCanteens(); + final refreshed = await client.fetchTuebingenCanteens(forceRefresh: true); + + expect(requestCount, 2); + expect(second.single.menus.single.items, first.single.menus.single.items); + expect(refreshed.single.menus.single.items.single, 'Meal 2'); + }); } diff --git a/flutter_app/test/cloud_agent_client_test.dart b/flutter_app/test/cloud_agent_client_test.dart index aebdbc9..2f45a62 100644 --- a/flutter_app/test/cloud_agent_client_test.dart +++ b/flutter_app/test/cloud_agent_client_test.dart @@ -598,7 +598,10 @@ class _FakeMailRepository extends MailRepository { _FakeMailRepository() : super.test(); @override - Future> listMailboxes(OnboardingProfile? profile) async { + Future> listMailboxes( + OnboardingProfile? profile, { + bool forceRefresh = false, + }) async { return const [ MailboxSummary( name: 'INBOX', @@ -620,6 +623,7 @@ class _FakeMailRepository extends MailRepository { String sender = '', String since = '', int scanLimit = 200, + bool forceRefresh = false, }) async { return const MailInboxSummary( account: 'ada42', @@ -644,6 +648,7 @@ class _FakeMailRepository extends MailRepository { OnboardingProfile? profile, { required String uid, String mailbox = 'INBOX', + bool forceRefresh = false, }) async { return const MailMessageDetail( uid: '7', diff --git a/flutter_app/test/local_model_catalog_test.dart b/flutter_app/test/local_model_catalog_test.dart new file mode 100644 index 0000000..52cc616 --- /dev/null +++ b/flutter_app/test/local_model_catalog_test.dart @@ -0,0 +1,23 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:studyos_agent/src/local_model_catalog.dart'; + +void main() { + test('default local model catalog uses downloadable entries', () { + final defaults = localModelCatalog.where((model) => model.id != 'custom'); + + expect(defaults, isNotEmpty); + expect( + defaults.every((model) => model.downloadUrl.startsWith('https://')), + isTrue, + ); + // lfm2.5 has no available LiteRT checkpoint, so it must stay out of defaults. + expect( + localModelCatalog.map((model) => model.id), + isNot(contains('lfm2-5-1-2b')), + ); + expect( + defaults.every((model) => model.fileName.endsWith('.litertlm')), + isTrue, + ); + }); +} diff --git a/flutter_app/test/mail_parsing_test.dart b/flutter_app/test/mail_parsing_test.dart index c48ee7e..416f0cd 100644 --- a/flutter_app/test/mail_parsing_test.dart +++ b/flutter_app/test/mail_parsing_test.dart @@ -54,4 +54,115 @@ Content-Type: text/html; charset=utf-8 expect(summary.preview, 'The deadline is 2026-06-20.'); expect(summary.isUnread, isTrue); }); + + test( + 'summary preview parses headers without requiring full message body', + () { + final summary = parseMailSummaryPreview( + rawHeaders: utf8.encode(''' +From: Prof X +Subject: Office hour +Date: Tue, 16 Jun 2026 10:00:00 +0200 +Content-Type: text/plain; charset=utf-8 +'''), + rawPreview: utf8.encode('Please bring your draft.'), + uid: '9', + isUnread: false, + ); + + expect(summary.subject, 'Office hour'); + expect(summary.fromAddress, 'prof@example.edu'); + expect(summary.preview, 'Please bring your draft.'); + expect(summary.isUnread, isFalse); + }, + ); + + group('parseBodyStructure', () { + test('returns no section for a single-part message', () { + final structure = parseBodyStructure( + '* 5 FETCH (BODYSTRUCTURE ' + '("TEXT" "PLAIN" ("CHARSET" "utf-8") NIL NIL "7BIT" 2279 48 NIL NIL NIL NIL))', + ); + + expect(structure, isNotNull); + expect(structure!.isMultipart, isFalse); + expect(structure.textSection, isNull); + expect(structure.attachmentNames, isEmpty); + }); + + test('prefers the text/plain part of a multipart/alternative', () { + final structure = parseBodyStructure( + '* 5 FETCH (BODYSTRUCTURE ' + '(("TEXT" "PLAIN" ("CHARSET" "utf-8") NIL NIL "QUOTED-PRINTABLE" 12 3 NIL NIL NIL NIL)' + '("TEXT" "HTML" ("CHARSET" "utf-8") NIL NIL "QUOTED-PRINTABLE" 40 5 NIL NIL NIL NIL)' + ' "ALTERNATIVE" ("BOUNDARY" "abc") NIL NIL NIL))', + ); + + expect(structure, isNotNull); + expect(structure!.isMultipart, isTrue); + expect(structure.textSection?.section, '1'); + expect(structure.textSection?.mimeType, 'text/plain'); + expect(structure.textSection?.encoding, 'quoted-printable'); + expect(structure.attachmentNames, isEmpty); + }); + + test('selects the text part and lists attachments in multipart/mixed', () { + final structure = parseBodyStructure( + '* 5 FETCH (BODYSTRUCTURE ' + '(("TEXT" "PLAIN" ("CHARSET" "utf-8") NIL NIL "7BIT" 100 4 NIL NIL NIL NIL)' + '("APPLICATION" "PDF" ("NAME" "slides.pdf") NIL NIL "BASE64" 90000 NIL ' + '("ATTACHMENT" ("FILENAME" "slides.pdf")) NIL)' + ' "MIXED" ("BOUNDARY" "xyz") NIL NIL NIL))', + ); + + expect(structure!.textSection?.section, '1'); + expect(structure.textSection?.mimeType, 'text/plain'); + expect(structure.attachmentNames, ['slides.pdf']); + }); + + test('assigns dotted section numbers for nested multiparts', () { + final structure = parseBodyStructure( + '* 5 FETCH (BODYSTRUCTURE ' + '((("TEXT" "PLAIN" ("CHARSET" "utf-8") NIL NIL "7BIT" 100 4 NIL NIL NIL NIL)' + '("TEXT" "HTML" ("CHARSET" "utf-8") NIL NIL "7BIT" 200 6 NIL NIL NIL NIL)' + ' "ALTERNATIVE" ("BOUNDARY" "inner") NIL NIL NIL)' + '("APPLICATION" "PDF" ("NAME" "doc.pdf") NIL NIL "BASE64" 90000 NIL ' + '("ATTACHMENT" ("FILENAME" "doc.pdf")) NIL)' + ' "MIXED" ("BOUNDARY" "outer") NIL NIL NIL))', + ); + + expect(structure!.textSection?.section, '1.1'); + expect(structure.attachmentNames, ['doc.pdf']); + }); + + test('returns null when no structure is present', () { + expect(parseBodyStructure('* 5 FETCH (UID 5 FLAGS (\\Seen))'), isNull); + }); + }); + + test( + 'buildTextPartMessage decodes a fetched text part with its encoding', + () { + final section = MailTextSection( + section: '1', + mimeType: 'text/plain', + charset: 'utf-8', + encoding: 'quoted-printable', + ); + final message = buildTextPartMessage( + rawHeaders: latin1.encode( + 'From: Info \r\n' + 'Subject: Update\r\n' + 'Content-Type: multipart/alternative; boundary="abc"\r\n', + ), + rawPartBody: latin1.encode('Bitte pr=C3=BCfen Sie das Dokument.'), + section: section, + ); + + final summary = parseMailSummary(message, uid: '3', isUnread: false); + expect(summary.subject, 'Update'); + expect(summary.fromAddress, 'info@example.edu'); + expect(summary.preview, 'Bitte prüfen Sie das Dokument.'); + }, + ); } diff --git a/flutter_app/test/mail_repository_test.dart b/flutter_app/test/mail_repository_test.dart index b80d772..5a40308 100644 --- a/flutter_app/test/mail_repository_test.dart +++ b/flutter_app/test/mail_repository_test.dart @@ -35,6 +35,27 @@ void main() { expect(client.summaryCount, 1); expect(client.closeCount, 1); }); + + test('mail summaries are cached until force refreshed', () async { + final client = _CountingMailClient(); + final repository = MailRepository.test( + profileStore: _FakeProfileStore(), + clientFactory: () => client, + ); + + await repository.fetchMailboxSummary(_profile, mailbox: 'INBOX', limit: 5); + await repository.fetchMailboxSummary(_profile, mailbox: 'INBOX', limit: 5); + await repository.fetchMailboxSummary( + _profile, + mailbox: 'INBOX', + limit: 5, + forceRefresh: true, + ); + + expect(client.loginCount, 2); + expect(client.summaryCount, 2); + expect(client.closeCount, 2); + }); } const _profile = OnboardingProfile( @@ -90,8 +111,7 @@ class _CountingMailClient extends MailClient { }) async { summaryCount += 1; expect(mailbox, 'INBOX'); - expect(limit, 20); - expect(unreadOnly, isTrue); + expect(limit, greaterThan(0)); return const MailInboxSummary( account: 'ada42', mailbox: 'INBOX', diff --git a/flutter_app/test/mail_tools_test.dart b/flutter_app/test/mail_tools_test.dart index 88cacb8..3039dc9 100644 --- a/flutter_app/test/mail_tools_test.dart +++ b/flutter_app/test/mail_tools_test.dart @@ -63,6 +63,7 @@ class _ToolMailRepository extends MailRepository { String sender = '', String since = '', int scanLimit = 200, + bool forceRefresh = false, }) async { expect(limit, lessThanOrEqualTo(10)); return const MailInboxSummary( @@ -84,7 +85,10 @@ class _ToolMailRepository extends MailRepository { } @override - Future> listMailboxes(OnboardingProfile? profile) async { + Future> listMailboxes( + OnboardingProfile? profile, { + bool forceRefresh = false, + }) async { return const []; } @@ -93,6 +97,7 @@ class _ToolMailRepository extends MailRepository { OnboardingProfile? profile, { required String uid, String mailbox = 'INBOX', + bool forceRefresh = false, }) async { return const MailMessageDetail( uid: '42',