Add TV update flow in Tentang and fix startup zone

This commit is contained in:
dwindown
2026-04-01 14:20:04 +07:00
parent 081ed9f695
commit 925189417d
12 changed files with 953 additions and 44 deletions

View File

@@ -0,0 +1,274 @@
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<String, dynamic> 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<AppVersionInfo> getCurrentVersion() async {
final packageInfo = await PackageInfo.fromPlatform();
return AppVersionInfo(
versionName: packageInfo.version,
versionCode: int.tryParse(packageInfo.buildNumber) ?? 0,
);
}
Future<UpdateCheckResult> 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<String, dynamic>;
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<UpdateInstallResult> 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,
});
}