diff --git a/android/app/src/main/kotlin/com/stormg/lumadrama/MainActivity.kt b/android/app/src/main/kotlin/com/stormg/lumadrama/MainActivity.kt index 8f25c38..71a3a09 100644 --- a/android/app/src/main/kotlin/com/stormg/lumadrama/MainActivity.kt +++ b/android/app/src/main/kotlin/com/stormg/lumadrama/MainActivity.kt @@ -12,6 +12,7 @@ import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper +import android.provider.Settings import android.util.Rational import android.content.pm.PackageManager import android.view.WindowManager @@ -65,6 +66,20 @@ class MainActivity : FlutterActivity() { result.success(false) } } + // 设备标识:SSAID 免权限,卸载重装不变,避免客户端自己生成的 id 每次重装都变 + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "com.stormg.lumadrama/device") + .setMethodCallHandler { call, result -> + if (call.method != "ssaid") { + result.notImplemented() + return@setMethodCallHandler + } + val value = try { + Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID) + } catch (_: Exception) { + null + } + result.success(value) + } MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "com.stormg.lumadrama/share") .setMethodCallHandler { call, result -> if (call.method != "shareText") { diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index b33feff..43a3513 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -63,6 +63,7 @@ final class RunnerFlutterViewController: FlutterViewController { func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) registerWirelessDataChannel(engineBridge) + registerDeviceIdChannel(engineBridge) let channel = FlutterMethodChannel( name: "com.stormg.lumadrama/share", binaryMessenger: engineBridge.applicationRegistrar.messenger() @@ -144,6 +145,21 @@ final class RunnerFlutterViewController: FlutterViewController { } } + /// 设备标识:IDFV 免权限,卸载本 App 不变,避免客户端自生成的 id 每次重装都变。 + private func registerDeviceIdChannel(_ engineBridge: FlutterImplicitEngineBridge) { + let channel = FlutterMethodChannel( + name: "com.stormg.lumadrama/device", + binaryMessenger: engineBridge.applicationRegistrar.messenger() + ) + channel.setMethodCallHandler { call, result in + guard call.method == "idfv" else { + result(FlutterMethodNotImplemented) + return + } + result(UIDevice.current.identifierForVendor?.uuidString) + } + } + private func registerWirelessDataChannel(_ engineBridge: FlutterImplicitEngineBridge) { let channel = FlutterMethodChannel( name: WirelessDataPrompt.channelName, diff --git a/lib/core/data/services/auth_session_store.dart b/lib/core/data/services/auth_session_store.dart index 20afccb..cf178b7 100644 --- a/lib/core/data/services/auth_session_store.dart +++ b/lib/core/data/services/auth_session_store.dart @@ -4,6 +4,7 @@ import 'dart:math'; import 'package:shared_preferences/shared_preferences.dart'; import '../models/auth_session.dart'; +import 'device_id_provider.dart'; /// 令牌与设备号的本地持久化。 /// @@ -11,13 +12,17 @@ import '../models/auth_session.dart'; /// `SharedPreferences.getInstance()` 在新版本里已标注弃用,两套混用还会让 /// widget 测试只 mock 到其中一套。 /// -/// 设备号是后端注册风控与后续"下载设备绑定"的依据,所以**首次生成后就固定下来**; -/// 不引 UUID 库(保持极简依赖),用「时间戳 + 随机数」拼一个够用的 id。 +/// 设备号优先取系统标识(Android SSAID / iOS IDFV),这样卸载重装后还是同一台设备; +/// 系统标识不可用时才回退到本地生成并持久化的 UUID,保证任何环境都能跑。 class AuthSessionStore { - AuthSessionStore({SharedPreferencesAsync? preferences}) - : _preferences = preferences ?? SharedPreferencesAsync(); + AuthSessionStore({ + SharedPreferencesAsync? preferences, + DeviceIdProvider? deviceIdProvider, + }) : _preferences = preferences ?? SharedPreferencesAsync(), + _deviceIdProvider = deviceIdProvider ?? DeviceIdProvider(); final SharedPreferencesAsync _preferences; + final DeviceIdProvider _deviceIdProvider; static const _kAccessToken = 'auth.accessToken'; static const _kRefreshToken = 'auth.refreshToken'; @@ -28,18 +33,34 @@ class AuthSessionStore { Future deviceId() async { if (_cachedDeviceId != null) return _cachedDeviceId!; - var value = await _preferences.getString(_kDeviceId) ?? ''; - if (value.isEmpty) { - final random = Random(); - value = - 'd-${DateTime.now().millisecondsSinceEpoch.toRadixString(36)}-' - '${random.nextInt(1 << 32).toRadixString(36)}'; - await _preferences.setString(_kDeviceId, value); - } + final systemId = await _deviceIdProvider.systemDeviceId(); + final value = systemId ?? await _localDeviceId(); _cachedDeviceId = value; return value; } + /// 系统标识取不到时的兜底:本地 UUIDv4,生成一次后固定下来。 + Future _localDeviceId() async { + final stored = await _preferences.getString(_kDeviceId) ?? ''; + if (stored.isNotEmpty) return stored; + final generated = 'd-${_uuidV4()}'; + await _preferences.setString(_kDeviceId, generated); + return generated; + } + + /// UUIDv4:122 位随机,碰撞概率可忽略,不引第三方依赖。 + String _uuidV4() { + final random = Random.secure(); + final bytes = List.generate(16, (_) => random.nextInt(256)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + String hex(int start, int end) => bytes + .sublist(start, end) + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join(); + return '${hex(0, 4)}-${hex(4, 6)}-${hex(6, 8)}-${hex(8, 10)}-${hex(10, 16)}'; + } + Future read() async { final accessToken = await _preferences.getString(_kAccessToken) ?? ''; final refreshToken = await _preferences.getString(_kRefreshToken) ?? ''; diff --git a/lib/core/data/services/device_id_provider.dart b/lib/core/data/services/device_id_provider.dart new file mode 100644 index 0000000..ab702db --- /dev/null +++ b/lib/core/data/services/device_id_provider.dart @@ -0,0 +1,36 @@ +// Author: fruit.com.cn + +import 'dart:io' show Platform; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +/// 读取系统级设备标识,两端都不需要任何权限。 +/// +/// - Android:SSAID(`Settings.Secure.ANDROID_ID`),卸载重装不变; +/// - iOS:IDFV(`identifierForVendor`),卸载本 App 不变,同厂商应用全删才会变。 +/// +/// 两端都由原生侧提供,不引插件:项目 iOS 走 SPM,避免混入 CocoaPods。 +/// 取不到(Web、测试环境、通道异常)时返回 null,由调用方回退到本地生成的 id。 +class DeviceIdProvider { + static const MethodChannel _channel = MethodChannel( + 'com.stormg.lumadrama/device', + ); + + Future systemDeviceId() async { + if (kIsWeb) return null; + try { + if (Platform.isAndroid) { + final ssaid = await _channel.invokeMethod('ssaid'); + if (ssaid != null && ssaid.isNotEmpty) return 'a-$ssaid'; + } + if (Platform.isIOS) { + final idfv = await _channel.invokeMethod('idfv'); + if (idfv != null && idfv.isNotEmpty) return 'i-$idfv'; + } + } catch (_) { + // 原生通道不可用(widget 测试)时走本地兜底 + } + return null; + } +} diff --git a/test/core/device_id_test.dart b/test/core/device_id_test.dart new file mode 100644 index 0000000..1ef86dc --- /dev/null +++ b/test/core/device_id_test.dart @@ -0,0 +1,65 @@ +// Author: fruit.com.cn + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/services/auth_session_store.dart'; +import 'package:fruitshorts/core/data/services/device_id_provider.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +/// 可控的系统设备标识来源。 +class _FakeDeviceIdProvider implements DeviceIdProvider { + _FakeDeviceIdProvider(this.value); + + final String? value; + int calls = 0; + + @override + Future systemDeviceId() async { + calls++; + return value; + } +} + +void main() { + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + }); + + test('should_prefer_system_device_id_over_local_value', () async { + final provider = _FakeDeviceIdProvider('a-9774d56d682e549c'); + final store = AuthSessionStore(deviceIdProvider: provider); + + expect(await store.deviceId(), 'a-9774d56d682e549c'); + }); + + test('should_fall_back_to_stable_local_uuid_when_system_id_missing', () async { + final store = AuthSessionStore( + deviceIdProvider: _FakeDeviceIdProvider(null), + ); + + final first = await store.deviceId(); + final second = await store.deviceId(); + + // 同一次运行内固定,且符合 UUIDv4 形态 + expect(first, second); + expect(first, startsWith('d-')); + expect( + RegExp( + r'^d-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', + ).hasMatch(first), + isTrue, + reason: '本地兜底必须是 UUIDv4,避免 32 位随机带来的碰撞', + ); + }); + + test('should_cache_system_id_within_one_session', () async { + final provider = _FakeDeviceIdProvider('i-E621E1F8-C36C-495A-93FC-0C247A3E6E5F'); + final store = AuthSessionStore(deviceIdProvider: provider); + + await store.deviceId(); + await store.deviceId(); + + expect(provider.calls, 1); + }); +}