ok
Some checks are pending
Quality check / Flutter analysis (push) Waiting to run
Quality check / CodeQL analysis (java-kotlin) (push) Waiting to run

This commit is contained in:
FabioMich66 2026-03-07 05:42:08 +01:00
parent 60b9efdd52
commit 5e112be16b
11 changed files with 1822 additions and 70 deletions

View file

@ -0,0 +1,96 @@
// lib/remote/auth_client.dart
import 'dart:convert';
import 'package:http/http.dart' as http;
/// Gestisce autenticazione remota e caching del Bearer token.
/// - [baseUrl]: URL base del server (con o senza '/')
/// - [email]/[password]: credenziali
/// - [loginPath]: path dell'endpoint di login (default 'auth/login')
/// - [timeout]: timeout per le richieste (default 20s)
class RemoteAuth {
final Uri base;
final String email;
final String password;
final String loginPath;
final Duration timeout;
String? _token;
RemoteAuth({
required String baseUrl,
required this.email,
required this.password,
this.loginPath = 'auth/login',
this.timeout = const Duration(seconds: 20),
}) : base = Uri.parse(baseUrl.endsWith('/') ? baseUrl : '$baseUrl/');
Uri get _loginUri => base.resolve(loginPath);
/// Esegue il login e memorizza il token.
/// Lancia eccezione con messaggio chiaro in caso di errore HTTP, rete o JSON.
Future<String> login() async {
final uri = _loginUri;
final headers = {'Content-Type': 'application/json'};
final bodyStr = json.encode({'email': email, 'password': password});
http.Response res;
try {
res = await http
.post(uri, headers: headers, body: bodyStr)
.timeout(timeout);
} catch (e) {
throw Exception('Login fallito: errore di rete verso $uri: $e');
}
// Follow esplicito per redirect POST moderni (307/308) mantenendo metodo e body
if ({307, 308}.contains(res.statusCode) && res.headers['location'] != null) {
final redirectUri = uri.resolve(res.headers['location']!);
try {
res = await http
.post(redirectUri, headers: headers, body: bodyStr)
.timeout(timeout);
} catch (e) {
throw Exception('Login fallito: errore di rete verso $redirectUri: $e');
}
}
if (res.statusCode != 200) {
final snippet = utf8.decode(res.bodyBytes.take(200).toList());
throw Exception(
'Login fallito: HTTP ${res.statusCode} ${res.reasonPhrase} $snippet',
);
}
// Parsing JSON robusto
Map<String, dynamic> map;
try {
map = json.decode(utf8.decode(res.bodyBytes)) as Map<String, dynamic>;
} catch (_) {
throw Exception('Login fallito: risposta non è un JSON valido');
}
// Supporto sia 'token' sia 'access_token'
final token = (map['token'] ?? map['access_token']) as String?;
if (token == null || token.isEmpty) {
throw Exception('Login fallito: token assente nella risposta');
}
_token = token;
return token;
}
/// Ritorna gli header con Bearer; se non hai token, esegue login.
Future<Map<String, String>> authHeaders() async {
_token ??= await login();
return {'Authorization': 'Bearer $_token'};
}
/// Forza il rinnovo del token (es. dopo 401) e ritorna i nuovi header.
Future<Map<String, String>> refreshAndHeaders() async {
_token = null;
return await authHeaders();
}
/// Accesso in sola lettura al token corrente (può essere null).
String? get token => _token;
}

View file

@ -0,0 +1,93 @@
// lib/remote/remote_client.dart
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'remote_models.dart';
import 'auth_client.dart';
class RemoteJsonClient {
final Uri indexUri; // es. https://prova.patachina.it/photos/
final RemoteAuth? auth; // opzionale: se presente, aggiunge Bearer
RemoteJsonClient(
String baseUrl,
String indexPath, {
this.auth,
}) : indexUri = Uri.parse(baseUrl.endsWith('/') ? baseUrl : '$baseUrl/')
.resolve(indexPath);
Future<List<RemotePhotoItem>> fetchAll() async {
Map<String, String> headers = {};
if (auth != null) {
headers = await auth!.authHeaders();
}
// DEBUG: stampa la URL precisa
// ignore: avoid_print
print('[remote-client] GET $indexUri');
http.Response res;
try {
res = await http.get(indexUri, headers: headers).timeout(const Duration(seconds: 20));
} catch (e) {
throw Exception('Errore rete su $indexUri: $e');
}
// Retry 1 volta in caso di 401 (token scaduto/invalidato)
if (res.statusCode == 401 && auth != null) {
headers = await auth!.refreshAndHeaders();
res = await http.get(indexUri, headers: headers).timeout(const Duration(seconds: 20));
}
// Follow 30x mantenendo Authorization
if ({301, 302, 307, 308}.contains(res.statusCode) && res.headers['location'] != null) {
final loc = res.headers['location']!;
final redirectUri = indexUri.resolve(loc);
res = await http.get(redirectUri, headers: headers).timeout(const Duration(seconds: 20));
}
if (res.statusCode != 200) {
final snippet = utf8.decode(res.bodyBytes.take(200).toList());
throw Exception('HTTP ${res.statusCode} ${res.reasonPhrase} su $indexUri. Body: $snippet');
}
final body = utf8.decode(res.bodyBytes);
// Qui siamo espliciti: ci aspettiamo SEMPRE una lista top-level
final dynamic decoded = json.decode(body);
if (decoded is! List) {
throw Exception('JSON inatteso: atteso array top-level, ricevuto ${decoded.runtimeType}');
}
final List<dynamic> rawList = decoded;
// --- DIAGNOSTICA: conteggio pattern dai dati del SERVER (non stampo il JSON intero)
int withOriginal = 0, withoutOriginal = 0, leadingSlash = 0, noLeadingSlash = 0;
for (final e in rawList) {
if (e is Map<String, dynamic>) {
final p = (e['path'] ?? '').toString();
if (p.startsWith('/')) {
leadingSlash++;
} else {
noLeadingSlash++;
}
if (p.contains('/original/')) {
withOriginal++;
} else {
withoutOriginal++;
}
}
}
// ignore: avoid_print
print('[remote-client] SERVER paths -> withOriginal=$withOriginal | withoutOriginal=$withoutOriginal | '
'leadingSlash=$leadingSlash | noLeadingSlash=$noLeadingSlash');
// Costruiamo List<RemotePhotoItem>
final List<RemotePhotoItem> items = rawList.map<RemotePhotoItem>((e) {
if (e is! Map<String, dynamic>) {
throw Exception('Elemento JSON non è una mappa: ${e.runtimeType} -> $e');
}
return RemotePhotoItem.fromJson(e);
}).toList();
return items;
}
}

View file

@ -0,0 +1,125 @@
// lib/remote/remote_models.dart
import 'url_utils.dart';
class RemotePhotoItem {
final String id;
final String name;
final String path;
final String? thub1, thub2;
final String? mimeType;
final int? width, height, sizeBytes;
final DateTime? takenAtUtc;
final double? lat, lng, alt;
final String? dataExifLegacy;
final String? user;
final int? durationMillis;
final RemoteLocation? location;
RemotePhotoItem({
required this.id,
required this.name,
required this.path,
this.thub1,
this.thub2,
this.mimeType,
this.width,
this.height,
this.sizeBytes,
this.takenAtUtc,
this.lat,
this.lng,
this.alt,
this.dataExifLegacy,
this.user,
this.durationMillis,
this.location,
});
// URL completo costruito solo in fase di lettura
// String get uri => "https://prova.patachina.it/$path";
// Costruzione URL assoluto delegata a utility (in base alle impostazioni)
String absoluteUrl(String baseUrl) => buildAbsoluteUri(baseUrl, path).toString();
static DateTime? _tryParseIsoUtc(dynamic v) {
if (v == null) return null;
try { return DateTime.parse(v.toString()).toUtc(); } catch (_) { return null; }
}
static double? _toDouble(dynamic v) {
if (v == null) return null;
if (v is num) return v.toDouble();
return double.tryParse(v.toString());
}
static int? _toMillis(dynamic v) {
if (v == null) return null;
final num? n = (v is num) ? v : num.tryParse(v.toString());
if (n == null) return null;
return n >= 1000 ? n.toInt() : (n * 1000).toInt();
}
factory RemotePhotoItem.fromJson(Map<String, dynamic> j) {
final gps = j['gps'] as Map<String, dynamic>?;
final loc = j['location'] is Map<String, dynamic>
? RemoteLocation.fromJson(j['location'] as Map<String, dynamic>)
: null;
return RemotePhotoItem(
id: (j['id'] ?? j['name']).toString(),
name: (j['name'] ?? '').toString(),
path: (j['path'] ?? '').toString(),
thub1: j['thub1']?.toString(),
thub2: j['thub2']?.toString(),
mimeType: j['mime_type']?.toString(),
width: (j['width'] as num?)?.toInt(),
height: (j['height'] as num?)?.toInt(),
sizeBytes: (j['size_bytes'] as num?)?.toInt(),
takenAtUtc: _tryParseIsoUtc(j['taken_at']),
dataExifLegacy: j['data']?.toString(),
lat: gps != null ? _toDouble(gps['lat']) : null,
lng: gps != null ? _toDouble(gps['lng']) : null,
alt: gps != null ? _toDouble(gps['alt']) : null,
user: j['user']?.toString(),
durationMillis: _toMillis(j['duration']),
location: loc,
);
}
}
class RemoteLocation {
final String? continent;
final String? country;
final String? region;
final String? postcode;
final String? city;
final String? countyCode;
final String? address;
final String? timezone;
final String? timeOffset;
RemoteLocation({
this.continent,
this.country,
this.region,
this.postcode,
this.city,
this.countyCode,
this.address,
this.timezone,
this.timeOffset,
});
factory RemoteLocation.fromJson(Map<String, dynamic> j) => RemoteLocation(
continent: j['continent']?.toString(),
country: j['country']?.toString(),
region: j['region']?.toString(),
postcode: j['postcode']?.toString(),
city: j['city']?.toString(),
countyCode:j['county_code']?.toString(),
address: j['address']?.toString(),
timezone: j['timezone']?.toString(),
timeOffset:j['time']?.toString(),
);
}

View file

@ -0,0 +1,375 @@
// lib/remote/remote_repository.dart
import 'package:flutter/foundation.dart' show debugPrint;
import 'package:sqflite/sqflite.dart';
import 'remote_models.dart';
class RemoteRepository {
final Database db;
RemoteRepository(this.db);
// =========================
// Helpers PRAGMA / schema
// =========================
Future<void> _ensureColumns(
DatabaseExecutor dbExec, {
required String table,
required Map<String, String> columnsAndTypes,
}) async {
try {
final rows = await dbExec.rawQuery('PRAGMA table_info($table);');
final existing = rows.map((r) => (r['name'] as String)).toSet();
for (final entry in columnsAndTypes.entries) {
final col = entry.key;
final typ = entry.value;
if (!existing.contains(col)) {
final sql = 'ALTER TABLE $table ADD COLUMN $col $typ;';
try {
await dbExec.execute(sql);
debugPrint('[RemoteRepository] executed: $sql');
} catch (e, st) {
debugPrint('[RemoteRepository] failed to execute $sql: $e\n$st');
}
}
}
} catch (e, st) {
debugPrint('[RemoteRepository] _ensureColumns($table) error: $e\n$st');
}
}
/// Assicura che le colonne GPS e alcune colonne "remote*" esistano nella tabella `entry`.
Future<void> _ensureEntryColumns(DatabaseExecutor dbExec) async {
await _ensureColumns(dbExec, table: 'entry', columnsAndTypes: const {
// GPS
'latitude': 'REAL',
'longitude': 'REAL',
'altitude': 'REAL',
// Campi remoti
'remoteId': 'TEXT',
'remotePath': 'TEXT',
'remoteThumb1': 'TEXT',
'remoteThumb2': 'TEXT',
'origin': 'INTEGER',
'provider': 'TEXT',
'trashed': 'INTEGER',
});
// Indice "normale" per velocizzare il lookup su remoteId
try {
await dbExec.execute(
'CREATE INDEX IF NOT EXISTS idx_entry_remoteId ON entry(remoteId);',
);
} catch (e, st) {
debugPrint('[RemoteRepository] create index error: $e\n$st');
}
}
// =========================
// Retry su SQLITE_BUSY
// =========================
bool _isBusy(Object e) {
final s = e.toString();
return s.contains('SQLITE_BUSY') || s.contains('database is locked');
}
Future<T> _withRetryBusy<T>(Future<T> Function() fn) async {
const maxAttempts = 3;
var delay = const Duration(milliseconds: 250);
for (var i = 0; i < maxAttempts; i++) {
try {
return await fn();
} catch (e) {
if (!_isBusy(e) || i == maxAttempts - 1) rethrow;
await Future.delayed(delay);
delay *= 2; // 250 500 1000 ms
}
}
// non dovrebbe arrivare qui
return await fn();
}
// =========================
// Normalizzazione SOLO per diagnostica (non cambia cosa salvi)
// =========================
String _normPath(String? p) {
if (p == null || p.isEmpty) return '';
var s = p.trim().replaceAll(RegExp(r'/+'), '/');
if (!s.startsWith('/')) s = '/$s';
return s;
}
/// Candidato "canonico" (inserisce '/original/' dopo '/photos/<User>/'
/// se manca). Usato solo per LOG/HINT, NON per scrivere.
String _canonCandidate(String? rawPath, String fileName) {
var s = _normPath(rawPath);
final seg = s.split('/'); // ['', 'photos', '<User>', maybe 'original', ...]
if (seg.length >= 4 && seg[1] == 'photos' && seg[3] != 'original' && seg[3] != 'thumbs') {
seg.insert(3, 'original');
}
if (fileName.isNotEmpty) {
seg[seg.length - 1] = fileName;
}
return seg.join('/');
}
// =========================
// Utilities
// =========================
bool _isVideoItem(RemotePhotoItem it) {
final mt = (it.mimeType ?? '').toLowerCase();
final p = (it.path).toLowerCase();
return mt.startsWith('video/') ||
p.endsWith('.mp4') ||
p.endsWith('.mov') ||
p.endsWith('.m4v') ||
p.endsWith('.mkv') ||
p.endsWith('.webm');
}
Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
// NON correggo: salvo esattamente quello che arriva (come ora)
return <String, Object?>{
'id': existingId,
'contentId': null,
'uri': null,
'path': it.path,
'sourceMimeType': it.mimeType,
'width': it.width,
'height': it.height,
'sourceRotationDegrees': null,
'sizeBytes': it.sizeBytes,
'title': it.name,
'dateAddedSecs': DateTime.now().millisecondsSinceEpoch ~/ 1000,
'dateModifiedMillis': null,
'sourceDateTakenMillis': it.takenAtUtc?.millisecondsSinceEpoch,
'durationMillis': it.durationMillis,
// REMOTI VISIBILI (come nel tuo file attuale)
'trashed': 0,
'origin': 1,
'provider': 'json@patachina',
// GPS (possono essere null)
'latitude': it.lat,
'longitude': it.lng,
'altitude': it.alt,
// campi remoti ( path raw, senza forzare /original/)
'remoteId': it.id,
'remotePath': it.path,
'remoteThumb1': it.thub1,
'remoteThumb2': it.thub2,
};
}
Map<String, Object?> _buildAddressRow(int newId, RemoteLocation location) {
return <String, Object?>{
'id': newId,
'addressLine': location.address,
'countryCode': null,
'countryName': location.country,
'adminArea': location.region,
'locality': location.city,
};
}
// =========================
// Upsert a chunk (DIAGNOSTICA inclusa)
// =========================
Future<void> upsertAll(List<RemotePhotoItem> items, {int chunkSize = 200}) async {
debugPrint('RemoteRepository.upsertAll: items=${items.length}');
if (items.isEmpty) return;
await _withRetryBusy(() => _ensureEntryColumns(db));
// Ordina: prima immagini, poi video
final images = <RemotePhotoItem>[];
final videos = <RemotePhotoItem>[];
for (final it in items) {
(_isVideoItem(it) ? videos : images).add(it);
}
final ordered = <RemotePhotoItem>[...images, ...videos];
for (var offset = 0; offset < ordered.length; offset += chunkSize) {
final end = (offset + chunkSize < ordered.length) ? offset + chunkSize : ordered.length;
final chunk = ordered.sublist(offset, end);
try {
await _withRetryBusy(() => db.transaction((txn) async {
final batch = txn.batch();
for (final it in chunk) {
// === DIAGNOSTICA PRE-LOOKUP ===
final raw = it.path;
final norm = _normPath(raw);
final cand = _canonCandidate(raw, it.name);
final hasOriginal = raw.contains('/original/');
final hasLeading = raw.startsWith('/');
debugPrint(
'[repo-upsert] in: rid=${it.id.substring(0,8)} name=${it.name} '
'raw="$raw" (original=${hasOriginal?"Y":"N"}, leading=${hasLeading?"Y":"N"})'
);
// Lookup record esistente SOLO per remoteId (comportamento attuale)
int? existingId;
try {
final existing = await txn.query(
'entry',
columns: ['id'],
where: 'origin=1 AND remoteId = ?',
whereArgs: [it.id],
limit: 1,
);
existingId = existing.isNotEmpty ? (existing.first['id'] as int?) : null;
} catch (e, st) {
debugPrint('[RemoteRepository] lookup existingId failed for remoteId=${it.id}: $e\n$st');
}
// === DIAGNOSTICA HINT: esisterebbe una riga compatibile per path? ===
// 1) path canonico (con /original/)
try {
final byCanon = await txn.query(
'entry',
columns: ['id'],
where: 'origin=1 AND remotePath = ?',
whereArgs: [cand],
limit: 1,
);
if (byCanon.isNotEmpty && existingId == null) {
final idCand = byCanon.first['id'];
debugPrint(
'[repo-upsert][HINT] trovata riga per CAND-remotePath="$cand" -> id=$idCand '
'(il lookup corrente per remoteId NON la vede: possibile causa duplicato)'
);
}
} catch (_) {}
// 2) path raw normalizzato (solo slash)
try {
final byNorm = await txn.query(
'entry',
columns: ['id'],
where: 'origin=1 AND remotePath = ?',
whereArgs: [norm],
limit: 1,
);
if (byNorm.isNotEmpty && existingId == null) {
final idNorm = byNorm.first['id'];
debugPrint(
'[repo-upsert][HINT] trovata riga per RAW-NORM-remotePath="$norm" -> id=$idNorm '
'(il lookup corrente per remoteId NON la vede: possibile causa duplicato)'
);
}
} catch (_) {}
// Riga completa ( salviamo il RAW come stai facendo ora)
final row = _buildEntryRow(it, existingId: existingId);
// Insert/replace
try {
batch.insert(
'entry',
row,
conflictAlgorithm: ConflictAlgorithm.replace,
);
} on DatabaseException catch (e, st) {
debugPrint('[RemoteRepository] batch insert failed for remoteId=${it.id}: $e\n$st');
final rowNoGps = Map<String, Object?>.from(row)
..remove('latitude')
..remove('longitude')
..remove('altitude');
batch.insert(
'entry',
rowNoGps,
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
}
await batch.commit(noResult: true);
// Secondo pass per address (immutato)
for (final it in chunk) {
if (it.location == null) continue;
try {
final rows = await txn.query(
'entry',
columns: ['id'],
where: 'origin=1 AND remoteId = ?',
whereArgs: [it.id],
limit: 1,
);
if (rows.isEmpty) continue;
final newId = rows.first['id'] as int;
final addr = _buildAddressRow(newId, it.location!);
await txn.insert(
'address',
addr,
conflictAlgorithm: ConflictAlgorithm.replace,
);
} catch (e, st) {
debugPrint('[RemoteRepository] insert address failed for remoteId=${it.id}: $e\n$st');
}
}
}));
} catch (e, st) {
debugPrint('[RemoteRepository] upsert chunk ${offset}..${end - 1} ERROR: $e\n$st');
rethrow;
}
}
}
// =========================
// Unicità & deduplica (immutato)
// =========================
Future<void> ensureUniqueRemoteId() async {
try {
await db.execute(
'CREATE UNIQUE INDEX IF NOT EXISTS uq_entry_remote_remoteId '
'ON entry(remoteId) WHERE origin=1',
);
debugPrint('[RemoteRepository] ensured UNIQUE index on entry(remoteId) for origin=1');
} catch (e, st) {
debugPrint('[RemoteRepository] ensureUniqueRemoteId error: $e\n$st');
}
}
Future<int> deduplicateRemotes() async {
try {
final deleted = await db.rawDelete(
'DELETE FROM entry '
'WHERE origin=1 AND remoteId IS NOT NULL AND id NOT IN ('
' SELECT MAX(id) FROM entry '
' WHERE origin=1 AND remoteId IS NOT NULL '
' GROUP BY remoteId'
')',
);
debugPrint('[RemoteRepository] deduplicateRemotes deleted=$deleted');
return deleted;
} catch (e, st) {
debugPrint('[RemoteRepository] deduplicateRemotes error: $e\n$st');
return 0;
}
}
Future<void> sanitizeRemotes() async {
await deduplicateRemotes();
await ensureUniqueRemoteId();
}
// =========================
// Utils
// =========================
Future<int> countRemote() async {
final rows = await db.rawQuery('SELECT COUNT(1) AS c FROM entry WHERE origin=1');
return (rows.first['c'] as int?) ?? 0;
}
}

View file

@ -0,0 +1,83 @@
// lib/remote/remote_settings.dart
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class RemoteSettings {
static const _storage = FlutterSecureStorage();
// Keys
static const _kEnabled = 'remote_enabled';
static const _kBaseUrl = 'remote_base_url';
static const _kIndexPath = 'remote_index_path';
static const _kEmail = 'remote_email';
static const _kPassword = 'remote_password';
// Default values:
// In DEBUG vogliamo valori pre-compilati; in RELEASE lasciamo vuoti/false.
static final bool defaultEnabled = kDebugMode ? true : false;
static final String defaultBaseUrl = kDebugMode ? 'https://prova.patachina.it' : '';
static final String defaultIndexPath = kDebugMode ? 'photos/' : '';
static final String defaultEmail = kDebugMode ? 'fabio@gmail.com' : '';
static final String defaultPassword = kDebugMode ? 'master66' : '';
bool enabled;
String baseUrl;
String indexPath;
String email;
String password;
RemoteSettings({
required this.enabled,
required this.baseUrl,
required this.indexPath,
required this.email,
required this.password,
});
/// Carica i setting dal secure storage.
/// Se un valore non esiste, usa i default (in debug: quelli precompilati).
static Future<RemoteSettings> load() async {
final enabledStr = await _storage.read(key: _kEnabled);
final baseUrl = await _storage.read(key: _kBaseUrl) ?? defaultBaseUrl;
final indexPath = await _storage.read(key: _kIndexPath) ?? defaultIndexPath;
final email = await _storage.read(key: _kEmail) ?? defaultEmail;
final password = await _storage.read(key: _kPassword) ?? defaultPassword;
final enabled = (enabledStr ?? (defaultEnabled ? 'true' : 'false')) == 'true';
return RemoteSettings(
enabled: enabled,
baseUrl: baseUrl,
indexPath: indexPath,
email: email,
password: password,
);
}
/// Scrive i setting nel secure storage.
Future<void> save() async {
await _storage.write(key: _kEnabled, value: enabled ? 'true' : 'false');
await _storage.write(key: _kBaseUrl, value: baseUrl);
await _storage.write(key: _kIndexPath, value: indexPath);
await _storage.write(key: _kEmail, value: email);
await _storage.write(key: _kPassword, value: password);
}
/// In DEBUG: se un valore non è ancora impostato, inizializzalo con i default.
/// NON sovrascrive valori già presenti (quindi puoi sempre entrare in Settings e cambiare).
static Future<void> debugSeedIfEmpty() async {
if (!kDebugMode) return;
Future<void> _seed(String key, String value) async {
final existing = await _storage.read(key: key);
if (existing == null) {
await _storage.write(key: key, value: value);
}
}
await _seed(_kEnabled, defaultEnabled ? 'true' : 'false');
await _seed(_kBaseUrl, defaultBaseUrl);
await _seed(_kIndexPath, defaultIndexPath);
await _seed(_kEmail, defaultEmail);
await _seed(_kPassword, defaultPassword);
}
}

View file

@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import 'remote_settings.dart';
class RemoteSettingsPage extends StatefulWidget {
const RemoteSettingsPage({super.key});
@override
State<RemoteSettingsPage> createState() => _RemoteSettingsPageState();
}
class _RemoteSettingsPageState extends State<RemoteSettingsPage> {
final _form = GlobalKey<FormState>();
bool _enabled = RemoteSettings.defaultEnabled;
final _baseUrl = TextEditingController(text: RemoteSettings.defaultBaseUrl);
final _indexPath = TextEditingController(text: RemoteSettings.defaultIndexPath);
final _email = TextEditingController();
final _password = TextEditingController();
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
final s = await RemoteSettings.load();
setState(() {
_enabled = s.enabled;
_baseUrl.text = s.baseUrl;
_indexPath.text = s.indexPath;
_email.text = s.email;
_password.text = s.password;
});
}
Future<void> _save() async {
if (!_form.currentState!.validate()) return;
final s = RemoteSettings(
enabled: _enabled,
baseUrl: _baseUrl.text.trim(),
indexPath: _indexPath.text.trim(),
email: _email.text.trim(),
password: _password.text,
);
await s.save();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Impostazioni remote salvate')));
Navigator.of(context).maybePop();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Remote Settings')),
body: Form(
key: _form,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
SwitchListTile(
title: const Text('Abilita sync remoto'),
value: _enabled,
onChanged: (v) => setState(() => _enabled = v),
),
TextFormField(
controller: _baseUrl,
decoration: const InputDecoration(labelText: 'Base URL (es. https://server.tld)'),
validator: (v) => (v==null || v.isEmpty) ? 'Obbligatorio' : null,
),
TextFormField(
controller: _indexPath,
decoration: const InputDecoration(labelText: 'Index path (es. photos/)'),
validator: (v) => (v==null || v.isEmpty) ? 'Obbligatorio' : null,
),
TextFormField(
controller: _email,
decoration: const InputDecoration(labelText: 'User/Email'),
),
TextFormField(
controller: _password,
obscureText: true,
decoration: const InputDecoration(labelText: 'Password'),
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: _save,
icon: const Icon(Icons.save),
label: const Text('Salva'),
),
],
),
),
);
}
}

View file

@ -0,0 +1,647 @@
// lib/remote/remote_test_page.dart
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sqflite/sqflite.dart';
// Integrazione impostazioni & auth remota (Fase 1)
import 'remote_settings.dart';
import 'auth_client.dart';
import 'url_utils.dart';
enum _RemoteFilter { all, visibleOnly, trashedOnly }
class RemoteTestPage extends StatefulWidget {
final Database db;
/// Base URL preferita (es. https://prova.patachina.it).
/// Se non la passi o è vuota, verrà usata quella in RemoteSettings.
final String? baseUrl;
const RemoteTestPage({
super.key,
required this.db,
this.baseUrl,
});
@override
State<RemoteTestPage> createState() => _RemoteTestPageState();
}
class _RemoteTestPageState extends State<RemoteTestPage> {
Future<List<_RemoteRow>>? _future;
String _baseUrl = '';
Map<String, String>? _authHeaders;
bool _navigating = false; // debounce del tap
_RemoteFilter _filter = _RemoteFilter.all;
// contatori diagnostici
int _countAll = 0;
int _countVisible = 0; // trashed=0
int _countTrashed = 0; // trashed=1
@override
void initState() {
super.initState();
_init(); // prepara baseUrl + header auth (se necessari), poi carica i dati
}
Future<void> _init() async {
// 1) Base URL: parametro > settings
final s = await RemoteSettings.load();
final candidate = (widget.baseUrl ?? '').trim();
_baseUrl = candidate.isNotEmpty ? candidate : s.baseUrl.trim();
// 2) Header Authorization (opzionale)
_authHeaders = null;
try {
if (_baseUrl.isNotEmpty && (s.email.isNotEmpty || s.password.isNotEmpty)) {
final auth = RemoteAuth(baseUrl: _baseUrl, email: s.email, password: s.password);
final token = await auth.login();
_authHeaders = {'Authorization': 'Bearer $token'};
}
} catch (_) {
// In debug non bloccare la pagina se il login immagini fallisce
_authHeaders = null;
}
// 3) Carica contatori e lista
await _refreshCounters();
_future = _load();
if (mounted) setState(() {});
}
Future<void> _refreshCounters() async {
// Totale remoti (origin=1), visibili e cestinati
final all = await widget.db.rawQuery(
"SELECT COUNT(*) AS c FROM entry WHERE origin=1",
);
final vis = await widget.db.rawQuery(
"SELECT COUNT(*) AS c FROM entry WHERE origin=1 AND trashed=0",
);
final tra = await widget.db.rawQuery(
"SELECT COUNT(*) AS c FROM entry WHERE origin=1 AND trashed=1",
);
_countAll = (all.first['c'] as int?) ?? 0;
_countVisible = (vis.first['c'] as int?) ?? 0;
_countTrashed = (tra.first['c'] as int?) ?? 0;
}
Future<List<_RemoteRow>> _load() async {
// Filtro WHERE in base al toggle
String extraWhere = '';
switch (_filter) {
case _RemoteFilter.visibleOnly:
extraWhere = ' AND trashed=0';
break;
case _RemoteFilter.trashedOnly:
extraWhere = ' AND trashed=1';
break;
case _RemoteFilter.all:
default:
extraWhere = '';
}
// Prende le prime 300 entry remote (includiamo il mime e il remoteId)
final rows = await widget.db.rawQuery(
'SELECT id, remoteId, title, remotePath, remoteThumb2, sourceMimeType, trashed '
'FROM entry WHERE origin=1$extraWhere '
'ORDER BY id DESC LIMIT 300',
);
return rows.map((r) {
return _RemoteRow(
id: r['id'] as int,
remoteId: (r['remoteId'] as String?) ?? '',
title: (r['title'] as String?) ?? '',
remotePath: r['remotePath'] as String?,
remoteThumb2: r['remoteThumb2'] as String?,
mime: r['sourceMimeType'] as String?,
trashed: (r['trashed'] as int?) ?? 0,
);
}).toList();
}
// Costruzione robusta dellURL assoluto:
// - se già assoluto ritorna comè
// - se relativo risolve contro _baseUrl (accetta con/senza '/')
String _absUrl(String? relativePath) {
if (relativePath == null || relativePath.isEmpty) return '';
final p = relativePath.trim();
// URL già assoluto
if (p.startsWith('http://') || p.startsWith('https://')) return p;
if (_baseUrl.isEmpty) return '';
try {
final base = Uri.parse(_baseUrl.endsWith('/') ? _baseUrl : '$_baseUrl/');
// normalizza: se inizia con '/', togliamo per usare resolve coerente
final rel = p.startsWith('/') ? p.substring(1) : p;
final resolved = base.resolve(rel);
return resolved.toString();
} catch (_) {
return '';
}
}
bool _isVideo(String? mime, String? path) {
final m = (mime ?? '').toLowerCase();
final p = (path ?? '').toLowerCase();
return m.startsWith('video/') ||
p.endsWith('.mp4') ||
p.endsWith('.mov') ||
p.endsWith('.m4v') ||
p.endsWith('.mkv') ||
p.endsWith('.webm');
}
Future<void> _onRefresh() async {
await _refreshCounters();
_future = _load();
if (mounted) setState(() {});
await _future;
}
Future<void> _diagnosticaDb() async {
try {
final dup = await widget.db.rawQuery('''
SELECT remoteId, COUNT(*) AS cnt
FROM entry
WHERE origin=1 AND remoteId IS NOT NULL
GROUP BY remoteId
HAVING cnt > 1
''');
final vis = await widget.db.rawQuery('''
SELECT COUNT(*) AS visible_remotes
FROM entry
WHERE origin=1 AND trashed=0
''');
final idx = await widget.db.rawQuery("PRAGMA index_list('entry')");
if (!mounted) return;
await showModalBottomSheet<void>(
context: context,
builder: (_) => Padding(
padding: const EdgeInsets.all(16),
child: SingleChildScrollView(
child: DefaultTextStyle(
style: Theme.of(context).textTheme.bodyMedium!,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Diagnostica DB', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
Text('Duplicati per remoteId:\n${dup.isEmpty ? "nessuno" : dup.map((e)=>e.toString()).join('\n')}'),
const SizedBox(height: 12),
Text('Remoti visibili in Aves (trashed=0): ${vis.first.values.first}'),
const SizedBox(height: 12),
Text('Indici su entry:\n${idx.map((e)=>e.toString()).join('\n')}'),
],
),
),
),
),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Diagnostica DB fallita: $e')),
);
}
}
/// 🔧 Pulisce duplicati per `remotePath` (tiene MAX(id)) e righe senza `remoteId`.
Future<void> _pulisciDuplicatiPath() async {
try {
final delNoId = await widget.db.rawDelete(
"DELETE FROM entry WHERE origin=1 AND (remoteId IS NULL OR TRIM(remoteId)='')",
);
final delByPath = await widget.db.rawDelete(
'DELETE FROM entry '
'WHERE origin=1 AND remotePath IS NOT NULL AND id NOT IN ('
' SELECT MAX(id) FROM entry '
' WHERE origin=1 AND remotePath IS NOT NULL '
' GROUP BY remotePath'
')',
);
await _onRefresh();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Pulizia completata: noId=$delNoId, dupPath=$delByPath')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Pulizia fallita: $e')),
);
}
}
Future<void> _nascondiRemotiInCollection() async {
try {
final changed = await widget.db.rawUpdate('''
UPDATE entry SET trashed=1
WHERE origin=1 AND trashed=0
''');
if (!mounted) return;
await _onRefresh();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Remoti nascosti dalla Collection: $changed')),
);
} on DatabaseException catch (e) {
final msg = e.toString();
if (!mounted) return;
// Probabile connessione R/O: istruisci a riaprire il DB in R/W
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
duration: const Duration(seconds: 5),
content: Text(
'UPDATE fallito (DB in sola lettura?): $msg\n'
'Apri il DB in R/W in HomePage._openRemoteTestPage (no readOnly).',
),
),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Errore UPDATE: $e')),
);
}
}
@override
Widget build(BuildContext context) {
final ready = (_baseUrl.isNotEmpty && _future != null);
return Scaffold(
appBar: AppBar(
title: const Text('[DEBUG] Remote Test'),
actions: [
IconButton(
icon: const Icon(Icons.bug_report_outlined),
tooltip: 'Diagnostica DB',
onPressed: _diagnosticaDb,
),
IconButton(
icon: const Icon(Icons.cleaning_services_outlined),
tooltip: 'Pulisci duplicati (path)',
onPressed: _pulisciDuplicatiPath,
),
IconButton(
icon: const Icon(Icons.visibility_off_outlined),
tooltip: 'Nascondi remoti in Collection',
onPressed: _nascondiRemotiInCollection,
),
],
),
body: !ready
? const Center(child: CircularProgressIndicator())
: Column(
children: [
// Header contatori + filtro
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
child: Row(
children: [
Expanded(
child: Wrap(
spacing: 8,
runSpacing: -6,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Chip(label: Text('Tot: $_countAll')),
Chip(label: Text('Visibili: $_countVisible')),
Chip(label: Text('Cestinati: $_countTrashed')),
],
),
),
const SizedBox(width: 8),
SegmentedButton<_RemoteFilter>(
segments: const [
ButtonSegment(value: _RemoteFilter.all, label: Text('Tutti')),
ButtonSegment(value: _RemoteFilter.visibleOnly, label: Text('Visibili')),
ButtonSegment(value: _RemoteFilter.trashedOnly, label: Text('Cestinati')),
],
selected: {_filter},
onSelectionChanged: (sel) async {
setState(() => _filter = sel.first);
await _onRefresh();
},
),
],
),
),
const Divider(height: 1),
Expanded(
child: RefreshIndicator(
onRefresh: _onRefresh,
child: FutureBuilder<List<_RemoteRow>>(
future: _future,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) {
return SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: SizedBox(
height: MediaQuery.of(context).size.height * .6,
child: Center(child: Text('Errore: ${snap.error}')),
),
);
}
final items = snap.data ?? const <_RemoteRow>[];
if (items.isEmpty) {
return SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: SizedBox(
height: MediaQuery.of(context).size.height * .6,
child: const Center(child: Text('Nessuna entry remota (origin=1)')),
),
);
}
return GridView.builder(
padding: const EdgeInsets.all(8),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3, mainAxisSpacing: 4, crossAxisSpacing: 4,
),
itemCount: items.length,
itemBuilder: (context, i) {
final it = items[i];
final isVideo = _isVideo(it.mime, it.remotePath);
final thumbUrl = _absUrl(it.remoteThumb2);
final fullUrl = _absUrl(it.remotePath);
final hasThumb = thumbUrl.isNotEmpty;
final hasFull = fullUrl.isNotEmpty;
final heroTag = 'remote_${it.id}';
return GestureDetector(
onLongPress: () async {
if (!context.mounted) return;
await showModalBottomSheet<void>(
context: context,
builder: (_) => Padding(
padding: const EdgeInsets.all(16),
child: SingleChildScrollView(
child: DefaultTextStyle(
style: Theme.of(context).textTheme.bodyMedium!,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('ID: ${it.id} remoteId: ${it.remoteId} trashed: ${it.trashed}'),
const SizedBox(height: 8),
Text('MIME: ${it.mime}'),
const Divider(),
SelectableText('FULL URL:\n$fullUrl'),
const SizedBox(height: 8),
SelectableText('THUMB URL:\n$thumbUrl'),
const SizedBox(height: 12),
Wrap(
spacing: 8,
children: [
ElevatedButton.icon(
onPressed: hasFull
? () async {
await Clipboard.setData(ClipboardData(text: fullUrl));
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('FULL URL copiato')),
);
}
}
: null,
icon: const Icon(Icons.copy),
label: const Text('Copia FULL'),
),
ElevatedButton.icon(
onPressed: hasThumb
? () async {
await Clipboard.setData(ClipboardData(text: thumbUrl));
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('THUMB URL copiato')),
);
}
}
: null,
icon: const Icon(Icons.copy_all),
label: const Text('Copia THUMB'),
),
],
),
],
),
),
),
),
);
},
onTap: () async {
if (_navigating) return; // debounce
_navigating = true;
try {
if (isVideo) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Video remoto: anteprima full non disponibile (thumb richiesta).'),
duration: Duration(seconds: 2),
),
);
return;
}
if (!hasFull) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('URL non valido')),
);
return;
}
await Navigator.of(context).push(
PageRouteBuilder(
pageBuilder: (_, __, ___) => _RemoteFullPage(
title: it.title,
url: fullUrl,
headers: _authHeaders,
heroTag: heroTag, // pairing Hero
),
transitionDuration: const Duration(milliseconds: 220),
),
);
} finally {
_navigating = false;
}
},
child: Hero(
tag: heroTag, // pairing Hero
child: DecoratedBox(
decoration: BoxDecoration(border: Border.all(color: Colors.black12)),
child: Stack(
fit: StackFit.expand,
children: [
_buildGridTile(isVideo, thumbUrl, fullUrl),
// Informazioni utili per capire cosa stiamo vedendo
Positioned(
left: 2,
bottom: 2,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
color: Colors.black54,
child: Text(
'id:${it.id} rid:${it.remoteId}${it.trashed==1 ? " (T)" : ""}',
style: const TextStyle(fontSize: 10, color: Colors.white),
),
),
),
Positioned(
right: 2,
top: 2,
child: Wrap(
spacing: 4,
children: [
if (hasFull)
const _MiniBadge(label: 'URL')
else
const _MiniBadge(label: 'NOURL', color: Colors.red),
if (hasThumb)
const _MiniBadge(label: 'THUMB')
else
const _MiniBadge(label: 'NOTH', color: Colors.orange),
],
),
),
],
),
),
),
);
},
);
},
),
),
),
],
),
);
}
Widget _buildGridTile(bool isVideo, String thumbUrl, String fullUrl) {
if (isVideo) {
// Per i video: NON usiamo Image.network(fullUrl).
// Usiamo la thumb se c'è, altrimenti placeholder con icona "play".
final base = thumbUrl.isEmpty
? const ColoredBox(color: Colors.black12)
: Image.network(
thumbUrl,
fit: BoxFit.cover,
headers: _authHeaders,
errorBuilder: (_, __, ___) => const Center(child: Icon(Icons.broken_image)),
);
return Stack(
fit: StackFit.expand,
children: [
base,
const Align(
alignment: Alignment.center,
child: Icon(Icons.play_circle_fill, color: Colors.white70, size: 48),
),
],
);
}
// Per le immagini: se non c'è thumb, posso usare direttamente l'URL full.
final displayUrl = thumbUrl.isEmpty ? fullUrl : thumbUrl;
if (displayUrl.isEmpty) {
return const ColoredBox(color: Colors.black12);
}
return Image.network(
displayUrl,
fit: BoxFit.cover,
headers: _authHeaders,
errorBuilder: (_, __, ___) => const Center(child: Icon(Icons.broken_image)),
);
}
}
class _RemoteRow {
final int id;
final String remoteId;
final String title;
final String? remotePath;
final String? remoteThumb2;
final String? mime;
final int trashed;
_RemoteRow({
required this.id,
required this.remoteId,
required this.title,
this.remotePath,
this.remoteThumb2,
this.mime,
required this.trashed,
});
}
class _MiniBadge extends StatelessWidget {
final String label;
final Color color;
const _MiniBadge({super.key, required this.label, this.color = Colors.black54});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(3),
),
child: Text(label, style: const TextStyle(fontSize: 9, color: Colors.white)),
);
}
}
class _RemoteFullPage extends StatelessWidget {
final String title;
final String url;
final Map<String, String>? headers;
final String heroTag; // pairing Hero
const _RemoteFullPage({
super.key,
required this.title,
required this.url,
required this.heroTag,
this.headers,
});
@override
Widget build(BuildContext context) {
final body = url.isEmpty
? const Text('URL non valido')
: Hero(
tag: heroTag, // pairing con la griglia
child: InteractiveViewer(
maxScale: 5,
child: Image.network(
url,
fit: BoxFit.contain,
headers: headers, // Authorization se il server lo richiede
errorBuilder: (_, __, ___) => const Icon(Icons.broken_image, size: 64),
),
),
);
return Scaffold(
appBar: AppBar(title: Text(title.isEmpty ? 'Remote' : title)),
body: Center(child: body),
);
}
}

View file

@ -0,0 +1,194 @@
// lib/remote/run_remote_sync.dart
//
// Esegue un ciclo di sincronizzazione "pull":
// 1) legge le impostazioni (server, path, user, password) da RemoteSettings
// 2) login Bearer token
// 3) GET dell'indice JSON (array di oggetti foto)
// 4) upsert nel DB 'entry' (e 'address' se presente) tramite RemoteRepository
//
// NOTE:
// - La versione "managed" (runRemoteSyncOnceManaged) apre/chiude il DB ed evita run concorrenti.
// - La versione "plain" (runRemoteSyncOnce) usa un Database già aperto (compatibilità).
// - PRAGMA per concorrenza (WAL, busy_timeout, ...).
// - Non logghiamo contenuti sensibili (password/token/body completi).
import 'package:path/path.dart' as p;
import 'package:sqflite/sqflite.dart';
import 'remote_settings.dart';
import 'auth_client.dart';
import 'remote_client.dart';
import 'remote_repository.dart';
// === Guardia anti-concorrenza (single-flight) per la run "managed" ===
bool _remoteSyncRunning = false;
/// Helper: retry esponenziale breve per SQLITE_BUSY.
Future<T> _withRetryBusy<T>(Future<T> Function() fn) async {
const maxAttempts = 3;
var delay = const Duration(milliseconds: 250);
for (var i = 0; i < maxAttempts; i++) {
try {
return await fn();
} catch (e) {
final msg = e.toString();
final isBusy = msg.contains('SQLITE_BUSY') || msg.contains('database is locked');
if (!isBusy || i == maxAttempts - 1) rethrow;
await Future.delayed(delay);
delay *= 2; // 250 500 1000 ms
}
}
// non dovrebbe arrivare qui
return await fn();
}
/// Versione "managed":
/// - impedisce run concorrenti
/// - apre/chiude da sola la connessione a `metadata.db` (istanza indipendente)
/// - imposta PRAGMA per concorrenza
/// - accetta override opzionali (utile in test)
Future<void> runRemoteSyncOnceManaged({
String? baseUrl,
String? indexPath,
String? email,
String? password,
}) async {
if (_remoteSyncRunning) {
// ignore: avoid_print
print('[remote-sync] already running, skip');
return;
}
_remoteSyncRunning = true;
Database? db;
try {
final dbDir = await getDatabasesPath();
final dbPath = p.join(dbDir, 'metadata.db');
db = await openDatabase(
dbPath,
singleInstance: false, // connessione indipendente (non chiude lhandle di Aves)
onConfigure: (db) async {
try {
// Alcuni PRAGMA ritornano valori usare SEMPRE rawQuery.
await db.rawQuery('PRAGMA journal_mode=WAL');
await db.rawQuery('PRAGMA synchronous=NORMAL');
await db.rawQuery('PRAGMA busy_timeout=3000');
await db.rawQuery('PRAGMA wal_autocheckpoint=1000');
await db.rawQuery('PRAGMA foreign_keys=ON');
// (Opzionale) verifica del mode corrente
final jm = await db.rawQuery('PRAGMA journal_mode');
final mode = jm.isNotEmpty ? jm.first.values.first : null;
// ignore: avoid_print
print('[remote-sync] journal_mode=$mode'); // atteso: wal
} catch (e, st) {
// ignore: avoid_print
print('[remote-sync][WARN] PRAGMA setup failed: $e\n$st');
// Non rilanciare: in estremo, continueremo con journaling di default
}
},
);
await runRemoteSyncOnce(
db: db,
baseUrl: baseUrl,
indexPath: indexPath,
email: email,
password: password,
);
} finally {
try {
await db?.close();
} catch (_) {
// In caso di close doppio/già chiuso, ignoro.
}
_remoteSyncRunning = false;
}
}
/// Versione "plain":
/// Esegue login, scarica /photos e fa upsert nel DB usando una connessione
/// SQLite **già aperta** (non viene chiusa qui).
///
/// Gli optional [baseUrl], [indexPath], [email], [password] permettono override
/// delle impostazioni salvate in `RemoteSettings` (comodo per test / debug).
Future<void> runRemoteSyncOnce({
required Database db,
String? baseUrl,
String? indexPath,
String? email,
String? password,
}) async {
try {
// 1) Carica impostazioni sicure (secure storage)
final s = await RemoteSettings.load();
final bUrl = (baseUrl ?? s.baseUrl).trim();
final ip = (indexPath ?? s.indexPath).trim();
final em = (email ?? s.email).trim();
final pw = (password ?? s.password);
if (bUrl.isEmpty || ip.isEmpty) {
throw StateError('Impostazioni remote incomplete: baseUrl/indexPath mancanti');
}
// 2) Autenticazione (Bearer)
final auth = RemoteAuth(baseUrl: bUrl, email: em, password: pw);
await auth.login(); // Se necessario, RemoteJsonClient può riloggare su 401
// 3) Client JSON (segue anche redirect 301/302/307/308)
final client = RemoteJsonClient(bUrl, ip, auth: auth);
// 4) Scarica lelenco di elementi remoti (array top-level)
final items = await client.fetchAll();
// 5) Upsert nel DB (con retry se incappiamo in SQLITE_BUSY)
final repo = RemoteRepository(db);
await _withRetryBusy(() => repo.upsertAll(items));
// 5.b) Impedisci futuri duplicati e ripulisci quelli già presenti
await repo.ensureUniqueRemoteId();
final removed = await repo.deduplicateRemotes();
// 5.c) Paracadute: assicura che i remoti NON siano mostrati nella Collection Aves
//await db.rawUpdate('UPDATE entry SET trashed=1 WHERE origin=1 AND trashed=0;');
// 5.d) **CLEANUP LEGACY**: elimina righe remote "orfane" o doppioni su remotePath
// - Righe senza remoteId (NULL o vuoto): non deduplicabili via UNIQUE vanno rimosse
final purgedNoId = await db.rawDelete(
"DELETE FROM entry WHERE origin=1 AND (remoteId IS NULL OR TRIM(remoteId)='')",
);
// - Doppioni per remotePath: tieni solo la riga con id MAX
// (copre i casi in cui in passato siano state create due righe per lo stesso path)
final purgedByPath = await db.rawDelete(
'DELETE FROM entry '
'WHERE origin=1 AND remotePath IS NOT NULL AND id NOT IN ('
' SELECT MAX(id) FROM entry '
' WHERE origin=1 AND remotePath IS NOT NULL '
' GROUP BY remotePath'
')',
);
// ignore: avoid_print
print('[remote-sync] cleanup: removed dup(remoteId)=$removed, purged(noId)=$purgedNoId, purged(byPath)=$purgedByPath');
// 6) Log sintetico
int? c;
try {
c = await repo.countRemote();
} catch (_) {
c = null;
}
// ignore: avoid_print
if (c == null) {
print('[remote-sync] import completato (conteggio non disponibile)');
} else {
print('[remote-sync] importati remoti: $c (base=$bUrl, index=$ip)');
}
} catch (e, st) {
// ignore: avoid_print
print('[remote-sync][ERROR] $e\n$st');
rethrow;
}
}

View file

@ -0,0 +1,7 @@
// lib/remote/url_utils.dart
Uri buildAbsoluteUri(String baseUrl, String relativePath) {
final base = Uri.parse(baseUrl.endsWith('/') ? baseUrl : '$baseUrl/');
final cleaned = relativePath.startsWith('/') ? relativePath.substring(1) : relativePath;
final segments = cleaned.split('/').where((s) => s.isNotEmpty).toList();
return base.replace(pathSegments: [...base.pathSegments, ...segments]);
}

View file

@ -77,10 +77,10 @@ class RemoteJsonClient {
} }
} }
// ignore: avoid_print // ignore: avoid_print
print('[remote-client] SERVER paths -> withOriginal=$withOriginal | withoutOriginal=$withoutOriginal | ' print('[remote-client] SERVER paths: withOriginal=$withOriginal '
'leadingSlash=$leadingSlash | noLeadingSlash=$noLeadingSlash'); 'withoutOriginal=$withoutOriginal leadingSlash=$leadingSlash noLeadingSlash=$noLeadingSlash');
// Costruiamo List<RemotePhotoItem> // Costruiamo a mano la List<RemotePhotoItem>, tipizzata esplicitamente
final List<RemotePhotoItem> items = rawList.map<RemotePhotoItem>((e) { final List<RemotePhotoItem> items = rawList.map<RemotePhotoItem>((e) {
if (e is! Map<String, dynamic>) { if (e is! Map<String, dynamic>) {
throw Exception('Elemento JSON non è una mappa: ${e.runtimeType} -> $e'); throw Exception('Elemento JSON non è una mappa: ${e.runtimeType} -> $e');

View file

@ -91,9 +91,10 @@ class RemoteRepository {
} }
// ========================= // =========================
// Normalizzazione SOLO per diagnostica (non cambia cosa salvi) // Normalizzazione / Canonicalizzazione
// ========================= // =========================
/// Normalizza gli slash e forza lo slash iniziale.
String _normPath(String? p) { String _normPath(String? p) {
if (p == null || p.isEmpty) return ''; if (p == null || p.isEmpty) return '';
var s = p.trim().replaceAll(RegExp(r'/+'), '/'); var s = p.trim().replaceAll(RegExp(r'/+'), '/');
@ -101,14 +102,14 @@ class RemoteRepository {
return s; return s;
} }
/// Candidato "canonico" (inserisce '/original/' dopo '/photos/<User>/' /// Inserisce '/original/' dopo '/photos/<User>/' se manca, e garantisce filename coerente.
/// se manca). Usato solo per LOG/HINT, NON per scrivere. String _canonFullPath(String? rawPath, String fileName) {
String _canonCandidate(String? rawPath, String fileName) {
var s = _normPath(rawPath); var s = _normPath(rawPath);
final seg = s.split('/'); // ['', 'photos', '<User>', maybe 'original', ...] final seg = s.split('/'); // ['', 'photos', '<User>', maybe 'original', ...]
if (seg.length >= 4 && seg[1] == 'photos' && seg[3] != 'original' && seg[3] != 'thumbs') { if (seg.length >= 4 && seg[1] == 'photos' && seg[3] != 'original' && seg[3] != 'thumbs') {
seg.insert(3, 'original'); seg.insert(3, 'original');
} }
// forza il filename finale (se fornito)
if (fileName.isNotEmpty) { if (fileName.isNotEmpty) {
seg[seg.length - 1] = fileName; seg[seg.length - 1] = fileName;
} }
@ -131,12 +132,14 @@ class RemoteRepository {
} }
Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) { Map<String, Object?> _buildEntryRow(RemotePhotoItem it, {int? existingId}) {
// NON correggo: salvo esattamente quello che arriva (come ora) final canonical = _canonFullPath(it.path, it.name);
final thumb = _normPath(it.thub2);
return <String, Object?>{ return <String, Object?>{
'id': existingId, 'id': existingId,
'contentId': null, 'contentId': null,
'uri': null, 'uri': null,
'path': it.path, 'path': canonical, // path interno
'sourceMimeType': it.mimeType, 'sourceMimeType': it.mimeType,
'width': it.width, 'width': it.width,
'height': it.height, 'height': it.height,
@ -147,7 +150,7 @@ class RemoteRepository {
'dateModifiedMillis': null, 'dateModifiedMillis': null,
'sourceDateTakenMillis': it.takenAtUtc?.millisecondsSinceEpoch, 'sourceDateTakenMillis': it.takenAtUtc?.millisecondsSinceEpoch,
'durationMillis': it.durationMillis, 'durationMillis': it.durationMillis,
// REMOTI VISIBILI (come nel tuo file attuale) // 👇 REMOTI visibili nella Collection (se la tua Collection include origin=1)
'trashed': 0, 'trashed': 0,
'origin': 1, 'origin': 1,
'provider': 'json@patachina', 'provider': 'json@patachina',
@ -155,11 +158,11 @@ class RemoteRepository {
'latitude': it.lat, 'latitude': it.lat,
'longitude': it.lng, 'longitude': it.lng,
'altitude': it.alt, 'altitude': it.alt,
// campi remoti ( path raw, senza forzare /original/) // campi remoti
'remoteId': it.id, 'remoteId': it.id,
'remotePath': it.path, 'remotePath': canonical, // <-- sempre canonico con /original/
'remoteThumb1': it.thub1, 'remoteThumb1': it.thub1,
'remoteThumb2': it.thub2, 'remoteThumb2': thumb,
}; };
} }
@ -175,15 +178,27 @@ class RemoteRepository {
} }
// ========================= // =========================
// Upsert a chunk (DIAGNOSTICA inclusa) // Upsert a chunk
// ========================= // =========================
/// Inserisce o aggiorna tutti gli elementi remoti.
///
/// - Assicura colonne `entry` (GPS + remote*)
/// - Canonicalizza i path (`/photos/<User>/original/...`)
/// - Lookup robusto: remoteId -> remotePath(canonico) -> remotePath(raw normalizzato)
/// - Ordina prima le immagini, poi i video
/// - In caso di errore schema su GPS, riprova senza i 3 campi GPS
Future<void> upsertAll(List<RemotePhotoItem> items, {int chunkSize = 200}) async { Future<void> upsertAll(List<RemotePhotoItem> items, {int chunkSize = 200}) async {
debugPrint('RemoteRepository.upsertAll: items=${items.length}'); debugPrint('RemoteRepository.upsertAll: items=${items.length}');
if (items.isEmpty) return; if (items.isEmpty) return;
// Garantisco lo schema una volta, poi procedo ai chunk
await _withRetryBusy(() => _ensureEntryColumns(db)); await _withRetryBusy(() => _ensureEntryColumns(db));
// Indici UNIQUE per prevenire futuri duplicati (id + path)
await ensureUniqueRemoteId();
await ensureUniqueRemotePath();
// Ordina: prima immagini, poi video // Ordina: prima immagini, poi video
final images = <RemotePhotoItem>[]; final images = <RemotePhotoItem>[];
final videos = <RemotePhotoItem>[]; final videos = <RemotePhotoItem>[];
@ -201,19 +216,10 @@ class RemoteRepository {
final batch = txn.batch(); final batch = txn.batch();
for (final it in chunk) { for (final it in chunk) {
// === DIAGNOSTICA PRE-LOOKUP === // Lookup record esistente per stabilire l'ID (REPLACE mantiene la PK)
final raw = it.path;
final norm = _normPath(raw);
final cand = _canonCandidate(raw, it.name);
final hasOriginal = raw.contains('/original/');
final hasLeading = raw.startsWith('/');
debugPrint(
'[repo-upsert] in: rid=${it.id.substring(0,8)} name=${it.name} '
'raw="$raw" (original=${hasOriginal?"Y":"N"}, leading=${hasLeading?"Y":"N"})'
);
// Lookup record esistente SOLO per remoteId (comportamento attuale)
int? existingId; int? existingId;
// 1) prova per remoteId
try { try {
final existing = await txn.query( final existing = await txn.query(
'entry', 'entry',
@ -222,59 +228,52 @@ class RemoteRepository {
whereArgs: [it.id], whereArgs: [it.id],
limit: 1, limit: 1,
); );
existingId = existing.isNotEmpty ? (existing.first['id'] as int?) : null; if (existing.isNotEmpty) {
existingId = existing.first['id'] as int?;
} else {
// 2) fallback per remotePath canonico
final canonical = _canonFullPath(it.path, it.name);
final byCanon = await txn.query(
'entry',
columns: ['id'],
where: 'origin=1 AND remotePath = ?',
whereArgs: [canonical],
limit: 1,
);
if (byCanon.isNotEmpty) {
existingId = byCanon.first['id'] as int?;
} else {
// 3) ultimo fallback: remotePath "raw normalizzato" (senza forzare /original/)
final rawNorm = _normPath(it.path);
final byRaw = await txn.query(
'entry',
columns: ['id'],
where: 'origin=1 AND remotePath = ?',
whereArgs: [rawNorm],
limit: 1,
);
if (byRaw.isNotEmpty) {
existingId = byRaw.first['id'] as int?;
}
}
}
} catch (e, st) { } catch (e, st) {
debugPrint('[RemoteRepository] lookup existingId failed for remoteId=${it.id}: $e\n$st'); debugPrint('[RemoteRepository] lookup existingId failed for remoteId=${it.id}: $e\n$st');
} }
// === DIAGNOSTICA HINT: esisterebbe una riga compatibile per path? === // Riga completa (con path canonico)
// 1) path canonico (con /original/)
try {
final byCanon = await txn.query(
'entry',
columns: ['id'],
where: 'origin=1 AND remotePath = ?',
whereArgs: [cand],
limit: 1,
);
if (byCanon.isNotEmpty && existingId == null) {
final idCand = byCanon.first['id'];
debugPrint(
'[repo-upsert][HINT] trovata riga per CAND-remotePath="$cand" -> id=$idCand '
'(il lookup corrente per remoteId NON la vede: possibile causa duplicato)'
);
}
} catch (_) {}
// 2) path raw normalizzato (solo slash)
try {
final byNorm = await txn.query(
'entry',
columns: ['id'],
where: 'origin=1 AND remotePath = ?',
whereArgs: [norm],
limit: 1,
);
if (byNorm.isNotEmpty && existingId == null) {
final idNorm = byNorm.first['id'];
debugPrint(
'[repo-upsert][HINT] trovata riga per RAW-NORM-remotePath="$norm" -> id=$idNorm '
'(il lookup corrente per remoteId NON la vede: possibile causa duplicato)'
);
}
} catch (_) {}
// Riga completa ( salviamo il RAW come stai facendo ora)
final row = _buildEntryRow(it, existingId: existingId); final row = _buildEntryRow(it, existingId: existingId);
// Insert/replace // Provo insert/replace con i campi completi (GPS inclusi)
try { try {
batch.insert( batch.insert(
'entry', 'entry',
row, row,
conflictAlgorithm: ConflictAlgorithm.replace, conflictAlgorithm: ConflictAlgorithm.replace,
); );
// Address: lo inseriamo in un secondo pass (post-commit) con PK certa
} on DatabaseException catch (e, st) { } on DatabaseException catch (e, st) {
// Se fallisce per schema GPS (colonne non create o tipo non compatibile), riprovo senza i 3 campi
debugPrint('[RemoteRepository] batch insert failed for remoteId=${it.id}: $e\n$st'); debugPrint('[RemoteRepository] batch insert failed for remoteId=${it.id}: $e\n$st');
final rowNoGps = Map<String, Object?>.from(row) final rowNoGps = Map<String, Object?>.from(row)
@ -292,16 +291,18 @@ class RemoteRepository {
await batch.commit(noResult: true); await batch.commit(noResult: true);
// Secondo pass per address (immutato) // Secondo pass per address, con PK certa
for (final it in chunk) { for (final it in chunk) {
if (it.location == null) continue; if (it.location == null) continue;
try { try {
// cerco per remoteId, altrimenti per path canonico
final canonical = _canonFullPath(it.path, it.name);
final rows = await txn.query( final rows = await txn.query(
'entry', 'entry',
columns: ['id'], columns: ['id'],
where: 'origin=1 AND remoteId = ?', where: 'origin=1 AND (remoteId = ? OR remotePath = ?)',
whereArgs: [it.id], whereArgs: [it.id, canonical],
limit: 1, limit: 1,
); );
if (rows.isEmpty) continue; if (rows.isEmpty) continue;
@ -326,9 +327,10 @@ class RemoteRepository {
} }
// ========================= // =========================
// Unicità & deduplica (immutato) // Unicità & deduplica
// ========================= // =========================
/// Crea un indice UNICO su `remoteId` limitato alle righe remote (origin=1).
Future<void> ensureUniqueRemoteId() async { Future<void> ensureUniqueRemoteId() async {
try { try {
await db.execute( await db.execute(
@ -341,6 +343,20 @@ class RemoteRepository {
} }
} }
/// Crea un indice UNICO su `remotePath` (solo remoti) per prevenire doppi.
Future<void> ensureUniqueRemotePath() async {
try {
await db.execute(
'CREATE UNIQUE INDEX IF NOT EXISTS uq_entry_remote_remotePath '
'ON entry(remotePath) WHERE origin=1 AND remotePath IS NOT NULL',
);
debugPrint('[RemoteRepository] ensured UNIQUE index on entry(remotePath) for origin=1');
} catch (e, st) {
debugPrint('[RemoteRepository] ensureUniqueRemotePath error: $e\n$st');
}
}
/// Rimuove duplicati remoti, tenendo la riga con id MAX per ciascun `remoteId`.
Future<int> deduplicateRemotes() async { Future<int> deduplicateRemotes() async {
try { try {
final deleted = await db.rawDelete( final deleted = await db.rawDelete(
@ -359,9 +375,31 @@ class RemoteRepository {
} }
} }
/// Rimuove duplicati per `remotePath` (exact match), tenendo l'ultima riga.
Future<int> deduplicateByRemotePath() async {
try {
final deleted = await db.rawDelete(
'DELETE FROM entry '
'WHERE origin=1 AND remotePath IS NOT NULL AND id NOT IN ('
' SELECT MAX(id) FROM entry '
' WHERE origin=1 AND remotePath IS NOT NULL '
' GROUP BY remotePath'
')',
);
debugPrint('[RemoteRepository] deduplicateByRemotePath deleted=$deleted');
return deleted;
} catch (e, st) {
debugPrint('[RemoteRepository] deduplicateByRemotePath error: $e\n$st');
return 0;
}
}
/// Helper combinato: prima pulisce i doppioni, poi impone lunicità.
Future<void> sanitizeRemotes() async { Future<void> sanitizeRemotes() async {
await deduplicateRemotes(); await deduplicateRemotes();
await deduplicateByRemotePath();
await ensureUniqueRemoteId(); await ensureUniqueRemoteId();
await ensureUniqueRemotePath();
} }
// ========================= // =========================