refactor: enable strict analysis options and fix type safety across codebase

Enable strict-casts, strict-inference, and strict-raw-types in
analysis_options.yaml. Add custom_lint with riverpod_lint. Fix all
resulting type warnings with explicit type parameters and safer casts.

Also improves APK update checker to detect device ABIs for correct
variant selection and fixes Deezer artist name parsing edge case.
This commit is contained in:
zarzet
2026-03-27 19:28:42 +07:00
parent 18d3612674
commit f29177216d
41 changed files with 397 additions and 174 deletions
+2 -2
View File
@@ -119,7 +119,7 @@ class AppStateDatabase {
final db = await database;
await db.transaction((txn) async {
final batch = txn.batch();
for (final entry in decoded.whereType<Map>()) {
for (final entry in decoded.whereType<Map<Object?, Object?>>()) {
final map = Map<String, dynamic>.from(entry);
final id = map['id'] as String?;
if (id == null || id.isEmpty) continue;
@@ -179,7 +179,7 @@ class AppStateDatabase {
final decoded = jsonDecode(rawRecent);
if (decoded is List) {
final batch = txn.batch();
for (final entry in decoded.whereType<Map>()) {
for (final entry in decoded.whereType<Map<Object?, Object?>>()) {
final map = Map<String, dynamic>.from(entry);
final type = map['type'] as String?;
final id = map['id'] as String?;
+1 -1
View File
@@ -124,7 +124,7 @@ class CsvImportService {
);
if (i < tracks.length - 1) {
await Future.delayed(const Duration(milliseconds: 100));
await Future<void>.delayed(const Duration(milliseconds: 100));
}
continue;
}
+2 -2
View File
@@ -224,7 +224,7 @@ class HistoryDatabase {
}
try {
final List<dynamic> jsonList = jsonDecode(jsonStr);
final jsonList = List<dynamic>.from(jsonDecode(jsonStr) as List);
_log.i(
'Migrating ${jsonList.length} items from SharedPreferences to SQLite',
);
@@ -233,7 +233,7 @@ class HistoryDatabase {
final batch = db.batch();
for (final json in jsonList) {
final map = json as Map<String, dynamic>;
final map = Map<String, dynamic>.from(json as Map);
batch.insert(
'history',
_jsonToDbRow(map),
@@ -155,11 +155,11 @@ class LibraryCollectionsDatabase {
final db = await database;
await db.transaction((txn) async {
for (final entry in wishlistRaw.whereType<Map>()) {
for (final entry in wishlistRaw.whereType<Map<Object?, Object?>>()) {
final map = Map<String, dynamic>.from(entry);
final trackKey = map['key'] as String?;
final track = map['track'];
if (trackKey == null || track is! Map) continue;
if (trackKey == null || track is! Map<Object?, Object?>) continue;
final addedAt = (map['addedAt'] as String?) ?? nowIso;
await txn.insert(_tableWishlist, {
'track_key': trackKey,
@@ -168,11 +168,11 @@ class LibraryCollectionsDatabase {
}, conflictAlgorithm: ConflictAlgorithm.replace);
}
for (final entry in lovedRaw.whereType<Map>()) {
for (final entry in lovedRaw.whereType<Map<Object?, Object?>>()) {
final map = Map<String, dynamic>.from(entry);
final trackKey = map['key'] as String?;
final track = map['track'];
if (trackKey == null || track is! Map) continue;
if (trackKey == null || track is! Map<Object?, Object?>) continue;
final addedAt = (map['addedAt'] as String?) ?? nowIso;
await txn.insert(_tableLoved, {
'track_key': trackKey,
@@ -181,7 +181,8 @@ class LibraryCollectionsDatabase {
}, conflictAlgorithm: ConflictAlgorithm.replace);
}
for (final playlistEntry in playlistsRaw.whereType<Map>()) {
for (final playlistEntry
in playlistsRaw.whereType<Map<Object?, Object?>>()) {
final playlist = Map<String, dynamic>.from(playlistEntry);
final playlistId = playlist['id'] as String?;
if (playlistId == null || playlistId.isEmpty) continue;
@@ -197,11 +198,12 @@ class LibraryCollectionsDatabase {
}, conflictAlgorithm: ConflictAlgorithm.replace);
final tracksRaw = (playlist['tracks'] as List?) ?? const [];
for (final trackEntry in tracksRaw.whereType<Map>()) {
for (final trackEntry
in tracksRaw.whereType<Map<Object?, Object?>>()) {
final trackMap = Map<String, dynamic>.from(trackEntry);
final trackKey = trackMap['key'] as String?;
final track = trackMap['track'];
if (trackKey == null || track is! Map) continue;
if (trackKey == null || track is! Map<Object?, Object?>) continue;
final addedAt = (trackMap['addedAt'] as String?) ?? nowIso;
await txn.insert(_tablePlaylistTracks, {
'playlist_id': playlistId,
+2 -2
View File
@@ -67,8 +67,8 @@ class PlatformBridge {
if (response['success'] == true) {
final service = response['service'] ?? payload.service;
final filePath = response['file_path'] ?? '';
final bitDepth = response['actual_bit_depth'];
final sampleRate = response['actual_sample_rate'];
final bitDepth = response['actual_bit_depth'] as num?;
final sampleRate = response['actual_sample_rate'] as num?;
final qualityStr = bitDepth != null && sampleRate != null
? ' ($bitDepth-bit/${(sampleRate / 1000).toStringAsFixed(1)}kHz)'
: '';
+1 -1
View File
@@ -65,7 +65,7 @@ class ShareIntentService {
_mediaSubscription = ReceiveSharingIntent.instance.getMediaStream().listen(
_handleSharedMedia,
onError: (err) => _log.e('Error: $err'),
onError: (Object err) => _log.e('Error: $err'),
);
final initialMedia = await ReceiveSharingIntent.instance.getInitialMedia();
+146 -24
View File
@@ -1,11 +1,26 @@
import 'dart:convert';
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:http/http.dart' as http;
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('UpdateChecker');
enum _ApkVariant { arm64, arm32, universal }
class _ApkAsset {
final String name;
final String url;
final _ApkVariant variant;
const _ApkAsset({
required this.name,
required this.url,
required this.variant,
});
}
class UpdateInfo {
final String version;
final String changelog;
@@ -94,32 +109,15 @@ class UpdateChecker {
DateTime.tryParse(releaseData['published_at'] as String? ?? '') ??
DateTime.now();
String? arm64Url;
String? universalUrl;
final assets = releaseData['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?;
final uri = downloadUrl != null ? Uri.tryParse(downloadUrl) : null;
if (uri == null || uri.scheme != 'https') {
_log.w('Skipping non-HTTPS APK URL: $downloadUrl');
continue;
}
if (name.contains('arm64') || name.contains('v8a')) {
arm64Url = downloadUrl;
} else if (name.contains('universal')) {
universalUrl = downloadUrl;
}
}
}
// Only arm64 is supported; fall back to universal if available
final apkUrl = arm64Url ?? universalUrl;
final assets = _collectApkAssets(
releaseData['assets'] as List<dynamic>? ?? const [],
);
final selectedAsset = await _selectApkForCurrentDevice(assets);
final apkUrl = selectedAsset?.url;
_log.i(
'Update available: $latestVersion (prerelease: $isPrerelease), APK URL: $apkUrl',
'Update available: $latestVersion (prerelease: $isPrerelease), '
'APK asset: ${selectedAsset?.name ?? 'none'}, APK URL: $apkUrl',
);
return UpdateInfo(
@@ -169,4 +167,128 @@ class UpdateChecker {
}
static String get currentVersion => AppInfo.version;
static List<_ApkAsset> _collectApkAssets(List<dynamic> assets) {
final apkAssets = <_ApkAsset>[];
for (final asset in assets.whereType<Map<Object?, Object?>>()) {
final assetMap = Map<String, dynamic>.from(asset);
final name = (assetMap['name'] as String? ?? '').trim();
final normalizedName = name.toLowerCase();
if (!normalizedName.endsWith('.apk')) {
continue;
}
final downloadUrl = assetMap['browser_download_url'] as String?;
final uri = downloadUrl != null ? Uri.tryParse(downloadUrl) : null;
if (uri == null || uri.scheme != 'https') {
_log.w('Skipping non-HTTPS APK URL: $downloadUrl');
continue;
}
final variant = _apkVariantFromName(normalizedName);
if (variant == null) {
_log.w('Skipping APK with unknown variant: $name');
continue;
}
apkAssets.add(
_ApkAsset(name: name, url: uri.toString(), variant: variant),
);
}
return apkAssets;
}
static _ApkVariant? _apkVariantFromName(String name) {
if (name.contains('universal')) {
return _ApkVariant.universal;
}
if (name.contains('arm64') || name.contains('arm64-v8a')) {
return _ApkVariant.arm64;
}
if (name.contains('arm32') ||
name.contains('armeabi') ||
name.contains('armv7') ||
name.contains('v7a')) {
return _ApkVariant.arm32;
}
return null;
}
static Future<_ApkAsset?> _selectApkForCurrentDevice(
List<_ApkAsset> assets,
) async {
if (assets.isEmpty) {
return null;
}
_ApkAsset? arm64Asset;
_ApkAsset? arm32Asset;
_ApkAsset? universalAsset;
for (final asset in assets) {
switch (asset.variant) {
case _ApkVariant.arm64:
arm64Asset ??= asset;
break;
case _ApkVariant.arm32:
arm32Asset ??= asset;
break;
case _ApkVariant.universal:
universalAsset ??= asset;
break;
}
}
final supportedAbis = await _getSupportedAndroidAbis();
final hasArm64 = supportedAbis.any(_isArm64Abi);
final hasArm32 = supportedAbis.any(_isArm32Abi);
if (hasArm64) {
return arm64Asset ?? universalAsset ?? arm32Asset;
}
if (hasArm32) {
return arm32Asset ?? universalAsset;
}
if (universalAsset != null) {
_log.w(
'Could not match APK asset to supported ABIs ${supportedAbis.join(', ')}; '
'falling back to universal APK.',
);
return universalAsset;
}
_log.w(
'Could not match APK asset to supported ABIs ${supportedAbis.join(', ')}; '
'no universal APK available.',
);
return null;
}
static Future<List<String>> _getSupportedAndroidAbis() async {
if (!Platform.isAndroid) {
return const [];
}
try {
final androidInfo = await DeviceInfoPlugin().androidInfo;
final supportedAbis = androidInfo.supportedAbis
.map((abi) => abi.toLowerCase())
.where((abi) => abi.isNotEmpty)
.toSet()
.toList();
_log.i('Detected supported Android ABIs: ${supportedAbis.join(', ')}');
return supportedAbis;
} catch (e) {
_log.w('Failed to detect supported Android ABIs: $e');
return const [];
}
}
static bool _isArm64Abi(String abi) =>
abi.contains('arm64') || abi.contains('aarch64');
static bool _isArm32Abi(String abi) =>
abi.contains('armeabi') || abi.contains('armv7') || abi.contains('arm');
}