diff --git a/.vscode/launch.json b/.vscode/launch.json index a222516..76ad313 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -41,7 +41,7 @@ "--dart-define", "cameraPreviewAspectRatio=240/320", ], - "program": "${workspaceFolder}/lib/main_release.dart", + "program": "${workspaceFolder}/lib/main_prod.dart", }, { "name": "prod-profile (android)", @@ -54,7 +54,7 @@ "--dart-define", "cameraPreviewAspectRatio=240/320", ], - "program": "${workspaceFolder}/lib/main_release.dart", + "program": "${workspaceFolder}/lib/main_prod.dart", }, { "name": "dev-debug (ios)", diff --git a/lib/application_wrapper.dart b/lib/application_wrapper.dart index ae7d1ae..9f824d4 100644 --- a/lib/application_wrapper.dart +++ b/lib/application_wrapper.dart @@ -26,11 +26,14 @@ class ApplicationWrapper extends StatelessWidget { @override Widget build(BuildContext context) { + final remoteConfigService = env.buildType != BuildType.dev + ? const RemoteConfigService(LightmeterAnalytics(api: LightmeterAnalyticsFirebase())) + : const MockRemoteConfigService(); return FutureBuilder( future: Future.wait([ SharedPreferences.getInstance(), const LightSensorService(LocalPlatform()).hasSensor(), - if (env.buildType != BuildType.dev) const RemoteConfigService().activeAndFetchFeatures(), + remoteConfigService.activeAndFetchFeatures(), ]), builder: (_, snapshot) { if (snapshot.data != null) { @@ -47,8 +50,7 @@ class ApplicationWrapper extends StatelessWidget { userPreferencesService: userPreferencesService, volumeEventsService: const VolumeEventsService(LocalPlatform()), child: RemoteConfigProvider( - remoteConfigService: - env.buildType != BuildType.dev ? const RemoteConfigService() : const MockRemoteConfigService(), + remoteConfigService: remoteConfigService, child: EquipmentProfileProvider( storageService: iapService, child: FilmsProvider( diff --git a/lib/data/analytics/analytics.dart b/lib/data/analytics/analytics.dart index 1bd6496..45bfd22 100644 --- a/lib/data/analytics/analytics.dart +++ b/lib/data/analytics/analytics.dart @@ -3,32 +3,56 @@ import 'dart:developer'; import 'package:flutter/foundation.dart'; import 'package:lightmeter/data/analytics/api/analytics_api_interface.dart'; -import 'package:lightmeter/data/analytics/entity/analytics_event.dart'; class LightmeterAnalytics { final ILightmeterAnalyticsApi _api; const LightmeterAnalytics({required ILightmeterAnalyticsApi api}) : _api = api; + void init() { + FlutterError.onError = (details) { + if (details.silent) return; + logCrash(details.exception, details.stack); + }; + PlatformDispatcher.instance.onError = (error, stack) { + logCrash(error, stack); + return true; + }; + } + Future logEvent( - LightmeterAnalyticsEvent event, { + String eventName, { Map? parameters, }) async { - if (kDebugMode) { - log(' logEvent: ${event.name} / $parameters'); + if (!kReleaseMode) { + log(' logEvent: $eventName / $parameters'); return; } return _api.logEvent( - event: event, + eventName, parameters: parameters, ); } - Future logUnlockProFeatures(String listTileTitle) async { - return logEvent( - LightmeterAnalyticsEvent.unlockProFeatures, - parameters: {"listTileTitle": listTileTitle}, + Future logCrash( + dynamic exception, + StackTrace? stackTrace, { + dynamic reason, + Iterable information = const [], + }) async { + log(exception.toString(), stackTrace: stackTrace); + if (!kReleaseMode) { + return; + } + + return _api.logCrash( + exception, + stackTrace, + reason: reason, + information: information, ); } + + Future setCustomKey(String key, String value) async => _api.setCustomKey(key, value); } diff --git a/lib/data/analytics/api/analytics_api_interface.dart b/lib/data/analytics/api/analytics_api_interface.dart index 1aa007f..ab75036 100644 --- a/lib/data/analytics/api/analytics_api_interface.dart +++ b/lib/data/analytics/api/analytics_api_interface.dart @@ -1,8 +1,15 @@ -import 'package:lightmeter/data/analytics/entity/analytics_event.dart'; - abstract class ILightmeterAnalyticsApi { - Future logEvent({ - required LightmeterAnalyticsEvent event, + Future logEvent( + String eventName, { Map? parameters, }); + + Future logCrash( + dynamic exception, + StackTrace? stack, { + dynamic reason, + Iterable information = const [], + }); + + Future setCustomKey(String key, String value); } diff --git a/lib/data/analytics/api/analytics_firebase.dart b/lib/data/analytics/api/analytics_firebase.dart index fb11d02..5c22454 100644 --- a/lib/data/analytics/api/analytics_firebase.dart +++ b/lib/data/analytics/api/analytics_firebase.dart @@ -1,20 +1,20 @@ import 'package:firebase_analytics/firebase_analytics.dart'; import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:flutter/foundation.dart'; import 'package:lightmeter/data/analytics/api/analytics_api_interface.dart'; -import 'package:lightmeter/data/analytics/entity/analytics_event.dart'; class LightmeterAnalyticsFirebase implements ILightmeterAnalyticsApi { const LightmeterAnalyticsFirebase(); @override - Future logEvent({ - required LightmeterAnalyticsEvent event, + Future logEvent( + String eventName, { Map? parameters, }) async { try { await FirebaseAnalytics.instance.logEvent( - name: event.name, + name: eventName, parameters: parameters, ); } on FirebaseException catch (e) { @@ -23,4 +23,25 @@ class LightmeterAnalyticsFirebase implements ILightmeterAnalyticsApi { debugPrint(e.toString()); } } + + @override + Future logCrash( + dynamic exception, + StackTrace? stackTrace, { + dynamic reason, + Iterable information = const [], + }) async { + FirebaseCrashlytics.instance.recordError( + exception, + stackTrace, + reason: reason, + information: information, + fatal: true, + ); + } + + @override + Future setCustomKey(String key, String value) async { + await FirebaseCrashlytics.instance.setCustomKey(key, value); + } } diff --git a/lib/data/analytics/entity/analytics_event.dart b/lib/data/analytics/entity/analytics_event.dart deleted file mode 100644 index 8275869..0000000 --- a/lib/data/analytics/entity/analytics_event.dart +++ /dev/null @@ -1,3 +0,0 @@ -enum LightmeterAnalyticsEvent { - unlockProFeatures, -} diff --git a/lib/data/remote_config_service.dart b/lib/data/remote_config_service.dart index 1ae86ef..e15a927 100644 --- a/lib/data/remote_config_service.dart +++ b/lib/data/remote_config_service.dart @@ -2,9 +2,9 @@ import 'dart:async'; import 'dart:developer'; import 'package:firebase_core/firebase_core.dart'; -import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:firebase_remote_config/firebase_remote_config.dart'; import 'package:flutter/foundation.dart'; +import 'package:lightmeter/data/analytics/analytics.dart'; import 'package:lightmeter/data/models/feature.dart'; abstract class IRemoteConfigService { @@ -24,7 +24,9 @@ abstract class IRemoteConfigService { } class RemoteConfigService implements IRemoteConfigService { - const RemoteConfigService(); + final LightmeterAnalytics analytics; + + const RemoteConfigService(this.analytics); @override Future activeAndFetchFeatures() async { @@ -73,8 +75,8 @@ class RemoteConfigService implements IRemoteConfigService { try { final feature = Feature.values.firstWhere((f) => f.name == value.key); result[feature] = value.value.toValue(feature); - } catch (e) { - log(e.toString()); + } catch (e, stackTrace) { + _logError(e, stackTrace: stackTrace); } } return result; @@ -88,8 +90,8 @@ class RemoteConfigService implements IRemoteConfigService { for (final key in event.updatedKeys) { try { updatedFeatures.add(Feature.values.firstWhere((element) => element.name == key)); - } catch (e) { - log(e.toString()); + } catch (e, stackTrace) { + _logError(e, stackTrace: stackTrace); } } return updatedFeatures; @@ -99,9 +101,7 @@ class RemoteConfigService implements IRemoteConfigService { @override bool isEnabled(Feature feature) => FirebaseRemoteConfig.instance.getBool(feature.name); - void _logError(dynamic throwable, {StackTrace? stackTrace}) { - FirebaseCrashlytics.instance.recordError(throwable, stackTrace); - } + void _logError(dynamic throwable, {StackTrace? stackTrace}) => analytics.logCrash(throwable, stackTrace); } class MockRemoteConfigService implements IRemoteConfigService { diff --git a/lib/firebase.dart b/lib/firebase.dart deleted file mode 100644 index 0a0e6b0..0000000 --- a/lib/firebase.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'dart:developer'; - -import 'package:firebase_core/firebase_core.dart'; -import 'package:firebase_crashlytics/firebase_crashlytics.dart'; -import 'package:flutter/foundation.dart'; - -import 'package:lightmeter/firebase_options.dart'; - -Future initializeFirebase({required bool handleErrors}) async { - try { - await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); - if (handleErrors) { - FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterFatalError; - PlatformDispatcher.instance.onError = (error, stack) { - FirebaseCrashlytics.instance.recordError(error, stack, fatal: true); - return true; - }; - } - } catch (e) { - log(e.toString()); - } -} diff --git a/lib/main_dev.dart b/lib/main_dev.dart index b43352f..0899050 100644 --- a/lib/main_dev.dart +++ b/lib/main_dev.dart @@ -1,18 +1,4 @@ -import 'package:flutter/material.dart'; -import 'package:lightmeter/application.dart'; -import 'package:lightmeter/application_wrapper.dart'; import 'package:lightmeter/environment.dart'; -import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; +import 'package:lightmeter/runner.dart'; -Future main() async { - WidgetsFlutterBinding.ensureInitialized(); - runApp( - IAPProducts( - products: [IAPProduct(storeId: IAPProductType.paidFeatures.storeId)], - child: const ApplicationWrapper( - Environment.dev(), - child: Application(), - ), - ), - ); -} +Future main() => runLightmeterApp(const Environment.dev()); diff --git a/lib/main_prod.dart b/lib/main_prod.dart index 3460f32..30e0ce1 100644 --- a/lib/main_prod.dart +++ b/lib/main_prod.dart @@ -1,19 +1,4 @@ -import 'package:flutter/material.dart'; -import 'package:lightmeter/application.dart'; -import 'package:lightmeter/application_wrapper.dart'; import 'package:lightmeter/environment.dart'; -import 'package:lightmeter/firebase.dart'; -import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; +import 'package:lightmeter/runner.dart'; -Future main() async { - WidgetsFlutterBinding.ensureInitialized(); - await initializeFirebase(handleErrors: true); - runApp( - const IAPProductsProvider( - child: ApplicationWrapper( - Environment.prod(), - child: Application(), - ), - ), - ); -} +Future main() => runLightmeterApp(const Environment.prod()); diff --git a/lib/main_release.dart b/lib/main_release.dart deleted file mode 100644 index eea83e2..0000000 --- a/lib/main_release.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:lightmeter/application.dart'; -import 'package:lightmeter/application_wrapper.dart'; -import 'package:lightmeter/environment.dart'; -import 'package:lightmeter/firebase.dart'; -import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; - -Future main() async { - WidgetsFlutterBinding.ensureInitialized(); - await initializeFirebase(handleErrors: false); - runApp( - const IAPProductsProvider( - child: ApplicationWrapper( - Environment.prod(), - child: Application(), - ), - ), - ); -} diff --git a/lib/providers/services_provider.dart b/lib/providers/services_provider.dart index 36f5674..1db019e 100644 --- a/lib/providers/services_provider.dart +++ b/lib/providers/services_provider.dart @@ -31,8 +31,10 @@ class ServicesProvider extends InheritedWidget { required super.child, }); - static ServicesProvider of(BuildContext context) { - return context.findAncestorWidgetOfExactType()!; + static ServicesProvider of(BuildContext context) => ServicesProvider.maybeOf(context)!; + + static ServicesProvider? maybeOf(BuildContext context) { + return context.findAncestorWidgetOfExactType(); } @override diff --git a/lib/runner.dart b/lib/runner.dart new file mode 100644 index 0000000..04d4321 --- /dev/null +++ b/lib/runner.dart @@ -0,0 +1,35 @@ +import 'dart:async'; + +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter/widgets.dart'; +import 'package:lightmeter/application.dart'; +import 'package:lightmeter/application_wrapper.dart'; +import 'package:lightmeter/data/analytics/analytics.dart'; +import 'package:lightmeter/data/analytics/api/analytics_firebase.dart'; +import 'package:lightmeter/environment.dart'; +import 'package:lightmeter/firebase_options.dart'; +import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; + +const _errorsLogger = LightmeterAnalytics(api: LightmeterAnalyticsFirebase()); + +Future runLightmeterApp(Environment env) async { + runZonedGuarded( + () async { + WidgetsFlutterBinding.ensureInitialized(); + if (env.buildType == BuildType.prod) { + await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); + } + _errorsLogger.init(); + final application = ApplicationWrapper(env, child: const Application()); + runApp( + env.buildType == BuildType.dev + ? IAPProducts( + products: [IAPProduct(storeId: IAPProductType.paidFeatures.storeId)], + child: application, + ) + : IAPProductsProvider(child: application), + ); + }, + _errorsLogger.logCrash, + ); +} diff --git a/lib/screens/metering/components/camera_container/bloc_container_camera.dart b/lib/screens/metering/components/camera_container/bloc_container_camera.dart index 3b5596b..cd91f59 100644 --- a/lib/screens/metering/components/camera_container/bloc_container_camera.dart +++ b/lib/screens/metering/components/camera_container/bloc_container_camera.dart @@ -7,6 +7,7 @@ import 'package:camera/camera.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lightmeter/data/analytics/analytics.dart'; import 'package:lightmeter/interactors/metering_interactor.dart'; import 'package:lightmeter/platform_config.dart'; import 'package:lightmeter/screens/metering/communication/bloc_communication_metering.dart'; @@ -22,6 +23,7 @@ part 'mock_bloc_container_camera.dart'; class CameraContainerBloc extends EvSourceBlocBase { final MeteringInteractor _meteringInteractor; + final LightmeterAnalytics _analytics; late final _WidgetsBindingObserver _observer; CameraController? _cameraController; @@ -42,6 +44,7 @@ class CameraContainerBloc extends EvSourceBlocBase(), + ServicesProvider.of(context).analytics, ) : CameraContainerBloc( MeteringInteractorProvider.of(context), context.read(), + ServicesProvider.of(context).analytics, )) ..add(const RequestPermissionEvent()), child: CameraContainer( diff --git a/lib/screens/shared/pro_features_dialog/widget_dialog_pro_features.dart b/lib/screens/shared/pro_features_dialog/widget_dialog_pro_features.dart index c58e94d..3b8d4f2 100644 --- a/lib/screens/shared/pro_features_dialog/widget_dialog_pro_features.dart +++ b/lib/screens/shared/pro_features_dialog/widget_dialog_pro_features.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/providers/services_provider.dart'; import 'package:lightmeter/res/dimens.dart'; import 'package:lightmeter/screens/metering/components/shared/readings_container/components/shared/animated_dialog_picker/components/animated_dialog/widget_dialog_animated.dart'; import 'package:lightmeter/screens/shared/transparent_dialog/widget_dialog_transparent.dart'; @@ -44,7 +45,12 @@ class ProFeaturesDialog extends StatelessWidget { ), FilledButton( onPressed: () { - _close(context).then((_) => IAPProductsProvider.maybeOf(context)?.buy(IAPProductType.paidFeatures)); + _close(context).then((_) { + ServicesProvider.maybeOf(context) + ?.analytics + .setCustomKey('iap_product_type', IAPProductType.paidFeatures.storeId); + IAPProductsProvider.maybeOf(context)?.buy(IAPProductType.paidFeatures); + }); }, child: Text(S.of(context).unlock), ), diff --git a/lib/utils/ev_from_bytes.dart b/lib/utils/ev_from_bytes.dart index c7d12ea..c7c7b63 100644 --- a/lib/utils/ev_from_bytes.dart +++ b/lib/utils/ev_from_bytes.dart @@ -1,27 +1,20 @@ -import 'dart:developer'; import 'dart:math' as math; -import 'dart:typed_data'; import 'package:exif/exif.dart'; +import 'package:flutter/foundation.dart'; import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; -Future evFromImage(Uint8List bytes) async { - try { - final tags = await readExifFromBytes(bytes); - final iso = double.tryParse("${tags["EXIF ISOSpeedRatings"]}"); - final apertureValueRatio = (tags["EXIF FNumber"]?.values as IfdRatios?)?.ratios.first; - final speedValueRatio = (tags["EXIF ExposureTime"]?.values as IfdRatios?)?.ratios.first; - if (iso == null || apertureValueRatio == null || speedValueRatio == null) { - log('Error parsing EXIF: ${tags.keys}'); - return null; - } - - final aperture = apertureValueRatio.numerator / apertureValueRatio.denominator; - final speed = speedValueRatio.numerator / speedValueRatio.denominator; - - return log2(math.pow(aperture, 2)) - log2(speed) - log2(iso / 100); - } catch (e) { - log(e.toString()); - return null; +Future evFromImage(Uint8List bytes) async { + final tags = await readExifFromBytes(bytes); + final iso = double.tryParse("${tags["EXIF ISOSpeedRatings"]}"); + final apertureValueRatio = (tags["EXIF FNumber"]?.values as IfdRatios?)?.ratios.first; + final speedValueRatio = (tags["EXIF ExposureTime"]?.values as IfdRatios?)?.ratios.first; + if (iso == null || apertureValueRatio == null || speedValueRatio == null) { + throw FlutterError('Error parsing EXIF: ${tags.keys}'); } + + final aperture = apertureValueRatio.numerator / apertureValueRatio.denominator; + final speed = speedValueRatio.numerator / speedValueRatio.denominator; + + return log2(math.pow(aperture, 2)) - log2(speed) - log2(iso / 100); } diff --git a/pubspec.yaml b/pubspec.yaml index 41fb21b..017a2cf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -28,7 +28,7 @@ dependencies: m3_lightmeter_iap: git: url: "https://github.com/vodemn/m3_lightmeter_iap" - ref: v0.7.1 + ref: v0.7.2 m3_lightmeter_resources: git: url: "https://github.com/vodemn/m3_lightmeter_resources" diff --git a/test/screens/metering/components/camera/bloc_container_camera_test.dart b/test/screens/metering/components/camera/bloc_container_camera_test.dart index 52c57c8..2ec043e 100644 --- a/test/screens/metering/components/camera/bloc_container_camera_test.dart +++ b/test/screens/metering/components/camera/bloc_container_camera_test.dart @@ -2,6 +2,7 @@ import 'package:bloc_test/bloc_test.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:lightmeter/data/analytics/analytics.dart'; import 'package:lightmeter/interactors/metering_interactor.dart'; import 'package:lightmeter/screens/metering/communication/bloc_communication_metering.dart'; import 'package:lightmeter/screens/metering/communication/event_communication_metering.dart' as communication_events; @@ -18,11 +19,14 @@ class _MockMeteringCommunicationBloc extends MockBloc implements MeteringCommunicationBloc {} +class _MockLightmeterAnalytics extends Mock implements LightmeterAnalytics {} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); late _MockMeteringInteractor meteringInteractor; late _MockMeteringCommunicationBloc communicationBloc; + late _MockLightmeterAnalytics analytics; late CameraContainerBloc bloc; const cameraMethodChannel = MethodChannel('plugins.flutter.io/camera'); @@ -110,16 +114,21 @@ void main() { setUpAll(() { meteringInteractor = _MockMeteringInteractor(); - communicationBloc = _MockMeteringCommunicationBloc(); + communicationBloc = _MockMeteringCommunicationBloc(); when(() => meteringInteractor.cameraEvCalibration).thenReturn(0.0); when(meteringInteractor.quickVibration).thenAnswer((_) async {}); + + analytics = _MockLightmeterAnalytics(); + registerFallbackValue(StackTrace.empty); + when(() => analytics.logCrash(any(), any())).thenAnswer((_) async {}); }); setUp(() { bloc = CameraContainerBloc( meteringInteractor, communicationBloc, + analytics, ); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(cameraMethodChannel, cameraMethodCallSuccessHandler); diff --git a/test/utils/ev_from_bytes_test.dart b/test/utils/ev_from_bytes_test.dart index f73a257..635bb3c 100644 --- a/test/utils/ev_from_bytes_test.dart +++ b/test/utils/ev_from_bytes_test.dart @@ -17,7 +17,7 @@ void main() { 'no EXIF', () { final bytes = File('assets/launcher_icon_dev_512.png').readAsBytesSync(); - expectLater(evFromImage(bytes), completion(null)); + expectLater(evFromImage(bytes), throwsFlutterError); }, ); });