import 'dart:convert'; import 'dart:io'; import 'package:crypto/crypto.dart'; import 'package:http/http.dart' as http; import 'package:open_filex/open_filex.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:path_provider/path_provider.dart'; class AppUpdateInfo { final String latestVersion; final int versionCode; final String apkUrl; final DateTime? publishedAt; final String notes; final String sha256; final int minSupportedVersionCode; const AppUpdateInfo({ required this.latestVersion, required this.versionCode, required this.apkUrl, required this.publishedAt, required this.notes, required this.sha256, required this.minSupportedVersionCode, }); static DateTime? _parsePublishedAt(String? rawValue) { final value = (rawValue ?? '').trim(); if (value.isEmpty) return null; // Accept timestamps with timezone offset both as +07:00 and +0700. final normalized = value.replaceFirstMapped( RegExp(r'([+-]\d{2})(\d{2})$'), (match) => '${match.group(1)}:${match.group(2)}', ); return DateTime.tryParse(normalized); } factory AppUpdateInfo.fromJson(Map json) { return AppUpdateInfo( latestVersion: (json['latest_version'] as String? ?? '').trim(), versionCode: (json['version_code'] as num?)?.toInt() ?? 0, apkUrl: (json['apk_url'] as String? ?? '').trim(), publishedAt: _parsePublishedAt(json['published_at'] as String?), notes: (json['notes'] as String? ?? '').trim(), sha256: (json['sha256'] as String? ?? '').trim(), minSupportedVersionCode: (json['min_supported_version_code'] as num?)?.toInt() ?? 1, ); } bool get isValid => latestVersion.isNotEmpty && versionCode > 0 && apkUrl.isNotEmpty; } class AppVersionInfo { final String versionName; final int versionCode; const AppVersionInfo({ required this.versionName, required this.versionCode, }); String get displayLabel => '$versionName+$versionCode'; } class UpdateCheckResult { final AppVersionInfo current; final AppUpdateInfo? remote; final bool updateAvailable; final String? errorMessage; const UpdateCheckResult({ required this.current, required this.remote, required this.updateAvailable, required this.errorMessage, }); bool get hasError => errorMessage != null; const UpdateCheckResult.error({ required AppVersionInfo current, required String message, }) : this( current: current, remote: null, updateAvailable: false, errorMessage: message, ); const UpdateCheckResult.success({ required AppVersionInfo current, required AppUpdateInfo remote, required bool updateAvailable, }) : this( current: current, remote: remote, updateAvailable: updateAvailable, errorMessage: null, ); } class UpdateService { UpdateService._(); static const String metadataUrl = 'https://files.jamshalat.com/latest.json'; static final UpdateService instance = UpdateService._(); Future getCurrentVersion() async { final packageInfo = await PackageInfo.fromPlatform(); return AppVersionInfo( versionName: packageInfo.version, versionCode: int.tryParse(packageInfo.buildNumber) ?? 0, ); } Future checkForUpdate() async { final current = await getCurrentVersion(); try { final response = await http.get( Uri.parse(metadataUrl), headers: const { 'Accept': 'application/json', 'Cache-Control': 'no-cache', }, ); if (response.statusCode != 200) { return UpdateCheckResult.error( current: current, message: 'Gagal mengambil data update (${response.statusCode}).', ); } final payload = json.decode(response.body) as Map; final remote = AppUpdateInfo.fromJson(payload); if (!remote.isValid) { return UpdateCheckResult.error( current: current, message: 'Format update tidak valid.', ); } final updateAvailable = remote.versionCode > current.versionCode; return UpdateCheckResult.success( current: current, remote: remote, updateAvailable: updateAvailable, ); } catch (_) { return UpdateCheckResult.error( current: current, message: 'Tidak dapat memeriksa update saat ini.', ); } } Future downloadAndTriggerInstall( AppUpdateInfo info, { void Function(double progress)? onProgress, }) async { if (!Platform.isAndroid) { return const UpdateInstallResult( success: false, message: 'Install update otomatis hanya didukung di Android.', ); } final uri = Uri.tryParse(info.apkUrl); if (uri == null) { return const UpdateInstallResult( success: false, message: 'URL APK tidak valid.', ); } final client = http.Client(); IOSink? sink; try { final request = http.Request('GET', uri); final response = await client.send(request); if (response.statusCode != 200) { return UpdateInstallResult( success: false, message: 'Download APK gagal (${response.statusCode}).', ); } final tempDir = await getTemporaryDirectory(); final file = File( '${tempDir.path}/jamshalat-update-v${info.latestVersion}-b${info.versionCode}.apk', ); if (file.existsSync()) { await file.delete(); } sink = file.openWrite(); final totalBytes = response.contentLength ?? 0; var receivedBytes = 0; await for (final chunk in response.stream) { sink.add(chunk); receivedBytes += chunk.length; if (totalBytes > 0) { onProgress?.call(receivedBytes / totalBytes); } } await sink.flush(); await sink.close(); sink = null; onProgress?.call(1); final expectedSha = info.sha256.trim().toLowerCase(); final downloadedSha = sha256 .convert(await file.readAsBytes()) .toString() .toLowerCase(); if (expectedSha.isNotEmpty && expectedSha != downloadedSha) { await file.delete(); return const UpdateInstallResult( success: false, message: 'Checksum APK tidak cocok. Download dibatalkan.', ); } final openResult = await OpenFilex.open( file.path, type: 'application/vnd.android.package-archive', ); if (openResult.type != ResultType.done) { final msg = openResult.message.trim().isNotEmpty ? openResult.message.trim() : 'Tidak dapat membuka installer Android.'; return UpdateInstallResult( success: false, message: msg, ); } return UpdateInstallResult( success: true, message: 'APK selesai diunduh. Silakan lanjutkan instalasi pada prompt Android.', downloadedFilePath: file.path, ); } catch (_) { return const UpdateInstallResult( success: false, message: 'Terjadi kesalahan saat mengunduh atau memasang update.', ); } finally { await sink?.close(); client.close(); } } } class UpdateInstallResult { final bool success; final String message; final String? downloadedFilePath; const UpdateInstallResult({ required this.success, required this.message, this.downloadedFilePath, }); }