Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -33,6 +34,8 @@ class AndroidLocalModelStore(context: Context) {
label: String,
fileName: String,
url: String,
expectedSizeBytes: Long = -1L,
expectedSha256: String = "",
onProgress: (Long, Long) -> Unit = { _, _ -> },
): Map<String, Any?> {
require(id.isNotBlank()) { "Model id is required." }
Expand All @@ -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()
}
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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 ->
Expand All @@ -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()
Expand Down Expand Up @@ -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,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -337,13 +337,18 @@ class MainActivity : FlutterActivity() {
val label = call.argument<String>("label").orEmpty()
val fileName = call.argument<String>("fileName").orEmpty()
val url = call.argument<String>("url").orEmpty()
val expectedSizeBytes =
(call.argument<Any>("expectedSizeBytes") as? Number)?.toLong() ?: -1L
val expectedSha256 = call.argument<String>("expectedSha256").orEmpty()
Thread {
try {
val model = localModelStore().downloadModel(
id = id,
label = label,
fileName = fileName,
url = url,
expectedSizeBytes = expectedSizeBytes,
expectedSha256 = expectedSha256,
onProgress = { receivedBytes, totalBytes ->
emitDownloadProgress(id, label, receivedBytes, totalBytes)
},
Expand Down
24 changes: 20 additions & 4 deletions flutter_app/lib/src/campus_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -19,8 +21,19 @@ class CampusClient {
};

final http.Client _httpClient;
final Duration cacheTtl;
static List<CampusCanteen>? _cachedCanteens;
static DateTime? _cachedAt;

Future<List<CampusCanteen>> fetchTuebingenCanteens() async {
Future<List<CampusCanteen>> 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}.');
Expand All @@ -29,11 +42,14 @@ class CampusClient {
if (decoded is! Map<String, Object?>) {
throw const CampusException('Mensa data has an unexpected format.');
}
return _tuebingenCanteenIds
final canteens = _tuebingenCanteenIds
.where(decoded.containsKey)
.map((id) => _canteenFromJson(id, decoded[id]))
.whereType<CampusCanteen>()
.toList();
_cachedCanteens = canteens;
_cachedAt = DateTime.now();
return canteens;
}

void close() => _httpClient.close();
Expand Down
27 changes: 19 additions & 8 deletions flutter_app/lib/src/local_model_catalog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,21 @@ class LocalModelOption {
required this.fileName,
required this.description,
this.downloadUrl = '',
this.expectedSizeBytes,
this.expectedSha256,
});

final String id;
final String label;
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<LocalModelOption> localModelCatalog = <LocalModelOption>[
Expand All @@ -22,18 +28,23 @@ const List<LocalModelOption> localModelCatalog = <LocalModelOption>[
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',
Expand Down
91 changes: 82 additions & 9 deletions flutter_app/lib/src/mail_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
);
}

Expand Down Expand Up @@ -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<List<int>> _fetchRawMessage(String uid) async {
final response = await _requireConnection().command(
'UID FETCH $uid (BODY.PEEK[])',
);
Future<MailBodyStructure?> _fetchBodyStructure(String uid) async {
try {
final response = await _requireConnection().command(
'UID FETCH $uid (BODYSTRUCTURE)',
);
return parseBodyStructure(response.text);
} on Object {
return null;
}
}

Future<List<int>> _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 <int>[];
throw const MailException('IMAP fetch returned no message body.');
}
return literal;
}
Expand Down
Loading
Loading