Skip to content

Commit

Permalink
Add a Lock to DownloadService. This ensures that download status upda…
Browse files Browse the repository at this point in the history
…tes are not processed while a download start/stop is requested (they are processed right after).
  • Loading branch information
Chralu committed Dec 22, 2024
1 parent 6e6c062 commit 06c88e9
Show file tree
Hide file tree
Showing 10 changed files with 101 additions and 76 deletions.
1 change: 1 addition & 0 deletions .tool-versions
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
flutter 3.24.5-stable
5 changes: 4 additions & 1 deletion lib/bloc/podcast/episode_bloc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import 'dart:async';
import 'package:anytime/bloc/bloc.dart';
import 'package:anytime/entities/episode.dart';
import 'package:anytime/services/audio/audio_player_service.dart';
import 'package:anytime/services/download/download_service.dart';
import 'package:anytime/services/podcast/podcast_service.dart';
import 'package:anytime/state/bloc_state.dart';
import 'package:logging/logging.dart';
Expand All @@ -17,6 +18,7 @@ import 'package:rxdart/rxdart.dart';
class EpisodeBloc extends Bloc {
final log = Logger('EpisodeBloc');
final PodcastService podcastService;
final DownloadService downloadService;
final AudioPlayerService audioPlayerService;

/// Add to sink to fetch list of current downloaded episodes.
Expand All @@ -43,6 +45,7 @@ class EpisodeBloc extends Bloc {
EpisodeBloc({
required this.podcastService,
required this.audioPlayerService,
required this.downloadService,
}) {
_init();
}
Expand All @@ -65,7 +68,7 @@ class EpisodeBloc extends Bloc {
await audioPlayerService.stop();
}

await podcastService.deleteDownload(episode!);
await downloadService.deleteDownload(episode!);

fetchDownloads(true);
});
Expand Down
5 changes: 4 additions & 1 deletion lib/services/audio/default_audio_player_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import 'package:anytime/entities/sleep.dart';
import 'package:anytime/entities/transcript.dart';
import 'package:anytime/repository/repository.dart';
import 'package:anytime/services/audio/audio_player_service.dart';
import 'package:anytime/services/download/download_service.dart';
import 'package:anytime/services/podcast/podcast_service.dart';
import 'package:anytime/services/settings/settings_service.dart';
import 'package:anytime/state/episode_state.dart';
Expand All @@ -37,6 +38,7 @@ class DefaultAudioPlayerService extends AudioPlayerService {
final log = Logger('DefaultAudioPlayerService');
final Repository repository;
final SettingsService settingsService;
final DownloadService downloadService;
final PodcastService podcastService;

late AudioHandler _audioHandler;
Expand Down Expand Up @@ -95,6 +97,7 @@ class DefaultAudioPlayerService extends AudioPlayerService {
required this.repository,
required this.settingsService,
required this.podcastService,
required this.downloadService,
}) {
AudioService.init(
builder: () => _DefaultAudioPlayerHandler(
Expand Down Expand Up @@ -535,7 +538,7 @@ class DefaultAudioPlayerService extends AudioPlayerService {
settingsService.deleteDownloadedPlayedEpisodes &&
_currentEpisode?.downloadState == DownloadState.downloaded && !sleepy
) {
await podcastService.deleteDownload(_currentEpisode!);
await downloadService.deleteDownload(_currentEpisode!);
}

_stopPositionTicker();
Expand Down
1 change: 1 addition & 0 deletions lib/services/download/download_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import 'package:anytime/entities/episode.dart';

abstract class DownloadService {
Future<bool> downloadEpisode(Episode episode);
Future<void> deleteDownload(Episode episode);

Future<Episode?> findEpisodeByTaskId(String taskId);

Expand Down
83 changes: 70 additions & 13 deletions lib/services/download/mobile_download_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,13 @@ import 'package:anytime/repository/repository.dart';
import 'package:anytime/services/download/download_manager.dart';
import 'package:anytime/services/download/download_service.dart';
import 'package:anytime/services/podcast/podcast_service.dart';
import 'package:anytime/services/settings/settings_service.dart';
import 'package:collection/collection.dart' show IterableExtension;
import 'package:flutter_downloader/flutter_downloader.dart';
import 'package:logging/logging.dart';
import 'package:mp3_info/mp3_info.dart';
import 'package:rxdart/rxdart.dart';
import 'package:synchronized/synchronized.dart';

/// An implementation of a [DownloadService] that handles downloading
/// of episodes on mobile.
Expand All @@ -25,19 +28,36 @@ class MobileDownloadService extends DownloadService {

final log = Logger('MobileDownloadService');
final Repository repository;
final SettingsService settingsService;
final DownloadManager downloadManager;
final PodcastService podcastService;

MobileDownloadService({required this.repository, required this.downloadManager, required this.podcastService}) {
downloadManager.downloadProgress.pipe(downloadProgress);
downloadProgress.listen((progress) {
_updateDownloadProgress(progress);
});
late final StreamSubscription _downloadProgressSubscription;

/// Lock ensures we wait for task creation and local save
/// before handling subsequent [Download update events].
final _downloadProgressLock = Lock();

MobileDownloadService({
required this.repository,
required this.downloadManager,
required this.settingsService,
required this.podcastService,
}) {
_downloadProgressSubscription = downloadManager.downloadProgress.listen(
(progress) async => await _downloadProgressLock.synchronized(
() {
downloadProgress.add(progress);
_updateDownloadProgress(progress);
},
),
);
}

@override
void dispose() {
downloadManager.dispose();
_downloadProgressSubscription.cancel();
}

@override
Expand Down Expand Up @@ -121,16 +141,18 @@ class MobileDownloadService extends DownloadService {
/// the URL before calling download and ensure it is https.
var url = await resolveUrl(episode.contentUrl!, forceHttps: true);

final taskId = await downloadManager.enqueueTask(url, downloadPath, filename);
await _downloadProgressLock.synchronized(() async {
final taskId = await downloadManager.enqueueTask(url, downloadPath, filename!);

// Update the episode with download data
episode.filepath = episodePath;
episode.filename = filename;
episode.downloadTaskId = taskId;
episode.downloadState = DownloadState.downloading;
episode.downloadPercentage = 0;
// Update the episode with download data
episode.filepath = episodePath;
episode.filename = filename;
episode.downloadTaskId = taskId;
episode.downloadState = DownloadState.downloading;
episode.downloadPercentage = 0;

await repository.saveEpisode(episode);
await repository.saveEpisode(episode);
});

return true;
}
Expand All @@ -143,6 +165,41 @@ class MobileDownloadService extends DownloadService {
}
}

@override
Future<void> deleteDownload(Episode episode) async => _downloadProgressLock.synchronized(() async {
// If this episode is currently downloading, cancel the download first.
if (episode.downloadState == DownloadState.downloaded) {
if (settingsService.markDeletedEpisodesAsPlayed) {
episode.played = true;
}
} else if (episode.downloadState == DownloadState.downloading && episode.downloadPercentage! < 100) {
await FlutterDownloader.cancel(taskId: episode.downloadTaskId!);
}

episode.downloadTaskId = null;
episode.downloadPercentage = 0;
episode.position = 0;
episode.downloadState = DownloadState.none;

if (episode.transcriptId != null && episode.transcriptId! > 0) {
await repository.deleteTranscriptById(episode.transcriptId!);
}

await repository.saveEpisode(episode);

if (await hasStoragePermission()) {
final f = File.fromUri(Uri.file(await resolvePath(episode)));

log.fine('Deleting file ${f.path}');

if (await f.exists()) {
f.delete();
}
}

return;
});

@override
Future<Episode?> findEpisodeByTaskId(String taskId) {
return repository.findEpisodeByTaskId(taskId);
Expand Down
37 changes: 0 additions & 37 deletions lib/services/podcast/mobile_podcast_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import 'dart:io';
import 'package:anytime/api/podcast/podcast_api.dart';
import 'package:anytime/core/utils.dart';
import 'package:anytime/entities/chapter.dart';
import 'package:anytime/entities/downloadable.dart';
import 'package:anytime/entities/episode.dart';
import 'package:anytime/entities/funding.dart';
import 'package:anytime/entities/person.dart';
Expand All @@ -20,7 +19,6 @@ import 'package:anytime/state/episode_state.dart';
import 'package:collection/collection.dart' show IterableExtension;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_downloader/flutter_downloader.dart';
import 'package:intl/intl.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart';
Expand Down Expand Up @@ -507,41 +505,6 @@ class MobilePodcastService extends PodcastService {
return repository.findAllEpisodes();
}

@override
Future<void> deleteDownload(Episode episode) async {
// If this episode is currently downloading, cancel the download first.
if (episode.downloadState == DownloadState.downloaded) {
if (settingsService.markDeletedEpisodesAsPlayed) {
episode.played = true;
}
} else if (episode.downloadState == DownloadState.downloading && episode.downloadPercentage! < 100) {
await FlutterDownloader.cancel(taskId: episode.downloadTaskId!);
}

episode.downloadTaskId = null;
episode.downloadPercentage = 0;
episode.position = 0;
episode.downloadState = DownloadState.none;

if (episode.transcriptId != null && episode.transcriptId! > 0) {
await repository.deleteTranscriptById(episode.transcriptId!);
}

await repository.saveEpisode(episode);

if (await hasStoragePermission()) {
final f = File.fromUri(Uri.file(await resolvePath(episode)));

log.fine('Deleting file ${f.path}');

if (await f.exists()) {
f.delete();
}
}

return;
}

@override
Future<void> toggleEpisodePlayed(Episode episode) async {
episode.played = !episode.played;
Expand Down
2 changes: 0 additions & 2 deletions lib/services/podcast/podcast_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -202,8 +202,6 @@ abstract class PodcastService {

Future<Transcript> loadTranscriptByUrl({required TranscriptUrl transcriptUrl});

Future<void> deleteDownload(Episode episode);

Future<void> toggleEpisodePlayed(Episode episode);

Future<List<Podcast>> subscriptions();
Expand Down
37 changes: 18 additions & 19 deletions lib/ui/anytime_podcast_app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ class AnytimePodcastApp extends StatefulWidget {
late DownloadService downloadService;
late AudioPlayerService audioPlayerService;
late OPMLService opmlService;
PodcastService? podcastService;
late PodcastService podcastService;
SettingsBloc? settingsBloc;
MobileSettingsService mobileSettingsService;
List<int> certificateAuthorityBytes;
Expand All @@ -82,24 +82,24 @@ class AnytimePodcastApp extends StatefulWidget {
settingsService: mobileSettingsService,
);

assert(podcastService != null);

downloadService = MobileDownloadService(
repository: repository,
downloadManager: MobileDownloaderManager(),
podcastService: podcastService!,
settingsService: mobileSettingsService,
podcastService: podcastService,
);

audioPlayerService = DefaultAudioPlayerService(
repository: repository,
settingsService: mobileSettingsService,
podcastService: podcastService!,
podcastService: podcastService,
downloadService: downloadService,
);

settingsBloc = SettingsBloc(mobileSettingsService);

opmlService = MobileOPMLService(
podcastService: podcastService!,
podcastService: podcastService,
repository: repository,
);

Expand Down Expand Up @@ -142,24 +142,27 @@ class AnytimePodcastAppState extends State<AnytimePodcastApp> {
providers: [
Provider<SearchBloc>(
create: (_) => SearchBloc(
podcastService: widget.podcastService!,
podcastService: widget.podcastService,
),
dispose: (_, value) => value.dispose(),
),
Provider<DiscoveryBloc>(
create: (_) => DiscoveryBloc(
podcastService: widget.podcastService!,
podcastService: widget.podcastService,
),
dispose: (_, value) => value.dispose(),
),
Provider<EpisodeBloc>(
create: (_) =>
EpisodeBloc(podcastService: widget.podcastService!, audioPlayerService: widget.audioPlayerService),
create: (_) => EpisodeBloc(
podcastService: widget.podcastService,
audioPlayerService: widget.audioPlayerService,
downloadService: widget.downloadService,
),
dispose: (_, value) => value.dispose(),
),
Provider<PodcastBloc>(
create: (_) => PodcastBloc(
podcastService: widget.podcastService!,
podcastService: widget.podcastService,
audioPlayerService: widget.audioPlayerService,
downloadService: widget.downloadService,
settingsService: widget.mobileSettingsService),
Expand All @@ -184,7 +187,7 @@ class AnytimePodcastAppState extends State<AnytimePodcastApp> {
Provider<QueueBloc>(
create: (_) => QueueBloc(
audioPlayerService: widget.audioPlayerService,
podcastService: widget.podcastService!,
podcastService: widget.podcastService,
),
dispose: (_, value) => value.dispose(),
)
Expand Down Expand Up @@ -264,8 +267,7 @@ class _AnytimeHomePageState extends State<AnytimeHomePage> with WidgetsBindingOb
/// This method handles the actual link supplied from [uni_links], either
/// at app startup or during running.
void _handleLinkEvent(Uri uri) async {
if ((uri.scheme == 'anytime-subscribe' || uri.scheme == 'https') &&
(uri.query.startsWith('uri=') || uri.query.startsWith('url='))) {
if ((uri.scheme == 'anytime-subscribe' || uri.scheme == 'https') && (uri.query.startsWith('uri=') || uri.query.startsWith('url='))) {
var path = uri.query.substring(4);
var loadPodcastBloc = Provider.of<PodcastBloc>(context, listen: false);
var routeName = NavigationRouteObserver().top!.settings.name;
Expand Down Expand Up @@ -360,9 +362,7 @@ class _AnytimeHomePageState extends State<AnytimeHomePage> with WidgetsBindingOb
context,
defaultTargetPlatform == TargetPlatform.iOS
? MaterialPageRoute<void>(
fullscreenDialog: false,
settings: const RouteSettings(name: 'search'),
builder: (context) => const Search())
fullscreenDialog: false, settings: const RouteSettings(name: 'search'), builder: (context) => const Search())
: SlideRightRoute(
widget: const Search(),
settings: const RouteSettings(name: 'search'),
Expand Down Expand Up @@ -476,8 +476,7 @@ class _AnytimeHomePageState extends State<AnytimeHomePage> with WidgetsBindingOb
selectedItemColor: Theme.of(context).iconTheme.color,
selectedFontSize: 11.0,
unselectedFontSize: 11.0,
unselectedItemColor:
HSLColor.fromColor(Theme.of(context).bottomAppBarTheme.color!).withLightness(0.8).toColor(),
unselectedItemColor: HSLColor.fromColor(Theme.of(context).bottomAppBarTheme.color!).withLightness(0.8).toColor(),
currentIndex: index,
onTap: pager.changePage,
items: <BottomNavigationBarItem>[
Expand Down
2 changes: 1 addition & 1 deletion pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1088,7 +1088,7 @@ packages:
source: hosted
version: "1.2.0"
synchronized:
dependency: transitive
dependency: "direct main"
description:
name: synchronized
sha256: "51b08572b9f091f8c3eb4d9d4be253f196ff0075d5ec9b10a884026d5b55d7bc"
Expand Down
Loading

0 comments on commit 06c88e9

Please sign in to comment.