diff --git a/.example.env b/.example.env index 9a3a760c..b7a0eaa0 100644 --- a/.example.env +++ b/.example.env @@ -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.5.3 +NEXT_PUBLIC_APP_VERSION=4.5.4 # âš ī¸ Your production domain WITHOUT https:// # All URL-based variables are derived from this. @@ -371,7 +371,7 @@ REQUEST_SIGNATURE_MAX_AGE=600 # âš ī¸ Minimum supported app version required to bypass forced update # 🚀 Runtime (Infisical `/runtime` folder → Server Env Var) -MIN_APP_VERSION=4.5.3 +MIN_APP_VERSION=4.5.4 # â„šī¸ Enforce Firebase App Check for all mobile clients in production # Valid: "true", "false" (default: false in dev, true recommended in prod) diff --git a/.github/workflows/build-guard.yml b/.github/workflows/build-guard.yml index 8ed26114..5be92ff3 100644 --- a/.github/workflows/build-guard.yml +++ b/.github/workflows/build-guard.yml @@ -29,4 +29,3 @@ jobs: run: | python3 -m pip install check-jsonschema check-jsonschema --schemafile https://json.schemastore.org/dependabot-2.0.json .github/dependabot.yml - diff --git a/mobile/android/app/proguard-rules.pro b/mobile/android/app/proguard-rules.pro index 36cf8867..cc3b34a1 100644 --- a/mobile/android/app/proguard-rules.pro +++ b/mobile/android/app/proguard-rules.pro @@ -1,13 +1,9 @@ # ProGuard rules for Flutter / native wrapper optimization # Standard Android optimizations and preservation rules. -# Preserve Flutter wrapper classes if needed --keep class io.flutter.app.** { *; } --keep class io.flutter.plugin.** { *; } --keep class io.flutter.util.** { *; } --keep class io.flutter.view.** { *; } --keep class io.flutter.** { *; } --keep class plugins.flutter.io.** { *; } +# Preserve generated plugin registrant and Flutter plugin reflection entry points +-keep class plugins.flutter.io.** { *; } # Suppress warnings for optional Play Core split install classes referenced by Flutter embedding -dontwarn com.google.android.play.core.** + diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index fccfe552..feea643f 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -13,7 +13,7 @@ android:icon="@mipmap/ic_launcher" android:allowBackup="false" android:debuggable="false" - android:enableOnBackInvokedCallback="false" + android:enableOnBackInvokedCallback="true" tools:ignore="HardcodedDebugMode"> - const String.fromEnvironment('APP_VERSION', defaultValue: '4.5.3'); + const String.fromEnvironment('APP_VERSION', defaultValue: '4.5.4'); /// Commit SHA injected by CI for release builds. static String get appCommitSha => diff --git a/mobile/lib/screens/navigation_shell.dart b/mobile/lib/screens/navigation_shell.dart index a5976259..77620b17 100644 --- a/mobile/lib/screens/navigation_shell.dart +++ b/mobile/lib/screens/navigation_shell.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'dart:ui'; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -534,7 +535,7 @@ class _NavigationShellState extends ConsumerState { ).colorScheme.surfaceContainer, backgroundImage: user?.profile?.avatarUrl != null - ? NetworkImage( + ? CachedNetworkImageProvider( user!.profile!.avatarUrl!, headers: { 'Origin': diff --git a/mobile/lib/screens/splash_screen.dart b/mobile/lib/screens/splash_screen.dart index 33617205..bc343280 100644 --- a/mobile/lib/screens/splash_screen.dart +++ b/mobile/lib/screens/splash_screen.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:io'; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -220,10 +221,14 @@ class _SplashScreenState extends ConsumerState { } prewarm(dashboardFuture, 'dashboard'); - prewarm(trackingFuture, 'tracking'); - prewarm(leaveFuture, 'leave'); - prewarm(scoreFuture, 'scores'); - prewarm(notificationsFuture, 'notifications'); + WidgetsBinding.instance.addPostFrameCallback((_) { + prewarm(trackingFuture, 'tracking'); + prewarm(leaveFuture, 'leave'); + Future.delayed(const Duration(milliseconds: 150), () { + prewarm(scoreFuture, 'scores'); + prewarm(notificationsFuture, 'notifications'); + }); + }); } @override @@ -315,7 +320,7 @@ class _SplashScreenState extends ConsumerState { finalUser.profile?.avatarUrl != null) { try { final _ = precacheImage( - NetworkImage( + CachedNetworkImageProvider( finalUser.profile!.avatarUrl!, headers: { 'Origin': AppConfig.supabaseOrigin, diff --git a/mobile/lib/services/api_service.dart b/mobile/lib/services/api_service.dart index 8d4b7192..e70ffd8e 100644 --- a/mobile/lib/services/api_service.dart +++ b/mobile/lib/services/api_service.dart @@ -189,20 +189,54 @@ class ApiService { _syncInFlight = () async { try { - final response = await client.get( - '${AppConfig.ghostclassApiUrl}/cron/sync?t=${now.millisecondsSinceEpoch}', - options: Options( - headers: {'Authorization': 'Bearer $supabaseToken'}, - sendTimeout: kDebugMode - ? const Duration(seconds: 45) - : const Duration(seconds: 30), - receiveTimeout: kDebugMode - ? const Duration(seconds: 45) - : const Duration(seconds: 30), - ), - ); - _lastSyncAt = DateTime.now(); - return response; + final currentToken = + (supabaseToken.isNotEmpty + ? supabaseToken + : Supabase.instance.client.auth.currentSession?.accessToken) ?? + ''; + + Future> makeSyncCall(String token) { + return client.get( + '${AppConfig.ghostclassApiUrl}/cron/sync?t=${now.millisecondsSinceEpoch}', + options: Options( + headers: {'Authorization': 'Bearer $token'}, + sendTimeout: kDebugMode + ? const Duration(seconds: 45) + : const Duration(seconds: 30), + receiveTimeout: kDebugMode + ? const Duration(seconds: 45) + : const Duration(seconds: 30), + ), + ); + } + + try { + final response = await makeSyncCall(currentToken); + _lastSyncAt = DateTime.now(); + return response; + } on DioException catch (dioErr) { + if (dioErr.response?.statusCode == 401) { + AppLogger.i( + 'ApiService: Sync returned 401, attempting token refresh...', + ); + try { + final refreshed = await Supabase.instance.client.auth + .refreshSession(); + final freshToken = refreshed.session?.accessToken; + if (freshToken != null && freshToken.isNotEmpty) { + final response = await makeSyncCall(freshToken); + _lastSyncAt = DateTime.now(); + return response; + } + } on Object catch (refreshErr) { + AppLogger.e( + 'ApiService: Session refresh during 401 sync retry failed', + refreshErr, + ); + } + } + rethrow; + } } on Object catch (e) { AppLogger.e('ApiService: Background sync failed', e); rethrow; diff --git a/mobile/lib/services/push_notification_service.dart b/mobile/lib/services/push_notification_service.dart index aafa9e33..a29bf8b1 100644 --- a/mobile/lib/services/push_notification_service.dart +++ b/mobile/lib/services/push_notification_service.dart @@ -52,6 +52,7 @@ class PushNotificationService { Timer? _deferredAuthTimer; bool _initialized = false; bool get isInitialized => _initialized; + bool _isRegistering = false; Dio get _dio => _ref.read(dioServiceProvider).dio; SecureStorageService get _storage => _ref.read(secureStorageProvider); @@ -273,6 +274,8 @@ class PushNotificationService { /// Synchronises the secure push token with the backend storage route. Future _syncTokenWithBackend(String token, {bool force = false}) async { + if (_isRegistering) return; + _isRegistering = true; try { if (!_ref.mounted) return; final cachedToken = await _storage.getNormalizedFcmToken(); @@ -298,6 +301,8 @@ class PushNotificationService { final session = data.session; if (session != null) { try { + final latestCached = await _storage.getNormalizedFcmToken(); + if (latestCached == token) return; final accessToken = session.accessToken; final response = await _registerTokenHttp(token, accessToken); if (response.statusCode == 200) { @@ -421,6 +426,8 @@ class PushNotificationService { 'PushNotificationService: analytics fcm_registered (exception)', ); } on Object catch (_) {} + } finally { + _isRegistering = false; } } diff --git a/mobile/lib/widgets/profile/profile_widgets.dart b/mobile/lib/widgets/profile/profile_widgets.dart index e1cb2508..49edc14d 100644 --- a/mobile/lib/widgets/profile/profile_widgets.dart +++ b/mobile/lib/widgets/profile/profile_widgets.dart @@ -1,3 +1,4 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:ghostclass/config/app_config.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -48,7 +49,7 @@ class ProfileHeader extends StatelessWidget { ), image: avatarUrl != null ? DecorationImage( - image: NetworkImage( + image: CachedNetworkImageProvider( avatarUrl!, headers: { 'Origin': AppConfig.supabaseOrigin, diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 31f4eb0b..d2d049e6 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -169,6 +169,30 @@ packages: url: "https://pub.dev" source: hosted version: "8.12.7" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.dev" + source: hosted + version: "1.3.1" characters: dependency: transitive description: @@ -534,6 +558,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.2" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "1de7849213b4c73c85aca7e0ac687a9a5d82ccdb594366b9dcc26cb6a2189cd2" + url: "https://pub.dev" + source: hosted + version: "3.4.2" flutter_launcher_icons: dependency: "direct dev" description: @@ -992,6 +1024,14 @@ packages: url: "https://pub.dev" source: hosted version: "9.5.0" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" package_config: dependency: transitive description: @@ -1264,6 +1304,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.4" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" sentry: dependency: transitive description: @@ -1429,6 +1477,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.2" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: "58a799e6ac17dd32fbab93813d39ed835a75ccc0f8f85b8955fe318c6712b082" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: d0548f9d7422a2dae99ec6f8b0a3074463b132d216fa5ba0d230eeefc901983b + url: "https://pub.dev" + source: hosted + version: "2.4.3" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "5bf6a55c166e73bf651ba7ec3ed486e577620e3dc8f3a9c6a258a8031b624590" + url: "https://pub.dev" + source: hosted + version: "2.5.11" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: c86ca18b8f666bbf903924687fe21cc16fc385d086005067e26619ca530bef9f + url: "https://pub.dev" + source: hosted + version: "2.4.3+1" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: f84939f84350d92d04416f8bc4dc52d3896aec7716cc9e80cf0146342139dc50 + url: "https://pub.dev" + source: hosted + version: "2.4.1" stack_trace: dependency: transitive description: @@ -1501,6 +1589,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.17.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "61894a1956de6b4fc1aefd0892e109514a1a706cbece3ac59decd90ff5a7a423" + url: "https://pub.dev" + source: hosted + version: "3.4.1+1" system_info2: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index a546ab5a..11c7514c 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -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.5.3+1 +version: 4.5.4+1 environment: sdk: ^3.11.4 @@ -27,6 +27,7 @@ environment: # the latest version available on pub.dev. To see which dependencies have newer # versions available, run `flutter pub outdated`. dependencies: + cached_network_image: ^3.4.1 crypto: ^3.0.2 cupertino_icons: ^1.0.8 device_info_plus: ^13.2.0 diff --git a/package-lock.json b/package-lock.json index e5e5cdd7..ca3e74f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ghostclass", - "version": "4.5.3", + "version": "4.5.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ghostclass", - "version": "4.5.3", + "version": "4.5.4", "dependencies": { "@hookform/resolvers": "^5.5.7", "@radix-ui/react-alert-dialog": "^1.1.23", diff --git a/package.json b/package.json index b9fa7f32..9463f7f2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ghostclass", - "version": "4.5.3", + "version": "4.5.4", "private": true, "engines": { "node": ">=22.12.0", diff --git a/public/openapi/openapi.yaml b/public/openapi/openapi.yaml index 46fe964f..b4379472 100644 --- a/public/openapi/openapi.yaml +++ b/public/openapi/openapi.yaml @@ -6,7 +6,7 @@ openapi: 3.1.0 info: title: GhostClass API - version: 4.5.3 + version: 4.5.4 description: | **GhostClass API** provides endpoints for authentication, profile synchronization, attendance integrations with EzyGo, telemetry, and build provenance.