diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b4407fe2..837a6320 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -88,6 +88,11 @@ jobs: - name: Build APK (Release) run: flutter build apk --release --split-per-abi + env: + KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }} + KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} - name: Rename APKs run: | diff --git a/.gitignore b/.gitignore index f1a99a70..cf94f39c 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,9 @@ android/.gradle/ android/app/libs/ android/local.properties android/*.iml +android/keystore.properties +android/*.jks +android/*.keystore # iOS ios/Frameworks/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fd429e8..1257d8e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [1.5.0-hotfix] - 2026-01-02 + +### Important Notice +We apologize for the inconvenience. Previous releases were signed with different keys, causing "package conflicts" errors when upgrading. Starting from this version, all releases will use a consistent signing key. + +**If you're upgrading from v1.5.0 or earlier, please uninstall the app first before installing this version.** This is a one-time requirement. Future updates will work seamlessly without uninstalling. + +### Added +- **In-App Update**: Download and install updates directly from the app + - Progress bar shows download status + - Automatic device architecture detection (arm64/arm32) + - Downloads correct APK for your device +- **Consistent App Signing**: All future releases will use the same signing key + +### Fixed +- **Update Checker**: Now downloads APK directly instead of opening browser + ## [1.5.0] - 2026-01-02 ### Added @@ -20,6 +37,10 @@ - **Multi-Progress Tracking for Concurrent Downloads**: Each concurrent download now shows individual progress percentage - Previously concurrent downloads jumped from 0% to 100% - Now each track shows real-time progress when downloading in parallel +- **In-App Update**: Download and install updates directly from the app + - Progress bar shows download status + - Automatic device architecture detection (arm64/arm32) + - Downloads correct APK for your device ### Changed - **Recent Downloads**: Now shows up to 10 items (was 5) for better scrolling diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index edebe50e..c64f1f59 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -5,6 +5,13 @@ plugins { id("dev.flutter.flutter-gradle-plugin") } +// Load keystore properties from file if exists +val keystorePropertiesFile = rootProject.file("keystore.properties") +val keystoreProperties = java.util.Properties() +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(java.io.FileInputStream(keystorePropertiesFile)) +} + android { namespace = "com.zarz.spotiflac" compileSdk = flutter.compileSdkVersion @@ -22,6 +29,30 @@ android { } } + signingConfigs { + create("release") { + if (keystorePropertiesFile.exists()) { + storeFile = file(keystoreProperties["storeFile"] as String) + storePassword = keystoreProperties["storePassword"] as String + keyAlias = keystoreProperties["keyAlias"] as String + keyPassword = keystoreProperties["keyPassword"] as String + } else if (System.getenv("KEYSTORE_BASE64") != null) { + // CI/CD: decode keystore from base64 environment variable + val keystoreFile = file("${project.buildDir}/keystore.jks") + if (!keystoreFile.exists()) { + keystoreFile.parentFile.mkdirs() + keystoreFile.writeBytes( + java.util.Base64.getDecoder().decode(System.getenv("KEYSTORE_BASE64")) + ) + } + storeFile = keystoreFile + storePassword = System.getenv("KEYSTORE_PASSWORD") + keyAlias = System.getenv("KEY_ALIAS") + keyPassword = System.getenv("KEY_PASSWORD") + } + } + } + defaultConfig { applicationId = "com.zarz.spotiflac" minSdk = flutter.minSdkVersion @@ -39,7 +70,12 @@ android { buildTypes { release { - signingConfig = signingConfigs.getByName("debug") + // Use release signing config if available, otherwise fall back to debug + signingConfig = if (signingConfigs.findByName("release")?.storeFile != null) { + signingConfigs.getByName("release") + } else { + signingConfigs.getByName("debug") + } // Enable code shrinking and resource shrinking isMinifyEnabled = true isShrinkResources = true diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 2b3d15cd..a698d753 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -11,6 +11,7 @@ + + + + + + diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 00000000..a7ff845b --- /dev/null +++ b/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index 71423010..bcec34f5 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -1,8 +1,8 @@ /// App version and info constants /// Update version here only - all other files will reference this class AppInfo { - static const String version = '1.5.0'; - static const String buildNumber = '14'; + static const String version = '1.5.0-hotfix'; + static const String buildNumber = '15'; static const String fullVersion = '$version+$buildNumber'; static const String appName = 'SpotiFLAC'; diff --git a/lib/services/apk_downloader.dart b/lib/services/apk_downloader.dart new file mode 100644 index 00000000..a76058b2 --- /dev/null +++ b/lib/services/apk_downloader.dart @@ -0,0 +1,69 @@ +import 'dart:io'; +import 'package:http/http.dart' as http; +import 'package:path_provider/path_provider.dart'; +import 'package:open_filex/open_filex.dart'; + +typedef ProgressCallback = void Function(int received, int total); + +class ApkDownloader { + static Future downloadApk({ + required String url, + required String version, + ProgressCallback? onProgress, + }) async { + try { + final client = http.Client(); + final request = http.Request('GET', Uri.parse(url)); + final response = await client.send(request); + + if (response.statusCode != 200) { + print('[ApkDownloader] Failed to download: ${response.statusCode}'); + return null; + } + + final contentLength = response.contentLength ?? 0; + + // Get download directory + final dir = await getExternalStorageDirectory(); + if (dir == null) { + print('[ApkDownloader] Could not get storage directory'); + return null; + } + + final filePath = '${dir.path}/SpotiFLAC-$version.apk'; + final file = File(filePath); + + // Delete if exists + if (await file.exists()) { + await file.delete(); + } + + final sink = file.openWrite(); + int received = 0; + + await for (final chunk in response.stream) { + sink.add(chunk); + received += chunk.length; + onProgress?.call(received, contentLength); + } + + await sink.close(); + client.close(); + + print('[ApkDownloader] Downloaded to: $filePath'); + return filePath; + } catch (e) { + print('[ApkDownloader] Error: $e'); + return null; + } + } + + static Future installApk(String filePath) async { + try { + final result = await OpenFilex.open(filePath); + print('[ApkDownloader] Open result: ${result.type} - ${result.message}'); + } catch (e) { + print('[ApkDownloader] Install error: $e'); + } + } +} diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index 0e610203..276313d1 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -10,6 +10,7 @@ class NotificationService { bool _isInitialized = false; static const int downloadProgressId = 1; + static const int updateDownloadId = 2; static const String channelId = 'download_progress'; static const String channelName = 'Download Progress'; static const String channelDescription = 'Shows download progress for tracks'; @@ -182,4 +183,121 @@ class NotificationService { Future cancelDownloadNotification() async { await _notifications.cancel(downloadProgressId); } + + // Update APK download notifications + Future showUpdateDownloadProgress({ + required String version, + required int received, + required int total, + }) async { + if (!_isInitialized) await initialize(); + + final percentage = total > 0 ? (received * 100 ~/ total) : 0; + final receivedMB = (received / 1024 / 1024).toStringAsFixed(1); + final totalMB = (total / 1024 / 1024).toStringAsFixed(1); + + final androidDetails = AndroidNotificationDetails( + channelId, + channelName, + channelDescription: channelDescription, + importance: Importance.low, + priority: Priority.low, + showProgress: true, + maxProgress: 100, + progress: percentage, + ongoing: true, + autoCancel: false, + playSound: false, + enableVibration: false, + onlyAlertOnce: true, + icon: '@mipmap/ic_launcher', + ); + + const iosDetails = DarwinNotificationDetails( + presentAlert: false, + presentBadge: false, + presentSound: false, + ); + + final details = NotificationDetails( + android: androidDetails, + iOS: iosDetails, + ); + + await _notifications.show( + updateDownloadId, + 'Downloading SpotiFLAC v$version', + '$receivedMB / $totalMB MB • $percentage%', + details, + ); + } + + Future showUpdateDownloadComplete({required String version}) async { + if (!_isInitialized) await initialize(); + + const androidDetails = AndroidNotificationDetails( + channelId, + channelName, + channelDescription: channelDescription, + importance: Importance.defaultImportance, + priority: Priority.defaultPriority, + autoCancel: true, + playSound: true, + icon: '@mipmap/ic_launcher', + ); + + const iosDetails = DarwinNotificationDetails( + presentAlert: true, + presentBadge: true, + presentSound: true, + ); + + const details = NotificationDetails( + android: androidDetails, + iOS: iosDetails, + ); + + await _notifications.show( + updateDownloadId, + 'Update Ready', + 'SpotiFLAC v$version downloaded. Tap to install.', + details, + ); + } + + Future showUpdateDownloadFailed() async { + if (!_isInitialized) await initialize(); + + const androidDetails = AndroidNotificationDetails( + channelId, + channelName, + channelDescription: channelDescription, + importance: Importance.defaultImportance, + priority: Priority.defaultPriority, + autoCancel: true, + icon: '@mipmap/ic_launcher', + ); + + const iosDetails = DarwinNotificationDetails( + presentAlert: true, + presentBadge: false, + presentSound: false, + ); + + const details = NotificationDetails( + android: androidDetails, + iOS: iosDetails, + ); + + await _notifications.show( + updateDownloadId, + 'Update Failed', + 'Could not download update. Try again later.', + details, + ); + } + + Future cancelUpdateNotification() async { + await _notifications.cancel(updateDownloadId); + } } diff --git a/lib/services/update_checker.dart b/lib/services/update_checker.dart index 6d85e970..bf61d0cf 100644 --- a/lib/services/update_checker.dart +++ b/lib/services/update_checker.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:io'; import 'package:http/http.dart' as http; import 'package:spotiflac_android/constants/app_info.dart'; @@ -6,12 +7,14 @@ class UpdateInfo { final String version; final String changelog; final String downloadUrl; + final String? apkDownloadUrl; // Direct APK download URL final DateTime publishedAt; const UpdateInfo({ required this.version, required this.changelog, required this.downloadUrl, + this.apkDownloadUrl, required this.publishedAt, }); } @@ -19,6 +22,40 @@ class UpdateInfo { class UpdateChecker { static const String _apiUrl = 'https://api.github.com/repos/${AppInfo.githubRepo}/releases/latest'; + /// Get device CPU architecture + static Future _getDeviceArch() async { + if (!Platform.isAndroid) return 'unknown'; + + try { + // Read CPU info from /proc/cpuinfo + final cpuInfo = await File('/proc/cpuinfo').readAsString(); + + // Check for 64-bit indicators + if (cpuInfo.contains('AArch64') || cpuInfo.contains('aarch64')) { + return 'arm64'; + } + + // Check architecture from uname + final result = await Process.run('uname', ['-m']); + final arch = result.stdout.toString().trim().toLowerCase(); + + if (arch.contains('aarch64') || arch.contains('arm64')) { + return 'arm64'; + } else if (arch.contains('armv7') || arch.contains('arm')) { + return 'arm32'; + } else if (arch.contains('x86_64')) { + return 'x86_64'; + } else if (arch.contains('x86') || arch.contains('i686')) { + return 'x86'; + } + + return 'arm64'; // Default to arm64 for modern devices + } catch (e) { + print('[UpdateChecker] Error detecting arch: $e'); + return 'arm64'; // Default fallback + } + } + /// Check for updates from GitHub releases static Future checkForUpdate() async { try { @@ -46,12 +83,46 @@ class UpdateChecker { final htmlUrl = data['html_url'] as String? ?? '${AppInfo.githubUrl}/releases'; final publishedAt = DateTime.tryParse(data['published_at'] as String? ?? '') ?? DateTime.now(); - print('[UpdateChecker] Update available: $latestVersion'); + // Find APK download URL from assets based on device architecture + final deviceArch = await _getDeviceArch(); + print('[UpdateChecker] Device architecture: $deviceArch'); + + String? arm64Url; + String? arm32Url; + String? universalUrl; + + final assets = data['assets'] as List? ?? []; + for (final asset in assets) { + final name = (asset['name'] as String? ?? '').toLowerCase(); + if (name.endsWith('.apk')) { + final downloadUrl = asset['browser_download_url'] as String?; + if (name.contains('arm64') || name.contains('v8a')) { + arm64Url = downloadUrl; + } else if (name.contains('arm32') || name.contains('v7a') || name.contains('armeabi')) { + arm32Url = downloadUrl; + } else if (name.contains('universal')) { + universalUrl = downloadUrl; + } + } + } + + // Select APK based on device architecture + String? apkUrl; + if (deviceArch == 'arm64') { + apkUrl = arm64Url ?? universalUrl ?? arm32Url; + } else if (deviceArch == 'arm32') { + apkUrl = arm32Url ?? universalUrl; + } else { + apkUrl = universalUrl ?? arm64Url ?? arm32Url; + } + + print('[UpdateChecker] Update available: $latestVersion, APK URL: $apkUrl'); return UpdateInfo( version: latestVersion, changelog: body, downloadUrl: htmlUrl, + apkDownloadUrl: apkUrl, publishedAt: publishedAt, ); } catch (e) { diff --git a/lib/widgets/update_dialog.dart b/lib/widgets/update_dialog.dart index 64715022..01c96186 100644 --- a/lib/widgets/update_dialog.dart +++ b/lib/widgets/update_dialog.dart @@ -2,8 +2,10 @@ import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:spotiflac_android/constants/app_info.dart'; import 'package:spotiflac_android/services/update_checker.dart'; +import 'package:spotiflac_android/services/apk_downloader.dart'; +import 'package:spotiflac_android/services/notification_service.dart'; -class UpdateDialog extends StatelessWidget { +class UpdateDialog extends StatefulWidget { final UpdateInfo updateInfo; final VoidCallback onDismiss; final VoidCallback onDisableUpdates; @@ -15,6 +17,84 @@ class UpdateDialog extends StatelessWidget { required this.onDisableUpdates, }); + @override + State createState() => _UpdateDialogState(); +} + +class _UpdateDialogState extends State { + bool _isDownloading = false; + double _progress = 0; + String _statusText = ''; + + Future _downloadAndInstall() async { + final apkUrl = widget.updateInfo.apkDownloadUrl; + + // If no direct APK URL, open release page + if (apkUrl == null) { + final uri = Uri.parse(widget.updateInfo.downloadUrl); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + if (mounted) Navigator.pop(context); + return; + } + + setState(() { + _isDownloading = true; + _progress = 0; + _statusText = 'Starting download...'; + }); + + final notificationService = NotificationService(); + + final filePath = await ApkDownloader.downloadApk( + url: apkUrl, + version: widget.updateInfo.version, + onProgress: (received, total) { + if (mounted) { + setState(() { + _progress = total > 0 ? received / total : 0; + final receivedMB = (received / 1024 / 1024).toStringAsFixed(1); + final totalMB = (total / 1024 / 1024).toStringAsFixed(1); + _statusText = '$receivedMB / $totalMB MB'; + }); + } + // Update notification + notificationService.showUpdateDownloadProgress( + version: widget.updateInfo.version, + received: received, + total: total, + ); + }, + ); + + if (filePath != null) { + await notificationService.showUpdateDownloadComplete( + version: widget.updateInfo.version, + ); + + if (mounted) { + Navigator.pop(context); + } + + // Open APK for installation + await ApkDownloader.installApk(filePath); + } else { + await notificationService.showUpdateDownloadFailed(); + + if (mounted) { + setState(() { + _isDownloading = false; + _statusText = 'Download failed'; + }); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Failed to download update')), + ); + } + } + } + @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; @@ -50,7 +130,7 @@ class UpdateDialog extends StatelessWidget { Icon(Icons.arrow_forward, size: 16, color: colorScheme.onPrimaryContainer), const SizedBox(width: 8), Text( - 'v${updateInfo.version}', + 'v${widget.updateInfo.version}', style: TextStyle( color: colorScheme.onPrimaryContainer, fontWeight: FontWeight.bold, @@ -70,60 +150,71 @@ class UpdateDialog extends StatelessWidget { ), const SizedBox(height: 8), - // Changelog content (scrollable) - Flexible( - child: Container( - constraints: const BoxConstraints(maxHeight: 200), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - ), - child: SingleChildScrollView( - padding: const EdgeInsets.all(12), - child: Text( - _formatChangelog(updateInfo.changelog), - style: Theme.of(context).textTheme.bodySmall, + // Changelog content (scrollable) - hide when downloading + if (!_isDownloading) + Flexible( + child: Container( + constraints: const BoxConstraints(maxHeight: 200), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: SingleChildScrollView( + padding: const EdgeInsets.all(12), + child: Text( + _formatChangelog(widget.updateInfo.changelog), + style: Theme.of(context).textTheme.bodySmall, + ), ), ), ), - ), + + // Download progress + if (_isDownloading) ...[ + const SizedBox(height: 8), + LinearProgressIndicator(value: _progress), + const SizedBox(height: 8), + Text( + _statusText, + style: Theme.of(context).textTheme.bodySmall, + ), + ], ], ), ), - actions: [ - // Don't remind again button - TextButton( - onPressed: () { - onDisableUpdates(); - Navigator.pop(context); - }, - child: Text( - 'Don\'t remind', - style: TextStyle(color: colorScheme.onSurfaceVariant), - ), - ), - // Later button - TextButton( - onPressed: () { - onDismiss(); - Navigator.pop(context); - }, - child: const Text('Later'), - ), - // Download button - FilledButton( - onPressed: () async { - final uri = Uri.parse(updateInfo.downloadUrl); - if (await canLaunchUrl(uri)) { - await launchUrl(uri, mode: LaunchMode.externalApplication); - } - if (context.mounted) { - Navigator.pop(context); - } - }, - child: const Text('Download'), - ), - ], + actions: _isDownloading + ? [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + ] + : [ + // Don't remind again button + TextButton( + onPressed: () { + widget.onDisableUpdates(); + Navigator.pop(context); + }, + child: Text( + 'Don\'t remind', + style: TextStyle(color: colorScheme.onSurfaceVariant), + ), + ), + // Later button + TextButton( + onPressed: () { + widget.onDismiss(); + Navigator.pop(context); + }, + child: const Text('Later'), + ), + // Download button + FilledButton( + onPressed: _downloadAndInstall, + child: const Text('Install'), + ), + ], ); } diff --git a/pubspec.yaml b/pubspec.yaml index cf6904c2..18863ae0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: 'none' -version: 1.5.0+14 +version: 1.5.0+15 environment: sdk: ^3.10.0