Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
3a43f01
refactor(oauth): promote PKCE helpers from dropbox to shared core/ser…
ericgriffin Jul 11, 2026
c53bf98
feat(lightroom): secure auth store for BYO Adobe credentials
ericgriffin Jul 11, 2026
346c7a9
fix(lightroom): package import in auth store
ericgriffin Jul 11, 2026
fb645b2
feat(lightroom): Adobe IMS OAuth2+PKCE auth manager
ericgriffin Jul 11, 2026
d88d9e4
style(lightroom): null-aware map elements in token forms
ericgriffin Jul 11, 2026
a531834
feat(lightroom): partner API client with abuse-guard stripping and pa…
ericgriffin Jul 11, 2026
0ff8aeb
style(lightroom): drop unused test import
ericgriffin Jul 11, 2026
3dd136b
feat(media): connector accounts repository (first consumer of the v72…
ericgriffin Jul 11, 2026
f383b7b
feat(media): connector columns on pending suggestions + suggestion CR…
ericgriffin Jul 11, 2026
cd7f0f2
feat(media): confidence-bearing timestamp matching on DivePhotoMatcher
ericgriffin Jul 11, 2026
4658a97
style(media): const DivePhotoMatcher call sites
ericgriffin Jul 11, 2026
64a14d1
feat(lightroom): per-device connector scan state in SharedPreferences
ericgriffin Jul 11, 2026
1c3f7a6
feat(lightroom): scan service - window merge, dedup, confident attach…
ericgriffin Jul 11, 2026
069660f
feat(media): connector resolver fills the serviceConnector registry slot
ericgriffin Jul 11, 2026
6f5e3ab
feat(lightroom): riverpod wiring, serviceConnector registry registrat…
ericgriffin Jul 11, 2026
1201133
feat(media-store): serviceConnector upload eligibility with thumb-onl…
ericgriffin Jul 11, 2026
265e58e
feat(lightroom): connect flow, settings page, route, and localized st…
ericgriffin Jul 11, 2026
fa30f34
feat(lightroom): scan actions on dive detail and trip overview, Open …
ericgriffin Jul 11, 2026
c5fe657
feat(lightroom): suggestion row with confirm and dismiss on dive detail
ericgriffin Jul 11, 2026
9916106
style(lightroom): clean suggestions-row test lints
ericgriffin Jul 11, 2026
c4e1e57
feat(lightroom): startup auto-poll trigger on the dive list page
ericgriffin Jul 11, 2026
bed668c
test(lightroom): raise patch coverage - settings page suite, dialog e…
ericgriffin Jul 11, 2026
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
51 changes: 49 additions & 2 deletions lib/core/database/database.dart
Original file line number Diff line number Diff line change
Expand Up @@ -952,7 +952,11 @@ class MediaSpecies extends Table {
Set<Column> get primaryKey => {id};
}

/// Pending photo suggestions for background scan feature
/// Pending photo suggestions for background scan feature.
///
/// v106: connector suggestions (Lightroom) reuse this table. For those
/// rows `connectorAccountId`/`remoteAssetId` are set and the remote asset
/// id is mirrored into the NOT NULL `platformAssetId` key column.
class PendingPhotoSuggestions extends Table {
TextColumn get id => text()();
TextColumn get diveId =>
Expand All @@ -962,6 +966,8 @@ class PendingPhotoSuggestions extends Table {
TextColumn get thumbnailPath => text().nullable()();
BoolColumn get dismissed => boolean().withDefault(const Constant(false))();
IntColumn get createdAt => integer()();
TextColumn get connectorAccountId => text().nullable()();
TextColumn get remoteAssetId => text().nullable()();

@override
Set<Column> get primaryKey => {id};
Expand Down Expand Up @@ -2153,7 +2159,7 @@ class AppDatabase extends _$AppDatabase {

/// The current schema version as a static constant so that pre-open checks
/// (e.g. version-mismatch guard) can reference it without an instance.
static const int currentSchemaVersion = 105;
static const int currentSchemaVersion = 106;

/// Every schema version that has a migration block in onUpgrade.
/// Used to calculate progress step counts. When adding a new migration,
Expand Down Expand Up @@ -2262,8 +2268,35 @@ class AppDatabase extends _$AppDatabase {
103,
104,
105,
106,
];

/// Idempotent DDL for the v106 connector-suggestion columns (Lightroom
/// auto-linking). Called from the v106 onUpgrade block and from the
/// beforeOpen backstop so a parallel-branch schema-version collision
/// cannot strand a database without them.
Future<void> _assertConnectorSuggestionColumns() async {
final cols = await customSelect(
"PRAGMA table_info('pending_photo_suggestions')",
).get();
// An empty PRAGMA result means the table itself is absent (only
// possible in minimal test fixtures); skip the ALTERs rather than fail.
if (cols.isEmpty) return;
final names = cols.map((c) => c.read<String>('name')).toSet();
if (!names.contains('connector_account_id')) {
await customStatement(
'ALTER TABLE pending_photo_suggestions '
'ADD COLUMN connector_account_id TEXT',
);
}
if (!names.contains('remote_asset_id')) {
await customStatement(
'ALTER TABLE pending_photo_suggestions '
'ADD COLUMN remote_asset_id TEXT',
);
}
}

/// Idempotent DDL for the v103 media store objects. Called from the v103
/// onUpgrade block and from the beforeOpen backstop so a parallel-branch
/// schema-version collision cannot strand a database without them.
Expand Down Expand Up @@ -5238,6 +5271,16 @@ class AppDatabase extends _$AppDatabase {
}
}
if (from < 105) await reportProgress();
if (from < 106) {
// Lightroom auto-linking: connector identity on pending photo
// suggestions. PRAGMA-guarded ALTERs keep this idempotent; the
// beforeOpen backstop re-asserts it against parallel-branch
// schema-version collisions (v104 weight planner and v105
// heading both renumbered this block already, the same disease
// the v103 comment documents).
await _assertConnectorSuggestionColumns();
}
if (from < 106) await reportProgress();
},
beforeOpen: (details) async {
// Enable foreign keys
Expand All @@ -5247,6 +5290,10 @@ class AppDatabase extends _$AppDatabase {
// self-guarding when the media table is absent).
await _assertMediaStoreSchema();

// v106 backstop: re-assert connector-suggestion columns (the helper
// is self-guarding when the suggestions table is absent).
await _assertConnectorSuggestionColumns();

// Built-in dive types are reference data: identical on every device and
// undeletable through DiveTypeRepository. Nothing else restores them --
// the seed runs only in onCreate and the one-shot v93 step -- yet a
Expand Down
6 changes: 6 additions & 0 deletions lib/core/router/app_router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ import 'package:submersion/features/backup/presentation/pages/backup_settings_pa
import 'package:submersion/features/settings/presentation/pages/cloud_sync_page.dart';
import 'package:submersion/features/media_store/presentation/pages/media_storage_page.dart';
import 'package:submersion/features/media_store/presentation/pages/transfers_page.dart';
import 'package:submersion/features/settings/presentation/pages/lightroom_settings_page.dart';
import 'package:submersion/features/settings/presentation/pages/s3_config_page.dart';
import 'package:submersion/features/settings/presentation/pages/fix_dive_times_page.dart';
import 'package:submersion/features/settings/presentation/pages/settings_page.dart';
Expand Down Expand Up @@ -918,6 +919,11 @@ final appRouterProvider = Provider<GoRouter>((ref) {
),
],
),
GoRoute(
path: 'lightroom',
name: 'lightroom',
builder: (context, state) => const LightroomSettingsPage(),
),
GoRoute(
path: 'fix-dive-times',
name: 'fixDiveTimes',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import 'package:http/http.dart' as http;
import 'package:submersion/core/services/cloud_storage/cloud_storage_provider.dart';
import 'package:submersion/core/services/cloud_storage/dropbox/dropbox_app.dart';
import 'package:submersion/core/services/cloud_storage/dropbox/dropbox_auth_store.dart';
import 'package:submersion/core/services/cloud_storage/dropbox/dropbox_pkce.dart';
import 'package:submersion/core/services/oauth/oauth_pkce.dart';
import 'package:submersion/core/services/logger_service.dart';

/// OAuth 2 PKCE lifecycle for Dropbox: authorize-URL construction, the
Expand Down
260 changes: 260 additions & 0 deletions lib/core/services/lightroom/adobe_ims_auth_manager.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
import 'dart:convert';

import 'package:http/http.dart' as http;

import 'package:submersion/core/services/cloud_storage/cloud_storage_provider.dart';
import 'package:submersion/core/services/lightroom/lightroom_auth_store.dart';
import 'package:submersion/core/services/logger_service.dart';
import 'package:submersion/core/services/oauth/oauth_pkce.dart';

/// OAuth 2 PKCE lifecycle against Adobe IMS for the Lightroom connector:
/// authorize-URL construction, the paste-the-redirected-URL code exchange,
/// in-memory access-token caching with single-flight refresh, disconnect.
///
/// Unlike Dropbox, IMS requires a redirect_uri. The app uses a fixed
/// documented URI; the browser lands there after consent and the user
/// pastes the full address (or just the code) back into the dialog.
/// IMS also rotates refresh tokens on refresh, so a rotated token is
/// persisted immediately.
class AdobeImsAuthManager {
AdobeImsAuthManager({
LightroomAuthStore? store,
http.Client? httpClient,
DateTime Function()? now,
String Function()? verifierGenerator,
}) : _store = store ?? LightroomAuthStore(),
_http = httpClient ?? http.Client(),
_now = now ?? DateTime.now,
_generateVerifier = verifierGenerator ?? generateCodeVerifier;

static final _log = LoggerService.forClass(AdobeImsAuthManager);

static final Uri _authorizeUri = Uri.parse(
'https://ims-na1.adobelogin.com/ims/authorize/v2',
);
static final Uri _tokenUri = Uri.parse(
'https://ims-na1.adobelogin.com/ims/token/v3',
);

/// Must match the redirect URI registered on the user's Adobe Developer
/// Console integration (BYO client id).
static const String redirectUri = 'https://submersion.app/lightroom/callback';

static const String scopes =
'openid,AdobeID,lr_partner_apis,lr_partner_rendition_apis,offline_access';

/// Refresh slightly before IMS's expiry so an access token is never
/// presented within its final minute.
static const Duration _expiryMargin = Duration(seconds: 60);

final LightroomAuthStore _store;
final http.Client _http;
final DateTime Function() _now;
final String Function() _generateVerifier;

String? _pendingVerifier;
String? _pendingClientId;
String? _pendingClientSecret;
String? _accessToken;
DateTime? _accessTokenExpiry;
Future<String>? _refreshInFlight;

/// The authorization code from pasted connect-dialog input: either the
/// raw code or the full redirected URL carrying a `code` parameter.
/// Null when the input is empty or a URL without a code (e.g. an error
/// redirect).
static String? extractAuthorizationCode(String input) {
final trimmed = input.trim();
if (trimmed.isEmpty) return null;
if (trimmed.contains('://')) {
final uri = Uri.tryParse(trimmed);
final code = uri?.queryParameters['code'];
return (code == null || code.isEmpty) ? null : code;
}
return trimmed;
}

/// Generates a fresh PKCE verifier and returns the IMS authorize URL to
/// open in the system browser.
Uri beginAuthorization({required String clientId, String? clientSecret}) {
if (clientId.trim().isEmpty) {
throw const CloudStorageException(
'Enter your Adobe client ID before connecting.',
);
}
final verifier = _generateVerifier();
_pendingVerifier = verifier;
_pendingClientId = clientId.trim();
_pendingClientSecret = (clientSecret == null || clientSecret.trim().isEmpty)
? null
: clientSecret.trim();
return _authorizeUri.replace(
queryParameters: {
'client_id': _pendingClientId!,
'scope': scopes,
'response_type': 'code',
'redirect_uri': redirectUri,
'code_challenge': codeChallengeS256(verifier),
'code_challenge_method': 'S256',
},
);
}

/// Exchanges the pasted redirected URL (or raw code) for tokens and
/// persists the connection. Requires a preceding [beginAuthorization]
/// in this session (the PKCE verifier is memory-only by design).
Future<LightroomAuthData> completeAuthorization(
String codeOrRedirectUrl,
) async {
final verifier = _pendingVerifier;
final clientId = _pendingClientId;
if (verifier == null || clientId == null) {
throw const CloudStorageException(
'No Adobe authorization is in progress. Reopen the connect dialog '
'and try again.',
);
}
final code = extractAuthorizationCode(codeOrRedirectUrl);
if (code == null) {
throw const CloudStorageException(
'No authorization code found. Paste the full address of the page '
'the browser landed on after signing in.',
);
}
final tokens = await _requestToken({
'grant_type': 'authorization_code',
'client_id': clientId,
'client_secret': ?_pendingClientSecret,
'code': code,
'code_verifier': verifier,
'redirect_uri': redirectUri,
});
final refreshToken = tokens['refresh_token'];
if (refreshToken is! String || refreshToken.isEmpty) {
throw const CloudStorageException(
'Adobe did not return a refresh token. Check that your integration '
'includes the offline_access scope.',
);
}
final auth = LightroomAuthData(
clientId: clientId,
clientSecret: _pendingClientSecret,
refreshToken: refreshToken,
);
await _store.save(auth);
_pendingVerifier = null;
_pendingClientId = null;
_pendingClientSecret = null;
_cacheAccessToken(tokens);
_log.info('Lightroom connected');
return auth;
}

/// A currently valid access token, refreshing through the stored refresh
/// token when needed. Concurrent callers share one refresh request.
Future<String> getAccessToken() {
final token = _accessToken;
final expiry = _accessTokenExpiry;
if (token != null && expiry != null && _now().isBefore(expiry)) {
return Future.value(token);
}
return _refreshInFlight ??= _refreshAccessToken().whenComplete(() {
_refreshInFlight = null;
});
}

/// Drops the cached access token so the next [getAccessToken] refreshes.
void invalidateAccessToken() {
_accessToken = null;
_accessTokenExpiry = null;
}

/// The stored connection, or null when Lightroom is not connected.
Future<LightroomAuthData?> loadAuth() => _store.load();

/// Persists updated connection data (catalog id, account labels).
Future<void> updateAuth(LightroomAuthData data) => _store.save(data);

/// Clears the stored connection. IMS offers no client-side revoke for
/// this flow; forgetting the refresh token ends the app's access.
Future<void> disconnect() async {
invalidateAccessToken();
await _store.clear();
_log.info('Lightroom disconnected');
}

Future<String> _refreshAccessToken() async {
final auth = await _store.load();
if (auth == null) {
throw const CloudStorageException(
'Lightroom is not connected. Connect Lightroom in Settings.',
);
}
final tokens = await _requestToken({
'grant_type': 'refresh_token',
'refresh_token': auth.refreshToken,
'client_id': auth.clientId,
'client_secret': ?auth.clientSecret,
});
final rotated = tokens['refresh_token'];
if (rotated is String &&
rotated.isNotEmpty &&
rotated != auth.refreshToken) {
await _store.save(auth.copyWith(refreshToken: rotated));
}
return _cacheAccessToken(tokens);
}

/// POSTs [form] to the IMS token endpoint and returns the decoded JSON.
/// 4xx means the grant was rejected (bad code, revoked refresh token);
/// the stored blob is intentionally NOT cleared -- only an explicit
/// disconnect destroys credentials.
Future<Map<String, Object?>> _requestToken(Map<String, String> form) async {
final http.Response response;
try {
response = await _http.post(_tokenUri, body: form);
} on Exception catch (e, st) {
throw CloudStorageException('Could not reach Adobe', e, st);
}
if (response.statusCode != 200) {
throw CloudStorageException(
response.statusCode >= 400 && response.statusCode < 500
? 'Adobe rejected the authorization. Reconnect Lightroom in '
'Settings.'
: 'Adobe authorization failed (${response.statusCode})',
_bodySummary(response),
);
}
final Object? decoded;
try {
decoded = jsonDecode(response.body);
} on FormatException {
throw const CloudStorageException(
'Unexpected response from Adobe authorization.',
);
}
if (decoded is! Map<String, Object?> ||
decoded['access_token'] is! String) {
throw const CloudStorageException(
'Unexpected response from Adobe authorization.',
);
}
return decoded;
}

String _cacheAccessToken(Map<String, Object?> tokens) {
final token = tokens['access_token'] as String;
final expiresIn = tokens['expires_in'];
final seconds = expiresIn is int ? expiresIn : 3600;
_accessToken = token;
_accessTokenExpiry = _now()
.add(Duration(seconds: seconds))
.subtract(_expiryMargin);
return token;
}

static String _bodySummary(http.Response response) {
final body = response.body;
return body.length <= 200 ? body : body.substring(0, 200);
}
}
Loading
Loading