diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 5ec2f5d..2f19796 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -23,6 +23,9 @@ Use `just` for common workflows: | `just gen` | Run `build_runner` for code generation | | `just check` | Format, lint, and test in sequence | +For release versioning, signing, packaging, and distribution, see the +[release documentation](docs/release.md). + ## Website The public website lives in `www` as an Astro project. diff --git a/docs/release.md b/docs/release.md index 383fea6..e531621 100644 --- a/docs/release.md +++ b/docs/release.md @@ -1,6 +1,6 @@ --- title: Release and Distribution Guide -updated: 2026-05-02 +updated: 2026-05-12 --- ## Shared Release Baseline @@ -19,6 +19,45 @@ updated: 2026-05-02 - `git tag vX.Y.Z` 5. Build store artifacts from that exact tag/commit. +## Versioning + +Use `pubspec.yaml` as the tracked source of truth for local builds: + +```yaml +version: 1.0.0+6 +``` + +The part before `+` is Flutter's build name. Keep it numeric and App +Store-safe because iOS maps it to `CFBundleShortVersionString`. Do not put +prerelease text such as `-alpha.6` in the build name or iOS +`MARKETING_VERSION`. + +The part after `+` is Flutter's build number. It maps to Android +`versionCode` and iOS `CFBundleVersion`, so it must be a monotonically +increasing integer for every uploaded build. It is not automatically "commits +since tag." Use the store or CI build sequence as the source of truth. For a +`v1.0.0-alpha.6` tag, build number `6` is valid only if `6` is the next upload +number for that app ID/package. If building from commits after the tag, assign +the next unused build number instead of reusing the tag's ordinal. + +Keep platform files aligned: + +- iOS: update the Runner target `MARKETING_VERSION` in + `ios/Runner.xcodeproj/project.pbxproj` to the numeric public version, for + example `1.0.0`. `Info.plist` should continue to read + `$(FLUTTER_BUILD_NAME)` and `$(FLUTTER_BUILD_NUMBER)`. +- Android: keep `android/app/build.gradle.kts` reading `versionName` and + `versionCode` from Flutter (`flutter.versionName` and `flutter.versionCode`). + +For prerelease UI labels, update `AppVersion.prereleaseLabel` in +`lib/core/app/app_version.dart`. With `version: 1.0.0+6` and label `alpha`, the +app renders `Lazurite v1.0.0 alpha 6`. + +After changing versions, run `flutter pub get`, then build from Flutter or the +IDE once so ignored local generated files such as +`ios/Flutter/Generated.xcconfig` and `android/local.properties` reflect the +current build name and number. + ## Environment Variables Use the root `.env.example` as the canonical variable list. Keep real values in untracked secrets (`.env.local`, CI secrets manager, etc.). @@ -252,6 +291,9 @@ Keep push-enabled binaries for Play/App Store/AltStore/Obtainium, and maintain a - Flutter Android release: - Flutter iOS release: +- Apple bundle short version (`CFBundleShortVersionString`): +- Apple build version (`CFBundleVersion`): +- Android app versioning: - Android signing + Play App Signing: - Play App Signing help: - App Store Connect uploads: diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 0c07766..36b989c 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -554,7 +554,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = "1.0.0-alpha"; + MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = org.stormlightlabs.lazurite; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -745,7 +745,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = "1.0.0-alpha"; + MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = org.stormlightlabs.lazurite; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -776,7 +776,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = "1.0.0-alpha"; + MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = org.stormlightlabs.lazurite; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/lib/core/app/app_version.dart b/lib/core/app/app_version.dart new file mode 100644 index 0000000..a1f76a0 --- /dev/null +++ b/lib/core/app/app_version.dart @@ -0,0 +1,65 @@ +import 'package:package_info_plus/package_info_plus.dart'; + +final class AppVersion { + const AppVersion._(); + + static const prereleaseLabel = 'alpha'; + + static Future displayLabel() async { + final packageInfo = await PackageInfo.fromPlatform(); + return displayLabelFor(packageInfo); + } + + static String displayLabelFor(PackageInfo packageInfo, {String? prereleaseLabel = AppVersion.prereleaseLabel}) { + final rawAppName = packageInfo.appName.trim(); + final appName = rawAppName.isEmpty ? 'Lazurite' : rawAppName; + final rawVersion = packageInfo.version.trim(); + final version = rawVersion.isEmpty ? '1.0.0' : rawVersion; + final buildNumber = packageInfo.buildNumber.trim(); + final parsedVersion = _ParsedVersion.parse(version); + final hasBuildNumber = buildNumber.isNotEmpty && buildNumber != version; + final channel = + parsedVersion.releaseChannel ?? + (hasBuildNumber ? _prereleaseChannelFromBuild(buildNumber, prereleaseLabel) : null); + + if (channel != null && channel.isNotEmpty) { + final channelIncludesBuildNumber = hasBuildNumber && channel.endsWith(' $buildNumber'); + final channelHasNumber = RegExp(r'(^| )\d+$').hasMatch(channel); + final suffix = !hasBuildNumber || channelIncludesBuildNumber + ? '' + : channelHasNumber + ? ' (build $buildNumber)' + : ' $buildNumber'; + return '$appName v${parsedVersion.baseVersion} $channel$suffix'; + } + + final buildSuffix = hasBuildNumber ? ' (build $buildNumber)' : ''; + return '$appName v$version$buildSuffix'; + } + + static String? _prereleaseChannelFromBuild(String buildNumber, String? prereleaseLabel) { + final label = prereleaseLabel?.trim(); + if (label == null || label.isEmpty || buildNumber.isEmpty) { + return null; + } + return '$label $buildNumber'; + } +} + +final class _ParsedVersion { + const _ParsedVersion({required this.baseVersion, required this.releaseChannel}); + + final String baseVersion; + final String? releaseChannel; + + static _ParsedVersion parse(String version) { + final separatorIndex = version.indexOf('-'); + if (separatorIndex <= 0 || separatorIndex == version.length - 1) { + return _ParsedVersion(baseVersion: version, releaseChannel: null); + } + + final baseVersion = version.substring(0, separatorIndex); + final releaseChannel = version.substring(separatorIndex + 1).replaceAll(RegExp(r'[-_.]+'), ' ').trim(); + return _ParsedVersion(baseVersion: baseVersion, releaseChannel: releaseChannel.isEmpty ? null : releaseChannel); + } +} diff --git a/lib/core/app/app_version_label.dart b/lib/core/app/app_version_label.dart new file mode 100644 index 0000000..d212a7c --- /dev/null +++ b/lib/core/app/app_version_label.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:lazurite/core/app/app_version.dart'; + +class AppVersionLabel extends StatefulWidget { + const AppVersionLabel({super.key, this.textAlign, this.loadDisplayLabel}); + + static const placeholderLabel = 'App version'; + static const errorLabel = 'Version unavailable'; + + final TextAlign? textAlign; + final Future Function()? loadDisplayLabel; + + @override + State createState() => _AppVersionLabelState(); +} + +class _AppVersionLabelState extends State { + late final Future _displayLabel = (widget.loadDisplayLabel ?? AppVersion.displayLabel)(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return FutureBuilder( + future: _displayLabel, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Tooltip( + message: 'Unable to load app version', + child: Text( + AppVersionLabel.errorLabel, + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error), + textAlign: widget.textAlign, + ), + ); + } + + final label = snapshot.data; + return Text( + label == null || label.isEmpty ? AppVersionLabel.placeholderLabel : label, + style: theme.textTheme.bodySmall, + textAlign: widget.textAlign, + ); + }, + ); + } +} diff --git a/lib/features/auth/data/auth_repository.dart b/lib/features/auth/data/auth_repository.dart index 02cc4b8..424ccc8 100644 --- a/lib/features/auth/data/auth_repository.dart +++ b/lib/features/auth/data/auth_repository.dart @@ -30,6 +30,17 @@ typedef OAuthRefreshSession = }); typedef AppPasswordRefreshSession = Future> Function({required String refreshJwt, String? service}); +typedef OAuthAuthorizeSession = Future<(Uri, OAuthContext)> Function(OAuthClient client, String? identity); +typedef OAuthCallbackSession = + Future Function(OAuthClient client, String callbackUrl, OAuthContext context); +typedef OAuthTokenBuilder = + Future Function( + OAuthSession session, { + required String fallbackHandle, + required String fallbackPdsHost, + required String oauthService, + String? oauthClientId, + }); final class AuthIdentifierResolutionException implements Exception { const AuthIdentifierResolutionException(this.message); @@ -65,24 +76,33 @@ class AuthRepository { SupportsCloseForMode supportsCloseForMode = supportsCloseForLaunchMode, OAuthRefreshSession oauthRefreshSession = _defaultOAuthRefreshSession, AppPasswordRefreshSession appPasswordRefreshSession = _defaultAppPasswordRefreshSession, + OAuthAuthorizeSession oauthAuthorizeSession = _defaultOAuthAuthorizeSession, + OAuthCallbackSession oauthCallbackSession = _defaultOAuthCallbackSession, + OAuthTokenBuilder? oauthTokenBuilder, Future Function(String clientId) loadClientMetadata = getClientMetadata, String Function()? oauthServiceResolver, bool Function()? slingshotIdentityFallbackEnabledResolver, SlingshotClient? slingshotClient, Future Function(String handle)? resolveHandleDid, Future> Function(String did)? resolveDidDocument, + Future Function(String pdsHost)? resolveAuthorizationServiceForPdsHost, }) : _database = database, _launchUrlWithMode = launchUrlWithMode, _closeInAppBrowser = closeInAppBrowser, _supportsCloseForMode = supportsCloseForMode, _oauthRefreshSession = oauthRefreshSession, _appPasswordRefreshSession = appPasswordRefreshSession, + _oauthAuthorizeSession = oauthAuthorizeSession, + _oauthCallbackSession = oauthCallbackSession, + _oauthTokenBuilder = oauthTokenBuilder, _loadClientMetadata = loadClientMetadata, _oauthServiceResolver = oauthServiceResolver ?? _defaultOAuthServiceResolver, _slingshotIdentityFallbackEnabledResolver = slingshotIdentityFallbackEnabledResolver ?? _defaultFalse, _slingshotClient = slingshotClient ?? SlingshotClient(), _resolveHandleDid = resolveHandleDid, - _resolveDidDocumentOverride = resolveDidDocument; + _resolveDidDocumentOverride = resolveDidDocument, + _resolveAuthorizationServiceForPdsHost = + resolveAuthorizationServiceForPdsHost ?? _defaultResolveAuthorizationServiceForPdsHost; static const String kClientId = 'https://lazurite.stormlightlabs.org/client-metadata.json'; static const String _oauthService = 'bsky.social'; @@ -108,12 +128,16 @@ class AuthRepository { final SupportsCloseForMode _supportsCloseForMode; final OAuthRefreshSession _oauthRefreshSession; final AppPasswordRefreshSession _appPasswordRefreshSession; + final OAuthAuthorizeSession _oauthAuthorizeSession; + final OAuthCallbackSession _oauthCallbackSession; + final OAuthTokenBuilder? _oauthTokenBuilder; final Future Function(String clientId) _loadClientMetadata; final String Function() _oauthServiceResolver; final bool Function() _slingshotIdentityFallbackEnabledResolver; final SlingshotClient _slingshotClient; final Future Function(String handle)? _resolveHandleDid; final Future> Function(String did)? _resolveDidDocumentOverride; + final Future Function(String pdsHost) _resolveAuthorizationServiceForPdsHost; Completer? _oauthCompleter; OAuthClient? _pendingOAuthClient; @@ -273,7 +297,7 @@ class AuthRepository { metadata.copyWith(redirectUris: [redirectUri.toString()]), service: oauthService, ); - final (authorizationUrl, context) = await oauthClient.authorize(_pendingHandle); + final (authorizationUrl, context) = await _oauthAuthorizeSession(oauthClient, _pendingHandle); _pendingService = oauthService; _pendingOAuthClient = oauthClient; @@ -562,18 +586,32 @@ class AuthRepository { } final callbackUri = Uri.parse(callbackUrl); + final exchangeService = oauthCallbackExchangeService(pendingService: service, callbackUri: callbackUri); + final normalizedPendingService = normalizeAtprotoServiceHost(service) ?? service; + final exchangeClient = exchangeService == normalizedPendingService + ? oauthClient + : OAuthClient(oauthClient.metadata, service: exchangeService); + + if (exchangeClient.service != oauthClient.service) { + log.w( + 'AuthRepository: OAuth callback issuer ${exchangeClient.service} differs from pending auth service ' + '${oauthClient.service}; exchanging authorization code at callback issuer.', + ); + } + log.d( 'AuthRepository: Exchanging OAuth callback for session using ' '${callbackUri.path} with query keys: ${callbackUri.queryParameters.keys.join(', ')}', ); - final oauthSession = await oauthClient.callback(callbackUrl, oauthContext); + final oauthSession = await _oauthCallbackSession(exchangeClient, callbackUrl, oauthContext); log.i('AuthRepository: OAuth token exchange succeeded for DID ${oauthSession.sub}'); - final tokens = await _buildOAuthTokens( + final buildTokens = _oauthTokenBuilder ?? _buildOAuthTokens; + final tokens = await buildTokens( oauthSession, fallbackHandle: fallbackHandle, fallbackPdsHost: _fallbackService, - oauthService: service, - oauthClientId: oauthClient.metadata.clientId, + oauthService: exchangeClient.service, + oauthClientId: exchangeClient.metadata.clientId, ); await saveSession(tokens, makeActive: true); log.i('AuthRepository: OAuth login completed for ${tokens.handle}'); @@ -782,7 +820,7 @@ class AuthRepository { return AuthIdentifierResolutionException('Unable to resolve "$identifier". $sanitizedMessage'); } - Future _resolveAuthorizationServiceForPdsHost(String pdsHost) async { + static Future _defaultResolveAuthorizationServiceForPdsHost(String pdsHost) async { final normalizedPdsHost = normalizeAtprotoServiceHost(pdsHost); if (normalizedPdsHost == null) { return null; @@ -1040,7 +1078,7 @@ class AuthRepository { ); } - String _sanitizeUriForLog(Uri uri) { + static String _sanitizeUriForLog(Uri uri) { return uri.replace(query: null, fragment: null).toString(); } @@ -1257,6 +1295,18 @@ class AuthRepository { return atp.refreshSession(refreshJwt: refreshJwt, service: service); } + static Future<(Uri, OAuthContext)> _defaultOAuthAuthorizeSession(OAuthClient client, String? identity) { + return client.authorize(identity); + } + + static Future _defaultOAuthCallbackSession( + OAuthClient client, + String callbackUrl, + OAuthContext context, + ) { + return client.callback(callbackUrl, context); + } + static String _defaultOAuthServiceResolver() { return AppViewProviders.descriptorForSetting(AppViewProviders.defaultKey).entrywayUrl.host; } @@ -1318,19 +1368,29 @@ class AuthRepository { candidates.add(resolvedAuthHost); } - final resolvedHost = normalizeAtprotoServiceHost(resolvedPdsHost); - if (resolvedHost != null) { - candidates.add(resolvedHost); + final preferredHost = normalizeAtprotoServiceHost(preferredAuthService); + if (preferredHost != null) { + candidates.add(preferredHost); } candidates.add(_oauthService); - final preferredHost = normalizeAtprotoServiceHost(preferredAuthService); - if (preferredHost != null) { - candidates.add(preferredHost); + final resolvedHost = normalizeAtprotoServiceHost(resolvedPdsHost); + if (resolvedHost != null) { + candidates.add(resolvedHost); } candidates.add(_fallbackService); return candidates.toList(growable: false); } + + @visibleForTesting + static String oauthCallbackExchangeService({required String pendingService, required Uri callbackUri}) { + final issuerHost = normalizeAtprotoServiceHost(callbackUri.queryParameters['iss']); + if (issuerHost != null) { + return issuerHost; + } + + return normalizeAtprotoServiceHost(pendingService) ?? pendingService; + } } diff --git a/lib/features/settings/presentation/about_screen.dart b/lib/features/settings/presentation/about_screen.dart index ea7faed..4984e74 100644 --- a/lib/features/settings/presentation/about_screen.dart +++ b/lib/features/settings/presentation/about_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import 'package:lazurite/core/app/app_version_label.dart'; import 'package:url_launcher/url_launcher.dart'; class AboutScreen extends StatelessWidget { @@ -94,7 +95,7 @@ class AboutScreen extends StatelessWidget { ], ), const SizedBox(height: 32), - Center(child: Text('Lazurite v1.0.0', style: theme.textTheme.bodySmall)), + const Center(child: AppVersionLabel()), ], ), ); diff --git a/lib/features/settings/presentation/privacy_policy_screen.dart b/lib/features/settings/presentation/privacy_policy_screen.dart index 221e057..4881291 100644 --- a/lib/features/settings/presentation/privacy_policy_screen.dart +++ b/lib/features/settings/presentation/privacy_policy_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import 'package:lazurite/core/app/app_version_label.dart'; import 'package:lazurite/features/settings/presentation/widgets/contact_section.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -107,7 +108,7 @@ class PrivacyPolicyScreen extends StatelessWidget { ), ContactSection(onStormlightLabsTap: () => _launch(_websiteUrl), onEmailTap: () => _launch(_emailUrl)), const SizedBox(height: 12), - Center(child: Text('Lazurite v1.0.0', style: theme.textTheme.bodySmall)), + const Center(child: AppVersionLabel()), ], ), ); diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart index cba03bd..eb2c6fa 100644 --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/app/app_version_label.dart'; import 'package:lazurite/core/cache/local_cache_maintenance_service.dart'; import 'package:lazurite/core/crash_reporting/crash_reporting_service.dart'; import 'package:lazurite/core/l10n/l10n.dart'; @@ -184,7 +185,7 @@ class SettingsScreen extends StatelessWidget { ), ], const SizedBox(height: 24), - Center(child: Text('Lazurite v1.0.0', style: context.textTheme.bodySmall)), + const Center(child: AppVersionLabel()), const SizedBox(height: 24), ], ), diff --git a/lib/features/settings/presentation/terms_of_service_screen.dart b/lib/features/settings/presentation/terms_of_service_screen.dart index 4c45e26..77ee089 100644 --- a/lib/features/settings/presentation/terms_of_service_screen.dart +++ b/lib/features/settings/presentation/terms_of_service_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import 'package:lazurite/core/app/app_version_label.dart'; import 'package:lazurite/features/settings/presentation/widgets/contact_section.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -123,7 +124,7 @@ class TermsOfServiceScreen extends StatelessWidget { ), ContactSection(onStormlightLabsTap: () => _launch(_websiteUrl), onEmailTap: () => _launch(_emailUrl)), const SizedBox(height: 12), - Center(child: Text('Lazurite v1.0.0', style: textTheme.bodySmall)), + const Center(child: AppVersionLabel()), ], ), ); diff --git a/pubspec.lock b/pubspec.lock index aa0d972..dc3c47f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1030,7 +1030,7 @@ packages: source: hosted version: "2.2.0" package_info_plus: - dependency: transitive + dependency: "direct main" description: name: package_info_plus sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20" diff --git a/pubspec.yaml b/pubspec.yaml index cb3c72c..c91e7c0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: lazurite description: A material BlueSky client. publish_to: "none" -version: 1.0.0+1 +version: 1.0.0+6 environment: sdk: ^3.10.1 @@ -24,6 +24,7 @@ dependencies: drift_flutter: ^0.2.8 go_router: ^17.1.0 path_provider: ^2.1.5 + package_info_plus: ^9.0.1 path: ^1.9.0 equatable: ^2.0.7 freezed_annotation: ^3.1.0 diff --git a/test/core/app/app_version_label_test.dart b/test/core/app/app_version_label_test.dart new file mode 100644 index 0000000..c134c3a --- /dev/null +++ b/test/core/app/app_version_label_test.dart @@ -0,0 +1,38 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/core/app/app_version_label.dart'; + +void main() { + group('AppVersionLabel', () { + testWidgets('reserves visible text while version metadata is loading', (tester) async { + final completer = Completer(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: AppVersionLabel(loadDisplayLabel: () => completer.future)), + ), + ); + + expect(find.text(AppVersionLabel.placeholderLabel), findsOneWidget); + + completer.complete('Lazurite Nightly v1.0.0 alpha 6'); + await tester.pump(); + + expect(find.text('Lazurite Nightly v1.0.0 alpha 6'), findsOneWidget); + }); + + testWidgets('shows a visible error label when version metadata fails', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: AppVersionLabel(loadDisplayLabel: () async => throw Exception('metadata unavailable'))), + ), + ); + await tester.pump(); + + expect(find.text(AppVersionLabel.errorLabel), findsOneWidget); + expect(find.byTooltip('Unable to load app version'), findsOneWidget); + }); + }); +} diff --git a/test/core/app/app_version_test.dart b/test/core/app/app_version_test.dart new file mode 100644 index 0000000..0072edb --- /dev/null +++ b/test/core/app/app_version_test.dart @@ -0,0 +1,108 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/core/app/app_version.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +void main() { + group('AppVersion', () { + test('shows current prerelease label for platform-safe numeric versions', () { + final label = AppVersion.displayLabelFor( + PackageInfo( + appName: 'Lazurite', + packageName: 'org.stormlightlabs.lazurite', + version: '1.0.0', + buildNumber: '6', + ), + ); + + expect(label, equals('Lazurite v1.0.0 alpha 6')); + }); + + test('uses app name from package metadata', () { + final label = AppVersion.displayLabelFor( + PackageInfo( + appName: 'Lazurite Nightly', + packageName: 'org.stormlightlabs.lazurite.nightly', + version: '1.0.0', + buildNumber: '6', + ), + ); + + expect(label, equals('Lazurite Nightly v1.0.0 alpha 6')); + }); + + test('falls back to Lazurite when package app name is empty', () { + final label = AppVersion.displayLabelFor( + PackageInfo(appName: ' ', packageName: 'org.stormlightlabs.lazurite', version: '1.0.0', buildNumber: '6'), + ); + + expect(label, equals('Lazurite v1.0.0 alpha 6')); + }); + + test('shows prerelease channel and build number together', () { + final label = AppVersion.displayLabelFor( + PackageInfo( + appName: 'Lazurite', + packageName: 'org.stormlightlabs.lazurite', + version: '1.0.0-alpha.6', + buildNumber: '6', + ), + ); + + expect(label, equals('Lazurite v1.0.0 alpha 6')); + }); + + test('uses build number as prerelease number when version has only the channel', () { + final label = AppVersion.displayLabelFor( + PackageInfo( + appName: 'Lazurite', + packageName: 'org.stormlightlabs.lazurite', + version: '1.0.0-alpha', + buildNumber: '6', + ), + ); + + expect(label, equals('Lazurite v1.0.0 alpha 6')); + }); + + test('shows native build separately when prerelease number differs', () { + final label = AppVersion.displayLabelFor( + PackageInfo( + appName: 'Lazurite', + packageName: 'org.stormlightlabs.lazurite', + version: '1.0.0-alpha.6', + buildNumber: '42', + ), + ); + + expect(label, equals('Lazurite v1.0.0 alpha 6 (build 42)')); + }); + + test('shows build number for stable versions', () { + final label = AppVersion.displayLabelFor( + PackageInfo( + appName: 'Lazurite', + packageName: 'org.stormlightlabs.lazurite', + version: '1.0.0', + buildNumber: '42', + ), + prereleaseLabel: null, + ); + + expect(label, equals('Lazurite v1.0.0 (build 42)')); + }); + + test('omits duplicate iOS build number fallback', () { + final label = AppVersion.displayLabelFor( + PackageInfo( + appName: 'Lazurite', + packageName: 'org.stormlightlabs.lazurite', + version: '1.0.0', + buildNumber: '1.0.0', + ), + prereleaseLabel: null, + ); + + expect(label, equals('Lazurite v1.0.0')); + }); + }); +} diff --git a/test/features/auth/data/auth_repository_test.dart b/test/features/auth/data/auth_repository_test.dart index 7c41e96..e860b50 100644 --- a/test/features/auth/data/auth_repository_test.dart +++ b/test/features/auth/data/auth_repository_test.dart @@ -1003,14 +1003,14 @@ void main() { }); group('oauth authorize candidates', () { - test('prioritizes resolved auth service before provider preference', () { + test('prioritizes resolved auth service, then preferred auth hosts, before PDS fallback', () { final candidates = AuthRepository.oauthAuthorizeServiceCandidates( preferredAuthService: 'blacksky.community', resolvedPdsHost: 'https://porcini.us-east.host.bsky.network', resolvedAuthService: 'https://bsky.social', ); - expect(candidates, equals(['bsky.social', 'porcini.us-east.host.bsky.network', 'blacksky.community'])); + expect(candidates, equals(['bsky.social', 'blacksky.community', 'porcini.us-east.host.bsky.network'])); }); test('deduplicates when preferred and resolved hosts match defaults', () { @@ -1024,6 +1024,125 @@ void main() { }); }); + group('oauth callback exchange service', () { + test('uses callback issuer when present', () { + final service = AuthRepository.oauthCallbackExchangeService( + pendingService: 'porcini.us-east.host.bsky.network', + callbackUri: Uri.parse( + 'https://lazurite.stormlightlabs.org/oauth/callback?code=abc&state=xyz&iss=https%3A%2F%2Fbsky.social', + ), + ); + + expect(service, equals('bsky.social')); + }); + + test('falls back to pending auth service when callback has no issuer', () { + final service = AuthRepository.oauthCallbackExchangeService( + pendingService: 'https://auth.example.com', + callbackUri: Uri.parse('org.stormlightlabs.lazurite:/oauth/callback?code=abc&state=xyz'), + ); + + expect(service, equals('auth.example.com')); + }); + + test('redeems callback with issuer host when it differs from launched auth service', () async { + final authorizeServices = []; + final callbackServices = []; + final launchedUrls = []; + + authRepository = AuthRepository( + database: mockDatabase, + loadClientMetadata: (_) async => _testClientMetadata(), + oauthServiceResolver: () => 'pending-auth.example', + resolveHandleDid: (_) async => 'did:plc:alice', + resolveDidDocument: (_) async => const { + 'service': [ + { + 'id': '#atproto_pds', + 'type': 'AtprotoPersonalDataServer', + 'serviceEndpoint': 'https://porcini.us-east.host.bsky.network', + }, + ], + }, + resolveAuthorizationServiceForPdsHost: (_) async => null, + launchUrlWithMode: (url, _) async { + launchedUrls.add(url); + return true; + }, + oauthAuthorizeSession: (client, identity) async { + authorizeServices.add(client.service); + expect(identity, equals('alice.bsky.social')); + return ( + Uri.https(client.service, '/oauth/authorize', const {'request_uri': 'urn:request'}), + const OAuthContext(codeVerifier: 'verifier', state: 'state', dpopNonce: 'nonce'), + ); + }, + oauthCallbackSession: (client, callbackUrl, context) async { + callbackServices.add(client.service); + expect(context.state, equals('state')); + expect(Uri.parse(callbackUrl).queryParameters['code'], equals('abc')); + return OAuthSession( + accessToken: 'access', + refreshToken: 'refresh', + tokenType: 'DPoP', + scope: 'atproto', + expiresAt: DateTime.now().add(const Duration(hours: 1)), + sub: 'did:plc:alice', + $dPoPNonce: 'next-nonce', + $publicKey: 'public-key', + $privateKey: 'private-key', + ); + }, + oauthTokenBuilder: + ( + session, { + required fallbackHandle, + required fallbackPdsHost, + required oauthService, + oauthClientId, + }) async => AuthTokens( + accessToken: session.accessToken, + refreshToken: session.refreshToken, + expiresAt: session.expiresAt, + did: session.sub, + handle: fallbackHandle, + service: fallbackPdsHost, + oauthService: oauthService, + oauthClientId: oauthClientId, + dpopNonce: session.$dPoPNonce, + dpopPublicKey: session.$publicKey, + dpopPrivateKey: session.$privateKey, + authMethod: AuthMethod.oauth, + ), + ); + when(() => mockDatabase.insertAccount(any())).thenAnswer((_) async => 1); + when( + () => mockDatabase.setSetting(AppDatabase.activeAccountDidSettingKey, 'did:plc:alice'), + ).thenAnswer((_) async => 1); + + final loginFuture = authRepository.loginWithOAuth('alice.bsky.social'); + await Future.delayed(Duration.zero); + + expect(authorizeServices, equals(['pending-auth.example'])); + expect(launchedUrls, hasLength(1)); + + final handled = await authRepository.completeOAuthCallbackFromUri( + Uri.parse( + 'https://lazurite.stormlightlabs.org/oauth/callback?code=abc&state=state&iss=https%3A%2F%2Fbsky.social', + ), + ); + + expect(handled, isTrue); + final tokens = await loginFuture; + + expect(callbackServices, equals(['bsky.social'])); + expect(tokens, isNotNull); + expect(tokens!.oauthService, equals('bsky.social')); + verify(() => mockDatabase.insertAccount(any())).called(1); + verify(() => mockDatabase.setSetting(AppDatabase.activeAccountDidSettingKey, 'did:plc:alice')).called(1); + }); + }); + group('slingshot identity fallback', () { test('does not use slingshot fallback when disabled', () async { authRepository = AuthRepository( diff --git a/test/features/settings/presentation/about_screen_test.dart b/test/features/settings/presentation/about_screen_test.dart index 884c86e..3baa4bf 100644 --- a/test/features/settings/presentation/about_screen_test.dart +++ b/test/features/settings/presentation/about_screen_test.dart @@ -1,9 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:lazurite/features/settings/presentation/about_screen.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; import 'package:url_launcher_platform_interface/link.dart'; import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; -import 'package:plugin_platform_interface/plugin_platform_interface.dart'; class _FakeUrlLauncher extends Fake with MockPlatformInterfaceMixin implements UrlLauncherPlatform { final List launchedUrls = []; @@ -30,6 +31,13 @@ void main() { setUp(() { fakeUrlLauncher = _FakeUrlLauncher(); UrlLauncherPlatform.instance = fakeUrlLauncher; + PackageInfo.setMockInitialValues( + appName: 'Lazurite', + packageName: 'org.stormlightlabs.lazurite', + version: '1.0.0', + buildNumber: '6', + buildSignature: '', + ); }); Widget buildSubject() => const MaterialApp(home: AboutScreen()); @@ -72,9 +80,9 @@ void main() { testWidgets('renders version string', (tester) async { await tester.pumpWidget(buildSubject()); - await tester.pump(); + await tester.pumpAndSettle(); - expect(find.textContaining('Lazurite v'), findsOneWidget); + expect(find.text('Lazurite v1.0.0 alpha 6'), findsOneWidget); }); testWidgets('renders email icon', (tester) async { diff --git a/test/features/settings/presentation/legal_screens_test.dart b/test/features/settings/presentation/legal_screens_test.dart index 6638974..0d5fbbe 100644 --- a/test/features/settings/presentation/legal_screens_test.dart +++ b/test/features/settings/presentation/legal_screens_test.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:lazurite/features/settings/presentation/privacy_policy_screen.dart'; import 'package:lazurite/features/settings/presentation/terms_of_service_screen.dart'; +import 'package:package_info_plus/package_info_plus.dart'; import 'package:plugin_platform_interface/plugin_platform_interface.dart'; import 'package:url_launcher_platform_interface/link.dart'; import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; @@ -31,6 +32,13 @@ void main() { setUp(() { fakeUrlLauncher = _FakeUrlLauncher(); UrlLauncherPlatform.instance = fakeUrlLauncher; + PackageInfo.setMockInitialValues( + appName: 'Lazurite', + packageName: 'org.stormlightlabs.lazurite', + version: '1.0.0', + buildNumber: '6', + buildSignature: '', + ); }); group('PrivacyPolicyScreen', () { @@ -61,6 +69,16 @@ void main() { expect(fakeUrlLauncher.launchedUrls, contains('https://stormlightlabs.org')); expect(fakeUrlLauncher.launchedUrls, contains('mailto:info@stormlightlabs.org')); }); + + testWidgets('renders version string', (tester) async { + await tester.pumpWidget(const MaterialApp(home: PrivacyPolicyScreen())); + await tester.pumpAndSettle(); + + await tester.scrollUntilVisible(find.text('Lazurite v1.0.0 alpha 6'), 300); + await tester.pumpAndSettle(); + + expect(find.text('Lazurite v1.0.0 alpha 6'), findsOneWidget); + }); }); group('TermsOfServiceScreen', () { @@ -93,5 +111,15 @@ void main() { expect(fakeUrlLauncher.launchedUrls, contains('https://stormlightlabs.org')); expect(fakeUrlLauncher.launchedUrls, contains('mailto:info@stormlightlabs.org')); }); + + testWidgets('renders version string', (tester) async { + await tester.pumpWidget(const MaterialApp(home: TermsOfServiceScreen())); + await tester.pumpAndSettle(); + + await tester.scrollUntilVisible(find.text('Lazurite v1.0.0 alpha 6'), 300); + await tester.pumpAndSettle(); + + expect(find.text('Lazurite v1.0.0 alpha 6'), findsOneWidget); + }); }); } diff --git a/test/features/settings/presentation/settings_screen_test.dart b/test/features/settings/presentation/settings_screen_test.dart index e3e2e4a..a398302 100644 --- a/test/features/settings/presentation/settings_screen_test.dart +++ b/test/features/settings/presentation/settings_screen_test.dart @@ -18,6 +18,7 @@ import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; import 'package:lazurite/features/settings/bloc/settings_state.dart'; import 'package:lazurite/features/settings/presentation/settings_screen.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:package_info_plus/package_info_plus.dart'; class MockAccountSwitcherCubit extends MockCubit implements AccountSwitcherCubit {} @@ -64,6 +65,13 @@ void main() { settingsCubit = MockSettingsCubit(); cacheMaintenanceService = MockLocalCacheMaintenanceService(); crashReportingService = FakeCrashReportingService(); + PackageInfo.setMockInitialValues( + appName: 'Lazurite', + packageName: 'org.stormlightlabs.lazurite', + version: '1.0.0', + buildNumber: '6', + buildSignature: '', + ); when(() => authBloc.state).thenReturn(const AuthState.unauthenticated()); whenListen(authBloc, const Stream.empty(), initialState: const AuthState.unauthenticated()); @@ -209,6 +217,16 @@ void main() { expect(find.text('Animations'), findsOneWidget); }); + testWidgets('renders native app version and build number', (tester) async { + await tester.pumpWidget(buildSubject()); + await tester.pumpAndSettle(); + + await tester.scrollUntilVisible(find.text('Lazurite v1.0.0 alpha 6'), 500); + await tester.pumpAndSettle(); + + expect(find.text('Lazurite v1.0.0 alpha 6'), findsOneWidget); + }); + testWidgets('shows the AT Protocol connection card for the authenticated account', (tester) async { final tokens = AuthTokens( accessToken: _buildJwt(