diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f8271ff6..57bbf852 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -15,6 +15,8 @@ on: env: GO_VERSION: "1.24" FLUTTER_VERSION: "3.38.7" + GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }} + GA4_API_SECRET: ${{ secrets.GA4_API_SECRET }} permissions: contents: write @@ -97,7 +99,7 @@ jobs: go build -ldflags="-w -s" -o ui/flutter/assets/exec/updater.exe github.com/GopeedLab/gopeed/cmd/updater cd ui/flutter $TAG = "v$env:VERSION" - flutter build windows --dart-define="UPDATE_CHANNEL=windowsPortable" + flutter build windows --dart-define="UPDATE_CHANNEL=windowsPortable" --dart-define="GA4_MEASUREMENT_ID=${{ env.GA4_MEASUREMENT_ID }}" --dart-define="GA4_API_SECRET=${{ env.GA4_API_SECRET }}" $system = "C:\Windows\System32" if ("${{ matrix.arch }}" -eq "amd64") { # for amd64 @@ -119,7 +121,7 @@ jobs: New-Item -Path build\windows\Output -ItemType Directory Compress-Archive -Path "$release*" -DestinationPath "build\windows\Output\Gopeed-$TAG-windows-${{ matrix.arch }}-portable.zip" - flutter build windows --dart-define="UPDATE_CHANNEL=windowsInstaller" + flutter build windows --dart-define="UPDATE_CHANNEL=windowsInstaller" --dart-define="GA4_MEASUREMENT_ID=${{ env.GA4_MEASUREMENT_ID }}" --dart-define="GA4_API_SECRET=${{ env.GA4_API_SECRET }}" cd build/windows echo @" ; Script generated by the Inno Setup Script Wizard. @@ -189,7 +191,7 @@ jobs: overwrite: true build-macos-amd64-lib: if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos' }} - runs-on: macos-13 + runs-on: macos-15-intel needs: [get-release] steps: - uses: actions/checkout@v3 @@ -274,7 +276,7 @@ jobs: VERSION: ${{ needs.get-release.outputs.version }} run: | cd ui/flutter - flutter build macos --dart-define="UPDATE_CHANNEL=macosDmg" + flutter build macos --dart-define="UPDATE_CHANNEL=macosDmg" --dart-define="GA4_MEASUREMENT_ID=${{ env.GA4_MEASUREMENT_ID }}" --dart-define="GA4_API_SECRET=${{ env.GA4_API_SECRET }}" cd build/macos/Build/Products/Release cat>appdmg.json< android/app/upload-keystore.jks - flutter build apk --dart-define="UPDATE_CHANNEL=androidApk" - flutter build apk --split-per-abi --dart-define="UPDATE_CHANNEL=androidApk" + flutter build apk --dart-define="UPDATE_CHANNEL=androidApk" --dart-define="GA4_MEASUREMENT_ID=${{ env.GA4_MEASUREMENT_ID }}" --dart-define="GA4_API_SECRET=${{ env.GA4_API_SECRET }}" + flutter build apk --split-per-abi --dart-define="UPDATE_CHANNEL=androidApk" --dart-define="GA4_MEASUREMENT_ID=${{ env.GA4_MEASUREMENT_ID }}" --dart-define="GA4_API_SECRET=${{ env.GA4_API_SECRET }}" mkdir dist cp build/app/outputs/flutter-apk/app-release.apk dist/Gopeed-v$VERSION-android.apk cp build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk dist/Gopeed-v$VERSION-android-armeabi-v7a.apk @@ -598,7 +606,7 @@ jobs: gomobile init gomobile bind -tags nosqlite -ldflags="-w -s -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION" -o ui/flutter/ios/Frameworks/Libgopeed.xcframework -target=ios github.com/GopeedLab/gopeed/bind/mobile cd ui/flutter - flutter build ios --no-codesign --dart-define="UPDATE_CHANNEL=iosIpa" + flutter build ios --no-codesign --dart-define="UPDATE_CHANNEL=iosIpa" --dart-define="GA4_MEASUREMENT_ID=${{ env.GA4_MEASUREMENT_ID }}" --dart-define="GA4_API_SECRET=${{ env.GA4_API_SECRET }}" mkdir Payload cp -r build/ios/iphoneos/Runner.app Payload zip -r -y Payload.zip Payload/Runner.app @@ -627,7 +635,7 @@ jobs: VERSION: ${{ needs.get-release.outputs.version }} run: | cd ui/flutter - flutter build web --no-web-resources-cdn + flutter build web --no-web-resources-cdn --dart-define="GA4_MEASUREMENT_ID=${{ env.GA4_MEASUREMENT_ID }}" --dart-define="GA4_API_SECRET=${{ env.GA4_API_SECRET }}" dart ../../.github/workflows/scripts/flutter_local_font.dart cd ../../ cp -r ui/flutter/build/web cmd/web/dist @@ -744,7 +752,7 @@ jobs: - name: Build flutter web run: | cd ui/flutter - flutter build web --no-web-resources-cdn --dart-define="UPDATE_CHANNEL=docker" + flutter build web --no-web-resources-cdn --dart-define="UPDATE_CHANNEL=docker" --dart-define="GA4_MEASUREMENT_ID=${{ env.GA4_MEASUREMENT_ID }}" --dart-define="GA4_API_SECRET=${{ env.GA4_API_SECRET }}" dart ../../.github/workflows/scripts/flutter_local_font.dart cd ../../ rm -rf cmd/web/dist diff --git a/ui/flutter/lib/app/modules/setting/views/setting_view.dart b/ui/flutter/lib/app/modules/setting/views/setting_view.dart index 8766cc0e..3755059b 100644 --- a/ui/flutter/lib/app/modules/setting/views/setting_view.dart +++ b/ui/flutter/lib/app/modules/setting/views/setting_view.dart @@ -14,6 +14,7 @@ import 'package:window_manager/window_manager.dart'; import '../../../../api/api.dart' as api; import '../../../../api/model/downloader_config.dart'; import '../../../../database/database.dart'; +import '../../../../util/analytics.dart'; import '../../../../i18n/message.dart'; import '../../../../util/input_formatter.dart'; import '../../../../util/locale_manager.dart'; @@ -859,6 +860,21 @@ class SettingView extends GetView { ); }); + final analyticsEnabled = Database.instance.getAnalyticsEnabled().obs; + buildAnalyticsEnabled() { + return ListTile( + title: Text('analyticsEnabled'.tr), + subtitle: Text('analyticsEnabledDesc'.tr), + trailing: Obx(() => Switch( + value: analyticsEnabled.value, + onChanged: (bool value) { + analyticsEnabled.value = value; + Database.instance.saveAnalyticsEnabled(value); + }, + )), + ); + } + buildThanks() { const thankPage = 'https://github.com/GopeedLab/gopeed/graphs/contributors'; @@ -1538,6 +1554,7 @@ class SettingView extends GetView { buildHomepage(), buildVersion(), buildAutoCheckUpdate(), + if (Config.isConfigured) buildAnalyticsEnabled(), buildThanks(), ]), )), diff --git a/ui/flutter/lib/database/database.dart b/ui/flutter/lib/database/database.dart index 2100f466..92b57869 100644 --- a/ui/flutter/lib/database/database.dart +++ b/ui/flutter/lib/database/database.dart @@ -11,6 +11,8 @@ const String _bookmark = 'bookmark'; const String _createHistory = 'createHistory'; const String _webToken = 'webToken'; const String _runAsMenubarApp = 'runAsMenubarApp'; +const String _analyticsEnabled = 'analyticsEnabled'; +const String _analyticsClientId = 'analyticsClientId'; class Database { static final Database _instance = Database._internal(); @@ -118,4 +120,20 @@ class Database { bool getRunAsMenubarApp() { return box.get(_runAsMenubarApp, defaultValue: false) as bool; } + + void saveAnalyticsEnabled(bool value) { + box.put(_analyticsEnabled, value); + } + + bool getAnalyticsEnabled() { + return box.get(_analyticsEnabled, defaultValue: true) as bool; + } + + void saveAnalyticsClientId(String clientId) { + box.put(_analyticsClientId, clientId); + } + + String? getAnalyticsClientId() { + return box.get(_analyticsClientId) as String?; + } } diff --git a/ui/flutter/lib/i18n/langs/en_us.dart b/ui/flutter/lib/i18n/langs/en_us.dart index 44540804..70d9ac5d 100644 --- a/ui/flutter/lib/i18n/langs/en_us.dart +++ b/ui/flutter/lib/i18n/langs/en_us.dart @@ -59,6 +59,8 @@ const enUS = { 'themeDark': 'Dark', 'locale': 'Language', 'notifyWhenNewVersion': 'Notify for updates', + 'analyticsEnabled': 'Upload Statistics', + 'analyticsEnabledDesc': 'Share anonymous usage data to help us improve', 'about': 'About', 'homepage': 'Homepage', 'version': 'Version', diff --git a/ui/flutter/lib/i18n/langs/zh_cn.dart b/ui/flutter/lib/i18n/langs/zh_cn.dart index 223dc764..d9e1eac5 100644 --- a/ui/flutter/lib/i18n/langs/zh_cn.dart +++ b/ui/flutter/lib/i18n/langs/zh_cn.dart @@ -57,6 +57,8 @@ const zhCN = { 'themeDark': '暗黑主题', 'locale': '语言', 'notifyWhenNewVersion': '有新版本时提醒', + 'analyticsEnabled': '上传统计数据', + 'analyticsEnabledDesc': '分享匿名使用数据以帮助我们改进', 'about': '关于', 'homepage': '主页', 'version': '版本', diff --git a/ui/flutter/lib/main.dart b/ui/flutter/lib/main.dart index da749f34..d5dfa43a 100644 --- a/ui/flutter/lib/main.dart +++ b/ui/flutter/lib/main.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_foreground_task/flutter_foreground_task.dart'; import 'package:get/get.dart'; +import 'package:gopeed/util/analytics.dart'; import 'package:hotkey_manager/hotkey_manager.dart'; import 'package:window_manager/window_manager.dart'; @@ -195,4 +196,13 @@ Future onStart() async { } }); } + + if (Config.isConfigured && Database.instance.getAnalyticsEnabled()) { + try { + await Analytics.instance.init(); + await Analytics.instance.logAppOpen(); + } catch (e) { + logger.w("GA4 init failed: $e"); + } + } } diff --git a/ui/flutter/lib/util/analytics.dart b/ui/flutter/lib/util/analytics.dart new file mode 100644 index 00000000..18d5d568 --- /dev/null +++ b/ui/flutter/lib/util/analytics.dart @@ -0,0 +1,137 @@ +import 'dart:io'; + +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; + +import 'package:gopeed/database/database.dart'; +import 'package:gopeed/util/package_info.dart'; + +import 'log_util.dart'; + +/// GA4 Measurement Protocol configuration from dart-define +class Config { + static const String measurementId = + String.fromEnvironment('GA4_MEASUREMENT_ID'); + static const String apiSecret = String.fromEnvironment('GA4_API_SECRET'); + + static bool get isConfigured => + measurementId.isNotEmpty && apiSecret.isNotEmpty; +} + +/// GA4 Measurement Protocol Analytics +class Analytics { + static final Analytics _instance = Analytics._internal(); + static Analytics get instance => _instance; + + Analytics._internal(); + + late String _clientId; + late int _sessionId; + late Dio _dio; + bool _initialized = false; + + Future init() async { + if (_initialized) return; + if (!Config.isConfigured) return; + + _clientId = await _getDeviceId(); + _dio = Dio(BaseOptions( + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 10), + )); + _sessionId = DateTime.now().millisecondsSinceEpoch ~/ 1000; + _initialized = true; + } + + Future _getDeviceId() async { + final deviceInfo = DeviceInfoPlugin(); + String? deviceId; + try { + if (kIsWeb) { + deviceId = null; + } else if (Platform.isAndroid) { + final androidInfo = await deviceInfo.androidInfo; + deviceId = androidInfo.id; + } else if (Platform.isIOS) { + final iosInfo = await deviceInfo.iosInfo; + deviceId = iosInfo.identifierForVendor; + } else if (Platform.isMacOS) { + final macInfo = await deviceInfo.macOsInfo; + deviceId = macInfo.systemGUID; + } else if (Platform.isWindows) { + final windowsInfo = await deviceInfo.windowsInfo; + deviceId = windowsInfo.deviceId; + } else if (Platform.isLinux) { + final linuxInfo = await deviceInfo.linuxInfo; + deviceId = linuxInfo.machineId; + } + } catch (e) { + debugPrint('GA4Analytics: Failed to get device id: $e'); + } + + // Fallback to persisted client id + if (deviceId == null || deviceId.isEmpty) { + deviceId = Database.instance.getAnalyticsClientId(); + if (deviceId == null || deviceId.isEmpty) { + final random = DateTime.now().microsecondsSinceEpoch % 2147483647; + final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; + deviceId = '$random.$timestamp'; + Database.instance.saveAnalyticsClientId(deviceId); + } + } + return deviceId; + } + + String _getPlatform() { + if (kIsWeb) return 'web'; + if (Platform.isAndroid) return 'android'; + if (Platform.isIOS) return 'ios'; + if (Platform.isMacOS) return 'macos'; + if (Platform.isWindows) return 'windows'; + if (Platform.isLinux) return 'linux'; + return 'unknown'; + } + + Future logAppOpen() async { + await logEvent('app_open'); + } + + Future logEvent(String name, [Map? params]) async { + if (!_initialized) { + return; + } + + final eventParams = { + 'session_id': _sessionId.toString(), + 'engagement_time_msec': 100, + 'platform': _getPlatform(), + 'app_version': packageInfo.version, + ...?params, + }; + + final payload = { + 'client_id': _clientId, + 'timestamp_micros': DateTime.now().microsecondsSinceEpoch, + 'events': [ + { + 'name': name, + 'params': eventParams, + } + ], + }; + + try { + await _dio.post( + 'https://www.google-analytics.com/mp/collect', + queryParameters: { + 'measurement_id': Config.measurementId, + 'api_secret': Config.apiSecret, + }, + data: payload, + ); + } catch (e) { + logger.w('GA4Analytics: Failed to send event "$name": $e'); + } + } +}