diff --git a/ui/flutter/lib/api/model/downloader_config.dart b/ui/flutter/lib/api/model/downloader_config.dart index 19ef03e3..07023ddc 100644 --- a/ui/flutter/lib/api/model/downloader_config.dart +++ b/ui/flutter/lib/api/model/downloader_config.dart @@ -196,7 +196,9 @@ class ExtraConfig { List createHistory; bool runAsMenubarApp; bool analyticsEnabled; - String analyticsClientId; + + /// Persisted UUID fallback; IDs returned by platform APIs are not stored here. + String deviceId; ExtraConfig({ this.themeMode = '', @@ -214,7 +216,7 @@ class ExtraConfig { this.createHistory = const [], this.runAsMenubarApp = false, this.analyticsEnabled = true, - this.analyticsClientId = '', + this.deviceId = '', }) : windowState = windowState ?? WindowStateConfig(); factory ExtraConfig.fromJson(Map? json) => diff --git a/ui/flutter/lib/api/model/downloader_config.g.dart b/ui/flutter/lib/api/model/downloader_config.g.dart index a5ede5f5..d627ef63 100644 --- a/ui/flutter/lib/api/model/downloader_config.g.dart +++ b/ui/flutter/lib/api/model/downloader_config.g.dart @@ -193,7 +193,7 @@ ExtraConfig _$ExtraConfigFromJson(Map json) => ExtraConfig( createHistory: (json['createHistory'] as List?)?.map((e) => e as String).toList() ?? const [], runAsMenubarApp: json['runAsMenubarApp'] as bool? ?? false, analyticsEnabled: json['analyticsEnabled'] as bool? ?? true, - analyticsClientId: json['analyticsClientId'] as String? ?? '', + deviceId: json['deviceId'] as String? ?? '', ); Map _$ExtraConfigToJson(ExtraConfig instance) => { @@ -212,7 +212,7 @@ Map _$ExtraConfigToJson(ExtraConfig instance) => json) => WindowStateConfig( diff --git a/ui/flutter/lib/app/application/app_runtime_controller.dart b/ui/flutter/lib/app/application/app_runtime_controller.dart index c463ecc0..0b551b2d 100644 --- a/ui/flutter/lib/app/application/app_runtime_controller.dart +++ b/ui/flutter/lib/app/application/app_runtime_controller.dart @@ -255,9 +255,9 @@ class AppRuntimeController extends AsyncNotifier { Future _initAnalytics(DownloaderConfig config) async { if (!AnalyticsConfig.isConfigured || !config.extra.analyticsEnabled) return; try { - await Analytics.instance.init(fallbackClientId: config.extra.analyticsClientId); - if (config.extra.analyticsClientId.isEmpty && Analytics.instance.clientId.isNotEmpty) { - config.extra.analyticsClientId = Analytics.instance.clientId; + final generatedClientId = await Analytics.instance.init(fallbackClientId: config.extra.deviceId); + if (generatedClientId != null) { + config.extra.deviceId = generatedClientId; await ref.read(gopeedServiceProvider).putConfig(config); } unawaited(Analytics.instance.logAppOpen()); diff --git a/ui/flutter/lib/features/settings/application/settings_controller.dart b/ui/flutter/lib/features/settings/application/settings_controller.dart index 4c77effb..311be7be 100644 --- a/ui/flutter/lib/features/settings/application/settings_controller.dart +++ b/ui/flutter/lib/features/settings/application/settings_controller.dart @@ -56,7 +56,7 @@ class SettingsController extends AsyncNotifier { ..bookmarks = latest.extra.bookmarks ..createHistory = latest.extra.createHistory ..runAsMenubarApp = latest.extra.runAsMenubarApp - ..analyticsClientId = latest.extra.analyticsClientId; + ..deviceId = latest.extra.deviceId; // The backend preserves protocol-owned results and validates the whole PUT. await service.putConfig(config); final saved = await service.getConfig(); diff --git a/ui/flutter/lib/util/analytics.dart b/ui/flutter/lib/util/analytics.dart index 9eca41f9..358732f6 100644 --- a/ui/flutter/lib/util/analytics.dart +++ b/ui/flutter/lib/util/analytics.dart @@ -1,8 +1,10 @@ import 'dart:io'; +import 'package:android_id/android_id.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; +import 'package:uuid/uuid.dart'; import 'log_util.dart'; import 'package_info.dart'; @@ -31,12 +33,23 @@ class Analytics { String get clientId => _initialized ? _clientId : ''; - Future init({String fallbackClientId = ''}) async { - if (_initialized || !AnalyticsConfig.isConfigured) return; - _clientId = await _getDeviceId(fallbackClientId); + /// Returns a newly generated fallback ID for the caller to persist, if any. + Future init({String fallbackClientId = ''}) async { + if (_initialized || !AnalyticsConfig.isConfigured) return null; + final deviceId = await _getDeviceId(); + String? generatedClientId; + if (deviceId != null && deviceId.isNotEmpty) { + _clientId = deviceId; + } else if (fallbackClientId.isNotEmpty) { + _clientId = fallbackClientId; + } else { + _clientId = const Uuid().v4(); + generatedClientId = _clientId; + } _sessionId = DateTime.now().millisecondsSinceEpoch ~/ 1000; _dio = Dio(BaseOptions(connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 10))); _initialized = true; + return generatedClientId; } Future logAppOpen() => logEvent('app_open'); @@ -70,14 +83,14 @@ class Analytics { } } - Future _getDeviceId(String fallbackClientId) async { + Future _getDeviceId() async { + if (kIsWeb) return null; + String? deviceId; try { final info = DeviceInfoPlugin(); - if (kIsWeb) { - deviceId = null; - } else if (Platform.isAndroid) { - deviceId = (await info.androidInfo).id; + if (Platform.isAndroid) { + deviceId = await const AndroidId().getId(); } else if (Platform.isIOS) { deviceId = (await info.iosInfo).identifierForVendor; } else if (Platform.isMacOS) { @@ -91,14 +104,6 @@ class Analytics { logger.w('GA4Analytics: failed to read device id: $error'); } - if (deviceId == null || deviceId.isEmpty) { - deviceId = fallbackClientId; - if (deviceId.isEmpty) { - final random = DateTime.now().microsecondsSinceEpoch % 2147483647; - final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; - deviceId = '$random.$timestamp'; - } - } return deviceId; } diff --git a/ui/flutter/pubspec.lock b/ui/flutter/pubspec.lock index fda93b4a..94eaa301 100644 --- a/ui/flutter/pubspec.lock +++ b/ui/flutter/pubspec.lock @@ -25,6 +25,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + android_id: + dependency: "direct main" + description: + name: android_id + sha256: "543bbfcf316de69d3ac36601d74eeaacd0248178a2671b00ad30d09f35bd3581" + url: "https://pub.dev" + source: hosted + version: "0.5.2+1" animation_kit: dependency: transitive description: @@ -1621,7 +1629,7 @@ packages: source: hosted version: "3.1.5" uuid: - dependency: transitive + dependency: "direct main" description: name: uuid sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" diff --git a/ui/flutter/pubspec.yaml b/ui/flutter/pubspec.yaml index af1372e7..9e59aa4b 100644 --- a/ui/flutter/pubspec.yaml +++ b/ui/flutter/pubspec.yaml @@ -47,6 +47,8 @@ dependencies: package_info_plus: ^10.2.1 pub_semver: ^2.2.0 device_info_plus: ^13.2.0 + android_id: ^0.5.2+1 + uuid: ^4.6.0 win32_registry: ^3.0.3 launch_at_startup: git: diff --git a/ui/flutter/test/api/downloader_config_test.dart b/ui/flutter/test/api/downloader_config_test.dart index eda82a5f..76d48dff 100644 --- a/ui/flutter/test/api/downloader_config_test.dart +++ b/ui/flutter/test/api/downloader_config_test.dart @@ -81,7 +81,7 @@ void main() { ..createHistory = ['https://example.com/file.zip'] ..runAsMenubarApp = true ..analyticsEnabled = false - ..analyticsClientId = 'client-id'; + ..deviceId = 'client-id'; final decoded = DownloaderConfig.fromJson(config.toJson()).extra; expect(decoded.windowState.isMaximized, isTrue); @@ -91,7 +91,22 @@ void main() { expect(decoded.createHistory, ['https://example.com/file.zip']); expect(decoded.runAsMenubarApp, isTrue); expect(decoded.analyticsEnabled, isFalse); - expect(decoded.analyticsClientId, 'client-id'); + expect(decoded.deviceId, 'client-id'); + }); + + test('legacy analytics client IDs are ignored without migration', () { + final config = ExtraConfig.fromJson({'analyticsClientId': 'AP3A.241105.008'}); + + expect(config.deviceId, isEmpty); + expect(config.toJson(), isNot(contains('analyticsClientId'))); + }); + + test('analytics fallback ID uses only its dedicated config field', () { + final config = ExtraConfig.fromJson({'analyticsClientId': 'AP3A.241105.008', 'deviceId': 'stored-fallback'}); + + expect(config.deviceId, 'stored-fallback'); + expect(config.toJson()['deviceId'], 'stored-fallback'); + expect(config.toJson(), isNot(contains('analyticsClientId'))); }); test('categories and the auto-categorize switch round-trip through the config', () { diff --git a/ui/flutter/test/features/settings/application/settings_controller_test.dart b/ui/flutter/test/features/settings/application/settings_controller_test.dart index 8629d330..0d6816f7 100644 --- a/ui/flutter/test/features/settings/application/settings_controller_test.dart +++ b/ui/flutter/test/features/settings/application/settings_controller_test.dart @@ -44,7 +44,7 @@ void main() { final backend = DownloaderConfig() ..extra.windowState = WindowStateConfig(isMaximized: true, width: 1200, height: 800) ..extra.createHistory = ['https://example.com/existing.zip'] - ..extra.analyticsClientId = 'existing-client'; + ..extra.deviceId = 'existing-client'; DownloaderConfig? saved; final registry = CapabilityRegistry(createAppCapabilityCodecs()) ..bind(GopeedMethods.getConfig, (_) => DownloaderConfig.fromJson(backend.toJson())) @@ -67,7 +67,7 @@ void main() { expect(saved?.downloadDir, 'E:/New'); expect(saved?.extra.windowState.isMaximized, isTrue); expect(saved?.extra.createHistory, ['https://example.com/existing.zip']); - expect(saved?.extra.analyticsClientId, 'existing-client'); + expect(saved?.extra.deviceId, 'existing-client'); }); test('protocol edits use a single full PUT and reload backend-owned tracker results', () async {