Add TV update flow in Tentang and fix startup zone
This commit is contained in:
274
lib/data/services/update_service.dart
Normal file
274
lib/data/services/update_service.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user