v1.5.0-hotfix: Fix app signing, add in-app update

This commit is contained in:
zarzet 2026-01-02 03:36:11 +07:00
parent d227d57545
commit 33e8ddd758
12 changed files with 487 additions and 55 deletions

View file

@ -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: |

3
.gitignore vendored
View file

@ -40,6 +40,9 @@ android/.gradle/
android/app/libs/
android/local.properties
android/*.iml
android/keystore.properties
android/*.jks
android/*.keystore
# iOS
ios/Frameworks/

View file

@ -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

View file

@ -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

View file

@ -11,6 +11,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:label="SpotiFLAC"
@ -76,6 +77,17 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<!-- FileProvider for APK installation -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileProvider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
<queries>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-files-path name="external_files" path="." />
<cache-path name="cache" path="." />
<files-path name="files" path="." />
</paths>

View file

@ -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';

View file

@ -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<String?> 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<void> 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');
}
}
}

View file

@ -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<void> cancelDownloadNotification() async {
await _notifications.cancel(downloadProgressId);
}
// Update APK download notifications
Future<void> 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<void> 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<void> 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<void> cancelUpdateNotification() async {
await _notifications.cancel(updateDownloadId);
}
}

View file

@ -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<String> _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<UpdateInfo?> 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<dynamic>? ?? [];
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) {

View file

@ -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<UpdateDialog> createState() => _UpdateDialogState();
}
class _UpdateDialogState extends State<UpdateDialog> {
bool _isDownloading = false;
double _progress = 0;
String _statusText = '';
Future<void> _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,7 +150,8 @@ class UpdateDialog extends StatelessWidget {
),
const SizedBox(height: 8),
// Changelog content (scrollable)
// Changelog content (scrollable) - hide when downloading
if (!_isDownloading)
Flexible(
child: Container(
constraints: const BoxConstraints(maxHeight: 200),
@ -81,20 +162,38 @@ class UpdateDialog extends StatelessWidget {
child: SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: Text(
_formatChangelog(updateInfo.changelog),
_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: [
actions: _isDownloading
? [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
]
: [
// Don't remind again button
TextButton(
onPressed: () {
onDisableUpdates();
widget.onDisableUpdates();
Navigator.pop(context);
},
child: Text(
@ -105,23 +204,15 @@ class UpdateDialog extends StatelessWidget {
// Later button
TextButton(
onPressed: () {
onDismiss();
widget.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'),
onPressed: _downloadAndInstall,
child: const Text('Install'),
),
],
);

View file

@ -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