Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ios/Flutter/Debug.xcconfig
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"
1 change: 1 addition & 0 deletions ios/Flutter/Release.xcconfig
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"
43 changes: 43 additions & 0 deletions ios/Podfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Uncomment this line to define a global platform for your project
# platform :ios, '13.0'

# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'

project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}

def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
end

File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
end

require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)

flutter_ios_podfile_setup

target 'Runner' do
use_frameworks!

flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
target 'RunnerTests' do
inherit! :search_paths
end
end

post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
end
end
13 changes: 13 additions & 0 deletions ios/Runner/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -45,5 +45,18 @@
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>com.sfo.fullstop</string>
<key>CFBundleURLSchemes</key>
<array>
<string>fullstop</string>
</array>
</dict>
</array>
</dict>
</plist>
69 changes: 69 additions & 0 deletions lib/application/di/auth_providers.dart
Original file line number Diff line number Diff line change
@@ -1,16 +1,37 @@
import 'dart:io';

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../core/services/oauth_service.dart';
import '../../data/datasources/credentials_local_datasource.dart';
import '../../data/datasources/credentials_shared_prefs_datasource.dart';
import '../../data/repositories/auth_repository_impl.dart';
import '../../domain/entities/proxy_settings.dart';
import '../../domain/repositories/auth_repository.dart';
import 'core_providers.dart';
import 'spotify_providers.dart';

/// Authentication-related providers

// SharedPreferences provider (async initialization required)
final sharedPreferencesProvider = FutureProvider<SharedPreferences>((ref) async {
return await SharedPreferences.getInstance();
});

// Credentials Local Data Source
// Uses SharedPreferences on macOS/iOS to avoid Keychain issues during development
final credentialsLocalDataSourceProvider = Provider<CredentialsLocalDataSource>(
(ref) {
// On macOS and iOS, use SharedPreferences to avoid Keychain/signing issues
if (Platform.isMacOS || Platform.isIOS) {
final prefsAsync = ref.watch(sharedPreferencesProvider);
return prefsAsync.when(
data: (prefs) => CredentialsSharedPrefsDataSource(prefs),
loading: () => _PlaceholderCredentialsDataSource(),
error: (_, __) => _PlaceholderCredentialsDataSource(),
);
}
// On other platforms, use secure storage
final secureStorage = ref.watch(secureStorageProvider);
return CredentialsLocalDataSourceImpl(secureStorage);
},
Expand Down Expand Up @@ -42,3 +63,51 @@ final authRepositoryProvider = Provider<AuthRepository>((ref) {
oauthService: oauthService,
);
});

/// Placeholder implementation while SharedPreferences is loading
class _PlaceholderCredentialsDataSource implements CredentialsLocalDataSource {
@override
Future<void> clearGetSongBpmApiKey() async {}
@override
Future<void> clearLlmCredentials() async {}
@override
Future<void> clearAppProxySettings() async {}
@override
Future<void> clearSpotifyCredentials() async {}
@override
Future<bool> getAudioFeaturesEnabled() async => false;
@override
Future<String?> getGetSongBpmApiKey() async => null;
@override
Future<bool> getGpuAccelerationEnabled() async => false;
@override
Future<String?> getLlmApiKey() async => null;
@override
Future<String?> getLlmBaseUrl() async => null;
@override
Future<String?> getLlmModel() async => null;
@override
Future<AppProxySettings> getAppProxySettings() async => const AppProxySettings();
@override
Future<String?> getSpotifyClientId() async => null;
@override
Future<String?> getSpotifyClientSecret() async => null;
@override
Future<bool> hasGetSongBpmApiKey() async => false;
@override
Future<bool> hasLlmConfig() async => false;
@override
Future<bool> hasSpotifyCredentials() async => false;
@override
Future<void> saveAppProxySettings(AppProxySettings config) async {}
@override
Future<void> saveLlmCredentials({String apiKey = '', required String model, required String baseUrl}) async {}
@override
Future<void> saveSpotifyCredentials({required String clientId, required String clientSecret}) async {}
@override
Future<void> setAudioFeaturesEnabled(bool enabled) async {}
@override
Future<void> setGetSongBpmApiKey(String apiKey) async {}
@override
Future<void> setGpuAccelerationEnabled(bool enabled) async {}
}
68 changes: 57 additions & 11 deletions lib/application/di/core_providers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'package:dio/dio.dart';
import 'package:dio/io.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:socks5_proxy/socks_client.dart' as socks5;
import '../../core/config/app_config.dart';
import '../../core/services/deep_link_service.dart';
Expand All @@ -13,11 +14,18 @@ import '../../core/services/url_launcher_service.dart';
import '../../core/services/window_service.dart';
import '../../core/utils/logger.dart';
import '../../data/datasources/auth_local_datasource.dart';
import '../../data/datasources/auth_shared_prefs_datasource.dart';
import '../../data/datasources/credentials_local_datasource.dart';
import '../../domain/entities/proxy_settings.dart';
import '../../data/services/app_links_deep_link_service.dart';
import '../../data/services/default_url_launcher_service.dart';
import '../../data/services/window_manager_service.dart';
import 'auth_providers.dart' show credentialsLocalDataSourceProvider;

// SharedPreferences provider for macOS/iOS
final sharedPrefsProvider = FutureProvider<SharedPreferences>((ref) async {
return await SharedPreferences.getInstance();
});

/// Core infrastructure providers
/// These are low-level services that other modules depend on
Expand All @@ -26,6 +34,10 @@ import '../../data/services/window_manager_service.dart';
final secureStorageProvider = Provider<FlutterSecureStorage>((ref) {
return const FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
mOptions: MacOsOptions(),
iOptions: IOSOptions(
accountName: 'com.sfo.fullstop',
),
);
});

Expand Down Expand Up @@ -114,23 +126,17 @@ final apiDioProvider = Provider<Dio>((ref) {
// Configure proxy if available
_configureDioProxy(dio, _currentProxyConfig);

final authLocalDataSource = ref.read(authLocalDataSourceProvider);
final secureStorage = ref.read(secureStorageProvider);
final credentialsLocalDataSource = CredentialsLocalDataSourceImpl(
secureStorage,
);
// Use ref.read inside interceptor to get fresh data source each time
// This ensures we don't use a stale placeholder when SharedPreferences loads
final authDio = ref.read(authDioProvider);

final tokenRefreshService = _getOrCreateTokenRefreshService(
authLocalDataSource,
credentialsLocalDataSource,
authDio,
);

dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) async {
// Dynamically get the data source to ensure we have the latest
final authLocalDataSource = ref.read(authLocalDataSourceProvider);
final token = await authLocalDataSource.getAccessToken();
AppLogger.info('API Request - Token available: ${token != null}');
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
Expand All @@ -140,6 +146,17 @@ final apiDioProvider = Provider<Dio>((ref) {
// Handle 401 Unauthorized - attempt to refresh token
if (error.response?.statusCode == 401) {
AppLogger.info('Received 401, attempting to refresh token...');
AppLogger.info('Request URL: ${error.requestOptions.uri}');

// Get fresh data sources for token refresh
final authLocalDataSource = ref.read(authLocalDataSourceProvider);
final credentialsDataSource = ref.read(credentialsLocalDataSourceProvider);

final tokenRefreshService = _getOrCreateTokenRefreshService(
authLocalDataSource,
credentialsDataSource,
authDio,
);

final newToken = await tokenRefreshService.refreshToken();

Expand Down Expand Up @@ -186,11 +203,40 @@ final miniPlayerServiceProvider = Provider<MiniPlayerService>((ref) {
});

// Auth Local Data Source (needed by apiDioProvider, so kept here)
// Uses SharedPreferences on macOS/iOS to avoid Keychain issues during development
final authLocalDataSourceProvider = Provider<AuthLocalDataSource>((ref) {
if (Platform.isMacOS || Platform.isIOS) {
final prefsAsync = ref.watch(sharedPrefsProvider);
return prefsAsync.when(
data: (prefs) => AuthSharedPrefsDataSource(prefs),
loading: () => _PlaceholderAuthDataSource(),
error: (_, __) => _PlaceholderAuthDataSource(),
);
}
final secureStorage = ref.watch(secureStorageProvider);
return AuthLocalDataSourceImpl(secureStorage);
});

/// Placeholder implementation while SharedPreferences is loading
class _PlaceholderAuthDataSource implements AuthLocalDataSource {
@override
Future<void> clearTokens() async {}
@override
Future<String?> getAccessToken() async => null;
@override
Future<String?> getRefreshToken() async => null;
@override
Future<DateTime?> getTokenExpiry() async => null;
@override
Future<bool> hasValidToken() async => false;
@override
Future<void> saveTokens({
required String accessToken,
required String refreshToken,
required DateTime expiry,
}) async {}
}

// Dio instance for LLM API calls (with proxy support)
final llmDioProvider = Provider<Dio>((ref) {
final dio = Dio(
Expand Down
82 changes: 82 additions & 0 deletions lib/data/datasources/auth_shared_prefs_datasource.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import 'package:shared_preferences/shared_preferences.dart';
import '../../core/constants/app_constants.dart';
import '../../core/errors/exceptions.dart';
import 'auth_local_datasource.dart';

/// Alternative AuthLocalDataSource implementation using SharedPreferences
/// Used on platforms where Keychain/SecureStorage is problematic (macOS/iOS dev)
class AuthSharedPrefsDataSource implements AuthLocalDataSource {
final SharedPreferences _prefs;

AuthSharedPrefsDataSource(this._prefs);

@override
Future<String?> getAccessToken() async {
try {
return _prefs.getString(AppConstants.accessTokenKey);
} catch (e) {
throw CacheException(message: 'Failed to read access token: $e');
}
}

@override
Future<String?> getRefreshToken() async {
try {
return _prefs.getString(AppConstants.refreshTokenKey);
} catch (e) {
throw CacheException(message: 'Failed to read refresh token: $e');
}
}

@override
Future<DateTime?> getTokenExpiry() async {
try {
final expiryStr = _prefs.getString(AppConstants.tokenExpiryKey);
if (expiryStr == null) return null;
return DateTime.parse(expiryStr);
} catch (e) {
throw CacheException(message: 'Failed to read token expiry: $e');
}
}

@override
Future<void> saveTokens({
required String accessToken,
required String refreshToken,
required DateTime expiry,
}) async {
try {
await _prefs.setString(AppConstants.accessTokenKey, accessToken);
await _prefs.setString(AppConstants.refreshTokenKey, refreshToken);
await _prefs.setString(AppConstants.tokenExpiryKey, expiry.toIso8601String());
} catch (e) {
throw CacheException(message: 'Failed to save tokens: $e');
}
}

@override
Future<void> clearTokens() async {
try {
await _prefs.remove(AppConstants.accessTokenKey);
await _prefs.remove(AppConstants.refreshTokenKey);
await _prefs.remove(AppConstants.tokenExpiryKey);
} catch (e) {
throw CacheException(message: 'Failed to clear tokens: $e');
}
}

@override
Future<bool> hasValidToken() async {
try {
final accessToken = await getAccessToken();
final expiry = await getTokenExpiry();

if (accessToken == null || expiry == null) return false;

// Token is valid if it expires more than 5 minutes from now
return expiry.isAfter(DateTime.now().add(const Duration(minutes: 5)));
} catch (e) {
return false;
}
}
}
Loading
Loading