replaced account data singleton with a session model
This commit is contained in:
@@ -17,6 +17,7 @@ Flutter-App für die Schul-Community: Webuntis-Stundenplan, Nextcloud Talk + Fil
|
|||||||
```
|
```
|
||||||
lib/
|
lib/
|
||||||
├── api/ HTTP-Layer pro Backend (mhsl/, marianumcloud/, webuntis/, holidays/)
|
├── api/ HTTP-Layer pro Backend (mhsl/, marianumcloud/, webuntis/, holidays/)
|
||||||
|
├── session/ Session-Modell, SessionManager, SessionLifecycle
|
||||||
├── state/app/modules/ BLoC pro Feature-Modul (timetable, chat, chat_list, files, ...)
|
├── state/app/modules/ BLoC pro Feature-Modul (timetable, chat, chat_list, files, ...)
|
||||||
├── state/app/infrastructure LoadableState<T>, DataLoader, geteilte BLoC-Bausteine
|
├── state/app/infrastructure LoadableState<T>, DataLoader, geteilte BLoC-Bausteine
|
||||||
├── view/ Screens
|
├── view/ Screens
|
||||||
@@ -51,6 +52,8 @@ lib/
|
|||||||
|
|
||||||
**Settings:** Pro Feature ein Freezed-Modell unter `lib/storage/`, persistiert via HydratedBloc.
|
**Settings:** Pro Feature ein Freezed-Modell unter `lib/storage/`, persistiert via HydratedBloc.
|
||||||
|
|
||||||
|
**Session:** Die aktive Sitzung liegt in `SessionManager().current` (`lib/session/`). Nextcloud-Zugriffe nur über `SessionManager().requireNextcloud()`. Abmelden ausschließlich über `SessionLifecycle.signOut()`. Die Keychain-Keys in `SessionKeys` sind eingefroren (Bestandsinstallationen, iOS-NSE).
|
||||||
|
|
||||||
## Build / Run
|
## Build / Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -180,11 +180,11 @@ AppDelegate) exakt so ab: `GenericPassword` + `kSecAttrAccount = <key>` +
|
|||||||
| `push_server_public_key_pem` | **Server**-Public-Key (per User), SPKI-PEM | `push_registration_store.dart` | **NSE** (Signatur prüfen) |
|
| `push_server_public_key_pem` | **Server**-Public-Key (per User), SPKI-PEM | `push_registration_store.dart` | **NSE** (Signatur prüfen) |
|
||||||
| `push_device_identifier` | NC Device-Identifier | `push_registration_store.dart` | Dart |
|
| `push_device_identifier` | NC Device-Identifier | `push_registration_store.dart` | Dart |
|
||||||
| `push_registered_fcm_token` | FCM-Token der Registrierung | `push_registration_store.dart` | Dart |
|
| `push_registered_fcm_token` | FCM-Token der Registrierung | `push_registration_store.dart` | Dart |
|
||||||
| `nextcloud_app_password` | NC App-Password | `account_data.dart` | **AppDelegate** (Basic-Auth) |
|
| `nextcloud_app_password` | NC App-Password | `session_manager.dart` | **AppDelegate** (Basic-Auth) |
|
||||||
| `nextcloud_username` | NC Username | `push_registration_store.dart` (**neu**) | **AppDelegate** (Basic-Auth) |
|
| `nextcloud_username` | NC Username | `push_registration_store.dart` (**neu**) | **AppDelegate** (Basic-Auth) |
|
||||||
| `nextcloud_base_url` | z.B. `https://cloud.marianum-fulda.de` | `push_registration_store.dart` (**neu**) | **AppDelegate** (OCS-URL) |
|
| `nextcloud_base_url` | z.B. `https://cloud.marianum-fulda.de` | `push_registration_store.dart` (**neu**) | **AppDelegate** (OCS-URL) |
|
||||||
|
|
||||||
> `username` und `password` (Realpasswort) liegen in AccountDatas **Default**-Storage
|
> `username` und `password` (Realpasswort) liegen im **Default**-Storage des SessionManagers
|
||||||
> **ohne** `groupId` → nur im primären Access-Group der App, **für die NSE
|
> **ohne** `groupId` → nur im primären Access-Group der App, **für die NSE
|
||||||
> unsichtbar**. Deshalb werden `nextcloud_username` + `nextcloud_base_url` bei
|
> unsichtbar**. Deshalb werden `nextcloud_username` + `nextcloud_base_url` bei
|
||||||
> jeder `register()`-Runde zusätzlich **group-scoped** geschrieben. Das
|
> jeder `register()`-Runde zusätzlich **group-scoped** geschrieben. Das
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import '../../../model/account_data.dart';
|
import '../../../session/session_manager.dart';
|
||||||
import '../../marianumcloud/talk/chat/get_chat_response.dart';
|
import '../../marianumcloud/talk/chat/get_chat_response.dart';
|
||||||
import '../../marianumcloud/talk/room/get_room_response.dart';
|
import '../../marianumcloud/talk/room/get_room_response.dart';
|
||||||
import '../demo_persona.dart';
|
import '../demo_persona.dart';
|
||||||
@@ -204,7 +204,8 @@ class DemoTalk {
|
|||||||
id: base + 2,
|
id: base + 2,
|
||||||
token: token,
|
token: token,
|
||||||
ago: const Duration(days: 1, hours: 6),
|
ago: const Duration(days: 1, hours: 6),
|
||||||
message: 'Danke! Können wir Aufgabe 5 nächste Stunde nochmal besprechen?',
|
message:
|
||||||
|
'Danke! Können wir Aufgabe 5 nächste Stunde nochmal besprechen?',
|
||||||
),
|
),
|
||||||
_msg(
|
_msg(
|
||||||
id: base + 3,
|
id: base + 3,
|
||||||
@@ -366,7 +367,7 @@ class DemoTalk {
|
|||||||
}) => _msg(
|
}) => _msg(
|
||||||
id: id,
|
id: id,
|
||||||
token: token,
|
token: token,
|
||||||
actor: AccountData().getUsername(),
|
actor: SessionManager().requireNextcloud().username,
|
||||||
display: DemoPersona.studentName,
|
display: DemoPersona.studentName,
|
||||||
ago: ago,
|
ago: ago,
|
||||||
message: message,
|
message: message,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import '../../model/account_data.dart';
|
import '../../session/session_manager.dart';
|
||||||
|
|
||||||
/// Central switch for the client-side demo mode.
|
/// Central switch for the client-side demo mode.
|
||||||
///
|
///
|
||||||
@@ -8,7 +8,7 @@ import '../../model/account_data.dart';
|
|||||||
/// Play reviewers and automated screenshot runs see a fully populated app
|
/// Play reviewers and automated screenshot runs see a fully populated app
|
||||||
/// without a real account and without any network dependency.
|
/// without a real account and without any network dependency.
|
||||||
///
|
///
|
||||||
/// The flag is persisted through [AccountData.isDemo], so a demo session
|
/// The flag is persisted through [Session.isDemo], so a demo session
|
||||||
/// survives cold starts exactly like a normal login. It works in release builds
|
/// survives cold starts exactly like a normal login. It works in release builds
|
||||||
/// too — reviewers run the shipped release build.
|
/// too — reviewers run the shipped release build.
|
||||||
class DemoMode {
|
class DemoMode {
|
||||||
@@ -23,5 +23,5 @@ class DemoMode {
|
|||||||
username.trim().toLowerCase().startsWith(usernamePrefix);
|
username.trim().toLowerCase().startsWith(usernamePrefix);
|
||||||
|
|
||||||
/// True while the active session is a demo session.
|
/// True while the active session is a demo session.
|
||||||
static bool get active => AccountData().isDemo;
|
static bool get active => SessionManager().isDemo;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:dio/dio.dart';
|
|||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:nextcloud/nextcloud.dart';
|
import 'package:nextcloud/nextcloud.dart';
|
||||||
|
|
||||||
|
import '../../session/session.dart';
|
||||||
import '../api_error.dart';
|
import '../api_error.dart';
|
||||||
import '../http_errors.dart';
|
import '../http_errors.dart';
|
||||||
import '../marianumcloud/talk/talk_error.dart';
|
import '../marianumcloud/talk/talk_error.dart';
|
||||||
@@ -61,7 +62,9 @@ AppException? _dioToAppException(DioException error) {
|
|||||||
AppException _dynamiteToAppException(DynamiteApiException error) {
|
AppException _dynamiteToAppException(DynamiteApiException error) {
|
||||||
final status = error.statusCode;
|
final status = error.statusCode;
|
||||||
final preview = previewBody(error.body);
|
final preview = previewBody(error.body);
|
||||||
final detail = preview.isEmpty ? 'HTTP $status' : 'HTTP $status body=$preview';
|
final detail = preview.isEmpty
|
||||||
|
? 'HTTP $status'
|
||||||
|
: 'HTTP $status body=$preview';
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 401:
|
case 401:
|
||||||
return AuthException.unauthorized(technicalDetails: detail);
|
return AuthException.unauthorized(technicalDetails: detail);
|
||||||
@@ -86,6 +89,9 @@ String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) {
|
|||||||
if (error is AppException) return error.userMessage;
|
if (error is AppException) return error.userMessage;
|
||||||
|
|
||||||
if (error is TalkError) return TalkException(error).userMessage;
|
if (error is TalkError) return TalkException(error).userMessage;
|
||||||
|
if (error is NextcloudUnavailableException) {
|
||||||
|
return 'Diese Funktion ist mit deinem Konto nicht verfügbar.';
|
||||||
|
}
|
||||||
|
|
||||||
if (error is DioException) {
|
if (error is DioException) {
|
||||||
final mapped = _dioToAppException(error);
|
final mapped = _dioToAppException(error);
|
||||||
@@ -136,6 +142,7 @@ String? errorToTechnicalDetails(Object? error) {
|
|||||||
bool errorAllowsRetry(Object? error) {
|
bool errorAllowsRetry(Object? error) {
|
||||||
if (error == null) return true;
|
if (error == null) return true;
|
||||||
if (error is AppException) return error.allowRetry;
|
if (error is AppException) return error.allowRetry;
|
||||||
|
if (error is NextcloudUnavailableException) return false;
|
||||||
if (error is DioException) {
|
if (error is DioException) {
|
||||||
final mapped = _dioToAppException(error);
|
final mapped = _dioToAppException(error);
|
||||||
if (mapped != null) return mapped.allowRetry;
|
if (mapped != null) return mapped.allowRetry;
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import 'dart:convert';
|
|||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
import '../../../model/account_data.dart';
|
import '../../../session/session_manager.dart';
|
||||||
import '../../http_errors.dart';
|
import '../../http_errors.dart';
|
||||||
import '../nextcloud_ocs.dart';
|
import '../nextcloud_ocs.dart';
|
||||||
|
|
||||||
/// Exchanges the user's real Nextcloud password for a scoped app password via
|
/// Exchanges the user's real Nextcloud password for a scoped app password via
|
||||||
/// `GET /ocs/v2.php/core/getapppassword`. All subsequent Nextcloud calls then
|
/// `GET /ocs/v2.php/core/getapppassword`. All subsequent Nextcloud calls then
|
||||||
/// authenticate with the app password (see [AccountData.getBasicAuthHeader]),
|
/// authenticate with the app password (see [NextcloudCredentials.basicAuthHeader]),
|
||||||
/// which is what the push-v2 registration binds to.
|
/// which is what the push-v2 registration binds to.
|
||||||
///
|
///
|
||||||
/// Must authenticate with the *real* password — an app password cannot mint
|
/// Must authenticate with the *real* password — an app password cannot mint
|
||||||
@@ -33,7 +33,9 @@ class GetAppPassword {
|
|||||||
// Deliberately NOT the shared Authorization value: that one prefers
|
// Deliberately NOT the shared Authorization value: that one prefers
|
||||||
// the app password, but an app password cannot mint another one —
|
// the app password, but an app password cannot mint another one —
|
||||||
// this endpoint requires the real password.
|
// this endpoint requires the real password.
|
||||||
'Authorization': AccountData().getRealPasswordBasicAuthHeader(),
|
'Authorization': SessionManager()
|
||||||
|
.requireNextcloud()
|
||||||
|
.realPasswordBasicAuthHeader,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
))!;
|
))!;
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import 'dart:typed_data';
|
|||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
import '../../../model/account_data.dart';
|
|
||||||
import '../../../model/endpoint_data.dart';
|
import '../../../model/endpoint_data.dart';
|
||||||
|
import '../../../session/session_manager.dart';
|
||||||
import '../../errors/parse_exception.dart';
|
import '../../errors/parse_exception.dart';
|
||||||
import '../../http_errors.dart';
|
import '../../http_errors.dart';
|
||||||
import '../nextcloud_ocs.dart';
|
import '../nextcloud_ocs.dart';
|
||||||
@@ -27,12 +27,12 @@ Uri _coreAvatarUri() {
|
|||||||
return Uri.https(endpoint.domain, '${endpoint.path}/avatar/');
|
return Uri.https(endpoint.domain, '${endpoint.path}/avatar/');
|
||||||
}
|
}
|
||||||
|
|
||||||
Uri _userInfoUri() =>
|
Uri _userInfoUri() => NextcloudOcs.uri(
|
||||||
NextcloudOcs.uri('cloud/users/${AccountData().getUsername()}');
|
'cloud/users/${SessionManager().requireNextcloud().username}',
|
||||||
|
);
|
||||||
|
|
||||||
Future<http.Response> _send(
|
Future<http.Response> _send(
|
||||||
Future<http.Response> Function(Uri uri, Map<String, String> headers)
|
Future<http.Response> Function(Uri uri, Map<String, String> headers) perform,
|
||||||
perform,
|
|
||||||
Uri uri,
|
Uri uri,
|
||||||
) async {
|
) async {
|
||||||
final headers = NextcloudOcs.headers();
|
final headers = NextcloudOcs.headers();
|
||||||
@@ -98,16 +98,13 @@ class GetUserInfo {
|
|||||||
try {
|
try {
|
||||||
final root = jsonDecode(response.body) as Map<String, dynamic>;
|
final root = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
final data =
|
final data =
|
||||||
(root['ocs'] as Map<String, dynamic>)['data']
|
(root['ocs'] as Map<String, dynamic>)['data'] as Map<String, dynamic>;
|
||||||
as Map<String, dynamic>;
|
|
||||||
return CloudUserInfo(
|
return CloudUserInfo(
|
||||||
userId: data['id'] as String,
|
userId: data['id'] as String,
|
||||||
displayName: (data['displayname'] as String?) ?? '',
|
displayName: (data['displayname'] as String?) ?? '',
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw ParseException(
|
throw ParseException(technicalDetails: 'Cloud $uri user info parse: $e');
|
||||||
technicalDetails: 'Cloud $uri user info parse: $e',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import '../../model/account_data.dart';
|
|
||||||
import '../../model/endpoint_data.dart';
|
import '../../model/endpoint_data.dart';
|
||||||
|
import '../../session/session_manager.dart';
|
||||||
|
|
||||||
/// Shared headers and URI builder for Nextcloud OCS v2 endpoints. Used by
|
/// Shared headers and URI builder for Nextcloud OCS v2 endpoints. Used by
|
||||||
/// TalkApi, AutocompleteApi, FileSharingApi.
|
/// TalkApi, AutocompleteApi, FileSharingApi.
|
||||||
@@ -16,7 +16,7 @@ class NextcloudOcs {
|
|||||||
static Map<String, String> headers() => {
|
static Map<String, String> headers() => {
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
'OCS-APIRequest': 'true',
|
'OCS-APIRequest': 'true',
|
||||||
'Authorization': AccountData().getBasicAuthHeader(),
|
'Authorization': SessionManager().requireNextcloud().basicAuthHeader,
|
||||||
};
|
};
|
||||||
|
|
||||||
static Uri uri(String pathSuffix, {Map<String, dynamic>? queryParameters}) {
|
static Uri uri(String pathSuffix, {Map<String, dynamic>? queryParameters}) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'package:nextcloud/nextcloud.dart';
|
import 'package:nextcloud/nextcloud.dart';
|
||||||
|
|
||||||
import '../../../model/account_data.dart';
|
|
||||||
import '../../../model/endpoint_data.dart';
|
import '../../../model/endpoint_data.dart';
|
||||||
|
import '../../../session/session_manager.dart';
|
||||||
import '../../api_response.dart';
|
import '../../api_response.dart';
|
||||||
|
|
||||||
abstract class WebdavApi<T> {
|
abstract class WebdavApi<T> {
|
||||||
@@ -20,8 +20,8 @@ abstract class WebdavApi<T> {
|
|||||||
static Future<WebDavClient> get webdav {
|
static Future<WebDavClient> get webdav {
|
||||||
// Keyed by user too: two accounts may share a password (no app password
|
// Keyed by user too: two accounts may share a password (no app password
|
||||||
// minted), and the client would keep the previous login name.
|
// minted), and the client would keep the previous login name.
|
||||||
final secret =
|
final nextcloud = SessionManager().requireNextcloud();
|
||||||
'${AccountData().getUsername()}:${AccountData().getNextcloudSecret()}';
|
final secret = '${nextcloud.username}:${nextcloud.secret}';
|
||||||
if (_webdav == null || _webdavSecret != secret) {
|
if (_webdav == null || _webdavSecret != secret) {
|
||||||
_webdavSecret = secret;
|
_webdavSecret = secret;
|
||||||
_webdav = establishWebdavConnection();
|
_webdav = establishWebdavConnection();
|
||||||
@@ -33,13 +33,13 @@ abstract class WebdavApi<T> {
|
|||||||
NextcloudClient(
|
NextcloudClient(
|
||||||
Uri.parse('https://${EndpointData().nextcloud().full()}'),
|
Uri.parse('https://${EndpointData().nextcloud().full()}'),
|
||||||
// App password preferred — with 2FA the real password is not accepted
|
// App password preferred — with 2FA the real password is not accepted
|
||||||
// by Nextcloud at all (see AccountData.usesLoginFlow).
|
// by Nextcloud at all (see NextcloudCredentials.usesLoginFlow).
|
||||||
password: AccountData().getNextcloudSecret(),
|
password: SessionManager().requireNextcloud().secret,
|
||||||
loginName: AccountData().getUsername(),
|
loginName: SessionManager().requireNextcloud().username,
|
||||||
).webdav;
|
).webdav;
|
||||||
|
|
||||||
/// Builds the WebDAV download URL without embedded credentials. Callers must
|
/// Builds the WebDAV download URL without embedded credentials. Callers must
|
||||||
/// authenticate via the [AccountData.authHeaders] header instead.
|
/// authenticate via the [NextcloudCredentials.authHeaders] header instead.
|
||||||
static String buildWebdavUrl() =>
|
static String buildWebdavUrl() =>
|
||||||
'https://${EndpointData().nextcloud().full()}/remote.php/dav/files/${AccountData().getUsername()}/';
|
'https://${EndpointData().nextcloud().full()}/remote.php/dav/files/${SessionManager().requireNextcloud().username}/';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
import '../../../model/account_data.dart';
|
import '../../../session/session.dart';
|
||||||
|
import '../../../session/session_manager.dart';
|
||||||
import '../queries/auth_login/auth_login.dart';
|
import '../queries/auth_login/auth_login.dart';
|
||||||
import 'device_token_name.dart';
|
import 'device_token_name.dart';
|
||||||
import 'token_storage.dart';
|
import 'token_storage.dart';
|
||||||
|
|
||||||
/// Adds the bearer token to outgoing Marianum-Connect requests and, on 401,
|
/// Adds the bearer token to outgoing Marianum-Connect requests and, on 401,
|
||||||
/// re-logs in once with the credentials in [AccountData] before retrying.
|
/// renews the token once before retrying.
|
||||||
class MarianumConnectAuthInterceptor extends Interceptor {
|
class MarianumConnectAuthInterceptor extends Interceptor {
|
||||||
static const _retriedKey = 'mc_auth_retried';
|
static const _retriedKey = 'mc_auth_retried';
|
||||||
|
|
||||||
@@ -87,25 +88,26 @@ class MarianumConnectAuthInterceptor extends Interceptor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> _performReLogin() async {
|
Future<bool> _performReLogin() async {
|
||||||
if (!AccountData().isPopulated()) return false;
|
final session = SessionManager().current;
|
||||||
final username = AccountData().getUsername();
|
if (session is! CredentialSession) return false;
|
||||||
|
final username = session.username;
|
||||||
// A background engine (widget task) keeps the account it loaded. When
|
// A background engine (widget task) keeps the account it loaded. When
|
||||||
// the app signed that account out, its revoked token answers 401 — a
|
// the app signed that account out, its revoked token answers 401 — a
|
||||||
// re-login would mint a fresh token for it into the shared keystore.
|
// re-login would mint a fresh token for it into the shared keystore.
|
||||||
if (await AccountData().readStoredUsername() != username) return false;
|
if (await SessionManager().readStoredUsername() != username) return false;
|
||||||
try {
|
try {
|
||||||
await _loginClient.run(
|
await _loginClient.run(
|
||||||
username: username,
|
username: username,
|
||||||
password: AccountData().getPassword(),
|
password: session.password,
|
||||||
tokenName: await DeviceTokenName.resolve(),
|
tokenName: await DeviceTokenName.resolve(),
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
if (await AccountData().readStoredUsername() == username) {
|
if (await SessionManager().readStoredUsername() == username) {
|
||||||
await _tokenStorage.clear();
|
await _tokenStorage.clear();
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
final stored = await AccountData().readStoredUsername();
|
final stored = await SessionManager().readStoredUsername();
|
||||||
if (stored == username) return true;
|
if (stored == username) return true;
|
||||||
// Signed out during the login: drop the orphaned token, unless another
|
// Signed out during the login: drop the orphaned token, unless another
|
||||||
// account already stored its own.
|
// account already stored its own.
|
||||||
|
|||||||
@@ -1,39 +1,43 @@
|
|||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
|
|
||||||
import '../../../model/account_data.dart';
|
import '../../../session/session.dart';
|
||||||
|
import '../../../session/session_lifecycle.dart';
|
||||||
|
import '../../../session/session_manager.dart';
|
||||||
import '../../errors/auth_exception.dart';
|
import '../../errors/auth_exception.dart';
|
||||||
import '../queries/auth_logout/auth_logout.dart';
|
|
||||||
import '../queries/auth_verify/auth_verify.dart';
|
import '../queries/auth_verify/auth_verify.dart';
|
||||||
import 'token_storage.dart';
|
|
||||||
|
|
||||||
/// Background credential probe — a server-side password rotation forces a
|
/// Credential probe. A server-side password rotation forces a re-login on the
|
||||||
/// re-login on the next cold start even when the bearer token would still
|
/// next cold start even when the bearer token would still be accepted.
|
||||||
/// be accepted.
|
|
||||||
class SessionValidator {
|
class SessionValidator {
|
||||||
static Future<void> probeStored({
|
static Future<void> probeStored({
|
||||||
required Future<void> Function() onInvalidated,
|
required Future<void> Function() onInvalidated,
|
||||||
}) async {
|
}) async {
|
||||||
if (!AccountData().isPopulated()) return;
|
final session = SessionManager().current;
|
||||||
// AuthVerify uses its own dio (bypassing the demo interceptor), so a demo
|
// The probes use their own dio (bypassing the demo interceptor), so a demo
|
||||||
// session must be skipped here or its missing token would 401 into a logout.
|
// session must be skipped or its missing token would 401 into a logout.
|
||||||
if (AccountData().isDemo) return;
|
if (session == null || session.isDemo) return;
|
||||||
final username = AccountData().getUsername();
|
final epoch = SessionManager().sessionEpoch;
|
||||||
final password = AccountData().getPassword();
|
|
||||||
final epoch = AccountData().sessionEpoch;
|
|
||||||
try {
|
try {
|
||||||
await AuthVerify().run(username: username, password: password);
|
switch (session) {
|
||||||
|
case CredentialSession(:final username, :final password):
|
||||||
|
await AuthVerify().run(username: username, password: password);
|
||||||
|
}
|
||||||
} on AuthException catch (e) {
|
} on AuthException catch (e) {
|
||||||
if (e.statusCode != 401) return;
|
if (e.statusCode != 401) return;
|
||||||
// The probed account already signed out; the 401 must not sign out
|
// The probed account already signed out; the 401 must not sign out
|
||||||
// whoever logged in meanwhile.
|
// whoever logged in meanwhile.
|
||||||
if (!AccountData().isCurrentSession(epoch)) return;
|
if (!SessionManager().isCurrentSession(epoch)) return;
|
||||||
log('MC: stored credentials rejected — forcing re-login');
|
log('MC: stored session rejected — forcing re-login');
|
||||||
await AuthLogout().run();
|
await SessionLifecycle.signOut(
|
||||||
await const MarianumConnectTokenStorage().clear();
|
notice: switch (session) {
|
||||||
await AccountData().removeData();
|
CredentialSession() =>
|
||||||
|
'Deine Zugangsdaten wurden vom Server abgelehnt. Vermutlich '
|
||||||
|
'wurde dein Passwort geändert. Bitte melde dich erneut an.',
|
||||||
|
},
|
||||||
|
);
|
||||||
await onInvalidated();
|
await onInvalidated();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log('MC: background credential check failed (transient): $e');
|
log('MC: background session check failed (transient): $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
|
||||||
|
import '../../errors/auth_exception.dart';
|
||||||
|
|
||||||
/// `first_unlock` accessibility so the token can be read during background
|
/// `first_unlock` accessibility so the token can be read during background
|
||||||
/// requests (telemetry heartbeat, push-triggered syncs) after the first device
|
/// requests (telemetry heartbeat, push-triggered syncs) after the first device
|
||||||
/// unlock following a reboot. The keychain default (`whenUnlocked`) throws
|
/// unlock following a reboot. The keychain default (`whenUnlocked`) throws
|
||||||
@@ -9,7 +12,7 @@ const IOSOptions _mcIosOptions = IOSOptions(
|
|||||||
);
|
);
|
||||||
|
|
||||||
/// Persists the Marianum-Connect bearer token in the platform keystore. Kept
|
/// Persists the Marianum-Connect bearer token in the platform keystore. Kept
|
||||||
/// separate from `AccountData` because the username/password live on (Nextcloud
|
/// separate from `SessionManager` because the username/password live on (Nextcloud
|
||||||
/// + MHSL still need them) while the MC token is short-lived and per-endpoint.
|
/// + MHSL still need them) while the MC token is short-lived and per-endpoint.
|
||||||
class MarianumConnectTokenStorage {
|
class MarianumConnectTokenStorage {
|
||||||
static const _tokenKey = 'mc_bearer_token';
|
static const _tokenKey = 'mc_bearer_token';
|
||||||
@@ -24,6 +27,18 @@ class MarianumConnectTokenStorage {
|
|||||||
|
|
||||||
Future<String?> readToken() => _storage.read(key: _tokenKey);
|
Future<String?> readToken() => _storage.read(key: _tokenKey);
|
||||||
|
|
||||||
|
/// Request options carrying the stored token, for probes that bypass the
|
||||||
|
/// auth interceptor. Throws [AuthException] when no token is stored.
|
||||||
|
Future<Options> requireBearerOptions(String caller) async {
|
||||||
|
final token = await readToken();
|
||||||
|
if (token == null || token.isEmpty) {
|
||||||
|
throw AuthException.unauthorized(
|
||||||
|
technicalDetails: '$caller: no bearer token in storage',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Options(headers: {'Authorization': 'Bearer $token'});
|
||||||
|
}
|
||||||
|
|
||||||
Future<String?> readTokenId() => _storage.read(key: _tokenIdKey);
|
Future<String?> readTokenId() => _storage.read(key: _tokenIdKey);
|
||||||
|
|
||||||
Future<DateTime?> readExpiresAt() async {
|
Future<DateTime?> readExpiresAt() async {
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import '../errors/app_exception.dart';
|
||||||
import 'errors/marianumconnect_error.dart';
|
import 'errors/marianumconnect_error.dart';
|
||||||
import 'marianumconnect_api.dart';
|
import 'marianumconnect_api.dart';
|
||||||
import 'marianumconnect_endpoint.dart';
|
import 'marianumconnect_endpoint.dart';
|
||||||
@@ -23,10 +26,14 @@ abstract class MarianumConnectQuery {
|
|||||||
try {
|
try {
|
||||||
return await body();
|
return await body();
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
throw mapMarianumConnectError(e);
|
throw mapError(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The AppException a failed call surfaces as. Query families with domain
|
||||||
|
/// error codes override this instead of re-implementing [guard].
|
||||||
|
AppException mapError(DioException error) => mapMarianumConnectError(error);
|
||||||
|
|
||||||
/// GETs [path] and parses the JSON object body with [fromJson].
|
/// GETs [path] and parses the JSON object body with [fromJson].
|
||||||
Future<T> getObject<T>(
|
Future<T> getObject<T>(
|
||||||
String path,
|
String path,
|
||||||
@@ -55,6 +62,16 @@ abstract class MarianumConnectQuery {
|
|||||||
.toList();
|
.toList();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// GETs the raw bytes of [path] (files that need the bearer token).
|
||||||
|
Future<Uint8List> getBytes(String path) => guard(() async {
|
||||||
|
final response = await dio.get<List<int>>(
|
||||||
|
endpoint(path),
|
||||||
|
options: Options(responseType: ResponseType.bytes),
|
||||||
|
);
|
||||||
|
final bytes = response.data!;
|
||||||
|
return bytes is Uint8List ? bytes : Uint8List.fromList(bytes);
|
||||||
|
});
|
||||||
|
|
||||||
/// Formats [d] as an ISO `yyyy-MM-dd` day for MarianumConnect query params.
|
/// Formats [d] as an ISO `yyyy-MM-dd` day for MarianumConnect query params.
|
||||||
String isoDate(DateTime d) =>
|
String isoDate(DateTime d) =>
|
||||||
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
|
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
|
||||||
|
|||||||
@@ -29,17 +29,12 @@ class AuthVerify extends MarianumConnectQuery {
|
|||||||
required String username,
|
required String username,
|
||||||
required String password,
|
required String password,
|
||||||
}) async {
|
}) async {
|
||||||
final token = await _tokenStorage.readToken();
|
final options = await _tokenStorage.requireBearerOptions('AuthVerify');
|
||||||
if (token == null || token.isEmpty) {
|
|
||||||
throw AuthException.unauthorized(
|
|
||||||
technicalDetails: 'AuthVerify: no bearer token in storage',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return guard(() async {
|
return guard(() async {
|
||||||
await dio.post<void>(
|
await dio.post<void>(
|
||||||
endpoint('auth/verify'),
|
endpoint('auth/verify'),
|
||||||
data: {'username': username, 'password': password},
|
data: {'username': username, 'password': password},
|
||||||
options: Options(headers: {'Authorization': 'Bearer $token'}),
|
options: options,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
|
||||||
|
|
||||||
import '../../marianumconnect_query.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
/// Downloads the raw PDF bytes of a Marianum Message from
|
/// Downloads the raw PDF bytes of a Marianum Message from
|
||||||
@@ -15,11 +13,6 @@ class GetNewsletterFile extends MarianumConnectQuery {
|
|||||||
|
|
||||||
GetNewsletterFile(this.id, {super.dio});
|
GetNewsletterFile(this.id, {super.dio});
|
||||||
|
|
||||||
Future<Uint8List> run() => guard(() async {
|
Future<Uint8List> run() =>
|
||||||
final response = await dio.get<List<int>>(
|
getBytes('newsletter/${Uri.encodeComponent(id)}/file');
|
||||||
endpoint('newsletter/${Uri.encodeComponent(id)}/file'),
|
|
||||||
options: Options(responseType: ResponseType.bytes),
|
|
||||||
);
|
|
||||||
return Uint8List.fromList(response.data!);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
|
||||||
|
|
||||||
import '../../marianumconnect_query.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
/// Downloads the raw bytes of a PROXIED_FILE ticker page from
|
/// Downloads the raw bytes of a PROXIED_FILE ticker page from
|
||||||
@@ -15,11 +13,6 @@ class GetTickerPageFile extends MarianumConnectQuery {
|
|||||||
|
|
||||||
GetTickerPageFile(this.slug, {super.dio});
|
GetTickerPageFile(this.slug, {super.dio});
|
||||||
|
|
||||||
Future<Uint8List> run() => guard(() async {
|
Future<Uint8List> run() =>
|
||||||
final response = await dio.get<List<int>>(
|
getBytes('ticker/pages/${Uri.encodeComponent(slug)}/file');
|
||||||
endpoint('ticker/pages/${Uri.encodeComponent(slug)}/file'),
|
|
||||||
options: Options(responseType: ResponseType.bytes),
|
|
||||||
);
|
|
||||||
return Uint8List.fromList(response.data!);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'dart:math';
|
|
||||||
|
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
|
||||||
|
import '../../../../utils/random_id.dart';
|
||||||
|
|
||||||
/// A stable, anonymous per-install identifier for telemetry. Generated once on
|
/// A stable, anonymous per-install identifier for telemetry. Generated once on
|
||||||
/// first use (128 bits from a cryptographic RNG) and persisted in the secure
|
/// first use (128 bits from a cryptographic RNG) and persisted in the secure
|
||||||
/// keystore, so a device stays a single row across password rotations and FCM
|
/// keystore, so a device stays a single row across password rotations and FCM
|
||||||
@@ -21,15 +21,9 @@ class TelemetryDeviceId {
|
|||||||
_cached = existing;
|
_cached = existing;
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
final generated = _generate();
|
final generated = randomHexId();
|
||||||
await _storage.write(key: _key, value: generated);
|
await _storage.write(key: _key, value: generated);
|
||||||
_cached = generated;
|
_cached = generated;
|
||||||
return generated;
|
return generated;
|
||||||
}
|
}
|
||||||
|
|
||||||
static String _generate() {
|
|
||||||
final random = Random.secure();
|
|
||||||
final bytes = List<int>.generate(16, (_) => random.nextInt(256));
|
|
||||||
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-8
@@ -2,8 +2,8 @@ import 'dart:developer';
|
|||||||
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import '../../../../model/account_data.dart';
|
import '../../../../session/session.dart';
|
||||||
import '../../../demo/demo_mode.dart';
|
import '../../../../session/session_manager.dart';
|
||||||
import '../../../mhsl/custom_timetable_event/get/get_custom_timetable_event.dart';
|
import '../../../mhsl/custom_timetable_event/get/get_custom_timetable_event.dart';
|
||||||
import '../../../mhsl/custom_timetable_event/get/get_custom_timetable_event_params.dart';
|
import '../../../mhsl/custom_timetable_event/get/get_custom_timetable_event_params.dart';
|
||||||
import '../../../mhsl/custom_timetable_event/remove/remove_custom_timetable_event.dart';
|
import '../../../mhsl/custom_timetable_event/remove/remove_custom_timetable_event.dart';
|
||||||
@@ -26,28 +26,32 @@ class CustomEventsMigration {
|
|||||||
const CustomEventsMigration._();
|
const CustomEventsMigration._();
|
||||||
|
|
||||||
static Future<void> runOnce() async {
|
static Future<void> runOnce() async {
|
||||||
if (DemoMode.active) return;
|
// Only password accounts can derive the legacy identity.
|
||||||
|
final session = SessionManager().current;
|
||||||
|
if (session is! CredentialSession || session.isDemo) return;
|
||||||
if (await _isDone()) return;
|
if (await _isDone()) return;
|
||||||
|
|
||||||
final epoch = AccountData().sessionEpoch;
|
final epoch = SessionManager().sessionEpoch;
|
||||||
try {
|
try {
|
||||||
final response = await GetCustomTimetableEvent(
|
final response = await GetCustomTimetableEvent(
|
||||||
GetCustomTimetableEventParams(AccountData().getUserSecret()),
|
GetCustomTimetableEventParams(session.legacyUserSecret),
|
||||||
).run();
|
).run();
|
||||||
|
|
||||||
for (final event in response.events) {
|
for (final event in response.events) {
|
||||||
// The POST authenticates with whoever is signed in now; after a
|
// The POST authenticates with whoever is signed in now; after a
|
||||||
// sign-out the previous account's events would land in the next one.
|
// sign-out the previous account's events would land in the next one.
|
||||||
if (!AccountData().isCurrentSession(epoch)) return;
|
if (!SessionManager().isCurrentSession(epoch)) return;
|
||||||
await TimetableCustomEventsAdd().run(event);
|
await TimetableCustomEventsAdd().run(event);
|
||||||
await RemoveCustomTimetableEvent(
|
await RemoveCustomTimetableEvent(
|
||||||
RemoveCustomTimetableEventParams(event.id),
|
RemoveCustomTimetableEventParams(event.id),
|
||||||
).run();
|
).run();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!AccountData().isCurrentSession(epoch)) return;
|
if (!SessionManager().isCurrentSession(epoch)) return;
|
||||||
await _markDone();
|
await _markDone();
|
||||||
log('Custom events migration: moved ${response.events.length} event(s) to Marianum-Connect.');
|
log(
|
||||||
|
'Custom events migration: moved ${response.events.length} event(s) to Marianum-Connect.',
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Leave the flag unset so the next launch retries; the delete-after-post
|
// Leave the flag unset so the next launch retries; the delete-after-post
|
||||||
// above keeps a partial run duplicate-free.
|
// above keeps a partial run duplicate-free.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import '../model/account_data.dart';
|
import '../session/session_manager.dart';
|
||||||
import 'api_response.dart';
|
import 'api_response.dart';
|
||||||
import 'cache_store.dart';
|
import 'cache_store.dart';
|
||||||
import 'errors/parse_exception.dart';
|
import 'errors/parse_exception.dart';
|
||||||
@@ -47,7 +47,7 @@ abstract class RequestCache<T extends ApiResponse?> {
|
|||||||
static void ignore(Exception e) {}
|
static void ignore(Exception e) {}
|
||||||
|
|
||||||
Future<void> start(String document) async {
|
Future<void> start(String document) async {
|
||||||
final epoch = AccountData().sessionEpoch;
|
final epoch = SessionManager().sessionEpoch;
|
||||||
try {
|
try {
|
||||||
final entry = await CacheStore.instance.read(document);
|
final entry = await CacheStore.instance.read(document);
|
||||||
var lastUpdate = entry?.lastUpdate ?? 0;
|
var lastUpdate = entry?.lastUpdate ?? 0;
|
||||||
@@ -85,7 +85,7 @@ abstract class RequestCache<T extends ApiResponse?> {
|
|||||||
final newValue = await onLoad();
|
final newValue = await onLoad();
|
||||||
// The cache is shared, so a late response of a signed-out
|
// The cache is shared, so a late response of a signed-out
|
||||||
// account would otherwise be cached for the next one.
|
// account would otherwise be cached for the next one.
|
||||||
if (!AccountData().isCurrentSession(epoch)) {
|
if (!SessionManager().isCurrentSession(epoch)) {
|
||||||
onError(const StaleSessionException());
|
onError(const StaleSessionException());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -10,7 +10,6 @@ import 'api/marianumconnect/marianumconnect_api.dart';
|
|||||||
import 'api/marianumconnect/queries/get_breakers/get_breakers_response.dart';
|
import 'api/marianumconnect/queries/get_breakers/get_breakers_response.dart';
|
||||||
import 'api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart';
|
import 'api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart';
|
||||||
import 'main.dart';
|
import 'main.dart';
|
||||||
import 'model/account_data.dart';
|
|
||||||
import 'model/data_cleaner.dart';
|
import 'model/data_cleaner.dart';
|
||||||
import 'notification/notification_controller.dart';
|
import 'notification/notification_controller.dart';
|
||||||
import 'notification/notification_service.dart';
|
import 'notification/notification_service.dart';
|
||||||
@@ -18,6 +17,7 @@ import 'notification/notification_tasks.dart';
|
|||||||
import 'push/push_registration.dart';
|
import 'push/push_registration.dart';
|
||||||
import 'push/push_tap_router.dart';
|
import 'push/push_tap_router.dart';
|
||||||
import 'routing/app_routes.dart';
|
import 'routing/app_routes.dart';
|
||||||
|
import 'session/session_manager.dart';
|
||||||
import 'share_intent/share_intent_listener.dart';
|
import 'share_intent/share_intent_listener.dart';
|
||||||
import 'state/app/modules/app_modules.dart';
|
import 'state/app/modules/app_modules.dart';
|
||||||
import 'state/app/modules/breaker/bloc/breaker_bloc.dart';
|
import 'state/app/modules/breaker/bloc/breaker_bloc.dart';
|
||||||
@@ -174,7 +174,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||||||
_timetableWidgetSync = timetable.stream.listen((state) {
|
_timetableWidgetSync = timetable.stream.listen((state) {
|
||||||
final data = state.data;
|
final data = state.data;
|
||||||
if (data is TimetableState && !state.isLoading) {
|
if (data is TimetableState && !state.isLoading) {
|
||||||
final epoch = AccountData().sessionEpoch;
|
final epoch = SessionManager().sessionEpoch;
|
||||||
Debouncer.debounce(
|
Debouncer.debounce(
|
||||||
'widgetPublish',
|
'widgetPublish',
|
||||||
const Duration(seconds: 1),
|
const Duration(seconds: 1),
|
||||||
@@ -194,7 +194,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||||||
// frames are done (a fresh bloc emit in the meantime supersedes it).
|
// frames are done (a fresh bloc emit in the meantime supersedes it).
|
||||||
final initialData = timetable.state.data;
|
final initialData = timetable.state.data;
|
||||||
if (initialData is TimetableState) {
|
if (initialData is TimetableState) {
|
||||||
final epoch = AccountData().sessionEpoch;
|
final epoch = SessionManager().sessionEpoch;
|
||||||
Debouncer.debounce(
|
Debouncer.debounce(
|
||||||
'widgetPublish',
|
'widgetPublish',
|
||||||
const Duration(seconds: 3),
|
const Duration(seconds: 3),
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ import '../api/marianumconnect/queries/timetable_get_week/timetable_get_week.dar
|
|||||||
import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event.dart';
|
import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event.dart';
|
||||||
import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_params.dart';
|
import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_params.dart';
|
||||||
import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart';
|
import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart';
|
||||||
import '../model/account_data.dart';
|
import '../session/session.dart';
|
||||||
|
import '../session/session_manager.dart';
|
||||||
import '../widget_data/widget_data_mapper.dart';
|
import '../widget_data/widget_data_mapper.dart';
|
||||||
import '../widget_data/widget_publisher.dart';
|
import '../widget_data/widget_publisher.dart';
|
||||||
import '../widget_data/widget_sync.dart';
|
import '../widget_data/widget_sync.dart';
|
||||||
@@ -81,17 +82,17 @@ class WidgetBackgroundTask {
|
|||||||
/// Throws on fetch failure so the worker path can signal a retry.
|
/// Throws on fetch failure so the worker path can signal a retry.
|
||||||
static Future<void> runRefreshNow({bool force = false}) async {
|
static Future<void> runRefreshNow({bool force = false}) async {
|
||||||
await WidgetSync.ensureInitialized();
|
await WidgetSync.ensureInitialized();
|
||||||
bool populated;
|
Session? session;
|
||||||
try {
|
try {
|
||||||
// Bounded: a hanging keystore read must not stall the caller's budget
|
// Bounded: a hanging keystore read must not stall the caller's budget
|
||||||
// (FCM handler ~25s on iOS) forever.
|
// (FCM handler ~25s on iOS) forever.
|
||||||
populated = await AccountData().waitForPopulation().timeout(
|
session = await SessionManager().waitForLoad().timeout(
|
||||||
const Duration(seconds: 10),
|
const Duration(seconds: 10),
|
||||||
);
|
);
|
||||||
} on TimeoutException {
|
} on TimeoutException {
|
||||||
populated = false;
|
session = null;
|
||||||
}
|
}
|
||||||
if (!populated) {
|
if (session == null) {
|
||||||
// Deliberately does NOT flip the widget to logged-out: a failed or slow
|
// Deliberately does NOT flip the widget to logged-out: a failed or slow
|
||||||
// keychain read (locked iOS device during the 06:00 silent push) is
|
// keychain read (locked iOS device during the 06:00 silent push) is
|
||||||
// indistinguishable from "never logged in" here, and blanking the
|
// indistinguishable from "never logged in" here, and blanking the
|
||||||
@@ -105,7 +106,7 @@ class WidgetBackgroundTask {
|
|||||||
log('[widget-refresh] snapshot is fresh, skipping refresh');
|
log('[widget-refresh] snapshot is fresh, skipping refresh');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await _refresh();
|
await _refresh(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> cancelAll() async {
|
static Future<void> cancelAll() async {
|
||||||
@@ -142,7 +143,7 @@ void _callbackDispatcher() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _refresh() async {
|
Future<void> _refresh(Session session) async {
|
||||||
await WidgetSync.ensureInitialized();
|
await WidgetSync.ensureInitialized();
|
||||||
// The background isolate doesn't go through main.dart's BlocBuilder, so we
|
// The background isolate doesn't go through main.dart's BlocBuilder, so we
|
||||||
// re-apply the endpoint the foreground last persisted. Without this the
|
// re-apply the endpoint the foreground last persisted. Without this the
|
||||||
@@ -181,11 +182,14 @@ Future<void> _refresh() async {
|
|||||||
final timegridFuture = _runOrNull<TimetableGetTimegridResponse>(
|
final timegridFuture = _runOrNull<TimetableGetTimegridResponse>(
|
||||||
() => TimetableGetTimegrid().run(),
|
() => TimetableGetTimegrid().run(),
|
||||||
);
|
);
|
||||||
final customEventsFuture = _runOrNull<GetCustomTimetableEventResponse>(
|
final customEventsFuture = switch (session) {
|
||||||
() => GetCustomTimetableEvent(
|
CredentialSession(:final legacyUserSecret) =>
|
||||||
GetCustomTimetableEventParams(AccountData().getUserSecret()),
|
_runOrNull<GetCustomTimetableEventResponse>(
|
||||||
).run(),
|
() => GetCustomTimetableEvent(
|
||||||
);
|
GetCustomTimetableEventParams(legacyUserSecret),
|
||||||
|
).run(),
|
||||||
|
),
|
||||||
|
};
|
||||||
final timetable = await timetableFuture;
|
final timetable = await timetableFuture;
|
||||||
final subjects = await subjectsFuture;
|
final subjects = await subjectsFuture;
|
||||||
final rooms = await roomsFuture;
|
final rooms = await roomsFuture;
|
||||||
|
|||||||
+14
-12
@@ -26,7 +26,6 @@ import 'api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart
|
|||||||
import 'app.dart';
|
import 'app.dart';
|
||||||
import 'background/widget_background_task.dart';
|
import 'background/widget_background_task.dart';
|
||||||
import 'firebase_options.dart';
|
import 'firebase_options.dart';
|
||||||
import 'model/account_data.dart';
|
|
||||||
import 'model/session_wipe.dart';
|
import 'model/session_wipe.dart';
|
||||||
import 'notification/notification_service.dart';
|
import 'notification/notification_service.dart';
|
||||||
import 'push/push_message_handler.dart';
|
import 'push/push_message_handler.dart';
|
||||||
@@ -34,6 +33,7 @@ import 'push/push_registration.dart';
|
|||||||
import 'push/push_registration_store.dart';
|
import 'push/push_registration_store.dart';
|
||||||
import 'push/push_renderer.dart';
|
import 'push/push_renderer.dart';
|
||||||
import 'routing/app_routes.dart';
|
import 'routing/app_routes.dart';
|
||||||
|
import 'session/session_manager.dart';
|
||||||
import 'share_intent/share_intent_listener.dart';
|
import 'share_intent/share_intent_listener.dart';
|
||||||
import 'state/app/modules/account/bloc/account_bloc.dart';
|
import 'state/app/modules/account/bloc/account_bloc.dart';
|
||||||
import 'state/app/modules/account/bloc/account_state.dart';
|
import 'state/app/modules/account/bloc/account_state.dart';
|
||||||
@@ -119,7 +119,7 @@ void _installErrorHandlers() {
|
|||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
log('MarianumMobile started');
|
log('MarianumMobile started');
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
AccountData().markUiEngine();
|
SessionManager().markUiEngine();
|
||||||
// Before any initialisation so startup failures reach the backend too.
|
// Before any initialisation so startup failures reach the backend too.
|
||||||
_installErrorHandlers();
|
_installErrorHandlers();
|
||||||
|
|
||||||
@@ -152,12 +152,12 @@ Future<void> main() async {
|
|||||||
_startupStep('documents dir', () async {
|
_startupStep('documents dir', () async {
|
||||||
AppPaths.documentsDir = (await getApplicationDocumentsDirectory()).path;
|
AppPaths.documentsDir = (await getApplicationDocumentsDirectory()).path;
|
||||||
}),
|
}),
|
||||||
// The keychain may still be locked right after device unlock; AccountData
|
// The keychain may still be locked right after device unlock; the session
|
||||||
// keeps retrying, so on timeout the app starts on the loading screen and
|
// keeps retrying, so on timeout the app starts on the loading screen and
|
||||||
// flips to the real state once the session is readable (see _MainState).
|
// flips to the real state once the session is readable (see _MainState).
|
||||||
_startupStep(
|
_startupStep(
|
||||||
'account data',
|
'account data',
|
||||||
AccountData().waitForPopulation,
|
SessionManager().waitForLoad,
|
||||||
timeout: const Duration(seconds: 5),
|
timeout: const Duration(seconds: 5),
|
||||||
// Expected on every background wake of a locked device; not an error.
|
// Expected on every background wake of a locked device; not an error.
|
||||||
report: false,
|
report: false,
|
||||||
@@ -224,7 +224,7 @@ Future<void> main() async {
|
|||||||
// has data ready by the time the user navigates to it. No-op when a
|
// has data ready by the time the user navigates to it. No-op when a
|
||||||
// cached payload is already present, so this does not undo the day-long
|
// cached payload is already present, so this does not undo the day-long
|
||||||
// root cache TTL.
|
// root cache TTL.
|
||||||
if (AccountData().isPopulated()) {
|
if (SessionManager().hasNextcloud) {
|
||||||
unawaited(
|
unawaited(
|
||||||
ListFilesCache.prefetchRootListing().onError(
|
ListFilesCache.prefetchRootListing().onError(
|
||||||
(e, _) => log('Files root prefetch failed: $e'),
|
(e, _) => log('Files root prefetch failed: $e'),
|
||||||
@@ -262,9 +262,9 @@ Future<void> main() async {
|
|||||||
}
|
}
|
||||||
|
|
||||||
AccountStatus _initialAccountStatus() {
|
AccountStatus _initialAccountStatus() {
|
||||||
final account = AccountData();
|
final session = SessionManager();
|
||||||
if (account.isPopulated()) return AccountStatus.loggedIn;
|
if (session.isSignedIn) return AccountStatus.loggedIn;
|
||||||
return account.isLoaded ? AccountStatus.loggedOut : AccountStatus.undefined;
|
return session.isLoaded ? AccountStatus.loggedOut : AccountStatus.undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
class Main extends StatefulWidget {
|
class Main extends StatefulWidget {
|
||||||
@@ -293,13 +293,13 @@ class _MainState extends State<Main> {
|
|||||||
Jiffy.setLocale('de');
|
Jiffy.setLocale('de');
|
||||||
_lastStatus = context.read<AccountBloc>().state.status;
|
_lastStatus = context.read<AccountBloc>().state.status;
|
||||||
|
|
||||||
AccountData().waitForPopulation().then((value) {
|
SessionManager().waitForLoad().then((session) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final accountBloc = context.read<AccountBloc>();
|
final accountBloc = context.read<AccountBloc>();
|
||||||
accountBloc.setStatus(
|
accountBloc.setStatus(
|
||||||
value ? AccountStatus.loggedIn : AccountStatus.loggedOut,
|
session != null ? AccountStatus.loggedIn : AccountStatus.loggedOut,
|
||||||
);
|
);
|
||||||
if (value) {
|
if (session != null) {
|
||||||
_scheduleSessionValidation(accountBloc);
|
_scheduleSessionValidation(accountBloc);
|
||||||
// Cold start while already logged in: the account status doesn't
|
// Cold start while already logged in: the account status doesn't
|
||||||
// change, so the loggedIn listener below never fires — refresh
|
// change, so the loggedIn listener below never fires — refresh
|
||||||
@@ -323,7 +323,9 @@ class _MainState extends State<Main> {
|
|||||||
void _prefetchBaseData(BuildContext context) {
|
void _prefetchBaseData(BuildContext context) {
|
||||||
context.read<TimetableBloc>().refresh();
|
context.read<TimetableBloc>().refresh();
|
||||||
unawaited(context.read<ChatListBloc>().refresh(silent: true));
|
unawaited(context.read<ChatListBloc>().refresh(silent: true));
|
||||||
unawaited(ListFilesCache.prefetchRootListing());
|
if (SessionManager().hasNextcloud) {
|
||||||
|
unawaited(ListFilesCache.prefetchRootListing());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Registers/self-heals the push subscription whenever the backend advertises
|
/// Registers/self-heals the push subscription whenever the backend advertises
|
||||||
|
|||||||
@@ -1,398 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:convert';
|
|
||||||
import 'dart:developer';
|
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:crypto/crypto.dart';
|
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
|
|
||||||
import '../push/push_secure_storage.dart';
|
|
||||||
import '../utils/exponential_backoff.dart';
|
|
||||||
|
|
||||||
class AccountData {
|
|
||||||
static const _usernameField = 'username';
|
|
||||||
static const _passwordField = 'password';
|
|
||||||
// App passwords live in the push-shared (group-scoped) keystore so the iOS
|
|
||||||
// Notification Service Extension can authenticate Nextcloud calls too.
|
|
||||||
// The talk password authenticates the second (apptype=talk) push
|
|
||||||
// registration — Nextcloud binds each push subscription to its session
|
|
||||||
// token, so two registrations need two app passwords.
|
|
||||||
static const _appPasswordField = 'nextcloud_app_password';
|
|
||||||
static const _appPasswordTalkField = 'nextcloud_app_password_talk';
|
|
||||||
// Marks accounts whose Nextcloud credentials came from Login Flow v2 (2FA):
|
|
||||||
// the real password is not valid against Nextcloud, only the flow-issued
|
|
||||||
// app password is — and no further app passwords can be minted silently.
|
|
||||||
static const _loginFlowField = 'nextcloud_login_flow';
|
|
||||||
// Persists the demo session across cold starts (see DemoMode).
|
|
||||||
static const _demoField = 'is_demo';
|
|
||||||
// Keeps isPopulated()/getPassword() valid; demo mode never uses a real one.
|
|
||||||
static const _demoPasswordPlaceholder = 'demo';
|
|
||||||
|
|
||||||
// `first_unlock` so a background launch on a locked device (silent push,
|
|
||||||
// BGAppRefresh) can still read the session. Items written by older versions
|
|
||||||
// carry the plugin default `unlocked` and are invisible to this instance
|
|
||||||
// until _migrateKeychainAccessibility moved them over.
|
|
||||||
static const FlutterSecureStorage _secureStorage = FlutterSecureStorage(
|
|
||||||
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
|
|
||||||
);
|
|
||||||
static const FlutterSecureStorage _legacySecureStorage = FlutterSecureStorage(
|
|
||||||
iOptions: IOSOptions(accessibility: KeychainAccessibility.unlocked),
|
|
||||||
);
|
|
||||||
static const List<String> _sessionFields = [
|
|
||||||
_usernameField,
|
|
||||||
_passwordField,
|
|
||||||
_demoField,
|
|
||||||
_loginFlowField,
|
|
||||||
];
|
|
||||||
|
|
||||||
static final AccountData _instance = AccountData._construct();
|
|
||||||
Completer<void> _populated = Completer();
|
|
||||||
|
|
||||||
factory AccountData() => _instance;
|
|
||||||
|
|
||||||
AccountData._construct() {
|
|
||||||
unawaited(_loadWithRetry());
|
|
||||||
}
|
|
||||||
|
|
||||||
int _sessionEpoch = 0;
|
|
||||||
|
|
||||||
/// Bumped on every sign-out. Async work captures it when it starts and
|
|
||||||
/// drops its result when it changed meanwhile, so a request of the previous
|
|
||||||
/// account cannot land in the next account's state or cache.
|
|
||||||
int get sessionEpoch => _sessionEpoch;
|
|
||||||
|
|
||||||
bool isCurrentSession(int epoch) => epoch == _sessionEpoch;
|
|
||||||
|
|
||||||
String? _username;
|
|
||||||
String? _password;
|
|
||||||
String? _appPassword;
|
|
||||||
String? _appPasswordTalk;
|
|
||||||
bool _isDemo = false;
|
|
||||||
bool _usesLoginFlow = false;
|
|
||||||
|
|
||||||
/// True while the active session is a local demo session (see DemoMode).
|
|
||||||
bool get isDemo => _isDemo;
|
|
||||||
|
|
||||||
/// True when the Nextcloud credentials were obtained via Login Flow v2
|
|
||||||
/// (browser login, e.g. because the account has two-factor authentication).
|
|
||||||
/// In that mode the stored real password only authenticates MarianumConnect;
|
|
||||||
/// every Nextcloud call must use the flow-issued app password.
|
|
||||||
bool get usesLoginFlow => _usesLoginFlow;
|
|
||||||
|
|
||||||
String getUsername() {
|
|
||||||
if (_username == null) throw Exception('Username not initialized');
|
|
||||||
return _username!;
|
|
||||||
}
|
|
||||||
|
|
||||||
String getPassword() {
|
|
||||||
if (_password == null) throw Exception('Password not initialized');
|
|
||||||
return _password!;
|
|
||||||
}
|
|
||||||
|
|
||||||
String getUserSecret() => sha512
|
|
||||||
.convert(utf8.encode('${getUsername()}:${getPassword()}'))
|
|
||||||
.toString();
|
|
||||||
|
|
||||||
Future<void> setData(String username, String password) async {
|
|
||||||
await _secureStorage.write(key: _usernameField, value: username);
|
|
||||||
await _secureStorage.write(key: _passwordField, value: password);
|
|
||||||
_username = username;
|
|
||||||
_password = password;
|
|
||||||
if (!_populated.isCompleted) _populated.complete();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enters a local demo session for [username] — no real credentials or token;
|
|
||||||
/// every backend is served from fixtures while [isDemo] is true (see DemoMode).
|
|
||||||
Future<void> setDemo(String username) async {
|
|
||||||
await _secureStorage.write(key: _usernameField, value: username);
|
|
||||||
await _secureStorage.write(
|
|
||||||
key: _passwordField,
|
|
||||||
value: _demoPasswordPlaceholder,
|
|
||||||
);
|
|
||||||
await _secureStorage.write(key: _demoField, value: 'true');
|
|
||||||
_username = username;
|
|
||||||
_password = _demoPasswordPlaceholder;
|
|
||||||
_isDemo = true;
|
|
||||||
if (!_populated.isCompleted) _populated.complete();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> removeData() async {
|
|
||||||
_sessionEpoch++;
|
|
||||||
_populated = Completer();
|
|
||||||
_username = null;
|
|
||||||
_password = null;
|
|
||||||
_appPassword = null;
|
|
||||||
_appPasswordTalk = null;
|
|
||||||
_isDemo = false;
|
|
||||||
_usesLoginFlow = false;
|
|
||||||
await _secureStorage.delete(key: _usernameField);
|
|
||||||
await _secureStorage.delete(key: _passwordField);
|
|
||||||
await _secureStorage.delete(key: _demoField);
|
|
||||||
await _secureStorage.delete(key: _loginFlowField);
|
|
||||||
await _clearAppPasswordStorage();
|
|
||||||
await _clearAppPasswordTalkStorage();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Persists a freshly minted Nextcloud app password. After this every
|
|
||||||
/// [getBasicAuthHeader] call authenticates with the app password instead of
|
|
||||||
/// the real password.
|
|
||||||
Future<void> setAppPassword(String appPassword) async {
|
|
||||||
_appPassword = appPassword;
|
|
||||||
try {
|
|
||||||
await pushSecureStorage.write(key: _appPasswordField, value: appPassword);
|
|
||||||
} on Object {
|
|
||||||
// Group-scoped keystore may be unavailable (e.g. iOS entitlement not yet
|
|
||||||
// provisioned). Keeping it in memory still lets this session use it.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> clearAppPassword() async {
|
|
||||||
_appPassword = null;
|
|
||||||
await _clearAppPasswordStorage();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Adopts an app password obtained via Login Flow v2 and switches the
|
|
||||||
/// account into flow mode (see [usesLoginFlow]). Any previously stored Talk
|
|
||||||
/// app password belonged to the old session era and is dropped — the second
|
|
||||||
/// (optional) flow pass stores a fresh one via [setAppPasswordTalk].
|
|
||||||
Future<void> setLoginFlow(String appPassword) async {
|
|
||||||
await setAppPassword(appPassword);
|
|
||||||
await clearAppPasswordTalk();
|
|
||||||
_usesLoginFlow = true;
|
|
||||||
await _secureStorage.write(key: _loginFlowField, value: 'true');
|
|
||||||
}
|
|
||||||
|
|
||||||
bool hasAppPassword() => _appPassword != null && _appPassword!.isNotEmpty;
|
|
||||||
|
|
||||||
/// Persists the app password backing the Talk push registration.
|
|
||||||
Future<void> setAppPasswordTalk(String appPassword) async {
|
|
||||||
_appPasswordTalk = appPassword;
|
|
||||||
try {
|
|
||||||
await pushSecureStorage.write(
|
|
||||||
key: _appPasswordTalkField,
|
|
||||||
value: appPassword,
|
|
||||||
);
|
|
||||||
} on Object {
|
|
||||||
// Group-scoped keystore may be unavailable — in-memory still works for
|
|
||||||
// this session, matching setAppPassword.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> clearAppPasswordTalk() async {
|
|
||||||
_appPasswordTalk = null;
|
|
||||||
await _clearAppPasswordTalkStorage();
|
|
||||||
}
|
|
||||||
|
|
||||||
bool hasAppPasswordTalk() =>
|
|
||||||
_appPasswordTalk != null && _appPasswordTalk!.isNotEmpty;
|
|
||||||
|
|
||||||
Future<void> _clearAppPasswordStorage() async {
|
|
||||||
try {
|
|
||||||
await pushSecureStorage.delete(key: _appPasswordField);
|
|
||||||
} on Object {
|
|
||||||
// ignore — nothing stored or keystore unavailable
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _clearAppPasswordTalkStorage() async {
|
|
||||||
try {
|
|
||||||
await pushSecureStorage.delete(key: _appPasswordTalkField);
|
|
||||||
} on Object {
|
|
||||||
// ignore — nothing stored or keystore unavailable
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// iOS keychain reads fail while protected data is unavailable (app launch
|
|
||||||
/// racing the unlock, background wake on a locked device). Without a retry
|
|
||||||
/// the completer never resolved and the app stayed on the launch screen.
|
|
||||||
Future<void> _loadWithRetry() async {
|
|
||||||
for (var attempt = 1; !_populated.isCompleted; attempt++) {
|
|
||||||
try {
|
|
||||||
await _migrateAndLoad();
|
|
||||||
return;
|
|
||||||
} catch (e, s) {
|
|
||||||
log('AccountData load failed (attempt $attempt): $e', stackTrace: s);
|
|
||||||
await Future<void>.delayed(exponentialBackoff(attempt));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stops waiting for the stored session; the app then behaves as logged
|
|
||||||
/// out. The keychain entries stay untouched so a later start can still
|
|
||||||
/// restore the session.
|
|
||||||
void abandonLoad() {
|
|
||||||
if (!_populated.isCompleted) _populated.complete();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _migrateAndLoad() async {
|
|
||||||
await _migrateFromLegacyStorage();
|
|
||||||
await _migrateKeychainAccessibility();
|
|
||||||
// Independent keystore reads, each a platform-channel round trip with
|
|
||||||
// decryption: issued together since this gates the first frame.
|
|
||||||
final (username, password, demo, loginFlow) = await (
|
|
||||||
_secureStorage.read(key: _usernameField),
|
|
||||||
_secureStorage.read(key: _passwordField),
|
|
||||||
_secureStorage.read(key: _demoField),
|
|
||||||
_secureStorage.read(key: _loginFlowField),
|
|
||||||
).wait;
|
|
||||||
_username = username;
|
|
||||||
_password = password;
|
|
||||||
_isDemo = demo == 'true';
|
|
||||||
_usesLoginFlow = loginFlow == 'true';
|
|
||||||
try {
|
|
||||||
final (appPassword, appPasswordTalk) = await (
|
|
||||||
pushSecureStorage.read(key: _appPasswordField),
|
|
||||||
pushSecureStorage.read(key: _appPasswordTalkField),
|
|
||||||
).wait;
|
|
||||||
_appPassword = appPassword;
|
|
||||||
_appPasswordTalk = appPasswordTalk;
|
|
||||||
} on Object {
|
|
||||||
_appPassword = null;
|
|
||||||
_appPasswordTalk = null;
|
|
||||||
}
|
|
||||||
if (!_populated.isCompleted) _populated.complete();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move credentials from the old SharedPreferences plain-text storage into the
|
|
||||||
// platform's secure keystore. Run once per install and clear the legacy keys.
|
|
||||||
Future<void> _migrateFromLegacyStorage() async {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
final legacyUsername = prefs.getString(_usernameField);
|
|
||||||
final legacyPassword = prefs.getString(_passwordField);
|
|
||||||
if (legacyUsername == null || legacyPassword == null) return;
|
|
||||||
|
|
||||||
final hasSecure = (await _secureStorage.read(key: _usernameField)) != null;
|
|
||||||
if (!hasSecure) {
|
|
||||||
await _secureStorage.write(key: _usernameField, value: legacyUsername);
|
|
||||||
await _secureStorage.write(key: _passwordField, value: legacyPassword);
|
|
||||||
}
|
|
||||||
await prefs.remove(_usernameField);
|
|
||||||
await prefs.remove(_passwordField);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _migrateKeychainAccessibility() async {
|
|
||||||
if (!Platform.isIOS) return;
|
|
||||||
for (final field in _sessionFields) {
|
|
||||||
final value = await _legacySecureStorage.read(key: field);
|
|
||||||
if (value == null) continue;
|
|
||||||
// Same account+service: the legacy item has to go before the re-add.
|
|
||||||
await _legacySecureStorage.delete(key: field);
|
|
||||||
await _secureStorage.write(key: field, value: value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _isUiEngine = false;
|
|
||||||
|
|
||||||
/// Called from `main()`; background entry points never run it.
|
|
||||||
void markUiEngine() => _isUiEngine = true;
|
|
||||||
|
|
||||||
/// Username currently in the keystore. Other engines (widget task, push
|
|
||||||
/// isolates) sign out or in without this instance noticing.
|
|
||||||
Future<String?> readStoredUsername() =>
|
|
||||||
_secureStorage.read(key: _usernameField);
|
|
||||||
|
|
||||||
/// Re-reads the session for long-lived background engines: they load once
|
|
||||||
/// and would otherwise keep acting with an account that signed out in the
|
|
||||||
/// app meanwhile. Keeps the known state when the keystore is unreadable.
|
|
||||||
Future<void> reloadFromStorage() async {
|
|
||||||
// The UI engine performs sign-in and sign-out itself, so its state is
|
|
||||||
// current; re-reading mid sign-out could resurrect the removed account.
|
|
||||||
if (_isUiEngine) return;
|
|
||||||
try {
|
|
||||||
final username = await _secureStorage.read(key: _usernameField);
|
|
||||||
final password = await _secureStorage.read(key: _passwordField);
|
|
||||||
final isDemo = (await _secureStorage.read(key: _demoField)) == 'true';
|
|
||||||
final usesLoginFlow =
|
|
||||||
(await _secureStorage.read(key: _loginFlowField)) == 'true';
|
|
||||||
String? appPassword;
|
|
||||||
String? appPasswordTalk;
|
|
||||||
try {
|
|
||||||
appPassword = await pushSecureStorage.read(key: _appPasswordField);
|
|
||||||
appPasswordTalk = await pushSecureStorage.read(
|
|
||||||
key: _appPasswordTalkField,
|
|
||||||
);
|
|
||||||
} on Object {
|
|
||||||
// Group keystore unavailable: fall back to the real password.
|
|
||||||
}
|
|
||||||
if (username != _username) _sessionEpoch++;
|
|
||||||
_username = username;
|
|
||||||
_password = password;
|
|
||||||
_isDemo = isDemo;
|
|
||||||
_usesLoginFlow = usesLoginFlow;
|
|
||||||
_appPassword = appPassword;
|
|
||||||
_appPasswordTalk = appPasswordTalk;
|
|
||||||
if (!_populated.isCompleted) _populated.complete();
|
|
||||||
} on Object catch (e) {
|
|
||||||
log('AccountData reload failed, keeping loaded state: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> waitForPopulation() async {
|
|
||||||
await _populated.future;
|
|
||||||
return isPopulated();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// True once the stored session has been read (or given up on).
|
|
||||||
bool get isLoaded => _populated.isCompleted;
|
|
||||||
|
|
||||||
bool isPopulated() => _username != null && _password != null;
|
|
||||||
|
|
||||||
/// Returns the value for an HTTP `Authorization` header using HTTP Basic.
|
|
||||||
/// Prefer this over embedding credentials in URLs — error logs and crash
|
|
||||||
/// reports often capture the URL but not headers.
|
|
||||||
String getBasicAuthHeader() {
|
|
||||||
_requirePopulated();
|
|
||||||
// Prefer the scoped app password once available; it survives real-password
|
|
||||||
// rotation and is what the push-v2 registration is bound to.
|
|
||||||
return _basicAuth(_appPassword ?? _password!);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Basic-auth header using the Talk app password — authenticates the
|
|
||||||
/// apptype=talk push registration (and its unregister). Throws when the
|
|
||||||
/// talk password has not been minted yet; callers treat that as a failed
|
|
||||||
/// talk registration and retry on the next start.
|
|
||||||
String getTalkBasicAuthHeader() {
|
|
||||||
_requirePopulated();
|
|
||||||
if (!hasAppPasswordTalk()) {
|
|
||||||
// Login-flow account whose second (talk) flow pass was skipped: no
|
|
||||||
// silent minting possible, the talk registration shares the single
|
|
||||||
// flow-issued credential.
|
|
||||||
if (_usesLoginFlow && hasAppPassword()) return _basicAuth(_appPassword!);
|
|
||||||
throw StateError('Talk app password not available yet');
|
|
||||||
}
|
|
||||||
return _basicAuth(_appPasswordTalk!);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Basic-auth header that always uses the real password. Needed exactly once,
|
|
||||||
/// to mint the app password via `core/getapppassword` (an app password cannot
|
|
||||||
/// mint another).
|
|
||||||
String getRealPasswordBasicAuthHeader() {
|
|
||||||
_requirePopulated();
|
|
||||||
return _basicAuth(_password!);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Secret authenticating against Nextcloud: the app password once available
|
|
||||||
/// (minted or flow-issued), otherwise the real password. Mirrors the
|
|
||||||
/// preference of [getBasicAuthHeader] for clients that need the raw secret
|
|
||||||
/// (WebDAV client construction).
|
|
||||||
String getNextcloudSecret() {
|
|
||||||
_requirePopulated();
|
|
||||||
return _appPassword ?? _password!;
|
|
||||||
}
|
|
||||||
|
|
||||||
void _requirePopulated() {
|
|
||||||
if (!isPopulated()) {
|
|
||||||
throw Exception(
|
|
||||||
'AccountData (e.g. username or password) is not initialized!',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String _basicAuth(String secret) =>
|
|
||||||
'Basic ${base64Encode(utf8.encode('$_username:$secret'))}';
|
|
||||||
|
|
||||||
/// Convenience wrapper around [getBasicAuthHeader] returning a single-entry
|
|
||||||
/// header map ready to merge into HTTP request headers.
|
|
||||||
Map<String, String> authHeaders() => {'Authorization': getBasicAuthHeader()};
|
|
||||||
}
|
|
||||||
@@ -8,8 +8,8 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
|||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
import '../api/marianumcloud/nextcloud_ocs.dart';
|
import '../api/marianumcloud/nextcloud_ocs.dart';
|
||||||
import '../model/account_data.dart';
|
|
||||||
import '../notification/notification_service.dart';
|
import '../notification/notification_service.dart';
|
||||||
|
import '../session/session_manager.dart';
|
||||||
import 'chat_thread_store.dart';
|
import 'chat_thread_store.dart';
|
||||||
import 'nid_store.dart';
|
import 'nid_store.dart';
|
||||||
import 'push_renderer.dart';
|
import 'push_renderer.dart';
|
||||||
@@ -38,7 +38,7 @@ void _plog(String message) {
|
|||||||
/// Handles Talk notification actions (inline reply, mark-as-read). Runs in the
|
/// Handles Talk notification actions (inline reply, mark-as-read). Runs in the
|
||||||
/// background isolate spawned by flutter_local_notifications, so it may not
|
/// background isolate spawned by flutter_local_notifications, so it may not
|
||||||
/// share any app state — it reads credentials straight from secure storage via
|
/// share any app state — it reads credentials straight from secure storage via
|
||||||
/// the [AccountData] singleton after awaiting population.
|
/// the [SessionManager] singleton after awaiting the stored session.
|
||||||
///
|
///
|
||||||
/// The class-level `vm:entry-point` pragma is REQUIRED in addition to the one
|
/// The class-level `vm:entry-point` pragma is REQUIRED in addition to the one
|
||||||
/// on [handleBackgroundResponse]: the callback is resolved via
|
/// on [handleBackgroundResponse]: the callback is resolved via
|
||||||
@@ -56,14 +56,14 @@ class PushActions {
|
|||||||
) async {
|
) async {
|
||||||
// The FLN action isolate starts WITHOUT main(): unlike the FCM background
|
// The FLN action isolate starts WITHOUT main(): unlike the FCM background
|
||||||
// isolate, plugins are not registered automatically there. Without this,
|
// isolate, plugins are not registered automatically there. Without this,
|
||||||
// AccountData's secure-storage/prefs reads throw or never complete → no
|
// The session's secure-storage/prefs reads throw or never complete → no
|
||||||
// auth header, the Talk POST never happens and the RemoteInput spinner
|
// auth header, the Talk POST never happens and the RemoteInput spinner
|
||||||
// runs forever.
|
// runs forever.
|
||||||
DartPluginRegistrant.ensureInitialized();
|
DartPluginRegistrant.ensureInitialized();
|
||||||
// The action engine lives as long as the process: without a reload a
|
// The action engine lives as long as the process: without a reload a
|
||||||
// reply would be sent with the account that was signed in when the
|
// reply would be sent with the account that was signed in when the
|
||||||
// engine first started.
|
// engine first started.
|
||||||
await AccountData().reloadFromStorage();
|
await SessionManager().reloadFromStorage();
|
||||||
|
|
||||||
_plog(
|
_plog(
|
||||||
'action=${response.actionId} payload=${response.payload} '
|
'action=${response.actionId} payload=${response.payload} '
|
||||||
@@ -129,7 +129,8 @@ class PushActions {
|
|||||||
/// any) followed by the technical reason.
|
/// any) followed by the technical reason.
|
||||||
static String actionFailureBody({String? lostText, required String detail}) {
|
static String actionFailureBody({String? lostText, required String detail}) {
|
||||||
return [
|
return [
|
||||||
if (lostText != null && lostText.isNotEmpty) 'Deine Nachricht: „$lostText“',
|
if (lostText != null && lostText.isNotEmpty)
|
||||||
|
'Deine Nachricht: „$lostText“',
|
||||||
'Grund: $detail',
|
'Grund: $detail',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
@@ -192,7 +193,10 @@ class PushActions {
|
|||||||
static Future<({bool ok, String detail})> sendReply(
|
static Future<({bool ok, String detail})> sendReply(
|
||||||
String chatToken,
|
String chatToken,
|
||||||
String message,
|
String message,
|
||||||
) => _ocsPost('apps/spreed/api/v1/chat/$chatToken', body: {'message': message});
|
) => _ocsPost(
|
||||||
|
'apps/spreed/api/v1/chat/$chatToken',
|
||||||
|
body: {'message': message},
|
||||||
|
);
|
||||||
|
|
||||||
static Future<({bool ok, String detail})> markRead(String chatToken) =>
|
static Future<({bool ok, String detail})> markRead(String chatToken) =>
|
||||||
_ocsPost('apps/spreed/api/v1/chat/$chatToken/read');
|
_ocsPost('apps/spreed/api/v1/chat/$chatToken/read');
|
||||||
@@ -204,10 +208,10 @@ class PushActions {
|
|||||||
try {
|
try {
|
||||||
// Bounded: a hanging population (e.g. keystore issue) must fail the
|
// Bounded: a hanging population (e.g. keystore issue) must fail the
|
||||||
// action instead of leaving the notification spinner running forever.
|
// action instead of leaving the notification spinner running forever.
|
||||||
final populated = await AccountData().waitForPopulation().timeout(
|
final session = await SessionManager().waitForLoad().timeout(
|
||||||
const Duration(seconds: 10),
|
const Duration(seconds: 10),
|
||||||
);
|
);
|
||||||
if (!populated) {
|
if (session?.nextcloud == null) {
|
||||||
_plog('Push action $path aborted: credentials unreadable in isolate');
|
_plog('Push action $path aborted: credentials unreadable in isolate');
|
||||||
return (
|
return (
|
||||||
ok: false,
|
ok: false,
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
|
||||||
|
/// Platform value MarianumConnect expects in push registrations.
|
||||||
|
String get pushPlatform => Platform.isIOS ? 'ios' : 'android';
|
||||||
|
|
||||||
|
/// App version sent along with push registrations; null when unavailable.
|
||||||
|
Future<String?> pushAppVersion() async {
|
||||||
|
try {
|
||||||
|
return (await PackageInfo.fromPlatform()).version;
|
||||||
|
} on Object {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,8 @@ import 'package:crypton/crypton.dart';
|
|||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
|
|
||||||
import '../background/widget_background_task.dart';
|
import '../background/widget_background_task.dart';
|
||||||
import '../model/account_data.dart';
|
|
||||||
import '../notification/notification_service.dart';
|
import '../notification/notification_service.dart';
|
||||||
|
import '../session/session_manager.dart';
|
||||||
import 'chat_thread_store.dart';
|
import 'chat_thread_store.dart';
|
||||||
import 'nid_store.dart';
|
import 'nid_store.dart';
|
||||||
import 'push_decryptor.dart';
|
import 'push_decryptor.dart';
|
||||||
@@ -56,7 +56,7 @@ PushKind classifyPush(Map<String, dynamic> data) {
|
|||||||
@pragma('vm:entry-point')
|
@pragma('vm:entry-point')
|
||||||
Future<void> pushOnBackgroundMessage(RemoteMessage message) async {
|
Future<void> pushOnBackgroundMessage(RemoteMessage message) async {
|
||||||
// This engine outlives sign-outs and logins in the app.
|
// This engine outlives sign-outs and logins in the app.
|
||||||
await AccountData().reloadFromStorage();
|
await SessionManager().reloadFromStorage();
|
||||||
await NotificationService().initializeNotifications();
|
await NotificationService().initializeNotifications();
|
||||||
await PushRenderer.ensureChannels();
|
await PushRenderer.ensureChannels();
|
||||||
await PushMessageHandler().handle(message);
|
await PushMessageHandler().handle(message);
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'package:nextcloud/notifications.dart' show generatePushTokenHash;
|
import 'package:nextcloud/notifications.dart' show generatePushTokenHash;
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
|
||||||
|
|
||||||
import '../api/demo/demo_mode.dart';
|
import '../api/demo/demo_mode.dart';
|
||||||
import '../api/marianumcloud/app_password/delete_app_password.dart';
|
import '../api/marianumcloud/app_password/delete_app_password.dart';
|
||||||
@@ -11,9 +10,11 @@ import '../api/marianumcloud/app_password/get_app_password.dart';
|
|||||||
import '../api/marianumconnect/marianumconnect_endpoint.dart';
|
import '../api/marianumconnect/marianumconnect_endpoint.dart';
|
||||||
import '../api/marianumconnect/queries/push_device_register/push_device_register.dart';
|
import '../api/marianumconnect/queries/push_device_register/push_device_register.dart';
|
||||||
import '../api/marianumconnect/queries/push_device_unregister/push_device_unregister.dart';
|
import '../api/marianumconnect/queries/push_device_unregister/push_device_unregister.dart';
|
||||||
import '../model/account_data.dart';
|
|
||||||
import '../model/endpoint_data.dart';
|
import '../model/endpoint_data.dart';
|
||||||
|
import '../session/nextcloud_credentials.dart';
|
||||||
|
import '../session/session_manager.dart';
|
||||||
import 'nextcloud_push_api.dart';
|
import 'nextcloud_push_api.dart';
|
||||||
|
import 'push_device_info.dart';
|
||||||
import 'push_keypair.dart';
|
import 'push_keypair.dart';
|
||||||
import 'push_registration_store.dart';
|
import 'push_registration_store.dart';
|
||||||
import 'push_registration_type.dart';
|
import 'push_registration_type.dart';
|
||||||
@@ -48,8 +49,6 @@ class PushRegistration {
|
|||||||
_store = store ?? const PushRegistrationStore(),
|
_store = store ?? const PushRegistrationStore(),
|
||||||
_nextcloud = nextcloud ?? NextcloudPushApi();
|
_nextcloud = nextcloud ?? NextcloudPushApi();
|
||||||
|
|
||||||
String get _platform => Platform.isIOS ? 'ios' : 'android';
|
|
||||||
|
|
||||||
String get _talkUserAgent =>
|
String get _talkUserAgent =>
|
||||||
Platform.isIOS ? talkUserAgentIos : talkUserAgentAndroid;
|
Platform.isIOS ? talkUserAgentIos : talkUserAgentAndroid;
|
||||||
|
|
||||||
@@ -63,20 +62,26 @@ class PushRegistration {
|
|||||||
/// slash) — persisted alongside the registration to detect endpoint changes.
|
/// slash) — persisted alongside the registration to detect endpoint changes.
|
||||||
String get currentNcBaseUrl => 'https://${EndpointData().nextcloud().full()}';
|
String get currentNcBaseUrl => 'https://${EndpointData().nextcloud().full()}';
|
||||||
|
|
||||||
|
NextcloudCredentials? get _nextcloudOrNull =>
|
||||||
|
SessionManager().current?.nextcloud;
|
||||||
|
|
||||||
/// Ensures the Nextcloud app password exists (idempotent, best-effort). Push
|
/// Ensures the Nextcloud app password exists (idempotent, best-effort). Push
|
||||||
/// registration binds to it, so it must be obtained before registering.
|
/// registration binds to it, so it must be obtained before registering.
|
||||||
Future<void> ensureAppPassword() async {
|
Future<void> ensureAppPassword() async {
|
||||||
if (AccountData().hasAppPassword()) return;
|
final nextcloud = _nextcloudOrNull;
|
||||||
if (AccountData().usesLoginFlow) {
|
if (nextcloud == null || nextcloud.hasAppPassword) return;
|
||||||
|
if (nextcloud.usesLoginFlow) {
|
||||||
// Flow-Konten (2FA): Basic Auth mit dem echten Passwort wird abgelehnt,
|
// Flow-Konten (2FA): Basic Auth mit dem echten Passwort wird abgelehnt,
|
||||||
// stilles Minting ist unmöglich. Reparatur nur interaktiv über
|
// stilles Minting ist unmöglich. Reparatur nur interaktiv über
|
||||||
// Einstellungen → „Nextcloud neu verbinden".
|
// Einstellungen → „Nextcloud neu verbinden".
|
||||||
log('Push: login-flow account without app password, cannot mint silently');
|
log(
|
||||||
|
'Push: login-flow account without app password, cannot mint silently',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
final appPassword = await GetAppPassword().run();
|
final appPassword = await GetAppPassword().run();
|
||||||
await AccountData().setAppPassword(appPassword);
|
await SessionManager().setAppPassword(appPassword);
|
||||||
} on Object catch (e) {
|
} on Object catch (e) {
|
||||||
log('Push: could not obtain app password (non-blocking): $e');
|
log('Push: could not obtain app password (non-blocking): $e');
|
||||||
}
|
}
|
||||||
@@ -85,15 +90,16 @@ class PushRegistration {
|
|||||||
/// Ensures the second app password backing the Talk registration exists
|
/// Ensures the second app password backing the Talk registration exists
|
||||||
/// (each `getapppassword` call with the real password mints a fresh one).
|
/// (each `getapppassword` call with the real password mints a fresh one).
|
||||||
Future<void> ensureTalkAppPassword() async {
|
Future<void> ensureTalkAppPassword() async {
|
||||||
if (AccountData().hasAppPasswordTalk()) return;
|
final nextcloud = _nextcloudOrNull;
|
||||||
|
if (nextcloud == null || nextcloud.hasAppPasswordTalk) return;
|
||||||
// Flow-Konten können still kein zweites App-Passwort münzen — das
|
// Flow-Konten können still kein zweites App-Passwort münzen — das
|
||||||
// Talk-Passwort kommt nur aus dem zweiten Login-Flow-Durchlauf; bis dahin
|
// Talk-Passwort kommt nur aus dem zweiten Login-Flow-Durchlauf; bis dahin
|
||||||
// teilt sich die Talk-Registrierung das eine App-Passwort (siehe
|
// teilt sich die Talk-Registrierung das eine App-Passwort (siehe
|
||||||
// AccountData.getTalkBasicAuthHeader).
|
// NextcloudCredentials.talkBasicAuthHeader).
|
||||||
if (AccountData().usesLoginFlow) return;
|
if (nextcloud.usesLoginFlow) return;
|
||||||
try {
|
try {
|
||||||
final appPassword = await GetAppPassword().run();
|
final appPassword = await GetAppPassword().run();
|
||||||
await AccountData().setAppPasswordTalk(appPassword);
|
await SessionManager().setAppPasswordTalk(appPassword);
|
||||||
} on Object catch (e) {
|
} on Object catch (e) {
|
||||||
log('Push: could not obtain talk app password (non-blocking): $e');
|
log('Push: could not obtain talk app password (non-blocking): $e');
|
||||||
}
|
}
|
||||||
@@ -107,7 +113,8 @@ class PushRegistration {
|
|||||||
/// fire-and-forget (and simply ignore the result).
|
/// fire-and-forget (and simply ignore the result).
|
||||||
Future<bool> register() async {
|
Future<bool> register() async {
|
||||||
if (DemoMode.active) return false;
|
if (DemoMode.active) return false;
|
||||||
final epoch = AccountData().sessionEpoch;
|
if (_nextcloudOrNull == null) return false;
|
||||||
|
final epoch = SessionManager().sessionEpoch;
|
||||||
final String? fcmToken;
|
final String? fcmToken;
|
||||||
try {
|
try {
|
||||||
fcmToken = await FirebaseMessaging.instance.getToken();
|
fcmToken = await FirebaseMessaging.instance.getToken();
|
||||||
@@ -135,16 +142,13 @@ class PushRegistration {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
String? appVersion;
|
final appVersion = await pushAppVersion();
|
||||||
try {
|
|
||||||
appVersion = (await PackageInfo.fromPlatform()).version;
|
|
||||||
} on Object {
|
|
||||||
appVersion = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Re-read: the ensure* calls above may have swapped the credentials.
|
||||||
|
final nextcloud = SessionManager().requireNextcloud();
|
||||||
final types = registrationTypesFor(
|
final types = registrationTypesFor(
|
||||||
usesLoginFlow: AccountData().usesLoginFlow,
|
usesLoginFlow: nextcloud.usesLoginFlow,
|
||||||
hasTalkAppPassword: AccountData().hasAppPasswordTalk(),
|
hasTalkAppPassword: nextcloud.hasAppPasswordTalk,
|
||||||
);
|
);
|
||||||
if (!types.contains(PushRegistrationType.general)) {
|
if (!types.contains(PushRegistrationType.general)) {
|
||||||
await _recordAttempt(
|
await _recordAttempt(
|
||||||
@@ -183,7 +187,7 @@ class PushRegistration {
|
|||||||
devicePublicKeyPem: pems.publicKeyPem,
|
devicePublicKeyPem: pems.publicKeyPem,
|
||||||
proxyServer: proxyServer,
|
proxyServer: proxyServer,
|
||||||
authorizationHeader: isTalk
|
authorizationHeader: isTalk
|
||||||
? AccountData().getTalkBasicAuthHeader()
|
? SessionManager().requireNextcloud().talkBasicAuthHeader
|
||||||
: null,
|
: null,
|
||||||
userAgent: isTalk ? _talkUserAgent : null,
|
userAgent: isTalk ? _talkUserAgent : null,
|
||||||
);
|
);
|
||||||
@@ -191,7 +195,7 @@ class PushRegistration {
|
|||||||
// Signed out while registering: persisting or announcing this
|
// Signed out while registering: persisting or announcing this
|
||||||
// registration would keep delivering the previous account's pushes,
|
// registration would keep delivering the previous account's pushes,
|
||||||
// and logoutCleanup already ran so nothing would unregister it.
|
// and logoutCleanup already ran so nothing would unregister it.
|
||||||
if (!AccountData().isCurrentSession(epoch)) return false;
|
if (!SessionManager().isCurrentSession(epoch)) return false;
|
||||||
|
|
||||||
await _store.save(
|
await _store.save(
|
||||||
type: type,
|
type: type,
|
||||||
@@ -207,7 +211,7 @@ class PushRegistration {
|
|||||||
deviceIdentifierSignature: registration.signature,
|
deviceIdentifierSignature: registration.signature,
|
||||||
userPublicKey: registration.publicKey,
|
userPublicKey: registration.publicKey,
|
||||||
pushToken: fcmToken,
|
pushToken: fcmToken,
|
||||||
platform: _platform,
|
platform: pushPlatform,
|
||||||
registrationType: type.wireName,
|
registrationType: type.wireName,
|
||||||
appVersion: appVersion,
|
appVersion: appVersion,
|
||||||
);
|
);
|
||||||
@@ -256,7 +260,7 @@ class PushRegistration {
|
|||||||
try {
|
try {
|
||||||
final endpoint = EndpointData().nextcloud();
|
final endpoint = EndpointData().nextcloud();
|
||||||
await _store.saveNativeAuthContext(
|
await _store.saveNativeAuthContext(
|
||||||
username: AccountData().getUsername(),
|
username: SessionManager().requireNextcloud().username,
|
||||||
baseUrl: 'https://${endpoint.full()}',
|
baseUrl: 'https://${endpoint.full()}',
|
||||||
);
|
);
|
||||||
} on Object catch (e) {
|
} on Object catch (e) {
|
||||||
@@ -274,7 +278,7 @@ class PushRegistration {
|
|||||||
// session token — each registration with its own app password.
|
// session token — each registration with its own app password.
|
||||||
await _nextcloud.unregister(
|
await _nextcloud.unregister(
|
||||||
authorizationHeader: type == PushRegistrationType.talk
|
authorizationHeader: type == PushRegistrationType.talk
|
||||||
? AccountData().getTalkBasicAuthHeader()
|
? SessionManager().requireNextcloud().talkBasicAuthHeader
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
} on Object catch (e) {
|
} on Object catch (e) {
|
||||||
@@ -428,7 +432,7 @@ class PushRegistration {
|
|||||||
/// is called with), then clear them locally. Ordered so the proxy stops
|
/// is called with), then clear them locally. Ordered so the proxy stops
|
||||||
/// pushing before credentials are gone.
|
/// pushing before credentials are gone.
|
||||||
Future<void> logoutCleanup() async {
|
Future<void> logoutCleanup() async {
|
||||||
if (DemoMode.active) return;
|
if (DemoMode.active || _nextcloudOrNull == null) return;
|
||||||
await unregister();
|
await unregister();
|
||||||
try {
|
try {
|
||||||
await DeleteAppPassword().run();
|
await DeleteAppPassword().run();
|
||||||
@@ -436,15 +440,16 @@ class PushRegistration {
|
|||||||
log('Push: delete app password failed: $e');
|
log('Push: delete app password failed: $e');
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (AccountData().hasAppPasswordTalk()) {
|
final nextcloud = SessionManager().requireNextcloud();
|
||||||
|
if (nextcloud.hasAppPasswordTalk) {
|
||||||
await DeleteAppPassword().run(
|
await DeleteAppPassword().run(
|
||||||
authorizationHeader: AccountData().getTalkBasicAuthHeader(),
|
authorizationHeader: nextcloud.talkBasicAuthHeader,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} on Object catch (e) {
|
} on Object catch (e) {
|
||||||
log('Push: delete talk app password failed: $e');
|
log('Push: delete talk app password failed: $e');
|
||||||
}
|
}
|
||||||
await AccountData().clearAppPassword();
|
await SessionManager().clearAppPassword();
|
||||||
await AccountData().clearAppPasswordTalk();
|
await SessionManager().clearAppPasswordTalk();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class PushRegistrationStore {
|
|||||||
// (reply / mark-as-read) directly via URLSession while the Flutter engine is
|
// (reply / mark-as-read) directly via URLSession while the Flutter engine is
|
||||||
// not guaranteed to run. It needs the Nextcloud username and base URL from the
|
// not guaranteed to run. It needs the Nextcloud username and base URL from the
|
||||||
// shared (group-scoped) keychain; the app password already lives there
|
// shared (group-scoped) keychain; the app password already lives there
|
||||||
// (AccountData writes `nextcloud_app_password` group-scoped).
|
// (SessionManager writes `nextcloud_app_password` group-scoped).
|
||||||
static const _usernameKey = 'nextcloud_username';
|
static const _usernameKey = 'nextcloud_username';
|
||||||
static const _baseUrlKey = 'nextcloud_base_url';
|
static const _baseUrlKey = 'nextcloud_base_url';
|
||||||
// Mirror of the in-app notification toggle (`notificationSettings.enabled`),
|
// Mirror of the in-app notification toggle (`notificationSettings.enabled`),
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const IOSOptions kPushIosOptions = IOSOptions(
|
|||||||
);
|
);
|
||||||
|
|
||||||
/// Shared secure storage instance for all push key material and registration
|
/// Shared secure storage instance for all push key material and registration
|
||||||
/// bookkeeping. Kept separate from [AccountData]'s default storage because the
|
/// bookkeeping. Kept separate from the session's default storage because the
|
||||||
/// entries here are group-scoped for NSE access.
|
/// entries here are group-scoped for NSE access.
|
||||||
const FlutterSecureStorage pushSecureStorage = FlutterSecureStorage(
|
const FlutterSecureStorage pushSecureStorage = FlutterSecureStorage(
|
||||||
iOptions: kPushIosOptions,
|
iOptions: kPushIosOptions,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
import '../model/account_data.dart';
|
import '../session/session_manager.dart';
|
||||||
import 'push_keypair.dart';
|
import 'push_keypair.dart';
|
||||||
import 'push_registration.dart';
|
import 'push_registration.dart';
|
||||||
import 'push_registration_store.dart';
|
import 'push_registration_store.dart';
|
||||||
@@ -124,14 +124,15 @@ Future<PushStatusReport> collectPushStatus({
|
|||||||
lastRegistrationError: await store.lastRegistrationError(type),
|
lastRegistrationError: await store.lastRegistrationError(type),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final nextcloud = SessionManager().current?.nextcloud;
|
||||||
return PushStatusReport(
|
return PushStatusReport(
|
||||||
settingEnabled: settingEnabled,
|
settingEnabled: settingEnabled,
|
||||||
osPermission: await _osPermission(),
|
osPermission: await _osPermission(),
|
||||||
serverCapability: !capabilitiesLoaded
|
serverCapability: !capabilitiesLoaded
|
||||||
? PushCheck.unknown
|
? PushCheck.unknown
|
||||||
: (capabilityPush ? PushCheck.ok : PushCheck.fail),
|
: (capabilityPush ? PushCheck.ok : PushCheck.fail),
|
||||||
appPasswordPresent: AccountData().hasAppPassword(),
|
appPasswordPresent: nextcloud?.hasAppPassword ?? false,
|
||||||
talkAppPasswordPresent: AccountData().hasAppPasswordTalk(),
|
talkAppPasswordPresent: nextcloud?.hasAppPasswordTalk ?? false,
|
||||||
keypairPresent: (await keypair.loadPublicKeyPem())?.isNotEmpty ?? false,
|
keypairPresent: (await keypair.loadPublicKeyPem())?.isNotEmpty ?? false,
|
||||||
general: await typeStatus(PushRegistrationType.general),
|
general: await typeStatus(PushRegistrationType.general),
|
||||||
talk: await typeStatus(PushRegistrationType.talk),
|
talk: await typeStatus(PushRegistrationType.talk),
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import '../api/marianumcloud/talk/room/get_room_response.dart';
|
|||||||
import '../api/marianumconnect/marianumconnect_endpoint.dart';
|
import '../api/marianumconnect/marianumconnect_endpoint.dart';
|
||||||
import '../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart';
|
import '../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart';
|
||||||
import '../main.dart';
|
import '../main.dart';
|
||||||
import '../model/account_data.dart';
|
|
||||||
import '../notification/notification_tasks.dart';
|
import '../notification/notification_tasks.dart';
|
||||||
|
import '../session/session_manager.dart';
|
||||||
import '../share_intent/pending_share.dart';
|
import '../share_intent/pending_share.dart';
|
||||||
import '../share_intent/remote_file_ref.dart';
|
import '../share_intent/remote_file_ref.dart';
|
||||||
import '../state/app/modules/app_modules.dart';
|
import '../state/app/modules/app_modules.dart';
|
||||||
@@ -395,7 +395,10 @@ class AppRoutes {
|
|||||||
// ChatBloc._loadChat with the freshly-fetched maxId — sending one
|
// ChatBloc._loadChat with the freshly-fetched maxId — sending one
|
||||||
// here too with the chat list's possibly-stale room.lastMessage.id
|
// here too with the chat list's possibly-stale room.lastMessage.id
|
||||||
// would race the fresh one and could regress the server cursor.
|
// would race the fresh one and could regress the server cursor.
|
||||||
context.read<ChatListBloc>().markRoomAsRead(room.token, room.lastMessage.id);
|
context.read<ChatListBloc>().markRoomAsRead(
|
||||||
|
room.token,
|
||||||
|
room.lastMessage.id,
|
||||||
|
);
|
||||||
NotificationTasks.clearNotificationsForChat(room.token);
|
NotificationTasks.clearNotificationsForChat(room.token);
|
||||||
TalkNavigator.pushSplitView(
|
TalkNavigator.pushSplitView(
|
||||||
context,
|
context,
|
||||||
@@ -425,7 +428,8 @@ class AppRoutes {
|
|||||||
static ResolvedPendingChat? resolvePendingChat(BuildContext context) {
|
static ResolvedPendingChat? resolvePendingChat(BuildContext context) {
|
||||||
final token = pendingChatToken.value;
|
final token = pendingChatToken.value;
|
||||||
if (token == null) return null;
|
if (token == null) return null;
|
||||||
if (!AccountData().isPopulated()) return null;
|
final nextcloud = SessionManager().current?.nextcloud;
|
||||||
|
if (nextcloud == null) return null;
|
||||||
|
|
||||||
final rooms = context.read<ChatListBloc>().state.data?.rooms;
|
final rooms = context.read<ChatListBloc>().state.data?.rooms;
|
||||||
final room = _findRoomByToken(rooms, token);
|
final room = _findRoomByToken(rooms, token);
|
||||||
@@ -438,7 +442,7 @@ class AppRoutes {
|
|||||||
);
|
);
|
||||||
return ResolvedPendingChat(
|
return ResolvedPendingChat(
|
||||||
room: room,
|
room: room,
|
||||||
selfId: AccountData().getUsername(),
|
selfId: nextcloud.username,
|
||||||
avatar: avatar,
|
avatar: avatar,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
/// Nextcloud identity of a session. Immutable; the session manager swaps in a
|
||||||
|
/// new instance whenever an app password is minted or revoked.
|
||||||
|
class NextcloudCredentials {
|
||||||
|
final String username;
|
||||||
|
|
||||||
|
/// The real account password. Invalid against Nextcloud when
|
||||||
|
/// [usesLoginFlow] is set (2FA accounts), where only [appPassword] works.
|
||||||
|
final String password;
|
||||||
|
final String? appPassword;
|
||||||
|
|
||||||
|
/// Backs the second (apptype=talk) push registration — Nextcloud binds each
|
||||||
|
/// push subscription to its session token, so two registrations need two
|
||||||
|
/// app passwords.
|
||||||
|
final String? appPasswordTalk;
|
||||||
|
|
||||||
|
/// True when the credentials came from Login Flow v2 (browser login, e.g.
|
||||||
|
/// because the account has two-factor authentication).
|
||||||
|
final bool usesLoginFlow;
|
||||||
|
|
||||||
|
const NextcloudCredentials({
|
||||||
|
required this.username,
|
||||||
|
required this.password,
|
||||||
|
this.appPassword,
|
||||||
|
this.appPasswordTalk,
|
||||||
|
this.usesLoginFlow = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get hasAppPassword => appPassword != null && appPassword!.isNotEmpty;
|
||||||
|
|
||||||
|
bool get hasAppPasswordTalk =>
|
||||||
|
appPasswordTalk != null && appPasswordTalk!.isNotEmpty;
|
||||||
|
|
||||||
|
/// The app password once available (minted or flow-issued), otherwise the
|
||||||
|
/// real password. It survives real-password rotation and is what the push
|
||||||
|
/// registration is bound to.
|
||||||
|
String get secret => hasAppPassword ? appPassword! : password;
|
||||||
|
|
||||||
|
/// HTTP Basic header value. Prefer headers over credentials in URLs — error
|
||||||
|
/// logs and crash reports often capture the URL but not headers.
|
||||||
|
String get basicAuthHeader => _basicAuth(secret);
|
||||||
|
|
||||||
|
Map<String, String> get authHeaders => {'Authorization': basicAuthHeader};
|
||||||
|
|
||||||
|
/// Authenticates the apptype=talk push registration (and its unregister).
|
||||||
|
/// Throws when the talk password has not been minted yet; callers treat that
|
||||||
|
/// as a failed talk registration and retry on the next start.
|
||||||
|
String get talkBasicAuthHeader {
|
||||||
|
if (hasAppPasswordTalk) return _basicAuth(appPasswordTalk!);
|
||||||
|
// Login-flow account whose second (talk) flow pass was skipped: no silent
|
||||||
|
// minting possible, the talk registration shares the flow credential.
|
||||||
|
if (usesLoginFlow && hasAppPassword) return _basicAuth(appPassword!);
|
||||||
|
throw StateError('Talk app password not available yet');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Always the real password. Needed to mint the app password via
|
||||||
|
/// `core/getapppassword` — an app password cannot mint another.
|
||||||
|
String get realPasswordBasicAuthHeader => _basicAuth(password);
|
||||||
|
|
||||||
|
NextcloudCredentials copyWith({
|
||||||
|
String? Function()? appPassword,
|
||||||
|
String? Function()? appPasswordTalk,
|
||||||
|
bool? usesLoginFlow,
|
||||||
|
}) => NextcloudCredentials(
|
||||||
|
username: username,
|
||||||
|
password: password,
|
||||||
|
appPassword: appPassword != null ? appPassword() : this.appPassword,
|
||||||
|
appPasswordTalk: appPasswordTalk != null
|
||||||
|
? appPasswordTalk()
|
||||||
|
: this.appPasswordTalk,
|
||||||
|
usesLoginFlow: usesLoginFlow ?? this.usesLoginFlow,
|
||||||
|
);
|
||||||
|
|
||||||
|
String _basicAuth(String secret) =>
|
||||||
|
'Basic ${base64Encode(utf8.encode('$username:$secret'))}';
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
|
|
||||||
|
import 'nextcloud_credentials.dart';
|
||||||
|
|
||||||
|
/// The signed-in account. Exactly one session is active at a time; features
|
||||||
|
/// check for the backend identities they need ([nextcloud]) instead of
|
||||||
|
/// assuming every account has all of them.
|
||||||
|
sealed class Session {
|
||||||
|
/// Local demo session: every backend is served from fixtures (see DemoMode).
|
||||||
|
final bool isDemo;
|
||||||
|
|
||||||
|
const Session({this.isDemo = false});
|
||||||
|
|
||||||
|
/// Nextcloud identity, or null for accounts without one.
|
||||||
|
NextcloudCredentials? get nextcloud;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Student, teacher or staff account: username + password, backed by
|
||||||
|
/// MarianumConnect and Nextcloud (with the same username and password).
|
||||||
|
final class CredentialSession extends Session {
|
||||||
|
@override
|
||||||
|
final NextcloudCredentials nextcloud;
|
||||||
|
|
||||||
|
CredentialSession({
|
||||||
|
required String username,
|
||||||
|
required String password,
|
||||||
|
String? appPassword,
|
||||||
|
String? appPasswordTalk,
|
||||||
|
bool usesLoginFlow = false,
|
||||||
|
super.isDemo,
|
||||||
|
}) : nextcloud = NextcloudCredentials(
|
||||||
|
username: username,
|
||||||
|
password: password,
|
||||||
|
appPassword: appPassword,
|
||||||
|
appPasswordTalk: appPasswordTalk,
|
||||||
|
usesLoginFlow: usesLoginFlow,
|
||||||
|
);
|
||||||
|
|
||||||
|
const CredentialSession._(this.nextcloud, {super.isDemo});
|
||||||
|
|
||||||
|
String get username => nextcloud.username;
|
||||||
|
|
||||||
|
String get password => nextcloud.password;
|
||||||
|
|
||||||
|
CredentialSession withNextcloud(NextcloudCredentials nextcloud) =>
|
||||||
|
CredentialSession._(nextcloud, isDemo: isDemo);
|
||||||
|
|
||||||
|
/// Legacy MHSL identity (`sha512(user:pass)`), only for the one-off custom
|
||||||
|
/// events migration.
|
||||||
|
String get legacyUserSecret =>
|
||||||
|
sha512.convert(utf8.encode('$username:$password')).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Thrown when a Nextcloud-only feature is reached with a session that has no
|
||||||
|
/// Nextcloud identity. Indicates a missing gate, not a user error.
|
||||||
|
class NextcloudUnavailableException implements Exception {
|
||||||
|
const NextcloudUnavailableException();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() =>
|
||||||
|
'NextcloudUnavailableException: session has no Nextcloud account';
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import 'session.dart';
|
||||||
|
|
||||||
|
/// Keychain keys of the session. Names are frozen: installed versions and the
|
||||||
|
/// iOS AppDelegate/NSE read them, so renaming would log every user out.
|
||||||
|
abstract final class SessionKeys {
|
||||||
|
static const username = 'username';
|
||||||
|
static const password = 'password';
|
||||||
|
static const appPassword = 'nextcloud_app_password';
|
||||||
|
static const appPasswordTalk = 'nextcloud_app_password_talk';
|
||||||
|
static const loginFlow = 'nextcloud_login_flow';
|
||||||
|
static const demo = 'is_demo';
|
||||||
|
|
||||||
|
// Absent on installs from before account kinds — see [decodeSession].
|
||||||
|
static const kind = 'session_kind';
|
||||||
|
|
||||||
|
static const kindCredential = 'credential';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebuilds the session from raw keychain values. Installs from before
|
||||||
|
/// account kinds carry no [SessionKeys.kind]; a stored username and password
|
||||||
|
/// then mean a credential session, so existing users stay signed in.
|
||||||
|
Session? decodeSession(Map<String, String?> raw) {
|
||||||
|
final isDemo = raw[SessionKeys.demo] == 'true';
|
||||||
|
switch (raw[SessionKeys.kind]) {
|
||||||
|
case null:
|
||||||
|
case SessionKeys.kindCredential:
|
||||||
|
final username = raw[SessionKeys.username];
|
||||||
|
final password = raw[SessionKeys.password];
|
||||||
|
if (username == null || password == null) return null;
|
||||||
|
return CredentialSession(
|
||||||
|
username: username,
|
||||||
|
password: password,
|
||||||
|
appPassword: raw[SessionKeys.appPassword],
|
||||||
|
appPasswordTalk: raw[SessionKeys.appPasswordTalk],
|
||||||
|
usesLoginFlow: raw[SessionKeys.loginFlow] == 'true',
|
||||||
|
isDemo: isDemo,
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
// Written by a newer app version; unknown here, treat as signed out.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keychain values for [session], excluding the group-scoped app passwords
|
||||||
|
/// (written separately so the iOS NSE can read them). `null` = delete.
|
||||||
|
Map<String, String?> encodeSessionFields(Session session) => switch (session) {
|
||||||
|
CredentialSession() => {
|
||||||
|
SessionKeys.kind: SessionKeys.kindCredential,
|
||||||
|
SessionKeys.username: session.username,
|
||||||
|
SessionKeys.password: session.password,
|
||||||
|
SessionKeys.demo: session.isDemo ? 'true' : null,
|
||||||
|
SessionKeys.loginFlow: session.nextcloud.usesLoginFlow ? 'true' : null,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import 'dart:developer';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../api/marianumconnect/queries/auth_logout/auth_logout.dart';
|
||||||
|
import '../push/push_registration.dart';
|
||||||
|
import 'session_manager.dart';
|
||||||
|
|
||||||
|
abstract final class SessionLifecycle {
|
||||||
|
/// Why the last sign-out happened, when the user did not ask for it. The
|
||||||
|
/// login screen shows it once and clears it — without it an expired session
|
||||||
|
/// just drops the user on the login screen with no explanation.
|
||||||
|
static final ValueNotifier<String?> signOutNotice = ValueNotifier(null);
|
||||||
|
|
||||||
|
/// Ordered teardown: unregister push and revoke the Nextcloud app passwords
|
||||||
|
/// (while those credentials still exist), then revoke the MC bearer token,
|
||||||
|
/// finally wipe the local session. Each step is best-effort so an offline
|
||||||
|
/// sign-out still reaches a clean local state.
|
||||||
|
static Future<void> signOut({String? notice}) async {
|
||||||
|
signOutNotice.value = notice;
|
||||||
|
try {
|
||||||
|
await PushRegistration().logoutCleanup();
|
||||||
|
} on Object catch (e) {
|
||||||
|
log('Sign-out: push cleanup failed: $e');
|
||||||
|
}
|
||||||
|
await AuthLogout().run();
|
||||||
|
await SessionManager().signOut();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:developer';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import '../push/push_secure_storage.dart';
|
||||||
|
import '../utils/exponential_backoff.dart';
|
||||||
|
import 'nextcloud_credentials.dart';
|
||||||
|
import 'session.dart';
|
||||||
|
import 'session_codec.dart';
|
||||||
|
|
||||||
|
/// Owns the active [Session] and its persistence. One instance per isolate;
|
||||||
|
/// the widget background isolate reads the same keychain.
|
||||||
|
class SessionManager {
|
||||||
|
// `first_unlock` so a background launch on a locked device (silent push,
|
||||||
|
// BGAppRefresh) can still read the session. Items written by older versions
|
||||||
|
// carry the plugin default `unlocked` and are invisible to this instance
|
||||||
|
// until _migrateKeychainAccessibility moved them over.
|
||||||
|
static const FlutterSecureStorage _secureStorage = FlutterSecureStorage(
|
||||||
|
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
|
||||||
|
);
|
||||||
|
static const FlutterSecureStorage _legacySecureStorage = FlutterSecureStorage(
|
||||||
|
iOptions: IOSOptions(accessibility: KeychainAccessibility.unlocked),
|
||||||
|
);
|
||||||
|
static const List<String> _sessionFields = [
|
||||||
|
SessionKeys.kind,
|
||||||
|
SessionKeys.username,
|
||||||
|
SessionKeys.password,
|
||||||
|
SessionKeys.demo,
|
||||||
|
SessionKeys.loginFlow,
|
||||||
|
];
|
||||||
|
|
||||||
|
static final SessionManager _instance = SessionManager._();
|
||||||
|
factory SessionManager() => _instance;
|
||||||
|
|
||||||
|
SessionManager._() {
|
||||||
|
unawaited(_loadWithRetry());
|
||||||
|
}
|
||||||
|
|
||||||
|
Completer<void> _loaded = Completer();
|
||||||
|
Session? _current;
|
||||||
|
|
||||||
|
Session? get current => _current;
|
||||||
|
|
||||||
|
int _sessionEpoch = 0;
|
||||||
|
|
||||||
|
/// Bumped whenever the signed-in account changes. Async work captures it when it starts and drops its
|
||||||
|
/// result when it changed meanwhile, so a request of the previous account
|
||||||
|
/// cannot land in the next account's state or cache.
|
||||||
|
int get sessionEpoch => _sessionEpoch;
|
||||||
|
|
||||||
|
bool isCurrentSession(int epoch) => epoch == _sessionEpoch;
|
||||||
|
|
||||||
|
bool _isUiEngine = false;
|
||||||
|
|
||||||
|
/// Called from `main()`; background entry points never run it.
|
||||||
|
void markUiEngine() => _isUiEngine = true;
|
||||||
|
|
||||||
|
bool get isSignedIn => _current != null;
|
||||||
|
|
||||||
|
bool get isDemo => _current?.isDemo ?? false;
|
||||||
|
|
||||||
|
/// Whether the session has a Nextcloud identity (Talk, Files, NC push).
|
||||||
|
bool get hasNextcloud => _current?.nextcloud != null;
|
||||||
|
|
||||||
|
/// True once the stored session has been read (or given up on).
|
||||||
|
bool get isLoaded => _loaded.isCompleted;
|
||||||
|
|
||||||
|
/// Resolves once the stored session is known. After [signOut] it stays
|
||||||
|
/// pending until the next sign-in.
|
||||||
|
Future<Session?> waitForLoad() async {
|
||||||
|
await _loaded.future;
|
||||||
|
return _current;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops waiting for the stored session; the app then behaves as signed
|
||||||
|
/// out. The keychain entries stay untouched so a later start can still
|
||||||
|
/// restore the session.
|
||||||
|
void abandonLoad() {
|
||||||
|
if (!_loaded.isCompleted) _loaded.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
NextcloudCredentials requireNextcloud() =>
|
||||||
|
_current?.nextcloud ?? (throw const NextcloudUnavailableException());
|
||||||
|
|
||||||
|
/// Replaces any stored session completely; no prior [signOut] needed.
|
||||||
|
Future<void> signIn(Session session) async {
|
||||||
|
if (!_isSameAccount(_current, session)) _sessionEpoch++;
|
||||||
|
await Future.wait([
|
||||||
|
for (final MapEntry(:key, :value) in encodeSessionFields(session).entries)
|
||||||
|
_writeSecret(key, value),
|
||||||
|
_writeGroupSecret(
|
||||||
|
SessionKeys.appPassword,
|
||||||
|
session.nextcloud?.appPassword,
|
||||||
|
),
|
||||||
|
_writeGroupSecret(
|
||||||
|
SessionKeys.appPasswordTalk,
|
||||||
|
session.nextcloud?.appPasswordTalk,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
_current = session;
|
||||||
|
if (!_loaded.isCompleted) _loaded.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> signOut() async {
|
||||||
|
_sessionEpoch++;
|
||||||
|
_loaded = Completer();
|
||||||
|
_current = null;
|
||||||
|
await Future.wait([
|
||||||
|
for (final field in _sessionFields) _secureStorage.delete(key: field),
|
||||||
|
_writeGroupSecret(SessionKeys.appPassword, null),
|
||||||
|
_writeGroupSecret(SessionKeys.appPasswordTalk, null),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _isSameAccount(Session? a, Session? b) => switch ((a, b)) {
|
||||||
|
(final CredentialSession a, final CredentialSession b) =>
|
||||||
|
a.username == b.username && a.isDemo == b.isDemo,
|
||||||
|
_ => a == b,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Persists a freshly minted Nextcloud app password; from then on every
|
||||||
|
/// Nextcloud call authenticates with it instead of the real password.
|
||||||
|
Future<void> setAppPassword(String appPassword) async {
|
||||||
|
_updateNextcloud((nc) => nc.copyWith(appPassword: () => appPassword));
|
||||||
|
await _writeGroupSecret(SessionKeys.appPassword, appPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clearAppPassword() async {
|
||||||
|
_updateNextcloud((nc) => nc.copyWith(appPassword: () => null));
|
||||||
|
await _writeGroupSecret(SessionKeys.appPassword, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adopts an app password obtained via Login Flow v2 and switches the
|
||||||
|
/// account into flow mode. A previously stored Talk app password belonged
|
||||||
|
/// to the old session era and is dropped — the second (optional) flow pass
|
||||||
|
/// stores a fresh one via [setAppPasswordTalk].
|
||||||
|
Future<void> setLoginFlow(String appPassword) async {
|
||||||
|
await setAppPassword(appPassword);
|
||||||
|
await clearAppPasswordTalk();
|
||||||
|
_updateNextcloud((nc) => nc.copyWith(usesLoginFlow: true));
|
||||||
|
await _secureStorage.write(key: SessionKeys.loginFlow, value: 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setAppPasswordTalk(String appPassword) async {
|
||||||
|
_updateNextcloud((nc) => nc.copyWith(appPasswordTalk: () => appPassword));
|
||||||
|
await _writeGroupSecret(SessionKeys.appPasswordTalk, appPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clearAppPasswordTalk() async {
|
||||||
|
_updateNextcloud((nc) => nc.copyWith(appPasswordTalk: () => null));
|
||||||
|
await _writeGroupSecret(SessionKeys.appPasswordTalk, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateNextcloud(
|
||||||
|
NextcloudCredentials Function(NextcloudCredentials) update,
|
||||||
|
) {
|
||||||
|
final session = _current;
|
||||||
|
if (session is CredentialSession) {
|
||||||
|
_current = session.withNextcloud(update(session.nextcloud));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _writeSecret(String key, String? value) => value == null
|
||||||
|
? _secureStorage.delete(key: key)
|
||||||
|
: _secureStorage.write(key: key, value: value);
|
||||||
|
|
||||||
|
// App passwords live in the push-shared (group-scoped) keystore so the iOS
|
||||||
|
// Notification Service Extension can authenticate Nextcloud calls too. That
|
||||||
|
// keystore may be unavailable (entitlement not provisioned); the in-memory
|
||||||
|
// copy still serves this session.
|
||||||
|
Future<void> _writeGroupSecret(String key, String? value) async {
|
||||||
|
try {
|
||||||
|
if (value == null) {
|
||||||
|
await pushSecureStorage.delete(key: key);
|
||||||
|
} else {
|
||||||
|
await pushSecureStorage.write(key: key, value: value);
|
||||||
|
}
|
||||||
|
} on Object {
|
||||||
|
// ignore — see above
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// iOS keychain reads fail while protected data is unavailable (app launch
|
||||||
|
/// racing the unlock, background wake on a locked device). Without a retry
|
||||||
|
/// the completer never resolved and the app stayed on the launch screen.
|
||||||
|
Future<void> _loadWithRetry() async {
|
||||||
|
for (var attempt = 1; !_loaded.isCompleted; attempt++) {
|
||||||
|
try {
|
||||||
|
await _migrateAndLoad();
|
||||||
|
return;
|
||||||
|
} catch (e, s) {
|
||||||
|
log('Session load failed (attempt $attempt): $e', stackTrace: s);
|
||||||
|
await Future<void>.delayed(exponentialBackoff(attempt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _migrateAndLoad() async {
|
||||||
|
await _migrateFromLegacyStorage();
|
||||||
|
await _migrateKeychainAccessibility();
|
||||||
|
_current = await _readActive();
|
||||||
|
if (!_loaded.isCompleted) _loaded.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The session in the active slots. On the startup critical path (and every
|
||||||
|
/// background wake): read in parallel instead of one keychain round-trip
|
||||||
|
/// after the other.
|
||||||
|
Future<Session?> _readActive() async {
|
||||||
|
final values = await Future.wait(
|
||||||
|
_sessionFields.map((field) => _secureStorage.read(key: field)),
|
||||||
|
);
|
||||||
|
final raw = Map<String, String?>.fromIterables(_sessionFields, values);
|
||||||
|
try {
|
||||||
|
final (appPassword, appPasswordTalk) = await (
|
||||||
|
pushSecureStorage.read(key: SessionKeys.appPassword),
|
||||||
|
pushSecureStorage.read(key: SessionKeys.appPasswordTalk),
|
||||||
|
).wait;
|
||||||
|
raw[SessionKeys.appPassword] = appPassword;
|
||||||
|
raw[SessionKeys.appPasswordTalk] = appPasswordTalk;
|
||||||
|
} on Object {
|
||||||
|
// Group keystore unavailable: fall back to the real password.
|
||||||
|
}
|
||||||
|
return decodeSession(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Username currently in the keystore. Other engines (widget task, push
|
||||||
|
/// isolates) sign out or in without this instance noticing.
|
||||||
|
Future<String?> readStoredUsername() =>
|
||||||
|
_secureStorage.read(key: SessionKeys.username);
|
||||||
|
|
||||||
|
/// Re-reads the session for long-lived background engines: they load once
|
||||||
|
/// and would otherwise keep acting with an account that signed out in the
|
||||||
|
/// app meanwhile. Keeps the known state when the keystore is unreadable.
|
||||||
|
Future<void> reloadFromStorage() async {
|
||||||
|
// The UI engine performs sign-in and sign-out itself, so its state is
|
||||||
|
// current; re-reading mid sign-out could resurrect the removed account.
|
||||||
|
if (_isUiEngine) return;
|
||||||
|
try {
|
||||||
|
final session = await _readActive();
|
||||||
|
if (!_isSameAccount(_current, session)) _sessionEpoch++;
|
||||||
|
_current = session;
|
||||||
|
if (!_loaded.isCompleted) _loaded.complete();
|
||||||
|
} on Object catch (e) {
|
||||||
|
log('Session reload failed, keeping loaded state: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move credentials from the old SharedPreferences plain-text storage into the
|
||||||
|
// platform's secure keystore. Run once per install and clear the legacy keys.
|
||||||
|
Future<void> _migrateFromLegacyStorage() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final legacyUsername = prefs.getString(SessionKeys.username);
|
||||||
|
final legacyPassword = prefs.getString(SessionKeys.password);
|
||||||
|
if (legacyUsername == null || legacyPassword == null) return;
|
||||||
|
|
||||||
|
final hasSecure =
|
||||||
|
(await _secureStorage.read(key: SessionKeys.username)) != null;
|
||||||
|
if (!hasSecure) {
|
||||||
|
await _secureStorage.write(
|
||||||
|
key: SessionKeys.username,
|
||||||
|
value: legacyUsername,
|
||||||
|
);
|
||||||
|
await _secureStorage.write(
|
||||||
|
key: SessionKeys.password,
|
||||||
|
value: legacyPassword,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await prefs.remove(SessionKeys.username);
|
||||||
|
await prefs.remove(SessionKeys.password);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _migrateKeychainAccessibility() async {
|
||||||
|
if (!Platform.isIOS) return;
|
||||||
|
final legacyValues = await Future.wait(
|
||||||
|
_sessionFields.map((field) => _legacySecureStorage.read(key: field)),
|
||||||
|
);
|
||||||
|
for (final (i, field) in _sessionFields.indexed) {
|
||||||
|
final value = legacyValues[i];
|
||||||
|
if (value == null) continue;
|
||||||
|
// Same account+service: the legacy item has to go before the re-add.
|
||||||
|
await _legacySecureStorage.delete(key: field);
|
||||||
|
await _secureStorage.write(key: field, value: value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-3
@@ -5,7 +5,7 @@ import 'package:hydrated_bloc/hydrated_bloc.dart';
|
|||||||
|
|
||||||
import '../../../../../api/errors/error_mapper.dart';
|
import '../../../../../api/errors/error_mapper.dart';
|
||||||
import '../../../../../api/errors/stale_session_exception.dart';
|
import '../../../../../api/errors/stale_session_exception.dart';
|
||||||
import '../../../../../model/account_data.dart';
|
import '../../../../../session/session_manager.dart';
|
||||||
import '../../../../../utils/session_single_flight.dart';
|
import '../../../../../utils/session_single_flight.dart';
|
||||||
import '../../loadable_state/loadable_state.dart';
|
import '../../loadable_state/loadable_state.dart';
|
||||||
import '../../loadable_state/loading_error.dart';
|
import '../../loadable_state/loading_error.dart';
|
||||||
@@ -132,12 +132,12 @@ abstract class LoadableHydratedBloc<
|
|||||||
/// its async continuations, are dropped once the account signed out, so a
|
/// its async continuations, are dropped once the account signed out, so a
|
||||||
/// late response of the previous account cannot refill the reset bloc.
|
/// late response of the previous account cannot refill the reset bloc.
|
||||||
R runInSession<R>(R Function() body) =>
|
R runInSession<R>(R Function() body) =>
|
||||||
runZoned(body, zoneValues: {_sessionKey: AccountData().sessionEpoch});
|
runZoned(body, zoneValues: {_sessionKey: SessionManager().sessionEpoch});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void add(LoadableHydratedBlocEvent<TState> event) {
|
void add(LoadableHydratedBlocEvent<TState> event) {
|
||||||
final epoch = Zone.current[_sessionKey];
|
final epoch = Zone.current[_sessionKey];
|
||||||
if (epoch is int && !AccountData().isCurrentSession(epoch)) return;
|
if (epoch is int && !SessionManager().isCurrentSession(epoch)) return;
|
||||||
super.add(event);
|
super.add(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import 'package:hydrated_bloc/hydrated_bloc.dart';
|
|||||||
import '../../../../../api/demo/data/demo_capabilities.dart';
|
import '../../../../../api/demo/data/demo_capabilities.dart';
|
||||||
import '../../../../../api/demo/demo_mode.dart';
|
import '../../../../../api/demo/demo_mode.dart';
|
||||||
import '../../../../../api/marianumconnect/queries/get_capabilities/get_capabilities.dart';
|
import '../../../../../api/marianumconnect/queries/get_capabilities/get_capabilities.dart';
|
||||||
import '../../../../../model/account_data.dart';
|
import '../../../../../session/session_manager.dart';
|
||||||
import 'capabilities_state.dart';
|
import 'capabilities_state.dart';
|
||||||
|
|
||||||
/// Holds the current user's mobile capability flags. Hydrated so the last
|
/// Holds the current user's mobile capability flags. Hydrated so the last
|
||||||
@@ -35,12 +35,12 @@ class CapabilitiesCubit extends HydratedCubit<CapabilitiesState> {
|
|||||||
emit(DemoCapabilities.state());
|
emit(DemoCapabilities.state());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final epoch = AccountData().sessionEpoch;
|
final epoch = SessionManager().sessionEpoch;
|
||||||
try {
|
try {
|
||||||
final response = await GetCapabilities().run();
|
final response = await GetCapabilities().run();
|
||||||
// A slow answer for the previous account must not decide the modules
|
// A slow answer for the previous account must not decide the modules
|
||||||
// of the next one.
|
// of the next one.
|
||||||
if (!AccountData().isCurrentSession(epoch)) return;
|
if (!SessionManager().isCurrentSession(epoch)) return;
|
||||||
emit(
|
emit(
|
||||||
CapabilitiesState(
|
CapabilitiesState(
|
||||||
viewForeignTimetables: response.viewForeignTimetables,
|
viewForeignTimetables: response.viewForeignTimetables,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import 'package:hydrated_bloc/hydrated_bloc.dart';
|
|||||||
import '../../../../../api/demo/data/demo_capabilities.dart';
|
import '../../../../../api/demo/data/demo_capabilities.dart';
|
||||||
import '../../../../../api/demo/demo_mode.dart';
|
import '../../../../../api/demo/demo_mode.dart';
|
||||||
import '../../../../../api/marianumcloud/capabilities/get_nextcloud_capabilities.dart';
|
import '../../../../../api/marianumcloud/capabilities/get_nextcloud_capabilities.dart';
|
||||||
import '../../../../../model/account_data.dart';
|
import '../../../../../session/session_manager.dart';
|
||||||
import 'nextcloud_capabilities_state.dart';
|
import 'nextcloud_capabilities_state.dart';
|
||||||
|
|
||||||
/// Holds the current user's Nextcloud `files_sharing` capabilities. Hydrated so
|
/// Holds the current user's Nextcloud `files_sharing` capabilities. Hydrated so
|
||||||
@@ -60,16 +60,17 @@ class NextcloudCapabilitiesCubit
|
|||||||
/// Refreshes capabilities from the server. On any failure the previously
|
/// Refreshes capabilities from the server. On any failure the previously
|
||||||
/// hydrated flags are kept but the state is marked `loaded`.
|
/// hydrated flags are kept but the state is marked `loaded`.
|
||||||
Future<void> load() async {
|
Future<void> load() async {
|
||||||
|
if (!SessionManager().hasNextcloud) return;
|
||||||
if (DemoMode.active) {
|
if (DemoMode.active) {
|
||||||
emit(DemoNextcloudCapabilities.state());
|
emit(DemoNextcloudCapabilities.state());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final epoch = AccountData().sessionEpoch;
|
final epoch = SessionManager().sessionEpoch;
|
||||||
try {
|
try {
|
||||||
final caps = await GetNextcloudCapabilities().run();
|
final caps = await GetNextcloudCapabilities().run();
|
||||||
// A slow answer for the previous account must not decide the modules
|
// A slow answer for the previous account must not decide the modules
|
||||||
// of the next one.
|
// of the next one.
|
||||||
if (!AccountData().isCurrentSession(epoch)) return;
|
if (!SessionManager().isCurrentSession(epoch)) return;
|
||||||
emit(
|
emit(
|
||||||
NextcloudCapabilitiesState(
|
NextcloudCapabilitiesState(
|
||||||
apiEnabled: caps.apiEnabled,
|
apiEnabled: caps.apiEnabled,
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import 'package:background_downloader/background_downloader.dart' as bd;
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
import '../../api/marianumcloud/webdav/webdav_api.dart';
|
import '../../api/marianumcloud/webdav/webdav_api.dart';
|
||||||
import '../../model/account_data.dart';
|
|
||||||
import '../../notification/notification_service.dart';
|
import '../../notification/notification_service.dart';
|
||||||
|
import '../../session/session_manager.dart';
|
||||||
import '../../share_intent/remote_file_ref.dart';
|
import '../../share_intent/remote_file_ref.dart';
|
||||||
import 'download_job.dart';
|
import 'download_job.dart';
|
||||||
|
|
||||||
@@ -116,7 +116,7 @@ class DownloadManager {
|
|||||||
final encodedPath = Uri.encodeComponent(remotePath).replaceAll('%2F', '/');
|
final encodedPath = Uri.encodeComponent(remotePath).replaceAll('%2F', '/');
|
||||||
final task = bd.DownloadTask(
|
final task = bd.DownloadTask(
|
||||||
url: '${WebdavApi.buildWebdavUrl()}$encodedPath',
|
url: '${WebdavApi.buildWebdavUrl()}$encodedPath',
|
||||||
headers: AccountData().authHeaders(),
|
headers: SessionManager().requireNextcloud().authHeaders,
|
||||||
filename: name,
|
filename: name,
|
||||||
baseDirectory: bd.BaseDirectory.temporary,
|
baseDirectory: bd.BaseDirectory.temporary,
|
||||||
directory: _directory,
|
directory: _directory,
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
/// Random hex id from a cryptographic RNG, e.g. for per-install identifiers.
|
||||||
|
String randomHexId({int bytes = 16}) {
|
||||||
|
final random = Random.secure();
|
||||||
|
return List<int>.generate(
|
||||||
|
bytes,
|
||||||
|
(_) => random.nextInt(256),
|
||||||
|
).map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Random RFC 4122 version-4 UUID, for ids a server expects in UUID form.
|
||||||
|
String randomUuidV4() {
|
||||||
|
final hex = randomHexId();
|
||||||
|
final variant = (int.parse(hex[16], radix: 16) & 0x3 | 0x8).toRadixString(16);
|
||||||
|
return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-'
|
||||||
|
'4${hex.substring(13, 16)}-$variant${hex.substring(17, 20)}-'
|
||||||
|
'${hex.substring(20)}';
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import '../model/account_data.dart';
|
import '../session/session_manager.dart';
|
||||||
|
|
||||||
/// Joins concurrent calls into the one already running for the same account
|
/// Joins concurrent calls into the one already running for the same account
|
||||||
/// session. A run left over from a signed-out session never blocks the next
|
/// session. A run left over from a signed-out session never blocks the next
|
||||||
@@ -8,7 +8,7 @@ class SessionSingleFlight {
|
|||||||
int? _epoch;
|
int? _epoch;
|
||||||
|
|
||||||
Future<void> run(Future<void> Function() action) {
|
Future<void> run(Future<void> Function() action) {
|
||||||
final epoch = AccountData().sessionEpoch;
|
final epoch = SessionManager().sessionEpoch;
|
||||||
final running = _running;
|
final running = _running;
|
||||||
if (running != null && _epoch == epoch) return running;
|
if (running != null && _epoch == epoch) return running;
|
||||||
_epoch = epoch;
|
_epoch = epoch;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../model/account_data.dart';
|
import '../../session/session_manager.dart';
|
||||||
import '../../theming/light_app_theme.dart';
|
import '../../theming/light_app_theme.dart';
|
||||||
import '../../widget/app_progress_indicator.dart';
|
import '../../widget/app_progress_indicator.dart';
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ class _AccountLoadingScreenState extends State<AccountLoadingScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: AccountData().abandonLoad,
|
onPressed: SessionManager().abandonLoad,
|
||||||
style: TextButton.styleFrom(foregroundColor: Colors.white),
|
style: TextButton.styleFrom(foregroundColor: Colors.white),
|
||||||
child: const Text('Zur Anmeldung'),
|
child: const Text('Zur Anmeldung'),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
import '../../background/widget_background_task.dart';
|
import '../../background/widget_background_task.dart';
|
||||||
|
import '../../session/session_lifecycle.dart';
|
||||||
import '../../state/app/modules/account/bloc/account_bloc.dart';
|
import '../../state/app/modules/account/bloc/account_bloc.dart';
|
||||||
import '../../state/app/modules/account/bloc/account_state.dart';
|
import '../../state/app/modules/account/bloc/account_state.dart';
|
||||||
import '../../state/app/modules/settings/bloc/settings_cubit.dart';
|
import '../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||||
@@ -11,6 +12,7 @@ import '../../storage/dev_tools_settings.dart';
|
|||||||
import '../../storage/settings.dart' as model;
|
import '../../storage/settings.dart' as model;
|
||||||
import '../../theming/light_app_theme.dart';
|
import '../../theming/light_app_theme.dart';
|
||||||
import '../../utils/haptics.dart';
|
import '../../utils/haptics.dart';
|
||||||
|
import '../../widget/info_dialog.dart';
|
||||||
import '../pages/settings/widgets/endpoint_picker.dart';
|
import '../pages/settings/widgets/endpoint_picker.dart';
|
||||||
import 'login_controller.dart';
|
import 'login_controller.dart';
|
||||||
import 'post_login_splash.dart';
|
import 'post_login_splash.dart';
|
||||||
@@ -34,6 +36,21 @@ class _LoginState extends State<Login> with SingleTickerProviderStateMixin {
|
|||||||
value: 1,
|
value: 1,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _showSignOutNotice());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An involuntary sign-out (expired token, rotated password) otherwise just
|
||||||
|
/// drops the user here without a word.
|
||||||
|
void _showSignOutNotice() {
|
||||||
|
final notice = SessionLifecycle.signOutNotice.value;
|
||||||
|
if (notice == null || !mounted) return;
|
||||||
|
SessionLifecycle.signOutNotice.value = null;
|
||||||
|
InfoDialog.show(context, notice, title: 'Erneut anmelden');
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ import '../../api/marianumconnect/auth/device_token_name.dart';
|
|||||||
import '../../api/marianumconnect/auth/token_storage.dart';
|
import '../../api/marianumconnect/auth/token_storage.dart';
|
||||||
import '../../api/marianumconnect/queries/auth_login/auth_login.dart';
|
import '../../api/marianumconnect/queries/auth_login/auth_login.dart';
|
||||||
import '../../api/marianumconnect/queries/auth_logout/auth_logout.dart';
|
import '../../api/marianumconnect/queries/auth_logout/auth_logout.dart';
|
||||||
import '../../model/account_data.dart';
|
|
||||||
import '../../model/session_wipe.dart';
|
import '../../model/session_wipe.dart';
|
||||||
|
import '../../session/session.dart';
|
||||||
|
import '../../session/session_manager.dart';
|
||||||
import '../../widget_data/widget_sync.dart';
|
import '../../widget_data/widget_sync.dart';
|
||||||
|
|
||||||
/// Outcome of a login attempt.
|
/// Outcome of a login attempt.
|
||||||
@@ -56,25 +57,18 @@ class LoginController extends ChangeNotifier {
|
|||||||
// Demo login: the prefix enters local demo mode, password ignored, no
|
// Demo login: the prefix enters local demo mode, password ignored, no
|
||||||
// network (see DemoMode).
|
// network (see DemoMode).
|
||||||
if (DemoMode.matches(user)) {
|
if (DemoMode.matches(user)) {
|
||||||
await AccountData().removeData();
|
await _discardPreviousAccount();
|
||||||
await const MarianumConnectTokenStorage().clear();
|
await SessionManager().signIn(
|
||||||
await WidgetSync.clear();
|
CredentialSession(username: user, password: 'demo', isDemo: true),
|
||||||
await WidgetSync.triggerUpdate();
|
);
|
||||||
await AccountData().setDemo(user);
|
|
||||||
_loading = false;
|
_loading = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
return LoginResult.success;
|
return LoginResult.success;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var signedIn = false;
|
||||||
try {
|
try {
|
||||||
await AccountData().removeData();
|
await _discardPreviousAccount();
|
||||||
// Vorherigen Token revoken bevor wir einen neuen anfordern — ein altes
|
|
||||||
// Account hätte sonst noch einen aktiven Token in api_tokens.
|
|
||||||
await const MarianumConnectTokenStorage().clear();
|
|
||||||
// Widget-Snapshot löschen, sonst blitzt nach Account-Wechsel kurz der
|
|
||||||
// Stundenplan des vorigen Users auf dem Home-Bildschirm.
|
|
||||||
await WidgetSync.clear();
|
|
||||||
await WidgetSync.triggerUpdate();
|
|
||||||
// AuthLogin = Credential-Probe + Token-Create in einem Call.
|
// AuthLogin = Credential-Probe + Token-Create in einem Call.
|
||||||
// 401 hier heißt: falsches Passwort.
|
// 401 hier heißt: falsches Passwort.
|
||||||
await AuthLogin().run(
|
await AuthLogin().run(
|
||||||
@@ -82,7 +76,10 @@ class LoginController extends ChangeNotifier {
|
|||||||
password: password,
|
password: password,
|
||||||
tokenName: await DeviceTokenName.resolve(),
|
tokenName: await DeviceTokenName.resolve(),
|
||||||
);
|
);
|
||||||
await AccountData().setData(user, password);
|
await SessionManager().signIn(
|
||||||
|
CredentialSession(username: user, password: password),
|
||||||
|
);
|
||||||
|
signedIn = true;
|
||||||
// Mint the Nextcloud app password now — it doubles as the Nextcloud
|
// Mint the Nextcloud app password now — it doubles as the Nextcloud
|
||||||
// credential probe: a rejection means 2FA is active (or the NC password
|
// credential probe: a rejection means 2FA is active (or the NC password
|
||||||
// diverges) and the login must finish interactively in the browser.
|
// diverges) and the login must finish interactively in the browser.
|
||||||
@@ -92,7 +89,9 @@ class LoginController extends ChangeNotifier {
|
|||||||
return ncReady ? LoginResult.success : LoginResult.nextcloudLoginRequired;
|
return ncReady ? LoginResult.success : LoginResult.nextcloudLoginRequired;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log(e.toString());
|
log(e.toString());
|
||||||
await AccountData().removeData();
|
// Only the account signed in by this attempt; while adding an account
|
||||||
|
// the active one is parked and restored on cancel.
|
||||||
|
if (signedIn) await SessionManager().signOut();
|
||||||
await const MarianumConnectTokenStorage().clear();
|
await const MarianumConnectTokenStorage().clear();
|
||||||
final isWrongCredentials = e is AuthException && e.statusCode == 401;
|
final isWrongCredentials = e is AuthException && e.statusCode == 401;
|
||||||
_errorMessage = isWrongCredentials
|
_errorMessage = isWrongCredentials
|
||||||
@@ -105,6 +104,17 @@ class LoginController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Vorherigen Token verwerfen, bevor ein neuer angefordert wird, und den
|
||||||
|
/// Widget-Snapshot löschen — sonst blitzt nach einem Account-Wechsel kurz
|
||||||
|
/// der Stundenplan des vorigen Users auf dem Home-Bildschirm. Die Session
|
||||||
|
/// selbst überschreibt signIn vollständig; beim Hinzufügen eines Kontos
|
||||||
|
/// liegt der Token des aktiven Kontos schon in dessen Tresor.
|
||||||
|
Future<void> _discardPreviousAccount() async {
|
||||||
|
await const MarianumConnectTokenStorage().clear();
|
||||||
|
await WidgetSync.clear();
|
||||||
|
await WidgetSync.triggerUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
/// Tries to mint the Nextcloud app password with the just-verified password.
|
/// Tries to mint the Nextcloud app password with the just-verified password.
|
||||||
/// `false` = Nextcloud rejected the credentials → Login Flow v2 required.
|
/// `false` = Nextcloud rejected the credentials → Login Flow v2 required.
|
||||||
/// Transport/server problems stay non-blocking (like the previous
|
/// Transport/server problems stay non-blocking (like the previous
|
||||||
@@ -112,7 +122,7 @@ class LoginController extends ChangeNotifier {
|
|||||||
Future<bool> _prepareNextcloudAppPassword() async {
|
Future<bool> _prepareNextcloudAppPassword() async {
|
||||||
try {
|
try {
|
||||||
final appPassword = await GetAppPassword().run();
|
final appPassword = await GetAppPassword().run();
|
||||||
await AccountData().setAppPassword(appPassword);
|
await SessionManager().setAppPassword(appPassword);
|
||||||
return true;
|
return true;
|
||||||
} on AuthException {
|
} on AuthException {
|
||||||
return false;
|
return false;
|
||||||
@@ -131,7 +141,7 @@ class LoginController extends ChangeNotifier {
|
|||||||
} on Object catch (e) {
|
} on Object catch (e) {
|
||||||
log('Login rollback: MC logout failed: $e');
|
log('Login rollback: MC logout failed: $e');
|
||||||
}
|
}
|
||||||
await AccountData().removeData();
|
await SessionManager().signOut();
|
||||||
await const MarianumConnectTokenStorage().clear();
|
await const MarianumConnectTokenStorage().clear();
|
||||||
_errorMessage =
|
_errorMessage =
|
||||||
'Die Anmeldung wurde abgebrochen — dein Konto erfordert die Bestätigung im Browser.';
|
'Die Anmeldung wurde abgebrochen — dein Konto erfordert die Bestätigung im Browser.';
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import 'package:flutter/material.dart';
|
|||||||
import '../../api/errors/error_mapper.dart';
|
import '../../api/errors/error_mapper.dart';
|
||||||
import '../../api/marianumcloud/app_password/delete_app_password.dart';
|
import '../../api/marianumcloud/app_password/delete_app_password.dart';
|
||||||
import '../../api/marianumcloud/login_flow/login_flow_api.dart';
|
import '../../api/marianumcloud/login_flow/login_flow_api.dart';
|
||||||
import '../../model/account_data.dart';
|
import '../../session/session_manager.dart';
|
||||||
import '../../utils/url_opener.dart';
|
import '../../utils/url_opener.dart';
|
||||||
import '../../widget/app_progress_indicator.dart';
|
import '../../widget/app_progress_indicator.dart';
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ enum _FlowStep { primary, talk }
|
|||||||
|
|
||||||
/// Runs the Nextcloud Login Flow v2: opens the browser login, polls until the
|
/// Runs the Nextcloud Login Flow v2: opens the browser login, polls until the
|
||||||
/// user confirmed it there (2FA happens inside the browser) and adopts the
|
/// user confirmed it there (2FA happens inside the browser) and adopts the
|
||||||
/// returned app password via [AccountData.setLoginFlow]. A second, skippable
|
/// returned app password via [SessionManager.setLoginFlow]. A second, skippable
|
||||||
/// pass mints the Talk app password so flow accounts keep BOTH push
|
/// pass mints the Talk app password so flow accounts keep BOTH push
|
||||||
/// subscriptions (see PushRegistrationType). Pops `true` once the primary
|
/// subscriptions (see PushRegistrationType). Pops `true` once the primary
|
||||||
/// credential was adopted, `false`/`null` when the user backs out before that.
|
/// credential was adopted, `false`/`null` when the user backs out before that.
|
||||||
@@ -104,7 +104,7 @@ class _NextcloudLoginFlowPageState extends State<NextcloudLoginFlowPage>
|
|||||||
final credentials = await _api.poll(flow);
|
final credentials = await _api.poll(flow);
|
||||||
if (credentials == null || _finished || !mounted) return;
|
if (credentials == null || _finished || !mounted) return;
|
||||||
if (!LoginFlowApi.loginNameMatches(
|
if (!LoginFlowApi.loginNameMatches(
|
||||||
expected: AccountData().getUsername(),
|
expected: SessionManager().requireNextcloud().username,
|
||||||
actual: credentials.loginName,
|
actual: credentials.loginName,
|
||||||
)) {
|
)) {
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
@@ -120,7 +120,7 @@ class _NextcloudLoginFlowPageState extends State<NextcloudLoginFlowPage>
|
|||||||
}
|
}
|
||||||
switch (_step) {
|
switch (_step) {
|
||||||
case _FlowStep.primary:
|
case _FlowStep.primary:
|
||||||
await AccountData().setLoginFlow(credentials.appPassword);
|
await SessionManager().setLoginFlow(credentials.appPassword);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
// Zweite Freigabe direkt anstoßen: die Browser-Session besteht
|
// Zweite Freigabe direkt anstoßen: die Browser-Session besteht
|
||||||
// bereits, es fehlt nur noch der Grant-Tipp.
|
// bereits, es fehlt nur noch der Grant-Tipp.
|
||||||
@@ -129,7 +129,7 @@ class _NextcloudLoginFlowPageState extends State<NextcloudLoginFlowPage>
|
|||||||
case _FlowStep.talk:
|
case _FlowStep.talk:
|
||||||
_finished = true;
|
_finished = true;
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
await AccountData().setAppPasswordTalk(credentials.appPassword);
|
await SessionManager().setAppPasswordTalk(credentials.appPassword);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
Navigator.of(context).pop(true);
|
Navigator.of(context).pop(true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import 'package:cached_network_image/cached_network_image.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart';
|
import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart';
|
||||||
import '../../../../model/account_data.dart';
|
|
||||||
import '../../../../model/endpoint_data.dart';
|
import '../../../../model/endpoint_data.dart';
|
||||||
|
import '../../../../session/session_manager.dart';
|
||||||
import '../data/file_type_icon.dart';
|
import '../data/file_type_icon.dart';
|
||||||
|
|
||||||
/// Leading slot for a file row: shows the Nextcloud thumbnail when the
|
/// Leading slot for a file row: shows the Nextcloud thumbnail when the
|
||||||
@@ -35,7 +35,7 @@ class FileLeading extends StatelessWidget {
|
|||||||
'https://${EndpointData().nextcloud().full()}'
|
'https://${EndpointData().nextcloud().full()}'
|
||||||
'/index.php/core/preview'
|
'/index.php/core/preview'
|
||||||
'?fileId=$fileId&x=128&y=128&a=0',
|
'?fileId=$fileId&x=128&y=128&a=0',
|
||||||
httpHeaders: AccountData().authHeaders(),
|
httpHeaders: SessionManager().requireNextcloud().authHeaders,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
fadeInDuration: Duration.zero,
|
fadeInDuration: Duration.zero,
|
||||||
fadeOutDuration: Duration.zero,
|
fadeOutDuration: Duration.zero,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import '../../../api/marianumconnect/queries/timetable_get_element_week/timetabl
|
|||||||
import '../../../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms.dart';
|
import '../../../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms.dart';
|
||||||
import '../../../api/marianumconnect/queries/timetable_get_students/timetable_get_students.dart';
|
import '../../../api/marianumconnect/queries/timetable_get_students/timetable_get_students.dart';
|
||||||
import '../../../api/marianumconnect/queries/timetable_get_teachers/timetable_get_teachers.dart';
|
import '../../../api/marianumconnect/queries/timetable_get_teachers/timetable_get_teachers.dart';
|
||||||
import '../../../model/account_data.dart';
|
import '../../../session/session_manager.dart';
|
||||||
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||||
import '../../../storage/timetable_favorites_settings.dart';
|
import '../../../storage/timetable_favorites_settings.dart';
|
||||||
import '../../../utils/haptics.dart';
|
import '../../../utils/haptics.dart';
|
||||||
@@ -56,7 +56,7 @@ class _ElementPickerPageState extends State<ElementPickerPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<List<_PickerItem>> _loadFor(TimetableElementType type) {
|
Future<List<_PickerItem>> _loadFor(TimetableElementType type) {
|
||||||
final epoch = AccountData().sessionEpoch;
|
final epoch = SessionManager().sessionEpoch;
|
||||||
final cached = _futures[type];
|
final cached = _futures[type];
|
||||||
if (cached != null &&
|
if (cached != null &&
|
||||||
cached.epoch == epoch &&
|
cached.epoch == epoch &&
|
||||||
|
|||||||
@@ -13,10 +13,11 @@ import '../../../api/marianumconnect/marianumconnect_endpoint.dart';
|
|||||||
import '../../../api/marianumconnect/queries/telemetry_heartbeat/telemetry_device_id.dart';
|
import '../../../api/marianumconnect/queries/telemetry_heartbeat/telemetry_device_id.dart';
|
||||||
import '../../../background/widget_background_task.dart';
|
import '../../../background/widget_background_task.dart';
|
||||||
import '../../../extensions/date_time.dart';
|
import '../../../extensions/date_time.dart';
|
||||||
import '../../../model/account_data.dart';
|
|
||||||
import '../../../model/endpoint_data.dart';
|
import '../../../model/endpoint_data.dart';
|
||||||
import '../../../push/push_registration_store.dart';
|
import '../../../push/push_registration_store.dart';
|
||||||
import '../../../push/push_registration_type.dart';
|
import '../../../push/push_registration_type.dart';
|
||||||
|
import '../../../session/session.dart';
|
||||||
|
import '../../../session/session_manager.dart';
|
||||||
import '../../../state/app/modules/account/bloc/account_bloc.dart';
|
import '../../../state/app/modules/account/bloc/account_bloc.dart';
|
||||||
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||||
import '../../../utils/clipboard_helper.dart';
|
import '../../../utils/clipboard_helper.dart';
|
||||||
@@ -44,7 +45,8 @@ class _DiagnosticsPageState extends State<DiagnosticsPage> {
|
|||||||
Future<List<_Section>> _collect() async {
|
Future<List<_Section>> _collect() async {
|
||||||
final settings = context.read<SettingsCubit>().val();
|
final settings = context.read<SettingsCubit>().val();
|
||||||
final accountStatus = context.read<AccountBloc>().state.status;
|
final accountStatus = context.read<AccountBloc>().state.status;
|
||||||
final account = AccountData();
|
final session = SessionManager().current;
|
||||||
|
final nextcloud = session?.nextcloud;
|
||||||
const tokens = MarianumConnectTokenStorage();
|
const tokens = MarianumConnectTokenStorage();
|
||||||
final push = PushRegistrationStore();
|
final push = PushRegistrationStore();
|
||||||
|
|
||||||
@@ -101,13 +103,17 @@ class _DiagnosticsPageState extends State<DiagnosticsPage> {
|
|||||||
title: 'Sitzung',
|
title: 'Sitzung',
|
||||||
rows: await rows({
|
rows: await rows({
|
||||||
'Status': () async => accountStatus.name,
|
'Status': () async => accountStatus.name,
|
||||||
'Benutzer': () async =>
|
'Benutzer': () async => switch (session) {
|
||||||
account.isPopulated() ? account.getUsername() : null,
|
CredentialSession(:final username) => username,
|
||||||
'Demo-Modus': () async => account.isDemo,
|
null => null,
|
||||||
'Login Flow v2': () async => account.usesLoginFlow,
|
},
|
||||||
'App-Passwort (Dateien)': () async => account.hasAppPassword(),
|
'Demo-Modus': () async => session?.isDemo ?? false,
|
||||||
'App-Passwort (Talk)': () async => account.hasAppPasswordTalk(),
|
'Login Flow v2': () async => nextcloud?.usesLoginFlow ?? false,
|
||||||
'Session-Epoche': () async => account.sessionEpoch,
|
'App-Passwort (Dateien)': () async =>
|
||||||
|
nextcloud?.hasAppPassword ?? false,
|
||||||
|
'App-Passwort (Talk)': () async =>
|
||||||
|
nextcloud?.hasAppPasswordTalk ?? false,
|
||||||
|
'Session-Epoche': () async => SessionManager().sessionEpoch,
|
||||||
'MC-Token-ID': tokens.readTokenId,
|
'MC-Token-ID': tokens.readTokenId,
|
||||||
'MC-Token läuft ab': tokens.readExpiresAt,
|
'MC-Token läuft ab': tokens.readExpiresAt,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
import '../../../../api/marianumcloud/cloud_users/cloud_users_actions.dart';
|
import '../../../../api/marianumcloud/cloud_users/cloud_users_actions.dart';
|
||||||
import '../../../../api/marianumconnect/queries/auth_logout/auth_logout.dart';
|
|
||||||
import '../../../../model/account_data.dart';
|
|
||||||
import '../../../../push/push_registration.dart';
|
import '../../../../push/push_registration.dart';
|
||||||
import '../../../../routing/app_routes.dart';
|
import '../../../../routing/app_routes.dart';
|
||||||
|
import '../../../../session/session_lifecycle.dart';
|
||||||
|
import '../../../../session/session_manager.dart';
|
||||||
import '../../../../state/app/modules/account/bloc/account_bloc.dart';
|
import '../../../../state/app/modules/account/bloc/account_bloc.dart';
|
||||||
import '../../../../state/app/modules/account/bloc/account_state.dart';
|
import '../../../../state/app/modules/account/bloc/account_state.dart';
|
||||||
import '../../../../widget/app_progress_indicator.dart';
|
import '../../../../widget/app_progress_indicator.dart';
|
||||||
@@ -84,13 +84,17 @@ class _AccountSectionState extends State<AccountSection> {
|
|||||||
setState(() => _avatarBusy = false);
|
setState(() => _avatarBusy = false);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
|
||||||
invalidateAvatarCache(id: AccountData().getUsername(), isGroup: false);
|
invalidateAvatarCache(
|
||||||
|
id: SessionManager().requireNextcloud().username,
|
||||||
|
isGroup: false,
|
||||||
|
);
|
||||||
setState(() => _avatarVersion++);
|
setState(() => _avatarVersion++);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final username = AccountData().getUsername();
|
final nextcloud = SessionManager().requireNextcloud();
|
||||||
|
final username = nextcloud.username;
|
||||||
final displayName = _displayName;
|
final displayName = _displayName;
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
@@ -175,7 +179,7 @@ class _AccountSectionState extends State<AccountSection> {
|
|||||||
// Nur für Login-Flow-Konten (2FA) sichtbar — Passwort-Konten heilen
|
// Nur für Login-Flow-Konten (2FA) sichtbar — Passwort-Konten heilen
|
||||||
// sich still über das App-Passwort-Minting und sollen von dem ganzen
|
// sich still über das App-Passwort-Minting und sollen von dem ganzen
|
||||||
// Flow-Mechanismus nichts mitbekommen.
|
// Flow-Mechanismus nichts mitbekommen.
|
||||||
if (!AccountData().isDemo && AccountData().usesLoginFlow)
|
if (!SessionManager().isDemo && nextcloud.usesLoginFlow)
|
||||||
AsyncListTile(
|
AsyncListTile(
|
||||||
leading: const Icon(Icons.cloud_sync_outlined),
|
leading: const Icon(Icons.cloud_sync_outlined),
|
||||||
title: const Text('Nextcloud neu verbinden'),
|
title: const Text('Nextcloud neu verbinden'),
|
||||||
@@ -203,7 +207,7 @@ class _AccountSectionState extends State<AccountSection> {
|
|||||||
|
|
||||||
Future<void> _showLogoutDialog(BuildContext context) async {
|
Future<void> _showLogoutDialog(BuildContext context) async {
|
||||||
// Flip AccountBloc state only after the dialog fully closes: doing it from
|
// Flip AccountBloc state only after the dialog fully closes: doing it from
|
||||||
// inside removeData (the previous approach) raced AsyncDialogAction's
|
// inside the sign-out (the previous approach) raced AsyncDialogAction's
|
||||||
// pop(true) against the listener's popUntil(isFirst) and could leave the
|
// pop(true) against the listener's popUntil(isFirst) and could leave the
|
||||||
// navigator in an inconsistent state.
|
// navigator in an inconsistent state.
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
@@ -219,14 +223,8 @@ class _AccountSectionState extends State<AccountSection> {
|
|||||||
context.read<AccountBloc>().setStatus(AccountStatus.loggedOut);
|
context.read<AccountBloc>().setStatus(AccountStatus.loggedOut);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ordered teardown: unregister push at Nextcloud + proxy and revoke the app
|
|
||||||
// password (while Nextcloud credentials are still available), THEN revoke the
|
|
||||||
// MC bearer token, and finally wipe local credentials. Each step is
|
|
||||||
// best-effort so an offline logout still reaches a clean local state.
|
|
||||||
Future<void> _performLogout() async {
|
Future<void> _performLogout() async {
|
||||||
await PushRegistration().logoutCleanup();
|
await SessionLifecycle.signOut();
|
||||||
await AuthLogout().run();
|
|
||||||
await AccountData().removeData();
|
|
||||||
_cachedDisplayName = null;
|
_cachedDisplayName = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
|
import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
|
||||||
import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart';
|
import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart';
|
||||||
import '../../../../model/account_data.dart';
|
|
||||||
import '../../../../model/endpoint_data.dart';
|
import '../../../../model/endpoint_data.dart';
|
||||||
|
import '../../../../session/session_manager.dart';
|
||||||
import '../../../../utils/emoji_detection.dart';
|
import '../../../../utils/emoji_detection.dart';
|
||||||
import '../../../../utils/url_opener.dart';
|
import '../../../../utils/url_opener.dart';
|
||||||
import '../widgets/highlighted_linkify.dart';
|
import '../widgets/highlighted_linkify.dart';
|
||||||
@@ -105,7 +105,7 @@ class ChatMessage {
|
|||||||
fadeInDuration: Duration.zero,
|
fadeInDuration: Duration.zero,
|
||||||
fadeOutDuration: Duration.zero,
|
fadeOutDuration: Duration.zero,
|
||||||
errorListener: (value) {},
|
errorListener: (value) {},
|
||||||
httpHeaders: AccountData().authHeaders(),
|
httpHeaders: SessionManager().requireNextcloud().authHeaders,
|
||||||
imageUrl:
|
imageUrl:
|
||||||
'https://${EndpointData().nextcloud().full()}/index.php/core/preview?fileId=${file!.id}&x=130&y=-1&a=1',
|
'https://${EndpointData().nextcloud().full()}/index.php/core/preview?fileId=${file!.id}&x=130&y=-1&a=1',
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import '../../../../api/marianumcloud/talk/get_reactions/get_reactions.dart';
|
import '../../../../api/marianumcloud/talk/get_reactions/get_reactions.dart';
|
||||||
import '../../../../api/marianumcloud/talk/get_reactions/get_reactions_response.dart';
|
import '../../../../api/marianumcloud/talk/get_reactions/get_reactions_response.dart';
|
||||||
import '../../../../model/account_data.dart';
|
import '../../../../session/session_manager.dart';
|
||||||
import '../../../../widget/centered_leading.dart';
|
import '../../../../widget/centered_leading.dart';
|
||||||
import '../../../../widget/emoji_text.dart';
|
import '../../../../widget/emoji_text.dart';
|
||||||
import '../../../../widget/loading_spinner.dart';
|
import '../../../../widget/loading_spinner.dart';
|
||||||
@@ -63,10 +63,10 @@ class _MessageReactionsState extends State<MessageReactions> {
|
|||||||
leading: CenteredLeading(EmojiText(entry.key)),
|
leading: CenteredLeading(EmojiText(entry.key)),
|
||||||
title: Text('${entry.value.length} mal reagiert'),
|
title: Text('${entry.value.length} mal reagiert'),
|
||||||
children: entry.value.map((e) {
|
children: entry.value.map((e) {
|
||||||
final isSelf = AccountData().getUsername() == e.actorId;
|
final isSelf =
|
||||||
|
SessionManager().requireNextcloud().username == e.actorId;
|
||||||
final isGuest =
|
final isGuest =
|
||||||
e.actorType ==
|
e.actorType == GetReactionsResponseObjectActorType.guests;
|
||||||
GetReactionsResponseObjectActorType.guests;
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: UserAvatar(id: e.actorId, isGroup: false),
|
leading: UserAvatar(id: e.actorId, isGroup: false),
|
||||||
title: Text(e.actorDisplayName),
|
title: Text(e.actorDisplayName),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import 'package:collection/collection.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../../../api/marianumcloud/talk/get_participants/get_participants_response.dart';
|
import '../../../../api/marianumcloud/talk/get_participants/get_participants_response.dart';
|
||||||
import '../../../../model/account_data.dart';
|
import '../../../../session/session_manager.dart';
|
||||||
import '../../../../widget/user_avatar.dart';
|
import '../../../../widget/user_avatar.dart';
|
||||||
import '../data/open_direct_chat.dart';
|
import '../data/open_direct_chat.dart';
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ class ParticipantsListView extends StatelessWidget {
|
|||||||
(participant) => participant.participantType,
|
(participant) => participant.participantType,
|
||||||
);
|
);
|
||||||
|
|
||||||
final selfId = AccountData().getUsername();
|
final selfId = SessionManager().requireNextcloud().username;
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Mitglieder')),
|
appBar: AppBar(title: const Text('Mitglieder')),
|
||||||
body: ListView(
|
body: ListView(
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import '../../../../api/marianumcloud/talk/get_shared_items/get_shared_items_ove
|
|||||||
import '../../../../api/marianumcloud/talk/get_shared_items/get_shared_items_response.dart';
|
import '../../../../api/marianumcloud/talk/get_shared_items/get_shared_items_response.dart';
|
||||||
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
||||||
import '../../../../extensions/date_time.dart';
|
import '../../../../extensions/date_time.dart';
|
||||||
import '../../../../model/account_data.dart';
|
|
||||||
import '../../../../model/endpoint_data.dart';
|
import '../../../../model/endpoint_data.dart';
|
||||||
|
import '../../../../session/session_manager.dart';
|
||||||
import '../../../../share_intent/remote_file_ref.dart';
|
import '../../../../share_intent/remote_file_ref.dart';
|
||||||
import '../../../../utils/downloads/download_job.dart';
|
import '../../../../utils/downloads/download_job.dart';
|
||||||
import '../../../../widget/app_progress_indicator.dart';
|
import '../../../../widget/app_progress_indicator.dart';
|
||||||
@@ -54,9 +54,10 @@ class SharedItemsPage {
|
|||||||
const SharedItemsPage(this.items, this.lastKnownMessageId, this.hasMore);
|
const SharedItemsPage(this.items, this.lastKnownMessageId, this.hasMore);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<GetChatResponseObject> _fileItems(List<GetChatResponseObject> items) => items
|
List<GetChatResponseObject> _fileItems(List<GetChatResponseObject> items) =>
|
||||||
.where((item) => item.messageParameters?['file']?.path != null)
|
items
|
||||||
.toList();
|
.where((item) => item.messageParameters?['file']?.path != null)
|
||||||
|
.toList();
|
||||||
|
|
||||||
SharedItemsPage buildSharedItemsPage(
|
SharedItemsPage buildSharedItemsPage(
|
||||||
GetSharedItemsResponse response,
|
GetSharedItemsResponse response,
|
||||||
@@ -140,7 +141,9 @@ class _SharedItemsViewState extends State<SharedItemsView>
|
|||||||
Future<void> _load() async {
|
Future<void> _load() async {
|
||||||
setState(() => _error = null);
|
setState(() => _error = null);
|
||||||
try {
|
try {
|
||||||
final overview = await SharedItemsView.prefetchOverview(widget.room.token);
|
final overview = await SharedItemsView.prefetchOverview(
|
||||||
|
widget.room.token,
|
||||||
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_overview = overview;
|
_overview = overview;
|
||||||
_prepareTabs();
|
_prepareTabs();
|
||||||
@@ -501,7 +504,10 @@ class _SharedItemTileState extends State<_SharedItemTile>
|
|||||||
if (isDownloading) {
|
if (isDownloading) {
|
||||||
confirmCancelDownload();
|
confirmCancelDownload();
|
||||||
} else {
|
} else {
|
||||||
startDownload(name: _file.name, remoteFile: RemoteFileRef.fromTalk(_file));
|
startDownload(
|
||||||
|
name: _file.name,
|
||||||
|
remoteFile: RemoteFileRef.fromTalk(_file),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -533,7 +539,7 @@ class _SharedItemTileState extends State<_SharedItemTile>
|
|||||||
children: [
|
children: [
|
||||||
CachedNetworkImage(
|
CachedNetworkImage(
|
||||||
imageUrl: _previewUrl,
|
imageUrl: _previewUrl,
|
||||||
httpHeaders: AccountData().authHeaders(),
|
httpHeaders: SessionManager().requireNextcloud().authHeaders,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
fadeInDuration: Duration.zero,
|
fadeInDuration: Duration.zero,
|
||||||
fadeOutDuration: Duration.zero,
|
fadeOutDuration: Duration.zero,
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
|||||||
import '../../../../api/marianumcloud/talk/room/notification_level.dart';
|
import '../../../../api/marianumcloud/talk/room/notification_level.dart';
|
||||||
import '../../../../api/marianumcloud/talk/set_read_marker/set_read_marker.dart';
|
import '../../../../api/marianumcloud/talk/set_read_marker/set_read_marker.dart';
|
||||||
import '../../../../extensions/date_time.dart';
|
import '../../../../extensions/date_time.dart';
|
||||||
import '../../../../model/account_data.dart';
|
|
||||||
import '../../../../notification/notification_tasks.dart';
|
import '../../../../notification/notification_tasks.dart';
|
||||||
import '../../../../routing/app_routes.dart';
|
import '../../../../routing/app_routes.dart';
|
||||||
|
import '../../../../session/session_manager.dart';
|
||||||
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
|
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
|
||||||
import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
||||||
import '../../../../utils/haptics.dart';
|
import '../../../../utils/haptics.dart';
|
||||||
@@ -53,13 +53,9 @@ class _ChatTileState extends State<ChatTile> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
AccountData().waitForPopulation().then((_) {
|
SessionManager().waitForLoad().then((session) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(
|
setState(() => selfUsername = session?.nextcloud?.username);
|
||||||
() => selfUsername = AccountData().isPopulated()
|
|
||||||
? AccountData().getUsername()
|
|
||||||
: null,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import '../../../../api/marianumcloud/talk/get_poll/get_poll_state_response.dart
|
|||||||
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
||||||
import '../../../../api/marianumcloud/talk/vote_poll/vote_poll.dart';
|
import '../../../../api/marianumcloud/talk/vote_poll/vote_poll.dart';
|
||||||
import '../../../../api/marianumcloud/talk/vote_poll/vote_poll_params.dart';
|
import '../../../../api/marianumcloud/talk/vote_poll/vote_poll_params.dart';
|
||||||
import '../../../../model/account_data.dart';
|
import '../../../../session/session_manager.dart';
|
||||||
import '../../../../widget/async_action_button.dart';
|
import '../../../../widget/async_action_button.dart';
|
||||||
import '../../../../widget/confirm_dialog.dart';
|
import '../../../../widget/confirm_dialog.dart';
|
||||||
import '../../../../widget/demo_restricted.dart';
|
import '../../../../widget/demo_restricted.dart';
|
||||||
@@ -186,7 +186,7 @@ class _PollOptionsListState extends State<PollOptionsList> {
|
|||||||
|
|
||||||
Widget _actionBar(GetPollStateResponseObject poll, ThemeData theme) {
|
Widget _actionBar(GetPollStateResponseObject poll, ThemeData theme) {
|
||||||
final canClose = poll.canClose(
|
final canClose = poll.canClose(
|
||||||
selfId: AccountData().getUsername(),
|
selfId: SessionManager().requireNextcloud().username,
|
||||||
participantType: widget.room.participantType,
|
participantType: widget.room.participantType,
|
||||||
);
|
);
|
||||||
if (!_isInteractive && !canClose) return const SizedBox.shrink();
|
if (!_isInteractive && !canClose) return const SizedBox.shrink();
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../model/account_data.dart';
|
|
||||||
import '../../model/endpoint_data.dart';
|
import '../../model/endpoint_data.dart';
|
||||||
|
import '../../session/session_manager.dart';
|
||||||
import '../../share_intent/remote_file_ref.dart';
|
import '../../share_intent/remote_file_ref.dart';
|
||||||
import '../app_progress_indicator.dart';
|
import '../app_progress_indicator.dart';
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ class _UnknownPreviewBlockState extends State<UnknownPreviewBlock> {
|
|||||||
width: _previewSize,
|
width: _previewSize,
|
||||||
height: _previewSize,
|
height: _previewSize,
|
||||||
child: CachedNetworkImage(
|
child: CachedNetworkImage(
|
||||||
httpHeaders: AccountData().authHeaders(),
|
httpHeaders: SessionManager().requireNextcloud().authHeaders,
|
||||||
imageUrl: _ncPreviewUrl(remote, width: 360),
|
imageUrl: _ncPreviewUrl(remote, width: 360),
|
||||||
fadeInDuration: Duration.zero,
|
fadeInDuration: Duration.zero,
|
||||||
fadeOutDuration: Duration.zero,
|
fadeOutDuration: Duration.zero,
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import 'package:flutter_svg/flutter_svg.dart';
|
|||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
import '../api/http_errors.dart';
|
import '../api/http_errors.dart';
|
||||||
import '../model/account_data.dart';
|
|
||||||
import '../model/endpoint_data.dart';
|
import '../model/endpoint_data.dart';
|
||||||
import '../push/push_avatar.dart';
|
import '../push/push_avatar.dart';
|
||||||
|
import '../session/session_manager.dart';
|
||||||
import 'a11y/a11y_labels.dart';
|
import 'a11y/a11y_labels.dart';
|
||||||
import 'avatar_disk_cache.dart';
|
import 'avatar_disk_cache.dart';
|
||||||
|
|
||||||
@@ -162,7 +162,9 @@ Future<AvatarPayload?> _fetchAvatarPayload(String url) async {
|
|||||||
() => http.get(
|
() => http.get(
|
||||||
Uri.parse(url),
|
Uri.parse(url),
|
||||||
headers: {
|
headers: {
|
||||||
...AccountData().authHeaders(),
|
// User avatars are public in Nextcloud, so they also load while the
|
||||||
|
// active session has no Nextcloud identity.
|
||||||
|
...?SessionManager().current?.nextcloud?.authHeaders,
|
||||||
'Accept': 'image/png,image/jpeg,image/webp,image/svg+xml',
|
'Accept': 'image/png,image/jpeg,image/webp,image/svg+xml',
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -368,7 +370,6 @@ class _UserAvatarState extends State<UserAvatar> {
|
|||||||
return listEquals(a.bytes, b.bytes);
|
return listEquals(a.bytes, b.bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final radius = widget.size.toDouble();
|
final radius = widget.size.toDouble();
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import 'dart:developer';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../model/account_data.dart';
|
import '../session/session_manager.dart';
|
||||||
import '../state/app/modules/timetable/bloc/timetable_state.dart';
|
import '../state/app/modules/timetable/bloc/timetable_state.dart';
|
||||||
import '../storage/settings.dart';
|
import '../storage/settings.dart';
|
||||||
import 'widget_data_mapper.dart';
|
import 'widget_data_mapper.dart';
|
||||||
@@ -48,7 +48,7 @@ class WidgetPublisher {
|
|||||||
bool isTeacher = false,
|
bool isTeacher = false,
|
||||||
int? epoch,
|
int? epoch,
|
||||||
}) {
|
}) {
|
||||||
final sessionEpoch = epoch ?? AccountData().sessionEpoch;
|
final sessionEpoch = epoch ?? SessionManager().sessionEpoch;
|
||||||
return _queue = _queue.then(
|
return _queue = _queue.then(
|
||||||
(_) => _publish(
|
(_) => _publish(
|
||||||
state,
|
state,
|
||||||
@@ -65,7 +65,7 @@ class WidgetPublisher {
|
|||||||
required bool isTeacher,
|
required bool isTeacher,
|
||||||
required int epoch,
|
required int epoch,
|
||||||
}) async {
|
}) async {
|
||||||
if (!AccountData().isCurrentSession(epoch)) return;
|
if (!SessionManager().isCurrentSession(epoch)) return;
|
||||||
try {
|
try {
|
||||||
final connectDouble =
|
final connectDouble =
|
||||||
settings?.timetableSettings.connectDoubleLessons ?? true;
|
settings?.timetableSettings.connectDoubleLessons ?? true;
|
||||||
@@ -106,7 +106,7 @@ class WidgetPublisher {
|
|||||||
);
|
);
|
||||||
// A publish still running at sign-out would put the previous account's
|
// A publish still running at sign-out would put the previous account's
|
||||||
// plan back onto the just cleared widget.
|
// plan back onto the just cleared widget.
|
||||||
if (!AccountData().isCurrentSession(epoch)) return;
|
if (!SessionManager().isCurrentSession(epoch)) return;
|
||||||
// Most bloc emits (week swipes, prefetches) don't touch the widget's
|
// Most bloc emits (week swipes, prefetches) don't touch the widget's
|
||||||
// window; skip the SharedPreferences commits and widget re-render then.
|
// window; skip the SharedPreferences commits and widget re-render then.
|
||||||
final signature = jsonEncode([
|
final signature = jsonEncode([
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:marianum_mobile/session/nextcloud_credentials.dart';
|
||||||
|
import 'package:marianum_mobile/session/session.dart';
|
||||||
|
import 'package:marianum_mobile/session/session_codec.dart';
|
||||||
|
|
||||||
|
String _basic(String user, String secret) =>
|
||||||
|
'Basic ${base64Encode(utf8.encode('$user:$secret'))}';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('decodeSession – installs from before account kinds', () {
|
||||||
|
test('username + password without a kind is a credential session', () {
|
||||||
|
final session = decodeSession({
|
||||||
|
'username': 'max',
|
||||||
|
'password': 'pw',
|
||||||
|
'nextcloud_app_password': 'app',
|
||||||
|
'nextcloud_app_password_talk': 'talk',
|
||||||
|
});
|
||||||
|
expect(session, isA<CredentialSession>());
|
||||||
|
final credential = session! as CredentialSession;
|
||||||
|
expect(credential.username, 'max');
|
||||||
|
expect(credential.password, 'pw');
|
||||||
|
expect(credential.isDemo, isFalse);
|
||||||
|
expect(credential.nextcloud.appPassword, 'app');
|
||||||
|
expect(credential.nextcloud.appPasswordTalk, 'talk');
|
||||||
|
expect(credential.nextcloud.usesLoginFlow, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('login-flow and demo flags are carried over', () {
|
||||||
|
final session =
|
||||||
|
decodeSession({
|
||||||
|
'username': 'demo@x',
|
||||||
|
'password': 'demo',
|
||||||
|
'is_demo': 'true',
|
||||||
|
'nextcloud_login_flow': 'true',
|
||||||
|
})!
|
||||||
|
as CredentialSession;
|
||||||
|
expect(session.isDemo, isTrue);
|
||||||
|
expect(session.nextcloud.usesLoginFlow, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing password means signed out', () {
|
||||||
|
expect(decodeSession({'username': 'max'}), isNull);
|
||||||
|
expect(decodeSession({}), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('decodeSession – kinds', () {
|
||||||
|
test('an unknown kind from a newer app version reads as signed out', () {
|
||||||
|
expect(
|
||||||
|
decodeSession({
|
||||||
|
'session_kind': 'something-new',
|
||||||
|
'username': 'max',
|
||||||
|
'password': 'pw',
|
||||||
|
}),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('encodeSessionFields', () {
|
||||||
|
test('round-trips a credential session', () {
|
||||||
|
final original = CredentialSession(
|
||||||
|
username: 'max',
|
||||||
|
password: 'pw',
|
||||||
|
usesLoginFlow: true,
|
||||||
|
);
|
||||||
|
final decoded =
|
||||||
|
decodeSession(encodeSessionFields(original))! as CredentialSession;
|
||||||
|
expect(decoded.username, 'max');
|
||||||
|
expect(decoded.password, 'pw');
|
||||||
|
expect(decoded.nextcloud.usesLoginFlow, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('NextcloudCredentials', () {
|
||||||
|
const base = NextcloudCredentials(username: 'max', password: 'pw');
|
||||||
|
|
||||||
|
test('prefers the app password once available', () {
|
||||||
|
expect(base.basicAuthHeader, _basic('max', 'pw'));
|
||||||
|
final withApp = base.copyWith(appPassword: () => 'app');
|
||||||
|
expect(withApp.basicAuthHeader, _basic('max', 'app'));
|
||||||
|
expect(withApp.secret, 'app');
|
||||||
|
expect(withApp.realPasswordBasicAuthHeader, _basic('max', 'pw'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('talk header needs its own app password', () {
|
||||||
|
expect(() => base.talkBasicAuthHeader, throwsStateError);
|
||||||
|
final withTalk = base.copyWith(appPasswordTalk: () => 't');
|
||||||
|
expect(withTalk.talkBasicAuthHeader, _basic('max', 't'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('login-flow accounts share the flow password for talk', () {
|
||||||
|
final flow = base.copyWith(
|
||||||
|
appPassword: () => 'flow',
|
||||||
|
usesLoginFlow: true,
|
||||||
|
);
|
||||||
|
expect(flow.talkBasicAuthHeader, _basic('max', 'flow'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('copyWith can clear an app password', () {
|
||||||
|
final cleared = base
|
||||||
|
.copyWith(appPassword: () => 'app')
|
||||||
|
.copyWith(appPassword: () => null);
|
||||||
|
expect(cleared.hasAppPassword, isFalse);
|
||||||
|
expect(cleared.secret, 'pw');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user