added cargo files

This commit is contained in:
2026-03-03 10:57:43 -05:00
parent 478a90e01b
commit 169df46bc2
813 changed files with 227273 additions and 9 deletions

View File

@@ -0,0 +1,112 @@
// Copyright 2020 Ben Hills and the project contributors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:pinepods_mobile/entities/episode.dart';
import 'package:pinepods_mobile/entities/sleep.dart';
import 'package:pinepods_mobile/state/queue_event_state.dart';
import 'package:pinepods_mobile/state/transcript_state_event.dart';
import 'package:rxdart/rxdart.dart';
enum AudioState {
none,
buffering,
starting,
playing,
pausing,
stopped,
error,
}
class PositionState {
Duration position;
late Duration length;
late int percentage;
Episode? episode;
final bool buffering;
PositionState({
required this.position,
required this.length,
required this.percentage,
this.episode,
this.buffering = false,
});
PositionState.emptyState()
: position = const Duration(seconds: 0),
length = const Duration(seconds: 0),
percentage = 0,
buffering = false;
}
/// This class defines the audio playback options supported by Pinepods.
///
/// The implementing classes will then handle the specifics for the platform we are running on.
abstract class AudioPlayerService {
/// Play a new episode, optionally resume at last save point.
Future<void> playEpisode({required Episode episode, bool resume = true});
/// Resume playing of current episode
Future<void> play();
/// Stop playing of current episode. Set update to false to stop
/// playback without saving any episode or positional updates.
Future<void> stop();
/// Pause the current episode.
Future<void> pause();
/// Rewind the current episode by pre-set number of seconds.
Future<void> rewind();
/// Fast forward the current episode by pre-set number of seconds.
Future<void> fastForward();
/// Seek to the specified position within the current episode.
Future<void> seek({required int position});
/// Call when the app is resumed to re-establish the audio service.
Future<Episode?> resume();
/// Add an episode to the playback queue
Future<void> addUpNextEpisode(Episode episode);
/// Remove an episode from the playback queue if it exists
Future<bool> removeUpNextEpisode(Episode episode);
/// Remove an episode from the playback queue if it exists
Future<bool> moveUpNextEpisode(Episode episode, int oldIndex, int newIndex);
/// Empty the up next queue
Future<void> clearUpNext();
/// Call when the app is about to be suspended.
Future<void> suspend();
/// Call to set the playback speed.
Future<void> setPlaybackSpeed(double speed);
/// Call to toggle trim silence.
Future<void> trimSilence(bool trim);
/// Call to toggle trim silence.
Future<void> volumeBoost(bool boost);
Future<void> searchTranscript(String search);
Future<void> clearTranscript();
void sleep(Sleep sleep);
Episode? nowPlaying;
/// Event listeners
Stream<AudioState>? playingState;
ValueStream<PositionState>? playPosition;
ValueStream<Episode?>? episodeEvent;
Stream<TranscriptState>? transcriptEvent;
Stream<int>? playbackError;
Stream<QueueListState>? queueState;
Stream<Sleep>? sleepStream;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
import 'package:flutter/material.dart';
// Global authentication notifier for cross-context communication
class AuthNotifier {
static VoidCallback? _globalLoginSuccessCallback;
static void setGlobalLoginSuccessCallback(VoidCallback? callback) {
_globalLoginSuccessCallback = callback;
}
static void notifyLoginSuccess() {
_globalLoginSuccessCallback?.call();
}
static void clearGlobalLoginSuccessCallback() {
_globalLoginSuccessCallback = null;
}
}

View File

@@ -0,0 +1,27 @@
// Copyright 2020 Ben Hills and the project contributors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'package:pinepods_mobile/entities/downloadable.dart';
class DownloadProgress {
final String id;
final int percentage;
final DownloadState status;
DownloadProgress(
this.id,
this.percentage,
this.status,
);
}
abstract class DownloadManager {
Future<String?> enqueueTask(String url, String downloadPath, String fileName);
Stream<DownloadProgress> get downloadProgress;
void dispose();
}

View File

@@ -0,0 +1,13 @@
// Copyright 2020 Ben Hills and the project contributors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:pinepods_mobile/entities/episode.dart';
abstract class DownloadService {
Future<bool> downloadEpisode(Episode episode);
Future<Episode?> findEpisodeByTaskId(String taskId);
void dispose();
}

View File

@@ -0,0 +1,119 @@
// Copyright 2020 Ben Hills and the project contributors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'dart:isolate';
import 'dart:ui';
import 'package:pinepods_mobile/core/environment.dart';
import 'package:pinepods_mobile/entities/downloadable.dart';
import 'package:pinepods_mobile/services/download/download_manager.dart';
import 'package:flutter_downloader/flutter_downloader.dart';
import 'package:logging/logging.dart';
/// A [DownloadManager] for handling downloading of podcasts on a mobile device.
@pragma('vm:entry-point')
class MobileDownloaderManager implements DownloadManager {
static const portName = 'downloader_send_port';
final log = Logger('MobileDownloaderManager');
final ReceivePort _port = ReceivePort();
final downloadController = StreamController<DownloadProgress>();
var _lastUpdateTime = 0;
@override
Stream<DownloadProgress> get downloadProgress => downloadController.stream;
MobileDownloaderManager() {
_init();
}
Future _init() async {
log.fine('Initialising download manager');
await FlutterDownloader.initialize();
IsolateNameServer.removePortNameMapping(portName);
IsolateNameServer.registerPortWithName(_port.sendPort, portName);
var tasks = await FlutterDownloader.loadTasks();
// Update the status of any tasks that may have been updated whilst
// Pinepods was close or in the background.
if (tasks != null && tasks.isNotEmpty) {
for (var t in tasks) {
_updateDownloadState(id: t.taskId, progress: t.progress, status: t.status);
/// If we are not queued or running we can safely clean up this event
if (t.status != DownloadTaskStatus.enqueued && t.status != DownloadTaskStatus.running) {
FlutterDownloader.remove(taskId: t.taskId, shouldDeleteContent: false);
}
}
}
_port.listen((dynamic data) {
final id = (data as List<dynamic>)[0] as String;
final status = DownloadTaskStatus.fromInt(data[1] as int);
final progress = data[2] as int;
_updateDownloadState(id: id, progress: progress, status: status);
});
FlutterDownloader.registerCallback(downloadCallback);
}
@override
Future<String?> enqueueTask(String url, String downloadPath, String fileName) async {
return await FlutterDownloader.enqueue(
url: url,
savedDir: downloadPath,
fileName: fileName,
showNotification: true,
openFileFromNotification: false,
headers: {
'User-Agent': Environment.userAgent(),
},
);
}
@override
void dispose() {
IsolateNameServer.removePortNameMapping(portName);
downloadController.close();
}
void _updateDownloadState({required String id, required int progress, required DownloadTaskStatus status}) {
var state = DownloadState.none;
var updateTime = DateTime.now().millisecondsSinceEpoch;
if (status == DownloadTaskStatus.enqueued) {
state = DownloadState.queued;
} else if (status == DownloadTaskStatus.canceled) {
state = DownloadState.cancelled;
} else if (status == DownloadTaskStatus.complete) {
state = DownloadState.downloaded;
} else if (status == DownloadTaskStatus.running) {
state = DownloadState.downloading;
} else if (status == DownloadTaskStatus.failed) {
state = DownloadState.failed;
} else if (status == DownloadTaskStatus.paused) {
state = DownloadState.paused;
}
/// If we are running, we want to limit notifications to 1 per second. Otherwise,
/// small downloads can cause a flood of events. Any other status we always want
/// to push through.
if (status != DownloadTaskStatus.running ||
progress == 0 ||
progress == 100 ||
updateTime > _lastUpdateTime + 1000) {
downloadController.add(DownloadProgress(id, progress, state));
_lastUpdateTime = updateTime;
}
}
@pragma('vm:entry-point')
static void downloadCallback(String id, int status, int progress) {
IsolateNameServer.lookupPortByName('downloader_send_port')?.send([id, status, progress]);
}
}

View File

@@ -0,0 +1,180 @@
// Copyright 2020 Ben Hills and the project contributors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'dart:io';
import 'package:pinepods_mobile/core/utils.dart';
import 'package:pinepods_mobile/entities/downloadable.dart';
import 'package:pinepods_mobile/entities/episode.dart';
import 'package:pinepods_mobile/entities/transcript.dart';
import 'package:pinepods_mobile/repository/repository.dart';
import 'package:pinepods_mobile/services/download/download_manager.dart';
import 'package:pinepods_mobile/services/download/download_service.dart';
import 'package:pinepods_mobile/services/podcast/podcast_service.dart';
import 'package:collection/collection.dart' show IterableExtension;
import 'package:logging/logging.dart';
import 'package:mp3_info/mp3_info.dart';
import 'package:rxdart/rxdart.dart';
/// An implementation of a [DownloadService] that handles downloading
/// of episodes on mobile.
class MobileDownloadService extends DownloadService {
static BehaviorSubject<DownloadProgress> downloadProgress = BehaviorSubject<DownloadProgress>();
final log = Logger('MobileDownloadService');
final Repository repository;
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);
});
}
@override
void dispose() {
downloadManager.dispose();
}
@override
Future<bool> downloadEpisode(Episode episode) async {
try {
final season = episode.season > 0 ? episode.season.toString() : '';
final epno = episode.episode > 0 ? episode.episode.toString() : '';
var dirty = false;
if (await hasStoragePermission()) {
// If this episode contains chapter, fetch them first.
if (episode.hasChapters && episode.chaptersUrl != null) {
var chapters = await podcastService.loadChaptersByUrl(url: episode.chaptersUrl!);
episode.chapters = chapters;
dirty = true;
}
// Next, if the episode supports transcripts download that next
if (episode.hasTranscripts) {
var sub = episode.transcriptUrls.firstWhereOrNull((element) => element.type == TranscriptFormat.json);
sub ??= episode.transcriptUrls.firstWhereOrNull((element) => element.type == TranscriptFormat.subrip);
sub ??= episode.transcriptUrls.firstWhereOrNull((element) => element.type == TranscriptFormat.html);
if (sub != null) {
var transcript = await podcastService.loadTranscriptByUrl(transcriptUrl: sub);
transcript = await podcastService.saveTranscript(transcript);
episode.transcript = transcript;
episode.transcriptId = transcript.id;
dirty = true;
}
}
if (dirty) {
await podcastService.saveEpisode(episode);
}
final episodePath = await resolveDirectory(episode: episode);
final downloadPath = await resolveDirectory(episode: episode, full: true);
var uri = Uri.parse(episode.contentUrl!);
// Ensure the download directory exists
await createDownloadDirectory(episode);
// Filename should be last segment of URI.
var filename = safeFile(uri.pathSegments.lastWhereOrNull((e) => e.toLowerCase().endsWith('.mp3')));
filename ??= safeFile(uri.pathSegments.lastWhereOrNull((e) => e.toLowerCase().endsWith('.m4a')));
if (filename == null) {
//TODO: Handle unsupported format.
} else {
// The last segment could also be a full URL. Take a second pass.
if (filename.contains('/')) {
try {
uri = Uri.parse(filename);
filename = uri.pathSegments.last;
} on FormatException {
// It wasn't a URL...
}
}
// Some podcasts use the same file name for each episode. If we have a
// season and/or episode number provided by iTunes we can use that. We
// will also append the filename with the publication date if available.
var pubDate = '';
if (episode.publicationDate != null) {
pubDate = '${episode.publicationDate!.millisecondsSinceEpoch ~/ 1000}-';
}
filename = '$season$epno$pubDate$filename';
log.fine('Download episode (${episode.title}) $filename to $downloadPath/$filename');
/// If we get a redirect to an http endpoint the download will fail. Let's fully resolve
/// 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);
// 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);
return true;
}
}
return false;
} catch (e, stack) {
log.warning('Episode download failed (${episode.title})', e, stack);
return false;
}
}
@override
Future<Episode?> findEpisodeByTaskId(String taskId) {
return repository.findEpisodeByTaskId(taskId);
}
Future<void> _updateDownloadProgress(DownloadProgress progress) async {
var episode = await repository.findEpisodeByTaskId(progress.id);
if (episode != null) {
// We might be called during the cleanup routine during startup.
// Do not bother updating if nothing has changed.
if (episode.downloadPercentage != progress.percentage || episode.downloadState != progress.status) {
episode.downloadPercentage = progress.percentage;
episode.downloadState = progress.status;
if (progress.percentage == 100) {
if (await hasStoragePermission()) {
final filename = await resolvePath(episode);
// If we do not have a duration for this file - let's calculate it
if (episode.duration == 0) {
var mp3Info = MP3Processor.fromFile(File(filename));
episode.duration = mp3Info.duration.inSeconds;
}
}
}
await repository.saveEpisode(episode);
}
}
}
}

View File

@@ -0,0 +1,202 @@
// lib/services/error_handling_service.dart
import 'dart:io';
import 'package:http/http.dart' as http;
/// Service for handling and categorizing errors, especially server connection issues
class ErrorHandlingService {
/// Checks if an error indicates a server connection issue
static bool isServerConnectionError(dynamic error) {
if (error == null) return false;
final errorString = error.toString().toLowerCase();
// Network-related errors
if (error is SocketException) return true;
if (error is HttpException) return true;
if (error is http.ClientException) return true;
// Check for common connection error patterns
final connectionErrorPatterns = [
'connection refused',
'connection timeout',
'connection failed',
'network is unreachable',
'no route to host',
'connection reset',
'connection aborted',
'host is unreachable',
'server unavailable',
'service unavailable',
'bad gateway',
'gateway timeout',
'connection timed out',
'failed host lookup',
'no address associated with hostname',
'network unreachable',
'operation timed out',
'handshake failure',
'certificate verify failed',
'ssl handshake failed',
'unable to connect',
'server closed the connection',
'connection closed',
'broken pipe',
'no internet connection',
'offline',
'dns lookup failed',
'name resolution failed',
];
return connectionErrorPatterns.any((pattern) => errorString.contains(pattern));
}
/// Checks if an error indicates authentication/authorization issues
static bool isAuthenticationError(dynamic error) {
if (error == null) return false;
final errorString = error.toString().toLowerCase();
final authErrorPatterns = [
'unauthorized',
'authentication failed',
'invalid credentials',
'access denied',
'forbidden',
'token expired',
'invalid token',
'login required',
'401',
'403',
];
return authErrorPatterns.any((pattern) => errorString.contains(pattern));
}
/// Checks if an error indicates server-side issues (5xx errors)
static bool isServerError(dynamic error) {
if (error == null) return false;
final errorString = error.toString().toLowerCase();
final serverErrorPatterns = [
'internal server error',
'server error',
'service unavailable',
'bad gateway',
'gateway timeout',
'500',
'502',
'503',
'504',
'505',
];
return serverErrorPatterns.any((pattern) => errorString.contains(pattern));
}
/// Gets a user-friendly error message based on the error type
static String getUserFriendlyErrorMessage(dynamic error) {
if (error == null) return 'An unknown error occurred';
if (isServerConnectionError(error)) {
return 'Unable to connect to the PinePods server. Please check your internet connection and server settings.';
}
if (isAuthenticationError(error)) {
return 'Authentication failed. Please check your login credentials.';
}
if (isServerError(error)) {
return 'The PinePods server is experiencing issues. Please try again later.';
}
// Return the original error message for other types of errors
return error.toString();
}
/// Gets an appropriate title for the error
static String getErrorTitle(dynamic error) {
if (error == null) return 'Error';
if (isServerConnectionError(error)) {
return 'Server Unavailable';
}
if (isAuthenticationError(error)) {
return 'Authentication Error';
}
if (isServerError(error)) {
return 'Server Error';
}
return 'Error';
}
/// Gets troubleshooting suggestions based on the error type
static List<String> getTroubleshootingSteps(dynamic error) {
if (error == null) return ['Please try again later'];
if (isServerConnectionError(error)) {
return [
'Check your internet connection',
'Verify server URL in settings',
'Ensure the PinePods server is running',
'Check if the server port is accessible',
'Contact your administrator if the issue persists',
];
}
if (isAuthenticationError(error)) {
return [
'Check your username and password',
'Ensure your account is still active',
'Try logging out and logging back in',
'Contact your administrator for help',
];
}
if (isServerError(error)) {
return [
'Wait a few minutes and try again',
'Check if the server is overloaded',
'Contact your administrator',
'Check server logs for more details',
];
}
return [
'Try refreshing the page',
'Restart the app if the issue persists',
'Contact support for assistance',
];
}
/// Wraps an async function call with error handling
static Future<T> handleApiCall<T>(
Future<T> Function() apiCall, {
String? context,
}) async {
try {
return await apiCall();
} catch (error) {
// Log the error with context if provided
if (context != null) {
print('API Error in $context: $error');
}
// Re-throw the error to be handled by the UI layer
rethrow;
}
}
}
/// Extension to make error checking easier
extension ErrorTypeExtension on dynamic {
bool get isServerConnectionError => ErrorHandlingService.isServerConnectionError(this);
bool get isAuthenticationError => ErrorHandlingService.isAuthenticationError(this);
bool get isServerError => ErrorHandlingService.isServerError(this);
String get userFriendlyMessage => ErrorHandlingService.getUserFriendlyErrorMessage(this);
String get errorTitle => ErrorHandlingService.getErrorTitle(this);
List<String> get troubleshootingSteps => ErrorHandlingService.getTroubleshootingSteps(this);
}

View File

@@ -0,0 +1,35 @@
// lib/services/global_services.dart
import 'package:pinepods_mobile/services/pinepods/pinepods_audio_service.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
/// Global service access point for the app
class GlobalServices {
static PinepodsAudioService? _pinepodsAudioService;
static PinepodsService? _pinepodsService;
/// Set the global services (called from PinepodsPodcastApp)
static void initialize({
required PinepodsAudioService pinepodsAudioService,
required PinepodsService pinepodsService,
}) {
_pinepodsAudioService = pinepodsAudioService;
_pinepodsService = pinepodsService;
}
/// Update global service credentials (called when user logs in or settings change)
static void setCredentials(String server, String apiKey) {
_pinepodsService?.setCredentials(server, apiKey);
}
/// Get the global PinepodsAudioService instance
static PinepodsAudioService? get pinepodsAudioService => _pinepodsAudioService;
/// Get the global PinepodsService instance
static PinepodsService? get pinepodsService => _pinepodsService;
/// Clear services (for testing or cleanup)
static void clear() {
_pinepodsAudioService = null;
_pinepodsService = null;
}
}

View File

@@ -0,0 +1,488 @@
// lib/services/logging/app_logger.dart
import 'dart:io';
import 'dart:collection';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as path_helper;
import 'package:intl/intl.dart';
enum LogLevel {
debug,
info,
warning,
error,
critical,
}
class LogEntry {
final DateTime timestamp;
final LogLevel level;
final String tag;
final String message;
final String? stackTrace;
LogEntry({
required this.timestamp,
required this.level,
required this.tag,
required this.message,
this.stackTrace,
});
String get levelString {
switch (level) {
case LogLevel.debug:
return 'DEBUG';
case LogLevel.info:
return 'INFO';
case LogLevel.warning:
return 'WARN';
case LogLevel.error:
return 'ERROR';
case LogLevel.critical:
return 'CRITICAL';
}
}
String get formattedMessage {
final timeStr = timestamp.toString().substring(0, 19); // Remove milliseconds for readability
var result = '[$timeStr] [$levelString] [$tag] $message';
if (stackTrace != null && stackTrace!.isNotEmpty) {
result += '\nStackTrace: $stackTrace';
}
return result;
}
}
class DeviceInfo {
final String platform;
final String osVersion;
final String model;
final String manufacturer;
final String appVersion;
final String buildNumber;
DeviceInfo({
required this.platform,
required this.osVersion,
required this.model,
required this.manufacturer,
required this.appVersion,
required this.buildNumber,
});
String get formattedInfo {
return '''
Device Information:
- Platform: $platform
- OS Version: $osVersion
- Model: $model
- Manufacturer: $manufacturer
- App Version: $appVersion
- Build Number: $buildNumber
''';
}
}
class AppLogger {
static final AppLogger _instance = AppLogger._internal();
factory AppLogger() => _instance;
AppLogger._internal();
static const int maxLogEntries = 1000; // Keep last 1000 log entries in memory
static const int maxSessionFiles = 5; // Keep last 5 session log files
static const String crashLogFileName = 'pinepods_last_crash.txt';
final Queue<LogEntry> _logs = Queue<LogEntry>();
DeviceInfo? _deviceInfo;
File? _currentSessionFile;
File? _crashLogFile;
Directory? _logsDirectory;
String? _sessionId;
bool _isInitialized = false;
// Initialize the logger and collect device info
Future<void> initialize() async {
if (_isInitialized) return;
await _collectDeviceInfo();
await _initializeLogFiles();
await _setupCrashHandler();
await _loadPreviousCrash();
_isInitialized = true;
// Log initialization
log(LogLevel.info, 'AppLogger', 'Logger initialized successfully');
}
Future<void> _collectDeviceInfo() async {
try {
final deviceInfoPlugin = DeviceInfoPlugin();
final packageInfo = await PackageInfo.fromPlatform();
if (Platform.isAndroid) {
final androidInfo = await deviceInfoPlugin.androidInfo;
_deviceInfo = DeviceInfo(
platform: 'Android',
osVersion: 'Android ${androidInfo.version.release} (API ${androidInfo.version.sdkInt})',
model: '${androidInfo.manufacturer} ${androidInfo.model}',
manufacturer: androidInfo.manufacturer,
appVersion: packageInfo.version,
buildNumber: packageInfo.buildNumber,
);
} else if (Platform.isIOS) {
final iosInfo = await deviceInfoPlugin.iosInfo;
_deviceInfo = DeviceInfo(
platform: 'iOS',
osVersion: '${iosInfo.systemName} ${iosInfo.systemVersion}',
model: iosInfo.model,
manufacturer: 'Apple',
appVersion: packageInfo.version,
buildNumber: packageInfo.buildNumber,
);
} else {
_deviceInfo = DeviceInfo(
platform: Platform.operatingSystem,
osVersion: Platform.operatingSystemVersion,
model: 'Unknown',
manufacturer: 'Unknown',
appVersion: packageInfo.version,
buildNumber: packageInfo.buildNumber,
);
}
} catch (e) {
// If device info collection fails, create a basic info object
try {
final packageInfo = await PackageInfo.fromPlatform();
_deviceInfo = DeviceInfo(
platform: Platform.operatingSystem,
osVersion: Platform.operatingSystemVersion,
model: 'Unknown',
manufacturer: 'Unknown',
appVersion: packageInfo.version,
buildNumber: packageInfo.buildNumber,
);
} catch (e2) {
_deviceInfo = DeviceInfo(
platform: 'Unknown',
osVersion: 'Unknown',
model: 'Unknown',
manufacturer: 'Unknown',
appVersion: 'Unknown',
buildNumber: 'Unknown',
);
}
}
}
void log(LogLevel level, String tag, String message, [String? stackTrace]) {
final entry = LogEntry(
timestamp: DateTime.now(),
level: level,
tag: tag,
message: message,
stackTrace: stackTrace,
);
_logs.add(entry);
// Keep only the last maxLogEntries in memory
while (_logs.length > maxLogEntries) {
_logs.removeFirst();
}
// Write to current session file asynchronously (don't await to avoid blocking)
_writeToSessionFile(entry);
// Also print to console in debug mode
if (kDebugMode) {
print(entry.formattedMessage);
}
}
// Convenience methods for different log levels
void debug(String tag, String message) => log(LogLevel.debug, tag, message);
void info(String tag, String message) => log(LogLevel.info, tag, message);
void warning(String tag, String message) => log(LogLevel.warning, tag, message);
void error(String tag, String message, [String? stackTrace]) => log(LogLevel.error, tag, message, stackTrace);
void critical(String tag, String message, [String? stackTrace]) => log(LogLevel.critical, tag, message, stackTrace);
// Log an exception with automatic stack trace
void logException(String tag, String message, dynamic exception, [StackTrace? stackTrace]) {
final stackTraceStr = stackTrace?.toString() ?? exception.toString();
error(tag, '$message: $exception', stackTraceStr);
}
// Get all logs
List<LogEntry> get logs => _logs.toList();
// Get logs filtered by level
List<LogEntry> getLogsByLevel(LogLevel level) {
return _logs.where((log) => log.level == level).toList();
}
// Get logs from a specific time period
List<LogEntry> getLogsInTimeRange(DateTime start, DateTime end) {
return _logs.where((log) =>
log.timestamp.isAfter(start) && log.timestamp.isBefore(end)
).toList();
}
// Get formatted log string for copying
String getFormattedLogs() {
final buffer = StringBuffer();
// Add device info
if (_deviceInfo != null) {
buffer.writeln(_deviceInfo!.formattedInfo);
}
// Add separator
buffer.writeln('=' * 50);
buffer.writeln('Application Logs:');
buffer.writeln('=' * 50);
// Add all logs
for (final log in _logs) {
buffer.writeln(log.formattedMessage);
}
// Add footer
buffer.writeln();
buffer.writeln('=' * 50);
buffer.writeln('End of logs - Total entries: ${_logs.length}');
buffer.writeln('Bug reports: https://github.com/madeofpendletonwool/pinepods/issues');
return buffer.toString();
}
// Clear all logs
void clearLogs() {
_logs.clear();
log(LogLevel.info, 'AppLogger', 'Logs cleared by user');
}
// Initialize log files and directory structure
Future<void> _initializeLogFiles() async {
try {
final appDocDir = await getApplicationDocumentsDirectory();
_logsDirectory = Directory(path_helper.join(appDocDir.path, 'logs'));
// Create logs directory if it doesn't exist
if (!await _logsDirectory!.exists()) {
await _logsDirectory!.create(recursive: true);
}
// Clean up old session files (keep only last 5)
await _cleanupOldSessionFiles();
// Create new session file
_sessionId = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now());
_currentSessionFile = File(path_helper.join(_logsDirectory!.path, 'session_$_sessionId.log'));
await _currentSessionFile!.create();
// Initialize crash log file
_crashLogFile = File(path_helper.join(_logsDirectory!.path, crashLogFileName));
if (!await _crashLogFile!.exists()) {
await _crashLogFile!.create();
}
log(LogLevel.info, 'AppLogger', 'Session log files initialized at ${_logsDirectory!.path}');
} catch (e) {
if (kDebugMode) {
print('Failed to initialize log files: $e');
}
}
}
// Clean up old session files, keeping only the most recent ones
Future<void> _cleanupOldSessionFiles() async {
try {
final files = await _logsDirectory!.list().toList();
final sessionFiles = files
.whereType<File>()
.where((f) => path_helper.basename(f.path).startsWith('session_'))
.toList();
// Sort by last modified date (newest first)
sessionFiles.sort((a, b) => b.lastModifiedSync().compareTo(a.lastModifiedSync()));
// Delete files beyond the limit
if (sessionFiles.length > maxSessionFiles) {
for (int i = maxSessionFiles; i < sessionFiles.length; i++) {
await sessionFiles[i].delete();
}
}
} catch (e) {
if (kDebugMode) {
print('Failed to cleanup old session files: $e');
}
}
}
// Write log entry to current session file
Future<void> _writeToSessionFile(LogEntry entry) async {
if (_currentSessionFile == null) return;
try {
await _currentSessionFile!.writeAsString(
'${entry.formattedMessage}\n',
mode: FileMode.append,
);
} catch (e) {
// Silently fail to avoid logging loops
if (kDebugMode) {
print('Failed to write log to session file: $e');
}
}
}
// Setup crash handler
Future<void> _setupCrashHandler() async {
FlutterError.onError = (FlutterErrorDetails details) {
_logCrash('Flutter Error', details.exception.toString(), details.stack);
// Still call the default error handler
FlutterError.presentError(details);
};
PlatformDispatcher.instance.onError = (error, stack) {
_logCrash('Platform Error', error.toString(), stack);
return true; // Mark as handled
};
}
// Log crash to persistent storage
Future<void> _logCrash(String type, String error, StackTrace? stackTrace) async {
try {
final crashInfo = {
'timestamp': DateTime.now().toIso8601String(),
'sessionId': _sessionId,
'type': type,
'error': error,
'stackTrace': stackTrace?.toString(),
'deviceInfo': _deviceInfo?.formattedInfo,
'recentLogs': _logs.length > 20 ? _logs.skip(_logs.length - 20).map((e) => e.formattedMessage).toList() : _logs.map((e) => e.formattedMessage).toList(), // Only last 20 entries
};
if (_crashLogFile != null) {
await _crashLogFile!.writeAsString(jsonEncode(crashInfo));
}
// Also log through normal logging
critical('CrashHandler', '$type: $error', stackTrace?.toString());
} catch (e) {
if (kDebugMode) {
print('Failed to log crash: $e');
}
}
}
// Load and log previous crash if exists
Future<void> _loadPreviousCrash() async {
if (_crashLogFile == null || !await _crashLogFile!.exists()) return;
try {
final crashData = await _crashLogFile!.readAsString();
if (crashData.isNotEmpty) {
final crash = jsonDecode(crashData);
warning('PreviousCrash', 'Previous crash detected: ${crash['type']} at ${crash['timestamp']}');
warning('PreviousCrash', 'Session: ${crash['sessionId'] ?? 'unknown'}');
warning('PreviousCrash', 'Error: ${crash['error']}');
if (crash['stackTrace'] != null) {
warning('PreviousCrash', 'Stack trace available in crash log file');
}
}
} catch (e) {
warning('AppLogger', 'Failed to load previous crash info: $e');
}
}
// Get list of available session files
Future<List<File>> getSessionFiles() async {
if (_logsDirectory == null) return [];
try {
final files = await _logsDirectory!.list().toList();
final sessionFiles = files
.whereType<File>()
.where((f) => path_helper.basename(f.path).startsWith('session_'))
.toList();
// Sort by last modified date (newest first)
sessionFiles.sort((a, b) => b.lastModifiedSync().compareTo(a.lastModifiedSync()));
return sessionFiles;
} catch (e) {
return [];
}
}
// Get current session file path
String? get currentSessionPath => _currentSessionFile?.path;
// Get crash log file path
String? get crashLogPath => _crashLogFile?.path;
// Get logs directory path
String? get logsDirectoryPath => _logsDirectory?.path;
// Check if previous crash exists
Future<bool> hasPreviousCrash() async {
if (_crashLogFile == null) return false;
try {
final exists = await _crashLogFile!.exists();
if (!exists) return false;
final content = await _crashLogFile!.readAsString();
return content.isNotEmpty;
} catch (e) {
return false;
}
}
// Clear crash log
Future<void> clearCrashLog() async {
if (_crashLogFile != null && await _crashLogFile!.exists()) {
await _crashLogFile!.writeAsString('');
}
}
// Get formatted logs with session info
String getFormattedLogsWithSessionInfo() {
final buffer = StringBuffer();
// Add session info
buffer.writeln('Session ID: $_sessionId');
buffer.writeln('Session started: ${DateTime.now().toString()}');
// Add device info
if (_deviceInfo != null) {
buffer.writeln(_deviceInfo!.formattedInfo);
}
// Add separator
buffer.writeln('=' * 50);
buffer.writeln('Application Logs (Current Session):');
buffer.writeln('=' * 50);
// Add all logs
for (final log in _logs) {
buffer.writeln(log.formattedMessage);
}
// Add footer
buffer.writeln();
buffer.writeln('=' * 50);
buffer.writeln('End of logs - Total entries: ${_logs.length}');
buffer.writeln('Session file: ${_currentSessionFile?.path}');
buffer.writeln('Bug reports: https://github.com/madeofpendletonwool/pinepods/issues');
return buffer.toString();
}
// Get device info
DeviceInfo? get deviceInfo => _deviceInfo;
}

View File

@@ -0,0 +1,532 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
class PinepodsLoginService {
static const String userAgent = 'PinePods Mobile/1.0';
/// Verify if the server is a valid PinePods instance
static Future<bool> verifyPinepodsInstance(String serverUrl) async {
try {
final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), '');
final url = Uri.parse('$normalizedUrl/api/pinepods_check');
final response = await http.get(
url,
headers: {'User-Agent': userAgent},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
return data['pinepods_instance'] == true;
}
return false;
} catch (e) {
return false;
}
}
/// Initial login - returns either API key or MFA session info
static Future<InitialLoginResponse> initialLogin(String serverUrl, String username, String password) async {
try {
final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), '');
final credentials = base64Encode(utf8.encode('$username:$password'));
final authHeader = 'Basic $credentials';
final url = Uri.parse('$normalizedUrl/api/data/get_key');
final response = await http.get(
url,
headers: {
'Authorization': authHeader,
'User-Agent': userAgent,
},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
// Check if MFA is required
if (data['status'] == 'mfa_required' && data['mfa_required'] == true) {
return InitialLoginResponse.mfaRequired(
serverUrl: normalizedUrl,
username: username,
userId: data['user_id'],
mfaSessionToken: data['mfa_session_token'],
);
}
// Normal flow - no MFA required
final apiKey = data['retrieved_key'];
if (apiKey != null) {
return InitialLoginResponse.success(apiKey: apiKey);
}
}
return InitialLoginResponse.failure('Authentication failed');
} catch (e) {
return InitialLoginResponse.failure('Error: ${e.toString()}');
}
}
/// Legacy method for backwards compatibility
@deprecated
static Future<String?> getApiKey(String serverUrl, String username, String password) async {
final result = await initialLogin(serverUrl, username, password);
return result.isSuccess ? result.apiKey : null;
}
/// Verify API key is valid
static Future<bool> verifyApiKey(String serverUrl, String apiKey) async {
try {
final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), '');
final url = Uri.parse('$normalizedUrl/api/data/verify_key');
final response = await http.get(
url,
headers: {
'Api-Key': apiKey,
'User-Agent': userAgent,
},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
return data['status'] == 'success';
}
return false;
} catch (e) {
return false;
}
}
/// Get user ID
static Future<int?> getUserId(String serverUrl, String apiKey) async {
try {
final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), '');
final url = Uri.parse('$normalizedUrl/api/data/get_user');
final response = await http.get(
url,
headers: {
'Api-Key': apiKey,
'User-Agent': userAgent,
},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
if (data['status'] == 'success' && data['retrieved_id'] != null) {
return data['retrieved_id'] as int;
}
}
return null;
} catch (e) {
return null;
}
}
/// Get user details
static Future<UserDetails?> getUserDetails(String serverUrl, String apiKey, int userId) async {
try {
final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), '');
final url = Uri.parse('$normalizedUrl/api/data/user_details_id/$userId');
final response = await http.get(
url,
headers: {
'Api-Key': apiKey,
'User-Agent': userAgent,
},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
return UserDetails.fromJson(data);
}
return null;
} catch (e) {
return null;
}
}
/// Get API configuration
static Future<ApiConfig?> getApiConfig(String serverUrl, String apiKey) async {
try {
final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), '');
final url = Uri.parse('$normalizedUrl/api/data/config');
final response = await http.get(
url,
headers: {
'Api-Key': apiKey,
'User-Agent': userAgent,
},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
return ApiConfig.fromJson(data);
}
return null;
} catch (e) {
return null;
}
}
/// Check if MFA is enabled for user
static Future<bool> checkMfaEnabled(String serverUrl, String apiKey, int userId) async {
try {
final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), '');
final url = Uri.parse('$normalizedUrl/api/data/check_mfa_enabled/$userId');
final response = await http.get(
url,
headers: {
'Api-Key': apiKey,
'User-Agent': userAgent,
},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
return data['mfa_enabled'] == true;
}
return false;
} catch (e) {
return false;
}
}
/// Verify MFA code and get API key during login (secure flow)
static Future<String?> verifyMfaAndGetKey(String serverUrl, String mfaSessionToken, String mfaCode) async {
try {
final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), '');
final url = Uri.parse('$normalizedUrl/api/data/verify_mfa_and_get_key');
final requestBody = jsonEncode({
'mfa_session_token': mfaSessionToken,
'mfa_code': mfaCode,
});
final response = await http.post(
url,
headers: {
'Content-Type': 'application/json',
'User-Agent': userAgent,
},
body: requestBody,
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
if (data['verified'] == true && data['status'] == 'success') {
return data['retrieved_key'];
}
}
return null;
} catch (e) {
return null;
}
}
/// Legacy MFA verification (for post-login MFA checks)
@deprecated
static Future<bool> verifyMfa(String serverUrl, String apiKey, int userId, String mfaCode) async {
try {
final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), '');
final url = Uri.parse('$normalizedUrl/api/data/verify_mfa');
final requestBody = jsonEncode({
'user_id': userId,
'mfa_code': mfaCode,
});
final response = await http.post(
url,
headers: {
'Api-Key': apiKey,
'Content-Type': 'application/json',
'User-Agent': userAgent,
},
body: requestBody,
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
return data['verified'] == true;
}
return false;
} catch (e) {
return false;
}
}
/// Complete login flow (new secure MFA implementation)
static Future<LoginResult> login(String serverUrl, String username, String password) async {
try {
// Step 1: Verify server
final isPinepods = await verifyPinepodsInstance(serverUrl);
if (!isPinepods) {
return LoginResult.failure('Not a valid PinePods server');
}
// Step 2: Initial login - get API key or MFA session
final initialResult = await initialLogin(serverUrl, username, password);
if (!initialResult.isSuccess) {
return LoginResult.failure(initialResult.errorMessage ?? 'Login failed');
}
if (initialResult.requiresMfa) {
// MFA required - return MFA prompt state
return LoginResult.mfaRequired(
serverUrl: initialResult.serverUrl!,
username: username,
userId: initialResult.userId!,
mfaSessionToken: initialResult.mfaSessionToken!,
);
}
// No MFA required - complete login with API key
return await _completeLoginWithApiKey(
serverUrl,
username,
initialResult.apiKey!,
);
} catch (e) {
return LoginResult.failure('Error: ${e.toString()}');
}
}
/// Complete MFA login flow
static Future<LoginResult> completeMfaLogin({
required String serverUrl,
required String username,
required String mfaSessionToken,
required String mfaCode,
}) async {
try {
// Verify MFA and get API key
final apiKey = await verifyMfaAndGetKey(serverUrl, mfaSessionToken, mfaCode);
if (apiKey == null) {
return LoginResult.failure('Invalid MFA code');
}
// Complete login with verified API key
return await _completeLoginWithApiKey(serverUrl, username, apiKey);
} catch (e) {
return LoginResult.failure('Error: ${e.toString()}');
}
}
/// Complete login flow with API key (common logic)
static Future<LoginResult> _completeLoginWithApiKey(String serverUrl, String username, String apiKey) async {
// Step 1: Verify API key
final isValidKey = await verifyApiKey(serverUrl, apiKey);
if (!isValidKey) {
return LoginResult.failure('API key verification failed');
}
// Step 2: Get user ID
final userId = await getUserId(serverUrl, apiKey);
if (userId == null) {
return LoginResult.failure('Failed to get user ID');
}
// Step 3: Get user details
final userDetails = await getUserDetails(serverUrl, apiKey, userId);
if (userDetails == null) {
return LoginResult.failure('Failed to get user details');
}
// Step 4: Get API configuration
final apiConfig = await getApiConfig(serverUrl, apiKey);
if (apiConfig == null) {
return LoginResult.failure('Failed to get server configuration');
}
return LoginResult.success(
serverUrl: serverUrl,
apiKey: apiKey,
userId: userId,
userDetails: userDetails,
apiConfig: apiConfig,
);
}
}
class InitialLoginResponse {
final bool isSuccess;
final bool requiresMfa;
final String? errorMessage;
final String? apiKey;
final String? serverUrl;
final String? username;
final int? userId;
final String? mfaSessionToken;
InitialLoginResponse._({
required this.isSuccess,
required this.requiresMfa,
this.errorMessage,
this.apiKey,
this.serverUrl,
this.username,
this.userId,
this.mfaSessionToken,
});
factory InitialLoginResponse.success({required String apiKey}) {
return InitialLoginResponse._(
isSuccess: true,
requiresMfa: false,
apiKey: apiKey,
);
}
factory InitialLoginResponse.mfaRequired({
required String serverUrl,
required String username,
required int userId,
required String mfaSessionToken,
}) {
return InitialLoginResponse._(
isSuccess: true,
requiresMfa: true,
serverUrl: serverUrl,
username: username,
userId: userId,
mfaSessionToken: mfaSessionToken,
);
}
factory InitialLoginResponse.failure(String errorMessage) {
return InitialLoginResponse._(
isSuccess: false,
requiresMfa: false,
errorMessage: errorMessage,
);
}
}
class LoginResult {
final bool isSuccess;
final bool requiresMfa;
final String? errorMessage;
final String? serverUrl;
final String? apiKey;
final String? username;
final int? userId;
final String? mfaSessionToken;
final UserDetails? userDetails;
final ApiConfig? apiConfig;
LoginResult._({
required this.isSuccess,
required this.requiresMfa,
this.errorMessage,
this.serverUrl,
this.apiKey,
this.username,
this.userId,
this.mfaSessionToken,
this.userDetails,
this.apiConfig,
});
factory LoginResult.success({
required String serverUrl,
required String apiKey,
required int userId,
required UserDetails userDetails,
required ApiConfig apiConfig,
}) {
return LoginResult._(
isSuccess: true,
requiresMfa: false,
serverUrl: serverUrl,
apiKey: apiKey,
userId: userId,
userDetails: userDetails,
apiConfig: apiConfig,
);
}
factory LoginResult.failure(String errorMessage) {
return LoginResult._(
isSuccess: false,
requiresMfa: false,
errorMessage: errorMessage,
);
}
factory LoginResult.mfaRequired({
required String serverUrl,
required String username,
required int userId,
required String mfaSessionToken,
}) {
return LoginResult._(
isSuccess: false,
requiresMfa: true,
serverUrl: serverUrl,
username: username,
userId: userId,
mfaSessionToken: mfaSessionToken,
);
}
}
class UserDetails {
final int userId;
final String? fullname;
final String? username;
final String? email;
UserDetails({
required this.userId,
this.fullname,
this.username,
this.email,
});
factory UserDetails.fromJson(Map<String, dynamic> json) {
return UserDetails(
userId: json['UserID'],
fullname: json['Fullname'],
username: json['Username'],
email: json['Email'],
);
}
}
class ApiConfig {
final String? apiUrl;
final String? proxyUrl;
final String? proxyHost;
final String? proxyPort;
final String? proxyProtocol;
final String? reverseProxy;
final String? peopleUrl;
ApiConfig({
this.apiUrl,
this.proxyUrl,
this.proxyHost,
this.proxyPort,
this.proxyProtocol,
this.reverseProxy,
this.peopleUrl,
});
factory ApiConfig.fromJson(Map<String, dynamic> json) {
return ApiConfig(
apiUrl: json['api_url'],
proxyUrl: json['proxy_url'],
proxyHost: json['proxy_host'],
proxyPort: json['proxy_port'],
proxyProtocol: json['proxy_protocol'],
reverseProxy: json['reverse_proxy'],
peopleUrl: json['people_url'],
);
}
}

View File

@@ -0,0 +1,405 @@
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:crypto/crypto.dart';
import 'package:http/http.dart' as http;
import 'package:url_launcher/url_launcher.dart';
class OidcService {
static const String userAgent = 'PinePods Mobile/1.0';
static const String callbackUrlScheme = 'pinepods';
static const String callbackPath = '/auth/callback';
/// Get available OIDC providers from server
static Future<List<OidcProvider>> getPublicProviders(String serverUrl) async {
try {
final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), '');
final url = Uri.parse('$normalizedUrl/api/data/public_oidc_providers');
final response = await http.get(
url,
headers: {'User-Agent': userAgent},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
final providers = (data['providers'] as List)
.map((provider) => OidcProvider.fromJson(provider))
.toList();
return providers;
}
return [];
} catch (e) {
return [];
}
}
/// Generate PKCE code verifier and challenge for secure OIDC flow
static OidcPkce generatePkce() {
final codeVerifier = _generateCodeVerifier();
final codeChallenge = _generateCodeChallenge(codeVerifier);
return OidcPkce(
codeVerifier: codeVerifier,
codeChallenge: codeChallenge,
);
}
/// Generate random state parameter
static String generateState() {
final random = Random.secure();
final bytes = List<int>.generate(32, (i) => random.nextInt(256));
return base64UrlEncode(bytes).replaceAll('=', '');
}
/// Store OIDC state on server (matches web implementation)
static Future<bool> storeOidcState({
required String serverUrl,
required String state,
required String clientId,
String? originUrl,
String? codeVerifier,
}) async {
try {
final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), '');
final url = Uri.parse('$normalizedUrl/api/auth/store_state');
final requestBody = jsonEncode({
'state': state,
'client_id': clientId,
'origin_url': originUrl,
'code_verifier': codeVerifier,
});
final response = await http.post(
url,
headers: {
'Content-Type': 'application/json',
'User-Agent': userAgent,
},
body: requestBody,
);
return response.statusCode == 200;
} catch (e) {
return false;
}
}
/// Build authorization URL and return it for in-app browser use
static Future<String?> buildOidcLoginUrl({
required OidcProvider provider,
required String serverUrl,
required String state,
OidcPkce? pkce,
}) async {
try {
// Store state on server first - use web origin for in-app browser
final stateStored = await storeOidcState(
serverUrl: serverUrl,
state: state,
clientId: provider.clientId,
originUrl: '$serverUrl/oauth/callback', // Use web callback for in-app browser
codeVerifier: pkce?.codeVerifier, // Include PKCE code verifier
);
if (!stateStored) {
return null;
}
// Build authorization URL
final authUri = Uri.parse(provider.authorizationUrl);
final queryParams = <String, String>{
'client_id': provider.clientId,
'response_type': 'code',
'scope': provider.scope,
'redirect_uri': '$serverUrl/api/auth/callback',
'state': state,
};
// Add PKCE parameters if provided
if (pkce != null) {
queryParams['code_challenge'] = pkce.codeChallenge;
queryParams['code_challenge_method'] = 'S256';
}
final authUrl = authUri.replace(queryParameters: queryParams);
return authUrl.toString();
} catch (e) {
return null;
}
}
/// Extract API key from callback URL (for in-app browser)
static String? extractApiKeyFromUrl(String url) {
try {
final uri = Uri.parse(url);
// Check if this is our callback URL with API key
if (uri.path.contains('/oauth/callback')) {
return uri.queryParameters['api_key'];
}
return null;
} catch (e) {
return null;
}
}
/// Handle OIDC callback and extract authentication result
static OidcCallbackResult parseCallback(String callbackUrl) {
try {
final uri = Uri.parse(callbackUrl);
final queryParams = uri.queryParameters;
// Check for error
if (queryParams.containsKey('error')) {
return OidcCallbackResult.error(
error: queryParams['error'] ?? 'Unknown error',
errorDescription: queryParams['error_description'],
);
}
// Check if we have an API key directly (PinePods backend provides this)
final apiKey = queryParams['api_key'];
if (apiKey != null && apiKey.isNotEmpty) {
return OidcCallbackResult.success(
apiKey: apiKey,
state: queryParams['state'],
);
}
// Fallback: Extract traditional OAuth code and state
final code = queryParams['code'];
final state = queryParams['state'];
if (code != null && state != null) {
return OidcCallbackResult.success(
code: code,
state: state,
);
}
return OidcCallbackResult.error(
error: 'missing_parameters',
errorDescription: 'Neither API key nor authorization code found in callback',
);
} catch (e) {
return OidcCallbackResult.error(
error: 'parse_error',
errorDescription: e.toString(),
);
}
}
/// Complete OIDC authentication by verifying with server
static Future<OidcAuthResult> completeAuthentication({
required String serverUrl,
required String code,
required String state,
OidcPkce? pkce,
}) async {
try {
final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), '');
final url = Uri.parse('$normalizedUrl/api/auth/oidc_complete');
final requestBody = <String, dynamic>{
'code': code,
'state': state,
};
// Add PKCE verifier if provided
if (pkce != null) {
requestBody['code_verifier'] = pkce.codeVerifier;
}
final response = await http.post(
url,
headers: {
'Content-Type': 'application/json',
'User-Agent': userAgent,
},
body: jsonEncode(requestBody),
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
return OidcAuthResult.success(
apiKey: data['api_key'],
userId: data['user_id'],
serverUrl: normalizedUrl,
);
} else {
final errorData = jsonDecode(response.body);
return OidcAuthResult.failure(
errorData['error'] ?? 'Authentication failed',
);
}
} catch (e) {
return OidcAuthResult.failure('Network error: ${e.toString()}');
}
}
/// Generate secure random code verifier
static String _generateCodeVerifier() {
final random = Random.secure();
// Generate 32 random bytes (256 bits) which will create a ~43 character base64url string
final bytes = List<int>.generate(32, (i) => random.nextInt(256));
// Use base64url encoding (- and _ instead of + and /) and remove padding
return base64UrlEncode(bytes).replaceAll('=', '');
}
/// Generate code challenge from verifier using SHA256
static String _generateCodeChallenge(String codeVerifier) {
final bytes = utf8.encode(codeVerifier);
final digest = sha256.convert(bytes);
return base64UrlEncode(digest.bytes)
.replaceAll('=', '')
.replaceAll('+', '-')
.replaceAll('/', '_');
}
}
/// OIDC Provider model
class OidcProvider {
final int providerId;
final String providerName;
final String clientId;
final String authorizationUrl;
final String scope;
final String? buttonColor;
final String? buttonText;
final String? buttonTextColor;
final String? iconSvg;
OidcProvider({
required this.providerId,
required this.providerName,
required this.clientId,
required this.authorizationUrl,
required this.scope,
this.buttonColor,
this.buttonText,
this.buttonTextColor,
this.iconSvg,
});
factory OidcProvider.fromJson(Map<String, dynamic> json) {
return OidcProvider(
providerId: json['provider_id'],
providerName: json['provider_name'],
clientId: json['client_id'],
authorizationUrl: json['authorization_url'],
scope: json['scope'],
buttonColor: json['button_color'],
buttonText: json['button_text'],
buttonTextColor: json['button_text_color'],
iconSvg: json['icon_svg'],
);
}
/// Get display text for the provider button
String get displayText => buttonText ?? 'Login with $providerName';
/// Get button color or default
String get buttonColorHex => buttonColor ?? '#007bff';
/// Get button text color or default
String get buttonTextColorHex => buttonTextColor ?? '#ffffff';
}
/// PKCE (Proof Key for Code Exchange) parameters
class OidcPkce {
final String codeVerifier;
final String codeChallenge;
OidcPkce({
required this.codeVerifier,
required this.codeChallenge,
});
}
/// OIDC callback parsing result
class OidcCallbackResult {
final bool isSuccess;
final String? code;
final String? state;
final String? apiKey;
final String? error;
final String? errorDescription;
OidcCallbackResult._({
required this.isSuccess,
this.code,
this.state,
this.apiKey,
this.error,
this.errorDescription,
});
factory OidcCallbackResult.success({
String? code,
String? state,
String? apiKey,
}) {
return OidcCallbackResult._(
isSuccess: true,
code: code,
state: state,
apiKey: apiKey,
);
}
factory OidcCallbackResult.error({
required String error,
String? errorDescription,
}) {
return OidcCallbackResult._(
isSuccess: false,
error: error,
errorDescription: errorDescription,
);
}
bool get hasApiKey => apiKey != null && apiKey!.isNotEmpty;
bool get hasCode => code != null && code!.isNotEmpty;
}
/// OIDC authentication completion result
class OidcAuthResult {
final bool isSuccess;
final String? apiKey;
final int? userId;
final String? serverUrl;
final String? errorMessage;
OidcAuthResult._({
required this.isSuccess,
this.apiKey,
this.userId,
this.serverUrl,
this.errorMessage,
});
factory OidcAuthResult.success({
required String apiKey,
required int userId,
required String serverUrl,
}) {
return OidcAuthResult._(
isSuccess: true,
apiKey: apiKey,
userId: userId,
serverUrl: serverUrl,
);
}
factory OidcAuthResult.failure(String errorMessage) {
return OidcAuthResult._(
isSuccess: false,
errorMessage: errorMessage,
);
}
}

View File

@@ -0,0 +1,504 @@
// lib/services/pinepods/pinepods_audio_service.dart
import 'dart:async';
import 'package:pinepods_mobile/entities/episode.dart';
import 'package:pinepods_mobile/entities/pinepods_episode.dart';
import 'package:pinepods_mobile/entities/chapter.dart';
import 'package:pinepods_mobile/entities/person.dart';
import 'package:pinepods_mobile/entities/transcript.dart';
import 'package:pinepods_mobile/services/audio/audio_player_service.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:logging/logging.dart';
class PinepodsAudioService {
final log = Logger('PinepodsAudioService');
final AudioPlayerService _audioPlayerService;
final PinepodsService _pinepodsService;
final SettingsBloc _settingsBloc;
Timer? _episodeUpdateTimer;
Timer? _userStatsTimer;
int? _currentEpisodeId;
int? _currentUserId;
bool _isYoutube = false;
double _lastRecordedPosition = 0;
/// Callbacks for pause/stop events
Function()? _onPauseCallback;
Function()? _onStopCallback;
PinepodsAudioService(
this._audioPlayerService,
this._pinepodsService,
this._settingsBloc, {
Function()? onPauseCallback,
Function()? onStopCallback,
}) : _onPauseCallback = onPauseCallback,
_onStopCallback = onStopCallback;
/// Play a PinePods episode with full server integration
Future<void> playPinepodsEpisode({
required PinepodsEpisode pinepodsEpisode,
bool resume = true,
}) async {
try {
final settings = _settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
log.warning('No user ID found - cannot play episode with server tracking');
return;
}
_currentUserId = userId;
_isYoutube = pinepodsEpisode.isYoutube;
log.info('Starting PinePods episode playback: ${pinepodsEpisode.episodeTitle}');
// Use the episode ID that's already available from the PinepodsEpisode
final episodeId = pinepodsEpisode.episodeId;
if (episodeId == 0) {
log.warning('Episode ID is 0 - cannot track playback');
return;
}
_currentEpisodeId = episodeId;
// Get podcast ID for settings
final podcastId = await _pinepodsService.getPodcastIdFromEpisode(
episodeId,
userId,
pinepodsEpisode.isYoutube,
);
// Get playback settings (speed, skip times)
final playDetails = await _pinepodsService.getPlayEpisodeDetails(
userId,
podcastId,
pinepodsEpisode.isYoutube,
);
// Fetch podcast 2.0 data including chapters
final podcast2Data = await _pinepodsService.fetchPodcasting2Data(episodeId, userId);
// Convert PinepodsEpisode to Episode for the audio player
final episode = _convertToEpisode(pinepodsEpisode, playDetails, podcast2Data);
// Set playback speed
await _audioPlayerService.setPlaybackSpeed(playDetails.playbackSpeed);
// Start playing with the existing audio service
await _audioPlayerService.playEpisode(episode: episode, resume: resume);
// Handle skip intro if enabled and episode just started
if (playDetails.startSkip > 0 && !resume) {
await Future.delayed(const Duration(milliseconds: 500)); // Wait for player to initialize
await _audioPlayerService.seek(position: playDetails.startSkip);
}
// Add to history
log.info('Adding episode $episodeId to history for user $userId');
final initialPosition = resume ? (pinepodsEpisode.listenDuration ?? 0).toDouble() : 0.0;
await _pinepodsService.recordListenDuration(
episodeId,
userId,
initialPosition, // Send seconds like web app does
pinepodsEpisode.isYoutube,
);
// Queue episode for tracking
log.info('Queueing episode $episodeId for user $userId');
await _pinepodsService.queueEpisode(
episodeId,
userId,
pinepodsEpisode.isYoutube,
);
// Increment played count
log.info('Incrementing played count for user $userId');
await _pinepodsService.incrementPlayed(userId);
// Start periodic updates
_startPeriodicUpdates();
log.info('PinePods episode playback started successfully');
} catch (e) {
log.severe('Error playing PinePods episode: $e');
rethrow;
}
}
/// Start periodic updates to server
void _startPeriodicUpdates() {
_stopPeriodicUpdates(); // Clean up any existing timers
log.info('Starting periodic updates - episode position every 15s, user stats every 60s');
// Episode position updates every 15 seconds (more frequent for reliability)
_episodeUpdateTimer = Timer.periodic(
const Duration(seconds: 15),
(_) => _safeUpdateEpisodePosition(),
);
// User listen time updates every 60 seconds
_userStatsTimer = Timer.periodic(
const Duration(seconds: 60),
(_) => _safeUpdateUserListenTime(),
);
}
/// Safely update episode position without affecting playback
void _safeUpdateEpisodePosition() async {
try {
await _updateEpisodePosition();
} catch (e) {
log.warning('Periodic sync completely failed but playback continues: $e');
// Completely isolate any network failures from affecting playback
}
}
/// Update episode position on server
Future<void> _updateEpisodePosition() async {
// Updating episode position
if (_currentEpisodeId == null || _currentUserId == null) {
log.warning('Skipping scheduled sync - missing episode ID ($_currentEpisodeId) or user ID ($_currentUserId)');
return;
}
try {
final positionState = _audioPlayerService.playPosition?.value;
if (positionState == null) return;
final currentPosition = positionState.position.inSeconds.toDouble();
// Only update if position has changed by more than 2 seconds (more responsive)
if ((currentPosition - _lastRecordedPosition).abs() > 2) {
// Convert seconds to minutes for the API
final currentPositionMinutes = currentPosition / 60.0;
// Position changed, syncing to server
await _pinepodsService.recordListenDuration(
_currentEpisodeId!,
_currentUserId!,
currentPosition, // Send seconds like web app does
_isYoutube,
);
_lastRecordedPosition = currentPosition;
// Sync completed successfully
}
} catch (e) {
log.warning('Failed to update episode position: $e');
}
}
/// Safely update user listen time without affecting playback
void _safeUpdateUserListenTime() async {
try {
await _updateUserListenTime();
} catch (e) {
log.warning('User stats sync completely failed but playback continues: $e');
// Completely isolate any network failures from affecting playback
}
}
/// Update user listen time statistics
Future<void> _updateUserListenTime() async {
if (_currentUserId == null) return;
try {
await _pinepodsService.incrementListenTime(_currentUserId!);
// User listen time updated
} catch (e) {
log.warning('Failed to update user listen time: $e');
}
}
/// Sync current position to server immediately (for pause/stop events)
Future<void> syncCurrentPositionToServer() async {
// Syncing current position to server
if (_currentEpisodeId == null || _currentUserId == null) {
log.warning('Cannot sync - missing episode ID ($_currentEpisodeId) or user ID ($_currentUserId)');
return;
}
try {
final positionState = _audioPlayerService.playPosition?.value;
if (positionState == null) {
log.warning('Cannot sync - positionState is null');
return;
}
final currentPosition = positionState.position.inSeconds.toDouble();
log.info('Syncing position to server: ${currentPosition}s for episode $_currentEpisodeId');
await _pinepodsService.recordListenDuration(
_currentEpisodeId!,
_currentUserId!,
currentPosition, // Send seconds like web app does
_isYoutube,
);
_lastRecordedPosition = currentPosition;
log.info('Successfully synced position to server: ${currentPosition}s');
} catch (e) {
log.warning('Failed to sync position to server: $e');
log.warning('Stack trace: ${StackTrace.current}');
}
}
/// Get server position for current episode
Future<double?> getServerPosition() async {
if (_currentEpisodeId == null || _currentUserId == null) return null;
try {
final episodeMetadata = await _pinepodsService.getEpisodeMetadata(
_currentEpisodeId!,
_currentUserId!,
isYoutube: _isYoutube,
);
return episodeMetadata?.listenDuration?.toDouble();
} catch (e) {
log.warning('Failed to get server position: $e');
return null;
}
}
/// Get server position for any episode
Future<double?> getServerPositionForEpisode(int episodeId, int userId, bool isYoutube) async {
try {
final episodeMetadata = await _pinepodsService.getEpisodeMetadata(
episodeId,
userId,
isYoutube: isYoutube,
);
return episodeMetadata?.listenDuration?.toDouble();
} catch (e) {
log.warning('Failed to get server position for episode $episodeId: $e');
return null;
}
}
/// Record listen duration when episode ends or is stopped
Future<void> recordListenDuration(double listenDuration) async {
if (_currentEpisodeId == null || _currentUserId == null) return;
try {
await _pinepodsService.recordListenDuration(
_currentEpisodeId!,
_currentUserId!,
listenDuration,
_isYoutube,
);
log.info('Recorded listen duration: ${listenDuration}s');
} catch (e) {
log.warning('Failed to record listen duration: $e');
}
}
/// Handle pause event - sync position to server
Future<void> onPause() async {
try {
await syncCurrentPositionToServer();
log.info('Pause event handled - position synced to server');
} catch (e) {
log.warning('Pause sync failed but pause succeeded: $e');
}
_onPauseCallback?.call();
}
/// Handle stop event - sync position to server
Future<void> onStop() async {
try {
await syncCurrentPositionToServer();
log.info('Stop event handled - position synced to server');
} catch (e) {
log.warning('Stop sync failed but stop succeeded: $e');
}
_onStopCallback?.call();
}
/// Stop periodic updates
void _stopPeriodicUpdates() {
_episodeUpdateTimer?.cancel();
_userStatsTimer?.cancel();
_episodeUpdateTimer = null;
_userStatsTimer = null;
}
/// Convert PinepodsEpisode to Episode for the audio player
Episode _convertToEpisode(PinepodsEpisode pinepodsEpisode, PlayEpisodeDetails playDetails, Map<String, dynamic>? podcast2Data) {
// Determine the content URL
String contentUrl;
if (pinepodsEpisode.downloaded && _currentEpisodeId != null && _currentUserId != null) {
// Use stream URL for local episodes
contentUrl = _pinepodsService.getStreamUrl(
_currentEpisodeId!,
_currentUserId!,
isYoutube: pinepodsEpisode.isYoutube,
isLocal: true,
);
} else if (pinepodsEpisode.isYoutube && _currentEpisodeId != null && _currentUserId != null) {
// Use stream URL for YouTube episodes
contentUrl = _pinepodsService.getStreamUrl(
_currentEpisodeId!,
_currentUserId!,
isYoutube: true,
isLocal: false,
);
} else {
// Use original URL for external episodes
contentUrl = pinepodsEpisode.episodeUrl;
}
// Process podcast 2.0 data
List<Chapter> chapters = [];
List<Person> persons = [];
List<TranscriptUrl> transcriptUrls = [];
String? chaptersUrl;
if (podcast2Data != null) {
// Extract chapters data
final chaptersData = podcast2Data['chapters'] as List<dynamic>?;
if (chaptersData != null) {
try {
chapters = chaptersData.map((chapterData) {
return Chapter(
title: chapterData['title'] ?? '',
startTime: _parseDouble(chapterData['startTime'] ?? chapterData['start_time'] ?? 0) ?? 0.0,
endTime: _parseDouble(chapterData['endTime'] ?? chapterData['end_time']),
imageUrl: chapterData['img'] ?? chapterData['image'],
url: chapterData['url'],
toc: chapterData['toc'] ?? true,
);
}).toList();
log.info('Loaded ${chapters.length} chapters from podcast 2.0 data');
} catch (e) {
log.warning('Error parsing chapters from podcast 2.0 data: $e');
}
}
// Extract chapters URL if available
chaptersUrl = podcast2Data['chapters_url'];
// Extract persons data
final personsData = podcast2Data['people'] as List<dynamic>?;
if (personsData != null) {
try {
persons = personsData.map((personData) {
return Person(
name: personData['name'] ?? '',
role: personData['role'] ?? '',
group: personData['group'] ?? '',
image: personData['img'],
link: personData['href'],
);
}).toList();
log.info('Loaded ${persons.length} persons from podcast 2.0 data');
} catch (e) {
log.warning('Error parsing persons from podcast 2.0 data: $e');
}
}
// Extract transcript data
final transcriptsData = podcast2Data['transcripts'] as List<dynamic>?;
if (transcriptsData != null) {
try {
transcriptUrls = transcriptsData.map((transcriptData) {
TranscriptFormat format = TranscriptFormat.unsupported;
// Determine format from URL, mime_type, or type field
final url = transcriptData['url'] ?? '';
final mimeType = transcriptData['mime_type'] ?? '';
final type = transcriptData['type'] ?? '';
// Processing transcript
if (url.toLowerCase().contains('.json') ||
mimeType.toLowerCase().contains('json') ||
type.toLowerCase().contains('json')) {
format = TranscriptFormat.json;
// Detected JSON transcript
} else if (url.toLowerCase().contains('.srt') ||
mimeType.toLowerCase().contains('srt') ||
type.toLowerCase().contains('srt') ||
type.toLowerCase().contains('subrip') ||
url.toLowerCase().contains('subrip')) {
format = TranscriptFormat.subrip;
// Detected SubRip transcript
} else if (url.toLowerCase().contains('transcript') ||
mimeType.toLowerCase().contains('html') ||
type.toLowerCase().contains('html')) {
format = TranscriptFormat.html;
// Detected HTML transcript
} else {
log.warning('Transcript format not recognized: mimeType=$mimeType, type=$type');
}
return TranscriptUrl(
url: url,
type: format,
language: transcriptData['language'] ?? transcriptData['lang'] ?? 'en',
rel: transcriptData['rel'],
);
}).toList();
log.info('Loaded ${transcriptUrls.length} transcript URLs from podcast 2.0 data');
} catch (e) {
log.warning('Error parsing transcripts from podcast 2.0 data: $e');
}
}
}
return Episode(
guid: pinepodsEpisode.episodeUrl,
podcast: pinepodsEpisode.podcastName,
title: pinepodsEpisode.episodeTitle,
description: pinepodsEpisode.episodeDescription,
link: pinepodsEpisode.episodeUrl,
publicationDate: DateTime.tryParse(pinepodsEpisode.episodePubDate) ?? DateTime.now(),
author: '',
duration: (pinepodsEpisode.episodeDuration * 1000).round(), // Convert to milliseconds
contentUrl: contentUrl,
position: pinepodsEpisode.completed ? 0 : ((pinepodsEpisode.listenDuration ?? 0) * 1000).round(), // Convert to milliseconds, reset to 0 for completed episodes
imageUrl: pinepodsEpisode.episodeArtwork,
played: pinepodsEpisode.completed,
chapters: chapters,
chaptersUrl: chaptersUrl,
persons: persons,
transcriptUrls: transcriptUrls,
);
}
/// Helper method to safely parse double values
double? _parseDouble(dynamic value) {
if (value == null) return null;
if (value is double) return value;
if (value is int) return value.toDouble();
if (value is String) {
try {
return double.parse(value);
} catch (e) {
log.warning('Failed to parse double from string: $value');
return null;
}
}
return null;
}
/// Clean up resources
void dispose() {
_stopPeriodicUpdates();
_currentEpisodeId = null;
_currentUserId = null;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,808 @@
// Copyright 2019 Ben Hills. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:collection';
import 'dart:io';
import 'package:pinepods_mobile/api/podcast/podcast_api.dart';
import 'package:pinepods_mobile/core/utils.dart';
import 'package:pinepods_mobile/entities/chapter.dart';
import 'package:pinepods_mobile/entities/downloadable.dart';
import 'package:pinepods_mobile/entities/episode.dart';
import 'package:pinepods_mobile/entities/funding.dart';
import 'package:pinepods_mobile/entities/person.dart';
import 'package:pinepods_mobile/entities/podcast.dart';
import 'package:pinepods_mobile/entities/transcript.dart';
import 'package:pinepods_mobile/l10n/messages_all.dart';
import 'package:pinepods_mobile/services/podcast/podcast_service.dart';
import 'package:pinepods_mobile/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';
import 'package:podcast_search/podcast_search.dart' as podcast_search;
class MobilePodcastService extends PodcastService {
final descriptionRegExp1 = RegExp(r'(</p><br>|</p></br>|<p><br></p>|<p></br></p>)');
final descriptionRegExp2 = RegExp(r'(<p><br></p>|<p></br></p>)');
final log = Logger('MobilePodcastService');
final _cache = _PodcastCache(maxItems: 10, expiration: const Duration(minutes: 30));
var _categories = <String>[];
var _intlCategories = <String?>[];
var _intlCategoriesSorted = <String>[];
MobilePodcastService({
required super.api,
required super.repository,
required super.settingsService,
}) {
_init();
}
Future<void> _init() async {
final List<Locale> systemLocales = PlatformDispatcher.instance.locales;
var currentLocale = Platform.localeName;
// Attempt to get current locale
var supportedLocale = await initializeMessages(Platform.localeName);
// If we do not support the default, try all supported locales
if (!supportedLocale) {
for (var l in systemLocales) {
supportedLocale = await initializeMessages('${l.languageCode}_${l.countryCode}');
if (supportedLocale) {
currentLocale = '${l.languageCode}_${l.countryCode}';
break;
}
}
if (!supportedLocale) {
// We give up! Default to English
currentLocale = 'en';
supportedLocale = await initializeMessages(currentLocale);
}
}
_setupGenres(currentLocale);
/// Listen for user changes in search provider. If changed, reload the genre list
settingsService.settingsListener.where((event) => event == 'search').listen((event) {
_setupGenres(currentLocale);
});
}
void _setupGenres(String locale) {
var categoryList = '';
/// Fetch the correct categories for the current local and selected provider.
if (settingsService.searchProvider == 'itunes') {
_categories = PodcastService.itunesGenres;
categoryList = Intl.message('discovery_categories_itunes', locale: locale);
} else {
_categories = PodcastService.podcastIndexGenres;
categoryList = Intl.message('discovery_categories_pindex', locale: locale);
}
_intlCategories = categoryList.split(',');
_intlCategoriesSorted = categoryList.split(',');
_intlCategoriesSorted.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
}
@override
Future<podcast_search.SearchResult> search({
required String term,
String? country,
String? attribute,
int? limit,
String? language,
int version = 0,
bool explicit = false,
}) {
return api.search(
term,
country: country,
attribute: attribute,
limit: limit,
language: language,
explicit: explicit,
searchProvider: settingsService.searchProvider,
);
}
@override
Future<podcast_search.SearchResult> charts({
int size = 20,
String? genre,
String? countryCode = '',
String? languageCode = '',
}) {
var providerGenre = _decodeGenre(genre);
return api.charts(
size: size,
searchProvider: settingsService.searchProvider,
genre: providerGenre,
countryCode: countryCode,
languageCode: languageCode,
);
}
@override
List<String> genres() {
return _intlCategoriesSorted;
}
/// Loads the specified [Podcast]. If the Podcast instance has an ID we'll fetch
/// it from storage. If not, we'll check the cache to see if we have seen it
/// recently and return that if available. If not, we'll make a call to load
/// it from the network.
/// TODO: The complexity of this method is now too high - needs to be refactored.
@override
Future<Podcast?> loadPodcast({
required Podcast podcast,
bool highlightNewEpisodes = false,
bool refresh = false,
}) async {
log.fine('loadPodcast. ID ${podcast.id} (refresh $refresh)');
if (podcast.id == null || refresh) {
podcast_search.Podcast? loadedPodcast;
var imageUrl = podcast.imageUrl;
var thumbImageUrl = podcast.thumbImageUrl;
var sourceUrl = podcast.url;
if (!refresh) {
log.fine('Not a refresh so try to fetch from cache');
loadedPodcast = _cache.item(podcast.url);
}
// If we didn't get a cache hit load the podcast feed.
if (loadedPodcast == null) {
var tries = 2;
var url = podcast.url;
while (tries-- > 0) {
try {
log.fine('Loading podcast from feed $url');
loadedPodcast = await _loadPodcastFeed(url: url);
tries = 0;
} catch (e) {
if (tries > 0) {
//TODO: Needs improving to only fall back if original URL was http and we forced it up to https.
if (e is podcast_search.PodcastCertificateException && url.startsWith('https')) {
log.fine('Certificate error whilst fetching podcast. Fallback to http and try again');
url = url.replaceFirst('https', 'http');
}
} else {
rethrow;
}
}
}
_cache.store(loadedPodcast!);
}
final title = _format(loadedPodcast.title);
final description = _format(loadedPodcast.description);
final copyright = _format(loadedPodcast.copyright);
final funding = <Funding>[];
final persons = <Person>[];
final existingEpisodes = await repository.findEpisodesByPodcastGuid(sourceUrl);
// If imageUrl is null we have not loaded the podcast as a result of a search.
if (imageUrl == null || imageUrl.isEmpty || refresh) {
imageUrl = loadedPodcast.image;
thumbImageUrl = loadedPodcast.image;
}
for (var f in loadedPodcast.funding) {
if (f.url != null) {
funding.add(Funding(url: f.url!, value: f.value ?? ''));
}
}
for (var p in loadedPodcast.persons) {
persons.add(Person(
name: p.name,
role: p.role ?? '',
group: p.group ?? '',
image: p.image,
link: p.link,
));
}
Podcast pc = Podcast(
guid: sourceUrl,
url: sourceUrl,
link: loadedPodcast.link,
title: title,
description: description,
imageUrl: imageUrl,
thumbImageUrl: thumbImageUrl,
copyright: copyright,
funding: funding,
persons: persons,
episodes: <Episode>[],
);
/// We could be following this podcast already. Let's check.
var follow = await repository.findPodcastByGuid(sourceUrl);
if (follow != null) {
// We are, so swap in the stored ID so we update the saved version later.
pc.id = follow.id;
// And preserve any filter & sort applied
pc.filter = follow.filter;
pc.sort = follow.sort;
}
// Usually, episodes are order by reverse publication date - but not always.
// Enforce that ordering. To prevent unnecessary sorting, we'll sample the
// first two episodes to see what order they are in.
if (loadedPodcast.episodes.length > 1) {
if (loadedPodcast.episodes[0].publicationDate!.millisecondsSinceEpoch <
loadedPodcast.episodes[1].publicationDate!.millisecondsSinceEpoch) {
loadedPodcast.episodes.sort((e1, e2) => e2.publicationDate!.compareTo(e1.publicationDate!));
}
}
// Loop through all episodes in the feed and check to see if we already have that episode
// stored. If we don't, it's a new episode so add it; if we do update our copy in case it's changed.
for (final episode in loadedPodcast.episodes) {
final existingEpisode = existingEpisodes.firstWhereOrNull((ep) => ep!.guid == episode.guid);
final author = episode.author?.replaceAll('\n', '').trim() ?? '';
final title = _format(episode.title);
final description = _format(episode.description);
final content = episode.content;
final episodeImage = episode.imageUrl == null || episode.imageUrl!.isEmpty ? pc.imageUrl : episode.imageUrl;
final episodeThumbImage =
episode.imageUrl == null || episode.imageUrl!.isEmpty ? pc.thumbImageUrl : episode.imageUrl;
final duration = episode.duration?.inSeconds ?? 0;
final transcriptUrls = <TranscriptUrl>[];
final episodePersons = <Person>[];
for (var t in episode.transcripts) {
late TranscriptFormat type;
switch (t.type) {
case podcast_search.TranscriptFormat.subrip:
type = TranscriptFormat.subrip;
break;
case podcast_search.TranscriptFormat.json:
type = TranscriptFormat.json;
break;
case podcast_search.TranscriptFormat.vtt:
type = TranscriptFormat.subrip; // Map VTT to subrip for now
break;
case podcast_search.TranscriptFormat.unsupported:
type = TranscriptFormat.unsupported;
break;
}
transcriptUrls.add(TranscriptUrl(url: t.url, type: type));
}
if (episode.persons.isNotEmpty) {
for (var p in episode.persons) {
episodePersons.add(Person(
name: p.name,
role: p.role!,
group: p.group!,
image: p.image,
link: p.link,
));
}
} else if (persons.isNotEmpty) {
episodePersons.addAll(persons);
}
if (existingEpisode == null) {
pc.newEpisodes = highlightNewEpisodes && pc.id != null;
pc.episodes.add(Episode(
highlight: pc.newEpisodes,
pguid: pc.guid,
guid: episode.guid,
podcast: pc.title,
title: title,
description: description,
content: content,
author: author,
season: episode.season ?? 0,
episode: episode.episode ?? 0,
contentUrl: episode.contentUrl,
link: episode.link,
imageUrl: episodeImage,
thumbImageUrl: episodeThumbImage,
duration: duration,
publicationDate: episode.publicationDate,
chaptersUrl: episode.chapters?.url,
transcriptUrls: transcriptUrls,
persons: episodePersons,
chapters: <Chapter>[],
));
} else {
/// Check if the ancillary episode data has changed.
if (!listEquals(existingEpisode.persons, episodePersons) ||
!listEquals(existingEpisode.transcriptUrls, transcriptUrls)) {
pc.updatedEpisodes = true;
}
existingEpisode.title = title;
existingEpisode.description = description;
existingEpisode.content = content;
existingEpisode.author = author;
existingEpisode.season = episode.season ?? 0;
existingEpisode.episode = episode.episode ?? 0;
existingEpisode.contentUrl = episode.contentUrl;
existingEpisode.link = episode.link;
existingEpisode.imageUrl = episodeImage;
existingEpisode.thumbImageUrl = episodeThumbImage;
existingEpisode.publicationDate = episode.publicationDate;
existingEpisode.chaptersUrl = episode.chapters?.url;
existingEpisode.transcriptUrls = transcriptUrls;
existingEpisode.persons = episodePersons;
// If the source duration is 0 do not update any saved, calculated duration.
if (duration > 0) {
existingEpisode.duration = duration;
}
pc.episodes.add(existingEpisode);
// Clear this episode from our existing list
existingEpisodes.remove(existingEpisode);
}
}
// Add any downloaded episodes that are no longer in the feed - they
// may have expired but we still want them.
var expired = <Episode>[];
for (final episode in existingEpisodes) {
var feedEpisode = loadedPodcast.episodes.firstWhereOrNull((ep) => ep.guid == episode!.guid);
if (feedEpisode == null && episode!.downloaded) {
pc.episodes.add(episode);
} else {
expired.add(episode!);
}
}
// If we are subscribed to this podcast and are simply refreshing we need to save the updated subscription.
// A non-null ID indicates this podcast is subscribed too. We also need to delete any expired episodes.
if (podcast.id != null && refresh) {
await repository.deleteEpisodes(expired);
pc = await repository.savePodcast(pc);
// Phew! Now, after all that, we have have a podcast filter in place. All episodes will have
// been saved, but we might not want to display them all. Let's filter.
pc.episodes = _sortAndFilterEpisodes(pc);
}
return pc;
} else {
return await loadPodcastById(id: podcast.id ?? 0);
}
}
@override
Future<Podcast?> loadPodcastById({required int id}) {
return repository.findPodcastById(id);
}
@override
Future<List<Chapter>> loadChaptersByUrl({required String url}) async {
var c = await _loadChaptersByUrl(url);
var chapters = <Chapter>[];
if (c != null) {
for (var chapter in c.chapters) {
chapters.add(Chapter(
title: chapter.title,
url: chapter.url,
imageUrl: chapter.imageUrl,
startTime: chapter.startTime,
endTime: chapter.endTime,
toc: chapter.toc,
));
}
}
return chapters;
}
/// This method will load either of the supported transcript types. Currently, we do not support
/// word level highlighting of transcripts, therefore this routine will also group transcript
/// lines together by speaker and/or timeframe.
@override
Future<Transcript> loadTranscriptByUrl({required TranscriptUrl transcriptUrl}) async {
var subtitles = <Subtitle>[];
var result = await _loadTranscriptByUrl(transcriptUrl);
var threshold = const Duration(seconds: 5);
Subtitle? groupSubtitle;
if (result != null) {
for (var index = 0; index < result.subtitles.length; index++) {
var subtitle = result.subtitles[index];
var completeGroup = true;
var data = subtitle.data;
if (groupSubtitle != null) {
if (transcriptUrl.type == TranscriptFormat.json) {
if (groupSubtitle.speaker == subtitle.speaker &&
(subtitle.start.compareTo(groupSubtitle.start + threshold) < 0 || subtitle.data.length == 1)) {
/// We need to handle transcripts that have spaces between sentences, and those
/// which do not.
if (groupSubtitle.data != null &&
(groupSubtitle.data!.endsWith(' ') || subtitle.data.startsWith(' ') || subtitle.data.length == 1)) {
data = '${groupSubtitle.data}${subtitle.data}';
} else {
data = '${groupSubtitle.data} ${subtitle.data.trim()}';
}
completeGroup = false;
}
} else {
if (groupSubtitle.start == subtitle.start) {
if (groupSubtitle.data != null &&
(groupSubtitle.data!.endsWith(' ') || subtitle.data.startsWith(' ') || subtitle.data.length == 1)) {
data = '${groupSubtitle.data}${subtitle.data}';
} else {
data = '${groupSubtitle.data} ${subtitle.data.trim()}';
}
completeGroup = false;
}
}
} else {
completeGroup = false;
groupSubtitle = Subtitle(
data: subtitle.data,
speaker: subtitle.speaker,
start: subtitle.start,
end: subtitle.end,
index: subtitle.index,
);
}
/// If we have a complete group, or we're the very last subtitle - add it.
if (completeGroup || index == result.subtitles.length - 1) {
groupSubtitle.data = groupSubtitle.data?.trim();
subtitles.add(groupSubtitle);
groupSubtitle = Subtitle(
data: subtitle.data,
speaker: subtitle.speaker,
start: subtitle.start,
end: subtitle.end,
index: subtitle.index,
);
} else {
groupSubtitle = Subtitle(
data: data,
speaker: subtitle.speaker,
start: groupSubtitle.start,
end: subtitle.end,
index: groupSubtitle.index,
);
}
}
}
return Transcript(subtitles: subtitles);
}
@override
Future<List<Episode>> loadDownloads() async {
return repository.findDownloads();
}
@override
Future<List<Episode>> loadEpisodes() async {
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;
episode.position = 0;
repository.saveEpisode(episode);
}
@override
Future<List<Podcast>> subscriptions() {
return repository.subscriptions();
}
@override
Future<void> unsubscribe(Podcast podcast) async {
if (await hasStoragePermission()) {
final filename = join(await getStorageDirectory(), safeFile(podcast.title));
final d = Directory.fromUri(Uri.file(filename));
if (await d.exists()) {
await d.delete(recursive: true);
}
}
return repository.deletePodcast(podcast);
}
@override
Future<Podcast?> subscribe(Podcast? podcast) async {
// We may already have episodes download for this podcast before the user
// hit subscribe.
if (podcast != null && podcast.guid != null) {
var savedEpisodes = await repository.findEpisodesByPodcastGuid(podcast.guid!);
if (podcast.episodes.isNotEmpty) {
for (var episode in podcast.episodes) {
var savedEpisode = savedEpisodes.firstWhereOrNull((ep) => ep!.guid == episode.guid);
if (savedEpisode != null) {
episode.pguid = podcast.guid;
}
}
}
return repository.savePodcast(podcast);
}
return Future.value(null);
}
@override
Future<Podcast?> save(Podcast podcast, {bool withEpisodes = true}) async {
return repository.savePodcast(podcast, withEpisodes: withEpisodes);
}
@override
Future<Episode> saveEpisode(Episode episode) async {
return repository.saveEpisode(episode);
}
@override
Future<List<Episode>> saveEpisodes(List<Episode> episodes) async {
return repository.saveEpisodes(episodes);
}
@override
Future<Transcript> saveTranscript(Transcript transcript) async {
return repository.saveTranscript(transcript);
}
@override
Future<void> saveQueue(List<Episode> episodes) async {
await repository.saveQueue(episodes);
}
@override
Future<List<Episode>> loadQueue() async {
return await repository.loadQueue();
}
/// Remove HTML padding from the content. The padding may look fine within
/// the context of a browser, but can look out of place on a mobile screen.
String _format(String? input) {
return input?.trim().replaceAll(descriptionRegExp2, '').replaceAll(descriptionRegExp1, '</p>') ?? '';
}
Future<podcast_search.Chapters?> _loadChaptersByUrl(String url) {
return compute<_FeedComputer, podcast_search.Chapters?>(
_loadChaptersByUrlCompute, _FeedComputer(api: api, url: url));
}
static Future<podcast_search.Chapters?> _loadChaptersByUrlCompute(_FeedComputer c) async {
podcast_search.Chapters? result;
try {
result = await c.api.loadChapters(c.url);
} catch (e) {
final log = Logger('MobilePodcastService');
log.fine('Failed to download chapters');
log.fine(e);
}
return result;
}
Future<podcast_search.Transcript?> _loadTranscriptByUrl(TranscriptUrl transcriptUrl) {
return compute<_TranscriptComputer, podcast_search.Transcript?>(
_loadTranscriptByUrlCompute, _TranscriptComputer(api: api, transcriptUrl: transcriptUrl));
}
static Future<podcast_search.Transcript?> _loadTranscriptByUrlCompute(_TranscriptComputer c) async {
podcast_search.Transcript? result;
try {
result = await c.api.loadTranscript(c.transcriptUrl);
} catch (e) {
final log = Logger('MobilePodcastService');
log.fine('Failed to download transcript');
log.fine(e);
}
return result;
}
/// Loading and parsing a podcast feed can take several seconds. Larger feeds
/// can end up blocking the UI thread. We perform our feed load in a
/// separate isolate so that the UI can continue to present a loading
/// indicator whilst the data is fetched without locking the UI.
Future<podcast_search.Podcast> _loadPodcastFeed({required String url}) {
return compute<_FeedComputer, podcast_search.Podcast>(_loadPodcastFeedCompute, _FeedComputer(api: api, url: url));
}
/// We have to separate the process of calling compute as you cannot use
/// named parameters with compute. The podcast feed load API uses named
/// parameters so we need to change it to a single, positional parameter.
static Future<podcast_search.Podcast> _loadPodcastFeedCompute(_FeedComputer c) {
return c.api.loadFeed(c.url);
}
/// The service providers expect the genre to be passed in English. This function takes
/// the selected genre and returns the English version.
String _decodeGenre(String? genre) {
var index = _intlCategories.indexOf(genre);
var decodedGenre = '';
if (index >= 0) {
decodedGenre = _categories[index];
if (decodedGenre == '<All>') {
decodedGenre = '';
}
}
return decodedGenre;
}
List<Episode> _sortAndFilterEpisodes(Podcast podcast) {
var filteredEpisodes = <Episode>[];
switch (podcast.filter) {
case PodcastEpisodeFilter.none:
filteredEpisodes = podcast.episodes;
break;
case PodcastEpisodeFilter.started:
filteredEpisodes = podcast.episodes.where((e) => e.highlight || e.position > 0).toList();
break;
case PodcastEpisodeFilter.played:
filteredEpisodes = podcast.episodes.where((e) => e.highlight || e.played).toList();
break;
case PodcastEpisodeFilter.notPlayed:
filteredEpisodes = podcast.episodes.where((e) => e.highlight || !e.played).toList();
break;
}
switch (podcast.sort) {
case PodcastEpisodeSort.none:
case PodcastEpisodeSort.latestFirst:
filteredEpisodes.sort((e1, e2) => e2.publicationDate!.compareTo(e1.publicationDate!));
case PodcastEpisodeSort.earliestFirst:
filteredEpisodes.sort((e1, e2) => e1.publicationDate!.compareTo(e2.publicationDate!));
case PodcastEpisodeSort.alphabeticalAscending:
filteredEpisodes.sort((e1, e2) => e1.title!.toLowerCase().compareTo(e2.title!.toLowerCase()));
case PodcastEpisodeSort.alphabeticalDescending:
filteredEpisodes.sort((e1, e2) => e2.title!.toLowerCase().compareTo(e1.title!.toLowerCase()));
}
return filteredEpisodes;
}
@override
Stream<Podcast?>? get podcastListener => repository.podcastListener;
@override
Stream<EpisodeState>? get episodeListener => repository.episodeListener;
}
/// A simple cache to reduce the number of network calls when loading podcast
/// feeds. We can cache up to [maxItems] items with each item having an
/// expiration time of [expiration]. The cache works as a FIFO queue, so if we
/// attempt to store a new item in the cache and it is full we remove the
/// first (and therefore oldest) item from the cache. Cache misses are returned
/// as null.
class _PodcastCache {
final int maxItems;
final Duration expiration;
final Queue<_CacheItem> _queue;
_PodcastCache({required this.maxItems, required this.expiration}) : _queue = Queue<_CacheItem>();
podcast_search.Podcast? item(String key) {
var hit = _queue.firstWhereOrNull((_CacheItem i) => i.podcast.url == key);
podcast_search.Podcast? p;
if (hit != null) {
var now = DateTime.now();
if (now.difference(hit.dateAdded) <= expiration) {
p = hit.podcast;
} else {
_queue.remove(hit);
}
}
return p;
}
void store(podcast_search.Podcast podcast) {
if (_queue.length == maxItems) {
_queue.removeFirst();
}
_queue.addLast(_CacheItem(podcast));
}
}
/// A simple class that stores an instance of a Podcast and the
/// date and time it was added. This can be used by the cache to
/// keep a small and up-to-date list of searched for Podcasts.
class _CacheItem {
final podcast_search.Podcast podcast;
final DateTime dateAdded;
_CacheItem(this.podcast) : dateAdded = DateTime.now();
}
class _FeedComputer {
final PodcastApi api;
final String url;
_FeedComputer({required this.api, required this.url});
}
class _TranscriptComputer {
final PodcastApi api;
final TranscriptUrl transcriptUrl;
_TranscriptComputer({required this.api, required this.transcriptUrl});
}

View File

@@ -0,0 +1,230 @@
// Copyright 2020 Ben Hills and the project contributors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:pinepods_mobile/api/podcast/podcast_api.dart';
import 'package:pinepods_mobile/entities/chapter.dart';
import 'package:pinepods_mobile/entities/episode.dart';
import 'package:pinepods_mobile/entities/podcast.dart';
import 'package:pinepods_mobile/entities/transcript.dart';
import 'package:pinepods_mobile/repository/repository.dart';
import 'package:pinepods_mobile/services/settings/settings_service.dart';
import 'package:pinepods_mobile/state/episode_state.dart';
import 'package:podcast_search/podcast_search.dart' as pcast;
/// The [PodcastService] handles interactions around podcasts including searching, fetching
/// the trending/charts podcasts, loading the podcast RSS feed and anciallary items such as
/// chapters and transcripts.
abstract class PodcastService {
final PodcastApi api;
final Repository repository;
final SettingsService settingsService;
static const itunesGenres = [
'<All>',
'Arts',
'Business',
'Comedy',
'Education',
'Fiction',
'Government',
'Health & Fitness',
'History',
'Kids & Family',
'Leisure',
'Music',
'News',
'Religion & Spirituality',
'Science',
'Society & Culture',
'Sports',
'TV & Film',
'Technology',
'True Crime',
];
static const podcastIndexGenres = <String>[
'<All>',
'After-Shows',
'Alternative',
'Animals',
'Animation',
'Arts',
'Astronomy',
'Automotive',
'Aviation',
'Baseball',
'Basketball',
'Beauty',
'Books',
'Buddhism',
'Business',
'Careers',
'Chemistry',
'Christianity',
'Climate',
'Comedy',
'Commentary',
'Courses',
'Crafts',
'Cricket',
'Cryptocurrency',
'Culture',
'Daily',
'Design',
'Documentary',
'Drama',
'Earth',
'Education',
'Entertainment',
'Entrepreneurship',
'Family',
'Fantasy',
'Fashion',
'Fiction',
'Film',
'Fitness',
'Food',
'Football',
'Games',
'Garden',
'Golf',
'Government',
'Health',
'Hinduism',
'History',
'Hobbies',
'Hockey',
'Home',
'How-To',
'Improv',
'Interviews',
'Investing',
'Islam',
'Journals',
'Judaism',
'Kids',
'Language',
'Learning',
'Leisure',
'Life',
'Management',
'Manga',
'Marketing',
'Mathematics',
'Medicine',
'Mental',
'Music',
'Natural',
'Nature',
'News',
'Non-Profit',
'Nutrition',
'Parenting',
'Performing',
'Personal',
'Pets',
'Philosophy',
'Physics',
'Places',
'Politics',
'Relationships',
'Religion',
'Reviews',
'Role-Playing',
'Rugby',
'Running',
'Science',
'Self-Improvement',
'Sexuality',
'Soccer',
'Social',
'Society',
'Spirituality',
'Sports',
'Stand-Up',
'Stories',
'Swimming',
'TV',
'Tabletop',
'Technology',
'Tennis',
'Travel',
'True Crime',
'Video-Games',
'Visual',
'Volleyball',
'Weather',
'Wilderness',
'Wrestling',
];
PodcastService({
required this.api,
required this.repository,
required this.settingsService,
});
Future<pcast.SearchResult> search({
required String term,
String? country,
String? attribute,
int? limit,
String? language,
int version = 0,
bool explicit = false,
});
Future<pcast.SearchResult> charts({
required int size,
String? genre,
String? countryCode,
String? languageCode,
});
List<String> genres();
Future<Podcast?> loadPodcast({
required Podcast podcast,
bool highlightNewEpisodes = false,
bool refresh = false,
});
Future<Podcast?> loadPodcastById({
required int id,
});
Future<List<Episode>> loadDownloads();
Future<List<Episode>> loadEpisodes();
Future<List<Chapter>> loadChaptersByUrl({required String url});
Future<Transcript> loadTranscriptByUrl({required TranscriptUrl transcriptUrl});
Future<void> deleteDownload(Episode episode);
Future<void> toggleEpisodePlayed(Episode episode);
Future<List<Podcast>> subscriptions();
Future<Podcast?> subscribe(Podcast podcast);
Future<void> unsubscribe(Podcast podcast);
Future<Podcast?> save(Podcast podcast, {bool withEpisodes = true});
Future<Episode> saveEpisode(Episode episode);
Future<List<Episode>> saveEpisodes(List<Episode> episodes);
Future<Transcript> saveTranscript(Transcript transcript);
Future<void> saveQueue(List<Episode> episodes);
Future<List<Episode>> loadQueue();
/// Event listeners
Stream<Podcast?>? podcastListener;
Stream<EpisodeState>? episodeListener;
}

View File

@@ -0,0 +1,162 @@
// lib/services/search_history_service.dart
import 'dart:convert';
import 'package:logging/logging.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Service for managing search history for different search types
/// Stores search terms separately for episode search and podcast search
class SearchHistoryService {
final log = Logger('SearchHistoryService');
static const int maxHistoryItems = 30;
static const String episodeSearchKey = 'episode_search_history';
static const String podcastSearchKey = 'podcast_search_history';
SearchHistoryService();
/// Adds a search term to episode search history
/// Moves existing term to top if already exists, otherwise adds as new
Future<void> addEpisodeSearchTerm(String searchTerm) async {
print('SearchHistoryService.addEpisodeSearchTerm called with: "$searchTerm"');
await _addSearchTerm(episodeSearchKey, searchTerm);
}
/// Adds a search term to podcast search history
/// Moves existing term to top if already exists, otherwise adds as new
Future<void> addPodcastSearchTerm(String searchTerm) async {
print('SearchHistoryService.addPodcastSearchTerm called with: "$searchTerm"');
await _addSearchTerm(podcastSearchKey, searchTerm);
}
/// Gets episode search history, most recent first
Future<List<String>> getEpisodeSearchHistory() async {
print('SearchHistoryService.getEpisodeSearchHistory called');
return await _getSearchHistory(episodeSearchKey);
}
/// Gets podcast search history, most recent first
Future<List<String>> getPodcastSearchHistory() async {
print('SearchHistoryService.getPodcastSearchHistory called');
return await _getSearchHistory(podcastSearchKey);
}
/// Clears episode search history
Future<void> clearEpisodeSearchHistory() async {
await _clearSearchHistory(episodeSearchKey);
}
/// Clears podcast search history
Future<void> clearPodcastSearchHistory() async {
await _clearSearchHistory(podcastSearchKey);
}
/// Removes a specific term from episode search history
Future<void> removeEpisodeSearchTerm(String searchTerm) async {
await _removeSearchTerm(episodeSearchKey, searchTerm);
}
/// Removes a specific term from podcast search history
Future<void> removePodcastSearchTerm(String searchTerm) async {
await _removeSearchTerm(podcastSearchKey, searchTerm);
}
/// Internal method to add a search term to specified history type
Future<void> _addSearchTerm(String historyKey, String searchTerm) async {
if (searchTerm.trim().isEmpty) return;
final trimmedTerm = searchTerm.trim();
print('SearchHistoryService: Adding search term "$trimmedTerm" to $historyKey');
try {
final prefs = await SharedPreferences.getInstance();
// Get existing history
final historyJson = prefs.getString(historyKey);
List<String> history = [];
if (historyJson != null) {
final List<dynamic> decodedList = jsonDecode(historyJson);
history = decodedList.cast<String>();
}
print('SearchHistoryService: Existing data for $historyKey: $history');
// Remove if already exists (to avoid duplicates)
history.remove(trimmedTerm);
// Add to beginning (most recent first)
history.insert(0, trimmedTerm);
// Limit to max items
if (history.length > maxHistoryItems) {
history = history.take(maxHistoryItems).toList();
}
// Save updated history
await prefs.setString(historyKey, jsonEncode(history));
print('SearchHistoryService: Updated $historyKey with ${history.length} terms: $history');
} catch (e) {
print('SearchHistoryService: Failed to add search term to $historyKey: $e');
log.warning('Failed to add search term to $historyKey: $e');
}
}
/// Internal method to get search history for specified type
Future<List<String>> _getSearchHistory(String historyKey) async {
try {
final prefs = await SharedPreferences.getInstance();
final historyJson = prefs.getString(historyKey);
print('SearchHistoryService: Getting history for $historyKey: $historyJson');
if (historyJson != null) {
final List<dynamic> decodedList = jsonDecode(historyJson);
final history = decodedList.cast<String>();
print('SearchHistoryService: Returning history for $historyKey: $history');
return history;
}
} catch (e) {
print('SearchHistoryService: Failed to get search history for $historyKey: $e');
}
print('SearchHistoryService: Returning empty history for $historyKey');
return [];
}
/// Internal method to clear search history for specified type
Future<void> _clearSearchHistory(String historyKey) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(historyKey);
print('SearchHistoryService: Cleared search history for $historyKey');
} catch (e) {
print('SearchHistoryService: Failed to clear search history for $historyKey: $e');
}
}
/// Internal method to remove specific term from history
Future<void> _removeSearchTerm(String historyKey, String searchTerm) async {
try {
final prefs = await SharedPreferences.getInstance();
final historyJson = prefs.getString(historyKey);
if (historyJson == null) return;
final List<dynamic> decodedList = jsonDecode(historyJson);
List<String> history = decodedList.cast<String>();
history.remove(searchTerm);
if (history.isEmpty) {
await prefs.remove(historyKey);
} else {
await prefs.setString(historyKey, jsonEncode(history));
}
print('SearchHistoryService: Removed "$searchTerm" from $historyKey');
} catch (e) {
print('SearchHistoryService: Failed to remove search term from $historyKey: $e');
}
}
}

View File

@@ -0,0 +1,277 @@
// Copyright 2020 Ben Hills and the project contributors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:math';
import 'package:pinepods_mobile/core/environment.dart';
import 'package:pinepods_mobile/entities/app_settings.dart';
import 'package:pinepods_mobile/services/settings/settings_service.dart';
import 'package:rxdart/rxdart.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// An implementation [SettingsService] for mobile devices backed by
/// shared preferences.
class MobileSettingsService extends SettingsService {
static late SharedPreferences _sharedPreferences;
static MobileSettingsService? _instance;
final settingsNotifier = PublishSubject<String>();
MobileSettingsService._create();
static Future<MobileSettingsService?> instance() async {
if (_instance == null) {
_instance = MobileSettingsService._create();
_sharedPreferences = await SharedPreferences.getInstance();
}
return _instance;
}
@override
bool get markDeletedEpisodesAsPlayed => _sharedPreferences.getBool('markplayedasdeleted') ?? false;
@override
set markDeletedEpisodesAsPlayed(bool value) {
_sharedPreferences.setBool('markplayedasdeleted', value);
settingsNotifier.sink.add('markplayedasdeleted');
}
@override
String? get pinepodsServer => _sharedPreferences.getString('pinepods_server');
@override
set pinepodsServer(String? value) {
if (value == null) {
_sharedPreferences.remove('pinepods_server');
} else {
_sharedPreferences.setString('pinepods_server', value);
}
settingsNotifier.sink.add('pinepods_server');
}
@override
String? get pinepodsApiKey => _sharedPreferences.getString('pinepods_api_key');
@override
set pinepodsApiKey(String? value) {
if (value == null) {
_sharedPreferences.remove('pinepods_api_key');
} else {
_sharedPreferences.setString('pinepods_api_key', value);
}
settingsNotifier.sink.add('pinepods_api_key');
}
@override
int? get pinepodsUserId => _sharedPreferences.getInt('pinepods_user_id');
@override
set pinepodsUserId(int? value) {
if (value == null) {
_sharedPreferences.remove('pinepods_user_id');
} else {
_sharedPreferences.setInt('pinepods_user_id', value);
}
settingsNotifier.sink.add('pinepods_user_id');
}
@override
String? get pinepodsUsername => _sharedPreferences.getString('pinepods_username');
@override
set pinepodsUsername(String? value) {
if (value == null) {
_sharedPreferences.remove('pinepods_username');
} else {
_sharedPreferences.setString('pinepods_username', value);
}
settingsNotifier.sink.add('pinepods_username');
}
@override
String? get pinepodsEmail => _sharedPreferences.getString('pinepods_email');
@override
set pinepodsEmail(String? value) {
if (value == null) {
_sharedPreferences.remove('pinepods_email');
} else {
_sharedPreferences.setString('pinepods_email', value);
}
settingsNotifier.sink.add('pinepods_email');
}
@override
bool get deleteDownloadedPlayedEpisodes => _sharedPreferences.getBool('deleteDownloadedPlayedEpisodes') ?? false;
@override
set deleteDownloadedPlayedEpisodes(bool value) {
_sharedPreferences.setBool('deleteDownloadedPlayedEpisodes', value);
settingsNotifier.sink.add('deleteDownloadedPlayedEpisodes');
}
@override
bool get storeDownloadsSDCard => _sharedPreferences.getBool('savesdcard') ?? false;
@override
set storeDownloadsSDCard(bool value) {
_sharedPreferences.setBool('savesdcard', value);
settingsNotifier.sink.add('savesdcard');
}
@override
bool get themeDarkMode {
var theme = _sharedPreferences.getString('theme') ?? 'Dark';
return theme == 'Dark';
}
@override
set themeDarkMode(bool value) {
_sharedPreferences.setString('theme', value ? 'Dark' : 'Light');
settingsNotifier.sink.add('theme');
}
String get theme {
return _sharedPreferences.getString('theme') ?? 'Dark';
}
set theme(String value) {
_sharedPreferences.setString('theme', value);
settingsNotifier.sink.add('theme');
}
@override
set playbackSpeed(double playbackSpeed) {
_sharedPreferences.setDouble('speed', playbackSpeed);
settingsNotifier.sink.add('speed');
}
@override
double get playbackSpeed {
var speed = _sharedPreferences.getDouble('speed') ?? 1.0;
// We used to use 0.25 increments and now we use 0.1. Round
// any setting that uses the old 0.25.
var mod = pow(10.0, 1).toDouble();
return ((speed * mod).round().toDouble() / mod);
}
@override
set searchProvider(String provider) {
_sharedPreferences.setString('search', provider);
settingsNotifier.sink.add('search');
}
@override
String get searchProvider {
// If we do not have PodcastIndex key, fallback to iTunes
if (podcastIndexKey.isEmpty) {
return 'itunes';
} else {
return _sharedPreferences.getString('search') ?? 'itunes';
}
}
@override
set externalLinkConsent(bool consent) {
_sharedPreferences.setBool('elconsent', consent);
settingsNotifier.sink.add('elconsent');
}
@override
bool get externalLinkConsent {
return _sharedPreferences.getBool('elconsent') ?? false;
}
@override
set autoOpenNowPlaying(bool autoOpenNowPlaying) {
_sharedPreferences.setBool('autoopennowplaying', autoOpenNowPlaying);
settingsNotifier.sink.add('autoopennowplaying');
}
@override
bool get autoOpenNowPlaying {
return _sharedPreferences.getBool('autoopennowplaying') ?? false;
}
@override
set showFunding(bool show) {
_sharedPreferences.setBool('showFunding', show);
settingsNotifier.sink.add('showFunding');
}
@override
bool get showFunding {
return _sharedPreferences.getBool('showFunding') ?? true;
}
@override
set autoUpdateEpisodePeriod(int period) {
_sharedPreferences.setInt('autoUpdateEpisodePeriod', period);
settingsNotifier.sink.add('autoUpdateEpisodePeriod');
}
@override
int get autoUpdateEpisodePeriod {
/// Default to 3 hours.
return _sharedPreferences.getInt('autoUpdateEpisodePeriod') ?? 180;
}
@override
set trimSilence(bool trim) {
_sharedPreferences.setBool('trimSilence', trim);
settingsNotifier.sink.add('trimSilence');
}
@override
bool get trimSilence {
return _sharedPreferences.getBool('trimSilence') ?? false;
}
@override
set volumeBoost(bool boost) {
_sharedPreferences.setBool('volumeBoost', boost);
settingsNotifier.sink.add('volumeBoost');
}
@override
bool get volumeBoost {
return _sharedPreferences.getBool('volumeBoost') ?? false;
}
@override
set layoutMode(int mode) {
_sharedPreferences.setInt('layout', mode);
settingsNotifier.sink.add('layout');
}
@override
int get layoutMode {
return _sharedPreferences.getInt('layout') ?? 0;
}
@override
List<String> get bottomBarOrder {
final orderString = _sharedPreferences.getString('bottom_bar_order');
if (orderString != null) {
return orderString.split(',');
}
return ['Home', 'Feed', 'Saved', 'Podcasts', 'Downloads', 'History', 'Playlists', 'Search'];
}
@override
set bottomBarOrder(List<String> value) {
_sharedPreferences.setString('bottom_bar_order', value.join(','));
settingsNotifier.sink.add('bottom_bar_order');
}
@override
AppSettings? settings;
@override
Stream<String> get settingsListener => settingsNotifier.stream;
}

View File

@@ -0,0 +1,87 @@
// Copyright 2020 Ben Hills and the project contributors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:pinepods_mobile/entities/app_settings.dart';
abstract class SettingsService {
AppSettings? get settings;
set settings(AppSettings? settings);
bool get themeDarkMode;
set themeDarkMode(bool value);
String get theme;
set theme(String value);
bool get markDeletedEpisodesAsPlayed;
set markDeletedEpisodesAsPlayed(bool value);
bool get deleteDownloadedPlayedEpisodes;
set deleteDownloadedPlayedEpisodes(bool value);
bool get storeDownloadsSDCard;
set storeDownloadsSDCard(bool value);
set playbackSpeed(double playbackSpeed);
double get playbackSpeed;
set searchProvider(String provider);
String get searchProvider;
set externalLinkConsent(bool consent);
bool get externalLinkConsent;
set autoOpenNowPlaying(bool autoOpenNowPlaying);
bool get autoOpenNowPlaying;
set showFunding(bool show);
bool get showFunding;
set autoUpdateEpisodePeriod(int period);
int get autoUpdateEpisodePeriod;
set trimSilence(bool trim);
bool get trimSilence;
set volumeBoost(bool boost);
bool get volumeBoost;
set layoutMode(int mode);
int get layoutMode;
Stream<String> get settingsListener;
String? get pinepodsServer;
set pinepodsServer(String? value);
String? get pinepodsApiKey;
set pinepodsApiKey(String? value);
int? get pinepodsUserId;
set pinepodsUserId(int? value);
String? get pinepodsUsername;
set pinepodsUsername(String? value);
String? get pinepodsEmail;
set pinepodsEmail(String? value);
List<String> get bottomBarOrder;
set bottomBarOrder(List<String> value);
}