diff --git a/ios/Podfile b/ios/Podfile index de176d9..ff76451 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -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 diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index b3108e2..0e59a08 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -1943,12 +1943,12 @@ class DownloadQueueNotifier extends Notifier { 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 { 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 { 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 { 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 { 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 { (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 { 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 { } _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 { // 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, diff --git a/lib/screens/setup_screen.dart b/lib/screens/setup_screen.dart index 230a5fa..e32204d 100644 --- a/lib/screens/setup_screen.dart +++ b/lib/screens/setup_screen.dart @@ -68,10 +68,12 @@ class _SetupScreenState extends ConsumerState { Future _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 { Future _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); diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index fc3976e..7606746 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -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 _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 _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 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, ); } diff --git a/lib/utils/file_access.dart b/lib/utils/file_access.dart index 1ac2b66..1dd4f1f 100644 --- a/lib/utils/file_access.dart +++ b/lib/utils/file_access.dart @@ -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 validateOrFixIosPath(String path, {String subfolder = 'SpotiFLAC'}) async { +Future 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 = []; + + 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//Documents/ + 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.', ); } }