diff --git a/.claude/settings.json b/.claude/settings.json
index bd7101f..8fb838e 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -9,7 +9,7 @@
"RUFLO_INTELLIGENCE_PIPELINE": "true",
"RUFLO_AGENT_BOOSTER": "true",
"RUFLO_MODEL_ROUTING": "auto",
- "CLAUDE_CODE_MAX_OUTPUT_TOKENS": "8000",
+ "CLAUDE_CODE_MAX_OUTPUT_TOKENS": "128000",
"DISABLE_NON_ESSENTIAL_MODEL_CALLS": "1",
"DISABLE_COST_WARNINGS": "1",
"USE_BUILTIN_RIPGREP": "1",
diff --git a/.env.template b/.env.template
index 82dab34..b0c231c 100644
--- a/.env.template
+++ b/.env.template
@@ -55,3 +55,14 @@ DEMO_BACKEND_PORT="5000"
# Use quantum simulator for testing (true = free, false = real hardware)
# USE_QUANTUM_SIMULATOR="true"
+
+# =============================================================================
+# OpenRouter Multi-Provider Paper Review
+# =============================================================================
+OPENROUTER_API_KEY=
+OPENROUTER_MODEL_REVIEW=openai/gpt-5.4
+OPENROUTER_MODEL_STRUCTURAL=google/gemini-3.1-pro-preview
+OPENROUTER_MODEL_NOVELTY=xai/grok-4
+OPENROUTER_MODEL_MATH=deepseek/deepseek-r1
+OPENROUTER_MODEL_LIT=qwen/qwen-3.6
+OPENROUTER_MODEL_LIT_ALT=zhipu/glm-5.1-mythos
diff --git a/app/android/app/build.gradle.kts b/app/android/app/build.gradle.kts
index 19204a5..9bcddb2 100644
--- a/app/android/app/build.gradle.kts
+++ b/app/android/app/build.gradle.kts
@@ -57,6 +57,14 @@ android {
}
}
+dependencies {
+ // Google AI Edge LiteRT-LM runtime for on-device Gemma inference.
+ // https://github.com/google-ai-edge/gallery
+ implementation("com.google.ai.edge.litert:litert-lm:1.0.0")
+ // MediaPipe fallback (older models that use .task format).
+ implementation("com.google.mediapipe:tasks-genai:0.10.22")
+}
+
flutter {
source = "../.."
}
diff --git a/app/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java b/app/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java
index a46d5b6..5c77d11 100644
--- a/app/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java
+++ b/app/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java
@@ -50,6 +50,11 @@ public static void registerWith(@NonNull FlutterEngine flutterEngine) {
} catch (Exception e) {
Log.e(TAG, "Error registering plugin google_sign_in_android, io.flutter.plugins.googlesignin.GoogleSignInPlugin", e);
}
+ try {
+ flutterEngine.getPlugins().add(new io.flutter.plugins.localauth.LocalAuthPlugin());
+ } catch (Exception e) {
+ Log.e(TAG, "Error registering plugin local_auth_android, io.flutter.plugins.localauth.LocalAuthPlugin", e);
+ }
try {
flutterEngine.getPlugins().add(new com.crazecoder.openfile.OpenFilePlugin());
} catch (Exception e) {
diff --git a/app/android/app/src/main/kotlin/com/qdaria/zipminator/MainActivity.kt b/app/android/app/src/main/kotlin/com/qdaria/zipminator/MainActivity.kt
index 5d5c7bf..40e8612 100644
--- a/app/android/app/src/main/kotlin/com/qdaria/zipminator/MainActivity.kt
+++ b/app/android/app/src/main/kotlin/com/qdaria/zipminator/MainActivity.kt
@@ -1,5 +1,19 @@
package com.qdaria.zipminator
import io.flutter.embedding.android.FlutterActivity
+import io.flutter.embedding.engine.FlutterEngine
-class MainActivity : FlutterActivity()
+class MainActivity : FlutterActivity() {
+ private var onDevicePlugin: OnDeviceInferencePlugin? = null
+
+ override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
+ super.configureFlutterEngine(flutterEngine)
+ onDevicePlugin = OnDeviceInferencePlugin(this, flutterEngine)
+ }
+
+ override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) {
+ onDevicePlugin?.dispose()
+ onDevicePlugin = null
+ super.cleanUpFlutterEngine(flutterEngine)
+ }
+}
diff --git a/app/assets/logos/Z.svg b/app/assets/logos/Z.svg
deleted file mode 100644
index 9ce1532..0000000
--- a/app/assets/logos/Z.svg
+++ /dev/null
@@ -1,46 +0,0 @@
-
-
diff --git a/app/assets/logos/zipminator.svg b/app/assets/logos/zipminator.svg
deleted file mode 100644
index 0efc5c5..0000000
--- a/app/assets/logos/zipminator.svg
+++ /dev/null
@@ -1,86 +0,0 @@
-
-
diff --git a/app/ios/Flutter/Generated.xcconfig b/app/ios/Flutter/Generated.xcconfig
index 19f2d68..7031d81 100644
--- a/app/ios/Flutter/Generated.xcconfig
+++ b/app/ios/Flutter/Generated.xcconfig
@@ -5,7 +5,7 @@ COCOAPODS_PARALLEL_CODE_SIGN=true
FLUTTER_TARGET=lib/main.dart
FLUTTER_BUILD_DIR=build
FLUTTER_BUILD_NAME=0.5.0
-FLUTTER_BUILD_NUMBER=36
+FLUTTER_BUILD_NUMBER=41
EXCLUDED_ARCHS[sdk=iphonesimulator*]=i386
EXCLUDED_ARCHS[sdk=iphoneos*]=armv7
DART_DEFINES=RkxVVFRFUl9WRVJTSU9OPTMuNDEuNA==,RkxVVFRFUl9DSEFOTkVMPXN0YWJsZQ==,RkxVVFRFUl9HSVRfVVJMPWh0dHBzOi8vZ2l0aHViLmNvbS9mbHV0dGVyL2ZsdXR0ZXIuZ2l0,RkxVVFRFUl9GUkFNRVdPUktfUkVWSVNJT049ZmYzN2JlZjYwMw==,RkxVVFRFUl9FTkdJTkVfUkVWSVNJT049ZTRiOGRjYTNmMQ==,RkxVVFRFUl9EQVJUX1ZFUlNJT049My4xMS4x
diff --git a/app/ios/Flutter/flutter_export_environment.sh b/app/ios/Flutter/flutter_export_environment.sh
index e852f2c..1d90d8e 100755
--- a/app/ios/Flutter/flutter_export_environment.sh
+++ b/app/ios/Flutter/flutter_export_environment.sh
@@ -6,7 +6,7 @@ export "COCOAPODS_PARALLEL_CODE_SIGN=true"
export "FLUTTER_TARGET=lib/main.dart"
export "FLUTTER_BUILD_DIR=build"
export "FLUTTER_BUILD_NAME=0.5.0"
-export "FLUTTER_BUILD_NUMBER=36"
+export "FLUTTER_BUILD_NUMBER=41"
export "DART_DEFINES=RkxVVFRFUl9WRVJTSU9OPTMuNDEuNA==,RkxVVFRFUl9DSEFOTkVMPXN0YWJsZQ==,RkxVVFRFUl9HSVRfVVJMPWh0dHBzOi8vZ2l0aHViLmNvbS9mbHV0dGVyL2ZsdXR0ZXIuZ2l0,RkxVVFRFUl9GUkFNRVdPUktfUkVWSVNJT049ZmYzN2JlZjYwMw==,RkxVVFRFUl9FTkdJTkVfUkVWSVNJT049ZTRiOGRjYTNmMQ==,RkxVVFRFUl9EQVJUX1ZFUlNJT049My4xMS4x"
export "DART_OBFUSCATION=false"
export "TRACK_WIDGET_CREATION=false"
diff --git a/app/ios/Runner/GeneratedPluginRegistrant.m b/app/ios/Runner/GeneratedPluginRegistrant.m
index 78ced65..5018611 100644
--- a/app/ios/Runner/GeneratedPluginRegistrant.m
+++ b/app/ios/Runner/GeneratedPluginRegistrant.m
@@ -48,6 +48,12 @@
@import integration_test;
#endif
+#if __has_include()
+#import
+#else
+@import local_auth_darwin;
+#endif
+
#if __has_include()
#import
#else
@@ -112,6 +118,7 @@ + (void)registerWithRegistry:(NSObject*)registry {
[FlutterWebRTCPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterWebRTCPlugin"]];
[FLTGoogleSignInPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTGoogleSignInPlugin"]];
[IntegrationTestPlugin registerWithRegistrar:[registry registrarForPlugin:@"IntegrationTestPlugin"]];
+ [LocalAuthPlugin registerWithRegistrar:[registry registrarForPlugin:@"LocalAuthPlugin"]];
[OpenFilePlugin registerWithRegistrar:[registry registrarForPlugin:@"OpenFilePlugin"]];
[PathProviderPlugin registerWithRegistrar:[registry registrarForPlugin:@"PathProviderPlugin"]];
[PermissionHandlerPlugin registerWithRegistrar:[registry registrarForPlugin:@"PermissionHandlerPlugin"]];
diff --git a/app/lib/app.dart b/app/lib/app.dart
index 1e1d8b3..3d5c0bd 100644
--- a/app/lib/app.dart
+++ b/app/lib/app.dart
@@ -1,17 +1,54 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:google_fonts/google_fonts.dart';
+import 'package:zipminator/core/providers/biometric_provider.dart';
import 'package:zipminator/core/providers/ratchet_provider.dart';
import 'package:zipminator/core/providers/theme_provider.dart';
import 'package:zipminator/core/router.dart';
import 'package:zipminator/core/theme/quantum_theme.dart';
/// Root application widget.
-class ZipminatorApp extends ConsumerWidget {
+class ZipminatorApp extends ConsumerStatefulWidget {
const ZipminatorApp({super.key});
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ ConsumerState createState() => _ZipminatorAppState();
+}
+
+class _ZipminatorAppState extends ConsumerState
+ with WidgetsBindingObserver {
+ @override
+ void initState() {
+ super.initState();
+ WidgetsBinding.instance.addObserver(this);
+ }
+
+ @override
+ void dispose() {
+ WidgetsBinding.instance.removeObserver(this);
+ super.dispose();
+ }
+
+ @override
+ void didChangeAppLifecycleState(AppLifecycleState state) {
+ // Lock when app goes to background; auto-unlock prompt on resume.
+ final bio = ref.read(biometricProvider);
+ if (!bio.hasValue) return; // Provider still loading.
+
+ if (state == AppLifecycleState.paused ||
+ state == AppLifecycleState.hidden) {
+ ref.read(biometricProvider.notifier).lock();
+ } else if (state == AppLifecycleState.resumed) {
+ if (bio.value!.locked) {
+ ref.read(biometricProvider.notifier).unlock();
+ }
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
final themeMode = ref.watch(themeModeProvider);
+ final biometric = ref.watch(biometricProvider);
// Auto-connect signaling server when authenticated (app-wide).
ref.watch(signalingInitProvider);
@@ -23,6 +60,56 @@ class ZipminatorApp extends ConsumerWidget {
darkTheme: QuantumTheme.dark(),
themeMode: themeMode,
routerConfig: appRouter,
+ builder: (context, child) {
+ final isLocked =
+ biometric.value?.locked ?? false;
+ return Stack(
+ children: [
+ child ?? const SizedBox.shrink(),
+ if (isLocked) _LockScreen(),
+ ],
+ );
+ },
+ );
+ }
+}
+
+/// Full-screen overlay shown when biometric lock is active.
+class _LockScreen extends ConsumerWidget {
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ return Material(
+ color: QuantumTheme.surfaceDark,
+ child: Center(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(Icons.lock_outline,
+ size: 64, color: QuantumTheme.quantumCyan),
+ const SizedBox(height: 16),
+ Text(
+ 'Zipminator is Locked',
+ style: GoogleFonts.outfit(
+ fontSize: 22,
+ fontWeight: FontWeight.w600,
+ color: QuantumTheme.textPrimary,
+ ),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ 'Authenticate to continue',
+ style: TextStyle(color: QuantumTheme.textSecondary),
+ ),
+ const SizedBox(height: 32),
+ ElevatedButton.icon(
+ onPressed: () =>
+ ref.read(biometricProvider.notifier).unlock(),
+ icon: const Icon(Icons.fingerprint),
+ label: const Text('Unlock'),
+ ),
+ ],
+ ),
+ ),
);
}
}
diff --git a/app/lib/core/providers/anonymizer_provider.dart b/app/lib/core/providers/anonymizer_provider.dart
index 7b90eed..3021211 100644
--- a/app/lib/core/providers/anonymizer_provider.dart
+++ b/app/lib/core/providers/anonymizer_provider.dart
@@ -1,3 +1,5 @@
+import 'dart:math';
+import 'dart:typed_data';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:zipminator/core/providers/pii_provider.dart';
@@ -124,10 +126,10 @@ class AnonymizerNotifier extends Notifier {
4 => '[${m.category.toUpperCase()}]',
5 => 'PII_${_hashFragment(m.matchedText)}',
6 => 'PII_${_randomFragment()}',
- 7 => '[${m.category.toUpperCase()}]', // Quantum jitter placeholder
- 8 => '[${m.category.toUpperCase()}]', // Differential privacy placeholder
- 9 => _generalize(m), // K-anonymity placeholder
- 10 => '[REDACTED]',
+ 7 => _quantumJitter(m),
+ 8 => _differentialPrivacy(m),
+ 9 => _kAnonymity(m),
+ 10 => _quantumRedact(m),
_ => '[REDACTED]',
};
result = result.replaceRange(m.start, m.end, replacement);
@@ -158,18 +160,139 @@ class AnonymizerNotifier extends Notifier {
return List.generate(6, (i) => chars[(now + i * 7) % chars.length]).join();
}
- /// Generalize quasi-identifiers (placeholder for L9 k-anonymity).
- String _generalize(PiiMatch m) {
+ /// L7: Quantum jitter. Adds entropy-seeded noise before masking.
+ /// Numeric chars get random digit replacement; alpha chars get random letter.
+ /// Each invocation produces different output (non-deterministic).
+ String _quantumJitter(PiiMatch m) {
+ final entropy = _getEntropy(m.matchedText.length);
+ final buf = StringBuffer();
+ for (var i = 0; i < m.matchedText.length; i++) {
+ final c = m.matchedText.codeUnitAt(i);
+ final noise = entropy[i % entropy.length];
+ if (c >= 48 && c <= 57) {
+ // digit: replace with noised digit
+ buf.writeCharCode(48 + (noise % 10));
+ } else if ((c >= 65 && c <= 90) || (c >= 97 && c <= 122)) {
+ // letter: replace with noised letter (preserve case)
+ final base = c >= 97 ? 97 : 65;
+ buf.writeCharCode(base + (noise % 26));
+ } else {
+ buf.write(m.matchedText[i]); // keep separators
+ }
+ }
+ return buf.toString();
+ }
+
+ /// L8: Differential privacy via Laplace mechanism.
+ /// For numeric PII, applies calibrated noise (epsilon=1.0).
+ /// For text PII, replaces with randomized pseudonym from category pool.
+ String _differentialPrivacy(PiiMatch m) {
+ final digits = m.matchedText.replaceAll(RegExp(r'[^0-9]'), '');
+ if (digits.length >= 3) {
+ // Numeric PII: apply Laplace noise
+ final value = int.tryParse(digits) ?? 0;
+ final entropy = _getEntropy(8);
+ // Laplace noise approximation: difference of two exponentials
+ final u1 = (entropy[0] + 1) / 257.0;
+ final u2 = (entropy[1] + 1) / 257.0;
+ const epsilon = 1.0;
+ const sensitivity = 1.0;
+ final noise = (sensitivity / epsilon) * (log(u1) - log(u2));
+ final noised = (value + noise).round().abs();
+ // Reconstruct with original separators
+ final noisedStr = noised.toString().padLeft(digits.length, '0');
+ var result = m.matchedText;
+ var di = 0;
+ final buf = StringBuffer();
+ for (var i = 0; i < result.length && di < noisedStr.length; i++) {
+ if (RegExp(r'[0-9]').hasMatch(result[i])) {
+ buf.write(noisedStr[di]);
+ di++;
+ } else {
+ buf.write(result[i]);
+ }
+ }
+ return buf.toString();
+ }
+ // Non-numeric: randomized pseudonym
+ return 'DP_${_randomFragment()}';
+ }
+
+ /// L9: K-anonymity generalization. Reduces quasi-identifiers to broader
+ /// categories (zip to area, age to range, names to initials).
+ String _kAnonymity(PiiMatch m) {
return switch (m.category.toLowerCase()) {
- 'email' => '[EMAIL_DOMAIN]',
- 'phone' => '[PHONE_AREA]',
+ 'email' => _generalizeEmail(m.matchedText),
+ 'phone' => _generalizePhone(m.matchedText),
'address' => '[REGION]',
- 'zip' || 'postal' => '[AREA]',
+ 'zip' || 'postal' => _generalizeZip(m.matchedText),
+ 'name' => _generalizeInitials(m.matchedText),
+ 'ssn' || 'national_id' => '***-**-${m.matchedText.substring(m.matchedText.length - 4).replaceAll(RegExp(r'[0-9]'), '*')}',
+ 'date' || 'dob' => _generalizeDate(m.matchedText),
'age' => '[AGE_RANGE]',
_ => '[${m.category.toUpperCase()}]',
};
}
+ /// L10: Quantum one-time-pad redaction. XOR with entropy bytes, then hash.
+ /// The result is cryptographically irreversible.
+ String _quantumRedact(PiiMatch m) {
+ final textBytes = Uint8List.fromList(m.matchedText.codeUnits);
+ final entropy = _getEntropy(textBytes.length);
+ // XOR text with entropy (one-time pad)
+ var xorHash = 0x811c9dc5;
+ for (var i = 0; i < textBytes.length; i++) {
+ final xored = textBytes[i] ^ entropy[i % entropy.length];
+ xorHash ^= xored;
+ xorHash = (xorHash * 0x01000193) & 0xFFFFFFFF;
+ }
+ final tag = xorHash.toRadixString(16).padLeft(6, '0').substring(0, 6);
+ return '[QR_$tag]';
+ }
+
+ // ── Entropy helper ──────────────────────────────────────────────────
+
+ /// Get entropy bytes from Rust QRNG bridge, falling back to secure random.
+ Uint8List _getEntropy(int length) {
+ // Use dart:math secure random (Rust FFI bridge has no getEntropy export).
+ final rng = Random.secure();
+ return Uint8List.fromList(
+ List.generate(length, (_) => rng.nextInt(256)),
+ );
+ }
+
+ // ── K-anonymity generalization helpers ──────────────────────────────
+
+ String _generalizeEmail(String email) {
+ final at = email.indexOf('@');
+ if (at < 0) return '[EMAIL]';
+ return '***@${email.substring(at + 1)}';
+ }
+
+ String _generalizePhone(String phone) {
+ final digits = phone.replaceAll(RegExp(r'[^0-9]'), '');
+ if (digits.length >= 7) return '${digits.substring(0, 3)}-***-****';
+ return '[PHONE]';
+ }
+
+ String _generalizeZip(String zip) {
+ final digits = zip.replaceAll(RegExp(r'[^0-9]'), '');
+ if (digits.length >= 3) return '${digits.substring(0, 3)}**';
+ return '[AREA]';
+ }
+
+ String _generalizeInitials(String name) {
+ final parts = name.trim().split(RegExp(r'\s+'));
+ return parts.map((p) => p.isNotEmpty ? '${p[0]}.' : '').join(' ');
+ }
+
+ String _generalizeDate(String date) {
+ // Keep only the year
+ final yearMatch = RegExp(r'(19|20)\d{2}').firstMatch(date);
+ if (yearMatch != null) return yearMatch.group(0)!;
+ return '[DATE]';
+ }
+
void clear() {
state = const AnonymizerState();
}
diff --git a/app/lib/core/providers/auth_provider.dart b/app/lib/core/providers/auth_provider.dart
index b3800a5..d5e17b4 100644
--- a/app/lib/core/providers/auth_provider.dart
+++ b/app/lib/core/providers/auth_provider.dart
@@ -28,6 +28,23 @@ class AuthState {
);
bool get isAuthenticated => user != null;
+
+ /// Display name from user_metadata, or email prefix.
+ String get displayName {
+ final meta = user?.userMetadata;
+ final fullName = meta?['full_name'] as String?;
+ if (fullName != null && fullName.isNotEmpty) return fullName;
+ final email = user?.email ?? '';
+ if (email.contains('@')) return email.split('@').first;
+ return email;
+ }
+
+ /// Username from user_metadata (null if not yet set).
+ String? get username => user?.userMetadata?['username'] as String?;
+
+ /// Whether onboarding (username creation) is needed.
+ bool get needsOnboarding =>
+ isAuthenticated && (username == null || username!.isEmpty);
}
/// Notifier that tracks Supabase auth state and exposes sign-in/sign-out.
@@ -39,6 +56,7 @@ class AuthNotifier extends Notifier {
final user = SupabaseService.currentUser;
_listenToAuthChanges();
ref.onDispose(() => _sub?.cancel());
+ if (user != null) _ensureUsername(user);
return AuthState(user: user);
}
@@ -48,12 +66,48 @@ class AuthNotifier extends Notifier {
final user = data.session?.user;
if (user != null) {
state = state.copyWith(user: user, isLoading: false);
+ _ensureUsername(user);
} else {
state = const AuthState(); // Fully reset on sign-out.
}
});
}
+ /// Auto-generate username from OAuth profile if not set.
+ /// Skips onboarding for users who signed in via Google/GitHub/LinkedIn.
+ Future _ensureUsername(User user) async {
+ final meta = user.userMetadata;
+ final existing = meta?['username'] as String?;
+ if (existing != null && existing.isNotEmpty) return;
+
+ // Derive username from email prefix or full_name
+ final email = user.email ?? '';
+ final fullName = meta?['full_name'] as String? ?? '';
+ String candidate;
+ if (fullName.isNotEmpty) {
+ candidate = fullName
+ .toLowerCase()
+ .replaceAll(RegExp(r'[^a-z0-9._-]'), '.')
+ .replaceAll(RegExp(r'\.{2,}'), '.');
+ } else if (email.contains('@')) {
+ candidate = email.split('@').first.toLowerCase();
+ } else {
+ return; // No data to derive from
+ }
+ if (candidate.length < 3) candidate = '${candidate}user';
+ if (candidate.length > 30) candidate = candidate.substring(0, 30);
+
+ try {
+ await SupabaseService.updateProfile(username: candidate);
+ final refreshed = SupabaseService.currentUser;
+ if (refreshed != null) {
+ state = state.copyWith(user: refreshed);
+ }
+ } catch (_) {
+ // Non-fatal; user can set username manually via onboarding
+ }
+ }
+
/// Email + password sign in.
Future signInWithEmail(String email, String password) async {
state = state.copyWith(isLoading: true, error: null);
@@ -103,15 +157,18 @@ class AuthNotifier extends Notifier {
}
/// OAuth via ASWebAuthenticationSession (iOS) or browser (macOS).
- /// Works for Google, GitHub, LinkedIn - handles redirect automatically.
+ /// Works for Google, GitHub, LinkedIn. Uses ephemeral sessions and
+ /// Supabase's getSessionFromUrl for correct PKCE + fragment handling.
Future signInWithOAuth(OAuthProvider provider) async {
state = state.copyWith(isLoading: true, error: null);
try {
- final response = await SupabaseService.signInWithOAuthProper(provider);
+ final response = await SupabaseService.signInWithOAuthBrowser(provider);
state = state.copyWith(user: response.user, isLoading: false);
} catch (e) {
final msg = e.toString();
- if (msg.contains('CANCELED') || msg.contains('canceled') || msg.contains('cancelled')) {
+ if (msg.contains('CANCELED') ||
+ msg.contains('canceled') ||
+ msg.contains('cancelled')) {
state = state.copyWith(isLoading: false);
} else {
state = state.copyWith(isLoading: false, error: msg);
@@ -119,6 +176,26 @@ class AuthNotifier extends Notifier {
}
}
+ /// Update the user's profile (username and/or display name).
+ Future updateProfile({
+ String? username,
+ String? displayName,
+ }) async {
+ try {
+ await SupabaseService.updateProfile(
+ username: username,
+ displayName: displayName,
+ );
+ // Refresh user to pick up new metadata.
+ final refreshed = SupabaseService.currentUser;
+ if (refreshed != null) {
+ state = state.copyWith(user: refreshed);
+ }
+ } catch (e) {
+ state = state.copyWith(error: e.toString());
+ }
+ }
+
/// Sign out completely. Clears all local session data.
Future signOut() async {
await SupabaseService.signOut();
diff --git a/app/lib/core/providers/biometric_provider.dart b/app/lib/core/providers/biometric_provider.dart
new file mode 100644
index 0000000..4047518
--- /dev/null
+++ b/app/lib/core/providers/biometric_provider.dart
@@ -0,0 +1,85 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+import 'package:zipminator/core/services/biometric_service.dart';
+
+const _kBiometricEnabled = 'biometric_lock_enabled';
+
+class BiometricState {
+ /// Whether the user has toggled biometric lock on in Settings.
+ final bool enabled;
+
+ /// Whether the app is currently locked (awaiting biometric).
+ final bool locked;
+
+ /// Whether biometric hardware is available on this device.
+ final bool available;
+
+ const BiometricState({
+ this.enabled = false,
+ this.locked = false,
+ this.available = false,
+ });
+
+ BiometricState copyWith({bool? enabled, bool? locked, bool? available}) =>
+ BiometricState(
+ enabled: enabled ?? this.enabled,
+ locked: locked ?? this.locked,
+ available: available ?? this.available,
+ );
+}
+
+class BiometricNotifier extends AsyncNotifier {
+ @override
+ Future build() async {
+ final prefs = await SharedPreferences.getInstance();
+ final enabled = prefs.getBool(_kBiometricEnabled) ?? false;
+ final available = await BiometricService.isAvailable;
+ return BiometricState(
+ enabled: enabled,
+ available: available,
+ locked: enabled && available,
+ );
+ }
+
+ /// Toggle biometric lock on/off. When turning on, verifies with biometric
+ /// first so only the device owner can enable it.
+ Future toggle() async {
+ final current = state.value ?? const BiometricState();
+ if (!current.available) return;
+
+ if (!current.enabled) {
+ // Turning ON: verify identity first.
+ final ok = await BiometricService.authenticate(
+ reason: 'Verify your identity to enable biometric lock',
+ );
+ if (!ok) return;
+ }
+
+ final newEnabled = !current.enabled;
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setBool(_kBiometricEnabled, newEnabled);
+ state = AsyncData(current.copyWith(enabled: newEnabled, locked: false));
+ }
+
+ /// Attempt to unlock. Called from the lock screen.
+ Future unlock() async {
+ final ok = await BiometricService.authenticate();
+ if (ok) {
+ final current = state.value ?? const BiometricState();
+ state = AsyncData(current.copyWith(locked: false));
+ }
+ return ok;
+ }
+
+ /// Lock the app (called when app goes to background).
+ void lock() {
+ final current = state.value;
+ if (current != null && current.enabled && current.available) {
+ state = AsyncData(current.copyWith(locked: true));
+ }
+ }
+}
+
+final biometricProvider =
+ AsyncNotifierProvider(
+ BiometricNotifier.new);
diff --git a/app/lib/core/providers/browser_provider.dart b/app/lib/core/providers/browser_provider.dart
index 629416a..b74a15f 100644
--- a/app/lib/core/providers/browser_provider.dart
+++ b/app/lib/core/providers/browser_provider.dart
@@ -6,6 +6,10 @@ class BrowserState {
final bool proxyActive;
final bool canGoBack;
final bool canGoForward;
+ final bool fingerprintProtection;
+ final bool cookieRotation;
+ final List history;
+ final int historyIndex;
final String? error;
const BrowserState({
@@ -14,6 +18,10 @@ class BrowserState {
this.proxyActive = true,
this.canGoBack = false,
this.canGoForward = false,
+ this.fingerprintProtection = true,
+ this.cookieRotation = true,
+ this.history = const ['https://zipminator.zip'],
+ this.historyIndex = 0,
this.error,
});
@@ -23,6 +31,10 @@ class BrowserState {
bool? proxyActive,
bool? canGoBack,
bool? canGoForward,
+ bool? fingerprintProtection,
+ bool? cookieRotation,
+ List? history,
+ int? historyIndex,
String? error,
}) =>
BrowserState(
@@ -31,6 +43,11 @@ class BrowserState {
proxyActive: proxyActive ?? this.proxyActive,
canGoBack: canGoBack ?? this.canGoBack,
canGoForward: canGoForward ?? this.canGoForward,
+ fingerprintProtection:
+ fingerprintProtection ?? this.fingerprintProtection,
+ cookieRotation: cookieRotation ?? this.cookieRotation,
+ history: history ?? this.history,
+ historyIndex: historyIndex ?? this.historyIndex,
error: error,
);
}
@@ -41,7 +58,43 @@ class BrowserNotifier extends Notifier {
void navigate(String url) {
final normalized = url.startsWith('http') ? url : 'https://$url';
- state = state.copyWith(url: normalized, isLoading: true);
+ // Truncate forward history on new navigation
+ final newHistory = [
+ ...state.history.sublist(0, state.historyIndex + 1),
+ normalized,
+ ];
+ state = state.copyWith(
+ url: normalized,
+ isLoading: true,
+ history: newHistory,
+ historyIndex: newHistory.length - 1,
+ canGoBack: newHistory.length > 1,
+ canGoForward: false,
+ );
+ }
+
+ void goBack() {
+ if (state.historyIndex <= 0) return;
+ final newIndex = state.historyIndex - 1;
+ state = state.copyWith(
+ url: state.history[newIndex],
+ historyIndex: newIndex,
+ canGoBack: newIndex > 0,
+ canGoForward: true,
+ isLoading: true,
+ );
+ }
+
+ void goForward() {
+ if (state.historyIndex >= state.history.length - 1) return;
+ final newIndex = state.historyIndex + 1;
+ state = state.copyWith(
+ url: state.history[newIndex],
+ historyIndex: newIndex,
+ canGoBack: true,
+ canGoForward: newIndex < state.history.length - 1,
+ isLoading: true,
+ );
}
void onPageFinished() {
@@ -55,6 +108,15 @@ class BrowserNotifier extends Notifier {
void toggleProxy() {
state = state.copyWith(proxyActive: !state.proxyActive);
}
+
+ void toggleFingerprint() {
+ state = state.copyWith(
+ fingerprintProtection: !state.fingerprintProtection);
+ }
+
+ void toggleCookieRotation() {
+ state = state.copyWith(cookieRotation: !state.cookieRotation);
+ }
}
final browserProvider =
diff --git a/app/lib/core/providers/comparison_provider.dart b/app/lib/core/providers/comparison_provider.dart
index a1b7474..e64bb4c 100644
--- a/app/lib/core/providers/comparison_provider.dart
+++ b/app/lib/core/providers/comparison_provider.dart
@@ -108,8 +108,8 @@ class ComparisonNotifier extends Notifier {
return;
}
- final apiKey = apiKeys[model.provider];
- if (apiKey == null || apiKey.isEmpty) {
+ final apiKey = model.provider.isLocal ? '' : (apiKeys[model.provider] ?? '');
+ if (!model.provider.isLocal && apiKey.isEmpty) {
_setError(
modelId,
'No API key for ${model.provider.displayName}',
diff --git a/app/lib/core/providers/mesh_provider.dart b/app/lib/core/providers/mesh_provider.dart
new file mode 100644
index 0000000..2ce4e5b
--- /dev/null
+++ b/app/lib/core/providers/mesh_provider.dart
@@ -0,0 +1,123 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+/// A mesh node in the Q-Mesh network.
+class MeshNode {
+ final String id;
+ final String label;
+ final bool isOnline;
+ final double? signalStrength;
+ final DateTime lastSeen;
+
+ const MeshNode({
+ required this.id,
+ required this.label,
+ this.isOnline = false,
+ this.signalStrength,
+ required this.lastSeen,
+ });
+}
+
+/// An edge between two mesh nodes.
+class MeshEdge {
+ final String sourceId;
+ final String targetId;
+ final double? latency;
+
+ const MeshEdge({
+ required this.sourceId,
+ required this.targetId,
+ this.latency,
+ });
+}
+
+/// Security profile of the mesh network.
+enum MeshSecurityProfile {
+ standard, // HMAC-SHA256 beacon auth
+ enhanced, // + SipHash frame integrity
+ quantum, // + QRNG-derived mesh keys
+}
+
+/// State for the Q-Mesh feature.
+class MeshState {
+ final List nodes;
+ final List edges;
+ final bool isConnected;
+ final MeshSecurityProfile securityProfile;
+ final DateTime? lastKeyRotation;
+ final int? meshKeyAge;
+ final String? error;
+
+ const MeshState({
+ this.nodes = const [],
+ this.edges = const [],
+ this.isConnected = false,
+ this.securityProfile = MeshSecurityProfile.standard,
+ this.lastKeyRotation,
+ this.meshKeyAge,
+ this.error,
+ });
+
+ MeshState copyWith({
+ List? nodes,
+ List? edges,
+ bool? isConnected,
+ MeshSecurityProfile? securityProfile,
+ DateTime? lastKeyRotation,
+ int? meshKeyAge,
+ String? error,
+ }) => MeshState(
+ nodes: nodes ?? this.nodes,
+ edges: edges ?? this.edges,
+ isConnected: isConnected ?? this.isConnected,
+ securityProfile: securityProfile ?? this.securityProfile,
+ lastKeyRotation: lastKeyRotation ?? this.lastKeyRotation,
+ meshKeyAge: meshKeyAge ?? this.meshKeyAge,
+ error: error,
+ );
+
+ int get onlineCount => nodes.where((n) => n.isOnline).length;
+}
+
+/// Manages Q-Mesh network state and provisioning.
+class MeshNotifier extends Notifier {
+ @override
+ MeshState build() {
+ // Seed with demo topology until live mesh discovery is implemented
+ final now = DateTime.now();
+ return MeshState(
+ nodes: [
+ MeshNode(id: 'esp32-001', label: 'Gateway', isOnline: true, signalStrength: -45, lastSeen: now),
+ MeshNode(id: 'esp32-002', label: 'Bedroom', isOnline: true, signalStrength: -62, lastSeen: now),
+ MeshNode(id: 'esp32-003', label: 'Kitchen', isOnline: true, signalStrength: -58, lastSeen: now),
+ MeshNode(id: 'esp32-004', label: 'Office', isOnline: false, signalStrength: null, lastSeen: now.subtract(const Duration(hours: 2))),
+ ],
+ edges: [
+ const MeshEdge(sourceId: 'esp32-001', targetId: 'esp32-002', latency: 12),
+ const MeshEdge(sourceId: 'esp32-001', targetId: 'esp32-003', latency: 8),
+ const MeshEdge(sourceId: 'esp32-002', targetId: 'esp32-004', latency: 25),
+ ],
+ isConnected: true,
+ securityProfile: MeshSecurityProfile.quantum,
+ lastKeyRotation: now.subtract(const Duration(hours: 6)),
+ meshKeyAge: 6,
+ );
+ }
+
+ void refreshTopology() {
+ // Will call Rust FFI when mesh discovery is wired
+ state = state.copyWith(error: null);
+ }
+
+ void rotateKey() {
+ state = state.copyWith(
+ lastKeyRotation: DateTime.now(),
+ meshKeyAge: 0,
+ );
+ }
+
+ void setSecurityProfile(MeshSecurityProfile profile) {
+ state = state.copyWith(securityProfile: profile);
+ }
+}
+
+final meshProvider = NotifierProvider(MeshNotifier.new);
diff --git a/app/lib/core/providers/on_device_provider.dart b/app/lib/core/providers/on_device_provider.dart
new file mode 100644
index 0000000..fd95d1c
--- /dev/null
+++ b/app/lib/core/providers/on_device_provider.dart
@@ -0,0 +1,142 @@
+import 'dart:async';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:zipminator/core/services/llm_provider.dart';
+import 'package:zipminator/core/services/on_device_service.dart';
+
+/// State for on-device model management.
+class OnDeviceState {
+ final bool runtimeAvailable;
+ final Set downloadedModelIds;
+ final String? activeModelId;
+ final String? downloadingModelId;
+ final double downloadProgress;
+ final String? error;
+
+ const OnDeviceState({
+ this.runtimeAvailable = false,
+ this.downloadedModelIds = const {},
+ this.activeModelId,
+ this.downloadingModelId,
+ this.downloadProgress = 0,
+ this.error,
+ });
+
+ bool get isDownloading => downloadingModelId != null;
+ bool get hasActiveModel => activeModelId != null;
+
+ bool isModelDownloaded(String modelId) =>
+ downloadedModelIds.contains(modelId);
+
+ OnDeviceState copyWith({
+ bool? runtimeAvailable,
+ Set? downloadedModelIds,
+ String? activeModelId,
+ String? downloadingModelId,
+ double? downloadProgress,
+ String? error,
+ bool clearActiveModel = false,
+ bool clearDownloading = false,
+ bool clearError = false,
+ }) =>
+ OnDeviceState(
+ runtimeAvailable: runtimeAvailable ?? this.runtimeAvailable,
+ downloadedModelIds: downloadedModelIds ?? this.downloadedModelIds,
+ activeModelId:
+ clearActiveModel ? null : (activeModelId ?? this.activeModelId),
+ downloadingModelId: clearDownloading
+ ? null
+ : (downloadingModelId ?? this.downloadingModelId),
+ downloadProgress: downloadProgress ?? this.downloadProgress,
+ error: clearError ? null : (error ?? this.error),
+ );
+}
+
+class OnDeviceNotifier extends Notifier {
+ StreamSubscription? _downloadSub;
+
+ @override
+ OnDeviceState build() {
+ ref.onDispose(() => _downloadSub?.cancel());
+ _init();
+ return const OnDeviceState();
+ }
+
+ Future _init() async {
+ final available = await OnDeviceService.isAvailable();
+ final downloaded = await OnDeviceService.listDownloadedModels();
+ state = state.copyWith(
+ runtimeAvailable: available,
+ downloadedModelIds: downloaded.toSet(),
+ );
+ }
+
+ /// Download a model from HuggingFace. Progress updates are streamed.
+ Future downloadModel(LLMModel model) async {
+ if (state.isDownloading) return;
+
+ state = state.copyWith(
+ downloadingModelId: model.id,
+ downloadProgress: 0,
+ clearError: true,
+ );
+
+ _downloadSub?.cancel();
+ _downloadSub = OnDeviceService.downloadModel(model).listen(
+ (progress) {
+ state = state.copyWith(downloadProgress: progress.progress);
+ if (progress.isComplete) {
+ state = state.copyWith(
+ downloadedModelIds: {...state.downloadedModelIds, model.id},
+ clearDownloading: true,
+ );
+ }
+ },
+ onError: (e) {
+ state = state.copyWith(
+ error: e.toString(),
+ clearDownloading: true,
+ );
+ },
+ );
+ }
+
+ /// Load a downloaded model into the inference engine.
+ Future activateModel(String modelId) async {
+ if (!state.isModelDownloaded(modelId)) {
+ state = state.copyWith(error: 'Model not downloaded');
+ return;
+ }
+
+ try {
+ // The native side resolves modelId to the stored file path.
+ final ok = await OnDeviceService.loadModel(modelId);
+ if (ok) {
+ state = state.copyWith(activeModelId: modelId, clearError: true);
+ } else {
+ state = state.copyWith(error: 'Failed to load model');
+ }
+ } catch (e) {
+ state = state.copyWith(error: e.toString());
+ }
+ }
+
+ /// Unload the active model from memory.
+ Future deactivateModel() async {
+ await OnDeviceService.unloadModel();
+ state = state.copyWith(clearActiveModel: true);
+ }
+
+ /// Delete a downloaded model from disk.
+ Future deleteModel(String modelId) async {
+ if (state.activeModelId == modelId) await deactivateModel();
+ await OnDeviceService.deleteModel(modelId);
+ final updated = Set.from(state.downloadedModelIds)..remove(modelId);
+ state = state.copyWith(downloadedModelIds: updated);
+ }
+
+ /// Refresh the list of downloaded models from native storage.
+ Future refresh() async => _init();
+}
+
+final onDeviceProvider =
+ NotifierProvider(OnDeviceNotifier.new);
diff --git a/app/lib/core/providers/qai_provider.dart b/app/lib/core/providers/qai_provider.dart
index 909513c..0c8d351 100644
--- a/app/lib/core/providers/qai_provider.dart
+++ b/app/lib/core/providers/qai_provider.dart
@@ -32,6 +32,7 @@ class QaiState {
});
bool get hasApiKey {
+ if (selectedProvider.isLocal) return true;
final key = apiKeys[selectedProvider];
return key != null && key.isNotEmpty;
}
@@ -125,7 +126,7 @@ class QaiNotifier extends Notifier {
try {
final service =
- createLLMService(state.selectedProvider, state.currentApiKey!);
+ createLLMService(state.selectedProvider, state.currentApiKey ?? '');
final apiMessages = state.messages
.map((m) => {
'role': m.isUser ? 'user' : 'assistant',
diff --git a/app/lib/core/providers/ratchet_provider.dart b/app/lib/core/providers/ratchet_provider.dart
index 73923de..973f9b9 100644
--- a/app/lib/core/providers/ratchet_provider.dart
+++ b/app/lib/core/providers/ratchet_provider.dart
@@ -750,8 +750,15 @@ class RatchetNotifier extends Notifier {
return null;
}
- // Offline / demo fallback: schedule auto-reply.
- _scheduleAutoReply();
+ // Offline: schedule demo auto-reply only when signaling is unreachable.
+ // In production (isLive was true but connection dropped), show an error.
+ if (!state.isLive) {
+ _scheduleAutoReply();
+ } else {
+ // Was live but disconnected mid-session: queue for retry
+ state = state.copyWith(
+ error: 'Signaling disconnected. Message queued for retry.');
+ }
// Attempt Rust ratchet encryption (best-effort).
if (state.sessionId != null && state.isConnected) {
diff --git a/app/lib/core/providers/srtp_provider.dart b/app/lib/core/providers/srtp_provider.dart
index 3b27393..6c4d272 100644
--- a/app/lib/core/providers/srtp_provider.dart
+++ b/app/lib/core/providers/srtp_provider.dart
@@ -1,9 +1,9 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter_riverpod/flutter_riverpod.dart';
-import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:zipminator/core/providers/ratchet_provider.dart';
import 'package:zipminator/core/services/conference_service.dart';
+import 'package:zipminator/core/services/webrtc_service.dart';
import 'package:zipminator/src/rust/api/simple.dart' as rust;
/// Call lifecycle phases.
@@ -113,13 +113,18 @@ class VoipState {
/// Manages VoIP call state with PQ-SRTP key derivation, live signaling,
/// and WebRTC conference support.
+///
+/// The call duration timer lives here (not in the widget) so it persists
+/// when the user navigates to other tabs mid-call.
class VoipNotifier extends Notifier {
ConferenceService? _conference;
StreamSubscription