feat(core): 设备号改用系统标识,重装不再变成新游客
- Android 读 SSAID、iOS 读 IDFV,均免权限,由原生通道提供,不引插件 - 取不到时回退本地 UUIDv4,替代原来 32 位随机 + 时间戳的拼接
This commit is contained in:
1 parent
935de13343
commit
1b19513efb
5 files changed
+165
-12
No files matched your search
@@ -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") {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String> 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<String> _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<int>.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<AuthSession?> read() async {
|
||||
final accessToken = await _preferences.getString(_kAccessToken) ?? '';
|
||||
final refreshToken = await _preferences.getString(_kRefreshToken) ?? '';
|
||||
|
||||
@@ -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<String?> systemDeviceId() async {
|
||||
if (kIsWeb) return null;
|
||||
try {
|
||||
if (Platform.isAndroid) {
|
||||
final ssaid = await _channel.invokeMethod<String>('ssaid');
|
||||
if (ssaid != null && ssaid.isNotEmpty) return 'a-$ssaid';
|
||||
}
|
||||
if (Platform.isIOS) {
|
||||
final idfv = await _channel.invokeMethod<String>('idfv');
|
||||
if (idfv != null && idfv.isNotEmpty) return 'i-$idfv';
|
||||
}
|
||||
} catch (_) {
|
||||
// 原生通道不可用(widget 测试)时走本地兜底
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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<String?> 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);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user