using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.Networking;
namespace MyInfoMate.Vr.Net
{
///
/// Cache disque du contenu — item E4 du lot XR-4.
///
/// C'est l'item qui justifie Unity plutôt que WebXR (§10 du plan) : une borne
/// d'accueil tourne 8 h par jour sans personne, et le wifi d'un musée tombe. En natif
/// on écrit sur le disque, sans quota de navigateur.
///
/// La règle est cache d'abord, jamais réseau d'abord : l'app démarre sur ce
/// qu'elle a, et se rafraîchit ensuite. Un serveur lent ou absent ne doit pas
/// retarder d'une seconde l'affichage d'un contenu déjà téléchargé.
///
/// Rien n'est jamais servi à moitié : le JSON est écrit dans un fichier temporaire
/// puis déplacé. Une coupure de courant pendant une écriture — le cas nominal pour
/// une borne qu'on débranche le soir — laisse l'ancienne version intacte, jamais un
/// fichier tronqué qui ne se relit pas.
///
public static class ContentCache
{
static string Root => Path.Combine(Application.persistentDataPath, "content");
// Un seul fichier par configuration, pas un par langue : l'export les porte
// toutes (voir ConfigurationExport.FetchAsync).
static string ExportPath(string configurationId) =>
Path.Combine(Root, $"{configurationId}.json");
static string MediaDirectory => Path.Combine(Root, "media");
/// Le contenu en cache, ou null si ce casque n'a jamais rien téléchargé.
public static string ReadExport(string configurationId)
{
var path = ExportPath(configurationId);
try
{
return File.Exists(path) ? File.ReadAllText(path, Encoding.UTF8) : null;
}
catch (IOException e)
{
Debug.LogError($"[Cache] Lecture de {path} impossible : {e.Message}");
return null;
}
}
public static DateTime? ExportDate(string configurationId)
{
var path = ExportPath(configurationId);
return File.Exists(path) ? File.GetLastWriteTimeUtc(path) : (DateTime?)null;
}
public static void WriteExport(string configurationId, string json)
{
var path = ExportPath(configurationId);
var temporary = path + ".tmp";
try
{
Directory.CreateDirectory(Root);
File.WriteAllText(temporary, json, Encoding.UTF8);
// File.Move ne remplace pas sur toutes les plateformes ; le couple
// delete + move est la seule forme qui marche partout, et la fenêtre
// entre les deux est couverte par le .tmp qui reste lisible.
if (File.Exists(path)) File.Delete(path);
File.Move(temporary, path);
}
catch (IOException e)
{
Debug.LogError($"[Cache] Écriture de {path} impossible : {e.Message}");
}
}
///
/// Le fichier local d'un média déjà téléchargé, ou null. Sans réseau et
/// sans attente : c'est ce qui permet à un appelant synchrone — une coroutine,
/// typiquement — de jouer un son depuis le disque au lieu de le streamer.
///
public static string CachedPath(string url)
{
if (string.IsNullOrEmpty(url)) return null;
var path = LocalPathOf(url);
return File.Exists(path) ? path : null;
}
static string LocalPathOf(string url) =>
Path.Combine(MediaDirectory, HashOf(url) + ExtensionOf(url));
///
/// Le fichier local d'un média, téléchargé si absent. Le nom vient d'un hachage
/// de l'URL : les sources du CMS ne sont pas des noms de fichiers sûrs, et deux
/// ressources peuvent porter le même nom d'affichage.
///
public static async Task MediaPathAsync(ApiClient client, string url)
{
if (string.IsNullOrEmpty(url)) return null;
var path = LocalPathOf(url);
if (File.Exists(path)) return path;
using var request = UnityWebRequest.Get(url);
var operation = request.SendWebRequest();
while (!operation.isDone) await Task.Yield();
if (request.result != UnityWebRequest.Result.Success)
{
Debug.LogError($"[Cache] {url} : {request.error}");
return null;
}
try
{
Directory.CreateDirectory(MediaDirectory);
var temporary = path + ".tmp";
File.WriteAllBytes(temporary, request.downloadHandler.data);
if (File.Exists(path)) File.Delete(path);
File.Move(temporary, path);
return path;
}
catch (IOException e)
{
Debug.LogError($"[Cache] Écriture de {path} impossible : {e.Message}");
return null;
}
}
/// Octets occupés par le cache. À afficher un jour dans le manager.
public static long SizeBytes()
{
if (!Directory.Exists(Root)) return 0;
long total = 0;
foreach (var file in Directory.GetFiles(Root, "*", SearchOption.AllDirectories))
total += new FileInfo(file).Length;
return total;
}
public static void Clear()
{
try
{
if (Directory.Exists(Root)) Directory.Delete(Root, true);
}
catch (IOException e)
{
Debug.LogError($"[Cache] Purge impossible : {e.Message}");
}
}
static string HashOf(string value)
{
using var sha = SHA1.Create();
var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(value));
var hex = new StringBuilder(bytes.Length * 2);
foreach (var b in bytes) hex.Append(b.ToString("x2"));
return hex.ToString();
}
///
/// L'extension compte : Unity choisit son décodeur vidéo et son importeur de
/// texture dessus. Une URL avec une query string ne doit pas la faire perdre.
///
static string ExtensionOf(string url)
{
var withoutQuery = url.Split('?')[0];
var extension = Path.GetExtension(withoutQuery);
return string.IsNullOrEmpty(extension) || extension.Length > 6 ? "" : extension;
}
}
}