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);
});
}