Files
impact/lib/services/wallet_identity_service.dart
T
qlionbleusamandClaude Opus 5 ba58cf8efc feat(session): swap the AI export button for a per-session switch
The end-of-session dialog no longer carries a second "finish" button.
"TERMINER TOUT" is now the only way out, and what it does with the target
depends on the AI training setting:

- setting on: the target is always sent, with a discreet reminder saying so;
- setting off: a "CONTRIBUER À L'IA" switch (off by default, never
  remembered) decides whether the target is sent or kept on the device;
- banned account or failed analysis: neither switch nor upload.

Turning the switch on for the first time shows the full program disclaimer
(what is sent, pseudonymity, banning rules); refusing leaves it off. That
text moved to a shared showAiConsentDialog() so the settings switch and the
session switch cannot drift apart, and a new is_ai_terms_accepted flag
remembers the acceptance without silently enabling the global setting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 14:15:03 +02:00

226 lines
9.5 KiB
Dart

import 'dart:math';
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'dart:io';
import 'package:flutter/foundation.dart';
class WalletIdentityService {
static const String _prefsKey = 'wallet_identity_phrase';
static const String _uploadEnabledKey = 'is_ai_upload_enabled';
static const String _termsAcceptedKey = 'is_ai_terms_accepted';
static const String _bannedKey = 'wallet_is_banned';
static const String _banReasonKey = 'wallet_ban_reason';
static const String _serverUrlKey = 'ai_server_url';
// A standard list of 256 words (8 bits of entropy per word)
static const List<String> _wordList = [
// ... same as before
'abandon', 'ability', 'able', 'about', 'above', 'absent', 'absorb', 'abstract', 'absurd', 'abuse',
'access', 'accident', 'account', 'accuse', 'achieve', 'acid', 'acoustic', 'acquire', 'across', 'act',
'action', 'actor', 'actress', 'actual', 'adapt', 'add', 'addict', 'address', 'adjust', 'admit',
'adult', 'advance', 'advice', 'aerobic', 'affair', 'afford', 'afraid', 'again', 'age', 'agent',
'agree', 'ahead', 'aim', 'air', 'airport', 'aisle', 'alarm', 'album', 'alcohol', 'alert',
'alien', 'all', 'alley', 'allow', 'almost', 'alone', 'alpha', 'already', 'also', 'alter',
'always', 'amateur', 'amazing', 'among', 'amount', 'amused', 'analyst', 'anchor', 'ancient', 'anger',
'angle', 'angry', 'animal', 'ankle', 'announce', 'annual', 'another', 'answer', 'antenna', 'antique',
'anxiety', 'any', 'apart', 'apology', 'appear', 'apple', 'approve', 'april', 'arch', 'arctic',
'area', 'arena', 'argue', 'arm', 'armed', 'armor', 'army', 'around', 'arrange', 'arrest',
'arrive', 'arrow', 'art', 'artefact', 'artist', 'artwork', 'ask', 'aspect', 'assault', 'asset',
'assist', 'assume', 'asthma', 'athlete', 'atom', 'attack', 'attend', 'attitude', 'attract', 'auction',
'audit', 'august', 'aunt', 'author', 'auto', 'autumn', 'average', 'avocado', 'avoid', 'awake',
'aware', 'away', 'awesome', 'awful', 'awkward', 'axis', 'baby', 'bachelor', 'bacon', 'badge',
'bag', 'balance', 'balcony', 'ball', 'bamboo', 'banana', 'banner', 'bar', 'barely', 'bargain',
'barrel', 'base', 'basic', 'basket', 'battle', 'beach', 'bean', 'beauty', 'because', 'become',
'beef', 'before', 'begin', 'behave', 'behind', 'believe', 'below', 'belt', 'bench', 'benefit',
'best', 'betray', 'better', 'between', 'beyond', 'bicycle', 'bid', 'bike', 'bind', 'biology',
'bird', 'birth', 'bitter', 'black', 'blade', 'blame', 'blanket', 'blast', 'bleak', 'bless',
'blind', 'blood', 'blossom', 'blouse', 'blue', 'blur', 'blush', 'board', 'boat', 'body',
'boil', 'bomb', 'bone', 'bonus', 'book', 'boost', 'border', 'boring', 'borrow', 'boss',
'bottom', 'bounce', 'box', 'boy', 'bracket', 'brain', 'brand', 'brass', 'brave', 'bread',
'breeze', 'brick', 'bridge', 'brief', 'bright', 'bring', 'brisk', 'broccoli', 'broken', 'bronze',
'broom', 'brother', 'brown', 'brush', 'bubble', 'buddy', 'budget', 'buffalo', 'build', 'bulb',
'bulk', 'bullet', 'bundle', 'bunker', 'burden', 'burger', 'burst', 'bus', 'business', 'butter'
];
/// URL par défaut du serveur de production
static const String defaultServerUrl = 'https://backendia.kevlar.cloud';
/// Clé d'authentification API secrète pour le backend
static const String apiKey = 'bully_secret_api_key_2026_x89';
/// Retourne l'URL de base du serveur configuré (par défaut: https://backendia.kevlar.cloud)
Future<String> getServerBaseUrl() async {
final prefs = await SharedPreferences.getInstance();
final customUrl = prefs.getString(_serverUrlKey);
if (customUrl != null && customUrl.trim().isNotEmpty) {
return customUrl.trim().replaceAll(RegExp(r'/api/upload/?$'), '');
}
return defaultServerUrl;
}
/// Définit une URL personnalisée pour le serveur IA
Future<void> setServerBaseUrl(String url) async {
final prefs = await SharedPreferences.getInstance();
final cleanUrl = url.trim().replaceAll(RegExp(r'/api/upload/?$'), '');
await prefs.setString(_serverUrlKey, cleanUrl);
}
/// Vérifie si l'utilisateur a accepté l'envoi de données à l'IA (et n'est pas banni)
Future<bool> isUploadEnabled() async {
final prefs = await SharedPreferences.getInstance();
final isBanned = prefs.getBool(_bannedKey) ?? false;
if (isBanned) return false;
return prefs.getBool(_uploadEnabledKey) ?? false;
}
/// Active ou désactive l'envoi de données
///
/// L'activation n'est proposée qu'après lecture des règles du programme :
/// on mémorise donc au passage que l'avertissement a été accepté.
Future<void> setUploadEnabled(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
final isBanned = prefs.getBool(_bannedKey) ?? false;
if (isBanned) {
await prefs.setBool(_uploadEnabledKey, false);
return;
}
await prefs.setBool(_uploadEnabledKey, enabled);
if (enabled) {
await prefs.setBool(_termsAcceptedKey, true);
}
}
/// Indique si les règles du programme IA ont déjà été lues et acceptées.
///
/// Utilisé par le switch « Contribuer à l'IA » de la fin de session :
/// l'avertissement (contenu envoyé, pseudonymat, bannissement) n'est
/// présenté qu'une seule fois, même si l'option globale des Paramètres
/// reste désactivée.
Future<bool> hasAcceptedAiTerms() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_termsAcceptedKey) ?? false;
}
/// Mémorise l'acceptation des règles du programme IA.
Future<void> setAiTermsAccepted(bool accepted) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_termsAcceptedKey, accepted);
}
/// Vérifie si ce wallet/utilisateur est banni en local
Future<bool> isBanned() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_bannedKey) ?? false;
}
/// Récupère le motif de bannissement enregistré
Future<String?> getBanReason() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_banReasonKey);
}
/// Enregistre l'état de bannissement et le motif
Future<void> setBanned(bool banned, {String? reason}) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_bannedKey, banned);
if (banned) {
await prefs.setString(_banReasonKey, reason ?? 'Photos non conformes aux règles de tir');
await prefs.setBool(_uploadEnabledKey, false);
} else {
await prefs.remove(_banReasonKey);
}
}
/// Synchronise le statut de modération/bannissement avec le serveur backend
Future<bool> syncBanStatus() async {
try {
final phrase = await getIdentityPhrase();
final phraseBytes = utf8.encode(phrase);
final walletHash = sha256.convert(phraseBytes).toString();
final baseUrl = await getServerBaseUrl();
final res = await http.get(
Uri.parse('$baseUrl/api/stats/$walletHash'),
headers: {'X-API-KEY': apiKey},
).timeout(
const Duration(seconds: 4),
);
if (res.statusCode == 200) {
final data = jsonDecode(res.body);
final isBannedOnServer = data['is_banned'] == true;
if (isBannedOnServer) {
await setBanned(true, reason: data['ban_reason']);
} else {
await setBanned(false);
}
return isBannedOnServer;
}
} catch (e) {
debugPrint('Erreur synchro ban: $e');
}
return await isBanned();
}
/// Gets the unique 15-word identity phrase
Future<String> getIdentityPhrase() async {
final prefs = await SharedPreferences.getInstance();
// Check if we already generated it
final existingPhrase = prefs.getString(_prefsKey);
if (existingPhrase != null && existingPhrase.isNotEmpty) {
return existingPhrase;
}
// Generate new phrase based on device ID
final phrase = await _generatePhraseFromDevice();
// Save for future use
await prefs.setString(_prefsKey, phrase);
return phrase;
}
Future<String> _generatePhraseFromDevice() async {
final deviceInfo = DeviceInfoPlugin();
String deviceId = 'unknown_device_${DateTime.now().millisecondsSinceEpoch}';
try {
if (Platform.isAndroid) {
final androidInfo = await deviceInfo.androidInfo;
deviceId = androidInfo.id; // Unique Android ID
} else if (Platform.isIOS) {
final iosInfo = await deviceInfo.iosInfo;
deviceId = iosInfo.identifierForVendor ?? deviceId;
} else if (Platform.isWindows) {
final windowsInfo = await deviceInfo.windowsInfo;
deviceId = windowsInfo.deviceId;
}
} catch (e) {
debugPrint('Erreur lecture device ID: $e');
}
// Add a salt to the device ID
final salt = 'bully_app_wallet_salt_v1';
final bytes = utf8.encode(deviceId + salt);
final digest = sha256.convert(bytes);
// Use the hash to seed a random number generator
// We take the first 4 bytes of the hash as the seed
final seedBytes = digest.bytes.sublist(0, 4);
final seed = (seedBytes[0] << 24) | (seedBytes[1] << 16) | (seedBytes[2] << 8) | seedBytes[3];
final random = Random(seed);
List<String> phraseWords = [];
for (int i = 0; i < 15; i++) {
final randomIndex = random.nextInt(_wordList.length);
phraseWords.add(_wordList[randomIndex]);
}
return phraseWords.join(' ');
}
}