Skip to content
Merged
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
2 changes: 0 additions & 2 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,6 @@ android {
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
// OIDC redirect scheme for flutter_appauth's RedirectUriReceiverActivity.
// Must match OidcConfig.redirectScheme (lib/config/oidc_config.dart).
}

flavorDimensions += "environment"
Expand Down
11 changes: 11 additions & 0 deletions android/app/src/dev/res/xml/network_security_config.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Dev flavor only: a local backend over plain HTTP. -->
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">localhost</domain>
<domain includeSubdomains="true">127.0.0.1</domain>
<domain includeSubdomains="true">10.0.2.2</domain>
<domain includeSubdomains="true">192.168.188.162</domain>
<domain includeSubdomains="true">192.168.188.93</domain>
</domain-config>
</network-security-config>
9 changes: 2 additions & 7 deletions android/app/src/main/res/xml/network_security_config.xml
Original file line number Diff line number Diff line change
@@ -1,10 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- No cleartext traffic in release flavors. The dev flavor overrides this file in src/dev. -->
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">localhost</domain>
<domain includeSubdomains="true">127.0.0.1</domain>
<domain includeSubdomains="true">10.0.2.2</domain>
<domain includeSubdomains="true">192.168.188.162</domain>
<domain includeSubdomains="true">192.168.188.93</domain>
</domain-config>
<base-config cleartextTrafficPermitted="false" />
</network-security-config>
20 changes: 19 additions & 1 deletion lib/services/linked_accounts_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,27 @@ class LinkedAccountsController extends ChangeNotifier with WidgetsBindingObserve
return out;
}

Future<void>? _loadInFlight;

/// Providers and accounts, fresh from the server. A failure is kept in [error] and the last
/// good lists stay on screen.
Future<void> load() async {
///
/// Listeners may call this while they are being notified, so a call during a load joins it.
/// The load counts as running until its last notification is out; otherwise a listener that
/// loads when nothing is [loaded] would start over after every failure.
Future<void> load() {
final running = _loadInFlight;
if (running != null) return running;
final done = Completer<void>();
_loadInFlight = done.future;
_load().whenComplete(() {
_loadInFlight = null;
done.complete();
});
return done.future;
}

Future<void> _load() async {
_loading = true;
_error = null;
_notify();
Expand Down
114 changes: 87 additions & 27 deletions lib/services/private_data_adapter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,46 +16,86 @@ class PrivateDataAdapter {
return d == null ? null : Date(d.year, d.month, d.day);
}

static String _classId(String subject) => 'private-class-$subject';

static String? _classIdOrNull(String? subject) => subject == null || subject.isEmpty ? null : _classId(subject);

/// The exam a grade belongs to. A grade without an exam id still needs one: the pages list grades through their exam.
static String _examIdOf(PrivateGrade g) => g.examId?.isNotEmpty == true ? g.examId! : 'private-grade-${g.id ?? '${g.subject}-${g.date}-${g.score}'}';

static GradeDto grade(PrivateGrade g) => GradeDto((b) => b
..id = g.id
..score = g.score
..examId = g.examId
..examId = _examIdOf(g)
..schoolUserId = privateSchoolUserId);

static ExamDto exam(PrivateExam e) => ExamDto((b) => b
..id = e.id
..name = (e.name?.isNotEmpty == true ? e.name! : e.subject) ?? 'Exam'
..classAverage = 0
..date = _dateOnly(e.startDate)
..classId = _classIdOrNull(e.subject)
..description = e.comment);

/// The stateless exam list holds upcoming exams, while grades mostly belong to past ones. An exam
/// is made up from the grade for those, as the account-mode sync does on the server.
static ExamDto _examFromGrade(PrivateGrade g) {
final date = _dateOnly(g.date);
return ExamDto((b) => b
..id = _examIdOf(g)
..name = g.comment?.isNotEmpty == true ? g.comment! : (date == null ? (g.subject ?? 'Grade') : 'Grade ${date.day}.${date.month}.${date.year}')
..classAverage = 0
..date = date
..classId = _classIdOrNull(g.subject));
}

static AbsenceDto absence(PrivateAbsence a) {
final from = _dateOr(a.from, DateTime.now());
return AbsenceDto((b) => b
..id = a.id
..reason = (a.reason?.isNotEmpty == true ? a.reason! : 'Absence')
..reason = a.reason?.isNotEmpty == true ? a.reason! : (a.subject?.isNotEmpty == true ? a.subject! : 'Absence')
..type = AbsenceType.absence
..isExcused = a.excused
..from = from
..until = _dateOr(a.to, from)
..schoolUserId = privateSchoolUserId);
}

/// Mirrors the words the server's holiday sync treats as a day off.
static const _dayOffWords = ['ferien', 'feiertag', 'schulfrei', 'unterrichtsfrei', 'brückentag', 'brueckentag', 'vacances', 'vacanze', 'congé', 'conge', 'neujahr', 'berchtold', 'karfreitag', 'ostern', 'ostermontag', 'tag der arbeit', 'auffahrt', 'pfingst', 'fronleichnam', 'bundesfeier', 'nationalfeiertag', 'maria himmelfahrt', 'allerheiligen', 'maria empfängnis', 'weihnacht', 'stephan'];

/// The stateless routes pass the school's own event type on: "Timetable", "Event", "Exam" and the like.
static AgendaEntryType _entryType(PrivateAgendaEvent e) {
final title = (e.title ?? '').toLowerCase();
return switch (e.type?.toLowerCase()) {
'holiday' => AgendaEntryType.holiday,
'exam' || 'test' => AgendaEntryType.test,
'event' => _dayOffWords.any(title.contains) ? AgendaEntryType.holiday : AgendaEntryType.event,
_ => AgendaEntryType.lesson,
};
}

static AgendaEntryDto agendaEntry(PrivateAgendaEvent e) => AgendaEntryDto((b) => b
..id = e.id
..entryType = e.type?.toLowerCase() == 'holiday'
? AgendaEntryType.holiday
: AgendaEntryType.lesson
..entryType = _entryType(e)
..title = (e.title?.isNotEmpty == true ? e.title! : 'Entry')
..place = e.room
..description = e.comment
..date = _dateOr(e.startDate, DateTime.now())
..endDate = _date(e.endDate)
..schoolUserId = privateSchoolUserId);

static SchoolUserDto schoolUser(
PrivateUserInfo? info,
List<PrivateGrade> grades,
List<PrivateAbsence> absences,
) =>
static List<String> _subjects(List<PrivateGrade> grades, List<PrivateExam> exams) {
final subjects = <String>{
for (final g in grades)
if (g.subject?.isNotEmpty == true) g.subject!,
for (final e in exams)
if (e.subject?.isNotEmpty == true) e.subject!,
};
return subjects.toList()..sort();
}

static SchoolUserDto schoolUser(PrivateUserInfo? info, List<PrivateGrade> grades, List<PrivateAbsence> absences, [List<PrivateExam> exams = const []]) =>
SchoolUserDto((b) => b
..id = privateSchoolUserId
..firstName = info?.firstName ?? ''
Expand All @@ -64,35 +104,55 @@ class PrivateDataAdapter {
..entryDate = _dateOnly(info?.entryDate)
..role = Roles.student
..grades = ListBuilder<GradeDto>(grades.map(grade))
..absences = ListBuilder<AbsenceDto>(absences.map(absence)));
..absences = ListBuilder<AbsenceDto>(absences.map(absence))
..classes = ListBuilder<UserClassDto>(_subjects(grades, exams).map((s) => UserClassDto((c) => c
..classId = _classId(s)
..className = s))));

static List<ExamDto> exams(List<PrivateExam> e) =>
e.map(exam).toList(growable: false);
/// The school's exams, plus one for every grade that has none in that list.
static List<ExamDto> exams(List<PrivateExam> e, [List<PrivateGrade> grades = const []]) {
final known = {for (final x in e) x.id};
return [
...e.map(exam),
for (final g in grades)
if (known.add(_examIdOf(g))) _examFromGrade(g),
];
}

static List<AbsenceDto> absencesList(List<PrivateAbsence> a) =>
a.map(absence).toList(growable: false);

static List<AgendaEntryDto> agenda(List<PrivateAgendaEvent> e) =>
e.map(agendaEntry).toList(growable: false);
/// The school's agenda, plus the exams still to come, which account mode also shows in the timetable.
static List<AgendaEntryDto> agenda(List<PrivateAgendaEvent> e, {List<PrivateExam> exams = const [], List<PrivateGrade> grades = const []}) {
final known = {for (final x in e) x.id};
final graded = {for (final g in grades) _examIdOf(g)};
return [
...e.map(agendaEntry),
for (final x in exams)
if (_date(x.startDate) != null && !graded.contains(x.id) && known.add(x.id))
AgendaEntryDto((b) => b
..id = x.id
..entryType = AgendaEntryType.test
..title = [if (x.subject?.isNotEmpty == true) x.subject!, if (x.name?.isNotEmpty == true && x.name != x.subject) x.name!].join(': ')
..place = x.room
..description = x.comment
..date = _date(x.startDate)!
..endDate = _date(x.endDate)
..classId = _classIdOrNull(x.subject)
..schoolUserId = privateSchoolUserId),
];
}

static List<ClassDto> classes(
List<PrivateGrade> grades,
List<PrivateExam> exams,
) {
final subjects = <String>{};
for (final g in grades) {
if (g.subject != null && g.subject!.isNotEmpty) subjects.add(g.subject!);
}
for (final e in exams) {
if (e.subject != null && e.subject!.isNotEmpty) subjects.add(e.subject!);
}
final sorted = subjects.toList()..sort();
return sorted
final all = PrivateDataAdapter.exams(exams, grades);
return _subjects(grades, exams)
.map((subject) => ClassDto((b) => b
..id = 'private-class-$subject'
..id = _classId(subject)
..name = subject
..exams = ListBuilder<ExamDto>(
exams.where((e) => e.subject == subject).map(exam))))
..exams = ListBuilder<ExamDto>(all.where((e) => e.classId == _classId(subject)))))
.toList(growable: false);
}
}
37 changes: 28 additions & 9 deletions lib/services/school_data_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,18 @@ class SchoolDataService extends ChangeNotifier {
Object? get error => _error;
bool get hasLoaded => _hasLoaded;

/// Test-only: sets the data directly and notifies, as a finished refresh does.
@visibleForTesting
void setForTest({SchoolUserDto? me, List<ExamDto> exams = const [], List<AgendaEntryDto> agenda = const [], List<AbsenceDto> absences = const [], List<ClassDto> classes = const []}) {
_me = me;
_exams = exams;
_agenda = agenda;
_absences = absences;
_classes = classes;
_hasLoaded = true;
notifyListeners();
}

Map<String, String> get classNameById {
final out = <String, String>{};
for (final c in _classes) {
Expand Down Expand Up @@ -229,19 +241,21 @@ class SchoolDataService extends ChangeNotifier {
List<AbsenceDto> absences;
List<AgendaEntryDto> agenda;
List<ClassDto> classes;
var complete = true;
if (account.accessToken != null) {
final d = await TokenProxyClient.instance.fetchAll(account);
mine = PrivateDataAdapter.schoolUser(d.userInfo, d.grades, d.absences);
exams = PrivateDataAdapter.exams(d.exams);
complete = d.failedSections == 0;
mine = PrivateDataAdapter.schoolUser(d.userInfo, d.grades, d.absences, d.exams);
exams = PrivateDataAdapter.exams(d.exams, d.grades);
absences = PrivateDataAdapter.absencesList(d.absences);
agenda = PrivateDataAdapter.agenda(d.agenda);
agenda = PrivateDataAdapter.agenda(d.agenda, exams: d.exams, grades: d.grades);
classes = PrivateDataAdapter.classes(d.grades, d.exams);
} else {
final d = await ScrapeProxyClient.instance.data(account);
mine = PrivateDataAdapter.schoolUser(d.userInfo, d.grades, const []);
exams = PrivateDataAdapter.exams(d.exams);
mine = PrivateDataAdapter.schoolUser(d.userInfo, d.grades, const [], d.exams);
exams = PrivateDataAdapter.exams(d.exams, d.grades);
absences = const [];
agenda = PrivateDataAdapter.agenda(d.agenda);
agenda = PrivateDataAdapter.agenda(d.agenda, exams: d.exams, grades: d.grades);
classes = PrivateDataAdapter.classes(d.grades, d.exams);
}

Expand All @@ -254,9 +268,14 @@ class SchoolDataService extends ChangeNotifier {
_reports = const [];
_teachers = const [];
_documents = const [];
await SchoolDataSnapshotStore.instance.save(
'private',
SchoolDataSnapshot(me: _me, exams: _exams, agenda: _agenda, absences: _absences, classes: _classes, reports: _reports, teachers: _teachers, documents: _documents));
// A partial answer is shown, but the last complete snapshot stays on disk for the next launch.
if (complete) {
await SchoolDataSnapshotStore.instance.save(
'private',
SchoolDataSnapshot(me: _me, exams: _exams, agenda: _agenda, absences: _absences, classes: _classes, reports: _reports, teachers: _teachers, documents: _documents));
} else {
ToastService.error('Some data could not be refreshed', 'Your school did not answer every request. Pull down to try again.');
}
_hasLoaded = true;
} catch (e) {
if (gen != _generation) return;
Expand Down
57 changes: 42 additions & 15 deletions lib/services/token_proxy_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'dart:convert';
import '../ui/widgets/browser_sign_in.dart';

import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';

import '../domain/private_data.dart';
import 'backend_dio.dart';
Expand All @@ -15,13 +16,17 @@ import 'totp_service.dart';
/// supplies its own credentials (token + context_state) per request; nothing is
/// stored server-side. The system's stateless route comes from the catalog.
class TokenProxyClient {
TokenProxyClient._();
TokenProxyClient._()
: _dio = backendDio(
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 60),
);
static final TokenProxyClient instance = TokenProxyClient._();

final Dio _dio = backendDio(
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 60),
);
@visibleForTesting
TokenProxyClient.withDio(this._dio);

final Dio _dio;

/// Headless credential login (private mode): POST email + password (+ TOTP) to
/// the stateless `/login` and get back tokens + the rotated context_state.
Expand Down Expand Up @@ -117,18 +122,36 @@ class TokenProxyClient {
}
}

/// Fetches every section at once. One section the school fails to answer does not cost the
/// others; only a refused token (the caller refreshes it) or a total failure throws.
Future<TokenPrivateData> _fetchAll(PrivateAccount a) async {
final info = await userInfo(a);
final g = await grades(a);
final e = await exams(a);
final ab = await absences(a);
final ag = await agenda(a);
var failed = 0;
DioException? failure;
Future<T> section<T>(Future<T> fetch, T fallback) async {
try {
return await fetch;
} on DioException catch (e) {
if (e.response?.statusCode == 401) rethrow;
failed++;
failure ??= e;
return fallback;
}
}

final info = section<PrivateUserInfo?>(userInfo(a), null);
final g = section(grades(a), const <PrivateGrade>[]);
final e = section(exams(a), const <PrivateExam>[]);
final ab = section(absences(a), const <PrivateAbsence>[]);
final ag = section(agenda(a), const <PrivateAgendaEvent>[]);
await Future.wait<Object?>([info, g, e, ab, ag]);
if (failed == 5) throw failure!;
return TokenPrivateData(
userInfo: info,
grades: g,
exams: e,
absences: ab,
agenda: ag,
userInfo: await info,
grades: await g,
exams: await e,
absences: await ab,
agenda: await ag,
failedSections: failed,
);
}

Expand Down Expand Up @@ -239,11 +262,15 @@ class TokenPrivateData {
final List<PrivateExam> exams;
final List<PrivateAbsence> absences;
final List<PrivateAgendaEvent> agenda;

/// How many sections could not be fetched and are empty here.
final int failedSections;
const TokenPrivateData({
required this.userInfo,
required this.grades,
required this.exams,
required this.absences,
required this.agenda,
this.failedSections = 0,
});
}
Loading
Loading