275 lines
7.5 KiB
Dart
275 lines
7.5 KiB
Dart
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,
|
|
});
|
|
}
|