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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .example.env
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ NEXT_PUBLIC_APP_NAME=GhostClass

# ⚠️ App version displayed in footer and health checks
# 🔨 Build-time (Infisical `/build-time` folder)
NEXT_PUBLIC_APP_VERSION=4.4.6
NEXT_PUBLIC_APP_VERSION=4.4.7

# ⚠️ Your production domain WITHOUT https://
# All URL-based variables are derived from this.
Expand Down Expand Up @@ -365,7 +365,7 @@ JWE_PRIVATE_KEY=

# ⚠️ Minimum supported app version required to bypass forced update
# 🚀 Runtime (Infisical `/runtime` folder → Server Env Var)
MIN_APP_VERSION=4.4.6
MIN_APP_VERSION=4.4.7

# ℹ️ Enforce Firebase App Check for all mobile clients in production
# Valid: "true", "false" (default: false in dev, true recommended in prod)
Expand Down
2 changes: 1 addition & 1 deletion mobile/lib/config/app_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ class AppConfig {

/// Current application version (derived from Infisical compilation injection).
static String get appVersion =>
const String.fromEnvironment('APP_VERSION', defaultValue: '4.4.6');
const String.fromEnvironment('APP_VERSION', defaultValue: '4.4.7');

/// Commit SHA injected by CI for release builds.
static String get appCommitSha =>
Expand Down
7 changes: 7 additions & 0 deletions mobile/lib/logic/error_utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ String formatApiError(dynamic response, String context) {
} else if (response is DioException) {
status = response.response?.statusCode;
final data = response.response?.data;

if (response.type == DioExceptionType.connectionTimeout ||
response.type == DioExceptionType.sendTimeout ||
response.type == DioExceptionType.receiveTimeout) {
return 'The server is taking longer than expected. Please try again in a moment.';
}
Comment thread
devakesu marked this conversation as resolved.

if (data is Map) {
code = (data['code'] ?? '').toString();
message = (data['message'] ?? data['error'] ?? data['detail'] ?? '')
Expand Down
65 changes: 63 additions & 2 deletions mobile/lib/services/auth_service.dart
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:ghostclass/config/app_config.dart';
import 'package:ghostclass/logic/app_exception.dart';
import 'package:ghostclass/services/dio_service.dart';
import 'package:ghostclass/services/logger.dart';
import 'package:ghostclass/services/secure_storage.dart';

/// AuthService
Expand All @@ -14,14 +16,26 @@ class AuthService {
final Ref _ref;
static final String _ghostclassBaseUrl = AppConfig.ghostclassApiUrl;
static final String _ezygoAuthUrl = AppConfig.ezygoAuthUrl;
static const Duration _loginTimeout = kDebugMode
? Duration(seconds: 45)
: Duration(seconds: 30);
static const Duration _provisionRetryBackoff = Duration(milliseconds: 500);

Dio get _dio => _ref.read(dioServiceProvider).dio;

Future<Response<dynamic>> loginAndProvision({
required String username,
required String password,
}) async {
final flowWatch = Stopwatch()..start();

AppLogger.d('AuthService: loginAndProvision start');
final loginStartMs = flowWatch.elapsedMilliseconds;
final ezygoResponse = await loginEzygo(username, password);
final loginDurationMs = flowWatch.elapsedMilliseconds - loginStartMs;
AppLogger.d(
'AuthService: loginEzygo completed in ${loginDurationMs}ms (status: ${ezygoResponse.statusCode})',
);
Comment thread
devakesu marked this conversation as resolved.
if (ezygoResponse.statusCode != 200) return ezygoResponse;

final data = ezygoResponse.data as Map<String, dynamic>?;
Expand All @@ -34,7 +48,15 @@ class AuthService {
);
}

final bridgeResponse = await provisionGhostClassSession(ezygoToken);
final provisionStartMs = flowWatch.elapsedMilliseconds;
final bridgeResponse = await _provisionGhostClassSessionWithRetry(
ezygoToken,
);
final provisionDurationMs =
flowWatch.elapsedMilliseconds - provisionStartMs;
AppLogger.d(
'AuthService: provisionGhostClassSession completed in ${provisionDurationMs}ms (status: ${bridgeResponse.statusCode})',
);
Comment thread
devakesu marked this conversation as resolved.
final bridgeData = bridgeResponse.data;
if (bridgeData is Map<String, dynamic>) {
bridgeResponse.data = {
Expand All @@ -46,6 +68,35 @@ class AuthService {
return bridgeResponse;
}

bool _isTransientProvisionFailure(Object error) {
if (error is DioException) {
return error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.receiveTimeout ||
error.type == DioExceptionType.sendTimeout ||
error.type == DioExceptionType.connectionError ||
error.type == DioExceptionType.unknown;
}
return false;
}

Future<Response<dynamic>> _provisionGhostClassSessionWithRetry(
String ezygoToken,
) async {
try {
return await provisionGhostClassSession(ezygoToken);
} on Object catch (e, st) {
if (!_isTransientProvisionFailure(e)) rethrow;

AppLogger.e(
'AuthService: provisionGhostClassSession transient failure. Retrying once...',
e,
st,
);
await Future<void>.delayed(_provisionRetryBackoff);
return provisionGhostClassSession(ezygoToken);
}
}
Comment thread
devakesu marked this conversation as resolved.

Future<Response<dynamic>> loginEzygo(String username, String password) async {
return _dio.post(
_ezygoAuthUrl,
Expand All @@ -54,7 +105,13 @@ class AuthService {
'password': password,
'stay_logged_in': true,
},
options: Options(validateStatus: (s) => s != null && s < 600),
options: Options(
connectTimeout: _loginTimeout,
receiveTimeout: _loginTimeout,
sendTimeout: _loginTimeout,
persistentConnection: false,
validateStatus: (s) => s != null && s < 600,
),
);
}

Expand All @@ -65,6 +122,10 @@ class AuthService {
'$_ghostclassBaseUrl/auth/save-token',
data: {'token': ezygoToken.trim()},
options: Options(
connectTimeout: _loginTimeout,
receiveTimeout: _loginTimeout,
sendTimeout: _loginTimeout,
persistentConnection: false,
extra: {'useLimitedToken': true},
validateStatus: (s) => s != null && s < 600,
),
Expand Down
2 changes: 1 addition & 1 deletion mobile/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 4.4.6+1
version: 4.4.7+1
environment:
sdk: ^3.11.4

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ghostclass",
"version": "4.4.6",
"version": "4.4.7",
"private": true,
"engines": {
"node": ">=22.12.0",
Expand Down
2 changes: 1 addition & 1 deletion public/openapi/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ openapi: 3.1.0

info:
title: GhostClass API
version: 4.4.6
version: 4.4.7
description: |
**GhostClass API** provides endpoints for authentication, profile synchronization,
attendance integrations with EzyGo, telemetry, and build provenance.
Expand Down
Loading