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