mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-03-31 00:39:24 +02:00
fix(ios): recover notification permission and path handling
This commit is contained in:
@@ -46,6 +46,11 @@ post_install do |installer|
|
||||
flutter_additional_ios_build_settings(target)
|
||||
target.build_configurations.each do |config|
|
||||
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '14.0'
|
||||
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= ['$(inherited)']
|
||||
definitions = config.build_settings['GCC_PREPROCESSOR_DEFINITIONS']
|
||||
unless definitions.include?('PERMISSION_NOTIFICATIONS=1')
|
||||
definitions << 'PERMISSION_NOTIFICATIONS=1'
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1943,12 +1943,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final albumArtist = _resolveAlbumArtistForMetadata(track, settings);
|
||||
metadata['ALBUMARTIST'] = albumArtist;
|
||||
|
||||
if (track.trackNumber != null) {
|
||||
if (track.trackNumber != null && track.trackNumber! > 0) {
|
||||
metadata['TRACKNUMBER'] = track.trackNumber.toString();
|
||||
metadata['TRACK'] = track.trackNumber.toString();
|
||||
}
|
||||
|
||||
if (track.discNumber != null) {
|
||||
if (track.discNumber != null && track.discNumber! > 0) {
|
||||
metadata['DISCNUMBER'] = track.discNumber.toString();
|
||||
metadata['DISC'] = track.discNumber.toString();
|
||||
}
|
||||
@@ -2085,12 +2085,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final albumArtist = _resolveAlbumArtistForMetadata(track, settings);
|
||||
metadata['ALBUMARTIST'] = albumArtist;
|
||||
|
||||
if (track.trackNumber != null) {
|
||||
if (track.trackNumber != null && track.trackNumber! > 0) {
|
||||
metadata['TRACKNUMBER'] = track.trackNumber.toString();
|
||||
metadata['TRACK'] = track.trackNumber.toString();
|
||||
}
|
||||
|
||||
if (track.discNumber != null) {
|
||||
if (track.discNumber != null && track.discNumber! > 0) {
|
||||
metadata['DISCNUMBER'] = track.discNumber.toString();
|
||||
metadata['DISC'] = track.discNumber.toString();
|
||||
}
|
||||
@@ -2249,11 +2249,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final albumArtist = _resolveAlbumArtistForMetadata(track, settings);
|
||||
metadata['ALBUMARTIST'] = albumArtist;
|
||||
|
||||
if (track.trackNumber != null) {
|
||||
if (track.trackNumber != null && track.trackNumber! > 0) {
|
||||
metadata['TRACKNUMBER'] = track.trackNumber.toString();
|
||||
}
|
||||
|
||||
if (track.discNumber != null) {
|
||||
if (track.discNumber != null && track.discNumber! > 0) {
|
||||
metadata['DISCNUMBER'] = track.discNumber.toString();
|
||||
}
|
||||
|
||||
@@ -2489,6 +2489,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
await musicDir.create(recursive: true);
|
||||
}
|
||||
state = state.copyWith(outputDir: musicDir.path);
|
||||
ref.read(settingsProvider.notifier).setDownloadDirectory(musicDir.path);
|
||||
} else if (!isValidIosWritablePath(state.outputDir)) {
|
||||
// Check for other invalid paths (like container root without Documents/)
|
||||
_log.w(
|
||||
@@ -2498,6 +2499,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final correctedPath = await validateOrFixIosPath(state.outputDir);
|
||||
_log.i('Corrected path: $correctedPath');
|
||||
state = state.copyWith(outputDir: correctedPath);
|
||||
ref.read(settingsProvider.notifier).setDownloadDirectory(correctedPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2898,9 +2900,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
(trackToDownload.isrc == null && deezerIsrc != null) ||
|
||||
(!_isValidISRC(trackToDownload.isrc ?? '') &&
|
||||
deezerIsrc != null) ||
|
||||
(trackToDownload.trackNumber == null &&
|
||||
deezerTrackNum != null) ||
|
||||
(trackToDownload.discNumber == null && deezerDiscNum != null);
|
||||
((trackToDownload.trackNumber == null ||
|
||||
trackToDownload.trackNumber! <= 0) &&
|
||||
deezerTrackNum != null &&
|
||||
deezerTrackNum > 0) ||
|
||||
((trackToDownload.discNumber == null ||
|
||||
trackToDownload.discNumber! <= 0) &&
|
||||
deezerDiscNum != null &&
|
||||
deezerDiscNum > 0);
|
||||
|
||||
if (needsEnrich) {
|
||||
trackToDownload = Track(
|
||||
@@ -2914,8 +2921,16 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
isrc: (deezerIsrc != null && _isValidISRC(deezerIsrc))
|
||||
? deezerIsrc
|
||||
: trackToDownload.isrc,
|
||||
trackNumber: trackToDownload.trackNumber ?? deezerTrackNum,
|
||||
discNumber: trackToDownload.discNumber ?? deezerDiscNum,
|
||||
trackNumber:
|
||||
(trackToDownload.trackNumber != null &&
|
||||
trackToDownload.trackNumber! > 0)
|
||||
? trackToDownload.trackNumber
|
||||
: deezerTrackNum,
|
||||
discNumber:
|
||||
(trackToDownload.discNumber != null &&
|
||||
trackToDownload.discNumber! > 0)
|
||||
? trackToDownload.discNumber
|
||||
: deezerDiscNum,
|
||||
releaseDate: trackToDownload.releaseDate ?? deezerReleaseDate,
|
||||
deezerId: deezerTrackId,
|
||||
availability: trackToDownload.availability,
|
||||
@@ -2993,6 +3008,17 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
_log.d('Output dir: $outputDir');
|
||||
|
||||
final normalizedTrackNumber =
|
||||
(trackToDownload.trackNumber != null &&
|
||||
trackToDownload.trackNumber! > 0)
|
||||
? trackToDownload.trackNumber!
|
||||
: 1;
|
||||
final normalizedDiscNumber =
|
||||
(trackToDownload.discNumber != null &&
|
||||
trackToDownload.discNumber! > 0)
|
||||
? trackToDownload.discNumber!
|
||||
: 1;
|
||||
|
||||
final payload = DownloadRequestPayload(
|
||||
isrc: trackToDownload.isrc ?? '',
|
||||
service: item.service,
|
||||
@@ -3008,8 +3034,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
// Keep prior behavior: non-YouTube paths were implicitly true.
|
||||
embedLyrics: isYouTube ? settings.embedLyrics : true,
|
||||
embedMaxQualityCover: settings.maxQualityCover,
|
||||
trackNumber: trackToDownload.trackNumber ?? 1,
|
||||
discNumber: trackToDownload.discNumber ?? 1,
|
||||
trackNumber: normalizedTrackNumber,
|
||||
discNumber: normalizedDiscNumber,
|
||||
releaseDate: trackToDownload.releaseDate ?? '',
|
||||
itemId: item.id,
|
||||
durationMs: trackToDownload.duration,
|
||||
|
||||
@@ -68,10 +68,12 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
|
||||
Future<void> _checkInitialPermissions() async {
|
||||
if (Platform.isIOS) {
|
||||
final notificationStatus = await Permission.notification.status;
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_storagePermissionGranted = true;
|
||||
_notificationPermissionGranted = true;
|
||||
_notificationPermissionGranted =
|
||||
notificationStatus.isGranted || notificationStatus.isProvisional;
|
||||
});
|
||||
}
|
||||
} else if (Platform.isAndroid) {
|
||||
@@ -181,7 +183,14 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
Future<void> _requestNotificationPermission() async {
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
if (_androidSdkVersion >= 33) {
|
||||
if (Platform.isIOS) {
|
||||
final status = await Permission.notification.request();
|
||||
if (status.isGranted || status.isProvisional) {
|
||||
setState(() => _notificationPermissionGranted = true);
|
||||
} else if (status.isPermanentlyDenied) {
|
||||
await _showPermissionDeniedDialog('Notification');
|
||||
}
|
||||
} else if (_androidSdkVersion >= 33) {
|
||||
final status = await Permission.notification.request();
|
||||
if (status.isGranted) {
|
||||
setState(() => _notificationPermissionGranted = true);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
class NotificationService {
|
||||
static final NotificationService _instance = NotificationService._internal();
|
||||
@@ -9,6 +12,7 @@ class NotificationService {
|
||||
final FlutterLocalNotificationsPlugin _notifications =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
bool _isInitialized = false;
|
||||
bool _notificationPermissionRequested = false;
|
||||
|
||||
static const int downloadProgressId = 1;
|
||||
static const int updateDownloadId = 2;
|
||||
@@ -28,8 +32,8 @@ class NotificationService {
|
||||
'@mipmap/ic_launcher',
|
||||
);
|
||||
const iosSettings = DarwinInitializationSettings(
|
||||
requestAlertPermission: true,
|
||||
requestBadgePermission: true,
|
||||
requestAlertPermission: false,
|
||||
requestBadgePermission: false,
|
||||
requestSoundPermission: false,
|
||||
);
|
||||
|
||||
@@ -72,6 +76,55 @@ class NotificationService {
|
||||
_isInitialized = true;
|
||||
}
|
||||
|
||||
Future<bool> _ensureNotificationPermission() async {
|
||||
if (!Platform.isIOS) return true;
|
||||
|
||||
final status = await Permission.notification.status;
|
||||
if (status.isGranted || status.isProvisional) return true;
|
||||
|
||||
if (_notificationPermissionRequested ||
|
||||
status.isPermanentlyDenied ||
|
||||
status.isRestricted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
_notificationPermissionRequested = true;
|
||||
final requested = await Permission.notification.request();
|
||||
return requested.isGranted || requested.isProvisional;
|
||||
}
|
||||
|
||||
Future<void> _showSafely({
|
||||
required int id,
|
||||
required String title,
|
||||
required String body,
|
||||
required NotificationDetails details,
|
||||
}) async {
|
||||
if (!await _ensureNotificationPermission()) return;
|
||||
|
||||
try {
|
||||
await _notifications.show(
|
||||
id: id,
|
||||
title: title,
|
||||
body: body,
|
||||
notificationDetails: details,
|
||||
);
|
||||
} on PlatformException catch (e) {
|
||||
final isNotificationsNotAllowed =
|
||||
Platform.isIOS &&
|
||||
(e.code == 'Error 1' ||
|
||||
(e.message?.contains('UNErrorDomain error 1') ?? false) ||
|
||||
e.toString().contains('UNErrorDomain error 1'));
|
||||
|
||||
if (isNotificationsNotAllowed) {
|
||||
debugPrint(
|
||||
'iOS notifications not allowed; skipping local notification',
|
||||
);
|
||||
return;
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> showDownloadProgress({
|
||||
required String trackName,
|
||||
required String artistName,
|
||||
@@ -110,11 +163,11 @@ class NotificationService {
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
await _notifications.show(
|
||||
await _showSafely(
|
||||
id: downloadProgressId,
|
||||
title: 'Downloading $trackName',
|
||||
body: '$artistName • $percentage%',
|
||||
notificationDetails: details,
|
||||
details: details,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -153,11 +206,11 @@ class NotificationService {
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
await _notifications.show(
|
||||
await _showSafely(
|
||||
id: downloadProgressId,
|
||||
title: 'Finalizing $trackName',
|
||||
body: '$artistName • Embedding metadata...',
|
||||
notificationDetails: details,
|
||||
details: details,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -203,11 +256,11 @@ class NotificationService {
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
await _notifications.show(
|
||||
await _showSafely(
|
||||
id: downloadProgressId,
|
||||
title: title,
|
||||
body: '$trackName - $artistName',
|
||||
notificationDetails: details,
|
||||
details: details,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -243,11 +296,11 @@ class NotificationService {
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
await _notifications.show(
|
||||
await _showSafely(
|
||||
id: downloadProgressId,
|
||||
title: title,
|
||||
body: '$completedCount tracks downloaded successfully',
|
||||
notificationDetails: details,
|
||||
details: details,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -300,11 +353,11 @@ class NotificationService {
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
await _notifications.show(
|
||||
await _showSafely(
|
||||
id: libraryScanId,
|
||||
title: 'Scanning local library',
|
||||
body: body,
|
||||
notificationDetails: details,
|
||||
details: details,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -346,11 +399,11 @@ class NotificationService {
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
await _notifications.show(
|
||||
await _showSafely(
|
||||
id: libraryScanId,
|
||||
title: 'Library scan complete',
|
||||
body: '$totalTracks tracks indexed$suffix',
|
||||
notificationDetails: details,
|
||||
details: details,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -379,11 +432,11 @@ class NotificationService {
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
await _notifications.show(
|
||||
await _showSafely(
|
||||
id: libraryScanId,
|
||||
title: 'Library scan failed',
|
||||
body: message,
|
||||
notificationDetails: details,
|
||||
details: details,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -412,11 +465,11 @@ class NotificationService {
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
await _notifications.show(
|
||||
await _showSafely(
|
||||
id: libraryScanId,
|
||||
title: 'Library scan cancelled',
|
||||
body: 'Scan stopped before completion.',
|
||||
notificationDetails: details,
|
||||
details: details,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -463,11 +516,11 @@ class NotificationService {
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
await _notifications.show(
|
||||
await _showSafely(
|
||||
id: updateDownloadId,
|
||||
title: 'Downloading SpotiFLAC v$version',
|
||||
body: '$receivedMB / $totalMB MB • $percentage%',
|
||||
notificationDetails: details,
|
||||
details: details,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -496,11 +549,11 @@ class NotificationService {
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
await _notifications.show(
|
||||
await _showSafely(
|
||||
id: updateDownloadId,
|
||||
title: 'Update Ready',
|
||||
body: 'SpotiFLAC v$version downloaded. Tap to install.',
|
||||
notificationDetails: details,
|
||||
details: details,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -528,11 +581,11 @@ class NotificationService {
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
await _notifications.show(
|
||||
await _showSafely(
|
||||
id: updateDownloadId,
|
||||
title: 'Update Failed',
|
||||
body: 'Could not download update. Try again later.',
|
||||
notificationDetails: details,
|
||||
details: details,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,14 @@ final _iosContainerRootPattern = RegExp(
|
||||
r'^(/private)?/var/mobile/Containers/Data/Application/[A-F0-9\-]+/?$',
|
||||
caseSensitive: false,
|
||||
);
|
||||
final _iosContainerPathWithoutLeadingSlashPattern = RegExp(
|
||||
r'^(private/)?var/mobile/Containers/Data/Application/[A-F0-9\-]+/.+',
|
||||
caseSensitive: false,
|
||||
);
|
||||
final _iosLegacyRelativeDocumentsPattern = RegExp(
|
||||
r'^Data/Application/[A-F0-9\-]+/Documents(?:/(.*))?$',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
/// Checks if a path is a valid writable directory on iOS.
|
||||
/// Returns false if:
|
||||
@@ -21,6 +29,7 @@ final _iosContainerRootPattern = RegExp(
|
||||
bool isValidIosWritablePath(String path) {
|
||||
if (!Platform.isIOS) return true;
|
||||
if (path.isEmpty) return false;
|
||||
if (!path.startsWith('/')) return false;
|
||||
|
||||
// Check if it's the container root (without Documents/, tmp/, etc.)
|
||||
if (_iosContainerRootPattern.hasMatch(path)) {
|
||||
@@ -54,16 +63,64 @@ bool isValidIosWritablePath(String path) {
|
||||
|
||||
/// Validates and potentially corrects an iOS path.
|
||||
/// Returns a valid Documents subdirectory path if the input is invalid.
|
||||
Future<String> validateOrFixIosPath(String path, {String subfolder = 'SpotiFLAC'}) async {
|
||||
Future<String> validateOrFixIosPath(
|
||||
String path, {
|
||||
String subfolder = 'SpotiFLAC',
|
||||
}) async {
|
||||
if (!Platform.isIOS) return path;
|
||||
|
||||
if (isValidIosWritablePath(path)) {
|
||||
return path;
|
||||
final trimmed = path.trim();
|
||||
if (isValidIosWritablePath(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
final docDir = await getApplicationDocumentsDirectory();
|
||||
final candidates = <String>[];
|
||||
|
||||
if (trimmed.isNotEmpty) {
|
||||
candidates.add(trimmed);
|
||||
}
|
||||
|
||||
// Some pickers can return absolute iOS paths without the leading slash.
|
||||
if (_iosContainerPathWithoutLeadingSlashPattern.hasMatch(trimmed)) {
|
||||
candidates.add('/$trimmed');
|
||||
}
|
||||
|
||||
// Recover legacy relative iOS path format:
|
||||
// Data/Application/<UUID>/Documents/<subdir>
|
||||
final legacyRelativeMatch = _iosLegacyRelativeDocumentsPattern.firstMatch(
|
||||
trimmed,
|
||||
);
|
||||
if (legacyRelativeMatch != null) {
|
||||
final suffix = (legacyRelativeMatch.group(1) ?? '').trim();
|
||||
final normalizedSuffix = suffix.startsWith('/')
|
||||
? suffix.substring(1)
|
||||
: suffix;
|
||||
candidates.add(
|
||||
normalizedSuffix.isEmpty
|
||||
? docDir.path
|
||||
: '${docDir.path}/$normalizedSuffix',
|
||||
);
|
||||
}
|
||||
|
||||
// Generic salvage for relative paths containing `Documents/...`.
|
||||
if (!trimmed.startsWith('/')) {
|
||||
final documentsMarker = 'Documents/';
|
||||
final index = trimmed.indexOf(documentsMarker);
|
||||
if (index >= 0) {
|
||||
final suffix = trimmed.substring(index + documentsMarker.length).trim();
|
||||
candidates.add(suffix.isEmpty ? docDir.path : '${docDir.path}/$suffix');
|
||||
}
|
||||
}
|
||||
|
||||
for (final candidate in candidates) {
|
||||
if (isValidIosWritablePath(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to app Documents directory
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final musicDir = Directory('${dir.path}/$subfolder');
|
||||
final musicDir = Directory('${docDir.path}/$subfolder');
|
||||
if (!await musicDir.exists()) {
|
||||
await musicDir.create(recursive: true);
|
||||
}
|
||||
@@ -96,11 +153,20 @@ IosPathValidationResult validateIosPath(String path) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!path.startsWith('/')) {
|
||||
return const IosPathValidationResult(
|
||||
isValid: false,
|
||||
errorReason:
|
||||
'Invalid path format. Please choose a local folder from Files.',
|
||||
);
|
||||
}
|
||||
|
||||
// Check if it's the container root
|
||||
if (_iosContainerRootPattern.hasMatch(path)) {
|
||||
return const IosPathValidationResult(
|
||||
isValid: false,
|
||||
errorReason: 'Cannot write to app container root. Please choose a subfolder like Documents.',
|
||||
errorReason:
|
||||
'Cannot write to app container root. Please choose a subfolder like Documents.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -110,7 +176,8 @@ IosPathValidationResult validateIosPath(String path) {
|
||||
path.contains('com~apple~CloudDocs')) {
|
||||
return const IosPathValidationResult(
|
||||
isValid: false,
|
||||
errorReason: 'iCloud Drive is not supported. Please choose a local folder.',
|
||||
errorReason:
|
||||
'iCloud Drive is not supported. Please choose a local folder.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -125,7 +192,8 @@ IosPathValidationResult validateIosPath(String path) {
|
||||
if (remainingPath.isEmpty || remainingPath == '/') {
|
||||
return const IosPathValidationResult(
|
||||
isValid: false,
|
||||
errorReason: 'Cannot write to app container root. Please use the default folder or choose a different location.',
|
||||
errorReason:
|
||||
'Cannot write to app container root. Please use the default folder or choose a different location.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user