import 'dart:convert'; import 'dart:io'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:http/http.dart' as http; import 'package:package_info_plus/package_info_plus.dart'; import 'package:shared_preferences/shared_preferences.dart'; // ─── Configuration ──────────────────────────────────────────────────────────── const _kGiteaBase = 'https://git.steggi-matrix.work'; const _kGiteaRepo = 'steggi/pyramid'; const _kCheckIntervalHours = 4; const _kPrefLastCheck = 'update_last_check_ms'; const _kPrefSkippedTag = 'update_skipped_tag'; // ─── Data model ─────────────────────────────────────────────────────────────── class UpdateInfo { final String tagName; final String releaseName; final String body; final String htmlUrl; final String? downloadUrl; final (int, int, int) remoteVersion; const UpdateInfo({ required this.tagName, required this.releaseName, required this.body, required this.htmlUrl, required this.remoteVersion, this.downloadUrl, }); bool get canDownload => downloadUrl != null; } // ─── Version helpers ────────────────────────────────────────────────────────── (int, int, int)? _parseVersion(String raw) { final clean = raw.trim().replaceFirst(RegExp(r'^v'), '').split('+').first; final parts = clean.split('.'); if (parts.length < 3) return null; try { return (int.parse(parts[0]), int.parse(parts[1]), int.parse(parts[2])); } catch (_) { return null; } } bool _isNewer((int, int, int) remote, (int, int, int) local) { if (remote.$1 != local.$1) return remote.$1 > local.$1; if (remote.$2 != local.$2) return remote.$2 > local.$2; return remote.$3 > local.$3; } // Rewrite the host in a Gitea asset URL to always use the configured base, // so downloads work from outside the home network once nginx is set up. String? _rewriteHost(String? url) { if (url == null) return null; return url.replaceFirst(RegExp(r'https?://[^/]+'), _kGiteaBase); } // Device ABIs in preference order (e.g. [arm64-v8a, armeabi-v7a, armeabi]), // fetched once from the platform. Used to pick the matching split APK. Future> _deviceAbis() async { if (!Platform.isAndroid) return const []; try { final abis = await const MethodChannel('chat.pyramid.pyramid/install') .invokeListMethod('getAbis'); return abis?.map((a) => a.toLowerCase()).toList() ?? const []; } catch (_) { return const []; } } String? _findDownloadUrl(List assets, List abis) { if (assets.isEmpty) return null; final list = assets.cast>(); String? pick(bool Function(String name) test) { for (final a in list) { final name = (a['name'] as String? ?? '').toLowerCase(); if (test(name)) return a['browser_download_url'] as String?; } return null; } if (Platform.isWindows) { return pick((n) => n.endsWith('.exe')) ?? pick((n) => n.endsWith('.msix')); } if (Platform.isAndroid) { // Prefer the split APK matching the device's best ABI, then weaker ABIs. // Fall back to any APK so fat-APK releases (pre-split) keep working. for (final abi in abis) { final url = pick((n) => n.endsWith('.apk') && n.contains(abi)); if (url != null) return url; } return pick((n) => n.endsWith('.apk')); } return null; } // ─── Fetch ──────────────────────────────────────────────────────────────────── Future _fetchLatestRelease(List abis) async { // Fetch enough releases to find the highest version even if they were // created out of chronological order (Gitea sorts by created_at, not semver). final uri = Uri.parse( '$_kGiteaBase/api/v1/repos/$_kGiteaRepo/releases?limit=20', ); // Let network/timeout exceptions propagate so the provider shows an error // state instead of silently appearing as "up to date". final response = await http .get(uri, headers: {'Accept': 'application/json'}) .timeout(const Duration(seconds: 10)); if (response.statusCode != 200) { throw Exception('Gitea antwortete mit ${response.statusCode}'); } try { final list = json.decode(response.body) as List; if (list.isEmpty) return null; // Find the release with the highest version number, ignoring drafts and // pre-releases. This is robust even when releases are created out of order. UpdateInfo? best; for (final item in list) { final data = item as Map; if (data['draft'] == true || data['prerelease'] == true) continue; final tagName = data['tag_name'] as String? ?? ''; if (tagName.isEmpty) continue; final remoteVer = _parseVersion(tagName); if (remoteVer == null) continue; if (best == null || _isNewer(remoteVer, best.remoteVersion)) { final assets = (data['assets'] as List?) ?? []; final downloadUrl = _rewriteHost(_findDownloadUrl(assets, abis)); best = UpdateInfo( tagName: tagName, releaseName: data['name'] as String? ?? tagName, body: data['body'] as String? ?? '', htmlUrl: '$_kGiteaBase/$_kGiteaRepo/releases/tag/$tagName', remoteVersion: remoteVer, downloadUrl: downloadUrl, ); } } return best; } catch (_) { return null; } } // ─── Provider ───────────────────────────────────────────────────────────────── final updateInfoProvider = FutureProvider((ref) async { // iOS: can't sideload, skip if (Platform.isIOS || Platform.isMacOS || Platform.isLinux) return null; final prefs = await SharedPreferences.getInstance(); final lastCheckMs = prefs.getInt(_kPrefLastCheck) ?? 0; final elapsed = DateTime.now().millisecondsSinceEpoch - lastCheckMs; if (elapsed < _kCheckIntervalHours * 3600 * 1000) return null; await prefs.setInt(_kPrefLastCheck, DateTime.now().millisecondsSinceEpoch); final remote = await _fetchLatestRelease(await _deviceAbis()); if (remote == null) return null; final info = await PackageInfo.fromPlatform(); final localVer = _parseVersion(info.version); if (localVer == null) return null; if (!_isNewer(remote.remoteVersion, localVer)) return null; final skipped = prefs.getString(_kPrefSkippedTag); if (skipped == remote.tagName) return null; return remote; }); // ─── Actions ────────────────────────────────────────────────────────────────── Future skipUpdateVersion(String tagName) async { final prefs = await SharedPreferences.getInstance(); await prefs.setString(_kPrefSkippedTag, tagName); } Future resetUpdateCheckTimer() async { final prefs = await SharedPreferences.getInstance(); await prefs.remove(_kPrefLastCheck); }