Commit 178a615ea0ebafc62d648ff3484334a712b9bf39

Authored by 常守达
1 parent 4c43a3d4

feat(ui): 退登添加日志诊断

import 'dart:async';
import 'package:doublefeel_flutter/core/logging/app_diagnostic_log_store.dart';
import 'package:flutter/widgets.dart';
import 'package:get/get.dart';
... ... @@ -50,4 +51,47 @@ abstract final class AppBootstrap {
unawaited(AppConfigService.to.fetch());
}
}
static Future<void> initForBackground() async {
WidgetsFlutterBinding.ensureInitialized();
AppLogger.init();
await UserAgentProvider.init();
final local = await LocalStorage.open();
final environmentConfig = AppEnvironmentConfig(local);
await environmentConfig.init();
// 1. 【核心】只读取已保存的地区与环境,不要在后台调用 _detectRegion() 重新探测并写入 SharedPreferences,防止误切海外服
// environmentConfig.environment.value = await local.readEnvironment();
// environmentConfig.region.value =
// await local.readRegion() ?? AppRegion.china;
final userPreferencesStorage =
UserPreferencesStorage(local.sharedPreferences);
await userPreferencesStorage.init();
final userAccountStorage = UserAccountStorage(local.sharedPreferences);
InitialBinding(
environmentConfig: environmentConfig,
userPreferencesStorage: userPreferencesStorage,
userAccountStorage: userAccountStorage,
localStorage: local,
).dependencies();
NativeHealthDataUpdateBridge.initialize();
// 2. 后台无 UI,不需要初始化全局 Loading 弹窗
LoadingService.init();
// 3. 后台计算不需要重复初始化融云/分析等前台 SDK
if (local.termsAgreed) {
await Get.find<UserStateService>().initializeSdks();
unawaited(AppConfigService.to.fetch());
}
AppDiagnosticLogStore.instance.log(
category: 'initForBackground',
action: 'success',
details: {},
);
}
}
... ...
... ... @@ -8,6 +8,7 @@ import 'package:doublefeel_flutter/core/services/user_state_service.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../../../core/logging/app_diagnostic_log_store.dart';
import '../../../../core/logging/app_logger.dart';
import '../../../../core/services/raw_data_service/health_raw_data_core_service.dart';
import '../../../../core/services/raw_data_service/health_raw_models.dart';
... ... @@ -30,6 +31,7 @@ class DeveloperOptionsItem {
}
enum DeveloperOptionsAction {
shareAppDiagnosticLog,
shareHealthRawDataDatabase,
shareNativeAppleHealthObserverRecord,
shareFlutterObserverRecord,
... ... @@ -72,6 +74,10 @@ class DeveloperOptionsController extends GetxController {
routeName: Routes.TOKEN_LOGIN,
),
DeveloperOptionsItem(
title: '分享启动及登录态诊断日志',
action: DeveloperOptionsAction.shareAppDiagnosticLog,
),
DeveloperOptionsItem(
title: '分享本地计算数据库文件',
action: DeveloperOptionsAction.shareHealthRawDataDatabase,
),
... ... @@ -138,6 +144,9 @@ class DeveloperOptionsController extends GetxController {
}
switch (item.action) {
case DeveloperOptionsAction.shareAppDiagnosticLog:
await shareAppDiagnosticLog();
break;
case DeveloperOptionsAction.shareHealthRawDataDatabase:
await shareHealthRawDataDatabase();
break;
... ... @@ -195,6 +204,16 @@ class DeveloperOptionsController extends GetxController {
}
}
Future<void> shareAppDiagnosticLog() async {
try {
final bytes = await AppDiagnosticLogStore.instance.readBytes();
await _platformHostApi.shareFileData(bytes);
} catch (error, stackTrace) {
AppLogger.e('Share AppDiagnosticLog failed', error, stackTrace);
Get.snackbar('Share failed', error.toString());
}
}
Future<void> shareHealthRawDataDatabase() async {
try {
final bytes = await _healthRawDataCoreService.readDatabaseFileBytes();
... ...
... ... @@ -2,6 +2,7 @@ import 'package:doublefeel_flutter/app/modules/friends/controllers/friend_trend_
import 'package:doublefeel_flutter/app/modules/friends/controllers/friends_controller.dart';
import 'package:doublefeel_flutter/core/constants/flutter_bridge_method_name.dart';
import 'package:doublefeel_flutter/core/constants/intent_keys.dart';
import 'package:doublefeel_flutter/core/logging/app_diagnostic_log_store.dart';
import 'package:doublefeel_flutter/core/network/api/friend_api.dart';
import 'package:doublefeel_flutter/core/result/app_result.dart';
import 'package:doublefeel_flutter/core/services/raw_data_service/health_raw_data_core_service.dart';
... ... @@ -47,6 +48,13 @@ class HomeController extends GetxController {
SchedulerBinding.instance.addPostFrameCallback((_) async {
try {
final route = await _platformHostApi.requestUnhandedUrl();
AppDiagnosticLogStore.instance.log(
category: 'requestUnhandedUrl',
action: 'native_return_url',
details: {
'route': route ?? '',
},
);
// final route ='doublefeel://flutter/home?tab=today&scroll_to_latest_hrv=true';
// final route = 'doublefeel://flutter/friend-home?user_id=131';
// final route = 'doublefeel://flutter/home?tab=trend&type=sleep';
... ...
import 'package:doublefeel_flutter/core/logging/app_diagnostic_log_store.dart';
import 'package:doublefeel_flutter/core/services/raw_data_service/health_raw_data_core_service.dart';
import 'package:doublefeel_flutter/core/util/app_toast.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
... ... @@ -43,6 +44,11 @@ class SplashController extends GetxController {
final splashDelay =
Get.testMode ? Duration.zero : const Duration(milliseconds: 800);
await Future<void>.delayed(splashDelay);
AppDiagnosticLogStore.instance.log(
category: 'Splash',
action: 'navigate_to_login_due_to_empty_token',
details: {},
);
Get.offAllNamed(AppRoutes.login);
return;
}
... ... @@ -55,6 +61,13 @@ class SplashController extends GetxController {
} catch (_) {}
await Get.offAllNamed(AppRoutes.home);
} else {
AppDiagnosticLogStore.instance.log(
category: 'Splash',
action: 'navigate_to_login_due_to_session_refresh_failed',
details: {
'tokenLength': token.length,
},
);
Get.offAllNamed(AppRoutes.login);
}
}
... ... @@ -77,6 +90,14 @@ class SplashController extends GetxController {
return true;
case AppFailure(:final error):
AppDiagnosticLogStore.instance.log(
category: 'Splash',
action: 'refresh_session_failure',
details: {
'errorType': error.runtimeType.toString(),
'displayMessage': error.displayMessage,
},
);
if (error.isAccessTokenInvalid) {
return false;
}
... ...
import 'package:doublefeel_flutter/core/logging/app_diagnostic_log_store.dart';
import 'package:doublefeel_flutter/core/network/dio_client.dart';
import 'package:doublefeel_flutter/pigeon/platform_api.g.dart';
import 'package:flutter/foundation.dart';
... ... @@ -21,6 +22,13 @@ class AppEnvironmentConfig {
Future<AppEnvironmentConfig> init() async {
environment.value = await _storage.readEnvironment();
_isNativeDebug = await _platformHostApi.isDebugEnvoriment();
AppDiagnosticLogStore.instance.log(
category: 'Debug',
action: 'detect_debug_from_native_success',
details: {
'isDebug': _isNativeDebug,
},
);
final stored = await _storage.readRegion();
if (stored == null) {
final detected = await _detectRegion();
... ... @@ -36,11 +44,27 @@ class AppEnvironmentConfig {
Future<AppRegion> _detectRegion() async {
try {
return await _platformHostApi.isChinaRegion()
? AppRegion.china
: AppRegion.global;
} catch (_) {
return _detectRegionFromLocale();
final isChina = await _platformHostApi.isChinaRegion();
AppDiagnosticLogStore.instance.log(
category: 'Region',
action: 'detect_region_from_native_success',
details: {
'isChina': isChina,
},
);
return isChina ? AppRegion.china : AppRegion.global;
} catch (error, stackTrace) {
final fallback = _detectRegionFromLocale();
AppDiagnosticLogStore.instance.log(
category: 'Region',
action: 'detect_region_from_native_failed',
details: {
'error': error.toString(),
'stackTrace': stackTrace.toString(),
'fallbackResult': fallback.name,
},
);
return fallback;
}
}
... ... @@ -72,8 +96,17 @@ class AppEnvironmentConfig {
if (Get.isRegistered<DioClient>()) {
Get.find<DioClient>().refreshBaseUrl();
}
} catch (e) {
} catch (e, stackTrace) {
debugPrint('setRegion failed: $e');
AppDiagnosticLogStore.instance.log(
category: 'Region',
action: 'set_region_failed',
details: {
'error': e.toString(),
'stackTrace': stackTrace.toString(),
'targetRegion': value.name,
},
);
}
}
... ...
... ... @@ -2,6 +2,7 @@ import 'package:doublefeel_flutter/app/actions/dialog_action.dart';
import 'package:doublefeel_flutter/app/models/dialog_meta_data.dart';
import 'package:doublefeel_flutter/app/routes/app_pages.dart';
import 'package:doublefeel_flutter/app/utils/dialog_utils.dart';
import 'package:doublefeel_flutter/core/logging/app_diagnostic_log_store.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:get/get.dart';
... ... @@ -64,7 +65,13 @@ class AppErrorHandler {
iconAsset: 'assets/images/common/ic_warning.png',
));
if (confirmResult.action != DialogAction.confirm) return;
AppDiagnosticLogStore.instance.log(
category: 'AccessToken',
action: 'invalid',
details: {
'error': '401',
},
);
Get.offAllNamed(AppRoutes.login);
}
... ...
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:path_provider/path_provider.dart';
import 'app_logger.dart';
/// Persists structured diagnostic events (region detection, login/user-prefs lifecycle, etc.)
/// to a local JSON file using path_provider, which can be shared from Developer Options.
class AppDiagnosticLogStore {
AppDiagnosticLogStore({Directory? rootDirectory})
: _rootDirectory = rootDirectory;
static final AppDiagnosticLogStore instance = AppDiagnosticLogStore();
static const _fileName = 'app_diagnostic_events.json';
static const _maxEventCount = 500;
final Directory? _rootDirectory;
Future<void> _writeQueue = Future.value();
/// Appends a diagnostic event to the local file.
Future<void> log({
required String category,
required String action,
Map<String, Object?>? details,
}) {
_writeQueue = _writeQueue.then((_) async {
try {
final now = DateTime.now();
final event = <String, Object?>{
'category': category,
'action': action,
'timestamp': now.toIso8601String(),
'timestamp_readable': _readableTime(now),
'timestamp_unix': now.millisecondsSinceEpoch / 1000,
if (details != null && details.isNotEmpty)
'details': _sanitizeMap(details),
};
try {
AppLogger.d('[$category] $action details=$details');
} catch (_) {}
final file = await _file();
await file.parent.create(recursive: true);
final events = await _readEvents(file);
events.add(event);
final limited = events.length > _maxEventCount
? events.sublist(events.length - _maxEventCount)
: events;
const encoder = JsonEncoder.withIndent(' ');
await file.writeAsString(encoder.convert(limited), flush: true);
} catch (error, stackTrace) {
try {
AppLogger.e('AppDiagnosticLogStore log failed', error, stackTrace);
} catch (_) {}
}
});
return _writeQueue;
}
/// Reads the log file as [Uint8List] bytes for sharing via platform APIs.
Future<Uint8List> readBytes() async {
final file = await _file();
if (!await file.exists()) {
const emptyContent = '[\n {\n "message": "No diagnostic logs found"\n }\n]';
return Uint8List.fromList(utf8.encode(emptyContent));
}
return await file.readAsBytes();
}
/// Reads the log file content as a formatted string.
Future<String> readText() async {
final file = await _file();
if (!await file.exists()) {
return '暂无诊断日志';
}
final text = await file.readAsString();
return text.trim().isEmpty ? '暂无诊断日志' : text;
}
/// Clears the log file.
Future<void> clear() async {
final file = await _file();
if (await file.exists()) {
await file.delete();
}
}
Future<File> _file() async {
final root = _rootDirectory ?? await getApplicationDocumentsDirectory();
return File('${root.path}/$_fileName');
}
Future<List<Map<String, Object?>>> _readEvents(File file) async {
if (!await file.exists()) {
return <Map<String, Object?>>[];
}
try {
final text = await file.readAsString();
if (text.trim().isEmpty) {
return <Map<String, Object?>>[];
}
final json = jsonDecode(text);
if (json is List) {
return json
.whereType<Map>()
.map((e) => _sanitizeMap(e.cast<String, Object?>()))
.toList();
}
} catch (_) {}
return <Map<String, Object?>>[];
}
static Map<String, Object?> _sanitizeMap(Map<String, Object?> map) {
return map.map((key, value) => MapEntry(key, _sanitizeValue(value)));
}
static Object? _sanitizeValue(Object? value) {
return switch (value) {
null => null,
String() => value,
num() => value,
bool() => value,
DateTime() => value.toIso8601String(),
List() => value.map(_sanitizeValue).toList(),
Map() => value.map(
(key, val) => MapEntry(key.toString(), _sanitizeValue(val)),
),
_ => value.toString(),
};
}
static String _readableTime(DateTime time) {
return '${time.year.toString().padLeft(4, '0')}-'
'${time.month.toString().padLeft(2, '0')}-'
'${time.day.toString().padLeft(2, '0')} '
'${time.hour.toString().padLeft(2, '0')}:'
'${time.minute.toString().padLeft(2, '0')}:'
'${time.second.toString().padLeft(2, '0')}';
}
}
... ...
import 'dart:convert';
import 'package:doublefeel_flutter/core/config/app_environment_config.dart';
import 'package:doublefeel_flutter/core/logging/app_diagnostic_log_store.dart';
import 'package:doublefeel_flutter/core/logging/app_logger.dart';
import 'package:doublefeel_flutter/core/services/raw_data_service/health_raw_data_core_service.dart';
import 'package:doublefeel_flutter/pigeon/platform_api.g.dart';
import 'package:get/get.dart';
... ... @@ -42,7 +44,17 @@ class UserPreferencesStorage {
try {
final map = jsonDecode(raw) as Map<String, dynamic>;
preferences.value = UserPreferences.fromJson(map);
} catch (_) {
} catch (error, stackTrace) {
AppLogger.e('UserPreferences JSON parse failed', error, stackTrace);
AppDiagnosticLogStore.instance.log(
category: 'UserPreferences',
action: 'load_deserialization_failed',
details: {
'error': error.toString(),
'stackTrace': stackTrace.toString(),
'rawPreview': raw.length > 200 ? '${raw.substring(0, 200)}...' : raw,
},
);
preferences.value = UserPreferences.empty;
}
_syncLoginInfoToNative(userInfo: preferences.value);
... ...
... ... @@ -41,7 +41,7 @@ Future<void> healthBackgroundMain() async {
await _healthBackgroundLog(channel, 'entry.start');
try {
await _healthBackgroundLog(channel, 'bootstrap.start');
await AppBootstrap.init();
await AppBootstrap.initForBackground();
await _healthBackgroundLog(channel, 'bootstrap.success');
} catch (error, stackTrace) {
await _healthBackgroundLog(
... ...