diff --git a/app/android/app/build.gradle b/app/android/app/build.gradle index 813e23ea..a1b8ea96 100644 --- a/app/android/app/build.gradle +++ b/app/android/app/build.gradle @@ -128,3 +128,6 @@ dependencies { implementation 'com.nkming.nc_photos.np_android_core:np_android_core' coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.0.3" } + +apply plugin: 'com.google.gms.google-services' +apply plugin: 'com.google.firebase.crashlytics' diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml index b871dac5..ff56756c 100644 --- a/app/android/app/src/main/AndroidManifest.xml +++ b/app/android/app/src/main/AndroidManifest.xml @@ -62,5 +62,8 @@ + diff --git a/app/android/build.gradle b/app/android/build.gradle index dd1dff32..e06a48e6 100644 --- a/app/android/build.gradle +++ b/app/android/build.gradle @@ -8,6 +8,8 @@ buildscript { dependencies { classpath 'com.android.tools.build:gradle:7.4.2' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + classpath 'com.google.gms:google-services:4.3.14' + classpath 'com.google.firebase:firebase-crashlytics-gradle:2.8.1' } } diff --git a/app/lib/app_init.dart b/app/lib/app_init.dart index c463fbcc..7d035273 100644 --- a/app/lib/app_init.dart +++ b/app/lib/app_init.dart @@ -1,5 +1,9 @@ +import 'dart:io'; + import 'package:equatable/equatable.dart'; import 'package:event_bus/event_bus.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:flutter/foundation.dart'; import 'package:google_mobile_ads/google_mobile_ads.dart'; import 'package:kiwi/kiwi.dart'; @@ -42,6 +46,7 @@ import 'package:nc_photos/entity/tagged_file/data_source.dart'; import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/mobile/android/android_info.dart'; import 'package:nc_photos/mobile/self_signed_cert_manager.dart'; +import 'package:nc_photos/object_extension.dart'; import 'package:nc_photos/platform/features.dart' as features; import 'package:nc_photos/session_storage.dart'; import 'package:nc_photos/touch_manager.dart'; @@ -85,6 +90,7 @@ Future init(InitIsolateType isolateType) async { // init session storage SessionStorage(); + await _initFirebase(); await _initAds(); _hasInitedInThisIsolate = true; @@ -98,6 +104,16 @@ void initLog() { np_log.initLog( isDebugMode: np_log.isDevMode, print: (log) => debugPrint(log, wrapWidth: 1024), + onLog: (record) { + if (_shouldReportCrashlytics(record)) { + FirebaseCrashlytics.instance.recordError( + record.error, + record.stackTrace, + reason: record.message, + printDetails: false, + ); + } + }, ); } @@ -236,5 +252,39 @@ Future _initAds() { return MobileAds.instance.initialize(); } +Future _initFirebase() async { + await Firebase.initializeApp(); + + // Crashlytics + if (features.isSupportCrashlytics) { + if (kDebugMode) { + await FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(false); + await FirebaseCrashlytics.instance.deleteUnsentReports(); + } + } +} + +bool _shouldReportCrashlytics(LogRecord record) { + if (kDebugMode || + !features.isSupportCrashlytics || + record.level < Level.SHOUT) { + return false; + } + final e = record.error; + // We ignore these SocketExceptions as they are likely caused by an unstable + // internet connection + // 7: No address associated with hostname + // 101: Network is unreachable + // 103: Software caused connection abort + // 104: Connection reset by peer + // 110: Connection timed out + // 113: No route to host + if (e is SocketException && + e.osError?.errorCode.isIn([7, 101, 103, 104, 110, 113]) == true) { + return false; + } + return true; +} + final _log = Logger("app_init"); var _hasInitedInThisIsolate = false; diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 9426914a..3330aa60 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -7,6 +7,8 @@ "settingsPrivacyTitle": "Privacy", "settingsPrivacyDescription": "Privacy-related settings", "settingsPrivacyPageTitle": "Privacy settings", + "settingsAnalyticsTitle": "Analytics", + "settingsAnalyticsSubtitle": "Collect analytics after an error to help developers better diagnose the issue", "settingsPrivacyPolicyTitle": "Privacy policy", "setupPrivacyAgreeStatement": "Please read carefully the above privacy policy. By continuing, you agree to our privacy policy", "photosTabLabel": "Photos", diff --git a/app/lib/object_extension.dart b/app/lib/object_extension.dart index ec2194e3..8ee63093 100644 --- a/app/lib/object_extension.dart +++ b/app/lib/object_extension.dart @@ -3,4 +3,7 @@ import 'package:np_common/object_util.dart'; extension ObjectExtension on T { /// Deprecated, use [let] U run(U Function(T obj) fn) => let(fn); + + /// Return if this is contained inside [iterable] + bool isIn(Iterable iterable) => iterable.contains(this); } diff --git a/app/lib/platform/features.dart b/app/lib/platform/features.dart index ac34dfe4..8867f83a 100644 --- a/app/lib/platform/features.dart +++ b/app/lib/platform/features.dart @@ -6,3 +6,4 @@ final isSupportSelfSignedCert = getRawPlatform() == NpPlatform.android; final isSupportEnhancement = getRawPlatform() == NpPlatform.android; final isSupportAds = getRawPlatform() != NpPlatform.web; +final isSupportCrashlytics = getRawPlatform() != NpPlatform.web; diff --git a/app/lib/widget/settings.dart b/app/lib/widget/settings.dart index 9f57a775..64e677d6 100644 --- a/app/lib/widget/settings.dart +++ b/app/lib/widget/settings.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:logging/logging.dart'; @@ -272,6 +273,15 @@ class _PrivacySettings extends StatefulWidget { } class _PrivacySettingsState extends State<_PrivacySettings> { + @override + initState() { + super.initState(); + if (features.isSupportCrashlytics) { + _isEnableAnalytics = + FirebaseCrashlytics.instance.isCrashlyticsCollectionEnabled; + } + } + @override build(BuildContext context) { return Scaffold( @@ -291,6 +301,13 @@ class _PrivacySettingsState extends State<_PrivacySettings> { SliverList( delegate: SliverChildListDelegate( [ + if (features.isSupportCrashlytics) + SwitchListTile( + title: Text(L10n.global().settingsAnalyticsTitle), + subtitle: Text(L10n.global().settingsAnalyticsSubtitle), + value: _isEnableAnalytics, + onChanged: (value) => _onAnalyticsChanged(value), + ), ListTile( title: Text(L10n.global().settingsPrivacyPolicyTitle), onTap: () { @@ -303,4 +320,13 @@ class _PrivacySettingsState extends State<_PrivacySettings> { ], ); } + + void _onAnalyticsChanged(bool value) { + setState(() { + _isEnableAnalytics = value; + }); + FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(value); + } + + late bool _isEnableAnalytics; } diff --git a/app/lib/widget/setup.dart b/app/lib/widget/setup.dart index 4ef7e28c..6f7dba05 100644 --- a/app/lib/widget/setup.dart +++ b/app/lib/widget/setup.dart @@ -1,9 +1,12 @@ +import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:logging/logging.dart'; import 'package:nc_photos/app_localizations.dart'; import 'package:nc_photos/controller/pref_controller.dart'; import 'package:nc_photos/entity/pref.dart'; import 'package:nc_photos/k.dart' as k; +import 'package:nc_photos/platform/features.dart' as features; import 'package:nc_photos/url_launcher_util.dart'; import 'package:nc_photos/widget/home.dart'; import 'package:nc_photos/widget/sign_in.dart'; @@ -250,6 +253,17 @@ class _PrivacyState extends State<_Privacy> { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + SwitchListTile( + title: Text(L10n.global().settingsAnalyticsTitle), + value: _isEnableAnalytics, + onChanged: _onAnalyticsValueChanged, + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text(L10n.global().settingsAnalyticsSubtitle), + ), + const SizedBox(height: 16), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: InkWell( @@ -286,4 +300,25 @@ class _PrivacyState extends State<_Privacy> { ), ); } + + @override + dispose() { + super.dispose(); + // persist user's choice + _log.info("[dispose] Analytics: $_isEnableAnalytics"); + if (features.isSupportCrashlytics) { + FirebaseCrashlytics.instance + .setCrashlyticsCollectionEnabled(_isEnableAnalytics); + } + } + + void _onAnalyticsValueChanged(bool value) { + setState(() { + _isEnableAnalytics = value; + }); + } + + bool _isEnableAnalytics = true; + + static final _log = Logger("widget.setup._PrivacyState"); } diff --git a/app/pubspec.lock b/app/pubspec.lock index aa7fd275..ec1702c1 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -9,6 +9,14 @@ packages: url: "https://pub.dev" source: hosted version: "67.0.0" + _flutterfire_internals: + dependency: transitive + description: + name: _flutterfire_internals + sha256: "4eec93681221723a686ad580c2e7d960e1017cf1a4e0a263c2573c2c6b0bf5cd" + url: "https://pub.dev" + source: hosted + version: "1.3.25" analyzer: dependency: transitive description: @@ -438,6 +446,46 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.0" + firebase_core: + dependency: "direct main" + description: + name: firebase_core + sha256: "53316975310c8af75a96e365f9fccb67d1c544ef0acdbf0d88bbe30eedd1c4f9" + url: "https://pub.dev" + source: hosted + version: "2.27.0" + firebase_core_platform_interface: + dependency: transitive + description: + name: firebase_core_platform_interface + sha256: c437ae5d17e6b5cc7981cf6fd458a5db4d12979905f9aafd1fea930428a9fe63 + url: "https://pub.dev" + source: hosted + version: "5.0.0" + firebase_core_web: + dependency: transitive + description: + name: firebase_core_web + sha256: c8e1d59385eee98de63c92f961d2a7062c5d9a65e7f45bdc7f1b0b205aab2492 + url: "https://pub.dev" + source: hosted + version: "2.11.5" + firebase_crashlytics: + dependency: "direct main" + description: + name: firebase_crashlytics + sha256: c4f1b723d417bc9c4774810e774ff91df8fb0032d33fb2888b2c887e865581b8 + url: "https://pub.dev" + source: hosted + version: "3.4.18" + firebase_crashlytics_platform_interface: + dependency: transitive + description: + name: firebase_crashlytics_platform_interface + sha256: c5a11fca3df76a98e3fa68fde8b10a08aacb9a7639f619fbfd4dad6c67a08643 + url: "https://pub.dev" + source: hosted + version: "3.6.25" fixnum: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 696665bd..648690ca 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -163,6 +163,8 @@ dependencies: woozy_search: ^2.0.3 # android/ios only google_mobile_ads: 5.1.0 + firebase_core: + firebase_crashlytics: 3.4.18 dependency_overrides: video_player: diff --git a/np_log/lib/src/log.dart b/np_log/lib/src/log.dart index 42bb56ac..f59d8f23 100644 --- a/np_log/lib/src/log.dart +++ b/np_log/lib/src/log.dart @@ -5,6 +5,7 @@ import 'package:logging/logging.dart'; void initLog({ required bool isDebugMode, void Function(String) print = print, + void Function(LogRecord record)? onLog, }) { Logger.root.level = !isDebugMode ? Level.WARNING : Level.ALL; Logger.root.onRecord.listen((record) { @@ -35,6 +36,8 @@ void initLog({ } print(msg); LogStream().add(msg); + + onLog?.call(record); }); }